diff --git a/.changeset/prefix-raw-html-ids.md b/.changeset/prefix-raw-html-ids.md new file mode 100644 index 00000000..280f051d --- /dev/null +++ b/.changeset/prefix-raw-html-ids.md @@ -0,0 +1,5 @@ +--- +"streamdown": patch +--- + +fix: restore `user-content-` clobberPrefix on sanitize so raw HTML ids cannot clobber `window`, and strip the doubled prefix on footnote ids that remark-rehype already prefixed. diff --git a/packages/streamdown/__tests__/allowed-tags.test.tsx b/packages/streamdown/__tests__/allowed-tags.test.tsx index a941971a..ad33dade 100644 --- a/packages/streamdown/__tests__/allowed-tags.test.tsx +++ b/packages/streamdown/__tests__/allowed-tags.test.tsx @@ -193,10 +193,12 @@ Content for snippet 2 ); - // Sanitizer's clobberPrefix is disabled to keep footnote backref hrefs in - // sync with their targets, so user-provided ids are preserved as-is. - const snippet1 = container.querySelector('[data-testid="snippet-1"]'); - const snippet2 = container.querySelector('[data-testid="snippet-2"]'); + const snippet1 = container.querySelector( + '[data-testid="snippet-user-content-1"]' + ); + const snippet2 = container.querySelector( + '[data-testid="snippet-user-content-2"]' + ); expect(snippet1).toBeTruthy(); expect(snippet2).toBeTruthy(); // Ensure snippet 2's content isn't absorbed into snippet 1 diff --git a/packages/streamdown/__tests__/anchor-links.test.tsx b/packages/streamdown/__tests__/anchor-links.test.tsx new file mode 100644 index 00000000..dcd3c3c7 --- /dev/null +++ b/packages/streamdown/__tests__/anchor-links.test.tsx @@ -0,0 +1,67 @@ +import { render } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { Streamdown } from "../index"; + +describe("Sanitized fragment links", () => { + it("rewrites raw HTML fragments but leaves prefixed and external links alone", () => { + const { container } = render( + + { + '
Details
\n\nRaw\n\n[Prefixed](#user-content-details)\n\n[External](https://example.com/#details)' + } +
+ ); + + expect(container.querySelector("[id]")?.id).toBe("user-content-details"); + expect( + Array.from(container.querySelectorAll("a[href]"), (link) => + link.getAttribute("href") + ) + ).toEqual([ + "#user-content-details", + "#user-content-details", + "https://example.com/#details", + ]); + }); + + it("keeps footnote references and backrefs aligned", () => { + const { container } = render( + + {"Note[^1].\n\n[^1]: Details."} + + ); + + const links = container.querySelectorAll('a[href^="#"]'); + expect(links.length).toBe(2); + for (const link of links) { + expect( + container.querySelector(link.getAttribute("href") ?? "") + ).toBeTruthy(); + expect(link.getAttribute("href")).not.toContain( + "user-content-user-content-" + ); + } + }); + + for (const mode of ["static", "streaming"] as const) { + for (const allowedTags of [undefined, { snippet: ["id"] }]) { + it(`keeps raw HTML targets reachable in ${mode} mode (${allowedTags ? "extended" : "default"} schema)`, () => { + const { container } = render( + + {'
Details
\n\n[Go](#details)'} +
+ ); + + const target = container.querySelector("[id]"); + const link = container.querySelector("a"); + + expect(target?.id).toBe("user-content-details"); + expect(link?.getAttribute("href")).toBe(`#${target?.id}`); + }); + } + } +}); diff --git a/packages/streamdown/__tests__/custom-tag-markdown.test.tsx b/packages/streamdown/__tests__/custom-tag-markdown.test.tsx index 85661f1a..5d36acf2 100644 --- a/packages/streamdown/__tests__/custom-tag-markdown.test.tsx +++ b/packages/streamdown/__tests__/custom-tag-markdown.test.tsx @@ -125,8 +125,12 @@ Content for snippet 2 ); - const snippet1 = container.querySelector('[data-testid="snippet-1"]'); - const snippet2 = container.querySelector('[data-testid="snippet-2"]'); + const snippet1 = container.querySelector( + '[data-testid="snippet-user-content-1"]' + ); + const snippet2 = container.querySelector( + '[data-testid="snippet-user-content-2"]' + ); expect(snippet1).toBeTruthy(); expect(snippet2).toBeTruthy(); expect(snippet1?.textContent).not.toContain("Snippet 2"); diff --git a/packages/streamdown/__tests__/footnotes.test.tsx b/packages/streamdown/__tests__/footnotes.test.tsx index f8ede71c..3c95a27d 100644 --- a/packages/streamdown/__tests__/footnotes.test.tsx +++ b/packages/streamdown/__tests__/footnotes.test.tsx @@ -178,6 +178,15 @@ GFM extends standard Markdown with powerful features[^1]. Here's a comprehensive } }); + it("prefixes raw HTML ids so they cannot clobber window", () => { + const markdown = '
raw
'; + const { container } = render({markdown}); + + expect(container.querySelector("#user-content-clobbered")).toBeTruthy(); + expect(container.querySelector("#clobbered")).toBeNull(); + expect((globalThis as { clobbered?: unknown }).clobbered).toBeUndefined(); + }); + it("should show footnotes once content arrives", () => { // Simulate streaming where definition has partial content const markdown = `Text with footnote[^1]. diff --git a/packages/streamdown/index.tsx b/packages/streamdown/index.tsx index e25fe043..a0ba7788 100644 --- a/packages/streamdown/index.tsx +++ b/packages/streamdown/index.tsx @@ -391,14 +391,52 @@ export type StreamdownProps = Options & { | undefined; } & AriaAttributes; +const CLOBBER_PREFIX = "user-content-"; + +interface HastNode { + children?: HastNode[]; + properties?: { id?: unknown; href?: unknown }; + type?: string; +} + +/** Normalize sanitized ids and keep fragment links aligned with their targets. */ +function rehypeUndoubleClobberPrefix() { + const doubled = `${CLOBBER_PREFIX}${CLOBBER_PREFIX}`; + const visit = (node: HastNode) => { + if ( + node.type === "element" && + typeof node.properties?.id === "string" && + node.properties.id.startsWith(doubled) + ) { + node.properties.id = node.properties.id.slice(CLOBBER_PREFIX.length); + } + // Fragment targets are sanitized too. Rewrite even when the target is in + // another streaming block, but leave already-prefixed footnote links alone. + if ( + node.type === "element" && + typeof node.properties?.href === "string" && + node.properties.href.startsWith("#") && + node.properties.href.length > 1 && + !node.properties.href.startsWith(`#${CLOBBER_PREFIX}`) + ) { + node.properties.href = `#${CLOBBER_PREFIX}${node.properties.href.slice(1)}`; + } + if (Array.isArray(node.children)) { + for (const child of node.children) { + visit(child); + } + } + }; + return (tree: HastNode) => { + visit(tree); + }; +} + const defaultSanitizeSchema = { ...defaultSchema, - // remark-rehype already prefixes footnote ids and backref hrefs with - // `user-content-` (its default `clobberPrefix`). hast-util-sanitize's default - // `clobberPrefix` is also `user-content-`, which would double-prefix ids like - // `user-content-user-content-fn-1` while leaving the (already-prefixed) href - // pointing at the un-doubled anchor. Disable it here to avoid the mismatch. - clobberPrefix: "", + // Keep the default `user-content-` clobberPrefix so raw HTML ids (from + // rehype-raw) cannot clobber `window`. remark-rehype already prefixes + // footnote ids; `rehypeUndoubleClobberPrefix` strips the doubled prefix. protocols: { ...defaultSchema.protocols, // `streamdown:` is the remend sentinel scheme for incomplete links/images @@ -415,6 +453,7 @@ const defaultSanitizeSchema = { export const defaultRehypePlugins: Record = { raw: rehypeRaw, sanitize: [rehypeSanitize, defaultSanitizeSchema], + undoubleClobberPrefix: rehypeUndoubleClobberPrefix, harden: [ harden, { @@ -1028,6 +1067,7 @@ export const Streamdown = memo( result = [ defaultRehypePlugins.raw, [rehypeSanitize, extendedSchema], + defaultRehypePlugins.undoubleClobberPrefix, defaultRehypePlugins.harden, ]; }