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).
262 lines
12 KiB
Text
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"
|
|
}
|