// 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 } }