diff --git a/site/scripts/build/assemble-layers.nu b/site/scripts/build/assemble-layers.nu
new file mode 100644
index 0000000..4ad859e
--- /dev/null
+++ b/site/scripts/build/assemble-layers.nu
@@ -0,0 +1,77 @@
+#!/usr/bin/env nu
+# Resolve a layered override chain into ONE materialised tree.
+#
+# The chain has three levels and the same rule at every hop: a file present in a
+# higher layer WINS; a file absent from it is INHERITED from the layer below.
+#
+# rustelo (framework) → website-htmx-rustelo (implementation) → outreach/site (consumer)
+#
+# The implementation already overrides framework pages this way; the consumer is the
+# third slot, and until now it did not exist for assets at all — htmx-assets/ was
+# refilled from the framework alone, so an override the implementation shipped never
+# reached the site.
+#
+# The cascade is resolved HERE, at build time: the Minijinja loader and the static
+# ServeDir each read one path and need no fallback logic.
+#
+# The output is destroyed and rebuilt on every run, which is the whole point — a file
+# that survives only in the output is a file that exists nowhere. Anything the consumer
+# owns belongs in its layer (site/_htmx/**), never in the target tree.
+#
+# Layers are positional, LOWEST FIRST. The base layer must exist; the rest are optional
+# (a consumer with nothing to override is a normal state, not an error).
+#
+# Usage:
+# nu assemble-layers.nu [ ...] --out [--exclude "*.lock.toml,*.md"]
+
+def main [
+ ...layers: string # override chain, lowest layer first; base must exist
+ --out: string # target tree (destroyed and rebuilt)
+ --exclude: string = "" # comma-separated basename globs pruned from the result
+ --label: string = "layers" # name used in the summary line
+]: nothing -> nothing {
+ if ($layers | is-empty) {
+ error make { msg: "no layers given — at least the base layer is required" }
+ }
+ if ($out | is-empty) {
+ error make { msg: "--out is required" }
+ }
+
+ let base = ($layers | first)
+ if not ($base | path exists) {
+ error make { msg: $"base layer not found: ($base)" }
+ }
+
+ # Rebuild from scratch so a file deleted upstream never lingers downstream.
+ if ($out | path exists) { ^rm -rf $out }
+ mkdir $out
+
+ let applied = $layers | each {|layer|
+ if ($layer | path exists) {
+ # `src/.` copies CONTENTS; -L dereferences symlinks (layers may live behind
+ # links and the tree must be self-contained); cp merges dirs and overwrites
+ # colliding files — which IS the override semantics.
+ ^cp -RL $"($layer)/." $out
+ { layer: $layer, present: true }
+ } else {
+ { layer: $layer, present: false }
+ }
+ }
+
+ let patterns = ($exclude | split row "," | each {|p| $p | str trim } | where {|p| $p | is-not-empty })
+ let pruned = $patterns | each {|pat|
+ let hits = (glob $"($out)/**/($pat)")
+ $hits | each {|f| ^rm -rf $f }
+ ($hits | length)
+ } | append 0 | math sum
+
+ for l in $applied {
+ let mark = if $l.present { $"(ansi green)+(ansi reset)" } else { $"(ansi dark_gray)·(ansi reset)" }
+ let note = if $l.present { "" } else { " (absent — inherited from below)" }
+ print $" ($mark) ($l.layer)($note)"
+ }
+
+ let count = (^find -L $out -type f | lines | where {|l| $l | is-not-empty } | length)
+ let prune_note = if $pruned > 0 { $" — ($pruned) pruned by --exclude" } else { "" }
+ print $"(ansi green)✅(ansi reset) ($label) → ($out) — ($count) files($prune_note)"
+}
diff --git a/site/scripts/build/check-live-server.nu b/site/scripts/build/check-live-server.nu
new file mode 100644
index 0000000..61dca1e
--- /dev/null
+++ b/site/scripts/build/check-live-server.nu
@@ -0,0 +1,135 @@
+#!/usr/bin/env nu
+
+# ¿ESTE SERVIDOR ES EL SITIO, O SOLO ALGO QUE RESPONDE EN ESE PUERTO?
+#
+# Toda comprobación contra la superficie viva empieza dando por hecho lo único que nunca verifica:
+# que al otro lado del puerto está el sitio. `check-served-live.nu` lo dice en su cabecera — «la
+# superficie viva es la única publicación que hay» — y luego acepta un `--base-url` y se cree lo
+# que le dan. Puede medir un servidor que no es este sitio y decirlo todo en verde.
+#
+# ── QUÉ PASÓ, MEDIDO (2026-07-16) ────────────────────────────────────────────────────────────
+#
+# Un servidor arrancado por una vía degradada —sin el entorno de run.sh— no trae RUSTELO_SITE_ROUTES,
+# así que NO FUSIONA las rutas que el sitio declara en site/config/routes/. Responde 200 en todo lo
+# que la imagen compartida trae compilado y 404 en lo que este sitio aporta. Contra ese servidor:
+# /conceptos daba 404 y /sobre-el-idioma daba 404. De ahí salió media sesión persiguiendo un fallo
+# de rutas que no existía, y dos diagnósticos falsos encadenados («las rutas están rotas», «el
+# conmutador de idioma manda a 404»). Las rutas estaban perfectas. El servidor no era el sitio.
+#
+# ── POR QUÉ NO COMPRUEBA LA CANONICAL, QUE SERÍA MÁS CORTO ──────────────────────────────────
+#
+# Un servidor degradado emite `canonical = http://127.0.0.1:3030` y uno de run.sh emite
+# `https://ontoref.dev`, porque run.sh exporta SITE_BASE_URL. Sería una línea. Y sería
+# `a-check-measures-the-capability-not-a-proxy` (adr-072) en estado puro: SITE_BASE_URL no es
+# «servir las rutas del sitio», es otra variable que resulta que hoy viaja en el mismo autobús.
+# El día que alguien exporte una y no la otra, la puerta dice OK sobre un servidor ciego — y lo
+# dirá con toda la confianza, que es lo que hace peligroso a un proxy.
+#
+# Así que se mide LA CAPACIDAD: pedir las rutas que el sitio declara, y comprobar que el servidor
+# las conoce. Es la pregunta entera y no su sombra.
+#
+# ── Y SE PREGUNTA CON MARCADOR POSITIVO ─────────────────────────────────────────────────────
+#
+# No «el título no es el de la 404»: en este sitio un 404 responde 200 con «ontoref — Article»
+# (medido hoy: /adr/999), la copia del 404 viaja incrustada en todas las páginas, y afirmar por
+# ausencia de un negativo es como se cuelan los falsos verdes. La ruta declara `title_key`, y el
+# .ftl dice qué texto es. Eso es lo que la página tiene que traer: la afirmación positiva de que
+# el servidor sirve ESTA página, no de que no sirve otra.
+#
+# Uso:
+# nu scripts/build/check-live-server.nu --base-url http://localhost:3030 --root .
+
+def main [
+ --base-url: string = "http://localhost:3030",
+ --root: string = ".",
+] {
+ let routes_dir = ([$root, "site", "config", "routes"] | path join)
+ if not ($routes_dir | path exists) {
+ print $"check-live-server: NO MIRÉ — ($routes_dir) no existe, y es lo único que dice qué rutas"
+ print " aporta este sitio. Sin eso no puedo distinguir un servidor bueno de uno ciego."
+ exit 1
+ }
+
+ # Las rutas del CONSUMIDOR, que son exactamente las que un servidor degradado se salta. Las de la
+ # imagen compartida no sirven de testigo: las responde igual sin haber leído nada de este sitio.
+ let declared = (
+ glob $"($routes_dir)/*.ncl"
+ | each {|f|
+ let r = (do { ^nickel export --format json $f } | complete)
+ if $r.exit_code != 0 {
+ print $"check-live-server: NO MIRÉ — ($f) no exporta: ($r.stderr | str trim | lines | first)"
+ exit 1
+ }
+ ($r.stdout | from json).routes
+ }
+ | flatten
+ | where {|r| ($r.enabled? | default true) }
+ )
+
+ # CERO RUTAS DECLARADAS ES UN HALLAZGO, NUNCA UN «TODO BIEN». Si el glob no encuentra nada, o el
+ # esquema cambió y `routes` ya no se llama así, esta puerta no tiene nada que pedir — y una puerta
+ # sin nada que pedir pasa siempre. Es `a-check-can-say-i-did-not-look` (adr-072), y es el modo en
+ # que las puertas se mueren solas: en verde.
+ if ($declared | is-empty) {
+ print $"check-live-server: NO MIRÉ — ($routes_dir) no declaró ni una ruta habilitada."
+ print " Una puerta sin nada que pedir pasa siempre. Eso no es un servidor sano: es ignorancia."
+ exit 1
+ }
+
+ mut bad = []
+ mut ok = []
+ for r in $declared {
+ let lang = ($r.language? | default "")
+ let key = ($r.title_key? | default "")
+ if ($key | is-empty) {
+ $bad = ($bad | append $"($r.path) — la ruta no declara title_key, así que no tengo marcador positivo que pedir")
+ continue
+ }
+ # El texto que el sitio PROMETE para esa página. Se busca en su idioma: una clave resuelta
+ # contra el .ftl equivocado compararía la página con la promesa de otro idioma.
+ let ftl_dir = ([$root, "site", "i18n", "locales", $lang, "pages"] | path join)
+ let want = (
+ glob $"($ftl_dir)/*.ftl"
+ | each {|f| open --raw $f | lines | where {|l| $l | str starts-with $"($key) = " } }
+ | flatten
+ | each {|l| $l | str replace $"($key) = " "" | str trim }
+ | get -o 0
+ )
+ if $want == null or ($want | is-empty) {
+ $bad = ($bad | append $"($r.path) — title_key '($key)' no está en ($lang)/pages/*.ftl: la ruta promete un texto que no existe")
+ continue
+ }
+
+ let res = (do { ^curl -s -o /tmp/cls-page.html -w "%{http_code}" $"($base_url)($r.path)" } | complete)
+ let code = ($res.stdout | str trim)
+ let body = (if ("/tmp/cls-page.html" | path exists) { open --raw /tmp/cls-page.html } else { "" })
+ let title = ($body | parse --regex '(?s)(?.*?)' | get -o t.0 | default "")
+
+ if $code != "200" {
+ $bad = ($bad | append $"($r.path) [($lang)] — HTTP ($code). El sitio la declara y el servidor no la conoce.")
+ } else if not ($title | str contains $want) {
+ # El 404 de este sitio responde 200. Por eso el veredicto lo da el TÍTULO contra la promesa,
+ # no el código: una página que existe trae lo que su title_key dice; una que no, trae la
+ # cáscara genérica y el mismo 200.
+ $bad = ($bad | append $"($r.path) [($lang)] — 200, pero el título es «($title)» y su title_key promete «($want)». Es la cáscara, no la página.")
+ } else {
+ $ok = ($ok | append $"($r.path) [($lang)]")
+ }
+ }
+
+ if ($bad | is-not-empty) {
+ print "check-live-server: ESTE SERVIDOR NO ES ESTE SITIO — rutas que el sitio declara y él no sirve:"
+ for b in $bad { print $" ✗ ($b)" }
+ print ""
+ print " Casi siempre significa que el servidor se arrancó sin el entorno de run.sh, así que"
+ print " RUSTELO_SITE_ROUTES no está y NO fusionó site/config/routes/. Responde 200 en todo lo"
+ print " que la imagen compartida trae compilado y 404 en lo que aporta este sitio."
+ print ""
+ print " ./run-secure.sh # SOPS exec-env → run.sh → el entorno completo"
+ print ""
+ print " NO midas nada contra este servidor: lo que te diga sobre esta web no es sobre esta web."
+ exit 1
+ }
+
+ print $"check-live-server: el servidor sirve las ($ok | length) rutas que este sitio declara — ($ok | str join ', ')"
+}
diff --git a/site/scripts/build/check-served-live.nu b/site/scripts/build/check-served-live.nu
new file mode 100644
index 0000000..fa8d53c
--- /dev/null
+++ b/site/scripts/build/check-served-live.nu
@@ -0,0 +1,265 @@
+#!/usr/bin/env nu
+
+# THE LIVE SURFACE IS THE ONLY PUBLICATION THERE IS.
+#
+# `gen-expediente.nu --check` closes the gap between the canonical tree (site/r/) and the served
+# tree (site/public/r/) — a real gap, and the one that made a case answer 200, sit in the index,
+# and be absent from the grid. But it compares two trees ON DISK, and the disk is itself a PROXY:
+# THE SERVER READS ITS INDEX INTO MEMORY AT STARTUP. Both trees said five, the grid showed four,
+# and only a restart fixed it. A check that stops at the filesystem cannot see that.
+#
+# So this is the witness at the end of the chain, and it asks the only question a reader can:
+#
+# IS THE CASE ON THE PAGE?
+#
+# Two rules govern how it asks, and both were learned by getting them wrong:
+#
+# · IT COUNTS THE CARD, NOT THE STRING. The ids of these cases also live in the page's nav, its
+# meta tags and two JSON blobs — `testigo-ciego` occurs FOUR TIMES before the first card ever
+# opens. A page-wide grep therefore reports a case as present while its card is missing, and
+# that is not hypothetical: it is precisely the verification that reported "5 cards" over a
+# grid that was rendering four (adr-072, a-check-measures-the-capability-not-a-proxy).
+#
+# · ZERO CARDS IS A FINDING, NEVER AN ANSWER. If the page yields no card at all, this check
+# cannot tell "the grid is empty" from "I no longer know how to read a card" — the selector
+# changed, the framework re-skinned, the page 500'd into a friendly shell. Both are findings.
+# An empty list is what a blind check returns (adr-072, a-check-can-say-i-did-not-look).
+#
+# Usage:
+# nu scripts/build/check-served-live.nu --base-url http://localhost:3030 --root .
+
+# ── The parser. Pure, and separated from the fetch SO THAT IT CAN BE FALSIFIED OFFLINE ────────
+#
+# A gate nobody has seen refuse is not a gate. Keeping this pure is what lets the oracle feed it
+# a page whose fifth case appears only in a JSON blob and assert that it is NOT counted.
+#
+# TWO INDEPENDENT READINGS OF THE SAME PAGE, because one reading cannot check itself.
+#
+# READING B (the marker) knows the framework's card class. It is precise, and it is COUPLED: a
+# re-skin renames the class and the reading silently returns nothing.
+#
+# READING A (the structure) knows no class at all. It deletes the regions that are not content —
+# ' ''
+ | str replace --all --regex '(?is)' ''
+ | str replace --all --regex '(?is)]*>.*?' ''
+ | str replace --all --regex '(?is)' ''
+ | str replace --all --regex '(?is)]*>.*?' ''
+ | str replace --all --regex '(?is)' ''
+ )
+ $body
+ | parse --regex ('href="' + $prefix + '(?P[^"/#?]+)"')
+ | get slug
+ | uniq
+}
+
+# The slugs RENDERED AS CARDS in a grid page, and nothing else. `marker` is DECLARED, never
+# guessed: site/config/expedientes-surfaces.ncl owns it, so a re-skin is repaired in data.
+export def cards-in [html: string, prefix: string, marker: string]: nothing -> list {
+ let blocks = ($html | split row ('class="' + $marker + ' '))
+ if ($blocks | length) <= 1 { return [] }
+ $blocks
+ | skip 1
+ | each { |b|
+ let hit = ($b | parse --regex ('href="' + $prefix + '(?P[^"/#?]+)"'))
+ if ($hit | is-empty) { null } else { ($hit | first | get slug) }
+ }
+ | compact
+ | uniq
+}
+
+# How many cards the page rendered. Distinguishes "the grid is empty" from "I could not read it".
+export def card-count [html: string, marker: string]: nothing -> int {
+ (($html | split row ('class="' + $marker + ' ')) | length) - 1
+}
+
+def main [
+ --base-url: string = "http://localhost:3030",
+ --root: string = ".",
+] {
+ let root_abs = ($root | path expand)
+
+ # ── ANTES DE MEDIR: ¿ESTE SERVIDOR ES ESTE SITIO? ───────────────────────────────────────────
+ #
+ # Esta comprobación existe porque «la superficie viva es la única publicación que hay» — y luego
+ # aceptaba un `--base-url` y se creía lo que le daban. Es la premisa que nunca verificaba: que al
+ # otro lado del puerto está el sitio. Un servidor arrancado sin el entorno de run.sh no fusiona
+ # site/config/routes/ y responde 404 a todo lo que este sitio aporta, mientras contesta 200 a lo
+ # que la imagen compartida trae compilado. Contra ese servidor, todo lo que esta puerta diga
+ # sobre esta web no es sobre esta web — y lo diría con la misma confianza.
+ #
+ # Va PRIMERO y aborta: publicar un veredicto medido contra un servidor que no es el sitio es peor
+ # que no medir. El de abajo es el mismo criterio que el resto del fichero — un hallazgo que no se
+ # puede alcanzar no es un hallazgo (adr-072).
+ let preflight = ([$root_abs, "scripts", "build", "check-live-server.nu"] | path join)
+ let pf = (do { ^nu $preflight --base-url $base_url --root $root_abs } | complete)
+ if $pf.exit_code != 0 {
+ print $pf.stdout
+ print "served-live: NO MIDO NADA CONTRA ESTE SERVIDOR."
+ exit 1
+ }
+
+ # ── The extent: languages (protocol) × surfaces (site) ──────────────────────────────────────
+ let lex = ([$root_abs, "..", "..", ".ontoref", "ontology", "lexicon.ncl"] | path join | path expand)
+ let spine = ([$root_abs, "..", "..", ".ontoref"] | path join | path expand)
+ if not ($lex | path exists) {
+ print $"served-live: I DID NOT LOOK — the declared extent does not exist: ($lex)"
+ exit 1
+ }
+ let lr = (do { ^nickel export --import-path $spine $lex } | complete)
+ if $lr.exit_code != 0 {
+ print $"served-live: I DID NOT LOOK — the declared extent failed to export: ($lex)"
+ exit 1
+ }
+ let langs = ($lr.stdout | from json).languages
+
+ let surf_file = ([$root_abs, "site", "config", "expedientes-surfaces.ncl"] | path join)
+ if not ($surf_file | path exists) {
+ print $"served-live: I DID NOT LOOK — no surface declaration at ($surf_file)"
+ exit 1
+ }
+ let sr = (do { ^nickel export $surf_file } | complete)
+ if $sr.exit_code != 0 {
+ print $"served-live: I DID NOT LOOK — the surface declaration failed to export: ($surf_file)"
+ exit 1
+ }
+ let surf = ($sr.stdout | from json)
+ let surfaces = $surf.surfaces
+ # The card marker is DECLARED. If the declaration has none, the witness does not fall back to a
+ # literal it happens to remember — a default here would be the folklore this file exists to end.
+ let marker = ($surf | get -o card | default "")
+ if ($marker | is-empty) {
+ print $"served-live: I DID NOT LOOK — ($surf_file) declares no `card` marker."
+ print " Without it I cannot tell a rendered card from a mention of its id in a JSON blob,"
+ print " and guessing the marker is exactly the folklore this declaration exists to end."
+ exit 1
+ }
+
+ mut findings = []
+
+ # COVERAGE ⊇ EXTENT, on the surface axis. Declare a language and give it no surface and the
+ # publisher would file its cases under `cases/` and serve them nowhere — silently, because the
+ # rule used to be `if es { casos } else { cases }`, which says every language that is not
+ # Spanish is English.
+ for l in $langs {
+ if ($surfaces | get -o $l | is-empty) {
+ $findings = ($findings | append
+ $"[($l)] the extent declares this language and site/config/expedientes-surfaces.ncl does not answer for it")
+ }
+ }
+
+ # The surfaces file REPEATS a path that routes.ncl owns. A repetition with no gate is a mirror,
+ # and a mirror drifts (adr-070). So the repetition is gated: rename the route and this breaks.
+ let routes = ([$root_abs, "site", "config", "routes.ncl"] | path join)
+ if ($routes | path exists) {
+ let rtxt = (open --raw $routes)
+ for l in ($surfaces | columns) {
+ let g = ($surfaces | get $l | get grid)
+ if not ($rtxt | str contains $'($l) = "($g)"') {
+ $findings = ($findings | append
+ $"[($l)] surface declares grid ($g), and routes.ncl does not — the mirror has drifted")
+ }
+ }
+ }
+
+ if ($findings | is-not-empty) {
+ print "served-live: EXTENT UNANSWERED — the surface declaration does not cover what is declared:"
+ for f in $findings { print $" ✗ ($f)" }
+ exit 1
+ }
+
+ # ── The live surface, per language ──────────────────────────────────────────────────────────
+ mut stale = []
+ mut looked = 0
+
+ for l in $langs {
+ let s = ($surfaces | get $l)
+ let idx = ([$root_abs, "site", "public", "r", "expedientes", $l, $s.dir, "index.json"] | path join)
+ if not ($idx | path exists) {
+ $stale = ($stale | append $"[($l)] no served index at ($idx) — nothing is published in this language")
+ continue
+ }
+ let expected = (open $idx | get posts? | default [] | where published? != false)
+ if ($expected | is-empty) { continue }
+
+ let url = $"($base_url)($s.grid)"
+ let r = (do { ^curl -s -f --max-time 15 $url } | complete)
+ if $r.exit_code != 0 {
+ # An unreachable page is not "no cases". It is "I did not look" — and this is the one place
+ # a live check is tempted to shrug and pass, which is how a deploy goes green over a dead site.
+ print $"served-live: I DID NOT LOOK — ($url) did not answer \(curl exit ($r.exit_code))."
+ print " A page I could not fetch is a FINDING. It is not an empty grid, and it is not green."
+ exit 1
+ }
+ let html = $r.stdout
+ let prefix = $"($s.grid)/($s.dir)/"
+ let shown = (cards-in $html $prefix $marker)
+ let structural = (content-links $html $prefix)
+
+ # THE TWO READINGS ADJUDICATE EACH OTHER, and this is the whole point of having two.
+ #
+ # The marker sees nothing and the structure sees cases: the page is FINE and the WITNESS is
+ # stale. Before there were two readings, this was indistinguishable from an empty grid — the
+ # check could only say "I found no cards", which is a mystery, and a mystery gets read as
+ # health. Now it is a defect with a name and an address.
+ if ($shown | is-empty) and ($structural | is-not-empty) {
+ print $"served-live: MY CARD MARKER IS STALE — not the page's fault, MINE."
+ print $" ($url) renders ($structural | length) case link\(s), and `class=\"($marker) \"` matches nothing in it."
+ print " The grid was re-skinned and this witness was not. It is not an empty grid, and it is"
+ print " NOT GREEN: a witness that cannot recognise what it is watching has stopped witnessing."
+ print " Repair the marker in site/config/expedientes-surfaces.ncl (`card`), then re-falsify."
+ exit 1
+ }
+
+ # Neither reading sees anything, and the index says cases are published: the page is broken,
+ # or it is not the page I think it is. Either way it is a finding, never an empty list.
+ if ($shown | is-empty) and ($structural | is-empty) {
+ print $"served-live: I DID NOT LOOK — ($url) answered, and NEITHER reading found a case in it."
+ print $" ($expected | length) case\(s) are published in ($l). The grid is broken, or this is"
+ print " not the page I think it is. Both are findings; neither is an empty result."
+ exit 1
+ }
+
+ # The structure sees a case the marker does not: a case link escaped the card element. The
+ # readings disagree, one of them is wrong, and the check does not get to pick. It says so.
+ let escaped = ($structural | where {|c| not ($c in $shown) })
+ if ($escaped | is-not-empty) {
+ $stale = ($stale | append
+ $"[($l)] my two readings of ($url) DISAGREE — ($escaped | str join ', ') appear as case links outside any `($marker)` card. One reading is wrong and I cannot tell which.")
+ }
+
+ for p in $expected {
+ if not ($p.slug in $shown) {
+ $stale = ($stale | append
+ $"[($l)] ($p.slug) — in the served index, NOT rendered as a card at ($url)")
+ }
+ }
+ $looked += 1
+ }
+
+ if ($stale | is-not-empty) {
+ print "served-live: STALE — the server is not serving what the tree says is published:"
+ for s in $stale { print $" ✗ ($s)" }
+ print " The disk is not the publication. The server reads its index INTO MEMORY AT STARTUP,"
+ print " so a mirrored tree it has never re-read is a tree that, to every reader, does not exist."
+ print " Reload the content index (or restart the server) — then this check is the proof."
+ exit 1
+ }
+
+ print $"served-live: the grid RENDERS every published case · ($langs | str join ', ') · ($looked) surface\(s) read live"
+}
diff --git a/site/scripts/build/gen-conceptos.nu b/site/scripts/build/gen-conceptos.nu
new file mode 100644
index 0000000..d46fa58
--- /dev/null
+++ b/site/scripts/build/gen-conceptos.nu
@@ -0,0 +1,305 @@
+#!/usr/bin/env nu
+
+# Project the GLOSSARY → the CONCEPTS page, server-rendered:
+# site/i18n/locales//pages/conceptos.ftl
+#
+# ── ESTA PÁGINA LEÍA EL ÁRBITRO Y CREÍA LEER EL GLOSARIO ──────────────────────────────────
+#
+# Hasta hoy proyectaba `ontology/registry.ncl`, que NO es el glosario del proyecto: es la unión
+# aplanada de las decisiones de traducción, una fila por (término × idioma de DESTINO). El
+# resultado se llamaba «Conceptos clave» y listaba `commit`, `push`, `rebase`, `exit`, `hook` —
+# palabras que `lexicon.ncl` describe en su propia cabecera como «ordinary words of the source
+# language» que tienen una decisión de traducción y NADA QUE DEFINIR. De los 20 conceptos del
+# proyecto (ondaod, espiral, síntesis, on+re, PAP, testigo verificado…) aparecía exactamente uno,
+# y por su política, no por su definición.
+#
+# Y el inglés salía VACÍO. No por falta de frases: las 20 definiciones inglesas llevaban meses
+# escritas. Las `rules` del registro se indexan por idioma de DESTINO, y el inglés es `src_lang`
+# — la fuente no se traduce a sí misma, así que no genera ni una fila. La página no estaba
+# incompleta: estaba leyendo un fichero que no podía contener la respuesta. Y el generador lo
+# contaba como un pendiente («the English glosses have not been written yet»), que es la forma
+# más cara de equivocarse: un hueco estructural disfrazado de tarea.
+#
+# Así que lee `ontology/glossary.ncl`, que es de donde son los conceptos: `definition` es
+# `i18n_str` y OBLIGATORIA (schemas/term.ncl), y el mismo dato lo sirve ya `ontoref describe term
+# --lang en|es`. Una fuente, dos superficies — que es lo que se creía tener.
+#
+# El léxico (`lexicon.ncl`) no sobra: gestiona las traducciones y alimenta la nota de
+# /sobre-el-idioma. Sencillamente no es el glosario, y una página que mezcla las dos cosas no
+# responde bien a ninguna de las dos preguntas.
+#
+# Usage (from outreach/site):
+# nu scripts/build/gen-conceptos.nu --root ../..
+# nu scripts/build/gen-conceptos.nu --root ../.. --check
+
+# ── EL TERCER FILO DEL MISMO CUCHILLO ────────────────────────────────────────────────────
+#
+# En Fluent, `{` abre un PLACEABLE — una variable a interpolar. La definición de `domain` cita la
+# ruta `code/domains/{id}/`, y eso convirtió el valor entero de `conceptos-html` en un mensaje con
+# parámetros: la página sirvió `__FLUENT_MSG_WITH_PARAMS__` donde iban los 20 conceptos, a 200, con
+# su
y su intro correctos encima. Medido hoy, en la primera ejecución de este generador.
+#
+# El valor anterior no tenía llaves por casualidad: las glosas del árbitro eran frases cortas, y las
+# definiciones del glosario citan rutas y código. La forma de la fuente cambió, y este escape es la
+# consecuencia — no una precaución.
+#
+# Este fichero ya conocía las otras dos caras (clave duplicada → cae el recurso; clave vacía → cae
+# el recurso). Las tres son lo mismo: un carácter que Fluent lee como sintaxis, dentro de un dato
+# que nadie escribió pensando en Fluent. Escapar es del generador; el dato no tiene por qué saber
+# a qué idioma de plantilla lo van a servir.
+#
+# El escape de Fluent para una llave literal es `{"{"}` — un placeable con una cadena dentro. Se
+# hace en UNA pasada vía marcador: sustituir `{` primero introduce `{` y `}` nuevos, y la segunda
+# pasada los destrozaría.
+def esc-ftl [s: string]: nothing -> string {
+ # A Fluent value is one line. A newline inside it would silently truncate the entry — and a
+ # glossary whose definitions end mid-sentence is worse than no glossary. Las definiciones son
+ # m-strings MULTILÍNEA, así que esto no es una defensa: es el caso normal.
+ $s
+ | str replace --all --regex '\s+' " "
+ | str trim
+ | str replace --all "{" "\u{1}" | str replace --all "}" "\u{2}"
+ | str replace --all "\u{1}" '{"{"}' | str replace --all "\u{2}" '{"}"}'
+}
+
+# ── EL RENDERER HABLANDO, NO EL DATO ──────────────────────────────────────────────────────
+#
+# Las categorías y los orígenes son ENUM del contrato ('Concept, 'Adr…). Su nombre en cada idioma
+# es cosa del renderer y vive aquí, una vez, igual que en expediente-vocab.nu. Un enum sin nombre
+# declarado es un ERROR, no un `default` silencioso: si mañana term.ncl añade 'Heuristic, esta
+# página tiene que decirlo — no imprimir "Heuristic" dentro de una página en español y aparentar
+# que alguien lo decidió.
+const CATEGORY = {
+ es: { Discipline: "Disciplina", Concept: "Concepto", Practice: "Práctica", Artifact: "Artefacto", Procedure: "Procedimiento", Tension: "Tensión", Antipattern: "Antipatrón" }
+ en: { Discipline: "Discipline", Concept: "Concept", Practice: "Practice", Artifact: "Artifact", Procedure: "Procedure", Tension: "Tension", Antipattern: "Antipattern" }
+}
+
+const ORIGIN = {
+ es: { Axiom: "Axioma", Tension: "Tensión", Practice: "Práctica", Adr: "ADR", Schema: "Esquema", Module: "Módulo", Crate: "Crate", External: "" }
+ en: { Axiom: "Axiom", Tension: "Tension", Practice: "Practice", Adr: "ADR", Schema: "Schema", Module: "Module", Crate: "Crate", External: "" }
+}
+
+const CHROME = {
+ es: {
+ title: "Conceptos clave — ontoref",
+ subtitle: "Las palabras que este proyecto usa, y qué significan",
+ desc: "El vocabulario de ontoref: cada concepto definido una sola vez, con su categoría, de dónde nace y con qué otros se relaciona.",
+ keywords: "glosario, conceptos, ontología, reflexión, ADR, ondaod, espiral, síntesis",
+ intro: "Este glosario no está escrito aquí. Se proyecta de .ontoref/ontology/glossary.ncl — el mismo fichero del que responde «ontoref describe term» en la terminal. Si una definición cambia, cambia en un sitio y cambia en los dos.",
+ draft: "BORRADOR",
+ draft_title: "Término nacido en sesión, todavía no ratificado como ADR (verified = false)",
+ origin_label: "nace de",
+ related_label: "relacionados",
+ more: "Más información →",
+ }
+ en: {
+ title: "Key concepts — ontoref",
+ subtitle: "The words this project uses, and what they mean",
+ desc: "The ontoref vocabulary: each concept defined once, with its category, where it comes from, and what it relates to.",
+ keywords: "glossary, concepts, ontology, reflection, ADR, ondaod, spiral, synthesis",
+ intro: "This glossary is not written here. It is projected from .ontoref/ontology/glossary.ncl — the same file «ontoref describe term» answers from in the terminal. Change a definition once, and it changes in both.",
+ draft: "DRAFT",
+ draft_title: "Session-originated term, not yet ratified as an ADR (verified = false)",
+ origin_label: "comes from",
+ related_label: "related",
+ more: "Learn more →",
+ }
+}
+
+# ── EL ENLACE AL ADR SE CONSTRUYE, Y HAY QUE CONSTRUIRLO BIEN ─────────────────────────────
+#
+# `origin.ref` es `adr-050`; la ruta es `/adr/050`. Y `/adr/adr-050` NO da 404: da 200 con el
+# título «ontoref — Article» — un 404 BLANDO. Medido hoy: /adr/999 hace exactamente lo mismo.
+# Así que un prefijo mal quitado no rompe nada visible, no mueve ningún código de estado, y
+# publica un enlace muerto que responde OK. En este sitio el status NO sirve para saber si una
+# página existe; por eso la puerta de abajo compara contra un marcador POSITIVO.
+def adr-path [ref: string]: nothing -> string {
+ $"/adr/($ref | str replace --regex '^adr-' '')"
+}
+
+def main [
+ --root: string = "../.."
+ --check
+] {
+ let gl = (
+ ^nickel export --format json --import-path $"($root)/.ontoref" $"($root)/.ontoref/ontology/glossary.ncl"
+ | from json
+ )
+ # La extensión se DECLARA, no se infiere de lo que hay escrito. Mismo criterio que
+ # gen-expediente.nu: preguntar «¿en qué idiomas existe esto?» al fichero que lo declara, para
+ # que añadir un idioma ROMPA esta página en vez de dejarla callada a medias.
+ let langs = (
+ ^nickel export --format json --import-path $"($root)/.ontoref" $"($root)/.ontoref/ontology/lexicon.ncl"
+ | from json | get languages
+ )
+
+ mut drift = []
+ mut gaps = []
+ for lang in $langs {
+ let c = ($CHROME | get -o $lang)
+ if $c == null {
+ error make { msg: $"'($lang)' está declarado en lexicon.ncl y esta página no sabe hablarlo — añade su bloque a CHROME en gen-conceptos.nu" }
+ }
+ let cats = ($CATEGORY | get $lang)
+ let origins = ($ORIGIN | get $lang)
+
+ let terms = (
+ $gl.terms
+ | each {|t|
+ let cat = ($cats | get -o ($t.category | into string))
+ if $cat == null {
+ error make { msg: $"($t.id): la categoría '($t.category)' no tiene nombre en ($lang) — declárala en CATEGORY (gen-conceptos.nu). Sin esto se imprimiría en inglés dentro de una página en español y nadie lo habría decidido." }
+ }
+ let o = ($t.origin? | default { kind: "External", ref: "" })
+ let okind = ($origins | get -o ($o.kind | into string) | default "")
+ # El enlace: el ADR del que nace el concepto, o el que su política ya declaró.
+ let pol_href = ($t.policy? | default {} | get -o by_lang | default {} | get -o $lang | default {} | get -o gloss_href | default "")
+ {
+ id: $t.id,
+ name: ($t.name | get -o $lang | default ""),
+ def: ($t.definition | get -o $lang | default ""),
+ cat: $cat,
+ origin: (if ($okind | is-empty) or ($o.ref | is-empty) { "" } else { $"($okind) · ($o.ref)" }),
+ href: (
+ if ($o.kind | into string) == "Adr" and ($o.ref | is-not-empty) { adr-path $o.ref }
+ else { $pol_href }
+ ),
+ verified: ($t.verified? | default false),
+ related: ($t.related_terms? | default []),
+ }
+ }
+ | sort-by name
+ )
+
+ # CERO CONCEPTOS NO ES UN ESTADO DE LA PÁGINA: ES UN GLOSARIO ROTO.
+ #
+ # La plantilla tenía una rama «vacío» porque el inglés SIEMPRE llegaba a 0 y había que
+ # confesarlo. Leyendo el glosario eso no puede pasar sin que algo esté mal — un export vacío,
+ # un import roto, un filtro de más. Así que se refusa aquí, antes de escribir, en vez de
+ # publicar una página que se disculpa por estar vacía. Una rama que no puede darse es una rama
+ # que nadie prueba, y el .ftl que la alimentaba ya no la emite.
+ if ($terms | is-empty) {
+ error make { msg: $"glossary.ncl no devolvió ni un concepto para ($lang) — no publico un glosario vacío que se explique a sí mismo. Revisa el export." }
+ }
+
+ # UN HUECO DECLARADO ES UN HUECO; UN HUECO CALLADO ES UNA MENTIRA.
+ # `definition.es` lleva `default = ""` en el contrato, así que un término PUEDE llegar sin su
+ # definición en un idioma y el export no chista. Esta página no lo va a disimular imprimiendo
+ # el nombre a secas: se recoge y se informa, con el término y el idioma. Es el mismo criterio
+ # que mató a esta página la primera vez — servir un hueco elegante en vez de nombrarlo.
+ for t in $terms {
+ if ($t.def | is-empty) { $gaps = ($gaps | append $"($t.id) [($lang)] — sin definition.($lang)") }
+ if ($t.name | is-empty) { $gaps = ($gaps | append $"($t.id) [($lang)] — sin name.($lang)") }
+ }
+
+ # El
se emite como UN valor Fluent y se pinta con `| safe` — lo renderiza el SERVIDOR,
+ # así que un rastreador y un lector de pantalla reciben el glosario entero sin ejecutar una
+ # línea de JavaScript. La plantilla itera contexto ESTRUCTURADO que llega de Rust, y un
+ # objeto de contexto nuevo sería tocar el crate por una página que es dato puro.
+ let ids = ($terms | get id)
+ let dl = (
+ $terms | each {|t|
+ let draft = (if $t.verified { "" } else { $"($c.draft)" })
+ let meta = ([
+ $"($t.cat)"
+ (if ($t.origin | is-empty) { "" } else { $"($c.origin_label) ($t.origin)" })
+ ] | str join "")
+ let link = (if ($t.href | is-empty) { "" } else { $"($c.more)" })
+ # Los relacionados apuntan al ancla del término EN ESTA MISMA PÁGINA, y SOLO si el término
+ # está publicado aquí: `related_terms` puede nombrar un id que no está en el glosario, y
+ # eso sería un que no va a ninguna parte y no falla en ningún sitio.
+ let live = ($t.related | where {|r| $r in $ids })
+ let rel = (
+ if ($live | is-empty) { "" } else {
+ let items = ($live | each {|r| $"(esc-ftl ($terms | where id == $r | get name | first))" } | str join ", ")
+ $"($c.related_label): ($items)"
+ }
+ )
+ $"
"
+
+ let ftl = ([
+ "# GENERATED from .ontoref/ontology/glossary.ncl by gen-conceptos.nu — do not edit by hand."
+ "# One source: the same glossary `ontoref describe term` answers from."
+ $"conceptos-page-title = ($c.title)"
+ $"conceptos-page-subtitle = ($c.subtitle)"
+ $"conceptos-page-description = ($c.desc)"
+ $"conceptos-page-keywords = ($c.keywords)"
+ $"conceptos-intro = ($c.intro)"
+ $"conceptos-more = ($c.more)"
+ $"conceptos-count = ($terms | length)"
+ $rows
+ ""
+ ] | str join "\n")
+
+ let out = $"site/i18n/locales/($lang)/pages/conceptos.ftl"
+
+ # A duplicate Fluent key drops the WHOLE language resource — 42 files concatenated into one.
+ # That is expediente 1/1852, and a generator that can still do it has learned nothing.
+ let keys = ($ftl | lines | where {|l| (not ($l | str starts-with "#")) and ($l | str contains " = ") } | each {|l| $l | split row " = " | first })
+ let dup = ($keys | group-by {|k| $k } | items {|k, v| { k: $k, n: ($v | length) } } | where n > 1 | get k)
+ if ($dup | is-not-empty) {
+ error make { msg: $"conceptos.ftl [($lang)]: claves Fluent duplicadas — el recurso se caería entero y dejaría el idioma sin texto: ($dup | str join ', ')" }
+ }
+ # UNA CLAVE SIN VALOR INVALIDA EL RECURSO IGUAL QUE UNA DUPLICADA. En Fluent, `clave =` sin
+ # valor es un ERROR DE SINTAXIS y el fichero entero deja de cargar: las 9 claves de esta página
+ # desaparecieron del bundle (las otras 1861 seguían), el
salió vacío y el sirvió
+ # `[conceptos-page-title]` a 200. El cerrojo contra duplicadas existía; contra vacías no — el
+ # mismo defecto del expediente 1/1852, cometido por quien lo publicó.
+ let empty = (
+ $ftl | lines
+ | where {|l| (not ($l | str starts-with "#")) and ($l | str trim | str ends-with "=") }
+ | each {|l| $l | split row "=" | first | str trim }
+ )
+ if ($empty | is-not-empty) {
+ error make { msg: $"conceptos.ftl [($lang)]: claves Fluent sin valor — `clave =` es un error de sintaxis y el fichero NO CARGA: ($empty | str join ', ')" }
+ }
+ # UNA LLAVE CRUDA TUMBA EL VALOR ENTERO, y lo hace a 200. `{` abre un placeable de Fluent, así
+ # que un dato que cite `{id}` (lo hace `domain`) convierte los 20 conceptos en
+ # `__FLUENT_MSG_WITH_PARAMS__` bajo un
perfecto. Se comprueba QUITANDO los escapes legítimos
+ # y exigiendo que no quede ninguna llave: así el cerrojo mide lo que Fluent va a leer, no lo que
+ # yo creía haber escrito. Cubre también la chrome, que no pasa por esc-ftl.
+ let raw_braces = (
+ $ftl
+ | str replace --all '{"{"}' ""
+ | str replace --all '{"}"}' ""
+ | parse --regex '(?[{}])' | get b
+ )
+ if ($raw_braces | is-not-empty) {
+ error make { msg: $"conceptos.ftl [($lang)]: llave sin escapar — Fluent la lee como placeable y sirve `__FLUENT_MSG_WITH_PARAMS__` en vez del glosario, a 200. Escápala con esc-ftl \(({$raw_braces | length}) encontradas\)" }
+ }
+ # Dos términos con el mismo id serían dos
con el mismo ancla: el navegador se queda con
+ # el primero y el enlace al segundo miente en silencio. El contrato no lo impide.
+ let dupid = ($ids | group-by {|k| $k } | items {|k, v| { k: $k, n: ($v | length) } } | where n > 1 | get k)
+ if ($dupid | is-not-empty) {
+ error make { msg: $"conceptos.ftl [($lang)]: ids repetidos — dos anclas iguales y un enlace que miente: ($dupid | str join ', ')" }
+ }
+
+ if $check {
+ let cur = (if ($out | path exists) { open --raw $out } else { "" })
+ if $cur != $ftl { $drift = ($drift | append $out) }
+ } else {
+ $ftl | save -f $out
+ print $"conceptos → ($out) [($terms | length) conceptos · ($terms | where href != "" | length) con enlace · ($terms | where {|t| not $t.verified } | length) borrador]"
+ }
+ }
+
+ if ($gaps | is-not-empty) {
+ print "conceptos: HUECOS — el contrato permite un idioma sin definición y el export no chista:"
+ for g in $gaps { print $" ⊘ ($g)" }
+ }
+
+ if $check {
+ if ($drift | is-not-empty) {
+ print $"conceptos-check: DRIFT — ($drift | str join ', ') no reproduce desde el glosario. Ejecuta `just conceptos`."
+ exit 1
+ }
+ if ($gaps | is-not-empty) {
+ print "conceptos-check: INCOMPLETO — hay conceptos sin definición en algún idioma declarado."
+ exit 1
+ }
+ print $"conceptos-check: la página reproduce desde el glosario · completa en ($langs | str join ', ')"
+ }
+}
diff --git a/site/scripts/build/gen-diagram.nu b/site/scripts/build/gen-diagram.nu
index 4cc5890..e829050 100644
--- a/site/scripts/build/gen-diagram.nu
+++ b/site/scripts/build/gen-diagram.nu
@@ -36,6 +36,11 @@ def r1 [x] { $x | into float | math round --precision 1 }
def main [
--root: string = "../.."
--out: string = "site/public/images/ontoref-diagram.svg"
+ # Gate mode: reproduce from core.ncl and refuse if what is served differs. Exists because the
+ # `diagram` recipe was outside `sync-full` — the generator ran by hand, once, and nothing
+ # compared its output against the ontology afterwards. That is `generator-outside-the-chain`,
+ # the anti-pattern this justfile already names. Same shape as gen-architecture.nu --check.
+ --check
] {
let ip = $"($root)/code/ontology"
let core = $"($root)/.ontoref/ontology/core.ncl"
@@ -117,6 +122,15 @@ def main [
($node_svg)
"
+ if $check {
+ if (not ($out | path exists)) or ((open --raw $out) != $svg) {
+ print $"(ansi red)DRIFT: ($out) does not reproduce from core.ncl \(run: just diagram\)(ansi reset)"
+ exit 1
+ }
+ print $"(ansi green)gen-diagram: up to date(ansi reset) · ($count) nodes · ($n_edges) edges"
+ return
+ }
+
let parent = ($out | path dirname)
if ($parent | is-not-empty) { mkdir $parent }
$svg | save -f $out
diff --git a/site/scripts/build/gen-glossary-json.nu b/site/scripts/build/gen-glossary-json.nu
new file mode 100644
index 0000000..1d545a2
--- /dev/null
+++ b/site/scripts/build/gen-glossary-json.nu
@@ -0,0 +1,105 @@
+#!/usr/bin/env nu
+
+# Project the term registry → the glossary the PAGE can read:
+# site/public/r/glosario-.json
+#
+# The glosses already existed. Every one of them — "NCL — el lenguaje de configuración tipada en
+# el que se declara todo esto", "un DAG — grafo dirigido acíclico" — was written in
+# .ontoref/ontology/lexicon.ncl for a reader who does not know the word, and then no surface ever
+# showed it to one. /acerca-de said NCL and DAG to people who had never met either, and the gate's
+# own GlossFirstUse check sat inert with a comment explaining why it could not bite: "the site
+# serves no canonical glossary route yet, so failing here would fail on a link nobody can add".
+#
+# This is that route. Nothing here is authored: the gloss, the preferred form and the learn-more
+# href all come from the registry, so the tooltip cannot become the SEVENTH vocabulary — which is
+# exactly the failure expediente 6/0 is about.
+#
+# Usage (from outreach/site):
+# nu scripts/build/gen-glossary-json.nu --root ../..
+# nu scripts/build/gen-glossary-json.nu --root ../.. --check
+
+def main [
+ --root: string = "../.."
+ --out-dir: string = "site/public/r"
+ --check
+] {
+ let reg = (
+ ^nickel export --format json --import-path $"($root)/.ontoref" $"($root)/.ontoref/ontology/registry.ncl"
+ | from json
+ )
+
+ mut drift = []
+ for lang in $reg.languages {
+ # A term earns a tooltip by HAVING A GLOSS — never by being in the registry. The registry
+ # governs every word the project writes; the tooltip is for the handful a reader can trip on.
+ # `drift` needs no tooltip: a Spanish reader gets «deriva» and understands it.
+ let terms = (
+ $reg.rules
+ | where lang == $lang
+ | where kind == "Term"
+ | where {|r| ($r.gloss? | default "") != "" }
+ | each {|r| {
+ term: $r.instead, # the form the reader actually SEES on the page
+ gloss: $r.gloss,
+ href: ($r.gloss_href? | default ""),
+ } }
+ | uniq-by term
+ | sort-by term
+ )
+ if ($terms | is-empty) {
+ # Not a crash: a language may legitimately gloss nothing yet. But say so — a silently empty
+ # glossary is a tooltip layer that never fires and nobody notices.
+ print $"glosario [($lang)]: 0 términos con glosa — no se emite"
+ continue
+ }
+
+ let payload = {
+ generated_from: ".ontoref/ontology/registry.ncl",
+ lang: $lang,
+ terms: $terms,
+ }
+ # LA GLOSA ABRE CON LA PALABRA QUE EL LECTOR HA SEÑALADO — Y ESO NO LO COMPROBABA NADIE.
+ #
+ # El tooltip sale pegado a la palabra. Si la frase empieza por OTRA, el lector cree que le
+ # están hablando de otra cosa: pasa el ratón por `FSM` y lee «a state machine —». Las 26
+ # glosas españolas cumplen la regla («un testigo —», «una gate —», «los modos —») y la cumplen
+ # por casualidad: se escribieron junto a su `prefer`. En inglés `prefer` es la forma FUENTE, y
+ # a veces no es la misma palabra que abría la frase española — así entraron `fsm` («a state
+ # machine» sobre `FSM`) y `red-team` («an adversarial test» sobre `red-team`), las dos en la
+ # primera tanda de glosas inglesas.
+ #
+ # Una convención que solo vive en la cabeza de quien escribe la siguiente entrada no es una
+ # convención: es una racha. Aquí se ejecuta.
+ let misaligned = ($terms | where {|t|
+ let head = ($t.gloss | split row " — " | first | str downcase)
+ not (($head | str contains ($t.term | str downcase)) or (($t.term | str downcase) | str contains $head))
+ })
+ if ($misaligned | is-not-empty) {
+ print $"glosario [($lang)]: la glosa no abre por la palabra que el lector señala —"
+ for m in $misaligned { print $" ✗ «($m.term)» → «($m.gloss | split row ' — ' | first)»" }
+ print " El tooltip se ancla en el término: la frase tiene que empezar por él, o el lector"
+ print " cree que le hablan de otra cosa. Corrige la glosa en el registro."
+ exit 1
+ }
+
+ let out = $"($out_dir)/glosario-($lang).json"
+ let json = ($payload | to json --indent 2)
+
+ if $check {
+ let cur = (if ($out | path exists) { open --raw $out } else { "" })
+ if $cur != $json { $drift = ($drift | append $out) }
+ } else {
+ mkdir $out_dir
+ $json | save -f $out
+ print $"glosario → ($out) [($terms | length) términos · ($terms | where href != "" | length) con enlace]"
+ }
+ }
+
+ if $check {
+ if ($drift | is-not-empty) {
+ print $"glossary-check: DRIFT — ($drift | str join ', ') no reproduce desde el registro. Ejecuta `just glossary`."
+ exit 1
+ }
+ print "glossary-check: el glosario servido reproduce desde el registro"
+ }
+}
diff --git a/site/scripts/build/gen-graph-page.nu b/site/scripts/build/gen-graph-page.nu
index 3124551..cd209dc 100644
--- a/site/scripts/build/gen-graph-page.nu
+++ b/site/scripts/build/gen-graph-page.nu
@@ -109,6 +109,11 @@ const TEMPLATE = r##'
def main [
--root: string = "../.."
--out: string = "site/public/images/ontoref-graph.html"
+ # Gate mode: reproduce from core.ncl and refuse if what is served differs. This page is the
+ # LIVE PRIZE the About routes to (ADR-057 hook-static / prize-live) — a prize that silently
+ # stops tracking the ontology is worse than no prize, because the reveal still promises it.
+ # Same shape as gen-architecture.nu --check.
+ --check
] {
let ip = $"($root)/code/ontology"
let core = $"($root)/.ontoref/ontology/core.ncl"
@@ -143,6 +148,15 @@ def main [
| str replace --all '__NPR__' ($npr | into string)
| str replace '/*__ELEMENTS__*/' $elements)
+ if $check {
+ if (not ($out | path exists)) or ((open --raw $out) != $html) {
+ print $"(ansi red)DRIFT: ($out) does not reproduce from core.ncl \(run: just diagram\)(ansi reset)"
+ exit 1
+ }
+ print $"(ansi green)gen-graph-page: up to date(ansi reset) · ($nodes | length) nodes · ($edge_els | length) edges"
+ return
+ }
+
let parent = ($out | path dirname)
if ($parent | is-not-empty) { mkdir $parent }
$html | save -f $out
diff --git a/site/scripts/build/gen-og-cards.nu b/site/scripts/build/gen-og-cards.nu
new file mode 100644
index 0000000..603749d
--- /dev/null
+++ b/site/scripts/build/gen-og-cards.nu
@@ -0,0 +1,124 @@
+#!/usr/bin/env nu
+
+# Compose the SOCIAL CARD for every publication that declares a hero, and bind it into the
+# frontmatter as `og_image`.
+#
+# Why a separate file at all — the hero is already there. Because the hero is a .webp, and the
+# crawlers that carry a link (X, LinkedIn, WhatsApp) DO NOT DECODE WEBP. Pointing og:image at the
+# hero does not degrade the card, it BLANKS it. The card has to be a PNG, and it has to be
+# 1200×630, because that is the box every crawler crops to.
+#
+# Why compose instead of rescale: heroes are panoramic (2.0:1 … 2.9:1) and the card is 1.9:1.
+# Fitting the whole hero leaves bands, and a flat colour band under a photographic image reads as
+# a rendering bug — so the bands ARE the hero, scaled to cover and blurred. Nothing is cropped:
+# these images carry their logo and their case number at the very edge.
+#
+# The renderer reads `og_image` and nothing else — it never derives the card's path from the
+# hero's. A path convention that lives only in the server is invisible to whoever writes the post,
+# and the day it stops matching, the card falls back to the site default IN SILENCE. Declared, not
+# inferred: the frontmatter says which card it has, or it has none.
+#
+# Usage:
+# nu scripts/build/gen-og-cards.nu --root . # all publications with a hero
+# nu scripts/build/gen-og-cards.nu --root . --check # assert cards + og_image exist; write nothing
+
+const CARD_W = 1200
+const CARD_H = 630
+
+# The card's name is the hero's PATH, flattened — not its basename. A deck's cover is
+# `/images/decks//0.webp`, so basenames collide: every deck would claim `og/0.png` and the
+# last one composed would win, handing the other decks a card of someone else's talk. The path is
+# what is unique, so the path is what names the card.
+def card-path [image_url: string]: nothing -> string {
+ let rel = ($image_url | str replace --regex '^/images/' '' | str replace --regex '\.[a-zA-Z0-9]+$' '')
+ $"/images/og/($rel | str replace --all '/' '-').png"
+}
+
+# One ffmpeg pass: the hero scaled to COVER the card and blurred becomes the backdrop; the WHOLE
+# hero, scaled to FIT (decrease), sits centred on top. -frames:v 1 because a webp decodes as a
+# one-frame video stream, not a still.
+#
+# `decrease` is the whole point. Scaling the foreground to the card's WIDTH works only while heroes
+# are panoramic: a 3:2 post hero (1536×1024) becomes 1200×800 — taller than the 630-high card — and
+# the overlay silently crops its top and bottom. Fit, then band: bands are visible and honest, a
+# crop is invisible and lies.
+def compose [src: string, dest: string] {
+ let filter = $"[0:v]scale=($CARD_W):($CARD_H):force_original_aspect_ratio=increase,crop=($CARD_W):($CARD_H),gblur=sigma=28[bg];[0:v]scale=($CARD_W):($CARD_H):force_original_aspect_ratio=decrease[fg];[bg][fg]overlay=\(W-w\)/2:\(H-h\)/2"
+ let r = (do { ^ffmpeg -y -loglevel error -i $src -filter_complex $filter -frames:v 1 $dest } | complete)
+ if $r.exit_code != 0 {
+ error make { msg: $"ffmpeg failed composing ($dest): ($r.stderr | str trim)" }
+ }
+}
+
+def main [
+ --root: string = ".", # site root (the dir containing site/)
+ --check, # assert every hero has a card and an og_image; write nothing
+] {
+ if (which ffmpeg | is-empty) {
+ error make { msg: "ffmpeg not found on PATH — it composes the 1200×630 card from the hero" }
+ }
+ let og_dir = ([$root, "site", "public", "images", "og"] | path join)
+ mkdir $og_dir
+
+ mut missing = []
+ mut wrote = 0
+
+ for md in (glob $"($root)/site/content/**/*.md" | sort) {
+ let raw = (open --raw $md)
+ # image_url first, thumbnail as the fallback — NOT image_url only. Some posts carry a hero
+ # under `thumbnail` alone (context-declared-not-filled does), and requiring image_url left
+ # exactly those with no card at all: the publication with art, published artless.
+ let img = (
+ ["image_url:" "thumbnail:"]
+ | each {|k|
+ $raw | lines | where {|l| $l | str starts-with $k } | get -o 0 | default ""
+ | str replace --regex $'^($k)\s*"?' '' | str replace --regex '"\s*$' ''
+ }
+ | where {|v| $v | is-not-empty }
+ | get -o 0 | default ""
+ )
+ if ($img | is-empty) { continue }
+
+ let hero = ([$root, "site", "public", ($img | str trim --left --char "/")] | path join)
+ if not ($hero | path exists) {
+ error make { msg: $"($md): image_url ($img) is not on disk at ($hero)" }
+ }
+
+ let card = (card-path $img)
+ let card_disk = ([$root, "site", "public", ($card | str trim --left --char "/")] | path join)
+ let declared = ($raw | lines | any {|l| $l | str starts-with $"og_image: \"($card)\"" })
+
+ if $check {
+ if not ($card_disk | path exists) { $missing = ($missing | append $"($md | path basename): card ($card) missing") }
+ if not $declared { $missing = ($missing | append $"($md | path basename): frontmatter does not declare og_image: ($card)") }
+ continue
+ }
+
+ compose $hero $card_disk
+
+ # Bind it into the frontmatter, right under the hero it was composed from. Replace an existing
+ # og_image rather than stacking a second one — this script must be re-runnable. The anchor is
+ # whichever field actually carried the hero: anchoring on image_url alone silently wrote no
+ # og_image at all for the posts that only have a thumbnail.
+ let anchor = (if ($raw | lines | any {|l| $l | str starts-with "image_url:" }) { "image_url" } else { "thumbnail" })
+ let bound = if ($raw | lines | any {|l| $l | str starts-with "og_image:" }) {
+ $raw | str replace --regex '(?m)^og_image:.*$' $"og_image: \"($card)\""
+ } else {
+ $raw | str replace --regex ("(?m)^(" + $anchor + ":.*)$") $"$1\nog_image: \"($card)\""
+ }
+ $bound | save --force $md
+ $wrote += 1
+ print $"og card → ($card) [($md | path basename)]"
+ }
+
+ if $check {
+ if ($missing | is-empty) {
+ print "og-cards-check: every hero has a 1200×630 card and declares it"
+ } else {
+ for m in $missing { print $" ✗ ($m)" }
+ error make { msg: $"og-cards-check: ($missing | length) publication\(s\) without a usable social card" }
+ }
+ } else {
+ print $"($wrote) card\(s\) composed"
+ }
+}
diff --git a/site/scripts/build/gen-spine-pages.nu b/site/scripts/build/gen-spine-pages.nu
index ec62262..079b40c 100644
--- a/site/scripts/build/gen-spine-pages.nu
+++ b/site/scripts/build/gen-spine-pages.nu
@@ -176,10 +176,26 @@ _($close_line)_
let prize_line = (if $is_es { $spine.prize.line } else { $spine.prize.en })
let i18n_pages = ($content | path dirname | path join "i18n" "locales" $lang "pages")
mkdir $i18n_pages
- # Evidence-hook banner lines, one fluent key per hooked door (spine-hook-).
+ # Evidence-hook banner lines, one fluent key per hooked DOOR (spine-hook-).
+ #
+ # Per DOOR — not per hook. The key is named for the door, and `evidence_hooks` is a list
+ # keyed by hook: two hooks already target `developer` (acceleration-whiplash and
+ # context-as-property), so iterating the list emitted `spine-hook-developer` TWICE. A
+ # duplicate id makes the whole .ftl invalid, and Fluent drops the entire resource — so the
+ # file that carries spine-hero, spine-sub and all four doors silently evaporated, and the
+ # home page rendered an EMPTY
. Nothing gated it, and nothing reads spine-hook-* yet, so
+ # the break arrived by way of a key no consumer even wants.
+ #
+ # First-wins, which is not a new rule: the door LANDING already picks `$hook | first` (above).
+ # The two now agree instead of one of them destroying the file.
let hook_keys = (
$spine.evidence_hooks? | default []
- | each {|h| let hl = (if $is_es { $h.hook.es } else { $h.hook.en }); $"spine-hook-($h.door) = ($hl)" }
+ | group-by door
+ | items {|door, hs|
+ let h = ($hs | first)
+ let hl = (if $is_es { $h.hook.es } else { $h.hook.en })
+ $"spine-hook-($door) = ($hl)"
+ }
| str join "\n"
)
# Per-door panel keys for the home vestibule (ADR-057): label + imperative line
@@ -201,7 +217,7 @@ _($close_line)_
| str join "\n"
)
let door_cta = (if $is_es { "spine-door-cta = Ver más\nspine-door-blog-cta = Posts\nspine-door-recipes-cta = Recetas" } else { "spine-door-cta = See more\nspine-door-blog-cta = Posts\nspine-door-recipes-cta = Recipes" })
- $"# GENERATED from .ontoref/positioning/spine.ncl by gen-spine-pages.nu — do not edit by hand.
+ let ftl = $"# GENERATED from .ontoref/positioning/spine.ncl by gen-spine-pages.nu — do not edit by hand.
spine-hero = ($hero_line)
spine-sub = ($sub_line)
spine-close = ($close_line)
@@ -210,7 +226,25 @@ spine-doors-heading = ($heading)
($door_cta)
($door_keys)
($hook_keys)
-" | save -f ($i18n_pages | path join "home_spine.ftl")
+"
+ # ── Refuse to write an invalid Fluent resource ───────────────────────────────
+ # A duplicate message id does not degrade gracefully: Fluent rejects the RESOURCE, and this
+ # one carries the hero, the sub-hero and every door. When `spine-hook-` was emitted
+ # twice, the home page rendered an empty
and blank buttons — a page with no words, from
+ # a generator that reported success. Every key here is composed from spine.ncl ids, so a
+ # collision means the id scheme stopped being injective; that is a bug in this script or a
+ # duplicate in the data, and it must stop the build, never reach a reader.
+ let dup = (
+ $ftl | lines
+ | where {|l| not ($l | str starts-with "#") and ($l | str contains " = ") }
+ | each {|l| $l | split row " = " | first }
+ | group-by {|k| $k } | items {|k, v| { key: $k, n: ($v | length) } }
+ | where n > 1 | get key
+ )
+ if ($dup | is-not-empty) {
+ error make { msg: $"home_spine.ftl [($lang)] would carry duplicate Fluent keys — the resource would be dropped whole, blanking the page: ($dup | str join ', ')" }
+ }
+ $ftl | save -f ($i18n_pages | path join "home_spine.ftl")
}
print $"spine generated: doors as `dominios` posts + _spine fragments for langs ($langs | str join ', ')"
diff --git a/site/scripts/build/gen-taglines.nu b/site/scripts/build/gen-taglines.nu
index 25bfe92..33a61d6 100644
--- a/site/scripts/build/gen-taglines.nu
+++ b/site/scripts/build/gen-taglines.nu
@@ -97,5 +97,30 @@ def main [
let out_dir = ($out | path dirname)
if ($out_dir | str length) > 0 { mkdir $out_dir }
$payload | to json --indent 2 | save -f $out
+
+ # ── The rotator's FIRST FRAME, server-rendered ───────────────────────────────
+ # The template used to seed the rotator's with `spine-hero` — harmless only while the
+ # real headline was baked into the hero raster and the
was sr-only. Now that the headline
+ # is live text over the art, that seed printed it a SECOND time, right under itself, until the
+ # JS swapped it (and forever, with JS off).
+ #
+ # The seed has to be what the rotator itself renders at idx 0: by_audience.all[0]. Which is a
+ # fact of taglines.ncl, so it is PROJECTED here — never retyped into an .ftl, where it would be
+ # a second copy of a line the spine already owns and free to drift from it.
+ let first = ($by_audience | get all | first)
+ for lang in ["es" "en"] {
+ let ftl = $"($out | path dirname | path dirname | path dirname)/i18n/locales/($lang)/pages/home_taglines.ftl"
+ let dir = ($ftl | path dirname)
+ if not ($dir | path exists) { mkdir $dir }
+ [
+ $"# GENERATED from .ontoref/positioning/taglines.ncl by gen-taglines.nu — do not edit by hand."
+ $"# The rotator's idx-0 frame, rendered server-side so the hero does not flash \(or, with JS"
+ $"# off, sit on\) a phrase the rotator was going to replace anyway."
+ $"home-tagline-initial = ($first | get $lang)"
+ ""
+ ] | str join "\n" | save -f $ftl
+ }
+
print $"taglines projected → ($out) [($data.taglines | length) phrases · audiences: ($present | str join ', ')]"
+ print $" first frame → i18n/locales/{es,en}/pages/home_taglines.ftl [($first.id)]"
}
diff --git a/site/scripts/hooks/pre-commit b/site/scripts/hooks/pre-commit
new file mode 100755
index 0000000..b3abb1e
--- /dev/null
+++ b/site/scripts/hooks/pre-commit
@@ -0,0 +1,40 @@
+#!/usr/bin/env bash
+# Refuse a commit that would record a hand-edited projection.
+#
+# Every publishing surface in site/ is derived: the expediente articles and the standalone
+# informe from their .ncl, the ADR pages from the spine, htmx-templates/ from its sources.
+# Editing the render instead of the source is the failure this repo has already paid for
+# twice — the work survives until the next build, and then it is gone without a word.
+#
+# Installed by `just install-hooks`. Skip a single commit with `git commit --no-verify`.
+
+set -uo pipefail
+here="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" # → outreach/site
+
+# Only gate commits that touch the site. Work elsewhere in outreach/ has its own rules.
+if ! git diff --cached --name-only | grep -q '^site/'; then
+ exit 0
+fi
+
+if ! command -v just > /dev/null 2>&1; then
+ echo "pre-commit: 'just' not on PATH — cannot run the gates. Commit refused."
+ echo " install just, or bypass once with: git commit --no-verify"
+ exit 1
+fi
+
+echo "pre-commit: the commit touches site/ — running the gates"
+if ! just --justfile "$here/justfile" check; then
+ cat <<'MSG'
+
+pre-commit: refused. A gate is red, which means something in the working tree does not
+reproduce from its source. Fix the SOURCE and regenerate — do not edit the render:
+
+ just expedientes # .ncl → the article bodies
+ just informe # .ncl → the standalone viewer
+ just adr-pages # spine → the ADR pages
+ just templates # sources → htmx-templates/
+
+Bypass, knowing what you are doing: git commit --no-verify
+MSG
+ exit 1
+fi