233 lines
9 KiB
Text
233 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
|
||
|
|
}
|