@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.
- package/lib/__tests__/markdown.test.js +1044 -0
- package/lib/__tests__/markdown.test.js.map +7 -0
- package/lib/compare.js +100 -0
- package/lib/compare.js.map +7 -0
- package/lib/index.js +47 -0
- package/lib/index.js.map +7 -0
- package/lib/marks.js +59 -0
- package/lib/marks.js.map +7 -0
- package/lib/node.js +34 -0
- package/lib/node.js.map +7 -0
- package/lib/parser.js +724 -0
- package/lib/parser.js.map +7 -0
- package/lib/serializer.js +614 -0
- package/lib/serializer.js.map +7 -0
- package/package.json +59 -0
- package/src/__tests__/markdown.test.ts +1076 -0
- package/src/compare.ts +119 -0
- package/src/index.ts +47 -0
- package/src/marks.ts +46 -0
- package/src/node.ts +24 -0
- package/src/parser.ts +853 -0
- package/src/serializer.ts +833 -0
- package/tsconfig.json +12 -0
- package/types/__tests__/markdown.test.d.ts +9 -0
- package/types/__tests__/markdown.test.d.ts.map +1 -0
- package/types/compare.d.ts +10 -0
- package/types/compare.d.ts.map +1 -0
- package/types/index.d.ts +14 -0
- package/types/index.d.ts.map +1 -0
- package/types/marks.d.ts +8 -0
- package/types/marks.d.ts.map +1 -0
- package/types/node.d.ts +4 -0
- package/types/node.d.ts.map +1 -0
- package/types/parser.d.ts +50 -0
- package/types/parser.d.ts.map +1 -0
- package/types/serializer.d.ts +102 -0
- package/types/serializer.d.ts.map +1 -0
|
@@ -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
|
+
' : '') +
|
|
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
|
+
' : '') +
|
|
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
|
+
' +
|
|
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, '/'))
|
|
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, '&')
|
|
792
|
+
.replace(/</g, '<')
|
|
793
|
+
.replace(/>/g, '>')
|
|
794
|
+
.replace(/"/g, '"')
|
|
795
|
+
.replace(/'/g, ''')
|
|
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
|
+
}
|