#!/usr/bin/env nu # NCL Index Generator # # Scans content directories for per-post *.ncl metadata files and generates # _index.ncl files at two levels: # # {content_dir}/{type}/{lang}/{category}/_index.ncl — category index # {content_dir}/{type}/{lang}/_index.ncl — language index (flat array) # # Both are valid Nickel arrays exportable via: # nickel export _index.ncl --format json # # Usage: # nu generate-ncl-index.nu [--content-dir PATH] [--type TYPE] [--lang LANG] [--dry-run] [--force] [--verbose] # # Environment: # SITE_CONTENT_PATH — overrides --content-dir default # Files to skip when scanning for post metadata inside category dirs. const SKIP_FILES = ["_index.ncl"] # --------------------------------------------------------------------------- # Entry point # --------------------------------------------------------------------------- def main [ --content-dir: string = "" # Root content directory. Defaults to $SITE_CONTENT_PATH or site/content --type: string = "" # Filter to one content type (e.g. blog, recipes) --lang: string = "" # Filter to one language (e.g. en, es) --dry-run # Print what would be written without writing files --force # Overwrite existing _index.ncl even if content is identical --validate # After generating, check slug/id uniqueness per lang root --verbose # Print per-file details ] { let root = resolve_content_dir $content_dir if not ($root | path exists) { error make { msg: $"Content directory not found: ($root)" } } print $"(ansi cyan)NCL Index Generator(ansi reset) — ($root)" if $dry_run { print $"(ansi yellow)[dry-run] no files will be written(ansi reset)" } let types = discover_types $root $type if ($types | is-empty) { print "(ansi yellow)No content types found.(ansi reset)" return } mut total_written = 0 mut total_skipped = 0 mut total_errors = 0 for ct in $types { let langs = discover_langs $root $ct $lang for language in $langs { let result = process_lang $root $ct $language $dry_run $force $verbose $total_written = $total_written + $result.written $total_skipped = $total_skipped + $result.skipped $total_errors = $total_errors + $result.errors } } print "" print $"(ansi green)Done(ansi reset) — written: ($total_written) skipped: ($total_skipped) errors: ($total_errors)" if $validate and not $dry_run { print "" validate_uniqueness $root $type $lang } } # --------------------------------------------------------------------------- # Discovery helpers # --------------------------------------------------------------------------- def resolve_content_dir [arg: string] { if $arg != "" { return $arg } $env.SITE_CONTENT_PATH? | default "site/content" } def discover_types [root: string, filter: string] { let candidates = (ls $root) | where type == "dir" | get name | each { |p| $p | path basename } | where { |d| not ($d | str starts-with ".") } if $filter != "" { $candidates | where { |d| $d == $filter } } else { $candidates | where { |ct| (ls $"($root)/($ct)") | any { |e| $e.type == "dir" } } } } def discover_langs [root: string, ct: string, filter: string] { let candidates = (ls $"($root)/($ct)") | where type == "dir" | get name | each { |p| $p | path basename } | where { |d| not ($d | str starts-with ".") } if $filter != "" { $candidates | where { |d| $d == $filter } } else { $candidates } } def discover_categories [lang_dir: string] { if not ($lang_dir | path exists) { return [] } (ls $lang_dir) | where type == "dir" | get name | each { |p| $p | path basename } | where { |d| not ($d | str starts-with ".") } } def discover_post_ncl [cat_dir: string] { if not ($cat_dir | path exists) { return [] } # Flat *.ncl files (legacy single-file posts) let flat = (ls $cat_dir) | where type == "file" | where { |e| ($e.name | path parse | get extension) == "ncl" } | get name | each { |p| $p | path basename } | where { |f| not ($SKIP_FILES | any { |s| $s == $f }) } # Page-bundle directories: subdirs containing index.ncl let bundles = (ls $cat_dir) | where type == "dir" | get name | each { |p| $p | path basename } | where { |d| not ($d | str starts-with ".") } | where { |d| ($"($cat_dir)/($d)/index.ncl" | path exists) } | each { |d| $"($d)/index.ncl" } ($flat | append $bundles) | sort } # --------------------------------------------------------------------------- # Per-language processing # --------------------------------------------------------------------------- def process_lang [root: string, ct: string, lang: string, dry_run: bool, force: bool, verbose: bool] { let lang_dir = $"($root)/($ct)/($lang)" let categories = discover_categories $lang_dir if ($categories | is-empty) { if $verbose { print $" ($ct)/($lang): no category subdirectories — skipping" } return { written: 0, skipped: 0, errors: 0 } } print $" (ansi blue)($ct)/($lang)(ansi reset)" mut written = 0 mut skipped = 0 mut errors = 0 mut lang_cat_dirs: list = [] for cat in $categories { let cat_dir = $"($lang_dir)/($cat)" let posts = discover_post_ncl $cat_dir if ($posts | is-empty) { if $verbose { print $" ($cat)/: no *.ncl post files — skipping" } continue } # Imports relative to the category _index.ncl: ./slug.ncl let cat_imports = $posts | each { |f| $" import \"./($f)\"" } let cat_index = $"($cat_dir)/_index.ncl" let cat_content = build_category_index $ct $lang $cat $cat_imports let outcome = write_index $cat_index $cat_content $dry_run $force if $outcome == "written" { $written = $written + 1 } if $outcome == "skipped" { $skipped = $skipped + 1 } if $outcome == "error" { $errors = $errors + 1 } print $" ($cat)/: ($posts | length) posts → _index.ncl ($outcome)" $lang_cat_dirs = ($lang_cat_dirs | append $cat) } # Language-level _index.ncl flattens all category arrays. if not ($lang_cat_dirs | is-empty) { let lang_imports = $lang_cat_dirs | each { |c| $" import \"./($c)/_index.ncl\"" } let lang_index = $"($lang_dir)/_index.ncl" let lang_content = build_lang_index $ct $lang $lang_imports let outcome = write_index $lang_index $lang_content $dry_run $force if $outcome == "written" { $written = $written + 1 } if $outcome == "skipped" { $skipped = $skipped + 1 } if $outcome == "error" { $errors = $errors + 1 } let n_cats = $lang_cat_dirs | length print $" _index.ncl — lang root, ($n_cats) categories → ($outcome)" } { written: $written, skipped: $skipped, errors: $errors } } # --------------------------------------------------------------------------- # NCL content builders # --------------------------------------------------------------------------- def build_category_index [ct: string, lang: string, cat: string, imports: list] { let header = build_header $"($ct)/($lang)/($cat)" let body = $imports | str join ",\n" $"($header)\n\n[\n($body),\n]\n" } def build_lang_index [ct: string, lang: string, cat_imports: list] { let header = build_header $"($ct)/($lang)" let body = $cat_imports | str join ",\n" # std.array.flatten collapses Array (Array T) -> Array T. # Each category _index.ncl is an Array, so this produces one flat list. $"($header)\n\nstd.array.flatten [\n($body),\n]\n" } def build_header [scope: string] { let ts = (date now | format date "%Y-%m-%dT%H:%M:%SZ") $"# AUTO-GENERATED — do not edit manually # Scope: ($scope) # Updated: ($ts) # Regen: nu scripts/content/generate-ncl-index.nu" } # --------------------------------------------------------------------------- # File I/O # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- # Uniqueness validation # --------------------------------------------------------------------------- # Export each lang-level _index.ncl via nickel and check slug + id uniqueness. # Duplicates are reported with the conflicting values. def validate_uniqueness [root: string, type_filter: string, lang_filter: string] { print $"(ansi cyan)Validating slug/id uniqueness...(ansi reset)" let types = discover_types $root $type_filter mut all_ok = true for ct in $types { let langs = discover_langs $root $ct $lang_filter for language in $langs { let index_path = $"($root)/($ct)/($language)/_index.ncl" if not ($index_path | path exists) { continue } let result = validate_lang_index $index_path $ct $language if not $result { $all_ok = false } } } if $all_ok { print $"(ansi green)All slugs and IDs are unique.(ansi reset)" } else { print $"(ansi red)Uniqueness errors found — fix duplicates before publishing.(ansi reset)" } } def validate_lang_index [index_path: string, ct: string, lang: string] { let json_result = try { nickel export $index_path --format json | from json } catch { |e| print $"(ansi red) ($ct)/($lang): nickel export failed — ($e.msg)(ansi reset)" return false } # Nushell converts JSON arrays to tables ("table<...>") or lists. let desc = $json_result | describe if not (($desc | str starts-with "list") or ($desc | str starts-with "table")) { print $"(ansi yellow) ($ct)/($lang): export is not an array — skipping uniqueness check(ansi reset)" return true } mut ok = true # Check slug duplicates let slug_dups = find_duplicates $json_result "slug" if not ($slug_dups | is-empty) { for dup in $slug_dups { print $"(ansi red) ($ct)/($lang): duplicate slug '($dup)'(ansi reset)" } $ok = false } # Check id duplicates (only entries that have an explicit id field) let with_id = $json_result | where { |e| ($e | columns | any { |c| $c == "id" }) } if not ($with_id | is-empty) { let id_dups = find_duplicates $with_id "id" if not ($id_dups | is-empty) { for dup in $id_dups { print $"(ansi red) ($ct)/($lang): duplicate id '($dup)'(ansi reset)" } $ok = false } } if $ok { let n = $json_result | length print $" ($ct)/($lang): ($n) entries — ok" } $ok } # Returns list of values that appear more than once in column `field`. def find_duplicates [records: list, field: string] { $records | each { |r| try { $r | get $field } catch { null } } | where { |v| $v != null } | sort | group-by { |v| $v } | transpose key entries | where { |row| ($row.entries | length) > 1 } | get key } # --------------------------------------------------------------------------- # File I/O # --------------------------------------------------------------------------- def write_index [path: string, content: string, dry_run: bool, force: bool] { if $dry_run { return "written" } if (not $force) and ($path | path exists) { let existing = open --raw $path if $existing == $content { return "skipped" } } try { $content | save --force $path "written" } catch { |e| print $"(ansi red) Error writing ($path): ($e.msg)(ansi reset)" "error" } }