@hcengineering/text-markdown 0.7.1

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,833 @@
1
+ //
2
+ // Copyright © 2025 Hardcore Engineering Inc.
3
+ //
4
+ // Licensed under the Eclipse Public License, Version 2.0 (the "License");
5
+ // you may not use this file except in compliance with the License. You may
6
+ // obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
7
+ //
8
+ // Unless required by applicable law or agreed to in writing, software
9
+ // distributed under the License is distributed on an "AS IS" BASIS,
10
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11
+ //
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+ //
15
+
16
+ import { MarkupMark, MarkupNode, MarkupNodeType } from '@hcengineering/text-core'
17
+ import { markupToHtml } from '@hcengineering/text-html'
18
+
19
+ import { isInSet, markEq } from './marks'
20
+ import { nodeContent, nodeAttrs } from './node'
21
+
22
+ type FirstDelim = (i: number, attrs?: Record<string, any>, parentAttrs?: Record<string, any>) => string
23
+ interface IState {
24
+ wrapBlock: (delim: string, firstDelim: string | null, node: MarkupNode, f: () => void) => void
25
+ flushClose: (size: number) => void
26
+ atBlank: () => void
27
+ ensureNewLine: () => void
28
+ write: (content: string) => void
29
+ closeBlock: (node: any) => void
30
+ text: (text: string, escape?: boolean) => void
31
+ render: (node: MarkupNode, parent: MarkupNode, index: number) => void
32
+ renderContent: (parent: MarkupNode) => void
33
+ renderInline: (parent: MarkupNode) => void
34
+ renderList: (node: MarkupNode, delim: string, firstDelim: FirstDelim) => void
35
+ esc: (str: string, startOfLine?: boolean) => string
36
+ htmlEsc: (str: string) => string
37
+ quote: (str: string) => string
38
+ repeat: (str: string, n: number) => string
39
+ markString: (mark: MarkupMark, open: boolean, parent: MarkupNode, index: number) => string
40
+ renderHtml: (node: MarkupNode) => string
41
+ refUrl: string
42
+ imageUrl: string
43
+ inAutolink?: boolean
44
+ renderAHref?: boolean
45
+ }
46
+
47
+ type NodeProcessor = (state: IState, node: MarkupNode, parent: MarkupNode, index: number) => void
48
+
49
+ interface InlineState {
50
+ active: MarkupMark[]
51
+ trailing: string
52
+ parent: MarkupNode
53
+ node?: MarkupNode
54
+ marks: MarkupMark[]
55
+ }
56
+
57
+ // *************************************************************
58
+
59
+ function backticksFor (side: boolean): string {
60
+ return side ? '`' : '`'
61
+ }
62
+
63
+ function isPlainURL (link: MarkupMark, parent: MarkupNode, index: number): boolean {
64
+ if (link.attrs?.title !== undefined || !/^\w+:/.test(link.attrs?.href)) return false
65
+ const content = parent.content?.[index]
66
+ if (content === undefined) {
67
+ return false
68
+ }
69
+ if (
70
+ content.type !== MarkupNodeType.text ||
71
+ content.text !== link.attrs?.href ||
72
+ content.marks?.[content.marks.length - 1] !== link
73
+ ) {
74
+ return false
75
+ }
76
+ return index === (parent.content?.length ?? 0) - 1 || !isInSet(link, parent.content?.[index + 1]?.marks ?? [])
77
+ }
78
+
79
+ const formatTodoItem: FirstDelim = (i, attrs, parentAttrs?: Record<string, any>) => {
80
+ const meta =
81
+ attrs?.todoid !== undefined && attrs?.userid !== undefined
82
+ ? `<!-- todoid=${attrs?.todoid},userid=${attrs?.userid} -->`
83
+ : ''
84
+
85
+ const bullet = parentAttrs?.bullet ?? '*'
86
+ return `${bullet} [${attrs?.checked === true ? 'x' : ' '}] ${meta}`
87
+ }
88
+
89
+ // *************************************************************
90
+
91
+ export const storeNodes: Record<string, NodeProcessor> = {
92
+ blockquote: (state, node) => {
93
+ state.wrapBlock('> ', null, node, () => {
94
+ state.renderContent(node)
95
+ })
96
+ },
97
+ codeBlock: (state, node) => {
98
+ state.write('```' + `${nodeAttrs(node).language ?? ''}` + '\n')
99
+ // TODO: Check for node.textContent
100
+ state.renderInline(node)
101
+ // state.text(node.text ?? '', false)
102
+ state.ensureNewLine()
103
+ state.write('```')
104
+ state.closeBlock(node)
105
+ },
106
+ mermaid: (state, node) => {
107
+ state.write('```mermaid\n')
108
+ state.renderInline(node)
109
+ state.ensureNewLine()
110
+ state.write('```')
111
+ state.closeBlock(node)
112
+ },
113
+ heading: (state, node) => {
114
+ const attrs = nodeAttrs(node)
115
+ if (attrs.marker === '=' && attrs.level === 1) {
116
+ state.renderInline(node)
117
+ state.ensureNewLine()
118
+ state.write('===\n')
119
+ } else if (attrs.marker === '-' && attrs.level === 2) {
120
+ state.renderInline(node)
121
+ state.ensureNewLine()
122
+ state.write('---\n')
123
+ } else {
124
+ state.write(state.repeat('#', attrs.level !== undefined ? Number(attrs.level) : 1) + ' ')
125
+ state.renderInline(node)
126
+ }
127
+ state.closeBlock(node)
128
+ },
129
+ horizontalRule: (state, node) => {
130
+ state.write(`${nodeAttrs(node).markup ?? '---'}`)
131
+ state.closeBlock(node)
132
+ },
133
+ bulletList: (state, node) => {
134
+ state.renderList(node, ' ', () => `${nodeAttrs(node).bullet ?? '*'}` + ' ')
135
+ },
136
+ taskList: (state, node) => {
137
+ state.renderList(node, ' ', () => '* [ ]' + ' ')
138
+ },
139
+ todoList: (state, node) => {
140
+ state.renderList(node, ' ', formatTodoItem)
141
+ },
142
+ orderedList: (state, node) => {
143
+ let start = 1
144
+ if (nodeAttrs(node).order !== undefined) {
145
+ start = Number(nodeAttrs(node).order)
146
+ }
147
+ const maxW = String(start + nodeContent(node).length - 1).length
148
+ const space = state.repeat(' ', maxW + 2)
149
+ state.renderList(node, space, (i: number) => {
150
+ const nStr = String(start + i)
151
+ return state.repeat(' ', maxW - nStr.length) + nStr + '. '
152
+ })
153
+ },
154
+ listItem: (state, node) => {
155
+ state.renderContent(node)
156
+ },
157
+ taskItem: (state, node) => {
158
+ state.renderContent(node)
159
+ },
160
+ todoItem: (state, node) => {
161
+ state.renderContent(node)
162
+ },
163
+ paragraph: (state, node) => {
164
+ state.renderInline(node)
165
+ state.closeBlock(node)
166
+ },
167
+ subLink: (state, node) => {
168
+ state.write('<sub>')
169
+ state.renderAHref = true
170
+ state.renderInline(node)
171
+ state.renderAHref = false
172
+ state.write('</sub>')
173
+ },
174
+
175
+ image: (state, node) => {
176
+ const attrs = nodeAttrs(node)
177
+ if (attrs.token != null && attrs['file-id'] != null) {
178
+ // Convert image to token format
179
+ state.write(
180
+ '![' +
181
+ state.esc(`${attrs.alt ?? ''}`) +
182
+ '](' +
183
+ (state.imageUrl +
184
+ `${attrs['file-id']}` +
185
+ `?file=${attrs['file-id']}` +
186
+ (attrs.width != null ? '&width=' + state.esc(`${attrs.width}`) : '') +
187
+ (attrs.height != null ? '&height=' + state.esc(`${attrs.height}`) : '') +
188
+ (attrs.token != null ? '&token=' + state.esc(`${attrs.token}`) : '')) +
189
+ (attrs.title != null ? ' ' + state.quote(`${attrs.title}`) : '') +
190
+ ')'
191
+ )
192
+ } else if (attrs['file-id'] != null) {
193
+ // Convert image to fileid format
194
+ state.write(
195
+ '![' +
196
+ state.esc(`${attrs.alt ?? ''}`) +
197
+ '](' +
198
+ (state.imageUrl +
199
+ `${attrs['file-id']}` +
200
+ (attrs.width != null ? '&width=' + state.esc(`${attrs.width}`) : '') +
201
+ (attrs.height != null ? '&height=' + state.esc(`${attrs.height}`) : '')) +
202
+ (attrs.title != null ? ' ' + state.quote(`${attrs.title}`) : '') +
203
+ ')'
204
+ )
205
+ } else {
206
+ if (attrs.width != null || attrs.height != null) {
207
+ state.write(
208
+ '<img' +
209
+ (attrs.width != null ? ` width="${state.esc(`${attrs.width}`)}"` : '') +
210
+ (attrs.height != null ? ` height="${state.esc(`${attrs.height}`)}"` : '') +
211
+ ` src="${state.esc(`${attrs.src}`)}"` +
212
+ (attrs.alt != null ? ` alt="${state.esc(`${attrs.alt}`)}"` : '') +
213
+ (attrs.title != null ? '>' + state.quote(`${attrs.title}`) + '</img>' : '>')
214
+ )
215
+ } else {
216
+ state.write(
217
+ '![' +
218
+ state.esc(`${attrs.alt ?? ''}`) +
219
+ '](' +
220
+ state.esc(`${attrs.src}`) +
221
+ (attrs.title != null ? ' ' + state.quote(`${attrs.title}`) : '') +
222
+ ')'
223
+ )
224
+ }
225
+ }
226
+ },
227
+ reference: (state, node) => {
228
+ const attrs = nodeAttrs(node)
229
+ let url = state.refUrl
230
+ if (!url.includes('?')) {
231
+ url += '?'
232
+ } else {
233
+ url += '&'
234
+ }
235
+ state.write(
236
+ '[' +
237
+ state.esc(`${attrs.label ?? ''}`) +
238
+ '](' +
239
+ `${url}${makeQuery({
240
+ _class: attrs.objectclass,
241
+ _id: attrs.id,
242
+ label: attrs.label
243
+ })}` +
244
+ (attrs.title !== undefined ? ' ' + state.quote(`${attrs.title}`) : '') +
245
+ ')'
246
+ )
247
+ },
248
+ markdown: (state, node) => {
249
+ state.renderInline(node)
250
+ state.closeBlock(node)
251
+ },
252
+ comment: (state, node) => {
253
+ state.write('<!--')
254
+ state.renderInline(node)
255
+ state.write('-->')
256
+ },
257
+ hardBreak: (state, node, parent, index) => {
258
+ const content = nodeContent(parent)
259
+ for (let i = index + 1; i < content.length; i++) {
260
+ if (content[i].type !== node.type) {
261
+ state.write('\\\n')
262
+ return
263
+ }
264
+ }
265
+ },
266
+ text: (state, node) => {
267
+ // Check if test has reference mark, in this case we need to remove [[]]
268
+ state.text(node.text ?? '')
269
+ },
270
+ emoji: (state, node) => {
271
+ state.text(node.attrs?.emoji as string)
272
+ },
273
+ table: (state, node) => {
274
+ state.write(state.renderHtml(node))
275
+ state.closeBlock(node)
276
+ },
277
+ embed: (state, node) => {
278
+ const attrs = nodeAttrs(node)
279
+ const embedUrl = attrs.src as string
280
+ state.write(`<a href="${encodeURI(embedUrl)}" data-type="embed">`)
281
+ // Slashes are escaped to prevent autolink creation
282
+ state.write(state.htmlEsc(embedUrl).replace(/\//g, '&#x2F;'))
283
+ state.write('</a>')
284
+ }
285
+ }
286
+
287
+ interface MarkProcessor {
288
+ open: ((_state: IState, mark: MarkupMark, parent: MarkupNode, index: number) => string) | string
289
+ close: ((_state: IState, mark: MarkupMark, parent: MarkupNode, index: number) => string) | string
290
+ mixable: boolean
291
+ expelEnclosingWhitespace: boolean
292
+ escape: boolean
293
+ }
294
+
295
+ export const storeMarks: Record<string, MarkProcessor> = {
296
+ em: {
297
+ open: '*',
298
+ close: '*',
299
+ mixable: true,
300
+ expelEnclosingWhitespace: true,
301
+ escape: true
302
+ },
303
+ italic: {
304
+ open: '*',
305
+ close: '*',
306
+ mixable: true,
307
+ expelEnclosingWhitespace: true,
308
+ escape: true
309
+ },
310
+ bold: {
311
+ open: '**',
312
+ close: '**',
313
+ mixable: true,
314
+ expelEnclosingWhitespace: true,
315
+ escape: true
316
+ },
317
+ strong: {
318
+ open: '**',
319
+ close: '**',
320
+ mixable: true,
321
+ expelEnclosingWhitespace: true,
322
+ escape: true
323
+ },
324
+ strike: {
325
+ open: '~~',
326
+ close: '~~',
327
+ mixable: true,
328
+ expelEnclosingWhitespace: true,
329
+ escape: true
330
+ },
331
+ underline: {
332
+ open: '<ins>',
333
+ close: '</ins>',
334
+ mixable: true,
335
+ expelEnclosingWhitespace: true,
336
+ escape: true
337
+ },
338
+ link: {
339
+ open: (state, mark, parent, index) => {
340
+ if (state.renderAHref === true) {
341
+ return `<a href="${encodeURI(mark.attrs?.href)}">`
342
+ } else {
343
+ state.inAutolink = isPlainURL(mark, parent, index)
344
+ return state.inAutolink ? '<' : '['
345
+ }
346
+ },
347
+ close: (state, mark, parent, index) => {
348
+ if (state.renderAHref === true) {
349
+ return '</a>'
350
+ } else {
351
+ const { inAutolink } = state
352
+ state.inAutolink = undefined
353
+
354
+ const href = (mark.attrs?.href as string) ?? ''
355
+ // eslint-disable-next-line
356
+ const url = href.replace(/[\(\)"\\<>]/g, '\\$&')
357
+ const hasSpaces = url.includes(' ')
358
+
359
+ return inAutolink === true
360
+ ? '>'
361
+ : '](' +
362
+ (hasSpaces ? `<${url}>` : url) +
363
+ (mark.attrs?.title !== undefined ? ` "${(mark.attrs?.title as string).replace(/"/g, '\\"')}"` : '') +
364
+ ')'
365
+ }
366
+ },
367
+ mixable: false,
368
+ expelEnclosingWhitespace: false,
369
+ escape: true
370
+ },
371
+ code: {
372
+ open: (state, mark, parent, index) => {
373
+ return backticksFor(false)
374
+ },
375
+ close: (state, mark, parent, index) => {
376
+ return backticksFor(true)
377
+ },
378
+ mixable: false,
379
+ expelEnclosingWhitespace: false,
380
+ escape: false
381
+ }
382
+ }
383
+
384
+ export type HtmlWriter = (markup: MarkupNode) => string
385
+
386
+ export interface StateOptions {
387
+ tightLists: boolean
388
+ refUrl: string
389
+ imageUrl: string
390
+ htmlWriter?: HtmlWriter
391
+ }
392
+ export class MarkdownState implements IState {
393
+ nodes: Record<string, NodeProcessor>
394
+ marks: Record<string, MarkProcessor>
395
+ delim: string
396
+ out: string
397
+ closed: boolean
398
+ closedNode?: MarkupNode
399
+ inTightList: boolean
400
+ options: StateOptions
401
+ refUrl: string
402
+ imageUrl: string
403
+ htmlWriter: HtmlWriter
404
+
405
+ constructor (
406
+ nodes = storeNodes,
407
+ marks = storeMarks,
408
+ options: StateOptions = { tightLists: true, refUrl: 'ref://', imageUrl: 'http://' }
409
+ ) {
410
+ this.nodes = nodes
411
+ this.marks = marks
412
+ this.delim = this.out = ''
413
+ this.closed = false
414
+ this.inTightList = false
415
+ this.refUrl = options.refUrl
416
+ this.imageUrl = options.imageUrl
417
+ this.htmlWriter = options.htmlWriter ?? markupToHtml
418
+
419
+ this.options = options
420
+ }
421
+
422
+ flushClose (size: number): void {
423
+ if (this.closed) {
424
+ if (!this.atBlank()) this.out += '\n'
425
+ if (size > 1) {
426
+ this.addDelim(size)
427
+ }
428
+ this.closed = false
429
+ }
430
+ }
431
+
432
+ private addDelim (size: number): void {
433
+ let delimMin = this.delim
434
+ const trim = /\s+$/.exec(delimMin)
435
+ if (trim !== null) {
436
+ delimMin = delimMin.slice(0, delimMin.length - trim[0].length)
437
+ }
438
+ for (let i = 1; i < size; i++) {
439
+ this.out += delimMin + '\n'
440
+ }
441
+ }
442
+
443
+ renderHtml (node: MarkupNode): string {
444
+ return this.htmlWriter(node)
445
+ }
446
+
447
+ wrapBlock (delim: string, firstDelim: string | null, node: MarkupNode, f: () => void): void {
448
+ const old = this.delim
449
+ this.write(firstDelim ?? delim)
450
+ this.delim += delim
451
+ f()
452
+ this.delim = old
453
+ this.closeBlock(node)
454
+ }
455
+
456
+ atBlank (): boolean {
457
+ return /(^|\n)$/.test(this.out)
458
+ }
459
+
460
+ // :: ()
461
+ // Ensure the current content ends with a newline.
462
+ ensureNewLine (): void {
463
+ if (!this.atBlank()) this.out += '\n'
464
+ }
465
+
466
+ // :: (?string)
467
+ // Prepare the state for writing output (closing closed paragraphs,
468
+ // adding delimiters, and so on), and then optionally add content
469
+ // (unescaped) to the output.
470
+ write (content: string): void {
471
+ this.flushClose(2)
472
+ if (this.delim !== undefined && this.atBlank()) this.out += this.delim
473
+ if (content.length > 0) this.out += content
474
+ }
475
+
476
+ // :: (Node)
477
+ // Close the block for the given node.
478
+ closeBlock (node: MarkupNode): void {
479
+ this.closedNode = node
480
+ this.closed = true
481
+ }
482
+
483
+ // :: (string, ?bool)
484
+ // Add the given text to the document. When escape is not `false`,
485
+ // it will be escaped.
486
+ text (text: string, escape = false): void {
487
+ const lines = text.split('\n')
488
+ for (let i = 0; i < lines.length; i++) {
489
+ const startOfLine = this.atBlank() || this.closed
490
+ this.write('')
491
+ this.out += escape ? this.esc(lines[i], startOfLine) : lines[i]
492
+ if (i !== lines.length - 1) this.out += '\n'
493
+ }
494
+ }
495
+
496
+ // :: (Node)
497
+ // Render the given node as a block.
498
+ render (node: MarkupNode, parent: MarkupNode, index: number): void {
499
+ if (this.nodes[node.type] === undefined) {
500
+ throw new Error('Token type `' + node.type + '` not supported by Markdown renderer')
501
+ }
502
+ this.nodes[node.type](this, node, parent, index)
503
+ }
504
+
505
+ // :: (Node)
506
+ // Render the contents of `parent` as block nodes.
507
+ renderContent (parent: MarkupNode): void {
508
+ nodeContent(parent).forEach((node: MarkupNode, i: number) => {
509
+ this.render(node, parent, i)
510
+ })
511
+ }
512
+
513
+ reorderMixableMark (state: InlineState, mark: MarkupMark, i: number, len: number): void {
514
+ for (let j = 0; j < state.active.length; j++) {
515
+ const other = state.active[j]
516
+ if (!this.marks[other.type].mixable || this.checkSwitchMarks(i, j, state, mark, other, len)) {
517
+ break
518
+ }
519
+ }
520
+ }
521
+
522
+ reorderMixableMarks (state: InlineState, len: number): void {
523
+ // Try to reorder 'mixable' marks, such as em and strong, which
524
+ // in Markdown may be opened and closed in different order, so
525
+ // that order of the marks for the token matches the order in
526
+ // active.
527
+
528
+ for (let i = 0; i < len; i++) {
529
+ const mark = state.marks[i]
530
+ const mm = this.marks[mark.type]
531
+ if (mm == null) {
532
+ break
533
+ }
534
+ if (!mm.mixable) break
535
+ this.reorderMixableMark(state, mark, i, len)
536
+ }
537
+ }
538
+
539
+ private checkSwitchMarks (
540
+ i: number,
541
+ j: number,
542
+ state: InlineState,
543
+ mark: MarkupMark,
544
+ other: MarkupMark,
545
+ len: number
546
+ ): boolean {
547
+ if (!markEq(mark, other) || i === j) {
548
+ return false
549
+ }
550
+ this.switchMarks(i, j, state, mark, len)
551
+ return true
552
+ }
553
+
554
+ private switchMarks (i: number, j: number, state: InlineState, mark: MarkupMark, len: number): void {
555
+ if (i > j) {
556
+ state.marks = state.marks
557
+ .slice(0, j)
558
+ .concat(mark)
559
+ .concat(state.marks.slice(j, i))
560
+ .concat(state.marks.slice(i + 1, len))
561
+ }
562
+ if (j > i) {
563
+ state.marks = state.marks
564
+ .slice(0, i)
565
+ .concat(state.marks.slice(i + 1, j))
566
+ .concat(mark)
567
+ .concat(state.marks.slice(j, len))
568
+ }
569
+ }
570
+
571
+ renderNodeInline (state: InlineState, index: number): void {
572
+ state.marks = state.node?.marks ?? []
573
+ this.updateHardBreakMarks(state, index)
574
+
575
+ const leading = this.adjustLeading(state)
576
+
577
+ const inner: MarkupMark | undefined = state.marks.length > 0 ? state.marks[state.marks.length - 1] : undefined
578
+ const noEsc = inner !== undefined && !(this.marks[inner.type]?.escape ?? false)
579
+ const len = state.marks.length - (noEsc ? 1 : 0)
580
+
581
+ this.reorderMixableMarks(state, len)
582
+
583
+ // Find the prefix of the mark set that didn't change
584
+ this.checkCloseMarks(state, len, index)
585
+
586
+ // Output any previously expelled trailing whitespace outside the marks
587
+ if (leading !== '') this.text(leading)
588
+
589
+ // Open the marks that need to be opened
590
+ this.checkOpenMarks(state, len, index, inner, noEsc)
591
+ }
592
+
593
+ private checkOpenMarks (
594
+ state: InlineState,
595
+ len: number,
596
+ index: number,
597
+ inner: MarkupMark | undefined,
598
+ noEsc: boolean
599
+ ): void {
600
+ if (state.node !== undefined) {
601
+ this.updateActiveMarks(state, len, index)
602
+
603
+ // Render the node. Special case code marks, since their content
604
+ // may not be escaped.
605
+ if (this.isNoEscapeRequire(state.node, inner, noEsc, state)) {
606
+ this.renderMarkText(inner as MarkupMark, state, index)
607
+ } else {
608
+ this.render(state.node, state.parent, index)
609
+ }
610
+ }
611
+ }
612
+
613
+ private isNoEscapeRequire (
614
+ node: MarkupNode,
615
+ inner: MarkupMark | undefined,
616
+ noEsc: boolean,
617
+ state: InlineState
618
+ ): boolean {
619
+ return inner !== undefined && noEsc && node.type === MarkupNodeType.text
620
+ }
621
+
622
+ private renderMarkText (inner: MarkupMark, state: InlineState, index: number): void {
623
+ this.text(
624
+ this.markString(inner, true, state.parent, index) +
625
+ (state.node?.text as string) +
626
+ this.markString(inner, false, state.parent, index + 1),
627
+ false
628
+ )
629
+ }
630
+
631
+ private updateActiveMarks (state: InlineState, len: number, index: number): void {
632
+ while (state.active.length < len) {
633
+ const add = state.marks[state.active.length]
634
+ state.active.push(add)
635
+ this.text(this.markString(add, true, state.parent, index), false)
636
+ }
637
+ }
638
+
639
+ private checkCloseMarks (state: InlineState, len: number, index: number): void {
640
+ let keep = 0
641
+ while (keep < Math.min(state.active.length, len) && markEq(state.marks[keep], state.active[keep])) {
642
+ ++keep
643
+ }
644
+
645
+ // Close the marks that need to be closed
646
+ while (keep < state.active.length) {
647
+ const mark = state.active.pop()
648
+ if (mark !== undefined) {
649
+ this.text(this.markString(mark, false, state.parent, index), false)
650
+ }
651
+ }
652
+ }
653
+
654
+ private adjustLeading (state: InlineState): string {
655
+ let leading = state.trailing
656
+ state.trailing = ''
657
+ // If whitespace has to be expelled from the node, adjust
658
+ // leading and trailing accordingly.
659
+ const node = state?.node
660
+ if (this.isText(node) && this.isMarksHasExpelEnclosingWhitespace(state)) {
661
+ const match = /^(\s*)(.*?)(\s*)$/m.exec(node?.text ?? '')
662
+ if (match !== null) {
663
+ const [leadMatch, innerMatch, trailMatch] = [match[1], match[2], match[3]]
664
+ leading += leadMatch
665
+ state.trailing = trailMatch
666
+ this.adjustLeadingTextNode(leadMatch, trailMatch, state, innerMatch, node as MarkupNode)
667
+ }
668
+ }
669
+ return leading
670
+ }
671
+
672
+ private isMarksHasExpelEnclosingWhitespace (state: InlineState): boolean {
673
+ return state.marks.some((mark) => this.marks[mark.type]?.expelEnclosingWhitespace)
674
+ }
675
+
676
+ private adjustLeadingTextNode (
677
+ lead: string,
678
+ trail: string,
679
+ state: InlineState,
680
+ inner: string,
681
+ node: MarkupNode
682
+ ): void {
683
+ if (lead !== '' || trail !== '') {
684
+ state.node = inner !== undefined ? { ...node, text: inner } : undefined
685
+ if (state.node === undefined) {
686
+ state.marks = state.active
687
+ }
688
+ }
689
+ }
690
+
691
+ private updateHardBreakMarks (state: InlineState, index: number): void {
692
+ if (state.node !== undefined && state.node.type === MarkupNodeType.hard_break) {
693
+ state.marks = this.filterHardBreakMarks(state.marks, index, state)
694
+ }
695
+ }
696
+
697
+ private filterHardBreakMarks (marks: MarkupMark[], index: number, state: InlineState): MarkupMark[] {
698
+ const content = state.parent.content ?? []
699
+ const next = content[index + 1]
700
+ if (!this.isHardbreakText(next)) {
701
+ return []
702
+ }
703
+ return marks.filter((m) => isInSet(m, next.marks ?? []))
704
+ }
705
+
706
+ private isHardbreakText (next?: MarkupNode): boolean {
707
+ return (
708
+ next !== undefined && (next.type !== MarkupNodeType.text || (next.text !== undefined && /\S/.test(next.text)))
709
+ )
710
+ }
711
+
712
+ private isText (node?: MarkupNode): boolean {
713
+ return node !== undefined && node.type === MarkupNodeType.text && node.text !== undefined
714
+ }
715
+
716
+ // :: (Node)
717
+ // Render the contents of `parent` as inline content.
718
+ renderInline (parent: MarkupNode): void {
719
+ const state: InlineState = { active: [], trailing: '', parent, marks: [] }
720
+ nodeContent(parent).forEach((nde, index) => {
721
+ state.node = nde
722
+ this.renderNodeInline(state, index)
723
+ })
724
+ state.node = undefined
725
+ this.renderNodeInline(state, 0)
726
+ }
727
+
728
+ // :: (Node, string, (number) → string)
729
+ // Render a node's content as a list. `delim` should be the extra
730
+ // indentation added to all lines except the first in an item,
731
+ // `firstDelim` is a function going from an item index to a
732
+ // delimiter for the first line of the item.
733
+ renderList (node: MarkupNode, delim: string, firstDelim: FirstDelim): void {
734
+ this.flushListClose(node)
735
+
736
+ const isTight: boolean =
737
+ typeof node.attrs?.tight !== 'undefined' ? node.attrs.tight === 'true' : this.options.tightLists
738
+ const prevTight = this.inTightList
739
+ this.inTightList = isTight
740
+
741
+ nodeContent(node).forEach((child, i) => {
742
+ this.renderListItem(node, child, i, isTight, delim, firstDelim)
743
+ })
744
+ this.inTightList = prevTight
745
+ }
746
+
747
+ renderListItem (
748
+ node: MarkupNode,
749
+ child: MarkupNode,
750
+ i: number,
751
+ isTight: boolean,
752
+ delim: string,
753
+ firstDelim: FirstDelim
754
+ ): void {
755
+ if (i > 0 && isTight) this.flushClose(1)
756
+ this.wrapBlock(delim, firstDelim(i, node.content?.[i].attrs, node.attrs), node, () => {
757
+ this.render(child, node, i)
758
+ })
759
+ }
760
+
761
+ private flushListClose (node: MarkupNode): void {
762
+ if (this.closed && this.closedNode?.type === node.type) {
763
+ this.flushClose(3)
764
+ } else if (this.inTightList) {
765
+ this.flushClose(1)
766
+ }
767
+ }
768
+
769
+ // :: (string, ?bool) → string
770
+ // Escape the given string so that it can safely appear in Markdown
771
+ // content. If `startOfLine` is true, also escape characters that
772
+ // has special meaning only at the start of the line.
773
+ esc (str: string, startOfLine = false): string {
774
+ if (str == null) {
775
+ return ''
776
+ }
777
+ str = str.replace(/[`*\\~\[\]]/g, '\\$&') // eslint-disable-line
778
+ if (startOfLine) {
779
+ str = str.replace(/^[:#\-*+]/, '\\$&').replace(/^(\d+)\./, '$1\\.')
780
+ }
781
+ str = str.replace(/\r?\n/g, '\\\n')
782
+ return str
783
+ }
784
+
785
+ htmlEsc (str: string): string {
786
+ if (str == null) {
787
+ return ''
788
+ }
789
+
790
+ return str
791
+ .replace(/&/g, '&amp;')
792
+ .replace(/</g, '&lt;')
793
+ .replace(/>/g, '&gt;')
794
+ .replace(/"/g, '&quot;')
795
+ .replace(/'/g, '&#039;')
796
+ }
797
+
798
+ quote (str: string): string {
799
+ const wrap = !(str?.includes('"') ?? false) ? '""' : !(str?.includes("'") ?? false) ? "''" : '()'
800
+ return wrap[0] + str + wrap[1]
801
+ }
802
+
803
+ // :: (string, number) → string
804
+ // Repeat the given string `n` times.
805
+ repeat (str: string, n: number): string {
806
+ let out = ''
807
+ for (let i = 0; i < n; i++) out += str
808
+ return out
809
+ }
810
+
811
+ // : (Mark, bool, string?) → string
812
+ // Get the markdown string for a given opening or closing mark.
813
+ markString (mark: MarkupMark, open: boolean, parent: MarkupNode, index: number): string {
814
+ let value = mark.attrs?.marker
815
+ if (value === undefined) {
816
+ const info = this.marks[mark.type]
817
+ if (info == null) {
818
+ throw new Error(`No info for mark ${mark.type}`)
819
+ }
820
+ value = open ? info.open : info.close
821
+ }
822
+ return typeof value === 'string' ? value : (value(this, mark, parent, index) ?? '')
823
+ }
824
+ }
825
+
826
+ function makeQuery (obj: Record<string, string | number | boolean | null | undefined>): string {
827
+ return Object.keys(obj)
828
+ .filter((it) => it[1] != null)
829
+ .map(function (k) {
830
+ return encodeURIComponent(k) + '=' + encodeURIComponent(obj[k] as string | number | boolean)
831
+ })
832
+ .join('&')
833
+ }