diff --git a/.changeset/resolve-cross-block-link-references.md b/.changeset/resolve-cross-block-link-references.md
new file mode 100644
index 00000000..db318635
--- /dev/null
+++ b/.changeset/resolve-cross-block-link-references.md
@@ -0,0 +1,7 @@
+---
+"streamdown": patch
+---
+
+fix: resolve reference links whose definition is in another block
+
+Documents with link reference definitions (`[label]: url`) are now kept in a single block, the same way documents with footnotes are, so `[text][label]` resolves instead of rendering as raw text.
diff --git a/packages/streamdown/__tests__/link-references.test.tsx b/packages/streamdown/__tests__/link-references.test.tsx
new file mode 100644
index 00000000..7cce5d3e
--- /dev/null
+++ b/packages/streamdown/__tests__/link-references.test.tsx
@@ -0,0 +1,81 @@
+import { render } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { parseMarkdownIntoBlocks, Streamdown } from "../index";
+
+// The incremental path only applies when the input extends the previous
+// input, so parsing an unrelated document first guarantees a full parse.
+const parseFresh = (markdown: string): string[] => {
+ parseMarkdownIntoBlocks("unrelated\n\ndocument\n");
+ return parseMarkdownIntoBlocks(markdown);
+};
+
+describe("link reference definitions", () => {
+ it("resolves a reference link whose definition is in a later block", () => {
+ const markdown =
+ "See [the docs][1] for details.\n\nMore text here.\n\n[1]: https://example.com/docs\n";
+
+ const { container } = render(
+
+ {markdown}
+
+ );
+
+ const link = container.querySelector('[data-streamdown="link"]');
+ expect(link?.getAttribute("href")).toBe("https://example.com/docs");
+ expect(link?.textContent).toBe("the docs");
+ expect(container.textContent).not.toContain("[the docs][1]");
+ expect(container.textContent).not.toContain("[1]:");
+ expect(container.textContent).toContain("More text here.");
+ });
+
+ it("resolves collapsed and shortcut references to an earlier definition", () => {
+ const markdown =
+ "[docs]: https://example.com/docs\n\nRead [docs][] or [docs].\n";
+
+ const { container } = render(
+
+ {markdown}
+
+ );
+
+ const links = container.querySelectorAll('[data-streamdown="link"]');
+ expect(links).toHaveLength(2);
+ for (const link of links) {
+ expect(link.getAttribute("href")).toBe("https://example.com/docs");
+ }
+ });
+
+ it("keeps documents with definitions in a single block", () => {
+ const markdown =
+ "See [the docs][1].\n\nMore text.\n\n> [1]: https://example.com\n";
+ expect(parseFresh(markdown)).toEqual([markdown]);
+ });
+
+ it("still splits documents without definitions", () => {
+ const markdown = "See [the docs](https://example.com).\n\nMore text.\n";
+ expect(parseFresh(markdown)).toHaveLength(2);
+ });
+
+ it("ignores definition-like lines inside code blocks", () => {
+ const markdown = "```\n[1]: not a definition\n```\n\nMore text.\n";
+ expect(parseFresh(markdown)).toHaveLength(2);
+ });
+
+ it("matches a full parse while a definition nested in a list streams in", () => {
+ const doc =
+ "Intro paragraph.\n\nSecond paragraph with [a link][b].\n\n1. item\n\n [b]: https://b.example\n\nAfter.\n";
+ parseMarkdownIntoBlocks("unrelated\n\ndocument\n");
+
+ for (let i = 1; i <= doc.length; i += 1) {
+ const prefix = doc.slice(0, i);
+ expect(parseMarkdownIntoBlocks(prefix)).toEqual(parseFresh(prefix));
+ }
+ expect(parseFresh(doc)).toEqual([doc]);
+ });
+});
diff --git a/packages/streamdown/lib/parse-blocks.tsx b/packages/streamdown/lib/parse-blocks.tsx
index 27256b1b..69029387 100644
--- a/packages/streamdown/lib/parse-blocks.tsx
+++ b/packages/streamdown/lib/parse-blocks.tsx
@@ -106,8 +106,20 @@ const lineEndingPattern = /\r\n|\r/g;
// `raw` text by its own remark pipeline later. `Lexer.lex` would also run the
// inline tokenizer over every block, which is wasted work, so call the block
// tokenizer directly.
-const lexBlocks = (markdown: string): Token[] =>
- new Lexer({ gfm: true }).blockTokens(markdown);
+//
+// The lexer also collects link reference definitions (`[label]: url`) from
+// every nesting level, but not from code, so `hasLinkDefinitions` tells
+// whether the blocks must be kept together.
+const lexBlocks = (
+ markdown: string
+): { tokens: Token[]; hasLinkDefinitions: boolean } => {
+ const lexer = new Lexer({ gfm: true });
+ const tokens = lexer.blockTokens(markdown);
+ return {
+ tokens,
+ hasLinkDefinitions: Object.keys(lexer.tokens.links).length > 0,
+ };
+};
// Streaming appends text to the end of the document. Text before the tail can
// still change meaning: a lone "#" is a heading that ends the paragraph above
@@ -291,9 +303,14 @@ const reuseParsedBlocks = (
return null;
}
- const tailBlocks = copyBlocks(
- mergeTokensIntoBlocks(lexBlocks(input.slice(verifiedLength)))
- );
+ const tail = lexBlocks(input.slice(verifiedLength));
+ // A definition the pattern above misses (inside a blockquote, or indented
+ // inside a list item) still needs the whole document as one block.
+ if (tail.hasLinkDefinitions) {
+ return null;
+ }
+
+ const tailBlocks = copyBlocks(mergeTokensIntoBlocks(tail.tokens));
return {
input,
@@ -332,14 +349,21 @@ export const parseMarkdownIntoBlocks = (markdown: string): string[] => {
}
const reused = lastParse ? reuseParsedBlocks(lastParse, input) : null;
- const entry =
- reused ??
- ({
+ let entry = reused;
+ if (!entry) {
+ const { tokens, hasLinkDefinitions } = lexBlocks(input);
+ // Each block is parsed into its own mdast tree, so a reference link like
+ // `[text][1]` only resolves when its `[1]: url` definition is in the same
+ // block. Like footnotes, keep documents with definitions in one block.
+ entry = {
input,
- blocks: copyBlocks(mergeTokensIntoBlocks(lexBlocks(input))),
+ blocks: hasLinkDefinitions
+ ? [copyString(input)]
+ : copyBlocks(mergeTokensIntoBlocks(tokens)),
verifiedCount: 0,
verifiedLength: 0,
- } satisfies ParseCache);
+ } satisfies ParseCache;
+ }
lastParse = entry;