Los casos diagnosticaban y no exigían. Cada uno cerraba con un veredicto —«un contrato que no
se aplica no es un contrato: es un comentario»— y esa lección no llegaba a ninguna parte: los
nueve apuntaban `related_modes` al modo que ESCRIBE expedientes. Un bucle circular. La serie
denunciaba el contrato desconectado y ella misma lo era.
El esquema ahora lo impide:
lesson_codified_in pares what/where con gramática de punteros verificados
(adr:NNN/id · qa:id · mode:id/paso · gate:just X · test:ruta · contract:ruta#campo)
lesson_debt lo que nada exige todavía, con la dirección de quien lo saldaría
LessonAccounted contrato que RECHAZA el caso que no llena ninguno de los dos.
Visto rechazar antes de rellenar nada. Confesar cuesta (adr-071).
replay la sesión repetida con protocolo: el prompt literal recibido (con su
procedencia declarada — una reconstrucción sin fuente es ficción), el
prompt corregido, las microtareas con su oráculo NOMBRADO, la puerta
antes de delegar y el disparador de adr?.
Contabilizados los diez casos × 2 idiomas. Cuatro llevan replay. El reparto es el hallazgo:
las lecciones que dolieron en MECANISMO acabaron en constraints de ADR; las que dolieron en
CONDUCTA DEL OPERADOR quedaron en deuda. Esa mitad era justo la que faltaba.
Nuevo: /docs/expedientes/protocolo.html y /docs/case-files/protocol.html — el protocolo de
sesión, PROYECTADO de los replays (gen-protocolo.nu, con --check en la cadena). No es una
tarjeta del grid: el grid indexa CASOS, y una proyección archivada entre las instancias de las
que se proyecta es el mismo error de categoría que un esquema archivado entre ellas. Lleva las
cuatro caras de una sola regla —el agente nunca es su propio oráculo— y la deuda viva de la
serie, que se recalcula sola: cuando una ⊘ se salda en su caso, desaparece de la página.
Nuevo expediente 8/0 — la cura que no volvió a casa (ES+EN, 'clinical). Las ocho líneas que
cerraron el caso fundacional existen, probadas y sirviendo, y no habían llegado a ninguna
parte: ni al commit de su instancia (donde alguien siguió añadiendo kinds A MANO hasta siete),
ni a las dos plantillas de las que nace cada sitio. 23 constraints en 8 ADRs, 0 ejecutadas.
Y su punto de abandono: el arquitecto recomendó borrar el atajo con «prueba empírica» construida
sobre dos árboles distintos — habría roto 6 de 8 rutas respondiendo 200. Lo paró el testigo que
el propio encargo exigía. La puerta cazó al arquitecto.
Deuda corregida en 404-PAP y 404-PAP-bis: decían que faltaban tres cosas cuando faltaba una.
El backport está hecho y la constraint escrita; sólo falta que algo la ejecute. Un howto no
expira solo — éste caducó en dos horas.
Y el mensaje de la puerta de ilustración daba rutas que no resuelven desde donde la puerta
corre. Corregido: las rutas son de la raíz de la constelación, y el conversor NO reescala.
Verificado: 22 casos reproducen · completos en en, es · servidos · informe reproduce ·
parity 11×2 · protocolo-check reproduce · oráculo de cobertura ALL PASS.
693 lines
42 KiB
Text
693 lines
42 KiB
Text
#!/usr/bin/env nu
|
|
|
|
# Project a typed expediente CASE (.ncl) into the BODY of its article .md.
|
|
#
|
|
# The .md frontmatter (title, excerpt, image, sort_order) stays AUTHORED. Everything
|
|
# below it is a projection: edit the .ncl, never the body. `just expedientes-check` fails
|
|
# if you edit the body — that is how the prose the old contract did not model came to
|
|
# live only in the generated file (adr-070, work-in-the-generated-tree).
|
|
#
|
|
# The sibling generator gen-informe.nu projects the SAME cases into the standalone viewer
|
|
# at site/public/images/expedientes/<lang>/expedientes.html. One source, two surfaces.
|
|
#
|
|
# The case is written in ONE skin (`skin`); the informe offers both. This renderer reads
|
|
# skins.<skin> and ignores the rest.
|
|
#
|
|
# Usage:
|
|
# nu scripts/build/gen-expediente.nu # all cases, all locales
|
|
# nu scripts/build/gen-expediente.nu --case <id> # one case
|
|
# nu scripts/build/gen-expediente.nu --check # assert reproducible, write nothing
|
|
|
|
use ./expediente-vocab.nu [VOCAB]
|
|
|
|
# LOS COLORES SON ABSOLUTOS Y LOS FONDOS SON RELATIVOS.
|
|
#
|
|
# El artículo se sirve dentro del tema del sitio, que el lector cambia. Un fondo opaco
|
|
# fijado aquí (`#111`) es legible en un tema y una lápida en el otro. Así que los fondos son
|
|
# SIEMPRE tintes translúcidos de gris —funcionan sobre claro y sobre oscuro— y el color
|
|
# fijo se reserva para trazos (bordes, iconos, cifras) que son luminancia media a propósito:
|
|
# se ven contra ambos. El texto NUNCA lleva color de fondo propio.
|
|
const CSS = '<style>
|
|
.exp{--exp-ink:currentColor;--exp-cost:#c8453a;--exp-yield:#37985f;--exp-debt:#c98a22;
|
|
--exp-tint:rgba(128,128,128,.10);--exp-tint2:rgba(128,128,128,.055);--exp-rule:rgba(128,128,128,.34);
|
|
--exp-mono:ui-monospace,"Courier New",monospace;
|
|
font-family:Georgia,"Times New Roman",serif}
|
|
.exp .exp-kick{font-family:var(--exp-mono);font-weight:700;font-size:.72rem;letter-spacing:.16em;text-transform:uppercase;opacity:.65}
|
|
.exp .exp-log{font-style:italic}
|
|
.exp .exp-meta{display:flex;flex-wrap:wrap;gap:.9rem;font-family:var(--exp-mono);font-size:.74rem;letter-spacing:.06em;text-transform:uppercase;opacity:.75;margin:.6rem 0 1.2rem}
|
|
.exp .exp-hero{display:block;margin:0 0 .6rem}
|
|
.exp .exp-hero img{width:100%;height:auto;border-radius:.5rem}
|
|
.exp .exp-btn{display:inline-block;font-family:var(--exp-mono);font-size:.78rem;padding:.4rem .8rem;border:1px solid;border-radius:.3rem;text-decoration:none}
|
|
.exp .exp-anam{border-left:3px solid var(--exp-cost);background:var(--exp-tint);border-radius:0 .4rem .4rem 0;padding:.7rem 1rem;margin:1.3rem 0;font-style:italic}
|
|
.exp details.exp-gloss{border:1px solid var(--exp-rule);border-radius:.45rem;margin:1.2rem 0;background:var(--exp-tint2)}
|
|
.exp details.exp-gloss>summary{cursor:pointer;list-style:none;display:flex;align-items:center;gap:.55rem;font-family:var(--exp-mono);font-weight:700;font-size:.74rem;letter-spacing:.1em;text-transform:uppercase;padding:.6rem .85rem;opacity:.85}
|
|
.exp details.exp-gloss>summary::-webkit-details-marker{display:none}
|
|
.exp details.exp-gloss>summary::before{content:"▸";display:inline-block;transition:transform .15s ease}
|
|
.exp details.exp-gloss[open]>summary{opacity:1;border-bottom:1px solid var(--exp-rule)}
|
|
.exp details.exp-gloss[open]>summary::before{transform:rotate(90deg)}
|
|
.exp .exp-gloss-body{padding:.2rem .95rem .8rem;font-size:.92rem}
|
|
.exp .exp-gloss dl{margin:0}
|
|
.exp .exp-gloss dt{font-weight:700;font-family:var(--exp-mono);font-size:.82rem;margin-top:.6rem}
|
|
.exp .exp-gloss dd{margin:.15rem 0 0}
|
|
.exp .exp-ex{font-family:var(--exp-mono);font-weight:700;font-size:.78rem;letter-spacing:.12em;text-transform:uppercase;opacity:1;
|
|
margin:2.5rem 0 .9rem;padding:.55rem .85rem;border-left:4px solid var(--exp-cost);border-radius:0 .3rem .3rem 0;
|
|
background:linear-gradient(90deg,var(--exp-tint),transparent 78%)}
|
|
.exp .exp-balance{display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin:1rem 0}
|
|
@media(max-width:44rem){.exp .exp-balance{grid-template-columns:1fr}}
|
|
.exp .exp-ledger{border:1px solid var(--exp-rule);border-radius:.4rem;overflow:hidden;background:var(--exp-tint2)}
|
|
.exp .exp-ledger h4{font-family:var(--exp-mono);font-size:.72rem;letter-spacing:.1em;text-transform:uppercase;margin:0;padding:.55rem .8rem;border-bottom:1px solid var(--exp-rule)}
|
|
.exp .exp-ledger.cost h4{background:rgba(200,69,58,.14)}
|
|
.exp .exp-ledger.yield h4{background:rgba(55,152,95,.14)}
|
|
.exp .exp-ledger ul{margin:0;padding:.4rem .9rem;list-style:none}
|
|
.exp .exp-ledger li{display:flex;justify-content:space-between;align-items:baseline;gap:.8rem;padding:.36rem 0;border-bottom:1px dashed var(--exp-rule);font-size:.9rem}
|
|
.exp .exp-ledger li:last-child{border-bottom:0}
|
|
.exp .exp-ledger b{font-family:var(--exp-mono);font-variant-numeric:tabular-nums;white-space:nowrap;text-align:right}
|
|
/* UNA FILA CUYO VALOR ES UNA FRASE NO ES UNA FILA DE CIFRAS.
|
|
`flex` + `nowrap` alinea un número contra su etiqueta; con una frase dentro, la frase se sale
|
|
de la caja — y se salía, en el balance derecho de todos los casos. El visor ya lo había
|
|
arreglado en su propia tabla (informe-template.html, `table.yield`) y el artículo no se
|
|
enteró: misma fuente, dos superficies, una sola corregida. Aquí la fila larga se APILA:
|
|
etiqueta arriba en versal pequeña, frase debajo, prosa como prosa. */
|
|
.exp .exp-ledger li.long{display:block}
|
|
.exp .exp-ledger li.long>span{display:block;font-family:var(--exp-mono);font-size:.66rem;letter-spacing:.09em;text-transform:uppercase;opacity:.62}
|
|
.exp .exp-ledger li.long>b{display:block;white-space:normal;text-align:left;font-family:inherit;font-weight:400;font-size:.92rem;margin-top:.2rem;overflow-wrap:anywhere}
|
|
/* `emphasis` lo declara el contrato y lo leía SOLO el visor. La fila marcada se tiñe y su valor
|
|
toma el color del balance al que pertenece — nunca crece de tamaño: el énfasis es lo que la
|
|
fila SIGNIFICA, no lo que ocupa (la misma corrección que el visor ya se hizo). */
|
|
.exp .exp-ledger li.em{background:var(--exp-tint);margin:0 -.55rem;padding-left:.55rem;padding-right:.55rem;border-radius:.25rem}
|
|
.exp .exp-ledger.cost li.em>b{color:var(--exp-cost)}
|
|
.exp .exp-ledger.yield li.em>b{color:var(--exp-yield)}
|
|
.exp .exp-ledger li.em.long>b{font-weight:600}
|
|
.exp .exp-susp{width:100%;border-collapse:collapse;font-size:.9rem;margin:.6rem 0}
|
|
.exp .exp-susp td{padding:.45rem .55rem;border-bottom:1px dashed var(--exp-rule);vertical-align:top}
|
|
.exp .exp-susp tr:nth-child(odd) td{background:var(--exp-tint2)}
|
|
.exp .exp-cont{width:100%;border-collapse:collapse;font-size:.92rem;margin:.8rem 0;border:1px solid var(--exp-rule);border-radius:.4rem;overflow:hidden}
|
|
.exp .exp-cont td{padding:.5rem .6rem;border-bottom:1px solid var(--exp-rule);vertical-align:top}
|
|
.exp .exp-cont tr:last-child td{border-bottom:0}
|
|
.exp .exp-cont tr:nth-child(odd) td{background:var(--exp-tint2)}
|
|
.exp .exp-cont td:first-child{width:45%;opacity:.85}
|
|
.exp pre{border:1px solid var(--exp-rule);border-left:3px solid var(--exp-rule);border-radius:.4rem}
|
|
/* LA LECCIÓN SE LEE COMO LO QUE ES: UNA LISTA DE COSAS QUE HOY MUERDEN.
|
|
Era prosa en una tabla, indistinguible del resto. Cada arista es un ✓ con la dirección
|
|
de quien la exige; la deuda es un ⊘ ámbar, y no se parece a un ✓ a propósito: una
|
|
lección que nada exige no puede tener el mismo aspecto que una que sí. */
|
|
.exp .exp-check{list-style:none;margin:.9rem 0;padding:0;display:grid;gap:.5rem}
|
|
.exp .exp-check li{display:flex;gap:.7rem;align-items:flex-start;border:1px solid var(--exp-rule);border-left:3px solid var(--exp-yield);border-radius:.4rem;background:var(--exp-tint2);padding:.6rem .8rem}
|
|
.exp .exp-check .exp-tick{color:var(--exp-yield);font-family:var(--exp-mono);font-weight:700;line-height:1.5}
|
|
.exp .exp-check b{display:block;font-weight:600}
|
|
.exp .exp-check code{display:inline-block;margin-top:.3rem;font-size:.78rem;opacity:.8;overflow-wrap:anywhere}
|
|
.exp .exp-debt{display:flex;gap:.7rem;align-items:flex-start;border:1px solid var(--exp-debt);border-left:3px solid var(--exp-debt);border-radius:.4rem;background:rgba(201,138,34,.10);padding:.6rem .8rem;margin:.9rem 0}
|
|
.exp .exp-debt .exp-tick{color:var(--exp-debt);font-family:var(--exp-mono);font-weight:700;line-height:1.5}
|
|
</style>'
|
|
|
|
# Heroes share a WIDTH, not a box. They are documents — the case number, the stamp and the
|
|
# ruled margin live AT the edges — so a centred crop to a common box eats the content it was
|
|
# supposed to frame (it did: it took the C off "CASE FILE" and half of "CASE CLOSED"). What
|
|
# lines the articles up in a column is the width; the height is whatever the document is, and
|
|
# the renderer STATES it rather than assuming it, because a declared aspect that is not the
|
|
# file's aspect is not a hint — it is a wrong answer the browser reserves space for.
|
|
const HERO_W = 1600
|
|
|
|
# Width and height read from the WEBP WE ACTUALLY SERVE — not from a .png sitting beside it. The
|
|
# png is a SOURCE: it lives in the resources tree and never ships, so a build that measured it
|
|
# would be measuring a file the deploy does not contain, and would break the day the source moved.
|
|
# Measure the artifact, not its ancestor.
|
|
#
|
|
# No external tool, so this holds wherever the build runs. RIFF container: "RIFF" ‥ "WEBP" then a
|
|
# fourcc at 12 that says which of the three encodings this is. cwebp emits VP8 for our opaque
|
|
# heroes, but VP8L/VP8X are read too — an unhandled fourcc must SAY so, not return a plausible 0.
|
|
def webp-dims [path: string]: nothing -> record {
|
|
let b = (open --raw $path)
|
|
let fourcc = ($b | bytes at 12..15 | decode utf-8)
|
|
match $fourcc {
|
|
"VP8 " => {
|
|
# Lossy: 3-byte frame tag, 3-byte start code, then 14-bit width and height, little-endian.
|
|
{ w: (($b | bytes at 26..27 | into int --endian little) mod 16384)
|
|
h: (($b | bytes at 28..29 | into int --endian little) mod 16384) }
|
|
}
|
|
"VP8L" => {
|
|
# Lossless: after the 0x2f signature, 14 bits of (width-1) then 14 bits of (height-1),
|
|
# packed across four bytes read as one little-endian u32.
|
|
let bits = ($b | bytes at 21..24 | into int --endian little)
|
|
{ w: (($bits mod 16384) + 1), h: ((($bits // 16384) mod 16384) + 1) }
|
|
}
|
|
"VP8X" => {
|
|
# Extended: 24-bit (canvas-1) values, little-endian.
|
|
{ w: (($b | bytes at 24..26 | into int --endian little) + 1)
|
|
h: (($b | bytes at 27..29 | into int --endian little) + 1) }
|
|
}
|
|
_ => { error make { msg: $"($path): not a WebP I can measure — fourcc at byte 12 is '($fourcc)', expected VP8 , VP8L or VP8X" } }
|
|
}
|
|
}
|
|
|
|
def esc []: string -> string {
|
|
$in | str replace --all '&' '&' | str replace --all '<' '<' | str replace --all '>' '>'
|
|
}
|
|
|
|
# Blank-line-separated paragraphs stay blank-line-separated: they are Markdown, and the
|
|
# body's chunks are joined by blank lines precisely so this prose renders AS Markdown.
|
|
def paras [txt: string]: nothing -> string {
|
|
$txt | split row "\n\n" | each {|p| $p | str trim } | where {|p| $p != "" } | str join "\n\n"
|
|
}
|
|
|
|
# UN VALOR QUE ES UNA FRASE SE MAQUETA COMO FRASE, Y EL ÉNFASIS SE LEE DEL DATO.
|
|
#
|
|
# La forma de la fila la decide su CONTENIDO, no el balance en que cae: «24» y «arreglar una de
|
|
# dos derivaciones no cambia nada» no son la misma tipografía puesta en dos sitios. El umbral
|
|
# mide el texto que el lector recibe, no el marcado que lo produce — por eso se cuenta DESPUÉS
|
|
# de `inline`, con las etiquetas fuera: `` `<head>` `` son 8 caracteres para quien lee y 27 para
|
|
# quien los cuenta mal, y un `<code>` largo empujaría la fila al carril equivocado.
|
|
#
|
|
# `emphasis` lo declara el contrato desde el principio (Ledger, en el esquema) y esta superficie
|
|
# no lo leía: el visor teñía la fila y el artículo la servía plana. Un campo que solo lee la
|
|
# mitad de los consumidores es exactamente el defecto de 24/16 — una fuente y dos lecturas.
|
|
const LEDGER_PROSE_AT = 42
|
|
|
|
def render-ledger [title: string, cls: string, rows: list]: nothing -> string {
|
|
let items = ($rows | each {|r|
|
|
let html = ($r.value | inline)
|
|
let plain = ($html | str replace --all --regex '<[^>]+>' '')
|
|
let klass = (
|
|
[(if ($plain | str length) > $LEDGER_PROSE_AT { "long" } else { "" })
|
|
(if ($r.emphasis? | default false) { "em" } else { "" })]
|
|
| where {|k| $k != "" } | str join " "
|
|
)
|
|
let attr = (if ($klass | is-empty) { "" } else { $" class=\"($klass)\"" })
|
|
$" <li($attr)><span>($r.label | inline)</span> <b>($html)</b></li>"
|
|
} | str join "\n")
|
|
$" <div class=\"exp-ledger ($cls)\">\n <h4>($title)</h4>\n <ul>\n($items)\n </ul>\n </div>"
|
|
}
|
|
|
|
|
|
# ── TEXTO QUE VA DENTRO DE HTML: SE ESCAPA ───────────────────────────────────────────────────
|
|
#
|
|
# La logline, las filas del balance y el glosario NO se emiten como markdown: se emiten DENTRO de
|
|
# un elemento (`<p class="exp-log">…`). Y markdown no procesa nada dentro de un bloque HTML — los
|
|
# backticks no protegen ahí. Así que un `<head>` citado en el texto entraba en la página COMO
|
|
# ETIQUETA: el navegador se topaba con un <head> dentro del <body> y el artículo dejaba de VERSE,
|
|
# aunque su texto siguiera en el HTML. Pasó el 2026-07-14: 47.561 caracteres servidos, ninguno
|
|
# visible, mientras el inglés —que no llevaba la etiqueta— se veía perfecto.
|
|
#
|
|
# Se convierte lo que el autor escribió y se escapa TODO lo demás. Envolver no es escapar.
|
|
def inline [] : string -> string {
|
|
$in
|
|
| str replace --all '&' '&'
|
|
| str replace --all '<' '<'
|
|
| str replace --all '>' '>'
|
|
| str replace --all --regex '`([^`]+)`' '<code>${1}</code>'
|
|
| str replace --all --regex '\*\*([^*]+)\*\*' '<b>${1}</b>'
|
|
}
|
|
|
|
def render-case [c: record, lang: string, glossary_js: string, hero: record]: nothing -> string {
|
|
let skin = ($c.skin | into string | str replace "'" "")
|
|
let v = ($VOCAB | get $lang)
|
|
let sk = ($c.skins | get -o $skin)
|
|
if $sk == null {
|
|
error make { msg: $"($c.id): declares skin '($skin)' but skins.($skin) is missing" }
|
|
}
|
|
# The line-up is three index-parallel arrays across two records. The contract cannot say
|
|
# so; the check can, and a check that runs is worth more than a type that does not exist.
|
|
let n = ($c.suspects | length)
|
|
if ($sk.alibis | length) != $n or ($sk.suspect_tags | length) != $n {
|
|
error make { msg: $"($c.id)/($skin): ($n) suspects but ($sk.alibis | length) alibis and ($sk.suspect_tags | length) tags — they are index-parallel" }
|
|
}
|
|
let w = ($v.skins | get $skin | get md)
|
|
|
|
mut out = ["<section class=\"exp\">" $CSS]
|
|
|
|
# Hero + CTA to the standalone informe. NOT decoration: the first run of this generator
|
|
# dropped them and only the check caught it — a regeneration that silently loses an
|
|
# element is the very drift this case is about.
|
|
#
|
|
# <picture> avif → webp, as every other published surface serves its images.
|
|
#
|
|
# One chunk, NOT one per line: the body's chunks are joined by BLANK lines (they are Markdown),
|
|
# and a blank line inside an HTML block closes it — the <source>s would come back wrapped in <p>.
|
|
# EL HERO ES OPCIONAL. LA RUTA AL INFORME, NO.
|
|
#
|
|
# El botón colgaba del mismo `if` que la imagen: sin obra, no había botón, y el artículo se
|
|
# quedaba SIN RUTA AL VISOR — en silencio, a 200. Dos casos se publicaron así el 2026-07-14, y
|
|
# el defecto ya estaba ESCRITO en el howto («sin imagen, no hay botón, y nada lo dice»). Escribir
|
|
# la advertencia no impidió nada, porque un howto no se ejecuta: se lee. La ilustración es
|
|
# decoración; el enlace al expediente completo es NAVEGACIÓN. No pueden compartir condición.
|
|
if ($hero.image | is-not-empty) {
|
|
$out = ($out | append ([
|
|
$"<a class=\"exp-hero\" href=\"($hero.href)\" target=\"_blank\" rel=\"noopener\" title=\"($w.hero_title)\">"
|
|
"<picture>"
|
|
$" <source srcset=\"($hero.avif)\" type=\"image/avif\">"
|
|
$" <source srcset=\"($hero.image)\" type=\"image/webp\">"
|
|
$" <img src=\"($hero.image)\" alt=\"($c.title)\" width=\"($hero.w)\" height=\"($hero.h)\" loading=\"lazy\">"
|
|
"</picture>"
|
|
"</a>"
|
|
] | str join "\n"))
|
|
}
|
|
# El botón SIEMPRE. Un expediente sin ruta a su propio informe es media publicación.
|
|
# Y junto a la autopsia, la pauta: el protocolo de sesión es la otra superficie de la
|
|
# serie, y un artículo que no la referencia sirve el diagnóstico escondiendo la receta.
|
|
let proto_btn = (
|
|
if ($hero.proto? | default "" | is-empty) { "" } else {
|
|
$" <a class=\"exp-btn\" href=\"($hero.proto)\" target=\"_blank\" rel=\"noopener\">($v.md.cta_protocol)</a>"
|
|
}
|
|
)
|
|
$out = ($out | append $"<p style=\"margin:0 0 1.4rem\"><a class=\"exp-btn\" href=\"($hero.href)\" target=\"_blank\" rel=\"noopener\">($w.cta)</a>($proto_btn)</p>")
|
|
|
|
# The article's opening breath may be longer than the informe's hook (recaida), and the
|
|
# ficha's labels are the GENRE's, not the locale's: a clinical case files a Historia Nº,
|
|
# not a Caso Nº. The old renderer hardcoded the detective labels onto every skin.
|
|
let iv = ($v.skins | get $skin | get informe)
|
|
let log = (if ($sk.logline_article | is-empty) { $sk.logline } else { $sk.logline_article })
|
|
|
|
$out = ($out | append $"<p class=\"exp-kick\">($sk.kicker? | default $w.kicker)</p>")
|
|
$out = ($out | append $"<p class=\"exp-log\">($log | inline)</p>")
|
|
$out = ($out | append $"<div class=\"exp-meta\"><span>($iv.meta_case) <b>($c.case_no)</b></span><span>($iv.meta_class): ($c.classification)</span><span>($v.md.status_label): ($c.status)</span></div>")
|
|
|
|
if ($sk.anamnesis? | default "" | is-not-empty) {
|
|
$out = ($out | append $"<div class=\"exp-anam\">\n(paras $sk.anamnesis)\n</div>")
|
|
}
|
|
|
|
# EL GLOSARIO SE ABRE SI HACE FALTA, Y NO ANTES.
|
|
#
|
|
# Iba desplegado y en el camino: entre la logline y el primer acto, el lector se comía la
|
|
# definición de términos que aún no había visto usar. Es material de CONSULTA, y una consulta
|
|
# se pide. `<details>` la pide sin una línea de JavaScript, sobrevive sin él, y el buscador
|
|
# y ⌘F ven el contenido igual porque sigue en el HTML — plegado no es ausente.
|
|
#
|
|
# El sustantivo es de la PIEL (`glosario` en el detective, `cuadro` en el clínico) y el verbo
|
|
# es del idioma. Se componen; no se hardcodean, que es como el renderer anterior le puso las
|
|
# etiquetas del detective a la historia clínica.
|
|
if ($c.glossary | is-not-empty) {
|
|
let dl = ($c.glossary | each {|t| $" <dt>($t.term)</dt><dd>($t.def)</dd>" } | str join "\n")
|
|
let cta = $"($v.md.gloss_show) ($w.gloss | str downcase)"
|
|
$out = ($out | append $"<details class=\"exp-gloss\">\n <summary>($cta)</summary>\n <div class=\"exp-gloss-body\">\n <dl>\n($dl)\n </dl>\n <p style=\"font-family:ui-monospace,monospace;font-size:.72rem;margin:.5rem 0 0\">($v.md.protocol)<a href=\"($c.ontoref_url)\" target=\"_blank\" rel=\"noopener\">ontoref.dev</a></p>\n </div>\n</details>")
|
|
}
|
|
|
|
# Doble balance: prose → ledgers → prose. The ledgers are data; the prose is editorial.
|
|
$out = ($out | append $"<p class=\"exp-ex\">($w.balance)</p>")
|
|
if ($sk.balance? | default "" | is-not-empty) { $out = ($out | append (paras $sk.balance)) }
|
|
let ct = (if ($c.cost_title | is-empty) { $v.md.cost_title } else { $c.cost_title })
|
|
let yt = (if ($c.yielded_title | is-empty) { $v.md.yielded_title } else { $c.yielded_title })
|
|
$out = ($out | append $"<div class=\"exp-balance\">\n(render-ledger $ct 'cost' $c.cost)\n(render-ledger $yt 'yield' $c.yielded)\n</div>")
|
|
if ($sk.balance_outro? | default "" | is-not-empty) { $out = ($out | append (paras $sk.balance_outro)) }
|
|
|
|
if ($sk.abandonment? | default "" | is-not-empty) {
|
|
$out = ($out | append $"<p class=\"exp-ex\">($w.abandon)</p>")
|
|
$out = ($out | append (paras $sk.abandonment))
|
|
}
|
|
|
|
if ($c.suspects | is-not-empty) {
|
|
$out = ($out | append $"<p class=\"exp-ex\">($w.suspects)</p>")
|
|
let rows = (0..<$n | each {|i|
|
|
let s = ($c.suspects | get $i)
|
|
$"<tr><td><b>($s.name)</b></td><td><em>“($sk.alibis | get $i)”</em></td><td>($sk.suspect_tags | get $i)</td></tr>"
|
|
} | str join "\n")
|
|
$out = ($out | append $"<table class=\"exp-susp\"><tbody>\n($rows)\n</tbody></table>")
|
|
}
|
|
|
|
for blk in [
|
|
{ h: $w.weapon, cap: $sk.weapon_caption, code: $c.weapon.code, note: ($sk.weapon_note? | default "") }
|
|
{ h: $w.fix, cap: $sk.fix_caption, code: $c.fix.code, note: ($sk.fix_note? | default "") }
|
|
] {
|
|
$out = ($out | append $"<p class=\"exp-ex\">($blk.h) — ($blk.cap)</p>")
|
|
# A BLANK LINE either side of the <pre> is load-bearing. The site renders CommonMark:
|
|
# `<section class="exp">` opens an HTML block of type 6, closed by the first blank line
|
|
# — and weapon.code HAS blank lines. Glued to its neighbours the whole body was ONE
|
|
# type-6 block that died inside the <pre>, so every `#` comment in the snippet re-parsed
|
|
# as an ATX heading (23 spurious <h1> in espejo, `</pre>` swallowed by the last). As its
|
|
# own chunk, <pre> opens a type-1 block: it closes only on `</pre>` and blank lines
|
|
# cannot touch it. Chunks are joined with "\n\n" at the bottom of this function.
|
|
$out = ($out | append $"<pre>($blk.code | str trim | esc)</pre>")
|
|
if ($blk.note | is-not-empty) { $out = ($out | append (paras $blk.note)) }
|
|
}
|
|
|
|
$out = ($out | append $"<p class=\"exp-ex\">($w.verdict)</p>")
|
|
$out = ($out | append (paras $sk.verdict_prose))
|
|
# `mechanism_html` is the rendered variant and the article ignored it, taking the plain one —
|
|
# whose markdown backticks land inside a RAW HTML <td>, where no markdown is processed. So the
|
|
# reader got literal backticks in the article while the viewer, reading the same field pair
|
|
# correctly, got the markup. One source, two surfaces, and only one of them was reading the data.
|
|
let rows = (
|
|
$c.containment | each {|r|
|
|
let mech = (if (($r.mechanism_html? | default "") | is-empty) { $r.mechanism } else { $r.mechanism_html })
|
|
$"<tr><td>($r.facet)</td><td>($mech)</td></tr>"
|
|
} | str join "\n"
|
|
)
|
|
$out = ($out | append $"<table class=\"exp-cont\"><tbody>\n($rows)\n</tbody></table>")
|
|
|
|
# ── THE REPLAY — the session, conducted so the guard exists before the agent starts.
|
|
# The autopsy above says what died; this says what the OPERATOR asks next time. The
|
|
# prompts are <pre> and escaped: they are quoted transcripts, never markup. The
|
|
# before-prompt carries its source in the heading — a reconstruction that cannot name
|
|
# the artifact it was read from is fiction wearing evidence's clothes.
|
|
let rep = ($c.replay? | default null)
|
|
if $rep != null {
|
|
$out = ($out | append $"<p class=\"exp-ex\">($w.replay)</p>")
|
|
$out = ($out | append $"<p><b>($v.md.replay_before)</b> <span style=\"opacity:.7;font-size:.85em\">— ($v.md.replay_source) <code>($rep.prompt_before_source | esc)</code></span></p>")
|
|
$out = ($out | append $"<pre>($rep.prompt_before | str trim | esc)</pre>")
|
|
$out = ($out | append $"<p><b>($v.md.replay_after)</b></p>")
|
|
$out = ($out | append $"<pre>($rep.prompt_after | str trim | esc)</pre>")
|
|
let steps = ($rep.steps | each {|s| $"<tr><td>($s.task | inline)</td><td>($s.verifiable | inline)</td></tr>" } | str join "\n")
|
|
$out = ($out | append $"<table class=\"exp-cont\"><tbody>\n<tr><td><b>($v.md.replay_task)</b></td><td><b>($v.md.replay_verif)</b></td></tr>\n($steps)\n</tbody></table>")
|
|
$out = ($out | append $"<p><b>($v.md.replay_gate):</b> ($rep.gate_before_delegation | inline)</p>")
|
|
if ($rep.adr_trigger | is-not-empty) {
|
|
$out = ($out | append $"<p><b>($v.md.replay_adr):</b> ($rep.adr_trigger | inline)</p>")
|
|
}
|
|
}
|
|
|
|
# ── THE LESSON'S LEDGER — always rendered: the schema refuses a case that neither
|
|
# names what enforces its lesson nor declares the debt. Same two-column shape as the
|
|
# containment table (rule | address), because it EXTENDS it: containment says which
|
|
# mechanism would have stopped this; this says where that mechanism now bites — or
|
|
# that nothing does yet, which is a third word, not a silence.
|
|
#
|
|
# LA FORMA DICE LO QUE LA TABLA NO DECÍA. Compartía maqueta con la contención —dos columnas
|
|
# grises— y por eso se leía como más prosa: la sección que dice qué te exige HOY el caso era
|
|
# tipográficamente indistinguible del relato de lo que pasó AYER. Es una lista de cerrojos
|
|
# vivos, así que se lee como una lista de cerrojos vivos: un ✓ por arista, con la dirección
|
|
# de quien la exige debajo. La deuda NO recibe un ✓ —recibe un ⊘ ámbar— porque «esto lo
|
|
# exige un test» y «esto no lo exige nada todavía» no pueden tener el mismo aspecto. Ahí es
|
|
# donde una checklist se convierte en el adorno que denuncia esta misma serie.
|
|
$out = ($out | append $"<p class=\"exp-ex\">($w.lesson)</p>")
|
|
if ($c.lesson_codified_in | is-not-empty) {
|
|
let edges = ($c.lesson_codified_in | each {|e| $" <li><span class=\"exp-tick\">✓</span><div><b>($e.what | inline)</b><code>($e.where | esc)</code></div></li>" } | str join "\n")
|
|
$out = ($out | append $"<ul class=\"exp-check\">\n($edges)\n</ul>")
|
|
}
|
|
if ($c.lesson_debt | is-not-empty) {
|
|
$out = ($out | append $"<p class=\"exp-debt\"><span class=\"exp-tick\">⊘</span><span><b>($v.md.debt_label):</b> ($c.lesson_debt | inline)</span></p>")
|
|
}
|
|
|
|
if ($sk.closing? | default "" | is-not-empty) {
|
|
if ($sk.closing_heading? | default "" | is-not-empty) {
|
|
$out = ($out | append $"<p class=\"exp-ex\">($sk.closing_heading)</p>")
|
|
}
|
|
$out = ($out | append (paras $sk.closing))
|
|
}
|
|
|
|
# The page glossary widget is INJECTED from its single source (_glossary.html). It used
|
|
# to be pasted by hand: 356 of the .md's 500 lines were that copy.
|
|
$out = ($out | append $glossary_js)
|
|
$out = ($out | append "</section>")
|
|
|
|
$out | str join "\n\n"
|
|
}
|
|
|
|
def main [
|
|
--root: string = ".", # site root (the dir containing site/)
|
|
--case: string = "", # one case id; empty = all
|
|
--check, # assert reproducible; write nothing
|
|
] {
|
|
let base = $"($root)/site/content/expedientes"
|
|
let gl_path = $"($base)/_glossary.html"
|
|
let glossary_js = (if ($gl_path | path exists) { open --raw $gl_path | str trim } else { "" })
|
|
|
|
let surf_file = ([$root, "site", "config", "expedientes-surfaces.ncl"] | path join)
|
|
let surfaces = (
|
|
if ($surf_file | path exists) {
|
|
let r = (do { ^nickel export $surf_file } | complete)
|
|
if $r.exit_code == 0 { ($r.stdout | from json).surfaces } else { {} }
|
|
} else { {} }
|
|
)
|
|
|
|
# THE EXTENT IS RESOLVED BEFORE ANYTHING IS PUBLISHED INTO IT.
|
|
#
|
|
# It used to be resolved AFTER the render loop, and the ordering was the bug: with no surface
|
|
# declared, the loop reached `$surfaces | get $lang` and died with a Nushell `column_not_found`
|
|
# — a stack trace, exit 1, and not one word about the missing declaration. The finding EXISTED,
|
|
# three hundred lines below, and could never be reached. A gate that crashes before it can name
|
|
# what is wrong refuses without reporting, which is the same silence it was written to break.
|
|
let lex = ([$root, "..", "..", ".ontoref", "ontology", "lexicon.ncl"] | path join | path expand)
|
|
let spine = ([$root, "..", "..", ".ontoref"] | path join | path expand)
|
|
|
|
# AN EXTENT I COULD NOT READ IS A FINDING, NEVER AN EMPTY LIST.
|
|
#
|
|
# This block used to answer `[]` when the lexicon was missing or failed to export — and `[]` is
|
|
# not "no languages", it is "I DID NOT LOOK". Both coverage checks below iterate over $langs, so
|
|
# an unreadable extent silently disarmed BOTH of them and the run printed a green receipt. That
|
|
# is the worst available green, and it is adr-072's own `a-check-can-say-i-did-not-look`, sitting
|
|
# inside the check that ADR points at.
|
|
let lex_err = (
|
|
if not ($lex | path exists) {
|
|
$"the declared extent does not exist: ($lex)"
|
|
} else {
|
|
let r = (do { ^nickel export --import-path $spine $lex } | complete)
|
|
if $r.exit_code != 0 {
|
|
$"the declared extent failed to export: ($lex) — ($r.stderr | str trim | lines | first)"
|
|
} else { "" }
|
|
}
|
|
)
|
|
let langs = (
|
|
if ($lex_err | is-not-empty) { [] } else {
|
|
(^nickel export --import-path $spine $lex | from json).languages
|
|
}
|
|
)
|
|
# THE SURFACE IS AN AXIS OF THE EXTENT, SO IT IS DECLARED, NOT GUESSED.
|
|
#
|
|
# This used to read `if $l == "es" { "casos" } else { "cases" }` — a rule which states that every
|
|
# language that is not Spanish is English. Add `fr` to the lexicon and it files French cases under
|
|
# `cases/` and nothing could object, because nothing declared what a language's directory SHOULD
|
|
# be. That is folklore in a script, and a witness cannot be measured against folklore (adr-072).
|
|
# A language declared with no surface is now a FINDING, not a default.
|
|
let unsurfaced = ($langs | where {|l| ($surfaces | get -o $l | is-empty) })
|
|
let dir_of = {|l| $surfaces | get $l | get dir }
|
|
|
|
if ($unsurfaced | is-not-empty) {
|
|
print $"expedientes-check: EXTENT UNANSWERED — declared languages with no surface: ($unsurfaced | str join ', ')"
|
|
print " site/config/expedientes-surfaces.ncl must say where a language's cases LIVE and where"
|
|
print " they are SERVED. Guessing it («everything that is not es is en») is how a language"
|
|
print " gets filed under the wrong tree and served from nowhere, in silence."
|
|
exit 1
|
|
}
|
|
|
|
let cases = (
|
|
glob $"($base)/*/*/*.ncl"
|
|
| where {|f| $case == "" or ($f | path basename | str replace ".ncl" "") == $case }
|
|
| sort
|
|
)
|
|
if ($cases | is-empty) { error make { msg: $"no expediente .ncl found under ($base)" } }
|
|
|
|
mut drift = []
|
|
mut noimg = []
|
|
mut fatal = []
|
|
for f in $cases {
|
|
let c = (^nickel export $f | from json)
|
|
let md_path = ($f | str replace ".ncl" ".md")
|
|
if not ($md_path | path exists) {
|
|
error make { msg: $"($md_path) does not exist — the .md carries the frontmatter; create it before generating the body" }
|
|
}
|
|
# The frontmatter is authored (title, excerpt, image, sort_order); only the BODY is
|
|
# projected. Splitting on the closing --- keeps authorship where it belongs.
|
|
let raw = (open --raw $md_path)
|
|
let parts = ($raw | split row --regex '(?m)^---\s*$' )
|
|
let front = $"---($parts.1)---"
|
|
let img = ($parts.1 | lines | where {|l| $l | str starts-with "image_url:" } | get -o 0 | default "" | str replace --regex '^image_url:\s*"?' '' | str replace --regex '"\s*$' '')
|
|
let lang = ($f | path dirname | path dirname | path basename)
|
|
# Deep-link by SLUG, not by id: the slug is the localised public identity (it is already this
|
|
# article's own URL), while the id is the Spanish join key that ties the two languages together.
|
|
# An English article linking to `#validador-que-nunca-se-ejecuto` was leaking the key into the
|
|
# reader's address bar. The viewer resolves either, so links already shared keep working.
|
|
# The <picture> needs the avif sibling, and a <source> pointing at a file that is not there
|
|
# fails SILENTLY — the browser just falls through to the webp and nobody ever learns the
|
|
# avif was never published. So the absence is an error here, where it is still cheap.
|
|
let avif = ($img | str replace --regex '\.webp$' '.avif')
|
|
mut dims = { w: 0, h: 0 }
|
|
if ($img | is-not-empty) {
|
|
if not ($img | str ends-with ".webp") {
|
|
error make { msg: $"($md_path): image_url must be a .webp \(the <picture> derives its avif sibling from it\) — got ($img)" }
|
|
}
|
|
# Both must be on disk. A <source> pointing at a file that is not there fails SILENTLY — the
|
|
# browser falls through to the webp and nobody ever learns the avif was never published.
|
|
for rel in [$img, $avif] {
|
|
let disk = ([$root, "site", "public", ($rel | str trim --left --char "/")] | path join)
|
|
if not ($disk | path exists) {
|
|
error make { msg: $"($md_path): hero references ($rel) but ($disk) does not exist" }
|
|
}
|
|
}
|
|
$dims = (webp-dims ([$root, "site", "public", ($img | str trim --left --char "/")] | path join))
|
|
# The one thing the heroes DO share. Heights differ because the documents differ; a width
|
|
# that differs would break the column, and there is no honest crop back to it (the content
|
|
# runs to the edge), so it is an error at build time, not a shrug at render time.
|
|
if $dims.w != $HERO_W {
|
|
error make { msg: $"($md_path): hero ($img) is ($dims.w)px wide — every hero must be exactly ($HERO_W)px. Rescale the SOURCE by WIDTH and re-encode \(never crop: the case number and the stamp sit at the edges\)." }
|
|
}
|
|
}
|
|
let hero = {
|
|
image: $img, avif: $avif, w: $dims.w, h: $dims.h,
|
|
href: $"((($surfaces | get $lang).viewer))#($c.slug)",
|
|
# The session-protocol page — the series' second standalone surface. Declared, like
|
|
# the viewer; an article that cannot point at the protocol serves the autopsy and
|
|
# hides the regimen.
|
|
proto: (($surfaces | get $lang).protocol? | default "")
|
|
}
|
|
let body = (render-case $c $lang $glossary_js $hero)
|
|
let new = $"($front)\n\n($body)\n"
|
|
|
|
if $check {
|
|
if $raw != $new { $drift = ($drift | append $"($lang)/($md_path | path basename)") }
|
|
# UN CASO SIN HERO NO TIENE RUTA A SU INFORME… y ahora sí la tiene (el botón se desacopló
|
|
# de la imagen), pero sigue publicándose SIN ILUSTRACIÓN, y eso no puede pasar en silencio.
|
|
# Estaba ESCRITO en el howto y no impidió nada: un howto no se ejecuta, se lee. Aquí sí se
|
|
# ejecuta. Un hueco declarado es un hueco; un hueco callado es una mentira.
|
|
if ($img | is-empty) { $noimg = ($noimg | append $"($lang)/($md_path | path basename)") }
|
|
# Se comprueba el CUERPO RENDERIZADO, no el .ncl: es lo que el lector recibe. Las etiquetas
|
|
# de documento (<head>, <title>, <html>, <body>) no pueden aparecer dentro de un artículo —
|
|
# ninguna de ellas es prosa, y todas rompen el parser.
|
|
# Se BLANQUEAN antes los spans de código y los bloques ``` — una etiqueta CITADA como código
|
|
# es una mención, y markdown la escapa correctamente. Lo letal es la etiqueta DESNUDA en
|
|
# prosa. Sin esta distinción el cerrojo grita ante `<title>` bien escrito, y un cerrojo que
|
|
# grita en falso es un cerrojo que se acaba desactivando.
|
|
let prose = (
|
|
$new
|
|
| str replace --all --regex '(?s)```.*?```' ' '
|
|
| str replace --all --regex '`[^`]*`' ' '
|
|
| str replace --all --regex '(?s)<pre>.*?</pre>' ' '
|
|
| str replace --all --regex '(?s)<code>.*?</code>' ' '
|
|
)
|
|
let bad = ($prose | parse --regex '<(?<t>head|title|html|body)>' | get t | uniq)
|
|
if ($bad | is-not-empty) { $fatal = ($fatal | append $"($lang)/($md_path | path basename): <($bad | str join '>, <')>") }
|
|
} else {
|
|
$new | save --force $md_path
|
|
print $"expediente → ($lang)/($md_path | path basename) [($c.skin | into string)]"
|
|
}
|
|
}
|
|
|
|
# ── COVERAGE ⊇ EXTENT ────────────────────────────────────────────────────────
|
|
#
|
|
# The check above only verifies what EXISTS. It reported "8 cases reproduce — no hand-edited
|
|
# render" while five cases existed in Spanish and three in English: GREEN OVER AN INCOMPLETE
|
|
# BILINGUAL SET. It could not say otherwise, because it had no idea what SHOULD exist — only
|
|
# what did. That is the exact pathology of expediente 3/0, committed by the very tool that
|
|
# published it.
|
|
#
|
|
# So the extent is declared, not inferred: the project's languages live in
|
|
# `.ontoref/ontology/lexicon.ncl`, and a case must exist in EVERY one of them. Adding a language
|
|
# there now breaks THIS check too, which is the whole point — the coverage expires when the
|
|
# mechanism grows, loudly, instead of going quiet.
|
|
#
|
|
# $langs / $unsurfaced / $dir_of are resolved at the TOP of this command, before a single case is
|
|
# rendered: publishing into an extent you have not resolved is what the ordering bug did.
|
|
mut missing = []
|
|
if ($langs | is-not-empty) and ($unsurfaced | is-empty) {
|
|
# Every case id that exists in ANY language must exist in ALL of them.
|
|
let ids = ($cases | each {|f| $f | path basename | str replace ".ncl" "" } | uniq)
|
|
for id in $ids {
|
|
for l in $langs {
|
|
let p = ([$base, $l, (do $dir_of $l), $"($id).ncl"] | path join)
|
|
if not ($p | path exists) {
|
|
$missing = ($missing | append $"($id) [($l)]")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
# ── THE SERVED TREE, WHICH IS THE ONLY ONE THAT EXISTS ───────────────────────
|
|
#
|
|
# `content_processor` writes the content indexes to `site/r/`. THE SERVER READS
|
|
# `site/public/r/`. A `just content` mirrors one into the other, and until it runs the case is
|
|
# generated, validated, activated, indexed — and INVISIBLE. Its article answers 200, its entry
|
|
# is in the index, and it is not in the grid.
|
|
#
|
|
# This is expediente 69/58 (the mirror that reverted its own work) on a path its cure did not
|
|
# cover. And it got past every check on this page because they all asked the same question —
|
|
# "does the file exist?" — of the tree NOBODY SERVES.
|
|
#
|
|
# So the check compares the two. It is the cheapest possible witness and it is the one that was
|
|
# missing: a canonical tree and a served tree that disagree is a publication that did not happen.
|
|
let served = ([$root, "site", "public", "r", "expedientes"] | path join)
|
|
let canonical = ([$root, "site", "r", "expedientes"] | path join)
|
|
mut unserved = []
|
|
if ($canonical | path exists) and ($served | path exists) and ($unsurfaced | is-empty) {
|
|
for l in $langs {
|
|
let d = (do $dir_of $l)
|
|
let c_idx = ([$canonical, $l, $d, "index.json"] | path join)
|
|
let s_idx = ([$served, $l, $d, "index.json"] | path join)
|
|
if ($c_idx | path exists) {
|
|
let c_ids = (open $c_idx | get posts? | default [] | get id)
|
|
let s_ids = (
|
|
if ($s_idx | path exists) { (open $s_idx | get posts? | default [] | get id) } else { [] }
|
|
)
|
|
for id in $c_ids {
|
|
if not ($id in $s_ids) {
|
|
$unserved = ($unserved | append $"($id) [($l)] — en site/r/ pero NO en site/public/r/")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if $check {
|
|
mut failed = false
|
|
if ($lex_err | is-not-empty) {
|
|
print "expedientes-check: I DID NOT LOOK — the declared extent is unreadable:"
|
|
print $" ✗ ($lex_err)"
|
|
print " the language and served-tree checks below both read it, so an unreadable extent"
|
|
print " disarms them BOTH. Zero findings because the check could not look is not green:"
|
|
print " it is ignorance wearing health's clothes (adr-072, a-check-can-say-i-did-not-look)."
|
|
$failed = true
|
|
}
|
|
if ($unserved | is-not-empty) {
|
|
print "expedientes-check: UNSERVED — generado, indexado… y no publicado:"
|
|
for u in $unserved { print $" ✗ ($u)" }
|
|
print " el servidor lee site/public/r/. Corre `just content` — sin eso el caso existe y no existe."
|
|
$failed = true
|
|
}
|
|
if ($drift | is-not-empty) {
|
|
print $"expedientes-check: DRIFT — these .md differ from what their .ncl produces: ($drift | str join ', ')"
|
|
print " the render is a projection; edit the .ncl, never the .md body."
|
|
$failed = true
|
|
}
|
|
if ($missing | is-not-empty) {
|
|
print $"expedientes-check: INCOMPLETE — a case must exist in every declared language \(($langs | str join ', ')\):"
|
|
for m in $missing { print $" ✗ ($m)" }
|
|
print " a case published in one language and not the other is not a case: it is half of one."
|
|
$failed = true
|
|
}
|
|
if ($fatal | is-not-empty) {
|
|
print "expedientes-check: ETIQUETA QUE ROMPE LA PÁGINA — HTML crudo en un campo de prosa:"
|
|
for f in $fatal { print $" ✗ ($f)" }
|
|
print " Un <head> o un <title> dentro del <body> desconciertan al parser del navegador y el"
|
|
print " artículo deja de VERSE aunque su texto siga en el HTML. Pasó el 2026-07-14: 47.561"
|
|
print " caracteres servidos y ni uno visible, mientras el inglés —que no llevaba la etiqueta—"
|
|
print " se veía perfecto. Cita la etiqueta como código (`<head>`), nunca desnuda."
|
|
$failed = true
|
|
}
|
|
if ($noimg | is-not-empty) {
|
|
print "expedientes-check: SIN ILUSTRACIÓN — publicado, y sin obra:"
|
|
for n in $noimg { print $" ✗ ($n)" }
|
|
print " El howto lo advertía por escrito desde el 2026-07-14 y se publicaron dos casos así igual,"
|
|
print " porque un howto no se ejecuta: se lee. Esta puerta sí se ejecuta."
|
|
print ""
|
|
# LAS RUTAS SON DESDE LA RAÍZ DE LA CONSTELACIÓN, Y ESTA PUERTA CORRE DESDE outreach/site.
|
|
#
|
|
# Decían `resources/site/…` y `nu scripts/img-to-web.nu` a secas — y NINGUNA de las dos resuelve
|
|
# desde donde esta puerta se ejecuta y donde le dice al lector que esté. Las dos viven dos niveles
|
|
# arriba. Es la misma clase de defecto que el howto del sitio ya documenta sobre `ore rustelo
|
|
# routes --check`: una instrucción que nombra algo que no existe DESDE DONDE ESTÁS es peor que
|
|
# no dar instrucción, porque el lector cree que hay red. Se corrigió el 2026-07-16, tras
|
|
# ejecutarla y ver que no resolvía.
|
|
print " La receta, con las rutas DESDE AQUÍ (outreach/site) — la fuente y el conversor viven"
|
|
print " en la raíz de la constelación, no en este repo:"
|
|
print " 1. obra PNG de 1600px de ANCHO EXACTO → ../../resources/site/<categoría>-<slug>.png"
|
|
print " (`expedientes-<slug-es>` y `cases-<slug-en>`; el conversor NO reescala: si el PNG"
|
|
print " no mide 1600 de ancho, esta puerta lo rechazará al final. `sips --resampleWidth"
|
|
print " 1600 <f>.png --out <f>.png` — por ANCHO, nunca recortando: el nº de caso y el"
|
|
print " sello viven en los bordes.)"
|
|
print " 2. cd ../.. && nu scripts/img-to-web.nu resources/site/<categoría>-<slug>.png"
|
|
print " 3. cp ../../resources/site/<categoría>-<slug>.{webp,avif} site/public/images/expedientes/"
|
|
print " 4. thumbnail / image_url / og_image en el frontmatter del .md"
|
|
print " 5. nu scripts/build/gen-og-cards.nu && just expedientes"
|
|
$failed = true
|
|
}
|
|
if $failed { exit 1 }
|
|
print $"expedientes-check: ($cases | length) case\(s\) reproduce · complete in ($langs | str join ', ') · served"
|
|
} else {
|
|
if ($lex_err | is-not-empty) {
|
|
print ""
|
|
print $"⚠ NO MIRÉ — la extensión declarada no se pudo leer: ($lex_err)"
|
|
print " sin ella no sé en qué idiomas DEBERÍA existir cada caso. Esto no es «todo bien»."
|
|
}
|
|
if ($missing | is-not-empty) {
|
|
print ""
|
|
print $"⚠ falta la versión de estos casos en algún idioma declarado \(($langs | str join ', ')\):"
|
|
for m in $missing { print $" ✗ ($m)" }
|
|
}
|
|
}
|
|
}
|