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
121 changes: 121 additions & 0 deletions packages/streamdown/__tests__/allowed-tags.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -218,3 +218,124 @@ Content for snippet 2
expect(container.textContent).toContain("Hello world");
});
});

describe("literalTagContent prop", () => {
it("should render underscore content as plain text (not emphasis)", () => {
const Mention = (props: CustomComponentProps) => (
<span data-testid="mention">{props.children as React.ReactNode}</span>
);

const { container } = render(
<Streamdown
allowedTags={{ mention: ["user_id"] }}
components={{ mention: Mention }}
literalTagContent={["mention"]}
mode="static"
>
{'<mention user_id="123">_some_username_</mention>'}
</Streamdown>
);

const mention = container.querySelector('[data-testid="mention"]');
expect(mention).toBeTruthy();
// Children should be plain text, not an <em> element
expect(mention?.querySelector("em")).toBeNull();
expect(mention?.textContent).toBe("_some_username_");
});

it("should only apply literal mode to the specified tags", () => {
const Mention = (props: CustomComponentProps) => (
<span data-testid="mention">{props.children as React.ReactNode}</span>
);
const Note = (props: CustomComponentProps) => (
<span data-testid="note">{props.children as React.ReactNode}</span>
);

const { container } = render(
<Streamdown
allowedTags={{ mention: [], note: [] }}
components={{ mention: Mention, note: Note }}
literalTagContent={["mention"]}
mode="static"
>
{"<mention>_literal_</mention> <note>_parsed_</note>"}
</Streamdown>
);

const mention = container.querySelector('[data-testid="mention"]');
const note = container.querySelector('[data-testid="note"]');

// mention: no emphasis, raw underscores
expect(mention?.querySelector("em")).toBeNull();
expect(mention?.textContent).toBe("_literal_");

// note: emphasis IS parsed (not in literalTagContent)
expect(note?.querySelector("em")).toBeTruthy();
});

it("should render bold, inline code and other markdown as plain text", () => {
const Tag = (props: CustomComponentProps) => (
<span data-testid="tag">{props.children as React.ReactNode}</span>
);

const { container } = render(
<Streamdown
allowedTags={{ tag: [] }}
components={{ tag: Tag }}
literalTagContent={["tag"]}
mode="static"
>
{"<tag>**bold** and `code`</tag>"}
</Streamdown>
);

const tag = container.querySelector('[data-testid="tag"]');
expect(tag?.querySelector("strong")).toBeNull();
expect(tag?.querySelector("code")).toBeNull();
expect(tag?.textContent).toContain("**bold**");
expect(tag?.textContent).toContain("`code`");
});

it("should have no effect when literalTagContent is an empty array", () => {
const Mention = (props: CustomComponentProps) => (
<span data-testid="mention">{props.children as React.ReactNode}</span>
);

const { container } = render(
<Streamdown
allowedTags={{ mention: [] }}
components={{ mention: Mention }}
literalTagContent={[]}
mode="static"
>
{"<mention>_parsed_</mention>"}
</Streamdown>
);

const mention = container.querySelector('[data-testid="mention"]');
// Markdown IS still parsed (empty literalTagContent = no effect)
expect(mention?.querySelector("em")).toBeTruthy();
});

it("should work in streaming mode", () => {
const Mention = (props: CustomComponentProps) => (
<span data-testid="mention">{props.children as React.ReactNode}</span>
);

const { container } = render(
<Streamdown
allowedTags={{ mention: ["user_id"] }}
components={{ mention: Mention }}
literalTagContent={["mention"]}
mode="streaming"
>
{'Hello <mention user_id="42">_handle_</mention>'}
</Streamdown>
);

const mention = container.querySelector('[data-testid="mention"]');
expect(mention).toBeTruthy();
expect(mention?.querySelector("em")).toBeNull();
expect(mention?.textContent).toBe("_handle_");
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import { preprocessLiteralTagContent } from "../lib/preprocess-literal-tag-content";

describe("preprocessLiteralTagContent", () => {
it("should return markdown unchanged when tagNames is empty", () => {
const md = "<mention>_hello_</mention>";
expect(preprocessLiteralTagContent(md, [])).toBe(md);
});

it("should escape underscores inside matching tags", () => {
const md = "<mention>_some_username_</mention>";
const result = preprocessLiteralTagContent(md, ["mention"]);
expect(result).toBe("<mention>\\_some\\_username\\_</mention>");
});

it("should escape asterisks inside matching tags", () => {
const md = "<tag>**bold** text</tag>";
const result = preprocessLiteralTagContent(md, ["tag"]);
expect(result).toContain("\\*\\*bold\\*\\*");
});

it("should escape backticks inside matching tags", () => {
const md = "<tag>`inline code`</tag>";
const result = preprocessLiteralTagContent(md, ["tag"]);
expect(result).toContain("\\`inline code\\`");
});

it("should not affect content outside matching tags", () => {
const md = "_outside_ <mention>_inside_</mention> _also_outside_";
const result = preprocessLiteralTagContent(md, ["mention"]);
// Outside content is unchanged
expect(result).toContain("_outside_");
expect(result).toContain("_also_outside_");
// Inside content is escaped
expect(result).toContain("\\_inside\\_");
});

it("should handle tags with attributes", () => {
const md = '<mention user_id="123">_some_username_</mention>';
const result = preprocessLiteralTagContent(md, ["mention"]);
expect(result).toBe(
'<mention user_id="123">\\_some\\_username\\_</mention>'
);
});

it("should handle multiple tags", () => {
const md = "<foo>_a_</foo> <bar>*b*</bar>";
const result = preprocessLiteralTagContent(md, ["foo", "bar"]);
expect(result).toContain("\\_a\\_");
expect(result).toContain("\\*b\\*");
});

it("should be case insensitive", () => {
const md = "<Mention>_hello_</Mention>";
const result = preprocessLiteralTagContent(md, ["mention"]);
expect(result).toBe("<Mention>\\_hello\\_</Mention>");
});

it("should leave unmatched tags unchanged", () => {
const md = "<other>_hello_</other>";
expect(preprocessLiteralTagContent(md, ["mention"])).toBe(md);
});

it("should handle content with no special characters unchanged", () => {
const md = "<mention>hello world</mention>";
const result = preprocessLiteralTagContent(md, ["mention"]);
expect(result).toBe("<mention>hello world</mention>");
});
});
45 changes: 43 additions & 2 deletions packages/streamdown/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import { parseMarkdownIntoBlocks } from "./lib/parse-blocks";
import { PluginContext } from "./lib/plugin-context";
import type { PluginConfig } from "./lib/plugin-types";
import { preprocessCustomTags } from "./lib/preprocess-custom-tags";
import { preprocessLiteralTagContent } from "./lib/preprocess-literal-tag-content";
import { rehypeLiteralTagContent } from "./lib/rehype/literal-tag-content";
import { cn } from "./lib/utils";

export type { BundledLanguage, BundledTheme } from "shiki";
Expand Down Expand Up @@ -139,6 +141,22 @@ export type StreamdownProps = Options & {
linkSafety?: LinkSafetyConfig;
/** Custom tags to allow through sanitization with their permitted attributes */
allowedTags?: AllowedTags;
/**
* Tags whose children should be treated as plain text (no markdown parsing).
* Useful for mention/entity tags in AI UIs where child content is a data
* label rather than prose. Requires the tag to also be listed in `allowedTags`.
*
* @example
* ```tsx
* <Streamdown
* allowedTags={{ mention: ['user_id'] }}
* literalTagContent={['mention']}
* >
* {`<mention user_id="123">@_some_username_</mention>`}
* </Streamdown>
* ```
*/
literalTagContent?: string[];
};

const defaultSanitizeSchema = {
Expand Down Expand Up @@ -314,6 +332,7 @@ export const Streamdown = memo(
enabled: true,
},
allowedTags,
literalTagContent,
...props
}: StreamdownProps) => {
// All hooks must be called before any conditional returns
Expand All @@ -336,7 +355,17 @@ export const Streamdown = memo(
? remend(children, remendOptions)
: children;

// Preprocess custom tags to prevent blank lines from splitting HTML blocks
// Escape markdown metacharacters inside literal-tag-content tags so that
// children are rendered as plain text rather than parsed as markdown.
// This must run BEFORE preprocessCustomTags so that the HTML comments
// (<!---->) inserted to preserve blank lines are not themselves escaped.
if (literalTagContent && literalTagContent.length > 0) {
result = preprocessLiteralTagContent(result, literalTagContent);
}

// Preprocess custom tags to prevent blank lines from splitting HTML blocks.
// Runs after preprocessLiteralTagContent so that the inserted <!---->
// markers are not corrupted by markdown metacharacter escaping.
if (allowedTagNames.length > 0) {
result = preprocessCustomTags(result, allowedTagNames);
}
Expand All @@ -348,6 +377,7 @@ export const Streamdown = memo(
shouldParseIncompleteMarkdown,
remendOptions,
allowedTagNames,
literalTagContent,
]);

const blocks = useMemo(
Expand Down Expand Up @@ -471,6 +501,10 @@ export const Streamdown = memo(
];
}

if (literalTagContent && literalTagContent.length > 0) {
result = [...result, [rehypeLiteralTagContent, literalTagContent]];
}

if (plugins?.math) {
result = [...result, plugins.math.rehypePlugin];
}
Expand All @@ -480,7 +514,14 @@ export const Streamdown = memo(
}

return result;
}, [rehypePlugins, plugins?.math, animatePlugin, isAnimating, allowedTags]);
}, [
rehypePlugins,
plugins?.math,
animatePlugin,
isAnimating,
allowedTags,
literalTagContent,
]);

const shouldHideCaret = useMemo(() => {
if (!isAnimating || blocksToRender.length === 0) {
Expand Down
50 changes: 50 additions & 0 deletions packages/streamdown/lib/preprocess-literal-tag-content.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* Escapes markdown metacharacters inside the content of specified custom tags,
* so that markdown inside those tags is rendered as plain text rather than
* being interpreted as formatting.
*
* This must run BEFORE the markdown parser sees the string, because by the time
* rehype plugins execute the markdown has already been parsed and structural
* information (e.g. underscores around emphasis) is lost.
*
* Example:
* Input: `<mention user_id="123">_some_username_</mention>`
* Output: `<mention user_id="123">\_some\_username\_</mention>`
* Rendered: literal `_some_username_`
*/

// All characters that CommonMark treats as potential markdown metacharacters
const MARKDOWN_ESCAPE_RE = /([\\`*_{}[\]()#+\-.!|~])/g;

const escapeMarkdown = (text: string): string =>
text.replace(MARKDOWN_ESCAPE_RE, "\\$1");

/**
* For each tag in `tagNames`, escapes markdown metacharacters inside the tag's
* content so that the parser treats the children as plain text.
*/
export const preprocessLiteralTagContent = (
markdown: string,
tagNames: string[]
): string => {
if (!tagNames.length) {
return markdown;
}

let result = markdown;

for (const tagName of tagNames) {
const pattern = new RegExp(
`(<${tagName}(?=[\\s>/])[^>]*>)([\\s\\S]*?)(</${tagName}\\s*>)`,
"gi"
);

result = result.replace(
pattern,
(_match, open: string, content: string, close: string) =>
open + escapeMarkdown(content) + close
);
}

return result;
};
42 changes: 42 additions & 0 deletions packages/streamdown/lib/rehype/literal-tag-content.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type { Element, Root, Text } from "hast";
import type { Plugin } from "unified";
import { visit } from "unist-util-visit";

/**
* Recursively collect all text content from a HAST node subtree.
*/
const collectText = (node: Element | Text): string => {
if (node.type === "text") {
return (node as Text).value;
}
if ("children" in node && Array.isArray(node.children)) {
return node.children
.map((child) => collectText(child as Element | Text))
.join("");
}
return "";
};

/**
* rehype plugin — replaces children of elements whose tag names are in
* `tagNames` with a single plain-text node. Run this after rehype-raw and
* rehype-sanitize so the custom elements already exist as proper HAST nodes.
*
* Works in tandem with `preprocessLiteralTagContent`, which escapes markdown
* syntax before remark parses it, ensuring the text value here reflects the
* original literal content (not stripped markdown markers).
*/
export const rehypeLiteralTagContent: Plugin<[string[]], Root> =
(tagNames) => (tree: Root) => {
if (!tagNames || tagNames.length === 0) {
return;
}
const tagSet = new Set(tagNames.map((t) => t.toLowerCase()));

visit(tree, "element", (node: Element) => {
if (tagSet.has(node.tagName.toLowerCase())) {
const text = collectText(node);
node.children = text ? [{ type: "text", value: text } as Text] : [];
}
});
};