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:
@@ -227,9 +227,18 @@ describe('purifyHtml -- Task 24: security properties on newly-allowed elements',
|
||||
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>');
|
||||
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');
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { purifyHtml } from './HtmlBlock';
|
||||
import { stableHash } from '../../utils/escape';
|
||||
import fixtureHtml from './__fixtures__/html-block-test-body.html?raw';
|
||||
|
||||
describe('purifyHtml', () => {
|
||||
test('strips script tags', () => {
|
||||
@@ -103,3 +105,192 @@ describe('purifyHtml iframe sandboxing (M-6)', () => {
|
||||
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 { HtmlBlock } from './HtmlBlock';
|
||||
import { HtmlBlock, purifyHtml } from './HtmlBlock';
|
||||
|
||||
const toHtml = (HtmlBlock as any).toHtml;
|
||||
|
||||
@@ -46,3 +46,24 @@ describe('HtmlBlock.toHtml markup path (C1 review finding)', () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React, { CSSProperties, useMemo } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { stableHash } from '../../utils/escape';
|
||||
import { scopeCss } from '../../utils/scope-css';
|
||||
|
||||
interface HtmlBlockProps {
|
||||
code: string;
|
||||
@@ -70,6 +72,13 @@ const PURIFY_CONFIG = {
|
||||
'line','polyline','polygon','path','text','tspan',
|
||||
'lineargradient','radialgradient','stop','clippath','mask','marker',
|
||||
'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
|
||||
// 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).
|
||||
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) --
|
||||
// they are now deliberately allowed above. style/script/object/embed/
|
||||
// link/meta stay forbidden; <style> in particular stays blocked even
|
||||
// inside the newly-allowed inline <svg> (a separate task is adding
|
||||
// scoped <style> support later -- see HtmlBlock.security.test.ts for the
|
||||
// svg><style> regression check).
|
||||
FORBID_TAGS: ['script','style','object','embed','link','meta'],
|
||||
// they are now deliberately allowed above. script/object/embed/link/meta
|
||||
// stay forbidden. <style> (Task 25) is now allowed too -- see ALLOWED_TAGS
|
||||
// comment above and scopeStyleBlocks() below; it survives sanitisation
|
||||
// here but its CSS gets scoped afterwards, including copies nested inside
|
||||
// the newly-allowed inline <svg> (querySelectorAll('style') in
|
||||
// scopeStyleBlocks() doesn't care about namespace/nesting depth).
|
||||
FORBID_TAGS: ['script','object','embed','link','meta'],
|
||||
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)
|
||||
@@ -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 {
|
||||
// Hook is added immediately before sanitize() and removed immediately
|
||||
// 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.
|
||||
DOMPurify.addHook('afterSanitizeAttributes', IFRAME_SANDBOX_HOOK);
|
||||
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 {
|
||||
DOMPurify.removeHook('afterSanitizeAttributes', IFRAME_SANDBOX_HOOK as any);
|
||||
}
|
||||
|
||||
@@ -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