This commit is contained in:
2025-12-02 07:53:20 +01:00
parent 4096b52244
commit e3aaa1b0f8
14 changed files with 461 additions and 360 deletions

View File

@@ -0,0 +1,208 @@
#!/usr/bin/env -S v -n -w -cg -gc none -cc tcc -d use_openssl -enable-globals run
import incubaid.herolib.web.doctree.meta
import incubaid.herolib.core.playbook
import incubaid.herolib.ui.console
// Comprehensive HeroScript for testing multi-level navigation depths
const test_heroscript_nav_depth = '
!!site.config
name: "nav_depth_test"
title: "Navigation Depth Test Site"
description: "Testing multi-level nested navigation"
tagline: "Deep navigation structures"
!!site.navbar
title: "Nav Depth Test"
!!site.navbar_item
label: "Home"
to: "/"
position: "left"
// ============================================================
// LEVEL 1: Simple top-level category
// ============================================================
!!site.page_category
path: "Why"
collapsible: true
collapsed: false
//COLLECTION WILL BE REPEATED, HAS NO INFLUENCE ON NAVIGATION LEVELS
!!site.page src: "mycollection:intro"
label: "Why Choose Us"
title: "Why Choose Us"
description: "Reasons to use this platform"
!!site.page src: "benefits"
label: "Key Benefits"
title: "Key Benefits"
description: "Main benefits overview"
// ============================================================
// LEVEL 1: Simple top-level category
// ============================================================
!!site.page_category
path: "Tutorials"
collapsible: true
collapsed: false
!!site.page src: "getting_started"
label: "Getting Started"
title: "Getting Started"
description: "Basic tutorial to get started"
!!site.page src: "first_steps"
label: "First Steps"
title: "First Steps"
description: "Your first steps with the platform"
// ============================================================
// LEVEL 3: Three-level nested category (Tutorials > Operations > Urgent)
// ============================================================
!!site.page_category
path: "Tutorials/Operations/Urgent"
collapsible: true
collapsed: false
!!site.page src: "emergency_restart"
label: "Emergency Restart"
title: "Emergency Restart"
description: "How to emergency restart the system"
!!site.page src: "critical_fixes"
label: "Critical Fixes"
title: "Critical Fixes"
description: "Apply critical fixes immediately"
!!site.page src: "incident_response"
label: "Incident Response"
title: "Incident Response"
description: "Handle incidents in real-time"
// ============================================================
// LEVEL 2: Two-level nested category (Tutorials > Operations)
// ============================================================
!!site.page_category
path: "Tutorials/Operations"
collapsible: true
collapsed: false
!!site.page src: "daily_checks"
label: "Daily Checks"
title: "Daily Checks"
description: "Daily maintenance checklist"
!!site.page src: "monitoring"
label: "Monitoring"
title: "Monitoring"
description: "System monitoring procedures"
!!site.page src: "backups"
label: "Backups"
title: "Backups"
description: "Backup and restore procedures"
// ============================================================
// LEVEL 1: One-to-two level (Tutorials)
// ============================================================
// Note: This creates a sibling at the Tutorials level (not nested deeper)
!!site.page src: "advanced_concepts"
label: "Advanced Concepts"
title: "Advanced Concepts"
description: "Deep dive into advanced concepts"
!!site.page src: "troubleshooting"
label: "Troubleshooting"
title: "Troubleshooting"
description: "Troubleshooting guide"
// ============================================================
// LEVEL 2: Two-level nested category (Why > FAQ)
// ============================================================
!!site.page_category
path: "Why/FAQ"
collapsible: true
collapsed: false
!!site.page src: "general"
label: "General Questions"
title: "General Questions"
description: "Frequently asked questions"
!!site.page src: "pricing_questions"
label: "Pricing"
title: "Pricing Questions"
description: "Questions about pricing"
!!site.page src: "technical_faq"
label: "Technical FAQ"
title: "Technical FAQ"
description: "Technical frequently asked questions"
!!site.page src: "support_faq"
label: "Support"
title: "Support FAQ"
description: "Support-related FAQ"
// ============================================================
// LEVEL 4: Four-level nested category (Tutorials > Operations > Database > Optimization)
// ============================================================
!!site.page_category
path: "Tutorials/Operations/Database/Optimization"
collapsible: true
collapsed: false
!!site.page src: "query_optimization"
label: "Query Optimization"
title: "Query Optimization"
description: "Optimize your database queries"
!!site.page src: "indexing_strategy"
label: "Indexing Strategy"
title: "Indexing Strategy"
description: "Effective indexing strategies"
!!site.page_category
path: "Tutorials/Operations/Database"
collapsible: true
collapsed: false
!!site.page src: "configuration"
label: "Configuration"
title: "Database Configuration"
description: "Configure your database"
!!site.page src: "replication"
label: "Replication"
title: "Database Replication"
description: "Set up database replication"
'
fn check(s2 meta.Site) {
// assert s == s2
}
// ========================================================
// SETUP: Create and process playbook
// ========================================================
console.print_item('Creating playbook from HeroScript')
mut plbook := playbook.new(text: test_heroscript_nav_depth)!
console.print_green(' Playbook created')
console.lf()
console.print_item('Processing site configuration')
meta.play(mut plbook)!
console.print_green(' Site processed')
console.lf()
console.print_item('Retrieving configured site')
mut nav_site := meta.get(name: 'nav_depth_test')!
console.print_green(' Site retrieved')
console.lf()
// check(nav_site)

View File

@@ -25,6 +25,7 @@ pub fn new(args FactoryArgs) !&Site {
config: SiteConfig{
name: name
}
root_category: Category{}
}
sites_global[name] = &site
return get(name: name)!

View File

@@ -1,8 +1,111 @@
module meta
@[heap]
struct Category {
pub mut:
path string // e.g. Operations/Daily (means 2 levels deep, first level is Operations)
collapsible bool = true
collapsed bool
items []CategoryItem
}
//return the label of the category (last part of the path)
pub fn (mut c Category) label() !string {
if c.path.count('/') == 0 {
return c.path
}
return c.path.all_after_last('/')
}
type CategoryItem = Page | Link | Category
pub fn (mut self Category) up(path string) !&Category {
// Split the requested path into parts
path_parts := path.split('/')
// Start at current category
mut current := &self
// Navigate through each part of the path
for part in path_parts {
// Skip empty parts (from leading/trailing slashes)
if part.len == 0 {
continue
}
// Check if this part already exists in current category's items
mut found := false
for item in current.items {
match item {
&Category {
item_label := item.label()!
if item_label == part {
current = item
found = true
break
}
}
else {}
}
}
// If not found, create a new category and add it
if !found {
mut new_category := Category{
path: part
collapsible: true
collapsed: true
items: []CategoryItem{}
}
current.items << new_category
current = &new_category
}
}
return current
}
fn (mut self Category) page_get(src string)! &Page {
for item in self.items {
match item {
Page {
if item.src == src {
return it
}
}
else {}
}
}
return error('Page with src="${src}" not found in site.')
}
fn (mut self Category) link_get(href string)! &Link {
for item in self.items {
match item {
Link {
if item.href == href {
return it
}
}
else {}
}
}
return error('Link with href="${href}" not found in site.')
}
fn (mut self Category) category_get(path string)! &Category {
for item in self.items {
match item {
Category {
if item.path == path {
return it
}
}
else {}
}
}
return error('Category with path="${path}" not found in site.')
}

View File

@@ -0,0 +1,76 @@
module meta
pub fn (mut self Category) str() string {
mut result := []string{}
if self.items.len == 0 {
return 'Sidebar is empty\n'
}
result << '📑 SIDEBAR STRUCTURE'
result << '━'.repeat(60)
for i, item in self.items {
is_last := i == self.items.len - 1
prefix := if is_last { ' ' } else { ' ' }
match item {
NavDoc {
result << '${prefix}📄 ${item.label}'
result << ' path: ${item.path}'
}
NavCat {
// Category header
collapse_icon := if item.collapsed { ' ' } else { ' ' }
result << '${prefix}${collapse_icon}📁 ${item.label}'
// Category metadata
if !item.collapsed {
result << ' collapsible: ${item.collapsible}'
result << ' items: ${item.items.len}'
// Sub-items
for j, sub_item in item.items {
is_last_sub := j == item.items.len - 1
sub_prefix := if is_last_sub { ' ' } else { ' ' }
match sub_item {
NavDoc {
result << '${sub_prefix}📄 ${sub_item.label} [${sub_item.src_path}]'
}
NavCat {
// Nested categories
sub_collapse_icon := if sub_item.collapsed { ' ' } else { ' ' }
result << '${sub_prefix}${sub_collapse_icon}📁 ${sub_item.label}'
}
NavLink {
result << '${sub_prefix}🔗 ${sub_item.label}'
if sub_item.description.len > 0 {
result << ' ${sub_item.description}'
}
}
}
}
}
}
NavLink {
result << '${prefix}🔗 ${item.label}'
result << ' href: ${item.href}'
if item.description.len > 0 {
result << ' desc: ${item.description}'
}
}
}
// Add spacing between root items
if i < self.items.len - 1 {
result << ''
}
}
result << '━'.repeat(60)
result << '📊 SUMMARY'
result << ' Total items: ${self.items.len}'
return result.join('\n') + '\n'
}

View File

@@ -11,4 +11,5 @@ pub mut:
hide_title bool // Should the title be hidden on the page?
hide bool // Should the page be hidden from navigation?
category_id int // Optional category ID this page belongs to, if 0 it means its at root level
nav_path string // navigation path e.g. "Operations/Daily"
}

View File

@@ -1,34 +0,0 @@
module meta
// ============================================================================
// Sidebar Navigation Models (Domain Types)
// is the result of walking through the pages, links and categories to build the sidebar structure
// ============================================================================
pub struct SideBar {
pub mut:
my_sidebar []NavItem
}
pub type NavItem = NavDoc | NavCat | NavLink
pub struct NavDoc {
pub:
path string // path is $collection/$name without .md, this is a subdir of the doctree export dir
label string
}
pub struct NavCat {
pub mut:
label string
collapsible bool = true
collapsed bool
items []NavItem
}
pub struct NavLink {
pub:
label string
href string
description string
}

View File

@@ -5,316 +5,28 @@ pub struct Site {
pub mut:
doctree_path string // path to the export of the doctree site
config SiteConfig // Full site configuration
pages []Page
links []Link
categories []Category
root // The root category containing all top-level items
announcements []Announcement // there can be more than 1 announcement
imports []ImportItem
build_dest []BuildDest // Production build destinations (from !!site.build_dest)
build_dest_dev []BuildDest // Development build destinations (from !!site.build_dest_dev)
}
pub fn (mut s Site) sidebar() SideBar {
mut result := SideBar{
my_sidebar: []NavItem{}
}
// If no pages, return empty sidebar
if s.pages.len == 0 {
return result
}
// Build a map of category_id -> pages for efficient lookup
mut category_pages := map[int][]Page{}
mut uncategorized_pages := []Page{}
// Group pages by category
eprintln('DEBUG: Grouping ${s.pages.len} pages into categories')
for page in s.pages {
if page.category_id == 0 {
// Page at root level (no category)
uncategorized_pages << page
eprintln(' Page "${page.src}": UNCATEGORIZED')
} else {
// Page belongs to a category
if page.category_id !in category_pages {
category_pages[page.category_id] = []Page{}
}
category_pages[page.category_id] << page
if page.category_id < s.categories.len {
eprintln(' Page "${page.src}": category_id=${page.category_id} -> "${s.categories[page.category_id].path}"')
} else {
eprintln(' Page "${page.src}": category_id=${page.category_id} -> INVALID INDEX!')
}
}
}
eprintln('DEBUG: Grouped into ${category_pages.len} categories + ${uncategorized_pages.len} uncategorized')
// Sort pages within each category by their order in the pages array
for category_id in category_pages.keys() {
category_pages[category_id].sort(a.src < b.src)
}
// Sort uncategorized pages
uncategorized_pages.sort(a.src < b.src)
// ============================================================
// Build nested category structure from path
// ============================================================
mut category_tree := map[string]&NavCat{}
mut parent_map := map[string]string{} // Map of path -> parent_path
// PASS 1: Create ALL category nodes first
// Collect all paths first, then sort by depth (shallow first)
mut all_paths := []string{}
for i, category in s.categories {
path_parts := if category.path.contains('/') {
category.path.split('/')
} else {
[category.path]
}
mut current_path := ''
for part_idx, part in path_parts {
if current_path.len > 0 {
current_path += '/'
}
current_path += part
// Add this path if not already added
if current_path !in category_tree {
all_paths << current_path
}
}
}
// Sort paths by depth (number of '/') so we create parents before children
all_paths.sort(a.count('/') < b.count('/'))
// Now create all nodes in order of depth
for path in all_paths {
if path !in category_tree {
path_parts := path.split('/')
part := path_parts[path_parts.len - 1]
// Find the category with this path to get collapsible/collapsed settings
mut collapsible := true
mut collapsed := false
for category in s.categories {
if category.path == path {
collapsible = category.collapsible
collapsed = category.collapsed
break
}
}
// Create new category node
mut new_cat := &NavCat{
label: part
collapsible: collapsible
collapsed: collapsed
items: []NavItem{}
}
category_tree[path] = new_cat
// Record parent for later linking
if path.contains('/') {
last_slash := path.last_index('/') or { 0 }
parent_path := path[0..last_slash]
parent_map[path] = parent_path
}
}
}
// PASS 2: Link all parent-child relationships
// Process these in order of depth to ensure parents are linked first
mut sorted_paths := parent_map.keys()
sorted_paths.sort(a.count('/') < b.count('/'))
for path in sorted_paths {
parent_path := parent_map[path]
if parent_path in category_tree && path in category_tree {
mut parent_cat := category_tree[parent_path]
child_cat := category_tree[path]
// Only add if not already added
mut already_added := false
for item in parent_cat.items {
if item is NavCat && item.label == child_cat.label {
already_added = true
break
}
}
if !already_added {
parent_cat.items << child_cat
}
}
}
// PASS 3: Add pages to their designated categories
eprintln('DEBUG PASS 3: Adding pages to categories')
for i, category in s.categories {
category_id := i // categories are 0-indexed in the page assignment
// Skip if no pages in this category
if category_id !in category_pages {
eprintln(' Category ${category_id} ("${category.path}"): no pages')
continue
}
// Build the full path for this category
full_path := category.path
eprintln(' Category ${category_id} ("${full_path}"): ${category_pages[category_id].len} pages')
// Add pages to this category
if full_path in category_tree {
mut leaf_cat := category_tree[full_path]
for page in category_pages[category_id] {
if !page.hide {
// Convert page src format "collection:name" to path "collection/name"
path := page.src.replace(':', '/')
eprintln(' Adding page: ${page.src} -> ${path}')
nav_doc := NavDoc{
path: path
label: if page.label.len > 0 { page.label } else { page.title }
}
leaf_cat.items << nav_doc
}
}
} else {
eprintln(' ERROR: Category path "${full_path}" not in category_tree!')
}
}
// ============================================================
// PASS 4: Add root-level categories to sidebar
// ============================================================
// Find all root-level categories (those without '/') and add them once
mut added_roots := map[string]bool{}
for i, category in s.categories {
// Only process top-level categories
if !category.path.contains('/') && category.path.len > 0 {
root_path := category.path
// Only add each root once
if root_path !in added_roots {
if root_path in category_tree {
result.my_sidebar << category_tree[root_path]
added_roots[root_path] = true
}
}
}
}
// ============================================================
// PASS 5: Add uncategorized pages at root level
// ============================================================
for page in uncategorized_pages {
if !page.hide {
// Convert page src format "collection:name" to path "collection/name"
path := page.src.replace(':', '/')
nav_doc := NavDoc{
path: path
label: if page.label.len > 0 { page.label } else { page.title }
}
result.my_sidebar << nav_doc
}
}
// ============================================================
// PASS 6: Add standalone links (if needed)
// ============================================================
for link in s.links {
nav_link := NavLink{
label: link.label
href: link.href
description: link.description
}
result.my_sidebar << nav_link
}
return result
pub fn (mut self Site) page_get(src string)! &Page {
return self.root.page_get(src)!
}
pub fn (mut s Site) sidebar_str() string {
mut result := []string{}
mut sidebar := s.sidebar()
if sidebar.my_sidebar.len == 0 {
return 'Sidebar is empty\n'
}
result << '📑 SIDEBAR STRUCTURE'
result << '━'.repeat(60)
for i, item in sidebar.my_sidebar {
is_last := i == sidebar.my_sidebar.len - 1
prefix := if is_last { ' ' } else { ' ' }
match item {
NavDoc {
result << '${prefix}📄 ${item.label}'
result << ' path: ${item.path}'
}
NavCat {
// Category header
collapse_icon := if item.collapsed { ' ' } else { ' ' }
result << '${prefix}${collapse_icon}📁 ${item.label}'
// Category metadata
if !item.collapsed {
result << ' collapsible: ${item.collapsible}'
result << ' items: ${item.items.len}'
// Sub-items
for j, sub_item in item.items {
is_last_sub := j == item.items.len - 1
sub_prefix := if is_last_sub { ' ' } else { ' ' }
match sub_item {
NavDoc {
result << '${sub_prefix}📄 ${sub_item.label} [${sub_item.path}]'
}
NavCat {
// Nested categories
sub_collapse_icon := if sub_item.collapsed { ' ' } else { ' ' }
result << '${sub_prefix}${sub_collapse_icon}📁 ${sub_item.label}'
}
NavLink {
result << '${sub_prefix}🔗 ${sub_item.label}'
if sub_item.description.len > 0 {
result << ' ${sub_item.description}'
}
}
}
}
}
}
NavLink {
result << '${prefix}🔗 ${item.label}'
result << ' href: ${item.href}'
if item.description.len > 0 {
result << ' desc: ${item.description}'
}
}
}
// Add spacing between root items
if i < sidebar.my_sidebar.len - 1 {
result << ''
}
}
result << '━'.repeat(60)
result << '📊 SUMMARY'
result << ' Total items: ${sidebar.my_sidebar.len}'
result << ' Pages: ${s.pages.len}'
result << ' Categories: ${s.categories.len}'
result << ' Links: ${s.links.len}'
return result.join('\n') + '\n'
pub fn (mut self Site) link_get(href string)! &Link {
return self.root.link_get(href)!
}
pub fn (mut self Site) category_get(path string)! &Category {
return self.root.category_get(path)!
}
//sidebar returns the root category for building the sidebar navigation
pub fn (mut self Site) sidebar()! &Category {
return self.root
}

View File

@@ -9,7 +9,8 @@ import incubaid.herolib.ui.console
// ============================================================
fn play_pages(mut plbook PlayBook, mut website Site) ! {
mut collection_current := ''
mut category_current := 0
mut category_current := &website.root_category // start at root category, this is basically the navigation tree root
// ============================================================
// PASS 1: Process all page and category actions
@@ -26,16 +27,18 @@ fn play_pages(mut plbook PlayBook, mut website Site) ! {
mut p := action.params
// label is empty when not specified (we support label & path for flexibility)
mut category_path := p.get_default('path', '')!
category_current = category_current.up(category_path)!
category_current.collapsible = p.get_default_true('collapsible')
category_current.collapsed = p.get_default_true('collapsed')
mut category := Category{
path: p.get_default('path', p.get_default('label', '')!)!
collapsible: p.get_default_true('collapsible')
collapsed: p.get_default_true('collapsed')
}
website.categories << category
category_current = website.categories.len - 1
console.print_item('Created page category: "${category.path}" ')
console.print_item('Created page category: "${category_current.path}" ')
action.done = true
println(category_current)
website.categories << category_current
$dbg();
continue
}
@@ -76,6 +79,8 @@ fn play_pages(mut plbook PlayBook, mut website Site) ! {
page_title := p.get_default('title', '')! // is title shown on the page, if not from the page content, if empty then will be brought in from the content
page_description := p.get_default('description', '')!
// Create page
mut page := Page{
src: '${page_collection}:${page_name}'

View File

@@ -127,7 +127,7 @@ pub mut:
}
// Generate sidebar navigation
sidebar := mysite.sidebar() // Returns SideBar
sidebar := mysite.sidebar()! // Returns SideBar
// Sidebar structure
pub struct SideBar {
@@ -159,7 +159,7 @@ pub:
}
// Example: iterate navigation
sidebar := mysite.sidebar()
sidebar := mysite.sidebar()!
for item in sidebar.my_sidebar {
match item {
NavDoc {

View File

@@ -560,7 +560,7 @@ pub fn test_navigation_depth() ! {
// ========================================================
console.print_header('TEST 3: Navigation Structure Analysis')
mut sidebar := nav_site.sidebar()
mut sidebar := nav_site.sidebar()!
console.print_item('Sidebar root items: ${sidebar.my_sidebar.len}')
console.lf()

View File

@@ -403,7 +403,7 @@ fn test_site2() ! {
// ========================================================
console.print_header('Validating Navigation Structure (Sidebar)')
mut sidebar := test_site.sidebar()
mut sidebar := test_site.sidebar()!
console.print_item('Sidebar has ${sidebar.my_sidebar.len} root items')
assert sidebar.my_sidebar.len > 0, 'Sidebar should not be empty'

View File

@@ -1,7 +1,7 @@
module docusaurus
import incubaid.herolib.core.pathlib
import incubaid.herolib.web.site
import incubaid.herolib.web.doctree.meta
import incubaid.herolib.osal.core as osal
import incubaid.herolib.ui.console
@@ -15,7 +15,7 @@ pub mut:
path_build pathlib.Path
errors []SiteError
config Configuration
website site.Site
website meta.Site
generated bool
}
@@ -50,7 +50,7 @@ pub fn (mut s DocSite) build_publish() ! {
'
retry: 0
)!
for item in s.website.siteconfig.build_dest {
for item in s.build_dest {
if item.path.trim_space().trim('/ ') == '' {
$if debug {
print_backtrace()

View File

@@ -1,6 +1,6 @@
module docusaurus
import incubaid.herolib.web.site
import incubaid.herolib.web.doctree.meta
// IS THE ONE AS USED BY DOCUSAURUS
@@ -87,9 +87,9 @@ pub mut:
}
// This function is a pure transformer: site.SiteConfig -> docusaurus.Configuration
fn new_configuration(mysite site.Site) !Configuration {
fn new_configuration(mysite meta.Site) !Configuration {
// Transform site.SiteConfig to docusaurus.Configuration
mut site_cfg := mysite.siteconfig
mut site_cfg := mysite.config
mut nav_items := []NavbarItem{}
for item in site_cfg.menu.items {
nav_items << NavbarItem{

View File

@@ -1,7 +1,16 @@
module doc
import incubaid.herolib.web.site
//this is the logic to create docusaurus sidebar.json from site.NavItems
import incubaid.herolib.web.doctree.meta as site
import json
// this is the logic to create docusaurus sidebar.json from site.NavItems
struct Sidebar {
pub mut:
items []NavItem
}
type NavItem = NavDoc | NavCat | NavLink
struct SidebarItem {
typ string @[json: 'type']
@@ -14,11 +23,32 @@ struct SidebarItem {
items []SidebarItem @[omitempty]
}
pub struct NavDoc {
pub mut:
id string
label string
}
pub struct NavCat {
pub mut:
label string
collapsible bool = true
collapsed bool
items []NavItem
}
pub struct NavLink {
pub mut:
label string
href string
description string
}
// ============================================================================
// JSON Serialization
// ============================================================================
pub fn sidebar_to_json(sb site.SideBar) !string {
pub fn sidebar_to_json(sb site.SideBar) !string {
items := sb.my_sidebar.map(to_sidebar_item(it))
return json.encode_pretty(items)
}
@@ -57,4 +87,3 @@ fn from_category(cat site.NavCat) SidebarItem {
items: cat.items.map(to_sidebar_item(it))
}
}