ontoref-outreach/site/scripts/build/lib/doc-page.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

139 lines
7.8 KiB
Text

#!/usr/bin/env nu
# Shared page chrome for projected static doc surfaces (ADRs, catalog, …).
# One source of truth for the brand palette, dark/light theme, nav, and the
# common structural CSS (.doc-* classes). Consumed by gen-adr-pages.nu and
# gen-catalog-pages.nu so both outreach/web (static) and the SSR site share an
# identical look and feel.
export def esc [s: string] {
$s | str replace --all '&' '&amp;' | str replace --all '<' '&lt;' | str replace --all '>' '&gt;' | str replace --all '"' '&quot;'
}
export def paras [text: string] {
$text | split row "\n\n" | where {|b| ($b | str trim | is-not-empty) }
| each {|b| $"<p>(esc ($b | str trim))</p>" } | str join "\n"
}
# A single <section> with an anchor + h2; empty body collapses to nothing.
export def sec [anchor: string, title: string, body: string] {
if ($body | is-empty) { "" } else { $"<section id=\"($anchor)\" class=\"doc-section\"><h2>(esc $title)</h2>($body)</section>" }
}
# Brand-aligned CSS: ontoref palette tokens, dark default + html.light overrides,
# content-graph SVG var bindings, and the .doc-* structural classes.
export def page-css [] { r#'<style>
:root{
--onto-amber:#E8A838;--onto-amber-dark:#C88A20;--onto-amber-light:#F0C265;
--onto-silver:#C0CCD8;--onto-dark:#0F1319;--onto-gray:#8090A4;--onto-gray2:#a8b4c8;
--onto-surface:#141c24;--onto-border:#1e2a38;
--onto-amber-rgb:232,168,56;--onto-silver-rgb:192,204,216;--onto-dark-rgb:15,19,25;--onto-amber-dark-rgb:200,138,32;
--cg-node:#4a5568;--cg-ego:#E8A838;--cg-onto:#9b59b6;
--cg-adr:#E8A838;--cg-edge:#1e2a38;--cg-label:#C0CCD8;
}
html.light{
--onto-dark:#F0F2F5;--onto-surface:#FFFFFF;--onto-border:#d1d9e0;
--onto-silver:#1a2030;--onto-gray:#5a6a7c;--onto-gray2:#374151;
--cg-node:#6b7280;--cg-ego:#C88A20;--cg-edge:#d1d5db;--cg-label:#374151;
}
*{box-sizing:border-box}
body{font-family:Inter,-apple-system,BlinkMacSystemFont,sans-serif;background:var(--onto-dark);color:var(--onto-silver);margin:0;transition:background .2s,color .2s}
a{color:var(--onto-amber);text-decoration:none}a:hover{text-decoration:underline}
h1{color:var(--onto-silver)}
.doc-nav{display:flex;justify-content:space-between;align-items:center;padding:.75rem 1.5rem;border-bottom:1px solid var(--onto-border);background:var(--onto-dark);position:sticky;top:0;backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);z-index:10}
.doc-nav-logo{font-weight:700;color:var(--onto-amber);text-decoration:none;font-family:monospace;font-size:1.1rem;letter-spacing:-.02em}
.doc-nav-ctl{display:flex;gap:.5rem;align-items:center}
.doc-nav-btn{padding:.25rem .75rem;border-radius:.4rem;text-decoration:none;border:1px solid var(--onto-border);color:var(--onto-silver);font-size:.85rem;background:none}
.doc-nav-btn:hover{border-color:var(--onto-amber);color:var(--onto-amber);text-decoration:none}
.theme-btn{background:none;border:1px solid var(--onto-border);border-radius:.4rem;padding:.25rem .5rem;cursor:pointer;color:var(--onto-silver);font-size:.9rem;line-height:1}
.theme-btn:hover{border-color:var(--onto-amber)}
.doc-wrap{max-width:52rem;margin:0 auto;padding:2rem 1rem}
.doc-head{border-bottom:1px solid var(--onto-border);padding-bottom:1rem;margin-bottom:1.5rem}
.doc-badge{display:inline-block;padding:.15rem .6rem;border-radius:.4rem;font:600 .75rem/1.4 monospace;margin-right:.35rem}
.doc-st-accepted{background:rgba(31,120,74,.2);color:#7fe0a8}
.doc-st-proposed{background:rgba(232,168,56,.15);color:#E8A838}
.doc-st-superseded,.doc-st-deprecated{background:rgba(224,128,128,.15);color:#e08080}
.doc-sev-hard{background:rgba(224,128,128,.15);color:#e08080}
.doc-sev-soft{background:var(--onto-surface);color:var(--onto-gray2)}
.doc-tag-structural{background:rgba(155,89,182,.15);color:#c39bd3}
.doc-tag-contextual{background:rgba(232,168,56,.15);color:#E8A838}
.doc-tag-operation{background:rgba(64,160,200,.15);color:#7fc8e0}
.doc-tag-validator{background:rgba(127,224,168,.12);color:#7fe0a8}
.doc-meta{font-family:monospace;opacity:.6;margin-left:.5rem;font-size:.9rem}
.doc-section{margin:1.5rem 0}
.doc-section h2{color:var(--onto-amber);font-size:1.1rem;margin-bottom:.5rem;font-weight:600}
.doc-section p{line-height:1.7;margin-bottom:.75rem}
.doc-list{list-style:none;padding:0}
.doc-list>li{display:flex;gap:.6rem;padding:.5rem 0;border-bottom:1px solid var(--onto-border);line-height:1.6}
.doc-alt-opt{font-weight:600;margin-bottom:.2rem}
.doc-alt-why{color:var(--onto-gray2);margin:0}
.doc-kv{width:100%;border-collapse:collapse;font-size:.9rem}
.doc-kv td{padding:.4rem .6rem;border-bottom:1px solid var(--onto-border);vertical-align:top}
.doc-kv td:first-child{font-family:monospace;color:var(--onto-gray2);white-space:nowrap;width:1%}
.doc-code{font-family:'JetBrains Mono',Consolas,monospace;font-size:.85em;color:var(--onto-amber-light)}
.doc-related a{font-family:monospace;color:var(--onto-amber)}
.doc-toc{border:1px solid var(--onto-border);border-radius:.5rem;padding:.75rem 1rem;margin:1rem 0;background:var(--onto-surface)}
.doc-toc p{font:600 .7rem/1 monospace;text-transform:uppercase;opacity:.5;margin:0 0 .5rem}
.doc-toc ul{list-style:none;padding:0;margin:0;display:flex;flex-wrap:wrap;gap:.5rem}
.doc-toc a{font-size:.8rem;color:var(--onto-silver);text-decoration:none}
.doc-toc a:hover{color:var(--onto-amber)}
.doc-search{width:100%;padding:.6rem .9rem;margin:0 0 1rem;border-radius:.5rem;border:1px solid var(--onto-border);background:var(--onto-surface);color:var(--onto-silver);font-size:.95rem}
.doc-search::placeholder{color:var(--onto-gray)}
.doc-search:focus{outline:none;border-color:var(--onto-amber)}
.doc-mini-graph{margin:1.5rem 0;border:1px solid var(--onto-border);border-radius:.5rem;overflow:hidden;background:var(--onto-surface);padding:.5rem}
.doc-mini-graph .content-graph-mini{width:100%;max-width:360px;display:block;margin:0 auto;height:auto}
.doc-idx-group{margin:1.5rem 0}
.doc-idx-group h2{color:var(--onto-amber);font-size:1rem;font-weight:600;margin-bottom:.5rem;font-family:monospace}
.doc-idx-list{list-style:none;padding:0}
.doc-idx-list>li{padding:.4rem 0;border-bottom:1px solid var(--onto-border)}
.doc-idx-list>li.hidden{display:none}
.doc-idx-list a{color:var(--onto-silver);display:block}
.doc-idx-list a:hover{color:var(--onto-amber)}
.doc-idx-list a code{font-size:.8rem;color:var(--onto-gray2);font-family:monospace;margin-right:.4rem}
.no-results{display:none;color:var(--onto-gray);font-style:italic;padding:1rem 0}
</style>'# }
# Persisted theme toggle, shared across all doc pages.
export def theme-js [] { r#'<script>
(function(){
var K="ontoref-theme";
function getT(){return localStorage.getItem(K)||"dark"}
function setT(t){
localStorage.setItem(K,t);
document.documentElement.classList.toggle("light",t==="light");
var b=document.getElementById("theme-btn");
if(b)b.textContent=t==="light"?"🌙":"☀️";
}
window.toggleTheme=function(){setT(getT()==="dark"?"light":"dark")};
setT(getT());
})();
</script>'# }
# Full HTML document: brand chrome + sibling-surface nav (ADRs · Catalog) + body.
export def page-shell [title: string, content: string] {
$"<!DOCTYPE html>
<html lang=\"en\">
<head>
<meta charset=\"utf-8\">
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
<title>(esc $title) · Ontoref</title>
<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">
<link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&family=JetBrains+Mono:wght@400;500&display=swap\" rel=\"stylesheet\">
(page-css)
</head>
<body>
<nav class=\"doc-nav\">
<a class=\"doc-nav-logo\" href=\"/\">ontoref</a>
<div class=\"doc-nav-ctl\">
<a class=\"doc-nav-btn\" href=\"/adr/\">ADRs</a>
<a class=\"doc-nav-btn\" href=\"/catalog/\">Catalog</a>
<button id=\"theme-btn\" class=\"theme-btn\" onclick=\"toggleTheme\(\)\" title=\"Toggle theme\">☀️</button>
</div>
</nav>
<div class=\"doc-wrap\">
($content)
</div>
(theme-js)
</body>
</html>"
}