Skip to content
Merged
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/literal-tag-content.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"streamdown": minor
---

Add `literalTagContent` prop for plain-text custom tag children
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>");
});
});
46 changes: 44 additions & 2 deletions packages/streamdown/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ import { PluginContext } from "./lib/plugin-context";
import type { PluginConfig, ThemeInput } from "./lib/plugin-types";
import { PrefixContext } from "./lib/prefix-context";
import { preprocessCustomTags } from "./lib/preprocess-custom-tags";
import { preprocessLiteralTagContent } from "./lib/preprocess-literal-tag-content";
import { rehypeLiteralTagContent } from "./lib/rehype/literal-tag-content";
import { remarkCodeMeta } from "./lib/remark/code-meta";
import {
defaultTranslations,
Expand Down Expand Up @@ -174,6 +176,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[];
/** Override UI strings for i18n / custom labels */
translations?: Partial<StreamdownTranslations>;
/** Custom icons to override the default icons used in controls */
Expand Down Expand Up @@ -380,6 +398,7 @@ export const Streamdown = memo(
enabled: true,
},
allowedTags,
literalTagContent,
translations,
icons: iconOverrides,
prefix,
Expand Down Expand Up @@ -443,7 +462,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 @@ -455,6 +484,7 @@ export const Streamdown = memo(
shouldParseIncompleteMarkdown,
remendOptions,
allowedTagNames,
literalTagContent,
]);

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

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

if (plugins?.math) {
result = [...result, plugins.math.rehypePlugin];
}
Expand All @@ -616,7 +650,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 Expand Up @@ -727,6 +768,7 @@ export const Streamdown = memo(
prevProps.className === nextProps.className &&
prevProps.linkSafety === nextProps.linkSafety &&
prevProps.normalizeHtmlIndentation === nextProps.normalizeHtmlIndentation &&
prevProps.literalTagContent === nextProps.literalTagContent &&
JSON.stringify(prevProps.translations) ===
JSON.stringify(nextProps.translations) &&
prevProps.prefix === nextProps.prefix
Expand Down
67 changes: 67 additions & 0 deletions packages/streamdown/lib/preprocess-literal-tag-content.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/**
* 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.
*
* Note: content that already contains backslash escapes (e.g. `\_`) will be
* double-escaped and render with a visible backslash. This is acceptable since
* the intended use case is raw data labels (usernames, handles) that should
* not contain markdown escape sequences.
*
* Example:
* Input: `<mention user_id="123">_some_username_</mention>`
* Output: `<mention user_id="123">\_some\_username\_</mention>`
* Rendered: literal `_some_username_`
*/

// Characters that CommonMark treats as inline formatting metacharacters.
// Only escapes characters that can trigger formatting anywhere in text:
// \ backslash escapes
// ` code spans
// * emphasis / strong
// _ emphasis / strong
// ~ strikethrough (GFM)
// [ link / image start
// ] link / image end
// | table cell delimiter (GFM)
// Line-start-only syntax (headings, lists, blockquotes) is not escaped
// because tag content rarely starts lines and the rehype plugin provides
// a safety net.
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;
};
Loading
Loading