From c0a31e4813b060e23fee5fca13af1c7c0a9188d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesu=CC=81s=20Pe=CC=81rez?= Date: Fri, 17 Jul 2026 01:00:32 +0100 Subject: [PATCH] chore: scripts for decks --- scripts/decks/decks.ncl | 68 ++++++- scripts/decks/gates/served.mjs | 68 +++++++ scripts/decks/gen-deck-pages.nu | 62 +++++-- scripts/decks/lib/anchors.mjs | 63 +++++++ scripts/decks/lib/build-body.mjs | 228 ++++++++++++++++++++++-- scripts/decks/lib/capture.mjs | 13 +- scripts/decks/lib/fetch-fonts.mjs | 6 +- scripts/decks/lib/paths.mjs | 7 +- scripts/decks/lib/project-assets.mjs | 76 +++++++- scripts/decks/lib/project.mjs | 48 ++++- scripts/decks/new-deck.nu | 131 ++++++++++++++ site/site/config/forms/contact.ncl | 27 +++ site/site/config/forms/work-request.ncl | 55 ++++++ site/site/config/routes.ncl | 29 +++ site/site/config/routes/en.ncl | 38 ++++ site/site/config/routes/es.ncl | 66 +++++++ 16 files changed, 930 insertions(+), 55 deletions(-) create mode 100644 scripts/decks/lib/anchors.mjs create mode 100644 scripts/decks/new-deck.nu create mode 100644 site/site/config/forms/contact.ncl create mode 100644 site/site/config/forms/work-request.ncl create mode 100644 site/site/config/routes/en.ncl create mode 100644 site/site/config/routes/es.ncl 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(/]*class="print-slide-container"[^>]*)>/g, (_, attrs) => { const i = n++ - return `
` + // 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 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 ? `` : '' + return `
${named}` }) // 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 }) => ` + +
${body}
` 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
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 + 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
, 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 diff --git a/scripts/decks/lib/capture.mjs b/scripts/decks/lib/capture.mjs index e3bda79..adf5e78 100644 --- a/scripts/decks/lib/capture.mjs +++ b/scripts/decks/lib/capture.mjs @@ -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 `) } - 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 } }) diff --git a/scripts/decks/lib/fetch-fonts.mjs b/scripts/decks/lib/fetch-fonts.mjs index e1a9e14..0cd423b 100644 --- a/scripts/decks/lib/fetch-fonts.mjs +++ b/scripts/decks/lib/fetch-fonts.mjs @@ -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(/]+href="(https:\/\/fonts\.googleapis\.com[^"]+)"/)?.[1] if (!href) return { skipped: 'el build no enlaza Google Fonts', css: '', files: [] } diff --git a/scripts/decks/lib/paths.mjs b/scripts/decks/lib/paths.mjs index 284c919..1185059 100644 --- a/scripts/decks/lib/paths.mjs +++ b/scripts/decks/lib/paths.mjs @@ -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`) diff --git a/scripts/decks/lib/project-assets.mjs b/scripts/decks/lib/project-assets.mjs index d6caf1c..8f0ba0f 100644 --- a/scripts/decks/lib/project-assets.mjs +++ b/scripts/decks/lib/project-assets.mjs @@ -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("/arch-diag-v2.svg"). + // Decode the entities BEFORE stripping quotes, or the ref starts with `&` instead of a path. + const decode = (s) => s + .replace(/"|�*34;/gi, '"') + .replace(/'|�*39;/gi, "'") + .replace(/&/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 (``) → 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(/]*>/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(/]+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 ("), 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("${p}")` : m }) const root = postcss.parse(css) root.walkDecls(d => { diff --git a/scripts/decks/lib/project.mjs b/scripts/decks/lib/project.mjs index 59d98c0..b1fe306 100644 --- a/scripts/decks/lib/project.mjs +++ b/scripts/decks/lib/project.mjs @@ -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)) diff --git a/scripts/decks/new-deck.nu b/scripts/decks/new-deck.nu new file mode 100644 index 0000000..ec2cbb8 --- /dev/null +++ b/scripts/decks/new-deck.nu @@ -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 +" +} diff --git a/site/site/config/forms/contact.ncl b/site/site/config/forms/contact.ncl new file mode 100644 index 0000000..e66e53a --- /dev/null +++ b/site/site/config/forms/contact.ncl @@ -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 }, + ], +} diff --git a/site/site/config/forms/work-request.ncl b/site/site/config/forms/work-request.ncl new file mode 100644 index 0000000..7600806 --- /dev/null +++ b/site/site/config/forms/work-request.ncl @@ -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-` 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 }, + ], +} diff --git a/site/site/config/routes.ncl b/site/site/config/routes.ncl index eee442e..4b193a1 100644 --- a/site/site/config/routes.ncl +++ b/site/site/config/routes.ncl @@ -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" } & { diff --git a/site/site/config/routes/en.ncl b/site/site/config/routes/en.ncl new file mode 100644 index 0000000..e0e37b7 --- /dev/null +++ b/site/site/config/routes/en.ncl @@ -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, + }, + ], +} diff --git a/site/site/config/routes/es.ncl b/site/site/config/routes/es.ncl new file mode 100644 index 0000000..8899098 --- /dev/null +++ b/site/site/config/routes/es.ncl @@ -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, + }, + ], +}