@tiptap/markdown 3.10.2 → 3.10.4
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.
- package/dist/index.cjs +136 -5
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +34 -4
- package/dist/index.d.ts +34 -4
- package/dist/index.js +135 -4
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/src/MarkdownManager.ts +175 -3
- package/src/utils.ts +28 -4
package/src/MarkdownManager.ts
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
findMarksToClose,
|
|
21
21
|
findMarksToCloseAtEnd,
|
|
22
22
|
findMarksToOpen,
|
|
23
|
+
isTaskItem,
|
|
23
24
|
reopenMarksAfterNode,
|
|
24
25
|
wrapInMarkdownBlock,
|
|
25
26
|
} from './utils.js'
|
|
@@ -303,6 +304,11 @@ export class MarkdownManager {
|
|
|
303
304
|
return null
|
|
304
305
|
}
|
|
305
306
|
|
|
307
|
+
// Special handling for 'list' tokens that may contain mixed bullet/task items
|
|
308
|
+
if (token.type === 'list') {
|
|
309
|
+
return this.parseListToken(token)
|
|
310
|
+
}
|
|
311
|
+
|
|
306
312
|
const handlers = this.getHandlersForToken(token.type)
|
|
307
313
|
const helpers = this.createParseHelpers()
|
|
308
314
|
|
|
@@ -339,7 +345,169 @@ export class MarkdownManager {
|
|
|
339
345
|
private lastParseResult: JSONContent | JSONContent[] | null = null
|
|
340
346
|
|
|
341
347
|
/**
|
|
342
|
-
*
|
|
348
|
+
* Parse a list token, handling mixed bullet and task list items by splitting them into separate lists.
|
|
349
|
+
* This ensures that consecutive task items and bullet items are grouped and parsed as separate list nodes.
|
|
350
|
+
*
|
|
351
|
+
* @param token The list token to parse
|
|
352
|
+
* @returns Array of parsed list nodes, or null if parsing fails
|
|
353
|
+
*/
|
|
354
|
+
private parseListToken(token: MarkdownToken): JSONContent | JSONContent[] | null {
|
|
355
|
+
if (!token.items || token.items.length === 0) {
|
|
356
|
+
// No items, parse normally
|
|
357
|
+
return this.parseTokenWithHandlers(token)
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const hasTask = token.items.some(item => isTaskItem(item).isTask)
|
|
361
|
+
const hasNonTask = token.items.some(item => !isTaskItem(item).isTask)
|
|
362
|
+
|
|
363
|
+
if (!hasTask || !hasNonTask || this.getHandlersForToken('taskList').length === 0) {
|
|
364
|
+
// Not mixed or no taskList extension, parse normally
|
|
365
|
+
return this.parseTokenWithHandlers(token)
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Mixed list with taskList extension available: split into separate lists
|
|
369
|
+
type TaskListItemToken = MarkdownToken & { type: 'taskItem'; checked?: boolean; indentLevel?: number }
|
|
370
|
+
const groups: { type: 'list' | 'taskList'; items: (MarkdownToken | TaskListItemToken)[] }[] = []
|
|
371
|
+
let currentGroup: (MarkdownToken | TaskListItemToken)[] = []
|
|
372
|
+
let currentType: 'list' | 'taskList' | null = null
|
|
373
|
+
|
|
374
|
+
for (let i = 0; i < token.items.length; i += 1) {
|
|
375
|
+
const item = token.items[i]
|
|
376
|
+
const { isTask, checked, indentLevel } = isTaskItem(item)
|
|
377
|
+
let processedItem = item
|
|
378
|
+
|
|
379
|
+
if (isTask) {
|
|
380
|
+
// Transform list_item into taskItem token
|
|
381
|
+
const raw = item.raw || item.text || ''
|
|
382
|
+
|
|
383
|
+
// Split raw content by lines to separate main content from nested
|
|
384
|
+
const lines = raw.split('\n')
|
|
385
|
+
|
|
386
|
+
// Extract main content from the first line
|
|
387
|
+
const firstLineMatch = lines[0].match(/^\s*[-+*]\s+\[([ xX])\]\s+(.*)$/)
|
|
388
|
+
const mainContent = firstLineMatch ? firstLineMatch[2] : ''
|
|
389
|
+
|
|
390
|
+
// Parse nested content from remaining lines
|
|
391
|
+
let nestedTokens: MarkdownToken[] = []
|
|
392
|
+
if (lines.length > 1) {
|
|
393
|
+
// Join all lines after the first
|
|
394
|
+
const nestedRaw = lines.slice(1).join('\n')
|
|
395
|
+
|
|
396
|
+
// Only parse if there's actual content
|
|
397
|
+
if (nestedRaw.trim()) {
|
|
398
|
+
// Find minimum indentation of non-empty lines
|
|
399
|
+
const nestedLines = lines.slice(1)
|
|
400
|
+
const nonEmptyLines = nestedLines.filter(line => line.trim())
|
|
401
|
+
if (nonEmptyLines.length > 0) {
|
|
402
|
+
const minIndent = Math.min(...nonEmptyLines.map(line => line.length - line.trimStart().length))
|
|
403
|
+
// Remove common indentation while preserving structure
|
|
404
|
+
const trimmedLines = nestedLines.map(line => {
|
|
405
|
+
if (!line.trim()) {
|
|
406
|
+
return '' // Keep empty lines
|
|
407
|
+
}
|
|
408
|
+
return line.slice(minIndent)
|
|
409
|
+
})
|
|
410
|
+
const nestedContent = trimmedLines.join('\n').trim()
|
|
411
|
+
// Use the lexer to parse nested content
|
|
412
|
+
if (nestedContent) {
|
|
413
|
+
// Use the full lexer pipeline to ensure inline tokens are populated
|
|
414
|
+
nestedTokens = this.markedInstance.lexer(`${nestedContent}\n`)
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
processedItem = {
|
|
421
|
+
type: 'taskItem',
|
|
422
|
+
raw: '',
|
|
423
|
+
mainContent,
|
|
424
|
+
indentLevel,
|
|
425
|
+
checked: checked ?? false,
|
|
426
|
+
text: mainContent,
|
|
427
|
+
tokens: this.lexer.inlineTokens(mainContent),
|
|
428
|
+
nestedTokens,
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const itemType: 'list' | 'taskList' = isTask ? 'taskList' : 'list'
|
|
433
|
+
|
|
434
|
+
if (currentType !== itemType) {
|
|
435
|
+
if (currentGroup.length > 0) {
|
|
436
|
+
groups.push({ type: currentType!, items: currentGroup })
|
|
437
|
+
}
|
|
438
|
+
currentGroup = [processedItem]
|
|
439
|
+
currentType = itemType
|
|
440
|
+
} else {
|
|
441
|
+
currentGroup.push(processedItem)
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (currentGroup.length > 0) {
|
|
446
|
+
groups.push({ type: currentType!, items: currentGroup })
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Parse each group as a separate token
|
|
450
|
+
const results: JSONContent[] = []
|
|
451
|
+
for (let i = 0; i < groups.length; i += 1) {
|
|
452
|
+
const group = groups[i]
|
|
453
|
+
const subToken = { ...token, type: group.type, items: group.items }
|
|
454
|
+
const parsed = this.parseToken(subToken)
|
|
455
|
+
if (parsed) {
|
|
456
|
+
if (Array.isArray(parsed)) {
|
|
457
|
+
results.push(...parsed)
|
|
458
|
+
} else {
|
|
459
|
+
results.push(parsed)
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
return results.length > 0 ? results : null
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Parse a token using registered handlers (extracted for reuse).
|
|
469
|
+
*/
|
|
470
|
+
private parseTokenWithHandlers(token: MarkdownToken): JSONContent | JSONContent[] | null {
|
|
471
|
+
if (!token.type) {
|
|
472
|
+
return null
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const handlers = this.getHandlersForToken(token.type)
|
|
476
|
+
const helpers = this.createParseHelpers()
|
|
477
|
+
|
|
478
|
+
// Try each handler until one returns a valid result
|
|
479
|
+
const result = handlers.find(handler => {
|
|
480
|
+
if (!handler.parseMarkdown) {
|
|
481
|
+
return false
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const parseResult = handler.parseMarkdown(token, helpers)
|
|
485
|
+
const normalized = this.normalizeParseResult(parseResult)
|
|
486
|
+
|
|
487
|
+
// Check if this handler returned a valid result (not null/empty array)
|
|
488
|
+
if (normalized && (!Array.isArray(normalized) || normalized.length > 0)) {
|
|
489
|
+
// Store result for return
|
|
490
|
+
this.lastParseResult = normalized
|
|
491
|
+
return true
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
return false
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
// If a handler worked, return its result
|
|
498
|
+
if (result && this.lastParseResult) {
|
|
499
|
+
const toReturn = this.lastParseResult
|
|
500
|
+
this.lastParseResult = null // Clean up
|
|
501
|
+
return toReturn
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// If no handler worked, try fallback parsing
|
|
505
|
+
return this.parseFallbackToken(token)
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Creates helper functions for parsing markdown tokens.
|
|
510
|
+
* @returns An object containing helper functions for parsing.
|
|
343
511
|
*/
|
|
344
512
|
private createParseHelpers(): MarkdownParseHelpers {
|
|
345
513
|
return {
|
|
@@ -759,8 +927,12 @@ export class MarkdownManager {
|
|
|
759
927
|
// Render the node
|
|
760
928
|
const nodeContent = this.renderNodeToMarkdown(node, parentNode, i, level)
|
|
761
929
|
|
|
762
|
-
// Reopen marks after the node
|
|
763
|
-
|
|
930
|
+
// Reopen marks after the node, but NOT after a hard break
|
|
931
|
+
// Hard breaks should terminate marks (they create a line break where marks don't continue)
|
|
932
|
+
const afterMarkdown =
|
|
933
|
+
node.type === 'hardBreak'
|
|
934
|
+
? ''
|
|
935
|
+
: reopenMarksAfterNode(marksToReopen, activeMarks, this.getMarkOpening.bind(this))
|
|
764
936
|
|
|
765
937
|
result.push(beforeMarkdown + nodeContent + afterMarkdown)
|
|
766
938
|
}
|
package/src/utils.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Content } from '@tiptap/core'
|
|
1
|
+
import type { Content, MarkdownToken } from '@tiptap/core'
|
|
2
2
|
import type { Fragment, Node } from '@tiptap/pm/model'
|
|
3
3
|
|
|
4
4
|
import type { ContentType } from './types'
|
|
@@ -26,7 +26,6 @@ export function wrapInMarkdownBlock(prefix: string, content: string) {
|
|
|
26
26
|
|
|
27
27
|
/**
|
|
28
28
|
* Identifies marks that need to be closed (active but not in current node).
|
|
29
|
-
* Returns the mark types in reverse order for proper closing sequence.
|
|
30
29
|
*/
|
|
31
30
|
export function findMarksToClose(activeMarks: Map<string, any>, currentMarks: Map<string, any>): string[] {
|
|
32
31
|
const marksToClose: string[] = []
|
|
@@ -35,7 +34,7 @@ export function findMarksToClose(activeMarks: Map<string, any>, currentMarks: Ma
|
|
|
35
34
|
marksToClose.push(markType)
|
|
36
35
|
}
|
|
37
36
|
})
|
|
38
|
-
return marksToClose
|
|
37
|
+
return marksToClose
|
|
39
38
|
}
|
|
40
39
|
|
|
41
40
|
/**
|
|
@@ -88,7 +87,7 @@ export function findMarksToCloseAtEnd(
|
|
|
88
87
|
}
|
|
89
88
|
}
|
|
90
89
|
|
|
91
|
-
return marksToCloseAtEnd
|
|
90
|
+
return marksToCloseAtEnd
|
|
92
91
|
}
|
|
93
92
|
|
|
94
93
|
/**
|
|
@@ -133,6 +132,31 @@ export function reopenMarksAfterNode(
|
|
|
133
132
|
return afterMarkdown
|
|
134
133
|
}
|
|
135
134
|
|
|
135
|
+
/**
|
|
136
|
+
* Check if a markdown list item token is a task item and extract its state.
|
|
137
|
+
*
|
|
138
|
+
* @param item The list item token to check
|
|
139
|
+
* @returns Object containing isTask flag, checked state, and indentation level
|
|
140
|
+
*
|
|
141
|
+
* @example
|
|
142
|
+
* ```ts
|
|
143
|
+
* isTaskItem({ raw: '- [ ] Task' }) // { isTask: true, checked: false, indentLevel: 0 }
|
|
144
|
+
* isTaskItem({ raw: ' - [x] Done' }) // { isTask: true, checked: true, indentLevel: 2 }
|
|
145
|
+
* isTaskItem({ raw: '- Regular' }) // { isTask: false, indentLevel: 0 }
|
|
146
|
+
* ```
|
|
147
|
+
*/
|
|
148
|
+
export function isTaskItem(item: MarkdownToken): { isTask: boolean; checked?: boolean; indentLevel: number } {
|
|
149
|
+
const raw = item.raw || item.text || ''
|
|
150
|
+
|
|
151
|
+
// Match patterns like "- [ ] " or " - [x] "
|
|
152
|
+
const match = raw.match(/^(\s*)[-+*]\s+\[([ xX])\]\s+/)
|
|
153
|
+
|
|
154
|
+
if (match) {
|
|
155
|
+
return { isTask: true, checked: match[2].toLowerCase() === 'x', indentLevel: match[1].length }
|
|
156
|
+
}
|
|
157
|
+
return { isTask: false, indentLevel: 0 }
|
|
158
|
+
}
|
|
159
|
+
|
|
136
160
|
/**
|
|
137
161
|
* Assumes the content type based off the content.
|
|
138
162
|
* @param content The content to assume the type for.
|