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
@@ -227,9 +227,18 @@ describe('purifyHtml -- Task 24: security properties on newly-allowed elements',
expect(out).not.toMatch(/onload/i); expect(out).not.toMatch(/onload/i);
}); });
test('style tag stays blocked even nested inside the newly-allowed inline svg', () => { test('Task 25: style tag nested inside the newly-allowed inline svg now survives, scoped', () => {
// Was "style tag stays blocked" pre-Task-25 -- <style> is now a
// deliberate escape hatch (see HtmlBlock.tsx's ALLOWED_TAGS/Task 25
// comment), including copies nested inside inline SVG:
// querySelectorAll('style') in scopeStyleBlocks() doesn't care about
// namespace/nesting depth, because CSS itself doesn't respect SVG
// subtree boundaries -- an unscoped <style> inside <svg> would still
// apply page-wide, so it needs the same scoping as a top-level one.
const out = purifyHtml('<svg><style>svg{color:red}</style><rect width="1" height="1"></rect></svg>'); const out = purifyHtml('<svg><style>svg{color:red}</style><rect width="1" height="1"></rect></svg>');
expect(out).not.toMatch(/<style/i); expect(out).toMatch(/<style/i);
expect(out).not.toContain('<style>svg{color:red}</style>'); // rewritten, not verbatim
expect(out).toMatch(/\.whp-html-\w+ svg\{color:red\}/);
expect(out).toContain('<rect'); expect(out).toContain('<rect');
}); });
@@ -1,5 +1,7 @@
import { describe, test, expect } from 'vitest'; import { describe, test, expect } from 'vitest';
import { purifyHtml } from './HtmlBlock'; import { purifyHtml } from './HtmlBlock';
import { stableHash } from '../../utils/escape';
import fixtureHtml from './__fixtures__/html-block-test-body.html?raw';
describe('purifyHtml', () => { describe('purifyHtml', () => {
test('strips script tags', () => { test('strips script tags', () => {
@@ -103,3 +105,192 @@ describe('purifyHtml iframe sandboxing (M-6)', () => {
expect(out).toContain('<p>hi</p>'); expect(out).toContain('<p>hi</p>');
}); });
}); });
describe('purifyHtml -- Task 25: block-scoped <style> support', () => {
test('a block with no <style> at all is untouched: no wrapper div added', () => {
const out = purifyHtml('<p>hello</p>');
expect(out).toBe('<p>hello</p>');
expect(out).not.toContain('<div');
});
test('blocks WITHOUT <style> are byte-identical to pre-Task-25 output (no wrapper regression)', () => {
// Same representative inputs the Task 24 suite already pins to an exact
// string -- re-asserted here under the Task 25 name so a future change
// that starts wrapping every block (not just style-bearing ones) fails
// loudly and obviously, not just as a Task 24 side-effect.
expect(purifyHtml('<p style="color: #ff0000">red text</p>')).toBe('<p style="color: #ff0000">red text</p>');
const table = '<table><thead><tr><th>Head</th></tr></thead><tbody><tr><td>Cell</td></tr></tbody></table>';
expect(purifyHtml(table)).toBe(table);
expect(purifyHtml('<a href="/x">x</a>')).toBe('<a href="/x">x</a>');
});
test('the full Task 24 fixture (no <style> in it) produces no wrapper and is unaffected', () => {
// The fixture is the broadest real-world stand-in this repo has for
// "a customer's actual pasted block". It contains no <style>, so this
// is the closest thing to a real before/after diff over a large,
// realistic input: the only lever Task 25 pulled (allowing <style> +
// FORCE_BODY) must produce PRECISELY the same output as before for
// content that never touches that lever.
const out = purifyHtml(fixtureHtml);
expect(out).not.toContain('<div class="whp-html-');
expect(out).not.toMatch(/<style[\s>]/i); // still no bare <style> in this fixture
});
test('an empty <style></style> (no CSS content) does not trigger a wrapper', () => {
const out = purifyHtml('<p>hi</p><style></style>');
expect(out).not.toContain('<div class="whp-html-');
});
test('a whitespace-only <style> does not trigger a wrapper', () => {
const out = purifyHtml('<p>hi</p><style> \n </style>');
expect(out).not.toContain('<div class="whp-html-');
});
test('a block WITH real <style> content gets wrapped in a scope-class div', () => {
const out = purifyHtml('<style>h1 { color: red; }</style><h1>Hi</h1>');
expect(out).toMatch(/^<div class="whp-html-[0-9a-z]+">/);
expect(out).toContain('<h1>Hi</h1>');
});
test('the style content is rewritten to only match inside the wrapper (the actual leak-prevention property)', () => {
const out = purifyHtml('<style>h1 { color: red; }</style><h1>Hi</h1>');
const scopeClass = out.match(/class="(whp-html-[0-9a-z]+)"/)![1];
expect(out).toContain(`.${scopeClass} h1 { color: red; }`);
// The bare, unscoped rule must not appear anywhere in the output --
// that's exactly the leak this feature exists to close.
expect(out).not.toContain('<style>h1 { color: red; }</style>');
});
test('scope class is deterministic: the SAME code produces the SAME class across repeated calls', () => {
const code = '<style>p { color: blue; }</style><p>x</p>';
const out1 = purifyHtml(code);
const out2 = purifyHtml(code);
expect(out1).toBe(out2);
const class1 = out1.match(/class="(whp-html-[0-9a-z]+)"/)![1];
const class2 = out2.match(/class="(whp-html-[0-9a-z]+)"/)![1];
expect(class1).toBe(class2);
});
test('pinned scope class for a known input -- guards against silent hash-function drift', () => {
// If this ever needs to change, it means the hash function itself
// changed -- which would silently churn every stored site's HTML on
// next save and desync already-published pages from a fresh Preview.
// That should be a loud, deliberate decision, not a side-effect of an
// unrelated refactor -- hence pinning the literal output here.
const code = '<style>h1{color:red}</style>';
expect(stableHash(code)).toBe('5fwbyn');
const out = purifyHtml(code);
expect(out).toContain('class="whp-html-5fwbyn"');
});
test('scope class is a pure function of `code` -- does not depend on Craft node id or call order', () => {
// purifyHtml's signature only ever takes the code string -- there is no
// node id parameter it could even reach for. This test documents that
// invariant so a future refactor threading a node id through here (as
// html-export.ts's renderNode already does for OTHER components, see
// its `scopeId` comment) doesn't silently get wired into this path too.
const codeA = '<style>h1 { color: red; }</style><h1>same content</h1>';
const codeB = '<style>h1 { color: red; }</style><h1>same content</h1>';
expect(codeA).toBe(codeB); // sanity: truly identical strings
const outA = purifyHtml(codeA);
const outB = purifyHtml(codeB);
expect(outA).toBe(outB);
});
test('FORCE_BODY regression: a block whose ENTIRE code is a leading <style> (nothing before it) still survives', () => {
// Without FORCE_BODY, DOMPurify parses `code` via DOMParser as a mini
// HTML document and serializes only <body>. Per the HTML5 parsing
// algorithm, a <style> tag with nothing before it is implicitly placed
// in the parser's <head>, which DOMPurify's body-only serialization
// never looks at -- the whole block would silently vanish. Confirmed
// empirically against dompurify+jsdom directly before this fix existed.
const out = purifyHtml('<style>h1{color:red}</style>');
expect(out).toContain('<style>');
expect(out).toContain('color:red');
});
test('FORCE_BODY regression: leading <style> immediately followed by markup, both survive', () => {
const out = purifyHtml('<style>h1{color:red}</style><h1>Hi</h1>');
expect(out).toContain('<h1>Hi</h1>');
expect(out).toMatch(/<style>[\s\S]*color:\s*red/);
});
test(':root / html / body inside a block map to the block wrapper itself (end-to-end through purifyHtml)', () => {
const out = purifyHtml('<style>:root { --brand: red; } body { margin: 0; }</style><p>x</p>');
const scopeClass = out.match(/class="(whp-html-[0-9a-z]+)"/)![1];
expect(out).toContain(`.${scopeClass} { --brand: red; }`);
expect(out).toContain(`.${scopeClass} { margin: 0; }`);
});
test('@import is stripped end-to-end (network-fetch/exfiltration channel)', () => {
const out = purifyHtml('<style>@import url("https://evil.example/x.css"); h1{color:red}</style><h1>x</h1>');
expect(out).not.toContain('@import');
expect(out).not.toContain('evil.example');
expect(out).toContain('color:red');
});
test('@keyframes body is not scoped (animation would otherwise break) -- end-to-end through purifyHtml', () => {
const out = purifyHtml(
'<style>@keyframes spin { from { opacity: 0; } to { opacity: 1; } }</style><h1>x</h1>',
);
expect(out).toContain('@keyframes spin');
expect(out).toMatch(/@keyframes spin\s*\{\s*from\s*\{\s*opacity:\s*0;?\s*\}\s*to\s*\{\s*opacity:\s*1;?\s*\}\s*\}/);
});
test('multiple <style> blocks in one Custom HTML block are each scoped under the SAME class', () => {
const out = purifyHtml('<style>h1{color:red}</style><h1>A</h1><style>p{color:blue}</style><p>B</p>');
const classes = [...out.matchAll(/class="(whp-html-[0-9a-z]+)"/g)].map((m) => m[1]);
expect(classes.length).toBeGreaterThanOrEqual(1);
expect(new Set(classes).size).toBe(1); // same block -> same scope class everywhere
});
});
describe('purifyHtml -- Task 25: security properties of the newly-allowed <style>', () => {
test('</style> inside a CSS comment cannot break out into executable markup', () => {
const out = purifyHtml(
'<style>/* </style><script>alert(1)</script> */ h1{color:red}</style><p>hi</p>',
);
expect(out).not.toContain('<script');
expect(out).not.toMatch(/on[a-z]+\s*=/i);
});
test('</style> inside a CSS string cannot break out into executable markup', () => {
const out = purifyHtml(
'<style>h1::before{content:"</style><script>alert(1)</script>"}</style><p>hi</p>',
);
expect(out).not.toContain('<script');
});
test('script/on*/javascript: are still stripped from markup sitting alongside a styled block', () => {
const out = purifyHtml(
'<style>h1{color:red}</style><p onclick="alert(1)">x</p><script>alert(2)</script><a href="javascript:alert(3)">y</a>',
);
expect(out).not.toMatch(/onclick/i);
expect(out).not.toContain('<script');
expect(out).not.toContain('javascript:');
});
test('iframe sandboxing still applies alongside a styled block', () => {
const out = purifyHtml('<style>h1{color:red}</style><iframe src="https://example.com/"></iframe>');
expect(out).toMatch(/<iframe[^>]*\bsandbox="[^"]+"/);
});
test(
'documented reality: DOMPurify does not sanitize CSS declaration values -- ' +
'expression()/behavior/-moz-binding pass through untouched (dead in modern browsers, ' +
'not exploitable there, but not filtered by this pipeline either)',
() => {
const out = purifyHtml(
'<style>div{width:expression(alert(1));behavior:url(evil.htc);-moz-binding:url(evil.xml#x)}</style><div>x</div>',
);
expect(out).toContain('expression(alert(1))');
expect(out).toContain('behavior:url(evil.htc)');
expect(out).toContain('-moz-binding:url(evil.xml#x)');
},
);
test('documented reality: url() to a remote host survives (legitimate for background-image, but a known CSS-exfiltration channel already accepted elsewhere in this config)', () => {
const out = purifyHtml('<style>div{background:url(https://tracker.example/pixel.png)}</style><div>x</div>');
expect(out).toContain('tracker.example');
});
});
@@ -1,5 +1,5 @@
import { describe, test, expect } from 'vitest'; import { describe, test, expect } from 'vitest';
import { HtmlBlock } from './HtmlBlock'; import { HtmlBlock, purifyHtml } from './HtmlBlock';
const toHtml = (HtmlBlock as any).toHtml; const toHtml = (HtmlBlock as any).toHtml;
@@ -46,3 +46,24 @@ describe('HtmlBlock.toHtml markup path (C1 review finding)', () => {
expect(html).toBe(code); expect(html).toBe(code);
}); });
}); });
describe('HtmlBlock.toHtml -- Task 25: block-scoped <style>, and editor/export byte-parity', () => {
test('a <style>-bearing block exports the same scoped wrapper purifyHtml() would produce in the editor canvas', () => {
// The editor canvas (HtmlBlock component) and toHtml() (Preview +
// Published export) both call the exact same purifyHtml(code) -- this
// is the byte-parity invariant this project treats as a hard
// requirement. Proven here by calling purifyHtml directly (as the
// canvas's useMemo does) and toHtml (as export does) on the identical
// code string and asserting the two never diverge.
const code = '<style>h1 { color: red; }</style><h1>Hi</h1>';
const { html } = toHtml({ code }, '');
expect(html).toBe(purifyHtml(code));
});
test('a <style>-free block still exports byte-identical to pre-Task-25 output (no wrapper regression) via toHtml', () => {
const code = '<p>hello</p>';
const { html } = toHtml({ code }, '');
expect(html).toBe('<p>hello</p>');
expect(html).not.toContain('<div');
});
});
+84 -7
View File
@@ -1,6 +1,8 @@
import React, { CSSProperties, useMemo } from 'react'; import React, { CSSProperties, useMemo } from 'react';
import { useNode, UserComponent } from '@craftjs/core'; import { useNode, UserComponent } from '@craftjs/core';
import DOMPurify from 'dompurify'; import DOMPurify from 'dompurify';
import { stableHash } from '../../utils/escape';
import { scopeCss } from '../../utils/scope-css';
interface HtmlBlockProps { interface HtmlBlockProps {
code: string; code: string;
@@ -70,6 +72,13 @@ const PURIFY_CONFIG = {
'line','polyline','polygon','path','text','tspan', 'line','polyline','polygon','path','text','tspan',
'lineargradient','radialgradient','stop','clippath','mask','marker', 'lineargradient','radialgradient','stop','clippath','mask','marker',
'pattern','switch','view', 'pattern','switch','view',
// Task 25: block-scoped <style> support. Formerly in FORBID_TAGS
// (stripped entirely). Now allowed through sanitisation -- its CSS is
// rewritten by scopeStyleBlocks()/scopeCss() below, immediately after
// DOMPurify runs, so it can only match inside this block's own wrapper
// element. See the FORCE_BODY comment below and scopeStyleBlocks() for
// why allowing the tag alone is not sufficient.
'style',
], ],
// NOTE: supplying ALLOWED_ATTR replaces DOMPurify's own default attribute // NOTE: supplying ALLOWED_ATTR replaces DOMPurify's own default attribute
// allowlist rather than extending it, so anything the product needs // allowlist rather than extending it, so anything the product needs
@@ -120,13 +129,31 @@ const PURIFY_CONFIG = {
// be dangerous -- is correctly not in that DOMPurify list). // be dangerous -- is correctly not in that DOMPurify list).
ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|tel):|data:image\/[a-z]+;base64,|[^a-z]|[a-z+.-]+(?:[^a-z+.\-:]|$))/i, ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|tel):|data:image\/[a-z]+;base64,|[^a-z]|[a-z+.-]+(?:[^a-z+.\-:]|$))/i,
// form/input/button/select/textarea removed from FORBID_TAGS (Task 24) -- // form/input/button/select/textarea removed from FORBID_TAGS (Task 24) --
// they are now deliberately allowed above. style/script/object/embed/ // they are now deliberately allowed above. script/object/embed/link/meta
// link/meta stay forbidden; <style> in particular stays blocked even // stay forbidden. <style> (Task 25) is now allowed too -- see ALLOWED_TAGS
// inside the newly-allowed inline <svg> (a separate task is adding // comment above and scopeStyleBlocks() below; it survives sanitisation
// scoped <style> support later -- see HtmlBlock.security.test.ts for the // here but its CSS gets scoped afterwards, including copies nested inside
// svg><style> regression check). // the newly-allowed inline <svg> (querySelectorAll('style') in
FORBID_TAGS: ['script','style','object','embed','link','meta'], // scopeStyleBlocks() doesn't care about namespace/nesting depth).
FORBID_TAGS: ['script','object','embed','link','meta'],
FORBID_ATTR: [/^on/i], FORBID_ATTR: [/^on/i],
// Task 25: without this, DOMPurify parses `input` as a full (mini) HTML
// document via DOMParser and only serializes <body>'s contents. Per the
// HTML5 parsing algorithm, a tag that can only legally appear in <head>
// -- and now that <style> is allowed, that includes <style> -- gets
// implicitly placed in <head> when it appears before any other content,
// and is silently lost (DOMPurify never looks at <head>). A block whose
// entire `code` is `<style>h1{color:red}</style>` -- a very plausible
// paste, style-before-markup is a common snippet shape -- would vanish
// with no error anywhere, despite <style> sitting right there in
// ALLOWED_TAGS. FORCE_BODY prepends an internal element before parsing so
// the parser is already in body-insertion-mode by the time it reaches the
// customer's first tag, keeping a leading <style> (or anything else) in
// <body> where DOMPurify's body-only serialization actually looks.
// Confirmed empirically against dompurify+jsdom directly (not just this
// app's behavior) -- see the "leading <style> with nothing before it"
// test in HtmlBlock.test.ts.
FORCE_BODY: true,
}; };
// M-6: `<iframe>` is allowed (maps/video embeds are a legitimate use case) // M-6: `<iframe>` is allowed (maps/video embeds are a legitimate use case)
@@ -146,6 +173,55 @@ const IFRAME_SANDBOX_HOOK = (node: Element): void => {
} }
}; };
/**
* Task 25: rewrite any surviving `<style>` element(s) in `sanitized` (the
* DOMPurify output) so their CSS only matches inside this block's own
* wrapper element, then wrap the whole thing in that wrapper.
*
* Deliberately does the LEAST work possible when there's nothing to scope:
* a cheap substring check bails out before touching the DOM at all, so a
* block that doesn't use <style> -- i.e. every block saved before this task
* -- gets `sanitized` back completely unchanged (same string, no wrapper,
* no re-serialization round-trip that could subtly reformat attributes).
* That byte-for-byte identity is a hard requirement: published pages
* already contain `toHtml()` output with NO wrapper element, and adding one
* unconditionally would silently change the DOM/box-model of every
* existing customer block. See HtmlBlock.test.ts's
* "blocks without <style> are byte-identical" tests, which run this
* against real fixture content and diff the exact string.
*
* Scope identifier: `whp-html-${stableHash(rawCode)}` -- `stableHash` is
* the existing djb2 hash from utils/escape.ts (already used for this exact
* class of problem, see `scopeId` in that file), applied to `rawCode` --
* the block's own `code` prop, nothing else. Pure function of the block's
* own content: no Math.random, no Date.now, no counter, and deliberately
* NOT the Craft node id (unlike `scopeId`), because a scope identifier that
* depends on anything outside `code` would make the editor canvas preview
* (which calls purifyHtml(code) on render) and the published output (which
* calls the same purifyHtml(code) at publish time) diverge whenever that
* outside thing differs between the two call sites, and would make the
* stored HTML churn on every save even when the block's own content didn't
* change. Hashing `code` guarantees purifyHtml(code) is fully deterministic
* on its own -- same code in, byte-identical output out, every time, in
* both places it's called.
*/
function scopeStyleBlocks(sanitized: string, rawCode: string): string {
if (!sanitized.includes('<style')) return sanitized;
const container = document.createElement('div');
container.innerHTML = sanitized;
const styleEls = Array.from(container.querySelectorAll('style'));
const nonEmpty = styleEls.filter((el) => (el.textContent || '').trim() !== '');
if (nonEmpty.length === 0) return sanitized;
const scopeClass = `whp-html-${stableHash(rawCode)}`;
for (const el of nonEmpty) {
el.textContent = scopeCss(el.textContent || '', `.${scopeClass}`);
}
return `<div class="${scopeClass}">${container.innerHTML}</div>`;
}
export function purifyHtml(input: string): string { export function purifyHtml(input: string): string {
// Hook is added immediately before sanitize() and removed immediately // Hook is added immediately before sanitize() and removed immediately
// after, scoped tightly to this single call -- so it can never leak onto // after, scoped tightly to this single call -- so it can never leak onto
@@ -154,7 +230,8 @@ export function purifyHtml(input: string): string {
// multiple copies of the same hook. // multiple copies of the same hook.
DOMPurify.addHook('afterSanitizeAttributes', IFRAME_SANDBOX_HOOK); DOMPurify.addHook('afterSanitizeAttributes', IFRAME_SANDBOX_HOOK);
try { try {
return DOMPurify.sanitize(input || '', PURIFY_CONFIG as any) as unknown as string; const sanitized = DOMPurify.sanitize(input || '', PURIFY_CONFIG as any) as unknown as string;
return scopeStyleBlocks(sanitized, input || '');
} finally { } finally {
DOMPurify.removeHook('afterSanitizeAttributes', IFRAME_SANDBOX_HOOK as any); DOMPurify.removeHook('afterSanitizeAttributes', IFRAME_SANDBOX_HOOK as any);
} }
+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);
}