diff --git a/craft/src/components/basic/HtmlBlock.test.ts b/craft/src/components/basic/HtmlBlock.test.ts
index d35d786..4c46b9e 100644
--- a/craft/src/components/basic/HtmlBlock.test.ts
+++ b/craft/src/components/basic/HtmlBlock.test.ts
@@ -2,6 +2,16 @@ 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';
+// Ground truth "before" output: purifyHtml(fixtureHtml) computed with the
+// EXACT HtmlBlock.tsx code as it stood at commit 6a9b227 (the commit
+// immediately before Task 25 -- `git show
+// 6a9b227:craft/src/components/basic/HtmlBlock.tsx`), run against the real
+// dompurify+jsdom, not guessed at or re-derived from reading the code. See
+// the "byte-diff against 6a9b227" describe block below -- this is the
+// literal regression check the Task 25 review asked for, after the first
+// round of `.not.toContain(...)`-style tests passed while FORCE_BODY was
+// silently changing output for a comment-led, style-free fixture.
+import preTask25FixtureOutput from './__fixtures__/html-block-test-body.pre-task25-output.html?raw';
describe('purifyHtml', () => {
test('strips script tags', () => {
@@ -294,3 +304,97 @@ describe('purifyHtml -- Task 25: security properties of the newly-allowed
/);
+ });
+});
+
+describe('purifyHtml -- review finding: idempotent over its own prior output', () => {
+ test('running purifyHtml() twice (customer pastes previously-published output into a fresh block) does not nest a second wrapper', () => {
+ const code = '
Hi
';
+ const once = purifyHtml(code);
+ const twice = purifyHtml(once);
+ expect(twice).toBe(once);
+ // Specifically: no second wrapper div, no double-prefixed selector.
+ expect((twice.match(/
{
+ const code = '
Hi
';
+ const gen1 = purifyHtml(code);
+ const gen2 = purifyHtml(gen1);
+ const gen3 = purifyHtml(gen2);
+ expect(gen3).toBe(gen1);
+ });
+});
diff --git a/craft/src/components/basic/HtmlBlock.tsx b/craft/src/components/basic/HtmlBlock.tsx
index 1215c5a..b99129e 100644
--- a/craft/src/components/basic/HtmlBlock.tsx
+++ b/craft/src/components/basic/HtmlBlock.tsx
@@ -78,6 +78,21 @@ const PURIFY_CONFIG = {
// 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.
+ //
+ // Review note (Task 25 follow-up, documented not fixed): DOMPurify's
+ // SAFE_FOR_XML default (on unless a caller explicitly disables it,
+ // which PURIFY_CONFIG does not) silently drops an ENTIRE ` -- a very plausible
- // paste, style-before-markup is a common snippet shape -- would vanish
- // with no error anywhere, despite ` -- a very
+// plausible paste, style-before-markup is a common snippet shape -- would
+// vanish with no error anywhere, despite ...` wrapper. Without this check,
+ * `scopeStyleBlocks` would hash the NEW `code` to a NEW scope class, fail
+ * to recognise the embedded selectors as already scoped (they're prefixed
+ * for the OLD class, not the new one `scopeCss`'s own idempotency guard
+ * checks against), and nest a second wrapper div around the first while
+ * re-prefixing every selector under the new class on top of the old one.
+ *
+ * Detects "the sanitized content IS ALREADY exactly one of our own scoped
+ * wrappers": a single root element, a
, whose class matches our own
+ * naming convention, and whose `