ontoref-outreach/site/scripts/build/gen-conceptos.nu
2026-07-17 01:01:30 +01:00

305 lines
18 KiB
Text
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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 ', ')"
}
}