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
5 changes: 5 additions & 0 deletions .changeset/inline-custom-tags.md
Original file line number Diff line number Diff line change
@@ -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.
91 changes: 91 additions & 0 deletions packages/streamdown/__tests__/custom-tags-markdown.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<Streamdown allowedTags={{ cite: ["id"] }} mode="static">
{'Intro <cite id="a" />\n\n- one\n\n- two\n\nLast'}
</Streamdown>
);

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) => (
<span data-testid="verse">{props.children as React.ReactNode}</span>
);

const { container } = render(
<Streamdown
allowedTags={{ verse: [] }}
components={{ verse: Verse }}
mode="static"
>
{"He said: <verse>line one\nline two</verse> and left."}
</Streamdown>
);

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", "- <note>\n **bold**\n </note>"],
["an ordered item", "1. <note>\n **bold**\n </note>"],
["an indented line", "- a\n\n <note>\n **bold**\n </note>"],
])("should still parse markdown in a tag that starts %s", (_name, md) => {
const Note = (props: CustomComponentProps) => (
<div data-testid="note">{props.children as React.ReactNode}</div>
);

const { container } = render(
<Streamdown
allowedTags={{ note: [] }}
components={{ note: Note }}
mode="static"
>
{md}
</Streamdown>
);

const strong = container.querySelector(
`[data-testid="note"] ${STRONG_SELECTOR}`
);
expect(strong?.textContent).toBe("bold");
});

it.each([
["on its own line", "<note>\na\n</note> <note>\n**b**\n</note>"],
["that ends a body line", "<note>\na</note><note>\n**b**\n</note>"],
["followed by a space", "<note>\na</note> <note>\n**b**\n</note>"],
["of a tag listed later", "<aside>\na</aside><note>\n**b**\n</note>"],
[
"of a tag listed later while streaming",
"<aside>\na</aside><note>\n**b**",
],
])("should still parse markdown in a tag after a closing tag %s", (_name, md) => {
const Note = (props: CustomComponentProps) => (
<div data-testid="note">{props.children as React.ReactNode}</div>
);

const { container } = render(
<Streamdown
allowedTags={{ note: [], aside: [] }}
components={{ note: Note }}
mode="static"
>
{md}
</Streamdown>
);

const strong = container.querySelector(
`[data-testid="note"] ${STRONG_SELECTOR}`
);
expect(strong?.textContent).toBe("b");
});
});
50 changes: 50 additions & 0 deletions packages/streamdown/__tests__/preprocess-custom-tags.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,4 +141,54 @@ describe("preprocessCustomTags", () => {
const result = preprocessCustomTags(md, ["custom"]);
expect(result).toBe("<custom>\n\n```md\n```js\n\nx\n```\n\n</custom>\n\n");
});

it("should leave blank lines after a self-closing tag unchanged", () => {
const md = '<cite id="a" />\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 = '<cite id="a" />\n\nText\n\n<cite id="b">\nBody\n</cite>';
const result = preprocessCustomTags(md, ["cite"]);
expect(result).toBe(
'<cite id="a" />\n\nText\n\n<cite id="b">\n\nBody\n\n</cite>\n\n'
);
});

it("should leave a tag after text with a soft line break unchanged", () => {
const md = "He said: <verse>line one\nline two</verse> 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: <verse>a\n\nb</verse> and left.";
expect(preprocessCustomTags(md, ["verse"])).toBe(md);
});

it("should leave an unclosed tag after text unchanged while streaming", () => {
const md = "He said: <verse>\nline one";
expect(preprocessCustomTags(md, ["verse"])).toBe(md);
});

it.each([
["<note>a</note> <note>\n**b**\n</note>"],
["Text <note>\na</note><note>\n**b**\n</note>"],
])("should leave a tag after an inline tag on the same line unchanged", (md) => {
expect(preprocessCustomTags(md, ["note"])).toBe(md);
});

it.each([
["<x>\na</x><y>\n**b**\n</y>"],
["<x>\na</x> - <y>\n**b**\n</y>"],
])("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<x>\n**b**\n</x>", ["x"])).toBe(
"\uFEFF<x>\n\n**b**\n\n</x>\n\n"
);
});
});
Loading
Loading