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
5 changes: 5 additions & 0 deletions .changeset/dirty-eels-stand.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"streamdown": patch
---

fix: escape HTML when rehype-raw is omitted (#330)
19 changes: 19 additions & 0 deletions packages/streamdown/__tests__/markdown.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <em>HTML</em> and <h2>heading</h2> tags",
rehypePlugins: [], // No rehype-raw
};

const { container } = render(<Markdown {...options} />);

// HTML should be escaped and displayed as text
expect(container.innerHTML).toContain("&lt;em&gt;");
expect(container.innerHTML).toContain("&lt;/em&gt;");
expect(container.innerHTML).toContain("&lt;h2&gt;");
expect(container.innerHTML).toContain("&lt;/h2&gt;");

// Should not contain actual HTML elements
expect(container.querySelector("em")).toBeFalsy();
expect(container.querySelector("h2")).toBeFalsy();
});
});

describe("Processor Caching", () => {
Expand Down
16 changes: 15 additions & 1 deletion packages/streamdown/lib/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -177,16 +179,28 @@ const getCachedProcessor = (options: Readonly<Options>) => {
return processor;
};

const hasRehypeRaw = (plugins: PluggableList): boolean =>
plugins.some((plugin) =>
Array.isArray(plugin) ? plugin[0] === rehypeRaw : plugin === rehypeRaw
);

const createProcessor = (options: Readonly<Options>) => {
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);
};
Expand Down
20 changes: 20 additions & 0 deletions packages/streamdown/lib/remark/escape-html.ts
Original file line number Diff line number Diff line change
@@ -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,
};
});
};