117 lines
5.2 KiB
JavaScript
117 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 }
|
||
|
|
}
|