Skip to content

Commit 2189e50

Browse files
ly-wang19claudevincentkoc
authored andcommitted
fix(markdown): a fenced-code line with trailing text is content, not a closing fence (openclaw#96745)
* fix(markdown): a fenced-code line with trailing text is content, not a closing fence scanFenceSpans accepted any line starting with >=3 matching fence markers as a closing fence, ignoring trailing text after the marker. Per CommonMark a closing fence may be followed only by whitespace, so a code-content line such as "``` not a close" was wrongly treated as a close: the block ended early, the following lines were reported as outside any fence, and the trailing marker line became a new unclosed opener. That made isSafeFenceBreak() return true for offsets inside the real code block and findFenceSpanAt() return undefined, so chunkers (chunkMarkdownText, the embedded-agent block chunker) could split inside a fenced code block — the exact thing this module exists to prevent. Require the closing fence's trailing text to be whitespace-only. Opening info strings, bare closes, and longer same-marker closes are unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(markdown): honor fence suffix whitespace rules Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com> --------- Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
1 parent 4d966f6 commit 2189e50

2 files changed

Lines changed: 53 additions & 4 deletions

File tree

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
// Tests fenced-code-block span scanning used to keep chunk breaks out of code blocks.
2+
import { describe, expect, it } from "vitest";
3+
import { isSafeFenceBreak, parseFenceSpans } from "./fences.js";
4+
5+
describe("parseFenceSpans closing-fence rules", () => {
6+
it("treats a marker line with trailing text as code content, not a closing fence", () => {
7+
// CommonMark: a closing fence may be followed only by whitespace, so "``` not a close" is code
8+
// content and the block stays open until the real closing fence. Reporting an interior offset
9+
// as a safe break would let a chunker split inside the code block.
10+
const text = "```\ncode\n``` not a close\nmore code\n```\n";
11+
const spans = parseFenceSpans(text);
12+
13+
expect(spans).toHaveLength(1);
14+
expect(isSafeFenceBreak(spans, text.indexOf("more code") + 1)).toBe(false);
15+
});
16+
17+
it("does not close on non-space/tab whitespace", () => {
18+
for (const suffix of ["\u00a0", "\v", "\f"]) {
19+
const text = `\`\`\`\ncode\n\`\`\`${suffix}\nmore code\n`;
20+
const spans = parseFenceSpans(text);
21+
22+
expect(spans).toHaveLength(1);
23+
expect(spans[0]?.end).toBe(text.length);
24+
expect(isSafeFenceBreak(spans, text.indexOf("more code") + 1)).toBe(false);
25+
}
26+
});
27+
28+
it("closes fences with CRLF line endings", () => {
29+
const text = "```\r\ncode\r\n```\r\nafter\r\n";
30+
const spans = parseFenceSpans(text);
31+
32+
expect(spans).toHaveLength(1);
33+
expect(isSafeFenceBreak(spans, text.indexOf("after") + 1)).toBe(true);
34+
});
35+
36+
it("still closes on a bare fence, a longer same-marker fence, and keeps an opener info string", () => {
37+
expect(parseFenceSpans("```\ncode\n```\nafter\n")).toHaveLength(1);
38+
expect(parseFenceSpans("```\ncode\n````` \nafter\n")).toHaveLength(1);
39+
expect(parseFenceSpans("```python\nx = 1\n```\n")).toHaveLength(1);
40+
41+
const closed = "```\ncode\n```\nafter\n";
42+
const spans = parseFenceSpans(closed);
43+
expect(isSafeFenceBreak(spans, closed.indexOf("after") + 1)).toBe(true);
44+
});
45+
});

packages/markdown-core/src/fences.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export function scanFenceSpans(
4141
while (offset <= buffer.length) {
4242
const nextNewline = buffer.indexOf("\n", offset);
4343
const lineEnd = nextNewline === -1 ? buffer.length : nextNewline;
44-
const line = buffer.slice(offset, lineEnd);
44+
const line = buffer.slice(offset, lineEnd).replace(/\r$/, "");
4545

4646
const match = line.match(/^( {0,3})(`{3,}|~{3,})(.*)$/);
4747
if (match && (offset > 0 || startsAtLineStart)) {
@@ -58,9 +58,13 @@ export function scanFenceSpans(
5858
marker,
5959
indent,
6060
};
61-
} else if (open.markerChar === markerChar && markerLen >= open.markerLen) {
62-
// CommonMark allows a closing fence to be longer than the opener, but
63-
// it must use the same marker character to avoid crossing fence kinds.
61+
} else if (
62+
open.markerChar === markerChar &&
63+
markerLen >= open.markerLen &&
64+
/^[ \t]*$/.test(match[3])
65+
) {
66+
// CommonMark permits only spaces or tabs after a closing fence. A marker line carrying
67+
// other trailing text is code content, not a close, so it must not end the block.
6468
const end = lineEnd;
6569
spans.push({
6670
start: open.start,

0 commit comments

Comments
 (0)