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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user