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, + }; + }); +};