@tiptap/markdown 3.7.0
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/LICENSE.md +21 -0
- package/README.md +18 -0
- package/dist/index.cjs +761 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +243 -0
- package/dist/index.d.ts +243 -0
- package/dist/index.js +761 -0
- package/dist/index.js.map +1 -0
- package/package.json +65 -0
- package/src/Extension.ts +213 -0
- package/src/MarkdownManager.ts +788 -0
- package/src/index.ts +3 -0
- package/src/types.ts +1 -0
- package/src/utils.ts +152 -0
|
@@ -0,0 +1,788 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AnyExtension,
|
|
3
|
+
type ExtendableConfig,
|
|
4
|
+
type JSONContent,
|
|
5
|
+
type MarkdownExtensionSpec,
|
|
6
|
+
type MarkdownParseHelpers,
|
|
7
|
+
type MarkdownParseResult,
|
|
8
|
+
type MarkdownRendererHelpers,
|
|
9
|
+
type MarkdownToken,
|
|
10
|
+
type MarkdownTokenizer,
|
|
11
|
+
type RenderContext,
|
|
12
|
+
flattenExtensions,
|
|
13
|
+
generateJSON,
|
|
14
|
+
getExtensionField,
|
|
15
|
+
} from '@tiptap/core'
|
|
16
|
+
import { type Lexer, type Token, type TokenizerExtension, marked } from 'marked'
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
closeMarksBeforeNode,
|
|
20
|
+
findMarksToClose,
|
|
21
|
+
findMarksToCloseAtEnd,
|
|
22
|
+
findMarksToOpen,
|
|
23
|
+
reopenMarksAfterNode,
|
|
24
|
+
wrapInMarkdownBlock,
|
|
25
|
+
} from './utils.js'
|
|
26
|
+
|
|
27
|
+
export class MarkdownManager {
|
|
28
|
+
private markedInstance: typeof marked
|
|
29
|
+
private lexer: Lexer
|
|
30
|
+
private registry: Map<string, MarkdownExtensionSpec[]>
|
|
31
|
+
private nodeTypeRegistry: Map<string, MarkdownExtensionSpec[]>
|
|
32
|
+
private indentStyle: 'space' | 'tab'
|
|
33
|
+
private indentSize: number
|
|
34
|
+
private baseExtensions: AnyExtension[] = []
|
|
35
|
+
private extensions: AnyExtension[] = []
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Create a MarkdownManager.
|
|
39
|
+
* @param options.marked Optional marked instance to use (injected).
|
|
40
|
+
* @param options.markedOptions Optional options to pass to marked.setOptions
|
|
41
|
+
* @param options.indentation Indentation settings (style and size).
|
|
42
|
+
* @param options.extensions An array of Tiptap extensions to register for markdown parsing and rendering.
|
|
43
|
+
*/
|
|
44
|
+
constructor(options?: {
|
|
45
|
+
marked?: typeof marked
|
|
46
|
+
markedOptions?: Parameters<typeof marked.setOptions>[0]
|
|
47
|
+
indentation?: { style?: 'space' | 'tab'; size?: number }
|
|
48
|
+
extensions: AnyExtension[]
|
|
49
|
+
}) {
|
|
50
|
+
this.markedInstance = options?.marked ?? marked
|
|
51
|
+
this.lexer = new this.markedInstance.Lexer()
|
|
52
|
+
this.indentStyle = options?.indentation?.style ?? 'space'
|
|
53
|
+
this.indentSize = options?.indentation?.size ?? 2
|
|
54
|
+
this.baseExtensions = options?.extensions || []
|
|
55
|
+
|
|
56
|
+
if (options?.markedOptions && typeof this.markedInstance.setOptions === 'function') {
|
|
57
|
+
this.markedInstance.setOptions(options.markedOptions)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
this.registry = new Map()
|
|
61
|
+
this.nodeTypeRegistry = new Map()
|
|
62
|
+
|
|
63
|
+
// If extensions were provided, register them now
|
|
64
|
+
if (options?.extensions) {
|
|
65
|
+
this.baseExtensions = options.extensions
|
|
66
|
+
const flattened = flattenExtensions(options.extensions)
|
|
67
|
+
flattened.forEach(ext => this.registerExtension(ext, false))
|
|
68
|
+
}
|
|
69
|
+
this.lexer = new this.markedInstance.Lexer() // Reset lexer to include all tokenizers
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Returns the underlying marked instance. */
|
|
73
|
+
get instance(): typeof marked {
|
|
74
|
+
return this.markedInstance
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Returns the correct indentCharacter (space or tab) */
|
|
78
|
+
get indentCharacter(): string {
|
|
79
|
+
return this.indentStyle === 'space' ? ' ' : '\t'
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Returns the correct indentString repeated X times */
|
|
83
|
+
get indentString(): string {
|
|
84
|
+
return this.indentCharacter.repeat(this.indentSize)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Helper to quickly check whether a marked instance is available. */
|
|
88
|
+
hasMarked(): boolean {
|
|
89
|
+
return !!this.markedInstance
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Register a Tiptap extension (Node/Mark/Extension). This will read
|
|
94
|
+
* `markdownName`, `parseMarkdown`, `renderMarkdown` and `priority` from the
|
|
95
|
+
* extension config (using the same resolution used across the codebase).
|
|
96
|
+
*/
|
|
97
|
+
registerExtension(extension: AnyExtension, recreateLexer: boolean = true): void {
|
|
98
|
+
// Keep track of all extensions for HTML parsing
|
|
99
|
+
this.extensions.push(extension)
|
|
100
|
+
|
|
101
|
+
const name = extension.name
|
|
102
|
+
const tokenName =
|
|
103
|
+
(getExtensionField(extension, 'markdownTokenName') as ExtendableConfig['markdownTokenName']) || name
|
|
104
|
+
const parseMarkdown = getExtensionField(extension, 'parseMarkdown') as ExtendableConfig['parseMarkdown'] | undefined
|
|
105
|
+
const renderMarkdown = getExtensionField(extension, 'renderMarkdown') as
|
|
106
|
+
| ExtendableConfig['renderMarkdown']
|
|
107
|
+
| undefined
|
|
108
|
+
const tokenizer = getExtensionField(extension, 'markdownTokenizer') as
|
|
109
|
+
| ExtendableConfig['markdownTokenizer']
|
|
110
|
+
| undefined
|
|
111
|
+
|
|
112
|
+
// Read the `markdown` object from the extension config. This allows
|
|
113
|
+
// extensions to provide `markdown: { name?, parseName?, renderName?, parse?, render?, match? }`.
|
|
114
|
+
const markdownCfg = (getExtensionField(extension, 'markdownOptions') ?? null) as ExtendableConfig['markdownOptions']
|
|
115
|
+
const isIndenting = markdownCfg?.indentsContent ?? false
|
|
116
|
+
|
|
117
|
+
const spec: MarkdownExtensionSpec = {
|
|
118
|
+
tokenName,
|
|
119
|
+
nodeName: name,
|
|
120
|
+
parseMarkdown,
|
|
121
|
+
renderMarkdown,
|
|
122
|
+
isIndenting,
|
|
123
|
+
tokenizer,
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Add to parse registry using parseName
|
|
127
|
+
if (tokenName && parseMarkdown) {
|
|
128
|
+
const parseExisting = this.registry.get(tokenName) || []
|
|
129
|
+
parseExisting.push(spec)
|
|
130
|
+
this.registry.set(tokenName, parseExisting)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Add to render registry using renderName (node type)
|
|
134
|
+
if (renderMarkdown) {
|
|
135
|
+
const renderExisting = this.nodeTypeRegistry.get(name) || []
|
|
136
|
+
renderExisting.push(spec)
|
|
137
|
+
this.nodeTypeRegistry.set(name, renderExisting)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Register custom tokenizer with marked.js
|
|
141
|
+
if (tokenizer && this.hasMarked()) {
|
|
142
|
+
this.registerTokenizer(tokenizer)
|
|
143
|
+
|
|
144
|
+
if (recreateLexer) {
|
|
145
|
+
this.lexer = new this.markedInstance.Lexer() // Reset lexer to include new tokenizer
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Register a custom tokenizer with marked.js for parsing non-standard markdown syntax.
|
|
152
|
+
*/
|
|
153
|
+
private registerTokenizer(tokenizer: MarkdownTokenizer): void {
|
|
154
|
+
if (!this.hasMarked()) {
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const { name, start, level = 'inline', tokenize } = tokenizer
|
|
159
|
+
|
|
160
|
+
// Helper functions that use a fresh lexer instance with all registered extensions
|
|
161
|
+
const tokenizeInline = (src: string) => {
|
|
162
|
+
return this.lexer.inlineTokens(src)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const tokenizeBlock = (src: string) => {
|
|
166
|
+
return this.lexer.blockTokens(src)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const helper = {
|
|
170
|
+
inlineTokens: tokenizeInline,
|
|
171
|
+
blockTokens: tokenizeBlock,
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
let startCb: (src: string) => number
|
|
175
|
+
|
|
176
|
+
if (!start) {
|
|
177
|
+
startCb = (src: string) => {
|
|
178
|
+
// For other tokenizers, try to find a match and return its position
|
|
179
|
+
const result = tokenize(src, [], helper)
|
|
180
|
+
if (result && result.raw) {
|
|
181
|
+
const index = src.indexOf(result.raw)
|
|
182
|
+
return index
|
|
183
|
+
}
|
|
184
|
+
return -1
|
|
185
|
+
}
|
|
186
|
+
} else {
|
|
187
|
+
startCb = typeof start === 'function' ? start : (src: string) => src.indexOf(start)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Create marked.js extension with proper types
|
|
191
|
+
const markedExtension: TokenizerExtension = {
|
|
192
|
+
name,
|
|
193
|
+
level,
|
|
194
|
+
start: startCb,
|
|
195
|
+
tokenizer: (src, tokens) => {
|
|
196
|
+
const result = tokenize(src, tokens, helper)
|
|
197
|
+
|
|
198
|
+
if (result && result.type) {
|
|
199
|
+
return {
|
|
200
|
+
...result,
|
|
201
|
+
type: result.type || name,
|
|
202
|
+
raw: result.raw || '',
|
|
203
|
+
tokens: (result.tokens || []) as Token[],
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return undefined
|
|
208
|
+
},
|
|
209
|
+
childTokens: [],
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Register with marked.js - use extensions array to control priority
|
|
213
|
+
this.markedInstance.use({
|
|
214
|
+
extensions: [markedExtension],
|
|
215
|
+
})
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Get registered handlers for a token type and try each until one succeeds. */
|
|
219
|
+
private getHandlersForToken(type: string): MarkdownExtensionSpec[] {
|
|
220
|
+
try {
|
|
221
|
+
return this.registry.get(type) || []
|
|
222
|
+
} catch {
|
|
223
|
+
return []
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Get the first handler for a token type (for backwards compatibility). */
|
|
228
|
+
private getHandlerForToken(type: string): MarkdownExtensionSpec | undefined {
|
|
229
|
+
// First try the markdown token registry (for parsing)
|
|
230
|
+
const markdownHandlers = this.getHandlersForToken(type)
|
|
231
|
+
if (markdownHandlers.length > 0) {
|
|
232
|
+
return markdownHandlers[0]
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Then try the node type registry (for rendering)
|
|
236
|
+
const nodeTypeHandlers = this.getHandlersForNodeType(type)
|
|
237
|
+
return nodeTypeHandlers.length > 0 ? nodeTypeHandlers[0] : undefined
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Get registered handlers for a node type (for rendering). */
|
|
241
|
+
private getHandlersForNodeType(type: string): MarkdownExtensionSpec[] {
|
|
242
|
+
try {
|
|
243
|
+
return this.nodeTypeRegistry.get(type) || []
|
|
244
|
+
} catch {
|
|
245
|
+
return []
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Serialize a ProseMirror-like JSON document (or node array) to a Markdown string
|
|
251
|
+
* using registered renderers and fallback renderers.
|
|
252
|
+
*/
|
|
253
|
+
serialize(docOrContent: JSONContent): string {
|
|
254
|
+
if (!docOrContent) {
|
|
255
|
+
return ''
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// If an array of nodes was passed
|
|
259
|
+
if (Array.isArray(docOrContent)) {
|
|
260
|
+
return this.renderNodes(docOrContent, docOrContent)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Single node
|
|
264
|
+
return this.renderNodes(docOrContent, docOrContent)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Parse markdown string into Tiptap JSON document using registered extension handlers.
|
|
269
|
+
*/
|
|
270
|
+
parse(markdown: string): JSONContent {
|
|
271
|
+
if (!this.hasMarked()) {
|
|
272
|
+
throw new Error('No marked instance available for parsing')
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Use marked to tokenize the markdown
|
|
276
|
+
const tokens = this.markedInstance.lexer(markdown)
|
|
277
|
+
|
|
278
|
+
// Convert tokens to Tiptap JSON
|
|
279
|
+
const content = this.parseTokens(tokens)
|
|
280
|
+
|
|
281
|
+
// Return a document node containing the parsed content
|
|
282
|
+
return {
|
|
283
|
+
type: 'doc',
|
|
284
|
+
content,
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Convert an array of marked tokens into Tiptap JSON nodes using registered extension handlers.
|
|
290
|
+
*/
|
|
291
|
+
private parseTokens(tokens: MarkdownToken[]): JSONContent[] {
|
|
292
|
+
return tokens
|
|
293
|
+
.map(token => this.parseToken(token))
|
|
294
|
+
.filter((parsed): parsed is JSONContent | JSONContent[] => parsed !== null)
|
|
295
|
+
.flatMap(parsed => (Array.isArray(parsed) ? parsed : [parsed]))
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Parse a single token into Tiptap JSON using the appropriate registered handler.
|
|
300
|
+
*/
|
|
301
|
+
private parseToken(token: MarkdownToken): JSONContent | JSONContent[] | null {
|
|
302
|
+
if (!token.type) {
|
|
303
|
+
return null
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const handlers = this.getHandlersForToken(token.type)
|
|
307
|
+
const helpers = this.createParseHelpers()
|
|
308
|
+
|
|
309
|
+
// Try each handler until one returns a valid result
|
|
310
|
+
const result = handlers.find(handler => {
|
|
311
|
+
if (!handler.parseMarkdown) {
|
|
312
|
+
return false
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const parseResult = handler.parseMarkdown(token, helpers)
|
|
316
|
+
const normalized = this.normalizeParseResult(parseResult)
|
|
317
|
+
|
|
318
|
+
// Check if this handler returned a valid result (not null/empty array)
|
|
319
|
+
if (normalized && (!Array.isArray(normalized) || normalized.length > 0)) {
|
|
320
|
+
// Store result for return
|
|
321
|
+
this.lastParseResult = normalized
|
|
322
|
+
return true
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return false
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
// If a handler worked, return its result
|
|
329
|
+
if (result && this.lastParseResult) {
|
|
330
|
+
const toReturn = this.lastParseResult
|
|
331
|
+
this.lastParseResult = null // Clean up
|
|
332
|
+
return toReturn
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// If no handler worked, try fallback parsing
|
|
336
|
+
return this.parseFallbackToken(token)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
private lastParseResult: JSONContent | JSONContent[] | null = null
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Create the helper functions that are passed to parse handlers.
|
|
343
|
+
*/
|
|
344
|
+
private createParseHelpers(): MarkdownParseHelpers {
|
|
345
|
+
return {
|
|
346
|
+
parseInline: (tokens: MarkdownToken[]) => this.parseInlineTokens(tokens),
|
|
347
|
+
parseChildren: (tokens: MarkdownToken[]) => this.parseTokens(tokens),
|
|
348
|
+
createTextNode: (text: string, marks?: Array<{ type: string; attrs?: any }>) => {
|
|
349
|
+
const node = {
|
|
350
|
+
type: 'text',
|
|
351
|
+
text,
|
|
352
|
+
marks: marks || undefined,
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return node
|
|
356
|
+
},
|
|
357
|
+
createNode: (type: string, attrs?: any, content?: JSONContent[]) => {
|
|
358
|
+
const node = {
|
|
359
|
+
type,
|
|
360
|
+
attrs: attrs || undefined,
|
|
361
|
+
content: content || undefined,
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (!attrs || Object.keys(attrs).length === 0) {
|
|
365
|
+
delete node.attrs
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return node
|
|
369
|
+
},
|
|
370
|
+
applyMark: (markType: string, content: JSONContent[], attrs?: any) => ({
|
|
371
|
+
mark: markType,
|
|
372
|
+
content,
|
|
373
|
+
attrs: attrs && Object.keys(attrs).length > 0 ? attrs : undefined,
|
|
374
|
+
}),
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Parse inline tokens (bold, italic, links, etc.) into text nodes with marks.
|
|
380
|
+
* This is the complex part that handles mark nesting and boundaries.
|
|
381
|
+
*/
|
|
382
|
+
private parseInlineTokens(tokens: MarkdownToken[]): JSONContent[] {
|
|
383
|
+
const result: JSONContent[] = []
|
|
384
|
+
|
|
385
|
+
// Process tokens sequentially
|
|
386
|
+
tokens.forEach(token => {
|
|
387
|
+
if (token.type === 'text') {
|
|
388
|
+
// Create text node
|
|
389
|
+
result.push({
|
|
390
|
+
type: 'text',
|
|
391
|
+
text: token.text || '',
|
|
392
|
+
})
|
|
393
|
+
} else if (token.type) {
|
|
394
|
+
// Handle inline marks (bold, italic, etc.)
|
|
395
|
+
const markHandler = this.getHandlerForToken(token.type)
|
|
396
|
+
if (markHandler && markHandler.parseMarkdown) {
|
|
397
|
+
const helpers = this.createParseHelpers()
|
|
398
|
+
const parsed = markHandler.parseMarkdown(token, helpers)
|
|
399
|
+
|
|
400
|
+
if (this.isMarkResult(parsed)) {
|
|
401
|
+
// This is a mark result - apply the mark to the content
|
|
402
|
+
const markedContent = this.applyMarkToContent(parsed.mark, parsed.content, parsed.attrs)
|
|
403
|
+
result.push(...markedContent)
|
|
404
|
+
} else {
|
|
405
|
+
// Regular inline node
|
|
406
|
+
const normalized = this.normalizeParseResult(parsed)
|
|
407
|
+
if (Array.isArray(normalized)) {
|
|
408
|
+
result.push(...normalized)
|
|
409
|
+
} else if (normalized) {
|
|
410
|
+
result.push(normalized)
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
} else if (token.tokens) {
|
|
414
|
+
// Fallback: try to parse children if they exist
|
|
415
|
+
result.push(...this.parseInlineTokens(token.tokens))
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
return result
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Apply a mark to content nodes.
|
|
425
|
+
*/
|
|
426
|
+
private applyMarkToContent(markType: string, content: JSONContent[], attrs?: any): JSONContent[] {
|
|
427
|
+
return content.map(node => {
|
|
428
|
+
if (node.type === 'text') {
|
|
429
|
+
// Add the mark to existing marks or create new marks array
|
|
430
|
+
const existingMarks = node.marks || []
|
|
431
|
+
const newMark = attrs ? { type: markType, attrs } : { type: markType }
|
|
432
|
+
return {
|
|
433
|
+
...node,
|
|
434
|
+
marks: [...existingMarks, newMark],
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// For non-text nodes, recursively apply to content
|
|
439
|
+
return {
|
|
440
|
+
...node,
|
|
441
|
+
content: node.content ? this.applyMarkToContent(markType, node.content, attrs) : undefined,
|
|
442
|
+
}
|
|
443
|
+
})
|
|
444
|
+
} /**
|
|
445
|
+
* Check if a parse result represents a mark to be applied.
|
|
446
|
+
*/
|
|
447
|
+
private isMarkResult(result: any): result is { mark: string; content: JSONContent[]; attrs?: any } {
|
|
448
|
+
return result && typeof result === 'object' && 'mark' in result
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Normalize parse results to ensure they're valid JSONContent.
|
|
453
|
+
*/
|
|
454
|
+
private normalizeParseResult(result: MarkdownParseResult): JSONContent | JSONContent[] | null {
|
|
455
|
+
if (!result) {
|
|
456
|
+
return null
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if (this.isMarkResult(result)) {
|
|
460
|
+
// This shouldn't happen at the top level, but handle it gracefully
|
|
461
|
+
return result.content
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
return result as JSONContent | JSONContent[]
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Fallback parsing for common tokens when no specific handler is registered.
|
|
469
|
+
*/
|
|
470
|
+
private parseFallbackToken(token: MarkdownToken): JSONContent | JSONContent[] | null {
|
|
471
|
+
switch (token.type) {
|
|
472
|
+
case 'paragraph':
|
|
473
|
+
return {
|
|
474
|
+
type: 'paragraph',
|
|
475
|
+
content: token.tokens ? this.parseInlineTokens(token.tokens) : [],
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
case 'heading':
|
|
479
|
+
return {
|
|
480
|
+
type: 'heading',
|
|
481
|
+
attrs: { level: token.depth || 1 },
|
|
482
|
+
content: token.tokens ? this.parseInlineTokens(token.tokens) : [],
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
case 'text':
|
|
486
|
+
return {
|
|
487
|
+
type: 'text',
|
|
488
|
+
text: token.text || '',
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
case 'html':
|
|
492
|
+
// Parse HTML using extensions' parseHTML methods
|
|
493
|
+
return this.parseHTMLToken(token)
|
|
494
|
+
|
|
495
|
+
case 'space':
|
|
496
|
+
return null
|
|
497
|
+
|
|
498
|
+
default:
|
|
499
|
+
// Unknown token type - try to parse children if they exist
|
|
500
|
+
if (token.tokens) {
|
|
501
|
+
return this.parseTokens(token.tokens)
|
|
502
|
+
}
|
|
503
|
+
return null
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Parse HTML tokens using extensions' parseHTML methods.
|
|
509
|
+
* This allows HTML within markdown to be parsed according to extension rules.
|
|
510
|
+
*/
|
|
511
|
+
private parseHTMLToken(token: MarkdownToken): JSONContent | JSONContent[] | null {
|
|
512
|
+
const html = token.text || token.raw || ''
|
|
513
|
+
|
|
514
|
+
if (!html.trim()) {
|
|
515
|
+
return null
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Use generateJSON to parse the HTML using extensions' parseHTML rules
|
|
519
|
+
try {
|
|
520
|
+
const parsed = generateJSON(html, this.baseExtensions)
|
|
521
|
+
|
|
522
|
+
// If the result is a doc node, extract its content
|
|
523
|
+
if (parsed.type === 'doc' && parsed.content) {
|
|
524
|
+
// For block-level HTML, return the content array
|
|
525
|
+
if (token.block) {
|
|
526
|
+
return parsed.content
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// For inline HTML, we need to flatten the content appropriately
|
|
530
|
+
// If there's only one paragraph with content, unwrap it
|
|
531
|
+
if (parsed.content.length === 1 && parsed.content[0].type === 'paragraph' && parsed.content[0].content) {
|
|
532
|
+
return parsed.content[0].content
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
return parsed.content
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
return parsed as JSONContent
|
|
539
|
+
} catch (error) {
|
|
540
|
+
throw new Error(`Failed to parse HTML in markdown: ${error}`)
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
renderNodeToMarkdown(node: JSONContent, parentNode?: JSONContent, index = 0, level = 0): string {
|
|
545
|
+
// if node is a text node, we simply return it's text content
|
|
546
|
+
// marks are handled at the array level in renderNodesWithMarkBoundaries
|
|
547
|
+
if (node.type === 'text') {
|
|
548
|
+
return node.text || ''
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
if (!node.type) {
|
|
552
|
+
return ''
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const handler = this.getHandlerForToken(node.type)
|
|
556
|
+
if (!handler) {
|
|
557
|
+
return ''
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const helpers: MarkdownRendererHelpers = {
|
|
561
|
+
renderChildren: (nodes, separator) => {
|
|
562
|
+
const childLevel = handler.isIndenting ? level + 1 : level
|
|
563
|
+
|
|
564
|
+
if (!Array.isArray(nodes) && (nodes as any).content) {
|
|
565
|
+
return this.renderNodes((nodes as any).content as JSONContent[], node, separator || '', index, childLevel)
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
return this.renderNodes(nodes, node, separator || '', index, childLevel)
|
|
569
|
+
},
|
|
570
|
+
indent: content => {
|
|
571
|
+
return this.indentString + content
|
|
572
|
+
},
|
|
573
|
+
wrapInBlock: wrapInMarkdownBlock,
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const context: RenderContext = {
|
|
577
|
+
index,
|
|
578
|
+
level,
|
|
579
|
+
parentType: parentNode?.type,
|
|
580
|
+
meta: {},
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// First render the node itself (this will render children recursively)
|
|
584
|
+
const rendered = handler.renderMarkdown?.(node, helpers, context) || ''
|
|
585
|
+
|
|
586
|
+
return rendered
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* Render a node or an array of nodes. Parent type controls how children
|
|
591
|
+
* are joined (which determines newline insertion between children).
|
|
592
|
+
*/
|
|
593
|
+
renderNodes(
|
|
594
|
+
nodeOrNodes: JSONContent | JSONContent[],
|
|
595
|
+
parentNode?: JSONContent,
|
|
596
|
+
separator = '',
|
|
597
|
+
index = 0,
|
|
598
|
+
level = 0,
|
|
599
|
+
): string {
|
|
600
|
+
// if we have just one node, call renderNodeToMarkdown directly
|
|
601
|
+
if (!Array.isArray(nodeOrNodes)) {
|
|
602
|
+
if (!nodeOrNodes.type) {
|
|
603
|
+
return ''
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
return this.renderNodeToMarkdown(nodeOrNodes, parentNode, index, level)
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
return this.renderNodesWithMarkBoundaries(nodeOrNodes, parentNode, separator, level)
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Render an array of nodes while properly tracking mark boundaries.
|
|
614
|
+
* This handles cases where marks span across multiple text nodes.
|
|
615
|
+
*/
|
|
616
|
+
private renderNodesWithMarkBoundaries(
|
|
617
|
+
nodes: JSONContent[],
|
|
618
|
+
parentNode?: JSONContent,
|
|
619
|
+
separator = '',
|
|
620
|
+
level = 0,
|
|
621
|
+
): string {
|
|
622
|
+
const result: string[] = []
|
|
623
|
+
const activeMarks: Map<string, any> = new Map()
|
|
624
|
+
|
|
625
|
+
nodes.forEach((node, i) => {
|
|
626
|
+
// Lookahead to the next node to determine if marks need to be closed
|
|
627
|
+
const nextNode = i < nodes.length - 1 ? nodes[i + 1] : null
|
|
628
|
+
|
|
629
|
+
if (!node.type) {
|
|
630
|
+
return
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
if (node.type === 'text') {
|
|
634
|
+
let textContent = node.text || ''
|
|
635
|
+
const currentMarks = new Map((node.marks || []).map(mark => [mark.type, mark]))
|
|
636
|
+
|
|
637
|
+
// Find marks that need to be closed and opened
|
|
638
|
+
const marksToClose = findMarksToClose(activeMarks, currentMarks)
|
|
639
|
+
const marksToOpen = findMarksToOpen(activeMarks, currentMarks)
|
|
640
|
+
|
|
641
|
+
// Close marks (in reverse order of how they were opened)
|
|
642
|
+
marksToClose.forEach(markType => {
|
|
643
|
+
const mark = activeMarks.get(markType)
|
|
644
|
+
const closeMarkdown = this.getMarkClosing(markType, mark)
|
|
645
|
+
if (closeMarkdown) {
|
|
646
|
+
textContent += closeMarkdown
|
|
647
|
+
}
|
|
648
|
+
activeMarks.delete(markType)
|
|
649
|
+
})
|
|
650
|
+
|
|
651
|
+
// Open new marks (should be at the beginning)
|
|
652
|
+
marksToOpen.forEach(({ type, mark }) => {
|
|
653
|
+
const openMarkdown = this.getMarkOpening(type, mark)
|
|
654
|
+
if (openMarkdown) {
|
|
655
|
+
textContent = openMarkdown + textContent
|
|
656
|
+
}
|
|
657
|
+
activeMarks.set(type, mark)
|
|
658
|
+
})
|
|
659
|
+
|
|
660
|
+
// Close marks at the end of this node if needed
|
|
661
|
+
const marksToCloseAtEnd = findMarksToCloseAtEnd(
|
|
662
|
+
activeMarks,
|
|
663
|
+
currentMarks,
|
|
664
|
+
nextNode,
|
|
665
|
+
this.markSetsEqual.bind(this),
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
marksToCloseAtEnd.forEach(markType => {
|
|
669
|
+
const mark = activeMarks.get(markType)
|
|
670
|
+
const closeMarkdown = this.getMarkClosing(markType, mark)
|
|
671
|
+
if (closeMarkdown) {
|
|
672
|
+
textContent += closeMarkdown
|
|
673
|
+
}
|
|
674
|
+
activeMarks.delete(markType)
|
|
675
|
+
})
|
|
676
|
+
|
|
677
|
+
result.push(textContent)
|
|
678
|
+
} else {
|
|
679
|
+
// For non-text nodes, close all active marks before rendering, then reopen after
|
|
680
|
+
const marksToReopen = new Map(activeMarks)
|
|
681
|
+
|
|
682
|
+
// Close all marks before the node
|
|
683
|
+
const beforeMarkdown = closeMarksBeforeNode(activeMarks, this.getMarkClosing.bind(this))
|
|
684
|
+
|
|
685
|
+
// Render the node
|
|
686
|
+
const nodeContent = this.renderNodeToMarkdown(node, parentNode, i, level)
|
|
687
|
+
|
|
688
|
+
// Reopen marks after the node
|
|
689
|
+
const afterMarkdown = reopenMarksAfterNode(marksToReopen, activeMarks, this.getMarkOpening.bind(this))
|
|
690
|
+
|
|
691
|
+
result.push(beforeMarkdown + nodeContent + afterMarkdown)
|
|
692
|
+
}
|
|
693
|
+
})
|
|
694
|
+
|
|
695
|
+
return result.join(separator)
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* Get the opening markdown syntax for a mark type.
|
|
700
|
+
*/
|
|
701
|
+
private getMarkOpening(markType: string, mark: any): string {
|
|
702
|
+
const handlers = this.getHandlersForNodeType(markType)
|
|
703
|
+
const handler = handlers.length > 0 ? handlers[0] : undefined
|
|
704
|
+
if (!handler || !handler.renderMarkdown) {
|
|
705
|
+
return ''
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// Use a unique placeholder that's extremely unlikely to appear in real content
|
|
709
|
+
const placeholder = '\uE000__TIPTAP_MARKDOWN_PLACEHOLDER__\uE001'
|
|
710
|
+
|
|
711
|
+
// For most marks, we can extract the opening syntax by rendering a simple case
|
|
712
|
+
const syntheticNode: JSONContent = {
|
|
713
|
+
type: markType,
|
|
714
|
+
attrs: mark.attrs || {},
|
|
715
|
+
content: [{ type: 'text', text: placeholder }],
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
try {
|
|
719
|
+
const rendered = handler.renderMarkdown(
|
|
720
|
+
syntheticNode,
|
|
721
|
+
{
|
|
722
|
+
renderChildren: () => placeholder,
|
|
723
|
+
indent: (content: string) => content,
|
|
724
|
+
wrapInBlock: (prefix: string, content: string) => prefix + content,
|
|
725
|
+
},
|
|
726
|
+
{ index: 0, level: 0, parentType: 'text', meta: {} },
|
|
727
|
+
)
|
|
728
|
+
|
|
729
|
+
// Extract the opening part (everything before placeholder)
|
|
730
|
+
const placeholderIndex = rendered.indexOf(placeholder)
|
|
731
|
+
return placeholderIndex >= 0 ? rendered.substring(0, placeholderIndex) : ''
|
|
732
|
+
} catch (err) {
|
|
733
|
+
throw new Error(`Failed to get mark opening for ${markType}: ${err}`)
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* Get the closing markdown syntax for a mark type.
|
|
739
|
+
*/
|
|
740
|
+
private getMarkClosing(markType: string, mark: any): string {
|
|
741
|
+
const handlers = this.getHandlersForNodeType(markType)
|
|
742
|
+
const handler = handlers.length > 0 ? handlers[0] : undefined
|
|
743
|
+
if (!handler || !handler.renderMarkdown) {
|
|
744
|
+
return ''
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// Use a unique placeholder that's extremely unlikely to appear in real content
|
|
748
|
+
const placeholder = '\uE000__TIPTAP_MARKDOWN_PLACEHOLDER__\uE001'
|
|
749
|
+
|
|
750
|
+
const syntheticNode: JSONContent = {
|
|
751
|
+
type: markType,
|
|
752
|
+
attrs: mark.attrs || {},
|
|
753
|
+
content: [{ type: 'text', text: placeholder }],
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
try {
|
|
757
|
+
const rendered = handler.renderMarkdown(
|
|
758
|
+
syntheticNode,
|
|
759
|
+
{
|
|
760
|
+
renderChildren: () => placeholder,
|
|
761
|
+
indent: (content: string) => content,
|
|
762
|
+
wrapInBlock: (prefix: string, content: string) => prefix + content,
|
|
763
|
+
},
|
|
764
|
+
{ index: 0, level: 0, parentType: 'text', meta: {} },
|
|
765
|
+
)
|
|
766
|
+
|
|
767
|
+
// Extract the closing part (everything after placeholder)
|
|
768
|
+
const placeholderIndex = rendered.indexOf(placeholder)
|
|
769
|
+
const placeholderEnd = placeholderIndex + placeholder.length
|
|
770
|
+
return placeholderIndex >= 0 ? rendered.substring(placeholderEnd) : ''
|
|
771
|
+
} catch (err) {
|
|
772
|
+
throw new Error(`Failed to get mark closing for ${markType}: ${err}`)
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* Check if two mark sets are equal.
|
|
778
|
+
*/
|
|
779
|
+
private markSetsEqual(marks1: Map<string, any>, marks2: Map<string, any>): boolean {
|
|
780
|
+
if (marks1.size !== marks2.size) {
|
|
781
|
+
return false
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
return Array.from(marks1.keys()).every(type => marks2.has(type))
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
export default MarkdownManager
|