diff --git a/.changeset/dirty-eels-stand.md b/.changeset/dirty-eels-stand.md
new file mode 100644
index 00000000..c5e3e3f4
--- /dev/null
+++ b/.changeset/dirty-eels-stand.md
@@ -0,0 +1,5 @@
+---
+"streamdown": patch
+---
+
+fix: escape HTML when rehype-raw is omitted (#330)
diff --git a/packages/streamdown/__tests__/markdown.test.tsx b/packages/streamdown/__tests__/markdown.test.tsx
index 6de4206b..5224841f 100644
--- a/packages/streamdown/__tests__/markdown.test.tsx
+++ b/packages/streamdown/__tests__/markdown.test.tsx
@@ -318,6 +318,25 @@ describe("Markdown Component", () => {
expect(em.textContent).toBe("HTML");
}
});
+
+ it("should escape HTML when rehype-raw is omitted", () => {
+ const options: Options = {
+ children: "Text with HTML and
heading
tags",
+ rehypePlugins: [], // No rehype-raw
+ };
+
+ const { container } = render();
+
+ // HTML should be escaped and displayed as text
+ expect(container.innerHTML).toContain("<em>");
+ expect(container.innerHTML).toContain("</em>");
+ expect(container.innerHTML).toContain("<h2>");
+ expect(container.innerHTML).toContain("</h2>");
+
+ // Should not contain actual HTML elements
+ expect(container.querySelector("em")).toBeFalsy();
+ expect(container.querySelector("h2")).toBeFalsy();
+ });
});
describe("Processor Caching", () => {
diff --git a/packages/streamdown/lib/markdown.ts b/packages/streamdown/lib/markdown.ts
index ea1f2632..1717f414 100644
--- a/packages/streamdown/lib/markdown.ts
+++ b/packages/streamdown/lib/markdown.ts
@@ -2,11 +2,13 @@ import type { Element, Nodes } from "hast";
import { toJsxRuntime } from "hast-util-to-jsx-runtime";
import type { ComponentType, JSX, ReactElement } from "react";
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
+import rehypeRaw from "rehype-raw";
import remarkParse from "remark-parse";
import type { Options as RemarkRehypeOptions } from "remark-rehype";
import remarkRehype from "remark-rehype";
import type { PluggableList } from "unified";
import { unified } from "unified";
+import { remarkEscapeHtml } from "./remark/escape-html";
export type ExtraProps = {
node?: Element | undefined;
@@ -177,16 +179,28 @@ const getCachedProcessor = (options: Readonly) => {
return processor;
};
+const hasRehypeRaw = (plugins: PluggableList): boolean =>
+ plugins.some((plugin) =>
+ Array.isArray(plugin) ? plugin[0] === rehypeRaw : plugin === rehypeRaw
+ );
+
const createProcessor = (options: Readonly) => {
const rehypePlugins = options.rehypePlugins || EMPTY_PLUGINS;
const remarkPlugins = options.remarkPlugins || EMPTY_PLUGINS;
+
+ // When rehype-raw is NOT present, escape HTML to display it as text
+ // When rehype-raw IS present, HTML is processed normally
+ const finalRemarkPlugins = hasRehypeRaw(rehypePlugins)
+ ? remarkPlugins
+ : [...remarkPlugins, remarkEscapeHtml];
+
const remarkRehypeOptions = options.remarkRehypeOptions
? { ...DEFAULT_REMARK_REHYPE_OPTIONS, ...options.remarkRehypeOptions }
: DEFAULT_REMARK_REHYPE_OPTIONS;
return unified()
.use(remarkParse)
- .use(remarkPlugins)
+ .use(finalRemarkPlugins)
.use(remarkRehype, remarkRehypeOptions)
.use(rehypePlugins);
};
diff --git a/packages/streamdown/lib/remark/escape-html.ts b/packages/streamdown/lib/remark/escape-html.ts
new file mode 100644
index 00000000..ae8a2dd2
--- /dev/null
+++ b/packages/streamdown/lib/remark/escape-html.ts
@@ -0,0 +1,20 @@
+import type { HTML, Root } from "mdast";
+import type { Plugin } from "unified";
+import type { Parent } from "unist";
+import { visit } from "unist-util-visit";
+
+// Convert HTML nodes to text when rehype-raw is not present
+// This allows HTML to be displayed as escaped text instead of being stripped
+export const remarkEscapeHtml: Plugin<[], Root> = () => (tree) => {
+ visit(tree, "html", (node: HTML, index: number | null, parent?: Parent) => {
+ if (!parent || typeof index !== "number") {
+ return;
+ }
+
+ // Convert HTML node to text node - React will handle escaping
+ parent.children[index] = {
+ type: "text",
+ value: node.value,
+ };
+ });
+};