Skip to content

Commit b392fbe

Browse files
sleitorhaydenbleaselclaude
authored
feat: add literalTagContent prop for plain-text custom tag children (#423)
* feat: add literalTagContent prop for plain-text custom tag children * fix: run preprocessLiteralTagContent before preprocessCustomTags to prevent HTML comment corruption When a tag appears in both allowedTags and literalTagContent and its content contains blank lines, the '<!---->' HTML comment inserted by preprocessCustomTags was subsequently corrupted to '<\!\-\-\-\->' by preprocessLiteralTagContent (which escapes '!' and '-' as markdown metacharacters). Fix: swap the execution order so preprocessLiteralTagContent runs first, then preprocessCustomTags inserts its markers. The HTML comments are never seen by the markdown escaper. * Add changeset for literalTagContent * fix: address review issues for literalTagContent - changeset patch→minor (new feature) - add literalTagContent to Streamdown memo comparison - narrow escape regex to inline-only metacharacters - use ElementContent type in collectText for proper HAST coverage - document double-escape limitation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Dmitrii Troitskii <jsleitor@gmail.com> Co-authored-by: Hayden Bleasel <hello@haydenbleasel.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e129f09 commit b392fbe

File tree

6 files changed

+346
-2
lines changed

6 files changed

+346
-2
lines changed

.changeset/literal-tag-content.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"streamdown": minor
3+
---
4+
5+
Add `literalTagContent` prop for plain-text custom tag children

packages/streamdown/__tests__/allowed-tags.test.tsx

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,3 +218,124 @@ Content for snippet 2
218218
expect(container.textContent).toContain("Hello world");
219219
});
220220
});
221+
222+
describe("literalTagContent prop", () => {
223+
it("should render underscore content as plain text (not emphasis)", () => {
224+
const Mention = (props: CustomComponentProps) => (
225+
<span data-testid="mention">{props.children as React.ReactNode}</span>
226+
);
227+
228+
const { container } = render(
229+
<Streamdown
230+
allowedTags={{ mention: ["user_id"] }}
231+
components={{ mention: Mention }}
232+
literalTagContent={["mention"]}
233+
mode="static"
234+
>
235+
{'<mention user_id="123">_some_username_</mention>'}
236+
</Streamdown>
237+
);
238+
239+
const mention = container.querySelector('[data-testid="mention"]');
240+
expect(mention).toBeTruthy();
241+
// Children should be plain text, not an <em> element
242+
expect(mention?.querySelector("em")).toBeNull();
243+
expect(mention?.textContent).toBe("_some_username_");
244+
});
245+
246+
it("should only apply literal mode to the specified tags", () => {
247+
const Mention = (props: CustomComponentProps) => (
248+
<span data-testid="mention">{props.children as React.ReactNode}</span>
249+
);
250+
const Note = (props: CustomComponentProps) => (
251+
<span data-testid="note">{props.children as React.ReactNode}</span>
252+
);
253+
254+
const { container } = render(
255+
<Streamdown
256+
allowedTags={{ mention: [], note: [] }}
257+
components={{ mention: Mention, note: Note }}
258+
literalTagContent={["mention"]}
259+
mode="static"
260+
>
261+
{"<mention>_literal_</mention> <note>_parsed_</note>"}
262+
</Streamdown>
263+
);
264+
265+
const mention = container.querySelector('[data-testid="mention"]');
266+
const note = container.querySelector('[data-testid="note"]');
267+
268+
// mention: no emphasis, raw underscores
269+
expect(mention?.querySelector("em")).toBeNull();
270+
expect(mention?.textContent).toBe("_literal_");
271+
272+
// note: emphasis IS parsed (not in literalTagContent)
273+
expect(note?.querySelector("em")).toBeTruthy();
274+
});
275+
276+
it("should render bold, inline code and other markdown as plain text", () => {
277+
const Tag = (props: CustomComponentProps) => (
278+
<span data-testid="tag">{props.children as React.ReactNode}</span>
279+
);
280+
281+
const { container } = render(
282+
<Streamdown
283+
allowedTags={{ tag: [] }}
284+
components={{ tag: Tag }}
285+
literalTagContent={["tag"]}
286+
mode="static"
287+
>
288+
{"<tag>**bold** and `code`</tag>"}
289+
</Streamdown>
290+
);
291+
292+
const tag = container.querySelector('[data-testid="tag"]');
293+
expect(tag?.querySelector("strong")).toBeNull();
294+
expect(tag?.querySelector("code")).toBeNull();
295+
expect(tag?.textContent).toContain("**bold**");
296+
expect(tag?.textContent).toContain("`code`");
297+
});
298+
299+
it("should have no effect when literalTagContent is an empty array", () => {
300+
const Mention = (props: CustomComponentProps) => (
301+
<span data-testid="mention">{props.children as React.ReactNode}</span>
302+
);
303+
304+
const { container } = render(
305+
<Streamdown
306+
allowedTags={{ mention: [] }}
307+
components={{ mention: Mention }}
308+
literalTagContent={[]}
309+
mode="static"
310+
>
311+
{"<mention>_parsed_</mention>"}
312+
</Streamdown>
313+
);
314+
315+
const mention = container.querySelector('[data-testid="mention"]');
316+
// Markdown IS still parsed (empty literalTagContent = no effect)
317+
expect(mention?.querySelector("em")).toBeTruthy();
318+
});
319+
320+
it("should work in streaming mode", () => {
321+
const Mention = (props: CustomComponentProps) => (
322+
<span data-testid="mention">{props.children as React.ReactNode}</span>
323+
);
324+
325+
const { container } = render(
326+
<Streamdown
327+
allowedTags={{ mention: ["user_id"] }}
328+
components={{ mention: Mention }}
329+
literalTagContent={["mention"]}
330+
mode="streaming"
331+
>
332+
{'Hello <mention user_id="42">_handle_</mention>'}
333+
</Streamdown>
334+
);
335+
336+
const mention = container.querySelector('[data-testid="mention"]');
337+
expect(mention).toBeTruthy();
338+
expect(mention?.querySelector("em")).toBeNull();
339+
expect(mention?.textContent).toBe("_handle_");
340+
});
341+
});
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { describe, expect, it } from "vitest";
2+
import { preprocessLiteralTagContent } from "../lib/preprocess-literal-tag-content";
3+
4+
describe("preprocessLiteralTagContent", () => {
5+
it("should return markdown unchanged when tagNames is empty", () => {
6+
const md = "<mention>_hello_</mention>";
7+
expect(preprocessLiteralTagContent(md, [])).toBe(md);
8+
});
9+
10+
it("should escape underscores inside matching tags", () => {
11+
const md = "<mention>_some_username_</mention>";
12+
const result = preprocessLiteralTagContent(md, ["mention"]);
13+
expect(result).toBe("<mention>\\_some\\_username\\_</mention>");
14+
});
15+
16+
it("should escape asterisks inside matching tags", () => {
17+
const md = "<tag>**bold** text</tag>";
18+
const result = preprocessLiteralTagContent(md, ["tag"]);
19+
expect(result).toContain("\\*\\*bold\\*\\*");
20+
});
21+
22+
it("should escape backticks inside matching tags", () => {
23+
const md = "<tag>`inline code`</tag>";
24+
const result = preprocessLiteralTagContent(md, ["tag"]);
25+
expect(result).toContain("\\`inline code\\`");
26+
});
27+
28+
it("should not affect content outside matching tags", () => {
29+
const md = "_outside_ <mention>_inside_</mention> _also_outside_";
30+
const result = preprocessLiteralTagContent(md, ["mention"]);
31+
// Outside content is unchanged
32+
expect(result).toContain("_outside_");
33+
expect(result).toContain("_also_outside_");
34+
// Inside content is escaped
35+
expect(result).toContain("\\_inside\\_");
36+
});
37+
38+
it("should handle tags with attributes", () => {
39+
const md = '<mention user_id="123">_some_username_</mention>';
40+
const result = preprocessLiteralTagContent(md, ["mention"]);
41+
expect(result).toBe(
42+
'<mention user_id="123">\\_some\\_username\\_</mention>'
43+
);
44+
});
45+
46+
it("should handle multiple tags", () => {
47+
const md = "<foo>_a_</foo> <bar>*b*</bar>";
48+
const result = preprocessLiteralTagContent(md, ["foo", "bar"]);
49+
expect(result).toContain("\\_a\\_");
50+
expect(result).toContain("\\*b\\*");
51+
});
52+
53+
it("should be case insensitive", () => {
54+
const md = "<Mention>_hello_</Mention>";
55+
const result = preprocessLiteralTagContent(md, ["mention"]);
56+
expect(result).toBe("<Mention>\\_hello\\_</Mention>");
57+
});
58+
59+
it("should leave unmatched tags unchanged", () => {
60+
const md = "<other>_hello_</other>";
61+
expect(preprocessLiteralTagContent(md, ["mention"])).toBe(md);
62+
});
63+
64+
it("should handle content with no special characters unchanged", () => {
65+
const md = "<mention>hello world</mention>";
66+
const result = preprocessLiteralTagContent(md, ["mention"]);
67+
expect(result).toBe("<mention>hello world</mention>");
68+
});
69+
});

packages/streamdown/index.tsx

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ import { PluginContext } from "./lib/plugin-context";
3333
import type { PluginConfig, ThemeInput } from "./lib/plugin-types";
3434
import { PrefixContext } from "./lib/prefix-context";
3535
import { preprocessCustomTags } from "./lib/preprocess-custom-tags";
36+
import { preprocessLiteralTagContent } from "./lib/preprocess-literal-tag-content";
37+
import { rehypeLiteralTagContent } from "./lib/rehype/literal-tag-content";
3638
import { remarkCodeMeta } from "./lib/remark/code-meta";
3739
import {
3840
defaultTranslations,
@@ -174,6 +176,22 @@ export type StreamdownProps = Options & {
174176
linkSafety?: LinkSafetyConfig;
175177
/** Custom tags to allow through sanitization with their permitted attributes */
176178
allowedTags?: AllowedTags;
179+
/**
180+
* Tags whose children should be treated as plain text (no markdown parsing).
181+
* Useful for mention/entity tags in AI UIs where child content is a data
182+
* label rather than prose. Requires the tag to also be listed in `allowedTags`.
183+
*
184+
* @example
185+
* ```tsx
186+
* <Streamdown
187+
* allowedTags={{ mention: ['user_id'] }}
188+
* literalTagContent={['mention']}
189+
* >
190+
* {`<mention user_id="123">@_some_username_</mention>`}
191+
* </Streamdown>
192+
* ```
193+
*/
194+
literalTagContent?: string[];
177195
/** Override UI strings for i18n / custom labels */
178196
translations?: Partial<StreamdownTranslations>;
179197
/** Custom icons to override the default icons used in controls */
@@ -380,6 +398,7 @@ export const Streamdown = memo(
380398
enabled: true,
381399
},
382400
allowedTags,
401+
literalTagContent,
383402
translations,
384403
icons: iconOverrides,
385404
prefix,
@@ -443,7 +462,17 @@ export const Streamdown = memo(
443462
? remend(children, remendOptions)
444463
: children;
445464

446-
// Preprocess custom tags to prevent blank lines from splitting HTML blocks
465+
// Escape markdown metacharacters inside literal-tag-content tags so that
466+
// children are rendered as plain text rather than parsed as markdown.
467+
// This must run BEFORE preprocessCustomTags so that the HTML comments
468+
// (<!---->) inserted to preserve blank lines are not themselves escaped.
469+
if (literalTagContent && literalTagContent.length > 0) {
470+
result = preprocessLiteralTagContent(result, literalTagContent);
471+
}
472+
473+
// Preprocess custom tags to prevent blank lines from splitting HTML blocks.
474+
// Runs after preprocessLiteralTagContent so that the inserted <!---->
475+
// markers are not corrupted by markdown metacharacter escaping.
447476
if (allowedTagNames.length > 0) {
448477
result = preprocessCustomTags(result, allowedTagNames);
449478
}
@@ -455,6 +484,7 @@ export const Streamdown = memo(
455484
shouldParseIncompleteMarkdown,
456485
remendOptions,
457486
allowedTagNames,
487+
literalTagContent,
458488
]);
459489

460490
const blocks = useMemo(
@@ -607,6 +637,10 @@ export const Streamdown = memo(
607637
];
608638
}
609639

640+
if (literalTagContent && literalTagContent.length > 0) {
641+
result = [...result, [rehypeLiteralTagContent, literalTagContent]];
642+
}
643+
610644
if (plugins?.math) {
611645
result = [...result, plugins.math.rehypePlugin];
612646
}
@@ -616,7 +650,14 @@ export const Streamdown = memo(
616650
}
617651

618652
return result;
619-
}, [rehypePlugins, plugins?.math, animatePlugin, isAnimating, allowedTags]);
653+
}, [
654+
rehypePlugins,
655+
plugins?.math,
656+
animatePlugin,
657+
isAnimating,
658+
allowedTags,
659+
literalTagContent,
660+
]);
620661

621662
const shouldHideCaret = useMemo(() => {
622663
if (!isAnimating || blocksToRender.length === 0) {
@@ -727,6 +768,7 @@ export const Streamdown = memo(
727768
prevProps.className === nextProps.className &&
728769
prevProps.linkSafety === nextProps.linkSafety &&
729770
prevProps.normalizeHtmlIndentation === nextProps.normalizeHtmlIndentation &&
771+
prevProps.literalTagContent === nextProps.literalTagContent &&
730772
JSON.stringify(prevProps.translations) ===
731773
JSON.stringify(nextProps.translations) &&
732774
prevProps.prefix === nextProps.prefix
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/**
2+
* Escapes markdown metacharacters inside the content of specified custom tags,
3+
* so that markdown inside those tags is rendered as plain text rather than
4+
* being interpreted as formatting.
5+
*
6+
* This must run BEFORE the markdown parser sees the string, because by the time
7+
* rehype plugins execute the markdown has already been parsed and structural
8+
* information (e.g. underscores around emphasis) is lost.
9+
*
10+
* Note: content that already contains backslash escapes (e.g. `\_`) will be
11+
* double-escaped and render with a visible backslash. This is acceptable since
12+
* the intended use case is raw data labels (usernames, handles) that should
13+
* not contain markdown escape sequences.
14+
*
15+
* Example:
16+
* Input: `<mention user_id="123">_some_username_</mention>`
17+
* Output: `<mention user_id="123">\_some\_username\_</mention>`
18+
* Rendered: literal `_some_username_`
19+
*/
20+
21+
// Characters that CommonMark treats as inline formatting metacharacters.
22+
// Only escapes characters that can trigger formatting anywhere in text:
23+
// \ backslash escapes
24+
// ` code spans
25+
// * emphasis / strong
26+
// _ emphasis / strong
27+
// ~ strikethrough (GFM)
28+
// [ link / image start
29+
// ] link / image end
30+
// | table cell delimiter (GFM)
31+
// Line-start-only syntax (headings, lists, blockquotes) is not escaped
32+
// because tag content rarely starts lines and the rehype plugin provides
33+
// a safety net.
34+
const MARKDOWN_ESCAPE_RE = /([\\`*_~[\]|])/g;
35+
36+
const escapeMarkdown = (text: string): string =>
37+
text.replace(MARKDOWN_ESCAPE_RE, "\\$1");
38+
39+
/**
40+
* For each tag in `tagNames`, escapes markdown metacharacters inside the tag's
41+
* content so that the parser treats the children as plain text.
42+
*/
43+
export const preprocessLiteralTagContent = (
44+
markdown: string,
45+
tagNames: string[]
46+
): string => {
47+
if (!tagNames.length) {
48+
return markdown;
49+
}
50+
51+
let result = markdown;
52+
53+
for (const tagName of tagNames) {
54+
const pattern = new RegExp(
55+
`(<${tagName}(?=[\\s>/])[^>]*>)([\\s\\S]*?)(</${tagName}\\s*>)`,
56+
"gi"
57+
);
58+
59+
result = result.replace(
60+
pattern,
61+
(_match, open: string, content: string, close: string) =>
62+
open + escapeMarkdown(content) + close
63+
);
64+
}
65+
66+
return result;
67+
};

0 commit comments

Comments
 (0)