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;