@depup/prosemirror-markdown 1.13.4-depup.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.
@@ -0,0 +1,483 @@
1
+ import {Node, Mark} from "prosemirror-model"
2
+
3
+ type MarkSerializerSpec = {
4
+ /// The string that should appear before a piece of content marked
5
+ /// by this mark, either directly or as a function that returns an
6
+ /// appropriate string.
7
+ open: string | ((state: MarkdownSerializerState, mark: Mark, parent: Node, index: number) => string),
8
+ /// The string that should appear after a piece of content marked by
9
+ /// this mark.
10
+ close: string | ((state: MarkdownSerializerState, mark: Mark, parent: Node, index: number) => string),
11
+ /// When `true`, this indicates that the order in which the mark's
12
+ /// opening and closing syntax appears relative to other mixable
13
+ /// marks can be varied. (For example, you can say `**a *b***` and
14
+ /// `*a **b***`, but not `` `a *b*` ``.)
15
+ mixable?: boolean,
16
+ /// When enabled, causes the serializer to move enclosing whitespace
17
+ /// from inside the marks to outside the marks. This is necessary
18
+ /// for emphasis marks as CommonMark does not permit enclosing
19
+ /// whitespace inside emphasis marks, see:
20
+ /// http:///spec.commonmark.org/0.26/#example-330
21
+ expelEnclosingWhitespace?: boolean,
22
+ /// Can be set to `false` to disable character escaping in a mark. A
23
+ /// non-escaping mark has to have the highest precedence (must
24
+ /// always be the innermost mark).
25
+ escape?: boolean
26
+ }
27
+
28
+ const blankMark: MarkSerializerSpec = {open: "", close: "", mixable: true}
29
+
30
+ /// A specification for serializing a ProseMirror document as
31
+ /// Markdown/CommonMark text.
32
+ export class MarkdownSerializer {
33
+ /// Construct a serializer with the given configuration. The `nodes`
34
+ /// object should map node names in a given schema to function that
35
+ /// take a serializer state and such a node, and serialize the node.
36
+ constructor(
37
+ /// The node serializer functions for this serializer.
38
+ readonly nodes: {[node: string]: (state: MarkdownSerializerState, node: Node, parent: Node, index: number) => void},
39
+ /// The mark serializer info.
40
+ readonly marks: {[mark: string]: MarkSerializerSpec},
41
+ readonly options: {
42
+ /// Extra characters can be added for escaping. This is passed
43
+ /// directly to String.replace(), and the matching characters are
44
+ /// preceded by a backslash.
45
+ escapeExtraCharacters?: RegExp,
46
+ /// Specify the node name of hard breaks.
47
+ /// Defaults to "hard_break"
48
+ hardBreakNodeName?: string,
49
+ /// By default, the serializer raises an error when it finds a
50
+ /// node or mark type for which no serializer is defined. Set
51
+ /// this to `false` to make it just ignore such elements,
52
+ /// rendering only their content.
53
+ strict?: boolean
54
+ } = {}
55
+ ) {}
56
+
57
+ /// Serialize the content of the given node to
58
+ /// [CommonMark](http://commonmark.org/).
59
+ serialize(content: Node, options: {
60
+ /// Whether to render lists in a tight style. This can be overridden
61
+ /// on a node level by specifying a tight attribute on the node.
62
+ /// Defaults to false.
63
+ tightLists?: boolean
64
+ } = {}) {
65
+ options = Object.assign({}, this.options, options)
66
+ let state = new MarkdownSerializerState(this.nodes, this.marks, options)
67
+ state.renderContent(content)
68
+ return state.out
69
+ }
70
+ }
71
+
72
+ /// A serializer for the [basic schema](#schema).
73
+ export const defaultMarkdownSerializer = new MarkdownSerializer({
74
+ blockquote(state, node) {
75
+ state.wrapBlock("> ", null, node, () => state.renderContent(node))
76
+ },
77
+ code_block(state, node) {
78
+ // Make sure the front matter fences are longer than any dash sequence within it
79
+ const backticks = node.textContent.match(/`{3,}/gm)
80
+ const fence = backticks ? (backticks.sort().slice(-1)[0] + "`") : "```"
81
+
82
+ state.write(fence + (node.attrs.params || "") + "\n")
83
+ state.text(node.textContent, false)
84
+ // Add a newline to the current content before adding closing marker
85
+ state.write("\n")
86
+ state.write(fence)
87
+ state.closeBlock(node)
88
+ },
89
+ heading(state, node) {
90
+ state.write(state.repeat("#", node.attrs.level) + " ")
91
+ state.renderInline(node, false)
92
+ state.closeBlock(node)
93
+ },
94
+ horizontal_rule(state, node) {
95
+ state.write(node.attrs.markup || "---")
96
+ state.closeBlock(node)
97
+ },
98
+ bullet_list(state, node) {
99
+ state.renderList(node, " ", () => (node.attrs.bullet || "*") + " ")
100
+ },
101
+ ordered_list(state, node) {
102
+ let start = node.attrs.order || 1
103
+ let maxW = String(start + node.childCount - 1).length
104
+ let space = state.repeat(" ", maxW + 2)
105
+ state.renderList(node, space, i => {
106
+ let nStr = String(start + i)
107
+ return state.repeat(" ", maxW - nStr.length) + nStr + ". "
108
+ })
109
+ },
110
+ list_item(state, node) {
111
+ state.renderContent(node)
112
+ },
113
+ paragraph(state, node) {
114
+ state.renderInline(node)
115
+ state.closeBlock(node)
116
+ },
117
+
118
+ image(state, node) {
119
+ state.write("![" + state.esc(node.attrs.alt || "") + "](" + node.attrs.src.replace(/[\(\)]/g, "\\$&") +
120
+ (node.attrs.title ? ' "' + node.attrs.title.replace(/"/g, '\\"') + '"' : "") + ")")
121
+ },
122
+ hard_break(state, node, parent, index) {
123
+ for (let i = index + 1; i < parent.childCount; i++)
124
+ if (parent.child(i).type != node.type) {
125
+ state.write("\\\n")
126
+ return
127
+ }
128
+ },
129
+ text(state, node) {
130
+ state.text(node.text!, !state.inAutolink)
131
+ }
132
+ }, {
133
+ em: {open: "*", close: "*", mixable: true, expelEnclosingWhitespace: true},
134
+ strong: {open: "**", close: "**", mixable: true, expelEnclosingWhitespace: true},
135
+ link: {
136
+ open(state, mark, parent, index) {
137
+ state.inAutolink = isPlainURL(mark, parent, index)
138
+ return state.inAutolink ? "<" : "["
139
+ },
140
+ close(state, mark, parent, index) {
141
+ let {inAutolink} = state
142
+ state.inAutolink = undefined
143
+ return inAutolink ? ">"
144
+ : "](" + mark.attrs.href.replace(/[\(\)"]/g, "\\$&") + (mark.attrs.title ? ` "${mark.attrs.title.replace(/"/g, '\\"')}"` : "") + ")"
145
+ },
146
+ mixable: true
147
+ },
148
+ code: {open(_state, _mark, parent, index) { return backticksFor(parent.child(index), -1) },
149
+ close(_state, _mark, parent, index) { return backticksFor(parent.child(index - 1), 1) },
150
+ escape: false}
151
+ })
152
+
153
+ function backticksFor(node: Node, side: number) {
154
+ let ticks = /`+/g, m: RegExpExecArray | null, len = 0
155
+ if (node.isText) while (m = ticks.exec(node.text!)) len = Math.max(len, m[0].length)
156
+ let result = len > 0 && side > 0 ? " `" : "`"
157
+ for (let i = 0; i < len; i++) result += "`"
158
+ if (len > 0 && side < 0) result += " "
159
+ return result
160
+ }
161
+
162
+ function isPlainURL(link: Mark, parent: Node, index: number) {
163
+ if (link.attrs.title || !/^\w+:/.test(link.attrs.href)) return false
164
+ let content = parent.child(index)
165
+ if (!content.isText || content.text != link.attrs.href || content.marks[content.marks.length - 1] != link) return false
166
+ return index == parent.childCount - 1 || !link.isInSet(parent.child(index + 1).marks)
167
+ }
168
+
169
+ /// This is an object used to track state and expose
170
+ /// methods related to markdown serialization. Instances are passed to
171
+ /// node and mark serialization methods (see `toMarkdown`).
172
+ export class MarkdownSerializerState {
173
+ /// @internal
174
+ delim: string = ""
175
+ /// @internal
176
+ out: string = ""
177
+ /// @internal
178
+ closed: Node | null = null
179
+ /// @internal
180
+ inAutolink: boolean | undefined = undefined
181
+ /// @internal
182
+ atBlockStart: boolean = false
183
+ /// @internal
184
+ inTightList: boolean = false
185
+
186
+ /// @internal
187
+ constructor(
188
+ /// @internal
189
+ readonly nodes: {[node: string]: (state: MarkdownSerializerState, node: Node, parent: Node, index: number) => void},
190
+ /// @internal
191
+ readonly marks: {[mark: string]: MarkSerializerSpec},
192
+ /// The options passed to the serializer.
193
+ readonly options: {tightLists?: boolean, escapeExtraCharacters?: RegExp, hardBreakNodeName?: string, strict?: boolean}
194
+ ) {
195
+ if (typeof this.options.tightLists == "undefined")
196
+ this.options.tightLists = false
197
+ if (typeof this.options.hardBreakNodeName == "undefined")
198
+ this.options.hardBreakNodeName = "hard_break"
199
+ }
200
+
201
+ /// @internal
202
+ flushClose(size: number = 2) {
203
+ if (this.closed) {
204
+ if (!this.atBlank()) this.out += "\n"
205
+ if (size > 1) {
206
+ let delimMin = this.delim
207
+ let trim = /\s+$/.exec(delimMin)
208
+ if (trim) delimMin = delimMin.slice(0, delimMin.length - trim[0].length)
209
+ for (let i = 1; i < size; i++)
210
+ this.out += delimMin + "\n"
211
+ }
212
+ this.closed = null
213
+ }
214
+ }
215
+
216
+ /// @internal
217
+ getMark(name: string) {
218
+ let info = this.marks[name]
219
+ if (!info) {
220
+ if (this.options.strict !== false)
221
+ throw new Error(`Mark type \`${name}\` not supported by Markdown renderer`)
222
+ info = blankMark
223
+ }
224
+ return info
225
+ }
226
+
227
+ /// Render a block, prefixing each line with `delim`, and the first
228
+ /// line in `firstDelim`. `node` should be the node that is closed at
229
+ /// the end of the block, and `f` is a function that renders the
230
+ /// content of the block.
231
+ wrapBlock(delim: string, firstDelim: string | null, node: Node, f: () => void) {
232
+ let old = this.delim
233
+ this.write(firstDelim != null ? firstDelim : delim)
234
+ this.delim += delim
235
+ f()
236
+ this.delim = old
237
+ this.closeBlock(node)
238
+ }
239
+
240
+ /// @internal
241
+ atBlank() {
242
+ return /(^|\n)$/.test(this.out)
243
+ }
244
+
245
+ /// Ensure the current content ends with a newline.
246
+ ensureNewLine() {
247
+ if (!this.atBlank()) this.out += "\n"
248
+ }
249
+
250
+ /// Prepare the state for writing output (closing closed paragraphs,
251
+ /// adding delimiters, and so on), and then optionally add content
252
+ /// (unescaped) to the output.
253
+ write(content?: string) {
254
+ this.flushClose()
255
+ if (this.delim && this.atBlank())
256
+ this.out += this.delim
257
+ if (content) this.out += content
258
+ }
259
+
260
+ /// Close the block for the given node.
261
+ closeBlock(node: Node) {
262
+ this.closed = node
263
+ }
264
+
265
+ /// Add the given text to the document. When escape is not `false`,
266
+ /// it will be escaped.
267
+ text(text: string, escape = true) {
268
+ let lines = text.split("\n")
269
+ for (let i = 0; i < lines.length; i++) {
270
+ this.write()
271
+ // Escape exclamation marks in front of links
272
+ if (!escape && lines[i][0] == "[" && /(^|[^\\])\!$/.test(this.out))
273
+ this.out = this.out.slice(0, this.out.length - 1) + "\\!"
274
+ this.out += escape ? this.esc(lines[i], this.atBlockStart) : lines[i]
275
+ if (i != lines.length - 1) this.out += "\n"
276
+ }
277
+ }
278
+
279
+ /// Render the given node as a block.
280
+ render(node: Node, parent: Node, index: number) {
281
+ if (this.nodes[node.type.name]) {
282
+ this.nodes[node.type.name](this, node, parent, index)
283
+ } else {
284
+ if (this.options.strict !== false) {
285
+ throw new Error("Token type `" + node.type.name + "` not supported by Markdown renderer")
286
+ } else if (!node.type.isLeaf) {
287
+ if (node.type.inlineContent) this.renderInline(node)
288
+ else this.renderContent(node)
289
+ if (node.isBlock) this.closeBlock(node)
290
+ }
291
+ }
292
+ }
293
+
294
+ /// Render the contents of `parent` as block nodes.
295
+ renderContent(parent: Node) {
296
+ parent.forEach((node, _, i) => this.render(node, parent, i))
297
+ }
298
+
299
+ /// Render the contents of `parent` as inline content.
300
+ renderInline(parent: Node, fromBlockStart = true) {
301
+ this.atBlockStart = fromBlockStart
302
+ let active: Mark[] = [], trailing = ""
303
+ let progress = (node: Node | null, offset: number, index: number) => {
304
+ let marks = node ? node.marks : []
305
+
306
+ // Remove marks from `hard_break` that are the last node inside
307
+ // that mark to prevent parser edge cases with new lines just
308
+ // before closing marks.
309
+ if (node && node.type.name === this.options.hardBreakNodeName)
310
+ marks = marks.filter(m => {
311
+ if (index + 1 == parent.childCount) return false
312
+ let next = parent.child(index + 1)
313
+ return m.isInSet(next.marks) && (!next.isText || /\S/.test(next.text!))
314
+ })
315
+
316
+ let leading = trailing
317
+ trailing = ""
318
+ // If whitespace has to be expelled from the node, adjust
319
+ // leading and trailing accordingly.
320
+ if (node && node.isText && marks.some(mark => {
321
+ let info = this.getMark(mark.type.name)
322
+ return info && info.expelEnclosingWhitespace && !mark.isInSet(active)
323
+ })) {
324
+ let [_, lead, rest] = /^(\s*)(.*)$/m.exec(node.text!)!
325
+ if (lead) {
326
+ leading += lead
327
+ node = rest ? (node as any).withText(rest) : null
328
+ if (!node) marks = active
329
+ }
330
+ }
331
+ if (node && node.isText && marks.some(mark => {
332
+ let info = this.getMark(mark.type.name)
333
+ return info && info.expelEnclosingWhitespace && !this.isMarkAhead(parent, index + 1, mark)
334
+ })) {
335
+ let [_, rest, trail] = /^(.*?)(\s*)$/m.exec(node.text!)!
336
+ if (trail) {
337
+ trailing = trail
338
+ node = rest ? (node as any).withText(rest) : null
339
+ if (!node) marks = active
340
+ }
341
+ }
342
+ let inner = marks.length ? marks[marks.length - 1] : null
343
+ let noEsc = inner && this.getMark(inner.type.name).escape === false
344
+ let len = marks.length - (noEsc ? 1 : 0)
345
+
346
+ // Try to reorder 'mixable' marks, such as em and strong, which
347
+ // in Markdown may be opened and closed in different order, so
348
+ // that order of the marks for the token matches the order in
349
+ // active.
350
+ outer: for (let i = 0; i < len; i++) {
351
+ let mark = marks[i]
352
+ if (!this.getMark(mark.type.name).mixable) break
353
+ for (let j = 0; j < active.length; j++) {
354
+ let other = active[j]
355
+ if (!this.getMark(other.type.name).mixable) break
356
+ if (mark.eq(other)) {
357
+ if (i > j)
358
+ marks = marks.slice(0, j).concat(mark).concat(marks.slice(j, i)).concat(marks.slice(i + 1, len))
359
+ else if (j > i)
360
+ marks = marks.slice(0, i).concat(marks.slice(i + 1, j)).concat(mark).concat(marks.slice(j, len))
361
+ continue outer
362
+ }
363
+ }
364
+ }
365
+
366
+ // Find the prefix of the mark set that didn't change
367
+ let keep = 0
368
+ while (keep < Math.min(active.length, len) && marks[keep].eq(active[keep])) ++keep
369
+
370
+ // Close the marks that need to be closed
371
+ while (keep < active.length)
372
+ this.text(this.markString(active.pop()!, false, parent, index), false)
373
+
374
+ // Output any previously expelled trailing whitespace outside the marks
375
+ if (leading) this.text(leading)
376
+
377
+ // Open the marks that need to be opened
378
+ if (node) {
379
+ while (active.length < len) {
380
+ let add = marks[active.length]
381
+ active.push(add)
382
+ this.text(this.markString(add, true, parent, index), false)
383
+ this.atBlockStart = false
384
+ }
385
+
386
+ // Render the node. Special case code marks, since their content
387
+ // may not be escaped.
388
+ if (noEsc && node.isText)
389
+ this.text(this.markString(inner!, true, parent, index) + node.text +
390
+ this.markString(inner!, false, parent, index + 1), false)
391
+ else
392
+ this.render(node, parent, index)
393
+ this.atBlockStart = false
394
+ }
395
+
396
+ // After the first non-empty text node is rendered, the end of output
397
+ // is no longer at block start.
398
+ //
399
+ // FIXME: If a non-text node writes something to the output for this
400
+ // block, the end of output is also no longer at block start. But how
401
+ // can we detect that?
402
+ if (node?.isText && node.nodeSize > 0) {
403
+ this.atBlockStart = false
404
+ }
405
+ }
406
+ parent.forEach(progress)
407
+ progress(null, 0, parent.childCount)
408
+ this.atBlockStart = false
409
+ }
410
+
411
+ /// Render a node's content as a list. `delim` should be the extra
412
+ /// indentation added to all lines except the first in an item,
413
+ /// `firstDelim` is a function going from an item index to a
414
+ /// delimiter for the first line of the item.
415
+ renderList(node: Node, delim: string, firstDelim: (index: number) => string) {
416
+ if (this.closed && this.closed.type == node.type)
417
+ this.flushClose(3)
418
+ else if (this.inTightList)
419
+ this.flushClose(1)
420
+
421
+ let isTight = typeof node.attrs.tight != "undefined" ? node.attrs.tight : this.options.tightLists
422
+ let prevTight = this.inTightList
423
+ this.inTightList = isTight
424
+ node.forEach((child, _, i) => {
425
+ if (i && isTight) this.flushClose(1)
426
+ this.wrapBlock(delim, firstDelim(i), node, () => this.render(child, node, i))
427
+ })
428
+ this.inTightList = prevTight
429
+ }
430
+
431
+ /// Escape the given string so that it can safely appear in Markdown
432
+ /// content. If `startOfLine` is true, also escape characters that
433
+ /// have special meaning only at the start of the line.
434
+ esc(str: string, startOfLine = false) {
435
+ str = str.replace(
436
+ /[`*\\~\[\]_]/g,
437
+ (m, i) => m == "_" && i > 0 && i + 1 < str.length && str[i-1].match(/\w/) && str[i+1].match(/\w/) ? m : "\\" + m
438
+ )
439
+ if (startOfLine) str = str.replace(/^(\+[ ]|[\-*>])/, "\\$&").replace(/^(\s*)(#{1,6})(\s|$)/, '$1\\$2$3').replace(/^(\s*\d+)\.\s/, "$1\\. ")
440
+ if (this.options.escapeExtraCharacters) str = str.replace(this.options.escapeExtraCharacters, "\\$&")
441
+ return str
442
+ }
443
+
444
+ /// @internal
445
+ quote(str: string) {
446
+ let wrap = str.indexOf('"') == -1 ? '""' : str.indexOf("'") == -1 ? "''" : "()"
447
+ return wrap[0] + str + wrap[1]
448
+ }
449
+
450
+ /// Repeat the given string `n` times.
451
+ repeat(str: string, n: number) {
452
+ let out = ""
453
+ for (let i = 0; i < n; i++) out += str
454
+ return out
455
+ }
456
+
457
+ /// Get the markdown string for a given opening or closing mark.
458
+ markString(mark: Mark, open: boolean, parent: Node, index: number) {
459
+ let info = this.getMark(mark.type.name)
460
+ let value = open ? info.open : info.close
461
+ return typeof value == "string" ? value : value(this, mark, parent, index)
462
+ }
463
+
464
+ /// Get leading and trailing whitespace from a string. Values of
465
+ /// leading or trailing property of the return object will be undefined
466
+ /// if there is no match.
467
+ getEnclosingWhitespace(text: string): {leading?: string, trailing?: string} {
468
+ return {
469
+ leading: (text.match(/^(\s+)/) || [undefined])[0],
470
+ trailing: (text.match(/(\s+)$/) || [undefined])[0]
471
+ }
472
+ }
473
+
474
+ /// @internal
475
+ isMarkAhead(parent: Node, index: number, mark: Mark) {
476
+ for (;; index++) {
477
+ if (index >= parent.childCount) return false
478
+ let next = parent.child(index)
479
+ if (next.type.name != this.options.hardBreakNodeName) return mark.isInSet(next.marks)
480
+ index++
481
+ }
482
+ }
483
+ }