From e0732b3c0d6fa8f94566e6e256324ca6e1ab50e7 Mon Sep 17 00:00:00 2001 From: Dmitrii Troitskii Date: Sat, 28 Mar 2026 10:39:59 +0000 Subject: [PATCH] fix(custom-tags): render Markdown inside custom tags with multiline content Adds rehypeMarkdownInCustomTags rehype plugin that re-parses raw text content of custom tag elements as Markdown. Previously, when a custom tag contained multiline content (e.g. `\n**bold**`), CommonMark treated the block as raw HTML, passing inner content through as raw text instead of Markdown. The plugin runs after rehype-raw and rehype-sanitize and only acts on elements whose children are text-only nodes containing newlines. Tags listed in `literalTagContent` are excluded from re-parsing. Closes #478 --- .changeset/fix-custom-tags-markdown.md | 14 +++ .../__tests__/custom-tags-markdown.test.tsx | 108 ++++++++++++++++++ packages/streamdown/index.tsx | 18 +++ .../lib/rehype/markdown-in-custom-tags.ts | 88 ++++++++++++++ 4 files changed, 228 insertions(+) create mode 100644 .changeset/fix-custom-tags-markdown.md create mode 100644 packages/streamdown/__tests__/custom-tags-markdown.test.tsx create mode 100644 packages/streamdown/lib/rehype/markdown-in-custom-tags.ts diff --git a/.changeset/fix-custom-tags-markdown.md b/.changeset/fix-custom-tags-markdown.md new file mode 100644 index 00000000..7b964a40 --- /dev/null +++ b/.changeset/fix-custom-tags-markdown.md @@ -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. ` +**bold**`), +CommonMark treated the block as raw HTML, stripping Markdown formatting. +Tags listed in `literalTagContent` are excluded from re-parsing. + +Closes #478 diff --git a/packages/streamdown/__tests__/custom-tags-markdown.test.tsx b/packages/streamdown/__tests__/custom-tags-markdown.test.tsx new file mode 100644 index 00000000..f87910d4 --- /dev/null +++ b/packages/streamdown/__tests__/custom-tags-markdown.test.tsx @@ -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 & 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 as 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) => ( +
{props.children as React.ReactNode}
+ ); + + const { container } = render( + + {"\n**bold**"} + + ); + + 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) => ( +
{props.children as React.ReactNode}
+ ); + + const { container } = render( + + {"**bold**"} + + ); + + 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) => ( +
{props.children as React.ReactNode}
+ ); + + const { container } = render( + + {"\n`code`"} + + ); + + 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) => ( +
{props.children as React.ReactNode}
+ ); + + const { container } = render( + + {"\n**bold**"} + + ); + + 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); + }); +}); diff --git a/packages/streamdown/index.tsx b/packages/streamdown/index.tsx index be32d9fd..001092a4 100644 --- a/packages/streamdown/index.tsx +++ b/packages/streamdown/index.tsx @@ -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, @@ -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]]; } @@ -733,6 +750,7 @@ export const Streamdown = memo( animatePlugin, isAnimating, allowedTags, + allowedTagNames, literalTagContent, ]); diff --git a/packages/streamdown/lib/rehype/markdown-in-custom-tags.ts b/packages/streamdown/lib/rehype/markdown-in-custom-tags.ts new file mode 100644 index 00000000..a8564bdc --- /dev/null +++ b/packages/streamdown/lib/rehype/markdown-in-custom-tags.ts @@ -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. `\n**bold**`), + * 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; + }); + };