chore: scripts for decks

This commit is contained in:
Jesús Pérez 2026-07-17 01:00:32 +01:00
parent 1faea7491c
commit c0a31e4813
16 changed files with 930 additions and 55 deletions

View file

@ -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 <kbd>Ctrl</kbd>+<kbd>F</kbd>, 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: <kbd>→</kbd> o <kbd>Espacio</kbd> avanzan, <kbd>←</kbd> o <kbd>Shift</kbd>+<kbd>Espacio</kbd> retroceden.",
"Ampliar — el botón <kbd>⤢</kbd> abre la slide a pantalla completa; <kbd>Esc</kbd> 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 <kbd>Ctrl</kbd>+<kbd>F</kbd>, 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: <kbd>→</kbd> or <kbd>Space</kbd> advance, <kbd>←</kbd> or <kbd>Shift</kbd>+<kbd>Space</kbd> go back.",
"Enlarge — the <kbd>⤢</kbd> button opens the slide full-screen; <kbd>Esc</kbd> closes it.",
"Copy code — hover a code block and press the button at its top-right to copy it.",
],
},
},
}

View file

@ -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 <url>
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 <url-de-la-pagina>'); 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)

View file

@ -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

View file

@ -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<Array<string|null>>} 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
})
}

View file

@ -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(/<div([^>]*class="print-slide-container"[^>]*)>/g, (_, attrs) => {
const i = n++
return `<figure class="deck-slide" data-slide="${i}"${i === 0 ? ' data-active' : ''}><div${attrs}>`
// Dos direcciones para una tarjeta, y hacen falta las dos:
// · `slide-N` — TODA slide es direccionable sin anotar nada. Es posicional a propósito:
// sirve para «cópiame la URL de lo que estoy viendo», donde el número ES lo que se mira.
// · el ancla nombrada — la que se escribe en el léxico, porque sobrevive a un reordenado.
// Un id por elemento, así que la nombrada va en un <a> vacío DENTRO de la figure: el hash
// resuelve al mismo sitio y el JS sube al .deck-slide que la contiene.
const a = anchors[i]
const named = a ? `<a class="deck-anchor" id="${a}" aria-hidden="true"></a>` : ''
return `<figure class="deck-slide" id="slide-${i + 1}" data-slide="${i}"${i === 0 ? ' data-active' : ''}>${named}<div${attrs}>`
})
// 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 }) => `
<button type="button" class="deck-nav" data-dir="-1" aria-label="${t.prev}">&larr;</button>
<button type="button" class="deck-nav" data-dir="1" aria-label="${t.next}">&rarr;</button>
</div>
<button type="button" class="deck-help-toggle" aria-expanded="false" aria-controls="deck-help"
aria-label="${t.help}" title="${t.help}">?</button>
</div>
<nav class="deck-toc" id="deck-toc" aria-label="${t.index_head}">
<p class="deck-toc-head">${t.index_head} ${n} ${t.slides}</p>
<ol class="deck-toc-list"></ol>
</nav>
<aside class="deck-help" id="deck-help" hidden aria-label="${t.help_title}">
<button type="button" class="deck-help-close" aria-label="×">×</button>
<p class="deck-help-title">${t.help_title}</p>
<ul class="deck-help-list">${t.help_items.map(it => {
const i = it.indexOf(' — ')
const label = i > 0 ? it.slice(0, i) : ''
const desc = i > 0 ? it.slice(i + 3) : it
return `<li>${label ? `<strong>${label}</strong> — ` : ''}${desc}</li>`
}).join('')}</ul>
</aside>
<div class="deck-read dark"><div class="deck-slides">${body}</div></div>
<div class="deck-backdrop"></div>
</div>`
const css = (W, H) => `
/* The deck CLAIMS its width. It has no intrinsic one the slides are width:100% and their
content is position:absolute, so the deck contributes ZERO to its parent's width calculation.
And the site's <main> is a flex item that shrinks to fit its content, so the page is as wide as
the longest paragraph on it: a deck page whose excerpt is empty collapsed to 728px and rendered
its slides at 400px, while the one with a long excerpt got 1216px. A deck that is as wide as the
prose beside it happens to be is not a deck. Declare the floor; do not inherit it. */
.deck-view { min-width: min(76rem, calc(100vw - 6rem)); }
.deck-slides { display: flex; flex-direction: column; gap: 1.5rem; }
.deck-slide {
@ -86,6 +130,17 @@ const css = (W, H) => `
bg-gray-900) that would not flip with a theme anyway. A light deck means re-authoring the
decks, not toggling a class. The chrome around it still follows the SITE's theme. */
.deck-slide { background: #0b0b0b; border: 1px solid rgba(255,255,255,.10); }
/* El ancla nombrada es una DIRECCIÓN, no un elemento: cero tamaño y fuera del flujo, o un <a>
vacío en línea abre un hueco de una línea en la cabecera de la slide. La .deck-slide ya es
position:relative (por el botón de zoom), así que absolute la ancla al borde de la tarjeta. */
.deck-anchor { position: absolute; top: 0; left: 0; width: 0; height: 0; overflow: hidden; }
/* LA BARRA PEGAJOSA SE COME LA CABECERA DE LA SLIDE A LA QUE ACABAS DE SALTAR.
Aterrizar «arriba del todo» de la tarjeta la deja DEBAJO de la barra: el título cortado por la
mitad, que es exactamente donde el enlace prometía dejarte. El margen va también en el .deck-ancla
porque el scroll NATIVO (sin JS, o antes de que arranque) apunta al ancla, no a la figure
ponerlo solo en .deck-slide no arregla el único caso en que el navegador scrollea solo.
4rem es el suelo; con JS se mide la barra de verdad y se pisa este valor. */
.deck-slide, .deck-anchor { scroll-margin-top: 4rem; }
/* The slide is a <figure>, and the site's .prose gives every figure margin: 2em 0. The card
is exempt from the boundary guard on purpose (deck- classes are ours), so that margin lands
on it and on the zoom modal it pushed the fixed element 32px down, past the bottom of the
@ -161,6 +216,47 @@ const css = (W, H) => `
.deck-modes button[aria-pressed="true"] { background: #E8A838; color: #1a1a1a; font-weight: 600; }
.deck-count { font-variant-numeric: tabular-nums; opacity: .7; }
.deck-navs { display: inline-flex; gap: .35rem; margin-left: auto; }
/* Help — a "how to use" toggle at the far right, opening a panel that explains the controls. */
/* Icon-only "?" the universal help affordance, rightmost and set apart from the nav arrows
(margin-left gap) so it reads as meta, not navigation. The label lives in aria-label + title. */
.deck-help-toggle {
margin-left: 1rem; flex: none;
width: 1.9rem; height: 1.9rem; padding: 0;
display: grid; place-content: center;
border: 1px solid color-mix(in srgb, currentColor 30%, transparent);
border-radius: 50%; background: transparent;
cursor: pointer; font: 600 1rem/1 inherit; color: inherit;
}
.deck-help-toggle:hover { background: color-mix(in srgb, currentColor 10%, transparent); }
.deck-help-toggle[aria-expanded="true"] { background: #E8A838; color: #1a1a1a; border-color: #E8A838; }
.deck-help {
position: relative; margin: 0 0 1rem; padding: 1rem 1.25rem;
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
border-left: 3px solid #E8A838; border-radius: .4rem;
background: color-mix(in srgb, currentColor 5%, transparent);
font-size: .85rem; line-height: 1.5;
}
.deck-help[hidden] { display: none; }
.deck-help-title { margin: 0 0 .6rem; font-weight: 600; }
.deck-help-list { margin: 0; padding-left: 1.1rem; display: grid; gap: .5rem; }
.deck-help-list strong { color: #E8A838; }
/* Key-cap styling, like daisyUI's kbd. Scoped to .deck-view so the site's .prose kbd cannot
override it. currentColor-based so it works in the site's light and dark themes. */
.deck-view .deck-help kbd {
display: inline-block; min-width: 1.5em; padding: .05em .45em;
border: 1px solid color-mix(in srgb, currentColor 35%, transparent);
border-bottom-width: 2px; border-radius: .35em;
background: color-mix(in srgb, currentColor 8%, transparent);
font: 600 .82em/1.4 ui-monospace, SFMono-Regular, Menlo, monospace;
text-align: center; vertical-align: baseline;
}
.deck-help-close {
position: absolute; top: .4rem; right: .5rem;
border: 0; background: transparent; color: inherit; opacity: .6;
font-size: 1.3rem; line-height: 1; cursor: pointer;
}
.deck-help-close:hover { opacity: 1; }
.deck-nav { border: 1px solid color-mix(in srgb, currentColor 25%, transparent); background: transparent; border-radius: .3rem;
width: 2rem; height: 2rem; cursor: pointer; font: inherit; color: inherit; }
.deck-nav:hover { background: color-mix(in srgb, currentColor 10%, transparent); }
@ -200,15 +296,17 @@ const css = (W, H) => `
.deck-view {
display: grid;
grid-template-columns: 15rem minmax(0, 1fr);
grid-template-areas: "bar bar" "toc slides";
grid-template-areas: "bar bar" "help help" "toc slides";
column-gap: 1.5rem;
}
.deck-view[data-toc="off"] {
grid-template-columns: minmax(0, 1fr);
grid-template-areas: "bar" "slides";
grid-template-areas: "bar" "help" "slides";
}
.deck-view .deck-bar { grid-area: bar; }
.deck-view .deck-read { grid-area: slides; }
/* Own full-width row below the bar. Hidden, [hidden]→display:none collapses the row to 0. */
.deck-view .deck-help { grid-area: help; }
.deck-view .deck-toc {
grid-area: toc;
position: sticky; top: 3.5rem; align-self: start; /* bajo la barra pegajosa */
@ -274,15 +372,33 @@ main:has(.deck-view[data-zoom]) { transform: none; }
/* The button ships in the captured DOM but measures 0x0: UnoCSS's preflight sets
button{padding:0} and the icon carries no intrinsic size, so its hit area is nothing.
It was there, and no human could ever click it. Give it a surface. */
/* Compensated for the slide's scale, exactly like the glossary marker and for exactly the
same reason, which I had already learned once and failed to generalise: this button is a UI
affordance drawn INSIDE the slide, and the slide is transform-scaled afterwards. On a deck the
site rendered at k=0.41, a 1.9rem button reached the screen at 12x12px: present, and impossible
to click. Gate K caught it on the second deck. Anything the READER must hit has to keep a
constant size on screen, whatever the slide's scale. */
.deck-view .slidev-code-copy {
width: 1.9rem; height: 1.9rem; padding: 0;
width: calc(1.9rem / max(var(--deck-k, 1), 0.35));
height: calc(1.9rem / max(var(--deck-k, 1), 0.35));
padding: 0;
top: .4rem; right: .4rem;
display: grid; place-content: center;
border-radius: .3rem; cursor: pointer;
background: rgba(255,255,255,.08); color: #dbd7ca;
opacity: 0; transition: opacity .15s, background .15s;
/* Discoverable, not opacity:0. On a dark code block, rgba(255,255,255,.08) with no border was a
dark square on dark invisible. Give it a lighter fill, a visible border and near-full
opacity so it reads as a button; brighten on hover. */
background: rgba(255,255,255,.14);
border: 1px solid rgba(255,255,255,.25) !important;
color: #f0ece0;
opacity: .75; transition: opacity .15s, background .15s;
}
.deck-view .slidev-code-wrapper:hover .slidev-code-copy,
.deck-view .slidev-code-copy:hover { opacity: 1; background: rgba(255,255,255,.28); }
.deck-view .slidev-code-copy svg {
width: calc(1rem / max(var(--deck-k, 1), 0.35));
height: calc(1rem / max(var(--deck-k, 1), 0.35));
}
.deck-view .slidev-code-copy svg { width: 1rem; height: 1rem; }
.deck-view .slidev-code-wrapper:hover .slidev-code-copy,
.deck-view .slidev-code-copy:focus-visible { opacity: 1; }
.deck-view .slidev-code-copy:hover { background: rgba(255,255,255,.18); }
@ -340,7 +456,8 @@ const js = (W, H) => `
// this projection exists to cure.
slides.forEach(function (s, k) {
s.setAttribute('data-slide-label', (k + 1) + ' / ' + n);
s.id = 'slide-' + (k + 1);
// El id ya viene EN EL MARCADO (build-body). Asignarlo aquí es asignarlo tarde: el
// navegador ya resolvió el hash y no encontró nada.
var h = s.querySelector('h1, h2, h3');
var title = (h && h.textContent.trim()) || (T_SLIDE + ' ' + (k + 1));
@ -373,9 +490,18 @@ const js = (W, H) => `
var k = parseFloat(getComputedStyle(slide).getPropertyValue('--deck-k')) || 1;
var top = page.getBoundingClientRect().top;
var max = H, all = page.querySelectorAll('*');
// An image reaching past 1.5x the frame is a decorative BLEED, not content: Slidev renders it
// oversized and clips it at the 552 frame ON PURPOSE (rustikon 17/23: a Ferris at ~1170px).
// Growing the card to contain it turns the slide into a giant portrait. A small image that
// overflows alongside the text (ontology's svg at ~660px) is NOT a bleed — it grows with the
// text. So: a bleeding image pins the slide to the native frame, and the card's overflow:hidden
// clips it exactly where Slidev does. Only TEXT overflow earns a taller card.
var BLEED = H * 1.5;
for (var i = 0; i < all.length; i++) {
var bottom = (all[i].getBoundingClientRect().bottom - top) / k;
var el = all[i];
var bottom = (el.getBoundingClientRect().bottom - top) / k;
if (bottom > max) max = bottom;
if (/^(img|svg|picture)$/i.test(el.tagName) && bottom > BLEED) return H;
}
return Math.ceil(max);
}
@ -442,6 +568,10 @@ const js = (W, H) => `
function goto(k) {
if (deck.dataset.mode === 'slider') { show(k); deck.focus(); }
else { i = k; slides[k].scrollIntoView({ behavior: 'smooth', block: 'start' }); }
// Mobile only (< 60rem, where the index and the slides cannot sit side by side): after a jump,
// close the index so the target slide is not left buried beneath it. On desktop the rail is a
// permanent sidebar — leave it open.
if (!window.matchMedia('(min-width: 60rem)').matches) setToc(false);
}
// The rail shows by default where there is width for a column; narrow, it starts hidden
@ -477,6 +607,17 @@ const js = (W, H) => `
attributes: true, attributeFilter: ['class', 'data-theme'],
});
// Help panel: a plain show/hide, and its own close button. Its content is server-rendered per
// language (from decks.ncl help_items); JS only toggles visibility.
var help = deck.querySelector('.deck-help');
var helpBtn = deck.querySelector('.deck-help-toggle');
function setHelp(on) {
help.hidden = !on;
helpBtn.setAttribute('aria-expanded', String(on));
}
helpBtn.addEventListener('click', function () { setHelp(help.hidden); });
deck.querySelector('.deck-help-close').addEventListener('click', function () { setHelp(false); });
var lastFocus = null;
function zoomOpen(k) {
lastFocus = document.activeElement;
@ -555,17 +696,78 @@ const js = (W, H) => `
// Printing in slider mode would emit exactly one slide.
window.addEventListener('beforeprint', function () { setMode('read'); });
// ── DÓNDE ATERRIZA UN ENLACE ────────────────────────────────────────────────────────
//
// Dos formas, y el lector no tiene por qué saber cuál le tocó:
// #slide-7 posicional — toda slide la tiene, es la URL de «lo que estoy viendo».
// #ncl nombrada — declarada en el frontmatter de la slide; es la que se escribe
// en el léxico, porque sobrevive a que el deck se reordene.
// La nombrada se resuelve por el DOM (getElementById → la .deck-slide que la contiene) en
// vez de por una tabla en este script: el ancla ya está en el marcado, y una segunda copia
// aquí es una segunda cosa que se puede desincronizar de la primera.
//
// El navegador NO puede hacer este salto solo. En modo slider la slide destino está en
// display:none y un ancla dentro de ella no tiene geometría: el scroll nativo no va a
// ninguna parte. Por eso el destino se ACTIVA antes de mirarlo, y por eso el id en el
// marcado no basta por sí solo.
function slideFromHash() {
var h = location.hash;
if (!h || h === '#') return -1;
var num = h.match(/^#slide-(\\d+)$/);
if (num) {
var k = +num[1] - 1;
return (k >= 0 && k < n) ? k : -1;
}
// Un id cualquiera del deck: puede ser el ancla nombrada o algo dentro de una slide.
var el = null;
try { el = document.getElementById(decodeURIComponent(h.slice(1))); } catch (e) { el = null; }
var fig = el && el.closest ? el.closest('.deck-slide') : null;
return fig ? slides.indexOf(fig) : -1;
}
// LA BARRA SE MIDE; NO SE ADIVINA. Es sticky en top:0, así que tapa la cabecera de la slide a
// la que acabas de saltar — el título cortado justo debajo. El CSS deja 4rem de suelo, pero la
// barra crece con el tipo de letra del sitio y con el ancho (los botones se envuelven), y un
// número escrito a mano está bien el día que se escribe. scroll-margin es lo único que
// scrollIntoView respeta, así que la medida va ahí.
function syncScrollMargin() {
var bar = deck.querySelector('.deck-bar');
var g = (bar ? Math.ceil(bar.getBoundingClientRect().height) : 48) + 12;
slides.forEach(function (s) { s.style.scrollMarginTop = g + 'px'; });
}
syncScrollMargin();
window.addEventListener('resize', syncScrollMargin);
function gotoHash(smooth) {
var k = slideFromHash();
if (k < 0) return false;
// No es goto(k): aquí no queremos cerrar el índice ni robar el foco de la página a la
// que el lector acaba de llegar. Solo poner delante la slide que pidió la URL.
if (deck.dataset.mode === 'slider') show(k);
else i = k;
// Y SE SCROLLEA EN LOS DOS MODOS. En slider esto faltaba: show(k) solo activa la slide, así
// que lo único que movía la página era el scroll NATIVO del navegador al ancla — hecho antes
// de que este script corra y con el deck todavía apilado. Al cambiar a slider el documento
// encoge de 16 tarjetas a una, ese scroll queda apuntando a un layout que ya no existe, y el
// lector aterriza con el título medio tapado por la barra. La slide correcta, mal puesta.
slides[k].scrollIntoView({ behavior: smooth ? 'smooth' : 'auto', block: 'start' });
return true;
}
// Measure AFTER the webfonts land. With the fallback font the text is shorter and every
// slide "fits"; once Nunito Sans arrives it grows and spills. Measured before fonts, every
// --deck-h came back 552 and nine slides were still cut.
// And measure while still stacked: a display:none slide has no geometry at all. So the
// switch to slider waits for the measurement, not the other way round.
var hash = (location.hash.match(/^#slide-(\\d+)$/) || [])[1];
// Take the mode immediately — no waiting, no race with the reader.
measureAll();
setMode('slider');
if (hash) show(+hash - 1);
gotoHash(false);
// Un enlace al MISMO deck con otro fragmento no recarga la página: sin esto, pulsar NCL y
// luego DAG desde otro tooltip cambia la URL y deja la slide donde estaba. La URL diría una
// cosa y la pantalla otra — que es la deriva de siempre, servida a 200.
window.addEventListener('hashchange', function () { gotoHash(true); });
// Then measure again once the webfonts land: with the fallback face the text is shorter
// and every slide "fits", so a pre-font measurement returns 552 for all of them and nine

View file

@ -18,7 +18,7 @@ import { createServer } from 'node:http'
import { readFile } from 'node:fs/promises'
import { existsSync } from 'node:fs'
import { extname, join, normalize } from 'node:path'
import { DIST, chromium } from './paths.mjs'
import { chromium } from './paths.mjs'
const MIME = {
'.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json',
@ -27,7 +27,7 @@ const MIME = {
'.ttf': 'font/ttf',
}
async function serveDist() {
async function serveDist(DIST) {
const server = createServer(async (req, res) => {
const p = join(DIST, normalize(new URL(req.url, 'http://x').pathname))
const file = existsSync(p) && extname(p) ? p : join(DIST, 'index.html')
@ -38,11 +38,14 @@ async function serveDist() {
return { server, port: server.address().port }
}
export async function capture() {
// One dist PER DECK. `slidev build` builds ONE entry: a shared dist/ means the second deck
// silently captures the first one's slides. The deck's own build dir is the only thing that
// makes this a pipeline rather than a one-off.
export async function capture(DIST) {
if (!existsSync(join(DIST, 'index.html'))) {
throw new Error(`no hay build en ${DIST} — ejecuta 'pnpm build' en outreach/presentation primero`)
throw new Error(`no hay build en ${DIST} — ejecuta: just deck-build <id>`)
}
const { server, port } = await serveDist()
const { server, port } = await serveDist(DIST)
const browser = await chromium.launch()
try {
const page = await browser.newPage({ viewport: { width: 1400, height: 900 } })

View file

@ -16,7 +16,7 @@
import { writeFile, mkdir } from 'node:fs/promises'
import { readFile } from 'node:fs/promises'
import { basename, join } from 'node:path'
import { DIST, postcss, publicBase, publicDir } from './paths.mjs'
import { postcss, publicBase, publicDir } from './paths.mjs'
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 '
+ '(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
@ -28,8 +28,8 @@ const LATIN = /^U\+0000-00FF/i
const LATIN_EXT = /^U\+0100-02BA/i
const wantedRange = (r) => LATIN.test(r.trim()) || LATIN_EXT.test(r.trim())
export async function fetchFonts(id, { apply = true } = {}) {
const index = await readFile(join(DIST, 'index.html'), 'utf8')
export async function fetchFonts(id, { apply = true, dist } = {}) {
const index = await readFile(join(dist, 'index.html'), 'utf8')
const href = index.match(/<link[^>]+href="(https:\/\/fonts\.googleapis\.com[^"]+)"/)?.[1]
if (!href) return { skipped: 'el build no enlaza Google Fonts', css: '', files: [] }

View file

@ -12,7 +12,7 @@ const HERE = dirname(fileURLToPath(import.meta.url)) // outreach/script
export const OUTREACH = resolve(HERE, '../../..') // outreach/
export const PRESENTATION = resolve(OUTREACH, 'presentation')
export const SITE = resolve(OUTREACH, 'site/site') // content/ + public/ live here
export const DIST = resolve(PRESENTATION, 'dist')
export const distDir = (id) => resolve(PRESENTATION, 'builds', id) // one build per deck
const require_ = createRequire(resolve(PRESENTATION, 'package.json'))
export const postcss = require_('postcss')
@ -20,4 +20,7 @@ export const { chromium } = require_('playwright-chromium')
export const publicBase = (id) => `/images/decks/${id}`
export const publicDir = (id) => resolve(SITE, `public/images/decks/${id}`)
export const contentFile = (lang, id) => resolve(SITE, `content/resources/${lang}/decks/${id}.md`)
// Category segment is localized (see decks.ncl `category`): ES decks live under
// content/resources/es/presentaciones/, EN under en/decks/. The loader resolves the body by
// {lang}/{category}/{id}.md, so the DIRECTORY must match the URL category, not only the frontmatter.
export const contentFile = (lang, id, category = 'decks') => resolve(SITE, `content/resources/${lang}/${category}/${id}.md`)

View file

@ -4,29 +4,89 @@
import { writeFile, mkdir, copyFile } from 'node:fs/promises'
import { existsSync } from 'node:fs'
import { basename, join } from 'node:path'
import { DIST, postcss, publicBase, publicDir } from './paths.mjs'
import { PRESENTATION, postcss, publicBase, publicDir } from './paths.mjs'
export async function projectAssets(html, css, id, { apply = true } = {}) {
export async function projectAssets(html, css, id, { apply = true, dist } = {}) {
const DIST = dist
const base = `${publicBase(id)}/assets`
const wanted = new Map()
const unresolved = []
// Where a deck asset can live, in resolution order:
// 1. the BUILD dir — everything Vite bundled + the root of the Slidev publicDir;
// 2. presentation/public — the configured publicDir, whose subtrees (images/) Slidev does
// NOT always copy into the build;
// 3. presentation/ — the deck's own dir, because a per-slide `background: ./images/foo.jpg`
// is relative to the MARKDOWN FILE, and `./images/` there means presentation/images/.
// Miss any of these and the background vanishes with no error — the projection just emits a
// dead path. Measured on Rustikon: 9 of 11 backgrounds lived at (1), 2 only at (3).
const ROOTS = [DIST, join(PRESENTATION, 'public'), PRESENTATION]
// A ref lifted from a serialized DOM attribute is HTML-encoded: a Vue-computed
// `background: url('/arch-diag-v2.svg')` comes back as url(&quot;/arch-diag-v2.svg&quot;).
// Decode the entities BEFORE stripping quotes, or the ref starts with `&` instead of a path.
const decode = (s) => s
.replace(/&quot;|&#0*34;/gi, '"')
.replace(/&apos;|&#0*39;/gi, "'")
.replace(/&amp;/gi, '&')
const claim = (ref) => {
const clean = ref.trim().replace(/^["']|["']$/g, '')
const clean = decode(ref.trim()).trim().replace(/^["']|["']$/g, '')
if (/^(data:|https?:|#|mailto:)/i.test(clean)) return null // external / inline: not ours
if (!clean.startsWith('/')) { unresolved.push({ ref: clean, why: 'referencia relativa, sin base' }); return null }
const onDisk = join(DIST, clean)
if (!existsSync(onDisk)) { unresolved.push({ ref: clean, why: `no está en dist/` }); return null }
// Slidev backgrounds arrive as `/foo.jpg`, `./foo.jpg`, `./images/foo.jpg` or bare `foo.jpg`
// — all rooted at the deck's public dir. Normalize to a single relative path and look for it.
const rel = clean.replace(/^\.?\//, '')
if (!rel || rel.includes('..')) { unresolved.push({ ref: clean, why: 'ref inválida' }); return null }
const pub = `${base}/${basename(clean)}`
let onDisk = null
for (const root of ROOTS) {
const cand = join(root, rel)
if (existsSync(cand)) { onDisk = cand; break }
}
if (!onDisk) { unresolved.push({ ref: clean, why: 'no está en el build ni en presentation/public' }); return null }
const pub = `${base}/${basename(rel)}`
wanted.set(clean, { onDisk, pub })
return pub
}
// Presentational width/height attributes (`<img width="120">`) → inline style. They are the
// author's sizing intent, but they are AUTHOR-origin presentational HINTS, and the scoped
// bundle's `all: revert` guard wipes the whole author origin — so the hint vanishes and the
// image balloons to its intrinsic size. Measured: ferris-heart.svg at width="120" rendered at
// 4417px and bled the slide to 1169px; failed.png at width="100" filled the frame. The deck's
// real styles survive the guard because they are `.deck-read …` rules that re-apply after it;
// an attribute is not a rule and nothing restores it. An INLINE style does — it outranks any
// non-important rule, `all: revert` included. Utility classes (w-40, -mt-5) are untouched: they
// are rules in the bundle, not attributes.
html = html.replace(/<img\b[^>]*>/gi, (tag) => {
const w = tag.match(/\bwidth\s*=\s*["']?([\d.]+%?)["']?/i)?.[1]
const h = tag.match(/\bheight\s*=\s*["']?([\d.]+%?)["']?/i)?.[1]
if (!w && !h) return tag
const sm = tag.match(/\bstyle\s*=\s*"([^"]*)"/i)
const existing = sm ? sm[1] : ''
const add = []
if (w && !/\bwidth\s*:/i.test(existing)) add.push(`width:${w.endsWith('%') ? w : w + 'px'}`)
if (h && !/\bheight\s*:/i.test(existing)) add.push(`height:${h.endsWith('%') ? h : h + 'px'}`)
if (!add.length) return tag
const merged = [add.join(';'), existing].filter(Boolean).join(';') // attr first, existing style wins
return sm ? tag.replace(sm[0], `style="${merged}"`) : tag.replace(/<img\b/i, `<img style="${merged}"`)
})
html = html
.replace(/(<img[^>]+src=")([^"]+)(")/g, (m, a, ref, z) => { const p = claim(ref); return p ? a + p + z : m })
.replace(/(background-image:\s*url\()([^)]+)(\))/g, (m, a, ref, z) => { const p = claim(ref); return p ? a + p + z : m })
// ALL url(...), not only the one glued to `background-image:` — Slidev emits
// `background-image: linear-gradient(...), url(...)`, where the url is after the gradient,
// so an anchored regex missed every per-slide background. Anchored at `url(` so the
// gradient's own parens are never touched, and the whole inner is handed to claim(), which
// already decodes entities and strips quotes.
//
// Re-emit with HTML-ENCODED quotes (&quot;), the exact form Slidev used. This is inside a
// double-quoted style="" attribute: writing literal url("…") closes the attribute at the
// first inner " and the browser parses the path as empty — the served HTML looked right in
// curl and computed to url("") in the browser. Measured on every Rustikon background.
.replace(/url\(([^)]*)\)/gi, (m, inner) => { const p = claim(inner); return p ? `url(&quot;${p}&quot;)` : m })
const root = postcss.parse(css)
root.walkDecls(d => {

View file

@ -16,7 +16,8 @@ import { scopeCss, pruneUnused } from './scope-css.mjs'
import { projectAssets } from './project-assets.mjs'
import { fetchFonts } from './fetch-fonts.mjs'
import { buildBody } from './build-body.mjs'
import { OUTREACH, SITE, publicBase, publicDir, contentFile } from './paths.mjs'
import { readAnchors } from './anchors.mjs'
import { OUTREACH, distDir, publicBase, publicDir, contentFile } from './paths.mjs'
const DRY = process.argv.includes('--dry-run')
const apply = !DRY
@ -24,7 +25,7 @@ const apply = !DRY
const manifest = JSON.parse(
execFileSync('nickel', ['export', '--format', 'json', join(OUTREACH, 'scripts/decks/decks.ncl')], { encoding: 'utf8' })
)
const { boundary_allow: ALLOW, slide: { width: W, height: H }, i18n: I18N } = manifest
const { boundary_allow: ALLOW, slide: { width: W, height: H }, i18n: I18N, category: CATEGORY } = manifest
// The generated body is fenced. Anything outside the markers is the author's and is left
// untouched — the frontmatter above all, which carries title, slug, thumbnail and og_image.
@ -39,18 +40,43 @@ function replaceBody(existing, generated) {
const report = []
for (const deck of manifest.decks) {
const { id, pages } = deck
// A deck may have one source per language. Group its pages by the source they actually come
// from: each distinct source is its own build, its own capture and its own asset dir. Sharing a
// capture across two sources is how an English URL ends up serving Spanish slides.
function variants(deck) {
const by = new Map()
for (const p of deck.pages) {
const source = p.source ?? deck.source
if (!by.has(source)) by.set(source, [])
by.get(source).push(p)
}
// One source → the deck's own id. Several → one asset dir per source, keyed by its languages,
// because the CSS, the fonts and the images of two different decks are two different things.
return [...by.entries()].map(([source, pages]) => ({
source,
pages,
key: by.size === 1 ? deck.id : `${deck.id}-${pages.map(p => p.lang).join('-')}`,
}))
}
const cap = await capture()
for (const deck of manifest.decks) {
for (const variant of variants(deck)) {
const { key: id, pages } = variant
const dist = distDir(id)
const cap = await capture(dist)
// Las anclas salen de la FUENTE (el frontmatter de cada slide), no de la captura: es lo único
// que sobrevive a que el deck se reordene. Por variante, porque una traducción es OTRA fuente
// — y sus anclas son las suyas, aunque el deck sea el mismo.
const anchors = await readAnchors(variant.source)
const { css: prunedCss, pruned, usesKatex, usesTwoslash } = pruneUnused(cap.css, cap.html)
const assets = await projectAssets(cap.html, prunedCss, id, { apply })
const fonts = await fetchFonts(id, { apply })
const assets = await projectAssets(cap.html, prunedCss, id, { apply, dist })
const fonts = await fetchFonts(id, { apply, dist })
const { css: scoped, stats } = scopeCss(assets.css, { scope: '.deck-read', allow: ALLOW })
// One capture, N pages. The deck BODY is shared — it is not translated — but the chrome
// around it is the site's UI and speaks the page's language, so it is built per page.
const views = Object.fromEntries(pages.map(p => [p.lang,
buildBody(assets.html, { id, count: cap.slideCount, W, H, t: I18N[p.lang] })]))
buildBody(assets.html, { id, count: cap.slideCount, W, H, t: I18N[p.lang], anchors })]))
const view = views[pages[0].lang]
// Syntax-check what we emit. A code generator can write an INVALID file and nobody
@ -92,7 +118,9 @@ for (const deck of manifest.decks) {
await writeFile(join(publicDir(id), 'deck.css'), bundle)
await writeFile(join(publicDir(id), 'deck.js'), view.js)
for (const p of pages) {
const file = contentFile(p.lang, id)
// The page file is named by the DECK id (one page per language, per deck). The assets are
// keyed by the VARIANT, because two sources are two different decks' worth of CSS.
const file = contentFile(p.lang, deck.id, CATEGORY[p.lang])
const existing = await readFile(file, 'utf8')
await writeFile(file, replaceBody(existing, bodyFor(views[p.lang])))
}
@ -107,8 +135,10 @@ for (const deck of manifest.decks) {
guard: `${stats.resetsExempted} resets exentos · ${stats.viewportPropsDropped} props de viewport descartadas`,
assets: assets.copied.length,
fuentes: `${fonts.files.length} woff2` + (fonts.notServed?.length ? ` · SIN SERVIR: ${fonts.notServed.join(', ')}` : ''),
fuente: variant.source,
hojasExternas: cap.externalSheets,
})
}
}
console.log(JSON.stringify({ modo: DRY ? 'DRY RUN — no se ha escrito nada' : 'aplicado', decks: report }, null, 2))

131
scripts/decks/new-deck.nu Normal file
View file

@ -0,0 +1,131 @@
#!/usr/bin/env nu
# Register a new Slidev deck as a site page.
#
# The projection REPLACES a page's body and PRESERVES its frontmatter — so the page has to exist
# first, and it must carry title, slug, thumbnail and og_image. That is the one thing the
# projection cannot invent, and the one step nobody remembers: without it, `just decks` fails with
# "la página de destino no tiene frontmatter; no la sobrescribo a ciegas".
#
# This scaffolds those pages and prints the decks.ncl entry to add. It deliberately does NOT edit
# decks.ncl: that file is the manifest a human curates (which deck, which slugs, which language),
# and a script that rewrites a manifest is a script that decides what should be published.
#
# Usage (from outreach/):
# nu scripts/decks/new-deck.nu rustikon.md rustikon-why-i-needed-rust \
# --title-es "Rustikon: por qué necesité Rust" --slug-es rustikon-por-que-necesite-rust \
# --title-en "Rustikon: Why I Needed Rust" --slug-en rustikon-why-i-needed-rust
def outreach [] { $env.FILE_PWD | path dirname | path dirname }
def main [
source: string # the Slidev md, relative to outreach/presentation/
id: string # the deck id: file name of the pages, and the asset dir
--title-es: string = "" # omite el par title/slug de un idioma y ese idioma NO tendrá página
--slug-es: string = ""
--title-en: string = ""
--slug-en: string = ""
--source-en: string = "" # fuente PROPIA de la página EN cuando el deck es bilingüe nativo
# (dos charlas reales, no una traducción): p. ej. rustikon.md (EN)
# + rustikon_es.md (ES). Omítelo si una sola fuente sirve ambos idiomas.
--date: string = "" # ISO; defaults to today
--author: string = "Jesús Pérez"
] {
let src = [(outreach) "presentation" $source] | path join
if not ($src | path exists) { error make { msg: $"no existe el deck: ($src)" } }
# A separate EN source must exist and must actually differ — a page's `source` equal to the
# deck's is just noise in the manifest, and a non-existent one fails the projection later, so
# catch both here where the message can name the file.
if (not ($source_en | is-empty)) {
let esrc = [(outreach) "presentation" $source_en] | path join
if not ($esrc | path exists) { error make { msg: $"no existe la fuente EN: ($esrc)" } }
if ($source_en == $source) { error make { msg: "--source-en es igual a la fuente del deck; omítelo" } }
}
let when = if ($date | is-empty) { (date now | format date "%Y-%m-%d") } else { $date }
# `source` per page: EN uses --source-en when given, otherwise the deck's own. Only a source
# that DIFFERS from the deck default is emitted in the manifest below.
let pages = [
{ lang: "es", title: $title_es, slug: $slug_es, source: $source }
{ lang: "en", title: $title_en, slug: $slug_en, source: (if ($source_en | is-empty) { $source } else { $source_en }) }
]
# A language with no title/slug gets NO page. A deck that exists only in Spanish must not have
# an English URL serving Spanish slides: a page that lies about its language is worse than a
# page that does not exist.
let wanted = ($pages | where {|p| not (($p.title | is-empty) or ($p.slug | is-empty)) })
if ($wanted | is-empty) { error make { msg: "necesitas al menos --title-es/--slug-es o --title-en/--slug-en" } }
# The URL category is localized (decks.ncl `category`): ES → presentaciones, EN → decks. The
# loader resolves the body by {lang}/{category}/{id}.md, so BOTH the directory and the
# frontmatter category must be the localized value, or the ES page 404s.
let cat = (^nickel export --format json ([(outreach) "scripts" "decks" "decks.ncl"] | path join)
| from json | get category)
for p in $wanted {
let category = ($cat | get $p.lang)
let dir = [(outreach) "site" "site" "content" "resources" $p.lang $category] | path join
let file = [$dir $"($id).md"] | path join
if ($file | path exists) {
print $"(ansi yellow)ya existe, no lo toco: ($file)(ansi reset)"
continue
}
mkdir $dir
# Only the frontmatter. The body is the projection's, and it is fenced by its own markers —
# anything written here below the frontmatter would be overwritten on the first `just decks`.
$"---
id: \"($id)\"
title: \"($p.title)\"
slug: \"($p.slug)\"
subtitle: \"\"
excerpt: \"\"
author: \"($author)\"
date: \"($when)\"
published: true
featured: false
category: \"($category)\"
tags: [\"ontoref\", \"slides\"]
# Card + share image. The webp carousel is no longer the CONTENT — the slides carry real text
# now — but slide 0 still makes the best thumbnail.
thumbnail: \"/images/decks/($id)/0.webp\"
image_url: \"/images/decks/($id)/0.webp\"
og_image: \"/images/og/decks-($id)-0.png\"
sort_order: 1
---
" | save --force $file
print $"(ansi green)creada:(ansi reset) ($file)"
}
# Emit `source = …` per page ONLY where it differs from the deck default — that is exactly the
# bilingual-native case (rustikon.md + rustikon_es.md), and it is the line deck-new used to make
# you add by hand.
let entries = ($wanted | each {|p|
if ($p.source == $source) {
$" { lang = \"($p.lang)\", slug = \"($p.slug)\" },"
} else {
$" { lang = \"($p.lang)\", slug = \"($p.slug)\", source = \"($p.source)\" },"
}
} | str join "\n")
print $"
(ansi cyan)Añade esta entrada a scripts/decks/decks.ncl(ansi reset) — el manifiesto lo curas tú,
no un script: qué deck se publica, con qué slug y en qué idioma es una decisión, no un derivado.
{
source = \"($source)\",
id = \"($id)\",
pages = [
($entries)
],
},
(ansi cyan)Y luego:(ansi reset)
just decks # build + captura + proyección + puertas de artefacto
just decks-served # las puertas sobre la PÁGINA servida
"
}

View file

@ -0,0 +1,27 @@
# Contact form. Migrated to the declarative handler, and the migration fixes a live defect:
# `contact.j2` posted to `/api/contact`, which answers 403 — the handler had moved to
# `/api/htmx/contact` and the template never followed. The handler's own doc comment says so
# ("replaces the previously dead POST /api/contact target in the contact template"). The endpoint
# moved, the template did not, and nothing checked. The contact form has been dead.
#
# Declared here it is served by the generic handler at POST /api/htmx/form/contact, and there is
# no longer a per-form endpoint that can drift away from its template.
let f = import "forms/contracts.ncl" in
f.make_form {
id = "contact",
deliver_to = "CONTACT_RECIPIENT",
subject = "Contact: {subject}",
success_key = "contact-success",
error_key = "contact-error",
fields = [
{ name = "name", kind = 'Text, required = true, label_key = "contact-name-label", hint_key = "contact-name-placeholder" },
{ name = "email", kind = 'Email, required = true, label_key = "contact-email-label", hint_key = "contact-email-placeholder" },
{ name = "subject", kind = 'Text, label_key = "contact-subject-label" },
{ name = "message", kind = 'Textarea, required = true, label_key = "contact-message-label", hint_key = "contact-message-placeholder" },
{ name = "website", kind = 'Honeypot, deliver = false },
],
}

View file

@ -0,0 +1,55 @@
# Work request — "Solicitar Cotización de Proyecto". A PUBLIC LEAD-GEN FORM with eleven fields.
#
# It has had a template for a long time and NO HANDLER: `POST /api/work-request` is registered
# nowhere in the stack, so every submission has gone to a route that does not exist. The page
# renders, so nothing ever looked wrong.
#
# Declared here, it is served by the generic handler (`POST /api/htmx/form/work-request`). No Rust,
# no build. Every field below is DELIVERED — which is the whole point: routing this form through
# the four-field contact endpoint would have dropped nine of eleven fields in silence, because
# serde ignores unknown fields by default.
let f = import "forms/contracts.ncl" in
f.make_form {
id = "work-request",
deliver_to = "WORK_REQUEST_RECIPIENT",
subject = "Work request: {project_type}",
success_key = "work-request-success",
error_key = "work-request-error",
fields = [
{ name = "name", kind = 'Text, required = true,
label_key = "work-request-name-label", hint_key = "work-request-name-placeholder" },
{ name = "email", kind = 'Email, required = true,
label_key = "work-request-email-label", hint_key = "work-request-email-placeholder" },
{ name = "company", kind = 'Text,
label_key = "work-request-company-label" },
{ name = "project_type", kind = 'Select,
label_key = "work-request-project-type-label",
# Labels are `work-request-project-type-label-<value>` in Fluent.
options = ["web", "infrastructure", "consulting", "other"] },
{ name = "project_description", kind = 'Textarea, required = true,
label_key = "work-request-description-label" },
{ name = "current_situation", kind = 'Textarea,
label_key = "work-request-current-situation-label" },
{ name = "goals", kind = 'Textarea,
label_key = "work-request-goals-label" },
{ name = "technical_requirements", kind = 'Textarea,
label_key = "work-request-technical-label" },
{ name = "timeline", kind = 'Text,
label_key = "work-request-timeline-label" },
{ name = "budget", kind = 'Text,
label_key = "work-request-budget-label" },
{ name = "contact_method", kind = 'Text,
label_key = "work-request-contact-method-label" },
# Bots fill every field they can see. This one is hidden; a non-empty value means a bot, and
# the handler confirms without delivering so it gets no signal.
{ name = "website", kind = 'Honeypot, deliver = false },
],
}

View file

@ -456,6 +456,35 @@ let { make_route, make_content_route, make_external, priorities, orders, no_menu
},
},
# La nota sobre el idioma. NO es un aviso legal: es el contrato de estilo del español de este
# sitio, y por eso comparte columna con los otros dos sin fingir que obliga a nada — la columna
# se llama "Condiciones y estilo", que es lo que de verdad reúne.
#
# `paths` declara LOS DOS idiomas porque la página existe en los dos (venía de routes/en.ncl y
# routes/es.ncl). Un `paths` con solo `es` la deja en 404 para todo el que no traiga cookie: el
# idioma se resuelve ANTES de buscar la ruta, y sin cookie ese idioma es inglés — la página solo
# española resultaba inalcanzable justo para quien llega de fuera.
#
# `labels`, en cambio, SÍ es solo `es`: el shell recorre los idiomas que labels declara, de modo
# que el enlace del footer aparece únicamente en español. La página inglesa sigue existiendo y
# sirviéndose; simplemente no se anuncia en un footer inglés donde no tendría a qué apelar.
#
# Se declara AQUÍ y no en `routes/{en,es}.ncl` (donde estaba) porque el footer del shell htmx lee
# los bloques `menu` de ESTE fichero; `menu_group` es la vía de Leptos y el shell htmx no la
# mira. Declarada allí, la página existía y respondía 200, y ningún menú podía enlazarla.
make_route "LenguajePage" { en = "/language", es = "/sobre-el-idioma" }
& {
priority = priorities.minimal,
menu = {
enabled = true,
order = 3,
icon = "fas fa-language",
label_key = "nav-lenguaje",
labels = { es = "Sobre el idioma" },
use_in = ["footer"],
},
},
# ── User (not in sitemap) ─────────────────────────────────────────────────
make_route "UserPage" { en = "/user", es = "/usuario" }
& {

View file

@ -0,0 +1,38 @@
# Routes THIS site contributes, in English. See es.ncl for the full rationale: the shared image owns
# what every site serves; a site owns what only it serves. NCL, not raw TOML — the consumer's data
# belongs inside a contract, same as the image's.
{
routes = [
{
path = "/language",
component = "LenguajePage",
title_key = "lenguaje-page-title",
language = "en",
enabled = true,
description_key = "lenguaje-page-description",
priority = 0.3,
menu_group = "footer",
menu_order = 3,
menu_icon = "fas fa-language",
show_in_sitemap = true,
},
# Conceptos clave — el glosario, proyectado del registro de términos (gen-conceptos.nu →
# conceptos.ftl + site/_htmx/templates/pages/concepts.j2). Servido, no pintado con JS: un
# glosario que un rastreador no puede leer renuncia a la mitad de para lo que existe.
#
# LA RUTA VA AQUÍ, no en routes.ncl. Se declaró primero allí y la página dio 404 — exactamente
# lo que el comentario de arriba avisa: `resolve_static_page` solo alcanza las rutas que aporta
# el site. Dos mecanismos, cada uno con una mitad, y el howell que lo explica estaba caducado.
{
path = "/concepts",
component = "ConceptosPage",
title_key = "conceptos-page-title",
language = "en",
enabled = true,
description_key = "conceptos-page-description",
priority = 0.5,
show_in_sitemap = true,
},
],
}

View file

@ -0,0 +1,66 @@
# Rutas que ESTE site aporta, en español. Fusionadas sobre las embebidas en la imagen por
# `merge_site_routes`, leídas al arrancar desde el directorio que nombra RUSTELO_SITE_ROUTES.
# En una colisión de ruta gana el site: está más cerca de su propia verdad que la imagen compartida.
#
# EN NCL, Y NO EN TOML CRUDO. La primera versión de esto era TOML sin esquema — es decir, el dato
# del consumidor FUERA del contrato que sí gobierna las rutas de la imagen. Un `component` mal
# escrito habría sido un 404 silencioso, comprobado por nadie. Poner el dato fuera de su contrato
# es exactamente el defecto que esta costura vino a cerrar, y yo lo había reintroducido al cerrarlo.
#
# El runtime ya sabe leer NCL: `rustelo_config::format::load_config` despacha por extensión, y el
# shell htmx carga `routes.ncl` y `footer.ncl` así en cada arranque. No es una dependencia nueva.
#
# LA GUARDA: el componente debe terminar en `Page`. En htmx, `resolve_static_page` sirve cualquier
# `*Page` genéricamente desde el registro (deriva `LenguajePage` → `lenguaje` y recoge la plantilla
# que el site trae en `site/_htmx/templates/pages/lenguaje.j2`). Cualquier otro nombre es un componente
# COMPILADO, y un consumidor no compila el binario: el runtime lo rechaza al arrancar, con un error
# en el log, en vez de dar un 404 que la config había prometido.
#
# Comprobar: ore rustelo routes --check
{
routes = [
# Nota sobre el lenguaje. No es un aviso legal — es un contrato de estilo, y es la única página
# de este sitio cuyo contenido se GENERA desde el registro de términos que además revisa cada
# texto en español antes de publicarlo (`scripts/gen-nota-lenguaje.nu` → el .ftl y el .j2; su
# `--check` rompe el build si la página deja de coincidir con la regla que dice explicar).
#
# EL MENÚ NO SE DECLARA AQUÍ. `menu_group` es la vía que lee Leptos; el shell htmx —el que sirve
# este sitio— construye sus menús leyendo los bloques `menu { use_in = [...] }` de `routes.ncl`.
# La entrada del footer vive allí (`make_route "LenguajePage"`), y la RUTA vive aquí, porque
# `resolve_static_page` solo alcanza a las rutas que aporta el site: declarada solo en
# `routes.ncl`, la página daba 404 — el componente no está compilado y nadie la renderizaba.
# Dos mecanismos, cada uno con una mitad; hasta que el shell lea también estas rutas para los
# menús, la declaración está partida a propósito y este comentario es la costura.
{
path = "/sobre-el-idioma",
component = "LenguajePage",
title_key = "lenguaje-page-title",
language = "es",
enabled = true,
description_key = "lenguaje-page-description",
priority = 0.3,
menu_group = "footer",
menu_order = 3,
menu_icon = "fas fa-language",
show_in_sitemap = true,
},
# Conceptos clave — el glosario, proyectado del registro de términos (gen-conceptos.nu →
# conceptos.ftl + site/_htmx/templates/pages/conceptos.j2). Servido, no pintado con JS: un
# glosario que un rastreador no puede leer renuncia a la mitad de para lo que existe.
#
# LA RUTA VA AQUÍ, no en routes.ncl. Se declaró primero allí y la página dio 404 — exactamente
# lo que el comentario de arriba avisa: `resolve_static_page` solo alcanza las rutas que aporta
# el site. Dos mecanismos, cada uno con una mitad, y el howell que lo explica estaba caducado.
{
path = "/conceptos",
component = "ConceptosPage",
title_key = "conceptos-page-title",
language = "es",
enabled = true,
description_key = "conceptos-page-description",
priority = 0.5,
show_in_sitemap = true,
},
],
}