ontoref-outreach/site/scripts/build/gen-expediente.nu
Jesús Pérez fbb6f99f44 expedientes: the render becomes a projection; publish case 69/58
The howto called the CASE object the single source of truth. It was not. The rendered .md
was hand-written — 356 of its 500 lines were the shared glossary widget pasted in verbatim,
and it carried editorial prose (the balance, the closing) that the contract never modelled.
A generator run against the old contract would have DELETED that prose: the exact
work-in-the-generated-tree shape adr-070 names.

So the contract was extended first (balance / balance_outro / closing, additive — every
existing case validates unchanged), the prose was rescued into it, and only then was
gen-expediente.nu written. The glossary widget is now injected from its single source
instead of retyped. `just expedientes-check` asserts every .md body reproduces from its .ncl:
edit the source, never the render.

The generator dropped the hero and the "show full expediente" CTA on its first run — half an
hour after the ADR that forbids exactly that. It was caught only because the output was
diffed against the previous render instead of trusted because it printed a green tick. Both
are emitted from data now, and the check is why we know.

New case, clinical skin: 69/58 — "the mirror that reverted its own work". The tool reported
"index.json with 69 posts" while the disk held 58; eleven ADRs (059..069) were written and
never published; zero content reached production for four weeks; 214 lines of template work
sat one command from deletion with no witness anywhere. Every number in its ledger traces to
a log or an artifact, per the mode's own rule — none is recalled.

Its verdict is the one the series exists to ask and rarely gets: "with ontoref, would it not
have gone like this?" — and here the answer is NO. It happened INSIDE ontoref, with ADR-066
("the check decides, never the reporter") accepted a week earlier and proven by an eight-path
falsación run. The protocol did not fail. Difusión was the blind spot: the one surface the
protocol did not govern. Every mechanism that would have caught every one of these failures
already existed and was already accepted in this very repo. None of them was pointed here.

Also: adr-070's pages (the ADR projection now runs in the build chain).
2026-07-12 12:11:42 +01:00

222 lines
13 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]
const CSS = '<style>
.exp{--exp-ink:currentColor;font-family:Georgia,"Times New Roman",serif}
.exp .exp-kick{font-family:ui-monospace,"Courier New",monospace;font-weight:700;font-size:.72rem;letter-spacing:.16em;text-transform:uppercase;opacity:.65}
.exp .exp-log{font-style:italic;max-width:46ch}
.exp .exp-meta{display:flex;flex-wrap:wrap;gap:.9rem;font-family:ui-monospace,"Courier New",monospace;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:ui-monospace,"Courier New",monospace;font-size:.78rem;padding:.4rem .8rem;border:1px solid;border-radius:.3rem;text-decoration:none}
.exp .exp-anam{border-left:3px solid;padding:.4rem 0 .4rem 1rem;margin:1.3rem 0;font-style:italic}
.exp .exp-gloss{border-left:3px solid;padding:.6rem 0 .6rem .9rem;margin:1rem 0;font-size:.92rem}
.exp .exp-gloss dl{margin:0}
.exp .exp-gloss dt{font-weight:700;font-family:ui-monospace,"Courier New",monospace;font-size:.82rem;margin-top:.5rem}
.exp .exp-gloss dd{margin:.15rem 0 0}
.exp .exp-ex{font-family:ui-monospace,"Courier New",monospace;font-weight:700;font-size:.8rem;letter-spacing:.1em;text-transform:uppercase;margin:1.8rem 0 .5rem;opacity:.8}
.exp .exp-balance{display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin:1rem 0}
@media(max-width:38rem){.exp .exp-balance{grid-template-columns:1fr}}
.exp .exp-ledger{border:1px solid;border-radius:.4rem;overflow:hidden}
.exp .exp-ledger h4{font-family:ui-monospace,"Courier New",monospace;font-size:.72rem;letter-spacing:.1em;text-transform:uppercase;margin:0;padding:.55rem .8rem;border-bottom:1px solid}
.exp .exp-ledger.cost h4{background:rgba(180,52,42,.12)}
.exp .exp-ledger.yield h4{background:rgba(20,120,60,.12)}
.exp .exp-ledger ul{margin:0;padding:.4rem .9rem;list-style:none}
.exp .exp-ledger li{display:flex;justify-content:space-between;gap:.8rem;padding:.32rem 0;border-bottom:1px dashed rgba(128,128,128,.35);font-size:.9rem}
.exp .exp-ledger li:last-child{border-bottom:0}
.exp .exp-ledger b{font-family:ui-monospace,"Courier New",monospace;font-variant-numeric:tabular-nums;white-space:nowrap}
.exp .exp-susp{width:100%;border-collapse:collapse;font-size:.9rem;margin:.6rem 0}
.exp .exp-susp td{padding:.4rem .5rem;border-bottom:1px dashed rgba(128,128,128,.35);vertical-align:top}
.exp .exp-cont{width:100%;border-collapse:collapse;font-size:.92rem;margin:.8rem 0}
.exp .exp-cont td{padding:.5rem .6rem;border-bottom:1px solid rgba(128,128,128,.3);vertical-align:top}
.exp .exp-cont td:first-child{width:45%;opacity:.85}
</style>'
def esc []: string -> string {
$in | str replace --all '&' '&amp;' | str replace --all '<' '&lt;' | str replace --all '>' '&gt;'
}
# 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"
}
def render-ledger [title: string, cls: string, rows: list]: nothing -> string {
let items = ($rows | each {|r| $" <li>($r.label) <b>($r.value)</b></li>" } | str join "\n")
$" <div class=\"exp-ledger ($cls)\">\n <h4>($title)</h4>\n <ul>\n($items)\n </ul>\n </div>"
}
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.
if ($hero.image | is-not-empty) {
$out = ($out | append $"<a class=\"exp-hero\" href=\"($hero.href)\" target=\"_blank\" rel=\"noopener\" title=\"($w.hero_title)\"><img src=\"($hero.image)\" alt=\"($c.title)\" width=\"1200\" height=\"675\" loading=\"lazy\"></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></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)</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>")
}
if ($c.glossary | is-not-empty) {
let dl = ($c.glossary | each {|t| $" <dt>($t.term)</dt><dd>($t.def)</dd>" } | str join "\n")
$out = ($out | append $"<div class=\"exp-gloss\">\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>")
}
# 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))
let rows = ($c.containment | each {|r| $"<tr><td>($r.facet)</td><td>($r.mechanism)</td></tr>" } | str join "\n")
$out = ($out | append $"<table class=\"exp-cont\"><tbody>\n($rows)\n</tbody></table>")
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 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 = []
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)
let hero = { image: $img, href: $"/images/expedientes/($lang)/expedientes.html#($c.id)" }
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)") }
} else {
$new | save --force $md_path
print $"expediente → ($lang)/($md_path | path basename) [($c.skin | into string)]"
}
}
if $check {
if ($drift | is-empty) {
print $"expedientes-check: ($cases | length) case\(s\) reproduce from their .ncl — no hand-edited render"
} else {
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."
exit 1
}
}
}