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"],