Skip to content

Commit 239e41d

Browse files
fix: Block-level Markdown escapes <details> containers when paragraphs/blank lines are present (#168)
* fix block-level markdown escaping details container * Create new-pants-leave.md
1 parent c68ebd6 commit 239e41d

File tree

3 files changed

+132
-3
lines changed

3 files changed

+132
-3
lines changed

.changeset/new-pants-leave.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"streamdown": patch
3+
---
4+
5+
fix: Block-level Markdown escapes <details> containers when paragraphs/blank lines are present
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { render } from "@testing-library/react";
2+
import { describe, expect, it, vi } from "vitest";
3+
import { Streamdown } from "../index";
4+
5+
// Mock the dependencies
6+
vi.mock("harden-react-markdown", () => ({
7+
default: (Component: any) => Component,
8+
}));
9+
10+
describe("HTML Block Elements with Multiline Content - #164", () => {
11+
it("should render multiline content inside details element", () => {
12+
const content = `<details>
13+
<summary>Summary</summary>
14+
15+
Paragraph inside details.
16+
</details>`;
17+
18+
const { container } = render(<Streamdown>{content}</Streamdown>);
19+
const details = container.querySelector("details");
20+
const paragraph = container.querySelector("p");
21+
22+
expect(details).toBeTruthy();
23+
expect(paragraph).toBeTruthy();
24+
// The paragraph should be inside the details element
25+
expect(details?.contains(paragraph as Node)).toBe(true);
26+
});
27+
28+
it("should render multiline content inside div element", () => {
29+
const content = `<div>
30+
31+
Paragraph inside div.
32+
</div>`;
33+
34+
const { container } = render(<Streamdown>{content}</Streamdown>);
35+
const div = container.querySelectorAll("div");
36+
const paragraph = container.querySelector("p");
37+
38+
expect(div.length).toBeGreaterThan(0);
39+
expect(paragraph).toBeTruthy();
40+
// The paragraph should be inside a div element
41+
const containingDiv = paragraph?.closest("div");
42+
expect(containingDiv).toBeTruthy();
43+
});
44+
45+
it("should handle multiple paragraphs inside details", () => {
46+
const content = `<details>
47+
<summary>Summary</summary>
48+
49+
First paragraph.
50+
51+
Second paragraph.
52+
</details>`;
53+
54+
const { container } = render(<Streamdown>{content}</Streamdown>);
55+
const details = container.querySelector("details");
56+
const paragraphs = container.querySelectorAll("p");
57+
58+
expect(details).toBeTruthy();
59+
expect(paragraphs.length).toBeGreaterThan(0);
60+
// All paragraphs should be inside the details element
61+
for (const p of paragraphs) {
62+
expect(details?.contains(p)).toBe(true);
63+
}
64+
});
65+
66+
it("should preserve nested structure in complex HTML blocks", () => {
67+
const content = `<div>
68+
<details>
69+
<summary>Nested Summary</summary>
70+
71+
Content in nested structure.
72+
</details>
73+
</div>`;
74+
75+
const { container } = render(<Streamdown>{content}</Streamdown>);
76+
const details = container.querySelector("details");
77+
const paragraph = container.querySelector("p");
78+
79+
expect(details).toBeTruthy();
80+
expect(paragraph).toBeTruthy();
81+
// The paragraph should be inside the details element
82+
expect(details?.contains(paragraph as Node)).toBe(true);
83+
});
84+
});

packages/streamdown/lib/parse-blocks.tsx

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,57 @@
11
import { Lexer } from "marked";
2+
import type { Token } from "marked";
23

34
export const parseMarkdownIntoBlocks = (markdown: string): string[] => {
45
const tokens = Lexer.lex(markdown, { gfm: true });
5-
const blocks = tokens.map((token) => token.raw);
66

7-
// Post-process to merge consecutive blocks that are part of the same math block
7+
// Post-process to merge consecutive blocks that belong together
88
const mergedBlocks: string[] = [];
9+
const htmlStack: string[] = []; // Track opening HTML tags
910

10-
for (const currentBlock of blocks) {
11+
for (let i = 0; i < tokens.length; i++) {
12+
const token = tokens[i];
13+
const currentBlock = token.raw;
14+
15+
// Check if we're inside an HTML block
16+
if (htmlStack.length > 0) {
17+
// We're inside an HTML block, merge with the previous block
18+
mergedBlocks[mergedBlocks.length - 1] += currentBlock;
19+
20+
// Check if this token closes an HTML tag
21+
if (token.type === "html") {
22+
const closingTagMatch = currentBlock.match(/<\/(\w+)>/);
23+
if (closingTagMatch) {
24+
const closingTag = closingTagMatch[1];
25+
// Check if this closes the most recent opening tag
26+
if (htmlStack[htmlStack.length - 1] === closingTag) {
27+
htmlStack.pop();
28+
}
29+
}
30+
}
31+
continue;
32+
}
33+
34+
// Check if this is an opening HTML block tag
35+
if (token.type === "html" && token.block) {
36+
const openingTagMatch = currentBlock.match(/<(\w+)[\s>]/);
37+
if (openingTagMatch) {
38+
const tagName = openingTagMatch[1];
39+
// Check if this is a self-closing tag or if there's a closing tag in the same block
40+
const hasClosingTag = currentBlock.includes(`</${tagName}>`);
41+
if (!hasClosingTag) {
42+
// This is an opening tag without a closing tag in the same block
43+
htmlStack.push(tagName);
44+
}
45+
}
46+
}
47+
48+
// Math block merging logic (existing)
1149
// Check if this is a standalone $$ that might be a closing delimiter
1250
if (currentBlock.trim() === "$$" && mergedBlocks.length > 0) {
1351
const previousBlock = mergedBlocks.at(-1);
1452

1553
if (!previousBlock) {
54+
mergedBlocks.push(currentBlock);
1655
continue;
1756
}
1857

@@ -32,6 +71,7 @@ export const parseMarkdownIntoBlocks = (markdown: string): string[] => {
3271
const previousBlock = mergedBlocks.at(-1);
3372

3473
if (!previousBlock) {
74+
mergedBlocks.push(currentBlock);
3575
continue;
3676
}
3777

0 commit comments

Comments
 (0)