200 lines
8.3 KiB
Text
200 lines
8.3 KiB
Text
|
|
#!/usr/bin/env nu
|
||
|
|
|
||
|
|
# Project the REPLAYS of the typed expediente CASEs (.ncl) → the session-protocol page,
|
||
|
|
# a STANDALONE document per locale (like the informe), served at the path each language
|
||
|
|
# declares in site/config/expedientes-surfaces.ncl (`protocol`).
|
||
|
|
#
|
||
|
|
# It is NOT a card in the grid: the grid indexes cases, and a projection filed among the
|
||
|
|
# instances it is projected from is the same category error as a schema filed among them.
|
||
|
|
# It lived one commit as a content card — the card read as an eleventh case, and the page
|
||
|
|
# wore the article chrome with none of the series' dress. Now it wears the series' own
|
||
|
|
# paper (scripts/build/protocolo-template.html owns the chrome; this script owns the data).
|
||
|
|
#
|
||
|
|
# The autopsies say what died and what now guards the grave; this page says how the
|
||
|
|
# OPERATOR conducts the next session. It is not written — it is projected, and `--check`
|
||
|
|
# fails if the output was edited by hand: a hand-kept checklist is the next drift.
|
||
|
|
#
|
||
|
|
# Usage:
|
||
|
|
# nu scripts/build/gen-protocolo.nu # every locale
|
||
|
|
# nu scripts/build/gen-protocolo.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 '>' '>'
|
||
|
|
}
|
||
|
|
|
||
|
|
# Prose that lands inside an HTML element: escaped, with the two authorial marks
|
||
|
|
# converted (`code` → mono span, **bold** → <b>) — the template page is HTML, not
|
||
|
|
# markdown, so nothing else survives on purpose.
|
||
|
|
def inline [] : string -> string {
|
||
|
|
$in
|
||
|
|
| str replace --all '&' '&'
|
||
|
|
| str replace --all '<' '<'
|
||
|
|
| str replace --all '>' '>'
|
||
|
|
| str replace --all --regex '`([^`]+)`' '<span class="mono">${1}</span>'
|
||
|
|
| str replace --all --regex '\*\*([^*]+)\*\*' '<b>${1}</b>'
|
||
|
|
}
|
||
|
|
|
||
|
|
# The reading order is the one the articles declare in their frontmatter — the same rule
|
||
|
|
# gen-informe.nu applies, because a second ordering rule would be a second source of truth.
|
||
|
|
def sort-order [ncl: string]: 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 render-body [cases: list, lang: string, grid: string]: nothing -> string {
|
||
|
|
let v = ($VOCAB | get $lang)
|
||
|
|
let p = $v.protocolo
|
||
|
|
let with_replay = ($cases | where {|c| ($c.case.replay? | default null) != null })
|
||
|
|
let n_rep = ($with_replay | length)
|
||
|
|
|
||
|
|
mut out = []
|
||
|
|
|
||
|
|
# ── header + stamp
|
||
|
|
$out = ($out | append ([
|
||
|
|
$"<div class='stamp' aria-hidden='true'><b>($p.stamp_lead)</b><span>($p.stamp_sub) · ($n_rep) REPLAYS</span></div>"
|
||
|
|
"<header>"
|
||
|
|
$" <div class='kicker'>($p.kicker)</div>"
|
||
|
|
$" <h1>($p.h1_lead)<span class='red'>($p.h1_red)</span></h1>"
|
||
|
|
$" <p class='logline'>($p.intro | inline)</p>"
|
||
|
|
$" <div class='meta'><span class='tag'>($p.replays_word) <b>($n_rep)</b></span><span class='tag'>($p.cases_word) <b>($cases | length)</b></span></div>"
|
||
|
|
"</header>"
|
||
|
|
] | str join "\n"))
|
||
|
|
|
||
|
|
# ── the four rules, as the series' lineup cards
|
||
|
|
let rules = ($p.rules | each {|r|
|
||
|
|
$" <div class='rule'><h3>($r.0 | inline)</h3><p>($r.1 | inline)</p></div>"
|
||
|
|
} | str join "\n")
|
||
|
|
$out = ($out | append ([
|
||
|
|
"<section>"
|
||
|
|
$" <div class='exhibit'>($p.rules_title)</div>"
|
||
|
|
$" <div class='rules'>\n($rules)\n </div>"
|
||
|
|
"</section>"
|
||
|
|
] | str join "\n"))
|
||
|
|
|
||
|
|
# ── the replays, case by case
|
||
|
|
$out = ($out | append $"<section><div class='exhibit'>($p.cases_title)</div></section>")
|
||
|
|
for r in $with_replay {
|
||
|
|
let c = $r.case
|
||
|
|
let href = $"($grid)/($c.slug)"
|
||
|
|
let rep = $c.replay
|
||
|
|
let steps = ($rep.steps | each {|s|
|
||
|
|
$"<tr><td>($s.task | inline)</td><td>($s.verifiable | inline)</td></tr>"
|
||
|
|
} | str join "\n")
|
||
|
|
let adr = (
|
||
|
|
if ($rep.adr_trigger | is-empty) { "" } else {
|
||
|
|
$"\n <p><b>($v.md.replay_adr):</b> ($rep.adr_trigger | inline)</p>"
|
||
|
|
}
|
||
|
|
)
|
||
|
|
$out = ($out | append ([
|
||
|
|
"<section>"
|
||
|
|
$" <div class='exhibit'>($c.case_no | esc)</div>"
|
||
|
|
$" <h2><a href='($href)'>($c.title | esc)</a></h2>"
|
||
|
|
$" <span class='caplabel'>($v.md.replay_after)</span>"
|
||
|
|
$"<pre>($rep.prompt_after | str trim | esc)</pre>"
|
||
|
|
$" <table class='cont'><tbody>\n<tr class='head'><td>($v.md.replay_task)</td><td>($v.md.replay_verif)</td></tr>\n($steps)\n </tbody></table>"
|
||
|
|
$" <p><b>($v.md.replay_gate):</b> ($rep.gate_before_delegation | inline)</p>($adr)"
|
||
|
|
$" <a class='caselink' href='($href)'>($p.open_case)</a>"
|
||
|
|
"</section>"
|
||
|
|
] | str join "\n"))
|
||
|
|
}
|
||
|
|
|
||
|
|
# ── the living debt: every ⊘ across the whole series. When a debt is settled in its
|
||
|
|
# case, it leaves this box on the next projection, on its own.
|
||
|
|
let indebted = ($cases | where {|c| ($c.case.lesson_debt | default "") != "" })
|
||
|
|
if ($indebted | is-not-empty) {
|
||
|
|
let rows = ($indebted | each {|r|
|
||
|
|
$"<tr><td><a href='($grid)/($r.case.slug)'>($r.case.case_no | esc)</a></td><td>⊘ ($r.case.lesson_debt | inline)</td></tr>"
|
||
|
|
} | str join "\n")
|
||
|
|
$out = ($out | append ([
|
||
|
|
"<section class='debts'>"
|
||
|
|
$" <h2>($p.debts_title)</h2>"
|
||
|
|
$" <p>($p.debts_intro | inline)</p>"
|
||
|
|
$" <table class='cont'><tbody>\n($rows)\n </tbody></table>"
|
||
|
|
"</section>"
|
||
|
|
] | str join "\n"))
|
||
|
|
}
|
||
|
|
|
||
|
|
$out = ($out | append $"<footer><span>($p.foot_left)</span><span><a href='https://ontoref.dev' target='_blank' rel='noopener'>ontoref.dev</a></span></footer>")
|
||
|
|
$out | str join "\n\n"
|
||
|
|
}
|
||
|
|
|
||
|
|
def main [
|
||
|
|
--root: string = ".",
|
||
|
|
--check,
|
||
|
|
] {
|
||
|
|
let base = $"($root)/site/content/expedientes"
|
||
|
|
let tpl = (open --raw $"($root)/scripts/build/protocolo-template.html")
|
||
|
|
|
||
|
|
let surf_file = ([$root, "site", "config", "expedientes-surfaces.ncl"] | path join)
|
||
|
|
if not ($surf_file | path exists) {
|
||
|
|
error make { msg: $"no surface declaration at ($surf_file) — I cannot know where to publish" }
|
||
|
|
}
|
||
|
|
let surfaces = (^nickel export $surf_file | from json | get surfaces)
|
||
|
|
|
||
|
|
mut drift = []
|
||
|
|
for lang in ($surfaces | columns | sort) {
|
||
|
|
let s = ($surfaces | get $lang)
|
||
|
|
# A language whose surface does not declare its protocol path is an UNANSWERED extent,
|
||
|
|
# not a language without one — the difference between a gap and a shrug.
|
||
|
|
let served = ($s.protocol? | default "")
|
||
|
|
if ($served | is-empty) {
|
||
|
|
error make { msg: $"($lang): expedientes-surfaces.ncl declares no `protocol` path — a language must answer for its protocol page, loudly" }
|
||
|
|
}
|
||
|
|
let cases = (
|
||
|
|
glob $"($base)/($lang)/($s.dir)/*.ncl"
|
||
|
|
| sort
|
||
|
|
| each {|f| { order: (sort-order $f), case: (^nickel export $f | from json) } }
|
||
|
|
| sort-by order
|
||
|
|
)
|
||
|
|
let n_replay = ($cases | where {|c| ($c.case.replay? | default null) != null } | length)
|
||
|
|
# A protocol page projected from zero replays is a page with nothing to say, and an
|
||
|
|
# empty page that answers 200 is this series' oldest pathology.
|
||
|
|
if $n_replay == 0 {
|
||
|
|
error make { msg: $"($lang): no case carries a replay — nothing to project. The page is a projection of replays, not a blank to fill by hand." }
|
||
|
|
}
|
||
|
|
|
||
|
|
let v = ($VOCAB | get $lang)
|
||
|
|
let body = (render-body $cases $lang $s.grid)
|
||
|
|
let html = (
|
||
|
|
$tpl
|
||
|
|
| str replace --all "{{LANG}}" $lang
|
||
|
|
| str replace --all "{{TITLE}}" $v.protocolo.page_title
|
||
|
|
| str replace "{{VIEWER_HREF}}" $s.viewer
|
||
|
|
| str replace "{{VIEWER_LABEL}}" $v.protocolo.viewer_label
|
||
|
|
| str replace "{{BACK_HREF}}" $v.ui.back_href
|
||
|
|
| str replace "{{BACK_LABEL}}" $v.ui.back
|
||
|
|
| str replace "{{BODY}}" $body
|
||
|
|
)
|
||
|
|
|
||
|
|
let out = ([$root, "site", "public", ($served | str trim --left --char "/")] | path join)
|
||
|
|
if $check {
|
||
|
|
let cur = (if ($out | path exists) { open --raw $out } else { "" })
|
||
|
|
if $cur != $html { $drift = ($drift | append $"($lang) → ($served)") }
|
||
|
|
} else {
|
||
|
|
mkdir ($out | path dirname)
|
||
|
|
$html | save --force $out
|
||
|
|
print $"protocolo → ($served) [($n_replay) replays de ($cases | length) casos]"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if $check {
|
||
|
|
if ($drift | is-empty) {
|
||
|
|
print "protocolo-check: the session-protocol page reproduces from the cases' replays — no hand-edited output"
|
||
|
|
} else {
|
||
|
|
print $"protocolo-check: DRIFT — ($drift | str join ', ') differ from what the replays produce."
|
||
|
|
print " the page is a projection; edit the case .ncl, never the served HTML."
|
||
|
|
exit 1
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|