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
6 changes: 6 additions & 0 deletions .changeset/incomplete-image-placeholder.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"remend": patch
"streamdown": patch
---

Incomplete images during streaming now render a loading placeholder instead of being removed entirely. Incomplete images (e.g. `![alt](https://exampl`) are replaced with `![alt](streamdown:incomplete-image)` by remend, and the streamdown `ImageComponent` renders an animated skeleton for this special URL. This mirrors the existing behavior for incomplete links (`streamdown:incomplete-link`).
5 changes: 5 additions & 0 deletions .changeset/line-numbers-option.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"streamdown": minor
---

Add `lineNumbers` prop to disable line numbers in code blocks
30 changes: 21 additions & 9 deletions packages/remend/__tests__/images.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ import { describe, expect, it } from "vitest";
import remend from "../src";

describe("image handling", () => {
it("should remove incomplete images", () => {
expect(remend("Text with ![incomplete image")).toBe("Text with ");
expect(remend("![partial")).toBe("");
it("should replace incomplete images with placeholder", () => {
expect(remend("Text with ![incomplete image")).toBe(
"Text with ![incomplete image](streamdown:incomplete-image)"
);
expect(remend("![partial")).toBe("![partial](streamdown:incomplete-image)");
});

it("should keep complete images unchanged", () => {
Expand All @@ -13,17 +15,27 @@ describe("image handling", () => {
});

it("should handle partial image at chunk boundary", () => {
expect(remend("See ![the diag")).toBe("See ");
// Images with partial URLs should be removed (images can't show skeleton)
expect(remend("![logo](./assets/log")).toBe("");
expect(remend("See ![the diag")).toBe(
"See ![the diag](streamdown:incomplete-image)"
);
// Images with partial URLs should use placeholder (not removed)
expect(remend("![logo](./assets/log")).toBe(
"![logo](streamdown:incomplete-image)"
);
});

it("should handle nested brackets in incomplete images", () => {
// When findMatchingClosingBracket returns -1 for an image (lines 74-79)
// For this to happen, we need an opening bracket with a ] but no proper matching
expect(remend("Text ![outer [inner]")).toBe("Text ");
expect(remend("![nested [brackets] text")).toBe("");
expect(remend("Start ![foo [bar] baz")).toBe("Start ");
expect(remend("Text ![outer [inner]")).toBe(
"Text ![outer [inner]](streamdown:incomplete-image)"
);
expect(remend("![nested [brackets] text")).toBe(
"![nested [brackets] text](streamdown:incomplete-image)"
);
expect(remend("Start ![foo [bar] baz")).toBe(
"Start ![foo [bar] baz](streamdown:incomplete-image)"
);
});

it("should not add trailing underscore for images with underscores in URL (#284)", () => {
Expand Down
13 changes: 8 additions & 5 deletions packages/remend/__tests__/links.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,10 +127,13 @@ describe("link handling with linkMode: text-only", () => {
);
});

it("should still remove incomplete images", () => {
// Images should still be removed entirely, regardless of linkMode
// Note: the space before the image is preserved
expect(remend("Text ![incomplete image", textOnlyOptions)).toBe("Text ");
expect(remend("Text ![alt](http://partial", textOnlyOptions)).toBe("Text ");
it("should still use placeholder for incomplete images regardless of linkMode", () => {
// Images use placeholder even in text-only mode (images can't show text-only)
expect(remend("Text ![incomplete image", textOnlyOptions)).toBe(
"Text ![incomplete image](streamdown:incomplete-image)"
);
expect(remend("Text ![alt](http://partial", textOnlyOptions)).toBe(
"Text ![alt](streamdown:incomplete-image)"
);
});
});
6 changes: 4 additions & 2 deletions packages/remend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export interface RemendOptions {
handlers?: RemendHandler[];
/** Strip incomplete HTML tags at end of streaming text (e.g., `text <custom` → `text`) */
htmlTags?: boolean;
/** Complete images (e.g., `![alt](url` → removed) */
/** Complete images (e.g., `![alt](url` → `![alt](streamdown:incomplete-image)`) */
images?: boolean;
/** Complete inline code formatting (e.g., `` `code `` → `` `code` ``) */
inlineCode?: boolean;
Expand Down Expand Up @@ -156,7 +156,9 @@ const builtInHandlers: Array<{
priority: PRIORITY.LINKS,
},
optionKey: "links",
earlyReturn: (result) => result.endsWith("](streamdown:incomplete-link)"),
earlyReturn: (result) =>
result.endsWith("](streamdown:incomplete-link)") ||
result.endsWith("](streamdown:incomplete-image)"),
},
{
handler: {
Expand Down
21 changes: 13 additions & 8 deletions packages/remend/src/link-image-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,17 +32,19 @@ const handleIncompleteUrl = (
// Extract everything before this link/image
const beforeLink = text.substring(0, startIndex);

// Extract the text between [ and ] (alt text for images, link text for links)
const altOrLinkText = text.substring(openBracketIndex + 1, lastParenIndex);

if (isImage) {
// For images with incomplete URLs, remove them entirely
return beforeLink;
// For images with incomplete URLs, replace with placeholder marker
return `${beforeLink}![${altOrLinkText}](streamdown:incomplete-image)`;
}

// For links with incomplete URLs, handle based on linkMode
const linkText = text.substring(openBracketIndex + 1, lastParenIndex);
if (linkMode === "text-only") {
return `${beforeLink}${linkText}`;
return `${beforeLink}${altOrLinkText}`;
}
return `${beforeLink}[${linkText}](streamdown:incomplete-link)`;
return `${beforeLink}[${altOrLinkText}](streamdown:incomplete-link)`;
};

// Helper to find the first incomplete [ (for text-only mode)
Expand Down Expand Up @@ -91,8 +93,9 @@ const handleIncompleteText = (
const beforeLink = text.substring(0, openIndex);

if (isImage) {
// For images, we remove them as they can't show skeleton
return beforeLink;
// For images with incomplete alt text, replace with placeholder marker
const altText = text.substring(i + 1);
return `${beforeLink}![${altText}](streamdown:incomplete-image)`;
}

// For links, handle based on linkMode
Expand All @@ -115,7 +118,9 @@ const handleIncompleteText = (
const beforeLink = text.substring(0, openIndex);

if (isImage) {
return beforeLink;
// For images with no matching closing bracket, replace with placeholder marker
const altText = text.substring(i + 1);
return `${beforeLink}![${altText}](streamdown:incomplete-image)`;
}

if (linkMode === "text-only") {
Expand Down
140 changes: 140 additions & 0 deletions packages/streamdown/__tests__/code-block-line-numbers.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { render } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Streamdown } from "../index";
import { CodeBlock } from "../lib/code-block";
import { CodeBlockBody } from "../lib/code-block/body";

describe("line numbers", () => {
const baseResult = {
tokens: [[{ content: "const x = 1;", color: "#000" }]],
bg: "#fff",
fg: "#000",
};

describe("CodeBlockBody", () => {
it("shows line numbers by default", () => {
const { container } = render(
<CodeBlockBody language="js" result={baseResult} />
);
const span = container.querySelector("code span");
// The line number classes include before:content-[counter(line)]
expect(span?.className).toContain("before:content-[counter(line)]");
});

it("hides line numbers when lineNumbers={false}", () => {
const { container } = render(
<CodeBlockBody language="js" lineNumbers={false} result={baseResult} />
);
const span = container.querySelector("code span");
expect(span?.className ?? "").not.toContain(
"before:content-[counter(line)]"
);
});

it("applies counter CSS classes to <code> when lineNumbers={true}", () => {
const { container } = render(
<CodeBlockBody language="js" lineNumbers={true} result={baseResult} />
);
const code = container.querySelector("code");
expect(code?.className).toContain("[counter-reset:line]");
});

it("does not apply counter CSS classes to <code> when lineNumbers={false}", () => {
const { container } = render(
<CodeBlockBody language="js" lineNumbers={false} result={baseResult} />
);
const code = container.querySelector("code");
expect(code?.className ?? "").not.toContain("[counter-reset:line]");
});

it("does not apply counterReset style when lineNumbers={false} and startLine is set", () => {
const { container } = render(
<CodeBlockBody
language="js"
lineNumbers={false}
result={baseResult}
startLine={5}
/>
);
const code = container.querySelector("code");
expect(code?.getAttribute("style") ?? "").not.toContain("counter-reset");
});

it("applies counterReset style when lineNumbers={true} and startLine > 1", () => {
const { container } = render(
<CodeBlockBody
language="js"
lineNumbers={true}
result={baseResult}
startLine={5}
/>
);
const code = container.querySelector("code");
expect(code?.getAttribute("style")).toContain("counter-reset");
});
});

describe("CodeBlock component", () => {
it("renders with line numbers by default", () => {
const { container } = render(
<CodeBlock code="const x = 1;" language="js" />
);
const body = container.querySelector(
'[data-streamdown="code-block-body"]'
);
expect(body).toBeTruthy();
});

it("renders without line numbers when lineNumbers={false}", () => {
const { container } = render(
<CodeBlock code="const x = 1;" language="js" lineNumbers={false} />
);
const code = container.querySelector("code");
expect(code?.className ?? "").not.toContain("[counter-reset:line]");
});
});

describe("Streamdown component", () => {
const markdown = "```js\nconst x = 1;\n```";

it("shows line numbers by default", () => {
const { container } = render(<Streamdown>{markdown}</Streamdown>);
const code = container.querySelector("code");
expect(code?.className).toContain("[counter-reset:line]");
});

it("hides line numbers when lineNumbers={false}", () => {
const { container } = render(
<Streamdown lineNumbers={false}>{markdown}</Streamdown>
);
const code = container.querySelector("code");
expect(code?.className ?? "").not.toContain("[counter-reset:line]");
});

it("shows line numbers when lineNumbers={true} (explicit)", () => {
const { container } = render(
<Streamdown lineNumbers={true}>{markdown}</Streamdown>
);
const code = container.querySelector("code");
expect(code?.className).toContain("[counter-reset:line]");
});

it("hides line numbers for noLineNumbers meta-string", () => {
const { container } = render(
<Streamdown>{"```js noLineNumbers\nconst x = 1;\n```"}</Streamdown>
);
const code = container.querySelector("code");
expect(code?.className ?? "").not.toContain("[counter-reset:line]");
});

it("noLineNumbers meta overrides lineNumbers={true} context", () => {
const { container } = render(
<Streamdown lineNumbers={true}>
{"```js noLineNumbers\nconst x = 1;\n```"}
</Streamdown>
);
const code = container.querySelector("code");
expect(code?.className ?? "").not.toContain("[counter-reset:line]");
});
});
});
21 changes: 21 additions & 0 deletions packages/streamdown/__tests__/components.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,27 @@ describe("Markdown Components", () => {
expect(link?.textContent).toBe("Incomplete link text");
});

it("should render incomplete image placeholder when src is streamdown:incomplete-image", () => {
const Img = components.img;
if (!Img) {
throw new Error("Img component not found");
}
const { container } = render(
<Img
alt="loading"
node={null as any}
src="streamdown:incomplete-image"
/>
);
const placeholder = container.querySelector(
'[data-streamdown="image-placeholder"]'
);
expect(placeholder).toBeTruthy();

const wrapper = container.querySelector('[data-streamdown="image-wrapper"]');
expect(wrapper?.getAttribute("data-incomplete")).toBe("true");
});

it("should render blockquote with correct classes", () => {
const Blockquote = components.blockquote;
if (!Blockquote) {
Expand Down
55 changes: 55 additions & 0 deletions packages/streamdown/__tests__/image.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -414,3 +414,58 @@ describe("ImageComponent", () => {
expect(img?.getAttribute("data-testid")).toBe("custom-image");
});
});

describe("incomplete image placeholder", () => {
it("should render a placeholder when src is streamdown:incomplete-image", () => {
const { container } = render(
<ImageComponent
alt="loading..."
node={null as any}
src="streamdown:incomplete-image"
/>
);

// Should NOT render an img tag
const img = container.querySelector('img[data-streamdown="image"]');
expect(img).toBeNull();

// Should render the placeholder div
const placeholder = container.querySelector(
'[data-streamdown="image-placeholder"]'
);
expect(placeholder).toBeTruthy();

// Wrapper should have data-incomplete="true"
const wrapper = container.querySelector('[data-streamdown="image-wrapper"]');
expect(wrapper?.getAttribute("data-incomplete")).toBe("true");
});

it("should not render download button for incomplete images", () => {
const { container } = render(
<ImageComponent
alt="loading..."
node={null as any}
src="streamdown:incomplete-image"
/>
);

const downloadButton = container.querySelector("button");
expect(downloadButton).toBeNull();
});

it("should render placeholder with correct CSS classes for animation", () => {
const { container } = render(
<ImageComponent
alt="loading..."
node={null as any}
src="streamdown:incomplete-image"
/>
);

const placeholder = container.querySelector(
'[data-streamdown="image-placeholder"]'
);
expect(placeholder?.className).toContain("animate-pulse");
expect(placeholder?.className).toContain("rounded-lg");
});
});
Loading
Loading