Skip to content

Commit 6bb03ca

Browse files
authored
fix: escape HTML when rehype-raw is omitted (#330) (#331)
* fix: escape HTML when rehype-raw is omitted (#330) * chore: add changeset
1 parent c995fb7 commit 6bb03ca

File tree

4 files changed

+59
-1
lines changed

4 files changed

+59
-1
lines changed

.changeset/dirty-eels-stand.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"streamdown": patch
3+
---
4+
5+
fix: escape HTML when rehype-raw is omitted (#330)

packages/streamdown/__tests__/markdown.test.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,25 @@ describe("Markdown Component", () => {
318318
expect(em.textContent).toBe("HTML");
319319
}
320320
});
321+
322+
it("should escape HTML when rehype-raw is omitted", () => {
323+
const options: Options = {
324+
children: "Text with <em>HTML</em> and <h2>heading</h2> tags",
325+
rehypePlugins: [], // No rehype-raw
326+
};
327+
328+
const { container } = render(<Markdown {...options} />);
329+
330+
// HTML should be escaped and displayed as text
331+
expect(container.innerHTML).toContain("&lt;em&gt;");
332+
expect(container.innerHTML).toContain("&lt;/em&gt;");
333+
expect(container.innerHTML).toContain("&lt;h2&gt;");
334+
expect(container.innerHTML).toContain("&lt;/h2&gt;");
335+
336+
// Should not contain actual HTML elements
337+
expect(container.querySelector("em")).toBeFalsy();
338+
expect(container.querySelector("h2")).toBeFalsy();
339+
});
321340
});
322341

323342
describe("Processor Caching", () => {

packages/streamdown/lib/markdown.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@ import type { Element, Nodes } from "hast";
22
import { toJsxRuntime } from "hast-util-to-jsx-runtime";
33
import type { ComponentType, JSX, ReactElement } from "react";
44
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
5+
import rehypeRaw from "rehype-raw";
56
import remarkParse from "remark-parse";
67
import type { Options as RemarkRehypeOptions } from "remark-rehype";
78
import remarkRehype from "remark-rehype";
89
import type { PluggableList } from "unified";
910
import { unified } from "unified";
11+
import { remarkEscapeHtml } from "./remark/escape-html";
1012

1113
export interface ExtraProps {
1214
node?: Element | undefined;
@@ -182,16 +184,28 @@ const getCachedProcessor = (options: Readonly<Options>) => {
182184
return processor;
183185
};
184186

187+
const hasRehypeRaw = (plugins: PluggableList): boolean =>
188+
plugins.some((plugin) =>
189+
Array.isArray(plugin) ? plugin[0] === rehypeRaw : plugin === rehypeRaw
190+
);
191+
185192
const createProcessor = (options: Readonly<Options>) => {
186193
const rehypePlugins = options.rehypePlugins || EMPTY_PLUGINS;
187194
const remarkPlugins = options.remarkPlugins || EMPTY_PLUGINS;
195+
196+
// When rehype-raw is NOT present, escape HTML to display it as text
197+
// When rehype-raw IS present, HTML is processed normally
198+
const finalRemarkPlugins = hasRehypeRaw(rehypePlugins)
199+
? remarkPlugins
200+
: [...remarkPlugins, remarkEscapeHtml];
201+
188202
const remarkRehypeOptions = options.remarkRehypeOptions
189203
? { ...DEFAULT_REMARK_REHYPE_OPTIONS, ...options.remarkRehypeOptions }
190204
: DEFAULT_REMARK_REHYPE_OPTIONS;
191205

192206
return unified()
193207
.use(remarkParse)
194-
.use(remarkPlugins)
208+
.use(finalRemarkPlugins)
195209
.use(remarkRehype, remarkRehypeOptions)
196210
.use(rehypePlugins);
197211
};
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import type { HTML, Root } from "mdast";
2+
import type { Plugin } from "unified";
3+
import type { Parent } from "unist";
4+
import { visit } from "unist-util-visit";
5+
6+
// Convert HTML nodes to text when rehype-raw is not present
7+
// This allows HTML to be displayed as escaped text instead of being stripped
8+
export const remarkEscapeHtml: Plugin<[], Root> = () => (tree) => {
9+
visit(tree, "html", (node: HTML, index: number | null, parent?: Parent) => {
10+
if (!parent || typeof index !== "number") {
11+
return;
12+
}
13+
14+
// Convert HTML node to text node - React will handle escaping
15+
parent.children[index] = {
16+
type: "text",
17+
value: node.value,
18+
};
19+
});
20+
};

0 commit comments

Comments
 (0)