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

78 lines
3.3 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
// Capture a built Slidev deck: its rendered DOM and the CSS actually in effect.
//
// The deck is NOT re-parsed. Slidev's markdown is not standard markdown (::right::,
// per-slide frontmatter, v-clicks) and its meaning does not live in the markdown at all
// — it lives in a compiled bundle of UnoCSS utilities, theme rules and <style scoped>
// blocks. Re-implementing that is re-implementing a renderer. So we let Slidev render,
// and take what it produced.
//
// The /print route lays every slide out at once with all clicks revealed — it is what
// `slidev export` drives internally.
//
// The CSS is read from document.styleSheets, not from disk: the build emits 17
// stylesheets, index.html links 2, and Vite loads the rest as route chunks. Picking
// files by hand is guesswork; the browser knows exactly what it applied, in cascade
// order. (Cross-origin sheets — Google Fonts — cannot be read this way and are reported,
// never silently dropped; fetch-fonts.mjs self-hosts them.)
import { createServer } from 'node:http'
import { readFile } from 'node:fs/promises'
import { existsSync } from 'node:fs'
import { extname, join, normalize } from 'node:path'
import { DIST, chromium } from './paths.mjs'
const MIME = {
'.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json',
'.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
'.webp': 'image/webp', '.gif': 'image/gif', '.woff': 'font/woff', '.woff2': 'font/woff2',
'.ttf': 'font/ttf',
}
async function serveDist() {
const server = createServer(async (req, res) => {
const p = join(DIST, normalize(new URL(req.url, 'http://x').pathname))
const file = existsSync(p) && extname(p) ? p : join(DIST, 'index.html')
res.setHeader('content-type', MIME[extname(file)] ?? 'application/octet-stream')
res.end(await readFile(file))
})
await new Promise(r => server.listen(0, r))
return { server, port: server.address().port }
}
export async function capture() {
if (!existsSync(join(DIST, 'index.html'))) {
throw new Error(`no hay build en ${DIST} — ejecuta 'pnpm build' en outreach/presentation primero`)
}
const { server, port } = await serveDist()
const browser = await chromium.launch()
try {
const page = await browser.newPage({ viewport: { width: 1400, height: 900 } })
await page.goto(`http://localhost:${port}/print`, { waitUntil: 'networkidle' })
await page.waitForTimeout(4000) // Shiki + fonts + click resolution
const out = await page.evaluate(() => {
const parts = [], external = []
for (const s of document.styleSheets) {
try {
parts.push([...s.cssRules].map(r => r.cssText).join('\n'))
} catch {
external.push(s.href) // cross-origin: reported, not dropped
}
}
const content = document.querySelector('#print-content')
const slides = content ? content.querySelectorAll('.print-slide-container') : []
return {
html: content?.innerHTML ?? '',
css: parts.join('\n'),
slideCount: slides.length,
externalSheets: external,
}
})
if (!out.slideCount) throw new Error('la ruta /print no produjo ninguna slide — ¿cambió Slidev?')
return out
} finally {
await browser.close()
server.close()
}
}