305 lines
18 KiB
Text
305 lines
18 KiB
Text
#!/usr/bin/env nu
|
||
|
||
# Project the GLOSSARY → the CONCEPTS page, server-rendered:
|
||
# site/i18n/locales/<lang>/pages/conceptos.ftl
|
||
#
|
||
# ── ESTA PÁGINA LEÍA EL ÁRBITRO Y CREÍA LEER EL GLOSARIO ──────────────────────────────────
|
||
#
|
||
# Hasta hoy proyectaba `ontology/registry.ncl`, que NO es el glosario del proyecto: es la unión
|
||
# aplanada de las decisiones de traducción, una fila por (término × idioma de DESTINO). El
|
||
# resultado se llamaba «Conceptos clave» y listaba `commit`, `push`, `rebase`, `exit`, `hook` —
|
||
# palabras que `lexicon.ncl` describe en su propia cabecera como «ordinary words of the source
|
||
# language» que tienen una decisión de traducción y NADA QUE DEFINIR. De los 20 conceptos del
|
||
# proyecto (ondaod, espiral, síntesis, on+re, PAP, testigo verificado…) aparecía exactamente uno,
|
||
# y por su política, no por su definición.
|
||
#
|
||
# Y el inglés salía VACÍO. No por falta de frases: las 20 definiciones inglesas llevaban meses
|
||
# escritas. Las `rules` del registro se indexan por idioma de DESTINO, y el inglés es `src_lang`
|
||
# — la fuente no se traduce a sí misma, así que no genera ni una fila. La página no estaba
|
||
# incompleta: estaba leyendo un fichero que no podía contener la respuesta. Y el generador lo
|
||
# contaba como un pendiente («the English glosses have not been written yet»), que es la forma
|
||
# más cara de equivocarse: un hueco estructural disfrazado de tarea.
|
||
#
|
||
# Así que lee `ontology/glossary.ncl`, que es de donde son los conceptos: `definition` es
|
||
# `i18n_str` y OBLIGATORIA (schemas/term.ncl), y el mismo dato lo sirve ya `ontoref describe term
|
||
# --lang en|es`. Una fuente, dos superficies — que es lo que se creía tener.
|
||
#
|
||
# El léxico (`lexicon.ncl`) no sobra: gestiona las traducciones y alimenta la nota de
|
||
# /sobre-el-idioma. Sencillamente no es el glosario, y una página que mezcla las dos cosas no
|
||
# responde bien a ninguna de las dos preguntas.
|
||
#
|
||
# Usage (from outreach/site):
|
||
# nu scripts/build/gen-conceptos.nu --root ../..
|
||
# nu scripts/build/gen-conceptos.nu --root ../.. --check
|
||
|
||
# ── EL TERCER FILO DEL MISMO CUCHILLO ────────────────────────────────────────────────────
|
||
#
|
||
# En Fluent, `{` abre un PLACEABLE — una variable a interpolar. La definición de `domain` cita la
|
||
# ruta `code/domains/{id}/`, y eso convirtió el valor entero de `conceptos-html` en un mensaje con
|
||
# parámetros: la página sirvió `__FLUENT_MSG_WITH_PARAMS__` donde iban los 20 conceptos, a 200, con
|
||
# su <h1> y su intro correctos encima. Medido hoy, en la primera ejecución de este generador.
|
||
#
|
||
# El valor anterior no tenía llaves por casualidad: las glosas del árbitro eran frases cortas, y las
|
||
# definiciones del glosario citan rutas y código. La forma de la fuente cambió, y este escape es la
|
||
# consecuencia — no una precaución.
|
||
#
|
||
# Este fichero ya conocía las otras dos caras (clave duplicada → cae el recurso; clave vacía → cae
|
||
# el recurso). Las tres son lo mismo: un carácter que Fluent lee como sintaxis, dentro de un dato
|
||
# que nadie escribió pensando en Fluent. Escapar es del generador; el dato no tiene por qué saber
|
||
# a qué idioma de plantilla lo van a servir.
|
||
#
|
||
# El escape de Fluent para una llave literal es `{"{"}` — un placeable con una cadena dentro. Se
|
||
# hace en UNA pasada vía marcador: sustituir `{` primero introduce `{` y `}` nuevos, y la segunda
|
||
# pasada los destrozaría.
|
||
def esc-ftl [s: string]: nothing -> string {
|
||
# A Fluent value is one line. A newline inside it would silently truncate the entry — and a
|
||
# glossary whose definitions end mid-sentence is worse than no glossary. Las definiciones son
|
||
# m-strings MULTILÍNEA, así que esto no es una defensa: es el caso normal.
|
||
$s
|
||
| str replace --all --regex '\s+' " "
|
||
| str trim
|
||
| str replace --all "{" "\u{1}" | str replace --all "}" "\u{2}"
|
||
| str replace --all "\u{1}" '{"{"}' | str replace --all "\u{2}" '{"}"}'
|
||
}
|
||
|
||
# ── EL RENDERER HABLANDO, NO EL DATO ──────────────────────────────────────────────────────
|
||
#
|
||
# Las categorías y los orígenes son ENUM del contrato ('Concept, 'Adr…). Su nombre en cada idioma
|
||
# es cosa del renderer y vive aquí, una vez, igual que en expediente-vocab.nu. Un enum sin nombre
|
||
# declarado es un ERROR, no un `default` silencioso: si mañana term.ncl añade 'Heuristic, esta
|
||
# página tiene que decirlo — no imprimir "Heuristic" dentro de una página en español y aparentar
|
||
# que alguien lo decidió.
|
||
const CATEGORY = {
|
||
es: { Discipline: "Disciplina", Concept: "Concepto", Practice: "Práctica", Artifact: "Artefacto", Procedure: "Procedimiento", Tension: "Tensión", Antipattern: "Antipatrón" }
|
||
en: { Discipline: "Discipline", Concept: "Concept", Practice: "Practice", Artifact: "Artifact", Procedure: "Procedure", Tension: "Tension", Antipattern: "Antipattern" }
|
||
}
|
||
|
||
const ORIGIN = {
|
||
es: { Axiom: "Axioma", Tension: "Tensión", Practice: "Práctica", Adr: "ADR", Schema: "Esquema", Module: "Módulo", Crate: "Crate", External: "" }
|
||
en: { Axiom: "Axiom", Tension: "Tension", Practice: "Practice", Adr: "ADR", Schema: "Schema", Module: "Module", Crate: "Crate", External: "" }
|
||
}
|
||
|
||
const CHROME = {
|
||
es: {
|
||
title: "Conceptos clave — ontoref",
|
||
subtitle: "Las palabras que este proyecto usa, y qué significan",
|
||
desc: "El vocabulario de ontoref: cada concepto definido una sola vez, con su categoría, de dónde nace y con qué otros se relaciona.",
|
||
keywords: "glosario, conceptos, ontología, reflexión, ADR, ondaod, espiral, síntesis",
|
||
intro: "Este glosario no está escrito aquí. Se proyecta de .ontoref/ontology/glossary.ncl — el mismo fichero del que responde «ontoref describe term» en la terminal. Si una definición cambia, cambia en un sitio y cambia en los dos.",
|
||
draft: "BORRADOR",
|
||
draft_title: "Término nacido en sesión, todavía no ratificado como ADR (verified = false)",
|
||
origin_label: "nace de",
|
||
related_label: "relacionados",
|
||
more: "Más información →",
|
||
}
|
||
en: {
|
||
title: "Key concepts — ontoref",
|
||
subtitle: "The words this project uses, and what they mean",
|
||
desc: "The ontoref vocabulary: each concept defined once, with its category, where it comes from, and what it relates to.",
|
||
keywords: "glossary, concepts, ontology, reflection, ADR, ondaod, spiral, synthesis",
|
||
intro: "This glossary is not written here. It is projected from .ontoref/ontology/glossary.ncl — the same file «ontoref describe term» answers from in the terminal. Change a definition once, and it changes in both.",
|
||
draft: "DRAFT",
|
||
draft_title: "Session-originated term, not yet ratified as an ADR (verified = false)",
|
||
origin_label: "comes from",
|
||
related_label: "related",
|
||
more: "Learn more →",
|
||
}
|
||
}
|
||
|
||
# ── EL ENLACE AL ADR SE CONSTRUYE, Y HAY QUE CONSTRUIRLO BIEN ─────────────────────────────
|
||
#
|
||
# `origin.ref` es `adr-050`; la ruta es `/adr/050`. Y `/adr/adr-050` NO da 404: da 200 con el
|
||
# título «ontoref — Article» — un 404 BLANDO. Medido hoy: /adr/999 hace exactamente lo mismo.
|
||
# Así que un prefijo mal quitado no rompe nada visible, no mueve ningún código de estado, y
|
||
# publica un enlace muerto que responde OK. En este sitio el status NO sirve para saber si una
|
||
# página existe; por eso la puerta de abajo compara contra un marcador POSITIVO.
|
||
def adr-path [ref: string]: nothing -> string {
|
||
$"/adr/($ref | str replace --regex '^adr-' '')"
|
||
}
|
||
|
||
def main [
|
||
--root: string = "../.."
|
||
--check
|
||
] {
|
||
let gl = (
|
||
^nickel export --format json --import-path $"($root)/.ontoref" $"($root)/.ontoref/ontology/glossary.ncl"
|
||
| from json
|
||
)
|
||
# La extensión se DECLARA, no se infiere de lo que hay escrito. Mismo criterio que
|
||
# gen-expediente.nu: preguntar «¿en qué idiomas existe esto?» al fichero que lo declara, para
|
||
# que añadir un idioma ROMPA esta página en vez de dejarla callada a medias.
|
||
let langs = (
|
||
^nickel export --format json --import-path $"($root)/.ontoref" $"($root)/.ontoref/ontology/lexicon.ncl"
|
||
| from json | get languages
|
||
)
|
||
|
||
mut drift = []
|
||
mut gaps = []
|
||
for lang in $langs {
|
||
let c = ($CHROME | get -o $lang)
|
||
if $c == null {
|
||
error make { msg: $"'($lang)' está declarado en lexicon.ncl y esta página no sabe hablarlo — añade su bloque a CHROME en gen-conceptos.nu" }
|
||
}
|
||
let cats = ($CATEGORY | get $lang)
|
||
let origins = ($ORIGIN | get $lang)
|
||
|
||
let terms = (
|
||
$gl.terms
|
||
| each {|t|
|
||
let cat = ($cats | get -o ($t.category | into string))
|
||
if $cat == null {
|
||
error make { msg: $"($t.id): la categoría '($t.category)' no tiene nombre en ($lang) — declárala en CATEGORY (gen-conceptos.nu). Sin esto se imprimiría en inglés dentro de una página en español y nadie lo habría decidido." }
|
||
}
|
||
let o = ($t.origin? | default { kind: "External", ref: "" })
|
||
let okind = ($origins | get -o ($o.kind | into string) | default "")
|
||
# El enlace: el ADR del que nace el concepto, o el que su política ya declaró.
|
||
let pol_href = ($t.policy? | default {} | get -o by_lang | default {} | get -o $lang | default {} | get -o gloss_href | default "")
|
||
{
|
||
id: $t.id,
|
||
name: ($t.name | get -o $lang | default ""),
|
||
def: ($t.definition | get -o $lang | default ""),
|
||
cat: $cat,
|
||
origin: (if ($okind | is-empty) or ($o.ref | is-empty) { "" } else { $"($okind) · ($o.ref)" }),
|
||
href: (
|
||
if ($o.kind | into string) == "Adr" and ($o.ref | is-not-empty) { adr-path $o.ref }
|
||
else { $pol_href }
|
||
),
|
||
verified: ($t.verified? | default false),
|
||
related: ($t.related_terms? | default []),
|
||
}
|
||
}
|
||
| sort-by name
|
||
)
|
||
|
||
# CERO CONCEPTOS NO ES UN ESTADO DE LA PÁGINA: ES UN GLOSARIO ROTO.
|
||
#
|
||
# La plantilla tenía una rama «vacío» porque el inglés SIEMPRE llegaba a 0 y había que
|
||
# confesarlo. Leyendo el glosario eso no puede pasar sin que algo esté mal — un export vacío,
|
||
# un import roto, un filtro de más. Así que se refusa aquí, antes de escribir, en vez de
|
||
# publicar una página que se disculpa por estar vacía. Una rama que no puede darse es una rama
|
||
# que nadie prueba, y el .ftl que la alimentaba ya no la emite.
|
||
if ($terms | is-empty) {
|
||
error make { msg: $"glossary.ncl no devolvió ni un concepto para ($lang) — no publico un glosario vacío que se explique a sí mismo. Revisa el export." }
|
||
}
|
||
|
||
# UN HUECO DECLARADO ES UN HUECO; UN HUECO CALLADO ES UNA MENTIRA.
|
||
# `definition.es` lleva `default = ""` en el contrato, así que un término PUEDE llegar sin su
|
||
# definición en un idioma y el export no chista. Esta página no lo va a disimular imprimiendo
|
||
# el nombre a secas: se recoge y se informa, con el término y el idioma. Es el mismo criterio
|
||
# que mató a esta página la primera vez — servir un hueco elegante en vez de nombrarlo.
|
||
for t in $terms {
|
||
if ($t.def | is-empty) { $gaps = ($gaps | append $"($t.id) [($lang)] — sin definition.($lang)") }
|
||
if ($t.name | is-empty) { $gaps = ($gaps | append $"($t.id) [($lang)] — sin name.($lang)") }
|
||
}
|
||
|
||
# El <dl> se emite como UN valor Fluent y se pinta con `| safe` — lo renderiza el SERVIDOR,
|
||
# así que un rastreador y un lector de pantalla reciben el glosario entero sin ejecutar una
|
||
# línea de JavaScript. La plantilla itera contexto ESTRUCTURADO que llega de Rust, y un
|
||
# objeto de contexto nuevo sería tocar el crate por una página que es dato puro.
|
||
let ids = ($terms | get id)
|
||
let dl = (
|
||
$terms | each {|t|
|
||
let draft = (if $t.verified { "" } else { $"<span class=\"cpt-draft\" title=\"($c.draft_title)\">($c.draft)</span>" })
|
||
let meta = ([
|
||
$"<span class=\"cpt-cat\">($t.cat)</span>"
|
||
(if ($t.origin | is-empty) { "" } else { $"<span class=\"cpt-origin\">($c.origin_label) ($t.origin)</span>" })
|
||
] | str join "")
|
||
let link = (if ($t.href | is-empty) { "" } else { $"<a class=\"cpt-more\" href=\"($t.href)\">($c.more)</a>" })
|
||
# Los relacionados apuntan al ancla del término EN ESTA MISMA PÁGINA, y SOLO si el término
|
||
# está publicado aquí: `related_terms` puede nombrar un id que no está en el glosario, y
|
||
# eso sería un <a href="#loquesea"> que no va a ninguna parte y no falla en ningún sitio.
|
||
let live = ($t.related | where {|r| $r in $ids })
|
||
let rel = (
|
||
if ($live | is-empty) { "" } else {
|
||
let items = ($live | each {|r| $"<a href=\"#($r)\">(esc-ftl ($terms | where id == $r | get name | first))</a>" } | str join ", ")
|
||
$"<span class=\"cpt-rel\">($c.related_label): ($items)</span>"
|
||
}
|
||
)
|
||
$"<dt id=\"($t.id)\">(esc-ftl $t.name)($draft)</dt><dd><span class=\"cpt-meta\">($meta)</span>(esc-ftl $t.def)($rel)($link)</dd>"
|
||
} | str join ""
|
||
)
|
||
let rows = $"conceptos-html = <dl class=\"cpt\">($dl)</dl>"
|
||
|
||
let ftl = ([
|
||
"# GENERATED from .ontoref/ontology/glossary.ncl by gen-conceptos.nu — do not edit by hand."
|
||
"# One source: the same glossary `ontoref describe term` answers from."
|
||
$"conceptos-page-title = ($c.title)"
|
||
$"conceptos-page-subtitle = ($c.subtitle)"
|
||
$"conceptos-page-description = ($c.desc)"
|
||
$"conceptos-page-keywords = ($c.keywords)"
|
||
$"conceptos-intro = ($c.intro)"
|
||
$"conceptos-more = ($c.more)"
|
||
$"conceptos-count = ($terms | length)"
|
||
$rows
|
||
""
|
||
] | str join "\n")
|
||
|
||
let out = $"site/i18n/locales/($lang)/pages/conceptos.ftl"
|
||
|
||
# A duplicate Fluent key drops the WHOLE language resource — 42 files concatenated into one.
|
||
# That is expediente 1/1852, and a generator that can still do it has learned nothing.
|
||
let keys = ($ftl | lines | where {|l| (not ($l | str starts-with "#")) and ($l | str contains " = ") } | each {|l| $l | split row " = " | first })
|
||
let dup = ($keys | group-by {|k| $k } | items {|k, v| { k: $k, n: ($v | length) } } | where n > 1 | get k)
|
||
if ($dup | is-not-empty) {
|
||
error make { msg: $"conceptos.ftl [($lang)]: claves Fluent duplicadas — el recurso se caería entero y dejaría el idioma sin texto: ($dup | str join ', ')" }
|
||
}
|
||
# UNA CLAVE SIN VALOR INVALIDA EL RECURSO IGUAL QUE UNA DUPLICADA. En Fluent, `clave =` sin
|
||
# valor es un ERROR DE SINTAXIS y el fichero entero deja de cargar: las 9 claves de esta página
|
||
# desaparecieron del bundle (las otras 1861 seguían), el <h1> salió vacío y el <title> sirvió
|
||
# `[conceptos-page-title]` a 200. El cerrojo contra duplicadas existía; contra vacías no — el
|
||
# mismo defecto del expediente 1/1852, cometido por quien lo publicó.
|
||
let empty = (
|
||
$ftl | lines
|
||
| where {|l| (not ($l | str starts-with "#")) and ($l | str trim | str ends-with "=") }
|
||
| each {|l| $l | split row "=" | first | str trim }
|
||
)
|
||
if ($empty | is-not-empty) {
|
||
error make { msg: $"conceptos.ftl [($lang)]: claves Fluent sin valor — `clave =` es un error de sintaxis y el fichero NO CARGA: ($empty | str join ', ')" }
|
||
}
|
||
# UNA LLAVE CRUDA TUMBA EL VALOR ENTERO, y lo hace a 200. `{` abre un placeable de Fluent, así
|
||
# que un dato que cite `{id}` (lo hace `domain`) convierte los 20 conceptos en
|
||
# `__FLUENT_MSG_WITH_PARAMS__` bajo un <h1> perfecto. Se comprueba QUITANDO los escapes legítimos
|
||
# y exigiendo que no quede ninguna llave: así el cerrojo mide lo que Fluent va a leer, no lo que
|
||
# yo creía haber escrito. Cubre también la chrome, que no pasa por esc-ftl.
|
||
let raw_braces = (
|
||
$ftl
|
||
| str replace --all '{"{"}' ""
|
||
| str replace --all '{"}"}' ""
|
||
| parse --regex '(?<b>[{}])' | get b
|
||
)
|
||
if ($raw_braces | is-not-empty) {
|
||
error make { msg: $"conceptos.ftl [($lang)]: llave sin escapar — Fluent la lee como placeable y sirve `__FLUENT_MSG_WITH_PARAMS__` en vez del glosario, a 200. Escápala con esc-ftl \(({$raw_braces | length}) encontradas\)" }
|
||
}
|
||
# Dos términos con el mismo id serían dos <dt> con el mismo ancla: el navegador se queda con
|
||
# el primero y el enlace al segundo miente en silencio. El contrato no lo impide.
|
||
let dupid = ($ids | group-by {|k| $k } | items {|k, v| { k: $k, n: ($v | length) } } | where n > 1 | get k)
|
||
if ($dupid | is-not-empty) {
|
||
error make { msg: $"conceptos.ftl [($lang)]: ids repetidos — dos anclas iguales y un enlace que miente: ($dupid | str join ', ')" }
|
||
}
|
||
|
||
if $check {
|
||
let cur = (if ($out | path exists) { open --raw $out } else { "" })
|
||
if $cur != $ftl { $drift = ($drift | append $out) }
|
||
} else {
|
||
$ftl | save -f $out
|
||
print $"conceptos → ($out) [($terms | length) conceptos · ($terms | where href != "" | length) con enlace · ($terms | where {|t| not $t.verified } | length) borrador]"
|
||
}
|
||
}
|
||
|
||
if ($gaps | is-not-empty) {
|
||
print "conceptos: HUECOS — el contrato permite un idioma sin definición y el export no chista:"
|
||
for g in $gaps { print $" ⊘ ($g)" }
|
||
}
|
||
|
||
if $check {
|
||
if ($drift | is-not-empty) {
|
||
print $"conceptos-check: DRIFT — ($drift | str join ', ') no reproduce desde el glosario. Ejecuta `just conceptos`."
|
||
exit 1
|
||
}
|
||
if ($gaps | is-not-empty) {
|
||
print "conceptos-check: INCOMPLETO — hay conceptos sin definición en algún idioma declarado."
|
||
exit 1
|
||
}
|
||
print $"conceptos-check: la página reproduce desde el glosario · completa en ($langs | str join ', ')"
|
||
}
|
||
}
|