@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/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
+ '&': '&amp;',
3
+ '<': '&lt;',
4
+ '>': '&gt;',
5
+ '"': '&quot;',
6
+ "'": '&#39;',
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
+ '&amp;': '&',
19
+ '&lt;': '<',
20
+ '&gt;': '>',
21
+ '&quot;': '"',
22
+ '&#39;': "'",
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
+ }