website-htmx-rustelo-code/scripts/content/generate-ncl-index.nu

345 lines
12 KiB
Text
Raw Permalink Normal View History

2026-07-10 03:44:13 +01:00
#!/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<string> = []
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<string>] {
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<string>] {
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"
}
}