#!/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 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 }