Merge branch 'development' into development_hetzner

* development:
  ...
  ...
  ...
  ...
  ...
This commit is contained in:
2025-11-30 15:58:18 +01:00
35 changed files with 2619 additions and 1270 deletions

View File

@@ -0,0 +1,308 @@
#!/usr/bin/env -S vrun
import incubaid.herolib.data.atlas
import incubaid.herolib.ui.console
import os
fn main() {
println('=== ATLAS DEBUG SCRIPT ===\n')
// Create and scan atlas
mut a := atlas.new(name: 'main')!
// Scan the collections
println('Scanning collections...\n')
a.scan(
path: '/Users/despiegk/code/git.ourworld.tf/geomind/docs_geomind/collections/mycelium_nodes_tiers'
)!
a.scan(
path: '/Users/despiegk/code/git.ourworld.tf/geomind/docs_geomind/collections/geomind_compare'
)!
a.scan(path: '/Users/despiegk/code/git.ourworld.tf/geomind/docs_geomind/collections/geoaware')!
a.scan(
path: '/Users/despiegk/code/git.ourworld.tf/tfgrid/docs_tfgrid4/collections/mycelium_economics'
)!
a.scan(
path: '/Users/despiegk/code/git.ourworld.tf/tfgrid/docs_tfgrid4/collections/mycelium_concepts'
)!
a.scan(
path: '/Users/despiegk/code/git.ourworld.tf/tfgrid/docs_tfgrid4/collections/mycelium_cloud_tech'
)!
// Initialize atlas (post-scanning validation)
a.init_post()!
// Print all pages per collection
println('\n=== COLLECTIONS & PAGES ===\n')
for col_name, col in a.collections {
println('Collection: ${col_name}')
println(' Pages (${col.pages.len}):')
if col.pages.len > 0 {
for page_name, _ in col.pages {
println(' - ${page_name}')
}
} else {
println(' (empty)')
}
println(' Files/Images (${col.files.len}):')
if col.files.len > 0 {
for file_name, _ in col.files {
println(' - ${file_name}')
}
} else {
println(' (empty)')
}
}
// Validate links (this will recursively find links across collections)
println('\n=== VALIDATING LINKS (RECURSIVE) ===\n')
a.validate_links()!
println(' Link validation complete\n')
// Check for broken links
println('\n=== BROKEN LINKS ===\n')
mut total_errors := 0
for col_name, col in a.collections {
if col.has_errors() {
println('Collection: ${col_name} (${col.errors.len} errors)')
for err in col.errors {
println(' [${err.category_str()}] Page: ${err.page_key}')
println(' Message: ${err.message}')
println('')
total_errors++
}
}
}
if total_errors == 0 {
println(' No broken links found!')
} else {
println('\n Total broken link errors: ${total_errors}')
}
// Show discovered links per page (validates recursive discovery)
println('\n\n=== DISCOVERED LINKS (RECURSIVE RESOLUTION) ===\n')
println('Checking for files referenced by cross-collection pages...\n')
mut total_links := 0
for col_name, col in a.collections {
mut col_has_links := false
for page_name, page in col.pages {
if page.links.len > 0 {
if !col_has_links {
println('Collection: ${col_name}')
col_has_links = true
}
println(' Page: ${page_name} (${page.links.len} links)')
for link in page.links {
target_col := if link.target_collection_name != '' {
link.target_collection_name
} else {
col_name
}
println(' ${target_col}:${link.target_item_name} [${link.file_type}]')
total_links++
}
}
}
}
println('\n Total links discovered: ${total_links}')
// List pages that need investigation
println('\n=== CHECKING SPECIFIC MISSING PAGES ===\n')
missing_pages := [
'compare_electricity',
'internet_basics',
'centralization_risk',
'gdp_negative',
]
// Check in geoaware collection
if 'geoaware' in a.collections {
mut geoaware := a.get_collection('geoaware')!
println('Collection: geoaware')
if geoaware.pages.len > 0 {
println(' All pages in collection:')
for page_name, _ in geoaware.pages {
println(' - ${page_name}')
}
} else {
println(' (No pages found)')
}
println('\n Checking for specific missing pages:')
for page_name in missing_pages {
exists := page_name in geoaware.pages
status := if exists { '' } else { '' }
println(' ${status} ${page_name}')
}
}
// Check for pages across all collections
println('\n\n=== LOOKING FOR MISSING PAGES ACROSS ALL COLLECTIONS ===\n')
for missing_page in missing_pages {
println('Searching for "${missing_page}":')
mut found := false
for col_name, col in a.collections {
if missing_page in col.pages {
println(' Found in: ${col_name}')
found = true
}
}
if !found {
println(' Not found in any collection')
}
}
// Check for the solution page
println('\n\n=== CHECKING FOR "solution" PAGE ===\n')
for col_name in ['mycelium_nodes_tiers', 'geomind_compare', 'geoaware', 'mycelium_economics',
'mycelium_concepts', 'mycelium_cloud_tech'] {
if col_name in a.collections {
mut col := a.get_collection(col_name)!
exists := col.page_exists('solution')!
status := if exists { '' } else { '' }
println('${status} ${col_name}: "solution" page')
}
}
// Print error summary
println('\n\n=== ERROR SUMMARY BY CATEGORY ===\n')
mut category_counts := map[string]int{}
for _, col in a.collections {
for err in col.errors {
cat_str := err.category_str()
category_counts[cat_str]++
}
}
if category_counts.len == 0 {
println(' No errors found!')
} else {
for cat, count in category_counts {
println('${cat}: ${count}')
}
}
// ===== EXPORT AND FILE VERIFICATION TEST =====
println('\n\n=== EXPORT AND FILE VERIFICATION TEST ===\n')
// Create export directory
export_path := '/tmp/atlas_debug_export'
if os.exists(export_path) {
os.rmdir_all(export_path)!
}
os.mkdir_all(export_path)!
println('Exporting to: ${export_path}\n')
a.export(destination: export_path)!
println(' Export completed\n')
// Collect all files found during link validation
mut expected_files := map[string]string{} // key: file_name, value: collection_name
mut file_count := 0
for col_name, col in a.collections {
for page_name, page in col.pages {
for link in page.links {
if link.status == .found && (link.file_type == .file || link.file_type == .image) {
file_key := link.target_item_name
expected_files[file_key] = link.target_collection_name
file_count++
}
}
}
}
println('Expected to find ${file_count} file references in links\n')
println('=== VERIFYING FILES IN EXPORT DIRECTORY ===\n')
// Get the first collection name (the primary exported collection)
mut primary_col_name := ''
for col_name, _ in a.collections {
primary_col_name = col_name
break
}
if primary_col_name == '' {
println(' No collections found')
} else {
mut verified_count := 0
mut missing_count := 0
mut found_files := map[string]bool{}
// Check both img and files directories
img_dir := '${export_path}/content/${primary_col_name}/img'
files_dir := '${export_path}/content/${primary_col_name}/files'
// Scan img directory
if os.exists(img_dir) {
img_files := os.ls(img_dir) or { []string{} }
for img_file in img_files {
found_files[img_file] = true
}
}
// Scan files directory
if os.exists(files_dir) {
file_list := os.ls(files_dir) or { []string{} }
for file in file_list {
found_files[file] = true
}
}
println('Files/Images found in export directory:')
if found_files.len > 0 {
for file_name, _ in found_files {
println(' ${file_name}')
if file_name in expected_files {
verified_count++
}
}
} else {
println(' (none found)')
}
println('\n=== FILE VERIFICATION RESULTS ===\n')
println('Expected files from links: ${file_count}')
println('Files found in export: ${found_files.len}')
println('Files verified (present in export): ${verified_count}')
// Check for missing expected files
for expected_file, source_col in expected_files {
if expected_file !in found_files {
missing_count++
println(' Missing: ${expected_file} (from ${source_col})')
}
}
if missing_count > 0 {
println('\n ${missing_count} expected files are MISSING from export!')
} else if verified_count == file_count && file_count > 0 {
println('\n All expected files are present in export directory!')
} else if file_count == 0 {
println('\n No file links were found during validation (check if pages have file references)')
}
// Show directory structure
println('\n=== EXPORT DIRECTORY STRUCTURE ===\n')
if os.exists('${export_path}/content/${primary_col_name}') {
println('${export_path}/content/${primary_col_name}/')
content_files := os.ls('${export_path}/content/${primary_col_name}') or { []string{} }
for item in content_files {
full_path := '${export_path}/content/${primary_col_name}/${item}'
if os.is_dir(full_path) {
sub_items := os.ls(full_path) or { []string{} }
println(' ${item}/ (${sub_items.len} items)')
for sub_item in sub_items {
println(' - ${sub_item}')
}
} else {
println(' - ${item}')
}
}
}
}
}

201
examples/web/site/USAGE.md Normal file
View File

@@ -0,0 +1,201 @@
# Site Module Usage Guide
## Quick Examples
### 1. Run Basic Example
```bash
cd examples/web/site
vrun process_site.vsh ./
```
With output:
```
=== Site Configuration Processor ===
Processing HeroScript files from: ./
Found 1 HeroScript file(s):
- basic.heroscript
Processing: basic.heroscript
=== Configuration Complete ===
Site: simple_docs
Title: Simple Documentation
Pages: 4
Description: A basic documentation site
Navigation structure:
- [Page] Getting Started
- [Page] Installation
- [Page] Usage Guide
- [Page] FAQ
✓ Site configuration ready for deployment
```
### 2. Run Multi-Section Example
```bash
vrun process_site.vsh ./
# Edit process_site.vsh to use multi_section.heroscript instead
```
### 3. Process Custom Directory
```bash
vrun process_site.vsh /path/to/your/site/config
```
## File Structure
```
docs/
├── 0_config.heroscript # Basic config
├── 1_menu.heroscript # Navigation
├── 2_pages.heroscript # Pages and categories
└── process.vsh # Your processing script
```
## Creating Your Own Site
1. **Create a config directory:**
```bash
mkdir my_site
cd my_site
```
2. **Create config file (0_config.heroscript):**
```heroscript
!!site.config
name: "my_site"
title: "My Site"
```
3. **Create pages file (1_pages.heroscript):**
```heroscript
!!site.page src: "docs:intro"
title: "Getting Started"
```
4. **Process with script:**
```bash
vrun ../process_site.vsh ./
```
## Common Workflows
### Workflow 1: Documentation Site
```
docs/
├── 0_config.heroscript
│ └── Basic config + metadata
├── 1_menu.heroscript
│ └── Navbar + footer
├── 2_getting_started.heroscript
│ └── Getting started pages
├── 3_api.heroscript
│ └── API reference pages
└── 4_advanced.heroscript
└── Advanced topic pages
```
### Workflow 2: Internal Knowledge Base
```
kb/
├── 0_config.heroscript
├── 1_navigation.heroscript
└── 2_articles.heroscript
```
### Workflow 3: Product Documentation with Imports
```
product_docs/
├── 0_config.heroscript
├── 1_imports.heroscript
│ └── Import shared templates
├── 2_menu.heroscript
└── 3_pages.heroscript
```
## Tips & Tricks
### Tip 1: Reuse Collections
```heroscript
# Specify once, reuse multiple times
!!site.page src: "guides:intro"
!!site.page src: "setup" # Reuses "guides"
!!site.page src: "deployment" # Still "guides"
# Switch to new collection
!!site.page src: "api:reference"
!!site.page src: "examples" # Now "api"
```
### Tip 2: Auto-Increment Categories
```heroscript
# Automatically positioned at 100, 200, 300...
!!site.page_category name: "basics"
!!site.page_category name: "advanced"
!!site.page_category name: "expert"
# Or specify explicit positions
!!site.page_category name: "basics" position: 10
!!site.page_category name: "advanced" position: 20
```
### Tip 3: Title Extraction
Let titles come from markdown files:
```heroscript
# Don't specify title
!!site.page src: "docs:introduction"
# Title will be extracted from # Heading in introduction.md
```
### Tip 4: Draft Pages
Hide pages while working on them:
```heroscript
!!site.page src: "docs:work_in_progress"
draft: true
title: "Work in Progress"
```
## Debugging
### Debug: Check What Got Configured
```v
mut s := site.get(name: 'my_site')!
println(s.pages) // All pages
println(s.nav) // Navigation structure
println(s.siteconfig) // Configuration
```
### Debug: List All Sites
```v
sites := site.list()
for site_name in sites {
println('Site: ${site_name}')
}
```
### Debug: Enable Verbose Output
Add `console.print_debug()` calls in your HeroScript processing.
## Next Steps
- Customize `process_site.vsh` for your needs
- Add your existing pages (in markdown)
- Export to Docusaurus
- Deploy to production
For more info, see the main [Site Module README](./readme.md).

View File

@@ -0,0 +1,53 @@
#!/usr/bin/env hero
# Basic single-section documentation site
!!site.config
name: "simple_docs"
title: "Simple Documentation"
description: "A basic documentation site"
copyright: "© 2024 Example"
url: "https://docs.example.com"
base_url: "/"
!!site.navbar
title: "Simple Docs"
logo_src: "img/logo.png"
!!site.navbar_item
label: "Docs"
to: "/"
position: "left"
!!site.navbar_item
label: "GitHub"
href: "https://github.com/example/repo"
position: "right"
!!site.footer
style: "dark"
!!site.footer_item
title: "Documentation"
label: "Getting Started"
to: "getting-started"
!!site.footer_item
title: "Community"
label: "Discord"
href: "https://discord.gg/example"
!!site.page src: "docs:introduction"
title: "Getting Started"
description: "Learn the basics"
!!site.page src: "installation"
title: "Installation"
description: "How to install"
!!site.page src: "usage"
title: "Usage Guide"
description: "How to use the system"
!!site.page src: "faq"
title: "FAQ"
description: "Frequently asked questions"

View File

@@ -0,0 +1,155 @@
#!/usr/bin/env hero
# Multi-section documentation with categories
!!site.config
name: "multi_docs"
title: "Complete Documentation"
description: "Comprehensive documentation with multiple sections"
tagline: "Everything you need to know"
copyright: "© 2024 Tech Company"
url: "https://docs.techcompany.com"
base_url: "/docs"
!!site.navbar
title: "Tech Documentation"
logo_src: "img/logo.svg"
!!site.navbar_item
label: "Documentation"
to: "/"
position: "left"
!!site.navbar_item
label: "API"
to: "api"
position: "left"
!!site.navbar_item
label: "GitHub"
href: "https://github.com/techcompany"
position: "right"
!!site.footer
style: "dark"
!!site.footer_item
title: "Guides"
label: "Getting Started"
to: "getting-started"
!!site.footer_item
title: "Guides"
label: "Installation"
to: "installation"
!!site.footer_item
title: "Company"
label: "Website"
href: "https://techcompany.com"
!!site.footer_item
title: "Legal"
label: "Privacy"
href: "https://techcompany.com/privacy"
# ==================================================
# Getting Started Section
# ==================================================
!!site.page_category
name: "getting_started"
label: "Getting Started"
position: 100
!!site.page src: "docs:introduction"
title: "Introduction"
description: "What is this project?"
!!site.page src: "installation"
title: "Installation"
description: "Get up and running"
!!site.page src: "quickstart"
title: "Quick Start"
description: "Your first steps"
# ==================================================
# Core Concepts Section
# ==================================================
!!site.page_category
name: "concepts"
label: "Core Concepts"
position: 200
!!site.page src: "concepts:architecture"
title: "Architecture"
description: "System design and architecture"
!!site.page src: "components"
title: "Components"
description: "Main system components"
!!site.page src: "data_flow"
title: "Data Flow"
description: "How data flows through the system"
!!site.page src: "security"
title: "Security"
description: "Security considerations"
# ==================================================
# Advanced Topics Section
# ==================================================
!!site.page_category
name: "advanced"
label: "Advanced Topics"
position: 300
!!site.page src: "advanced:performance"
title: "Performance Tuning"
description: "Optimize your system"
!!site.page src: "scaling"
title: "Scaling"
description: "Scale to millions of users"
!!site.page src: "deployment"
title: "Deployment"
description: "Deploy to production"
# ==================================================
# API Reference Section
# ==================================================
!!site.page_category
name: "api"
label: "API Reference"
position: 400
!!site.page src: "api:overview"
title: "API Overview"
description: "API capabilities and base URLs"
!!site.page src: "rest_api"
title: "REST API"
description: "Complete REST API documentation"
!!site.page src: "graphql_api"
title: "GraphQL"
description: "GraphQL API documentation"
!!site.page src: "webhooks"
title: "Webhooks"
description: "Implement webhooks in your app"
# ==================================================
# Publishing
# ==================================================
!!site.publish
path: "/var/www/html/docs"
!!site.publish_dev
path: "/tmp/docs-preview"

View File

@@ -0,0 +1,116 @@
#!/usr/bin/env -S v -n -w -gc none -cg -cc tcc -d use_openssl -enable-globals run
import incubaid.herolib.core.playbook
import incubaid.herolib.web.site
import incubaid.herolib.ui.console
import os
// Process a site configuration from HeroScript files
println(console.color_fg(.green) + '=== Site Configuration Processor ===' + console.reset())
// Get directory from command line or use default
mut config_dir := './docs'
if os.args.len > 1 {
config_dir = os.args[1]
}
if !os.exists(config_dir) {
console.print_stderr('Error: Directory not found: ${config_dir}')
exit(1)
}
console.print_item('Processing HeroScript files from: ${config_dir}')
// Find all heroscript files
mut heroscript_files := []string{}
entries := os.ls(config_dir) or {
console.print_stderr('Error reading directory: ${err}')
exit(1)
}
for entry in entries {
if entry.ends_with('.heroscript') {
heroscript_files << entry
}
}
// Sort files (to ensure numeric prefix order)
heroscript_files.sort()
if heroscript_files.len == 0 {
console.print_stderr('No .heroscript files found in ${config_dir}')
exit(1)
}
console.print_item('Found ${heroscript_files.len} HeroScript file(s):')
for file in heroscript_files {
console.print_item(' - ${file}')
}
// Process each file
mut site_names := []string{}
for file in heroscript_files {
full_path := os.join_path(config_dir, file)
console.print_lf(1)
console.print_header('Processing: ${file}')
mut plbook := playbook.new(path: full_path) or {
console.print_stderr('Error loading ${file}: ${err}')
continue
}
site.play(mut plbook) or {
console.print_stderr('Error processing ${file}: ${err}')
continue
}
}
// Get all configured sites
site_names = site.list()
if site_names.len == 0 {
console.print_stderr('No sites were configured')
exit(1)
}
console.print_lf(2)
console.print_green('=== Configuration Complete ===')
// Display configured sites
for site_name in site_names {
mut configured_site := site.get(name: site_name) or { continue }
console.print_header('Site: ${site_name}')
console.print_item('Title: ${configured_site.siteconfig.title}')
console.print_item('Pages: ${configured_site.pages.len}')
console.print_item('Description: ${configured_site.siteconfig.description}')
// Show pages organized by category
if configured_site.nav.my_sidebar.len > 0 {
console.print_item('Navigation structure:')
for nav_item in configured_site.nav.my_sidebar {
match nav_item {
site.NavDoc {
console.print_item(' - [Page] ${nav_item.label}')
}
site.NavCat {
console.print_item(' - [Category] ${nav_item.label}')
for sub_item in nav_item.items {
match sub_item {
site.NavDoc {
console.print_item(' - ${sub_item.label}')
}
else {}
}
}
}
else {}
}
}
}
console.print_lf(1)
}
println(console.color_fg(.green) + ' Site configuration ready for deployment' + console.reset())

View File

@@ -0,0 +1,177 @@
module atlas
import incubaid.herolib.core.pathlib
import os
import json
const test_base = '/tmp/atlas_test'
// Test recursive export with chained cross-collection links
// Setup: Collection A links to B, Collection B links to C
// Expected: When exporting A, it should include pages from B and C
fn test_export_recursive_links() {
// Create 3 collections with chained links
col_a_path := '${test_base}/recursive_export/col_a'
col_b_path := '${test_base}/recursive_export/col_b'
col_c_path := '${test_base}/recursive_export/col_c'
os.mkdir_all(col_a_path)!
os.mkdir_all(col_b_path)!
os.mkdir_all(col_c_path)!
// Collection A: links to B
mut cfile_a := pathlib.get_file(path: '${col_a_path}/.collection', create: true)!
cfile_a.write('name:col_a')!
mut page_a := pathlib.get_file(path: '${col_a_path}/page_a.md', create: true)!
page_a.write('# Page A\\n\\nThis is page A.\\n\\n[Link to Page B](col_b:page_b)')!
// Collection B: links to C
mut cfile_b := pathlib.get_file(path: '${col_b_path}/.collection', create: true)!
cfile_b.write('name:col_b')!
mut page_b := pathlib.get_file(path: '${col_b_path}/page_b.md', create: true)!
page_b.write('# Page B\\n\\nThis is page B with link to C.\\n\\n[Link to Page C](col_c:page_c)')!
// Collection C: final page
mut cfile_c := pathlib.get_file(path: '${col_c_path}/.collection', create: true)!
cfile_c.write('name:col_c')!
mut page_c := pathlib.get_file(path: '${col_c_path}/page_c.md', create: true)!
page_c.write('# Page C\\n\\nThis is the final page in the chain.')!
// Create Atlas and add all collections
mut a := new()!
a.add_collection(mut pathlib.get_dir(path: col_a_path)!)!
a.add_collection(mut pathlib.get_dir(path: col_b_path)!)!
a.add_collection(mut pathlib.get_dir(path: col_c_path)!)!
// Validate links before export to populate page.links
a.validate_links()!
// Export
export_path := '${test_base}/export_recursive'
a.export(destination: export_path)!
// ===== VERIFICATION PHASE =====
// 1. Verify directory structure exists
assert os.exists('${export_path}/content'), 'Export content directory should exist'
assert os.exists('${export_path}/content/col_a'), 'Collection col_a directory should exist'
assert os.exists('${export_path}/meta'), 'Export meta directory should exist'
// 2. Verify all pages exist in col_a export directory
// Note: Exported pages from other collections go to col_a directory
assert os.exists('${export_path}/content/col_a/page_a.md'), 'page_a.md should be exported'
assert os.exists('${export_path}/content/col_a/page_b.md'), 'page_b.md from col_b should be included'
assert os.exists('${export_path}/content/col_a/page_c.md'), 'page_c.md from col_c should be included'
// 3. Verify page content is correct
content_a := os.read_file('${export_path}/content/col_a/page_a.md')!
assert content_a.contains('# Page A'), 'page_a content should have title'
assert content_a.contains('This is page A'), 'page_a content should have expected text'
assert content_a.contains('[Link to Page B]'), 'page_a should have link to page_b'
content_b := os.read_file('${export_path}/content/col_a/page_b.md')!
assert content_b.contains('# Page B'), 'page_b content should have title'
assert content_b.contains('This is page B'), 'page_b content should have expected text'
assert content_b.contains('[Link to Page C]'), 'page_b should have link to page_c'
content_c := os.read_file('${export_path}/content/col_a/page_c.md')!
assert content_c.contains('# Page C'), 'page_c content should have title'
assert content_c.contains('This is the final page'), 'page_c content should have expected text'
// 4. Verify metadata exists and is valid
assert os.exists('${export_path}/meta/col_a.json'), 'Metadata file for col_a should exist'
meta_content := os.read_file('${export_path}/meta/col_a.json')!
assert meta_content.len > 0, 'Metadata file should not be empty'
// // Parse metadata JSON and verify structure
// mut meta := json.decode(map[string]map[string]interface{}, meta_content) or {
// panic('Failed to parse metadata JSON: ${err}')
// }
// assert meta.len > 0, 'Metadata should have content'
// assert meta['name'] != none, 'Metadata should have name field'
// 5. Verify that pages from B and C are NOT exported to separate col_b and col_c directories
// (they should only be in col_a directory)
meta_col_b_exists := os.exists('${export_path}/meta/col_b.json')
meta_col_c_exists := os.exists('${export_path}/meta/col_c.json')
assert !meta_col_b_exists, 'col_b metadata should not exist (pages copied to col_a)'
assert !meta_col_c_exists, 'col_c metadata should not exist (pages copied to col_a)'
// 6. Verify the recursive depth worked
// All three pages should be accessible through the exported col_a
assert os.exists('${export_path}/content/col_a/page_a.md'), 'Level 1 page should exist'
assert os.exists('${export_path}/content/col_a/page_b.md'), 'Level 2 page (via A->B) should exist'
assert os.exists('${export_path}/content/col_a/page_c.md'), 'Level 3 page (via A->B->C) should exist'
// 7. Verify that the link chain is properly documented
// page_a links to page_b, page_b links to page_c
// The links should be preserved in the exported content
page_a_content := os.read_file('${export_path}/content/col_a/page_a.md')!
page_b_content := os.read_file('${export_path}/content/col_a/page_b.md')!
page_c_content := os.read_file('${export_path}/content/col_a/page_c.md')!
// Links are preserved with collection:page format
assert page_a_content.contains('col_b:page_b') || page_a_content.contains('page_b'), 'page_a should reference page_b'
assert page_b_content.contains('col_c:page_c') || page_b_content.contains('page_c'), 'page_b should reference page_c'
println(' Recursive cross-collection export test passed')
println(' - All 3 pages exported to col_a directory (A -> B -> C)')
println(' - Content verified for all pages')
println(' - Metadata validated')
println(' - Link chain preserved')
}
// Test recursive export with cross-collection images
// Setup: Collection A links to image in Collection B
// Expected: Image should be copied to col_a export directory
fn test_export_recursive_with_images() {
col_a_path := '${test_base}/recursive_img/col_a'
col_b_path := '${test_base}/recursive_img/col_b'
os.mkdir_all(col_a_path)!
os.mkdir_all(col_b_path)!
os.mkdir_all('${col_a_path}/img')!
os.mkdir_all('${col_b_path}/img')!
// Collection A with local image
mut cfile_a := pathlib.get_file(path: '${col_a_path}/.collection', create: true)!
cfile_a.write('name:col_a')!
mut page_a := pathlib.get_file(path: '${col_a_path}/page_a.md', create: true)!
page_a.write('# Page A\\n\\n![Local Image](local.png)\\n\\n[Link to B](col_b:page_b)')!
// Create local image
os.write_file('${col_a_path}/img/local.png', 'fake png data')!
// Collection B with image and linked page
mut cfile_b := pathlib.get_file(path: '${col_b_path}/.collection', create: true)!
cfile_b.write('name:col_b')!
mut page_b := pathlib.get_file(path: '${col_b_path}/page_b.md', create: true)!
page_b.write('# Page B\\n\\n![B Image](b_image.jpg)')!
// Create image in collection B
os.write_file('${col_b_path}/img/b_image.jpg', 'fake jpg data')!
// Create Atlas
mut a := new()!
a.add_collection(mut pathlib.get_dir(path: col_a_path)!)!
a.add_collection(mut pathlib.get_dir(path: col_b_path)!)!
// Validate and export
a.validate_links()!
export_path := '${test_base}/export_recursive_img'
a.export(destination: export_path)!
// Verify pages exported
assert os.exists('${export_path}/content/col_a/page_a.md'), 'page_a should exist'
assert os.exists('${export_path}/content/col_a/page_b.md'), 'page_b from col_b should be included'
// Verify images exported to col_a image directory
assert os.exists('${export_path}/content/col_a/img/local.png'), 'Local image should exist'
assert os.exists('${export_path}/content/col_a/img/b_image.jpg'), 'Image from cross-collection reference should be copied'
println(' Recursive cross-collection with images test passed')
}

View File

@@ -2,6 +2,7 @@ module atlas
import incubaid.herolib.core.pathlib
import os
import json
const test_base = '/tmp/atlas_test'
@@ -381,48 +382,3 @@ fn test_get_edit_url() {
// Assert the URLs are correct
// assert edit_url == 'https://github.com/test/repo/edit/main/test_page.md'
}
fn test_export_recursive_links() {
// Create 3 collections with chained links
col_a_path := '${test_base}/recursive_export/col_a'
col_b_path := '${test_base}/recursive_export/col_b'
col_c_path := '${test_base}/recursive_export/col_c'
os.mkdir_all(col_a_path)!
os.mkdir_all(col_b_path)!
os.mkdir_all(col_c_path)!
// Collection A
mut cfile_a := pathlib.get_file(path: '${col_a_path}/.collection', create: true)!
cfile_a.write('name:col_a')!
mut page_a := pathlib.get_file(path: '${col_a_path}/page_a.md', create: true)!
page_a.write('# Page A\n\n[Link to B](col_b:page_b)')!
// Collection B
mut cfile_b := pathlib.get_file(path: '${col_b_path}/.collection', create: true)!
cfile_b.write('name:col_b')!
mut page_b := pathlib.get_file(path: '${col_b_path}/page_b.md', create: true)!
page_b.write('# Page B\n\n[Link to C](col_c:page_c)')!
// Collection C
mut cfile_c := pathlib.get_file(path: '${col_c_path}/.collection', create: true)!
cfile_c.write('name:col_c')!
mut page_c := pathlib.get_file(path: '${col_c_path}/page_c.md', create: true)!
page_c.write('# Page C\n\nFinal content')!
// Export
mut a := new()!
a.add_collection(mut pathlib.get_dir(path: col_a_path)!)!
a.add_collection(mut pathlib.get_dir(path: col_b_path)!)!
a.add_collection(mut pathlib.get_dir(path: col_c_path)!)!
export_path := '${test_base}/export_recursive'
a.export(destination: export_path)!
// Verify all pages were exported
assert os.exists('${export_path}/content/col_a/page_a.md')
assert os.exists('${export_path}/content/col_a/page_b.md') // From Collection B
assert os.exists('${export_path}/content/col_a/page_c.md') // From Collection C
// TODO: test not complete
}

View File

@@ -17,8 +17,8 @@ AtlasClient provides methods to:
```v
import incubaid.herolib.web.atlas_client
// Create client
mut client := atlas_client.new(export_dir: '/tmp/atlas_export')!
// Create client, exports will be in $/hero/var/atlas_export by default
mut client := atlas_client.new()!
// List collections
collections := client.list_collections()!

View File

@@ -247,20 +247,6 @@ pub fn (mut c AtlasClient) get_collection_metadata(collection_name string) !Coll
return metadata
}
// get_page_links returns the links found in a page by reading the metadata
pub fn (mut c AtlasClient) get_page_links(collection_name string, page_name string) ![]LinkMetadata {
// Get collection metadata
metadata := c.get_collection_metadata(collection_name)!
// Apply name normalization to page name
fixed_page_name := texttools.name_fix_no_ext(page_name)
// Find the page in metadata
if fixed_page_name in metadata.pages {
return metadata.pages[fixed_page_name].links
}
return error('page_not_found: Page "${page_name}" not found in collection metadata, for collection: "${collection_name}"')
}
// get_collection_errors returns the errors for a collection from metadata
pub fn (mut c AtlasClient) get_collection_errors(collection_name string) ![]ErrorMetadata {
metadata := c.get_collection_metadata(collection_name)!
@@ -273,6 +259,30 @@ pub fn (mut c AtlasClient) has_errors(collection_name string) bool {
return errors.len > 0
}
pub fn (mut c AtlasClient) copy_pages(collection_name string, page_name string, destination_path string) ! {
// Get page links from metadata
links := c.get_page_links(collection_name, page_name)!
// Create img subdirectory
mut img_dest := pathlib.get_dir(path: '${destination_path}', create: true)!
// Copy only image links
for link in links {
if link.file_type != .page {
continue
}
if link.status == .external {
continue
}
// Get image path and copy
img_path := c.get_page_path(link.target_collection_name, link.target_item_name)!
mut src := pathlib.get_file(path: img_path)!
src.copy(dest: '${img_dest.path}/${src.name_fix_keepext()}')!
console.print_debug(' ********. Copied page: ${src.path} to ${img_dest.path}/${src.name_fix_keepext()}')
}
}
pub fn (mut c AtlasClient) copy_images(collection_name string, page_name string, destination_path string) ! {
// Get page links from metadata
links := c.get_page_links(collection_name, page_name)!

View File

@@ -0,0 +1,119 @@
module client
import incubaid.herolib.core.pathlib
import incubaid.herolib.core.texttools
import incubaid.herolib.ui.console
import os
import json
import incubaid.herolib.core.redisclient
// get_page_links returns all links found in a page and pages linked to it (recursive)
// This includes transitive links through page-to-page references
// External links, files, and images do not recurse further
pub fn (mut c AtlasClient) get_page_links(collection_name string, page_name string) ![]LinkMetadata {
mut visited := map[string]bool{}
mut all_links := []LinkMetadata{}
c.collect_page_links_recursive(collection_name, page_name, mut visited, mut all_links)!
return all_links
}
// collect_page_links_recursive is the internal recursive implementation
// It traverses all linked pages and collects all links found
//
// Thread safety: Each call to get_page_links gets its own visited map
// Circular references are prevented by tracking visited pages
//
// Link types behavior:
// - .page links: Recursively traverse to get links from the target page
// - .file and .image links: Included in results but not recursively expanded
// - .external links: Included in results but not recursively expanded
fn (mut c AtlasClient) collect_page_links_recursive(collection_name string, page_name string, mut visited map[string]bool, mut all_links []LinkMetadata) ! {
// Create unique key for cycle detection
page_key := '${collection_name}:${page_name}'
// Prevent infinite loops on circular page references
// Example: Page A Page B Page A
if page_key in visited {
return
}
visited[page_key] = true
// Get collection metadata
metadata := c.get_collection_metadata(collection_name)!
fixed_page_name := texttools.name_fix_no_ext(page_name)
// Find the page in metadata
if fixed_page_name !in metadata.pages {
return error('page_not_found: Page "${page_name}" not found in collection metadata, for collection: "${collection_name}"')
}
page_meta := metadata.pages[fixed_page_name]
// Add all direct links from this page to the result
// This includes: pages, files, images, and external links
all_links << page_meta.links
// Recursively traverse only page-to-page links
for link in page_meta.links {
// Only recursively process links to other pages within the atlas
// Skip external links (http, https, mailto, etc.)
// Skip file and image links (these don't have "contained" links)
if link.file_type != .page || link.status == .external {
continue
}
// Recursively collect links from the target page
c.collect_page_links_recursive(link.target_collection_name, link.target_item_name, mut visited, mut all_links) or {
// If we encounter an error (e.g., target page doesn't exist in metadata),
// we continue processing other links rather than failing completely
// This provides graceful degradation for broken link references
continue
}
}
}
// get_image_links returns all image links found in a page and related pages (recursive)
// This is a convenience function that filters get_page_links to only image links
pub fn (mut c AtlasClient) get_image_links(collection_name string, page_name string) ![]LinkMetadata {
all_links := c.get_page_links(collection_name, page_name)!
mut image_links := []LinkMetadata{}
for link in all_links {
if link.file_type == .image {
image_links << link
}
}
return image_links
}
// get_file_links returns all file links (non-image) found in a page and related pages (recursive)
// This is a convenience function that filters get_page_links to only file links
pub fn (mut c AtlasClient) get_file_links(collection_name string, page_name string) ![]LinkMetadata {
all_links := c.get_page_links(collection_name, page_name)!
mut file_links := []LinkMetadata{}
for link in all_links {
if link.file_type == .file {
file_links << link
}
}
return file_links
}
// get_page_link_targets returns all page-to-page link targets found in a page and related pages
// This is a convenience function that filters get_page_links to only page links
pub fn (mut c AtlasClient) get_page_link_targets(collection_name string, page_name string) ![]LinkMetadata {
all_links := c.get_page_links(collection_name, page_name)!
mut page_links := []LinkMetadata{}
for link in all_links {
if link.file_type == .page && link.status != .external {
page_links << link
}
}
return page_links
}

View File

@@ -7,7 +7,7 @@ import json
@[params]
pub struct ExportArgs {
pub mut:
destination string @[requireds]
destination string @[required]
reset bool = true
include bool = true
redis bool = true
@@ -90,6 +90,44 @@ pub fn (mut c Collection) export(args CollectionExportArgs) ! {
c.collect_cross_collection_references(mut page, mut cross_collection_pages, mut
cross_collection_files, mut processed_cross_pages)!
// println('------- ${c.name} ${page.key()}')
// if page.key() == 'geoaware:solution' && c.name == 'mycelium_nodes_tiers' {
// println(cross_collection_pages)
// println(cross_collection_files)
// // println(processed_cross_pages)
// $dbg;
// }
// copy the pages to the right exported path
for _, mut ref_page in cross_collection_pages {
mut src_file := ref_page.path()!
mut subdir_path := pathlib.get_dir(
path: '${col_dir.path}'
create: true
)!
mut dest_path := '${subdir_path.path}/${ref_page.name}.md'
src_file.copy(dest: dest_path)!
// println(dest_path)
// $dbg;
}
// copy the files to the right exported path
for _, mut ref_file in cross_collection_files {
mut src_file2 := ref_file.path()!
// Determine subdirectory based on file type
mut subdir := if ref_file.is_image() { 'img' } else { 'files' }
// Ensure subdirectory exists
mut subdir_path := pathlib.get_dir(
path: '${col_dir.path}/${subdir}'
create: true
)!
mut dest_path := '${subdir_path.path}/${ref_file.name}'
mut dest_file2 := pathlib.get_file(path: dest_path, create: true)!
src_file2.copy(dest: dest_file2.path)!
}
processed_local_pages[page.name] = true
// Redis operations...
@@ -117,65 +155,6 @@ pub fn (mut c Collection) export(args CollectionExportArgs) ! {
mut dest_file := pathlib.get_file(path: dest_path, create: true)!
src_file.copy(dest: dest_file.path)!
}
// Second pass: copy all collected cross-collection pages and process their links recursively
// Keep iterating until no new cross-collection references are found
for {
mut found_new_references := false
// Process all cross-collection pages we haven't processed yet
for page_key, mut ref_page in cross_collection_pages {
if page_key in processed_cross_pages {
continue // Already processed this page's links
}
// Mark as processed to avoid infinite loops
processed_cross_pages[page_key] = true
found_new_references = true
// Get the referenced page content with includes processed
ref_content := ref_page.content_with_fixed_links(
include: args.include
cross_collection: true
export_mode: true
)!
// Write the referenced page to this collection's directory
mut dest_file := pathlib.get_file(
path: '${col_dir.path}/${ref_page.name}.md'
create: true
)!
dest_file.write(ref_content)!
// CRITICAL: Recursively process links in this cross-collection page
// This ensures we get pages/files/images referenced by ref_page
c.collect_cross_collection_references(mut ref_page, mut cross_collection_pages, mut
cross_collection_files, mut processed_cross_pages)!
}
// If we didn't find any new references, we're done with the recursive pass
if !found_new_references {
break
}
}
// Third pass: copy ALL collected cross-collection referenced files/images
for _, mut ref_file in cross_collection_files {
mut src_file := ref_file.path()!
// Determine subdirectory based on file type
mut subdir := if ref_file.is_image() { 'img' } else { 'files' }
// Ensure subdirectory exists
mut subdir_path := pathlib.get_dir(
path: '${col_dir.path}/${subdir}'
create: true
)!
mut dest_path := '${subdir_path.path}/${ref_file.name}'
mut dest_file := pathlib.get_file(path: dest_path, create: true)!
src_file.copy(dest: dest_file.path)!
}
}
// Helper function to recursively collect cross-collection references
@@ -184,6 +163,17 @@ fn (mut c Collection) collect_cross_collection_references(mut page Page,
mut all_cross_pages map[string]&Page,
mut all_cross_files map[string]&File,
mut processed_pages map[string]bool) ! {
page_key := page.key()
// If we've already processed this page, skip it (prevents infinite loops with cycles)
if page_key in processed_pages {
return
}
// Mark this page as processed BEFORE recursing (prevents infinite loops with circular references)
processed_pages[page_key] = true
// Process all links in the current page
// Use cached links from validation (before transformation) to preserve collection info
for mut link in page.links {
if link.status != .found {
@@ -192,15 +182,19 @@ fn (mut c Collection) collect_cross_collection_references(mut page Page,
is_local := link.target_collection_name == c.name
// Collect cross-collection page references
// Collect cross-collection page references and recursively process them
if link.file_type == .page && !is_local {
page_key := '${link.target_collection_name}:${link.target_item_name}'
page_ref := '${link.target_collection_name}:${link.target_item_name}'
// Only add if not already collected
if page_key !in all_cross_pages {
if page_ref !in all_cross_pages {
mut target_page := link.target_page()!
all_cross_pages[page_key] = target_page
// Don't mark as processed yet - we'll do that when we actually process its links
all_cross_pages[page_ref] = target_page
// Recursively process the target page's links to find more cross-collection references
// This ensures we collect ALL transitive cross-collection page and file references
c.collect_cross_collection_references(mut target_page, mut all_cross_pages, mut
all_cross_files, mut processed_pages)!
}
}

View File

@@ -1,15 +0,0 @@
in atlas/
check format of groups
see content/groups
now the groups end with .group
check how the include works, so we can include another group in the group as defined, only works in same folder
in the scan function in atlas, now make scan_groups function, find groups, only do this for collection as named groups
do not add collection groups to atlas, this is a system collection
make the groups and add them to atlas
give clear instructions for coding agent how to write the code

View File

@@ -3,6 +3,7 @@ module atlas
import incubaid.herolib.core.playbook { PlayBook }
import incubaid.herolib.develop.gittools
import incubaid.herolib.ui.console
import os
// Play function to process HeroScript actions for Atlas
pub fn play(mut plbook PlayBook) ! {
@@ -66,7 +67,7 @@ pub fn play(mut plbook PlayBook) ! {
for mut action in export_actions {
mut p := action.params
name = p.get_default('name', 'main')!
destination := p.get_default('destination', '/tmp/atlas_export')!
destination := p.get_default('destination', '${os.home_dir()}/hero/var/atlas_export')!
reset := p.get_default_true('reset')
include := p.get_default_true('include')
redis := p.get_default_true('redis')

View File

@@ -1,4 +0,0 @@
- first find all pages
- then for each page find all links

View File

@@ -33,7 +33,7 @@ put in .hero file and execute with hero or but shebang line on top of .hero scri
!!atlas.scan git_url:"https://git.ourworld.tf/tfgrid/docs_tfgrid4/src/branch/main/collections/tests"
!!atlas.export destination: '/tmp/atlas_export'
!!atlas.export
```

View File

@@ -71,9 +71,9 @@ pub struct DevArgs {
pub mut:
host string = 'localhost'
port int = 3000
open bool = true // whether to open the browser automatically
watch_changes bool = false // whether to watch for changes in docs and rebuild automatically
skip_generate bool = false // whether to skip generation (useful when docs are pre-generated, e.g., from atlas)
open bool = true // whether to open the browser automatically
watch_changes bool // whether to watch for changes in docs and rebuild automatically
skip_generate bool // whether to skip generation (useful when docs are pre-generated, e.g., from atlas)
}
pub fn (mut s DocSite) open(args DevArgs) ! {

View File

@@ -142,6 +142,10 @@ fn (mut generator SiteGenerator) page_generate(args_ Page) ! {
pagefile.write(c)!
generator.client.copy_pages(collection_name, page_name, pagefile.path_dir()) or {
generator.error("Couldn't copy pages for page:'${page_name}' in collection:'${collection_name}'\nERROR:${err}")!
return
}
generator.client.copy_images(collection_name, page_name, pagefile.path_dir()) or {
generator.error("Couldn't copy images for page:'${page_name}' in collection:'${collection_name}'\nERROR:${err}")!
return

View File

@@ -1,30 +0,0 @@
module docusaurus
pub interface IDocClient {
mut:
// Path methods - get absolute paths to resources
get_page_path(collection_name string, page_name string) !string
get_file_path(collection_name string, file_name string) !string
get_image_path(collection_name string, image_name string) !string
// Existence checks - verify if resources exist
page_exists(collection_name string, page_name string) bool
file_exists(collection_name string, file_name string) bool
image_exists(collection_name string, image_name string) bool
// Content retrieval
get_page_content(collection_name string, page_name string) !string
// Listing methods - enumerate resources
list_collections() ![]string
list_pages(collection_name string) ![]string
list_files(collection_name string) ![]string
list_images(collection_name string) ![]string
list_pages_map() !map[string][]string
list_markdown() !string
// Image operations
// get_page_paths(collection_name string, page_name string) !(string, []string)
copy_images(collection_name string, page_name string, destination_path string) !
copy_files(collection_name string, page_name string, destination_path string) !
}

View File

@@ -1,6 +1,7 @@
module docusaurus
import incubaid.herolib.core.playbook { PlayBook }
import os
pub fn play(mut plbook PlayBook) ! {
if !plbook.exists(filter: 'docusaurus.') {
@@ -17,7 +18,7 @@ pub fn play(mut plbook PlayBook) ! {
reset: param_define.get_default_false('reset')
template_update: param_define.get_default_false('template_update')
install: param_define.get_default_false('install')
atlas_dir: param_define.get_default('atlas_dir', '/tmp/atlas_export')!
atlas_dir: param_define.get_default('atlas_dir', '${os.home_dir()}/hero/var/atlas_export')!
use_atlas: param_define.get_default_false('use_atlas')
)!

View File

@@ -1,536 +0,0 @@
# AI Instructions for Site Module HeroScript
This document provides comprehensive instructions for AI agents working with the Site module's HeroScript format.
## HeroScript Format Overview
HeroScript is a declarative configuration language with the following characteristics:
### Basic Syntax
```heroscript
!!actor.action
param1: "value1"
param2: "value2"
multiline_param: "
This is a multiline value.
It can span multiple lines.
"
arg1 arg2 // Arguments without keys
```
**Key Rules:**
1. Actions start with `!!` followed by `actor.action` format
2. Parameters are indented and use `key: "value"` or `key: value` format
3. Values with spaces must be quoted
4. Multiline values are supported with quotes
5. Arguments without keys are space-separated
6. Comments start with `//`
## Site Module Actions
### 1. Site Configuration (`!!site.config`)
**Purpose:** Define the main site configuration including title, description, and metadata.
**Required Parameters:**
- `name`: Site identifier (will be normalized to snake_case)
**Optional Parameters:**
- `title`: Site title (default: "Documentation Site")
- `description`: Site description
- `tagline`: Site tagline
- `favicon`: Path to favicon (default: "img/favicon.png")
- `image`: Default site image (default: "img/tf_graph.png")
- `copyright`: Copyright text
- `url`: Main site URL
- `base_url`: Base URL path (default: "/")
- `url_home`: Home page path
**Example:**
```heroscript
!!site.config
name: "my_documentation"
title: "My Documentation Site"
description: "Comprehensive technical documentation"
tagline: "Learn everything you need"
url: "https://docs.example.com"
base_url: "/"
```
**AI Guidelines:**
- Always include `name` parameter
- Use descriptive titles and descriptions
- Ensure URLs are properly formatted with protocol
### 2. Metadata Configuration (`!!site.config_meta`)
**Purpose:** Override specific metadata for SEO purposes.
**Optional Parameters:**
- `title`: SEO-specific title (overrides site.config title for meta tags)
- `image`: SEO-specific image (overrides site.config image for og:image)
- `description`: SEO-specific description
**Example:**
```heroscript
!!site.config_meta
title: "My Docs - Complete Guide"
image: "img/social-preview.png"
description: "The ultimate guide to using our platform"
```
**AI Guidelines:**
- Use only when SEO metadata needs to differ from main config
- Keep titles concise for social media sharing
- Use high-quality images for social previews
### 3. Navigation Bar (`!!site.navbar` or `!!site.menu`)
**Purpose:** Configure the main navigation bar.
**Optional Parameters:**
- `title`: Navigation title (defaults to site.config title)
- `logo_alt`: Logo alt text
- `logo_src`: Logo image path
- `logo_src_dark`: Dark mode logo path
**Example:**
```heroscript
!!site.navbar
title: "My Site"
logo_alt: "My Site Logo"
logo_src: "img/logo.svg"
logo_src_dark: "img/logo-dark.svg"
```
**AI Guidelines:**
- Use `!!site.navbar` for modern syntax (preferred)
- `!!site.menu` is supported for backward compatibility
- Provide both light and dark logos when possible
### 4. Navigation Items (`!!site.navbar_item` or `!!site.menu_item`)
**Purpose:** Add items to the navigation bar.
**Required Parameters (one of):**
- `to`: Internal link path
- `href`: External URL
**Optional Parameters:**
- `label`: Display text (required in practice)
- `position`: "left" or "right" (default: "right")
**Example:**
```heroscript
!!site.navbar_item
label: "Documentation"
to: "docs/intro"
position: "left"
!!site.navbar_item
label: "GitHub"
href: "https://github.com/myorg/repo"
position: "right"
```
**AI Guidelines:**
- Use `to` for internal navigation
- Use `href` for external links
- Position important items on the left, secondary items on the right
### 5. Footer Configuration (`!!site.footer`)
**Purpose:** Configure footer styling.
**Optional Parameters:**
- `style`: "dark" or "light" (default: "dark")
**Example:**
```heroscript
!!site.footer
style: "dark"
```
### 6. Footer Items (`!!site.footer_item`)
**Purpose:** Add links to the footer, grouped by title.
**Required Parameters:**
- `title`: Group title (items with same title are grouped together)
- `label`: Link text
**Required Parameters (one of):**
- `to`: Internal link path
- `href`: External URL
**Example:**
```heroscript
!!site.footer_item
title: "Docs"
label: "Introduction"
to: "intro"
!!site.footer_item
title: "Docs"
label: "API Reference"
to: "api"
!!site.footer_item
title: "Community"
label: "Discord"
href: "https://discord.gg/example"
```
**AI Guidelines:**
- Group related links under the same title
- Use consistent title names across related items
- Provide both internal and external links as appropriate
### 7. Page Categories (`!!site.page_category`)
**Purpose:** Create a section/category to organize pages.
**Required Parameters:**
- `name`: Category identifier (snake_case)
**Optional Parameters:**
- `label`: Display name (auto-generated from name if not provided)
- `position`: Manual sort order (auto-incremented if not specified)
- `path`: URL path segment (defaults to normalized label)
**Example:**
```heroscript
!!site.page_category
name: "getting_started"
label: "Getting Started"
position: 100
!!site.page_category
name: "advanced_topics"
label: "Advanced Topics"
```
**AI Guidelines:**
- Use descriptive snake_case names
- Let label be auto-generated when possible (name_fix converts to Title Case)
- Categories persist for all subsequent pages until a new category is declared
- Position values should leave gaps (100, 200, 300) for future insertions
### 8. Pages (`!!site.page`)
**Purpose:** Define individual pages in the site.
**Required Parameters:**
- `src`: Source reference as `collection:page_name` (required for first page in a collection)
**Optional Parameters:**
- `name`: Page identifier (extracted from src if not provided)
- `title`: Page title (extracted from markdown if not provided)
- `description`: Page description for metadata
- `slug`: Custom URL slug
- `position`: Manual sort order (auto-incremented if not specified)
- `draft`: Mark as draft (default: false)
- `hide_title`: Hide title in rendering (default: false)
- `path`: Custom path (defaults to current category name)
- `category`: Override current category
- `title_nr`: Title numbering level
**Example:**
```heroscript
!!site.page src: "docs:introduction"
description: "Introduction to the platform"
slug: "/"
!!site.page src: "quickstart"
description: "Get started in 5 minutes"
!!site.page src: "installation"
title: "Installation Guide"
description: "How to install and configure"
position: 10
```
**AI Guidelines:**
- **Collection Persistence:** Specify collection once (e.g., `docs:introduction`), then subsequent pages only need page name (e.g., `quickstart`)
- **Category Persistence:** Pages belong to the most recently declared category
- **Title Extraction:** Prefer extracting titles from markdown files
- **Position Management:** Use automatic positioning unless specific order is required
- **Description Required:** Always provide descriptions for SEO
- **Slug Usage:** Use slug for special pages like homepage (`slug: "/"`)
### 9. Import External Content (`!!site.import`)
**Purpose:** Import content from external sources.
**Optional Parameters:**
- `name`: Import identifier
- `url`: Git URL or HTTP URL
- `path`: Local file system path
- `dest`: Destination path in site
- `replace`: Comma-separated key:value pairs for variable replacement
- `visible`: Whether imported content is visible (default: true)
**Example:**
```heroscript
!!site.import
url: "https://github.com/example/docs"
dest: "external"
replace: "VERSION:1.0.0,PROJECT:MyProject"
visible: true
```
**AI Guidelines:**
- Use for shared documentation across multiple sites
- Replace variables using `${VARIABLE}` syntax in source content
- Set `visible: false` for imported templates or partials
### 10. Publish Destinations (`!!site.publish` and `!!site.publish_dev`)
**Purpose:** Define where to publish the built site.
**Optional Parameters:**
- `path`: File system path or URL
- `ssh_name`: SSH connection name for remote deployment
**Example:**
```heroscript
!!site.publish
path: "/var/www/html/docs"
ssh_name: "production_server"
!!site.publish_dev
path: "/tmp/docs-preview"
```
**AI Guidelines:**
- Use `!!site.publish` for production deployments
- Use `!!site.publish_dev` for development/preview deployments
- Can specify multiple destinations
## File Organization Best Practices
### Naming Convention
Use numeric prefixes to control execution order:
```
0_config.heroscript # Site configuration
1_navigation.heroscript # Menu and footer
2_intro.heroscript # Introduction pages
3_guides.heroscript # User guides
4_reference.heroscript # API reference
```
**AI Guidelines:**
- Always use numeric prefixes (0_, 1_, 2_, etc.)
- Leave gaps in numbering (0, 10, 20) for future insertions
- Group related configurations in the same file
- Process order matters: config → navigation → pages
### Execution Order Rules
1. **Configuration First:** `!!site.config` must be processed before other actions
2. **Categories Before Pages:** Declare `!!site.page_category` before pages in that category
3. **Collection Persistence:** First page in a collection must specify `collection:page_name`
4. **Category Persistence:** Pages inherit the most recent category declaration
## Common Patterns
### Pattern 1: Simple Documentation Site
```heroscript
!!site.config
name: "simple_docs"
title: "Simple Documentation"
!!site.navbar
title: "Simple Docs"
!!site.page src: "docs:index"
description: "Welcome page"
slug: "/"
!!site.page src: "getting-started"
description: "Getting started guide"
!!site.page src: "api"
description: "API reference"
```
### Pattern 2: Multi-Section Documentation
```heroscript
!!site.config
name: "multi_section_docs"
title: "Complete Documentation"
!!site.page_category
name: "introduction"
label: "Introduction"
!!site.page src: "docs:welcome"
description: "Welcome to our documentation"
!!site.page src: "overview"
description: "Platform overview"
!!site.page_category
name: "tutorials"
label: "Tutorials"
!!site.page src: "tutorial_basics"
description: "Basic tutorial"
!!site.page src: "tutorial_advanced"
description: "Advanced tutorial"
```
### Pattern 3: Complex Site with External Links
```heroscript
!!site.config
name: "complex_site"
title: "Complex Documentation Site"
url: "https://docs.example.com"
!!site.navbar
title: "My Platform"
logo_src: "img/logo.svg"
!!site.navbar_item
label: "Docs"
to: "docs/intro"
position: "left"
!!site.navbar_item
label: "API"
to: "api"
position: "left"
!!site.navbar_item
label: "GitHub"
href: "https://github.com/example/repo"
position: "right"
!!site.footer
style: "dark"
!!site.footer_item
title: "Documentation"
label: "Getting Started"
to: "docs/intro"
!!site.footer_item
title: "Community"
label: "Discord"
href: "https://discord.gg/example"
!!site.page_category
name: "getting_started"
!!site.page src: "docs:introduction"
description: "Introduction to the platform"
slug: "/"
!!site.page src: "installation"
description: "Installation guide"
```
## Error Prevention
### Common Mistakes to Avoid
1. **Missing Collection on First Page:**
```heroscript
# WRONG - no collection specified
!!site.page src: "introduction"
# CORRECT
!!site.page src: "docs:introduction"
```
2. **Category Without Name:**
```heroscript
# WRONG - missing name
!!site.page_category
label: "Getting Started"
# CORRECT
!!site.page_category
name: "getting_started"
label: "Getting Started"
```
3. **Missing Description:**
```heroscript
# WRONG - no description
!!site.page src: "docs:intro"
# CORRECT
!!site.page src: "docs:intro"
description: "Introduction to the platform"
```
4. **Incorrect File Ordering:**
```
# WRONG - pages before config
pages.heroscript
config.heroscript
# CORRECT - config first
0_config.heroscript
1_pages.heroscript
```
## Validation Checklist
When generating HeroScript for the Site module, verify:
- [ ] `!!site.config` includes `name` parameter
- [ ] All pages have `description` parameter
- [ ] First page in each collection specifies `collection:page_name`
- [ ] Categories are declared before their pages
- [ ] Files use numeric prefixes for ordering
- [ ] Navigation items have either `to` or `href`
- [ ] Footer items are grouped by `title`
- [ ] External URLs include protocol (https://)
- [ ] Paths don't have trailing slashes unless intentional
- [ ] Draft pages are marked with `draft: true`
## Integration with V Code
When working with the Site module in V code:
```v
import incubaid.herolib.web.site
import incubaid.herolib.core.playbook
// Process HeroScript files
mut plbook := playbook.new(path: '/path/to/heroscripts')!
site.play(mut plbook)!
// Access configured site
mut mysite := site.get(name: 'my_site')!
// Iterate through pages
for page in mysite.pages {
println('Page: ${page.name} - ${page.description}')
}
// Iterate through sections
for section in mysite.sections {
println('Section: ${section.label}')
}
```
## Summary
The Site module's HeroScript format provides a declarative way to configure websites with:
- Clear separation of concerns (config, navigation, content)
- Automatic ordering and organization
- Collection and category persistence for reduced repetition
- Flexible metadata and SEO configuration
- Support for both internal and external content
Always follow the execution order rules, use numeric file prefixes, and provide complete metadata for best results.

View File

@@ -3,7 +3,7 @@ module site
import incubaid.herolib.core.texttools
__global (
websites map[string]&Site
mywebsites map[string]&Site
)
@[params]
@@ -15,7 +15,13 @@ pub mut:
pub fn new(args FactoryArgs) !&Site {
name := texttools.name_fix(args.name)
websites[name] = &Site{
// Check if a site with this name already exists
if name in mywebsites {
// Return the existing site instead of creating a new one
return get(name: name)!
}
mywebsites[name] = &Site{
siteconfig: SiteConfig{
name: name
}
@@ -25,18 +31,17 @@ pub fn new(args FactoryArgs) !&Site {
pub fn get(args FactoryArgs) !&Site {
name := texttools.name_fix(args.name)
mut sc := websites[name] or { return error('siteconfig with name "${name}" does not exist') }
mut sc := mywebsites[name] or { return error('siteconfig with name "${name}" does not exist') }
return sc
}
pub fn exists(args FactoryArgs) bool {
name := texttools.name_fix(args.name)
mut sc := websites[name] or { return false }
return true
return name in mywebsites
}
pub fn default() !&Site {
if websites.len == 0 {
if mywebsites.len == 0 {
return new(name: 'default')!
}
return get()!
@@ -44,5 +49,5 @@ pub fn default() !&Site {
// list returns all site names that have been created
pub fn list() []string {
return websites.keys()
return mywebsites.keys()
}

143
lib/web/site/model_nav.v Normal file
View File

@@ -0,0 +1,143 @@
module site
import json
// Top-level config
pub struct NavConfig {
pub mut:
my_sidebar []NavItem
// myTopbar []NavItem //not used yet
// myFooter []NavItem //not used yet
}
// -------- Variant Type --------
pub type NavItem = NavDoc | NavCat | NavLink
// --------- DOC ITEM ----------
pub struct NavDoc {
pub:
id string // is the page id
label string
}
// --------- CATEGORY ----------
pub struct NavCat {
pub mut:
label string
collapsible bool
collapsed bool
items []NavItem
}
// --------- LINK ----------
pub struct NavLink {
pub:
label string
href string
description string
}
// -------- JSON SERIALIZATION --------
// NavItemJson is used for JSON export with type discrimination
pub struct NavItemJson {
pub mut:
type_field string @[json: 'type']
// For doc
id string @[omitempty]
label string @[omitempty]
// For link
href string @[omitempty]
description string @[omitempty]
// For category
collapsible bool
collapsed bool
items []NavItemJson @[omitempty]
}
// Convert a single NavItem to JSON-serializable format
fn nav_item_to_json(item NavItem) !NavItemJson {
return match item {
NavDoc {
NavItemJson{
type_field: 'doc'
id: item.id
label: item.label
collapsible: false
collapsed: false
}
}
NavLink {
NavItemJson{
type_field: 'link'
label: item.label
href: item.href
description: item.description
collapsible: false
collapsed: false
}
}
NavCat {
mut json_items := []NavItemJson{}
for sub_item in item.items {
json_items << nav_item_to_json(sub_item)!
}
NavItemJson{
type_field: 'category'
label: item.label
collapsible: item.collapsible
collapsed: item.collapsed
items: json_items
}
}
}
}
// Convert entire NavConfig sidebar to JSON string
fn (nc NavConfig) sidebar_to_json() !string {
mut result := []NavItemJson{}
for item in nc.my_sidebar {
result << nav_item_to_json(item)!
}
return json.encode_pretty(result)
}
// // Convert entire NavConfig topbar to JSON-serializable array
// fn (nc NavConfig) topbar_to_json() ![]NavItemJson {
// mut result := []NavItemJson{}
// for item in nc.myTopbar {
// result << nav_item_to_json(item)!
// }
// return result
// }
// // Convert entire NavConfig footer to JSON-serializable array
// fn (nc NavConfig) footer_to_json() ![]NavItemJson {
// mut result := []NavItemJson{}
// for item in nc.myFooter {
// result << nav_item_to_json(item)!
// }
// return result
// }
// port topbar as formatted JSON string
// pub fn (nc NavConfig) jsondump_topbar() !string {
// items := nc.topbar_to_json()!
// return json.encode_pretty(items)
// }
// // Export footer as formatted JSON string
// pub fn (nc NavConfig) jsondump_footer() !string {
// items := nc.footer_to_json()!
// return json.encode_pretty(items)
// }
// // Export all navigation as object with sidebar, topbar, footer
// pub fn (nc NavConfig) jsondump_all() !string {
// all_nav := map[string][]NavItemJson{
// 'sidebar': nc.sidebar_to_json()!
// 'topbar': nc.topbar_to_json()!
// 'footer': nc.footer_to_json()!
// }
// return json.encode_pretty(all_nav)
// }

View File

@@ -1,16 +1,12 @@
module site
// Page represents a single documentation page
pub struct Page {
pub mut:
name string
title string
description string
draft bool
position int
hide_title bool
src string @[required] // always in format collection:page_name, can use the default collection if no : specified
path string @[required] // is without the page name, so just the path to the folder where the page is in
section_name string
title_nr int
slug string
id string // Unique identifier: "collection:page_name"
title string // Display title (optional, extracted from markdown if empty)
description string // Brief description for metadata
draft bool // Mark as draft (hidden from navigation)
hide_title bool // Hide the title when rendering
src string // Source reference (same as id in this format)
}

View File

@@ -0,0 +1,9 @@
module site
@[heap]
pub struct Site {
pub mut:
pages map[string]Page // key: "collection:page_name"
nav NavConfig // Navigation sidebar configuration
siteconfig SiteConfig // Full site configuration
}

View File

@@ -1,18 +0,0 @@
module site
@[heap]
pub struct Site {
pub mut:
pages []Page
sections []Section
siteconfig SiteConfig
}
pub struct Section {
pub mut:
name string
position int
path string
label string
description string
}

View File

@@ -4,222 +4,93 @@ import os
import incubaid.herolib.core.playbook { PlayBook }
import incubaid.herolib.core.texttools
import time
import incubaid.herolib.ui.console
// Main entry point for processing site HeroScript
pub fn play(mut plbook PlayBook) ! {
if !plbook.exists(filter: 'site.') {
return
}
console.print_header('Processing Site Configuration')
// ============================================================
// STEP 1: Initialize core site configuration
// ============================================================
console.print_item('Step 1: Loading site configuration')
mut config_action := plbook.ensure_once(filter: 'site.config')!
mut p := config_action.params
name := p.get_default('name', 'default')! // Use 'default' as fallback name
// configure the website
name := p.get_default('name', 'default')!
mut website := new(name: name)!
mut config := &website.siteconfig
// Load core configuration
config.name = texttools.name_fix(name)
config.title = p.get_default('title', 'Documentation Site')!
config.description = p.get_default('description', 'Comprehensive documentation built with Docusaurus.')!
config.tagline = p.get_default('tagline', 'Your awesome documentation')!
config.favicon = p.get_default('favicon', 'img/favicon.png')!
config.image = p.get_default('image', 'img/tf_graph.png')!
config.copyright = p.get_default('copyright', '© ' + time.now().year.str() +
' Example Organization')!
config.copyright = p.get_default('copyright', '© ${time.now().year} Example Organization')!
config.url = p.get_default('url', '')!
config.base_url = p.get_default('base_url', '/')!
config.url_home = p.get_default('url_home', '')!
// Process !!site.config_meta for specific metadata overrides
mut meta_action := plbook.ensure_once(filter: 'site.config_meta')!
mut p_meta := meta_action.params
config_action.done = true
// If 'title' is present in site.config_meta, it overrides. Otherwise, meta_title remains empty or uses site.config.title logic in docusaurus model.
config.meta_title = p_meta.get_default('title', config.title)!
// If 'image' is present in site.config_meta, it overrides. Otherwise, meta_image remains empty or uses site.config.image logic.
config.meta_image = p_meta.get_default('image', config.image)!
// If 'description' is present in site.config_meta, it overrides the main description
if p_meta.exists('description') {
config.description = p_meta.get('description')!
// ============================================================
// STEP 2: Apply optional metadata overrides
// ============================================================
console.print_item('Step 2: Applying metadata overrides')
if plbook.exists_once(filter: 'site.config_meta') {
mut meta_action := plbook.get(filter: 'site.config_meta')!
mut p_meta := meta_action.params
config.meta_title = p_meta.get_default('title', config.title)!
config.meta_image = p_meta.get_default('image', config.image)!
if p_meta.exists('description') {
config.description = p_meta.get('description')!
}
meta_action.done = true
}
config_action.done = true // Mark the action as done
meta_action.done = true
// ============================================================
// STEP 3: Configure content imports
// ============================================================
console.print_item('Step 3: Configuring content imports')
play_imports(mut plbook, mut config)!
play_import(mut plbook, mut config)!
play_menu(mut plbook, mut config)!
// ============================================================
// STEP 4: Configure navigation menu
// ============================================================
console.print_item('Step 4: Configuring navigation menu')
play_navbar(mut plbook, mut config)!
// ============================================================
// STEP 5: Configure footer
// ============================================================
console.print_item('Step 5: Configuring footer')
play_footer(mut plbook, mut config)!
// ============================================================
// STEP 6: Configure announcement bar (optional)
// ============================================================
console.print_item('Step 6: Configuring announcement bar (if present)')
play_announcement(mut plbook, mut config)!
play_publish(mut plbook, mut config)!
play_publish_dev(mut plbook, mut config)!
// ============================================================
// STEP 7: Configure publish destinations
// ============================================================
console.print_item('Step 7: Configuring publish destinations')
play_publishing(mut plbook, mut config)!
// ============================================================
// STEP 8: Build pages and navigation structure
// ============================================================
console.print_item('Step 8: Processing pages and building navigation')
play_pages(mut plbook, mut website)!
}
fn play_import(mut plbook PlayBook, mut config SiteConfig) ! {
mut import_actions := plbook.find(filter: 'site.import')!
// println('import_actions: ${import_actions}')
for mut action in import_actions {
mut p := action.params
mut replace_map := map[string]string{}
if replace_str := p.get_default('replace', '') {
parts := replace_str.split(',')
for part in parts {
kv := part.split(':')
if kv.len == 2 {
replace_map[kv[0].trim_space()] = kv[1].trim_space()
}
}
}
mut importpath := p.get_default('path', '')!
if importpath != '' {
if !importpath.starts_with('/') {
importpath = os.abs_path('${plbook.path}/${importpath}')
}
}
mut import_ := ImportItem{
name: p.get_default('name', '')!
url: p.get_default('url', '')!
path: importpath
dest: p.get_default('dest', '')!
replace: replace_map
visible: p.get_default_false('visible')
}
config.imports << import_
action.done = true // Mark the action as done
}
}
fn play_menu(mut plbook PlayBook, mut config SiteConfig) ! {
mut navbar_actions := plbook.find(filter: 'site.navbar')!
if navbar_actions.len > 0 {
for mut action in navbar_actions { // Should ideally be one, but loop for safety
mut p := action.params
config.menu.title = p.get_default('title', config.title)! // Use existing config.title as ultimate fallback
config.menu.logo_alt = p.get_default('logo_alt', '')!
config.menu.logo_src = p.get_default('logo_src', '')!
config.menu.logo_src_dark = p.get_default('logo_src_dark', '')!
action.done = true // Mark the action as done
}
} else {
// Fallback to site.menu for title if site.navbar is not found
mut menu_actions := plbook.find(filter: 'site.menu')!
for mut action in menu_actions {
mut p := action.params
config.menu.title = p.get_default('title', config.title)!
config.menu.logo_alt = p.get_default('logo_alt', '')!
config.menu.logo_src = p.get_default('logo_src', '')!
config.menu.logo_src_dark = p.get_default('logo_src_dark', '')!
action.done = true // Mark the action as done
}
}
mut menu_item_actions := plbook.find(filter: 'site.navbar_item')!
if menu_item_actions.len == 0 {
// Fallback to site.menu_item if site.navbar_item is not found
menu_item_actions = plbook.find(filter: 'site.menu_item')!
}
// Clear existing menu items to prevent duplication
config.menu.items = []MenuItem{}
for mut action in menu_item_actions {
mut p := action.params
mut item := MenuItem{
label: p.get_default('label', 'Documentation')!
href: p.get_default('href', '')!
to: p.get_default('to', '')!
position: p.get_default('position', 'right')!
}
config.menu.items << item
action.done = true // Mark the action as done
}
}
fn play_footer(mut plbook PlayBook, mut config SiteConfig) ! {
mut footer_actions := plbook.find(filter: 'site.footer')!
for mut action in footer_actions {
mut p := action.params
config.footer.style = p.get_default('style', 'dark')!
action.done = true // Mark the action as done
}
mut footer_item_actions := plbook.find(filter: 'site.footer_item')!
mut links_map := map[string][]FooterItem{}
// Clear existing footer links to prevent duplication
config.footer.links = []FooterLink{}
for mut action in footer_item_actions {
mut p := action.params
title := p.get_default('title', 'Docs')!
mut item := FooterItem{
label: p.get_default('label', 'Introduction')!
href: p.get_default('href', '')!
to: p.get_default('to', '')!
}
if title !in links_map {
links_map[title] = []FooterItem{}
}
links_map[title] << item
action.done = true // Mark the action as done
}
// Convert map to footer links array
for title, items in links_map {
config.footer.links << FooterLink{
title: title
items: items
}
}
}
fn play_announcement(mut plbook PlayBook, mut config SiteConfig) ! {
mut announcement_actions := plbook.find(filter: 'site.announcement')!
if announcement_actions.len > 0 {
// Only process the first announcement action
mut action := announcement_actions[0]
mut p := action.params
config.announcement = AnnouncementBar{
id: p.get_default('id', 'announcement')!
content: p.get_default('content', '')!
background_color: p.get_default('background_color', '#20232a')!
text_color: p.get_default('text_color', '#fff')!
is_closeable: p.get_default_true('is_closeable')
}
action.done = true // Mark the action as done
}
}
fn play_publish(mut plbook PlayBook, mut config SiteConfig) ! {
mut build_dest_actions := plbook.find(filter: 'site.publish')!
for mut action in build_dest_actions {
mut p := action.params
mut dest := BuildDest{
path: p.get_default('path', '')! // can be url
ssh_name: p.get_default('ssh_name', '')!
}
config.build_dest << dest
action.done = true // Mark the action as done
}
}
fn play_publish_dev(mut plbook PlayBook, mut config SiteConfig) ! {
mut build_dest_actions := plbook.find(filter: 'site.publish_dev')!
for mut action in build_dest_actions {
mut p := action.params
mut dest := BuildDest{
path: p.get_default('path', '')! // can be url
ssh_name: p.get_default('ssh_name', '')!
}
config.build_dest_dev << dest
action.done = true // Mark the action as done
}
console.print_green('Site configuration complete')
}

View File

@@ -0,0 +1,34 @@
module site
import os
import incubaid.herolib.core.playbook { PlayBook }
import incubaid.herolib.core.texttools
import time
import incubaid.herolib.ui.console
// ============================================================
// ANNOUNCEMENT: Process announcement bar (optional)
// ============================================================
fn play_announcement(mut plbook PlayBook, mut config SiteConfig) ! {
mut announcement_actions := plbook.find(filter: 'site.announcement')!
if announcement_actions.len > 0 {
// Only process the first announcement action
mut action := announcement_actions[0]
mut p := action.params
content := p.get('content') or {
return error('!!site.announcement: must specify "content"')
}
config.announcement = AnnouncementBar{
id: p.get_default('id', 'announcement')!
content: content
background_color: p.get_default('background_color', '#20232a')!
text_color: p.get_default('text_color', '#fff')!
is_closeable: p.get_default_true('is_closeable')
}
action.done = true
}
}

View File

@@ -0,0 +1,62 @@
module site
import os
import incubaid.herolib.core.playbook { PlayBook }
import incubaid.herolib.core.texttools
import time
import incubaid.herolib.ui.console
// ============================================================
// FOOTER: Process footer configuration
// ============================================================
fn play_footer(mut plbook PlayBook, mut config SiteConfig) ! {
// Process footer style (optional)
mut footer_actions := plbook.find(filter: 'site.footer')!
for mut action in footer_actions {
mut p := action.params
config.footer.style = p.get_default('style', 'dark')!
action.done = true
}
// Process footer items (multiple)
mut footer_item_actions := plbook.find(filter: 'site.footer_item')!
mut links_map := map[string][]FooterItem{}
// Clear existing links to prevent duplication
config.footer.links = []FooterLink{}
for mut action in footer_item_actions {
mut p := action.params
title := p.get_default('title', 'Docs')!
label := p.get('label') or {
return error('!!site.footer_item: must specify "label"')
}
mut item := FooterItem{
label: label
href: p.get_default('href', '')!
to: p.get_default('to', '')!
}
// Validate that href or to is specified
if item.href.len == 0 && item.to.len == 0 {
return error('!!site.footer_item for "${label}": must specify either "href" or "to"')
}
if title !in links_map {
links_map[title] = []FooterItem{}
}
links_map[title] << item
action.done = true
}
// Convert map to footer links array
for title, items in links_map {
config.footer.links << FooterLink{
title: title
items: items
}
}
}

View File

@@ -0,0 +1,51 @@
module site
import os
import incubaid.herolib.core.playbook { PlayBook }
import incubaid.herolib.core.texttools
import time
import incubaid.herolib.ui.console
// ============================================================
// IMPORTS: Process content imports
// ============================================================
fn play_imports(mut plbook PlayBook, mut config SiteConfig) ! {
mut import_actions := plbook.find(filter: 'site.import')!
for mut action in import_actions {
mut p := action.params
// Parse replacement patterns (comma-separated key:value pairs)
mut replace_map := map[string]string{}
if replace_str := p.get_default('replace', '') {
parts := replace_str.split(',')
for part in parts {
kv := part.split(':')
if kv.len == 2 {
replace_map[kv[0].trim_space()] = kv[1].trim_space()
}
}
}
// Get path (can be relative to playbook path)
mut import_path := p.get_default('path', '')!
if import_path != '' {
if !import_path.starts_with('/') {
import_path = os.abs_path('${plbook.path}/${import_path}')
}
}
// Create import item
mut import_item := ImportItem{
name: p.get_default('name', '')!
url: p.get_default('url', '')!
path: import_path
dest: p.get_default('dest', '')!
replace: replace_map
visible: p.get_default_false('visible')
}
config.imports << import_item
action.done = true
}
}

View File

@@ -0,0 +1,60 @@
module site
import os
import incubaid.herolib.core.playbook { PlayBook }
import incubaid.herolib.core.texttools
import time
import incubaid.herolib.ui.console
// ============================================================
// NAVBAR: Process navigation menu
// ============================================================
fn play_navbar(mut plbook PlayBook, mut config SiteConfig) ! {
// Try 'site.navbar' first, then fallback to deprecated 'site.menu'
mut navbar_actions := plbook.find(filter: 'site.navbar')!
if navbar_actions.len == 0 {
navbar_actions = plbook.find(filter: 'site.menu')!
}
// Configure navbar metadata
if navbar_actions.len > 0 {
for mut action in navbar_actions {
mut p := action.params
config.menu.title = p.get_default('title', config.title)!
config.menu.logo_alt = p.get_default('logo_alt', '')!
config.menu.logo_src = p.get_default('logo_src', '')!
config.menu.logo_src_dark = p.get_default('logo_src_dark', '')!
action.done = true
}
}
// Process navbar items
mut navbar_item_actions := plbook.find(filter: 'site.navbar_item')!
if navbar_item_actions.len == 0 {
navbar_item_actions = plbook.find(filter: 'site.menu_item')!
}
// Clear existing items to prevent duplication
config.menu.items = []MenuItem{}
for mut action in navbar_item_actions {
mut p := action.params
label := p.get('label') or { return error('!!site.navbar_item: must specify "label"') }
mut item := MenuItem{
label: label
href: p.get_default('href', '')!
to: p.get_default('to', '')!
position: p.get_default('position', 'right')!
}
// Validate that at least href or to is specified
if item.href.len == 0 && item.to.len == 0 {
return error('!!site.navbar_item: must specify either "href" or "to" for label "${label}"')
}
config.menu.items << item
action.done = true
}
}

View File

@@ -1,135 +0,0 @@
module site
import incubaid.herolib.core.playbook { PlayBook }
import incubaid.herolib.core.texttools
// plays the sections & pages
fn play_pages(mut plbook PlayBook, mut site Site) ! {
// mut siteconfig := &site.siteconfig
// if only 1 doctree is specified, then we use that as the default doctree name
// mut doctreename := 'main' // Not used for now, keep commented for future doctree integration
// if plbook.exists(filter: 'site.doctree') {
// if plbook.exists_once(filter: 'site.doctree') {
// mut action := plbook.get(filter: 'site.doctree')!
// mut p := action.params
// doctreename = p.get('name') or { return error('need to specify name in site.doctree') }
// } else {
// return error("can't have more than one site.doctree")
// }
// }
mut section_current := Section{} // is the category
mut position_section := 1
mut position_category := 100 // Start categories at position 100
mut collection_current := '' // current collection we are working on
mut all_actions := plbook.find(filter: 'site.')!
for mut action in all_actions {
if action.done {
continue
}
mut p := action.params
if action.name == 'page_category' {
mut section := Section{}
section.name = p.get('name') or {
return error('need to specify name in site.page_category. Action: ${action}')
}
position_section = 1 // go back to default position for pages in the category
section.position = p.get_int_default('position', position_category)!
if section.position == position_category {
position_category += 100 // Increment for next category
}
section.label = p.get_default('label', texttools.name_fix_snake_to_pascal(section.name))!
section.path = p.get_default('path', texttools.name_fix(section.label))!
section.description = p.get_default('description', '')!
site.sections << section
action.done = true // Mark the action as done
section_current = section
continue // next action
}
if action.name == 'page' {
mut pagesrc := p.get_default('src', '')!
mut pagename := p.get_default('name', '')!
mut pagecollection := ''
if pagesrc.contains(':') {
pagecollection = pagesrc.split(':')[0]
pagename = pagesrc.split(':')[1]
} else {
if collection_current.len > 0 {
pagecollection = collection_current
pagename = pagesrc // ADD THIS LINE - use pagesrc as the page name
} else {
return error('need to specify collection in page.src path as collection:page_name or make sure someone before you did. Got src="${pagesrc}" with no collection set. Action: ${action}')
}
}
pagecollection = texttools.name_fix(pagecollection)
collection_current = pagecollection
pagename = texttools.name_fix_keepext(pagename)
if pagename.ends_with('.md') {
pagename = pagename.replace('.md', '')
}
if pagename == '' {
return error('need to specify name in page.src or specify in path as collection:page_name. Action: ${action}')
}
if pagecollection == '' {
return error('need to specify collection in page.src or specify in path as collection:page_name. Action: ${action}')
}
// recreate the pagepath
pagesrc = '${pagecollection}:${pagename}'
// get sectionname from category, page_category or section, if not specified use current section
section_name := p.get_default('category', p.get_default('page_category', p.get_default('section',
section_current.name)!)!)!
mut pagepath := p.get_default('path', section_current.path)!
pagepath = pagepath.trim_space().trim('/')
// Only apply name_fix if it's a simple name (no path separators)
// For paths like 'appendix/internet_today', preserve the structure
if !pagepath.contains('/') {
pagepath = texttools.name_fix(pagepath)
}
// Ensure pagepath ends with / to indicate it's a directory path
if pagepath.len > 0 && !pagepath.ends_with('/') {
pagepath += '/'
}
mut mypage := Page{
section_name: section_name
name: pagename
path: pagepath
src: pagesrc
}
mypage.position = p.get_int_default('position', 0)!
if mypage.position == 0 {
mypage.position = section_current.position + position_section
position_section += 1
}
mypage.title = p.get_default('title', '')!
mypage.description = p.get_default('description', '')!
mypage.slug = p.get_default('slug', '')!
mypage.draft = p.get_default_false('draft')
mypage.hide_title = p.get_default_false('hide_title')
mypage.title_nr = p.get_int_default('title_nr', 0)!
site.pages << mypage
action.done = true // Mark the action as done
}
// println(action)
// println(section_current)
// println(site.pages.last())
// $dbg;
}
}

203
lib/web/site/play_pages.v Normal file
View File

@@ -0,0 +1,203 @@
module site
import os
import incubaid.herolib.core.playbook { PlayBook }
import incubaid.herolib.core.texttools
import time
import incubaid.herolib.ui.console
// ============================================================
// Helper function: normalize name while preserving .md extension handling
// ============================================================
fn normalize_page_name(name string) string {
mut result := name
// Remove .md extension if present for processing
if result.ends_with('.md') {
result = result[0..result.len - 3]
}
// Apply name fixing
return texttools.name_fix(result)
}
// ============================================================
// Internal structure for tracking category information
// ============================================================
struct CategoryInfo {
pub mut:
name string
label string
position int
nav_items []NavItem
}
// ============================================================
// PAGES: Process pages and build navigation structure
// ============================================================
fn play_pages(mut plbook PlayBook, mut website Site) ! {
mut collection_current := '' // Track current collection for reuse
mut categories := map[string]CategoryInfo{} // Map of category name -> info
mut category_current := '' // Track current active category
mut root_nav_items := []NavItem{} // Root-level items (pages without category)
mut next_category_position := 100 // Auto-increment position for categories
// ============================================================
// PASS 1: Process all page and category actions
// ============================================================
mut all_actions := plbook.find(filter: 'site.')!
for mut action in all_actions {
if action.done {
continue
}
// ========== PAGE CATEGORY ==========
if action.name == 'page_category' {
mut p := action.params
category_name := p.get('name') or {
return error('!!site.page_category: must specify "name"')
}
category_name_fixed := texttools.name_fix(category_name)
// Get label (derive from name if not specified)
mut label := p.get_default('label', texttools.name_fix_snake_to_pascal(category_name_fixed))!
mut position := p.get_int_default('position', next_category_position)!
// Auto-increment position if using default
if position == next_category_position {
next_category_position += 100
}
// Create and store category info
categories[category_name_fixed] = CategoryInfo{
name: category_name_fixed
label: label
position: position
nav_items: []NavItem{}
}
category_current = category_name_fixed
console.print_item('Created page category: "${label}" (${category_name_fixed})')
action.done = true
continue
}
// ========== PAGE ==========
if action.name == 'page' {
mut p := action.params
mut page_src := p.get_default('src', '')!
mut page_collection := ''
mut page_name := ''
// Parse collection:page format from src
if page_src.contains(':') {
parts := page_src.split(':')
page_collection = texttools.name_fix(parts[0])
page_name = normalize_page_name(parts[1])
} else {
// Use previously specified collection if available
if collection_current.len > 0 {
page_collection = collection_current
page_name = normalize_page_name(page_src)
} else {
return error('!!site.page: must specify source as "collection:page_name" in "src".\nGot src="${page_src}" with no collection previously set.\nEither specify "collection:page_name" or define a collection first.')
}
}
// Validation
if page_name.len == 0 {
return error('!!site.page: could not extract valid page name from src="${page_src}"')
}
if page_collection.len == 0 {
return error('!!site.page: could not determine collection')
}
// Store collection for subsequent pages
collection_current = page_collection
// Build page ID
page_id := '${page_collection}:${page_name}'
// Get optional page metadata
page_title := p.get_default('title', '')!
page_description := p.get_default('description', '')!
page_draft := p.get_default_false('draft')
page_hide_title := p.get_default_false('hide_title')
// Create page
mut page := Page{
id: page_id
title: page_title
description: page_description
draft: page_draft
hide_title: page_hide_title
src: page_id
}
website.pages[page_id] = page
// Create navigation item
nav_doc := NavDoc{
id: page_id
label: if page_title.len > 0 { page_title } else { page_name }
}
// Add to appropriate category or root
if category_current.len > 0 {
if category_current in categories {
mut cat_info := categories[category_current]
cat_info.nav_items << nav_doc
categories[category_current] = cat_info
console.print_debug('Added page "${page_id}" to category "${category_current}"')
}
} else {
root_nav_items << nav_doc
console.print_debug('Added root page "${page_id}"')
}
action.done = true
continue
}
}
// ============================================================
// PASS 2: Build final navigation structure from categories
// ============================================================
console.print_item('Building navigation structure...')
mut final_nav_items := []NavItem{}
// Add root items first
for item in root_nav_items {
final_nav_items << item
}
// Sort categories by position and add them
mut sorted_categories := []CategoryInfo{}
for _, cat_info in categories {
sorted_categories << cat_info
}
// Sort by position
sorted_categories.sort(a.position < b.position)
// Convert categories to NavCat items and add to navigation
for cat_info in sorted_categories {
// Unwrap NavDoc items from cat_info.nav_items (they're already NavItem)
nav_cat := NavCat{
label: cat_info.label
collapsible: true
collapsed: false
items: cat_info.nav_items
}
final_nav_items << nav_cat
console.print_debug('Added category to nav: "${cat_info.label}" with ${cat_info.nav_items.len} items')
}
// Update website navigation
website.nav.my_sidebar = final_nav_items
console.print_green('Navigation structure built with ${website.pages.len} pages in ${categories.len} categories')
}

View File

@@ -0,0 +1,46 @@
module site
import os
import incubaid.herolib.core.playbook { PlayBook }
import incubaid.herolib.core.texttools
import time
import incubaid.herolib.ui.console
// ============================================================
// PUBLISHING: Configure build and publish destinations
// ============================================================
fn play_publishing(mut plbook PlayBook, mut config SiteConfig) ! {
// Production publish destinations
mut build_dest_actions := plbook.find(filter: 'site.publish')!
for mut action in build_dest_actions {
mut p := action.params
path := p.get('path') or {
return error('!!site.publish: must specify "path"')
}
mut dest := BuildDest{
path: path
ssh_name: p.get_default('ssh_name', '')!
}
config.build_dest << dest
action.done = true
}
// Development publish destinations
mut build_dest_dev_actions := plbook.find(filter: 'site.publish_dev')!
for mut action in build_dest_dev_actions {
mut p := action.params
path := p.get('path') or {
return error('!!site.publish_dev: must specify "path"')
}
mut dest := BuildDest{
path: path
ssh_name: p.get_default('ssh_name', '')!
}
config.build_dest_dev << dest
action.done = true
}
}

View File

@@ -2,43 +2,83 @@
The Site module provides a structured way to define website configurations, navigation menus, pages, and sections using HeroScript. It's designed to work with static site generators like Docusaurus.
## Purpose
The Site module allows you to:
- Define website structure and configuration in a declarative way using HeroScript
- Organize pages into sections/categories
- Configure navigation menus and footers
- Manage page metadata (title, description, slug, etc.)
- Support multiple content collections
- Define build and publish destinations
## Quick Start
### Minimal HeroScript Example
```heroscript
!!site.config
name: "my_docs"
title: "My Documentation"
!!site.page src: "docs:introduction"
title: "Getting Started"
!!site.page src: "setup"
title: "Installation"
```
### Processing with V Code
```v
#!/usr/bin/env -S v -n -w -gc none -cg -cc tcc -d use_openssl -enable-globals run
import incubaid.herolib.develop.gittools
import incubaid.herolib.core.playbook
import incubaid.herolib.web.site
import incubaid.herolib.core.playcmds
import incubaid.herolib.ui.console
// Clone or use existing repository with HeroScript files
mysitepath := gittools.path(
git_url: 'https://git.ourworld.tf/tfgrid/docs_tfgrid4/src/branch/main/ebooks/tech'
git_pull: true
)!
// Process HeroScript file
mut plbook := playbook.new(path: './site_config.heroscript')!
// Process all HeroScript files in the path
playcmds.run(heroscript_path: mysitepath.path)!
// Execute site configuration
site.play(mut plbook)!
// Get the configured site
mut mysite := site.get(name: 'tfgrid_tech')!
println(mysite)
// Access the configured site
mut mysite := site.get(name: 'my_docs')!
// Print available pages
pages_map := mysite.list_pages()
for page_id, _ in pages_map {
console.print_item('Page: ${page_id}')
}
println('Site has ${mysite.pages.len} pages')
```
---
## Core Concepts
### Site
A website configuration that contains pages, navigation structure, and metadata.
### Page
A single page with:
- **ID**: `collection:page_name` format
- **Title**: Display name (optional - extracted from markdown if not provided)
- **Description**: SEO metadata
- **Draft**: Hidden from navigation if true
### Category (Section)
Groups related pages together in the navigation sidebar. Automatically collapsed/expandable.
### Collection
A logical group of pages. Pages reuse the collection once specified.
```heroscript
!!site.page src: "tech:intro" # Specifies collection "tech"
!!site.page src: "benefits" # Reuses collection "tech"
!!site.page src: "components" # Still uses collection "tech"
!!site.page src: "api:reference" # Switches to collection "api"
!!site.page src: "endpoints" # Uses collection "api"
```
---
## HeroScript Syntax
### Basic Configuration
### 1. Site Configuration (Required)
```heroscript
!!site.config
@@ -51,20 +91,49 @@ println(mysite)
copyright: "© 2024 My Organization"
url: "https://docs.example.com"
base_url: "/"
url_home: "/docs"
```
### Navigation Menu
**Parameters:**
- `name` - Internal site identifier (default: 'default')
- `title` - Main site title (shown in browser tab)
- `description` - Site description for SEO
- `tagline` - Short tagline/subtitle
- `favicon` - Path to favicon image
- `image` - Default OG image for social sharing
- `copyright` - Copyright notice
- `url` - Full site URL for Docusaurus
- `base_url` - Base URL path (e.g., "/" or "/docs/")
- `url_home` - Home page path
### 2. Metadata Overrides (Optional)
```heroscript
!!site.config_meta
title: "My Docs - Technical Reference"
image: "img/tech-og.png"
description: "Technical documentation and API reference"
```
Overrides specific metadata for SEO without changing core config.
### 3. Navigation Bar
```heroscript
!!site.navbar
title: "My Site"
title: "My Documentation"
logo_alt: "Site Logo"
logo_src: "img/logo.svg"
logo_src_dark: "img/logo-dark.svg"
!!site.navbar_item
label: "Documentation"
to: "docs/intro"
to: "intro"
position: "left"
!!site.navbar_item
label: "API Reference"
to: "docs/api"
position: "left"
!!site.navbar_item
@@ -73,7 +142,13 @@ println(mysite)
position: "right"
```
### Footer Configuration
**Parameters:**
- `label` - Display text (required)
- `to` - Internal link
- `href` - External URL
- `position` - "left" or "right" in navbar
### 4. Footer Configuration
```heroscript
!!site.footer
@@ -87,242 +162,234 @@ println(mysite)
!!site.footer_item
title: "Docs"
label: "Getting Started"
href: "https://docs.example.com/getting-started"
to: "getting-started"
!!site.footer_item
title: "Community"
label: "Discord"
href: "https://discord.gg/example"
!!site.footer_item
title: "Legal"
label: "Privacy"
href: "https://example.com/privacy"
```
## Page Organization
### Example 1: Simple Pages Without Categories
When you don't need categories, pages are added sequentially. The collection only needs to be specified once, then it's reused for subsequent pages.
### 5. Announcement Bar (Optional)
```heroscript
!!site.page src: "mycelium_tech:introduction"
description: "Introduction to ThreeFold Technology"
slug: "/"
!!site.page src: "vision"
description: "Our Vision for the Future Internet"
!!site.page src: "what"
description: "What ThreeFold is Building"
!!site.page src: "presentation"
description: "ThreeFold Technology Presentation"
!!site.page src: "status"
description: "Current Development Status"
!!site.announcement
id: "new-release"
content: "🎉 Version 2.0 is now available!"
background_color: "#20232a"
text_color: "#fff"
is_closeable: true
```
**Key Points:**
### 6. Pages and Categories
- First page specifies collection as `tech:introduction` (collection:page_name format)
- Subsequent pages only need the page name (e.g., `vision`) - the `tech` collection is reused
- If `title` is not specified, it will be extracted from the markdown file itself
- Pages are ordered by their appearance in the HeroScript file
- `slug` can be used to customize the URL path (e.g., `"/"` for homepage)
#### Simple: Pages Without Categories
### Example 2: Pages with Categories
```heroscript
!!site.page src: "guides:introduction"
title: "Getting Started"
description: "Introduction to the platform"
Categories (sections) help organize pages into logical groups with their own navigation structure.
!!site.page src: "installation"
title: "Installation"
!!site.page src: "configuration"
title: "Configuration"
```
#### Advanced: Pages With Categories
```heroscript
!!site.page_category
name: "first_principle_thinking"
label: "First Principle Thinking"
name: "basics"
label: "Getting Started"
!!site.page src: "first_principle_thinking:hardware_badly_used"
description: "Hardware is not used properly, why it is important to understand hardware"
!!site.page src: "guides:introduction"
title: "Introduction"
description: "Learn the basics"
!!site.page src: "internet_risk"
description: "Internet risk, how to mitigate it, and why it is important"
!!site.page src: "installation"
title: "Installation"
!!site.page src: "onion_analogy"
description: "Compare onion with a computer, layers of abstraction"
```
!!site.page src: "configuration"
title: "Configuration"
**Key Points:**
- `!!site.page_category` creates a new section/category
- `name` is the internal identifier (snake_case)
- `label` is the display name (automatically derived from `name` if not specified)
- Category name is converted to title case: `first_principle_thinking` → "First Principle Thinking"
- Once a category is defined, all subsequent pages belong to it until a new category is declared
- Collection persistence works the same: specify once (e.g., `first_principle_thinking:hardware_badly_used`), then reuse
### Example 3: Advanced Page Configuration
```heroscript
!!site.page_category
name: "components"
label: "System Components"
position: 100
name: "advanced"
label: "Advanced Topics"
!!site.page src: "mycelium_tech:mycelium"
title: "Mycelium Network"
description: "Peer-to-peer overlay network"
slug: "mycelium-network"
position: 1
draft: false
hide_title: false
!!site.page src: "advanced:performance"
title: "Performance Tuning"
!!site.page src: "fungistor"
title: "Fungistor Storage"
description: "Distributed storage system"
position: 2
!!site.page src: "scaling"
title: "Scaling Guide"
```
**Available Page Parameters:**
**Page Parameters:**
- `src` - Source as `collection:page` (first page) or just `page_name` (reuse collection)
- `title` - Page title (optional, extracted from markdown if not provided)
- `description` - Page description
- `draft` - Hide from navigation (default: false)
- `hide_title` - Don't show title in page (default: false)
- `src`: Source reference as `collection:page_name` (required for first page in collection)
- `title`: Page title (optional, extracted from markdown if not provided)
- `description`: Page description for metadata
- `slug`: Custom URL slug
- `position`: Manual ordering (auto-incremented if not specified)
- `draft`: Mark page as draft (default: false)
- `hide_title`: Hide the page title in rendering (default: false)
- `path`: Custom path for the page (defaults to category name)
- `category`: Override the current category for this page
## File Organization
HeroScript files should be organized with numeric prefixes to control execution order:
```
docs/
├── 0_config.heroscript # Site configuration
├── 1_menu.heroscript # Navigation and footer
├── 2_intro_pages.heroscript # Introduction pages
├── 3_tech_pages.heroscript # Technical documentation
└── 4_api_pages.heroscript # API reference
```
**Important:** Files are processed in alphabetical order, so use numeric prefixes (0_, 1_, 2_, etc.) to ensure correct execution sequence.
## Import External Content
### 7. Content Imports
```heroscript
!!site.import
url: "https://github.com/example/external-docs"
path: "/local/path/to/repo"
dest: "external"
replace: "PROJECT_NAME:My Project,VERSION:1.0.0"
visible: true
```
## Publish Destinations
### 8. Publishing Destinations
```heroscript
!!site.publish
path: "/var/www/html/docs"
ssh_name: "production_server"
ssh_name: "production"
!!site.publish_dev
path: "/tmp/docs-preview"
```
## Factory Methods
---
### Create or Get a Site
## Common Patterns
```v
import incubaid.herolib.web.site
### Pattern 1: Multi-Section Technical Documentation
// Create a new site
mut mysite := site.new(name: 'my_docs')!
```heroscript
!!site.config
name: "tech_docs"
title: "Technical Documentation"
// Get an existing site
mut mysite := site.get(name: 'my_docs')!
!!site.page_category
name: "getting_started"
label: "Getting Started"
// Get default site
mut mysite := site.default()!
!!site.page src: "docs:intro"
title: "Introduction"
// Check if site exists
if site.exists(name: 'my_docs') {
println('Site exists')
}
!!site.page src: "installation"
title: "Installation"
// List all sites
sites := site.list()
println(sites)
!!site.page_category
name: "concepts"
label: "Core Concepts"
!!site.page src: "concepts:architecture"
title: "Architecture"
!!site.page src: "components"
title: "Components"
!!site.page_category
name: "api"
label: "API Reference"
!!site.page src: "api:rest"
title: "REST API"
!!site.page src: "graphql"
title: "GraphQL"
```
### Using with PlayBook
### Pattern 2: Simple Blog/Knowledge Base
```v
import incubaid.herolib.core.playbook
import incubaid.herolib.web.site
```heroscript
!!site.config
name: "blog"
title: "Knowledge Base"
// Create playbook from path
mut plbook := playbook.new(path: '/path/to/heroscripts')!
!!site.page src: "articles:first_post"
title: "Welcome to Our Blog"
// Process site configuration
site.play(mut plbook)!
!!site.page src: "second_post"
title: "Understanding the Basics"
// Access the configured site
mut mysite := site.get(name: 'my_site')!
!!site.page src: "third_post"
title: "Advanced Techniques"
```
## Data Structures
### Pattern 3: Project with External Imports
### Site
```heroscript
!!site.config
name: "project_docs"
title: "Project Documentation"
```v
pub struct Site {
pub mut:
pages []Page
sections []Section
siteconfig SiteConfig
}
!!site.import
url: "https://github.com/org/shared-docs"
dest: "shared"
visible: true
!!site.page_category
name: "product"
label: "Product Guide"
!!site.page src: "docs:overview"
title: "Overview"
!!site.page src: "features"
title: "Features"
!!site.page_category
name: "resources"
label: "Shared Resources"
!!site.page src: "shared:common"
title: "Common Patterns"
```
### Page
---
```v
pub struct Page {
pub mut:
name string // Page identifier
title string // Display title
description string // Page description
draft bool // Draft status
position int // Sort order
hide_title bool // Hide title in rendering
src string // Source as collection:page_name
path string // URL path (without page name)
section_name string // Category/section name
title_nr int // Title numbering level
slug string // Custom URL slug
}
## File Organization
Organize HeroScript files with numeric prefixes to control execution order:
```
docs/
├── 0_config.heroscript
│ └── !!site.config and !!site.config_meta
├── 1_menu.heroscript
│ └── !!site.navbar and !!site.footer
├── 2_pages.heroscript
│ └── !!site.page_category and !!site.page actions
└── 3_publish.heroscript
└── !!site.publish destinations
```
### Section
**Why numeric prefixes?**
```v
pub struct Section {
pub mut:
name string // Internal identifier
position int // Sort order
path string // URL path
label string // Display name
}
```
Files are processed in alphabetical order. Numeric prefixes ensure:
- Site config runs first
- Navigation menu configures before pages
- Pages build the final structure
- Publishing configured last
## Best Practices
---
1. **File Naming**: Use numeric prefixes (0_, 1_, 2_) to control execution order
2. **Collection Reuse**: Specify collection once, then reuse for subsequent pages
3. **Category Organization**: Group related pages under categories for better navigation
4. **Title Extraction**: Let titles be extracted from markdown files when possible
5. **Position Management**: Use automatic positioning unless you need specific ordering
6. **Description**: Always provide descriptions for better SEO and navigation
7. **Draft Status**: Use `draft: true` for work-in-progress pages
## Processing Order
## Complete Example
The Site module processes HeroScript in this strict order:
See `examples/web/site/site_example.vsh` for a complete working example.
1. Site Configuration
2. Metadata Overrides
3. Imports
4. Navigation
5. Footer
6. Announcement
7. Publishing
8. Pages & Categories
For a real-world example, check: <https://git.ourworld.tf/tfgrid/docs_tfgrid4/src/branch/main/ebooks/tech>
Each stage depends on previous stages completing successfully.

View File

@@ -0,0 +1,445 @@
module site
import incubaid.herolib.core.playbook
import incubaid.herolib.web.site
import incubaid.herolib.ui.console
import os
// Big comprehensive HeroScript for testing
const test_heroscript = '
!!site.config
name: "test_docs"
title: "Test Documentation Site"
description: "A comprehensive test documentation site"
tagline: "Testing everything"
favicon: "img/favicon.png"
image: "img/test-og.png"
copyright: "© 2024 Test Organization"
url: "https://test.example.com"
base_url: "/"
url_home: "/docs"
!!site.config_meta
title: "Test Docs - Advanced"
image: "img/test-og-alternative.png"
description: "Advanced test documentation"
!!site.navbar
title: "Test Documentation"
logo_alt: "Test Logo"
logo_src: "img/logo.svg"
logo_src_dark: "img/logo-dark.svg"
!!site.navbar_item
label: "Getting Started"
to: "intro"
position: "left"
!!site.navbar_item
label: "API Reference"
to: "api"
position: "left"
!!site.navbar_item
label: "GitHub"
href: "https://github.com/example/test"
position: "right"
!!site.navbar_item
label: "Blog"
href: "https://blog.example.com"
position: "right"
!!site.footer
style: "dark"
!!site.footer_item
title: "Documentation"
label: "Introduction"
to: "intro"
!!site.footer_item
title: "Documentation"
label: "Getting Started"
to: "getting-started"
!!site.footer_item
title: "Documentation"
label: "Advanced Topics"
to: "advanced"
!!site.footer_item
title: "Community"
label: "Discord"
href: "https://discord.gg/example"
!!site.footer_item
title: "Community"
label: "Twitter"
href: "https://twitter.com/example"
!!site.footer_item
title: "Legal"
label: "Privacy Policy"
href: "https://example.com/privacy"
!!site.footer_item
title: "Legal"
label: "Terms of Service"
href: "https://example.com/terms"
!!site.announcement
id: "v2-release"
content: "🎉 Version 2.0 is now available! Check out the new features."
background_color: "#1a472a"
text_color: "#fff"
is_closeable: true
!!site.page_category
name: "getting_started"
label: "Getting Started"
position: 10
!!site.page src: "guides:introduction"
title: "Introduction to Test Docs"
description: "Learn what this project is about"
!!site.page src: "installation"
title: "Installation Guide"
description: "How to install and setup"
!!site.page src: "quick_start"
title: "Quick Start"
description: "5 minute quick start guide"
!!site.page_category
name: "concepts"
label: "Core Concepts"
position: 20
!!site.page src: "concepts:architecture"
title: "Architecture Overview"
description: "Understanding the system architecture"
!!site.page src: "components"
title: "Key Components"
description: "Learn about the main components"
!!site.page src: "workflow"
title: "Typical Workflow"
description: "How to use the system"
!!site.page_category
name: "api"
label: "API Reference"
position: 30
!!site.page src: "api:rest"
title: "REST API"
description: "Complete REST API reference"
!!site.page src: "graphql"
title: "GraphQL API"
description: "GraphQL API documentation"
!!site.page src: "webhooks"
title: "Webhooks"
description: "Webhook configuration and examples"
!!site.page_category
name: "advanced"
label: "Advanced Topics"
position: 40
!!site.page src: "advanced:performance"
title: "Performance Optimization"
description: "Tips for optimal performance"
!!site.page src: "scaling"
title: "Scaling Guide"
description: "How to scale the system"
!!site.page src: "security"
title: "Security Best Practices"
description: "Security considerations and best practices"
!!site.page src: "troubleshooting"
title: "Troubleshooting"
description: "Common issues and solutions"
draft: false
!!site.publish
path: "/var/www/html/docs"
ssh_name: "production-server"
!!site.publish_dev
path: "/tmp/docs-dev"
'
fn test_site1() ! {
console.print_header('Site Module Comprehensive Test')
console.lf()
// ========================================================
// TEST 1: Create playbook from heroscript
// ========================================================
console.print_item('TEST 1: Creating playbook from HeroScript')
mut plbook := playbook.new(text: test_heroscript)!
console.print_green(' Playbook created successfully')
console.lf()
// ========================================================
// TEST 2: Process site configuration
// ========================================================
console.print_item('TEST 2: Processing site.play()')
site.play(mut plbook)!
console.print_green(' Site configuration processed successfully')
console.lf()
// ========================================================
// TEST 3: Retrieve site and validate
// ========================================================
console.print_item('TEST 3: Retrieving configured site')
mut test_site := site.get(name: 'test_docs')!
console.print_green(' Site retrieved successfully')
console.lf()
// ========================================================
// TEST 4: Validate SiteConfig
// ========================================================
console.print_header('Validating SiteConfig')
mut config := &test_site.siteconfig
help_test_string('Site Name', config.name, 'test_docs')
help_test_string('Site Title', config.title, 'Test Documentation Site')
help_test_string('Site Description', config.description, 'A comprehensive test documentation site')
help_test_string('Site Tagline', config.tagline, 'Testing everything')
help_test_string('Copyright', config.copyright, '© 2024 Test Organization')
help_test_string('Base URL', config.base_url, '/')
help_test_string('URL Home', config.url_home, '/docs')
help_test_string('Meta Title', config.meta_title, 'Test Docs - Advanced')
help_test_string('Meta Image', config.meta_image, 'img/test-og-alternative.png')
assert config.build_dest.len == 1, 'Should have 1 production build destination'
console.print_green(' Production build dest: ${config.build_dest[0].path}')
assert config.build_dest_dev.len == 1, 'Should have 1 dev build destination'
console.print_green(' Dev build dest: ${config.build_dest_dev[0].path}')
console.lf()
// ========================================================
// TEST 5: Validate Menu Configuration
// ========================================================
console.print_header('Validating Menu Configuration')
mut menu := config.menu
help_test_string('Menu Title', menu.title, 'Test Documentation')
help_test_string('Menu Logo Alt', menu.logo_alt, 'Test Logo')
help_test_string('Menu Logo Src', menu.logo_src, 'img/logo.svg')
help_test_string('Menu Logo Src Dark', menu.logo_src_dark, 'img/logo-dark.svg')
assert menu.items.len == 4, 'Should have 4 navbar items, got ${menu.items.len}'
console.print_green(' Menu has 4 navbar items')
// Validate navbar items
help_test_navbar_item(menu.items[0], 'Getting Started', 'intro', '', 'left')
help_test_navbar_item(menu.items[1], 'API Reference', 'api', '', 'left')
help_test_navbar_item(menu.items[2], 'GitHub', '', 'https://github.com/example/test',
'right')
help_test_navbar_item(menu.items[3], 'Blog', '', 'https://blog.example.com', 'right')
console.lf()
// ========================================================
// TEST 6: Validate Footer Configuration
// ========================================================
console.print_header('Validating Footer Configuration')
mut footer := config.footer
help_test_string('Footer Style', footer.style, 'dark')
assert footer.links.len == 3, 'Should have 3 footer link groups, got ${footer.links.len}'
console.print_green(' Footer has 3 link groups')
// Validate footer structure
for link_group in footer.links {
console.print_item('Footer group: "${link_group.title}" has ${link_group.items.len} items')
}
// Detailed footer validation
mut doc_links := footer.links.filter(it.title == 'Documentation')
assert doc_links.len == 1, 'Should have 1 Documentation link group'
assert doc_links[0].items.len == 3, 'Documentation should have 3 items'
console.print_green(' Documentation footer: 3 items')
mut community_links := footer.links.filter(it.title == 'Community')
assert community_links.len == 1, 'Should have 1 Community link group'
assert community_links[0].items.len == 2, 'Community should have 2 items'
console.print_green(' Community footer: 2 items')
mut legal_links := footer.links.filter(it.title == 'Legal')
assert legal_links.len == 1, 'Should have 1 Legal link group'
assert legal_links[0].items.len == 2, 'Legal should have 2 items'
console.print_green(' Legal footer: 2 items')
console.lf()
// ========================================================
// TEST 7: Validate Announcement Bar
// ========================================================
console.print_header('Validating Announcement Bar')
mut announcement := config.announcement
help_test_string('Announcement ID', announcement.id, 'v2-release')
help_test_string('Announcement Content', announcement.content, '🎉 Version 2.0 is now available! Check out the new features.')
help_test_string('Announcement BG Color', announcement.background_color, '#1a472a')
help_test_string('Announcement Text Color', announcement.text_color, '#fff')
assert announcement.is_closeable == true, 'Announcement should be closeable'
console.print_green(' Announcement bar configured correctly')
console.lf()
// ========================================================
// TEST 8: Validate Pages
// ========================================================
console.print_header('Validating Pages')
mut pages := test_site.pages.clone()
assert pages.len == 13, 'Should have 13 pages, got ${pages.len}'
console.print_green(' Total pages: ${pages.len}')
// List and validate pages
mut page_ids := pages.keys()
page_ids.sort()
for page_id in page_ids {
mut page := pages[page_id]
console.print_debug(' Page: ${page_id} - "${page.title}"')
}
// Validate specific pages
assert 'guides:introduction' in pages, 'guides:introduction page not found'
console.print_green(' Found guides:introduction')
assert 'concepts:architecture' in pages, 'concepts:architecture page not found'
console.print_green(' Found concepts:architecture')
assert 'api:rest' in pages, 'api:rest page not found'
console.print_green(' Found api:rest')
console.lf()
// ========================================================
// TEST 9: Validate Navigation Structure
// ========================================================
console.print_header('Validating Navigation Structure')
mut sidebar := unsafe { test_site.nav.my_sidebar.clone() }
console.print_item('Navigation sidebar has ${sidebar.len} items')
// Count categories
mut category_count := 0
mut doc_count := 0
for item in sidebar {
match item {
site.NavCat {
category_count++
console.print_debug(' Category: "${item.label}" with ${item.items.len} sub-items')
}
site.NavDoc {
doc_count++
console.print_debug(' Doc: "${item.label}" (${item.id})')
}
site.NavLink {
console.print_debug(' Link: "${item.label}" -> ${item.href}')
}
}
}
assert category_count == 4, 'Should have 4 categories, got ${category_count}'
console.print_green(' Navigation has 4 categories')
// Validate category structure
for item in sidebar {
match item {
site.NavCat {
console.print_item('Category: "${item.label}"')
println(' Collapsible: ${item.collapsible}, Collapsed: ${item.collapsed}')
println(' Items: ${item.items.len}')
// Validate sub-items
for sub_item in item.items {
match sub_item {
site.NavDoc {
println(' - ${sub_item.label} (${sub_item.id})')
}
else {
println(' - Unexpected item type')
}
}
}
}
else {}
}
}
console.lf()
// ========================================================
// TEST 10: Validate Site Factory
// ========================================================
console.print_header('Validating Site Factory')
mut all_sites := site.list()
console.print_item('Total sites registered: ${all_sites.len}')
for site_name in all_sites {
console.print_debug(' - ${site_name}')
}
assert all_sites.contains('test_docs'), 'test_docs should be in sites list'
console.print_green(' test_docs found in factory')
assert site.exists(name: 'test_docs'), 'test_docs should exist'
console.print_green(' test_docs verified to exist')
console.lf()
// ========================================================
// FINAL SUMMARY
// ========================================================
console.print_header('Test Summary')
console.print_green(' All tests passed successfully!')
console.print_item('Site Name: ${config.name}')
console.print_item('Pages: ${pages.len}')
console.print_item('Navigation Categories: ${category_count}')
console.print_item('Navbar Items: ${menu.items.len}')
console.print_item('Footer Groups: ${footer.links.len}')
console.print_item('Announcement: Active')
console.print_item('Build Destinations: ${config.build_dest.len} prod, ${config.build_dest_dev.len} dev')
console.lf()
console.print_green('All validations completed successfully!')
}
// ============================================================
// Helper Functions for Testing
// ============================================================
fn help_test_string(label string, actual string, expected string) {
if actual == expected {
console.print_green(' ${label}: "${actual}"')
} else {
console.print_stderr(' ${label}: expected "${expected}", got "${actual}"')
panic('Test failed: ${label}')
}
}
fn help_test_navbar_item(item MenuItem, label string, to string, href string, position string) {
assert item.label == label, 'Expected label "${label}", got "${item.label}"'
assert item.to == to, 'Expected to "${to}", got "${item.to}"'
assert item.href == href, 'Expected href "${href}", got "${item.href}"'
assert item.position == position, 'Expected position "${position}", got "${item.position}"'
console.print_green(' Navbar item: "${label}"')
}