diff --git a/.changeset/incomplete-image-placeholder.md b/.changeset/incomplete-image-placeholder.md new file mode 100644 index 00000000..2fdaff41 --- /dev/null +++ b/.changeset/incomplete-image-placeholder.md @@ -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`). diff --git a/packages/remend/__tests__/images.test.ts b/packages/remend/__tests__/images.test.ts index 0d4cdf02..e66cfd38 100644 --- a/packages/remend/__tests__/images.test.ts +++ b/packages/remend/__tests__/images.test.ts @@ -2,9 +2,13 @@ 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](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=)" + ); + expect(remend("![partial")).toBe( + "![partial](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=)" + ); }); it("should keep complete images unchanged", () => { @@ -13,17 +17,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](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=)" + ); + // Images with partial URLs should use placeholder (not removed) + expect(remend("![logo](./assets/log")).toBe( + "![logo](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=)" + ); }); 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]](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=)" + ); + expect(remend("![nested [brackets] text")).toBe( + "![nested [brackets] text](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=)" + ); + expect(remend("Start ![foo [bar] baz")).toBe( + "Start ![foo [bar] baz](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=)" + ); }); it("should not add trailing underscore for images with underscores in URL (#284)", () => { diff --git a/packages/remend/__tests__/links.test.ts b/packages/remend/__tests__/links.test.ts index bc0bb04e..b4c487d1 100644 --- a/packages/remend/__tests__/links.test.ts +++ b/packages/remend/__tests__/links.test.ts @@ -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](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=)" + ); + expect(remend("Text ![alt](http://partial", textOnlyOptions)).toBe( + "Text ![alt](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=)" + ); }); }); diff --git a/packages/remend/src/index.ts b/packages/remend/src/index.ts index 005c9fea..8c2d4932 100644 --- a/packages/remend/src/index.ts +++ b/packages/remend/src/index.ts @@ -14,14 +14,18 @@ import { } from "./katex-handler"; import { handleIncompleteLinksAndImages, + INCOMPLETE_IMAGE_PLACEHOLDER, type LinkMode, } from "./link-image-handler"; import { handleIncompleteSetextHeading } from "./setext-heading-handler"; import { handleSingleTildeEscape } from "./single-tilde-handler"; import { handleIncompleteStrikethrough } from "./strikethrough-handler"; -export type { LinkMode } from "./link-image-handler"; // biome-ignore lint/performance/noBarrelFile: "Re-exports utility functions for public API convenience" +export { + INCOMPLETE_IMAGE_PLACEHOLDER, + type LinkMode, +} from "./link-image-handler"; export { isWithinCodeBlock, isWithinLinkOrImageUrl, @@ -57,7 +61,7 @@ export interface RemendOptions { handlers?: RemendHandler[]; /** Strip incomplete HTML tags at end of streaming text (e.g., `text result.endsWith("](streamdown:incomplete-link)"), + earlyReturn: (result) => + result.endsWith("](streamdown:incomplete-link)") || + result.endsWith(`](${INCOMPLETE_IMAGE_PLACEHOLDER})`), }, { handler: { diff --git a/packages/remend/src/link-image-handler.ts b/packages/remend/src/link-image-handler.ts index 067fd45d..c457202f 100644 --- a/packages/remend/src/link-image-handler.ts +++ b/packages/remend/src/link-image-handler.ts @@ -6,6 +6,13 @@ import { export type LinkMode = "protocol" | "text-only"; +// 1x1 transparent PNG data URI used as a placeholder src for incomplete images. +// Using a data: URI (instead of a custom streamdown: scheme) ensures the +// placeholder survives strict URL-sanitizing rehype/remark plugins (e.g. +// rehype-harden) that only allow http(s)/data schemes for image src. +export const INCOMPLETE_IMAGE_PLACEHOLDER = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="; + // Helper function to handle incomplete URLs in links/images const handleIncompleteUrl = ( text: string, @@ -32,17 +39,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}](${INCOMPLETE_IMAGE_PLACEHOLDER})`; } // 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) @@ -91,8 +100,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}](${INCOMPLETE_IMAGE_PLACEHOLDER})`; } // For links, handle based on linkMode @@ -115,7 +125,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}](${INCOMPLETE_IMAGE_PLACEHOLDER})`; } if (linkMode === "text-only") { diff --git a/packages/streamdown/__tests__/components.test.tsx b/packages/streamdown/__tests__/components.test.tsx index 9c62b6d8..45981d73 100644 --- a/packages/streamdown/__tests__/components.test.tsx +++ b/packages/streamdown/__tests__/components.test.tsx @@ -199,6 +199,29 @@ describe("Markdown Components", () => { expect(link?.textContent).toBe("Incomplete link text"); }); + it("should render incomplete image placeholder when src is the incomplete-image placeholder", () => { + const Img = components.img; + if (!Img) { + throw new Error("Img component not found"); + } + const { container } = render( + loading + ); + 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) { diff --git a/packages/streamdown/__tests__/image.test.tsx b/packages/streamdown/__tests__/image.test.tsx index 88931292..238c2a69 100644 --- a/packages/streamdown/__tests__/image.test.tsx +++ b/packages/streamdown/__tests__/image.test.tsx @@ -414,3 +414,60 @@ describe("ImageComponent", () => { expect(img?.getAttribute("data-testid")).toBe("custom-image"); }); }); + +describe("incomplete image placeholder", () => { + it("should render a placeholder when src is the incomplete-image placeholder", () => { + const { container } = render( + + ); + + // 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( + + ); + + const downloadButton = container.querySelector("button"); + expect(downloadButton).toBeNull(); + }); + + it("should render placeholder with correct CSS classes for animation", () => { + const { container } = render( + + ); + + const placeholder = container.querySelector( + '[data-streamdown="image-placeholder"]' + ); + expect(placeholder?.className).toContain("animate-pulse"); + expect(placeholder?.className).toContain("rounded-lg"); + }); +}); diff --git a/packages/streamdown/lib/image.tsx b/packages/streamdown/lib/image.tsx index b6290772..5d265158 100644 --- a/packages/streamdown/lib/image.tsx +++ b/packages/streamdown/lib/image.tsx @@ -1,5 +1,6 @@ import type { DetailedHTMLProps, ImgHTMLAttributes } from "react"; import { useCallback, useEffect, useRef, useState } from "react"; +import { INCOMPLETE_IMAGE_PLACEHOLDER } from "remend"; import { useIcons } from "./icon-context"; import type { ExtraProps } from "./markdown"; import { useCn } from "./prefix-context"; @@ -118,6 +119,23 @@ export const ImageComponent = ({ return null; } + const isIncomplete = src === INCOMPLETE_IMAGE_PLACEHOLDER; + + if (isIncomplete) { + return ( +
+
+
+ ); + } + return (