chore: scripts for site

This commit is contained in:
Jesús Pérez 2026-07-17 01:01:30 +01:00
parent 378aa1e064
commit bd23e03b21
11 changed files with 1142 additions and 4 deletions

View file

@ -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 <base> [<mid> ...] --out <dir> [--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)"
}

View file

@ -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)<title>(?<t>.*?)</title>' | 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 ', ')"
}

View file

@ -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 —
# <script>, <style>, <template>, <nav>, <header>, <footer> — and asks what case links survive.
# It cannot tell a card from a sidebar link, so it is not the assertion. But it CANNOT GO BLIND
# TO A RE-SKIN, because it never knew the skin.
#
# So the readings check each other. If the marker finds nothing and the structure finds cases, the
# page is fine and THE WITNESS IS STALE — and it can say so, by name. That sentence is the whole
# reason for the second reading: "I found no cards" is a mystery, and a mystery reads as an empty
# grid. "My card marker `ds-card` no longer matches a page that renders five cases" is a defect,
# and it names its own repair.
# The case links that survive once everything that is not page CONTENT is deleted.
# Class-free, skin-free, framework-free — this is the reading that cannot be re-skinned into
# silence, and it is why the marker's staleness is detectable at all.
export def content-links [html: string, prefix: string]: nothing -> list<string> {
let body = (
$html
| str replace --all --regex '(?is)<script[^>]*>.*?</script>' ''
| str replace --all --regex '(?is)<style[^>]*>.*?</style>' ''
| str replace --all --regex '(?is)<template[^>]*>.*?</template>' ''
| str replace --all --regex '(?is)<nav[^>]*>.*?</nav>' ''
| str replace --all --regex '(?is)<header[^>]*>.*?</header>' ''
| str replace --all --regex '(?is)<footer[^>]*>.*?</footer>' ''
)
$body
| parse --regex ('href="' + $prefix + '(?P<slug>[^"/#?]+)"')
| 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<string> {
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<slug>[^"/#?]+)"'))
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"
}

View file

@ -0,0 +1,305 @@
#!/usr/bin/env nu
# Project the GLOSSARY → the CONCEPTS page, server-rendered:
# site/i18n/locales/<lang>/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 <h1> 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 <dl> 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 { $"<span class=\"cpt-draft\" title=\"($c.draft_title)\">($c.draft)</span>" })
let meta = ([
$"<span class=\"cpt-cat\">($t.cat)</span>"
(if ($t.origin | is-empty) { "" } else { $"<span class=\"cpt-origin\">($c.origin_label) ($t.origin)</span>" })
] | str join "")
let link = (if ($t.href | is-empty) { "" } else { $"<a class=\"cpt-more\" href=\"($t.href)\">($c.more)</a>" })
# 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 <a href="#loquesea"> 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| $"<a href=\"#($r)\">(esc-ftl ($terms | where id == $r | get name | first))</a>" } | str join ", ")
$"<span class=\"cpt-rel\">($c.related_label): ($items)</span>"
}
)
$"<dt id=\"($t.id)\">(esc-ftl $t.name)($draft)</dt><dd><span class=\"cpt-meta\">($meta)</span>(esc-ftl $t.def)($rel)($link)</dd>"
} | str join ""
)
let rows = $"conceptos-html = <dl class=\"cpt\">($dl)</dl>"
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 <h1> salió vacío y el <title> 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 <h1> 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 '(?<b>[{}])' | 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 <dt> 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 ', ')"
}
}

View file

@ -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)
</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

View file

@ -0,0 +1,105 @@
#!/usr/bin/env nu
# Project the term registry → the glossary the PAGE can read:
# site/public/r/glosario-<lang>.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"
}
}

View file

@ -109,6 +109,11 @@ const TEMPLATE = r##'<!doctype html>
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

View file

@ -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/<deck>/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"
}
}

View file

@ -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-<door>).
# Evidence-hook banner lines, one fluent key per hooked DOOR (spine-hook-<door>).
#
# 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 <h1>. 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-<door>` was emitted
# twice, the home page rendered an empty <h1> 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 ', ')"

View file

@ -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 <span> with `spine-hero` — harmless only while the
# real headline was baked into the hero raster and the <h1> 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)]"
}

40
site/scripts/hooks/pre-commit Executable file
View file

@ -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