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)(?<t>.*?)' | 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)" + } + ) + $"
(esc-ftl $t.name)($draft)
($meta)(esc-ftl $t.def)($rel)($link)
" + } | str join "" + ) + let rows = $"conceptos-html =
($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

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 <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 ', ')" + } +} 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) </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-<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" + } +} 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##'<!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 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/<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" + } +} 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-<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 ', ')" 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 <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)]" } 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