#!/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** → ) — 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 '`([^`]+)`' '${1}'
| str replace --all --regex '\*\*([^*]+)\*\*' '${1}'
}
# 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 ([
$"
($p.stamp_lead)($p.stamp_sub) · ($n_rep) REPLAYS
"
""
] | str join "\n"))
# ── the four rules, as the series' lineup cards
let rules = ($p.rules | each {|r|
$" ($r.0 | inline)
($r.1 | inline)
"
} | str join "\n")
$out = ($out | append ([
""
$" ($p.rules_title)
"
$" \n($rules)\n
"
""
] | str join "\n"))
# ── the replays, case by case
$out = ($out | append $"")
for r in $with_replay {
let c = $r.case
let href = $"($grid)/($c.slug)"
let rep = $c.replay
let steps = ($rep.steps | each {|s|
$"| ($s.task | inline) | ($s.verifiable | inline) |
"
} | str join "\n")
let adr = (
if ($rep.adr_trigger | is-empty) { "" } else {
$"\n ($v.md.replay_adr): ($rep.adr_trigger | inline)
"
}
)
$out = ($out | append ([
""
$" ($c.case_no | esc)
"
$" "
$" ($v.md.replay_after)"
$"($rep.prompt_after | str trim | esc)
"
$" \n| ($v.md.replay_task) | ($v.md.replay_verif) |
\n($steps)\n
"
$" ($v.md.replay_gate): ($rep.gate_before_delegation | inline)
($adr)"
$" ($p.open_case)"
""
] | 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|
$"| ($r.case.case_no | esc) | ⊘ ($r.case.lesson_debt | inline) |
"
} | str join "\n")
$out = ($out | append ([
""
$" ($p.debts_title)
"
$" ($p.debts_intro | inline)
"
$" "
""
] | str join "\n"))
}
$out = ($out | append $"")
$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
}
}
}