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]*?)(${tagName}\\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(`${name}\\s*>`, "i");
+ const closeRe = new RegExp(`${tagName}\\s*>`, "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]*?)(${tagName}\\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;