Skip to content
Merged
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/resolve-cross-block-link-references.md
Original file line number Diff line number Diff line change
@@ -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.
81 changes: 81 additions & 0 deletions packages/streamdown/__tests__/link-references.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<Streamdown
linkSafety={{ enabled: false }}
parseIncompleteMarkdown={false}
>
{markdown}
</Streamdown>
);

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(
<Streamdown
linkSafety={{ enabled: false }}
parseIncompleteMarkdown={false}
>
{markdown}
</Streamdown>
);

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]);
});
});
44 changes: 34 additions & 10 deletions packages/streamdown/lib/parse-blocks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;

Expand Down
Loading