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).
164 lines
6.6 KiB
Text
164 lines
6.6 KiB
Text
#!/usr/bin/env nu
|
||
|
||
# Project the typed expediente CASEs (.ncl) → the standalone informe, one per locale:
|
||
# site/public/images/expedientes/<lang>/expedientes.html
|
||
#
|
||
# The informe was hand-kept HTML with its data inlined as a JS array, while the ARTICLES
|
||
# were already projected from the .ncl. Same cases, two hands — so they drifted: deriva's
|
||
# ledger carried 6 rows in the article and 8 here, and `espejo` never made it in at all,
|
||
# which left its own article linking to an anchor that did not exist. One source now; the
|
||
# viewer is a projection like everything else, and `just informe-check` says so.
|
||
#
|
||
# The template (scripts/build/informe-template.html) owns the CSS, the chrome and the
|
||
# render() function. This script owns only the data: it composes each CASE with the
|
||
# locale × skin vocabulary into the shape render() already reads.
|
||
#
|
||
# Usage:
|
||
# nu scripts/build/gen-informe.nu # every locale
|
||
# nu scripts/build/gen-informe.nu --check # assert reproducible, write nothing
|
||
|
||
use ./expediente-vocab.nu [VOCAB]
|
||
|
||
def esc []: string -> string {
|
||
$in | str replace --all '&' '&' | str replace --all '<' '<' | str replace --all '>' '>'
|
||
}
|
||
|
||
def ledger [rows: list]: nothing -> list {
|
||
$rows | each {|r| [$r.label, $r.value, (if $r.emphasis { 1 } else { 0 })] }
|
||
}
|
||
|
||
# The renderer wraps nothing in quotes; the old hand-kept data carried the curly quotes
|
||
# inside each alibi. Add them here so the .ncl stores the words, not the typography.
|
||
def quoted [xs: list]: nothing -> list {
|
||
$xs | each {|a| $"“($a)”" }
|
||
}
|
||
|
||
def skin-data [c: record, sk: record, lang: string, skin: string]: nothing -> record {
|
||
let v = ($VOCAB | get $lang)
|
||
let iv = ($v.skins | get $skin | get informe)
|
||
{
|
||
kicker: ($sk.kicker? | default $iv.kicker)
|
||
h1: [$sk.h1_lead, $sk.h1_red]
|
||
logline: $sk.logline
|
||
meta: [$iv.meta_case, $iv.meta_class, $sk.victim, $"($v.md.status_label): ($iv.status_word)"]
|
||
# The exhibit label is half vocabulary ("Exhibit A — ") and half case ("El cuerpo").
|
||
ex: ($sk.exhibits | enumerate | each {|e| $"($iv.exhibit_prefix | get $e.index)($e.item)" })
|
||
h2: $sk.headings
|
||
lead2: $sk.lead_cost
|
||
lead3: $sk.lead_suspects
|
||
weaponCap: $sk.weapon_caption
|
||
weaponAfter: $sk.weapon_after
|
||
fixCap: $sk.fix_caption
|
||
fixAfter: $sk.fix_after
|
||
verdictLead: $sk.verdict_lead
|
||
no: $iv.suspect_noun
|
||
stamp: [$iv.stamp_lead, $sk.stamp]
|
||
alibis: (quoted $sk.alibis)
|
||
tags: $sk.suspect_tags
|
||
footL: $"($iv.foot_left) ($c.case_no)"
|
||
footR: $sk.foot_right
|
||
}
|
||
}
|
||
|
||
def case-data [c: record, lang: string]: nothing -> record {
|
||
let v = ($VOCAB | get $lang)
|
||
let n = ($c.suspects | length)
|
||
for skin in ($c.skins | columns) {
|
||
let sk = ($c.skins | get $skin)
|
||
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" }
|
||
}
|
||
}
|
||
{
|
||
id: $c.id
|
||
caseNo: $c.case_no
|
||
classification: $c.classification
|
||
tab: $c.tab
|
||
printHref: $c.print_href
|
||
costCaption: $c.cost_caption
|
||
# The glossary is set in richer type here than in the article; same definition.
|
||
glossary: ($c.glossary | each {|t| { t: $t.term, d: (if ($t.def_html | is-empty) { $t.def } else { $t.def_html }) } })
|
||
glossaryMore: $"($v.md.protocol)<a href='($c.ontoref_url)' target='_blank' rel='noopener'>ontoref.dev</a>"
|
||
cost: (ledger $c.cost)
|
||
yielded: (ledger $c.yielded)
|
||
yieldedTitle: (if ($c.yielded_title | is-empty) { $v.md.yielded_title } else { $c.yielded_title })
|
||
suspectsTech: ($c.suspects | each {|s| $s.name })
|
||
# A snippet with no highlighted variant is shown escaped rather than not shown.
|
||
weaponCode: (if ($c.weapon.code_html | is-empty) { $c.weapon.code | str trim | esc } else { $c.weapon.code_html | str trim })
|
||
fixCode: (if ($c.fix.code_html | is-empty) { $c.fix.code | str trim | esc } else { $c.fix.code_html | str trim })
|
||
contain: ($c.containment | each {|r| [$r.facet, (if ($r.mechanism_html | is-empty) { $r.mechanism } else { $r.mechanism_html })] })
|
||
verdictClose: $c.verdict_close
|
||
skin: ($c.skin | into string | str replace "'" "")
|
||
skins: ($c.skins | columns | reduce --fold {} {|skin, acc|
|
||
$acc | insert $skin (skin-data $c ($c.skins | get $skin) $lang $skin)
|
||
})
|
||
}
|
||
}
|
||
|
||
# The reading order is the one the articles already declare in their frontmatter. A second
|
||
# ordering rule would be a second source of truth.
|
||
def sort-order [ncl: path]: nothing -> int {
|
||
let md = ($ncl | str replace ".ncl" ".md")
|
||
if not ($md | path exists) { return 999 }
|
||
open --raw $md
|
||
| lines
|
||
| where {|l| $l | str starts-with "sort_order:" }
|
||
| get -o 0
|
||
| default "sort_order: 999"
|
||
| str replace --regex '^sort_order:\s*' ''
|
||
| str trim
|
||
| into int
|
||
}
|
||
|
||
def main [
|
||
--root: string = ".",
|
||
--check,
|
||
] {
|
||
let base = $"($root)/site/content/expedientes"
|
||
let tpl = (open --raw $"($root)/scripts/build/informe-template.html")
|
||
|
||
let langs = (ls $base | where type == dir | get name | each {|d| $d | path basename } | where {|d| not ($d | str starts-with "_") } | sort)
|
||
if ($langs | is-empty) { error make { msg: $"no locale dirs under ($base)" } }
|
||
|
||
mut drift = []
|
||
for lang in $langs {
|
||
let ncls = (glob $"($base)/($lang)/*/*.ncl")
|
||
if ($ncls | is-empty) { continue }
|
||
|
||
let cases = (
|
||
$ncls
|
||
| each {|f| { order: (sort-order $f), c: (^nickel export $f | from json) } }
|
||
| sort-by order
|
||
| each {|r| case-data $r.c $lang }
|
||
)
|
||
let v = ($VOCAB | get $lang)
|
||
let data = $" var UI = ($v.ui | to json -r);\n\n var CASES = ($cases | to json -r);"
|
||
|
||
let html = (
|
||
$tpl
|
||
| str replace --all "{{LANG}}" $lang
|
||
| str replace --all "{{TITLE}}" $v.ui.page_title
|
||
| str replace "{{DATA}}" $data
|
||
)
|
||
|
||
let out = $"($root)/site/public/images/expedientes/($lang)/expedientes.html"
|
||
if $check {
|
||
let cur = (if ($out | path exists) { open --raw $out } else { "" })
|
||
if $cur != $html { $drift = ($drift | append $"($lang)/expedientes.html") }
|
||
} else {
|
||
mkdir ($out | path dirname)
|
||
$html | save --force $out
|
||
print $"informe → ($lang)/expedientes.html [($cases | length) casos]"
|
||
}
|
||
}
|
||
|
||
if $check {
|
||
if ($drift | is-empty) {
|
||
print $"informe-check: ($langs | length) locale\(s\) reproduce from their .ncl — no hand-edited viewer"
|
||
} else {
|
||
print $"informe-check: DRIFT — ($drift | str join ', ') differ from what the .ncl produce."
|
||
print " the viewer is a projection; edit the .ncl, never the HTML."
|
||
exit 1
|
||
}
|
||
}
|
||
}
|