diff --git a/scripts/decks/decks.ncl b/scripts/decks/decks.ncl
index 28315cd..9999149 100644
--- a/scripts/decks/decks.ncl
+++ b/scripts/decks/decks.ncl
@@ -1,7 +1,13 @@
# Which Slidev deck projects onto which site pages.
#
-# One source, N pages: the deck is not translated, so a single capture feeds both the
-# es and en surfaces — they differ only in slug and frontmatter, never in body.
+# ONE SOURCE PER PAGE, with the deck's `source` as the default.
+#
+# The first shape of this file said "one source, N pages" — true only for a deck that is not
+# translated. A bilingual deck is TWO sources, and without a per-page source the only way to have
+# an English page is to serve Spanish slides under an English URL. A page that lies about its
+# language is worse than a page that does not exist.
+#
+# So: `pages[].source` overrides the deck's. Omit a page and that language simply has no page.
{
decks = [
{
@@ -9,7 +15,34 @@
id = "ontoref-ontologia-reflexion",
pages = [
{ lang = "es", slug = "ontoref-ontologia-reflexion" },
- { lang = "en", slug = "ontoref-ontology-and-reflection" },
+ # The English page has its OWN source. The deck is Spanish (the "46/57 mix" I first
+ # reported was a false signal — my classifier counted `runtime`, `Nickel`, `commit` as
+ # English; the prose is clean Spanish). ontology_slides_en.md is a controlled translation
+ # that keeps the product name, the coined terms and the glossary lexicon, and uses the
+ # REAL English field names for the code (nodes/invariant/description/…) — which the
+ # Spanish deck localizes and so, on that one point, the English deck is more faithful.
+ { lang = "en", slug = "ontoref-ontology-and-reflection",
+ source = "ontology_slides_en.md" },
+ ],
+ },
+ {
+ # An intro to ontoref for an INFRA audience: "does AI manage your infra — do you know what's
+ # happening?". Authored in Spanish (ai-infra_es.md), translated to English (ai-infra.md) as a
+ # faithful counterpart — same content, glossary lexicon and real English field names in the
+ # code. Two sources → one build/capture/asset-dir each.
+ source = "ai-infra_es.md",
+ id = "ai-infra",
+ pages = [
+ { lang = "es", slug = "la-ia-gestiona-tu-infra" },
+ { lang = "en", slug = "does-ai-manage-your-infra", source = "ai-infra.md" },
+ ],
+ },
+ {
+ source = "rustikon_es.md",
+ id = "rustikon",
+ pages = [
+ { lang = "es", slug = "por-que-necesite-rust" },
+ { lang = "en", slug = "why-i-needed-rust", source = "rustikon.md" },
],
},
],
@@ -19,6 +52,13 @@
# point of the projection. `deck-`: the projection's own chrome (card, switcher, zoom).
boundary_allow = ["[class*=\"gloss-\"]", "[class*=\"deck-\"]"],
+ # The URL category segment, per language. The route is /recursos/{category}/{slug}; the KIND
+ # localizes (recursos↔resources) but {category} is filled literally from the content, so an ES
+ # deck under category "decks" gives /recursos/decks/… — Spanish path, English word. Localizing
+ # the category (es = "presentaciones") keeps the whole ES URL Spanish. Runtime route, no rebuild;
+ # registers on server restart.
+ category = { es = "presentaciones", en = "decks" },
+
# Slidev's native slide geometry. The WIDTH is the scaling base; the height is only a
# floor — 12 of 16 slides in this deck hold more than 552px, so the card grows to its
# measured content instead of cutting it.
@@ -32,7 +72,17 @@
index_head = "Índice", slides = "diapositivas",
prev = "Anterior", next = "Siguiente",
zoom = "Ampliar diapositiva", slide = "Diapositiva",
- aria = "Deck de %n diapositivas. Con el foco puesto, las flechas o la barra espaciadora navegan.",
+ aria = "Presentación de %n diapositivas. Con el foco puesto, las flechas o la barra espaciadora navegan.",
+ help = "Cómo usar",
+ help_title = "Cómo usar esta presentación",
+ help_items = [
+ "Diapositivas / Leer todo — pasa las slides una a una, o vélas todas apiladas (ahí funciona buscar con Ctrl+F, seleccionar y copiar el texto).",
+ "Índice — muestra u oculta el índice lateral; al pulsar una entrada saltas a esa slide.",
+ "Términos del glosario — algunas palabras llevan un realce ámbar: pasa el ratón por encima (o púlsalas en móvil) para ver una definición breve.",
+ "Teclado — con la presentación seleccionada: → o Espacio avanzan, ← o Shift+Espacio retroceden.",
+ "Ampliar — el botón ⤢ abre la slide a pantalla completa; Esc la cierra.",
+ "Copiar código — pasa el ratón por un bloque de código y pulsa el botón de arriba a la derecha para copiarlo.",
+ ],
},
en = {
read = "Read all", slider = "Slides", index = "Index",
@@ -40,6 +90,16 @@
prev = "Previous", next = "Next",
zoom = "Enlarge slide", slide = "Slide",
aria = "Deck of %n slides. With focus on it, arrows or the space bar navigate.",
+ help = "How to use",
+ help_title = "How to use this deck",
+ help_items = [
+ "Slides / Read all — step through one at a time, or see them all stacked (there Ctrl+F, select and copy the text all work).",
+ "Index — shows or hides the side index; clicking an entry jumps to that slide.",
+ "Glossary terms — some words carry a faint amber highlight: hover them (or tap on mobile) for a short definition.",
+ "Keyboard — with the deck focused: → or Space advance, ← or Shift+Space go back.",
+ "Enlarge — the ⤢ button opens the slide full-screen; Esc closes it.",
+ "Copy code — hover a code block and press the button at its top-right to copy it.",
+ ],
},
},
}
diff --git a/scripts/decks/gates/served.mjs b/scripts/decks/gates/served.mjs
index bea71fb..c5020e3 100644
--- a/scripts/decks/gates/served.mjs
+++ b/scripts/decks/gates/served.mjs
@@ -12,9 +12,15 @@
// This is the gate whose absence hid a real bug — every other check
// measured the BOX, and the box was always right while the content
// stayed 980px inside a 328px phone. Measure the thing you claim.
+// M ANCHORS every `gloss_href` the term registry points AT THIS PAGE lands on the
+// slide it names — measured by LOADING the URL and reading which slide
+// came up, never by checking that an id exists in the DOM.
//
// Usage: node gates/served.mjs
import { chromium } from '../lib/paths.mjs'
+import { execFileSync } from 'node:child_process'
+import { join } from 'node:path'
+import { OUTREACH } from '../lib/paths.mjs'
const URL_ = process.argv[2]
if (!URL_) { console.error('uso: node gates/served.mjs '); process.exit(2) }
@@ -291,6 +297,68 @@ const fail = (k, msg) => { out[k] = 'FAIL — ' + msg; ok = false }
await ctx.close()
}
+// ── M: un enlace con fragmento ATERRIZA EN SU SLIDE ───────────────────────────────────
+//
+// El glosario del sitio enlaza `NCL` a este deck. Hasta ahora el enlace no llevaba fragmento y
+// dejaba al lector en la portada: le prometías la definición y le dabas una presentación entera
+// por delante. Ahora lleva `#ncl`, y esta puerta existe porque un fragmento es la clase de
+// enlace que se rompe SIN QUE NADA FALLE — borras la slide, o le cambias el ancla, y la página
+// sigue respondiendo 200 y sigue enseñando la portada. Exactamente igual que antes. El fallo es
+// invisible desde dentro: solo se ve comparando lo que el enlace PROMETE con dónde CAE.
+//
+// Por eso no se comprueba que el id esté en el DOM: se CARGA la URL que el lector va a pulsar y
+// se mira qué slide quedó delante. Un id presente y un salto que no ocurre son indistinguibles
+// para el lector, y solo una de las dos cosas es la que le importa.
+{
+ const spine = join(OUTREACH, '..', '.ontoref')
+ const reg = JSON.parse(execFileSync('nickel',
+ ['export', '--format', 'json', '--import-path', spine, join(spine, 'ontology/registry.ncl')],
+ { encoding: 'utf8' }))
+
+ const here = new global.URL(URL_)
+ // Sólo los enlaces que apuntan A ESTA página: la puerta corre por página servida, y un
+ // gloss_href a /adr/050 no es asunto de este deck.
+ const linked = reg.rules
+ .filter(r => (r.gloss_href ?? '').includes('#'))
+ .map(r => ({ term: r.instead, href: r.gloss_href }))
+ .filter(r => new global.URL(r.href, here).pathname === here.pathname)
+
+ if (!linked.length) {
+ out.M_anclas = 'PASS — ningún término del registro enlaza con fragmento a esta página'
+ } else {
+ const p = await browser.newPage({ viewport: { width: 1280, height: 900 } })
+ const bad = []
+ for (const l of linked) {
+ const frag = decodeURIComponent(new global.URL(l.href, here).hash.slice(1))
+ await p.goto(new global.URL(l.href, here).href, { waitUntil: 'networkidle' })
+ await p.evaluate(() => document.fonts.ready)
+ await p.waitForTimeout(400)
+ const r = await p.evaluate((f) => {
+ const el = document.getElementById(f)
+ const target = el && el.closest('.deck-slide')
+ const active = document.querySelector('.deck-slide[data-active]')
+ const slides = [].slice.call(document.querySelectorAll('.deck-slide'))
+ const title = (s) => { const h = s && s.querySelector('h1,h2,h3'); return h ? h.textContent.trim() : '(sin título)' }
+ return {
+ exists: !!el,
+ want: target ? slides.indexOf(target) + 1 : -1,
+ got: active ? slides.indexOf(active) + 1 : -1,
+ gotTitle: title(active),
+ wantTitle: title(target),
+ }
+ }, frag)
+ if (!r.exists) {
+ bad.push(`«${l.term}» → ${l.href}: no hay ninguna slide con el ancla "${frag}" — el enlace deja al lector en la portada, a 200 y sin decir nada`)
+ } else if (r.got !== r.want) {
+ bad.push(`«${l.term}» → ${l.href}: el ancla vive en la slide ${r.want} (${r.wantTitle}) y la página abrió en la ${r.got} (${r.gotTitle})`)
+ }
+ }
+ await p.close()
+ if (bad.length) fail('M_anclas', bad.join(' · '))
+ else out.M_anclas = `PASS — ${linked.length} enlace(s) del glosario caen en su slide: ${linked.map(l => l.term + ' → ' + l.href.split('#')[1]).join(', ')}`
+ }
+}
+
await browser.close()
console.log(JSON.stringify(out, null, 2))
process.exit(ok ? 0 : 1)
diff --git a/scripts/decks/gen-deck-pages.nu b/scripts/decks/gen-deck-pages.nu
index d28e63b..1cf9364 100644
--- a/scripts/decks/gen-deck-pages.nu
+++ b/scripts/decks/gen-deck-pages.nu
@@ -22,19 +22,59 @@ def outreach [] { $env.FILE_PWD | path dirname | path dirname }
def presentation [] { [(outreach) "presentation"] | path join }
-# Slidev must have produced a dist/ — the capture reads the browser's applied CSS from it,
-# not the source. Without a build there is nothing to project.
-def ensure-build [] {
- let dist = [(presentation) "dist" "index.html"] | path join
- if not ($dist | path exists) {
- print $"(ansi yellow)no hay build; ejecutando 'pnpm build' en presentation/(ansi reset)"
- cd (presentation)
- ^pnpm build
- }
+def manifest [] {
+ let f = [(outreach) "scripts" "decks" "decks.ncl"] | path join
+ ^nickel export --format json $f | from json
}
-def main [--dry-run] {
- ensure-build
+# ONE dist PER DECK. `slidev build` builds a single entry, so a shared dist/ means the second
+# deck silently captures the FIRST one's slides — the projection would be wrong and nothing
+# would say so. The deck's own build dir is what makes this a pipeline instead of a one-off.
+#
+# --force rebuilds even if the dist exists: the whole point is that the page follows the source,
+# and a stale dist is a page that quietly stopped following it.
+def ensure-build [id: string, source: string, --force] {
+ let dest = [(presentation) "builds" $id] | path join
+ let built = [$dest "index.html"] | path join
+ let src = [(presentation) $source] | path join
+
+ # Rebuild when the SOURCE is newer than the build. Without this, editing a slide and running
+ # `just decks` silently captured the stale build — the whole point of the projection is that the
+ # page follows the source, so a build older than its source is a page that stopped following it.
+ # --force rebuilds regardless (e.g. after a Slidev/theme upgrade the source mtime did not move).
+ let stale = if ($built | path exists) {
+ (($src | path exists) and (($src | path expand | path type) == "file")
+ and (((ls $src).0.modified) > ((ls $built).0.modified)))
+ } else { true }
+
+ if not $force and not $stale { return }
+
+ print $"(ansi cyan)slidev build ($source) → builds/($id)(ansi reset)"
+ cd (presentation)
+ # `slidev build` IGNORES --output (it is an `export` flag; on `build` it is accepted and does
+ # nothing). Measured: it wrote to dist/ in both flag positions. So build where it insists, then
+ # move. Fighting the CLI here would be fragile; moving a directory is not.
+ rm -rf dist
+ ^./node_modules/.bin/slidev build $source
+ if not ("dist/index.html" | path exists) {
+ error make { msg: $"slidev build no produjo dist/ para ($source)" }
+ }
+ mkdir builds
+ rm -rf $dest
+ mv dist $dest
+}
+
+def main [--dry-run, --force] {
+ # One build per SOURCE, not per deck: a bilingual deck is two sources, and sharing a build is
+ # how an English URL ends up serving Spanish slides. The variant key mirrors lib/project.mjs.
+ for d in (manifest).decks {
+ let sources = ($d.pages | each {|p| $p.source? | default $d.source } | uniq)
+ for src in $sources {
+ let langs = ($d.pages | where {|p| ($p.source? | default $d.source) == $src } | get lang)
+ let key = if ($sources | length) == 1 { $d.id } else { $"($d.id)-($langs | str join '-')" }
+ ensure-build $key $src --force=$force
+ }
+ }
let flags = if $dry_run { ["--dry-run"] } else { [] }
let script = [(outreach) "scripts" "decks" "lib" "project.mjs"] | path join
diff --git a/scripts/decks/lib/anchors.mjs b/scripts/decks/lib/anchors.mjs
new file mode 100644
index 0000000..70f96bd
--- /dev/null
+++ b/scripts/decks/lib/anchors.mjs
@@ -0,0 +1,63 @@
+// Read each slide's declared ANCHOR from the deck source — with Slidev's own parser.
+//
+// An anchor lets a link land ON a slide instead of at the top of the deck: the glossary
+// tooltip over `NCL` goes to the Nickel slide, not to the cover. Without one the link
+// cannot be written at all, and until now it could not: the slide ids were assigned by
+// deck.js at runtime, so the served HTML carried none and the browser resolved the hash
+// before they existed.
+//
+// WHY THE ANCHOR LIVES IN THE SLIDE'S FRONTMATTER, and not in the registry that links to it:
+// a `#slide-7` is a POSITIONAL pointer into a document that reorders. Insert one slide and
+// every link points one slide off — at 200, in silence, with nothing to fail. That is the
+// drift this whole projection exists to refuse. Declared in the slide, the anchor travels
+// with it when it moves, and dies loudly with it when it is deleted (see the gate).
+//
+// WHY SLIDEV'S PARSER AND NOT A `---` SPLIT: this file's neighbours say it plainly — the deck
+// is not re-parsed, because Slidev's markdown is not standard markdown. A `---` inside a code
+// fence would silently shift every anchor onto the wrong slide, which is the exact failure the
+// anchors exist to prevent. Slidev already ships the parser that draws these boundaries; the
+// only correct number of implementations of it is one, and it is not this one.
+import { createRequire } from 'node:module'
+import { realpathSync } from 'node:fs'
+import { readFile } from 'node:fs/promises'
+import { resolve } from 'node:path'
+import { PRESENTATION } from './paths.mjs'
+
+// pnpm links @slidev/parser under the CLI, never at the top level, and createRequire does not
+// resolve the symlink for you: pointed at the link path it looks for a node_modules that is not
+// there. Realpath first, and resolution happens in the tree that actually has the package.
+const parserPromise = (async () => {
+ const cli = realpathSync(resolve(PRESENTATION, 'node_modules/@slidev/cli/package.json'))
+ return import(createRequire(cli).resolve('@slidev/parser'))
+})()
+
+// The anchor is a URL fragment, so it must survive being one: lowercase, no spaces, no accents.
+// Rejected here rather than normalised — an anchor that is not what the author typed is an
+// anchor the author cannot link to from memory, and they would find out in the browser.
+const SHAPE = /^[a-z0-9][a-z0-9-]*$/
+
+/**
+ * @param {string} source filename relative to outreach/presentation/
+ * @returns {Promise>} one entry per slide, index-parallel to the capture
+ */
+export async function readAnchors(source) {
+ const { parse } = await parserPromise
+ const file = resolve(PRESENTATION, source)
+ const data = await parse(await readFile(file, 'utf8'), file)
+
+ const seen = new Map()
+ return data.slides.map((s, i) => {
+ const a = s.frontmatter?.anchor
+ if (a == null || a === '') return null
+ if (typeof a !== 'string' || !SHAPE.test(a)) {
+ throw new Error(`${source} slide ${i + 1}: anchor "${a}" no vale como fragmento de URL — minúsculas, dígitos y guiones (empezando por letra o dígito)`)
+ }
+ // Two slides claiming one anchor is a link with two destinations: the browser takes the
+ // first and the author never learns which one they were pointing at.
+ if (seen.has(a)) {
+ throw new Error(`${source}: el ancla "${a}" está declarada en las slides ${seen.get(a) + 1} y ${i + 1} — un ancla nombra UNA slide`)
+ }
+ seen.set(a, i)
+ return a
+ })
+}
diff --git a/scripts/decks/lib/build-body.mjs b/scripts/decks/lib/build-body.mjs
index fdc20a3..fecf3f8 100644
--- a/scripts/decks/lib/build-body.mjs
+++ b/scripts/decks/lib/build-body.mjs
@@ -10,11 +10,35 @@
// reaches with Ctrl+F — 2.1k words stacked, ~30 in slider. That is the price of the
// default, and it is stated rather than buried.
-export function buildBody(deckHtml, { id, count, W, H, t }) {
+// EL ANCLA SE EMITE EN EL HTML; NO SE ASIGNA EN EL SCRIPT.
+//
+// `s.id = 'slide-' + (k+1)` vivía en deck.js, y por eso ninguna slide era direccionable: el
+// navegador resuelve el hash de la URL ANTES de que el script corra, así que `#slide-7` no
+// encontraba nada — y en modo slider la slide destino está en `display:none`, o sea que ni
+// scroll había que hacer. Un id que aparece después de que el navegador lo busque no es un id:
+// es un adorno. Los cuatro términos del glosario que enlazan a este deck aterrizaban todos en
+// la portada por esto, no porque a nadie se le ocurriera escribir el fragmento.
+//
+// En el marcado, el ancla existe para el hash, para el rastreador y para quien no tiene JS.
+export function buildBody(deckHtml, { id, count, W, H, t, anchors = [] }) {
+ // Las anclas se leen de la FUENTE y las slides se cuentan en la CAPTURA. Si los dos números
+ // no coinciden, la lista está desalineada y cada ancla nombra a la slide de al lado — en
+ // silencio, que es el único fallo de este fichero que un lector no podría ver.
+ if (anchors.length && anchors.length !== count) {
+ throw new Error(`la fuente declara ${anchors.length} slides y la captura contó ${count}: las anclas quedarían pegadas a la slide equivocada`)
+ }
let n = 0
let body = deckHtml.replace(/
`
})
// Each .print-slide-container is a top-level sibling, so the figure closes right before
// the next one opens, and once at the end.
@@ -23,7 +47,7 @@ export function buildBody(deckHtml, { id, count, W, H, t }) {
if (n !== count) throw new Error(`envolví ${n} slides pero la captura contó ${count}`)
- return { html: chrome(body, { n, t }), css: css(W, H), js: js(W, H), slides: n }
+ return { html: chrome(body, { n, t }), css: css(W, H), js: js(W, H), slides: n, anchors }
}
// TWO layers, deliberately.
@@ -53,16 +77,36 @@ const chrome = (body, { n, t }) => `