diff --git a/lib/web/doctree/core/doctree.v b/lib/data/atlas/atlas.v similarity index 68% rename from lib/web/doctree/core/doctree.v rename to lib/data/atlas/atlas.v index 8435abf5..a1ce54b6 100644 --- a/lib/web/doctree/core/doctree.v +++ b/lib/data/atlas/atlas.v @@ -1,12 +1,12 @@ -module core +module atlas -import incubaid.herolib.web.doctree +import incubaid.herolib.core.texttools import incubaid.herolib.core.pathlib import incubaid.herolib.ui.console import incubaid.herolib.data.paramsparser @[heap] -pub struct DocTree { +pub struct Atlas { pub mut: name string collections map[string]&Collection @@ -14,7 +14,7 @@ pub mut: } // Create a new collection -fn (mut self DocTree) add_collection(mut path pathlib.Path) !Collection { +fn (mut self Atlas) add_collection(mut path pathlib.Path) !Collection { mut name := path.name_fix_no_ext() mut filepath := path.file_get('.collection')! content := filepath.read()! @@ -24,17 +24,18 @@ fn (mut self DocTree) add_collection(mut path pathlib.Path) !Collection { name = params.get('name')! } } - name = doctree.name_fix(name) - console.print_item("Adding collection '${name}' to DocTree '${self.name}' at path '${path.path}'") + + name = texttools.name_fix(name) + console.print_item("Adding collection '${name}' to Atlas '${self.name}' at path '${path.path}'") if name in self.collections { - return error('Collection ${name} already exists in DocTree ${self.name}') + return error('Collection ${name} already exists in Atlas ${self.name}') } mut c := Collection{ name: name path: path.path // absolute path - doctree: &self // Set doctree reference + atlas: &self // Set atlas reference error_cache: map[string]bool{} } @@ -46,24 +47,38 @@ fn (mut self DocTree) add_collection(mut path pathlib.Path) !Collection { } // Get a collection by name -pub fn (a DocTree) get_collection(name string) !&Collection { +pub fn (a Atlas) get_collection(name string) !&Collection { return a.collections[name] or { return CollectionNotFound{ name: name - msg: 'Collection not found in DocTree ${a.name}' + msg: 'Collection not found in Atlas ${a.name}' } } } // Validate all links in all collections -pub fn (mut a DocTree) init_post() ! { +pub fn (mut a Atlas) init_post() ! { for _, mut col in a.collections { col.init_post()! } } -// Add a group to the doctree -pub fn (mut a DocTree) group_add(mut group Group) ! { +// 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) ! { if group.name in a.groups { return error('Group ${group.name} already exists') } @@ -71,13 +86,13 @@ pub fn (mut a DocTree) group_add(mut group Group) ! { } // Get a group by name -pub fn (a DocTree) group_get(name string) !&Group { - name_lower := doctree.name_fix(name) +pub fn (a Atlas) group_get(name string) !&Group { + name_lower := texttools.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 DocTree) groups_get(session Session) []&Group { +pub fn (a Atlas) groups_get(session Session) []&Group { mut matching := []&Group{} email_lower := session.email.to_lower() @@ -102,7 +117,7 @@ pub mut: ignore []string // list of directory names to ignore } -pub fn (mut a DocTree) scan(args ScanArgs) ! { +pub fn (mut a Atlas) scan(args ScanArgs) ! { mut path := pathlib.get_dir(path: args.path)! mut ignore := args.ignore.clone() ignore = ignore.map(it.to_lower()) @@ -110,7 +125,7 @@ pub fn (mut a DocTree) scan(args ScanArgs) ! { } // Scan a directory for collections -fn (mut a DocTree) scan_(mut dir pathlib.Path, ignore_ []string) ! { +fn (mut a Atlas) 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}') diff --git a/lib/data/atlas/atlas_save_test.v b/lib/data/atlas/atlas_save_test.v new file mode 100644 index 00000000..09344d5e --- /dev/null +++ b/lib/data/atlas/atlas_save_test.v @@ -0,0 +1,207 @@ +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() +} diff --git a/lib/web/doctree/core/processor_test.v b/lib/data/atlas/atlas_test.v similarity index 68% rename from lib/web/doctree/core/processor_test.v rename to lib/data/atlas/atlas_test.v index 78f0b4a3..9ff8d0a9 100644 --- a/lib/web/doctree/core/processor_test.v +++ b/lib/data/atlas/atlas_test.v @@ -1,34 +1,26 @@ -module core +module atlas import incubaid.herolib.core.pathlib import os -import json -const test_base = '/tmp/doctree_test' +const test_base = '/tmp/atlas_test' -// Clean up before and after each test -fn setup_test() { +fn testsuite_begin() { os.rmdir_all(test_base) or {} - os.mkdir_all(test_base) or {} + os.mkdir_all(test_base)! } -fn cleanup_test() { +fn testsuite_end() { os.rmdir_all(test_base) or {} } -fn test_create_doctree() { - setup_test() - defer { cleanup_test() } - - mut a := new(name: 'test_doctree')! - assert a.name == 'test_doctree' +fn test_create_atlas() { + mut a := new(name: 'test_atlas')! + assert a.name == 'test_atlas' 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)! @@ -46,9 +38,6 @@ 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)! @@ -66,9 +55,6 @@ fn test_scan() { } fn test_export() { - setup_test() - defer { cleanup_test() } - // Setup col_path := '${test_base}/source/col1' export_path := '${test_base}/export' @@ -90,9 +76,6 @@ 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)! @@ -112,7 +95,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, redis: false)! + a.export(destination: export_path, include: true)! // Verify exported page1 has page2 content included exported := os.read_file('${export_path}/content/test_col/page1.md')! @@ -122,9 +105,6 @@ 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)! @@ -138,7 +118,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, redis: false)! + a.export(destination: export_path, include: false)! // Verify exported page1 still has include action exported := os.read_file('${export_path}/content/test_col2/page1.md')! @@ -146,28 +126,18 @@ 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)! @@ -187,11 +157,7 @@ fn test_find_links() { assert links.len >= 2 } -// Test with a valid link to ensure no errors are reported -fn test_find_links_valid_link() { - setup_test() - defer { cleanup_test() } - +fn test_validate_links() { // Setup col_path := '${test_base}/link_test' os.mkdir_all(col_path)! @@ -210,17 +176,15 @@ fn test_find_links_valid_link() { 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)! @@ -236,17 +200,13 @@ fn test_validate_broken_links() { a.add_collection(mut pathlib.get_dir(path: col_path)!)! // Validate - a.export(destination: '${test_base}/validate_broken_links', redis: false)! + a.validate_links()! // 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)! @@ -269,22 +229,20 @@ fn test_fix_links() { mut p := col.page_get('page1')! original := p.content()! - assert original.contains('[Link](page2)') + println('Original: ${original}') 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)! @@ -310,9 +268,6 @@ 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' @@ -338,19 +293,20 @@ 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 - a.export(destination: '${test_base}/export_cross', redis: false)! + // Fix links - cross-collection links should NOT be rewritten + a.fix_links()! 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)! @@ -365,13 +321,9 @@ 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)! @@ -380,13 +332,9 @@ 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' @@ -410,21 +358,16 @@ 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 doctree := new(name: 'test_doctree')! + mut atlas := new(name: 'test_atlas')! 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 := doctree.add_collection(mut pathlib.get_dir(path: col_path)!)! + mut col := atlas.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)! @@ -433,14 +376,13 @@ fn test_get_edit_url() { // Get the page and collection edit URLs page := col.page_get('test_page')! - // No asserts in original, adding one for completeness - assert page.name == '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' } 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' @@ -450,95 +392,37 @@ fn test_export_recursive_links() { os.mkdir_all(col_b_path)! os.mkdir_all(col_c_path)! - // Collection A: links to B + // Collection A mut cfile_a := pathlib.get_file(path: '${col_a_path}/.collection', create: true)! cfile_a.write('name:col_a')! mut page_a := pathlib.get_file(path: '${col_a_path}/page_a.md', create: true)! - page_a.write('# Page A\n\nThis is page A.\n\n[Link to Page B](col_b:page_b)')! + page_a.write('# Page A\n\n[Link to B](col_b:page_b)')! - // Collection B: links to C + // Collection B mut cfile_b := pathlib.get_file(path: '${col_b_path}/.collection', create: true)! cfile_b.write('name:col_b')! mut page_b := pathlib.get_file(path: '${col_b_path}/page_b.md', create: true)! - page_b.write('# Page B\n\nThis is page B with link to C.\n\n[Link to Page C](col_c:page_c)')! + page_b.write('# Page B\n\n[Link to C](col_c:page_c)')! - // Collection C: final page + // Collection C mut cfile_c := pathlib.get_file(path: '${col_c_path}/.collection', create: true)! cfile_c.write('name:col_c')! mut page_c := pathlib.get_file(path: '${col_c_path}/page_c.md', create: true)! - page_c.write('# Page C\n\nThis is the final page in the chain.')! + page_c.write('# Page C\n\nFinal content')! - // Create DocTree and add all collections + // Export mut a := new()! a.add_collection(mut pathlib.get_dir(path: col_a_path)!)! a.add_collection(mut pathlib.get_dir(path: col_b_path)!)! a.add_collection(mut pathlib.get_dir(path: col_c_path)!)! - // Export export_path := '${test_base}/export_recursive' - a.export(destination: export_path, redis: false)! + a.export(destination: export_path)! - // 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' + // 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 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' + // TODO: test not complete } diff --git a/lib/web/doctree/client/README.md b/lib/data/atlas/client/README.md similarity index 83% rename from lib/web/doctree/client/README.md rename to lib/data/atlas/client/README.md index 923202d4..4a2f1f76 100644 --- a/lib/web/doctree/client/README.md +++ b/lib/data/atlas/client/README.md @@ -1,10 +1,10 @@ -# DocTreeClient +# AtlasClient -A simple API for accessing document collections exported by the `doctree` module. +A simple API for accessing document collections exported by the `atlas` module. ## What It Does -DocTreeClient provides methods to: +AtlasClient provides methods to: - List collections, pages, files, and images - Check if resources exist @@ -15,10 +15,10 @@ DocTreeClient provides methods to: ## Quick Start ```v -import incubaid.herolib.web.doctree_client +import incubaid.herolib.web.atlas_client -// Create client, exports will be in $/hero/var/doctree_export by default -mut client := doctree_client.new()! +// Create client +mut client := atlas_client.new(export_dir: '${os.home_dir()}/hero/var/atlas_export')! // List collections collections := client.list_collections()! @@ -34,7 +34,7 @@ if client.has_errors('my_collection')! { ## Export Structure -DocTree exports to this structure: +Atlas exports to this structure: ```txt export_dir/ @@ -87,9 +87,9 @@ Names are normalized using `name_fix()`: ## Example -See `examples/data/doctree_client/basic_usage.vsh` for a complete working example. +See `examples/data/atlas_client/basic_usage.vsh` for a complete working example. ## See Also -- `lib/data/doctree/` - DocTree module for exporting collections +- `lib/data/atlas/` - Atlas module for exporting collections - `lib/web/doctreeclient/` - Alternative client for doctree collections diff --git a/lib/web/doctree/client/client.v b/lib/data/atlas/client/client.v similarity index 56% rename from lib/web/doctree/client/client.v rename to lib/data/atlas/client/client.v index b62640ae..01140d90 100644 --- a/lib/web/doctree/client/client.v +++ b/lib/data/atlas/client/client.v @@ -7,17 +7,17 @@ import os import json import incubaid.herolib.core.redisclient -// DocTreeClient provides access to exported documentation collections +// AtlasClient provides access to Atlas-exported documentation collections // It reads from both the exported directory structure and Redis metadata -pub struct DocTreeClient { +pub struct AtlasClient { pub mut: redis &redisclient.Redis - export_dir string // Path to the doctree export directory (contains content/ and meta/) + export_dir string // Path to the atlas 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 DocTreeClient) get_page_path(collection_name string, page_name string) !string { +pub fn (mut c AtlasClient) 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 DocTreeClient) get_page_path(collection_name string, page_name str // 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 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_) +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_) // Check if export directory exists if !os.exists(c.export_dir) { @@ -62,11 +62,11 @@ pub fn (mut c DocTreeClient) get_file_path(collection_name_ string, file_name_ s // 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 DocTreeClient) get_image_path(collection_name_ string, image_name_ string) !string { +pub fn (mut c AtlasClient) get_image_path(collection_name_ string, image_name_ string) !string { // Apply name normalization - collection_name := texttools.name_fix(collection_name_) + collection_name := texttools.name_fix_no_ext(collection_name_) // Images keep their original names with extensions - image_name := texttools.name_fix(image_name_) + image_name := texttools.name_fix_keepext(image_name_) // Check if export directory exists if !os.exists(c.export_dir) { @@ -85,28 +85,28 @@ pub fn (mut c DocTreeClient) get_image_path(collection_name_ string, image_name_ } // page_exists checks if a page exists in a collection -pub fn (mut c DocTreeClient) page_exists(collection_name string, page_name string) bool { +pub fn (mut c AtlasClient) 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 DocTreeClient) file_exists(collection_name string, file_name string) bool { +pub fn (mut c AtlasClient) 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 DocTreeClient) image_exists(collection_name string, image_name string) bool { +pub fn (mut c AtlasClient) 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 DocTreeClient) get_page_content(collection_name string, page_name string) !string { +pub fn (mut c AtlasClient) 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 DocTreeClient) get_page_content(collection_name string, page_name // list_collections returns a list of all collection names // Collections are directories in {export_dir}/content/ -pub fn (mut c DocTreeClient) list_collections() ![]string { +pub fn (mut c AtlasClient) 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 DocTreeClient) 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 DocTreeClient) list_pages(collection_name string) ![]string { +pub fn (mut c AtlasClient) 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 DocTreeClient) 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 DocTreeClient) list_files(collection_name string) ![]string { +pub fn (mut c AtlasClient) 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 DocTreeClient) list_files(collection_name string) ![]string { } // list_images returns a list of all image names in a collection -pub fn (mut c DocTreeClient) list_images(collection_name string) ![]string { +pub fn (mut c AtlasClient) 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 DocTreeClient) 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 DocTreeClient) list_pages_map() !map[string][]string { +pub fn (mut c AtlasClient) list_pages_map() !map[string][]string { mut result := map[string][]string{} collections := c.list_collections()! @@ -199,11 +199,38 @@ pub fn (mut c DocTreeClient) 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 DocTreeClient) get_collection_metadata(collection_name string) !CollectionMetadata { +pub fn (mut c AtlasClient) get_collection_metadata(collection_name string) !CollectionMetadata { // Apply name normalization - fixed_collection_name := texttools.name_fix(collection_name) + fixed_collection_name := texttools.name_fix_no_ext(collection_name) meta_path := os.join_path(c.export_dir, 'meta', '${fixed_collection_name}.json') @@ -220,95 +247,78 @@ pub fn (mut c DocTreeClient) get_collection_metadata(collection_name string) !Co 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 DocTreeClient) get_collection_errors(collection_name string) ![]ErrorMetadata { +pub fn (mut c AtlasClient) 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 DocTreeClient) has_errors(collection_name string) bool { +pub fn (mut c AtlasClient) has_errors(collection_name string) bool { errors := c.get_collection_errors(collection_name) or { return false } return errors.len > 0 } -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 - +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)! + + // 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()}') + } } -// // 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 +// 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)! -// // 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)! -// // 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()}') -// } -// } + // 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()}') + } +} diff --git a/lib/web/doctree/client/client_test.v b/lib/data/atlas/client/client_test.v similarity index 74% rename from lib/web/doctree/client/client_test.v rename to lib/data/atlas/client/client_test.v index 267acc6e..ee6a339d 100644 --- a/lib/web/doctree/client/client_test.v +++ b/lib/data/atlas/client/client_test.v @@ -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(), 'doctree_client_test_${os.getpid()}') + test_dir := os.join_path(os.temp_dir(), 'atlas_client_test_${os.getpid()}') // Clean up if exists if os.exists(test_dir) { @@ -54,7 +54,28 @@ fn setup_test_export() string { "name": "page2", "path": "", "collection_name": "testcollection", - "links": [] + "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" + } + ] } }, "files": { @@ -89,7 +110,14 @@ fn setup_test_export() string { } }, "files": {}, - "errors": [] + "errors": [ + { + "category": "test", + "page_key": "intro", + "message": "Test error", + "line": 10 + } + ] }' os.write_file(os.join_path(test_dir, 'meta', 'anothercollection.json'), metadata2) or { panic(err) @@ -427,6 +455,23 @@ 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() @@ -440,6 +485,21 @@ 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() @@ -453,17 +513,78 @@ 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() @@ -475,7 +596,7 @@ fn test_has_errors_false() { assert has_errors == false } -// Test has_errors - collection not found returns false +// Test has_errors - collection not found fn test_has_errors_collection_not_found() { test_dir := setup_test_export() defer { cleanup_test_export(test_dir) } @@ -492,16 +613,64 @@ 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) } - // 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')) + // 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 } // Test naming normalization edge cases diff --git a/lib/web/doctree/client/factory.v b/lib/data/atlas/client/factory.v similarity index 56% rename from lib/web/doctree/client/factory.v rename to lib/data/atlas/client/factory.v index a16b2210..065391f4 100644 --- a/lib/web/doctree/client/factory.v +++ b/lib/data/atlas/client/factory.v @@ -3,18 +3,18 @@ module client import incubaid.herolib.core.base @[params] -pub struct DocTreeClientArgs { +pub struct AtlasClientArgs { pub: - export_dir string @[required] // Path to doctree export directory + export_dir string @[required] // Path to atlas export directory } -// Create a new DocTreeClient instance +// Create a new AtlasClient instance // The export_dir should point to the directory containing content/ and meta/ subdirectories -pub fn new(args DocTreeClientArgs) !&DocTreeClient { +pub fn new(args AtlasClientArgs) !&AtlasClient { mut context := base.context()! mut redis := context.redis()! - return &DocTreeClient{ + return &AtlasClient{ redis: redis export_dir: args.export_dir } diff --git a/lib/web/doctree/client/model.v b/lib/data/atlas/client/model.v similarity index 87% rename from lib/web/doctree/client/model.v rename to lib/data/atlas/client/model.v index 12de934d..8de6f386 100644 --- a/lib/web/doctree/client/model.v +++ b/lib/data/atlas/client/model.v @@ -1,6 +1,6 @@ module client -// DocTreeClient provides access to DocTree-exported documentation collections +// AtlasClient provides access to Atlas-exported documentation collections // It reads from both the exported directory structure and Redis metadata // List of recognized image file extensions @@ -22,16 +22,6 @@ 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 { diff --git a/lib/web/doctree/core/collection.v b/lib/data/atlas/collection.v similarity index 63% rename from lib/web/doctree/core/collection.v rename to lib/data/atlas/collection.v index 655f1c19..0cf30aec 100644 --- a/lib/web/doctree/core/collection.v +++ b/lib/data/atlas/collection.v @@ -1,11 +1,11 @@ -module core +module atlas import incubaid.herolib.core.pathlib -import incubaid.herolib.web.doctree as doctreetools - +import incubaid.herolib.core.texttools +import incubaid.herolib.develop.gittools 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 - doctree &DocTree @[skip; str: skip] + atlas &Atlas @[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.find_links()! + c.validate_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 := doctreetools.name_fix(name_) + name := texttools.name_fix_no_ext(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 := doctreetools.name_fix(name_) + name := texttools.name_fix_keepext(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 := doctreetools.name_fix(name_) + name := texttools.name_fix_keepext(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 := doctreetools.name_fix(name_) + name := texttools.name_fix_keepext(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 := doctreetools.name_fix(name_) + name := texttools.name_fix_no_ext(name_) return name in c.pages } // Check if image exists pub fn (c Collection) image_exists(name_ string) !bool { - name := doctreetools.name_fix(name_) + name := texttools.name_fix_keepext(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 := doctreetools.name_fix(name_) + name := texttools.name_fix_keepext(name_) f := c.files[name] or { return false } return f.ftype == .file } pub fn (c Collection) file_or_image_exists(name_ string) !bool { - name := doctreetools.name_fix(name_) + name := texttools.name_fix_keepext(name_) _ := c.files[name] or { return false } return true } @@ -247,6 +247,31 @@ 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 @@ -255,8 +280,8 @@ pub fn (c Collection) can_read(session Session) bool { } // Get user's groups - mut doctree := c.doctree - groups := doctree.groups_get(session) + mut atlas := c.atlas + groups := atlas.groups_get(session) group_names := groups.map(it.name) // Check if any of user's groups are in read ACL @@ -277,8 +302,8 @@ pub fn (c Collection) can_write(session Session) bool { } // Get user's groups - mut doctree := c.doctree - groups := doctree.groups_get(session) + mut atlas := c.atlas + groups := atlas.groups_get(session) group_names := groups.map(it.name) // Check if any of user's groups are in write ACL @@ -290,3 +315,104 @@ 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)! + } + } +} diff --git a/lib/web/doctree/core/collection_error.v b/lib/data/atlas/collection_error.v similarity index 97% rename from lib/web/doctree/core/collection_error.v rename to lib/data/atlas/collection_error.v index 63e96eb3..f347d1a5 100644 --- a/lib/web/doctree/core/collection_error.v +++ b/lib/data/atlas/collection_error.v @@ -1,7 +1,7 @@ -module core +module atlas import crypto.md5 - +import incubaid.herolib.ui.console pub enum CollectionErrorCategory { circular_include diff --git a/lib/web/doctree/core/error.v b/lib/data/atlas/error.v similarity index 97% rename from lib/web/doctree/core/error.v rename to lib/data/atlas/error.v index 05548f2b..4ed8d1db 100644 --- a/lib/web/doctree/core/error.v +++ b/lib/data/atlas/error.v @@ -1,4 +1,4 @@ -module core +module atlas pub struct CollectionNotFound { Error diff --git a/lib/web/doctree/core/export.v b/lib/data/atlas/export.v similarity index 63% rename from lib/web/doctree/core/export.v rename to lib/data/atlas/export.v index 09e17b77..ac21479d 100644 --- a/lib/web/doctree/core/export.v +++ b/lib/data/atlas/export.v @@ -1,4 +1,4 @@ -module core +module atlas import incubaid.herolib.core.pathlib import incubaid.herolib.core.base @@ -7,27 +7,22 @@ import json @[params] pub struct ExportArgs { pub mut: - destination string @[required] + destination string @[requireds] reset bool = true include bool = true redis bool = true } -// Export all collections and do all processing steps -pub fn (mut a DocTree) export(args ExportArgs) ! { +// Export all collections +pub fn (mut a Atlas) export(args ExportArgs) ! { mut dest := pathlib.get_dir(path: args.destination, create: true)! if args.reset { dest.empty()! } - // 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()! - } + // Validate links before export to populate page.links + a.validate_links()! for _, mut col in a.collections { col.export( @@ -37,10 +32,6 @@ pub fn (mut a DocTree) export(args ExportArgs) ! { redis: args.redis )! } - - for _, mut col in a.collections { - col.fix_links()! - } } @[params] @@ -99,51 +90,13 @@ 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('doctree:${c.name}', page.name, page.path)! + redis.hset('atlas:${c.name}', page.name, page.path)! } } @@ -164,6 +117,65 @@ 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 @@ -172,17 +184,6 @@ 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 { @@ -191,19 +192,15 @@ fn (mut c Collection) collect_cross_collection_references(mut page Page, is_local := link.target_collection_name == c.name - // Collect cross-collection page references and recursively process them + // Collect cross-collection page references if link.file_type == .page && !is_local { - page_ref := '${link.target_collection_name}:${link.target_item_name}' + page_key := '${link.target_collection_name}:${link.target_item_name}' // Only add if not already collected - if page_ref !in all_cross_pages { + if page_key !in all_cross_pages { mut target_page := link.target_page()! - 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)! + all_cross_pages[page_key] = target_page + // Don't mark as processed yet - we'll do that when we actually process its links } } diff --git a/lib/data/atlas/factory.v b/lib/data/atlas/factory.v new file mode 100644 index 00000000..187046d0 --- /dev/null +++ b/lib/data/atlas/factory.v @@ -0,0 +1,61 @@ +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 + } +} diff --git a/lib/web/doctree/core/file.v b/lib/data/atlas/file.v similarity index 98% rename from lib/web/doctree/core/file.v rename to lib/data/atlas/file.v index 6e860b58..ecd714e7 100644 --- a/lib/web/doctree/core/file.v +++ b/lib/data/atlas/file.v @@ -1,4 +1,4 @@ -module core +module atlas import incubaid.herolib.core.pathlib import os diff --git a/lib/data/atlas/getters.v b/lib/data/atlas/getters.v new file mode 100644 index 00000000..bbc2e2c9 --- /dev/null +++ b/lib/data/atlas/getters.v @@ -0,0 +1,102 @@ +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 +} diff --git a/lib/web/doctree/core/group.v b/lib/data/atlas/group.v similarity index 94% rename from lib/web/doctree/core/group.v rename to lib/data/atlas/group.v index ace756ae..70b29b54 100644 --- a/lib/web/doctree/core/group.v +++ b/lib/data/atlas/group.v @@ -1,6 +1,6 @@ -module core +module atlas -import incubaid.herolib.web.doctree +import incubaid.herolib.core.texttools 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 := doctree.name_fix(args.name) + mut name := texttools.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: doctree.name_fix(filename) + name: texttools.name_fix(filename) patterns: []string{} } diff --git a/lib/web/doctree/core/instruction.md b/lib/data/atlas/instruction.md similarity index 51% rename from lib/web/doctree/core/instruction.md rename to lib/data/atlas/instruction.md index b7eab766..f0ae7f35 100644 --- a/lib/web/doctree/core/instruction.md +++ b/lib/data/atlas/instruction.md @@ -1,4 +1,4 @@ -in doctree/ +in atlas/ 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 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 +in the scan function in atlas, now make scan_groups function, find groups, only do this for collection as named groups +do not add collection groups to atlas, this is a system collection -make the groups and add them to doctree +make the groups and add them to atlas give clear instructions for coding agent how to write the code diff --git a/lib/web/doctree/core/link.v b/lib/data/atlas/link.v similarity index 87% rename from lib/web/doctree/core/link.v rename to lib/data/atlas/link.v index 3025cae1..18da796d 100644 --- a/lib/web/doctree/core/link.v +++ b/lib/data/atlas/link.v @@ -1,6 +1,7 @@ -module core +module atlas -import incubaid.herolib.web.doctree as doctreetools +import incubaid.herolib.core.texttools +import incubaid.herolib.ui.console pub enum LinkFileType { page // Default: link to another page @@ -42,7 +43,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.doctree.page_get(self.key()) + return self.page.collection.atlas.page_get(self.key()) } // Get the target file this link points to @@ -50,7 +51,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.doctree.file_or_image_get(self.key()) + return self.page.collection.atlas.file_or_image_get(self.key()) } // Find all markdown links in content @@ -160,10 +161,23 @@ fn (mut p Page) parse_link_target(mut link Link) ! { // Format: $collection:$pagename or $collection:$pagename.md if target.contains(':') { - link.target_collection_name, link.target_item_name = doctreetools.key_parse(target)! + 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]) + } + } } else { // For file links, use name without extension; for page links, normalize normally - link.target_item_name = doctreetools.name_fix(target) + 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_collection_name = p.collection.name } @@ -175,11 +189,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.doctree.file_or_image_exists(link.key())! + target_exists = p.collection.atlas.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.doctree.page_exists(link.key())! + target_exists = p.collection.atlas.page_exists(link.key())! } // console.print_debug('Link target exists: ${target_exists} for key=${link.key()}') @@ -284,3 +298,14 @@ 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) +} diff --git a/lib/web/doctree/core/page.v b/lib/data/atlas/page.v similarity index 77% rename from lib/web/doctree/core/page.v rename to lib/data/atlas/page.v index 757151b9..3c739ad9 100644 --- a/lib/web/doctree/core/page.v +++ b/lib/data/atlas/page.v @@ -1,7 +1,7 @@ -module core +module atlas import incubaid.herolib.core.pathlib -import incubaid.herolib.web.doctree as doctreetools +import incubaid.herolib.core.texttools @[heap] pub struct Page { @@ -11,18 +11,9 @@ 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: @@ -45,7 +36,7 @@ pub mut: include bool } -// Read content can be with or without processing includes +// Read content without processing includes pub fn (mut p Page) content(args ReadContentArgs) !string { mut mypath := p.path()! mut content := mypath.read()! @@ -58,7 +49,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 doctree := p.collection.doctree + mut atlas := p.collection.atlas // Prevent circular includes page_key := p.key() if page_key in visited { @@ -89,16 +80,34 @@ fn (mut p Page) process_includes(content string, mut visited map[string]bool) !s mut target_page := '' if include_ref.contains(':') { - target_collection, target_page = doctreetools.key_parse(include_ref)! + 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 << '' + continue + } } else { - target_page = doctreetools.name_fix(include_ref) + 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] } // Build page key page_ref := '${target_collection}:${target_page}' - // Get the referenced page from doctree - mut include_page := doctree.page_get(page_ref) or { + // Get the referenced page from atlas + mut include_page := atlas.page_get(page_ref) or { p.collection.error( category: .missing_include page_key: page_key diff --git a/lib/web/doctree/core/play.v b/lib/data/atlas/play.v similarity index 57% rename from lib/web/doctree/core/play.v rename to lib/data/atlas/play.v index 76ece516..034e4fcd 100644 --- a/lib/web/doctree/core/play.v +++ b/lib/data/atlas/play.v @@ -1,36 +1,36 @@ -module core +module atlas import incubaid.herolib.core.playbook { PlayBook } import incubaid.herolib.develop.gittools import incubaid.herolib.ui.console import os -// Play function to process HeroScript actions for DocTree +// Play function to process HeroScript actions for Atlas pub fn play(mut plbook PlayBook) ! { - if !plbook.exists(filter: 'doctree.') { + if !plbook.exists(filter: 'atlas.') { return } - // Track which doctrees we've processed in this playbook - mut processed_doctreees := map[string]bool{} + // Track which atlases we've processed in this playbook + mut processed_atlases := map[string]bool{} mut name := '' // Process scan actions - scan directories for collections - mut scan_actions := plbook.find(filter: 'doctree.scan')! + mut scan_actions := plbook.find(filter: 'atlas.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 DocTree '${name}' with ignore patterns: ${ignore}") - // Get or create doctree from global map - mut doctree_instance := if exists(name) { + console.print_item("Scanning Atlas '${name}' with ignore patterns: ${ignore}") + // Get or create atlas from global map + mut atlas_instance := if exists(name) { get(name)! } else { - console.print_debug('DocTree not found, creating a new one') + console.print_debug('Atlas not found, creating a new one') new(name: name)! } - processed_doctreees[name] = true + processed_atlases[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 doctree.scan action.') + return error('Either "path" or "git_url" must be provided for atlas.scan action.') } - doctree_instance.scan(path: path, ignore: ignore)! + atlas_instance.scan(path: path, ignore: ignore)! action.done = true - // No need to call set() again - doctree is already in global map from new() + // No need to call set() again - atlas is already in global map from new() // and we're modifying it by reference } - // 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()! + // 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()! } // Process export actions - export collections to destination - mut export_actions := plbook.find(filter: 'doctree.export')! + mut export_actions := plbook.find(filter: 'atlas.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/doctree_export')! + destination := p.get_default('destination', '${os.home_dir()}/hero/var/atlas_export')! reset := p.get_default_true('reset') include := p.get_default_true('include') redis := p.get_default_true('redis') - mut doctree_instance := get(name) or { - return error("DocTree '${name}' not found. Use !!doctree.scan first.") + mut atlas_instance := get(name) or { + return error("Atlas '${name}' not found. Use !!atlas.scan first.") } - doctree_instance.export( + atlas_instance.export( destination: destination reset: reset include: include diff --git a/lib/data/atlas/process.md b/lib/data/atlas/process.md new file mode 100644 index 00000000..1730a12c --- /dev/null +++ b/lib/data/atlas/process.md @@ -0,0 +1,4 @@ + + +- first find all pages +- then for each page find all links \ No newline at end of file diff --git a/lib/web/doctree/core/readme.md b/lib/data/atlas/readme.md similarity index 83% rename from lib/web/doctree/core/readme.md rename to lib/data/atlas/readme.md index 15da6a50..dbb27a6b 100644 --- a/lib/web/doctree/core/readme.md +++ b/lib/data/atlas/readme.md @@ -1,4 +1,4 @@ -# DocTree Module +# Atlas 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') - DocTree instance name +- `name` (optional, default: 'main') - Atlas 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 -!!doctree.scan git_url:"https://git.ourworld.tf/tfgrid/docs_tfgrid4/src/branch/main/collections/tests" +!!atlas.scan git_url:"https://git.ourworld.tf/tfgrid/docs_tfgrid4/src/branch/main/collections/tests" -!!doctree.export +!!atlas.export destination: '/tmp/atlas_export' ``` @@ -42,10 +42,10 @@ put this in .hero file ## usage in herolib ```v -import incubaid.herolib.web.doctree +import incubaid.herolib.data.atlas -// Create a new DocTree -mut a := doctree.new(name: 'my_docs')! +// Create a new Atlas +mut a := atlas.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 := doctree.new()! +mut a := atlas.new()! a.scan(path: './docs')! ``` @@ -191,7 +191,7 @@ for _, col in a.collections { ### Include Processing -DocTree supports simple include processing using `!!include` actions: +Atlas supports simple include processing using `!!include` actions: ```v // Export with includes processed (default) @@ -241,11 +241,11 @@ content := page.content()! ## Git Integration -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. +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. ### Automatic Detection -When scanning collections, DocTree walks up the directory tree to find the `.git` directory and captures: +When scanning collections, Atlas 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, DocTree walks up the directory tree to find the `.git You can scan collections directly from a git repository: ```heroscript -!!doctree.scan +!!atlas.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 := doctree.page_get('guides:intro')! +mut page := atlas.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 -DocTree supports standard Markdown links with several formats for referencing pages within collections. +Atlas 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 DocTree: +Check all links in your Atlas: ```v -mut a := doctree.new()! +mut a := atlas.new()! a.scan(path: './docs')! // Validate all links -a.find_links()! +a.validate_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 := doctree.new()! +mut a := atlas.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 DocTree, the directory structure is organized as: +When you export an Atlas, the directory structure is organized as: $$\text{export\_dir}/ \begin{cases} @@ -409,17 +409,17 @@ $$\text{export\_dir}/ ## Redis Integration -DocTree uses Redis to store metadata about collections, pages, images, and files for fast lookups and caching. +Atlas 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, DocTree stores: +When `redis: true` is set during export, Atlas stores: -1. **Collection Paths** - Hash: `doctree:path` +1. **Collection Paths** - Hash: `atlas:path` - Key: collection name - Value: exported collection directory path -2. **Collection Contents** - Hash: `doctree:` +2. **Collection Contents** - Hash: `atlas:` - 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, DocTree stores: ### Redis Usage Examples ```v -import incubaid.herolib.web.doctree +import incubaid.herolib.data.atlas import incubaid.herolib.core.base // Export with Redis metadata (default) -mut a := doctree.new(name: 'docs')! +mut a := atlas.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('doctree:path', 'guides')! +col_path := redis.hget('atlas:path', 'guides')! println('Guides collection exported to: ${col_path}') // Get page location -page_path := redis.hget('doctree:guides', 'introduction')! +page_path := redis.hget('atlas:guides', 'introduction')! println('Introduction page: ${page_path}') // Output: introduction.md // Get image location -img_path := redis.hget('doctree:guides', 'logo.png')! +img_path := redis.hget('atlas: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.web.doctree +import incubaid.herolib.data.atlas -mut a := doctree.new(name: 'my_docs')! +mut a := atlas.new(name: 'my_docs')! a.scan(path: './docs')! // Save all collections to a specified directory @@ -497,32 +497,32 @@ save_path/ ## HeroScript Integration -DocTree integrates with HeroScript, allowing you to define DocTree operations in `.vsh` or playbook files. +Atlas integrates with HeroScript, allowing you to define Atlas operations in `.vsh` or playbook files. ### Using in V Scripts -Create a `.vsh` script to process DocTree operations: +Create a `.vsh` script to process Atlas 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.web.doctree +import incubaid.herolib.data.atlas // Define your HeroScript content heroscript := " -!!doctree.scan path: './docs' +!!atlas.scan path: './docs' -!!doctree.export destination: './output' include: true +!!atlas.export destination: './output' include: true " // Create playbook from text mut plbook := playbook.new(text: heroscript)! -// Execute doctree actions -doctree.play(mut plbook)! +// Execute atlas actions +atlas.play(mut plbook)! -println('DocTree processing complete!') +println('Atlas processing complete!') ``` ### Using in Playbook Files @@ -530,11 +530,11 @@ println('DocTree processing complete!') Create a `docs.play` file: ```heroscript -!!doctree.scan +!!atlas.scan name: 'main' path: '~/code/docs' -!!doctree.export +!!atlas.export destination: '~/code/output' reset: true include: true @@ -565,11 +565,11 @@ playcmds.run(mut plbook)! Errors are automatically collected and reported: ```heroscript -!!doctree.scan +!!atlas.scan path: './docs' # Errors will be printed during export -!!doctree.export +!!atlas.export destination: './output' ``` @@ -583,13 +583,13 @@ Collection guides - Errors (2) ### Auto-Export Behavior -If you use `!!doctree.scan` **without** an explicit `!!doctree.export`, DocTree will automatically export to the default location (current directory). +If you use `!!atlas.scan` **without** an explicit `!!atlas.export`, Atlas 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 `!!doctree.validate` to catch broken links early +1. **Always validate before export**: Use `!!atlas.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 -- [ ] `doctree.validate` playbook action -- [ ] `doctree.fix_links` playbook action +- [ ] `atlas.validate` playbook action +- [ ] `atlas.fix_links` playbook action - [ ] Auto-save on collection modifications - [ ] Collection version control \ No newline at end of file diff --git a/lib/web/doctree/client/markdown.v b/lib/web/doctree/client/markdown.v deleted file mode 100644 index bbf39da9..00000000 --- a/lib/web/doctree/client/markdown.v +++ /dev/null @@ -1,28 +0,0 @@ -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 -} diff --git a/lib/web/doctree/core/collection_process.v b/lib/web/doctree/core/collection_process.v deleted file mode 100644 index 7ce185f2..00000000 --- a/lib/web/doctree/core/collection_process.v +++ /dev/null @@ -1,69 +0,0 @@ -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()! -} diff --git a/lib/web/doctree/core/collection_scan.v b/lib/web/doctree/core/collection_scan.v deleted file mode 100644 index eab7de91..00000000 --- a/lib/web/doctree/core/collection_scan.v +++ /dev/null @@ -1,84 +0,0 @@ -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)! - } - } -} diff --git a/lib/web/doctree/core/factory.v b/lib/web/doctree/core/factory.v deleted file mode 100644 index 985999ba..00000000 --- a/lib/web/doctree/core/factory.v +++ /dev/null @@ -1,61 +0,0 @@ -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 - } -} diff --git a/lib/web/doctree/core/getters.v b/lib/web/doctree/core/getters.v deleted file mode 100644 index 22d839bb..00000000 --- a/lib/web/doctree/core/getters.v +++ /dev/null @@ -1,86 +0,0 @@ -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 -} diff --git a/lib/web/doctree/core/recursive_link_test.v b/lib/web/doctree/core/recursive_link_test.v deleted file mode 100644 index f508b809..00000000 --- a/lib/web/doctree/core/recursive_link_test.v +++ /dev/null @@ -1,187 +0,0 @@ -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') -} diff --git a/lib/web/doctree/meta/factory.v b/lib/web/doctree/meta/factory.v deleted file mode 100644 index d4059311..00000000 --- a/lib/web/doctree/meta/factory.v +++ /dev/null @@ -1,62 +0,0 @@ -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() -} diff --git a/lib/web/doctree/meta/model_announcement.v b/lib/web/doctree/meta/model_announcement.v deleted file mode 100644 index 85df6825..00000000 --- a/lib/web/doctree/meta/model_announcement.v +++ /dev/null @@ -1,11 +0,0 @@ -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'] -} diff --git a/lib/web/doctree/meta/model_builddest.v b/lib/web/doctree/meta/model_builddest.v deleted file mode 100644 index ce3eb412..00000000 --- a/lib/web/doctree/meta/model_builddest.v +++ /dev/null @@ -1,7 +0,0 @@ -module meta - -pub struct BuildDest { -pub mut: - path string - ssh_name string -} diff --git a/lib/web/doctree/meta/model_category.v b/lib/web/doctree/meta/model_category.v deleted file mode 100644 index 5f3d97a2..00000000 --- a/lib/web/doctree/meta/model_category.v +++ /dev/null @@ -1,89 +0,0 @@ -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 -} diff --git a/lib/web/doctree/meta/model_category_str.v b/lib/web/doctree/meta/model_category_str.v deleted file mode 100644 index 78206abf..00000000 --- a/lib/web/doctree/meta/model_category_str.v +++ /dev/null @@ -1,76 +0,0 @@ -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' -} diff --git a/lib/web/doctree/meta/model_import.v b/lib/web/doctree/meta/model_import.v deleted file mode 100644 index fd0242f9..00000000 --- a/lib/web/doctree/meta/model_import.v +++ /dev/null @@ -1,11 +0,0 @@ -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 -} diff --git a/lib/web/doctree/meta/model_link.v b/lib/web/doctree/meta/model_link.v deleted file mode 100644 index baf6cf15..00000000 --- a/lib/web/doctree/meta/model_link.v +++ /dev/null @@ -1,8 +0,0 @@ -module meta - -struct Link { -pub mut: - label string - href string - description string -} diff --git a/lib/web/doctree/meta/model_page.v b/lib/web/doctree/meta/model_page.v deleted file mode 100644 index 8569e786..00000000 --- a/lib/web/doctree/meta/model_page.v +++ /dev/null @@ -1,15 +0,0 @@ -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" -} diff --git a/lib/web/doctree/meta/model_site.v b/lib/web/doctree/meta/model_site.v deleted file mode 100644 index 48c0d3cc..00000000 --- a/lib/web/doctree/meta/model_site.v +++ /dev/null @@ -1,30 +0,0 @@ -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 -} diff --git a/lib/web/doctree/meta/play.v b/lib/web/doctree/meta/play.v deleted file mode 100644 index c7633f90..00000000 --- a/lib/web/doctree/meta/play.v +++ /dev/null @@ -1,96 +0,0 @@ -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') -} diff --git a/lib/web/doctree/meta/play_announcement.v b/lib/web/doctree/meta/play_announcement.v deleted file mode 100644 index 66f7c8e9..00000000 --- a/lib/web/doctree/meta/play_announcement.v +++ /dev/null @@ -1,30 +0,0 @@ -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 - } -} diff --git a/lib/web/doctree/meta/play_footer.v b/lib/web/doctree/meta/play_footer.v deleted file mode 100644 index 1ba25a71..00000000 --- a/lib/web/doctree/meta/play_footer.v +++ /dev/null @@ -1,62 +0,0 @@ -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 - } - } -} diff --git a/lib/web/doctree/meta/play_imports.v b/lib/web/doctree/meta/play_imports.v deleted file mode 100644 index 64a511f0..00000000 --- a/lib/web/doctree/meta/play_imports.v +++ /dev/null @@ -1,50 +0,0 @@ -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 - } -} diff --git a/lib/web/doctree/meta/play_navbar.v b/lib/web/doctree/meta/play_navbar.v deleted file mode 100644 index c486da99..00000000 --- a/lib/web/doctree/meta/play_navbar.v +++ /dev/null @@ -1,60 +0,0 @@ -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 - } -} diff --git a/lib/web/doctree/meta/play_pages_categories.v b/lib/web/doctree/meta/play_pages_categories.v deleted file mode 100644 index d6188899..00000000 --- a/lib/web/doctree/meta/play_pages_categories.v +++ /dev/null @@ -1,116 +0,0 @@ -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') -} diff --git a/lib/web/doctree/meta/play_publish.v b/lib/web/doctree/meta/play_publish.v deleted file mode 100644 index 8b4a5d5e..00000000 --- a/lib/web/doctree/meta/play_publish.v +++ /dev/null @@ -1,46 +0,0 @@ -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 - } -} diff --git a/lib/web/doctree/meta/readme.md b/lib/web/doctree/meta/readme.md deleted file mode 100644 index 9818228d..00000000 --- a/lib/web/doctree/meta/readme.md +++ /dev/null @@ -1,676 +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. - -## 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 -``` diff --git a/lib/web/doctree/meta2/site_nav_test.v b/lib/web/doctree/meta2/site_nav_test.v deleted file mode 100644 index c087e8a0..00000000 --- a/lib/web/doctree/meta2/site_nav_test.v +++ /dev/null @@ -1,746 +0,0 @@ -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') - } -} diff --git a/lib/web/doctree/meta2/siteplay_test.v b/lib/web/doctree/meta2/siteplay_test.v deleted file mode 100644 index ad076a83..00000000 --- a/lib/web/doctree/meta2/siteplay_test.v +++ /dev/null @@ -1,500 +0,0 @@ -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}"') -} diff --git a/lib/web/doctree/utils.v b/lib/web/doctree/utils.v deleted file mode 100644 index 5e75f084..00000000 --- a/lib/web/doctree/utils.v +++ /dev/null @@ -1,49 +0,0 @@ -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 -} diff --git a/lib/web/docusaurus/README.md b/lib/web/docusaurus/README.md index 9cda7dff..6bbba091 100644 --- a/lib/web/docusaurus/README.md +++ b/lib/web/docusaurus/README.md @@ -2,256 +2,100 @@ 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 -p /path/to/your/ebook +hero docs -d -path /path/to/your/site # Build for production -hero docs -p /path/to/your/ebook +hero docs -b -path /path/to/your/site # Build and publish -hero docs -bp -p /path/to/your/ebook +hero docs -bp -path /path/to/your/site ``` ---- - -## 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: +### Example HeroScript ```heroscript -// Scan local collections -!!doctree.scan path:"../../collections/my_collection" -// 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 +// Define the Docusaurus build environment, is optional !!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/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 -!!docusaurus.dev site:"my_site" open:true ``` ---- +## see sites to define a site -## HeroScript Actions Reference +the site needs to be defined following the generic site definition, see the `lib/web/site` module for more details. -### `!!doctree.scan` +```heroscript -Scans a directory for markdown collections: +//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" -- `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 +!!site.menu_item + label: "Homepage" + href: "https://example.com" + position: "right" -### `!!doctree.export` +// ... add footer, pages, etc. using !!site.* actions ... -Exports scanned collections: +``` -- `include` (bool): Include content in export (default: `true`) -- `destination` (string): Export directory +### Heroscript Actions -### `!!docusaurus.define` +- `!!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`. -Configures the Docusaurus build environment: +- `!!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). -- `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.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. -### `!!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 +- `!!docusaurus.build`: Builds the static site for production. + - `site` (string, required): The name of the site to build. diff --git a/lib/web/docusaurus/config.v b/lib/web/docusaurus/config.v index cbef02ed..6418be43 100644 --- a/lib/web/docusaurus/config.v +++ b/lib/web/docusaurus/config.v @@ -17,7 +17,9 @@ pub mut: reset bool template_update bool coderoot string - doctree_dir string + // Client configuration + use_atlas bool // true = atlas_client, false = doctreeclient + atlas_dir string // Required when use_atlas = true } @[params] @@ -29,7 +31,9 @@ pub mut: reset bool template_update bool coderoot string - doctree_dir string + // Client configuration + use_atlas bool // true = atlas_client, false = doctreeclient + atlas_dir string // Required when use_atlas = true } // return the last know config @@ -38,8 +42,8 @@ pub fn config() !DocusaurusConfig { docusaurus_config << DocusaurusConfigParams{} } mut args := docusaurus_config[0] or { panic('bug in docusaurus config') } - if args.doctree_dir == '' { - return error('doctree_dir is not set') + if args.use_atlas && args.atlas_dir == '' { + return error('use_atlas is true but atlas_dir is not set') } if args.path_build == '' { args.path_build = '${os.home_dir()}/hero/var/docusaurus/build' @@ -58,7 +62,8 @@ pub fn config() !DocusaurusConfig { install: args.install reset: args.reset template_update: args.template_update - doctree_dir: args.doctree_dir + use_atlas: args.use_atlas + atlas_dir: args.atlas_dir } if c.install { install(c)! diff --git a/lib/web/docusaurus/dsite.v b/lib/web/docusaurus/dsite.v index 1d061847..80f48226 100644 --- a/lib/web/docusaurus/dsite.v +++ b/lib/web/docusaurus/dsite.v @@ -1,7 +1,7 @@ module docusaurus import incubaid.herolib.core.pathlib -import incubaid.herolib.web.doctree.meta +import incubaid.herolib.web.site 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 meta.Site + website site.Site generated bool } @@ -50,7 +50,7 @@ pub fn (mut s DocSite) build_publish() ! { ' retry: 0 )! - for item in s.build_dest { + for item in s.website.siteconfig.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 // 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) + 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) } pub fn (mut s DocSite) open(args DevArgs) ! { diff --git a/lib/web/docusaurus/dsite_configuration.v b/lib/web/docusaurus/dsite_configuration.v index d58134c3..8d067b2f 100644 --- a/lib/web/docusaurus/dsite_configuration.v +++ b/lib/web/docusaurus/dsite_configuration.v @@ -1,16 +1,15 @@ module docusaurus -import incubaid.herolib.web.doctree.meta +import incubaid.herolib.web.site // IS THE ONE AS USED BY DOCUSAURUS pub struct Configuration { pub mut: - main Main - navbar Navbar - footer Footer - sidebar_json_txt string // will hold the sidebar.json content - announcement AnnouncementBar + main Main + navbar Navbar + footer Footer + announcement AnnouncementBar } pub struct Main { @@ -79,17 +78,18 @@ 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'] } -// This function is a pure transformer: site.SiteConfig -> docusaurus.Configuration -fn new_configuration(mysite meta.Site) !Configuration { +// ... (struct definitions remain the same) ... + +// This function is now a pure transformer: site.SiteConfig -> docusaurus.Configuration +fn new_configuration(site_cfg site.SiteConfig) !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,10 +116,8 @@ fn new_configuration(mysite meta.Site) !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 @@ -149,7 +147,7 @@ fn new_configuration(mysite meta.Site) !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 @@ -158,20 +156,18 @@ fn new_configuration(mysite meta.Site) !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)! } diff --git a/lib/web/docusaurus/dsite_generate.v b/lib/web/docusaurus/dsite_generate.v index 9a1436d4..fb25f61b 100644 --- a/lib/web/docusaurus/dsite_generate.v +++ b/lib/web/docusaurus/dsite_generate.v @@ -33,10 +33,6 @@ 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()! diff --git a/lib/web/docusaurus/dsite_generate_docs.v b/lib/web/docusaurus/dsite_generate_docs.v index c181579e..3fb548d7 100644 --- a/lib/web/docusaurus/dsite_generate_docs.v +++ b/lib/web/docusaurus/dsite_generate_docs.v @@ -1,163 +1,438 @@ module docusaurus import incubaid.herolib.core.pathlib -import incubaid.herolib.web.doctree.client as doctree_client +import incubaid.herolib.data.atlas.client as atlas_client +import incubaid.herolib.web.site { Page, Section, Site } import incubaid.herolib.data.markdown.tools as markdowntools import incubaid.herolib.ui.console -import incubaid.herolib.web.site -import os -// ============================================================================ -// Doc Linking - Generate Docusaurus docs from DocTree collections -// ============================================================================ +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 +} -// 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) +// 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 := atlas_client.new(export_dir: c.atlas_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_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..] } - 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 + if !name[i].is_digit() { + // Not a numeric prefix pattern, return as-is + return name } } } - return '' + return name } -// 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()! - docs_path := '${c.path_build.path}/docs' - - reset_docs_dir(docs_path)! - console.print_header('Write doc: ${docs_path}') - - mut client := doctree_client.new(export_dir: c.doctree_dir)! - mut errors := []string{} - - // 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 { - '' +// 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}' } - for _, page in docsite.website.pages { - process_page(mut client, docs_path, page, first_doc_page, mut errors) + // Current at root, target in subdirectory + if current_dir == '' && target_dir != '' { + return './${target_dir}/${page_name}' } - if errors.len > 0 { - report_errors(mut client, errors)! + // 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}' } - console.print_green('Successfully linked ${docsite.website.pages.len} pages to docs folder') + // 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 reset_docs_dir(docs_path string) ! { - if os.exists(docs_path) { - os.rmdir_all(docs_path) or {} +// 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('/') + } } - os.mkdir_all(docs_path)! -} - -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')}') -} - -// ============================================================================ -// Page Processing -// ============================================================================ - -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 - } - - content := client.get_page_content(collection, page_name) or { - errors << "Page not found: '${collection}:${page_name}'" - return - } - - // Check if this page is the docs landing page - is_landing_page := first_doc_page.len > 0 && page_name == first_doc_page - - write_page(docs_path, page_name, page, content, is_landing_page) or { - errors << "Failed to write page '${page_name}': ${err.msg()}" - return - } - - 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') + // 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 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 } diff --git a/lib/web/docusaurus/dsite_generate_docs__.v b/lib/web/docusaurus/dsite_generate_docs__.v deleted file mode 100644 index 53f7ab7a..00000000 --- a/lib/web/docusaurus/dsite_generate_docs__.v +++ /dev/null @@ -1,442 +0,0 @@ -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 -// } diff --git a/lib/web/docusaurus/dsite_store_structure.v b/lib/web/docusaurus/dsite_store_structure.v new file mode 100644 index 00000000..ded27431 --- /dev/null +++ b/lib/web/docusaurus/dsite_store_structure.v @@ -0,0 +1,40 @@ +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)! +// } +// } diff --git a/lib/web/docusaurus/dsite_to_sidebar_json.v b/lib/web/docusaurus/dsite_to_sidebar_json.v deleted file mode 100644 index 564e0de4..00000000 --- a/lib/web/docusaurus/dsite_to_sidebar_json.v +++ /dev/null @@ -1,89 +0,0 @@ -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)) - } -} diff --git a/lib/web/docusaurus/factory.v b/lib/web/docusaurus/factory.v index 73a510eb..8a7562ae 100644 --- a/lib/web/docusaurus/factory.v +++ b/lib/web/docusaurus/factory.v @@ -15,7 +15,7 @@ pub fn dsite_define(sitename string) ! { console.print_header('Add Docusaurus Site: ${sitename}') mut c := config()! - _ := '${c.path_publish.path}/${sitename}' + path_publish := '${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)! + config: new_configuration(website.siteconfig)! website: website } diff --git a/lib/web/docusaurus/for_testing/README.md b/lib/web/docusaurus/for_testing/README.md deleted file mode 100644 index d370d8c3..00000000 --- a/lib/web/docusaurus/for_testing/README.md +++ /dev/null @@ -1,82 +0,0 @@ -# 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 - diff --git a/lib/web/docusaurus/for_testing/collections/test_collection/.collection b/lib/web/docusaurus/for_testing/collections/test_collection/.collection deleted file mode 100644 index 92322691..00000000 --- a/lib/web/docusaurus/for_testing/collections/test_collection/.collection +++ /dev/null @@ -1,3 +0,0 @@ -name: test_collection -description: Test collection for link resolution testing - diff --git a/lib/web/docusaurus/for_testing/collections/test_collection/page1.md b/lib/web/docusaurus/for_testing/collections/test_collection/page1.md deleted file mode 100644 index b16b09d6..00000000 --- a/lib/web/docusaurus/for_testing/collections/test_collection/page1.md +++ /dev/null @@ -1,21 +0,0 @@ -# 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) - diff --git a/lib/web/docusaurus/for_testing/collections/test_collection/page2.md b/lib/web/docusaurus/for_testing/collections/test_collection/page2.md deleted file mode 100644 index dcd8bc07..00000000 --- a/lib/web/docusaurus/for_testing/collections/test_collection/page2.md +++ /dev/null @@ -1,30 +0,0 @@ -# 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) - diff --git a/lib/web/docusaurus/for_testing/collections/test_collection/page3.md b/lib/web/docusaurus/for_testing/collections/test_collection/page3.md deleted file mode 100644 index 8c3144a6..00000000 --- a/lib/web/docusaurus/for_testing/collections/test_collection/page3.md +++ /dev/null @@ -1,39 +0,0 @@ -# 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) - diff --git a/lib/web/docusaurus/for_testing/collections/test_collection/page4.md b/lib/web/docusaurus/for_testing/collections/test_collection/page4.md deleted file mode 100644 index 556c1ef2..00000000 --- a/lib/web/docusaurus/for_testing/collections/test_collection/page4.md +++ /dev/null @@ -1,37 +0,0 @@ -# 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) - diff --git a/lib/web/docusaurus/for_testing/collections/test_collection/page5.md b/lib/web/docusaurus/for_testing/collections/test_collection/page5.md deleted file mode 100644 index 4953fc4c..00000000 --- a/lib/web/docusaurus/for_testing/collections/test_collection/page5.md +++ /dev/null @@ -1,43 +0,0 @@ -# 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) - diff --git a/lib/web/docusaurus/for_testing/collections/test_collection/page6.md b/lib/web/docusaurus/for_testing/collections/test_collection/page6.md deleted file mode 100644 index d38fff10..00000000 --- a/lib/web/docusaurus/for_testing/collections/test_collection/page6.md +++ /dev/null @@ -1,44 +0,0 @@ -# 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) - diff --git a/lib/web/docusaurus/for_testing/collections/test_collection/page7.md b/lib/web/docusaurus/for_testing/collections/test_collection/page7.md deleted file mode 100644 index 3b8af335..00000000 --- a/lib/web/docusaurus/for_testing/collections/test_collection/page7.md +++ /dev/null @@ -1,37 +0,0 @@ -# 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) - diff --git a/lib/web/docusaurus/for_testing/ebooks/test_site/config.hero b/lib/web/docusaurus/for_testing/ebooks/test_site/config.hero deleted file mode 100644 index 24cd6adf..00000000 --- a/lib/web/docusaurus/for_testing/ebooks/test_site/config.hero +++ /dev/null @@ -1,16 +0,0 @@ -!!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" - diff --git a/lib/web/docusaurus/for_testing/ebooks/test_site/include.hero b/lib/web/docusaurus/for_testing/ebooks/test_site/include.hero deleted file mode 100644 index fab4c15d..00000000 --- a/lib/web/docusaurus/for_testing/ebooks/test_site/include.hero +++ /dev/null @@ -1,4 +0,0 @@ -!!docusaurus.define name:'test_site' - -!!doctree.export include:true - diff --git a/lib/web/docusaurus/for_testing/ebooks/test_site/menus.hero b/lib/web/docusaurus/for_testing/ebooks/test_site/menus.hero deleted file mode 100644 index 0c4c601a..00000000 --- a/lib/web/docusaurus/for_testing/ebooks/test_site/menus.hero +++ /dev/null @@ -1,33 +0,0 @@ -// 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" - diff --git a/lib/web/docusaurus/for_testing/ebooks/test_site/pages.heroscript b/lib/web/docusaurus/for_testing/ebooks/test_site/pages.heroscript deleted file mode 100644 index 8711c9c5..00000000 --- a/lib/web/docusaurus/for_testing/ebooks/test_site/pages.heroscript +++ /dev/null @@ -1,34 +0,0 @@ -// 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" - diff --git a/lib/web/docusaurus/for_testing/ebooks/test_site/scan.hero b/lib/web/docusaurus/for_testing/ebooks/test_site/scan.hero deleted file mode 100644 index f93336b0..00000000 --- a/lib/web/docusaurus/for_testing/ebooks/test_site/scan.hero +++ /dev/null @@ -1,2 +0,0 @@ -!!doctree.scan path:"../../collections/test_collection" - diff --git a/lib/web/docusaurus/model_configuration_test.v b/lib/web/docusaurus/model_configuration_test.v new file mode 100644 index 00000000..1bc61bdc --- /dev/null +++ b/lib/web/docusaurus/model_configuration_test.v @@ -0,0 +1,106 @@ +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.') +} diff --git a/lib/web/docusaurus/play.v b/lib/web/docusaurus/play.v index 67ddede8..14b6f0f1 100644 --- a/lib/web/docusaurus/play.v +++ b/lib/web/docusaurus/play.v @@ -1,8 +1,6 @@ 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) ! { @@ -10,78 +8,62 @@ pub fn play(mut plbook PlayBook) ! { return } - mut dsite := process_define(mut plbook)! + // 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)! + dsite.generate()! - process_build(mut plbook, mut dsite)! - process_publish(mut plbook, mut dsite)! - process_dev(mut plbook, mut dsite)! + 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 + } 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)! -} diff --git a/lib/web/site/ai_instructions.md b/lib/web/site/ai_instructions.md new file mode 100644 index 00000000..8db0c377 --- /dev/null +++ b/lib/web/site/ai_instructions.md @@ -0,0 +1,536 @@ +# 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. \ No newline at end of file diff --git a/lib/web/site/factory.v b/lib/web/site/factory.v new file mode 100644 index 00000000..6d0cb5fc --- /dev/null +++ b/lib/web/site/factory.v @@ -0,0 +1,48 @@ +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() +} diff --git a/lib/web/site/model_page.v b/lib/web/site/model_page.v new file mode 100644 index 00000000..30bfeaed --- /dev/null +++ b/lib/web/site/model_page.v @@ -0,0 +1,16 @@ +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 +} diff --git a/lib/web/site/model_site_section.v b/lib/web/site/model_site_section.v new file mode 100644 index 00000000..df491fa0 --- /dev/null +++ b/lib/web/site/model_site_section.v @@ -0,0 +1,18 @@ +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 +} diff --git a/lib/web/doctree/meta/model_siteconfig.v b/lib/web/site/model_siteconfig.v similarity index 56% rename from lib/web/doctree/meta/model_siteconfig.v rename to lib/web/site/model_siteconfig.v index fce68ed0..c7a3d04c 100644 --- a/lib/web/doctree/meta/model_siteconfig.v +++ b/lib/web/site/model_siteconfig.v @@ -1,4 +1,4 @@ -module meta +module site import os // Combined config structure @@ -15,6 +15,7 @@ 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:) @@ -23,6 +24,21 @@ 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 @@ -62,3 +78,20 @@ 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 +} diff --git a/lib/web/site/play.v b/lib/web/site/play.v new file mode 100644 index 00000000..907185ad --- /dev/null +++ b/lib/web/site/play.v @@ -0,0 +1,225 @@ +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 + } +} diff --git a/lib/web/site/play_page.v b/lib/web/site/play_page.v new file mode 100644 index 00000000..333293df --- /dev/null +++ b/lib/web/site/play_page.v @@ -0,0 +1,135 @@ +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; + } +} diff --git a/lib/web/site/readme.md b/lib/web/site/readme.md new file mode 100644 index 00000000..40670c8a --- /dev/null +++ b/lib/web/site/readme.md @@ -0,0 +1,328 @@ +# 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: