From 2ded37fc8fa77343a78a983966c691236bf0bc65 Mon Sep 17 00:00:00 2001 From: xuzhenzhen Date: Wed, 22 Jul 2026 10:44:02 +0800 Subject: [PATCH] fix(sanitize): prevent double `user-content-` prefix on footnote ids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Footnote and heading ids were rendered with a doubled prefix (e.g. `user-content-user-content-fn-1`) because both `remark-rehype` and `rehype-sanitize` default their `clobberPrefix` to `user-content-`, so the prefix was applied twice in the pipeline. remark-rehype emits footnote backref hrefs (`href="#fn-1"`) coordinated with its own `clobberPrefix`, so disabling the prefix upstream would desync hrefs from their targets and break footnote navigation. Disable `clobberPrefix` on rehype-sanitize instead, letting remark-rehype own the single, consistent prefixing of both hrefs and ids. - index.tsx: set `clobberPrefix: ""` on `defaultSanitizeSchema` - __tests__/footnotes.test.tsx: add regression test asserting footnote ids are prefixed exactly once - __tests__/allowed-tags.test.tsx: update assertions — user-provided ids on custom tags are no longer clobbered by sanitize --- ...fix-footnote-double-user-content-prefix.md | 7 +++++ .../__tests__/allowed-tags.test.tsx | 11 +++---- .../streamdown/__tests__/footnotes.test.tsx | 29 +++++++++++++++++++ packages/streamdown/index.tsx | 6 ++++ 4 files changed, 46 insertions(+), 7 deletions(-) create mode 100644 .changeset/fix-footnote-double-user-content-prefix.md diff --git a/.changeset/fix-footnote-double-user-content-prefix.md b/.changeset/fix-footnote-double-user-content-prefix.md new file mode 100644 index 00000000..ec40112a --- /dev/null +++ b/.changeset/fix-footnote-double-user-content-prefix.md @@ -0,0 +1,7 @@ +--- +"streamdown": patch +--- + +Fix doubled `user-content-` prefix on footnote ids + +Footnote list items were rendered with `id="user-content-user-content-fn-1"` because both `remark-rehype` and `rehype-sanitize` default their `clobberPrefix` to `user-content-`, so the prefix was applied twice. Disabled `clobberPrefix` on `rehype-sanitize` so `remark-rehype` remains the single, consistent prefixer of both footnote ids and backref hrefs — restoring working footnote navigation. diff --git a/packages/streamdown/__tests__/allowed-tags.test.tsx b/packages/streamdown/__tests__/allowed-tags.test.tsx index c1a25471..a941971a 100644 --- a/packages/streamdown/__tests__/allowed-tags.test.tsx +++ b/packages/streamdown/__tests__/allowed-tags.test.tsx @@ -193,13 +193,10 @@ Content for snippet 2 ); - // rehype-sanitize prefixes id attributes with "user-content-" - const snippet1 = container.querySelector( - '[data-testid="snippet-user-content-1"]' - ); - const snippet2 = container.querySelector( - '[data-testid="snippet-user-content-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"]'); expect(snippet1).toBeTruthy(); expect(snippet2).toBeTruthy(); // Ensure snippet 2's content isn't absorbed into snippet 1 diff --git a/packages/streamdown/__tests__/footnotes.test.tsx b/packages/streamdown/__tests__/footnotes.test.tsx index bf8c09da..f8ede71c 100644 --- a/packages/streamdown/__tests__/footnotes.test.tsx +++ b/packages/streamdown/__tests__/footnotes.test.tsx @@ -149,6 +149,35 @@ GFM extends standard Markdown with powerful features[^1]. Here's a comprehensive } }); + it("should not double-prefix footnote ids with user-content-", () => { + const markdown = `First[^1] and second[^note2]. + +[^1]: First footnote content. +[^note2]: Second footnote content.`; + + const { container } = render({markdown}); + + // Footnote list item ids should be prefixed with `user-content-` exactly once + // (not `user-content-user-content-fn-1`). + expect(container.querySelector("#user-content-fn-1")).toBeTruthy(); + expect(container.querySelector("#user-content-fn-note2")).toBeTruthy(); + expect( + container.querySelector("#user-content-user-content-fn-1") + ).toBeNull(); + expect( + container.querySelector("#user-content-user-content-fn-note2") + ).toBeNull(); + + // Footnote label heading id should also not be doubled. + const labelIds = Array.from(container.querySelectorAll("[id]")) + .map((el) => el.id) + .filter((id) => id.includes("footnote-label")); + expect(labelIds.length).toBeGreaterThan(0); + for (const id of labelIds) { + expect(id.startsWith("user-content-user-content-")).toBe(false); + } + }); + 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 be32d9fd..2fa93ebc 100644 --- a/packages/streamdown/index.tsx +++ b/packages/streamdown/index.tsx @@ -237,6 +237,12 @@ export type StreamdownProps = Options & { 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: "", protocols: { ...defaultSchema.protocols, href: [...(defaultSchema.protocols?.href ?? []), "tel"],