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
438export 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
2862export function toggleTaskAtIndex ( markdown : string , taskIndex : number , checked : boolean ) : string {
29- const lines = markdown . split ( "\n" ) ;
30- const taskPattern = / ^ ( \s * [ - * + ] \s + ) \[ ( [ x X ] ) \] ( \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
5273export 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 + ) \[ ( [ x X ] ) \] ( \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 + ) \[ ( [ x X ] ) \] ( \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
104115export function hasCompletedTasks ( markdown : string ) : boolean {
105- const completedTaskPattern = / ^ ( \s * [ - * + ] \s + ) \[ ( [ x X ] ) \] ( \s + . * ) $ / m ;
106- return completedTaskPattern . test ( markdown ) ;
116+ const tasks = extractTasksFromAst ( markdown ) ;
117+ return tasks . some ( ( t ) => t . checked ) ;
107118}
108119
109120export function getTaskLineNumber ( markdown : string , taskIndex : number ) : number {
110- const lines = markdown . split ( "\n" ) ;
111- const taskPattern = / ^ ( \s * [ - * + ] \s + ) \[ ( [ x X ] ) \] ( \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
127130export interface TaskItem {
@@ -133,27 +136,37 @@ export interface TaskItem {
133136}
134137
135138export 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 + ) \[ ( [ x X ] ) \] ( \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 + \[ [ x X ] \] \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