Skip to content

Commit 4bac3cb

Browse files
committed
fix(mobile): keep quoted mermaid fences inside their quote
Track the opener's blockquote depth and only accept closers at that depth or deeper, so an unquoted fence cannot close a quoted block and swallow following content into a diagram. Add regression coverage.
1 parent 8018ff2 commit 4bac3cb

2 files changed

Lines changed: 30 additions & 4 deletions

File tree

‎apps/mobile/src/lib/mermaidBlocks.test.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,15 @@ describe("splitMarkdownWithMermaid", () => {
8989
expect(segments[1]).toMatchObject({ kind: "mermaid", source: "A --> B" });
9090
});
9191

92+
it("does not let an unquoted fence close a quoted mermaid block", () => {
93+
const markdown = ["> ```mermaid", "> A --> B", "Outside", "```ts", "const x = 1;", "```"].join(
94+
"\n",
95+
);
96+
const segments = splitMarkdownWithMermaid(markdown);
97+
expect(segments.map((segment) => segment.kind)).toEqual(["markdown"]);
98+
expect(segments[0]).toMatchObject({ kind: "markdown", markdown });
99+
});
100+
92101
it("ignores link definitions inside fenced code", () => {
93102
const segments = splitMarkdownWithMermaid(
94103
[

‎apps/mobile/src/lib/mermaidBlocks.ts‎

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ function stripQuote(line: string): string {
1717
return line.replace(QUOTE_RE, "");
1818
}
1919

20+
function quoteDepth(line: string): number {
21+
const match = QUOTE_RE.exec(line);
22+
if (!match) return 0;
23+
return match[0].split(">").length - 1;
24+
}
25+
2026
function fenceLanguage(info: string): string {
2127
return info.trim().split(/\s+/, 1)[0]?.toLowerCase() ?? "";
2228
}
@@ -29,11 +35,19 @@ function fenceHash(source: string): string {
2935
return `m${(hash >>> 0).toString(36)}`;
3036
}
3137

32-
function closerIndex(lines: readonly string[], from: number, fence: string): number {
38+
function closerIndex(
39+
lines: readonly string[],
40+
depths: readonly number[],
41+
from: number,
42+
fence: string,
43+
minDepth: number,
44+
): number {
3345
const char = fence[0] === "`" ? "`" : "~";
3446
const closeRe = new RegExp(`^ {0,3}${char}{${fence.length},}[ \\t]*$`);
3547
for (let scan = from; scan < lines.length; scan += 1) {
36-
if (closeRe.test(lines[scan] ?? "")) return scan;
48+
// A fenced block ends when its blockquote ends, so only a fence at the
49+
// opener's quote depth or deeper can close it.
50+
if ((depths[scan] ?? 0) >= minDepth && closeRe.test(lines[scan] ?? "")) return scan;
3751
}
3852
return -1;
3953
}
@@ -42,12 +56,13 @@ function closerIndex(lines: readonly string[], from: number, fence: string): num
4256
function collectDefinitions(markdown: string): string[] {
4357
const found: string[] = [];
4458
const lines = markdown.split("\n").map(stripQuote);
59+
const depths = markdown.split("\n").map(quoteDepth);
4560
let index = 0;
4661
while (index < lines.length) {
4762
const line = lines[index] ?? "";
4863
const open = FENCE_RE.exec(line);
4964
if (open) {
50-
const close = closerIndex(lines, index + 1, open[1] ?? "```");
65+
const close = closerIndex(lines, depths, index + 1, open[1] ?? "```", depths[index] ?? 0);
5166
index = close === -1 ? lines.length : close + 1;
5267
continue;
5368
}
@@ -69,6 +84,7 @@ export function splitMarkdownWithMermaid(markdown: string): MermaidMarkdownSegme
6984
> = [];
7085
const lines = markdown.split("\n");
7186
const text = lines.map(stripQuote);
87+
const depths = lines.map(quoteDepth);
7288
let markdownStart = 0;
7389
let index = 0;
7490

@@ -89,7 +105,8 @@ export function splitMarkdownWithMermaid(markdown: string): MermaidMarkdownSegme
89105
continue;
90106
}
91107
const fence = open[1] ?? "```";
92-
const close = closerIndex(text, index + 1, fence);
108+
const depth = depths[index] ?? 0;
109+
const close = closerIndex(text, depths, index + 1, fence, depth);
93110
if (close === -1) {
94111
index += 1;
95112
continue;

0 commit comments

Comments
 (0)