ontoref-outreach/site/scripts/build/gen-adr-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

262 lines
12 KiB
Text

#!/usr/bin/env nu
# Project the ADR set (.ontoref/adrs) into renderable content — same role as
# gen-spine-pages / gen-content-graph, decoupled from the live ADRs. Two modes:
# content : markdown per ADR into the `adr` content-kind tree
# static : self-contained brand-wrapped HTML viewer — outreach/web + docs
#
# Page chrome (brand palette, dark/light theme, nav, .doc-* CSS) comes from the
# shared lib/doc-page.nu module so ADR + catalog surfaces look identical.
#
# Usage:
# nu scripts/build/gen-adr-pages.nu --root ../.. --mode both --slug ontoref
use lib/doc-page.nu [esc paras sec page-shell]
def short-adr [rid: string] {
let m = ($rid | parse --regex '(?P<k>adr-\d+)')
if ($m | is-empty) { $rid } else { $m.k.0 }
}
# ── section renderers (HTML, static mode) ─────────────────────────────────────
def html-constraints [items: list] {
if ($items | is-empty) { return "" }
let rows = ($items | each {|c|
let sev = ($c.severity? | default "")
$"<li><span class=\"doc-badge doc-sev-(($sev | str downcase))\">(if ($sev | is-empty) { '—' } else { esc $sev })</span><div><p>(esc ($c.claim? | default ''))</p></div></li>"
} | str join)
$"<ul class=\"doc-list\">($rows)</ul>"
}
def html-alts [items: list] {
if ($items | is-empty) { return "" }
let rows = ($items | each {|a|
$"<li><p class=\"doc-alt-opt\">(esc ($a.option? | default ''))</p><p class=\"doc-alt-why\"><strong>Rejected:</strong> (esc ($a.why_rejected? | default ''))</p></li>"
} | str join)
$"<ul class=\"doc-list\">($rows)</ul>"
}
def html-anti [items: list] {
if ($items | is-empty) { return "" }
let rows = ($items | each {|p|
if ($p | describe | str starts-with "record") {
$"<li><strong>(esc ($p.name? | default ($p.id? | default '')))</strong><p>(esc ($p.description? | default ''))</p></li>"
} else { $"<li>(esc $p)</li>" }
} | str join)
$"<ul class=\"doc-list\">($rows)</ul>"
}
# Related ADR links: /adr/NNN path, ADR-NNN display text
def html-related [ids: list] {
if ($ids | is-empty) { return "" }
let links = ($ids | each {|r|
let k = (short-adr $r)
let num = ($k | str replace 'adr-' '')
$"<a href=\"/adr/($num)\">ADR-(esc $num)</a>"
} | str join " · ")
$"<p class=\"doc-related\">($links)</p>"
}
def html-toc [adr: record] {
let secs = [
[anchor title present];
[context "Context" ($adr.context? | default "" | is-not-empty)]
[decision "Decision" ($adr.decision? | default "" | is-not-empty)]
[constraints "Constraints" (($adr.constraints? | default []) | is-not-empty)]
[alternatives "Alternatives" (($adr.alternatives_considered? | default []) | is-not-empty)]
[anti-patterns "Anti-patterns" (($adr.anti_patterns? | default []) | is-not-empty)]
[related "Related ADRs" (($adr.related_adrs? | default []) | is-not-empty)]
]
let items = ($secs | where present | each {|s| $"<li><a href=\"#($s.anchor)\">(esc $s.title)</a></li>" } | str join)
if ($items | is-empty) { "" } else { $"<nav class=\"doc-toc\"><p>On this page</p><ul>($items)</ul></nav>" }
}
# ── markdown render (content-kind mode) ───────────────────────────────────────
def render-md [adr: record, daemon: string, slug: string] {
let aid = $adr.id
let num = ($aid | str replace 'adr-' '')
let status = ($adr.status? | default "Unknown")
let st_l = ($status | str downcase)
let title = ($adr.title? | default "" | str replace --all '"' "'")
let excerpt = ($adr.context? | default "" | str trim | split row "\n" | first | default "" | str substring 0..200 | str replace --all '"' "'")
let front = $"---
id: \"($aid)\"
title: \"($title)\"
slug: \"($num)\"
subtitle: \"($status)\"
excerpt: \"($excerpt)\"
author: \"ontoref\"
date: \"($adr.date? | default '')\"
published: true
featured: false
category: \"($st_l)\"
tags: [\"($aid)\", \"adr\", \"($st_l)\"]
css_class: \"category-adr\"
---
"
let cons = (($adr.constraints? | default []) | each {|c| $"<li><strong>(esc ($c.severity? | default ''))</strong> (esc ($c.claim? | default ''))</li>" } | str join)
let alts = (($adr.alternatives_considered? | default []) | each {|a| $"<li><strong>(esc ($a.option? | default ''))</strong> — <em>rejected:</em> (esc ($a.why_rejected? | default ''))</li>" } | str join)
let anti = (($adr.anti_patterns? | default []) | each {|p|
if ($p | describe | str starts-with "record") { $"<li><strong>(esc ($p.name? | default ($p.id? | default '')))</strong> — (esc ($p.description? | default ''))</li>" } else { $"<li>(esc $p)</li>" }
} | str join)
let rel = (($adr.related_adrs? | default []) | each {|r| let k = (short-adr $r); let rn = ($k | str replace 'adr-' ''); $"<a href=\"/adr/($rn)\">ADR-(esc $rn)</a>" } | str join " · ")
mut body = $"<h2>Context</h2>\n\n(paras ($adr.context? | default ''))\n\n<h2>Decision</h2>\n\n(paras ($adr.decision? | default ''))\n"
if ($cons | is-not-empty) { $body = $body + $"\n<h2>Constraints</h2>\n\n<ul>($cons)</ul>\n" }
if ($alts | is-not-empty) { $body = $body + $"\n<h2>Alternatives considered</h2>\n\n<ul>($alts)</ul>\n" }
if ($anti | is-not-empty) { $body = $body + $"\n<h2>Anti-patterns</h2>\n\n<ul>($anti)</ul>\n" }
if ($rel | is-not-empty) { $body = $body + $"\n<h2>Related ADRs</h2>\n\n<p>($rel)</p>\n" }
$"($front)($body)"
}
# Sidecar .ncl for the content-graph generator (foot mini-graph)
def render-adr-ncl [adr: record] {
let aid = $adr.id
let num = ($aid | str replace 'adr-' '')
let st_l = ($adr.status? | default "" | str downcase)
let title = ($adr.title? | default "" | str replace --all '"' "'")
let rel = (($adr.related_adrs? | default []) | each {|r| short-adr $r } | uniq | where {|k| $k != $aid } | each {|k| $"\"($k)\"" } | str join ", ")
$"{
id = \"($aid)\",
title = \"($title)\",
slug = \"($num)\",
excerpt = \"\",
tags = [\"adr\", \"($st_l)\"],
page_route = \"/adr/($num)\",
graph = {
implements = [],
related_to = [($rel)],
},
}
"
}
# ── page render (static mode) ─────────────────────────────────────────────────
def render-html [adr: record, daemon: string, slug: string, mini_svg: string] {
let aid = $adr.id
let status = ($adr.status? | default "Unknown")
let st_l = ($status | str downcase)
let graph_section = if ($mini_svg | is-not-empty) {
$"<div class=\"doc-mini-graph\">($mini_svg)</div>"
} else { "" }
let body = ([
$"<div class=\"doc-head\">
<span class=\"doc-badge doc-st-($st_l)\">(esc $status)</span>
<span class=\"doc-meta\">(esc $aid) · (esc ($adr.date? | default ''))</span>
<h1 style=\"margin:.5rem 0 0\">(esc ($adr.title? | default ''))</h1>
</div>"
$graph_section
(html-toc $adr)
(sec "context" "Context" (paras ($adr.context? | default "")))
(sec "decision" "Decision" (paras ($adr.decision? | default "")))
(sec "constraints" "Constraints" (html-constraints ($adr.constraints? | default [])))
(sec "alternatives" "Alternatives considered" (html-alts ($adr.alternatives_considered? | default [])))
(sec "anti-patterns" "Anti-patterns" (html-anti ($adr.anti_patterns? | default [])))
(sec "related" "Related ADRs" (html-related ($adr.related_adrs? | default [])))
] | str join "\n")
page-shell ($adr.title? | default "") $body
}
def render-index [adrs: list] {
let order = ["Accepted" "Proposed" "Superseded" "Deprecated"]
let groups = ($adrs | group-by {|a| $a.status? | default "Other" })
let keys = ($order | append ($groups | columns) | uniq | where {|k| $k in ($groups | columns) })
let search_entries = ($adrs | each {|a|
let id = ($a.id? | default "")
let num = ($id | str replace 'adr-' '')
let title = ($a.title? | default "" | str replace --all "'" "\\'")
let status = ($a.status? | default "" | str replace --all "'" "\\'")
let ctx = ($a.context? | default "" | str trim | str substring 0..500 | str replace --all "'" "\\'" | str replace --all "\n" " ")
let dec = ($a.decision? | default "" | str trim | str substring 0..500 | str replace --all "'" "\\'" | str replace --all "\n" " ")
$" \{id:'($id)',num:'($num)',title:'($title)',status:'($status)',text:'($ctx) ($dec)'\}"
} | str join ",\n")
let blocks = ($keys | each {|st|
let items = ($groups | get $st | sort-by id)
let rows = ($items | each {|a|
let num = ($a.id | str replace 'adr-' '')
$"<li data-id=\"($a.id)\" data-title=\"(esc ($a.title? | default ''))\" data-text=\"(esc ($a.context? | default '' | str trim | str substring 0..300)) (esc ($a.decision? | default '' | str trim | str substring 0..300))\"><a href=\"/adr/($num)/\"><code>ADR-(esc $num)</code>(esc ($a.title? | default ''))</a></li>"
} | str join)
$"<div class=\"doc-idx-group\" data-status=\"(esc $st)\"><h2>(esc $st) \(($items | length)\)</h2><ul class=\"doc-idx-list\">($rows)</ul></div>"
} | str join)
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 1rem\">Architecture Decision Records</h1>
<input id=\"doc-search\" class=\"doc-search\" type=\"search\" placeholder=\"Search ADRs by title or content…\">
<p id=\"no-results\" class=\"no-results\">No ADRs match your search.</p>
($blocks)
($search_js)"
page-shell "Architecture Decision Records" $content
}
# ── main ──────────────────────────────────────────────────────────────────────
def main [
--root: string = "../.."
--mode: string = "static" # static | content | both
--out: string = "site/public/adr" # static output dir
--content-out: string = "site/content/adr/en" # content-kind output
--daemon: string = "https://ontoref.dev"
--slug: string = "ontoref"
] {
let adrs_dir = $"($root)/.ontoref/adrs"
let ip_data = $"($root)/code/ontology"
let daemon = ($daemon | str trim --right --char '/')
let do_static = ($mode in ["static" "both"])
let do_content = ($mode in ["content" "both"])
let cg_path = $"($root)/outreach/site/site/public/r/content_graph.json"
let mini_graphs = if ($cg_path | path exists) { (open $cg_path).mini } else { {} }
let files = (glob $"($adrs_dir)/adr-*.ncl" | where {|f| ($f | path basename) =~ '^adr-[0-9]' } | sort)
mut adrs = []
for f in $files {
let adr = (try { nickel export --format json --import-path $ip_data --import-path $adrs_dir $f | from json } catch { null })
if ($adr == null) { print $" skip ($f | path basename)"; continue }
$adrs = ($adrs | append $adr)
if $do_static {
let num = ($adr.id | str replace 'adr-' '')
let dir = $"($out)/($num)"
mkdir $dir
let mini_svg = (if $num in $mini_graphs { $mini_graphs | get $num } else { "" })
render-html $adr $daemon $slug $mini_svg | save -f $"($dir)/index.html"
}
if $do_content {
let cat = ($adr.status? | default "adr" | str downcase)
mkdir $"($content_out)/($cat)"
render-md $adr $daemon $slug | save -f $"($content_out)/($cat)/($adr.id).md"
render-adr-ncl $adr | save -f $"($content_out)/($cat)/($adr.id).ncl"
}
}
if $do_static {
mkdir $out
render-index $adrs | save -f $"($out)/index.html"
}
print $"gen-adr-pages [($mode)]: ($adrs | length) ADRs"
}