123 lines
6.3 KiB
JavaScript
123 lines
6.3 KiB
JavaScript
// 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 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 = '<!-- 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) {
|
|
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 <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 &&,
|
|
// 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) {
|
|
// 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))
|