Compare commits

...

3 Commits

Author SHA1 Message Date
4e053f8aaf ... 2025-12-02 09:55:15 +01:00
fe12d1cc18 Merge branch 'development' into development_doctree111
* development:
  atlas back
2025-12-02 09:54:30 +01:00
ecb9fbfa67 ... 2025-12-02 09:48:50 +01:00
83 changed files with 5175 additions and 3146 deletions

View File

@@ -1,4 +1,4 @@
module builder
pub fn (mut node Node) ubuntu_sources_fix() {
}
// pub fn (mut node Node) ubuntu_sources_fix() {
// }

View File

@@ -1,207 +0,0 @@
module atlas
import incubaid.herolib.core.pathlib
import os
const test_dir = '/tmp/atlas_save_test'
fn testsuite_begin() {
os.rmdir_all(test_dir) or {}
os.mkdir_all(test_dir)!
}
fn testsuite_end() {
os.rmdir_all(test_dir) or {}
}
fn test_save_and_load_basic() {
// Create a collection with some content
col_path := '${test_dir}/docs'
os.mkdir_all(col_path)!
mut cfile := pathlib.get_file(path: '${col_path}/.collection', create: true)!
cfile.write('name:docs')!
mut page1 := pathlib.get_file(path: '${col_path}/intro.md', create: true)!
page1.write('# Introduction\n\nWelcome to the docs!')!
mut page2 := pathlib.get_file(path: '${col_path}/guide.md', create: true)!
page2.write('# Guide\n\nMore content here.')!
// Create and scan atlas
mut a := new(name: 'my_docs')!
a.scan(path: test_dir)!
assert a.collections.len == 1
// Save all collections
// a.save(destination_meta: '/tmp/atlas_meta')!
// assert os.exists('${col_path}/.collection.json')
// // Load in a new atlas
// mut a2 := new(name: 'loaded_docs')!
// a2.load_from_directory(test_dir)!
// assert a2.collections.len == 1
// // Access loaded data
// loaded_col := a2.get_collection('docs')!
// assert loaded_col.name == 'docs'
// assert loaded_col.pages.len == 2
// // Verify pages exist
// assert loaded_col.page_exists('intro')
// assert loaded_col.page_exists('guide')
// // Read page content
// mut intro_page := loaded_col.page_get('intro')!
// content := intro_page.read_content()!
// assert content.contains('# Introduction')
// assert content.contains('Welcome to the docs!')
}
fn test_save_and_load_with_includes() {
col_path := '${test_dir}/docs_include'
os.mkdir_all(col_path)!
mut cfile := pathlib.get_file(path: '${col_path}/.collection', create: true)!
cfile.write('name:docs')!
mut page1 := pathlib.get_file(path: '${col_path}/intro.md', create: true)!
page1.write('# Introduction\n\nWelcome to the docs!')!
mut page2 := pathlib.get_file(path: '${col_path}/guide.md', create: true)!
page2.write('# Guide\n\n!!include docs:intro\n\nMore content here.')!
// Create and scan atlas
mut a := new(name: 'my_docs')!
a.scan(path: '${test_dir}/docs_include')!
// Validate links (should find the include)
a.validate_links()!
col := a.get_collection('docs')!
assert !col.has_errors()
// // Save
// a.save(destination_meta: '/tmp/atlas_meta')!
// // Load
// mut a2 := new(name: 'loaded')!
// a2.load_from_directory('${test_dir}/docs_include')!
// loaded_col := a2.get_collection('docs')!
// assert loaded_col.pages.len == 2
// assert !loaded_col.has_errors()
}
fn test_save_and_load_with_errors() {
col_path := '${test_dir}/docs_errors'
os.mkdir_all(col_path)!
mut cfile := pathlib.get_file(path: '${col_path}/.collection', create: true)!
cfile.write('name:docs')!
// Create page with broken link
mut page1 := pathlib.get_file(path: '${col_path}/broken.md', create: true)!
page1.write('[Broken link](nonexistent)')!
// Create and scan atlas
mut a := new(name: 'my_docs')!
a.scan(path: '${test_dir}/docs_errors')!
// Validate - will generate errors
a.validate_links()!
col := a.get_collection('docs')!
assert col.has_errors()
initial_error_count := col.errors.len
// // Save with errors
// a.save(destination_meta: '/tmp/atlas_meta')!
// // Load
// mut a2 := new(name: 'loaded')!
// a2.load_from_directory('${test_dir}/docs_errors')!
// loaded_col := a2.get_collection('docs')!
// assert loaded_col.has_errors()
// assert loaded_col.errors.len == initial_error_count
// assert loaded_col.error_cache.len == initial_error_count
}
fn test_save_and_load_multiple_collections() {
// Create multiple collections
col1_path := '${test_dir}/multi/col1'
col2_path := '${test_dir}/multi/col2'
os.mkdir_all(col1_path)!
os.mkdir_all(col2_path)!
mut cfile1 := pathlib.get_file(path: '${col1_path}/.collection', create: true)!
cfile1.write('name:col1')!
mut cfile2 := pathlib.get_file(path: '${col2_path}/.collection', create: true)!
cfile2.write('name:col2')!
mut page1 := pathlib.get_file(path: '${col1_path}/page1.md', create: true)!
page1.write('# Page 1')!
mut page2 := pathlib.get_file(path: '${col2_path}/page2.md', create: true)!
page2.write('# Page 2')!
// Create and save
mut a := new(name: 'multi')!
a.scan(path: '${test_dir}/multi')!
assert a.collections.len == 2
// a.save(destination_meta: '/tmp/atlas_meta')!
// // Load from directory
// mut a2 := new(name: 'loaded')!
// a2.load_from_directory('${test_dir}/multi')!
// assert a2.collections.len == 2
// assert a2.get_collection('col1')!.page_exists('page1')
// assert a2.get_collection('col2')!.page_exists('page2')
}
fn test_save_and_load_with_images() {
col_path := '${test_dir}/docs_images'
os.mkdir_all(col_path)!
os.mkdir_all('${col_path}/img')!
mut cfile := pathlib.get_file(path: '${col_path}/.collection', create: true)!
cfile.write('name:docs')!
mut page := pathlib.get_file(path: '${col_path}/page.md', create: true)!
page.write('# Page with image')!
// Create a dummy image file
mut img := pathlib.get_file(path: '${col_path}/img/test.png', create: true)!
img.write('fake png data')!
// Create and scan
mut a := new(name: 'my_docs')!
a.scan(path: '${test_dir}/docs_images')!
col := a.get_collection('docs')!
// assert col.images.len == 1
assert col.image_exists('test.png')!
// // Save
// a.save(destination_meta: '/tmp/atlas_meta')!
// // Load
// mut a2 := new(name: 'loaded')!
// a2.load_from_directory('${test_dir}/docs_images')!
// loaded_col := a2.get_collection('docs')!
// assert loaded_col.images.len == 1
// assert loaded_col.image_exists('test.png')!
img_file := col.image_get('test.png')!
assert img_file.name == 'test.png'
assert img_file.is_image()
}

View File

@@ -1,61 +0,0 @@
module atlas
import incubaid.herolib.core.texttools
import incubaid.herolib.core.pathlib
import incubaid.herolib.ui.console
import incubaid.herolib.data.paramsparser
__global (
atlases shared map[string]&Atlas
)
@[params]
pub struct AtlasNewArgs {
pub mut:
name string = 'default'
}
// Create a new Atlas
pub fn new(args AtlasNewArgs) !&Atlas {
mut name := texttools.name_fix(args.name)
mut a := &Atlas{
name: name
}
set(a)
return a
}
// Get Atlas from global map
pub fn get(name string) !&Atlas {
mut fixed_name := texttools.name_fix(name)
rlock atlases {
if fixed_name in atlases {
return atlases[fixed_name] or { return error('Atlas ${name} not found') }
}
}
return error("Atlas '${name}' not found")
}
// Check if Atlas exists
pub fn exists(name string) bool {
mut fixed_name := texttools.name_fix(name)
rlock atlases {
return fixed_name in atlases
}
}
// List all Atlas names
pub fn list() []string {
rlock atlases {
return atlases.keys()
}
}
// Store Atlas in global map
fn set(atlas &Atlas) {
lock atlases {
atlases[atlas.name] = atlas
}
}

View File

@@ -1,102 +0,0 @@
module atlas
// Get a page from any collection using format "collection:page"
pub fn (a Atlas) page_get(key string) !&Page {
parts := key.split(':')
if parts.len != 2 {
return error('Invalid page key format. Use "collection:page" in page_get')
}
col := a.get_collection(parts[0])!
return col.page_get(parts[1])!
}
// Get an image from any collection using format "collection:image"
pub fn (a Atlas) image_get(key string) !&File {
parts := key.split(':')
if parts.len != 2 {
return error('Invalid image key format. Use "collection:image" in image_get')
}
col := a.get_collection(parts[0])!
return col.image_get(parts[1])!
}
// Get a file from any collection using format "collection:file"
pub fn (a Atlas) file_get(key string) !&File {
parts := key.split(':')
if parts.len != 2 {
return error('Invalid file key format. Use "collection:file" in file_get')
}
col := a.get_collection(parts[0])!
return col.file_get(parts[1])!
}
// Get a file (can be image) from any collection using format "collection:file"
pub fn (a Atlas) file_or_image_get(key string) !&File {
parts := key.split(':')
if parts.len != 2 {
return error('Invalid file key format. Use "collection:file"')
}
col := a.get_collection(parts[0])!
return col.file_or_image_get(parts[1])!
}
// Check if page exists
pub fn (a Atlas) page_exists(key string) !bool {
parts := key.split(':')
if parts.len != 2 {
return error("Invalid file key format. Use 'collection:file' in page_exists")
}
col := a.get_collection(parts[0]) or { return false }
return col.page_exists(parts[1])
}
// Check if image exists
pub fn (a Atlas) image_exists(key string) !bool {
parts := key.split(':')
if parts.len != 2 {
return error("Invalid file key format. Use 'collection:file' in image_exists")
}
col := a.get_collection(parts[0]) or { return false }
return col.image_exists(parts[1])
}
// Check if file exists
pub fn (a Atlas) file_exists(key string) !bool {
parts := key.split(':')
if parts.len != 2 {
return error("Invalid file key format. Use 'collection:file' in file_exists")
}
col := a.get_collection(parts[0]) or { return false }
return col.file_exists(parts[1])
}
pub fn (a Atlas) file_or_image_exists(key string) !bool {
parts := key.split(':')
if parts.len != 2 {
return error("Invalid file key format. Use 'collection:file' in file_or_image_exists")
}
col := a.get_collection(parts[0]) or { return false }
return col.file_or_image_exists(parts[1])
}
// List all pages in Atlas
pub fn (a Atlas) list_pages() map[string][]string {
mut result := map[string][]string{}
for col_name, col in a.collections {
mut page_names := []string{}
for page_name, _ in col.pages {
page_names << page_name
}
page_names.sort()
result[col_name] = page_names
}
return result
}

View File

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

View File

@@ -1,10 +1,10 @@
# AtlasClient
# DocTreeClient
A simple API for accessing document collections exported by the `atlas` module.
A simple API for accessing document collections exported by the `doctree` module.
## What It Does
AtlasClient provides methods to:
DocTreeClient provides methods to:
- List collections, pages, files, and images
- Check if resources exist
@@ -15,10 +15,10 @@ AtlasClient provides methods to:
## Quick Start
```v
import incubaid.herolib.web.atlas_client
import incubaid.herolib.web.doctree_client
// Create client
mut client := atlas_client.new(export_dir: '${os.home_dir()}/hero/var/atlas_export')!
// Create client, exports will be in $/hero/var/doctree_export by default
mut client := doctree_client.new()!
// List collections
collections := client.list_collections()!
@@ -34,7 +34,7 @@ if client.has_errors('my_collection')! {
## Export Structure
Atlas exports to this structure:
DocTree exports to this structure:
```txt
export_dir/
@@ -87,9 +87,9 @@ Names are normalized using `name_fix()`:
## Example
See `examples/data/atlas_client/basic_usage.vsh` for a complete working example.
See `examples/data/doctree_client/basic_usage.vsh` for a complete working example.
## See Also
- `lib/data/atlas/` - Atlas module for exporting collections
- `lib/data/doctree/` - DocTree module for exporting collections
- `lib/web/doctreeclient/` - Alternative client for doctree collections

View File

@@ -7,17 +7,17 @@ import os
import json
import incubaid.herolib.core.redisclient
// AtlasClient provides access to Atlas-exported documentation collections
// DocTreeClient provides access to exported documentation collections
// It reads from both the exported directory structure and Redis metadata
pub struct AtlasClient {
pub struct DocTreeClient {
pub mut:
redis &redisclient.Redis
export_dir string // Path to the atlas export directory (contains content/ and meta/)
export_dir string // Path to the doctree export directory (contains content/ and meta/)
}
// get_page_path returns the path for a page in a collection
// Pages are stored in {export_dir}/content/{collection}/{page}.md
pub fn (mut c AtlasClient) get_page_path(collection_name string, page_name string) !string {
pub fn (mut c DocTreeClient) get_page_path(collection_name string, page_name string) !string {
// Apply name normalization
fixed_collection_name := texttools.name_fix(collection_name)
fixed_page_name := texttools.name_fix(page_name)
@@ -40,9 +40,9 @@ pub fn (mut c AtlasClient) get_page_path(collection_name string, page_name strin
// get_file_path returns the path for a file in a collection
// Files are stored in {export_dir}/content/{collection}/{filename}
pub fn (mut c AtlasClient) get_file_path(collection_name_ string, file_name_ string) !string {
collection_name := texttools.name_fix_no_ext(collection_name_)
file_name := texttools.name_fix_keepext(file_name_)
pub fn (mut c DocTreeClient) get_file_path(collection_name_ string, file_name_ string) !string {
collection_name := texttools.name_fix(collection_name_)
file_name := texttools.name_fix(file_name_)
// Check if export directory exists
if !os.exists(c.export_dir) {
@@ -62,11 +62,11 @@ pub fn (mut c AtlasClient) get_file_path(collection_name_ string, file_name_ str
// get_image_path returns the path for an image in a collection
// Images are stored in {export_dir}/content/{collection}/{imagename}
pub fn (mut c AtlasClient) get_image_path(collection_name_ string, image_name_ string) !string {
pub fn (mut c DocTreeClient) get_image_path(collection_name_ string, image_name_ string) !string {
// Apply name normalization
collection_name := texttools.name_fix_no_ext(collection_name_)
collection_name := texttools.name_fix(collection_name_)
// Images keep their original names with extensions
image_name := texttools.name_fix_keepext(image_name_)
image_name := texttools.name_fix(image_name_)
// Check if export directory exists
if !os.exists(c.export_dir) {
@@ -85,28 +85,28 @@ pub fn (mut c AtlasClient) get_image_path(collection_name_ string, image_name_ s
}
// page_exists checks if a page exists in a collection
pub fn (mut c AtlasClient) page_exists(collection_name string, page_name string) bool {
pub fn (mut c DocTreeClient) page_exists(collection_name string, page_name string) bool {
// Try to get the page path - if it succeeds, the page exists
_ := c.get_page_path(collection_name, page_name) or { return false }
return true
}
// file_exists checks if a file exists in a collection
pub fn (mut c AtlasClient) file_exists(collection_name string, file_name string) bool {
pub fn (mut c DocTreeClient) file_exists(collection_name string, file_name string) bool {
// Try to get the file path - if it succeeds, the file exists
_ := c.get_file_path(collection_name, file_name) or { return false }
return true
}
// image_exists checks if an image exists in a collection
pub fn (mut c AtlasClient) image_exists(collection_name string, image_name string) bool {
pub fn (mut c DocTreeClient) image_exists(collection_name string, image_name string) bool {
// Try to get the image path - if it succeeds, the image exists
_ := c.get_image_path(collection_name, image_name) or { return false }
return true
}
// get_page_content returns the content of a page in a collection
pub fn (mut c AtlasClient) get_page_content(collection_name string, page_name string) !string {
pub fn (mut c DocTreeClient) get_page_content(collection_name string, page_name string) !string {
// Get the path for the page
page_path := c.get_page_path(collection_name, page_name)!
@@ -124,7 +124,7 @@ pub fn (mut c AtlasClient) get_page_content(collection_name string, page_name st
// list_collections returns a list of all collection names
// Collections are directories in {export_dir}/content/
pub fn (mut c AtlasClient) list_collections() ![]string {
pub fn (mut c DocTreeClient) list_collections() ![]string {
content_dir := os.join_path(c.export_dir, 'content')
// Check if content directory exists
@@ -148,7 +148,7 @@ pub fn (mut c AtlasClient) list_collections() ![]string {
// list_pages returns a list of all page names in a collection
// Uses metadata to get the authoritative list of pages that belong to this collection
pub fn (mut c AtlasClient) list_pages(collection_name string) ![]string {
pub fn (mut c DocTreeClient) list_pages(collection_name string) ![]string {
// Get metadata which contains the authoritative list of pages
metadata := c.get_collection_metadata(collection_name)!
@@ -162,7 +162,7 @@ pub fn (mut c AtlasClient) list_pages(collection_name string) ![]string {
}
// list_files returns a list of all file names in a collection (excluding pages and images)
pub fn (mut c AtlasClient) list_files(collection_name string) ![]string {
pub fn (mut c DocTreeClient) list_files(collection_name string) ![]string {
metadata := c.get_collection_metadata(collection_name)!
mut file_names := []string{}
for file_name, file_meta in metadata.files {
@@ -174,7 +174,7 @@ pub fn (mut c AtlasClient) list_files(collection_name string) ![]string {
}
// list_images returns a list of all image names in a collection
pub fn (mut c AtlasClient) list_images(collection_name string) ![]string {
pub fn (mut c DocTreeClient) list_images(collection_name string) ![]string {
metadata := c.get_collection_metadata(collection_name)!
mut images := []string{}
for file_name, file_meta in metadata.files {
@@ -187,7 +187,7 @@ pub fn (mut c AtlasClient) list_images(collection_name string) ![]string {
// list_pages_map returns a map of collection names to a list of page names within that collection.
// The structure is map[collectionname][]pagename.
pub fn (mut c AtlasClient) list_pages_map() !map[string][]string {
pub fn (mut c DocTreeClient) list_pages_map() !map[string][]string {
mut result := map[string][]string{}
collections := c.list_collections()!
@@ -199,38 +199,11 @@ pub fn (mut c AtlasClient) list_pages_map() !map[string][]string {
return result
}
// list_markdown returns the collections and their pages in markdown format.
pub fn (mut c AtlasClient) list_markdown() !string {
mut markdown_output := ''
pages_map := c.list_pages_map()!
if pages_map.len == 0 {
return 'No collections or pages found in this atlas export.'
}
mut sorted_collections := pages_map.keys()
sorted_collections.sort()
for col_name in sorted_collections {
page_names := pages_map[col_name]
markdown_output += '## ${col_name}\n'
if page_names.len == 0 {
markdown_output += ' * No pages in this collection.\n'
} else {
for page_name in page_names {
markdown_output += ' * ${page_name}\n'
}
}
markdown_output += '\n' // Add a newline for spacing between collections
}
return markdown_output
}
// get_collection_metadata reads and parses the metadata JSON file for a collection
// Metadata is stored in {export_dir}/meta/{collection}.json
pub fn (mut c AtlasClient) get_collection_metadata(collection_name string) !CollectionMetadata {
pub fn (mut c DocTreeClient) get_collection_metadata(collection_name string) !CollectionMetadata {
// Apply name normalization
fixed_collection_name := texttools.name_fix_no_ext(collection_name)
fixed_collection_name := texttools.name_fix(collection_name)
meta_path := os.join_path(c.export_dir, 'meta', '${fixed_collection_name}.json')
@@ -247,78 +220,95 @@ 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 {
pub fn (mut c DocTreeClient) get_collection_errors(collection_name string) ![]ErrorMetadata {
metadata := c.get_collection_metadata(collection_name)!
return metadata.errors
}
// has_errors checks if a collection has any errors
pub fn (mut c AtlasClient) has_errors(collection_name string) bool {
pub fn (mut c DocTreeClient) has_errors(collection_name string) bool {
errors := c.get_collection_errors(collection_name) or { return false }
return errors.len > 0
}
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)!
pub fn (mut c DocTreeClient) copy_collection(collection_name string, destination_path string) ! {
// TODO: list over all pages, links & files and copy them to destination
// Create img subdirectory
mut img_dest := pathlib.get_dir(path: '${destination_path}/img', create: true)!
// Copy only image links
for link in links {
if link.file_type != .image {
continue
}
if link.status == .external {
continue
}
// Get image path and copy
img_path := c.get_image_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 image: ${src.path} to ${img_dest.path}/${src.name_fix_keepext()}')
}
}
// copy_files copies all non-image files from a page to a destination directory
// Files are placed in {destination}/files/ subdirectory
// Only copies files referenced in the page (via links)
pub fn (mut c AtlasClient) copy_files(collection_name string, page_name string, destination_path string) ! {
// Get page links from metadata
links := c.get_page_links(collection_name, page_name)!
// // will copy all pages linked from a page to a destination directory as well as the page itself
// pub fn (mut c DocTreeClient) copy_pages(collection_name string, page_name string, destination_path string) ! {
// // TODO: copy page itself
// Create files subdirectory
mut files_dest := pathlib.get_dir(path: '${destination_path}/files', create: true)!
// // Get page links from metadata
// links := c.get_page_links(collection_name, page_name)!
// Copy only file links (non-image files)
for link in links {
if link.file_type != .file {
continue
}
if link.status == .external {
continue
}
// println(link)
// Get file path and copy
file_path := c.get_file_path(link.target_collection_name, link.target_item_name)!
mut src := pathlib.get_file(path: file_path)!
// src.copy(dest: '${files_dest.path}/${src.name_fix_keepext()}')!
console.print_debug('Copied file: ${src.path} to ${files_dest.path}/${src.name_fix_keepext()}')
}
}
// // 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_no_ext()}')!
// console.print_debug(' ********. Copied page: ${src.path} to ${img_dest.path}/${src.name_fix_no_ext()}')
// }
// }
// pub fn (mut c DocTreeClient) 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)!
// // Create img subdirectory
// mut img_dest := pathlib.get_dir(path: '${destination_path}/img', create: true)!
// // Copy only image links
// for link in links {
// if link.file_type != .image {
// continue
// }
// if link.status == .external {
// continue
// }
// // Get image path and copy
// img_path := c.get_image_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_no_ext()}')!
// // console.print_debug('Copied image: ${src.path} to ${img_dest.path}/${src.name_fix()}')
// }
// }
// // copy_files copies all non-image files from a page to a destination directory
// // Files are placed in {destination}/files/ subdirectory
// // Only copies files referenced in the page (via links)
// pub fn (mut c DocTreeClient) copy_files(collection_name string, page_name string, destination_path string) ! {
// // Get page links from metadata
// links := c.get_page_links(collection_name, page_name)!
// // Create files subdirectory
// mut files_dest := pathlib.get_dir(path: '${destination_path}/files', create: true)!
// // Copy only file links (non-image files)
// for link in links {
// if link.file_type != .file {
// continue
// }
// if link.status == .external {
// continue
// }
// // println(link)
// // Get file path and copy
// file_path := c.get_file_path(link.target_collection_name, link.target_item_name)!
// mut src := pathlib.get_file(path: file_path)!
// // src.copy(dest: '${files_dest.path}/${src.name_fix_no_ext()}')!
// console.print_debug('Copied file: ${src.path} to ${files_dest.path}/${src.name_fix_no_ext()}')
// }
// }

View File

@@ -5,7 +5,7 @@ import incubaid.herolib.core.texttools
// Helper function to create a test export directory structure
fn setup_test_export() string {
test_dir := os.join_path(os.temp_dir(), 'atlas_client_test_${os.getpid()}')
test_dir := os.join_path(os.temp_dir(), 'doctree_client_test_${os.getpid()}')
// Clean up if exists
if os.exists(test_dir) {
@@ -54,28 +54,7 @@ fn setup_test_export() string {
"name": "page2",
"path": "",
"collection_name": "testcollection",
"links": [
{
"src": "logo.png",
"text": "logo",
"target": "logo.png",
"line": 3,
"target_collection_name": "testcollection",
"target_item_name": "logo.png",
"status": "ok",
"file_type": "image"
},
{
"src": "data.csv",
"text": "data",
"target": "data.csv",
"line": 4,
"target_collection_name": "testcollection",
"target_item_name": "data.csv",
"status": "ok",
"file_type": "file"
}
]
"links": []
}
},
"files": {
@@ -110,14 +89,7 @@ fn setup_test_export() string {
}
},
"files": {},
"errors": [
{
"category": "test",
"page_key": "intro",
"message": "Test error",
"line": 10
}
]
"errors": []
}'
os.write_file(os.join_path(test_dir, 'meta', 'anothercollection.json'), metadata2) or {
panic(err)
@@ -455,23 +427,6 @@ fn test_list_pages_map() {
assert pages_map['anothercollection'].len == 1
}
// Test list_markdown
fn test_list_markdown() {
test_dir := setup_test_export()
defer { cleanup_test_export(test_dir) }
mut client := new(export_dir: test_dir) or { panic(err) }
markdown := client.list_markdown() or { panic(err) }
assert markdown.contains('testcollection')
assert markdown.contains('anothercollection')
assert markdown.contains('page1')
assert markdown.contains('page2')
assert markdown.contains('intro')
assert markdown.contains('##')
assert markdown.contains('*')
}
// Test get_collection_metadata - success
fn test_get_collection_metadata_success() {
test_dir := setup_test_export()
@@ -485,21 +440,6 @@ fn test_get_collection_metadata_success() {
assert metadata.errors.len == 0
}
// Test get_collection_metadata - with errors
fn test_get_collection_metadata_with_errors() {
test_dir := setup_test_export()
defer { cleanup_test_export(test_dir) }
mut client := new(export_dir: test_dir) or { panic(err) }
metadata := client.get_collection_metadata('anothercollection') or { panic(err) }
assert metadata.name == 'anothercollection'
assert metadata.pages.len == 1
assert metadata.errors.len == 1
assert metadata.errors[0].message == 'Test error'
assert metadata.errors[0].line == 10
}
// Test get_collection_metadata - not found
fn test_get_collection_metadata_not_found() {
test_dir := setup_test_export()
@@ -513,78 +453,17 @@ fn test_get_collection_metadata_not_found() {
assert false, 'Should have returned an error'
}
// Test get_page_links - success
fn test_get_page_links_success() {
test_dir := setup_test_export()
defer { cleanup_test_export(test_dir) }
mut client := new(export_dir: test_dir) or { panic(err) }
links := client.get_page_links('testcollection', 'page2') or { panic(err) }
assert links.len == 2
assert links[0].target_item_name == 'logo.png'
assert links[0].target_collection_name == 'testcollection'
assert links[0].file_type == .image
}
// Test get_page_links - no links
fn test_get_page_links_empty() {
test_dir := setup_test_export()
defer { cleanup_test_export(test_dir) }
mut client := new(export_dir: test_dir) or { panic(err) }
links := client.get_page_links('testcollection', 'page1') or { panic(err) }
assert links.len == 0
}
// Test get_page_links - page not found
fn test_get_page_links_page_not_found() {
test_dir := setup_test_export()
defer { cleanup_test_export(test_dir) }
mut client := new(export_dir: test_dir) or { panic(err) }
client.get_page_links('testcollection', 'nonexistent') or {
assert err.msg().contains('page_not_found')
return
}
assert false, 'Should have returned an error'
}
// Test get_collection_errors - success
fn test_get_collection_errors_success() {
test_dir := setup_test_export()
defer { cleanup_test_export(test_dir) }
mut client := new(export_dir: test_dir) or { panic(err) }
errors := client.get_collection_errors('anothercollection') or { panic(err) }
assert errors.len == 1
assert errors[0].message == 'Test error'
}
// Test get_collection_errors - no errors
fn test_get_collection_errors_empty() {
test_dir := setup_test_export()
defer { cleanup_test_export(test_dir) }
mut client := new(export_dir: test_dir) or { panic(err) }
errors := client.get_collection_errors('testcollection') or { panic(err) }
assert errors.len == 0
}
// Test has_errors - true
fn test_has_errors_true() {
test_dir := setup_test_export()
defer { cleanup_test_export(test_dir) }
mut client := new(export_dir: test_dir) or { panic(err) }
has_errors := client.has_errors('anothercollection')
assert has_errors == true
}
// Test has_errors - false
fn test_has_errors_false() {
test_dir := setup_test_export()
@@ -596,7 +475,7 @@ fn test_has_errors_false() {
assert has_errors == false
}
// Test has_errors - collection not found
// Test has_errors - collection not found returns false
fn test_has_errors_collection_not_found() {
test_dir := setup_test_export()
defer { cleanup_test_export(test_dir) }
@@ -613,64 +492,16 @@ fn test_copy_images_success() {
defer { cleanup_test_export(test_dir) }
dest_dir := os.join_path(os.temp_dir(), 'copy_dest_${os.getpid()}')
defer { os.rmdir_all(dest_dir) or {} }
os.mkdir_all(dest_dir) or { panic(err) }
defer { cleanup_test_export(dest_dir) }
mut client := new(export_dir: test_dir) or { panic(err) }
client.copy_images('testcollection', 'page2', dest_dir) or { panic(err) }
// Check that logo.png was copied to img subdirectory
assert os.exists(os.join_path(dest_dir, 'img', 'logo.png'))
}
// Test copy_images - no images
fn test_copy_images_no_images() {
test_dir := setup_test_export()
defer { cleanup_test_export(test_dir) }
dest_dir := os.join_path(os.temp_dir(), 'copy_dest_empty_${os.getpid()}')
os.mkdir_all(dest_dir) or { panic(err) }
defer { cleanup_test_export(dest_dir) }
mut client := new(export_dir: test_dir) or { panic(err) }
client.copy_images('testcollection', 'page1', dest_dir) or { panic(err) }
// Should succeed even with no images
assert true
}
// Test copy_files - success
fn test_copy_files_success() {
test_dir := setup_test_export()
defer { cleanup_test_export(test_dir) }
dest_dir := os.join_path(os.temp_dir(), 'copy_files_dest_${os.getpid()}')
os.mkdir_all(dest_dir) or { panic(err) }
defer { cleanup_test_export(dest_dir) }
mut client := new(export_dir: test_dir) or { panic(err) }
// Note: test data would need to be updated to have file links in page2
// For now, this test demonstrates the pattern
client.copy_files('testcollection', 'page2', dest_dir) or { panic(err) }
// Check that files were copied to files subdirectory
// assert os.exists(os.join_path(dest_dir, 'files', 'somefile.csv'))
}
// Test copy_files - no files
fn test_copy_files_no_files() {
test_dir := setup_test_export()
defer { cleanup_test_export(test_dir) }
dest_dir := os.join_path(os.temp_dir(), 'copy_files_empty_${os.getpid()}')
os.mkdir_all(dest_dir) or { panic(err) }
defer { cleanup_test_export(dest_dir) }
mut client := new(export_dir: test_dir) or { panic(err) }
client.copy_files('testcollection', 'page1', dest_dir) or { panic(err) }
// Should succeed even with no file links
assert true
// Check that images were copied to img subdirectory
assert os.exists(os.join_path(dest_dir, 'img', 'logo.png'))
assert os.exists(os.join_path(dest_dir, 'img', 'banner.jpg'))
}
// Test naming normalization edge cases

View File

@@ -3,18 +3,18 @@ module client
import incubaid.herolib.core.base
@[params]
pub struct AtlasClientArgs {
pub struct DocTreeClientArgs {
pub:
export_dir string @[required] // Path to atlas export directory
export_dir string @[required] // Path to doctree export directory
}
// Create a new AtlasClient instance
// Create a new DocTreeClient instance
// The export_dir should point to the directory containing content/ and meta/ subdirectories
pub fn new(args AtlasClientArgs) !&AtlasClient {
pub fn new(args DocTreeClientArgs) !&DocTreeClient {
mut context := base.context()!
mut redis := context.redis()!
return &AtlasClient{
return &DocTreeClient{
redis: redis
export_dir: args.export_dir
}

View File

@@ -0,0 +1,28 @@
module client
// list_markdown returns the collections and their pages in markdown format.
pub fn (mut c DocTreeClient) list_markdown() !string {
mut markdown_output := ''
pages_map := c.list_pages_map()!
if pages_map.len == 0 {
return 'No collections or pages found in this doctree export.'
}
mut sorted_collections := pages_map.keys()
sorted_collections.sort()
for col_name in sorted_collections {
page_names := pages_map[col_name]
markdown_output += '## ${col_name}\n'
if page_names.len == 0 {
markdown_output += ' * No pages in this collection.\n'
} else {
for page_name in page_names {
markdown_output += ' * ${page_name}\n'
}
}
markdown_output += '\n' // Add a newline for spacing between collections
}
return markdown_output
}

View File

@@ -1,6 +1,6 @@
module client
// AtlasClient provides access to Atlas-exported documentation collections
// DocTreeClient provides access to DocTree-exported documentation collections
// It reads from both the exported directory structure and Redis metadata
// List of recognized image file extensions
@@ -22,6 +22,16 @@ pub mut:
path string
collection_name string
links []LinkMetadata
title string
description string
questions []Question
}
pub struct Question {
pub mut:
question string
answer string
}
pub struct FileMetadata {

View File

@@ -1,11 +1,11 @@
module atlas
module core
import incubaid.herolib.core.pathlib
import incubaid.herolib.core.texttools
import incubaid.herolib.develop.gittools
import incubaid.herolib.web.doctree as doctreetools
import incubaid.herolib.data.paramsparser { Params }
import incubaid.herolib.ui.console
import os
pub struct Session {
pub mut:
@@ -21,7 +21,7 @@ pub mut:
path string // absolute path
pages map[string]&Page
files map[string]&File
atlas &Atlas @[skip; str: skip]
doctree &DocTree @[skip; str: skip]
errors []CollectionError
error_cache map[string]bool
git_url string
@@ -41,7 +41,7 @@ fn (mut c Collection) init_pre() ! {
}
fn (mut c Collection) init_post() ! {
c.validate_links()!
c.find_links()!
c.init_git_info()!
}
@@ -95,7 +95,7 @@ fn (mut c Collection) add_file(mut p pathlib.Path) ! {
// Get a page by name
pub fn (c Collection) page_get(name_ string) !&Page {
name := texttools.name_fix_no_ext(name_)
name := doctreetools.name_fix(name_)
return c.pages[name] or { return PageNotFound{
collection: c.name
page: name
@@ -104,7 +104,7 @@ pub fn (c Collection) page_get(name_ string) !&Page {
// Get an image by name
pub fn (c Collection) image_get(name_ string) !&File {
name := texttools.name_fix_keepext(name_)
name := doctreetools.name_fix(name_)
mut img := c.files[name] or { return FileNotFound{
collection: c.name
file: name
@@ -117,7 +117,7 @@ pub fn (c Collection) image_get(name_ string) !&File {
// Get a file by name
pub fn (c Collection) file_get(name_ string) !&File {
name := texttools.name_fix_keepext(name_)
name := doctreetools.name_fix(name_)
mut f := c.files[name] or { return FileNotFound{
collection: c.name
file: name
@@ -129,7 +129,7 @@ pub fn (c Collection) file_get(name_ string) !&File {
}
pub fn (c Collection) file_or_image_get(name_ string) !&File {
name := texttools.name_fix_keepext(name_)
name := doctreetools.name_fix(name_)
mut f := c.files[name] or { return FileNotFound{
collection: c.name
file: name
@@ -139,26 +139,26 @@ pub fn (c Collection) file_or_image_get(name_ string) !&File {
// Check if page exists
pub fn (c Collection) page_exists(name_ string) !bool {
name := texttools.name_fix_no_ext(name_)
name := doctreetools.name_fix(name_)
return name in c.pages
}
// Check if image exists
pub fn (c Collection) image_exists(name_ string) !bool {
name := texttools.name_fix_keepext(name_)
name := doctreetools.name_fix(name_)
f := c.files[name] or { return false }
return f.ftype == .image
}
// Check if file exists
pub fn (c Collection) file_exists(name_ string) !bool {
name := texttools.name_fix_keepext(name_)
name := doctreetools.name_fix(name_)
f := c.files[name] or { return false }
return f.ftype == .file
}
pub fn (c Collection) file_or_image_exists(name_ string) !bool {
name := texttools.name_fix_keepext(name_)
name := doctreetools.name_fix(name_)
_ := c.files[name] or { return false }
return true
}
@@ -247,31 +247,6 @@ pub fn (c Collection) print_errors() {
}
}
// Validate all links in collection
pub fn (mut c Collection) validate_links() ! {
for _, mut page in c.pages {
content := page.content(include: true)!
page.links = page.find_links(content)! // will walk over links see if errors and add errors
}
}
// Fix all links in collection (rewrite files)
pub fn (mut c Collection) fix_links() ! {
for _, mut page in c.pages {
// Read original content
content := page.content()!
// Fix links
fixed_content := page.content_with_fixed_links()!
// Write back if changed
if fixed_content != content {
mut p := page.path()!
p.write(fixed_content)!
}
}
}
// Check if session can read this collection
pub fn (c Collection) can_read(session Session) bool {
// If no ACL set, everyone can read
@@ -280,8 +255,8 @@ pub fn (c Collection) can_read(session Session) bool {
}
// Get user's groups
mut atlas := c.atlas
groups := atlas.groups_get(session)
mut doctree := c.doctree
groups := doctree.groups_get(session)
group_names := groups.map(it.name)
// Check if any of user's groups are in read ACL
@@ -302,8 +277,8 @@ pub fn (c Collection) can_write(session Session) bool {
}
// Get user's groups
mut atlas := c.atlas
groups := atlas.groups_get(session)
mut doctree := c.doctree
groups := doctree.groups_get(session)
group_names := groups.map(it.name)
// Check if any of user's groups are in write ACL
@@ -315,104 +290,3 @@ pub fn (c Collection) can_write(session Session) bool {
return false
}
// Detect git repository URL for a collection
fn (mut c Collection) init_git_info() ! {
mut current_path := c.path()!
// Walk up directory tree to find .git
mut git_repo := current_path.parent_find('.git') or {
// No git repo found
return
}
if git_repo.path == '' {
panic('Unexpected empty git repo path')
}
mut gs := gittools.new()!
mut p := c.path()!
mut location := gs.gitlocation_from_path(p.path)!
r := os.execute_opt('cd ${p.path} && git branch --show-current')!
location.branch_or_tag = r.output.trim_space()
c.git_url = location.web_url()!
}
////////////SCANNING FUNCTIONS ?//////////////////////////////////////////////////////
fn (mut c Collection) scan(mut dir pathlib.Path) ! {
mut entries := dir.list(recursive: false)!
for mut entry in entries.paths {
// Skip hidden files/dirs
if entry.name().starts_with('.') || entry.name().starts_with('_') {
continue
}
if entry.is_dir() {
// Recursively scan subdirectories
mut mutable_entry := entry
c.scan(mut mutable_entry)!
continue
}
// Process files based on extension
match entry.extension_lower() {
'md' {
mut mutable_entry := entry
c.add_page(mut mutable_entry)!
}
else {
mut mutable_entry := entry
c.add_file(mut mutable_entry)!
}
}
}
}
// Scan for ACL files
fn (mut c Collection) scan_acl() ! {
// Look for read.acl in collection directory
read_acl_path := '${c.path()!.path}/read.acl'
if os.exists(read_acl_path) {
content := os.read_file(read_acl_path)!
// Split by newlines and normalize
c.acl_read = content.split('\n')
.map(it.trim_space())
.filter(it.len > 0)
.map(it.to_lower())
}
// Look for write.acl in collection directory
write_acl_path := '${c.path()!.path}/write.acl'
if os.exists(write_acl_path) {
content := os.read_file(write_acl_path)!
// Split by newlines and normalize
c.acl_write = content.split('\n')
.map(it.trim_space())
.filter(it.len > 0)
.map(it.to_lower())
}
}
// scan_groups scans the collection's directory for .group files and loads them into memory.
pub fn (mut c Collection) scan_groups() ! {
if c.name != 'groups' {
return error('scan_groups only works on "groups" collection')
}
mut p := c.path()!
mut entries := p.list(recursive: false)!
for mut entry in entries.paths {
if entry.extension_lower() == 'group' {
filename := entry.name_fix_no_ext()
mut visited := map[string]bool{}
mut group := parse_group_file(filename, c.path()!.path, mut visited)!
c.atlas.group_add(mut group)!
}
}
}

View File

@@ -1,7 +1,7 @@
module atlas
module core
import crypto.md5
import incubaid.herolib.ui.console
pub enum CollectionErrorCategory {
circular_include

View File

@@ -0,0 +1,69 @@
module core
import incubaid.herolib.develop.gittools
import os
import incubaid.herolib.data.markdown.tools as markdowntools
// Validate all links in collection
fn (mut c Collection) find_links() ! {
for _, mut page in c.pages {
content := page.content(include: true)!
page.links = page.find_links(content)! // will walk over links see if errors and add errors
}
}
// Fix all links in collection (rewrite files)
fn (mut c Collection) fix_links() ! {
for _, mut page in c.pages {
// Read original content
content := page.content()!
// Fix links
fixed_content := page.content_with_fixed_links()!
// Write back if changed
if fixed_content != content {
mut p := page.path()!
p.write(fixed_content)!
}
}
}
pub fn (mut c Collection) title_descriptions() ! {
for _, mut p in c.pages {
if p.title == '' {
p.title = markdowntools.extract_title(p.content(include: true)!)
}
// TODO in future should do AI
if p.description == '' {
p.description = p.title
}
}
}
// Detect git repository URL for a collection
fn (mut c Collection) init_git_info() ! {
mut current_path := c.path()!
// Walk up directory tree to find .git
mut git_repo := current_path.parent_find('.git') or {
// No git repo found
return
}
if git_repo.path == '' {
panic('Unexpected empty git repo path')
}
mut gs := gittools.new()!
mut p := c.path()!
mut location := gs.gitlocation_from_path(p.path)!
r := os.execute_opt('cd ${p.path} && git branch --show-current')!
location.branch_or_tag = r.output.trim_space()
c.git_url = location.web_url()!
}

View File

@@ -0,0 +1,84 @@
module core
import incubaid.herolib.core.pathlib
import os
////////////SCANNING FUNCTIONS ?//////////////////////////////////////////////////////
fn (mut c Collection) scan(mut dir pathlib.Path) ! {
mut entries := dir.list(recursive: false)!
for mut entry in entries.paths {
// Skip hidden files/dirs
if entry.name().starts_with('.') || entry.name().starts_with('_') {
continue
}
if entry.is_dir() {
// Recursively scan subdirectories
mut mutable_entry := entry
c.scan(mut mutable_entry)!
continue
}
// Process files based on extension
match entry.extension_lower() {
'md' {
mut mutable_entry := entry
c.add_page(mut mutable_entry)!
}
else {
mut mutable_entry := entry
c.add_file(mut mutable_entry)!
}
}
}
}
// Scan for ACL files
fn (mut c Collection) scan_acl() ! {
// Look for read.acl in collection directory
read_acl_path := '${c.path()!.path}/read.acl'
if os.exists(read_acl_path) {
content := os.read_file(read_acl_path)!
// Split by newlines and normalize
c.acl_read = content.split('\n')
.map(it.trim_space())
.filter(it.len > 0)
.map(it.to_lower())
}
// Look for write.acl in collection directory
write_acl_path := '${c.path()!.path}/write.acl'
if os.exists(write_acl_path) {
content := os.read_file(write_acl_path)!
// Split by newlines and normalize
c.acl_write = content.split('\n')
.map(it.trim_space())
.filter(it.len > 0)
.map(it.to_lower())
}
}
// scan_groups scans the collection's directory for .group files and loads them into memory.
pub fn (mut c Collection) scan_groups() ! {
if c.name != 'groups' {
return error('scan_groups only works on "groups" collection')
}
mut p := c.path()!
mut entries := p.list(recursive: false)!
for mut entry in entries.paths {
if entry.extension_lower() == 'group' {
filename := entry.name_fix_no_ext()
mut visited := map[string]bool{}
mut group := parse_group_file(filename, c.path()!.path, mut visited)!
c.doctree.group_add(mut group)!
}
}
}

View File

@@ -1,12 +1,12 @@
module atlas
module core
import incubaid.herolib.core.texttools
import incubaid.herolib.web.doctree
import incubaid.herolib.core.pathlib
import incubaid.herolib.ui.console
import incubaid.herolib.data.paramsparser
@[heap]
pub struct Atlas {
pub struct DocTree {
pub mut:
name string
collections map[string]&Collection
@@ -14,7 +14,7 @@ pub mut:
}
// Create a new collection
fn (mut self Atlas) add_collection(mut path pathlib.Path) !Collection {
fn (mut self DocTree) add_collection(mut path pathlib.Path) !Collection {
mut name := path.name_fix_no_ext()
mut filepath := path.file_get('.collection')!
content := filepath.read()!
@@ -24,18 +24,17 @@ fn (mut self Atlas) add_collection(mut path pathlib.Path) !Collection {
name = params.get('name')!
}
}
name = texttools.name_fix(name)
console.print_item("Adding collection '${name}' to Atlas '${self.name}' at path '${path.path}'")
name = doctree.name_fix(name)
console.print_item("Adding collection '${name}' to DocTree '${self.name}' at path '${path.path}'")
if name in self.collections {
return error('Collection ${name} already exists in Atlas ${self.name}')
return error('Collection ${name} already exists in DocTree ${self.name}')
}
mut c := Collection{
name: name
path: path.path // absolute path
atlas: &self // Set atlas reference
doctree: &self // Set doctree reference
error_cache: map[string]bool{}
}
@@ -47,38 +46,24 @@ fn (mut self Atlas) add_collection(mut path pathlib.Path) !Collection {
}
// Get a collection by name
pub fn (a Atlas) get_collection(name string) !&Collection {
pub fn (a DocTree) get_collection(name string) !&Collection {
return a.collections[name] or {
return CollectionNotFound{
name: name
msg: 'Collection not found in Atlas ${a.name}'
msg: 'Collection not found in DocTree ${a.name}'
}
}
}
// Validate all links in all collections
pub fn (mut a Atlas) init_post() ! {
pub fn (mut a DocTree) init_post() ! {
for _, mut col in a.collections {
col.init_post()!
}
}
// Validate all links in all collections
pub fn (mut a Atlas) validate_links() ! {
for _, mut col in a.collections {
col.validate_links()!
}
}
// Fix all links in all collections (rewrite source files)
pub fn (mut a Atlas) fix_links() ! {
for _, mut col in a.collections {
col.fix_links()!
}
}
// Add a group to the atlas
pub fn (mut a Atlas) group_add(mut group Group) ! {
// Add a group to the doctree
pub fn (mut a DocTree) group_add(mut group Group) ! {
if group.name in a.groups {
return error('Group ${group.name} already exists')
}
@@ -86,13 +71,13 @@ pub fn (mut a Atlas) group_add(mut group Group) ! {
}
// Get a group by name
pub fn (a Atlas) group_get(name string) !&Group {
name_lower := texttools.name_fix(name)
pub fn (a DocTree) group_get(name string) !&Group {
name_lower := doctree.name_fix(name)
return a.groups[name_lower] or { return error('Group ${name} not found') }
}
// Get all groups matching a session's email
pub fn (a Atlas) groups_get(session Session) []&Group {
pub fn (a DocTree) groups_get(session Session) []&Group {
mut matching := []&Group{}
email_lower := session.email.to_lower()
@@ -117,7 +102,7 @@ pub mut:
ignore []string // list of directory names to ignore
}
pub fn (mut a Atlas) scan(args ScanArgs) ! {
pub fn (mut a DocTree) scan(args ScanArgs) ! {
mut path := pathlib.get_dir(path: args.path)!
mut ignore := args.ignore.clone()
ignore = ignore.map(it.to_lower())
@@ -125,7 +110,7 @@ pub fn (mut a Atlas) scan(args ScanArgs) ! {
}
// Scan a directory for collections
fn (mut a Atlas) scan_(mut dir pathlib.Path, ignore_ []string) ! {
fn (mut a DocTree) scan_(mut dir pathlib.Path, ignore_ []string) ! {
console.print_item('Scanning directory: ${dir.path}')
if !dir.is_dir() {
return error('Path is not a directory: ${dir.path}')

View File

@@ -1,4 +1,4 @@
module atlas
module core
pub struct CollectionNotFound {
Error

View File

@@ -1,4 +1,4 @@
module atlas
module core
import incubaid.herolib.core.pathlib
import incubaid.herolib.core.base
@@ -7,22 +7,27 @@ import json
@[params]
pub struct ExportArgs {
pub mut:
destination string @[requireds]
destination string @[required]
reset bool = true
include bool = true
redis bool = true
}
// Export all collections
pub fn (mut a Atlas) export(args ExportArgs) ! {
// Export all collections and do all processing steps
pub fn (mut a DocTree) export(args ExportArgs) ! {
mut dest := pathlib.get_dir(path: args.destination, create: true)!
if args.reset {
dest.empty()!
}
// Validate links before export to populate page.links
a.validate_links()!
// first make sure we have all links identified, in the pages itself
// and make sure we know the git info
for _, mut col in a.collections {
col.find_links()!
col.init_git_info()!
col.title_descriptions()!
}
for _, mut col in a.collections {
col.export(
@@ -32,6 +37,10 @@ pub fn (mut a Atlas) export(args ExportArgs) ! {
redis: args.redis
)!
}
for _, mut col in a.collections {
col.fix_links()!
}
}
@[params]
@@ -90,13 +99,51 @@ 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...
if args.redis {
mut context := base.context()!
mut redis := context.redis()!
redis.hset('atlas:${c.name}', page.name, page.path)!
redis.hset('doctree:${c.name}', page.name, page.path)!
}
}
@@ -117,65 +164,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 +172,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 +191,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

@@ -0,0 +1,61 @@
module core
import incubaid.herolib.web.doctree as doctreetools
import incubaid.herolib.core.pathlib
import incubaid.herolib.ui.console
import incubaid.herolib.data.paramsparser
__global (
doctrees shared map[string]&DocTree
)
@[params]
pub struct DocTreeNewArgs {
pub mut:
name string = 'default'
}
// Create a new DocTree
pub fn new(args DocTreeNewArgs) !&DocTree {
mut name := doctreetools.name_fix(args.name)
mut a := &DocTree{
name: name
}
set(a)
return a
}
// Get DocTree from global map
pub fn get(name string) !&DocTree {
mut fixed_name := doctreetools.name_fix(name)
rlock doctrees {
if fixed_name in doctrees {
return doctrees[fixed_name] or { return error('DocTree ${name} not found') }
}
}
return error("DocTree '${name}' not found")
}
// Check if DocTree exists
pub fn exists(name string) bool {
mut fixed_name := doctreetools.name_fix(name)
rlock doctrees {
return fixed_name in doctrees
}
}
// List all DocTree names
pub fn list() []string {
rlock doctrees {
return doctrees.keys()
}
}
// Store DocTree in global map
fn set(doctree &DocTree) {
lock doctrees {
doctrees[doctree.name] = doctree
}
}

View File

@@ -1,4 +1,4 @@
module atlas
module core
import incubaid.herolib.core.pathlib
import os

View File

@@ -0,0 +1,86 @@
module core
import incubaid.herolib.web.doctree
// Get a page from any collection using format "collection:page"
pub fn (a DocTree) page_get(key string) !&Page {
parts := key.split(':')
if parts.len != 2 {
return error('Invalid page key format. Use "collection:page" in page_get')
}
col := a.get_collection(parts[0])!
return col.page_get(parts[1])!
}
// Get an image from any collection using format "collection:image"
pub fn (a DocTree) image_get(key string) !&File {
parts := key.split(':')
if parts.len != 2 {
return error('Invalid image key format. Use "collection:image" in image_get')
}
col := a.get_collection(parts[0])!
return col.image_get(parts[1])!
}
// Get a file from any collection using format "collection:file"
pub fn (a DocTree) file_get(key string) !&File {
parts := key.split(':')
if parts.len != 2 {
return error('Invalid file key format. Use "collection:file" in file_get')
}
col := a.get_collection(parts[0])!
return col.file_get(parts[1])!
}
// Get a file (can be image) from any collection using format "collection:file"
pub fn (a DocTree) file_or_image_get(key string) !&File {
c, n := doctree.key_parse(key)!
col := a.get_collection(c)!
return col.file_or_image_get(n)!
}
// Check if page exists
pub fn (a DocTree) page_exists(key string) !bool {
c, n := doctree.key_parse(key)!
col := a.get_collection(c) or { return false }
return col.page_exists(n)
}
// Check if image exists
pub fn (a DocTree) image_exists(key string) !bool {
c, n := doctree.key_parse(key)!
col := a.get_collection(c) or { return false }
return col.image_exists(n)
}
// Check if file exists
pub fn (a DocTree) file_exists(key string) !bool {
c, n := doctree.key_parse(key)!
col := a.get_collection(c) or { return false }
return col.file_exists(n)
}
pub fn (a DocTree) file_or_image_exists(key string) !bool {
c, n := doctree.key_parse(key)!
col := a.get_collection(c) or { return false }
return col.file_or_image_exists(n)
}
// List all pages in DocTree
pub fn (a DocTree) list_pages() map[string][]string {
mut result := map[string][]string{}
for col_name, col in a.collections {
mut page_names := []string{}
for page_name, _ in col.pages {
page_names << page_name
}
page_names.sort()
result[col_name] = page_names
}
return result
}

View File

@@ -1,6 +1,6 @@
module atlas
module core
import incubaid.herolib.core.texttools
import incubaid.herolib.web.doctree
import incubaid.herolib.core.pathlib
import os
@@ -20,7 +20,7 @@ pub mut:
// Create a new Group
pub fn new_group(args GroupNewArgs) !Group {
mut name := texttools.name_fix(args.name)
mut name := doctree.name_fix(args.name)
mut patterns := args.patterns.map(it.to_lower())
return Group{
@@ -72,7 +72,7 @@ fn parse_group_file(filename string, base_path string, mut visited map[string]bo
visited[filename] = true
mut group := Group{
name: texttools.name_fix(filename)
name: doctree.name_fix(filename)
patterns: []string{}
}

View File

@@ -1,4 +1,4 @@
in atlas/
in doctree/
check format of groups
see content/groups
@@ -7,9 +7,9 @@ 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
in the scan function in doctree, now make scan_groups function, find groups, only do this for collection as named groups
do not add collection groups to doctree, this is a system collection
make the groups and add them to atlas
make the groups and add them to doctree
give clear instructions for coding agent how to write the code

View File

@@ -1,7 +1,6 @@
module atlas
module core
import incubaid.herolib.core.texttools
import incubaid.herolib.ui.console
import incubaid.herolib.web.doctree as doctreetools
pub enum LinkFileType {
page // Default: link to another page
@@ -43,7 +42,7 @@ pub fn (mut self Link) target_page() !&Page {
if self.status == .external {
return error('External links do not have a target page')
}
return self.page.collection.atlas.page_get(self.key())
return self.page.collection.doctree.page_get(self.key())
}
// Get the target file this link points to
@@ -51,7 +50,7 @@ pub fn (mut self Link) target_file() !&File {
if self.status == .external {
return error('External links do not have a target file')
}
return self.page.collection.atlas.file_or_image_get(self.key())
return self.page.collection.doctree.file_or_image_get(self.key())
}
// Find all markdown links in content
@@ -161,23 +160,10 @@ fn (mut p Page) parse_link_target(mut link Link) ! {
// Format: $collection:$pagename or $collection:$pagename.md
if target.contains(':') {
parts := target.split(':')
if parts.len >= 2 {
link.target_collection_name = texttools.name_fix(parts[0])
// For file links, use name without extension; for page links, normalize normally
if link.file_type == .file {
link.target_item_name = texttools.name_fix_no_ext(parts[1])
} else {
link.target_item_name = normalize_page_name(parts[1])
}
}
link.target_collection_name, link.target_item_name = doctreetools.key_parse(target)!
} else {
// For file links, use name without extension; for page links, normalize normally
if link.file_type == .file {
link.target_item_name = texttools.name_fix_no_ext(target).trim_space()
} else {
link.target_item_name = normalize_page_name(target).trim_space()
}
link.target_item_name = doctreetools.name_fix(target)
link.target_collection_name = p.collection.name
}
@@ -189,11 +175,11 @@ fn (mut p Page) parse_link_target(mut link Link) ! {
mut error_prefix := 'Broken link'
if link.file_type == .file || link.file_type == .image {
target_exists = p.collection.atlas.file_or_image_exists(link.key())!
target_exists = p.collection.doctree.file_or_image_exists(link.key())!
error_category = .invalid_file_reference
error_prefix = if link.file_type == .file { 'Broken file link' } else { 'Broken image link' }
} else {
target_exists = p.collection.atlas.page_exists(link.key())!
target_exists = p.collection.doctree.page_exists(link.key())!
}
// console.print_debug('Link target exists: ${target_exists} for key=${link.key()}')
@@ -298,14 +284,3 @@ fn (mut p Page) filesystem_link_path(mut link Link) !string {
return target_path.path_relative(source_path.path)!
}
/////////////TOOLS//////////////////////////////////
// Normalize page name (remove .md, apply name_fix)
fn normalize_page_name(name string) string {
mut clean := name
if clean.ends_with('.md') {
clean = clean[0..clean.len - 3]
}
return texttools.name_fix(clean)
}

View File

@@ -1,7 +1,7 @@
module atlas
module core
import incubaid.herolib.core.pathlib
import incubaid.herolib.core.texttools
import incubaid.herolib.web.doctree as doctreetools
@[heap]
pub struct Page {
@@ -11,9 +11,18 @@ pub mut:
collection_name string
links []Link
// macros []Macro
title string
description string
questions []Question
collection &Collection @[skip; str: skip] // Reference to parent collection
}
pub struct Question {
pub mut:
question string
answer string
}
@[params]
pub struct NewPageArgs {
pub:
@@ -36,7 +45,7 @@ pub mut:
include bool
}
// Read content without processing includes
// Read content can be with or without processing includes
pub fn (mut p Page) content(args ReadContentArgs) !string {
mut mypath := p.path()!
mut content := mypath.read()!
@@ -49,7 +58,7 @@ pub fn (mut p Page) content(args ReadContentArgs) !string {
// Recursively process includes
fn (mut p Page) process_includes(content string, mut visited map[string]bool) !string {
mut atlas := p.collection.atlas
mut doctree := p.collection.doctree
// Prevent circular includes
page_key := p.key()
if page_key in visited {
@@ -80,34 +89,16 @@ fn (mut p Page) process_includes(content string, mut visited map[string]bool) !s
mut target_page := ''
if include_ref.contains(':') {
parts := include_ref.split(':')
if parts.len == 2 {
target_collection = texttools.name_fix(parts[0])
target_page = texttools.name_fix(parts[1])
} else {
p.collection.error(
category: .include_syntax_error
page_key: page_key
message: 'Invalid include format: `${include_ref}`'
show_console: false
)
processed_lines << '<!-- Invalid include format: ${include_ref} -->'
continue
}
target_collection, target_page = doctreetools.key_parse(include_ref)!
} else {
target_page = texttools.name_fix(include_ref)
}
// Remove .md extension if present
if target_page.ends_with('.md') {
target_page = target_page[0..target_page.len - 3]
target_page = doctreetools.name_fix(include_ref)
}
// Build page key
page_ref := '${target_collection}:${target_page}'
// Get the referenced page from atlas
mut include_page := atlas.page_get(page_ref) or {
// Get the referenced page from doctree
mut include_page := doctree.page_get(page_ref) or {
p.collection.error(
category: .missing_include
page_key: page_key

View File

@@ -1,36 +1,36 @@
module atlas
module core
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
// Play function to process HeroScript actions for DocTree
pub fn play(mut plbook PlayBook) ! {
if !plbook.exists(filter: 'atlas.') {
if !plbook.exists(filter: 'doctree.') {
return
}
// Track which atlases we've processed in this playbook
mut processed_atlases := map[string]bool{}
// Track which doctrees we've processed in this playbook
mut processed_doctreees := map[string]bool{}
mut name := ''
// Process scan actions - scan directories for collections
mut scan_actions := plbook.find(filter: 'atlas.scan')!
mut scan_actions := plbook.find(filter: 'doctree.scan')!
for mut action in scan_actions {
mut p := action.params
name = p.get_default('name', 'main')!
ignore := p.get_list_default('ignore', [])!
console.print_item("Scanning Atlas '${name}' with ignore patterns: ${ignore}")
// Get or create atlas from global map
mut atlas_instance := if exists(name) {
console.print_item("Scanning DocTree '${name}' with ignore patterns: ${ignore}")
// Get or create doctree from global map
mut doctree_instance := if exists(name) {
get(name)!
} else {
console.print_debug('Atlas not found, creating a new one')
console.print_debug('DocTree not found, creating a new one')
new(name: name)!
}
processed_atlases[name] = true
processed_doctreees[name] = true
mut path := p.get_default('path', '')!
@@ -45,38 +45,38 @@ pub fn play(mut plbook PlayBook) ! {
)!.path
}
if path == '' {
return error('Either "path" or "git_url" must be provided for atlas.scan action.')
return error('Either "path" or "git_url" must be provided for doctree.scan action.')
}
atlas_instance.scan(path: path, ignore: ignore)!
doctree_instance.scan(path: path, ignore: ignore)!
action.done = true
// No need to call set() again - atlas is already in global map from new()
// No need to call set() again - doctree is already in global map from new()
// and we're modifying it by reference
}
// Run init_post on all processed atlases
for atlas_name, _ in processed_atlases {
mut atlas_instance_post := get(atlas_name)!
atlas_instance_post.init_post()!
// Run init_post on all processed doctrees
for doctree_name, _ in processed_doctreees {
mut doctree_instance_post := get(doctree_name)!
doctree_instance_post.init_post()!
}
// Process export actions - export collections to destination
mut export_actions := plbook.find(filter: 'atlas.export')!
mut export_actions := plbook.find(filter: 'doctree.export')!
// Process explicit export actions
for mut action in export_actions {
mut p := action.params
name = p.get_default('name', 'main')!
destination := p.get_default('destination', '${os.home_dir()}/hero/var/atlas_export')!
destination := p.get_default('destination', '${os.home_dir()}/hero/var/doctree_export')!
reset := p.get_default_true('reset')
include := p.get_default_true('include')
redis := p.get_default_true('redis')
mut atlas_instance := get(name) or {
return error("Atlas '${name}' not found. Use !!atlas.scan first.")
mut doctree_instance := get(name) or {
return error("DocTree '${name}' not found. Use !!doctree.scan first.")
}
atlas_instance.export(
doctree_instance.export(
destination: destination
reset: reset
include: include

View File

@@ -1,26 +1,34 @@
module atlas
module core
import incubaid.herolib.core.pathlib
import os
import json
const test_base = '/tmp/atlas_test'
const test_base = '/tmp/doctree_test'
fn testsuite_begin() {
// Clean up before and after each test
fn setup_test() {
os.rmdir_all(test_base) or {}
os.mkdir_all(test_base)!
os.mkdir_all(test_base) or {}
}
fn testsuite_end() {
fn cleanup_test() {
os.rmdir_all(test_base) or {}
}
fn test_create_atlas() {
mut a := new(name: 'test_atlas')!
assert a.name == 'test_atlas'
fn test_create_doctree() {
setup_test()
defer { cleanup_test() }
mut a := new(name: 'test_doctree')!
assert a.name == 'test_doctree'
assert a.collections.len == 0
}
fn test_add_collection() {
setup_test()
defer { cleanup_test() }
// Create test collection
col_path := '${test_base}/col1'
os.mkdir_all(col_path)!
@@ -38,6 +46,9 @@ fn test_add_collection() {
}
fn test_scan() {
setup_test()
defer { cleanup_test() }
// Create test structure
os.mkdir_all('${test_base}/docs/guides')!
mut cfile := pathlib.get_file(path: '${test_base}/docs/guides/.collection', create: true)!
@@ -55,6 +66,9 @@ fn test_scan() {
}
fn test_export() {
setup_test()
defer { cleanup_test() }
// Setup
col_path := '${test_base}/source/col1'
export_path := '${test_base}/export'
@@ -76,6 +90,9 @@ fn test_export() {
}
fn test_export_with_includes() {
setup_test()
defer { cleanup_test() }
// Setup: Create pages with includes
col_path := '${test_base}/include_test'
os.mkdir_all(col_path)!
@@ -95,7 +112,7 @@ fn test_export_with_includes() {
a.add_collection(mut pathlib.get_dir(path: col_path)!)!
export_path := '${test_base}/export_include'
a.export(destination: export_path, include: true)!
a.export(destination: export_path, include: true, redis: false)!
// Verify exported page1 has page2 content included
exported := os.read_file('${export_path}/content/test_col/page1.md')!
@@ -105,6 +122,9 @@ fn test_export_with_includes() {
}
fn test_export_without_includes() {
setup_test()
defer { cleanup_test() }
col_path := '${test_base}/no_include_test'
os.mkdir_all(col_path)!
@@ -118,7 +138,7 @@ fn test_export_without_includes() {
a.add_collection(mut pathlib.get_dir(path: col_path)!)!
export_path := '${test_base}/export_no_include'
a.export(destination: export_path, include: false)!
a.export(destination: export_path, include: false, redis: false)!
// Verify exported page1 still has include action
exported := os.read_file('${export_path}/content/test_col2/page1.md')!
@@ -126,18 +146,28 @@ fn test_export_without_includes() {
}
fn test_error_deduplication() {
setup_test()
defer { cleanup_test() }
mut a := new(name: 'test')!
col_path := '${test_base}/err_dedup_col'
os.mkdir_all(col_path)!
mut cfile := pathlib.get_file(path: '${col_path}/.collection', create: true)!
cfile.write('name:err_dedup_col')!
mut col := a.add_collection(mut pathlib.get_dir(path: col_path)!)!
assert col.name == 'err_dedup_col' // Ensure collection is added correctly
}
fn test_error_hash() {
setup_test()
defer { cleanup_test() }
// This test had no content, leaving it as a placeholder.
}
fn test_find_links() {
setup_test()
defer { cleanup_test() }
col_path := '${test_base}/find_links_test'
os.mkdir_all(col_path)!
@@ -157,7 +187,11 @@ fn test_find_links() {
assert links.len >= 2
}
fn test_validate_links() {
// Test with a valid link to ensure no errors are reported
fn test_find_links_valid_link() {
setup_test()
defer { cleanup_test() }
// Setup
col_path := '${test_base}/link_test'
os.mkdir_all(col_path)!
@@ -176,15 +210,17 @@ fn test_validate_links() {
mut a := new()!
a.add_collection(mut pathlib.get_dir(path: col_path)!)!
// Validate
a.validate_links()!
// Should have no errors
col := a.get_collection('test_col')!
assert col.errors.len == 0
a.export(destination: '${test_base}/export_links', redis: false)!
}
fn test_validate_broken_links() {
setup_test()
defer { cleanup_test() }
// Setup
col_path := '${test_base}/broken_link_test'
os.mkdir_all(col_path)!
@@ -200,13 +236,17 @@ fn test_validate_broken_links() {
a.add_collection(mut pathlib.get_dir(path: col_path)!)!
// Validate
a.validate_links()!
a.export(destination: '${test_base}/validate_broken_links', redis: false)!
// Should have error
col := a.get_collection('test_col')!
assert col.errors.len > 0
}
fn test_fix_links() {
setup_test()
defer { cleanup_test() }
// Setup - all pages in same directory for simpler test
col_path := '${test_base}/fix_link_test'
os.mkdir_all(col_path)!
@@ -229,20 +269,22 @@ fn test_fix_links() {
mut p := col.page_get('page1')!
original := p.content()!
println('Original: ${original}')
assert original.contains('[Link](page2)')
fixed := p.content_with_fixed_links(FixLinksArgs{
include: true
cross_collection: true
export_mode: false
})!
println('Fixed: ${fixed}')
// The fix_links should work on content
assert fixed.contains('[Link](page2.md)')
}
fn test_link_formats() {
setup_test()
defer { cleanup_test() }
col_path := '${test_base}/link_format_test'
os.mkdir_all(col_path)!
@@ -268,6 +310,9 @@ fn test_link_formats() {
}
fn test_cross_collection_links() {
setup_test()
defer { cleanup_test() }
// Setup two collections
col1_path := '${test_base}/col1_cross'
col2_path := '${test_base}/col2_cross'
@@ -293,20 +338,19 @@ fn test_cross_collection_links() {
a.add_collection(mut pathlib.get_dir(path: col1_path)!)!
a.add_collection(mut pathlib.get_dir(path: col2_path)!)!
// Validate - should pass
a.validate_links()!
col1 := a.get_collection('col1')!
assert col1.errors.len == 0
// Fix links - cross-collection links should NOT be rewritten
a.fix_links()!
a.export(destination: '${test_base}/export_cross', redis: false)!
fixed := page1.read()!
assert fixed.contains('[Link to col2](col2:page2)') // Unchanged
}
fn test_save_and_load() {
setup_test()
defer { cleanup_test() }
// Setup
col_path := '${test_base}/save_test'
os.mkdir_all(col_path)!
@@ -321,9 +365,13 @@ fn test_save_and_load() {
mut a := new(name: 'test')!
a.add_collection(mut pathlib.get_dir(path: col_path)!)!
col := a.get_collection('test_col')!
assert col.name == 'test_col'
}
fn test_save_with_errors() {
setup_test()
defer { cleanup_test() }
col_path := '${test_base}/error_save_test'
os.mkdir_all(col_path)!
@@ -332,9 +380,13 @@ fn test_save_with_errors() {
mut a := new(name: 'test')!
mut col := a.add_collection(mut pathlib.get_dir(path: col_path)!)!
assert col.name == 'err_col' // Ensure collection is added correctly
}
fn test_load_from_directory() {
setup_test()
defer { cleanup_test() }
// Setup multiple collections
col1_path := '${test_base}/load_dir/col1'
col2_path := '${test_base}/load_dir/col2'
@@ -358,16 +410,21 @@ fn test_load_from_directory() {
mut a := new(name: 'test')!
a.add_collection(mut pathlib.get_dir(path: col1_path)!)!
a.add_collection(mut pathlib.get_dir(path: col2_path)!)!
assert a.collections.len == 2
}
fn test_get_edit_url() {
setup_test()
defer { cleanup_test() }
// Create a mock collection
mut atlas := new(name: 'test_atlas')!
mut doctree := new(name: 'test_doctree')!
col_path := '${test_base}/git_test'
os.mkdir_all(col_path)!
mut cfile := pathlib.get_file(path: '${col_path}/.collection', create: true)!
cfile.write('name:git_test_col')!
mut col := atlas.add_collection(mut pathlib.get_dir(path: col_path)!)!
mut col := doctree.add_collection(mut pathlib.get_dir(path: col_path)!)!
col.git_url = 'https://github.com/test/repo.git' // Assuming git_url is a field on Collection
// Create a mock page
mut page_path := pathlib.get_file(path: '${col_path}/test_page.md', create: true)!
@@ -376,13 +433,14 @@ fn test_get_edit_url() {
// Get the page and collection edit URLs
page := col.page_get('test_page')!
// edit_url := page.get_edit_url()! // This method does not exist
// Assert the URLs are correct
// assert edit_url == 'https://github.com/test/repo/edit/main/test_page.md'
// No asserts in original, adding one for completeness
assert page.name == 'test_page'
}
fn test_export_recursive_links() {
setup_test()
defer { cleanup_test() }
// Create 3 collections with chained links
col_a_path := '${test_base}/recursive_export/col_a'
col_b_path := '${test_base}/recursive_export/col_b'
@@ -392,37 +450,95 @@ fn test_export_recursive_links() {
os.mkdir_all(col_b_path)!
os.mkdir_all(col_c_path)!
// Collection A
// 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\n[Link to B](col_b:page_b)')!
page_a.write('# Page A\n\nThis is page A.\n\n[Link to Page B](col_b:page_b)')!
// Collection 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\n[Link to C](col_c:page_c)')!
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
// 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\nFinal content')!
page_c.write('# Page C\n\nThis is the final page in the chain.')!
// Export
// Create DocTree 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)!)!
// Export
export_path := '${test_base}/export_recursive'
a.export(destination: export_path)!
a.export(destination: export_path, redis: false)!
// 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
// 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'
// TODO: test not complete
// Verify all pages exist in col_a export 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'
// Verify metadata files exist
assert os.exists('${export_path}/meta/col_a.json'), 'col_a metadata should exist'
assert os.exists('${export_path}/meta/col_b.json'), 'col_b metadata should exist'
assert os.exists('${export_path}/meta/col_c.json'), 'col_c metadata should exist'
}
fn test_export_recursive_with_images() {
setup_test()
defer { cleanup_test() }
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 DocTree
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)!)!
export_path := '${test_base}/export_recursive_img'
a.export(destination: export_path, redis: false)!
// 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'
}

View File

@@ -1,4 +1,4 @@
# Atlas Module
# DocTree Module
A lightweight document collection manager for V, inspired by doctree but simplified.
@@ -18,7 +18,7 @@ put in .hero file and execute with hero or but shebang line on top of .hero scri
**Scan Parameters:**
- `name` (optional, default: 'main') - Atlas instance name
- `name` (optional, default: 'main') - DocTree instance name
- `path` (required when git_url not provided) - Directory path to scan
- `git_url` (alternative to path) - Git repository URL to clone/checkout
- `git_root` (optional when using git_url, default: ~/code) - Base directory for cloning
@@ -31,9 +31,9 @@ put in .hero file and execute with hero or but shebang line on top of .hero scri
```heroscript
#!/usr/bin/env hero
!!atlas.scan git_url:"https://git.ourworld.tf/tfgrid/docs_tfgrid4/src/branch/main/collections/tests"
!!doctree.scan git_url:"https://git.ourworld.tf/tfgrid/docs_tfgrid4/src/branch/main/collections/tests"
!!atlas.export destination: '/tmp/atlas_export'
!!doctree.export
```
@@ -42,10 +42,10 @@ put this in .hero file
## usage in herolib
```v
import incubaid.herolib.data.atlas
import incubaid.herolib.web.doctree
// Create a new Atlas
mut a := atlas.new(name: 'my_docs')!
// Create a new DocTree
mut a := doctree.new(name: 'my_docs')!
// Scan a directory for collections
a.scan(path: '/path/to/docs')!
@@ -94,7 +94,7 @@ file := a.file_get('guides:diagram')!
### Scanning for Collections
```v
mut a := atlas.new()!
mut a := doctree.new()!
a.scan(path: './docs')!
```
@@ -191,7 +191,7 @@ for _, col in a.collections {
### Include Processing
Atlas supports simple include processing using `!!include` actions:
DocTree supports simple include processing using `!!include` actions:
```v
// Export with includes processed (default)
@@ -241,11 +241,11 @@ content := page.content()!
## Git Integration
Atlas automatically detects the git repository URL for each collection and stores it for reference. This allows users to easily navigate to the source for editing.
DocTree automatically detects the git repository URL for each collection and stores it for reference. This allows users to easily navigate to the source for editing.
### Automatic Detection
When scanning collections, Atlas walks up the directory tree to find the `.git` directory and captures:
When scanning collections, DocTree walks up the directory tree to find the `.git` directory and captures:
- **git_url**: The remote origin URL
- **git_branch**: The current branch
@@ -254,7 +254,7 @@ When scanning collections, Atlas walks up the directory tree to find the `.git`
You can scan collections directly from a git repository:
```heroscript
!!atlas.scan
!!doctree.scan
name: 'my_docs'
git_url: 'https://github.com/myorg/docs.git'
git_root: '~/code' // optional, defaults to ~/code
@@ -265,7 +265,7 @@ The repository will be automatically cloned if it doesn't exist locally.
### Accessing Edit URLs
```v
mut page := atlas.page_get('guides:intro')!
mut page := doctree.page_get('guides:intro')!
edit_url := page.get_edit_url()!
println('Edit at: ${edit_url}')
// Output: Edit at: https://github.com/myorg/docs/edit/main/guides.md
@@ -282,7 +282,7 @@ Collection guides source: https://github.com/myorg/docs.git (branch: main)
This allows published documentation to link back to the source repository for contributions.
## Links
Atlas supports standard Markdown links with several formats for referencing pages within collections.
DocTree supports standard Markdown links with several formats for referencing pages within collections.
### Link Formats
@@ -313,14 +313,14 @@ Link using a path - **only the filename is used** for matching:
#### Validation
Check all links in your Atlas:
Check all links in your DocTree:
```v
mut a := atlas.new()!
mut a := doctree.new()!
a.scan(path: './docs')!
// Validate all links
a.validate_links()!
a.find_links()!
// Check for errors
for _, col in a.collections {
@@ -335,7 +335,7 @@ for _, col in a.collections {
Automatically rewrite links with correct relative paths:
```v
mut a := atlas.new()!
mut a := doctree.new()!
a.scan(path: './docs')!
// Fix all links in place
@@ -384,7 +384,7 @@ After fix (assuming pages are in subdirectories):
### Export Directory Structure
When you export an Atlas, the directory structure is organized as:
When you export an DocTree, the directory structure is organized as:
$$\text{export\_dir}/
\begin{cases}
@@ -409,17 +409,17 @@ $$\text{export\_dir}/
## Redis Integration
Atlas uses Redis to store metadata about collections, pages, images, and files for fast lookups and caching.
DocTree uses Redis to store metadata about collections, pages, images, and files for fast lookups and caching.
### Redis Data Structure
When `redis: true` is set during export, Atlas stores:
When `redis: true` is set during export, DocTree stores:
1. **Collection Paths** - Hash: `atlas:path`
1. **Collection Paths** - Hash: `doctree:path`
- Key: collection name
- Value: exported collection directory path
2. **Collection Contents** - Hash: `atlas:<collection_name>`
2. **Collection Contents** - Hash: `doctree:<collection_name>`
- Pages: `page_name``page_name.md`
- Images: `image_name.ext``img/image_name.ext`
- Files: `file_name.ext``files/file_name.ext`
@@ -427,11 +427,11 @@ When `redis: true` is set during export, Atlas stores:
### Redis Usage Examples
```v
import incubaid.herolib.data.atlas
import incubaid.herolib.web.doctree
import incubaid.herolib.core.base
// Export with Redis metadata (default)
mut a := atlas.new(name: 'docs')!
mut a := doctree.new(name: 'docs')!
a.scan(path: './docs')!
a.export(
destination: './output'
@@ -443,15 +443,15 @@ mut context := base.context()!
mut redis := context.redis()!
// Get collection path
col_path := redis.hget('atlas:path', 'guides')!
col_path := redis.hget('doctree:path', 'guides')!
println('Guides collection exported to: ${col_path}')
// Get page location
page_path := redis.hget('atlas:guides', 'introduction')!
page_path := redis.hget('doctree:guides', 'introduction')!
println('Introduction page: ${page_path}') // Output: introduction.md
// Get image location
img_path := redis.hget('atlas:guides', 'logo.png')!
img_path := redis.hget('doctree:guides', 'logo.png')!
println('Logo image: ${img_path}') // Output: img/logo.png
```
@@ -468,9 +468,9 @@ println('Logo image: ${img_path}') // Output: img/logo.png
Save collection metadata to JSON files for archival or cross-tool compatibility:
```v
import incubaid.herolib.data.atlas
import incubaid.herolib.web.doctree
mut a := atlas.new(name: 'my_docs')!
mut a := doctree.new(name: 'my_docs')!
a.scan(path: './docs')!
// Save all collections to a specified directory
@@ -497,32 +497,32 @@ save_path/
## HeroScript Integration
Atlas integrates with HeroScript, allowing you to define Atlas operations in `.vsh` or playbook files.
DocTree integrates with HeroScript, allowing you to define DocTree operations in `.vsh` or playbook files.
### Using in V Scripts
Create a `.vsh` script to process Atlas operations:
Create a `.vsh` script to process DocTree operations:
```v
#!/usr/bin/env -S v -n -w -cg -gc none -cc tcc -d use_openssl -enable-globals run
import incubaid.herolib.core.playbook
import incubaid.herolib.data.atlas
import incubaid.herolib.web.doctree
// Define your HeroScript content
heroscript := "
!!atlas.scan path: './docs'
!!doctree.scan path: './docs'
!!atlas.export destination: './output' include: true
!!doctree.export destination: './output' include: true
"
// Create playbook from text
mut plbook := playbook.new(text: heroscript)!
// Execute atlas actions
atlas.play(mut plbook)!
// Execute doctree actions
doctree.play(mut plbook)!
println('Atlas processing complete!')
println('DocTree processing complete!')
```
### Using in Playbook Files
@@ -530,11 +530,11 @@ println('Atlas processing complete!')
Create a `docs.play` file:
```heroscript
!!atlas.scan
!!doctree.scan
name: 'main'
path: '~/code/docs'
!!atlas.export
!!doctree.export
destination: '~/code/output'
reset: true
include: true
@@ -565,11 +565,11 @@ playcmds.run(mut plbook)!
Errors are automatically collected and reported:
```heroscript
!!atlas.scan
!!doctree.scan
path: './docs'
# Errors will be printed during export
!!atlas.export
!!doctree.export
destination: './output'
```
@@ -583,13 +583,13 @@ Collection guides - Errors (2)
### Auto-Export Behavior
If you use `!!atlas.scan` **without** an explicit `!!atlas.export`, Atlas will automatically export to the default location (current directory).
If you use `!!doctree.scan` **without** an explicit `!!doctree.export`, DocTree will automatically export to the default location (current directory).
To disable auto-export, include an explicit (empty) export action or simply don't include any scan actions.
### Best Practices
1. **Always validate before export**: Use `!!atlas.validate` to catch broken links early
1. **Always validate before export**: Use `!!doctree.validate` to catch broken links early
2. **Use named instances**: When working with multiple documentation sets, use the `name` parameter
3. **Enable Redis for production**: Use `redis: true` for web deployments to enable fast lookups
4. **Process includes during export**: Keep `include: true` to embed referenced content in exported files
@@ -599,7 +599,7 @@ The following features are planned but not yet available:
- [ ] Load collections from `.collection.json` files
- [ ] Python API for reading collections
- [ ] `atlas.validate` playbook action
- [ ] `atlas.fix_links` playbook action
- [ ] `doctree.validate` playbook action
- [ ] `doctree.fix_links` playbook action
- [ ] Auto-save on collection modifications
- [ ] Collection version control

View File

@@ -0,0 +1,187 @@
module core
import incubaid.herolib.core.pathlib
import os
import json
const test_base = '/tmp/doctree_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() {
os.rmdir_all('${test_base}') or { }
// 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 DocTree 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)!)!
// 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'
assert os.exists('${export_path}/content/col_b/page_a.md')==false, 'page_a.md should not be exported'
assert os.exists('${export_path}/content/col_b/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'
assert os.exists('${export_path}/content/col_c/page_a.md')==false, 'page_a.md should not be exported'
assert os.exists('${export_path}/content/col_c/page_b.md')==false, 'page_b.md from col_b should not be included'
assert os.exists('${export_path}/content/col_c/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(DocTree, meta_content) or {
panic('Failed to parse metadata JSON: ${err}')
}
assert meta.name != "", 'Metadata should have name field'
//check metadata for all collections exists
assert os.exists('${export_path}/meta/col_a.json'), 'col_a metadata should exist'
assert os.exists('${export_path}/meta/col_b.json'), 'col_b metadata should exist'
assert os.exists('${export_path}/meta/col_c.json'), 'col_c metadata should exist'
// 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() {
os.rmdir_all('${test_base}') or { }
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 DocTree
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)!)!
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

@@ -0,0 +1,62 @@
module meta
import incubaid.herolib.core.texttools
__global (
sites_global map[string]&Site
)
@[params]
pub struct FactoryArgs {
pub mut:
name string = 'default'
}
pub fn new(args FactoryArgs) !&Site {
name := texttools.name_fix(args.name)
// Check if a site with this name already exists
if name in sites_global {
// Return the existing site instead of creating a new one
return get(name: name)!
}
mut site := Site{
config: SiteConfig{
name: name
}
root: Category{}
}
sites_global[name] = &site
return get(name: name)!
}
pub fn get(args FactoryArgs) !&Site {
name := texttools.name_fix(args.name)
// mut sc := sites_global[name] or { return error('siteconfig with name "${name}" does not exist') }
return sites_global[name] or {
print_backtrace()
return error('could not get site with name:${name}')
}
}
pub fn exists(args FactoryArgs) bool {
name := texttools.name_fix(args.name)
return name in sites_global
}
pub fn reset() {
sites_global.clear()
}
pub fn default() !&Site {
if sites_global.len == 0 {
return new(name: 'default')!
}
return get()!
}
// list returns all site names that have been created
pub fn list() []string {
return sites_global.keys()
}

View File

@@ -0,0 +1,11 @@
module meta
// Announcement bar config structure
pub struct Announcement {
pub mut:
// id string @[json: 'id']
content string @[json: 'content']
background_color string @[json: 'backgroundColor']
text_color string @[json: 'textColor']
is_closeable bool @[json: 'isCloseable']
}

View File

@@ -0,0 +1,7 @@
module meta
pub struct BuildDest {
pub mut:
path string
ssh_name string
}

View File

@@ -0,0 +1,89 @@
module meta
@[heap]
struct Category {
pub mut:
path string // e.g. Operations/Daily (means 2 levels deep, first level is Operations)
collapsible bool = true
collapsed bool
items []CategoryItem
}
// return the label of the category (last part of the path)
pub fn (mut c Category) label() !string {
if c.path.count('/') == 0 {
return c.path
}
return c.path.all_after_last('/')
}
type CategoryItem = Page | Link | Category
// return all items as CategoryItem references recursive
pub fn (mut self Category) items_get() ![]&CategoryItem {
mut result := []&CategoryItem{}
for i in 0 .. self.items.len {
mut c := self.items[i]
match mut c {
Category {
result << c.items_get()!
}
else {
result << &c
}
}
}
return result
}
pub fn (mut self Category) page_get(src string) !&Page {
for c in self.items_get()! {
match c {
Page {
if c.src == src {
return &c
}
}
else {}
}
}
return error('Page with src="${src}" not found in site.')
}
pub fn (mut self Category) link_get(href string) !&Link {
for c in self.items_get()! {
match c {
Link {
if c.href == href {
return &c
}
}
else {}
}
}
return error('Link with href="${href}" not found in site.')
}
pub fn (mut self Category) category_get(path string) !&Category {
for i in 0 .. self.items.len {
mut c := self.items[i]
match mut c {
Category {
if c.path == path {
return &c
}
}
else {}
}
}
mut new_category := Category{
path: path
collapsible: true
collapsed: true
items: []CategoryItem{}
}
// Add the new category as a sum type variant
self.items << new_category
// Update current_category_ref to point to the newly added category in the slice
return &new_category
}

View File

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

View File

@@ -0,0 +1,11 @@
module meta
// is to import one site into another, can be used to e.g. import static parts from one location into the build one we are building
pub struct ImportItem {
pub mut:
url string // http git url can be to specific path
path string
dest string // location in the docs folder of the place where we will build the documentation site e.g. docusaurus
replace map[string]string // will replace ${NAME} in the imported content
visible bool = true
}

View File

@@ -0,0 +1,8 @@
module meta
struct Link {
pub mut:
label string
href string
description string
}

View File

@@ -0,0 +1,15 @@
module meta
// Page represents a single documentation page
pub struct Page {
pub mut:
src string // Unique identifier: "collection:page_name" marks where the page is from. (is also name_fix'ed)
label string // Display label in navigation e.g. "Getting Started"
title string // Display title (optional, extracted from markdown if empty)
description string // Brief description for metadata
draft bool // Is this page a draft? Means only show in development mode
hide_title bool // Should the title be hidden on the page?
hide bool // Should the page be hidden from navigation?
category_id int // Optional category ID this page belongs to, if 0 it means its at root level
nav_path string // navigation path e.g. "Operations/Daily"
}

View File

@@ -0,0 +1,30 @@
module meta
@[heap]
pub struct Site {
pub mut:
doctree_path string // path to the export of the doctree site
config SiteConfig // Full site configuration
root Category // The root category containing all top-level items
announcements []Announcement // there can be more than 1 announcement
imports []ImportItem
build_dest []BuildDest // Production build destinations (from !!site.build_dest)
build_dest_dev []BuildDest // Development build destinations (from !!site.build_dest_dev)
}
pub fn (mut self Site) page_get(src string) !&Page {
return self.root.page_get(src)!
}
pub fn (mut self Site) link_get(href string) !&Link {
return self.root.link_get(href)!
}
pub fn (mut self Site) category_get(path string) !&Category {
return self.root.category_get(path)!
}
// sidebar returns the root category for building the sidebar navigation
pub fn (mut self Site) sidebar() !&Category {
return &self.root
}

View File

@@ -1,4 +1,4 @@
module site
module meta
import os
// Combined config structure
@@ -15,7 +15,6 @@ pub mut:
copyright string = 'someone'
footer Footer
menu Menu
imports []ImportItem
// New fields for Docusaurus compatibility
url string // The main URL of the site (from !!site.config url:)
@@ -24,21 +23,6 @@ pub mut:
meta_title string // Specific title for SEO metadata (from !!site.config_meta title:)
meta_image string // Specific image for SEO metadata (og:image) (from !!site.config_meta image:)
build_dest []BuildDest // Production build destinations (from !!site.build_dest)
build_dest_dev []BuildDest // Development build destinations (from !!site.build_dest_dev)
announcement AnnouncementBar // Announcement bar configuration (from !!site.announcement)
}
// Announcement bar config structure
pub struct AnnouncementBar {
pub mut:
id string @[json: 'id']
content string @[json: 'content']
background_color string @[json: 'backgroundColor']
text_color string @[json: 'textColor']
is_closeable bool @[json: 'isCloseable']
}
// Footer config structures
@@ -78,20 +62,3 @@ pub mut:
logo_src string @[json: 'logoSrc']
logo_src_dark string @[json: 'logoSrcDark']
}
pub struct BuildDest {
pub mut:
path string
ssh_name string
}
// is to import one docusaurus site into another, can be used to e.g. import static parts from one location into the build one we are building
pub struct ImportItem {
pub mut:
name string // will normally be empty
url string // http git url can be to specific path
path string
dest string // location in the docs folder of the place where we will build the documentation site e.g. docusaurus
replace map[string]string // will replace ${NAME} in the imported content
visible bool = true
}

View File

@@ -0,0 +1,96 @@
module meta
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')!
mut website := new(name: name)!
mut config := &website.config
// 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} Example Organization')!
config.url = p.get_default('url', '')!
config.base_url = p.get_default('base_url', '/')!
config.url_home = p.get_default('url_home', '')!
config_action.done = true
// ============================================================
// 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
}
// ============================================================
// STEP 3: Configure content imports
// ============================================================
console.print_item('Step 3: Configuring content imports')
play_imports(mut plbook, mut website)!
// ============================================================
// 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 website)!
// ============================================================
// STEP 7: Configure publish destinations
// ============================================================
console.print_item('Step 7: Configuring publish destinations')
play_publishing(mut plbook, mut website)!
// ============================================================
// STEP 8: Build pages and navigation structure
// ============================================================
console.print_item('Step 8: Processing pages and building navigation')
play_pages(mut plbook, mut website)!
console.print_green('Site configuration complete')
}

View File

@@ -0,0 +1,30 @@
module meta
import incubaid.herolib.core.playbook { PlayBook }
// ============================================================
// ANNOUNCEMENT: Process announcement bar (optional)
// ============================================================
fn play_announcement(mut plbook PlayBook, mut site Site) ! {
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"')
}
site.announcements << Announcement{
// id: p.get('id')!
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 meta
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,50 @@
module meta
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 site Site) ! {
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{
url: p.get_default('url', '')!
path: import_path
dest: p.get_default('dest', '')!
replace: replace_map
visible: p.get_default_false('visible')
}
site.imports << import_item
action.done = true
}
}

View File

@@ -0,0 +1,60 @@
module meta
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

@@ -0,0 +1,116 @@
module meta
import incubaid.herolib.core.playbook { PlayBook }
import incubaid.herolib.web.doctree as doctreetools
import incubaid.herolib.ui.console
// ============================================================
// PAGES & CATEGORIES: Process pages and build navigation structure
// ============================================================
fn play_pages(mut plbook PlayBook, mut website Site) ! {
mut collection_current := ''
mut category_current := &website.root // start at root category, this is basically the navigation tree root
// ============================================================
// PASS 1: Process all page_category and page actions
// ============================================================
mut all_actions := plbook.find(filter: 'site.')!
for mut action in all_actions {
if action.done {
continue
}
// Skip actions that are not page or page_category
if action.name != 'page_category' && action.name != 'page' {
continue
}
// ========== PAGE CATEGORY ==========
if action.name == 'page_category' {
mut p := action.params
category_path := p.get_default('path', '')!
if category_path.len == 0 {
return error('!!site.page_category: must specify "path"')
}
// Navigate/create category structure
category_current = category_current.category_get(category_path)!
category_current.collapsible = p.get_default_true('collapsible')
category_current.collapsed = p.get_default_false('collapsed')
console.print_item('Created page category: "${category_current.path}"')
action.done = true
println(category_current)
// $dbg();
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(':') {
page_collection, page_name = doctreetools.key_parse(page_src)!
} else {
// Use previously specified collection if available
if collection_current.len > 0 {
page_collection = collection_current
page_name = doctreetools.name_fix(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
// Get optional page metadata
mut page_label := p.get_default('label', '')! // CHANGED: added mut
if page_label.len == 0 {
page_label = p.get_default('title', '')!
}
page_title := p.get_default('title', '')!
page_description := p.get_default('description', '')!
// Create page object
mut page := Page{
src: '${page_collection}:${page_name}'
label: page_label
title: page_title
description: page_description
draft: p.get_default_false('draft')
hide_title: p.get_default_false('hide_title')
hide: p.get_default_false('hide')
nav_path: category_current.path
}
// Add page to current category
category_current.items << page
console.print_item('Added page: "${page.src}" (label: "${page.label}")')
action.done = true
continue
}
}
console.print_green('Pages and categories processing complete')
}

View File

@@ -0,0 +1,46 @@
module meta
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 website Site) ! {
// 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', '')!
}
website.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', '')!
}
website.build_dest_dev << dest
action.done = true
}
}

View File

@@ -0,0 +1,676 @@
# Site Module
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.
## Quick Start
### Minimal HeroScript Example
```heroscript
!!site.config
name: "my_docs"
title: "My Documentation"
!!site.page src: "docs:introduction"
label: "Getting Started"
title: "Getting Started"
!!site.page src: "setup"
label: "Installation"
title: "Installation"
```
### Processing with V Code
```v
import incubaid.herolib.core.playbook
import incubaid.herolib.web.doctree.meta as site_module
import incubaid.herolib.ui.console
// Process HeroScript file
mut plbook := playbook.new(path: './site_config.heroscript')!
// Execute site configuration
site_module.play(mut plbook)!
// Access the configured site
mut mysite := site_module.get(name: 'my_docs')!
// Print available pages
for page in mysite.pages {
console.print_item('Page: "${page.src}" - "${page.title}"')
}
println('Site has ${mysite.pages.len} pages')
```
---
## API Reference
### Site Factory
Factory functions to create and retrieve site instances:
```v
// Create a new site
mut mysite := site_module.new(name: 'my_docs')!
// Get existing site
mut mysite := site_module.get(name: 'my_docs')!
// Check if site exists
if site_module.exists(name: 'my_docs') {
println('Site exists')
}
// Get all site names
site_names := site_module.list() // Returns []string
// Get default site (creates if needed)
mut default := site_module.default()!
```
### Site Object Structure
```v
@[heap]
pub struct Site {
pub mut:
doctree_path string // path to the export of the doctree site
config SiteConfig // Full site configuration
pages []Page // Array of pages
links []Link // Array of links
categories []Category // Array of categories
announcements []Announcement // Array of announcements (can be multiple)
imports []ImportItem // Array of imports
build_dest []BuildDest // Production build destinations
build_dest_dev []BuildDest // Development build destinations
}
```
### Accessing Pages
```v
// Access all pages
pages := mysite.pages // []Page
// Access specific page by index
page := mysite.pages[0]
// Page structure
pub struct Page {
pub mut:
src string // "collection:page_name" format (unique identifier)
label string // Display label in navigation
title string // Display title on page (extracted from markdown if empty)
description string // SEO metadata
draft bool // Hide from navigation if true
hide_title bool // Don't show title on page
hide bool // Hide page completely
category_id int // Optional category ID (0 = root level)
}
```
### Categories and Navigation
```v
// Access all categories
categories := mysite.categories // []Category
// Category structure
pub struct Category {
pub mut:
path string // e.g., "Getting Started" or "Operations/Daily"
collapsible bool = true
collapsed bool
}
// Generate sidebar navigation
sidebar := mysite.sidebar()! // Returns SideBar
// Sidebar structure
pub struct SideBar {
pub mut:
my_sidebar []NavItem
}
pub type NavItem = NavDoc | NavCat | NavLink
pub struct NavDoc {
pub:
path string // path is $collection/$name without .md
label string
}
pub struct NavCat {
pub mut:
label string
collapsible bool = true
collapsed bool
items []NavItem // nested NavDoc/NavCat/NavLink
}
pub struct NavLink {
pub:
label string
href string
description string
}
// Example: iterate navigation
sidebar := mysite.sidebar()!
for item in sidebar.my_sidebar {
match item {
NavDoc {
println('Page: ${item.label} (${item.path})')
}
NavCat {
println('Category: ${item.label} (${item.items.len} items)')
}
NavLink {
println('Link: ${item.label} -> ${item.href}')
}
}
}
// Print formatted sidebar
println(mysite.str())
```
### Site Configuration
```v
@[heap]
pub struct SiteConfig {
pub mut:
// Core
name string
title string
description string
tagline string
favicon string
image string
copyright string
// URLs (Docusaurus)
url string // Full site URL
base_url string // Base path (e.g., "/" or "/docs/")
url_home string // Home page path
// SEO Metadata
meta_title string // SEO title override
meta_image string // OG image override
// Navigation & Footer
footer Footer
menu Menu
// Publishing
build_dest []BuildDest // Production destinations
build_dest_dev []BuildDest // Development destinations
// Imports
imports []ImportItem
}
pub struct BuildDest {
pub mut:
path string
ssh_name string
}
pub struct Menu {
pub mut:
title string
items []MenuItem
logo_alt string
logo_src string
logo_src_dark string
}
pub struct MenuItem {
pub mut:
href string
to string
label string
position string // "left" or "right"
}
pub struct Footer {
pub mut:
style string // e.g., "dark" or "light"
links []FooterLink
}
pub struct FooterLink {
pub mut:
title string
items []FooterItem
}
pub struct FooterItem {
pub mut:
label string
to string
href string
}
pub struct Announcement {
pub mut:
content string
background_color string
text_color string
is_closeable bool
}
pub struct ImportItem {
pub mut:
url string // http or git url
path string
dest string // location in docs folder
replace map[string]string
visible bool = true
}
```
---
## Core Concepts
### Site
A website configuration that contains pages, navigation structure, and metadata. Each site is registered globally and can be retrieved by name.
### Page
A single documentation page with:
- **src**: `collection:page_name` format (unique identifier)
- **label**: Display name in sidebar
- **title**: Display name on page (extracted from markdown if empty)
- **description**: SEO metadata
- **draft**: Hidden from navigation if true
- **category_id**: Links page to a category (0 = root level)
### Category (Section)
Groups related pages together in the navigation sidebar. Categories can be nested and are automatically collapsed/expandable.
```heroscript
!!site.page_category
path: "Getting Started"
collapsible: true
collapsed: false
!!site.page src: "tech:intro"
category_id: 1 // Links to the category above
```
### 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
### 1. Site Configuration (Required)
```heroscript
!!site.config
name: "my_site"
title: "My Documentation Site"
description: "Comprehensive documentation"
tagline: "Your awesome documentation"
favicon: "img/favicon.png"
image: "img/site-image.png"
copyright: "© 2024 My Organization"
url: "https://docs.example.com"
base_url: "/"
url_home: "/docs"
```
**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 Documentation"
logo_alt: "Site Logo"
logo_src: "img/logo.svg"
logo_src_dark: "img/logo-dark.svg"
!!site.navbar_item
label: "Documentation"
to: "intro"
position: "left"
!!site.navbar_item
label: "API Reference"
to: "docs/api"
position: "left"
!!site.navbar_item
label: "GitHub"
href: "https://github.com/myorg/myrepo"
position: "right"
```
**Parameters:**
- `label` - Display text (required)
- `to` - Internal link
- `href` - External URL
- `position` - "left" or "right" in navbar
### 4. Footer Configuration
```heroscript
!!site.footer
style: "dark"
!!site.footer_item
title: "Docs"
label: "Introduction"
to: "intro"
!!site.footer_item
title: "Docs"
label: "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"
```
### 5. Announcement Bar (Optional)
Multiple announcements are supported and stored in an array:
```heroscript
!!site.announcement
content: "🎉 Version 2.0 is now available!"
background_color: "#20232a"
text_color: "#fff"
is_closeable: true
```
**Note:** Each `!!site.announcement` block adds to the `announcements[]` array. Only the first is typically displayed, but all are stored.
### 6. Pages and Categories
#### Simple: Pages Without Categories
```heroscript
!!site.page src: "guides:introduction"
label: "Getting Started"
title: "Getting Started"
description: "Introduction to the platform"
!!site.page src: "installation"
label: "Installation"
title: "Installation"
```
#### Advanced: Pages With Categories
```heroscript
!!site.page_category
path: "Getting Started"
collapsible: true
collapsed: false
!!site.page src: "guides:introduction"
label: "Introduction"
title: "Introduction"
description: "Learn the basics"
!!site.page src: "installation"
label: "Installation"
title: "Installation"
!!site.page src: "configuration"
label: "Configuration"
title: "Configuration"
!!site.page_category
path: "Advanced Topics"
collapsible: true
collapsed: false
!!site.page src: "advanced:performance"
label: "Performance Tuning"
title: "Performance Tuning"
!!site.page src: "scaling"
label: "Scaling Guide"
title: "Scaling Guide"
```
**Page Parameters:**
- `src` - Source as `collection:page_name` (first page) or just `page_name` (reuse collection)
- `label` - Display label in sidebar (required)
- `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)
- `hide` - Hide page completely (default: false)
**Category Parameters:**
- `path` - Category path/label (required)
- `collapsible` - Allow collapsing (default: true)
- `collapsed` - Initially collapsed (default: false)
### 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
```
### 8. Publishing Destinations
```heroscript
!!site.publish
path: "/var/www/html/docs"
ssh_name: "production"
!!site.publish_dev
path: "/tmp/docs-preview"
```
---
## Common Patterns
### Pattern 1: Multi-Section Technical Documentation
```heroscript
!!site.config
name: "tech_docs"
title: "Technical Documentation"
!!site.page_category
path: "Getting Started"
collapsible: true
collapsed: false
!!site.page src: "docs:intro"
label: "Introduction"
title: "Introduction"
!!site.page src: "installation"
label: "Installation"
title: "Installation"
!!site.page_category
path: "Core Concepts"
collapsible: true
collapsed: false
!!site.page src: "concepts:architecture"
label: "Architecture"
title: "Architecture"
!!site.page src: "components"
label: "Components"
title: "Components"
!!site.page_category
path: "API Reference"
collapsible: true
collapsed: false
!!site.page src: "api:rest"
label: "REST API"
title: "REST API"
!!site.page src: "graphql"
label: "GraphQL"
title: "GraphQL"
```
### Pattern 2: Simple Blog/Knowledge Base
```heroscript
!!site.config
name: "blog"
title: "Knowledge Base"
!!site.page src: "articles:first_post"
label: "Welcome to Our Blog"
title: "Welcome to Our Blog"
!!site.page src: "second_post"
label: "Understanding the Basics"
title: "Understanding the Basics"
!!site.page src: "third_post"
label: "Advanced Techniques"
title: "Advanced Techniques"
```
### Pattern 3: Project with External Imports
```heroscript
!!site.config
name: "project_docs"
title: "Project Documentation"
!!site.import
url: "https://github.com/org/shared-docs"
dest: "shared"
visible: true
!!site.page_category
path: "Product Guide"
collapsible: true
collapsed: false
!!site.page src: "docs:overview"
label: "Overview"
title: "Overview"
!!site.page src: "features"
label: "Features"
title: "Features"
!!site.page_category
path: "Shared Resources"
collapsible: true
collapsed: false
!!site.page src: "shared:common"
label: "Common Patterns"
title: "Common Patterns"
```
---
## File Organization
### Recommended Ebook Structure
The modern ebook structure uses `.hero` files for configuration and `.heroscript` files for page definitions:
```
my_ebook/
├── scan.hero # !!doctree.scan - collection scanning
├── config.hero # !!site.config - site configuration
├── menus.hero # !!site.navbar and !!site.footer
├── include.hero # !!docusaurus.define and !!doctree.export
├── 1_intro.heroscript # Page definitions (categories + pages)
├── 2_concepts.heroscript # More page definitions
└── 3_advanced.heroscript # Additional pages
```
### File Types
- **`.hero` files**: Configuration files processed in any order
- **`.heroscript` files**: Page definition files processed alphabetically
Use numeric prefixes on `.heroscript` files to control page/category ordering in the sidebar.
### Example scan.hero
```heroscript
!!doctree.scan path:"../../collections/my_collection"
```
### Example include.hero
```heroscript
// Include shared configuration (optional)
!!play.include path:'../../heroscriptall' replace:'SITENAME:my_ebook'
// Or define directly
!!docusaurus.define name:'my_ebook'
!!doctree.export include:true
```
### Running an Ebook
```bash
# Development server
hero docs -d -p /path/to/my_ebook
# Build for production
hero docs -p /path/to/my_ebook
```

View File

@@ -0,0 +1,746 @@
module meta
import incubaid.herolib.core.playbook
import incubaid.herolib.ui.console
// Comprehensive HeroScript for testing multi-level navigation depths
const test_heroscript_nav_depth = '
!!site.config
name: "nav_depth_test"
title: "Navigation Depth Test Site"
description: "Testing multi-level nested navigation"
tagline: "Deep navigation structures"
!!site.navbar
title: "Nav Depth Test"
!!site.navbar_item
label: "Home"
to: "/"
position: "left"
// ============================================================
// LEVEL 1: Simple top-level category
// ============================================================
!!site.page_category
path: "Why"
collapsible: true
collapsed: false
//COLLECTION WILL BE REPEATED, HAS NO INFLUENCE ON NAVIGATION LEVELS
!!site.page src: "mycollection:intro"
label: "Why Choose Us"
title: "Why Choose Us"
description: "Reasons to use this platform"
!!site.page src: "benefits"
label: "Key Benefits"
title: "Key Benefits"
description: "Main benefits overview"
// ============================================================
// LEVEL 1: Simple top-level category
// ============================================================
!!site.page_category
path: "Tutorials"
collapsible: true
collapsed: false
!!site.page src: "getting_started"
label: "Getting Started"
title: "Getting Started"
description: "Basic tutorial to get started"
!!site.page src: "first_steps"
label: "First Steps"
title: "First Steps"
description: "Your first steps with the platform"
// ============================================================
// LEVEL 3: Three-level nested category (Tutorials > Operations > Urgent)
// ============================================================
!!site.page_category
path: "Tutorials/Operations/Urgent"
collapsible: true
collapsed: false
!!site.page src: "emergency_restart"
label: "Emergency Restart"
title: "Emergency Restart"
description: "How to emergency restart the system"
!!site.page src: "critical_fixes"
label: "Critical Fixes"
title: "Critical Fixes"
description: "Apply critical fixes immediately"
!!site.page src: "incident_response"
label: "Incident Response"
title: "Incident Response"
description: "Handle incidents in real-time"
// ============================================================
// LEVEL 2: Two-level nested category (Tutorials > Operations)
// ============================================================
!!site.page_category
path: "Tutorials/Operations"
collapsible: true
collapsed: false
!!site.page src: "daily_checks"
label: "Daily Checks"
title: "Daily Checks"
description: "Daily maintenance checklist"
!!site.page src: "monitoring"
label: "Monitoring"
title: "Monitoring"
description: "System monitoring procedures"
!!site.page src: "backups"
label: "Backups"
title: "Backups"
description: "Backup and restore procedures"
// ============================================================
// LEVEL 1: One-to-two level (Tutorials)
// ============================================================
// Note: This creates a sibling at the Tutorials level (not nested deeper)
!!site.page src: "advanced_concepts"
label: "Advanced Concepts"
title: "Advanced Concepts"
description: "Deep dive into advanced concepts"
!!site.page src: "troubleshooting"
label: "Troubleshooting"
title: "Troubleshooting"
description: "Troubleshooting guide"
// ============================================================
// LEVEL 2: Two-level nested category (Why > FAQ)
// ============================================================
!!site.page_category
path: "Why/FAQ"
collapsible: true
collapsed: false
!!site.page src: "general"
label: "General Questions"
title: "General Questions"
description: "Frequently asked questions"
!!site.page src: "pricing_questions"
label: "Pricing"
title: "Pricing Questions"
description: "Questions about pricing"
!!site.page src: "technical_faq"
label: "Technical FAQ"
title: "Technical FAQ"
description: "Technical frequently asked questions"
!!site.page src: "support_faq"
label: "Support"
title: "Support FAQ"
description: "Support-related FAQ"
// ============================================================
// LEVEL 4: Four-level nested category (Tutorials > Operations > Database > Optimization)
// ============================================================
!!site.page_category
path: "Tutorials/Operations/Database/Optimization"
collapsible: true
collapsed: false
!!site.page src: "query_optimization"
label: "Query Optimization"
title: "Query Optimization"
description: "Optimize your database queries"
!!site.page src: "indexing_strategy"
label: "Indexing Strategy"
title: "Indexing Strategy"
description: "Effective indexing strategies"
!!site.page_category
path: "Tutorials/Operations/Database"
collapsible: true
collapsed: false
!!site.page src: "configuration"
label: "Configuration"
title: "Database Configuration"
description: "Configure your database"
!!site.page src: "replication"
label: "Replication"
title: "Database Replication"
description: "Set up database replication"
'
fn check(s2 Site) {
mut s := Site{
doctree_path: ''
config: SiteConfig{
name: 'nav_depth_test'
title: 'Navigation Depth Test Site'
description: 'Testing multi-level nested navigation'
tagline: 'Deep navigation structures'
favicon: 'img/favicon.png'
image: 'img/tf_graph.png'
copyright: '© 2025 Example Organization'
footer: Footer{
style: 'dark'
links: []
}
menu: Menu{
title: 'Nav Depth Test'
items: [
MenuItem{
href: ''
to: '/'
label: 'Home'
position: 'left'
},
]
logo_alt: ''
logo_src: ''
logo_src_dark: ''
}
url: ''
base_url: '/'
url_home: ''
meta_title: ''
meta_image: ''
}
pages: [
Page{
src: 'mycollection:intro'
label: 'Why Choose Us'
title: 'Why Choose Us'
description: 'Reasons to use this platform'
draft: false
hide_title: false
hide: false
category_id: 0
},
Page{
src: 'mycollection:benefits'
label: 'Key Benefits'
title: 'Key Benefits'
description: 'Main benefits overview'
draft: false
hide_title: false
hide: false
category_id: 0
},
Page{
src: 'mycollection:getting_started'
label: 'Getting Started'
title: 'Getting Started'
description: 'Basic tutorial to get started'
draft: false
hide_title: false
hide: false
category_id: 1
},
Page{
src: 'mycollection:first_steps'
label: 'First Steps'
title: 'First Steps'
description: 'Your first steps with the platform'
draft: false
hide_title: false
hide: false
category_id: 1
},
Page{
src: 'mycollection:emergency_restart'
label: 'Emergency Restart'
title: 'Emergency Restart'
description: 'How to emergency restart the system'
draft: false
hide_title: false
hide: false
category_id: 2
},
Page{
src: 'mycollection:critical_fixes'
label: 'Critical Fixes'
title: 'Critical Fixes'
description: 'Apply critical fixes immediately'
draft: false
hide_title: false
hide: false
category_id: 2
},
Page{
src: 'mycollection:incident_response'
label: 'Incident Response'
title: 'Incident Response'
description: 'Handle incidents in real-time'
draft: false
hide_title: false
hide: false
category_id: 2
},
Page{
src: 'mycollection:daily_checks'
label: 'Daily Checks'
title: 'Daily Checks'
description: 'Daily maintenance checklist'
draft: false
hide_title: false
hide: false
category_id: 3
},
Page{
src: 'mycollection:monitoring'
label: 'Monitoring'
title: 'Monitoring'
description: 'System monitoring procedures'
draft: false
hide_title: false
hide: false
category_id: 3
},
Page{
src: 'mycollection:backups'
label: 'Backups'
title: 'Backups'
description: 'Backup and restore procedures'
draft: false
hide_title: false
hide: false
category_id: 3
},
Page{
src: 'mycollection:advanced_concepts'
label: 'Advanced Concepts'
title: 'Advanced Concepts'
description: 'Deep dive into advanced concepts'
draft: false
hide_title: false
hide: false
category_id: 3
},
Page{
src: 'mycollection:troubleshooting'
label: 'Troubleshooting'
title: 'Troubleshooting'
description: 'Troubleshooting guide'
draft: false
hide_title: false
hide: false
category_id: 3
},
Page{
src: 'mycollection:general'
label: 'General Questions'
title: 'General Questions'
description: 'Frequently asked questions'
draft: false
hide_title: false
hide: false
category_id: 4
},
Page{
src: 'mycollection:pricing_questions'
label: 'Pricing'
title: 'Pricing Questions'
description: 'Questions about pricing'
draft: false
hide_title: false
hide: false
category_id: 4
},
Page{
src: 'mycollection:technical_faq'
label: 'Technical FAQ'
title: 'Technical FAQ'
description: 'Technical frequently asked questions'
draft: false
hide_title: false
hide: false
category_id: 4
},
Page{
src: 'mycollection:support_faq'
label: 'Support'
title: 'Support FAQ'
description: 'Support-related FAQ'
draft: false
hide_title: false
hide: false
category_id: 4
},
Page{
src: 'mycollection:query_optimization'
label: 'Query Optimization'
title: 'Query Optimization'
description: 'Optimize your database queries'
draft: false
hide_title: false
hide: false
category_id: 5
},
Page{
src: 'mycollection:indexing_strategy'
label: 'Indexing Strategy'
title: 'Indexing Strategy'
description: 'Effective indexing strategies'
draft: false
hide_title: false
hide: false
category_id: 5
},
Page{
src: 'mycollection:configuration'
label: 'Configuration'
title: 'Database Configuration'
description: 'Configure your database'
draft: false
hide_title: false
hide: false
category_id: 6
},
Page{
src: 'mycollection:replication'
label: 'Replication'
title: 'Database Replication'
description: 'Set up database replication'
draft: false
hide_title: false
hide: false
category_id: 6
},
]
links: []
categories: [
Category{
path: 'Why'
collapsible: true
collapsed: false
},
Category{
path: 'Tutorials'
collapsible: true
collapsed: false
},
Category{
path: 'Tutorials/Operations/Urgent'
collapsible: true
collapsed: false
},
Category{
path: 'Tutorials/Operations'
collapsible: true
collapsed: false
},
Category{
path: 'Why/FAQ'
collapsible: true
collapsed: false
},
Category{
path: 'Tutorials/Operations/Database/Optimization'
collapsible: true
collapsed: false
},
Category{
path: 'Tutorials/Operations/Database'
collapsible: true
collapsed: false
},
]
announcements: []
imports: []
build_dest: []
build_dest_dev: []
}
assert s == s2
}
pub fn test_navigation_depth() ! {
console.print_header('🧭 Navigation Depth Multi-Level Test')
console.lf()
// ========================================================
// SETUP: Create and process playbook
// ========================================================
console.print_item('Creating playbook from HeroScript')
mut plbook := playbook.new(text: test_heroscript_nav_depth)!
console.print_green(' Playbook created')
console.lf()
console.print_item('Processing site configuration')
play(mut plbook)!
console.print_green(' Site processed')
console.lf()
console.print_item('Retrieving configured site')
mut nav_site := get(name: 'nav_depth_test')!
console.print_green(' Site retrieved')
console.lf()
check(nav_site)
// ========================================================
// TEST 1: Validate Categories Structure
// ========================================================
console.print_header('TEST 1: Validate Categories Structure')
console.print_item('Total categories: ${nav_site.categories.len}')
for i, category in nav_site.categories {
depth := calculate_category_depth(category.path)
console.print_debug(' [${i}] Path: "${category.path}" (Depth: ${depth})')
}
// Assertions for category structure
mut all_paths := nav_site.categories.map(it.path)
assert all_paths.contains('Why'), 'Missing "Why" category'
console.print_green(' Level 1: "Why" found')
assert all_paths.contains('Tutorials'), 'Missing "Tutorials" category'
console.print_green(' Level 1: "Tutorials" found')
assert all_paths.contains('Why/FAQ'), 'Missing "Why/FAQ" category'
console.print_green(' Level 2: "Why/FAQ" found')
assert all_paths.contains('Tutorials/Operations'), 'Missing "Tutorials/Operations" category'
console.print_green(' Level 2: "Tutorials/Operations" found')
assert all_paths.contains('Tutorials/Operations/Urgent'), 'Missing "Tutorials/Operations/Urgent" category'
console.print_green(' Level 3: "Tutorials/Operations/Urgent" found')
assert all_paths.contains('Tutorials/Operations/Database'), 'Missing "Tutorials/Operations/Database" category'
console.print_green(' Level 3: "Tutorials/Operations/Database" found')
assert all_paths.contains('Tutorials/Operations/Database/Optimization'), 'Missing "Tutorials/Operations/Database/Optimization" category'
console.print_green(' Level 4: "Tutorials/Operations/Database/Optimization" found')
console.lf()
// ========================================================
// TEST 2: Validate Pages Distribution
// ========================================================
console.print_header('TEST 2: Validate Pages Distribution')
console.print_item('Total pages: ${nav_site.pages.len}')
mut pages_by_category := map[int]int{}
for page in nav_site.pages {
cat_id := page.category_id
if cat_id !in pages_by_category {
pages_by_category[cat_id] = 0
}
pages_by_category[cat_id]++
}
console.print_debug('Pages per category:')
for cat_id, count in pages_by_category {
mut cat_name := 'Root (Uncategorized)'
// category_id is 1-based, index is 0-based
if cat_id > 0 && cat_id <= nav_site.categories.len {
cat_name = nav_site.categories[cat_id - 1].path
}
console.print_debug(' Category ${cat_id} [${cat_name}]: ${count} pages')
}
// Validate we have pages in multiple categories
assert pages_by_category.len >= 5, 'Should have pages in at least 5 categories'
console.print_green(' Pages distributed across multiple category levels')
console.lf()
// ========================================================
// TEST 3: Validate Navigation Structure (Sidebar)
// ========================================================
console.print_header('TEST 3: Navigation Structure Analysis')
mut sidebar := nav_site.sidebar()!
console.print_item('Sidebar root items: ${sidebar.my_sidebar.len}')
console.lf()
// Analyze structure
mut stats := analyze_sidebar_structure(sidebar.my_sidebar)
console.print_debug('Structure Analysis:')
console.print_debug(' Total root items: ${stats.root_items}')
console.print_debug(' Categories: ${stats.categories}')
console.print_debug(' Pages: ${stats.pages}')
console.print_debug(' Links: ${stats.links}')
console.print_debug(' Max nesting depth: ${stats.max_depth}')
println(nav_site.sidebar_str())
println(sidebar)
assert stats.categories >= 6, 'Should have at least 6 categories'
console.print_green(' Multiple category levels present')
assert stats.max_depth >= 4, 'Should have nesting depth of at least 4 levels (0-indexed root, so 3+1)'
console.print_green(' Deep nesting verified (depth: ${stats.max_depth})')
console.lf()
// ========================================================
// TEST 4: Validate Specific Path Hierarchies
// ========================================================
console.print_header('TEST 4: Path Hierarchy Validation')
// Find categories and check parent-child relationships
let_test_hierarchy(nav_site.categories)
console.lf()
// ========================================================
// TEST 5: Print Sidebar Structure
// ========================================================
console.print_header('📑 COMPLETE SIDEBAR STRUCTURE')
console.lf()
println(nav_site.sidebar_str())
console.print_header(' All Navigation Depth Tests Passed!')
}
// ============================================================
// Helper Structures
// ============================================================
struct SidebarStats {
pub mut:
root_items int
categories int
pages int
links int
max_depth int // Max nesting depth including root as depth 1
}
// ============================================================
// Helper Functions
// ============================================================
fn calculate_category_depth(path string) int {
if path.len == 0 {
return 0 // Or handle as an error/special case
}
// Count slashes + 1 for the depth
// "Why" -> 1
// "Why/FAQ" -> 2
return path.split('/').len
}
fn analyze_sidebar_structure(items []NavItem) SidebarStats {
mut stats := SidebarStats{}
stats.root_items = items.len
for item in items {
// Calculate depth for the current item and update max_depth
// The calculate_nav_item_depth function correctly handles recursion for NavCat
// and returns current_depth for leaf nodes (NavDoc, NavLink).
// We start at depth 1 for root-level items.
depth := calculate_nav_item_depth(item, 1)
if depth > stats.max_depth {
stats.max_depth = depth
}
// Now categorize and count based on item type
if item is NavCat {
stats.categories++
// Recursively count pages and categories within this NavCat
stats.pages += count_nested_pages_in_navcat(item)
stats.categories += count_nested_categories_in_navcat(item)
} else if item is NavDoc {
stats.pages++
} else if item is NavLink {
stats.links++
}
}
return stats
}
fn calculate_nav_item_depth(item NavItem, current_depth int) int {
mut max_depth_in_branch := current_depth
if item is NavCat {
for sub_item in item.items {
depth := calculate_nav_item_depth(sub_item, current_depth + 1)
if depth > max_depth_in_branch {
max_depth_in_branch = depth
}
}
}
// NavDoc and NavLink are leaf nodes, their depth is current_depth
return max_depth_in_branch
}
fn count_nested_pages_in_navcat(cat NavCat) int {
mut count := 0
for item in cat.items {
if item is NavDoc {
count++
} else if item is NavCat {
count += count_nested_pages_in_navcat(item)
}
}
return count
}
fn count_nested_categories_in_navcat(cat NavCat) int {
mut count := 0
for item in cat.items {
if item is NavCat {
count++
count += count_nested_categories_in_navcat(item)
}
}
return count
}
fn let_test_hierarchy(categories []Category) {
console.print_item('Validating path hierarchies:')
// Group by depth
mut by_depth := map[int][]string{}
for category in categories {
depth := calculate_category_depth(category.path)
if depth !in by_depth {
by_depth[depth] = []string{}
}
by_depth[depth] << category.path
}
// Print organized by depth
// Assuming max depth is 4 based on the script
for depth := 1; depth <= 4; depth++ {
if depth in by_depth {
console.print_debug(' Depth ${depth}:')
for path in by_depth[depth] {
console.print_debug(' ${path}')
}
}
}
// Validate specific hierarchies
mut all_paths := categories.map(it.path)
// Hierarchy: Why -> Why/FAQ
if all_paths.contains('Why') && all_paths.contains('Why/FAQ') {
console.print_green(' Hierarchy verified: Why Why/FAQ')
}
// Hierarchy: Tutorials -> Tutorials/Operations -> Tutorials/Operations/Urgent
if all_paths.contains('Tutorials') && all_paths.contains('Tutorials/Operations')
&& all_paths.contains('Tutorials/Operations/Urgent') {
console.print_green(' Hierarchy verified: Tutorials Operations Urgent')
}
// Hierarchy: Tutorials/Operations -> Tutorials/Operations/Database -> Tutorials/Operations/Database/Optimization
if all_paths.contains('Tutorials/Operations/Database')
&& all_paths.contains('Tutorials/Operations/Database/Optimization') {
console.print_green(' Hierarchy verified: Operations Database Optimization')
}
}

View File

@@ -0,0 +1,500 @@
module meta
import incubaid.herolib.core.playbook
import incubaid.herolib.ui.console
// 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
content: "🎉 Version 2.0 is now available! Check out the new features."
background_color: "#1a472a"
text_color: "#fff"
is_closeable: true
!!site.page_category
path: "Getting Started"
collapsible: true
collapsed: false
!!site.page src: "guides:introduction"
label: "Introduction to Test Docs"
title: "Introduction to Test Docs"
description: "Learn what this project is about"
!!site.page src: "installation"
label: "Installation Guide"
title: "Installation Guide"
description: "How to install and setup"
!!site.page src: "quick_start"
label: "Quick Start"
title: "Quick Start"
description: "5 minute quick start guide"
!!site.page_category
path: "Core Concepts"
collapsible: true
collapsed: false
!!site.page src: "concepts:architecture"
label: "Architecture Overview"
title: "Architecture Overview"
description: "Understanding the system architecture"
!!site.page src: "components"
label: "Key Components"
title: "Key Components"
description: "Learn about the main components"
!!site.page src: "workflow"
label: "Typical Workflow"
title: "Typical Workflow"
description: "How to use the system"
!!site.page_category
path: "API Reference"
collapsible: true
collapsed: false
!!site.page src: "api:rest"
label: "REST API"
title: "REST API"
description: "Complete REST API reference"
!!site.page src: "graphql"
label: "GraphQL API"
title: "GraphQL API"
description: "GraphQL API documentation"
!!site.page src: "webhooks"
label: "Webhooks"
title: "Webhooks"
description: "Webhook configuration and examples"
!!site.page_category
path: "Advanced Topics"
collapsible: true
collapsed: false
!!site.page src: "advanced:performance"
label: "Performance Optimization"
title: "Performance Optimization"
description: "Tips for optimal performance"
!!site.page src: "scaling"
label: "Scaling Guide"
title: "Scaling Guide"
!!site.page src: "security"
label: "Security Best Practices"
title: "Security Best Practices"
description: "Security considerations and best practices"
!!site.page src: "troubleshooting"
label: "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 - Part 1')
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()')
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 := 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.config
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, 'Advanced test documentation')
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 test_site.build_dest.len == 1, 'Should have 1 production build destination'
console.print_green(' Production build dest: ${test_site.build_dest[0].path}')
assert test_site.build_dest_dev.len == 1, 'Should have 1 dev build destination'
console.print_green(' Dev build dest: ${test_site.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')
assert test_site.announcements.len == 1, 'Should have 1 announcement, got ${test_site.announcements.len}'
console.print_green(' Announcement bar present')
mut announcement := test_site.announcements[0]
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()
}
fn test_site2() ! {
console.print_header('Site Module Comprehensive Test - Part 2')
console.lf()
reset()
mut plbook := playbook.new(text: test_heroscript)!
play(mut plbook)!
mut test_site := get(name: 'test_docs')!
// ========================================================
// TEST 8: Validate Pages
// ========================================================
console.print_header('Validating Pages')
println(test_site)
assert test_site.pages.len == 13, 'Should have 13 pages, got ${test_site.pages.len}'
console.print_green(' Total pages: ${test_site.pages.len}')
// List and validate pages
for i, page in test_site.pages {
console.print_debug(' Page ${i}: "${page.src}" - "${page.label}"')
}
// Validate specific pages exist by src
mut src_exists := false
for page in test_site.pages {
if page.src == 'guides:introduction' {
src_exists = true
break
}
}
assert src_exists, 'guides:introduction page not found'
console.print_green(' Found guides:introduction')
src_exists = false
for page in test_site.pages {
if page.src == 'concepts:architecture' {
src_exists = true
break
}
}
assert src_exists, 'concepts:architecture page not found'
console.print_green(' Found concepts:architecture')
src_exists = false
for page in test_site.pages {
if page.src == 'api:rest' {
src_exists = true
break
}
}
assert src_exists, 'api:rest page not found'
console.print_green(' Found api:rest')
console.lf()
// ========================================================
// TEST 9: Validate Categories
// ========================================================
console.print_header('Validating Categories')
assert test_site.categories.len == 4, 'Should have 4 categories, got ${test_site.categories.len}'
console.print_green(' Total categories: ${test_site.categories.len}')
for i, category in test_site.categories {
console.print_debug(' Category ${i}: "${category.path}" (collapsible: ${category.collapsible}, collapsed: ${category.collapsed})')
}
// Validate category paths
mut category_paths := test_site.categories.map(it.path)
assert category_paths.contains('Getting Started'), 'Missing "Getting Started" category'
console.print_green(' Found "Getting Started" category')
assert category_paths.contains('Core Concepts'), 'Missing "Core Concepts" category'
console.print_green(' Found "Core Concepts" category')
assert category_paths.contains('API Reference'), 'Missing "API Reference" category'
console.print_green(' Found "API Reference" category')
assert category_paths.contains('Advanced Topics'), 'Missing "Advanced Topics" category'
console.print_green(' Found "Advanced Topics" category')
console.lf()
// ========================================================
// TEST 10: Validate Navigation Structure (Sidebar)
// ========================================================
console.print_header('Validating Navigation Structure (Sidebar)')
mut sidebar := test_site.sidebar()!
console.print_item('Sidebar has ${sidebar.my_sidebar.len} root items')
assert sidebar.my_sidebar.len > 0, 'Sidebar should not be empty'
console.print_green(' Sidebar generated successfully')
// Count categories in sidebar
mut sidebar_category_count := 0
mut sidebar_doc_count := 0
for item in sidebar.my_sidebar {
match item {
NavCat {
sidebar_category_count++
}
NavDoc {
sidebar_doc_count++
}
else {
// Other types
}
}
}
console.print_item('Sidebar contains: ${sidebar_category_count} categories, ${sidebar_doc_count} docs')
// Detailed sidebar validation
for i, item in sidebar.my_sidebar {
match item {
NavCat {
console.print_debug(' Category ${i}: "${item.label}" (${item.items.len} items)')
for sub_item in item.items {
match sub_item {
NavDoc {
console.print_debug(' Doc: "${sub_item.label}" (${sub_item.path})')
}
else {}
}
}
}
NavDoc {
console.print_debug(' Doc ${i}: "${item.label}" (${item.path})')
}
else {}
}
}
console.lf()
// ========================================================
// TEST 11: Validate Site Factory
// ========================================================
console.print_header('Validating Site Factory')
mut all_sites := 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 exists(name: 'test_docs'), 'test_docs should exist'
console.print_green(' test_docs verified to exist')
console.lf()
// ========================================================
// TEST 12: Validate Print Output
// ========================================================
console.print_header('Site Sidebar String Output')
println(test_site.sidebar_str())
}
// ============================================================
// 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}"')
}

49
lib/web/doctree/utils.v Normal file
View File

@@ -0,0 +1,49 @@
module doctree
import incubaid.herolib.core.texttools
// returns collection and file name from "collection:file" format
// works for file, image, page keys
pub fn key_parse(key string) !(string, string) {
parts := key.split(':')
if parts.len != 2 {
return error('Invalid key format. Use "collection:file"')
}
col := texttools.name_fix(parts[0])
file := texttools.name_fix(parts[1])
return col, file
}
// ============================================================
// Helper function: normalize name while preserving .md extension handling
// ============================================================
pub fn name_fix(name string) string {
mut result := name
// Remove .md extension if present for processing
result = result.replace('/', '_')
if result.ends_with('.md') {
result = result[0..result.len - 3]
}
// Apply name fixing
result = strip_numeric_prefix(result)
return texttools.name_fix(result)
}
// Strip numeric prefix from filename (e.g., "03_linux_installation" -> "linux_installation")
// Docusaurus automatically strips these prefixes from URLs
fn strip_numeric_prefix(name string) string {
// Match pattern: digits followed by underscore at the start
if name.len > 2 && name[0].is_digit() {
for i := 1; i < name.len; i++ {
if name[i] == `_` {
// Found the underscore, return everything after it
return name[i + 1..]
}
if !name[i].is_digit() {
// Not a numeric prefix pattern, return as-is
return name
}
}
}
return name
}

View File

@@ -2,100 +2,256 @@
This module allows you to build and manage Docusaurus websites using a generic configuration layer provided by `lib/web/site`.
### Workflow
1. **Configure Your Site**: Define your site's metadata, navigation, footer, pages, and content sources using `!!site.*` actions in a `.heroscript` file. This creates a generic site definition.
2. **Define Docusaurus Build**: Use `!!docusaurus.define` to specify build paths and other factory-level settings.
3. **Link Site to Docusaurus**: Use `!!docusaurus.add` to link your generic site configuration to the Docusaurus factory. This tells HeroLib to build this specific site using Docusaurus.
4. **Run Actions**: Use actions like `!!docusaurus.dev` or `!!docusaurus.build` to generate and serve your site.
### Hero Command (Recommended)
For quick setup and development, use the hero command:
```bash
# Start development server
hero docs -d -path /path/to/your/site
hero docs -d -p /path/to/your/ebook
# Build for production
hero docs -b -path /path/to/your/site
hero docs -p /path/to/your/ebook
# Build and publish
hero docs -bp -path /path/to/your/site
hero docs -bp -p /path/to/your/ebook
```
### Example HeroScript
---
## Ebook Directory Structure
The recommended structure for an ebook follows this pattern:
```
my_ebook/
├── scan.hero # DocTree collection scanning
├── config.hero # Site configuration
├── menus.hero # Navbar and footer configuration
├── include.hero # Docusaurus define and doctree export
├── 1_intro.heroscript # Page definitions (numbered for ordering)
├── 2_concepts.heroscript # More page definitions
└── 3_advanced.heroscript # Additional pages
```
### File Descriptions
#### `scan.hero` - Scan Collections
Defines which collections to scan for content:
```heroscript
// Scan local collections
!!doctree.scan path:"../../collections/my_collection"
// Define the Docusaurus build environment, is optional
// Scan remote collections from git
!!doctree.scan git_url:"https://git.example.com/org/repo/src/branch/main/collections/docs"
```
#### `config.hero` - Site Configuration
Core site settings:
```heroscript
!!site.config
name:"my_ebook"
title:"My Awesome Ebook"
tagline:"Documentation made easy"
url:"https://docs.example.com"
url_home:"docs/"
base_url:"/my_ebook/"
favicon:"img/favicon.png"
copyright:"© 2024 My Organization"
default_collection:"my_collection"
!!site.config_meta
description:"Comprehensive documentation for my project"
title:"My Ebook - Documentation"
keywords:"docs, ebook, tutorial"
```
**Note:** When `url_home` ends with `/` (e.g., `docs/`), the first page in the sidebar automatically becomes the landing page. This means both `/docs/` and `/docs/intro` will work.
#### `menus.hero` - Navigation Configuration
```heroscript
!!site.navbar
title:"My Ebook"
!!site.navbar_item
label:"Documentation"
to:"docs/"
position:"left"
!!site.navbar_item
label:"GitHub"
href:"https://github.com/myorg/myrepo"
position:"right"
!!site.footer
style:"dark"
!!site.footer_item
title:"Docs"
label:"Getting Started"
to:"docs/"
!!site.footer_item
title:"Community"
label:"GitHub"
href:"https://github.com/myorg/myrepo"
```
#### `include.hero` - Docusaurus Setup
Links to shared configuration or defines docusaurus directly:
```heroscript
// Option 1: Include shared configuration with variable replacement
!!play.include path:'../../heroscriptall' replace:'SITENAME:my_ebook'
// Option 2: Define directly
!!docusaurus.define name:'my_ebook'
!!doctree.export include:true
```
#### Page Definition Files (`*.heroscript`)
Define pages and categories:
```heroscript
// Define a category
!!site.page_category name:'getting_started' label:"Getting Started"
// Define pages (first page specifies collection, subsequent pages reuse it)
!!site.page src:"my_collection:intro"
title:"Introduction"
!!site.page src:"installation"
title:"Installation Guide"
!!site.page src:"configuration"
title:"Configuration"
// New category
!!site.page_category name:'advanced' label:"Advanced Topics"
!!site.page src:"my_collection:performance"
title:"Performance Tuning"
```
---
## Collections
Collections are directories containing markdown files. They're scanned by DocTree and referenced in page definitions.
```
collections/
├── my_collection/
│ ├── .collection # Marker file (empty)
│ ├── intro.md
│ ├── installation.md
│ └── configuration.md
└── another_collection/
├── .collection
└── overview.md
```
Pages reference collections using `collection:page` format:
```heroscript
!!site.page src:"my_collection:intro" # Specifies collection
!!site.page src:"installation" # Reuses previous collection
!!site.page src:"another_collection:overview" # Switches collection
```
---
## Legacy Configuration
The older approach using `!!docusaurus.add` is still supported but not recommended:
```heroscript
!!docusaurus.define
path_build: "/tmp/docusaurus_build"
path_publish: "/tmp/docusaurus_publish"
reset: 1
install: 1
template_update: 1
!!docusaurus.add
sitename:"my_site"
path:"./path/to/my/site/source"
path_publish: "/tmp/docusaurus_publish" //optional
git_url:"https://git.threefold.info/tfgrid/docs_tfgrid4/src/branch/main/ebooks/tech" //optional: can use git to pull the site source
git_root:"/tmp/code" //optional: where to clone git repo
git_reset:1 //optional: reset git repo
git_pull:1 //optional: pull latest changes
play:true //required when using git_url: process heroscript files from source path
// Run the development server
!!docusaurus.dev site:"my_site" open:true watch_changes:true
path:"./path/to/site"
!!docusaurus.dev site:"my_site" open:true
```
## see sites to define a site
---
the site needs to be defined following the generic site definition, see the `lib/web/site` module for more details.
## HeroScript Actions Reference
```heroscript
### `!!doctree.scan`
//Configure the site using the generic 'site' module
!!site.config
name: "my_site"
title: "My Awesome Docs"
tagline: "The best docs ever"
url: "https://docs.example.com"
base_url: "/"
copyright: "Example Corp"
Scans a directory for markdown collections:
!!site.menu_item
label: "Homepage"
href: "https://example.com"
position: "right"
- `path` (string): Local path to scan
- `git_url` (string): Git URL to clone and scan
- `name` (string): DocTree instance name (default: `main`)
- `ignore` (list): Directory names to skip
// ... add footer, pages, etc. using !!site.* actions ...
### `!!doctree.export`
```
Exports scanned collections:
### Heroscript Actions
- `include` (bool): Include content in export (default: `true`)
- `destination` (string): Export directory
- `!!docusaurus.define`: Configures a Docusaurus factory instance.
- `name` (string): Name of the factory (default: `default`).
- `path_build` (string): Path to build the site.
- `path_publish` (string): Path to publish the final build.
- `reset` (bool): If `true`, clean the build directory before starting.
- `template_update` (bool): If `true`, update the Docusaurus template.
- `install` (bool): If `true`, run `bun install`.
### `!!docusaurus.define`
- `!!docusaurus.add`: Links a configured site to the Docusaurus factory.
- `site` (string, required): The name of the site defined in `!!site.config`.
- `path` (string, required): The local filesystem path to the site's source directory (e.g., for `static/` folder).
Configures the Docusaurus build environment:
- `!!docusaurus.dev`: Runs the Docusaurus development server.
- `site` (string, required): The name of the site to run.
- `host` (string): Host to bind to (default: `localhost`).
- `port` (int): Port to use (default: `3000`).
- `open` (bool): Open the site in a browser.
- `watch_changes` (bool): Watch for source file changes and auto-reload.
- `name` (string, required): Site name (must match `!!site.config` name)
- `path_build` (string): Build directory path
- `path_publish` (string): Publish directory path
- `reset` (bool): Clean build directory before starting
- `template_update` (bool): Update Docusaurus template
- `install` (bool): Run `bun install`
- `doctree_dir` (string): DocTree export directory
- `!!docusaurus.build`: Builds the static site for production.
- `site` (string, required): The name of the site to build.
### `!!site.config`
Core site configuration:
- `name` (string, required): Unique site identifier
- `title` (string): Site title
- `tagline` (string): Site tagline
- `url` (string): Full site URL
- `base_url` (string): Base URL path (e.g., `/my_ebook/`)
- `url_home` (string): Home page path (e.g., `docs/`)
- `default_collection` (string): Default collection for pages
- `favicon` (string): Favicon path
- `copyright` (string): Copyright notice
### `!!site.page`
Defines a documentation page:
- `src` (string, required): Source as `collection:page` or just `page` (reuses previous collection)
- `title` (string): Page title
- `description` (string): Page description
- `draft` (bool): Hide from navigation
- `hide_title` (bool): Don't show title on page
### `!!site.page_category`
Defines a sidebar category:
- `name` (string, required): Category identifier
- `label` (string): Display label
- `position` (int): Sort order
---
## See Also
- `lib/web/site` - Generic site configuration module
- `lib/data/doctree` - DocTree collection management

View File

@@ -17,9 +17,7 @@ pub mut:
reset bool
template_update bool
coderoot string
// Client configuration
use_atlas bool // true = atlas_client, false = doctreeclient
atlas_dir string // Required when use_atlas = true
doctree_dir string
}
@[params]
@@ -31,9 +29,7 @@ pub mut:
reset bool
template_update bool
coderoot string
// Client configuration
use_atlas bool // true = atlas_client, false = doctreeclient
atlas_dir string // Required when use_atlas = true
doctree_dir string
}
// return the last know config
@@ -42,8 +38,8 @@ pub fn config() !DocusaurusConfig {
docusaurus_config << DocusaurusConfigParams{}
}
mut args := docusaurus_config[0] or { panic('bug in docusaurus config') }
if args.use_atlas && args.atlas_dir == '' {
return error('use_atlas is true but atlas_dir is not set')
if args.doctree_dir == '' {
return error('doctree_dir is not set')
}
if args.path_build == '' {
args.path_build = '${os.home_dir()}/hero/var/docusaurus/build'
@@ -62,8 +58,7 @@ pub fn config() !DocusaurusConfig {
install: args.install
reset: args.reset
template_update: args.template_update
use_atlas: args.use_atlas
atlas_dir: args.atlas_dir
doctree_dir: args.doctree_dir
}
if c.install {
install(c)!

View File

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

View File

@@ -1,15 +1,16 @@
module docusaurus
import incubaid.herolib.web.site
import incubaid.herolib.web.doctree.meta
// IS THE ONE AS USED BY DOCUSAURUS
pub struct Configuration {
pub mut:
main Main
navbar Navbar
footer Footer
announcement AnnouncementBar
main Main
navbar Navbar
footer Footer
sidebar_json_txt string // will hold the sidebar.json content
announcement AnnouncementBar
}
pub struct Main {
@@ -78,18 +79,17 @@ pub mut:
pub struct AnnouncementBar {
pub mut:
id string @[json: 'id']
// id string @[json: 'id']
content string @[json: 'content']
background_color string @[json: 'backgroundColor']
text_color string @[json: 'textColor']
is_closeable bool @[json: 'isCloseable']
}
// ... (struct definitions remain the same) ...
// This function is now a pure transformer: site.SiteConfig -> docusaurus.Configuration
fn new_configuration(site_cfg site.SiteConfig) !Configuration {
// This function is a pure transformer: site.SiteConfig -> docusaurus.Configuration
fn new_configuration(mysite meta.Site) !Configuration {
// Transform site.SiteConfig to docusaurus.Configuration
mut site_cfg := mysite.config
mut nav_items := []NavbarItem{}
for item in site_cfg.menu.items {
nav_items << NavbarItem{
@@ -116,8 +116,10 @@ fn new_configuration(site_cfg site.SiteConfig) !Configuration {
}
}
sidebar_json_txt := mysite.nav.sidebar_to_json()!
cfg := Configuration{
main: Main{
main: Main{
title: site_cfg.title
tagline: site_cfg.tagline
favicon: site_cfg.favicon
@@ -147,7 +149,7 @@ fn new_configuration(site_cfg site.SiteConfig) !Configuration {
copyright: site_cfg.copyright
name: site_cfg.name
}
navbar: Navbar{
navbar: Navbar{
title: site_cfg.menu.title
logo: Logo{
alt: site_cfg.menu.logo_alt
@@ -156,18 +158,20 @@ fn new_configuration(site_cfg site.SiteConfig) !Configuration {
}
items: nav_items
}
footer: Footer{
footer: Footer{
style: site_cfg.footer.style
links: footer_links
}
announcement: AnnouncementBar{
id: site_cfg.announcement.id
announcement: AnnouncementBar{
// id: site_cfg.announcement.id
content: site_cfg.announcement.content
background_color: site_cfg.announcement.background_color
text_color: site_cfg.announcement.text_color
is_closeable: site_cfg.announcement.is_closeable
}
sidebar_json_txt: sidebar_json_txt
}
return config_fix(cfg)!
}

View File

@@ -33,6 +33,10 @@ pub fn (mut docsite DocSite) generate() ! {
mut announcement_file := pathlib.get_file(path: '${cfg_path}/announcement.json', create: true)!
announcement_file.write(json.encode_pretty(docsite.config.announcement))!
// generate sidebar.json, now new way to drive docusaurus navigation
mut sidebar_file := pathlib.get_file(path: '${cfg_path}/sidebar.json', create: true)!
sidebar_file.write(docsite.config.sidebar_json_txt)!
docsite.generate_docs()!
docsite.import()!

View File

@@ -1,438 +1,163 @@
module docusaurus
import incubaid.herolib.core.pathlib
import incubaid.herolib.data.atlas.client as atlas_client
import incubaid.herolib.web.site { Page, Section, Site }
import incubaid.herolib.web.doctree.client as doctree_client
import incubaid.herolib.data.markdown.tools as markdowntools
import incubaid.herolib.ui.console
import incubaid.herolib.web.site
import os
struct SiteGenerator {
mut:
siteconfig_name string
path pathlib.Path
client IDocClient
flat bool // if flat then won't use sitenames as subdir's
site Site
errors []string // collect errors here
// ============================================================================
// Doc Linking - Generate Docusaurus docs from DocTree collections
// ============================================================================
// get_first_doc_from_sidebar recursively finds the first doc ID in the sidebar.
// Used to determine which page should get slug: / in frontmatter when url_home ends with "/".
fn get_first_doc_from_sidebar(items []site.NavItem) string {
for item in items {
match item {
site.NavDoc {
return site.extract_page_id(item.id)
}
site.NavCat {
// Recursively search in category items
doc := get_first_doc_from_sidebar(item.items)
if doc.len > 0 {
return doc
}
}
site.NavLink {
// Skip links, we want docs
continue
}
}
}
return ''
}
// Generate docs from site configuration
// generate_docs generates markdown files from site page definitions.
// Pages are fetched from DocTree collections and written with frontmatter.
pub fn (mut docsite DocSite) generate_docs() ! {
c := config()!
// we generate the docs in the build path
docs_path := '${c.path_build.path}/docs'
// Create the appropriate client based on configuration
mut client_instance := atlas_client.new(export_dir: c.atlas_dir)!
mut client := IDocClient(client_instance)
reset_docs_dir(docs_path)!
console.print_header('Write doc: ${docs_path}')
mut gen := SiteGenerator{
path: pathlib.get_dir(path: docs_path, create: true)!
client: client
flat: true
site: docsite.website
}
mut client := doctree_client.new(export_dir: c.doctree_dir)!
mut errors := []string{}
for section in gen.site.sections {
gen.section_generate(section)!
}
for page in gen.site.pages {
gen.page_generate(page)!
}
if gen.errors.len > 0 {
println('Page List: is header collection and page name per collection.\nAvailable pages:\n${gen.client.list_markdown()!}')
return error('Errors occurred during site generation:\n${gen.errors.join('\n\n')}\n')
}
}
fn (mut generator SiteGenerator) error(msg string) ! {
console.print_stderr('Error: ${msg}')
generator.errors << msg
}
fn (mut generator SiteGenerator) page_generate(args_ Page) ! {
mut args := args_
mut content := ['---']
mut parts := args.src.split(':')
if parts.len != 2 {
generator.error("Invalid src format for page '${args.src}', expected format: collection:page_name, TODO: fix in ${args.path}, check the collection & page_name exists in the pagelist")!
return
}
collection_name := parts[0]
page_name := parts[1]
mut page_content := generator.client.get_page_content(collection_name, page_name) or {
generator.error("Couldn't find page '${collection_name}:${page_name}' is formatted as collectionname:pagename. TODO: fix in ${args.path}, check the collection & page_name exists in the pagelist. ")!
return
}
if args.description.len == 0 {
descnew := markdowntools.extract_title(page_content)
if descnew != '' {
args.description = descnew
} else {
args.description = page_name
}
}
if args.title.len == 0 {
descnew := markdowntools.extract_title(page_content)
if descnew != '' {
args.title = descnew
} else {
args.title = page_name
}
}
// Escape single quotes in YAML by doubling them
escaped_title := args.title.replace("'", "''")
content << "title: '${escaped_title}'"
if args.description.len > 0 {
escaped_description := args.description.replace("'", "''")
content << "description: '${escaped_description}'"
}
if args.slug.len > 0 {
escaped_slug := args.slug.replace("'", "''")
content << "slug: '${escaped_slug}'"
}
if args.hide_title {
content << 'hide_title: ${args.hide_title}'
}
if args.draft {
content << 'draft: ${args.draft}'
}
if args.position > 0 {
content << 'sidebar_position: ${args.position}'
}
content << '---'
mut c := content.join('\n')
if args.title_nr > 0 {
// Set the title number in the page content
page_content = markdowntools.set_titles(page_content, args.title_nr)
}
// Fix links to account for nested categories
page_content = generator.fix_links(page_content, args.path)
c += '\n${page_content}\n'
if args.path.ends_with('/') || args.path.trim_space() == '' {
// means is dir
args.path += page_name
}
if !args.path.ends_with('.md') {
args.path += '.md'
}
mut pagepath := '${generator.path.path}/${args.path}'
mut pagefile := pathlib.get_file(path: pagepath, create: true)!
pagefile.write(c)!
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
}
generator.client.copy_files(collection_name, page_name, pagefile.path_dir()) or {
generator.error("Couldn't copy files for page:'${page_name}' in collection:'${collection_name}'\nERROR:${err}")!
return
}
}
fn (mut generator SiteGenerator) section_generate(args_ Section) ! {
mut args := args_
mut c := ''
if args.description.len > 0 {
c = '{
"label": "${args.label}",
"position": ${args.position},
"link": {
"type": "generated-index",
"description": "${args.description}"
}
}'
// Determine if we need to set a docs landing page (when url_home ends with "/")
first_doc_page := if docsite.website.siteconfig.url_home.ends_with('/') {
get_first_doc_from_sidebar(docsite.website.nav.my_sidebar)
} else {
c = '{
"label": "${args.label}",
"position": ${args.position},
"link": {
"type": "generated-index"
}
}'
''
}
mut category_path := '${generator.path.path}/${args.path}/_category_.json'
mut catfile := pathlib.get_file(path: category_path, create: true)!
for _, page in docsite.website.pages {
process_page(mut client, docs_path, page, first_doc_page, mut errors)
}
catfile.write(c)!
if errors.len > 0 {
report_errors(mut client, errors)!
}
console.print_green('Successfully linked ${docsite.website.pages.len} pages to docs folder')
}
// Strip numeric prefix from filename (e.g., "03_linux_installation" -> "linux_installation")
// Docusaurus automatically strips these prefixes from URLs
fn strip_numeric_prefix(name string) string {
// Match pattern: digits followed by underscore at the start
if name.len > 2 && name[0].is_digit() {
for i := 1; i < name.len; i++ {
if name[i] == `_` {
// Found the underscore, return everything after it
return name[i + 1..]
}
if !name[i].is_digit() {
// Not a numeric prefix pattern, return as-is
return name
}
}
fn reset_docs_dir(docs_path string) ! {
if os.exists(docs_path) {
os.rmdir_all(docs_path) or {}
}
return name
os.mkdir_all(docs_path)!
}
// Calculate relative path from current directory to target directory
// current_dir: directory of the current page (e.g., '' for root, 'tokens' for tokens/, 'farming/advanced' for nested)
// target_dir: directory of the target page
// page_name: name of the target page
// Returns: relative path (e.g., './page', '../dir/page', '../../page')
fn calculate_relative_path(current_dir string, target_dir string, page_name string) string {
// Both at root level
if current_dir == '' && target_dir == '' {
return './${page_name}'
}
// Current at root, target in subdirectory
if current_dir == '' && target_dir != '' {
return './${target_dir}/${page_name}'
}
// Current in subdirectory, target at root
if current_dir != '' && target_dir == '' {
// Count directory levels to go up
levels := current_dir.split('/').len
up := '../'.repeat(levels)
return '${up}${page_name}'
}
// Both in subdirectories
current_parts := current_dir.split('/')
target_parts := target_dir.split('/')
// Find common prefix
mut common_len := 0
for i := 0; i < current_parts.len && i < target_parts.len; i++ {
if current_parts[i] == target_parts[i] {
common_len++
} else {
break
}
}
// Calculate how many levels to go up
up_levels := current_parts.len - common_len
mut path_parts := []string{}
// Add ../ for each level up
for _ in 0 .. up_levels {
path_parts << '..'
}
// Add remaining target path parts
for i in common_len .. target_parts.len {
path_parts << target_parts[i]
}
// Add page name
path_parts << page_name
return path_parts.join('/')
fn report_errors(mut client doctree_client.DocTreeClient, errors []string) ! {
available := client.list_markdown() or { 'Could not list available pages' }
console.print_stderr('Available pages:\n${available}')
return error('Errors during doc generation:\n${errors.join('\n\n')}')
}
// Fix links to account for nested categories and Docusaurus URL conventions
fn (generator SiteGenerator) fix_links(content string, current_page_path string) string {
mut result := content
// ============================================================================
// Page Processing
// ============================================================================
// Extract current page's directory path
mut current_dir := current_page_path.trim('/')
if current_dir.contains('/') && !current_dir.ends_with('/') {
last_part := current_dir.all_after_last('/')
if last_part.contains('.') {
current_dir = current_dir.all_before_last('/')
}
}
// If path is just a filename or empty, current_dir should be empty (root level)
if !current_dir.contains('/') && current_dir.contains('.') {
current_dir = ''
fn process_page(mut client doctree_client.DocTreeClient, docs_path string, page site.Page, first_doc_page string, mut errors []string) {
collection, page_name := parse_page_src(page.src) or {
errors << err.msg()
return
}
// Build maps for link fixing
mut collection_paths := map[string]string{} // collection -> directory path (for nested collections)
mut page_to_path := map[string]string{} // page_name -> full directory path in Docusaurus
mut collection_page_map := map[string]string{} // "collection:page" -> directory path
for page in generator.site.pages {
parts := page.src.split(':')
if parts.len != 2 {
continue
}
collection := parts[0]
page_name := parts[1]
// Extract directory path from page.path
mut dir_path := page.path.trim('/')
if dir_path.contains('/') && !dir_path.ends_with('/') {
last_part := dir_path.all_after_last('/')
if last_part.contains('.') || last_part == page_name {
dir_path = dir_path.all_before_last('/')
}
}
// Store collection -> directory mapping for nested collections
if dir_path != collection && dir_path != '' {
collection_paths[collection] = dir_path
}
// Store page_name -> directory path for fixing same-collection links
// Strip numeric prefix from page_name for the map key
clean_page_name := strip_numeric_prefix(page_name)
page_to_path[clean_page_name] = dir_path
// Store collection:page -> directory path for fixing collection:page format links
collection_page_map['${collection}:${clean_page_name}'] = dir_path
content := client.get_page_content(collection, page_name) or {
errors << "Page not found: '${collection}:${page_name}'"
return
}
// STEP 1: Strip numeric prefixes from all page references in links FIRST
mut lines := result.split('\n')
for i, line in lines {
if !line.contains('](') {
continue
}
// Check if this page is the docs landing page
is_landing_page := first_doc_page.len > 0 && page_name == first_doc_page
mut new_line := line
parts := line.split('](')
if parts.len < 2 {
continue
}
for j := 1; j < parts.len; j++ {
close_idx := parts[j].index(')') or { continue }
link_url := parts[j][..close_idx]
mut new_url := link_url
if link_url.contains('/') {
path_part := link_url.all_before_last('/')
file_part := link_url.all_after_last('/')
new_file := strip_numeric_prefix(file_part)
if new_file != file_part {
new_url = '${path_part}/${new_file}'
}
} else {
new_url = strip_numeric_prefix(link_url)
}
if new_url != link_url {
new_line = new_line.replace('](${link_url})', '](${new_url})')
}
}
lines[i] = new_line
}
result = lines.join('\n')
// STEP 2: Replace ../collection/ with ../actual/nested/path/ for cross-collection links
for collection, actual_path in collection_paths {
result = result.replace('../${collection}/', '../${actual_path}/')
write_page(docs_path, page_name, page, content, is_landing_page) or {
errors << "Failed to write page '${page_name}': ${err.msg()}"
return
}
// STEP 3: Fix same-collection links: ./page -> correct path based on Docusaurus structure
for page_name, target_dir in page_to_path {
old_link := './${page_name}'
if result.contains(old_link) {
new_link := calculate_relative_path(current_dir, target_dir, page_name)
result = result.replace(old_link, new_link)
}
}
// STEP 4: Convert collection:page format to proper relative paths
// Calculate relative path from current page to target page
for collection_page, target_dir in collection_page_map {
old_pattern := collection_page
if result.contains(old_pattern) {
// Extract just the page name from "collection:page"
page_name := collection_page.all_after(':')
new_link := calculate_relative_path(current_dir, target_dir, page_name)
result = result.replace(old_pattern, new_link)
}
}
// STEP 5: Fix bare page references (from atlas self-contained exports)
// Atlas exports convert cross-collection links to simple relative links like "token_system2.md"
// We need to transform these to proper relative paths based on Docusaurus structure
for page_name, target_dir in page_to_path {
// Match links in the format ](page_name) or ](page_name.md)
old_link_with_md := '](${page_name}.md)'
old_link_without_md := '](${page_name})'
if result.contains(old_link_with_md) || result.contains(old_link_without_md) {
new_link := calculate_relative_path(current_dir, target_dir, page_name)
// Replace both .md and non-.md versions
result = result.replace(old_link_with_md, '](${new_link})')
result = result.replace(old_link_without_md, '](${new_link})')
}
}
// STEP 6: Remove .md extensions from all remaining links (Docusaurus doesn't use them in URLs)
result = result.replace('.md)', ')')
// STEP 7: Fix image links to point to img/ subdirectory
// Images are copied to img/ subdirectory by copy_images(), so we need to update the links
// Transform ![alt](image.png) to ![alt](img/image.png) for local images only
mut image_lines := result.split('\n')
for i, line in image_lines {
// Find image links: ![...](...) but skip external URLs
if line.contains('![') {
mut pos := 0
for {
img_start := line.index_after('![', pos) or { break }
alt_end := line.index_after(']', img_start) or { break }
if alt_end + 1 >= line.len || line[alt_end + 1] != `(` {
pos = alt_end + 1
continue
}
url_start := alt_end + 2
url_end := line.index_after(')', url_start) or { break }
url := line[url_start..url_end]
// Skip external URLs and already-prefixed img/ paths
if url.starts_with('http://') || url.starts_with('https://')
|| url.starts_with('img/') || url.starts_with('./img/') {
pos = url_end + 1
continue
}
// Skip absolute paths and paths with ../
if url.starts_with('/') || url.starts_with('../') {
pos = url_end + 1
continue
}
// This is a local image reference - add img/ prefix
new_url := 'img/${url}'
image_lines[i] = line[0..url_start] + new_url + line[url_end..]
break
}
}
}
result = image_lines.join('\n')
return result
copy_page_assets(mut client, docs_path, collection, page_name)
console.print_item('Generated: ${page_name}.md')
}
fn parse_page_src(src string) !(string, string) {
parts := src.split(':')
if parts.len != 2 {
return error("Invalid src format '${src}' - expected 'collection:page_name'")
}
return parts[0], parts[1]
}
fn write_page(docs_path string, page_name string, page site.Page, content string, is_landing_page bool) ! {
frontmatter := build_frontmatter(page, content, is_landing_page)
final_content := frontmatter + '\n\n' + content
output_path := '${docs_path}/${page_name}.md'
mut file := pathlib.get_file(path: output_path, create: true)!
file.write(final_content)!
}
fn copy_page_assets(mut client doctree_client.DocTreeClient, docs_path string, collection string, page_name string) {
client.copy_images(collection, page_name, docs_path) or {}
client.copy_files(collection, page_name, docs_path) or {}
}
// ============================================================================
// Frontmatter Generation
// ============================================================================
fn build_frontmatter(page site.Page, content string, is_landing_page bool) string {
title := get_title(page, content)
description := get_description(page, title)
mut lines := ['---']
lines << "title: '${title}'"
lines << "description: '${description}'"
// if page.id.contains('tfhowto_tools'){
// println('extracted title: ${title}')
// println('page.src: ${lines}')
// $dbg;
// }
// Add slug: / for the docs landing page so /docs/ works directly
if is_landing_page {
lines << 'slug: /'
}
if page.draft {
lines << 'draft: true'
}
if page.hide_title {
lines << 'hide_title: true'
}
lines << '---'
return lines.join('\n')
}

View File

@@ -0,0 +1,442 @@
module docusaurus
import incubaid.herolib.core.pathlib
// import incubaid.herolib.web.doctree.client as doctree_client
// import incubaid.herolib.web.site { Page, Section, Site }
// import incubaid.herolib.data.markdown.tools as markdowntools
// import incubaid.herolib.ui.console
// struct SiteGenerator {
// mut:
// siteconfig_name string
// path pathlib.Path
// client IDocClient
// flat bool // if flat then won't use sitenames as subdir's
// site Site
// errors []string // collect errors here
// }
// // Generate docs from site configuration
// pub fn (mut docsite DocSite) generate_docs() ! {
// c := config()!
// // we generate the docs in the build path
// docs_path := '${c.path_build.path}/docs'
// // Create the appropriate client based on configuration
// mut client_instance := doctree_client.new(export_dir: c.doctree_dir)!
// mut client := IDocClient(client_instance)
// mut gen := SiteGenerator{
// path: pathlib.get_dir(path: docs_path, create: true)!
// client: client
// flat: true
// site: docsite.website
// }
// for section in gen.site.sections {
// gen.section_generate(section)!
// }
// for page in gen.site.pages {
// gen.page_generate(page)!
// }
// if gen.errors.len > 0 {
// println('Page List: is header collection and page name per collection.\nAvailable pages:\n${gen.client.list_markdown()!}')
// return error('Errors occurred during site generation:\n${gen.errors.join('\n\n')}\n')
// }
// }
// fn (mut generator SiteGenerator) error(msg string) ! {
// console.print_stderr('Error: ${msg}')
// generator.errors << msg
// }
// fn (mut generator SiteGenerator) page_generate(args_ Page) ! {
// mut args := args_
// mut content := ['---']
// mut parts := args.src.split(':')
// if parts.len != 2 {
// generator.error("Invalid src format for page '${args.src}', expected format: collection:page_name, TODO: fix in ${args.path}, check the collection & page_name exists in the pagelist")!
// return
// }
// collection_name := parts[0]
// page_name := parts[1]
// mut page_content := generator.client.get_page_content(collection_name, page_name) or {
// generator.error("Couldn't find page '${collection_name}:${page_name}' is formatted as collectionname:pagename. TODO: fix in ${args.path}, check the collection & page_name exists in the pagelist. ")!
// return
// }
// if args.description.len == 0 {
// descnew := markdowntools.extract_title(page_content)
// if descnew != '' {
// args.description = descnew
// } else {
// args.description = page_name
// }
// }
// if args.title.len == 0 {
// descnew := markdowntools.extract_title(page_content)
// if descnew != '' {
// args.title = descnew
// } else {
// args.title = page_name
// }
// }
// // Escape single quotes in YAML by doubling them
// escaped_title := args.title.replace("'", "''")
// content << "title: '${escaped_title}'"
// if args.description.len > 0 {
// escaped_description := args.description.replace("'", "''")
// content << "description: '${escaped_description}'"
// }
// if args.slug.len > 0 {
// escaped_slug := args.slug.replace("'", "''")
// content << "slug: '${escaped_slug}'"
// }
// if args.hide_title {
// content << 'hide_title: ${args.hide_title}'
// }
// if args.draft {
// content << 'draft: ${args.draft}'
// }
// if args.position > 0 {
// content << 'sidebar_position: ${args.position}'
// }
// content << '---'
// mut c := content.join('\n')
// if args.title_nr > 0 {
// // Set the title number in the page content
// page_content = markdowntools.set_titles(page_content, args.title_nr)
// }
// // Fix links to account for nested categories
// page_content = generator.fix_links(page_content, args.path)
// c += '\n${page_content}\n'
// if args.path.ends_with('/') || args.path.trim_space() == '' {
// // means is dir
// args.path += page_name
// }
// if !args.path.ends_with('.md') {
// args.path += '.md'
// }
// mut pagepath := '${generator.path.path}/${args.path}'
// mut pagefile := pathlib.get_file(path: pagepath, create: true)!
// 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
// }
// generator.client.copy_files(collection_name, page_name, pagefile.path_dir()) or {
// generator.error("Couldn't copy files for page:'${page_name}' in collection:'${collection_name}'\nERROR:${err}")!
// return
// }
// }
// fn (mut generator SiteGenerator) section_generate(args_ Section) ! {
// mut args := args_
// mut c := ''
// if args.description.len > 0 {
// c = '{
// "label": "${args.label}",
// "position": ${args.position},
// "link": {
// "type": "generated-index",
// "description": "${args.description}"
// }
// }'
// } else {
// c = '{
// "label": "${args.label}",
// "position": ${args.position},
// "link": {
// "type": "generated-index"
// }
// }'
// }
// mut category_path := '${generator.path.path}/${args.path}/_category_.json'
// mut catfile := pathlib.get_file(path: category_path, create: true)!
// catfile.write(c)!
// }
// // Strip numeric prefix from filename (e.g., "03_linux_installation" -> "linux_installation")
// // Docusaurus automatically strips these prefixes from URLs
// fn strip_numeric_prefix(name string) string {
// // Match pattern: digits followed by underscore at the start
// if name.len > 2 && name[0].is_digit() {
// for i := 1; i < name.len; i++ {
// if name[i] == `_` {
// // Found the underscore, return everything after it
// return name[i + 1..]
// }
// if !name[i].is_digit() {
// // Not a numeric prefix pattern, return as-is
// return name
// }
// }
// }
// return name
// }
// // Calculate relative path from current directory to target directory
// // current_dir: directory of the current page (e.g., '' for root, 'tokens' for tokens/, 'farming/advanced' for nested)
// // target_dir: directory of the target page
// // page_name: name of the target page
// // Returns: relative path (e.g., './page', '../dir/page', '../../page')
// fn calculate_relative_path(current_dir string, target_dir string, page_name string) string {
// // Both at root level
// if current_dir == '' && target_dir == '' {
// return './${page_name}'
// }
// // Current at root, target in subdirectory
// if current_dir == '' && target_dir != '' {
// return './${target_dir}/${page_name}'
// }
// // Current in subdirectory, target at root
// if current_dir != '' && target_dir == '' {
// // Count directory levels to go up
// levels := current_dir.split('/').len
// up := '../'.repeat(levels)
// return '${up}${page_name}'
// }
// // Both in subdirectories
// current_parts := current_dir.split('/')
// target_parts := target_dir.split('/')
// // Find common prefix
// mut common_len := 0
// for i := 0; i < current_parts.len && i < target_parts.len; i++ {
// if current_parts[i] == target_parts[i] {
// common_len++
// } else {
// break
// }
// }
// // Calculate how many levels to go up
// up_levels := current_parts.len - common_len
// mut path_parts := []string{}
// // Add ../ for each level up
// for _ in 0 .. up_levels {
// path_parts << '..'
// }
// // Add remaining target path parts
// for i in common_len .. target_parts.len {
// path_parts << target_parts[i]
// }
// // Add page name
// path_parts << page_name
// return path_parts.join('/')
// }
// // Fix links to account for nested categories and Docusaurus URL conventions
// fn (generator SiteGenerator) fix_links(content string, current_page_path string) string {
// mut result := content
// // Extract current page's directory path
// mut current_dir := current_page_path.trim('/')
// if current_dir.contains('/') && !current_dir.ends_with('/') {
// last_part := current_dir.all_after_last('/')
// if last_part.contains('.') {
// current_dir = current_dir.all_before_last('/')
// }
// }
// // If path is just a filename or empty, current_dir should be empty (root level)
// if !current_dir.contains('/') && current_dir.contains('.') {
// current_dir = ''
// }
// // Build maps for link fixing
// mut collection_paths := map[string]string{} // collection -> directory path (for nested collections)
// mut page_to_path := map[string]string{} // page_name -> full directory path in Docusaurus
// mut collection_page_map := map[string]string{} // "collection:page" -> directory path
// for page in generator.site.pages {
// parts := page.src.split(':')
// if parts.len != 2 {
// continue
// }
// collection := parts[0]
// page_name := parts[1]
// // Extract directory path from page.path
// mut dir_path := page.path.trim('/')
// if dir_path.contains('/') && !dir_path.ends_with('/') {
// last_part := dir_path.all_after_last('/')
// if last_part.contains('.') || last_part == page_name {
// dir_path = dir_path.all_before_last('/')
// }
// }
// // Store collection -> directory mapping for nested collections
// if dir_path != collection && dir_path != '' {
// collection_paths[collection] = dir_path
// }
// // Store page_name -> directory path for fixing same-collection links
// // Strip numeric prefix from page_name for the map key
// clean_page_name := strip_numeric_prefix(page_name)
// page_to_path[clean_page_name] = dir_path
// // Store collection:page -> directory path for fixing collection:page format links
// collection_page_map['${collection}:${clean_page_name}'] = dir_path
// }
// // STEP 1: Strip numeric prefixes from all page references in links FIRST
// mut lines := result.split('\n')
// for i, line in lines {
// if !line.contains('](') {
// continue
// }
// mut new_line := line
// parts := line.split('](')
// if parts.len < 2 {
// continue
// }
// for j := 1; j < parts.len; j++ {
// close_idx := parts[j].index(')') or { continue }
// link_url := parts[j][..close_idx]
// mut new_url := link_url
// if link_url.contains('/') {
// path_part := link_url.all_before_last('/')
// file_part := link_url.all_after_last('/')
// new_file := strip_numeric_prefix(file_part)
// if new_file != file_part {
// new_url = '${path_part}/${new_file}'
// }
// } else {
// new_url = strip_numeric_prefix(link_url)
// }
// if new_url != link_url {
// new_line = new_line.replace('](${link_url})', '](${new_url})')
// }
// }
// lines[i] = new_line
// }
// result = lines.join('\n')
// // STEP 2: Replace ../collection/ with ../actual/nested/path/ for cross-collection links
// for collection, actual_path in collection_paths {
// result = result.replace('../${collection}/', '../${actual_path}/')
// }
// // STEP 3: Fix same-collection links: ./page -> correct path based on Docusaurus structure
// for page_name, target_dir in page_to_path {
// old_link := './${page_name}'
// if result.contains(old_link) {
// new_link := calculate_relative_path(current_dir, target_dir, page_name)
// result = result.replace(old_link, new_link)
// }
// }
// // STEP 4: Convert collection:page format to proper relative paths
// // Calculate relative path from current page to target page
// for collection_page, target_dir in collection_page_map {
// old_pattern := collection_page
// if result.contains(old_pattern) {
// // Extract just the page name from "collection:page"
// page_name := collection_page.all_after(':')
// new_link := calculate_relative_path(current_dir, target_dir, page_name)
// result = result.replace(old_pattern, new_link)
// }
// }
// // STEP 5: Fix bare page references (from doctree self-contained exports)
// // DocTree exports convert cross-collection links to simple relative links like "token_system2.md"
// // We need to transform these to proper relative paths based on Docusaurus structure
// for page_name, target_dir in page_to_path {
// // Match links in the format ](page_name) or ](page_name.md)
// old_link_with_md := '](${page_name}.md)'
// old_link_without_md := '](${page_name})'
// if result.contains(old_link_with_md) || result.contains(old_link_without_md) {
// new_link := calculate_relative_path(current_dir, target_dir, page_name)
// // Replace both .md and non-.md versions
// result = result.replace(old_link_with_md, '](${new_link})')
// result = result.replace(old_link_without_md, '](${new_link})')
// }
// }
// // STEP 6: Remove .md extensions from all remaining links (Docusaurus doesn't use them in URLs)
// result = result.replace('.md)', ')')
// // STEP 7: Fix image links to point to img/ subdirectory
// // Images are copied to img/ subdirectory by copy_images(), so we need to update the links
// // Transform ![alt](image.png) to ![alt](img/image.png) for local images only
// mut image_lines := result.split('\n')
// for i, line in image_lines {
// // Find image links: ![...](...) but skip external URLs
// if line.contains('![') {
// mut pos := 0
// for {
// img_start := line.index_after('![', pos) or { break }
// alt_end := line.index_after(']', img_start) or { break }
// if alt_end + 1 >= line.len || line[alt_end + 1] != `(` {
// pos = alt_end + 1
// continue
// }
// url_start := alt_end + 2
// url_end := line.index_after(')', url_start) or { break }
// url := line[url_start..url_end]
// // Skip external URLs and already-prefixed img/ paths
// if url.starts_with('http://') || url.starts_with('https://')
// || url.starts_with('img/') || url.starts_with('./img/') {
// pos = url_end + 1
// continue
// }
// // Skip absolute paths and paths with ../
// if url.starts_with('/') || url.starts_with('../') {
// pos = url_end + 1
// continue
// }
// // This is a local image reference - add img/ prefix
// new_url := 'img/${url}'
// image_lines[i] = line[0..url_start] + new_url + line[url_end..]
// break
// }
// }
// }
// result = image_lines.join('\n')
// return result
// }

View File

@@ -1,40 +0,0 @@
module docusaurus
import incubaid.herolib.core.base
import incubaid.herolib.core.texttools
// // Store the Docusaurus site structure in Redis for link processing
// // This maps collection:page to their actual Docusaurus paths
// pub fn (mut docsite DocSite) store_site_structure() ! {
// mut context := base.context()!
// mut redis := context.redis()!
// // Store mapping of collection:page to docusaurus path (without .md extension)
// for page in docsite.website.pages {
// parts := page.src.split(':')
// if parts.len != 2 {
// continue
// }
// collection_name := texttools.name_fix(parts[0])
// page_name := texttools.name_fix(parts[1])
// // Calculate the docusaurus path (without .md extension for URLs)
// mut doc_path := page.path
// // Handle empty or root path
// if doc_path.trim_space() == '' || doc_path == '/' {
// doc_path = page_name
// } else if doc_path.ends_with('/') {
// doc_path += page_name
// }
// // Remove .md extension if present for URL paths
// if doc_path.ends_with('.md') {
// doc_path = doc_path[..doc_path.len - 3]
// }
// // Store in Redis with key format: collection:page.md
// key := '${collection_name}:${page_name}.md'
// redis.hset('doctree_docusaurus_paths', key, doc_path)!
// }
// }

View File

@@ -0,0 +1,89 @@
module doc
import incubaid.herolib.web.doctree.meta as site
import json
// this is the logic to create docusaurus sidebar.json from site.NavItems
struct Sidebar {
pub mut:
items []NavItem
}
type NavItem = NavDoc | NavCat | NavLink
struct SidebarItem {
typ string @[json: 'type']
id string @[omitempty]
label string
href string @[omitempty]
description string @[omitempty]
collapsible bool @[json: 'collapsible'; omitempty]
collapsed bool @[json: 'collapsed'; omitempty]
items []SidebarItem @[omitempty]
}
pub struct NavDoc {
pub mut:
id string
label string
}
pub struct NavCat {
pub mut:
label string
collapsible bool = true
collapsed bool
items []NavItem
}
pub struct NavLink {
pub mut:
label string
href string
description string
}
// ============================================================================
// JSON Serialization
// ============================================================================
pub fn sidebar_to_json(sb site.SideBar) !string {
items := sb.my_sidebar.map(to_sidebar_item(it))
return json.encode_pretty(items)
}
fn to_sidebar_item(item site.NavItem) SidebarItem {
return match item {
NavDoc { from_doc(item) }
NavLink { from_link(item) }
NavCat { from_category(item) }
}
}
fn from_doc(doc site.NavDoc) SidebarItem {
return SidebarItem{
typ: 'doc'
id: doc.id
label: doc.label
}
}
fn from_link(link site.NavLink) SidebarItem {
return SidebarItem{
typ: 'link'
label: link.label
href: link.href
description: link.description
}
}
fn from_category(cat site.NavCat) SidebarItem {
return SidebarItem{
typ: 'category'
label: cat.label
collapsible: cat.collapsible
collapsed: cat.collapsed
items: cat.items.map(to_sidebar_item(it))
}
}

View File

@@ -15,7 +15,7 @@ pub fn dsite_define(sitename string) ! {
console.print_header('Add Docusaurus Site: ${sitename}')
mut c := config()!
path_publish := '${c.path_publish.path}/${sitename}'
_ := '${c.path_publish.path}/${sitename}'
path_build_ := '${c.path_build.path}'
// Get the site object after processing, this is the website which is a generic definition of a site
@@ -26,7 +26,7 @@ pub fn dsite_define(sitename string) ! {
name: sitename
path_publish: pathlib.get_dir(path: '${path_build_}/build', create: true)!
path_build: pathlib.get_dir(path: path_build_, create: true)!
config: new_configuration(website.siteconfig)!
config: new_configuration(website)!
website: website
}

View File

@@ -0,0 +1,82 @@
# Docusaurus Link Resolution Test
This directory contains a comprehensive test for the herolib documentation linking mechanism.
## Structure
```
for_testing/
├── README.md # This file
├── collections/
│ └── test_collection/ # Markdown source files
│ ├── .collection # Collection metadata
│ ├── page1.md # Introduction
│ ├── page2.md # Basic Concepts
│ ├── page3.md # Configuration
│ ├── page4.md # Advanced Features
│ ├── page5.md # Troubleshooting
│ ├── page6.md # Best Practices
│ └── page7.md # Conclusion
└── ebooks/
└── test_site/ # Heroscript configuration
├── heroscriptall # Master configuration (entry point)
├── config.heroscript # Site configuration
├── pages.heroscript # Page definitions
└── docusaurus.heroscript # Docusaurus settings
```
## What This Tests
1. **Link Resolution** - Each page contains links using the `[text](collection:page)` format
2. **Navigation Chain** - Pages link sequentially: 1 → 2 → 3 → 4 → 5 → 6 → 7
3. **Sidebar Generation** - All 7 pages should appear in the sidebar
4. **Category Support** - Pages are organized into categories (root, basics, advanced, reference)
## Running the Test
From the herolib root directory:
```bash
# Build herolib first
./cli/compile.vsh
# Run the test site
/Users/mahmoud/hero/bin/hero docs -d -p lib/web/docusaurus/for_testing/ebooks/test_site
```
## Expected Results
When the test runs successfully:
1. ✅ All 7 pages are generated in `~/hero/var/docusaurus/build/docs/`
2. ✅ Sidebar shows all pages organized by category
3. ✅ Clicking navigation links works (page1 → page2 → ... → page7)
4. ✅ No broken links or 404 errors
5. ✅ Back-links also work (e.g., page7 → page1)
## Link Syntax Being Tested
```markdown
[Next Page](test_collection:page2)
```
This should resolve to a proper Docusaurus link when the site is built.
## Verification
After running the test:
1. Open http://localhost:3000/test/ in your browser
2. Click through all navigation links from Page 1 to Page 7
3. Verify the back-link on Page 7 returns to Page 1
4. Check the sidebar displays all pages correctly
## Troubleshooting
If links don't resolve:
1. Check that the collection is registered in the doctree
2. Verify page names match (no typos)
3. Run with debug flag (`-d`) to see detailed output
4. Check `~/hero/var/docusaurus/build/docs/` for generated files

View File

@@ -0,0 +1,3 @@
name: test_collection
description: Test collection for link resolution testing

View File

@@ -0,0 +1,21 @@
# Page 1: Introduction
Welcome to the documentation linking test. This page serves as the entry point for testing herolib's link resolution mechanism.
## Overview
This test suite consists of 7 interconnected pages that form a chain. Each page links to the next, demonstrating that the `collection:page_name` link syntax works correctly across multiple layers.
## What We're Testing
- Link resolution using `collection:page_name` format
- Proper generation of Docusaurus-compatible links
- Navigation chain integrity from page 1 through page 7
- Sidebar generation with all pages
## Navigation
Continue to the next section to learn about the basic concepts.
**Next:** [Page 2: Basic Concepts](test_collection:page2)

View File

@@ -0,0 +1,30 @@
# Page 2: Basic Concepts
This page covers the basic concepts of the documentation system.
## Link Syntax
In herolib, links between pages use the format:
```
[Link Text](collection_name:page_name)
```
For example, to link to `page3` in `test_collection`:
```markdown
[Go to Page 3](test_collection:page3)
```
## How It Works
1. The parser identifies links with the `collection:page` format
2. During site generation, these are resolved to actual file paths
3. Docusaurus receives properly formatted relative links
## Navigation
**Previous:** [Page 1: Introduction](test_collection:page1)
**Next:** [Page 3: Configuration](test_collection:page3)

View File

@@ -0,0 +1,39 @@
# Page 3: Configuration
This page explains configuration options for the documentation system.
## Site Configuration
The site is configured using heroscript files:
```heroscript
!!site.config
name:"test_site"
title:"Test Documentation"
base_url:"/test/"
url_home:"docs/page1"
```
## Page Definitions
Each page is defined using the `!!site.page` action:
```heroscript
!!site.page src:"test_collection:page1"
title:"Introduction"
```
## Important Settings
| Setting | Description |
|---------|-------------|
| `name` | Unique page identifier |
| `collection` | Source collection name |
| `title` | Display title in sidebar |
## Navigation
**Previous:** [Page 2: Basic Concepts](test_collection:page2)
**Next:** [Page 4: Advanced Features](test_collection:page4)

View File

@@ -0,0 +1,37 @@
# Page 4: Advanced Features
This page covers advanced features of the linking mechanism.
## Cross-Collection Links
You can link to pages in different collections:
```markdown
[Link to other collection](other_collection:some_page)
```
## Categories
Pages can be organized into categories:
```heroscript
!!site.page_category name:'advanced' label:"Advanced Topics"
!!site.page name:'page4' collection:'test_collection'
title:"Advanced Features"
```
## Multiple Link Formats
The system supports various link formats:
1. **Collection links:** `[text](collection:page)`
2. **Relative links:** `[text](./other_page.md)`
3. **External links:** `[text](https://example.com)`
## Navigation
**Previous:** [Page 3: Configuration](test_collection:page3)
**Next:** [Page 5: Troubleshooting](test_collection:page5)

View File

@@ -0,0 +1,43 @@
# Page 5: Troubleshooting
This page helps you troubleshoot common issues.
## Common Issues
### Broken Links
If links appear broken, check:
1. The collection name is correct
2. The page name matches the markdown filename (without `.md`)
3. The collection is properly registered in the doctree
### Page Not Found
Ensure the page is defined in your heroscript:
```heroscript
!!site.page name:'page5' collection:'test_collection'
title:"Troubleshooting"
```
## Debugging Tips
- Run with debug flag: `hero docs -d -p .`
- Check the generated `sidebar.json`
- Verify the docs output in `~/hero/var/docusaurus/build/docs/`
## Error Messages
| Error | Solution |
| ------------------------ | ---------------------------- |
| "Page not found" | Check page name spelling |
| "Collection not found" | Verify doctree configuration |
| "Link resolution failed" | Check link syntax |
## Navigation
**Previous:** [Page 4: Advanced Features](test_collection:page4)
**Next:** [Page 6: Best Practices](test_collection:page6)

View File

@@ -0,0 +1,44 @@
# Page 6: Best Practices
This page outlines best practices for documentation.
## Naming Conventions
- Use lowercase for page names: `page_name.md`
- Use underscores for multi-word names: `my_long_page_name.md`
- Keep names short but descriptive
## Link Organization
### Do This ✓
```markdown
See the [configuration guide](test_collection:page3) for details.
```
### Avoid This ✗
```markdown
Click [here](test_collection:page3) for more.
```
## Documentation Structure
A well-organized documentation site should:
1. **Start with an introduction** - Explain what the documentation covers
2. **Progress logically** - Each page builds on the previous
3. **End with reference material** - API docs, troubleshooting, etc.
## Content Guidelines
- Keep paragraphs short
- Use code blocks for examples
- Include navigation links at the bottom of each page
## Navigation
**Previous:** [Page 5: Troubleshooting](test_collection:page5)
**Next:** [Page 7: Conclusion](test_collection:page7)

View File

@@ -0,0 +1,37 @@
# Page 7: Conclusion
Congratulations! You've reached the final page of the documentation linking test.
## Summary
This test suite demonstrated:
- ✅ Link resolution using `collection:page_name` format
- ✅ Navigation chain across 7 pages
- ✅ Proper sidebar generation
- ✅ Docusaurus-compatible output
## Test Verification
If you've reached this page by clicking through all the navigation links, the linking mechanism is working correctly!
### Link Chain Verified
1. [Page 1: Introduction](test_collection:page1) → Entry point
2. [Page 2: Basic Concepts](test_collection:page2) → Link syntax
3. [Page 3: Configuration](test_collection:page3) → Site setup
4. [Page 4: Advanced Features](test_collection:page4) → Cross-collection links
5. [Page 5: Troubleshooting](test_collection:page5) → Common issues
6. [Page 6: Best Practices](test_collection:page6) → Guidelines
7. **Page 7: Conclusion** → You are here!
## What's Next
You can now use the herolib documentation system with confidence that links will resolve correctly across your entire documentation site.
## Navigation
**Previous:** [Page 6: Best Practices](test_collection:page6)
**Back to Start:** [Page 1: Introduction](test_collection:page1)

View File

@@ -0,0 +1,16 @@
!!site.config
name:"test_site"
title:"Link Resolution Test"
tagline:"Testing herolib documentation linking mechanism"
url:"http://localhost:3000"
url_home:"docs/"
base_url:"/test/"
favicon:"img/favicon.png"
copyright:"© 2024 Herolib Test"
default_collection:"test_collection"
!!site.config_meta
description:"Test suite for verifying herolib documentation link resolution across multiple pages"
title:"Link Resolution Test - Herolib"
keywords:"herolib, docusaurus, testing, links, documentation"

View File

@@ -0,0 +1,4 @@
!!docusaurus.define name:'test_site'
!!doctree.export include:true

View File

@@ -0,0 +1,33 @@
// Navbar configuration
!!site.navbar
title:"Link Test"
!!site.navbar_item
label:"Documentation"
to:"docs/"
position:"left"
!!site.navbar_item
label:"GitHub"
href:"https://github.com/incubaid/herolib"
position:"right"
// Footer configuration
!!site.footer
style:"dark"
!!site.footer_item
title:"Docs"
label:"Introduction"
to:"docs/"
!!site.footer_item
title:"Docs"
label:"Configuration"
to:"docs/page3"
!!site.footer_item
title:"Community"
label:"GitHub"
href:"https://github.com/incubaid/herolib"

View File

@@ -0,0 +1,34 @@
// Page Definitions for Link Resolution Test
// Each page maps to a markdown file in the test_collection
// Root pages (no category)
!!site.page src:"test_collection:page1"
title:"Introduction"
!!site.page src:"page2"
title:"Basic Concepts"
// Basics category
!!site.page_category name:'basics' label:"Getting Started"
!!site.page src:"page3"
title:"Configuration"
!!site.page src:"page4"
title:"Advanced Features"
// Advanced category
!!site.page_category name:'advanced' label:"Advanced Topics"
!!site.page src:"page5"
title:"Troubleshooting"
!!site.page src:"page6"
title:"Best Practices"
// Reference category
!!site.page_category name:'reference' label:"Reference"
!!site.page src:"page7"
title:"Conclusion"

View File

@@ -0,0 +1,2 @@
!!doctree.scan path:"../../collections/test_collection"

View File

@@ -1,106 +0,0 @@
module docusaurus
import os
import incubaid.herolib.core.pathlib
import incubaid.herolib.core.base // For context and Redis, if test needs to manage it
import time
const test_heroscript_content = '!!site.config\n name:"Kristof"\n title:"Internet Geek"\n tagline:"Internet Geek"\n url:"https://friends.threefold.info"\n url_home:"docs/"\n base_url:"/kristof/"\n favicon:"img/favicon.png"\n image:"img/tf_graph.png"\n copyright:"Kristof"\n\n!!site.config_meta\n description:"ThreeFold is laying the foundation for a geo aware Web 4, the next generation of the Internet."\n image:"https://threefold.info/kristof/img/tf_graph.png"\n title:"ThreeFold Technology Vision"\n\n!!site.build_dest\n ssh_name:"production"\n path:"/root/hero/www/info/kristof"\n\n!!site.navbar\n title:"Kristof = Chief Executive Geek"\n logo_alt:"Kristof Logo"\n logo_src:"img/logo.svg"\n logo_src_dark:"img/logo.svg"\n\n!!site.navbar_item\n label:"ThreeFold Technology"\n href:"https://threefold.info/kristof/"\n position:"right"\n\n!!site.navbar_item\n label:"ThreeFold.io"\n href:"https://threefold.io"\n position:"right"\n\n!!site.footer\n style:"dark"\n\n!!site.footer_item\n title:"Docs"\n label:"Introduction"\n href:"/docs"\n\n!!site.footer_item\n title:"Docs"\n label:"TFGrid V4 Docs"\n href:"https://docs.threefold.io/"\n\n!!site.footer_item\n title:"Community"\n label:"Telegram"\n href:"https://t.me/threefold"\n\n!!site.footer_item\n title:"Community"\n label:"X"\n href:"https://x.com/threefold_io"\n\n!!site.footer_item\n title:"Links"\n label:"ThreeFold.io"\n href:"https://threefold.io"\n'
fn test_load_configuration_from_heroscript() ! {
// Ensure context is initialized for Redis connection if siteconfig.new() needs it implicitly
base.context()!
temp_cfg_dir := os.join_path(os.temp_dir(), 'test_docusaurus_cfg_${time.ticks()}')
os.mkdir_all(temp_cfg_dir)!
defer {
os.rmdir_all(temp_cfg_dir) or { eprintln('Error removing temp dir.') }
}
heroscript_path := os.join_path(temp_cfg_dir, 'config.heroscript')
os.write_file(heroscript_path, test_heroscript_content)!
config := load_configuration(temp_cfg_dir)!
// Main assertions
assert config.main.name == 'kristof' // texttools.name_fix converts to lowercase
assert config.main.title == 'Internet Geek'
assert config.main.tagline == 'Internet Geek'
assert config.main.url == 'https://friends.threefold.info'
assert config.main.url_home == 'docs/'
assert config.main.base_url == '/kristof/'
assert config.main.favicon == 'img/favicon.png'
assert config.main.image == 'img/tf_graph.png'
assert config.main.copyright == 'Kristof'
// Metadata assertions
assert config.main.metadata.title == 'ThreeFold Technology Vision'
assert config.main.metadata.description == 'ThreeFold is laying the foundation for a geo aware Web 4, the next generation of the Internet.'
assert config.main.metadata.image == 'https://threefold.info/kristof/img/tf_graph.png'
// Build Dest assertions
assert config.main.build_dest.len == 1
assert config.main.build_dest[0] == '/root/hero/www/info/kristof'
// Navbar assertions
assert config.navbar.title == 'Kristof = Chief Executive Geek'
assert config.navbar.logo.alt == 'Kristof Logo'
assert config.navbar.logo.src == 'img/logo.svg'
assert config.navbar.logo.src_dark == 'img/logo.svg'
assert config.navbar.items.len == 2
assert config.navbar.items[0].label == 'ThreeFold Technology'
assert config.navbar.items[0].href == 'https://threefold.info/kristof/'
assert config.navbar.items[0].position == 'right'
assert config.navbar.items[1].label == 'ThreeFold.io'
assert config.navbar.items[1].href == 'https://threefold.io'
assert config.navbar.items[1].position == 'right'
// Footer assertions
assert config.footer.style == 'dark'
assert config.footer.links.len == 3 // 'Docs', 'Community', 'Links'
// Check 'Docs' footer links
mut docs_link_found := false
for link in config.footer.links {
if link.title == 'Docs' {
docs_link_found = true
assert link.items.len == 2
assert link.items[0].label == 'Introduction'
assert link.items[0].href == '/docs'
assert link.items[1].label == 'TFGrid V4 Docs'
assert link.items[1].href == 'https://docs.threefold.io/'
break
}
}
assert docs_link_found
// Check 'Community' footer links
mut community_link_found := false
for link in config.footer.links {
if link.title == 'Community' {
community_link_found = true
assert link.items.len == 2
assert link.items[0].label == 'Telegram'
assert link.items[0].href == 'https://t.me/threefold'
assert link.items[1].label == 'X'
assert link.items[1].href == 'https://x.com/threefold_io'
break
}
}
assert community_link_found
// Check 'Links' footer links
mut links_link_found := false
for link in config.footer.links {
if link.title == 'Links' {
links_link_found = true
assert link.items.len == 1
assert link.items[0].label == 'ThreeFold.io'
assert link.items[0].href == 'https://threefold.io'
break
}
}
assert links_link_found
println('test_load_configuration_from_heroscript passed successfully.')
}

View File

@@ -1,6 +1,8 @@
module docusaurus
import incubaid.herolib.core.playbook { PlayBook }
import incubaid.herolib.web.doctree
import incubaid.herolib.ui.console
import os
pub fn play(mut plbook PlayBook) ! {
@@ -8,62 +10,78 @@ pub fn play(mut plbook PlayBook) ! {
return
}
// there should be 1 define section
mut action_define := plbook.ensure_once(filter: 'docusaurus.define')!
mut param_define := action_define.params
config_set(
path_build: param_define.get_default('path_build', '')!
path_publish: param_define.get_default('path_publish', '')!
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', '${os.home_dir()}/hero/var/atlas_export')!
use_atlas: param_define.get_default_false('use_atlas')
)!
site_name := param_define.get('name') or {
return error('In docusaurus.define, param "name" is required.')
}
dsite_define(site_name)!
action_define.done = true
mut dsite := dsite_get(site_name)!
mut dsite := process_define(mut plbook)!
dsite.generate()!
mut actions_build := plbook.find(filter: 'docusaurus.build')!
if actions_build.len > 1 {
return error('Multiple "docusaurus.build" actions found. Only one is allowed.')
}
for mut action in actions_build {
dsite.build()!
action.done = true
}
mut actions_export := plbook.find(filter: 'docusaurus.publish')!
if actions_export.len > 1 {
return error('Multiple "docusaurus.publish" actions found. Only one is allowed.')
}
for mut action in actions_export {
dsite.build_publish()!
action.done = true
}
mut actions_dev := plbook.find(filter: 'docusaurus.dev')!
if actions_dev.len > 1 {
return error('Multiple "docusaurus.dev" actions found. Only one is allowed.')
}
for mut action in actions_dev {
mut p := action.params
dsite.dev(
host: p.get_default('host', 'localhost')!
port: p.get_int_default('port', 3000)!
open: p.get_default_false('open')
)!
action.done = true
}
process_build(mut plbook, mut dsite)!
process_publish(mut plbook, mut dsite)!
process_dev(mut plbook, mut dsite)!
plbook.ensure_processed(filter: 'docusaurus.')!
}
fn process_define(mut plbook PlayBook) !&DocSite {
mut action := plbook.ensure_once(filter: 'docusaurus.define')!
p := action.params
doctree_dir := p.get_default('doctree_dir', '${os.home_dir()}/hero/var/doctree_export')!
config_set(
path_build: p.get_default('path_build', '')!
path_publish: p.get_default('path_publish', '')!
reset: p.get_default_false('reset')
template_update: p.get_default_false('template_update')
install: p.get_default_false('install')
doctree_dir: doctree_dir
)!
site_name := p.get('name') or { return error('docusaurus.define: "name" is required') }
doctree_name := p.get_default('doctree', 'main')!
export_doctree(doctree_name, doctree_dir)!
dsite_define(site_name)!
action.done = true
return dsite_get(site_name)!
}
fn process_build(mut plbook PlayBook, mut dsite DocSite) ! {
if !plbook.max_once(filter: 'docusaurus.build')! {
return
}
mut action := plbook.get(filter: 'docusaurus.build')!
dsite.build()!
action.done = true
}
fn process_publish(mut plbook PlayBook, mut dsite DocSite) ! {
if !plbook.max_once(filter: 'docusaurus.publish')! {
return
}
mut action := plbook.get(filter: 'docusaurus.publish')!
dsite.build_publish()!
action.done = true
}
fn process_dev(mut plbook PlayBook, mut dsite DocSite) ! {
if !plbook.max_once(filter: 'docusaurus.dev')! {
return
}
mut action := plbook.get(filter: 'docusaurus.dev')!
p := action.params
dsite.dev(
host: p.get_default('host', 'localhost')!
port: p.get_int_default('port', 3000)!
open: p.get_default_false('open')
)!
action.done = true
}
fn export_doctree(name string, dir string) ! {
if !doctree.exists(name) {
return
}
console.print_debug('Auto-exporting DocTree "${name}" to ${dir}')
mut a := doctree.get(name)!
a.export(destination: dir, reset: true, include: true, redis: false)!
}

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

@@ -1,48 +0,0 @@
module site
import incubaid.herolib.core.texttools
__global (
websites map[string]&Site
)
@[params]
pub struct FactoryArgs {
pub mut:
name string = 'default'
}
pub fn new(args FactoryArgs) !&Site {
name := texttools.name_fix(args.name)
websites[name] = &Site{
siteconfig: SiteConfig{
name: name
}
}
return get(name: name)!
}
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') }
return sc
}
pub fn exists(args FactoryArgs) bool {
name := texttools.name_fix(args.name)
mut sc := websites[name] or { return false }
return true
}
pub fn default() !&Site {
if websites.len == 0 {
return new(name: 'default')!
}
return get()!
}
// list returns all site names that have been created
pub fn list() []string {
return websites.keys()
}

View File

@@ -1,16 +0,0 @@
module site
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
}

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

@@ -1,225 +0,0 @@
module site
import os
import incubaid.herolib.core.playbook { PlayBook }
import incubaid.herolib.core.texttools
import time
pub fn play(mut plbook PlayBook) ! {
if !plbook.exists(filter: 'site.') {
return
}
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
mut website := new(name: name)!
mut config := &website.siteconfig
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.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
// 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')!
}
config_action.done = true // Mark the action as done
meta_action.done = true
play_import(mut plbook, mut config)!
play_menu(mut plbook, mut config)!
play_footer(mut plbook, mut config)!
play_announcement(mut plbook, mut config)!
play_publish(mut plbook, mut config)!
play_publish_dev(mut plbook, mut config)!
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
}
}

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;
}
}

View File

@@ -1,328 +0,0 @@
# Site Module
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
```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.web.site
import incubaid.herolib.core.playcmds
// 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 all HeroScript files in the path
playcmds.run(heroscript_path: mysitepath.path)!
// Get the configured site
mut mysite := site.get(name: 'tfgrid_tech')!
println(mysite)
```
## HeroScript Syntax
### Basic Configuration
```heroscript
!!site.config
name: "my_site"
title: "My Documentation Site"
description: "Comprehensive documentation"
tagline: "Your awesome documentation"
favicon: "img/favicon.png"
image: "img/site-image.png"
copyright: "© 2024 My Organization"
url: "https://docs.example.com"
base_url: "/"
```
### Navigation Menu
```heroscript
!!site.navbar
title: "My Site"
logo_alt: "Site Logo"
logo_src: "img/logo.svg"
logo_src_dark: "img/logo-dark.svg"
!!site.navbar_item
label: "Documentation"
to: "docs/intro"
position: "left"
!!site.navbar_item
label: "GitHub"
href: "https://github.com/myorg/myrepo"
position: "right"
```
### Footer Configuration
```heroscript
!!site.footer
style: "dark"
!!site.footer_item
title: "Docs"
label: "Introduction"
to: "intro"
!!site.footer_item
title: "Docs"
label: "Getting Started"
href: "https://docs.example.com/getting-started"
!!site.footer_item
title: "Community"
label: "Discord"
href: "https://discord.gg/example"
```
## 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.
```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"
```
**Key Points:**
- 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)
### Example 2: Pages with Categories
Categories (sections) help organize pages into logical groups with their own navigation structure.
```heroscript
!!site.page_category
name: "first_principle_thinking"
label: "First Principle Thinking"
!!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: "internet_risk"
description: "Internet risk, how to mitigate it, and why it is important"
!!site.page src: "onion_analogy"
description: "Compare onion with a computer, layers of abstraction"
```
**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
!!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: "fungistor"
title: "Fungistor Storage"
description: "Distributed storage system"
position: 2
```
**Available Page Parameters:**
- `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
```heroscript
!!site.import
url: "https://github.com/example/external-docs"
dest: "external"
replace: "PROJECT_NAME:My Project,VERSION:1.0.0"
visible: true
```
## Publish Destinations
```heroscript
!!site.publish
path: "/var/www/html/docs"
ssh_name: "production_server"
!!site.publish_dev
path: "/tmp/docs-preview"
```
## Factory Methods
### Create or Get a Site
```v
import incubaid.herolib.web.site
// Create a new site
mut mysite := site.new(name: 'my_docs')!
// Get an existing site
mut mysite := site.get(name: 'my_docs')!
// Get default site
mut mysite := site.default()!
// Check if site exists
if site.exists(name: 'my_docs') {
println('Site exists')
}
// List all sites
sites := site.list()
println(sites)
```
### Using with PlayBook
```v
import incubaid.herolib.core.playbook
import incubaid.herolib.web.site
// Create playbook from path
mut plbook := playbook.new(path: '/path/to/heroscripts')!
// Process site configuration
site.play(mut plbook)!
// Access the configured site
mut mysite := site.get(name: 'my_site')!
```
## Data Structures
### Site
```v
pub struct Site {
pub mut:
pages []Page
sections []Section
siteconfig SiteConfig
}
```
### 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
}
```
### Section
```v
pub struct Section {
pub mut:
name string // Internal identifier
position int // Sort order
path string // URL path
label string // Display name
}
```
## 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
## Complete Example
See `examples/web/site/site_example.vsh` for a complete working example.
For a real-world example, check: <https://git.ourworld.tf/tfgrid/docs_tfgrid4/src/branch/main/ebooks/tech>