mirror of https://github.com/usememos/memos.git
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
|
|
@ -38,7 +38,10 @@
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"lucide-react": "^0.544.0",
|
"lucide-react": "^0.544.0",
|
||||||
|
"mdast-util-from-markdown": "^2.0.2",
|
||||||
|
"mdast-util-gfm": "^3.1.0",
|
||||||
"mermaid": "^11.12.1",
|
"mermaid": "^11.12.1",
|
||||||
|
"micromark-extension-gfm": "^3.0.0",
|
||||||
"mime": "^4.1.0",
|
"mime": "^4.1.0",
|
||||||
"mobx": "^6.15.0",
|
"mobx": "^6.15.0",
|
||||||
"mobx-react-lite": "^4.1.1",
|
"mobx-react-lite": "^4.1.1",
|
||||||
|
|
|
||||||
|
|
@ -92,9 +92,18 @@ importers:
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^0.544.0
|
specifier: ^0.544.0
|
||||||
version: 0.544.0(react@18.3.1)
|
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:
|
mermaid:
|
||||||
specifier: ^11.12.1
|
specifier: ^11.12.1
|
||||||
version: 11.12.1
|
version: 11.12.1
|
||||||
|
micromark-extension-gfm:
|
||||||
|
specifier: ^3.0.0
|
||||||
|
version: 3.0.0
|
||||||
mime:
|
mime:
|
||||||
specifier: ^4.1.0
|
specifier: ^4.1.0
|
||||||
version: 4.1.0
|
version: 4.1.0
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,39 @@
|
||||||
// Utilities for manipulating markdown strings (GitHub-style approach)
|
// Utilities for manipulating markdown strings using AST parsing
|
||||||
// These functions modify the raw markdown text directly without parsing to AST
|
// 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 {
|
export function toggleTaskAtLine(markdown: string, lineNumber: number, checked: boolean): string {
|
||||||
const lines = markdown.split("\n");
|
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 {
|
export function toggleTaskAtIndex(markdown: string, taskIndex: number, checked: boolean): string {
|
||||||
const lines = markdown.split("\n");
|
const tasks = extractTasksFromAst(markdown);
|
||||||
const taskPattern = /^(\s*[-*+]\s+)\[([ xX])\](\s+.*)$/;
|
|
||||||
|
|
||||||
let currentTaskIndex = 0;
|
if (taskIndex < 0 || taskIndex >= tasks.length) {
|
||||||
|
return markdown;
|
||||||
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++;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return lines.join("\n");
|
const task = tasks[taskIndex];
|
||||||
|
return toggleTaskAtLine(markdown, task.lineNumber, checked);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function removeCompletedTasks(markdown: string): string {
|
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 lines = markdown.split("\n");
|
||||||
const completedTaskPattern = /^(\s*[-*+]\s+)\[([xX])\](\s+.*)$/;
|
|
||||||
const result: string[] = [];
|
const result: string[] = [];
|
||||||
|
|
||||||
for (let i = 0; i < lines.length; i++) {
|
for (let i = 0; i < lines.length; i++) {
|
||||||
const line = lines[i];
|
if (completedLineNumbers.has(i)) {
|
||||||
|
|
||||||
// Skip completed tasks
|
|
||||||
if (completedTaskPattern.test(line)) {
|
|
||||||
// Also skip the following line if it's empty (preserve spacing)
|
// Also skip the following line if it's empty (preserve spacing)
|
||||||
if (i + 1 < lines.length && lines[i + 1].trim() === "") {
|
if (i + 1 < lines.length && lines[i + 1].trim() === "") {
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
result.push(lines[i]);
|
||||||
result.push(line);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.join("\n");
|
return result.join("\n");
|
||||||
|
|
@ -77,22 +100,10 @@ export function countTasks(markdown: string): {
|
||||||
completed: number;
|
completed: number;
|
||||||
incomplete: number;
|
incomplete: number;
|
||||||
} {
|
} {
|
||||||
const lines = markdown.split("\n");
|
const tasks = extractTasksFromAst(markdown);
|
||||||
const taskPattern = /^(\s*[-*+]\s+)\[([ xX])\](\s+.*)$/;
|
|
||||||
|
|
||||||
let total = 0;
|
const total = tasks.length;
|
||||||
let completed = 0;
|
const completed = tasks.filter((t) => t.checked).length;
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
const match = line.match(taskPattern);
|
|
||||||
if (match) {
|
|
||||||
total++;
|
|
||||||
const checkmark = match[2];
|
|
||||||
if (checkmark.toLowerCase() === "x") {
|
|
||||||
completed++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
total,
|
total,
|
||||||
|
|
@ -102,26 +113,18 @@ export function countTasks(markdown: string): {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasCompletedTasks(markdown: string): boolean {
|
export function hasCompletedTasks(markdown: string): boolean {
|
||||||
const completedTaskPattern = /^(\s*[-*+]\s+)\[([xX])\](\s+.*)$/m;
|
const tasks = extractTasksFromAst(markdown);
|
||||||
return completedTaskPattern.test(markdown);
|
return tasks.some((t) => t.checked);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTaskLineNumber(markdown: string, taskIndex: number): number {
|
export function getTaskLineNumber(markdown: string, taskIndex: number): number {
|
||||||
const lines = markdown.split("\n");
|
const tasks = extractTasksFromAst(markdown);
|
||||||
const taskPattern = /^(\s*[-*+]\s+)\[([ xX])\](\s+.*)$/;
|
|
||||||
|
|
||||||
let currentTaskIndex = 0;
|
if (taskIndex < 0 || taskIndex >= tasks.length) {
|
||||||
|
return -1;
|
||||||
for (let i = 0; i < lines.length; i++) {
|
|
||||||
if (taskPattern.test(lines[i])) {
|
|
||||||
if (currentTaskIndex === taskIndex) {
|
|
||||||
return i;
|
|
||||||
}
|
|
||||||
currentTaskIndex++;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return -1;
|
return tasks[taskIndex].lineNumber;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TaskItem {
|
export interface TaskItem {
|
||||||
|
|
@ -133,27 +136,37 @@ export interface TaskItem {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function extractTasks(markdown: string): TaskItem[] {
|
export function extractTasks(markdown: string): TaskItem[] {
|
||||||
const lines = markdown.split("\n");
|
const tree = fromMarkdown(markdown, {
|
||||||
const taskPattern = /^(\s*)([-*+]\s+)\[([ xX])\](\s+.*)$/;
|
extensions: [gfm()],
|
||||||
const tasks: TaskItem[] = [];
|
mdastExtensions: [gfmFromMarkdown()],
|
||||||
|
});
|
||||||
|
|
||||||
|
const lines = markdown.split("\n");
|
||||||
|
const tasks: TaskItem[] = [];
|
||||||
let taskIndex = 0;
|
let taskIndex = 0;
|
||||||
|
|
||||||
for (let lineNumber = 0; lineNumber < lines.length; lineNumber++) {
|
visit(tree, "listItem", (node: ListItem) => {
|
||||||
const line = lines[lineNumber];
|
if (typeof node.checked === "boolean" && node.position?.start.line) {
|
||||||
const match = line.match(taskPattern);
|
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({
|
tasks.push({
|
||||||
lineNumber,
|
lineNumber,
|
||||||
taskIndex: taskIndex++,
|
taskIndex: taskIndex++,
|
||||||
checked: checkmark.toLowerCase() === "x",
|
checked: node.checked,
|
||||||
content: content.trim(),
|
content,
|
||||||
indentation: indentStr.length,
|
indentation,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
return tasks;
|
return tasks;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue