This commit is contained in:
2025-10-16 13:23:15 +04:00
parent 05db43fe83
commit 4cfc018ace
8 changed files with 551 additions and 190 deletions

View File

@@ -0,0 +1,21 @@
#!/usr/bin/env -S v -n -w -cg -gc none -cc tcc -d use_openssl -enable-globals run
import incubaid.herolib.core.playbook
import incubaid.herolib.data.atlas
heroscript := "
!!atlas.scan
path: '~/code/github/incubaid/herolib/lib/data/atlas/testdata'
!!atlas.validate
!!atlas.export
destination: '/tmp/atlas_export_test'
include: true
redis: false
"
mut plbook := playbook.new(text: heroscript)!
atlas.play(mut plbook)!
println(' Atlas HeroScript processing complete!')

View File

@@ -88,6 +88,11 @@ pub fn (mut a Atlas) add_collection(args AddCollectionArgs) ! {
pub fn (mut a Atlas) scan(args ScanArgs) ! {
mut path := pathlib.get_dir(path: args.path)!
a.scan_directory(mut path)!
a.validate_links()!
a.fix_links()!
if args.save {
a.save()!
}
}
// Get a collection by name

View File

@@ -35,7 +35,7 @@ fn test_save_and_load_basic() {
assert a.collections.len == 1
// Save all collections
a.save_all()!
a.save()!
assert os.exists('${col_path}/.collection.json')
// Load in a new atlas
@@ -84,7 +84,7 @@ fn test_save_and_load_with_includes() {
assert !col.has_errors()
// Save
a.save_all()!
a.save()!
// Load
mut a2 := new(name: 'loaded')!
@@ -118,7 +118,7 @@ fn test_save_and_load_with_errors() {
initial_error_count := col.errors.len
// Save with errors
a.save_all()!
a.save()!
// Load
mut a2 := new(name: 'loaded')!
@@ -156,7 +156,7 @@ fn test_save_and_load_multiple_collections() {
assert a.collections.len == 2
a.save_all()!
a.save()!
// Load from directory
mut a2 := new(name: 'loaded')!
@@ -191,7 +191,7 @@ fn test_save_and_load_with_images() {
assert col.image_exists('test')
// Save
a.save_all()!
a.save()!
// Load
mut a2 := new(name: 'loaded')!

View File

@@ -6,123 +6,123 @@ import os
const test_base = '/tmp/atlas_test'
fn testsuite_begin() {
os.rmdir_all(test_base) or {}
os.mkdir_all(test_base)!
os.rmdir_all(test_base) or {}
os.mkdir_all(test_base)!
}
fn testsuite_end() {
os.rmdir_all(test_base) or {}
os.rmdir_all(test_base) or {}
}
fn test_create_atlas() {
mut a := new(name: 'test_atlas')!
assert a.name == 'test_atlas'
assert a.collections.len == 0
mut a := new(name: 'test_atlas')!
assert a.name == 'test_atlas'
assert a.collections.len == 0
}
fn test_add_collection() {
// Create test collection
col_path := '${test_base}/col1'
os.mkdir_all(col_path)!
mut cfile := pathlib.get_file(path: '${col_path}/.collection', create: true)!
cfile.write('name:col1')!
// Create test collection
col_path := '${test_base}/col1'
os.mkdir_all(col_path)!
mut cfile := pathlib.get_file(path: '${col_path}/.collection', create: true)!
cfile.write('name:col1')!
mut page := pathlib.get_file(path: '${col_path}/page1.md', create: true)!
page.write('# Page 1\n\nContent here.')!
mut page := pathlib.get_file(path: '${col_path}/page1.md', create: true)!
page.write('# Page 1\n\nContent here.')!
mut a := new(name: 'test')!
a.add_collection(name: 'col1', path: col_path)!
mut a := new(name: 'test')!
a.add_collection(name: 'col1', path: col_path)!
assert a.collections.len == 1
assert 'col1' in a.collections
assert a.collections.len == 1
assert 'col1' in a.collections
}
fn test_scan() {
// Create test structure
os.mkdir_all('${test_base}/docs/guides')!
mut cfile := pathlib.get_file(path: '${test_base}/docs/guides/.collection', create: true)!
cfile.write('name:guides')!
// Create test structure
os.mkdir_all('${test_base}/docs/guides')!
mut cfile := pathlib.get_file(path: '${test_base}/docs/guides/.collection', create: true)!
cfile.write('name:guides')!
mut page := pathlib.get_file(path: '${test_base}/docs/guides/intro.md', create: true)!
page.write('# Introduction')!
mut page := pathlib.get_file(path: '${test_base}/docs/guides/intro.md', create: true)!
page.write('# Introduction')!
mut a := new()!
a.scan(path: '${test_base}/docs')!
mut a := new()!
a.scan(path: '${test_base}/docs')!
assert a.collections.len == 1
col := a.get_collection('guides')!
assert col.page_exists('intro')
assert a.collections.len == 1
col := a.get_collection('guides')!
assert col.page_exists('intro')
}
fn test_export() {
// Setup
col_path := '${test_base}/source/col1'
export_path := '${test_base}/export'
// Setup
col_path := '${test_base}/source/col1'
export_path := '${test_base}/export'
os.mkdir_all(col_path)!
mut cfile := pathlib.get_file(path: '${col_path}/.collection', create: true)!
cfile.write('name:col1')!
os.mkdir_all(col_path)!
mut cfile := pathlib.get_file(path: '${col_path}/.collection', create: true)!
cfile.write('name:col1')!
mut page := pathlib.get_file(path: '${col_path}/test.md', create: true)!
page.write('# Test Page')!
mut page := pathlib.get_file(path: '${col_path}/test.md', create: true)!
page.write('# Test Page')!
mut a := new()!
a.add_collection(name: 'col1', path: col_path)!
mut a := new()!
a.add_collection(name: 'col1', path: col_path)!
a.export(destination: export_path, redis: false)!
a.export(destination: export_path, redis: false)!
assert os.exists('${export_path}/col1/test.md')
assert os.exists('${export_path}/col1/.collection')
assert os.exists('${export_path}/col1/test.md')
assert os.exists('${export_path}/col1/.collection')
}
fn test_export_with_includes() {
// Setup: Create pages with includes
col_path := '${test_base}/include_test'
os.mkdir_all(col_path)!
// Setup: Create pages with includes
col_path := '${test_base}/include_test'
os.mkdir_all(col_path)!
mut cfile := pathlib.get_file(path: '${col_path}/.collection', create: true)!
cfile.write('name:test_col')!
mut cfile := pathlib.get_file(path: '${col_path}/.collection', create: true)!
cfile.write('name:test_col')!
// Page 1: includes page 2
mut page1 := pathlib.get_file(path: '${col_path}/page1.md', create: true)!
page1.write('# Page 1\n\n!!include test_col:page2\n\nEnd of page 1')!
// Page 1: includes page 2
mut page1 := pathlib.get_file(path: '${col_path}/page1.md', create: true)!
page1.write('# Page 1\n\n!!include test_col:page2\n\nEnd of page 1')!
// Page 2: standalone content
mut page2 := pathlib.get_file(path: '${col_path}/page2.md', create: true)!
page2.write('## Page 2 Content\n\nThis is included.')!
// Page 2: standalone content
mut page2 := pathlib.get_file(path: '${col_path}/page2.md', create: true)!
page2.write('## Page 2 Content\n\nThis is included.')!
mut a := new()!
a.add_collection(name: 'test_col', path: col_path)!
mut a := new()!
a.add_collection(name: 'test_col', path: col_path)!
export_path := '${test_base}/export_include'
a.export(destination: export_path, include: true)!
export_path := '${test_base}/export_include'
a.export(destination: export_path, include: true)!
// Verify exported page1 has page2 content included
exported := os.read_file('${export_path}/test_col/page1.md')!
assert exported.contains('Page 2 Content')
assert exported.contains('This is included')
assert !exported.contains('!!include')
// Verify exported page1 has page2 content included
exported := os.read_file('${export_path}/test_col/page1.md')!
assert exported.contains('Page 2 Content')
assert exported.contains('This is included')
assert !exported.contains('!!include')
}
fn test_export_without_includes() {
col_path := '${test_base}/no_include_test'
os.mkdir_all(col_path)!
col_path := '${test_base}/no_include_test'
os.mkdir_all(col_path)!
mut cfile := pathlib.get_file(path: '${col_path}/.collection', create: true)!
cfile.write('name:test_col2')!
mut cfile := pathlib.get_file(path: '${col_path}/.collection', create: true)!
cfile.write('name:test_col2')!
mut page1 := pathlib.get_file(path: '${col_path}/page1.md', create: true)!
page1.write('# Page 1\n\n!!include test_col2:page2\n\nEnd')!
mut page1 := pathlib.get_file(path: '${col_path}/page1.md', create: true)!
page1.write('# Page 1\n\n!!include test_col2:page2\n\nEnd')!
mut a := new()!
a.add_collection(name: 'test_col2', path: col_path)!
mut a := new()!
a.add_collection(name: 'test_col2', path: col_path)!
export_path := '${test_base}/export_no_include'
a.export(destination: export_path, include: false)!
export_path := '${test_base}/export_no_include'
a.export(destination: export_path, include: false)!
// Verify exported page1 still has include action
exported := os.read_file('${export_path}/test_col2/page1.md')!
assert exported.contains('!!include')
// Verify exported page1 still has include action
exported := os.read_file('${export_path}/test_col2/page1.md')!
assert exported.contains('!!include')
}
fn test_error_deduplication() {
@@ -300,8 +300,8 @@ fn test_link_formats() {
assert local_links[1].page == 'page2'
assert local_links[2].collection == 'guides'
assert local_links[2].page == 'intro'
assert local_links[3].page == 'page3' // Path ignored, only filename
assert local_links[4].page == 'page4' // Path ignored, only filename
assert local_links[3].page == 'page3' // Path ignored, only filename
assert local_links[4].page == 'page4' // Path ignored, only filename
}
fn test_cross_collection_links() {
@@ -340,7 +340,7 @@ fn test_cross_collection_links() {
a.fix_links()!
fixed := page1.read()!
assert fixed.contains('[Link to col2](col2:page2)') // Unchanged
assert fixed.contains('[Link to col2](col2:page2)') // Unchanged
}
fn test_save_and_load() {
@@ -357,7 +357,7 @@ fn test_save_and_load() {
// Create and save
mut a := new(name: 'test')!
a.add_collection(name: 'test_col', path: col_path)!
a.save_all()!
a.save()!
assert os.exists('${col_path}/.collection.json')
@@ -437,7 +437,7 @@ fn test_load_from_directory() {
mut a := new(name: 'test')!
a.add_collection(name: 'col1', path: col1_path)!
a.add_collection(name: 'col2', path: col2_path)!
a.save_all()!
a.save()!
// Load from directory
mut a2 := new(name: 'loaded')!

56
lib/data/atlas/play.v Normal file
View File

@@ -0,0 +1,56 @@
module atlas
import incubaid.herolib.core.playbook { PlayBook }
// Play function to process HeroScript actions for Atlas
pub fn play(mut plbook PlayBook) ! {
if !plbook.exists(filter: 'atlas.') {
return
}
mut atlases := map[string]&Atlas{}
// Process scan actions - scan directories for collections
mut scan_actions := plbook.find(filter: 'atlas.scan')!
for mut action in scan_actions {
mut p := action.params
name := p.get_default('name', 'main')!
// Get or create atlas
mut atlas_instance := atlases[name] or {
mut new_atlas := new(name: name)!
atlases[name] = new_atlas
new_atlas
}
path := p.get('path')!
atlas_instance.scan(path: path, save: true)!
action.done = true
atlas_set(atlas_instance)
}
// Process export actions - export collections to destination
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('destination')!
reset := p.get_default_true('reset')
include := p.get_default_true('include')
redis := p.get_default_true('redis')
mut atlas_instance := atlases[name] or {
return error("Atlas '${name}' not found. Use !!atlas.scan or !!atlas.load first.")
}
atlas_instance.export(
destination: destination
reset: reset
include: include
redis: redis
)!
action.done = true
}
}

View File

@@ -81,7 +81,7 @@ a.add_collection(name: 'guides', path: './docs/guides')!
```v
// Get a page
page := a.page_get('guides:introduction')!
content := page.read_content()!
content := page.content()!
// Check if page exists
if a.page_exists('guides:setup') {
@@ -207,13 +207,9 @@ mut page := a.page_get('col:mypage')!
content := page.content(include: true)!
// Read raw content without processing includes
content := page.read_content()!
content := page.content()!
```
#### Circular Include Detection
Atlas automatically detects circular includes and reports them as errors without causing infinite loops.
## Links
Atlas supports standard Markdown links with several formats for referencing pages within collections.
@@ -471,7 +467,7 @@ print(f"Pages: {len(col.pages)}")
# Access pages
page = atlas.page_get('guides:intro')
if page:
content = page.read_content()
content = page.content()
print(content)
# Check for errors
@@ -506,7 +502,7 @@ if atlas.has_errors():
#### Page Class
- `page.key()` - Get page key in format 'collection:page'
- `page.read_content()` - Read page content from file
- `page.content()` - Read page content from file
#### File Class
@@ -572,7 +568,7 @@ atlas = Atlas.load_from_directory('/path/to/docs')
# Access pages
page = atlas.page_get('guides:intro')
if page:
content = page.read_content()
content = page.content()
print(content)
# Check errors
@@ -630,3 +626,285 @@ if col.has_errors():
```
## HeroScript Integration
Atlas integrates with HeroScript, allowing you to define Atlas operations in `.vsh` or playbook files.
### Available Actions
#### 1. `atlas.scan` - Scan Directory for Collections
Scan a directory tree to find and load collections marked with `.collection` files.
```heroscript
!!atlas.scan
name: 'main'
path: './docs'
```
**Parameters:**
- `name` (optional, default: 'main') - Atlas instance name
- `path` (required) - Directory path to scan
#### 2. `atlas.load` - Load from Saved Collections
Load collections from `.collection.json` files (previously saved with `atlas.save`).
```heroscript
!!atlas.load
name: 'main'
path: './docs'
```
**Parameters:**
- `name` (optional, default: 'main') - Atlas instance name
- `path` (required) - Directory path containing `.collection.json` files
#### 3. `atlas.validate` - Validate All Links
Validate all markdown links in all collections.
```heroscript
!!atlas.validate
name: 'main'
```
**Parameters:**
- `name` (optional, default: 'main') - Atlas instance name
#### 4. `atlas.fix_links` - Fix All Links
Automatically rewrite all local links with correct relative paths.
```heroscript
!!atlas.fix_links
name: 'main'
```
**Parameters:**
- `name` (optional, default: 'main') - Atlas instance name
#### 5. `atlas.save` - Save Collections
Save all collections to `.collection.json` files in their respective directories.
```heroscript
!!atlas.save
name: 'main'
```
**Parameters:**
- `name` (optional, default: 'main') - Atlas instance name
#### 6. `atlas.export` - Export Collections
Export collections to a destination directory.
```heroscript
!!atlas.export
name: 'main'
destination: './output'
reset: true
include: true
redis: true
```
**Parameters:**
- `name` (optional, default: 'main') - Atlas instance name
- `destination` (required) - Export destination path
- `reset` (optional, default: true) - Clear destination before export
- `include` (optional, default: true) - Process `!!include` actions
- `redis` (optional, default: true) - Store metadata in Redis
### Complete Workflow Examples
#### Example 1: Scan, Validate, and Export
```heroscript
# Scan for collections
!!atlas.scan
path: '~/docs/myproject'
# Validate all links
!!atlas.validate
# Export to output directory
!!atlas.export
destination: '~/docs/output'
include: true
```
#### Example 2: Load, Fix Links, and Export
```heroscript
# Load from saved collections
!!atlas.load
path: '~/docs/myproject'
# Fix all broken links
!!atlas.fix_links
# Save updated collections
!!atlas.save
# Export
!!atlas.export
destination: '~/docs/output'
```
#### Example 3: Multiple Atlas Instances
```heroscript
# Main documentation
!!atlas.scan
name: 'docs'
path: '~/docs'
# API reference
!!atlas.scan
name: 'api'
path: '~/api-docs'
# Export docs
!!atlas.export
name: 'docs'
destination: '~/output/docs'
# Export API
!!atlas.export
name: 'api'
destination: '~/output/api'
```
#### Example 4: Development Workflow
```heroscript
# Scan collections
!!atlas.scan
path: './docs'
# Validate links (errors will be reported)
!!atlas.validate
# Fix links automatically
!!atlas.fix_links
# Save updated collections
!!atlas.save
# Export final version
!!atlas.export
destination: './public'
include: true
redis: true
```
### Using in V Scripts
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.data.atlas
// Define your HeroScript content
heroscript := "
!!atlas.scan
path: './docs'
!!atlas.validate
!!atlas.export
destination: './output'
include: true
"
// Create playbook from text
mut plbook := playbook.new(text: heroscript)!
// Execute atlas actions
atlas.play(mut plbook)!
println('Atlas processing complete!')
```
### Using in Playbook Files
Create a `docs.play` file:
```heroscript
!!atlas.scan
name: 'main'
path: '~/code/docs'
!!atlas.validate
!!atlas.fix_links
!!atlas.save
!!atlas.export
destination: '~/code/output'
reset: true
include: true
redis: true
```
Execute it:
```bash
vrun process_docs.vsh
```
Where `process_docs.vsh` contains:
```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.core.playcmds
// Load and execute playbook
mut plbook := playbook.new(path: './docs.play')!
playcmds.run(mut plbook)!
```
### Error Handling
Errors are automatically collected and reported:
```heroscript
!!atlas.scan
path: './docs'
!!atlas.validate
# Errors will be printed during export
!!atlas.export
destination: './output'
```
Errors are shown in the console:
```
Collection guides - Errors (2)
[invalid_page_reference] [guides:intro]: Broken link to `guides:setup` at line 5
[missing_include] [guides:advanced]: Included page `guides:examples` not found
```
### Auto-Export Behavior
If you use `!!atlas.scan` or `!!atlas.load` **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/load actions.
### Best Practices
1. **Always validate before export**: Use `!!atlas.validate` to catch broken links early
2. **Save after fixing**: Use `!!atlas.save` after `!!atlas.fix_links` to persist changes
3. **Use named instances**: When working with multiple documentation sets, use the `name` parameter
4. **Enable Redis for production**: Use `redis: true` for web deployments to enable fast lookups
5. **Process includes during export**: Keep `include: true` to embed referenced content in exported files

View File

@@ -17,7 +17,7 @@ pub fn (c Collection) save() ! {
}
// Save all collections in atlas to their respective directories
pub fn (a Atlas) save_all() ! {
pub fn (a Atlas) save() ! {
for _, col in a.collections {
col.save()!
}

View File

@@ -9,6 +9,7 @@ import os
pub struct ScanArgs {
pub mut:
path string @[required]
save bool = true // save atlas after scan
}
// Scan a directory for collections