feat(site-builder): add block-scoped <style> support to Custom HTML block
<style> was previously in FORBID_TAGS and stripped entirely. It's now allowed, but its CSS is rewritten by a new hand-rolled scoper (src/utils/scope-css.ts) so a customer's rules only match inside their own block's wrapper -- never leak out and restyle the rest of the page. The wrapper div (class="whp-html-<hash>") is only emitted when a block actually has surviving <style> content, so blocks that don't use it stay byte-identical to before this change. Key findings, both covered by tests: - DOMPurify's body-only serialization silently drops a <style> tag that appears before any other content in a block (the HTML5 parser implicitly places it in <head>, which DOMPurify never looks at). Fixed with FORCE_BODY: true. - DOMPurify does not sanitize CSS declaration values at all (expression(), behavior:, url() to any host all pass through verbatim) -- @import is stripped explicitly by scopeCss() since it's the one CSS-level exfiltration/fetch vector in scope here. Scope identifier reuses the existing djb2 stableHash() from utils/escape.ts (already used for this exact class of problem) over the block's own `code` string -- deterministic, no node id, no Math.random/Date.now. 1141/1141 tests passing (was 1077), tsc clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,252 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { scopeCss } from './scope-css';
|
||||
|
||||
const SCOPE = '.whp-html-1a2b3c4d';
|
||||
|
||||
describe('scopeCss -- basic selector scoping', () => {
|
||||
test('a single simple selector gets prefixed', () => {
|
||||
expect(scopeCss('h1 { color: red; }', SCOPE)).toBe(`${SCOPE} h1 { color: red; }`);
|
||||
});
|
||||
|
||||
test('multiple rules each get prefixed independently', () => {
|
||||
const input = 'h1 { color: red; } p { color: blue; }';
|
||||
const out = scopeCss(input, SCOPE);
|
||||
expect(out).toContain(`${SCOPE} h1 { color: red; }`);
|
||||
expect(out).toContain(`${SCOPE} p { color: blue; }`);
|
||||
});
|
||||
|
||||
test('a compound descendant selector is prefixed as a whole, not per-token', () => {
|
||||
expect(scopeCss('div.card > h2 { color: red; }', SCOPE)).toBe(`${SCOPE} div.card > h2 { color: red; }`);
|
||||
});
|
||||
|
||||
test('pseudo-classes/elements survive attached to their element', () => {
|
||||
expect(scopeCss('a:hover { color: red; }', SCOPE)).toBe(`${SCOPE} a:hover { color: red; }`);
|
||||
expect(scopeCss('p::before { content: "x"; }', SCOPE)).toBe(`${SCOPE} p::before { content: "x"; }`);
|
||||
});
|
||||
|
||||
test('the universal selector is prefixed', () => {
|
||||
expect(scopeCss('* { box-sizing: border-box; }', SCOPE)).toBe(`${SCOPE} * { box-sizing: border-box; }`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scopeCss -- comma-separated selector lists (every selector must be scoped)', () => {
|
||||
test('h1, h2 > p scopes BOTH selectors, not just the first', () => {
|
||||
const out = scopeCss('h1, h2 > p { margin: 0; }', SCOPE);
|
||||
expect(out).toBe(`${SCOPE} h1, ${SCOPE} h2 > p { margin: 0; }`);
|
||||
});
|
||||
|
||||
test('a long comma list scopes every entry', () => {
|
||||
const out = scopeCss('h1, h2, h3, h4 { font-weight: bold; }', SCOPE);
|
||||
expect(out).toBe(`${SCOPE} h1, ${SCOPE} h2, ${SCOPE} h3, ${SCOPE} h4 { font-weight: bold; }`);
|
||||
});
|
||||
|
||||
test('a comma inside :not(...) is not treated as a selector-list separator', () => {
|
||||
const out = scopeCss('div:not(h1, h2) { color: red; }', SCOPE);
|
||||
expect(out).toBe(`${SCOPE} div:not(h1, h2) { color: red; }`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scopeCss -- @media / @supports / @container recurse into the body', () => {
|
||||
test('@media keeps its condition prelude intact and scopes the selector inside', () => {
|
||||
const input = '@media (min-width: 600px) { h1 { color: red; } }';
|
||||
const out = scopeCss(input, SCOPE);
|
||||
expect(out).toBe(`@media (min-width: 600px) { ${SCOPE} h1 { color: red; } }`);
|
||||
});
|
||||
|
||||
test('@supports keeps its condition prelude intact and scopes the selector inside', () => {
|
||||
const input = '@supports (display: grid) { .grid { display: grid; } }';
|
||||
const out = scopeCss(input, SCOPE);
|
||||
expect(out).toBe(`@supports (display: grid) { ${SCOPE} .grid { display: grid; } }`);
|
||||
});
|
||||
|
||||
test('@container keeps its condition prelude intact and scopes the selector inside', () => {
|
||||
const input = '@container (min-width: 400px) { .card { padding: 8px; } }';
|
||||
const out = scopeCss(input, SCOPE);
|
||||
expect(out).toBe(`@container (min-width: 400px) { ${SCOPE} .card { padding: 8px; } }`);
|
||||
});
|
||||
|
||||
test('multiple rules inside one @media block are each scoped', () => {
|
||||
const input = '@media (min-width: 600px) { h1 { color: red; } p { color: blue; } }';
|
||||
const out = scopeCss(input, SCOPE);
|
||||
expect(out).toBe(`@media (min-width: 600px) { ${SCOPE} h1 { color: red; } ${SCOPE} p { color: blue; } }`);
|
||||
});
|
||||
|
||||
test('a comma-separated selector list inside @media is fully scoped', () => {
|
||||
const input = '@media (min-width: 600px) { h1, h2 { color: red; } }';
|
||||
const out = scopeCss(input, SCOPE);
|
||||
expect(out).toBe(`@media (min-width: 600px) { ${SCOPE} h1, ${SCOPE} h2 { color: red; } }`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scopeCss -- @keyframes body is left untouched', () => {
|
||||
test('keyframe selectors (from/to/percentages) are not scoped', () => {
|
||||
const input = '@keyframes spin { from { opacity: 0; } 50% { opacity: 0.5; } to { opacity: 1; } }';
|
||||
expect(scopeCss(input, SCOPE)).toBe(input);
|
||||
});
|
||||
|
||||
test('vendor-prefixed @-webkit-keyframes body is also left untouched', () => {
|
||||
const input = '@-webkit-keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }';
|
||||
expect(scopeCss(input, SCOPE)).toBe(input);
|
||||
});
|
||||
|
||||
test('a normal rule after a @keyframes block is still scoped (parser resyncs correctly)', () => {
|
||||
const input = '@keyframes spin { from { opacity: 0; } to { opacity: 1; } } h1 { color: red; }';
|
||||
const out = scopeCss(input, SCOPE);
|
||||
expect(out).toBe(`@keyframes spin { from { opacity: 0; } to { opacity: 1; } } ${SCOPE} h1 { color: red; }`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scopeCss -- @font-face is left entirely alone (no selector to scope)', () => {
|
||||
test('@font-face block passes through byte-identical', () => {
|
||||
const input = "@font-face { font-family: 'Custom'; src: url(custom.woff2) format('woff2'); }";
|
||||
expect(scopeCss(input, SCOPE)).toBe(input);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scopeCss -- @import is stripped', () => {
|
||||
test('a bare @import statement is removed', () => {
|
||||
const out = scopeCss('@import url("https://evil.example/x.css");', SCOPE);
|
||||
expect(out).not.toContain('@import');
|
||||
expect(out).not.toContain('evil.example');
|
||||
});
|
||||
|
||||
test('@import surrounded by real rules: only the import is removed, the rules survive scoped', () => {
|
||||
const input = '@import url("x.css"); h1 { color: red; } p { color: blue; }';
|
||||
const out = scopeCss(input, SCOPE);
|
||||
expect(out).not.toContain('@import');
|
||||
expect(out).toContain(`${SCOPE} h1 { color: red; }`);
|
||||
expect(out).toContain(`${SCOPE} p { color: blue; }`);
|
||||
});
|
||||
|
||||
test('@import with a semicolon inside its quoted url is still recognized as ONE statement', () => {
|
||||
// The url itself doesn't contain a semicolon in practice, but this
|
||||
// proves the statement-terminator scan is string-aware in general: a
|
||||
// quoted string's contents (whatever they are) never end the statement
|
||||
// early.
|
||||
const input = '@import url("foo.css?x=1;y=2"); h1 { color: red; }';
|
||||
const out = scopeCss(input, SCOPE);
|
||||
expect(out).not.toContain('@import');
|
||||
expect(out).not.toContain('foo.css');
|
||||
expect(out).toContain(`${SCOPE} h1 { color: red; }`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scopeCss -- :root / html / body map to the scope root itself', () => {
|
||||
test(':root custom properties target the wrapper, not a nonexistent descendant', () => {
|
||||
expect(scopeCss(':root { --brand: red; }', SCOPE)).toBe(`${SCOPE} { --brand: red; }`);
|
||||
});
|
||||
|
||||
test('html selector maps to the scope root', () => {
|
||||
expect(scopeCss('html { background: #fff; }', SCOPE)).toBe(`${SCOPE} { background: #fff; }`);
|
||||
});
|
||||
|
||||
test('body selector maps to the scope root', () => {
|
||||
expect(scopeCss('body { margin: 0; }', SCOPE)).toBe(`${SCOPE} { margin: 0; }`);
|
||||
});
|
||||
|
||||
test('case-insensitive: HTML and BODY also map to the scope root', () => {
|
||||
expect(scopeCss('HTML { color: red; }', SCOPE)).toBe(`${SCOPE} { color: red; }`);
|
||||
expect(scopeCss('BODY { color: red; }', SCOPE)).toBe(`${SCOPE} { color: red; }`);
|
||||
});
|
||||
|
||||
test(':root mixed into a comma list scopes the other entries normally', () => {
|
||||
const out = scopeCss(':root, h1 { color: red; }', SCOPE);
|
||||
expect(out).toBe(`${SCOPE}, ${SCOPE} h1 { color: red; }`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scopeCss -- comments and strings are not treated as syntax', () => {
|
||||
test('a brace inside a comment does not confuse block matching', () => {
|
||||
const input = 'h1 { color: red; /* comment with a { brace */ }';
|
||||
const out = scopeCss(input, SCOPE);
|
||||
expect(out).toBe(`${SCOPE} ${input}`);
|
||||
});
|
||||
|
||||
test('a comma inside a comment does not split a selector list', () => {
|
||||
const input = 'h1 /* a, b */ , p { color: red; }';
|
||||
const out = scopeCss(input, SCOPE);
|
||||
expect(out).toBe(`${SCOPE} h1 /* a, b */, ${SCOPE} p { color: red; }`);
|
||||
});
|
||||
|
||||
test('an @ inside a comment does not trigger at-rule handling', () => {
|
||||
// The comment sits in front of the selector text, so it stays part of
|
||||
// what gets prefixed (a CSS comment is insignificant whitespace to the
|
||||
// parser -- `.scope /* c */ h1` is equivalent to `.scope h1`). What
|
||||
// this test really guards: the leading "@import" text INSIDE the
|
||||
// comment must not make the classifier treat this as an @import
|
||||
// statement and strip the whole rule.
|
||||
const input = '/* @import fake */ h1 { color: red; }';
|
||||
const out = scopeCss(input, SCOPE);
|
||||
expect(out).toBe(`${SCOPE} /* @import fake */ h1 { color: red; }`);
|
||||
expect(out).toContain('color: red');
|
||||
});
|
||||
|
||||
test('a brace inside a quoted content string does not confuse block matching', () => {
|
||||
const input = 'p::before { content: "{ not a brace }"; }';
|
||||
expect(scopeCss(input, SCOPE)).toBe(`${SCOPE} ${input}`);
|
||||
});
|
||||
|
||||
test('a comma inside a quoted string does not split a selector list', () => {
|
||||
const input = 'h1[data-x="a,b"], p { color: red; }';
|
||||
const out = scopeCss(input, SCOPE);
|
||||
expect(out).toBe(`${SCOPE} h1[data-x="a,b"], ${SCOPE} p { color: red; }`);
|
||||
});
|
||||
|
||||
test('a semicolon inside a quoted string does not end an @import early', () => {
|
||||
const input = 'h1::before { content: "a;b"; } p { color: red; }';
|
||||
const out = scopeCss(input, SCOPE);
|
||||
expect(out).toBe(`${SCOPE} h1::before { content: "a;b"; } ${SCOPE} p { color: red; }`);
|
||||
});
|
||||
|
||||
test('an unterminated comment consumes to end of string without throwing', () => {
|
||||
expect(() => scopeCss('h1 { color: red; } /* unterminated', SCOPE)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('scopeCss -- idempotency (running twice must not double-prefix)', () => {
|
||||
test('a plain selector is not re-prefixed on a second pass', () => {
|
||||
const once = scopeCss('h1 { color: red; }', SCOPE);
|
||||
const twice = scopeCss(once, SCOPE);
|
||||
expect(twice).toBe(once);
|
||||
expect(twice.match(new RegExp(SCOPE.replace('.', '\\.'), 'g'))?.length).toBe(1);
|
||||
});
|
||||
|
||||
test(':root-mapped rule is not re-prefixed on a second pass', () => {
|
||||
const once = scopeCss(':root { --brand: red; }', SCOPE);
|
||||
const twice = scopeCss(once, SCOPE);
|
||||
expect(twice).toBe(once);
|
||||
});
|
||||
|
||||
test('a comma list is not re-prefixed on a second pass', () => {
|
||||
const once = scopeCss('h1, h2 > p { margin: 0; }', SCOPE);
|
||||
const twice = scopeCss(once, SCOPE);
|
||||
expect(twice).toBe(once);
|
||||
});
|
||||
|
||||
test('a rule inside @media is not re-prefixed on a second pass', () => {
|
||||
const once = scopeCss('@media (min-width: 600px) { h1 { color: red; } }', SCOPE);
|
||||
const twice = scopeCss(once, SCOPE);
|
||||
expect(twice).toBe(once);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scopeCss -- misc/edge cases', () => {
|
||||
test('empty input returns empty string', () => {
|
||||
expect(scopeCss('', SCOPE)).toBe('');
|
||||
});
|
||||
|
||||
test('whitespace-only input round-trips without throwing', () => {
|
||||
expect(() => scopeCss(' \n ', SCOPE)).not.toThrow();
|
||||
});
|
||||
|
||||
test('a relative selector starting with a combinator is scoped as a descendant of the wrapper', () => {
|
||||
// ">h1" is unusual outside CSS nesting but should not crash the scanner.
|
||||
const out = scopeCss('> h1 { color: red; }', SCOPE);
|
||||
expect(out).toBe(`${SCOPE} > h1 { color: red; }`);
|
||||
});
|
||||
|
||||
test('an unknown braced at-rule (e.g. @page) is left untouched', () => {
|
||||
const input = '@page { margin: 1in; }';
|
||||
expect(scopeCss(input, SCOPE)).toBe(input);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* Block-scoped CSS rewriter for the Custom HTML block's `<style>` support.
|
||||
*
|
||||
* `scopeCss(css, scopeSelector)` rewrites every rule's selector so it only
|
||||
* matches inside the block's own wrapper element (`scopeSelector`, e.g.
|
||||
* `.whp-html-1a2b3c4d`) -- a customer's `<style>h1{color:red}</style>`
|
||||
* restyles their own block, never the rest of the page.
|
||||
*
|
||||
* Hand-rolled, dependency-free tokenise-then-transform parser -- same shape
|
||||
* as `formatHtml` in `./format-html.ts` (read that file's header first; it
|
||||
* documents why a naive regex approach breaks on this class of problem and
|
||||
* went through three fix rounds to get right). The key primitive shared by
|
||||
* both scanners is "walk forward, but treat `{`/`}`/`,`/`@` inside a CSS
|
||||
* comment or a quoted string as inert text, not syntax" -- see
|
||||
* `skipCommentOrString` below.
|
||||
*
|
||||
* What this does NOT do (by design, not oversight -- see each branch):
|
||||
* - `@keyframes` bodies are copied through untouched. Their selectors
|
||||
* (`from`, `to`, `50%`) are keyframe offsets, not element selectors --
|
||||
* scoping them would break the animation. The animation `name` stays
|
||||
* global (a documented wart: two blocks using the same `@keyframes name`
|
||||
* collide) -- fixing that would mean rewriting every `animation-name`
|
||||
* declaration too, which is out of scope here.
|
||||
* - `@font-face` has no selector at all; copied through untouched.
|
||||
* - `@import` is stripped entirely (network fetch + exfiltration channel,
|
||||
* and its remote rules would never go through this scoper anyway).
|
||||
* - `@media`/`@supports`/`@container` keep their own prelude (the
|
||||
* `(min-width: ...)` condition etc.) untouched; only the selectors
|
||||
* *inside* the block are recursively scoped.
|
||||
* - `:root`, `html`, `body` map to `scopeSelector` itself (not
|
||||
* `${scopeSelector} html`, which would match nothing -- `html` is an
|
||||
* ancestor of the wrapper, not a descendant). This is how a customer's
|
||||
* `:root { --brand: ... }` custom-property block keeps working instead of
|
||||
* silently doing nothing.
|
||||
* - Any other at-rule with a `{ }` body (`@page`, `@layer`, ...) is copied
|
||||
* through untouched -- deliberately conservative rather than guessing at
|
||||
* a selector-scoping rule for at-rules this task doesn't enumerate.
|
||||
*/
|
||||
|
||||
/**
|
||||
* If a CSS comment (`/* ... *\/`) or a single/double-quoted string starts at
|
||||
* `css[i]`, returns the index immediately after it. Otherwise returns `i`
|
||||
* unchanged (caller treats `css[i]` as ordinary syntax). An unterminated
|
||||
* comment/string consumes to the end of the string rather than looping
|
||||
* forever or mis-splitting the remainder.
|
||||
*/
|
||||
function skipCommentOrString(css: string, i: number): number {
|
||||
if (css.charCodeAt(i) === 47 /* '/' */ && css[i + 1] === '*') {
|
||||
const end = css.indexOf('*/', i + 2);
|
||||
return end === -1 ? css.length : end + 2;
|
||||
}
|
||||
const ch = css[i];
|
||||
if (ch === '"' || ch === "'") {
|
||||
let j = i + 1;
|
||||
while (j < css.length) {
|
||||
if (css[j] === '\\') {
|
||||
j += 2;
|
||||
continue;
|
||||
}
|
||||
if (css[j] === ch) return j + 1;
|
||||
j += 1;
|
||||
}
|
||||
return css.length;
|
||||
}
|
||||
return i;
|
||||
}
|
||||
|
||||
/** Split `text` on top-level commas -- respecting comments, strings, and
|
||||
* parens (so `:not(h1, h2)` doesn't get split into two selectors). */
|
||||
function splitTopLevelCommas(text: string): string[] {
|
||||
const parts: string[] = [];
|
||||
let start = 0;
|
||||
let depth = 0;
|
||||
let i = 0;
|
||||
while (i < text.length) {
|
||||
const skip = skipCommentOrString(text, i);
|
||||
if (skip !== i) {
|
||||
i = skip;
|
||||
continue;
|
||||
}
|
||||
const ch = text[i];
|
||||
if (ch === '(') depth += 1;
|
||||
else if (ch === ')') depth = Math.max(0, depth - 1);
|
||||
else if (ch === ',' && depth === 0) {
|
||||
parts.push(text.slice(start, i));
|
||||
i += 1;
|
||||
start = i;
|
||||
continue;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
parts.push(text.slice(start));
|
||||
return parts;
|
||||
}
|
||||
|
||||
const ROOT_LIKE = new Set([':root', 'html', 'body']);
|
||||
|
||||
/** Scope a single selector (no top-level commas left in it). */
|
||||
function scopeSingleSelector(selector: string, scopeSelector: string): string {
|
||||
const trimmed = selector.trim();
|
||||
if (trimmed === '') return trimmed;
|
||||
|
||||
const lower = trimmed.toLowerCase();
|
||||
// `:root`/`html`/`body` target an ancestor OUTSIDE the block -- map to the
|
||||
// scope root itself rather than prefixing (`${scopeSelector} html` would
|
||||
// match nothing, since html is never a descendant of the wrapper div).
|
||||
if (ROOT_LIKE.has(lower)) return scopeSelector;
|
||||
|
||||
// Idempotency guard (Constraint: running scopeCss twice must not
|
||||
// double-prefix). Our own output only ever takes two shapes: the bare
|
||||
// scope selector (from the ROOT_LIKE branch above) or
|
||||
// `${scopeSelector} ${original}` (the branch below) -- recognising both
|
||||
// is enough to make a second pass a no-op.
|
||||
if (trimmed === scopeSelector || trimmed.startsWith(`${scopeSelector} `)) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
return `${scopeSelector} ${trimmed}`;
|
||||
}
|
||||
|
||||
/** Scope a comma-separated selector list, e.g. `h1, h2 > p`. */
|
||||
function scopeSelectorList(selectorList: string, scopeSelector: string): string {
|
||||
return splitTopLevelCommas(selectorList)
|
||||
.map((s) => scopeSingleSelector(s, scopeSelector))
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope a rule's raw prelude text (as sliced straight out of the source,
|
||||
* still carrying whatever whitespace sat between the previous rule and the
|
||||
* selector, and between the selector and the `{`). Only the trimmed core is
|
||||
* run through `scopeSelectorList`; the original leading/trailing whitespace
|
||||
* envelope is preserved around it so scoping doesn't visibly reformat the
|
||||
* customer's CSS (e.g. `h1 {` stays `${scope} h1 {`, not `${scope} h1{`).
|
||||
*/
|
||||
function scopeRulePrelude(prelude: string, scopeSelector: string): string {
|
||||
const leadLen = prelude.length - prelude.replace(/^\s+/, '').length;
|
||||
const trailLen = prelude.length - prelude.replace(/\s+$/, '').length;
|
||||
const lead = prelude.slice(0, leadLen);
|
||||
const core = prelude.slice(leadLen, prelude.length - trailLen);
|
||||
const trail = trailLen ? prelude.slice(prelude.length - trailLen) : '';
|
||||
return lead + scopeSelectorList(core, scopeSelector) + trail;
|
||||
}
|
||||
|
||||
const KEYFRAMES_RE = /^@(-\w+-)?keyframes\b/i;
|
||||
const FONT_FACE_RE = /^@font-face\b/i;
|
||||
const RECURSE_RE = /^@(media|supports|container)\b/i;
|
||||
const IMPORT_RE = /^@import\b/i;
|
||||
|
||||
/**
|
||||
* Recursively transform one nesting level of CSS text: a sequence of
|
||||
* `selector { declarations }` rules and/or at-rules. Used both for the
|
||||
* top-level stylesheet and, recursively, for the body of a `@media`/
|
||||
* `@supports`/`@container` block (whose contents are themselves a nested
|
||||
* sequence of rules).
|
||||
*/
|
||||
function transformBlock(css: string, scopeSelector: string): string {
|
||||
let result = '';
|
||||
let i = 0;
|
||||
const n = css.length;
|
||||
|
||||
while (i < n) {
|
||||
const start = i;
|
||||
|
||||
// Scan the prelude (selector list, or an at-rule's condition/name) up to
|
||||
// the next top-level `{`, `;`, or stray `}` -- comment/string aware so a
|
||||
// brace or semicolon inside a comment or quoted string is inert.
|
||||
let j = i;
|
||||
while (j < n) {
|
||||
const skip = skipCommentOrString(css, j);
|
||||
if (skip !== j) {
|
||||
j = skip;
|
||||
continue;
|
||||
}
|
||||
const ch = css[j];
|
||||
if (ch === '{' || ch === ';' || ch === '}') break;
|
||||
j += 1;
|
||||
}
|
||||
|
||||
if (j >= n) {
|
||||
// Trailing content with no terminator (whitespace, or a syntax error
|
||||
// in the customer's CSS) -- copy through verbatim rather than drop it.
|
||||
result += css.slice(start);
|
||||
break;
|
||||
}
|
||||
|
||||
if (css[j] === '}') {
|
||||
// Stray close-brace with no matching open at this level -- shouldn't
|
||||
// happen for well-formed input, but copy it through rather than
|
||||
// fabricate a parse error or lose customer content.
|
||||
result += css.slice(start, j + 1);
|
||||
i = j + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const prelude = css.slice(start, j);
|
||||
const trimmedPrelude = prelude.trim();
|
||||
|
||||
if (css[j] === ';') {
|
||||
// Statement at-rule, e.g. `@import url(...);` or `@charset "UTF-8";`.
|
||||
if (IMPORT_RE.test(trimmedPrelude)) {
|
||||
// Strip @import entirely: it fetches remote CSS outside this
|
||||
// function's scoping guarantee, and is a plain exfiltration
|
||||
// channel (the fetch itself leaks referrer/cookies/IP to whatever
|
||||
// host the customer -- or an attacker who edited their block --
|
||||
// points it at).
|
||||
} else {
|
||||
result += css.slice(start, j + 1);
|
||||
}
|
||||
i = j + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// css[j] === '{' : find the matching close brace for THIS block,
|
||||
// comment/string aware, counting nested braces (a @media block's body
|
||||
// contains further `{ }` rule blocks).
|
||||
const blockContentStart = j + 1;
|
||||
let depth = 1;
|
||||
let k = blockContentStart;
|
||||
while (k < n && depth > 0) {
|
||||
const skip = skipCommentOrString(css, k);
|
||||
if (skip !== k) {
|
||||
k = skip;
|
||||
continue;
|
||||
}
|
||||
const ch = css[k];
|
||||
if (ch === '{') depth += 1;
|
||||
else if (ch === '}') depth -= 1;
|
||||
k += 1;
|
||||
}
|
||||
// k is now just past the matching '}' (or end of string if unterminated).
|
||||
const blockContentEnd = depth === 0 ? k - 1 : k;
|
||||
const body = css.slice(blockContentStart, blockContentEnd);
|
||||
|
||||
if (KEYFRAMES_RE.test(trimmedPrelude) || FONT_FACE_RE.test(trimmedPrelude)) {
|
||||
// @keyframes: body selectors are keyframe offsets (from/to/50%), not
|
||||
// element selectors -- scoping them would break the animation.
|
||||
// @font-face: has no selector to scope at all.
|
||||
// Either way: copy the whole block through untouched.
|
||||
result += prelude + '{' + body + '}';
|
||||
} else if (RECURSE_RE.test(trimmedPrelude)) {
|
||||
// @media/@supports/@container: keep the condition prelude as-is,
|
||||
// recursively scope the selectors nested inside.
|
||||
result += prelude + '{' + transformBlock(body, scopeSelector) + '}';
|
||||
} else if (trimmedPrelude.startsWith('@')) {
|
||||
// Any other braced at-rule (@page, @layer, ...): conservatively leave
|
||||
// untouched rather than guess at a scoping rule this task doesn't
|
||||
// enumerate.
|
||||
result += prelude + '{' + body + '}';
|
||||
} else {
|
||||
// Ordinary rule: prelude is a selector list.
|
||||
result += scopeRulePrelude(prelude, scopeSelector) + '{' + body + '}';
|
||||
}
|
||||
|
||||
i = k;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite `css` so every rule only matches inside `scopeSelector` (expected
|
||||
* to be a single class selector like `.whp-html-1a2b3c4d`, matching the
|
||||
* Custom HTML block's own wrapper element). Pure function: same
|
||||
* `(css, scopeSelector)` in, same string out, every time -- see the module
|
||||
* doc comment above for the exact at-rule/selector rules applied.
|
||||
*/
|
||||
export function scopeCss(css: string, scopeSelector: string): string {
|
||||
if (!css) return '';
|
||||
return transformBlock(css, scopeSelector);
|
||||
}
|
||||
Reference in New Issue
Block a user