chore: scripts for decks
This commit is contained in:
parent
1faea7491c
commit
c0a31e4813
16 changed files with 930 additions and 55 deletions
|
|
@ -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.",
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
63
scripts/decks/lib/anchors.mjs
Normal file
63
scripts/decks/lib/anchors.mjs
Normal 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
|
||||
})
|
||||
}
|
||||
|
|
@ -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}">←</button>
|
||||
<button type="button" class="deck-nav" data-dir="1" aria-label="${t.next}">→</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
|
||||
|
|
|
|||
|
|
@ -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 } })
|
||||
|
|
|
|||
|
|
@ -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: [] }
|
||||
|
||||
|
|
|
|||
|
|
@ -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`)
|
||||
|
|
|
|||
|
|
@ -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 (`<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 ("), 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 => {
|
||||
|
|
|
|||
|
|
@ -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
131
scripts/decks/new-deck.nu
Normal 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
|
||||
"
|
||||
}
|
||||
27
site/site/config/forms/contact.ncl
Normal file
27
site/site/config/forms/contact.ncl
Normal 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 },
|
||||
],
|
||||
}
|
||||
55
site/site/config/forms/work-request.ncl
Normal file
55
site/site/config/forms/work-request.ncl
Normal 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 },
|
||||
],
|
||||
}
|
||||
|
|
@ -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" }
|
||||
& {
|
||||
|
|
|
|||
38
site/site/config/routes/en.ncl
Normal file
38
site/site/config/routes/en.ncl
Normal 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,
|
||||
},
|
||||
],
|
||||
}
|
||||
66
site/site/config/routes/es.ncl
Normal file
66
site/site/config/routes/es.ncl
Normal 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,
|
||||
},
|
||||
],
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue