diff --git a/web/package.json b/web/package.json index b72f4911a..d2cd1ba82 100644 --- a/web/package.json +++ b/web/package.json @@ -38,7 +38,10 @@ "leaflet": "^1.9.4", "lodash-es": "^4.17.21", "lucide-react": "^0.544.0", + "mdast-util-from-markdown": "^2.0.2", + "mdast-util-gfm": "^3.1.0", "mermaid": "^11.12.1", + "micromark-extension-gfm": "^3.0.0", "mime": "^4.1.0", "mobx": "^6.15.0", "mobx-react-lite": "^4.1.1", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index d6418dcfd..ba48132b8 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -92,9 +92,18 @@ importers: lucide-react: specifier: ^0.544.0 version: 0.544.0(react@18.3.1) + mdast-util-from-markdown: + specifier: ^2.0.2 + version: 2.0.2 + mdast-util-gfm: + specifier: ^3.1.0 + version: 3.1.0 mermaid: specifier: ^11.12.1 version: 11.12.1 + micromark-extension-gfm: + specifier: ^3.0.0 + version: 3.0.0 mime: specifier: ^4.1.0 version: 4.1.0 diff --git a/web/src/utils/markdown-manipulation.ts b/web/src/utils/markdown-manipulation.ts index 6ae68719e..33d2a9158 100644 --- a/web/src/utils/markdown-manipulation.ts +++ b/web/src/utils/markdown-manipulation.ts @@ -1,5 +1,39 @@ -// Utilities for manipulating markdown strings (GitHub-style approach) -// These functions modify the raw markdown text directly without parsing to AST +// Utilities for manipulating markdown strings using AST parsing +// Uses mdast for accurate task detection that properly handles code blocks + +import type { ListItem } from "mdast"; +import { fromMarkdown } from "mdast-util-from-markdown"; +import { gfmFromMarkdown } from "mdast-util-gfm"; +import { gfm } from "micromark-extension-gfm"; +import { visit } from "unist-util-visit"; + +interface TaskInfo { + lineNumber: number; + checked: boolean; +} + +// Extract all task list items from markdown using AST parsing +// This correctly ignores task-like patterns inside code blocks +function extractTasksFromAst(markdown: string): TaskInfo[] { + const tree = fromMarkdown(markdown, { + extensions: [gfm()], + mdastExtensions: [gfmFromMarkdown()], + }); + + const tasks: TaskInfo[] = []; + + visit(tree, "listItem", (node: ListItem) => { + // Only process actual task list items (those with a checkbox) + if (typeof node.checked === "boolean" && node.position?.start.line) { + tasks.push({ + lineNumber: node.position.start.line - 1, // Convert to 0-based + checked: node.checked, + }); + } + }); + + return tasks; +} export function toggleTaskAtLine(markdown: string, lineNumber: number, checked: boolean): string { const lines = markdown.split("\n"); @@ -26,47 +60,36 @@ export function toggleTaskAtLine(markdown: string, lineNumber: number, checked: } export function toggleTaskAtIndex(markdown: string, taskIndex: number, checked: boolean): string { - const lines = markdown.split("\n"); - const taskPattern = /^(\s*[-*+]\s+)\[([ xX])\](\s+.*)$/; + const tasks = extractTasksFromAst(markdown); - let currentTaskIndex = 0; - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - const match = line.match(taskPattern); - - if (match) { - if (currentTaskIndex === taskIndex) { - const [, prefix, , suffix] = match; - const newCheckmark = checked ? "x" : " "; - lines[i] = `${prefix}[${newCheckmark}]${suffix}`; - break; - } - currentTaskIndex++; - } + if (taskIndex < 0 || taskIndex >= tasks.length) { + return markdown; } - return lines.join("\n"); + const task = tasks[taskIndex]; + return toggleTaskAtLine(markdown, task.lineNumber, checked); } export function removeCompletedTasks(markdown: string): string { + const tasks = extractTasksFromAst(markdown); + const completedLineNumbers = new Set(tasks.filter((t) => t.checked).map((t) => t.lineNumber)); + + if (completedLineNumbers.size === 0) { + return markdown; + } + const lines = markdown.split("\n"); - const completedTaskPattern = /^(\s*[-*+]\s+)\[([xX])\](\s+.*)$/; const result: string[] = []; for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - - // Skip completed tasks - if (completedTaskPattern.test(line)) { + if (completedLineNumbers.has(i)) { // Also skip the following line if it's empty (preserve spacing) if (i + 1 < lines.length && lines[i + 1].trim() === "") { i++; } continue; } - - result.push(line); + result.push(lines[i]); } return result.join("\n"); @@ -77,22 +100,10 @@ export function countTasks(markdown: string): { completed: number; incomplete: number; } { - const lines = markdown.split("\n"); - const taskPattern = /^(\s*[-*+]\s+)\[([ xX])\](\s+.*)$/; + const tasks = extractTasksFromAst(markdown); - let total = 0; - let completed = 0; - - for (const line of lines) { - const match = line.match(taskPattern); - if (match) { - total++; - const checkmark = match[2]; - if (checkmark.toLowerCase() === "x") { - completed++; - } - } - } + const total = tasks.length; + const completed = tasks.filter((t) => t.checked).length; return { total, @@ -102,26 +113,18 @@ export function countTasks(markdown: string): { } export function hasCompletedTasks(markdown: string): boolean { - const completedTaskPattern = /^(\s*[-*+]\s+)\[([xX])\](\s+.*)$/m; - return completedTaskPattern.test(markdown); + const tasks = extractTasksFromAst(markdown); + return tasks.some((t) => t.checked); } export function getTaskLineNumber(markdown: string, taskIndex: number): number { - const lines = markdown.split("\n"); - const taskPattern = /^(\s*[-*+]\s+)\[([ xX])\](\s+.*)$/; + const tasks = extractTasksFromAst(markdown); - let currentTaskIndex = 0; - - for (let i = 0; i < lines.length; i++) { - if (taskPattern.test(lines[i])) { - if (currentTaskIndex === taskIndex) { - return i; - } - currentTaskIndex++; - } + if (taskIndex < 0 || taskIndex >= tasks.length) { + return -1; } - return -1; + return tasks[taskIndex].lineNumber; } export interface TaskItem { @@ -133,27 +136,37 @@ export interface TaskItem { } export function extractTasks(markdown: string): TaskItem[] { - const lines = markdown.split("\n"); - const taskPattern = /^(\s*)([-*+]\s+)\[([ xX])\](\s+.*)$/; - const tasks: TaskItem[] = []; + const tree = fromMarkdown(markdown, { + extensions: [gfm()], + mdastExtensions: [gfmFromMarkdown()], + }); + const lines = markdown.split("\n"); + const tasks: TaskItem[] = []; let taskIndex = 0; - for (let lineNumber = 0; lineNumber < lines.length; lineNumber++) { - const line = lines[lineNumber]; - const match = line.match(taskPattern); + visit(tree, "listItem", (node: ListItem) => { + if (typeof node.checked === "boolean" && node.position?.start.line) { + const lineNumber = node.position.start.line - 1; + const line = lines[lineNumber]; + + // Extract indentation + const indentMatch = line.match(/^(\s*)/); + const indentation = indentMatch ? indentMatch[1].length : 0; + + // Extract content (text after the checkbox) + const contentMatch = line.match(/^\s*[-*+]\s+\[[ xX]\]\s+(.*)/); + const content = contentMatch ? contentMatch[1] : ""; - if (match) { - const [, indentStr, , checkmark, content] = match; tasks.push({ lineNumber, taskIndex: taskIndex++, - checked: checkmark.toLowerCase() === "x", - content: content.trim(), - indentation: indentStr.length, + checked: node.checked, + content, + indentation, }); } - } + }); return tasks; }