memos/web/src/utils/markdown-manipulation.ts

173 lines
4.5 KiB
TypeScript

// 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");
if (lineNumber < 0 || lineNumber >= lines.length) {
return markdown;
}
const line = lines[lineNumber];
// Match task list patterns: - [ ], - [x], - [X], etc.
const taskPattern = /^(\s*[-*+]\s+)\[([ xX])\](\s+.*)$/;
const match = line.match(taskPattern);
if (!match) {
return markdown;
}
const [, prefix, , suffix] = match;
const newCheckmark = checked ? "x" : " ";
lines[lineNumber] = `${prefix}[${newCheckmark}]${suffix}`;
return lines.join("\n");
}
export function toggleTaskAtIndex(markdown: string, taskIndex: number, checked: boolean): string {
const tasks = extractTasksFromAst(markdown);
if (taskIndex < 0 || taskIndex >= tasks.length) {
return markdown;
}
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 result: string[] = [];
for (let i = 0; i < lines.length; i++) {
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(lines[i]);
}
return result.join("\n");
}
export function countTasks(markdown: string): {
total: number;
completed: number;
incomplete: number;
} {
const tasks = extractTasksFromAst(markdown);
const total = tasks.length;
const completed = tasks.filter((t) => t.checked).length;
return {
total,
completed,
incomplete: total - completed,
};
}
export function hasCompletedTasks(markdown: string): boolean {
const tasks = extractTasksFromAst(markdown);
return tasks.some((t) => t.checked);
}
export function getTaskLineNumber(markdown: string, taskIndex: number): number {
const tasks = extractTasksFromAst(markdown);
if (taskIndex < 0 || taskIndex >= tasks.length) {
return -1;
}
return tasks[taskIndex].lineNumber;
}
export interface TaskItem {
lineNumber: number;
taskIndex: number;
checked: boolean;
content: string;
indentation: number;
}
export function extractTasks(markdown: string): TaskItem[] {
const tree = fromMarkdown(markdown, {
extensions: [gfm()],
mdastExtensions: [gfmFromMarkdown()],
});
const lines = markdown.split("\n");
const tasks: TaskItem[] = [];
let taskIndex = 0;
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] : "";
tasks.push({
lineNumber,
taskIndex: taskIndex++,
checked: node.checked,
content,
indentation,
});
}
});
return tasks;
}