website-htmx-rustelo-code/scripts/content/build-ncl-json.nu
2026-07-10 03:44:13 +01:00

232 lines
9 KiB
Text

#!/usr/bin/env nu
# NCL → index.json Builder
#
# Exports each {type}/{lang}/_index.ncl via `nickel export` and writes
# the result to the server content output dirs with the expected envelope:
#
# { generated_at, content_type, language, posts: [...] }
#
# Output locations (both written when they exist):
# $SITE_SERVER_CONTENT_ROOT/{type}/{lang}/index.json (SSR filesystem read)
# $SITE_PUBLIC_PATH/r/{type}/{lang}/index.json (static asset for WASM)
#
# Usage:
# nu build-ncl-json.nu [--content-dir PATH] [--output-dir PATH]
# [--public-dir PATH] [--type TYPE] [--lang LANG]
# [--dry-run] [--verbose]
#
# Environment variables (override defaults):
# SITE_CONTENT_PATH — content root (default: site/content)
# SITE_SERVER_CONTENT_ROOT — SSR output root (default: target/site/r)
# SITE_PUBLIC_PATH — public assets root (default: public)
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def main [
--content-dir: string = "" # Content root
--output-dir: string = "" # SSR output root (target/site/r)
--public-dir: string = "" # Public assets dir (public)
--type: string = "" # Filter to one content type
--lang: string = "" # Filter to one language
--dry-run # Print what would be written without writing
--verbose # Verbose output per post
] {
let content_root = resolve_env "SITE_CONTENT_PATH" $content_dir "site/content"
let ssr_root = resolve_env "SITE_SERVER_CONTENT_ROOT" $output_dir "target/site/r"
let public_root = resolve_env "SITE_PUBLIC_PATH" $public_dir "public"
if not ($content_root | path exists) {
error make { msg: $"Content directory not found: ($content_root)" }
}
print $"(ansi cyan)NCL → JSON Builder(ansi reset)"
print $" content : ($content_root)"
print $" ssr-out : ($ssr_root)"
print $" pub-out : ($public_root)/r"
if $dry_run { print $"(ansi yellow)[dry-run](ansi reset)" }
let types = discover_dirs $content_root $type
if ($types | is-empty) { print "No content types found."; return }
mut total_written = 0
mut total_skipped = 0
mut total_errors = 0
for ct in $types {
let langs = discover_dirs $"($content_root)/($ct)" $lang
for language in $langs {
let result = export_lang $content_root $ct $language $ssr_root $public_root $dry_run $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 $total_errors > 0 { exit 1 }
}
# ---------------------------------------------------------------------------
# Per-language export
# ---------------------------------------------------------------------------
def export_lang [
content_root: string, ct: string, lang: string,
ssr_root: string, public_root: string,
dry_run: bool, verbose: bool
] {
let ncl_index = $"($content_root)/($ct)/($lang)/_index.ncl"
if not ($ncl_index | path exists) {
if $verbose { print $" skip ($ct)/($lang): no _index.ncl" }
return { written: 0, skipped: 1, errors: 0 }
}
print $" (ansi blue)($ct)/($lang)(ansi reset)"
# Export NCL → raw JSON string
let json_str = try {
nickel export $ncl_index --format json
} catch { |e|
print $" (ansi red)FAIL(ansi reset) nickel export: ($e.msg | str trim)"
return { written: 0, skipped: 0, errors: 1 }
}
# Parse and validate we got an array
let posts_raw = try {
$json_str | from json
} catch { |e|
print $" (ansi red)FAIL(ansi reset) JSON parse: ($e.msg | str trim)"
return { written: 0, skipped: 0, errors: 1 }
}
let type_name = $posts_raw | describe
if not ($type_name | str starts-with "table") and not ($type_name | str starts-with "list") {
print $" (ansi red)FAIL(ansi reset) expected JSON array, got ($type_name)"
return { written: 0, skipped: 0, errors: 1 }
}
let post_count = $posts_raw | length
if $verbose { print $" ($post_count) posts" }
# Build server envelope
let now = (date now | format date "%Y-%m-%dT%H:%M:%S%.6fZ")
let envelope = {
generated_at: $now,
content_type: $ct,
language: $lang,
posts: $posts_raw,
}
let json_out = $envelope | to json --indent 2
let ssr_path = $"($ssr_root)/($ct)/($lang)/index.json"
let public_path = $"($public_root)/r/($ct)/($lang)/index.json"
if $dry_run {
print $" [dry-run] → ($ssr_path)"
print $" [dry-run] → ($public_path)"
print $" [dry-run] → ($public_path | str replace 'index.json' 'filter-index.json')"
return { written: 1, skipped: 0, errors: 0 }
}
let wrote_ssr = write_mkdir $ssr_path $json_out $verbose
let wrote_pub = write_mkdir $public_path $json_out $verbose
# Generate filter-index.json (categories + tags with counts)
let metadata_file = $"($content_root)/($ct)/($ct).ncl"
if ($metadata_file | path exists) {
let metadata = try {
nickel export $metadata_file --format json | from json
} catch { |e|
if $verbose { print $" ⚠️ Could not load metadata: ($e.msg | str trim)" }
null
}
if ($metadata != null) {
# Build categories as a record (HashMap) — key = category id, value = {emoji, count}
# FilterIndex expects HashMap<String, CategoryData> not an array.
let categories = (
$metadata.categories
| reduce -f {} { |cat, acc|
let cat_emoji = if ($cat.emoji? | is-empty) { "" } else { $cat.emoji }
let count = ($posts_raw | where { |row| ($row.category? | default "") == $cat.id } | length)
$acc | insert $cat.id { emoji: $cat_emoji, count: $count }
}
)
# Build tags as a record (HashMap) — key = tag id, value = {emoji, count}
let tags = (
$metadata.tags
| reduce -f {} { |tag, acc|
let tag_emoji = if ($tag.emoji? | is-empty) { "" } else { $tag.emoji }
let count = ($posts_raw | where { |row| ($row.tags? | default [] | any { |t| $t == $tag.id }) } | length)
$acc | insert $tag.id { emoji: $tag_emoji, count: $count }
}
)
# Create filter-index envelope — categories/tags as objects (HashMap) for FilterIndex serde
let filter_envelope = {
generated_at: $now
content_type: $ct
language: $lang
total_posts: ($posts_raw | length)
categories: $categories
tags: $tags
}
let filter_json = $filter_envelope | to json --indent 2
let filter_ssr_path = $"($ssr_root)/($ct)/($lang)/filter-index.json"
let filter_public_path = $"($public_root)/r/($ct)/($lang)/filter-index.json"
let _wrote_filter_ssr = write_mkdir $filter_ssr_path $filter_json $verbose
let _wrote_filter_pub = write_mkdir $filter_public_path $filter_json $verbose
}
}
if $wrote_ssr or $wrote_pub {
print $" (ansi green)ok(ansi reset) ($post_count) posts + filter-index"
{ written: 1, skipped: 0, errors: 0 }
} else {
print $" (ansi yellow)skip(ansi reset) output dirs not found"
{ written: 0, skipped: 1, errors: 0 }
}
}
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def resolve_env [var_name: string, cli_arg: string, fallback: string] {
if $cli_arg != "" { return $cli_arg }
try { $env | get $var_name } catch { $fallback }
}
def discover_dirs [parent: string, filter: string] {
if not ($parent | path exists) { return [] }
let all = (ls $parent)
| where type == "dir"
| get name
| each { |p| $p | path basename }
| where { |d| not ($d | str starts-with ".") }
if $filter != "" { $all | where { |d| $d == $filter } } else { $all }
}
# Create parent dirs if grandparent exists, then write file.
# Returns true if written, false if grandparent (output root) doesn't exist.
def write_mkdir [path: string, content: string, verbose: bool] {
let parent = $path | path dirname
let grandparent = $parent | path dirname
if not ($grandparent | path exists) {
if $verbose { print $" skip \(root not found\): ($grandparent)" }
return false
}
mkdir $parent
$content | save --force $path
true
}