Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/fix-footnote-double-user-content-prefix.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 4 additions & 7 deletions packages/streamdown/__tests__/allowed-tags.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -193,13 +193,10 @@ Content for snippet 2
</Streamdown>
);

// 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
Expand Down
29 changes: 29 additions & 0 deletions packages/streamdown/__tests__/footnotes.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<Streamdown>{markdown}</Streamdown>);

// 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].
Expand Down
6 changes: 6 additions & 0 deletions packages/streamdown/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: "",
Comment thread
helloyou2012 marked this conversation as resolved.
protocols: {
...defaultSchema.protocols,
href: [...(defaultSchema.protocols?.href ?? []), "tel"],
Expand Down