@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.
@@ -0,0 +1,47 @@
1
+ export interface MarkdownFeatures {
2
+ readonly headings: boolean
3
+ readonly lists: boolean
4
+ readonly quotes: boolean
5
+ readonly code: boolean
6
+ readonly codeSpans: boolean
7
+ readonly tables: boolean
8
+ readonly rules: boolean
9
+ readonly images: boolean
10
+ readonly links: boolean
11
+ readonly emphasis: boolean
12
+ readonly directives: boolean
13
+ readonly mentions: boolean
14
+ readonly attachments: boolean
15
+ }
16
+
17
+ export const FULL_FEATURES: MarkdownFeatures = {
18
+ headings: true,
19
+ lists: true,
20
+ quotes: true,
21
+ code: true,
22
+ codeSpans: true,
23
+ tables: true,
24
+ rules: true,
25
+ images: true,
26
+ links: true,
27
+ emphasis: true,
28
+ directives: true,
29
+ mentions: true,
30
+ attachments: true,
31
+ }
32
+
33
+ export const SIGNATURE_FEATURES: MarkdownFeatures = {
34
+ headings: false,
35
+ lists: false,
36
+ quotes: false,
37
+ code: false,
38
+ codeSpans: true,
39
+ tables: false,
40
+ rules: false,
41
+ images: false,
42
+ links: true,
43
+ emphasis: true,
44
+ directives: false,
45
+ mentions: false,
46
+ attachments: false,
47
+ }
package/src/index.ts ADDED
@@ -0,0 +1,80 @@
1
+ export {
2
+ type AttributedAuthor,
3
+ memberByNameHref,
4
+ type QuoteAttribution,
5
+ quoteAttribution,
6
+ } from './attribution'
7
+ export { bbcodeToMarkdown } from './bbcode'
8
+ export { type ParseOptions, parse } from './blocks'
9
+ export {
10
+ BodyFormat,
11
+ type MarkdownRenderOptions,
12
+ postBodyHtml,
13
+ RENDER_VERSION,
14
+ type RenderablePost,
15
+ type RenderedBody,
16
+ renderMarkdown,
17
+ sourceAsMarkdown,
18
+ vocabularyOptions,
19
+ } from './body'
20
+ export { escapeAttribute, escapeHtml, unescapeHtml } from './escape'
21
+ export { escapeMarkdownText, plainAuthorName } from './escape-source'
22
+ export {
23
+ type CompiledSmilies,
24
+ compileSmilies,
25
+ createDirectiveRegistry,
26
+ type DirectiveDefinition,
27
+ type DirectiveRegistry,
28
+ directiveNames,
29
+ NO_DIRECTIVES,
30
+ renderSmilies,
31
+ type SmileyDefinition,
32
+ } from './extensions'
33
+ export {
34
+ FULL_FEATURES,
35
+ type MarkdownFeatures,
36
+ SIGNATURE_FEATURES,
37
+ } from './features'
38
+ export { type InlineContext, parseInline } from './inline'
39
+ export { DEFAULT_LIMITS, type MarkdownLimits } from './limits'
40
+ export { extractMentions, extractQuotedAuthors } from './mentions'
41
+ export {
42
+ type Alignment,
43
+ type Block,
44
+ type Inline,
45
+ type ListItem,
46
+ type MarkdownDocument,
47
+ type TableCell,
48
+ textOf,
49
+ } from './nodes'
50
+ export {
51
+ authorRef,
52
+ CORE_RENDERING,
53
+ type MarkdownAuthorRef,
54
+ type MarkdownPipeline,
55
+ type MarkdownRenderContext,
56
+ type MarkdownSourceKind,
57
+ NO_VOCABULARY_SOURCE,
58
+ renderThrough,
59
+ } from './pipeline'
60
+ export { plainText, summarise } from './plain'
61
+ export { type QuoteInput, quoteBlock } from './quote'
62
+ export {
63
+ localizeQuoteAttribution,
64
+ type RenderContext,
65
+ renderDocument,
66
+ renderInline,
67
+ } from './render'
68
+ export { safeImageUrl, safeUrl, type UrlPolicy } from './url'
69
+ export {
70
+ type BoardVocabulary,
71
+ compileVocabulary,
72
+ EMPTY_VOCABULARY,
73
+ type VocabularySource,
74
+ } from './vocabulary'
75
+ export {
76
+ applyWordFilter,
77
+ type CompiledWordFilter,
78
+ compileWordFilter,
79
+ type WordFilterRule,
80
+ } from './word-filter'
package/src/inline.ts ADDED
@@ -0,0 +1,525 @@
1
+ import type { NodeBudget } from './budget'
2
+ import type { MarkdownFeatures } from './features'
3
+ import type { MarkdownLimits } from './limits'
4
+ import type { Inline } from './nodes'
5
+ import { textOf } from './nodes'
6
+
7
+ export interface InlineContext {
8
+ readonly features: MarkdownFeatures
9
+ readonly limits: MarkdownLimits
10
+ readonly directives: ReadonlySet<string>
11
+ readonly budget: NodeBudget
12
+ }
13
+
14
+ const PUNCTUATION = /[!-#%-*,-/:;?@[-\]_{}\p{P}\p{S}]/u
15
+
16
+ const ESCAPABLE = /[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~]/
17
+
18
+ const DIRECTIVE_NAME = /^[a-z][a-z0-9]{0,15}$/
19
+
20
+ const BARE_URL = /^(https?:\/\/|www\.)[^\s<]*/i
21
+
22
+ const AUTOLINK = /^<([a-z][a-z0-9+.-]{1,31}:[^\s<>]*)>/i
23
+ const AUTOLINK_EMAIL = /^<([^\s<>@]+@[^\s<>@]+\.[^\s<>@]+)>/
24
+
25
+ const MENTION = /^@([\p{L}\p{N}][\p{L}\p{N}._-]*)/u
26
+
27
+ const MENTION_QUOTED = /^@"([^"\n]+)"/
28
+
29
+ const MENTION_NAME = /^[\p{L}\p{N}][\p{L}\p{N} ._-]*$/u
30
+
31
+ const MENTION_MAX = 64
32
+
33
+ const ATTACHMENT = /^\[attachment=(\d{1,10})\]/
34
+
35
+ type Delimiter = {
36
+ readonly t: 'delim'
37
+ readonly char: string
38
+ length: number
39
+ readonly canOpen: boolean
40
+ readonly canClose: boolean
41
+ readonly raw: string
42
+ }
43
+
44
+ type Item = { readonly t: 'node'; readonly node: Inline } | Delimiter
45
+
46
+ function isWhitespace(character: string | undefined): boolean {
47
+ return character === undefined || /\s/.test(character)
48
+ }
49
+
50
+ function isPunctuation(character: string | undefined): boolean {
51
+ return character !== undefined && PUNCTUATION.test(character)
52
+ }
53
+
54
+ function flanking(
55
+ source: string,
56
+ start: number,
57
+ end: number,
58
+ char: string,
59
+ ): { canOpen: boolean; canClose: boolean } {
60
+ const before = start === 0 ? undefined : source[start - 1]
61
+ const after = end >= source.length ? undefined : source[end]
62
+
63
+ const leftFlanking =
64
+ !isWhitespace(after) && (!isPunctuation(after) || isWhitespace(before) || isPunctuation(before))
65
+ const rightFlanking =
66
+ !isWhitespace(before) && (!isPunctuation(before) || isWhitespace(after) || isPunctuation(after))
67
+
68
+ if (char === '_') {
69
+ return {
70
+ canOpen: leftFlanking && (!rightFlanking || isPunctuation(before)),
71
+ canClose: rightFlanking && (!leftFlanking || isPunctuation(after)),
72
+ }
73
+ }
74
+ return { canOpen: leftFlanking, canClose: rightFlanking }
75
+ }
76
+
77
+ function matchingBracket(source: string, from: number): number {
78
+ let depth = 0
79
+ let index = from
80
+ while (index < source.length) {
81
+ const character = source[index]!
82
+ if (character === '\\') {
83
+ index += 2
84
+ continue
85
+ }
86
+ if (character === '`') {
87
+ const run = /^`+/.exec(source.slice(index))![0]
88
+ const close = source.indexOf(run, index + run.length)
89
+ index = close === -1 ? index + run.length : close + run.length
90
+ continue
91
+ }
92
+ if (character === '[') depth += 1
93
+ else if (character === ']') {
94
+ depth -= 1
95
+ if (depth === 0) return index
96
+ }
97
+ index += 1
98
+ }
99
+ return -1
100
+ }
101
+
102
+ interface Destination {
103
+ readonly url: string
104
+ readonly title: string | null
105
+ readonly end: number
106
+ }
107
+
108
+ function readDestination(source: string, from: number): Destination | null {
109
+ if (source[from] !== '(') return null
110
+ let index = from + 1
111
+ while (index < source.length && /\s/.test(source[index]!)) index += 1
112
+
113
+ let url: string
114
+ if (source[index] === '<') {
115
+ const close = source.indexOf('>', index + 1)
116
+ if (close === -1) return null
117
+ url = source.slice(index + 1, close)
118
+ if (url.includes('\n')) return null
119
+ index = close + 1
120
+ } else {
121
+ let depth = 0
122
+ const start = index
123
+ while (index < source.length) {
124
+ const character = source[index]!
125
+ if (character === '\\' && index + 1 < source.length) {
126
+ index += 2
127
+ continue
128
+ }
129
+ if (/\s/.test(character)) break
130
+ if (character === '(') depth += 1
131
+ if (character === ')') {
132
+ if (depth === 0) break
133
+ depth -= 1
134
+ }
135
+ index += 1
136
+ }
137
+ url = unescapeBackslashes(source.slice(start, index))
138
+ }
139
+
140
+ while (index < source.length && /\s/.test(source[index]!)) index += 1
141
+
142
+ let title: string | null = null
143
+ const opener = source[index]
144
+ if (opener === '"' || opener === "'" || opener === '(') {
145
+ const closer = opener === '(' ? ')' : opener
146
+ const close = source.indexOf(closer, index + 1)
147
+ if (close === -1) return null
148
+ title = unescapeBackslashes(source.slice(index + 1, close))
149
+ index = close + 1
150
+ while (index < source.length && /\s/.test(source[index]!)) index += 1
151
+ }
152
+
153
+ if (source[index] !== ')') return null
154
+ return { url: url.trim(), title, end: index + 1 }
155
+ }
156
+
157
+ function unescapeBackslashes(value: string): string {
158
+ return value.replace(/\\(.)/gs, (whole, character: string) =>
159
+ ESCAPABLE.test(character) ? character : whole,
160
+ )
161
+ }
162
+
163
+ function trimBareUrl(url: string): string {
164
+ let end = url.length
165
+ while (end > 0) {
166
+ const character = url[end - 1]!
167
+ if ('.,;:!?"\''.includes(character)) {
168
+ end -= 1
169
+ continue
170
+ }
171
+ if (character === ')' || character === ']') {
172
+ const open = character === ')' ? '(' : '['
173
+ const slice = url.slice(0, end)
174
+ const opens = slice.split(open).length - 1
175
+ const closes = slice.split(character).length - 1
176
+ if (closes > opens) {
177
+ end -= 1
178
+ continue
179
+ }
180
+ }
181
+ break
182
+ }
183
+ return url.slice(0, end)
184
+ }
185
+
186
+ export function parseInline(source: string, context: InlineContext, allowLinks = true): Inline[] {
187
+ const items: Item[] = []
188
+ let buffer = ''
189
+ let index = 0
190
+ let delimiters = 0
191
+
192
+ const push = (node: Inline): boolean => {
193
+ if (!context.budget.take()) return false
194
+ items.push({ t: 'node', node })
195
+ return true
196
+ }
197
+
198
+ const flush = (): void => {
199
+ if (buffer.length === 0) return
200
+ items.push({ t: 'node', node: { kind: 'text', value: buffer } })
201
+ buffer = ''
202
+ }
203
+
204
+ while (index < source.length) {
205
+ if (context.budget.spent) {
206
+ context.budget.exhaust()
207
+ buffer += source.slice(index)
208
+ break
209
+ }
210
+ const character = source[index]!
211
+
212
+ if (character === '\\') {
213
+ const next = source[index + 1]
214
+ if (next !== undefined && ESCAPABLE.test(next)) {
215
+ buffer += next
216
+ index += 2
217
+ continue
218
+ }
219
+ buffer += character
220
+ index += 1
221
+ continue
222
+ }
223
+
224
+ if (character === '\n') {
225
+ flush()
226
+ if (!push({ kind: 'break' })) break
227
+ index += 1
228
+ while (index < source.length && (source[index] === ' ' || source[index] === '\t')) index += 1
229
+ continue
230
+ }
231
+
232
+ if (character === '`' && context.features.codeSpans) {
233
+ const run = /^`+/.exec(source.slice(index))![0]
234
+ let search = index + run.length
235
+ let close = -1
236
+ while (search < source.length) {
237
+ const at = source.indexOf(run, search)
238
+ if (at === -1) break
239
+ if (source[at + run.length] === '`') {
240
+ search = at + /^`+/.exec(source.slice(at))![0].length
241
+ continue
242
+ }
243
+ close = at
244
+ break
245
+ }
246
+ if (close !== -1) {
247
+ let value = source.slice(index + run.length, close).replace(/\n/g, ' ')
248
+ if (
249
+ value.length > 2 &&
250
+ value.startsWith(' ') &&
251
+ value.endsWith(' ') &&
252
+ value.trim() !== ''
253
+ ) {
254
+ value = value.slice(1, -1)
255
+ }
256
+ flush()
257
+ if (!push({ kind: 'code', value })) break
258
+ index = close + run.length
259
+ continue
260
+ }
261
+ buffer += run
262
+ index += run.length
263
+ continue
264
+ }
265
+
266
+ if (character === '<' && allowLinks && context.features.links) {
267
+ const rest = source.slice(index)
268
+ const scheme = AUTOLINK.exec(rest)
269
+ const email = AUTOLINK_EMAIL.exec(rest)
270
+ if (scheme !== null) {
271
+ flush()
272
+ if (
273
+ !push({
274
+ kind: 'link',
275
+ href: scheme[1]!,
276
+ title: null,
277
+ children: [{ kind: 'text', value: scheme[1]! }],
278
+ })
279
+ )
280
+ break
281
+ index += scheme[0].length
282
+ continue
283
+ }
284
+ if (email !== null) {
285
+ flush()
286
+ if (
287
+ !push({
288
+ kind: 'link',
289
+ href: `mailto:${email[1]!}`,
290
+ title: null,
291
+ children: [{ kind: 'text', value: email[1]! }],
292
+ })
293
+ ) {
294
+ break
295
+ }
296
+ index += email[0].length
297
+ continue
298
+ }
299
+ buffer += character
300
+ index += 1
301
+ continue
302
+ }
303
+
304
+ if (character === '!' && source[index + 1] === '[') {
305
+ const close = matchingBracket(source, index + 1)
306
+ const destination = close === -1 ? null : readDestination(source, close + 1)
307
+ if (destination !== null) {
308
+ const alt = textOf(parseInline(source.slice(index + 2, close), context, false))
309
+ flush()
310
+ if (context.features.images) {
311
+ if (!push({ kind: 'image', src: destination.url, alt })) break
312
+ } else {
313
+ buffer += alt
314
+ }
315
+ index = destination.end
316
+ continue
317
+ }
318
+ buffer += character
319
+ index += 1
320
+ continue
321
+ }
322
+
323
+ if (character === '[' && context.features.attachments) {
324
+ const attachment = ATTACHMENT.exec(source.slice(index))
325
+ if (attachment !== null) {
326
+ flush()
327
+ if (!push({ kind: 'attachment', id: Number(attachment[1]) })) break
328
+ index += attachment[0].length
329
+ continue
330
+ }
331
+ }
332
+
333
+ if (character === '[' && allowLinks && context.features.links) {
334
+ const close = matchingBracket(source, index)
335
+ const destination = close === -1 ? null : readDestination(source, close + 1)
336
+ if (destination !== null) {
337
+ const children = parseInline(source.slice(index + 1, close), context, false)
338
+ flush()
339
+ if (!push({ kind: 'link', href: destination.url, title: destination.title, children }))
340
+ break
341
+ index = destination.end
342
+ continue
343
+ }
344
+ buffer += character
345
+ index += 1
346
+ continue
347
+ }
348
+
349
+ if (character === ':' && context.features.directives) {
350
+ const match = /^:([a-z][a-z0-9]{0,15})\[/.exec(source.slice(index))
351
+ if (match !== null && context.directives.has(match[1]!) && DIRECTIVE_NAME.test(match[1]!)) {
352
+ const bracket = index + match[0].length - 1
353
+ const close = matchingBracket(source, bracket)
354
+ if (close !== -1) {
355
+ const children = parseInline(source.slice(bracket + 1, close), context, allowLinks)
356
+ flush()
357
+ if (!push({ kind: 'directive', name: match[1]!, children })) break
358
+ index = close + 1
359
+ continue
360
+ }
361
+ }
362
+ buffer += character
363
+ index += 1
364
+ continue
365
+ }
366
+
367
+ if (character === '@' && allowLinks && context.features.mentions) {
368
+ const before = index === 0 ? undefined : source[index - 1]
369
+ if (before === undefined || /[\s(<*_~]/.test(before)) {
370
+ const rest = source.slice(index)
371
+ const quoted = MENTION_QUOTED.exec(rest)
372
+ const bare = quoted === null ? MENTION.exec(rest) : null
373
+
374
+ let name: string | null = null
375
+ let consumed = 0
376
+ if (quoted !== null && MENTION_NAME.test(quoted[1]!)) {
377
+ name = quoted[1]!
378
+ consumed = quoted[0].length
379
+ } else if (bare !== null) {
380
+ name = bare[1]!.replace(/\.+$/, '')
381
+ consumed = 1 + name.length
382
+ }
383
+
384
+ if (name !== null && name.length > 0 && name.length <= MENTION_MAX) {
385
+ flush()
386
+ if (!push({ kind: 'mention', name })) break
387
+ index += consumed
388
+ continue
389
+ }
390
+ }
391
+ buffer += character
392
+ index += 1
393
+ continue
394
+ }
395
+
396
+ if (
397
+ (character === '*' || character === '_' || character === '~') &&
398
+ context.features.emphasis
399
+ ) {
400
+ let end = index
401
+ while (end < source.length && source[end] === character) end += 1
402
+ const run = source.slice(index, end)
403
+ if (delimiters >= context.limits.maxDelimiters) {
404
+ buffer += run
405
+ index += run.length
406
+ continue
407
+ }
408
+ const { canOpen, canClose } = flanking(source, index, index + run.length, character)
409
+ if (!canOpen && !canClose) {
410
+ buffer += run
411
+ index += run.length
412
+ continue
413
+ }
414
+ flush()
415
+ delimiters += 1
416
+ items.push({ t: 'delim', char: character, length: run.length, canOpen, canClose, raw: run })
417
+ index += run.length
418
+ continue
419
+ }
420
+
421
+ if (allowLinks && context.features.links) {
422
+ const before = index === 0 ? undefined : source[index - 1]
423
+ if (before === undefined || /[\s(<*_~]/.test(before)) {
424
+ const bare = BARE_URL.exec(source.slice(index))
425
+ if (bare !== null) {
426
+ const url = trimBareUrl(bare[0])
427
+ if (url.length > 0 && url.length <= context.limits.maxUrlLength) {
428
+ flush()
429
+ const href = url.toLowerCase().startsWith('www.') ? `https://${url}` : url
430
+ if (
431
+ !push({ kind: 'link', href, title: null, children: [{ kind: 'text', value: url }] })
432
+ )
433
+ break
434
+ index += url.length
435
+ continue
436
+ }
437
+ }
438
+ }
439
+ }
440
+
441
+ buffer += character
442
+ index += 1
443
+ }
444
+
445
+ flush()
446
+ return resolveEmphasis(items, context)
447
+ }
448
+
449
+ function literal(items: readonly Item[]): Inline[] {
450
+ const out: Inline[] = []
451
+ for (const item of items) {
452
+ const node: Inline = item.t === 'delim' ? { kind: 'text', value: item.raw } : item.node
453
+ const last = out[out.length - 1]
454
+ if (node.kind === 'text' && last !== undefined && last.kind === 'text') {
455
+ out[out.length - 1] = { kind: 'text', value: last.value + node.value }
456
+ continue
457
+ }
458
+ out.push(node)
459
+ }
460
+ return out
461
+ }
462
+
463
+ function resolveEmphasis(items: Item[], context: InlineContext): Inline[] {
464
+ let index = 0
465
+ while (index < items.length) {
466
+ const closer = items[index]!
467
+ if (closer.t !== 'delim' || !closer.canClose) {
468
+ index += 1
469
+ continue
470
+ }
471
+
472
+ let found = -1
473
+ for (let back = index - 1; back >= 0; back -= 1) {
474
+ const candidate = items[back]!
475
+ if (candidate.t !== 'delim' || candidate.char !== closer.char || !candidate.canOpen) continue
476
+ const bothWays = candidate.canClose || closer.canOpen
477
+ const ruleOfThree =
478
+ bothWays &&
479
+ (candidate.length + closer.length) % 3 === 0 &&
480
+ !(candidate.length % 3 === 0 && closer.length % 3 === 0)
481
+ if (ruleOfThree) continue
482
+ found = back
483
+ break
484
+ }
485
+
486
+ if (found === -1) {
487
+ index += 1
488
+ continue
489
+ }
490
+
491
+ if (!context.budget.take()) break
492
+
493
+ const opener = items[found] as Delimiter
494
+ const strike = closer.char === '~'
495
+ const use = strike
496
+ ? Math.min(opener.length, closer.length, 2)
497
+ : opener.length >= 2 && closer.length >= 2
498
+ ? 2
499
+ : 1
500
+
501
+ const children = literal(items.slice(found + 1, index))
502
+ const node: Inline = strike
503
+ ? { kind: 'strike', children }
504
+ : use === 2
505
+ ? { kind: 'strong', children }
506
+ : { kind: 'emphasis', children }
507
+
508
+ opener.length -= use
509
+ closer.length -= use
510
+
511
+ const replacement: Item[] = []
512
+ if (opener.length > 0) {
513
+ replacement.push({ ...opener, raw: opener.raw.slice(0, opener.length) })
514
+ }
515
+ replacement.push({ t: 'node', node })
516
+ if (closer.length > 0) {
517
+ replacement.push({ ...closer, raw: closer.raw.slice(0, closer.length) })
518
+ }
519
+
520
+ items.splice(found, index - found + 1, ...replacement)
521
+ index = found + replacement.length - (closer.length > 0 ? 1 : 0)
522
+ }
523
+
524
+ return literal(items)
525
+ }
package/src/limits.ts ADDED
@@ -0,0 +1,15 @@
1
+ export interface MarkdownLimits {
2
+ readonly maxInput: number
3
+ readonly maxDepth: number
4
+ readonly maxNodes: number
5
+ readonly maxUrlLength: number
6
+ readonly maxDelimiters: number
7
+ }
8
+
9
+ export const DEFAULT_LIMITS: MarkdownLimits = {
10
+ maxInput: 64 * 1024,
11
+ maxDepth: 12,
12
+ maxNodes: 4000,
13
+ maxUrlLength: 2048,
14
+ maxDelimiters: 2000,
15
+ }