From 3d32b1a67b920bbcd2469ca4b4ca72b142a3f2a7 Mon Sep 17 00:00:00 2001 From: Dmitrii Troitskii Date: Thu, 26 Mar 2026 18:38:13 +0000 Subject: [PATCH] fix(custom-tags): restructure inline content with newlines to prevent ATX heading block splitting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Custom tags with content inline on the opening tag line and a newline inside would be prematurely closed by the CommonMark parser when the newline was followed by a block-level element like an ATX heading (# title). Example: hi # yes The marked Lexer parsed this as: - paragraph: ' hi\n' - heading: '# yes' The preprocessor already handled blank lines (\n\n) by restructuring the tag to block-level HTML. Extend the condition to also restructure when content is inline (doesn't start with \n) and contains any newline, since any newline in inline content can introduce block-starting sequences. Tags where content already starts on its own line (\nContent\n) are unaffected — they're correctly parsed as block-level HTML even with headings inside. Fixes #477 --- .../__tests__/preprocess-custom-tags.test.ts | 16 +++++++++++++++- .../streamdown/lib/preprocess-custom-tags.ts | 14 ++++++++++---- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/packages/streamdown/__tests__/preprocess-custom-tags.test.ts b/packages/streamdown/__tests__/preprocess-custom-tags.test.ts index f87d0166..58ff8494 100644 --- a/packages/streamdown/__tests__/preprocess-custom-tags.test.ts +++ b/packages/streamdown/__tests__/preprocess-custom-tags.test.ts @@ -51,11 +51,25 @@ describe("preprocessCustomTags", () => { expect(preprocessCustomTags(md, ["custom"])).toBe(md); }); - it("should not modify content without blank lines", () => { + it("should not modify content without blank lines (no newline)", () => { + // Single-line content without any newlines is fine as inline HTML const md = "Hello World"; expect(preprocessCustomTags(md, ["custom"])).toBe(md); }); + it("should restructure inline content with a newline followed by ATX heading", () => { + // A heading inside inline content would prematurely terminate the custom tag + const md = " hi\n # yes"; + const result = preprocessCustomTags(md, ["ai-thinking"]); + expect(result).toBe("\n hi\n # yes\n\n\n"); + }); + + it("should restructure inline content with any newline (prevents block element splitting)", () => { + const md = "hello\nworld"; + const result = preprocessCustomTags(md, ["custom"]); + expect(result).toBe("\nhello\nworld\n\n\n"); + }); + it("should not modify tags where content already starts on own line without blank lines", () => { const md = "\nHello\n"; expect(preprocessCustomTags(md, ["custom"])).toBe(md); diff --git a/packages/streamdown/lib/preprocess-custom-tags.ts b/packages/streamdown/lib/preprocess-custom-tags.ts index 0c54268c..df258142 100644 --- a/packages/streamdown/lib/preprocess-custom-tags.ts +++ b/packages/streamdown/lib/preprocess-custom-tags.ts @@ -35,10 +35,16 @@ export const preprocessCustomTags = ( result = result.replace( pattern, (_match, open: string, content: string, close: string) => { - // Only restructure when content contains blank lines that would - // split the HTML block. Tags without blank lines work fine as - // inline HTML and don't need restructuring. - if (!content.includes("\n\n")) { + // Restructure when: + // 1. Content has blank lines (\n\n) — these would split the HTML block, or + // 2. Content starts inline (not preceded by \n) and contains any newline — + // a newline inside inline content can introduce block-level elements like + // ATX headings (# title) that the parser would treat as a new block, + // prematurely terminating the custom tag scope. + const hasBlankLines = content.includes("\n\n"); + const isInlineContent = !content.startsWith("\n"); + const hasNewline = content.includes("\n"); + if (!(hasBlankLines || (isInlineContent && hasNewline))) { return open + content + close; }