ontoref-outreach/scripts/decks/lib/project.mjs

115 lines
5.7 KiB
JavaScript
Raw Normal View History

decks: the slides stop being pixels — Slidev's own render, captured and re-homed The published deck was a hand-written carousel of exported PNGs: the words of every slide were an IMAGE of words. Nothing indexed them, no glossary tooltip could reach them, no screen reader read them, and the page drifted from the source the moment a slide changed. The deck is NOT re-parsed. Slidev's markdown is not standard markdown, and its meaning does not live in the markdown anyway — it lives in a compiled bundle of UnoCSS utilities, theme rules and <style scoped> blocks. Re-implementing that is re-implementing a renderer, in any language. So Slidev renders (its own /print route, the one `slidev export` drives) and we capture what it produced: the DOM, and the CSS the browser actually applied. Measured on the served page: 0 → 2152 indexable words, 16/16 slides reachable, 0 third-party origins added, 0 broken references. Twelve traps, none of which raised an error: · The CSS must be asked of the BROWSER (17 stylesheets; index.html links 2). · Collapsing :root/html/body imports the SPA's viewport cage — it clipped the deck at 900px and swallowed 14 of 16 slides, silently. · UnoCSS's preflight, scoped, kills the glossary underline it was supposed to enable. · :not() inherits its argument's specificity — the boundary guard grew with every exemption until it reverted the slide scaling. :not(:where(…)) contributes zero. · scale(calc(100cqw / 980)) is invalid CSS and is dropped in silence; on a phone the box was 328px while the content stayed 980px and two thirds of every slide was cut. · The site's markdown renderer destroys an inline <script>: curly quotes, injected <p>, &amp;&amp;. CSS and JS go to external files — correctness, not optimisation. · The 980x552 frame CUT 12 of 16 slides (Slidev cuts them too; nobody had seen it, because the slides were PNGs of themselves). · transform: translateY(0) — an IDENTITY transform — makes <main> the containing block for position:fixed, and the zoom modal landed at top = -221px. Two views over one DOM (slider default, stacked reads with Ctrl+F), an index derived from each slide's own H1, a sticky rail, zoom, and Slidev's own copy buttons revived — they shipped in the captured DOM at 0x0, unclickable by any human. Twelve gates (A–L), and IN THE CHAIN: `just decks` in sync-full, `decks-check` in `check` (on disk), `decks-served` in `check-live` (the page). A generator outside the chain is a generator that does not run.
2026-07-14 19:41:51 +01:00
// Drive the whole projection: capture → prune → assets → fonts → scope → body → write.
//
// Usage: node lib/project.mjs [--dry-run]
//
// Reads decks.ncl. Writes, per deck id:
// site/public/images/decks/<id>/deck.css scoped bundle + self-hosted @font-face
// site/public/images/decks/<id>/assets/* images the slides reference
// site/public/images/decks/<id>/fonts/* woff2, latin + latin-ext
// site/content/resources/<lang>/decks/<id>.md body replaced, FRONTMATTER PRESERVED
import { readFile, writeFile, mkdir } from 'node:fs/promises'
import { execFileSync } from 'node:child_process'
import vm from 'node:vm'
import { join, dirname } from 'node:path'
import { capture } from './capture.mjs'
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'
const DRY = process.argv.includes('--dry-run')
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
// 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.
const BEGIN = '<!-- BEGIN deck-projection — generado por scripts/decks; no editar a mano -->'
const END = '<!-- END deck-projection -->'
function replaceBody(existing, generated) {
const fm = existing.match(/^---\n[\s\S]*?\n---\n/)
if (!fm) throw new Error('la página de destino no tiene frontmatter; no la sobrescribo a ciegas')
return fm[0] + '\n' + BEGIN + '\n' + generated + '\n' + END + '\n'
}
const report = []
for (const deck of manifest.decks) {
const { id, pages } = deck
const cap = await capture()
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 { 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] })]))
const view = views[pages[0].lang]
// Syntax-check what we emit. A code generator can write an INVALID file and nobody
// notices: it lands on disk, the server returns 200, and only the browser rejects it —
// in a console no one is reading. Measured: an escaped \n inside the JS template literal
// became a real newline inside a regex, deck.js stopped parsing, and every control on the
// page went dead while the deck still rendered perfectly. That is the generator's bug,
// and it must break AT GENERATION, not in the reader's browser.
try {
new vm.Script(view.js, { filename: `${id}/deck.js` })
} catch (e) {
throw new Error(`el deck.js generado para ${id} no parsea: ${e.message}`)
}
const bundle = [fonts.css, scoped, view.css].filter(Boolean).join('\n')
// CSS and JS go to EXTERNAL files, referenced from the body.
//
// Not an optimisation — a correctness requirement. An inline <script> inside a markdown
// body is handed to a typographer: measured on the real site, the renderer injected <p>
// at the first blank line, turned every ' into a curly ', escaped && into &amp;&amp;,
// and promoted an indented block to <pre><code>. The result was `SyntaxError:
// Unexpected token '<'` and a deck that rendered perfectly and did nothing. The fix is
// not to escape the JS — that is fighting the renderer — but to take it out of the
// markdown. Cacheability is the bonus.
//
// A <link>/<script src> in the BODY (rather than the head) is what the content pipeline
// allows: a markdown page cannot reach <head>. Browsers honour both. The cleaner route —
// frontmatter declaring assets that post.j2 hoists into <head> — needs a template change;
// it is recorded in the plan, not smuggled in here.
const bodyFor = (v) => [
`<link rel="stylesheet" href="${publicBase(id)}/deck.css">`,
v.html,
`<script src="${publicBase(id)}/deck.js" defer></script>`,
].join('\n')
if (apply) {
await mkdir(publicDir(id), { recursive: true })
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)
const existing = await readFile(file, 'utf8')
await writeFile(file, replaceBody(existing, bodyFor(views[p.lang])))
}
}
report.push({
id,
slides: cap.slideCount,
paginas: pages.map(p => `${p.lang}:/${p.lang === 'es' ? 'recursos' : 'resources'}/decks/${p.slug}`),
css: `${Math.round(bundle.length / 1024)} KB (${stats.rules} reglas)`,
podado: `katex ${pruned.katexRules}+${pruned.katexFontFace} · twoslash ${pruned.twoslashRules}`,
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(', ')}` : ''),
hojasExternas: cap.externalSheets,
})
}
console.log(JSON.stringify({ modo: DRY ? 'DRY RUN — no se ha escrito nada' : 'aplicado', decks: report }, null, 2))