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
14 changes: 14 additions & 0 deletions .changeset/fix-custom-tags-markdown.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"streamdown": patch
---

fix(custom-tags): render Markdown inside custom tags with multiline content

Adds a new rehype plugin (rehypeMarkdownInCustomTags) that re-parses raw text
content of custom tag elements as Markdown. Previously, when a custom tag
contained multiline content (e.g. `<ai-thinking>
**bold**</ai-thinking>`),
CommonMark treated the block as raw HTML, stripping Markdown formatting.
Tags listed in `literalTagContent` are excluded from re-parsing.

Closes #478
108 changes: 108 additions & 0 deletions packages/streamdown/__tests__/custom-tags-markdown.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { render } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Streamdown } from "../index";
import type { ExtraProps } from "../lib/markdown";

type CustomComponentProps = Record<string, unknown> & ExtraProps;

// Regex for matching literal asterisks (possibly backslash-escaped) in plain text output
const LITERAL_BOLD_RE = /\*{2}bold\*{2}|\\?\*{1,2}bold\\?\*{1,2}/;

// Streamdown renders <strong> as <span data-streamdown="strong"> via its default components.
// Use this selector in all assertions.
const STRONG_SELECTOR = '[data-streamdown="strong"]';

describe("Issue #478 - Markdown inside custom tags (multiline content)", () => {
it("should render bold markdown inside custom tag with newline prefix", () => {
const AiThinking = (props: CustomComponentProps) => (
<div data-testid="ai-thinking">{props.children as React.ReactNode}</div>
);

const { container } = render(
<Streamdown
allowedTags={{ "ai-thinking": [] }}
components={{ "ai-thinking": AiThinking }}
mode="static"
>
{"<ai-thinking>\n**bold**</ai-thinking>"}
</Streamdown>
);

const el = container.querySelector('[data-testid="ai-thinking"]');
expect(el).toBeTruthy();

// Should contain a bold element for **bold**
const strong = el?.querySelector(STRONG_SELECTOR);
expect(strong).toBeTruthy();
expect(strong?.textContent).toBe("bold");
});

it("single-line content should still work", () => {
const AiThinking = (props: CustomComponentProps) => (
<div data-testid="ai-thinking">{props.children as React.ReactNode}</div>
);

const { container } = render(
<Streamdown
allowedTags={{ "ai-thinking": [] }}
components={{ "ai-thinking": AiThinking }}
mode="static"
>
{"<ai-thinking>**bold**</ai-thinking>"}
</Streamdown>
);

const el = container.querySelector('[data-testid="ai-thinking"]');
expect(el).toBeTruthy();
const strong = el?.querySelector(STRONG_SELECTOR);
expect(strong).toBeTruthy();
expect(strong?.textContent).toBe("bold");
});

it("should render inline code inside custom tag with multiline content", () => {
const AiThinking = (props: CustomComponentProps) => (
<div data-testid="ai-thinking">{props.children as React.ReactNode}</div>
);

const { container } = render(
<Streamdown
allowedTags={{ "ai-thinking": [] }}
components={{ "ai-thinking": AiThinking }}
mode="static"
>
{"<ai-thinking>\n`code`</ai-thinking>"}
</Streamdown>
);

const el = container.querySelector('[data-testid="ai-thinking"]');
expect(el).toBeTruthy();
const code = el?.querySelector("code");
expect(code).toBeTruthy();
expect(code?.textContent).toBe("code");
});

it("should not affect literalTagContent tags", () => {
const AiThinking = (props: CustomComponentProps) => (
<div data-testid="ai-thinking">{props.children as React.ReactNode}</div>
);

const { container } = render(
<Streamdown
allowedTags={{ "ai-thinking": [] }}
components={{ "ai-thinking": AiThinking }}
literalTagContent={["ai-thinking"]}
mode="static"
>
{"<ai-thinking>\n**bold**</ai-thinking>"}
</Streamdown>
);

const el = container.querySelector('[data-testid="ai-thinking"]');
expect(el).toBeTruthy();
// literalTagContent should suppress markdown parsing — no bold element
expect(el?.querySelector(STRONG_SELECTOR)).toBeNull();
// The text content should contain the literal asterisks (possibly backslash-escaped)
const text = el?.textContent ?? "";
expect(text).toMatch(LITERAL_BOLD_RE);
});
});
18 changes: 18 additions & 0 deletions packages/streamdown/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ 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 { rehypeMarkdownInCustomTags } from "./lib/rehype/markdown-in-custom-tags";
import { remarkCodeMeta } from "./lib/remark/code-meta";
import {
defaultTranslations,
Expand Down Expand Up @@ -714,6 +715,22 @@ export const Streamdown = memo(
];
}

// Re-parse text content of custom tags as Markdown. This fixes the case
// where a custom tag with multiline content is parsed as an HTML block by
// CommonMark, which passes inner content through as raw text instead of
// Markdown. We skip tags listed in literalTagContent (those intentionally
// suppress Markdown parsing). Only applied when allowedTags are defined.
if (allowedTagNames.length > 0) {
result = [
...result,
[
rehypeMarkdownInCustomTags,
allowedTagNames,
literalTagContent ?? [],
],
];
}

if (literalTagContent && literalTagContent.length > 0) {
result = [...result, [rehypeLiteralTagContent, literalTagContent]];
}
Expand All @@ -733,6 +750,7 @@ export const Streamdown = memo(
animatePlugin,
isAnimating,
allowedTags,
allowedTagNames,
literalTagContent,
]);

Expand Down
88 changes: 88 additions & 0 deletions packages/streamdown/lib/rehype/markdown-in-custom-tags.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/**
* rehype plugin — re-parses text content of custom tag elements as Markdown.
*
* When a custom tag contains multiline content (e.g. `<ai-thinking>\n**bold**</ai-thinking>`),
* the CommonMark parser treats the entire block as an HTML block, passing the
* inner content through as raw text rather than parsing it as Markdown. This
* plugin corrects that by finding affected elements and replacing their
* text-only children with a proper Markdown-parsed HAST subtree.
*
* Runs after rehype-raw and rehype-sanitize so that custom elements already
* exist as proper HAST nodes. Must NOT be applied to tags listed in
* `literalTagContent` (those intentionally suppress Markdown parsing).
*/

import type { Element, ElementContent, Root } from "hast";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import type { Plugin } from "unified";
import { unified } from "unified";
import { visit } from "unist-util-visit";

/** Returns true when every child of the element is a text or comment node. */
const hasOnlyTextChildren = (node: Element): boolean =>
node.children.every(
(child) => child.type === "text" || child.type === "comment"
);

/** Collect raw text from an element's text/comment children. */
const collectRawText = (node: Element): string =>
node.children
.map((child) =>
child.type === "text"
? (child as { type: "text"; value: string }).value
: ""
)
.join("");

/**
* Re-parse `markdown` text as a Markdown HAST subtree, stripping the root
* wrapper so the children can be spliced directly into the parent element.
*/
const parseMarkdownToHast = (markdown: string): ElementContent[] => {
const processor = unified()
.use(remarkParse)
.use(remarkRehype, { allowDangerousHtml: true });

const mdast = processor.parse(markdown);
// runSync transforms mdast → hast
const hast = processor.runSync(mdast) as Root;
return hast.children as ElementContent[];
};

export const rehypeMarkdownInCustomTags: Plugin<[string[], string[]?], Root> =
(tagNames, literalTagNames = []) =>
(tree: Root) => {
if (!tagNames || tagNames.length === 0) {
return;
}
const tagSet = new Set(tagNames.map((t) => t.toLowerCase()));
const literalSet = new Set(
(literalTagNames ?? []).map((t) => t.toLowerCase())
);

visit(tree, "element", (node: Element) => {
const tag = node.tagName.toLowerCase();
if (!tagSet.has(tag) || literalSet.has(tag)) {
return;
}

// Only act when the element has text-only children that look like they
// contain unparsed Markdown (i.e., the content came through as an HTML
// block and was not Markdown-parsed by remark).
if (!hasOnlyTextChildren(node)) {
return;
}

const rawText = collectRawText(node);

// Skip if no newlines — single-line content is already parsed inline.
if (!rawText.includes("\n")) {
return;
}

// Re-parse the raw text as Markdown and replace the element's children.
const parsed = parseMarkdownToHast(rawText);
node.children = parsed;
});
};
Loading