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

150 lines
7.5 KiB
Text

#!/usr/bin/env nu
# Project the ontology (core.ncl) into a SELF-CONTAINED, navigable Cytoscape graph
# page. This is the interactive "prize" the About graph block links to (ADR-057
# hook-static / prize-live) — but daemon-free: the data is embedded and the page
# uses the cytoscape libs already shipped under /js/. The site's own /graph
# (Leptos GraphView) renders an empty canvas under htmx-ssr (no WASM hydration),
# so we ship our own page instead.
#
# Served as a static file at /images/ontoref-graph.html (only /images, /js, /styles
# are static-served; bare paths are intercepted by the content router).
#
# Usage (from outreach/site):
# nu scripts/build/gen-graph-page.nu --root ../.. --out site/public/images/ontoref-graph.html
const TEMPLATE = r##'<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>ontoref · ontology graph</title>
<style>
:root{--amber:#E8A838;--silver:#C0CCD8;--dark:#0F1319;--surface:#141c24;--border:#1e2a38;--gray:#8090A4}
*{box-sizing:border-box}
html,body{margin:0;height:100%;background:var(--dark);color:var(--silver);font-family:Inter,system-ui,sans-serif}
#bar{position:fixed;top:0;left:0;right:0;height:52px;display:flex;align-items:center;gap:14px;padding:0 18px;border-bottom:1px solid var(--border);background:rgba(15,19,25,.86);backdrop-filter:blur(8px);z-index:5}
#bar .brand{font-family:ui-monospace,monospace;font-weight:700;color:var(--amber);font-size:1.05rem;text-decoration:none}
#bar .brand:hover{opacity:.82}
#bar .meta{color:var(--gray);font-size:.85rem}
#bar .btn{margin-left:auto;color:var(--silver);text-decoration:none;border:1px solid var(--border);border-radius:.4rem;padding:.3rem .75rem;font-size:.85rem}
#bar .btn:hover{border-color:var(--amber);color:var(--amber)}
#cy{position:fixed;top:52px;left:0;right:0;bottom:0}
.panel{position:fixed;background:rgba(20,28,36,.92);border:1px solid var(--border);border-radius:.5rem;z-index:5}
#legend{left:18px;bottom:18px;padding:.6rem .85rem;font-size:.8rem}
#legend .row{display:flex;align-items:center;gap:.5rem;margin:.28rem 0}
#legend .dot{width:11px;height:11px;border-radius:50%;flex:0 0 auto}
#hint{right:18px;bottom:18px;padding:.45rem .7rem;color:var(--gray);font-size:.78rem}
</style>
</head>
<body>
<div id="bar">
<a href="/" class="brand" title="ontoref home">ontoref</a>
<span class="meta">ontology graph · __NCOUNT__ nodes · __ECOUNT__ edges</span>
<a href="/about" class="btn">← About</a>
</div>
<div id="cy"></div>
<div id="legend" class="panel">
<div class="row"><span class="dot" style="background:#E8A838"></span>Axioms · __NAX__</div>
<div class="row"><span class="dot" style="background:#C0CCD8"></span>Tensions · __NTN__</div>
<div class="row"><span class="dot" style="background:#7fc8e0"></span>Practices · __NPR__</div>
</div>
<div id="hint" class="panel">drag to pan · scroll to zoom · click a node to focus · click empty space to reset</div>
<script type="application/json" id="graph-data">/*__ELEMENTS__*/</script>
<script src="/js/cytoscape.min.js"></script>
<script src="/js/layout-base.js"></script>
<script src="/js/cose-base.js"></script>
<script src="/js/cytoscape-fcose.js"></script>
<script>
(function(){
var COL = { Axiom:"#E8A838", Tension:"#C0CCD8", Practice:"#7fc8e0" };
var data = JSON.parse(document.getElementById("graph-data").textContent);
var cy = cytoscape({
container: document.getElementById("cy"),
elements: data,
wheelSensitivity: 0.2,
style: [
{ selector:"node", style:{
"background-color": function(ele){ return COL[ele.data("level")] || "#7fc8e0"; },
"width": function(ele){ return ele.data("level")==="Axiom" ? 30 : (ele.data("level")==="Tension" ? 26 : 20); },
"height": function(ele){ return ele.data("level")==="Axiom" ? 30 : (ele.data("level")==="Tension" ? 26 : 20); },
"label":"data(title)", "color":"#C0CCD8", "font-size":"9px",
"text-valign":"bottom", "text-halign":"center", "text-margin-y":3,
"min-zoomed-font-size":7, "border-width":1, "border-color":"#0F1319"
} },
{ selector:"edge", style:{ "width":1, "line-color":"#46607c", "opacity":0.34, "curve-style":"haystack" } },
{ selector:".faded", style:{ "opacity":0.07, "text-opacity":0.07 } },
{ selector:"node.hi", style:{ "border-width":2, "border-color":"#E8A838", "opacity":1, "text-opacity":1 } },
{ selector:"edge.hi", style:{ "line-color":"#E8A838", "opacity":0.85, "width":1.6 } }
]
});
cy.minZoom(0.15); cy.maxZoom(3);
if (window.cytoscapeFcose) { try { cytoscape.use(window.cytoscapeFcose); } catch(e){} }
// fcose with packComponents:true PACKS the 5 edgeless orphan nodes right next to
// the main component (no scattered corners, no two groups); nodeDimensionsInclude-
// Labels spaces by label box (kills overlap); fit + post-fit fills the viewport.
var COMMON = { animate:false, fit:true, padding:50, nodeDimensionsIncludeLabels:true };
var layoutOpts = Object.assign({
name:"fcose", quality:"proof", randomize:true, packComponents:true,
idealEdgeLength:80, nodeSeparation:95, gravity:0.25, numIter:2500
}, COMMON);
var l;
try { l = cy.layout(layoutOpts); }
catch(e){ l = cy.layout(Object.assign({ name:"cose", componentSpacing:90, nodeRepulsion:500000 }, COMMON)); }
l.one("layoutstop", function(){ cy.fit(undefined, 50); });
l.run();
cy.on("tap","node",function(ev){
var n = ev.target;
var nb = n.closedNeighborhood();
cy.elements().addClass("faded");
nb.removeClass("faded").addClass("hi");
});
cy.on("tap", function(ev){ if (ev.target === cy){ cy.elements().removeClass("faded hi"); } });
})();
</script>
</body>
</html>
'##
def main [
--root: string = "../.."
--out: string = "site/public/images/ontoref-graph.html"
] {
let ip = $"($root)/code/ontology"
let core = $"($root)/.ontoref/ontology/core.ncl"
if not ($core | path exists) { error make { msg: $"core.ncl not found: ($core)" } }
let graph = (nickel export --format json --import-path $ip $core | from json)
let nodes = ($graph | get nodes)
let edges = ($graph.edges? | default [])
let ids = ($nodes | each {|n| $n.id? | default "" } | where {|x| $x | is-not-empty })
let node_els = ($nodes | each {|n| { data: {
id: ($n.id? | default ""),
title: ($n.name? | default ($n.id? | default "")),
level: ($n.level? | default "Practice"),
} } })
let edge_els = ($edges | where {|e| (($e.from? | default "") in $ids) and (($e.to? | default "") in $ids) } | each {|e| { data: {
id: $"($e.from)__($e.to)__($e.kind? | default 'rel')",
source: ($e.from? | default ""),
target: ($e.to? | default ""),
kind: ($e.kind? | default ""),
} } })
let elements = ($node_els | append $edge_els | to json --raw)
let nax = ($nodes | where {|n| ($n.level? | default "") == "Axiom" } | length)
let ntn = ($nodes | where {|n| ($n.level? | default "") == "Tension" } | length)
let npr = ($nodes | where {|n| ($n.level? | default "") == "Practice" } | length)
let html = ($TEMPLATE
| str replace --all '__NCOUNT__' ($nodes | length | into string)
| str replace --all '__ECOUNT__' ($edge_els | length | into string)
| str replace --all '__NAX__' ($nax | into string)
| str replace --all '__NTN__' ($ntn | into string)
| str replace --all '__NPR__' ($npr | into string)
| str replace '/*__ELEMENTS__*/' $elements)
let parent = ($out | path dirname)
if ($parent | is-not-empty) { mkdir $parent }
$html | save -f $out
print $"gen-graph-page: ($nodes | length) nodes · ($edge_els | length) edges → ($out)"
}