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
@@ -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');
});
});