ontoref-outreach/scripts/decks/lib/scope-css.mjs
Jesús Pérez da93544de3 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

116 lines
5.2 KiB
JavaScript

// Scope the captured Slidev bundle so it cannot reach out of the deck, and the site
// cannot reach in — except where the site is explicitly entitled to.
//
// Three traps, each one measured, each one silent if you get it wrong:
//
// 1. ROOT COLLAPSE. :root/html/body must be REPLACED by the scope, not prefixed
// (`.deck-read html{}` matches nothing, and dropping :root strips every custom
// property the deck resolves against). But collapsing them also imports what those
// selectors mean as a DOCUMENT: Slidev ships `html,body{height:100%;overflow:hidden}`
// — a full-viewport, non-scrolling SPA canvas. On an embedded block that is a cage:
// it clipped the deck at 900px and swallowed 14 of 16 slides, with no error.
//
// 2. THE INBOUND GUARD. UnoCSS's preflight normalizes UA defaults; it does not defend
// against the host page's own element rules (`code{background:#eee}` sails straight
// through). One `all: revert` closes the class — but it must NOT be blind: the site
// deliberately decorates deck text (glossary terms are the whole point).
//
// 3. SPECIFICITY. Write `:not(:where(a,b))`, never `:not(a):not(b)`. A plain :not()
// inherits its argument's specificity, so every entry added to the allow-list would
// raise the guard a notch and start outranking the projection's own rules — measured:
// two exemptions pushed it to (0,3,0) and it reverted the slide-scaling transform.
// :where() contributes zero, pinning the guard at (0,1,0) however long the list grows.
import { postcss } from './paths.mjs'
const { list } = postcss
const ROOT_RE = /^(:root|html|body|html\s+body|html:root)$/i
// Only the document root can meaningfully claim the viewport. An embedded block cannot.
const VIEWPORT_PROPS =
/^(height|min-height|max-height|width|min-width|max-width|overflow(-[xy])?|position|top|right|bottom|left|inset)$/i
// Reset rules are exactly those whose rightmost compound is a universal or a bare tag.
// Real deck styling always carries a class and never targets an allow-listed element.
const BARE_COMPOUND = /(^|[\s>+~])(\*|[a-zA-Z][\w-]*)$/
export function scopeCss(css, { scope = '.deck-read', allow = [] } = {}) {
const exempt = allow.length ? `:not(:where(${allow.join(',')}))` : ''
const stats = { rules: 0, rootCollapsed: 0, darkCollapsed: 0, viewportPropsDropped: 0, resetsExempted: 0 }
const scopeSelector = (sel) => {
const s = sel.trim()
if (s.startsWith(scope)) return s
if (ROOT_RE.test(s)) { stats.rootCollapsed++; return scope }
// `html.dark` / `.dark x` → the wrapper carries `dark`. Preserving the distinction
// (rather than force-enabling dark) keeps the bundle's own light/dark cascade intact
// and independent of whatever theme the site is in.
const dark = s.match(/^(?:html)?\.dark\b(.*)$/i)
if (dark) {
stats.darkCollapsed++
const rest = dark[1].trim()
return rest ? `${scope}.dark ${rest}` : `${scope}.dark`
}
const rooted = s.match(/^(?:html|body)\s+(.+)$/i)
if (rooted) { stats.rootCollapsed++; return `${scope} ${rooted[1]}` }
return `${scope} ${s}`
}
const exemptResets = (sel) => {
if (!exempt || !BARE_COMPOUND.test(sel)) return sel
stats.resetsExempted++
return sel.replace(BARE_COMPOUND, `$1$2${exempt}`)
}
const plugin = {
postcssPlugin: 'scope-deck',
Once(root) {
root.walkRules(rule => {
// Keyframe steps (0%, from, to) and @font-face descriptors are not selectors.
const at = rule.parent?.type === 'atrule' ? rule.parent.name.toLowerCase() : null
if (at && (at.endsWith('keyframes') || at === 'font-face')) return
stats.rules++
const parts = list.comma(rule.selector)
const cameFromRoot = parts.some(p => ROOT_RE.test(p.trim()))
rule.selector = parts.map(scopeSelector).map(exemptResets).join(',')
if (cameFromRoot) {
rule.walkDecls(d => {
if (VIEWPORT_PROPS.test(d.prop)) { d.remove(); stats.viewportPropsDropped++ }
})
if (!rule.nodes.length) rule.remove()
}
})
},
}
const guard = exempt ? `${scope},${scope} *${exempt}{all:revert}\n` : ''
const out = postcss([plugin]).process(css, { from: undefined }).css
return { css: guard + out, stats }
}
// The deck ships machinery it never uses. Prune ONLY what the DOM demonstrably lacks —
// guessing here silently strips a live style. KaTeX alone carries 20 @font-face blocks
// pointing at /assets/KaTeX_*.woff2, which on the site would be 20 requests to 404.
export function pruneUnused(css, html) {
const usesKatex = /class="[^"]*\bkatex\b/.test(html)
const usesTwoslash = /class="[^"]*\btwoslash\b/.test(html)
const pruned = { katexFontFace: 0, katexRules: 0, twoslashRules: 0 }
const root = postcss.parse(css)
if (!usesKatex) {
root.walkAtRules('font-face', at => {
if (/font-family:\s*["']?KaTeX_/i.test(at.toString())) { at.remove(); pruned.katexFontFace++ }
})
root.walkRules(r => { if (/\bkatex\b/i.test(r.selector)) { r.remove(); pruned.katexRules++ } })
}
if (!usesTwoslash) {
root.walkRules(r => { if (/\btwoslash\b/i.test(r.selector)) { r.remove(); pruned.twoslashRules++ } })
}
return { css: root.toString(), pruned, usesKatex, usesTwoslash }
}