mirror of
https://github.com/usememos/memos.git
synced 2025-12-16 21:59:25 +08:00
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>
This commit is contained in:
parent
3dc740c752
commit
8af8b9d238
3 changed files with 93 additions and 68 deletions
|
|
@ -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",
|
||||
|
|
|
|||
9
web/pnpm-lock.yaml
generated
9
web/pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue