Skip to content

Commit 8af8b9d

Browse files
boojackclaude
andcommitted
fix(web): use AST parsing for task detection to handle code blocks correctly
Fixes #5319. Checkboxes inside code blocks were incorrectly counted when toggling tasks, causing the wrong checkbox to be checked. Replaced regex-based task detection with mdast AST parsing which properly ignores code block content. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 3dc740c commit 8af8b9d

File tree

3 files changed

+92
-67
lines changed

3 files changed

+92
-67
lines changed

web/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,10 @@
3838
"leaflet": "^1.9.4",
3939
"lodash-es": "^4.17.21",
4040
"lucide-react": "^0.544.0",
41+
"mdast-util-from-markdown": "^2.0.2",
42+
"mdast-util-gfm": "^3.1.0",
4143
"mermaid": "^11.12.1",
44+
"micromark-extension-gfm": "^3.0.0",
4245
"mime": "^4.1.0",
4346
"mobx": "^6.15.0",
4447
"mobx-react-lite": "^4.1.1",

web/pnpm-lock.yaml

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 80 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,39 @@
1-
// Utilities for manipulating markdown strings (GitHub-style approach)
2-
// These functions modify the raw markdown text directly without parsing to AST
1+
// Utilities for manipulating markdown strings using AST parsing
2+
// Uses mdast for accurate task detection that properly handles code blocks
3+
4+
import type { ListItem } from "mdast";
5+
import { fromMarkdown } from "mdast-util-from-markdown";
6+
import { gfmFromMarkdown } from "mdast-util-gfm";
7+
import { gfm } from "micromark-extension-gfm";
8+
import { visit } from "unist-util-visit";
9+
10+
interface TaskInfo {
11+
lineNumber: number;
12+
checked: boolean;
13+
}
14+
15+
// Extract all task list items from markdown using AST parsing
16+
// This correctly ignores task-like patterns inside code blocks
17+
function extractTasksFromAst(markdown: string): TaskInfo[] {
18+
const tree = fromMarkdown(markdown, {
19+
extensions: [gfm()],
20+
mdastExtensions: [gfmFromMarkdown()],
21+
});
22+
23+
const tasks: TaskInfo[] = [];
24+
25+
visit(tree, "listItem", (node: ListItem) => {
26+
// Only process actual task list items (those with a checkbox)
27+
if (typeof node.checked === "boolean" && node.position?.start.line) {
28+
tasks.push({
29+
lineNumber: node.position.start.line - 1, // Convert to 0-based
30+
checked: node.checked,
31+
});
32+
}
33+
});
34+
35+
return tasks;
36+
}
337

438
export function toggleTaskAtLine(markdown: string, lineNumber: number, checked: boolean): string {
539
const lines = markdown.split("\n");
@@ -26,47 +60,36 @@ export function toggleTaskAtLine(markdown: string, lineNumber: number, checked:
2660
}
2761

2862
export function toggleTaskAtIndex(markdown: string, taskIndex: number, checked: boolean): string {
29-
const lines = markdown.split("\n");
30-
const taskPattern = /^(\s*[-*+]\s+)\[([ xX])\](\s+.*)$/;
31-
32-
let currentTaskIndex = 0;
63+
const tasks = extractTasksFromAst(markdown);
3364

34-
for (let i = 0; i < lines.length; i++) {
35-
const line = lines[i];
36-
const match = line.match(taskPattern);
37-
38-
if (match) {
39-
if (currentTaskIndex === taskIndex) {
40-
const [, prefix, , suffix] = match;
41-
const newCheckmark = checked ? "x" : " ";
42-
lines[i] = `${prefix}[${newCheckmark}]${suffix}`;
43-
break;
44-
}
45-
currentTaskIndex++;
46-
}
65+
if (taskIndex < 0 || taskIndex >= tasks.length) {
66+
return markdown;
4767
}
4868

49-
return lines.join("\n");
69+
const task = tasks[taskIndex];
70+
return toggleTaskAtLine(markdown, task.lineNumber, checked);
5071
}
5172

5273
export function removeCompletedTasks(markdown: string): string {
74+
const tasks = extractTasksFromAst(markdown);
75+
const completedLineNumbers = new Set(tasks.filter((t) => t.checked).map((t) => t.lineNumber));
76+
77+
if (completedLineNumbers.size === 0) {
78+
return markdown;
79+
}
80+
5381
const lines = markdown.split("\n");
54-
const completedTaskPattern = /^(\s*[-*+]\s+)\[([xX])\](\s+.*)$/;
5582
const result: string[] = [];
5683

5784
for (let i = 0; i < lines.length; i++) {
58-
const line = lines[i];
59-
60-
// Skip completed tasks
61-
if (completedTaskPattern.test(line)) {
85+
if (completedLineNumbers.has(i)) {
6286
// Also skip the following line if it's empty (preserve spacing)
6387
if (i + 1 < lines.length && lines[i + 1].trim() === "") {
6488
i++;
6589
}
6690
continue;
6791
}
68-
69-
result.push(line);
92+
result.push(lines[i]);
7093
}
7194

7295
return result.join("\n");
@@ -77,22 +100,10 @@ export function countTasks(markdown: string): {
77100
completed: number;
78101
incomplete: number;
79102
} {
80-
const lines = markdown.split("\n");
81-
const taskPattern = /^(\s*[-*+]\s+)\[([ xX])\](\s+.*)$/;
82-
83-
let total = 0;
84-
let completed = 0;
103+
const tasks = extractTasksFromAst(markdown);
85104

86-
for (const line of lines) {
87-
const match = line.match(taskPattern);
88-
if (match) {
89-
total++;
90-
const checkmark = match[2];
91-
if (checkmark.toLowerCase() === "x") {
92-
completed++;
93-
}
94-
}
95-
}
105+
const total = tasks.length;
106+
const completed = tasks.filter((t) => t.checked).length;
96107

97108
return {
98109
total,
@@ -102,26 +113,18 @@ export function countTasks(markdown: string): {
102113
}
103114

104115
export function hasCompletedTasks(markdown: string): boolean {
105-
const completedTaskPattern = /^(\s*[-*+]\s+)\[([xX])\](\s+.*)$/m;
106-
return completedTaskPattern.test(markdown);
116+
const tasks = extractTasksFromAst(markdown);
117+
return tasks.some((t) => t.checked);
107118
}
108119

109120
export function getTaskLineNumber(markdown: string, taskIndex: number): number {
110-
const lines = markdown.split("\n");
111-
const taskPattern = /^(\s*[-*+]\s+)\[([ xX])\](\s+.*)$/;
112-
113-
let currentTaskIndex = 0;
121+
const tasks = extractTasksFromAst(markdown);
114122

115-
for (let i = 0; i < lines.length; i++) {
116-
if (taskPattern.test(lines[i])) {
117-
if (currentTaskIndex === taskIndex) {
118-
return i;
119-
}
120-
currentTaskIndex++;
121-
}
123+
if (taskIndex < 0 || taskIndex >= tasks.length) {
124+
return -1;
122125
}
123126

124-
return -1;
127+
return tasks[taskIndex].lineNumber;
125128
}
126129

127130
export interface TaskItem {
@@ -133,27 +136,37 @@ export interface TaskItem {
133136
}
134137

135138
export function extractTasks(markdown: string): TaskItem[] {
139+
const tree = fromMarkdown(markdown, {
140+
extensions: [gfm()],
141+
mdastExtensions: [gfmFromMarkdown()],
142+
});
143+
136144
const lines = markdown.split("\n");
137-
const taskPattern = /^(\s*)([-*+]\s+)\[([ xX])\](\s+.*)$/;
138145
const tasks: TaskItem[] = [];
139-
140146
let taskIndex = 0;
141147

142-
for (let lineNumber = 0; lineNumber < lines.length; lineNumber++) {
143-
const line = lines[lineNumber];
144-
const match = line.match(taskPattern);
148+
visit(tree, "listItem", (node: ListItem) => {
149+
if (typeof node.checked === "boolean" && node.position?.start.line) {
150+
const lineNumber = node.position.start.line - 1;
151+
const line = lines[lineNumber];
152+
153+
// Extract indentation
154+
const indentMatch = line.match(/^(\s*)/);
155+
const indentation = indentMatch ? indentMatch[1].length : 0;
156+
157+
// Extract content (text after the checkbox)
158+
const contentMatch = line.match(/^\s*[-*+]\s+\[[ xX]\]\s+(.*)/);
159+
const content = contentMatch ? contentMatch[1] : "";
145160

146-
if (match) {
147-
const [, indentStr, , checkmark, content] = match;
148161
tasks.push({
149162
lineNumber,
150163
taskIndex: taskIndex++,
151-
checked: checkmark.toLowerCase() === "x",
152-
content: content.trim(),
153-
indentation: indentStr.length,
164+
checked: node.checked,
165+
content,
166+
indentation,
154167
});
155168
}
156-
}
169+
});
157170

158171
return tasks;
159172
}

0 commit comments

Comments
 (0)