345 lines
12 KiB
Text
345 lines
12 KiB
Text
|
|
#!/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"
|
||
|
|
}
|
||
|
|
}
|