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
5 changes: 5 additions & 0 deletions .changeset/remend-link-block-scope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"remend": patch
---

Complete unfinished links and images only in the current block: the paragraph, list item, heading or table row at the end of the text. An unclosed `[` or `](` in an earlier block no longer adds `](streamdown:incomplete-link)` to later text, or drops the text after it.
72 changes: 72 additions & 0 deletions packages/remend/__tests__/link-block-scope.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import remend from "../src";
import { findLastInlineBlockStart } from "../src/link-image-handler";

describe("link healing stays in the last inline block", () => {
it("does not close a bracket from an earlier paragraph", () => {
expect(remend("See [1 for details.\n\nThe next paragraph")).toBe(
"See [1 for details.\n\nThe next paragraph"
);
expect(remend("Arrays use x[i notation.\n\nThen we")).toBe(
"Arrays use x[i notation.\n\nThen we"
);
});

it("keeps later paragraphs after an earlier unfinished URL", () => {
const text =
"Read [the docs](https://example.com/guide\n\nNext paragraph keeps streaming";
expect(remend(text)).toBe(text);
});

it('does not pair a later "](" with a bracket from an earlier block', () => {
const text = "Text [a\n\nb](https://exa";
expect(remend(text)).toBe(text);
});

it("does not move an image's later paragraphs into its alt text", () => {
expect(remend("![diagram\n\nThe caption follows")).toBe(
"![diagram\n\nThe caption follows"
);
});

it("treats list items, headings and table rows as separate blocks", () => {
expect(remend("- item [one\n- item two")).toBe("- item [one\n- item two");
expect(remend("# Title [x\nBody text")).toBe("# Title [x\nBody text");
expect(remend("| a [b | c |\n| d | e")).toBe("| a [b | c |\n| d | e");
});

it("still heals an incomplete link in the current block", () => {
expect(remend("Done.\n\nSee [the docs")).toBe(
"Done.\n\nSee [the docs](streamdown:incomplete-link)"
);
expect(remend("See [1 here.\n\nRead [the docs](https://exa")).toBe(
"See [1 here.\n\nRead [the docs](streamdown:incomplete-link)"
);
expect(remend("- item [one\n- item [two")).toBe(
"- item [one\n- item [two](streamdown:incomplete-link)"
);
expect(remend("> quote [link\n> continues")).toBe(
"> quote [link\n> continues](streamdown:incomplete-link)"
);
expect(remend("- item [link\n continues")).toBe(
"- item [link\n continues](streamdown:incomplete-link)"
);
});

it("leaves earlier brackets alone in text-only mode", () => {
expect(
remend("See [1 for details.\n\nRead [the docs", { linkMode: "text-only" })
).toBe("See [1 for details.\n\nRead the docs");
});

it("finds the start of the last inline block", () => {
expect(findLastInlineBlockStart("one\ntwo")).toBe(0);
expect(findLastInlineBlockStart("one\n\ntwo")).toBe(5);
expect(findLastInlineBlockStart("one\n\n")).toBe(5);
expect(findLastInlineBlockStart("- a\n- b")).toBe(4);
expect(findLastInlineBlockStart("# h\ntext")).toBe(4);
expect(findLastInlineBlockStart("text\n# h")).toBe(5);
expect(findLastInlineBlockStart("```\ncode\n```\nafter")).toBe(13);
expect(findLastInlineBlockStart("a\n---\nb")).toBe(6);
});
});
84 changes: 73 additions & 11 deletions packages/remend/src/link-image-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ export const INCOMPLETE_IMAGE_PLACEHOLDER = "streamdown:incomplete-image";
const handleIncompleteUrl = (
text: string,
lastParenIndex: number,
linkMode: LinkMode
linkMode: LinkMode,
blockStart: number
): string | null => {
const afterParen = text.substring(lastParenIndex + 2);
if (afterParen.includes(")")) {
Expand All @@ -27,7 +28,11 @@ const handleIncompleteUrl = (
// Now find the matching opening bracket for the ] before (
const openBracketIndex = findMatchingOpeningBracket(text, lastParenIndex);

if (openBracketIndex === -1 || isInsideCodeBlock(text, openBracketIndex)) {
// The link text must start in the same block as its "]("
if (
openBracketIndex < blockStart ||
isInsideCodeBlock(text, openBracketIndex)
) {
return null;
}

Expand All @@ -53,10 +58,54 @@ const handleIncompleteUrl = (
return `${beforeLink}[${altOrLinkText}](streamdown:incomplete-link)`;
};

const blankLinePattern = /^[ \t]*$/;
const fenceLinePattern = /^ {0,3}(?:`{3,}|~{3,})/;
const thematicBreakPattern =
/^ {0,3}(?:(?:\*[ \t]*){3,}|(?:-[ \t]*){3,}|(?:_[ \t]*){3,}|=+[ \t]*)$/;
const headingPattern = /^ {0,3}#{1,6}(?:[ \t]|$)/;
const blockStartPattern = /^[ \t]*(?:[*+-]|\d{1,9}[.)])(?:[ \t]|$)|^ {0,3}\|/;

// Index where the last inline block starts. Link text cannot continue past a
// blank line, a fence, a thematic break or setext underline, a heading, a
// list item, or a table row, so brackets before this index are literal.
// Scans backwards, so the cost is proportional to the last block.
export const findLastInlineBlockStart = (text: string): number => {
let lineEnd = text.length;
let isLastLine = true;
while (lineEnd >= 0) {
const lineStart = text.lastIndexOf("\n", lineEnd - 1) + 1;
const line = text.slice(lineStart, lineEnd);
if (
!isLastLine &&
(blankLinePattern.test(line) ||
fenceLinePattern.test(line) ||
thematicBreakPattern.test(line))
) {
return lineEnd + 1;
}
if (headingPattern.test(line)) {
return isLastLine ? lineStart : lineEnd + 1;
}
if (blockStartPattern.test(line)) {
return lineStart;
}
if (lineStart === 0) {
return 0;
}
lineEnd = lineStart - 1;
isLastLine = false;
}
return 0;
};

// Helper to find the first incomplete [ (for text-only mode)
// Always returns a valid index since callers guarantee text[maxPos] is an incomplete [
const findFirstIncompleteBracket = (text: string, maxPos: number): number => {
for (let j = 0; j < maxPos; j++) {
const findFirstIncompleteBracket = (
text: string,
maxPos: number,
blockStart: number
): number => {
for (let j = blockStart; j < maxPos; j++) {
if (text[j] === "[" && !isInsideCodeBlock(text, j)) {
// Skip if it's an image
if (j > 0 && text[j - 1] === "!") {
Expand Down Expand Up @@ -86,7 +135,8 @@ const findFirstIncompleteBracket = (text: string, maxPos: number): number => {
const handleIncompleteText = (
text: string,
i: number,
linkMode: LinkMode
linkMode: LinkMode,
blockStart: number
): string | null => {
// Check if there's a ! before it
const isImage = i > 0 && text[i - 1] === "!";
Expand All @@ -107,7 +157,7 @@ const handleIncompleteText = (
// For links, handle based on linkMode
if (linkMode === "text-only") {
// Find the first incomplete [ and strip just that bracket
const firstIncomplete = findFirstIncompleteBracket(text, i);
const firstIncomplete = findFirstIncompleteBracket(text, i, blockStart);
return (
text.substring(0, firstIncomplete) + text.substring(firstIncomplete + 1)
);
Expand All @@ -131,7 +181,7 @@ const handleIncompleteText = (

if (linkMode === "text-only") {
// Find the first incomplete [ and strip just that bracket
const firstIncomplete = findFirstIncompleteBracket(text, i);
const firstIncomplete = findFirstIncompleteBracket(text, i, blockStart);
return (
text.substring(0, firstIncomplete) + text.substring(firstIncomplete + 1)
);
Expand All @@ -147,10 +197,22 @@ const healTrailingLinkOrImage = (text: string, linkMode: LinkMode): string => {
// Look for patterns like [text]( or ![text]( at the end of text
// We need to handle nested brackets in the link text

// Brackets in earlier blocks are literal text. Healing them would append a
// closer to the wrong block, or drop every block after them.
const blockStart = findLastInlineBlockStart(text);

// Start from the end and look for ]( pattern
const lastParenIndex = text.lastIndexOf("](");
if (lastParenIndex !== -1 && !isInsideCodeBlock(text, lastParenIndex)) {
const result = handleIncompleteUrl(text, lastParenIndex, linkMode);
if (
lastParenIndex >= blockStart &&
!isInsideCodeBlock(text, lastParenIndex)
) {
const result = handleIncompleteUrl(
text,
lastParenIndex,
linkMode,
blockStart
);
if (result !== null) {
return result;
}
Expand All @@ -160,11 +222,11 @@ const healTrailingLinkOrImage = (text: string, linkMode: LinkMode): string => {
// Search backwards for an opening bracket that doesn't have a matching closing bracket
for (
let i = text.lastIndexOf("[");
i !== -1;
i >= blockStart;
i = i === 0 ? -1 : text.lastIndexOf("[", i - 1)
) {
if (!isInsideCodeBlock(text, i)) {
const result = handleIncompleteText(text, i, linkMode);
const result = handleIncompleteText(text, i, linkMode, blockStart);
if (result !== null) {
return result;
}
Expand Down
Loading