@meith/markdown 0.16.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 +165 -0
- package/package.json +21 -0
- package/src/attribution.ts +77 -0
- package/src/bbcode.ts +243 -0
- package/src/blocks.ts +459 -0
- package/src/body.ts +88 -0
- package/src/budget.ts +23 -0
- package/src/escape-source.ts +11 -0
- package/src/escape.ts +27 -0
- package/src/extensions.ts +96 -0
- package/src/features.ts +47 -0
- package/src/index.ts +80 -0
- package/src/inline.ts +525 -0
- package/src/limits.ts +15 -0
- package/src/mentions.ts +66 -0
- package/src/nodes.ts +71 -0
- package/src/pipeline.ts +47 -0
- package/src/plain.ts +78 -0
- package/src/quote.ts +47 -0
- package/src/render.ts +194 -0
- package/src/url.ts +31 -0
- package/src/vocabulary.ts +68 -0
- package/src/word-filter.ts +64 -0
package/src/blocks.ts
ADDED
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
import { NodeBudget } from './budget'
|
|
2
|
+
import { type DirectiveRegistry, NO_DIRECTIVES, RESERVED_DIRECTIVE_NAMES } from './extensions'
|
|
3
|
+
import { FULL_FEATURES, type MarkdownFeatures } from './features'
|
|
4
|
+
import { type InlineContext, parseInline } from './inline'
|
|
5
|
+
import { DEFAULT_LIMITS, type MarkdownLimits } from './limits'
|
|
6
|
+
import type { Alignment, Block, ListItem, MarkdownDocument, TableCell } from './nodes'
|
|
7
|
+
|
|
8
|
+
export interface ParseOptions {
|
|
9
|
+
readonly limits?: Partial<MarkdownLimits>
|
|
10
|
+
readonly features?: MarkdownFeatures
|
|
11
|
+
readonly directives?: DirectiveRegistry
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const ATX = /^ {0,3}(#{1,6})(?:[ \t]+(.*?))?[ \t]*$/
|
|
15
|
+
const FENCE = /^( {0,3})(`{3,}|~{3,})[ \t]*([^`\n]*?)[ \t]*$/
|
|
16
|
+
const RULE = /^ {0,3}(?:(?:\*[ \t]*){3,}|(?:-[ \t]*){3,}|(?:_[ \t]*){3,})$/
|
|
17
|
+
const QUOTE = /^ {0,3}>[ ]?/
|
|
18
|
+
const BULLET = /^( {0,3})([-+*])([ \t]+|$)/
|
|
19
|
+
const ORDERED = /^( {0,3})(\d{1,9})([.)])([ \t]+|$)/
|
|
20
|
+
const SETEXT = /^ {0,3}(=+|-+)[ \t]*$/
|
|
21
|
+
const DIRECTIVE_OPEN = /^ {0,3}:::[ \t]*([a-z][a-z0-9]{0,15})[ \t]*$/
|
|
22
|
+
const DIRECTIVE_CLOSE = /^ {0,3}:::[ \t]*$/
|
|
23
|
+
const TASK = /^\[([ xX])\][ \t]+/
|
|
24
|
+
const TABLE_DELIMITER = /^ {0,3}\|?[ \t]*:?-+:?[ \t]*(\|[ \t]*:?-+:?[ \t]*)*\|?[ \t]*$/
|
|
25
|
+
|
|
26
|
+
function closesFence(line: string, marker: string): boolean {
|
|
27
|
+
const trimmed = line.trim()
|
|
28
|
+
if (trimmed.length < marker.length) return false
|
|
29
|
+
if (indentOf(line) > 3) return false
|
|
30
|
+
return trimmed.split('').every((character) => character === marker[0])
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function stripIndent(line: string, count: number): string {
|
|
34
|
+
let removed = 0
|
|
35
|
+
while (removed < count && line[removed] === ' ') removed += 1
|
|
36
|
+
return line.slice(removed)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isBlank(line: string | undefined): boolean {
|
|
40
|
+
return line === undefined || line.trim() === ''
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function indentOf(line: string): number {
|
|
44
|
+
let count = 0
|
|
45
|
+
while (line[count] === ' ') count += 1
|
|
46
|
+
return count
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isBlockDirective(name: string, directives: DirectiveRegistry): boolean {
|
|
50
|
+
return RESERVED_DIRECTIVE_NAMES.has(name) || directives.block.has(name)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface Context {
|
|
54
|
+
readonly features: MarkdownFeatures
|
|
55
|
+
readonly limits: MarkdownLimits
|
|
56
|
+
readonly directives: DirectiveRegistry
|
|
57
|
+
readonly budget: NodeBudget
|
|
58
|
+
readonly inline: InlineContext
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function startsBlock(line: string, context: Context): boolean {
|
|
62
|
+
const { features } = context
|
|
63
|
+
if (features.code && FENCE.test(line)) return true
|
|
64
|
+
if (features.rules && RULE.test(line)) return true
|
|
65
|
+
if (features.headings && ATX.test(line)) return true
|
|
66
|
+
if (features.quotes && QUOTE.test(line)) return true
|
|
67
|
+
if (features.lists && (BULLET.test(line) || ORDERED.test(line))) return true
|
|
68
|
+
if (features.directives) {
|
|
69
|
+
const directive = DIRECTIVE_OPEN.exec(line)
|
|
70
|
+
if (directive !== null && isBlockDirective(directive[1]!, context.directives)) return true
|
|
71
|
+
}
|
|
72
|
+
return false
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function splitRow(line: string): string[] {
|
|
76
|
+
const trimmed = line
|
|
77
|
+
.trim()
|
|
78
|
+
.replace(/^\|/, '')
|
|
79
|
+
.replace(/(?<!\\)\|$/, '')
|
|
80
|
+
const cells: string[] = []
|
|
81
|
+
let current = ''
|
|
82
|
+
for (let index = 0; index < trimmed.length; index += 1) {
|
|
83
|
+
const character = trimmed[index]!
|
|
84
|
+
if (character === '\\' && trimmed[index + 1] === '|') {
|
|
85
|
+
current += '|'
|
|
86
|
+
index += 1
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
if (character === '|') {
|
|
90
|
+
cells.push(current.trim())
|
|
91
|
+
current = ''
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
current += character
|
|
95
|
+
}
|
|
96
|
+
cells.push(current.trim())
|
|
97
|
+
return cells
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function alignmentOf(cell: string): Alignment {
|
|
101
|
+
const left = cell.startsWith(':')
|
|
102
|
+
const right = cell.endsWith(':')
|
|
103
|
+
if (left && right) return 'center'
|
|
104
|
+
if (right) return 'right'
|
|
105
|
+
if (left) return 'left'
|
|
106
|
+
return null
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function parse(source: string, options: ParseOptions = {}): MarkdownDocument {
|
|
110
|
+
const limits: MarkdownLimits = { ...DEFAULT_LIMITS, ...options.limits }
|
|
111
|
+
const features = options.features ?? FULL_FEATURES
|
|
112
|
+
const directives = options.directives ?? NO_DIRECTIVES
|
|
113
|
+
const budget = new NodeBudget(limits.maxNodes)
|
|
114
|
+
|
|
115
|
+
const context: Context = {
|
|
116
|
+
features,
|
|
117
|
+
limits,
|
|
118
|
+
directives,
|
|
119
|
+
budget,
|
|
120
|
+
inline: { features, limits, directives: directives.inline, budget },
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const limited = source.length > limits.maxInput
|
|
124
|
+
const body = limited ? source.slice(0, limits.maxInput) : source
|
|
125
|
+
|
|
126
|
+
const lines = body
|
|
127
|
+
.replace(/\r\n?/g, '\n')
|
|
128
|
+
.split('\n')
|
|
129
|
+
.map((line) => line.replace(/^[ \t]+/, (run) => run.replace(/\t/g, ' ')))
|
|
130
|
+
|
|
131
|
+
const blocks = parseBlocks(lines, context, 0)
|
|
132
|
+
|
|
133
|
+
if (limited) {
|
|
134
|
+
blocks.push({
|
|
135
|
+
kind: 'paragraph',
|
|
136
|
+
inline: [{ kind: 'text', value: source.slice(limits.maxInput) }],
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return { blocks, truncated: limited || budget.exhausted }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function parseBlocks(lines: readonly string[], context: Context, depth: number): Block[] {
|
|
144
|
+
const blocks: Block[] = []
|
|
145
|
+
let index = 0
|
|
146
|
+
|
|
147
|
+
const add = (block: Block): boolean => {
|
|
148
|
+
if (!context.budget.take()) return false
|
|
149
|
+
blocks.push(block)
|
|
150
|
+
return true
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const surrender = (from: number): void => {
|
|
154
|
+
context.budget.exhaust()
|
|
155
|
+
const rest = lines.slice(from).join('\n').trim()
|
|
156
|
+
if (rest === '') return
|
|
157
|
+
blocks.push({ kind: 'paragraph', inline: [{ kind: 'text', value: rest }] })
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
while (index < lines.length) {
|
|
161
|
+
if (context.budget.spent) {
|
|
162
|
+
surrender(index)
|
|
163
|
+
break
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const line = lines[index]!
|
|
167
|
+
|
|
168
|
+
if (isBlank(line)) {
|
|
169
|
+
index += 1
|
|
170
|
+
continue
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const fence = context.features.code ? FENCE.exec(line) : null
|
|
174
|
+
if (fence !== null) {
|
|
175
|
+
const marker = fence[2]!
|
|
176
|
+
const language = fence[3]!.split(/\s+/)[0] ?? ''
|
|
177
|
+
const indent = fence[1]!.length
|
|
178
|
+
let end = index + 1
|
|
179
|
+
while (end < lines.length && !closesFence(lines[end]!, marker)) end += 1
|
|
180
|
+
const value = lines
|
|
181
|
+
.slice(index + 1, end)
|
|
182
|
+
.map((candidate) => stripIndent(candidate, indent))
|
|
183
|
+
.join('\n')
|
|
184
|
+
if (!add({ kind: 'code', language: language === '' ? null : language, value })) {
|
|
185
|
+
surrender(index)
|
|
186
|
+
break
|
|
187
|
+
}
|
|
188
|
+
index = end + 1
|
|
189
|
+
continue
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (context.features.rules && RULE.test(line)) {
|
|
193
|
+
if (!add({ kind: 'rule' })) {
|
|
194
|
+
surrender(index)
|
|
195
|
+
break
|
|
196
|
+
}
|
|
197
|
+
index += 1
|
|
198
|
+
continue
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const atx = context.features.headings ? ATX.exec(line) : null
|
|
202
|
+
if (atx !== null) {
|
|
203
|
+
const level = atx[1]!.length as 1 | 2 | 3 | 4 | 5 | 6
|
|
204
|
+
const text = (atx[2] ?? '').replace(/[ \t]+#+[ \t]*$/, '')
|
|
205
|
+
if (!add({ kind: 'heading', level, inline: parseInline(text, context.inline) })) {
|
|
206
|
+
surrender(index)
|
|
207
|
+
break
|
|
208
|
+
}
|
|
209
|
+
index += 1
|
|
210
|
+
continue
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const directive = context.features.directives ? DIRECTIVE_OPEN.exec(line) : null
|
|
214
|
+
if (directive !== null && isBlockDirective(directive[1]!, context.directives)) {
|
|
215
|
+
if (depth >= context.limits.maxDepth) {
|
|
216
|
+
blocks.push({ kind: 'paragraph', inline: [{ kind: 'text', value: line.trim() }] })
|
|
217
|
+
index += 1
|
|
218
|
+
continue
|
|
219
|
+
}
|
|
220
|
+
let end = index + 1
|
|
221
|
+
while (end < lines.length && !DIRECTIVE_CLOSE.test(lines[end]!)) end += 1
|
|
222
|
+
const children = parseBlocks(lines.slice(index + 1, end), context, depth + 1)
|
|
223
|
+
if (!add({ kind: 'directive', name: directive[1]!, children })) {
|
|
224
|
+
surrender(index)
|
|
225
|
+
break
|
|
226
|
+
}
|
|
227
|
+
index = end + 1
|
|
228
|
+
continue
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (context.features.quotes && QUOTE.test(line)) {
|
|
232
|
+
if (depth >= context.limits.maxDepth) {
|
|
233
|
+
blocks.push({ kind: 'paragraph', inline: [{ kind: 'text', value: line }] })
|
|
234
|
+
index += 1
|
|
235
|
+
continue
|
|
236
|
+
}
|
|
237
|
+
const inner: string[] = []
|
|
238
|
+
let end = index
|
|
239
|
+
while (end < lines.length) {
|
|
240
|
+
const candidate = lines[end]!
|
|
241
|
+
if (QUOTE.test(candidate)) {
|
|
242
|
+
inner.push(candidate.replace(QUOTE, ''))
|
|
243
|
+
end += 1
|
|
244
|
+
continue
|
|
245
|
+
}
|
|
246
|
+
if (isBlank(candidate) || startsBlock(candidate, context)) break
|
|
247
|
+
if (isBlank(lines[end - 1])) break
|
|
248
|
+
inner.push(candidate)
|
|
249
|
+
end += 1
|
|
250
|
+
}
|
|
251
|
+
const children = parseBlocks(inner, context, depth + 1)
|
|
252
|
+
if (!add({ kind: 'quote', children })) {
|
|
253
|
+
surrender(index)
|
|
254
|
+
break
|
|
255
|
+
}
|
|
256
|
+
index = end
|
|
257
|
+
continue
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const marker = context.features.lists ? listMarker(line) : null
|
|
261
|
+
if (marker !== null) {
|
|
262
|
+
if (depth >= context.limits.maxDepth) {
|
|
263
|
+
blocks.push({ kind: 'paragraph', inline: [{ kind: 'text', value: line }] })
|
|
264
|
+
index += 1
|
|
265
|
+
continue
|
|
266
|
+
}
|
|
267
|
+
const list = readList(lines, index, context, depth)
|
|
268
|
+
if (!add(list.block)) {
|
|
269
|
+
surrender(index)
|
|
270
|
+
break
|
|
271
|
+
}
|
|
272
|
+
index = list.end
|
|
273
|
+
continue
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (
|
|
277
|
+
context.features.tables &&
|
|
278
|
+
line.includes('|') &&
|
|
279
|
+
index + 1 < lines.length &&
|
|
280
|
+
TABLE_DELIMITER.test(lines[index + 1]!) &&
|
|
281
|
+
splitRow(lines[index + 1]!).length === splitRow(line).length
|
|
282
|
+
) {
|
|
283
|
+
const table = readTable(lines, index, context)
|
|
284
|
+
if (!add(table.block)) {
|
|
285
|
+
surrender(index)
|
|
286
|
+
break
|
|
287
|
+
}
|
|
288
|
+
index = table.end
|
|
289
|
+
continue
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const paragraph: string[] = [line]
|
|
293
|
+
let end = index + 1
|
|
294
|
+
let heading: 1 | 2 | null = null
|
|
295
|
+
while (end < lines.length) {
|
|
296
|
+
const candidate = lines[end]!
|
|
297
|
+
const setext = context.features.headings ? SETEXT.exec(candidate) : null
|
|
298
|
+
if (setext !== null) {
|
|
299
|
+
heading = setext[1]!.startsWith('=') ? 1 : 2
|
|
300
|
+
end += 1
|
|
301
|
+
break
|
|
302
|
+
}
|
|
303
|
+
if (isBlank(candidate) || startsBlock(candidate, context)) break
|
|
304
|
+
if (
|
|
305
|
+
context.features.tables &&
|
|
306
|
+
candidate.includes('|') &&
|
|
307
|
+
end + 1 < lines.length &&
|
|
308
|
+
TABLE_DELIMITER.test(lines[end + 1]!) &&
|
|
309
|
+
splitRow(lines[end + 1]!).length === splitRow(candidate).length
|
|
310
|
+
) {
|
|
311
|
+
break
|
|
312
|
+
}
|
|
313
|
+
paragraph.push(candidate)
|
|
314
|
+
end += 1
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const text = paragraph.join('\n').trim()
|
|
318
|
+
if (text !== '') {
|
|
319
|
+
const inline = parseInline(text, context.inline)
|
|
320
|
+
const block: Block =
|
|
321
|
+
heading === null
|
|
322
|
+
? { kind: 'paragraph', inline }
|
|
323
|
+
: { kind: 'heading', level: heading, inline }
|
|
324
|
+
if (!add(block)) {
|
|
325
|
+
surrender(index)
|
|
326
|
+
break
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
index = end
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return blocks
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
interface Marker {
|
|
336
|
+
readonly ordered: boolean
|
|
337
|
+
readonly start: number
|
|
338
|
+
readonly width: number
|
|
339
|
+
readonly delimiter: string
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function listMarker(line: string): Marker | null {
|
|
343
|
+
const bullet = BULLET.exec(line)
|
|
344
|
+
if (bullet !== null) {
|
|
345
|
+
const spaces = bullet[3] === '' ? 1 : bullet[3]!.length
|
|
346
|
+
return {
|
|
347
|
+
ordered: false,
|
|
348
|
+
start: 1,
|
|
349
|
+
width: bullet[1]!.length + 1 + spaces,
|
|
350
|
+
delimiter: bullet[2]!,
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
const ordered = ORDERED.exec(line)
|
|
354
|
+
if (ordered !== null) {
|
|
355
|
+
const spaces = ordered[4] === '' ? 1 : ordered[4]!.length
|
|
356
|
+
return {
|
|
357
|
+
ordered: true,
|
|
358
|
+
start: Number(ordered[2]),
|
|
359
|
+
width: ordered[1]!.length + ordered[2]!.length + 1 + spaces,
|
|
360
|
+
delimiter: ordered[3]!,
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return null
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function readList(
|
|
367
|
+
lines: readonly string[],
|
|
368
|
+
from: number,
|
|
369
|
+
context: Context,
|
|
370
|
+
depth: number,
|
|
371
|
+
): { block: Block; end: number } {
|
|
372
|
+
const first = listMarker(lines[from]!)!
|
|
373
|
+
const items: ListItem[] = []
|
|
374
|
+
let loose = false
|
|
375
|
+
let index = from
|
|
376
|
+
|
|
377
|
+
while (index < lines.length) {
|
|
378
|
+
const marker = listMarker(lines[index]!)
|
|
379
|
+
if (marker === null || marker.ordered !== first.ordered || marker.delimiter !== first.delimiter)
|
|
380
|
+
break
|
|
381
|
+
|
|
382
|
+
const itemLines: string[] = [lines[index]!.slice(marker.width)]
|
|
383
|
+
index += 1
|
|
384
|
+
|
|
385
|
+
let trailingBlanks = 0
|
|
386
|
+
while (index < lines.length) {
|
|
387
|
+
const candidate = lines[index]!
|
|
388
|
+
if (isBlank(candidate)) {
|
|
389
|
+
itemLines.push('')
|
|
390
|
+
trailingBlanks += 1
|
|
391
|
+
index += 1
|
|
392
|
+
continue
|
|
393
|
+
}
|
|
394
|
+
if (indentOf(candidate) >= marker.width) {
|
|
395
|
+
itemLines.push(candidate.slice(marker.width))
|
|
396
|
+
trailingBlanks = 0
|
|
397
|
+
index += 1
|
|
398
|
+
continue
|
|
399
|
+
}
|
|
400
|
+
if (listMarker(candidate) !== null) break
|
|
401
|
+
if (trailingBlanks > 0 || startsBlock(candidate, context)) break
|
|
402
|
+
itemLines.push(candidate.trimStart())
|
|
403
|
+
index += 1
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
while (itemLines.length > 0 && isBlank(itemLines[itemLines.length - 1])) itemLines.pop()
|
|
407
|
+
if (itemLines.some(isBlank)) loose = true
|
|
408
|
+
if (trailingBlanks > 0 && index < lines.length && listMarker(lines[index]!) !== null)
|
|
409
|
+
loose = true
|
|
410
|
+
|
|
411
|
+
let checked: boolean | null = null
|
|
412
|
+
const task = TASK.exec(itemLines[0] ?? '')
|
|
413
|
+
if (task !== null) {
|
|
414
|
+
checked = task[1]!.toLowerCase() === 'x'
|
|
415
|
+
itemLines[0] = itemLines[0]!.slice(task[0].length)
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
items.push({ checked, children: parseBlocks(itemLines, context, depth + 1) })
|
|
419
|
+
if (context.budget.spent) break
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
return {
|
|
423
|
+
block: {
|
|
424
|
+
kind: 'list',
|
|
425
|
+
ordered: first.ordered,
|
|
426
|
+
start: first.ordered ? first.start : 1,
|
|
427
|
+
tight: !loose,
|
|
428
|
+
items,
|
|
429
|
+
},
|
|
430
|
+
end: index,
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function readTable(
|
|
435
|
+
lines: readonly string[],
|
|
436
|
+
from: number,
|
|
437
|
+
context: Context,
|
|
438
|
+
): { block: Block; end: number } {
|
|
439
|
+
const header = splitRow(lines[from]!)
|
|
440
|
+
const align = splitRow(lines[from + 1]!).map(alignmentOf)
|
|
441
|
+
const cell = (value: string): TableCell => ({ inline: parseInline(value, context.inline) })
|
|
442
|
+
|
|
443
|
+
const rows: TableCell[][] = []
|
|
444
|
+
let index = from + 2
|
|
445
|
+
while (index < lines.length) {
|
|
446
|
+
const line = lines[index]!
|
|
447
|
+
if (isBlank(line) || !line.includes('|')) break
|
|
448
|
+
if (startsBlock(line, context)) break
|
|
449
|
+
const cells = splitRow(line)
|
|
450
|
+
rows.push(Array.from({ length: header.length }, (_unused, column) => cell(cells[column] ?? '')))
|
|
451
|
+
index += 1
|
|
452
|
+
if (context.budget.spent) break
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
return {
|
|
456
|
+
block: { kind: 'table', head: header.map(cell), align, rows },
|
|
457
|
+
end: index,
|
|
458
|
+
}
|
|
459
|
+
}
|
package/src/body.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { bbcodeToMarkdown } from './bbcode'
|
|
2
|
+
import { type ParseOptions, parse } from './blocks'
|
|
3
|
+
import type { CompiledSmilies } from './extensions'
|
|
4
|
+
import { localizeQuoteAttribution, type RenderContext, renderDocument } from './render'
|
|
5
|
+
import type { BoardVocabulary } from './vocabulary'
|
|
6
|
+
|
|
7
|
+
export const RENDER_VERSION = 6
|
|
8
|
+
|
|
9
|
+
export const BodyFormat = {
|
|
10
|
+
LegacyBBCode: 0,
|
|
11
|
+
Markdown: 1,
|
|
12
|
+
} as const
|
|
13
|
+
export type BodyFormat = (typeof BodyFormat)[keyof typeof BodyFormat]
|
|
14
|
+
|
|
15
|
+
export interface RenderedBody {
|
|
16
|
+
readonly html: string
|
|
17
|
+
readonly truncated: boolean
|
|
18
|
+
readonly version: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface MarkdownRenderOptions extends ParseOptions {
|
|
22
|
+
readonly smilies?: CompiledSmilies | undefined
|
|
23
|
+
readonly headingOffset?: number
|
|
24
|
+
readonly quoteAttribution?: RenderContext['quoteAttribution']
|
|
25
|
+
readonly spoilerLabel?: RenderContext['spoilerLabel']
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function renderMarkdown(source: string, options: MarkdownRenderOptions = {}): RenderedBody {
|
|
29
|
+
const document = parse(source, options)
|
|
30
|
+
return {
|
|
31
|
+
html: renderDocument(document, {
|
|
32
|
+
smilies: options.smilies,
|
|
33
|
+
...(options.headingOffset === undefined ? {} : { headingOffset: options.headingOffset }),
|
|
34
|
+
...(options.quoteAttribution === undefined
|
|
35
|
+
? {}
|
|
36
|
+
: { quoteAttribution: options.quoteAttribution }),
|
|
37
|
+
...(options.spoilerLabel === undefined ? {} : { spoilerLabel: options.spoilerLabel }),
|
|
38
|
+
}),
|
|
39
|
+
truncated: document.truncated,
|
|
40
|
+
version: RENDER_VERSION,
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function sourceAsMarkdown(source: string, format: number | undefined): string {
|
|
45
|
+
return (format ?? BodyFormat.Markdown) === BodyFormat.LegacyBBCode
|
|
46
|
+
? bbcodeToMarkdown(source)
|
|
47
|
+
: source
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface RenderablePost {
|
|
51
|
+
readonly message: string
|
|
52
|
+
readonly messageHtml: string | null
|
|
53
|
+
readonly renderVersion: number
|
|
54
|
+
readonly bodyFormat?: number
|
|
55
|
+
readonly vocabVersion?: number
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function postBodyHtml(
|
|
59
|
+
post: RenderablePost,
|
|
60
|
+
vocabulary?: BoardVocabulary,
|
|
61
|
+
options?: Pick<MarkdownRenderOptions, 'quoteAttribution'>,
|
|
62
|
+
): string {
|
|
63
|
+
const revision = vocabulary?.revision ?? 0
|
|
64
|
+
const format = post.bodyFormat ?? BodyFormat.Markdown
|
|
65
|
+
|
|
66
|
+
if (
|
|
67
|
+
post.messageHtml !== null &&
|
|
68
|
+
post.renderVersion === RENDER_VERSION &&
|
|
69
|
+
format === BodyFormat.Markdown &&
|
|
70
|
+
(post.vocabVersion ?? 0) === revision
|
|
71
|
+
) {
|
|
72
|
+
return options?.quoteAttribution === undefined
|
|
73
|
+
? post.messageHtml
|
|
74
|
+
: localizeQuoteAttribution(post.messageHtml, options.quoteAttribution)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return renderMarkdown(sourceAsMarkdown(post.message, format), {
|
|
78
|
+
...vocabularyOptions(vocabulary),
|
|
79
|
+
...options,
|
|
80
|
+
}).html
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function vocabularyOptions(vocabulary: BoardVocabulary | undefined): MarkdownRenderOptions {
|
|
84
|
+
if (vocabulary === undefined) return {}
|
|
85
|
+
return vocabulary.smilies === undefined
|
|
86
|
+
? { directives: vocabulary.directives }
|
|
87
|
+
: { directives: vocabulary.directives, smilies: vocabulary.smilies }
|
|
88
|
+
}
|
package/src/budget.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export class NodeBudget {
|
|
2
|
+
private used = 0
|
|
3
|
+
public exhausted = false
|
|
4
|
+
|
|
5
|
+
constructor(private readonly max: number) {}
|
|
6
|
+
|
|
7
|
+
take(): boolean {
|
|
8
|
+
if (this.used >= this.max) {
|
|
9
|
+
this.exhausted = true
|
|
10
|
+
return false
|
|
11
|
+
}
|
|
12
|
+
this.used += 1
|
|
13
|
+
return true
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
get spent(): boolean {
|
|
17
|
+
return this.used >= this.max
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
exhaust(): void {
|
|
21
|
+
this.exhausted = true
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export function escapeMarkdownText(value: string): string {
|
|
2
|
+
return value
|
|
3
|
+
.replace(/([\\`*_[\]<>|~])/g, '\\$1')
|
|
4
|
+
.split('\n')
|
|
5
|
+
.map((line) => line.replace(/^(\s*)(:::|[#>+=-]|\d+[.)])/, '$1\\$2'))
|
|
6
|
+
.join('\n')
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function plainAuthorName(name: string): string {
|
|
10
|
+
return name.replace(/[*[\]`\\]/g, '').trim()
|
|
11
|
+
}
|
package/src/escape.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
const HTML_ESCAPES: Readonly<Record<string, string>> = {
|
|
2
|
+
'&': '&',
|
|
3
|
+
'<': '<',
|
|
4
|
+
'>': '>',
|
|
5
|
+
'"': '"',
|
|
6
|
+
"'": ''',
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function escapeHtml(value: string): string {
|
|
10
|
+
return value.replace(/[&<>"']/g, (character) => HTML_ESCAPES[character]!)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function escapeAttribute(value: string): string {
|
|
14
|
+
return escapeHtml(value)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const HTML_UNESCAPES: Readonly<Record<string, string>> = {
|
|
18
|
+
'&': '&',
|
|
19
|
+
'<': '<',
|
|
20
|
+
'>': '>',
|
|
21
|
+
'"': '"',
|
|
22
|
+
''': "'",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function unescapeHtml(value: string): string {
|
|
26
|
+
return value.replace(/&(?:amp|lt|gt|quot|#39);/g, (entity) => HTML_UNESCAPES[entity] ?? entity)
|
|
27
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { escapeAttribute, escapeHtml } from './escape'
|
|
2
|
+
import { safeImageUrl } from './url'
|
|
3
|
+
|
|
4
|
+
export interface DirectiveDefinition {
|
|
5
|
+
readonly name: string
|
|
6
|
+
readonly block: boolean
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface SmileyDefinition {
|
|
10
|
+
readonly code: string
|
|
11
|
+
readonly src: string
|
|
12
|
+
readonly alt?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface CompiledSmilies {
|
|
16
|
+
readonly entries: readonly Readonly<{ code: string; src: string; alt: string }>[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface DirectiveRegistry {
|
|
20
|
+
readonly block: ReadonlySet<string>
|
|
21
|
+
readonly inline: ReadonlySet<string>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const NO_DIRECTIVES: DirectiveRegistry = { block: new Set(), inline: new Set() }
|
|
25
|
+
|
|
26
|
+
export const RESERVED_DIRECTIVE_NAMES: ReadonlySet<string> = new Set(['spoiler'])
|
|
27
|
+
|
|
28
|
+
export function createDirectiveRegistry(
|
|
29
|
+
definitions: readonly DirectiveDefinition[] = [],
|
|
30
|
+
): DirectiveRegistry {
|
|
31
|
+
const block = new Set<string>()
|
|
32
|
+
const inline = new Set<string>()
|
|
33
|
+
|
|
34
|
+
for (const definition of definitions) {
|
|
35
|
+
const name = definition.name.toLowerCase()
|
|
36
|
+
if (!/^[a-z][a-z0-9]{0,15}$/.test(name)) {
|
|
37
|
+
throw new Error('A directive name is 1–16 letters or digits and starts with a letter.')
|
|
38
|
+
}
|
|
39
|
+
if (RESERVED_DIRECTIVE_NAMES.has(name)) {
|
|
40
|
+
throw new Error(`:${name} is a built-in directive and cannot be redefined.`)
|
|
41
|
+
}
|
|
42
|
+
if (block.has(name) || inline.has(name)) throw new Error(`Directive :${name} already exists.`)
|
|
43
|
+
if (definition.block) block.add(name)
|
|
44
|
+
else inline.add(name)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return { block, inline }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function directiveNames(registry: DirectiveRegistry): readonly string[] {
|
|
51
|
+
return [...registry.block, ...registry.inline].sort()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function compileSmilies(definitions: readonly SmileyDefinition[]): CompiledSmilies {
|
|
55
|
+
const codes = new Set<string>()
|
|
56
|
+
const entries = definitions.map((definition) => {
|
|
57
|
+
if (definition.code.length === 0 || definition.code.length > 32 || /\s/.test(definition.code)) {
|
|
58
|
+
throw new Error('A smiley code must be 1–32 non-space characters.')
|
|
59
|
+
}
|
|
60
|
+
if (codes.has(definition.code)) throw new Error(`Smiley code ${definition.code} is duplicated.`)
|
|
61
|
+
codes.add(definition.code)
|
|
62
|
+
|
|
63
|
+
const src = safeImageUrl(definition.src)
|
|
64
|
+
if (src === null) throw new Error(`Smiley ${definition.code} has an unsafe image URL.`)
|
|
65
|
+
return { code: definition.code, src, alt: definition.alt ?? definition.code }
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
entries.sort((a, b) => b.code.length - a.code.length || a.code.localeCompare(b.code))
|
|
69
|
+
return { entries }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function renderSmilies(text: string, smilies: CompiledSmilies | undefined): string {
|
|
73
|
+
if (smilies === undefined || smilies.entries.length === 0) return escapeHtml(text)
|
|
74
|
+
|
|
75
|
+
let html = ''
|
|
76
|
+
let cursor = 0
|
|
77
|
+
while (cursor < text.length) {
|
|
78
|
+
let match: (typeof smilies.entries)[number] | undefined
|
|
79
|
+
for (const entry of smilies.entries) {
|
|
80
|
+
if (text.startsWith(entry.code, cursor)) {
|
|
81
|
+
match = entry
|
|
82
|
+
break
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (match === undefined) {
|
|
87
|
+
html += escapeHtml(text[cursor]!)
|
|
88
|
+
cursor += 1
|
|
89
|
+
continue
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
html += `<img src="${escapeAttribute(match.src)}" alt="${escapeAttribute(match.alt)}" loading="lazy" class="md-smiley">`
|
|
93
|
+
cursor += match.code.length
|
|
94
|
+
}
|
|
95
|
+
return html
|
|
96
|
+
}
|