ontoref-outreach/site/scripts/build/gen-catalog-pages.nu
Jesús Pérez 72a389f056 site: materialize the outreach repo's site projection, with witnesses
First real commit of site/. Until now outreach/.git tracked exactly one file
(README.md) and every content pack stamped git_sha=5619a40 regardless of what it
shipped — a signature with no provenance behind it. The site had no witness, which
is why a deploy could erase two sessions of work with nothing to diff against and
nothing to restore from.

Tracked here (authored): site/content, site/config, site/i18n, site/public/images,
justfile, scripts/, .just/, run*.sh, rustelo.manifest.toml.

Ignored (reproducible, or secret):
  .k, .env                    real keys — outreach mirrors to a PUBLIC remote
  site/r, site/public/r       content indexes, rebuilt by `just content`
  provisioning/.content-packs 228 MB of deploy tarballs
  cache, data, logs_*.out     runtime droppings

Three trees are deliberately NOT ignored despite looking generated, because their
declared source cannot currently reproduce them — ignoring them would delete the
only copy:
  site/public/images          196 of 209 images exist nowhere else (assets/site is empty)
  site/content/{adr,catalog}  projections whose generators are unwired (gen-adr-pages)
  site/content/domains        or failing (`just graph`)
They become ignorable when an audit proves regeneration is a no-op, not before.

Fixes carried in this import
---------------------------
content: the mirror ran one way and reverted its own work. content_processor writes
site/r, but the recipe ended with `rsync -a site/public/r/ -> site/r/`, so each fresh
index was clobbered by the stale deploy-tree copy: the tool reported "index.json with
69 posts" while the disk kept 58. Meanwhile public/r is the tree the deploy pack ships,
so no new content could ever reach production. The two trees own different files, so
the mirror now runs both ways: content indexes r -> public/r (with --delete, or a
renamed slug survives as a live URL), and the four nu artifacts (about, adr-map,
taglines, content_graph) back public/r -> r, excluded from the outbound leg so stale
copies cannot overwrite the fresh originals.

templates: the assembler has two layers (framework defaults, source-project templates)
but the level chain has three (rustelo -> website-htmx-rustelo -> outreach/site). The
site level had no overlay slot, so its template work had nowhere legitimate to live and
ended up in htmx-templates/ — the one tree `just templates` rm -rf's. That is how the
nav submenus were lost. site/templates-overlay/ is the missing third slot; 214 lines
(nav submenus, subscribe CTA, post engagement block) now live in a declared source and
survive regeneration byte-identical.

adr: projected ADR-059..069 into the content tree. They were written months ago and
never reached the site because gen-adr-pages.nu is wired into no recipe — a generator
outside the dependency chain is a generator that does not run.

Gates added
-----------
templates-check  reassembles into a temp tree and asserts an empty diff against the
                 live one: any drift means htmx-templates/ holds work that exists
                 live one: any drift means htmx-templates/ holds work that exists
                 nowhere else and the next run deletes it.

Both gates run before the destructive operation, not after. A check you can only run
afterwards is not a gate, it is an autopsy.

Refs: ADR-062 (projection-not-own-repo: site is a Projection of outreach, not its own
repo — different deploy target is not a different visibility boundary), ADR-048
(materialization), ADR-066 (the check decides, never the reporter — which the content
pipeline, the surface that publishes ontoref, was the last place not to honour).
2026-07-11 23:44:00 +01:00

313 lines
14 KiB
Text

#!/usr/bin/env nu
# Project the catalog of verifiables (.ontoref/catalog) into renderable content —
# sibling of gen-adr-pages.nu. The catalog holds two kinds:
# operations : typed agent actions (inputs, effects, constraints, witness shape)
# validators : the criteria that gate them (category, severity, slice reads)
#
# Two modes, identical to the ADR surface:
# static : self-contained brand-wrapped HTML viewer — outreach/web + docs
# content : markdown per entry into the `catalog` content-kind tree (needs the
# kind registered in content.ncl/routes.ncl — a rebuild boundary)
#
# Page chrome comes from lib/doc-page.nu so ADR + catalog look identical.
#
# Usage:
# nu scripts/build/gen-catalog-pages.nu --root ../.. --mode static --out ../../outreach/web/catalog
use lib/doc-page.nu [esc paras sec page-shell]
# ── shared small renderers ────────────────────────────────────────────────────
def kv-table [rows: list] {
let body = ($rows | where {|r| ($r.1 | str trim | is-not-empty) }
| each {|r| $"<tr><td>(esc $r.0)</td><td>(esc $r.1)</td></tr>" } | str join)
if ($body | is-empty) { "" } else { $"<table class=\"doc-kv\">($body)</table>" }
}
def bool-str [b] { if ($b == true) { "yes" } else if ($b == false) { "no" } else { "" } }
# Link a referenced validator id to its catalog page if it resolves to a known
# entry; otherwise render it as inline code (inline expression refs, unknowns).
def vref-link [ref, known_ids: list] {
if (($ref | describe) != "string") { return "<code class=\"doc-code\">inline</code>" }
if ($ref in $known_ids) {
$"<a href=\"/catalog/($ref)/\"><code class=\"doc-code\">($ref)</code></a>"
} else {
$"<code class=\"doc-code\">(esc $ref)</code>"
}
}
# ── validator page ────────────────────────────────────────────────────────────
def render-validator-html [v: record] {
let cat = ($v.category? | default "")
let sev = ($v.severity? | default "")
let cat_badge = if ($cat | is-not-empty) { $"<span class=\"doc-badge doc-tag-(($cat | str downcase))\">(esc $cat)</span>" } else { "" }
let sev_badge = if ($sev | is-not-empty) { $"<span class=\"doc-badge doc-sev-(($sev | str downcase))\">(esc $sev)</span>" } else { "" }
let meta = (kv-table [
["criterion" ($v.criterion_ref? | default "")]
["predicate" ($v.predicate_ref? | default "")]
["decidable" (bool-str ($v.decidable?))]
["touches spiral" (bool-str ($v.touches_spiral?))]
["needs state root" (bool-str ($v.slice?.needs_state_root?))]
])
let reads = ($v.slice?.reads? | default [])
let reads_html = if ($reads | is-empty) { "" } else {
let rows = ($reads | each {|r|
let fields = (($r.fields? | default []) | each {|f| esc $f } | str join ", ")
$"<li><strong>(esc ($r.entity? | default ''))</strong><div><p class=\"doc-code\">($fields)</p></div></li>"
} | str join)
$"<ul class=\"doc-list\">($rows)</ul>"
}
let body = ([
$"<div class=\"doc-head\">
<span class=\"doc-badge doc-tag-validator\">Validator</span>($cat_badge)($sev_badge)
<span class=\"doc-meta\">(esc ($v.id? | default ''))</span>
<h1 style=\"margin:.5rem 0 0\">(esc ($v.id? | default ''))</h1>
</div>"
(sec "description" "Description" (paras ($v.description? | default "")))
(sec "spec" "Specification" $meta)
(sec "reads" "Reads (slice)" $reads_html)
] | str join "\n")
page-shell ($v.id? | default "Validator") $body
}
# ── operation page ────────────────────────────────────────────────────────────
def render-operation-html [op: record, known_ids: list] {
let sla = ($op.validation_sla? | default "")
let sla_str = if (($sla | describe) == "string") { $sla } else if (($sla | describe | str starts-with "record")) { ($sla.tag? | default "") } else { "" }
let pre = (kv-table [
["slice query" ($op.precondition?.slice_query? | default "")]
["slice kind" ($op.precondition?.slice_kind? | default "")]
["validation SLA" $sla_str]
["roles" (($op.actor_policy?.roles? | default []) | str join ", ")]
])
let inputs = ($op.inputs? | default {})
let inputs_html = if (($inputs | columns | length) == 0) { "" } else {
let rows = ($inputs | transpose k v | each {|i| $"<tr><td>(esc $i.k)</td><td class=\"doc-code\">(esc $i.v)</td></tr>" } | str join)
$"<table class=\"doc-kv\">($rows)</table>"
}
let effects = ($op.effects? | default [])
let effects_html = if ($effects | is-empty) { "" } else {
let rows = ($effects | each {|e|
let attrs = (($e.attrs? | default []) | each {|a| esc $a } | str join ", ")
let dims = (($e.dimensions? | default []) | where {|d| ($d | str trim | is-not-empty) } | each {|d| esc $d } | str join ", ")
let dim_s = if ($dims | is-empty) { "" } else { $" · dims: ($dims)" }
$"<li><span class=\"doc-badge doc-sev-soft\">(esc ($e.kind? | default ''))</span><div><p><strong>(esc ($e.entity? | default ''))</strong> — <span class=\"doc-code\">($attrs)</span>($dim_s)</p></div></li>"
} | str join)
$"<ul class=\"doc-list\">($rows)</ul>"
}
let pre_v = ($op.constraints?.pre? | default [])
let post_v = ($op.constraints?.post? | default [])
let constraints_html = if (($pre_v | is-empty) and ($post_v | is-empty)) { "" } else {
let pre_links = if ($pre_v | is-empty) { "—" } else { ($pre_v | each {|r| vref-link $r $known_ids } | str join " · ") }
let post_links = if ($post_v | is-empty) { "—" } else { ($post_v | each {|r| vref-link $r $known_ids } | str join " · ") }
$"<table class=\"doc-kv\"><tr><td>pre</td><td>($pre_links)</td></tr><tr><td>post</td><td>($post_links)</td></tr></table>"
}
let ws = ($op.witness_shape? | default {})
let witness_html = if (($ws | columns | length) == 0) { "" } else {
let proofs = (($ws.proof_paths? | default []) | each {|p| esc $p } | str join "<br>")
(kv-table [
["payload kind" ($ws.payload_kind? | default "")]
["proof paths" ""]
] | str replace '<td>proof paths</td><td></td>' $"<td>proof paths</td><td class=\"doc-code\">($proofs)</td>")
}
let renders = ($op.render_paths? | default [])
let render_html = if ($renders | is-empty) { "" } else {
let items = ($renders | each {|p| $"<li><span class=\"doc-code\">(esc $p)</span></li>" } | str join)
$"<ul class=\"doc-list\">($items)</ul>"
}
let sla_badge = if ($sla_str | is-not-empty) { $"<span class=\"doc-badge doc-sev-soft\">(esc $sla_str)</span>" } else { "" }
let body = ([
$"<div class=\"doc-head\">
<span class=\"doc-badge doc-tag-operation\">Operation</span>($sla_badge)
<span class=\"doc-meta\">(esc ($op.id? | default ''))</span>
<h1 style=\"margin:.5rem 0 0\">(esc ($op.id? | default ''))</h1>
</div>"
(sec "description" "Description" (paras ($op.description? | default "")))
(sec "preconditions" "Preconditions & policy" $pre)
(sec "inputs" "Inputs" $inputs_html)
(sec "effects" "Effects" $effects_html)
(sec "constraints" "Validators" $constraints_html)
(sec "witness" "Witness shape" $witness_html)
(sec "renders" "Render paths" $render_html)
] | str join "\n")
page-shell ($op.id? | default "Operation") $body
}
# ── index ──────────────────────────────────────────────────────────────────────
def render-index [ops: list, validators: list] {
let op_search = ($ops | each {|o|
let id = ($o.id? | default "")
let desc = ($o.description? | default "" | str trim | str substring 0..400 | str replace --all "'" "\\'" | str replace --all "\n" " ")
$" \{id:'($id)',kind:'operation',text:'($desc)'\}"
})
let v_search = ($validators | each {|v|
let id = ($v.id? | default "")
let desc = ($v.description? | default "" | str trim | str substring 0..400 | str replace --all "'" "\\'" | str replace --all "\n" " ")
$" \{id:'($id)',kind:'validator',text:'($desc)'\}"
})
let op_rows = ($ops | sort-by id | each {|o|
$"<li data-id=\"($o.id? | default '')\" data-title=\"(esc ($o.id? | default ''))\" data-text=\"(esc ($o.description? | default '' | str trim | str substring 0..300))\"><a href=\"/catalog/($o.id? | default '')/\"><code>op</code>(esc ($o.id? | default ''))</a></li>"
} | str join)
let v_rows = ($validators | sort-by id | each {|v|
$"<li data-id=\"($v.id? | default '')\" data-title=\"(esc ($v.id? | default ''))\" data-text=\"(esc ($v.description? | default '' | str trim | str substring 0..300))\"><a href=\"/catalog/($v.id? | default '')/\"><code>val</code>(esc ($v.id? | default ''))</a></li>"
} | str join)
let blocks = $"<div class=\"doc-idx-group\" data-status=\"Operations\"><h2>Operations \(($ops | length)\)</h2><ul class=\"doc-idx-list\">($op_rows)</ul></div><div class=\"doc-idx-group\" data-status=\"Validators\"><h2>Validators \(($validators | length)\)</h2><ul class=\"doc-idx-list\">($v_rows)</ul></div>"
let search_js = $"<script>
var searchInput=document.getElementById\('doc-search'\);
var noResults=document.getElementById\('no-results'\);
function doSearch\(\){
var q=searchInput.value.toLowerCase\(\).trim\(\);
var hits=0;
document.querySelectorAll\('.doc-idx-list>li'\).forEach\(function\(li\){
var show=!q||li.dataset.title.toLowerCase\(\).indexOf\(q\)>-1||li.dataset.text.toLowerCase\(\).indexOf\(q\)>-1;
li.classList.toggle\('hidden',!show\);
if\(show\)hits++;
}\);
document.querySelectorAll\('.doc-idx-group'\).forEach\(function\(g\){
var visible=g.querySelectorAll\('li:not\(.hidden\)'\).length>0;
g.style.display=visible?'':'none';
}\);
noResults.style.display=hits===0&&q?'block':'none';
}
searchInput.addEventListener\('input',doSearch\);
</script>"
let content = $"<h1 style=\"margin:0 0 .5rem\">Catalog of Verifiables</h1>
<p class=\"doc-meta\" style=\"margin:0 0 1rem;display:block\">Typed agent operations and the validators that gate them \(ADR-024 / ADR-026 / ADR-050\).</p>
<input id=\"doc-search\" class=\"doc-search\" type=\"search\" placeholder=\"Search catalog by id or description…\">
<p id=\"no-results\" class=\"no-results\">Nothing in the catalog matches your search.</p>
($blocks)
($search_js)"
page-shell "Catalog of Verifiables" $content
}
# ── content-kind sidecar (for the future `catalog` kind + content_graph) ──────
def render-catalog-ncl [id: string, kind: string, title: string, related: list] {
let rel = ($related | uniq | where {|k| $k != $id } | each {|k| $"\"($k)\"" } | str join ", ")
$"{
id = \"($id)\",
title = \"($title)\",
slug = \"($id)\",
excerpt = \"\",
tags = [\"catalog\", \"($kind)\"],
page_route = \"/catalog/($id)\",
graph = {
implements = [],
related_to = [($rel)],
},
}
"
}
def render-md [id: string, kind: string, description: string] {
let excerpt = ($description | str trim | str substring 0..200 | str replace --all '"' "'")
$"---
id: \"($id)\"
title: \"($id)\"
slug: \"($id)\"
subtitle: \"($kind)\"
excerpt: \"($excerpt)\"
author: \"ontoref\"
published: true
featured: false
category: \"($kind)\"
tags: [\"($id)\", \"catalog\", \"($kind)\"]
css_class: \"category-catalog\"
---
<h2>Description</h2>
(paras $description)
"
}
# ── main ──────────────────────────────────────────────────────────────────────
def main [
--root: string = "../.."
--mode: string = "static" # static | content | both
--out: string = "site/public/catalog" # static output dir
--content-out: string = "site/content/catalog/en" # content-kind output
] {
let cat_dir = $"($root)/.ontoref/catalog"
let ip_data = $"($root)/code/ontology"
let do_static = ($mode in ["static" "both"])
let do_content = ($mode in ["content" "both"])
let op_files = (glob $"($cat_dir)/operations/*.ncl" | where {|f| ($f | path basename) not-in ["schema.ncl" "_template.ncl"] } | sort)
let v_files = (glob $"($cat_dir)/validators/*.ncl" | where {|f| ($f | path basename) != "schema.ncl" } | sort)
mut ops = []
for f in $op_files {
let op = (try { nickel export --format json --import-path $ip_data --import-path $cat_dir $f | from json } catch {|e| print $" skip ($f | path basename): ($e.msg)"; null })
if ($op != null) { $ops = ($ops | append $op) }
}
mut validators = []
for f in $v_files {
let v = (try { nickel export --format json --import-path $ip_data --import-path $cat_dir $f | from json } catch {|e| print $" skip ($f | path basename): ($e.msg)"; null })
if ($v != null) { $validators = ($validators | append $v) }
}
let known_ids = ($validators | each {|v| $v.id? | default "" } | where {|x| $x | is-not-empty })
if $do_static {
for op in $ops {
let id = ($op.id? | default "")
if ($id | is-empty) { continue }
let dir = $"($out)/($id)"
mkdir $dir
render-operation-html $op $known_ids | save -f $"($dir)/index.html"
}
for v in $validators {
let id = ($v.id? | default "")
if ($id | is-empty) { continue }
let dir = $"($out)/($id)"
mkdir $dir
render-validator-html $v | save -f $"($dir)/index.html"
}
mkdir $out
render-index $ops $validators | save -f $"($out)/index.html"
}
if $do_content {
for op in $ops {
let id = ($op.id? | default "")
if ($id | is-empty) { continue }
mkdir $"($content_out)/operations"
let related = (($op.constraints?.pre? | default []) | append ($op.constraints?.post? | default []) | where {|r| ($r | describe) == "string" })
render-md $id "operations" ($op.description? | default "") | save -f $"($content_out)/operations/($id).md"
render-catalog-ncl $id "operation" $id $related | save -f $"($content_out)/operations/($id).ncl"
}
for v in $validators {
let id = ($v.id? | default "")
if ($id | is-empty) { continue }
mkdir $"($content_out)/validators"
render-md $id "validators" ($v.description? | default "") | save -f $"($content_out)/validators/($id).md"
render-catalog-ncl $id "validator" $id [] | save -f $"($content_out)/validators/($id).ncl"
}
}
print $"gen-catalog-pages [($mode)]: ($ops | length) operations, ($validators | length) validators"
}