115 lines
5.7 KiB
JavaScript
115 lines
5.7 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 { 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 &&,
|
||
|
|
// 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))
|