// 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//deck.css scoped bundle + self-hosted @font-face // site/public/images/decks//assets/* images the slides reference // site/public/images/decks//fonts/* woff2, latin + latin-ext // site/content/resources//decks/.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 = '' const END = '' 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 `, ].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))