diff --git a/.changeset/inline-custom-tags.md b/.changeset/inline-custom-tags.md new file mode 100644 index 00000000..ad561d91 --- /dev/null +++ b/.changeset/inline-custom-tags.md @@ -0,0 +1,5 @@ +--- +"streamdown": patch +--- + +Stop treating self-closing custom tags as containers: they no longer turn later blank lines into ``, which split loose lists, or pair with a later closing tag. Leave a custom tag that follows text on its line as inline HTML, so a line break inside it no longer moves its body out of the tag. diff --git a/packages/streamdown/__tests__/custom-tags-markdown.test.tsx b/packages/streamdown/__tests__/custom-tags-markdown.test.tsx index 58c8829e..425b85f2 100644 --- a/packages/streamdown/__tests__/custom-tags-markdown.test.tsx +++ b/packages/streamdown/__tests__/custom-tags-markdown.test.tsx @@ -132,3 +132,94 @@ describe("Fenced code blocks inside custom tags", () => { expect(code?.textContent).not.toContain(""); }); }); + +describe("Custom tag position on the line", () => { + it("should keep a loose list whole after a self-closing tag", () => { + const { container } = render( + + {'Intro \n\n- one\n\n- two\n\nLast'} + + ); + + const lists = container.querySelectorAll("ul"); + expect(lists).toHaveLength(1); + expect(lists[0].querySelectorAll("li")).toHaveLength(2); + }); + + it("should keep a soft line break inside a tag that follows text", () => { + const Verse = (props: CustomComponentProps) => ( + {props.children as React.ReactNode} + ); + + const { container } = render( + + {"He said: line one\nline two and left."} + + ); + + const verse = container.querySelector('[data-testid="verse"]'); + expect(verse?.textContent).toBe("line one\nline two"); + expect(verse?.closest("p")?.textContent).toBe( + "He said: line one\nline two and left." + ); + }); + + it.each([ + ["a bullet item", "- \n **bold**\n "], + ["an ordered item", "1. \n **bold**\n "], + ["an indented line", "- a\n\n \n **bold**\n "], + ])("should still parse markdown in a tag that starts %s", (_name, md) => { + const Note = (props: CustomComponentProps) => ( +
{props.children as React.ReactNode}
+ ); + + const { container } = render( + + {md} + + ); + + const strong = container.querySelector( + `[data-testid="note"] ${STRONG_SELECTOR}` + ); + expect(strong?.textContent).toBe("bold"); + }); + + it.each([ + ["on its own line", "\na\n \n**b**\n"], + ["that ends a body line", "\na\n**b**\n"], + ["followed by a space", "\na \n**b**\n"], + ["of a tag listed later", "\n**b**\n"], + [ + "of a tag listed later while streaming", + "\n**b**", + ], + ])("should still parse markdown in a tag after a closing tag %s", (_name, md) => { + const Note = (props: CustomComponentProps) => ( +
{props.children as React.ReactNode}
+ ); + + const { container } = render( + + {md} + + ); + + const strong = container.querySelector( + `[data-testid="note"] ${STRONG_SELECTOR}` + ); + expect(strong?.textContent).toBe("b"); + }); +}); diff --git a/packages/streamdown/__tests__/preprocess-custom-tags.test.ts b/packages/streamdown/__tests__/preprocess-custom-tags.test.ts index 544e7d1a..9d622191 100644 --- a/packages/streamdown/__tests__/preprocess-custom-tags.test.ts +++ b/packages/streamdown/__tests__/preprocess-custom-tags.test.ts @@ -141,4 +141,54 @@ describe("preprocessCustomTags", () => { const result = preprocessCustomTags(md, ["custom"]); expect(result).toBe("\n\n```md\n```js\n\nx\n```\n\n\n\n"); }); + + it("should leave blank lines after a self-closing tag unchanged", () => { + const md = '\n\n- one\n\n- two\n\nLast'; + expect(preprocessCustomTags(md, ["cite"])).toBe(md); + }); + + it("should not pair a self-closing tag with a later closing tag", () => { + const md = '\n\nText\n\n\nBody\n'; + const result = preprocessCustomTags(md, ["cite"]); + expect(result).toBe( + '\n\nText\n\n\n\nBody\n\n\n\n' + ); + }); + + it("should leave a tag after text with a soft line break unchanged", () => { + const md = "He said: line one\nline two and left."; + expect(preprocessCustomTags(md, ["verse"])).toBe(md); + }); + + it("should leave a tag after text unchanged when its body has a blank line", () => { + const md = "He said: a\n\nb and left."; + expect(preprocessCustomTags(md, ["verse"])).toBe(md); + }); + + it("should leave an unclosed tag after text unchanged while streaming", () => { + const md = "He said: \nline one"; + expect(preprocessCustomTags(md, ["verse"])).toBe(md); + }); + + it.each([ + ["a \n**b**\n"], + ["Text \na\n**b**\n"], + ])("should leave a tag after an inline tag on the same line unchanged", (md) => { + expect(preprocessCustomTags(md, ["note"])).toBe(md); + }); + + it.each([ + ["\na\n**b**\n"], + ["\na - \n**b**\n"], + ])("should not depend on the tag order for adjacent containers", (md) => { + expect(preprocessCustomTags(md, ["y", "x"])).toBe( + preprocessCustomTags(md, ["x", "y"]) + ); + }); + + it("should treat a leading byte order mark as the start of the line", () => { + expect(preprocessCustomTags("\uFEFF\n**b**\n", ["x"])).toBe( + "\uFEFF\n\n**b**\n\n\n\n" + ); + }); }); diff --git a/packages/streamdown/lib/preprocess-custom-tags.ts b/packages/streamdown/lib/preprocess-custom-tags.ts index fc9e9f6c..00ed78eb 100644 --- a/packages/streamdown/lib/preprocess-custom-tags.ts +++ b/packages/streamdown/lib/preprocess-custom-tags.ts @@ -5,7 +5,10 @@ * * 1. **Same-line content** (`**bold**`) is left alone. The parser * treats the tag as inline HTML inside a paragraph, so nested markdown still - * runs as phrasing content. + * runs as phrasing content. So is a tag that follows text on its line + * (`Note: a\nb`): padding it would end the paragraph after the + * opening tag and leave the tag empty. Self-closing tags (``) are + * left alone too. * * 2. **Multi-line content** must interrupt the HTML block for nested markdown * to parse. HTML blocks end at a blank line, the same shape used by @@ -17,9 +20,10 @@ * * * - * This function normalizes multi-line custom tags into that shape: a blank - * line after the opening tag and before the closing tag (with a trailing - * blank line so later markdown is not absorbed). + * This function normalizes multi-line custom tags that start a block (see + * createStartsBlock) into that shape: a blank line after the opening tag + * and before the closing tag (with a trailing blank line so later markdown + * is not absorbed). * * 3. **Internal blank lines** are replaced with HTML comments (``) so * that, if the block is ever treated as a contiguous HTML unit (e.g. by a @@ -28,9 +32,10 @@ * is parsing between the open/close tags. * * 4. **Streaming / unclosed tags**: while the close tag has not arrived yet, - * the same blank-line interruption is applied after the opening tag so - * nested markdown can parse incrementally. Without this, incomplete tags - * stay a single raw HTML block and `**bold**` renders as literal text. + * the same blank-line interruption is applied after an opening tag that + * starts a block, so nested markdown can parse incrementally. Without + * this, incomplete tags stay a single raw HTML block and `**bold**` renders + * as literal text. * * `parseMarkdownIntoBlocks` re-merges the interrupted open/content/close * tokens so streaming still treats the whole custom tag as one block. @@ -41,6 +46,149 @@ const TRAILING_NEWLINES_RE = /\n*$/; const BLANK_LINE_RE = /\n\n/g; const FENCE_RE = /^[ \t]*(`{3,}|~{3,})(.*)$/; +// An opening tag that is not self-closing: matches `` and ``, +// not ``. +const openTagSource = (tagName: string): string => + `<${tagName}(?=[\\s>/])(?:[^>]*[^>/])?>`; + +const closedTagPattern = (tagName: string): RegExp => + new RegExp( + `(${openTagSource(tagName)})([\\s\\S]*?)()`, + "gi" + ); + +// A run that a tag may follow at the start of its line, or after the closing +// tag of a padded container, and still start a block: indentation, list or +// blockquote markers, and closing tags. +const BLOCK_PREFIX_RE = + /[ \t]*(?:(?:[*+-]|\d{1,9}[.)])[ \t]+|>[ \t]*|<\/[A-Za-z][^<>\n]*>[ \t]*)*/y; +// The characters that can end such a run, before its trailing whitespace. +const PREFIX_RUN_END_CHARS = ">*+-.)"; + +// Index of the last number in ascending `sorted` that is at most `value`. +const findLastAtMost = (sorted: number[], value: number): number => { + let low = -1; + let high = sorted.length - 1; + while (low < high) { + const mid = Math.ceil((low + high) / 2); + if (sorted[mid] <= value) { + low = mid; + } else { + high = mid - 1; + } + } + return low; +}; + +// The opening tag start and closing tag end of every allowed pair with a +// multi-line body, in document order. +const multilinePairs = ( + markdown: string, + tagNames: string[] +): [open: number, closeEnd: number][] => { + const pairs: [open: number, closeEnd: number][] = []; + for (const tagName of tagNames) { + for (const match of markdown.matchAll(closedTagPattern(tagName))) { + if (match[2].includes("\n")) { + pairs.push([match.index, match.index + match[0].length]); + } + } + } + return pairs.sort((a, b) => a[0] - b[0]); +}; + +/** + * Returns a check for whether the tag at an index of `markdown` starts a block. + * It does when only a block prefix run (see BLOCK_PREFIX_RE) precedes it on its + * line, or separates it from the closing tag of a multi-line container that + * itself starts a block: padding puts a blank line after that closing tag. + * Otherwise the tag follows text, which makes it inline HTML rather than a + * container. + */ +const createStartsBlock = (markdown: string, tagNames: string[]) => { + const prefixEnds = new Map(); + let lineStarts: number[] | undefined; + let closeEnds: number[] | undefined; + const paddedCloseEnds = new Set(); + + const lineStartOf = (index: number): number => { + if (!lineStarts) { + // remark drops a byte order mark at the start of the document. + lineStarts = [markdown.startsWith("\uFEFF") ? 1 : 0]; + for ( + let newline = markdown.indexOf("\n"); + newline !== -1; + newline = markdown.indexOf("\n", newline + 1) + ) { + lineStarts.push(newline + 1); + } + } + return lineStarts[findLastAtMost(lineStarts, index)]; + }; + + // Where the block prefix run that begins at `start` ends. + const prefixEnd = (start: number): number => { + let end = prefixEnds.get(start); + if (end === undefined) { + BLOCK_PREFIX_RE.lastIndex = start; + end = start + (BLOCK_PREFIX_RE.exec(markdown)?.[0].length ?? 0); + prefixEnds.set(start, end); + } + return end; + }; + + // Ends of the closing tags of allowed pairs with a multi-line body. Pairs + // are decided in document order, and a container only depends on pairs that + // close before it opens, so those are always decided first. The array is + // assigned before deciding, for the startsBlock calls made while deciding. + const getCloseEnds = (): number[] => { + if (!closeEnds) { + const pairs = multilinePairs(markdown, tagNames); + closeEnds = pairs.map(([, closeEnd]) => closeEnd).sort((a, b) => a - b); + for (const [open, closeEnd] of pairs) { + if (startsBlock(open)) { + paddedCloseEnds.add(closeEnd); + } + } + } + return closeEnds; + }; + + const followsPaddedContainer = (lineStart: number, index: number) => { + let last = index - 1; + while ( + last > lineStart && + (markdown[last] === " " || markdown[last] === "\t") + ) { + last -= 1; + } + if (last <= lineStart || !PREFIX_RUN_END_CHARS.includes(markdown[last])) { + return false; + } + const ends = getCloseEnds(); + for (let i = findLastAtMost(ends, index); i >= 0; i -= 1) { + // A closing tag further back would have to reach the tag through this + // one, so the first run that falls short settles it. + if (ends[i] <= lineStart || prefixEnd(ends[i]) !== index) { + return false; + } + if (paddedCloseEnds.has(ends[i])) { + return true; + } + } + return false; + }; + + const startsBlock = (index: number): boolean => { + const lineStart = lineStartOf(index); + return ( + prefixEnd(lineStart) === index || followsPaddedContainer(lineStart, index) + ); + }; + + return startsBlock; +}; + /** * Replaces blank lines with `` placeholders, except inside fenced code * blocks. A comment inside a fence is not markup, it is literal code text, so @@ -128,16 +276,19 @@ const normalizeClosedTag = ( }; /** - * For unclosed opening tags (still streaming), ensure a blank line follows - * the open tag when body content has started on a later line. That ends the - * HTML block so the body can parse as markdown before the close tag arrives. - * Internal blank lines are also replaced with `` for consistency. + * For unclosed opening tags that start a block (still streaming), ensure a + * blank line follows the open tag when body content has started on a later + * line. That ends the HTML block so the body can parse as markdown before the + * close tag arrives. Internal blank lines are also replaced with `` for + * consistency. */ const normalizeUnclosedOpenTags = ( markdown: string, - tagName: string + tagName: string, + tagNames: string[] ): string => { - const openOnly = new RegExp(`<(${tagName})(?=[\\s>/])([^>]*)>`, "gi"); + const openOnly = new RegExp(openTagSource(tagName), "gi"); + const startsBlock = createStartsBlock(markdown, tagNames); let result = ""; let lastIndex = 0; @@ -145,12 +296,18 @@ const normalizeUnclosedOpenTags = ( while (match) { const fullOpen = match[0]; - const name = match[1]; const openEnd = match.index + fullOpen.length; const after = markdown.slice(openEnd); + // A tag that follows text on its line is inline HTML, not a streaming + // container. + if (!startsBlock(match.index)) { + match = openOnly.exec(markdown); + continue; + } + // Skip if a matching close tag exists later — closed-pair pass owns it. - const closeRe = new RegExp(``, "i"); + const closeRe = new RegExp(``, "i"); if (closeRe.test(after)) { match = openOnly.exec(markdown); continue; @@ -218,18 +375,20 @@ export const preprocessCustomTags = ( let result = markdown; for (const tagName of tagNames) { - const closedPattern = new RegExp( - `(<${tagName}(?=[\\s>/])[^>]*>)([\\s\\S]*?)()`, - "gi" - ); - + const startsBlock = createStartsBlock(result, tagNames); result = result.replace( - closedPattern, - (_match, open: string, content: string, close: string) => - normalizeClosedTag(open, content, close) + closedTagPattern(tagName), + ( + match: string, + open: string, + content: string, + close: string, + offset: number + ) => + startsBlock(offset) ? normalizeClosedTag(open, content, close) : match ); - result = normalizeUnclosedOpenTags(result, tagName); + result = normalizeUnclosedOpenTags(result, tagName, tagNames); } return result;