@puredesktop/platform-editor 1.0.0-beta.1 → 1.0.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/package.json +1 -1
  2. package/src/CollapsePanel.tsx +35 -35
  3. package/src/DocumentDiffView.tsx +130 -130
  4. package/src/DocumentEditor.test.ts +118 -118
  5. package/src/DocumentEditor.tsx +2651 -2631
  6. package/src/alignedLineDiff.test.ts +52 -52
  7. package/src/alignedLineDiff.ts +67 -67
  8. package/src/collectionAssetSrc.ts +2 -2
  9. package/src/commentUtils.test.ts +350 -350
  10. package/src/commentUtils.ts +546 -546
  11. package/src/constants/toolKeys.ts +14 -14
  12. package/src/contentFormat.test.ts +27 -27
  13. package/src/contentFormat.ts +18 -18
  14. package/src/editorExtensions.ts +155 -155
  15. package/src/extensions/appliedChangeMark.ts +115 -115
  16. package/src/extensions/autoReviewPrompts.test.ts +529 -529
  17. package/src/extensions/autoReviewPrompts.ts +1442 -1442
  18. package/src/extensions/collectionImage.ts +26 -26
  19. package/src/extensions/collectionImagePaste.ts +215 -215
  20. package/src/extensions/commentMark.ts +223 -223
  21. package/src/extensions/documentAssetIdentity.ts +54 -54
  22. package/src/extensions/figure.ts +92 -92
  23. package/src/extensions/footnote.ts +186 -186
  24. package/src/extensions/indexMarker.ts +88 -88
  25. package/src/extensions/mathEditing.ts +239 -239
  26. package/src/extensions/mermaidBlock.tsx +297 -297
  27. package/src/extensions/slashCommands.test.ts +170 -170
  28. package/src/extensions/slashCommands.ts +746 -746
  29. package/src/extensions/smartTypography.test.ts +74 -74
  30. package/src/extensions/smartTypography.ts +120 -120
  31. package/src/index.ts +138 -137
  32. package/src/insertCollectionImage.test.ts +60 -60
  33. package/src/insertCollectionImage.ts +63 -63
  34. package/src/insertInlineAssetKind.test.ts +67 -67
  35. package/src/insertInlineAssetKind.ts +49 -49
  36. package/src/mermaidPreview.test.ts +54 -54
  37. package/src/mermaidPreview.ts +115 -115
  38. package/src/styled.ts +697 -676
  39. package/src/toolbar/ToolBtn.tsx +77 -63
  40. package/src/toolbar/Toolbar.test.tsx +325 -325
  41. package/src/toolbar/Toolbar.tsx +662 -624
  42. package/src/toolbar/groups/HeadingGroup.tsx +175 -153
  43. package/src/toolbar/overlayTypes.ts +28 -28
  44. package/src/useEditorExtensions.ts +22 -22
  45. package/src/utils/markdownUtils.ts +331 -331
@@ -1,331 +1,331 @@
1
- import { marked } from 'marked'
2
- import TurndownService from 'turndown'
3
-
4
- const turndownService = new TurndownService({
5
- headingStyle: 'atx',
6
- codeBlockStyle: 'fenced',
7
- emDelimiter: '*',
8
- strongDelimiter: '**',
9
- bulletListMarker: '-',
10
- hr: '---',
11
- })
12
-
13
- // Clean up whitespace in paragraphs and divs
14
- turndownService.addRule('cleanWhitespace', {
15
- filter: (node): boolean => node.nodeName === 'P' || node.nodeName === 'DIV',
16
- replacement: (content: string): string => `${content.trim()}\n\n`,
17
- })
18
-
19
- turndownService.addRule('taskCheckbox', {
20
- filter: (node): boolean =>
21
- node.nodeName === 'INPUT' && (node as HTMLInputElement).type === 'checkbox',
22
- replacement: (_content: string, node): string =>
23
- (node as HTMLInputElement).checked ? '[x] ' : '[ ] ',
24
- })
25
-
26
- turndownService.addRule('taskItem', {
27
- filter: (node): boolean =>
28
- node.nodeName === 'LI' &&
29
- (node as HTMLElement).dataset?.type === 'taskItem',
30
- replacement: (content: string, node): string => {
31
- const element = node as HTMLElement
32
- const checked =
33
- element.getAttribute('data-checked') === 'true' ||
34
- (
35
- element.querySelector(
36
- 'input[type="checkbox"]',
37
- ) as HTMLInputElement | null
38
- )?.checked === true
39
- const prefix = checked ? '- [x] ' : '- [ ] '
40
- const body = content.replace(/^\s*\[(x| )\]\s*/i, '').trim()
41
- if (!body) return `${prefix}\n`
42
-
43
- if (looksLikeMarkdownTable(body)) {
44
- return `${prefix}\n${indentMarkdown(body)}\n`
45
- }
46
-
47
- const [firstBlock, ...restBlocks] = body.split(/\n{2,}/)
48
- const head = `${prefix}${firstBlock.trim()}`
49
- if (restBlocks.length === 0) return `${head}\n`
50
- return `${head}\n\n${indentMarkdown(restBlocks.join('\n\n').trim())}\n`
51
- },
52
- })
53
-
54
- turndownService.addRule('table', {
55
- filter: (node): boolean => node.nodeName === 'TABLE',
56
- replacement: (_content: string, node): string => {
57
- const table = node as HTMLTableElement
58
- const rows = Array.from(table.querySelectorAll('tr')).map(row =>
59
- Array.from(row.children).map(cell =>
60
- normalizeCell(cell.textContent ?? ''),
61
- ),
62
- )
63
- if (rows.length === 0) return ''
64
-
65
- const header = rows[0]
66
- const body = rows.slice(1)
67
- const separator = header.map(() => '---')
68
- const lines = [
69
- `| ${header.join(' | ')} |`,
70
- `| ${separator.join(' | ')} |`,
71
- ...body.map(row => `| ${padRow(row, header.length).join(' | ')} |`),
72
- ]
73
-
74
- return `\n\n${lines.join('\n')}\n\n`
75
- },
76
- })
77
-
78
- // Index markers (#172): <a class="ix" data-term="X">term</a> serializes
79
- // back as raw HTML so the round-trip is lossless. CommonMark passes
80
- // inline HTML through unchanged.
81
- turndownService.keep(
82
- node =>
83
- node.nodeName === 'A' &&
84
- (node as HTMLElement).classList?.contains('ix') === true,
85
- )
86
-
87
- turndownService.keep(
88
- node =>
89
- node.nodeName === 'SPAN' &&
90
- (node as HTMLElement).hasAttribute?.('data-comment-id') === true,
91
- )
92
-
93
- turndownService.keep(node => node.nodeName === 'SUB' || node.nodeName === 'SUP')
94
-
95
- turndownService.addRule('inlineMath', {
96
- filter: (node): boolean =>
97
- node.nodeName === 'SPAN' &&
98
- (node as HTMLElement).dataset?.type === 'inline-math',
99
- replacement: (_content: string, node): string => {
100
- const latex = (node as HTMLElement).getAttribute('data-latex') ?? ''
101
- return latex ? `$${latex}$` : ''
102
- },
103
- })
104
-
105
- turndownService.addRule('blockMath', {
106
- filter: (node): boolean =>
107
- node.nodeName === 'DIV' &&
108
- (node as HTMLElement).dataset?.type === 'block-math',
109
- replacement: (_content: string, node): string => {
110
- const latex = (node as HTMLElement).getAttribute('data-latex') ?? ''
111
- return latex ? `\n\n$$\n${latex}\n$$\n\n` : ''
112
- },
113
- })
114
-
115
- // <figure><img/><figcaption>caption</figcaption></figure> → ![caption](src)
116
- // The render pipeline already wraps caption-bearing markdown images in
117
- // a real <figure>, so this lossy serialization round-trips cleanly for
118
- // PDF output. The alt attribute is used as the markdown alt text only
119
- // when the figure has no caption.
120
- turndownService.addRule('figure', {
121
- filter: (node): boolean => node.nodeName === 'FIGURE',
122
- replacement: (_content: string, node): string => {
123
- const el = node as HTMLElement
124
- const img = el.querySelector('img')
125
- const captionEl = el.querySelector('figcaption')
126
- if (!img) return ''
127
- const src = img.getAttribute('src') ?? ''
128
- const alt = img.getAttribute('alt') ?? ''
129
- const caption = (captionEl?.textContent ?? '').trim()
130
- const text = caption || alt
131
- return `\n\n![${text}](${src})\n\n`
132
- },
133
- })
134
-
135
- /** Convert markdown string to HTML. Returns empty string for falsy input. */
136
- export const toHtml = (text: string): string =>
137
- text ? renderMarkdownWithTaskLists(text) : ''
138
-
139
- /** Convert HTML string to markdown. Returns empty string for falsy input. */
140
- export const toMd = (html: string): string =>
141
- html ? normalizeMarkdown(turndownService.turndown(html)) : ''
142
-
143
- /** Batch-convert [key, markdown] entries to [key, html]. */
144
- export const toHtmlEntries = (
145
- entries: Array<[string, string]>,
146
- ): Array<[string, string]> =>
147
- entries.map(([key, value]) => [key, toHtml(value)])
148
-
149
- /** Batch-convert [key, html] entries to [key, markdown]. */
150
- export const toMdEntries = (
151
- entries: Array<[string, string]>,
152
- keyTransform?: (key: string) => string,
153
- ): Array<[string, string]> =>
154
- entries.map(([key, value]) => [
155
- keyTransform ? keyTransform(key) : key,
156
- toMd(value),
157
- ])
158
-
159
- function normalizeCell(value: string): string {
160
- return value.replace(/\|/g, '\\|').replace(/\s+/g, ' ').trim()
161
- }
162
-
163
- function padRow(row: string[], length: number): string[] {
164
- if (row.length >= length) return row
165
- return [...row, ...Array.from({ length: length - row.length }, () => '')]
166
- }
167
-
168
- function normalizeMarkdown(markdown: string): string {
169
- return markdown
170
- .replace(/^- {2,}/gm, '- ')
171
- .replace(/^(\s*[-*])\s+\[(x| )\]\s+/gim, '$1 [$2] ')
172
- .replace(/\n{3,}/g, '\n\n')
173
- .trimEnd()
174
- }
175
-
176
- function renderMarkdownWithTaskLists(markdown: string): string {
177
- const normalized = markdown.replace(/\r\n?/g, '\n')
178
- const lines = normalized.split('\n')
179
- const parts: string[] = []
180
- let buffer: string[] = []
181
- let index = 0
182
-
183
- const flushBuffer = (): void => {
184
- if (buffer.length === 0) return
185
- const chunk = buffer.join('\n')
186
- if (chunk.trim()) {
187
- parts.push(marked.parse(chunk) as string)
188
- }
189
- buffer = []
190
- }
191
-
192
- while (index < lines.length) {
193
- const taskMatch = matchTaskLine(lines[index])
194
- if (!taskMatch || taskMatch.indent !== 0) {
195
- buffer.push(lines[index])
196
- index += 1
197
- continue
198
- }
199
-
200
- flushBuffer()
201
- const rendered = renderTaskListBlock(lines, index, taskMatch.indent)
202
- parts.push(rendered.html)
203
- index = rendered.nextIndex
204
- }
205
-
206
- flushBuffer()
207
- return parts.join('')
208
- }
209
-
210
- function renderTaskListBlock(
211
- lines: string[],
212
- startIndex: number,
213
- baseIndent: number,
214
- ): { html: string; nextIndex: number } {
215
- const items: string[] = []
216
- let index = startIndex
217
-
218
- while (index < lines.length) {
219
- while (
220
- index < lines.length &&
221
- lines[index].trim() === '' &&
222
- matchTaskLine(lines[index + 1] ?? '')?.indent === baseIndent
223
- ) {
224
- index += 1
225
- }
226
-
227
- const taskMatch = matchTaskLine(lines[index] ?? '')
228
- if (!taskMatch || taskMatch.indent !== baseIndent) break
229
-
230
- const nestedLines: string[] = []
231
- index += 1
232
-
233
- while (index < lines.length) {
234
- const line = lines[index]
235
- const nextTask = matchTaskLine(line)
236
- if (nextTask && nextTask.indent === baseIndent) break
237
-
238
- if (line.trim() === '') {
239
- nestedLines.push('')
240
- index += 1
241
- continue
242
- }
243
-
244
- const indent = countIndent(line)
245
- if (indent <= baseIndent) break
246
-
247
- nestedLines.push(stripNestedIndent(line, baseIndent))
248
- index += 1
249
- }
250
-
251
- const itemBody = buildTaskItemBody(taskMatch.text, nestedLines)
252
- items.push(
253
- `<li data-type="taskItem" data-checked="${
254
- taskMatch.checked
255
- }"><label><input type="checkbox"${
256
- taskMatch.checked ? ' checked="checked"' : ''
257
- }><span></span></label><div>${itemBody}</div></li>`,
258
- )
259
- }
260
-
261
- return {
262
- html: `<ul data-type="taskList">${items.join('')}</ul>`,
263
- nextIndex: index,
264
- }
265
- }
266
-
267
- function buildTaskItemBody(mainText: string, nestedLines: string[]): string {
268
- const nestedMarkdown = nestedLines.join('\n').trim()
269
- const tableCandidate = [mainText, nestedMarkdown]
270
- .filter(Boolean)
271
- .join('\n')
272
- .trim()
273
-
274
- if (looksLikeMarkdownTable(tableCandidate)) {
275
- return (marked.parse(tableCandidate) as string).trim()
276
- }
277
-
278
- const body: string[] = []
279
- if (mainText.trim()) {
280
- body.push(`<p>${marked.parseInline(mainText.trim()) as string}</p>`)
281
- }
282
- if (nestedMarkdown) {
283
- body.push(renderMarkdownWithTaskLists(nestedMarkdown).trim())
284
- }
285
- if (body.length === 0) {
286
- body.push('<p></p>')
287
- }
288
- return body.join('')
289
- }
290
-
291
- function looksLikeMarkdownTable(markdown: string): boolean {
292
- const lines = markdown
293
- .split('\n')
294
- .map(line => line.trim())
295
- .filter(Boolean)
296
- if (lines.length < 2) return false
297
- return (
298
- /^\|.*\|$/.test(lines[0]) &&
299
- /^\|?(?:\s*:?-+:?\s*\|)+\s*:?-+:?\s*\|?$/.test(lines[1])
300
- )
301
- }
302
-
303
- function matchTaskLine(
304
- line: string,
305
- ): { indent: number; checked: boolean; text: string } | null {
306
- const match = line.match(/^(\s*)[-+*]\s+\[([ xX])\]\s*(.*)$/)
307
- if (!match) return null
308
- return {
309
- indent: match[1].length,
310
- checked: match[2].toLowerCase() === 'x',
311
- text: match[3] ?? '',
312
- }
313
- }
314
-
315
- function countIndent(line: string): number {
316
- const match = line.match(/^(\s*)/)
317
- return match?.[1].length ?? 0
318
- }
319
-
320
- function stripNestedIndent(line: string, baseIndent: number): string {
321
- const preferred = ' '.repeat(baseIndent + 4)
322
- if (line.startsWith(preferred)) return line.slice(preferred.length)
323
- return line.slice(Math.min(line.length, baseIndent + 2))
324
- }
325
-
326
- function indentMarkdown(markdown: string): string {
327
- return markdown
328
- .split('\n')
329
- .map(line => (line.trim() ? ` ${line}` : ''))
330
- .join('\n')
331
- }
1
+ import { marked } from 'marked'
2
+ import TurndownService from 'turndown'
3
+
4
+ const turndownService = new TurndownService({
5
+ headingStyle: 'atx',
6
+ codeBlockStyle: 'fenced',
7
+ emDelimiter: '*',
8
+ strongDelimiter: '**',
9
+ bulletListMarker: '-',
10
+ hr: '---',
11
+ })
12
+
13
+ // Clean up whitespace in paragraphs and divs
14
+ turndownService.addRule('cleanWhitespace', {
15
+ filter: (node): boolean => node.nodeName === 'P' || node.nodeName === 'DIV',
16
+ replacement: (content: string): string => `${content.trim()}\n\n`,
17
+ })
18
+
19
+ turndownService.addRule('taskCheckbox', {
20
+ filter: (node): boolean =>
21
+ node.nodeName === 'INPUT' && (node as HTMLInputElement).type === 'checkbox',
22
+ replacement: (_content: string, node): string =>
23
+ (node as HTMLInputElement).checked ? '[x] ' : '[ ] ',
24
+ })
25
+
26
+ turndownService.addRule('taskItem', {
27
+ filter: (node): boolean =>
28
+ node.nodeName === 'LI' &&
29
+ (node as HTMLElement).dataset?.type === 'taskItem',
30
+ replacement: (content: string, node): string => {
31
+ const element = node as HTMLElement
32
+ const checked =
33
+ element.getAttribute('data-checked') === 'true' ||
34
+ (
35
+ element.querySelector(
36
+ 'input[type="checkbox"]',
37
+ ) as HTMLInputElement | null
38
+ )?.checked === true
39
+ const prefix = checked ? '- [x] ' : '- [ ] '
40
+ const body = content.replace(/^\s*\[(x| )\]\s*/i, '').trim()
41
+ if (!body) return `${prefix}\n`
42
+
43
+ if (looksLikeMarkdownTable(body)) {
44
+ return `${prefix}\n${indentMarkdown(body)}\n`
45
+ }
46
+
47
+ const [firstBlock, ...restBlocks] = body.split(/\n{2,}/)
48
+ const head = `${prefix}${firstBlock.trim()}`
49
+ if (restBlocks.length === 0) return `${head}\n`
50
+ return `${head}\n\n${indentMarkdown(restBlocks.join('\n\n').trim())}\n`
51
+ },
52
+ })
53
+
54
+ turndownService.addRule('table', {
55
+ filter: (node): boolean => node.nodeName === 'TABLE',
56
+ replacement: (_content: string, node): string => {
57
+ const table = node as HTMLTableElement
58
+ const rows = Array.from(table.querySelectorAll('tr')).map(row =>
59
+ Array.from(row.children).map(cell =>
60
+ normalizeCell(cell.textContent ?? ''),
61
+ ),
62
+ )
63
+ if (rows.length === 0) return ''
64
+
65
+ const header = rows[0]
66
+ const body = rows.slice(1)
67
+ const separator = header.map(() => '---')
68
+ const lines = [
69
+ `| ${header.join(' | ')} |`,
70
+ `| ${separator.join(' | ')} |`,
71
+ ...body.map(row => `| ${padRow(row, header.length).join(' | ')} |`),
72
+ ]
73
+
74
+ return `\n\n${lines.join('\n')}\n\n`
75
+ },
76
+ })
77
+
78
+ // Index markers (#172): <a class="ix" data-term="X">term</a> serializes
79
+ // back as raw HTML so the round-trip is lossless. CommonMark passes
80
+ // inline HTML through unchanged.
81
+ turndownService.keep(
82
+ node =>
83
+ node.nodeName === 'A' &&
84
+ (node as HTMLElement).classList?.contains('ix') === true,
85
+ )
86
+
87
+ turndownService.keep(
88
+ node =>
89
+ node.nodeName === 'SPAN' &&
90
+ (node as HTMLElement).hasAttribute?.('data-comment-id') === true,
91
+ )
92
+
93
+ turndownService.keep(node => node.nodeName === 'SUB' || node.nodeName === 'SUP')
94
+
95
+ turndownService.addRule('inlineMath', {
96
+ filter: (node): boolean =>
97
+ node.nodeName === 'SPAN' &&
98
+ (node as HTMLElement).dataset?.type === 'inline-math',
99
+ replacement: (_content: string, node): string => {
100
+ const latex = (node as HTMLElement).getAttribute('data-latex') ?? ''
101
+ return latex ? `$${latex}$` : ''
102
+ },
103
+ })
104
+
105
+ turndownService.addRule('blockMath', {
106
+ filter: (node): boolean =>
107
+ node.nodeName === 'DIV' &&
108
+ (node as HTMLElement).dataset?.type === 'block-math',
109
+ replacement: (_content: string, node): string => {
110
+ const latex = (node as HTMLElement).getAttribute('data-latex') ?? ''
111
+ return latex ? `\n\n$$\n${latex}\n$$\n\n` : ''
112
+ },
113
+ })
114
+
115
+ // <figure><img/><figcaption>caption</figcaption></figure> → ![caption](src)
116
+ // The render pipeline already wraps caption-bearing markdown images in
117
+ // a real <figure>, so this lossy serialization round-trips cleanly for
118
+ // PDF output. The alt attribute is used as the markdown alt text only
119
+ // when the figure has no caption.
120
+ turndownService.addRule('figure', {
121
+ filter: (node): boolean => node.nodeName === 'FIGURE',
122
+ replacement: (_content: string, node): string => {
123
+ const el = node as HTMLElement
124
+ const img = el.querySelector('img')
125
+ const captionEl = el.querySelector('figcaption')
126
+ if (!img) return ''
127
+ const src = img.getAttribute('src') ?? ''
128
+ const alt = img.getAttribute('alt') ?? ''
129
+ const caption = (captionEl?.textContent ?? '').trim()
130
+ const text = caption || alt
131
+ return `\n\n![${text}](${src})\n\n`
132
+ },
133
+ })
134
+
135
+ /** Convert markdown string to HTML. Returns empty string for falsy input. */
136
+ export const toHtml = (text: string): string =>
137
+ text ? renderMarkdownWithTaskLists(text) : ''
138
+
139
+ /** Convert HTML string to markdown. Returns empty string for falsy input. */
140
+ export const toMd = (html: string): string =>
141
+ html ? normalizeMarkdown(turndownService.turndown(html)) : ''
142
+
143
+ /** Batch-convert [key, markdown] entries to [key, html]. */
144
+ export const toHtmlEntries = (
145
+ entries: Array<[string, string]>,
146
+ ): Array<[string, string]> =>
147
+ entries.map(([key, value]) => [key, toHtml(value)])
148
+
149
+ /** Batch-convert [key, html] entries to [key, markdown]. */
150
+ export const toMdEntries = (
151
+ entries: Array<[string, string]>,
152
+ keyTransform?: (key: string) => string,
153
+ ): Array<[string, string]> =>
154
+ entries.map(([key, value]) => [
155
+ keyTransform ? keyTransform(key) : key,
156
+ toMd(value),
157
+ ])
158
+
159
+ function normalizeCell(value: string): string {
160
+ return value.replace(/\|/g, '\\|').replace(/\s+/g, ' ').trim()
161
+ }
162
+
163
+ function padRow(row: string[], length: number): string[] {
164
+ if (row.length >= length) return row
165
+ return [...row, ...Array.from({ length: length - row.length }, () => '')]
166
+ }
167
+
168
+ function normalizeMarkdown(markdown: string): string {
169
+ return markdown
170
+ .replace(/^- {2,}/gm, '- ')
171
+ .replace(/^(\s*[-*])\s+\[(x| )\]\s+/gim, '$1 [$2] ')
172
+ .replace(/\n{3,}/g, '\n\n')
173
+ .trimEnd()
174
+ }
175
+
176
+ function renderMarkdownWithTaskLists(markdown: string): string {
177
+ const normalized = markdown.replace(/\r\n?/g, '\n')
178
+ const lines = normalized.split('\n')
179
+ const parts: string[] = []
180
+ let buffer: string[] = []
181
+ let index = 0
182
+
183
+ const flushBuffer = (): void => {
184
+ if (buffer.length === 0) return
185
+ const chunk = buffer.join('\n')
186
+ if (chunk.trim()) {
187
+ parts.push(marked.parse(chunk) as string)
188
+ }
189
+ buffer = []
190
+ }
191
+
192
+ while (index < lines.length) {
193
+ const taskMatch = matchTaskLine(lines[index])
194
+ if (!taskMatch || taskMatch.indent !== 0) {
195
+ buffer.push(lines[index])
196
+ index += 1
197
+ continue
198
+ }
199
+
200
+ flushBuffer()
201
+ const rendered = renderTaskListBlock(lines, index, taskMatch.indent)
202
+ parts.push(rendered.html)
203
+ index = rendered.nextIndex
204
+ }
205
+
206
+ flushBuffer()
207
+ return parts.join('')
208
+ }
209
+
210
+ function renderTaskListBlock(
211
+ lines: string[],
212
+ startIndex: number,
213
+ baseIndent: number,
214
+ ): { html: string; nextIndex: number } {
215
+ const items: string[] = []
216
+ let index = startIndex
217
+
218
+ while (index < lines.length) {
219
+ while (
220
+ index < lines.length &&
221
+ lines[index].trim() === '' &&
222
+ matchTaskLine(lines[index + 1] ?? '')?.indent === baseIndent
223
+ ) {
224
+ index += 1
225
+ }
226
+
227
+ const taskMatch = matchTaskLine(lines[index] ?? '')
228
+ if (!taskMatch || taskMatch.indent !== baseIndent) break
229
+
230
+ const nestedLines: string[] = []
231
+ index += 1
232
+
233
+ while (index < lines.length) {
234
+ const line = lines[index]
235
+ const nextTask = matchTaskLine(line)
236
+ if (nextTask && nextTask.indent === baseIndent) break
237
+
238
+ if (line.trim() === '') {
239
+ nestedLines.push('')
240
+ index += 1
241
+ continue
242
+ }
243
+
244
+ const indent = countIndent(line)
245
+ if (indent <= baseIndent) break
246
+
247
+ nestedLines.push(stripNestedIndent(line, baseIndent))
248
+ index += 1
249
+ }
250
+
251
+ const itemBody = buildTaskItemBody(taskMatch.text, nestedLines)
252
+ items.push(
253
+ `<li data-type="taskItem" data-checked="${
254
+ taskMatch.checked
255
+ }"><label><input type="checkbox"${
256
+ taskMatch.checked ? ' checked="checked"' : ''
257
+ }><span></span></label><div>${itemBody}</div></li>`,
258
+ )
259
+ }
260
+
261
+ return {
262
+ html: `<ul data-type="taskList">${items.join('')}</ul>`,
263
+ nextIndex: index,
264
+ }
265
+ }
266
+
267
+ function buildTaskItemBody(mainText: string, nestedLines: string[]): string {
268
+ const nestedMarkdown = nestedLines.join('\n').trim()
269
+ const tableCandidate = [mainText, nestedMarkdown]
270
+ .filter(Boolean)
271
+ .join('\n')
272
+ .trim()
273
+
274
+ if (looksLikeMarkdownTable(tableCandidate)) {
275
+ return (marked.parse(tableCandidate) as string).trim()
276
+ }
277
+
278
+ const body: string[] = []
279
+ if (mainText.trim()) {
280
+ body.push(`<p>${marked.parseInline(mainText.trim()) as string}</p>`)
281
+ }
282
+ if (nestedMarkdown) {
283
+ body.push(renderMarkdownWithTaskLists(nestedMarkdown).trim())
284
+ }
285
+ if (body.length === 0) {
286
+ body.push('<p></p>')
287
+ }
288
+ return body.join('')
289
+ }
290
+
291
+ function looksLikeMarkdownTable(markdown: string): boolean {
292
+ const lines = markdown
293
+ .split('\n')
294
+ .map(line => line.trim())
295
+ .filter(Boolean)
296
+ if (lines.length < 2) return false
297
+ return (
298
+ /^\|.*\|$/.test(lines[0]) &&
299
+ /^\|?(?:\s*:?-+:?\s*\|)+\s*:?-+:?\s*\|?$/.test(lines[1])
300
+ )
301
+ }
302
+
303
+ function matchTaskLine(
304
+ line: string,
305
+ ): { indent: number; checked: boolean; text: string } | null {
306
+ const match = line.match(/^(\s*)[-+*]\s+\[([ xX])\]\s*(.*)$/)
307
+ if (!match) return null
308
+ return {
309
+ indent: match[1].length,
310
+ checked: match[2].toLowerCase() === 'x',
311
+ text: match[3] ?? '',
312
+ }
313
+ }
314
+
315
+ function countIndent(line: string): number {
316
+ const match = line.match(/^(\s*)/)
317
+ return match?.[1].length ?? 0
318
+ }
319
+
320
+ function stripNestedIndent(line: string, baseIndent: number): string {
321
+ const preferred = ' '.repeat(baseIndent + 4)
322
+ if (line.startsWith(preferred)) return line.slice(preferred.length)
323
+ return line.slice(Math.min(line.length, baseIndent + 2))
324
+ }
325
+
326
+ function indentMarkdown(markdown: string): string {
327
+ return markdown
328
+ .split('\n')
329
+ .map(line => (line.trim() ? ` ${line}` : ''))
330
+ .join('\n')
331
+ }