// 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 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 { readAnchors } from './anchors.mjs' import { readManifest, variants } from './variants.mjs' import { distDir, publicBase, publicDir, contentFile } from './paths.mjs' const DRY = process.argv.includes('--dry-run') const apply = !DRY const manifest = readManifest() 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. 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) { 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, 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], anchors })])) 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) { // 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]))) } } 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(', ')}` : ''), fuente: variant.source, hojasExternas: cap.externalSheets, }) } } console.log(JSON.stringify({ modo: DRY ? 'DRY RUN — no se ha escrito nada' : 'aplicado', decks: report }, null, 2))