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:
2026-08-09 17:10:57 -07:00
co-authored by Claude Opus 5
parent 6a9b227dda
commit 32f4092156
6 changed files with 831 additions and 10 deletions
+252
View File
@@ -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);
});
});
+271
View File
@@ -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);
}