@jarenjs/md 0.34.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.
Files changed (49) hide show
  1. package/README.md +520 -0
  2. package/dist/types/ast.d.ts +181 -0
  3. package/dist/types/bake.d.ts +61 -0
  4. package/dist/types/compiler.d.ts +141 -0
  5. package/dist/types/component/index.d.ts +101 -0
  6. package/dist/types/directives.d.ts +126 -0
  7. package/dist/types/entities.d.ts +40 -0
  8. package/dist/types/footnotes.d.ts +83 -0
  9. package/dist/types/frontmatter.d.ts +67 -0
  10. package/dist/types/html.d.ts +72 -0
  11. package/dist/types/index.d.ts +30 -0
  12. package/dist/types/loader.d.ts +84 -0
  13. package/dist/types/mdx.d.ts +45 -0
  14. package/dist/types/parser.d.ts +116 -0
  15. package/dist/types/plugins/highlight.d.ts +64 -0
  16. package/dist/types/plugins/index.d.ts +64 -0
  17. package/dist/types/plugins/mermaid.d.ts +12 -0
  18. package/dist/types/scanner.d.ts +240 -0
  19. package/dist/types/to-html.d.ts +104 -0
  20. package/dist/types/to-md.d.ts +23 -0
  21. package/dist/types/to-vnode.d.ts +161 -0
  22. package/dist/types/utils.d.ts +63 -0
  23. package/docs/LOADER.md +92 -0
  24. package/docs/MD-FORMAT.md +502 -0
  25. package/docs/PLUGINS.md +277 -0
  26. package/package.json +80 -0
  27. package/schemas/jaren-md-ast.schema.json +296 -0
  28. package/src/ast.js +346 -0
  29. package/src/bake.js +104 -0
  30. package/src/compiler.js +167 -0
  31. package/src/component/index.js +191 -0
  32. package/src/directives.js +371 -0
  33. package/src/entities.js +107 -0
  34. package/src/footnotes.js +180 -0
  35. package/src/frontmatter.js +947 -0
  36. package/src/html.js +281 -0
  37. package/src/index.js +76 -0
  38. package/src/loader.js +0 -0
  39. package/src/mdx.js +219 -0
  40. package/src/parser.js +1685 -0
  41. package/src/plugins/highlight.js +325 -0
  42. package/src/plugins/index.js +75 -0
  43. package/src/plugins/mermaid.js +14 -0
  44. package/src/scanner.js +832 -0
  45. package/src/to-html.js +425 -0
  46. package/src/to-md.js +396 -0
  47. package/src/to-vnode.js +766 -0
  48. package/src/utils.js +107 -0
  49. package/styles/md.css +238 -0
package/src/scanner.js ADDED
@@ -0,0 +1,832 @@
1
+ //@ts-check
2
+ /**
3
+ * @file Line-level scanners for the block parser.
4
+ *
5
+ * Pure, allocation-conscious functions that classify one (detabbed)
6
+ * line at a char-code level: does a construct start here, and where
7
+ * does its content begin? The parser in parser.js owns all state; this
8
+ * module owns none. Every regular expression is compiled once at
9
+ * module load — nothing in here builds a pattern per call.
10
+ */
11
+
12
+ import { countIndent } from './utils.js';
13
+ import { decodeReferences } from './entities.js';
14
+
15
+ const CC_SPACE = 0x20;
16
+ const CC_HASH = 0x23;
17
+ const CC_STAR = 0x2A;
18
+ const CC_PLUS = 0x2B;
19
+ const CC_MINUS = 0x2D;
20
+ const CC_DOT = 0x2E;
21
+ const CC_RPAREN = 0x29;
22
+ const CC_LT = 0x3C;
23
+ const CC_GT = 0x3E;
24
+ const CC_EQ = 0x3D;
25
+ const CC_BACKTICK = 0x60;
26
+ const CC_TILDE = 0x7E;
27
+ const CC_UNDERSCORE = 0x5F;
28
+ const CC_PIPE = 0x7C;
29
+
30
+ /**
31
+ * Thematic break: three or more `*`, `-` or `_` (same character),
32
+ * interleaved with spaces, nothing else on the line.
33
+ * @param {string} line
34
+ * @param {number} start first non-space offset
35
+ * @returns {boolean}
36
+ */
37
+ export function scanThematicBreak(line, start) {
38
+ const marker = line.charCodeAt(start);
39
+ if (marker !== CC_STAR && marker !== CC_MINUS && marker !== CC_UNDERSCORE) {
40
+ return false;
41
+ }
42
+ let count = 0;
43
+ for (let i = start; i < line.length; i++) {
44
+ const c = line.charCodeAt(i);
45
+ if (c === marker) count++;
46
+ else if (c !== CC_SPACE && c !== 0x09) return false;
47
+ }
48
+ return count >= 3;
49
+ }
50
+
51
+ /**
52
+ * ATX heading: `#{1,6}` followed by space or end of line. Returns the
53
+ * depth and the heading text (closing `#` run stripped), or null.
54
+ * @param {string} line
55
+ * @param {number} start first non-space offset
56
+ * @returns {{ depth: number, text: string } | null}
57
+ */
58
+ export function scanAtxHeading(line, start) {
59
+ let depth = 0;
60
+ let i = start;
61
+ while (i < line.length && line.charCodeAt(i) === CC_HASH && depth < 7) {
62
+ depth++;
63
+ i++;
64
+ }
65
+ if (depth === 0 || depth > 6) return null;
66
+ if (i < line.length && line.charCodeAt(i) !== CC_SPACE && line.charCodeAt(i) !== 0x09) {
67
+ return null;
68
+ }
69
+ let end = line.length;
70
+ while (end > i && isSpaceCode(line.charCodeAt(end - 1))) end--;
71
+ // A trailing `#` run preceded by a space (or the opener) is a closer.
72
+ let closer = end;
73
+ while (closer > i && line.charCodeAt(closer - 1) === CC_HASH) closer--;
74
+ if (closer < end && (closer === i || isSpaceCode(line.charCodeAt(closer - 1)))) {
75
+ end = closer;
76
+ while (end > i && isSpaceCode(line.charCodeAt(end - 1))) end--;
77
+ }
78
+ while (i < end && isSpaceCode(line.charCodeAt(i))) i++;
79
+ return { depth, text: line.slice(i, end) };
80
+ }
81
+
82
+ /**
83
+ * Code fence opener: three or more backticks or tildes. A backtick
84
+ * fence's info string may not contain a backtick.
85
+ * @param {string} line
86
+ * @param {number} start first non-space offset
87
+ * @returns {{ marker: number, length: number, info: string } | null}
88
+ */
89
+ export function scanFenceOpen(line, start) {
90
+ const marker = line.charCodeAt(start);
91
+ if (marker !== CC_BACKTICK && marker !== CC_TILDE) return null;
92
+ let i = start;
93
+ while (i < line.length && line.charCodeAt(i) === marker) i++;
94
+ const length = i - start;
95
+ if (length < 3) return null;
96
+ const info = line.slice(i).trim();
97
+ if (marker === CC_BACKTICK && info.indexOf('`') !== -1) return null;
98
+ return { marker, length, info };
99
+ }
100
+
101
+ /**
102
+ * Does this line close a fence opened with `marker` × `length`?
103
+ * @param {string} line
104
+ * @param {number} marker
105
+ * @param {number} length
106
+ * @returns {boolean}
107
+ */
108
+ export function scanFenceClose(line, marker, length) {
109
+ const start = countIndent(line);
110
+ if (start - 0 >= 4) return false;
111
+ let i = start;
112
+ while (i < line.length && line.charCodeAt(i) === marker) i++;
113
+ if (i - start < length) return false;
114
+ while (i < line.length) {
115
+ if (!isSpaceCode(line.charCodeAt(i))) return false;
116
+ i++;
117
+ }
118
+ return true;
119
+ }
120
+
121
+ /**
122
+ * Split a fence info string into `lang` (first word) and `meta` (the
123
+ * rest), resolving backslash escapes and character references in both.
124
+ * @param {string} info
125
+ * @returns {{ lang: string|null, meta: string|null }}
126
+ */
127
+ export function splitFenceInfo(info) {
128
+ if (info === '') return { lang: null, meta: null };
129
+ let i = 0;
130
+ while (i < info.length && !isSpaceCode(info.charCodeAt(i))) i++;
131
+ // an info string carries escapes and references like a destination does
132
+ const lang = decodeReferences(info.slice(0, i));
133
+ const meta = decodeReferences(info.slice(i).trim());
134
+ return { lang, meta: meta === '' ? null : meta };
135
+ }
136
+
137
+ /**
138
+ * Blockquote marker at `offset`: `>` with an optional following space.
139
+ * Returns the content offset, or -1.
140
+ * @param {string} line
141
+ * @param {number} offset first non-space offset
142
+ * @returns {number}
143
+ */
144
+ export function scanBlockquote(line, offset) {
145
+ if (line.charCodeAt(offset) !== CC_GT) return -1;
146
+ const next = line.charCodeAt(offset + 1);
147
+ return next === CC_SPACE || next === 0x09 ? offset + 2 : offset + 1;
148
+ }
149
+
150
+ /**
151
+ * List marker: `-`/`+`/`*` bullet or `1.`/`1)` ordered (start ≤ 9
152
+ * digits), followed by a space or line end. Returns the marker
153
+ * geometry the parser turns into a list container, or null.
154
+ * @param {string} line
155
+ * @param {number} start first non-space offset
156
+ * @returns {{ ordered: boolean, bullet: string, start: number,
157
+ * delimiter: string, contentOffset: number } | null}
158
+ */
159
+ export function scanListMarker(line, start) {
160
+ const c = line.charCodeAt(start);
161
+ let markerEnd;
162
+ let ordered = false;
163
+ let ordinal = 1;
164
+ let bullet = '';
165
+ let delimiter = '';
166
+ if (c === CC_MINUS || c === CC_PLUS || c === CC_STAR) {
167
+ bullet = line[start];
168
+ markerEnd = start + 1;
169
+ }
170
+ else if (c >= 0x30 && c <= 0x39) {
171
+ let i = start;
172
+ while (i < line.length && line.charCodeAt(i) >= 0x30 && line.charCodeAt(i) <= 0x39) i++;
173
+ if (i - start > 9) return null;
174
+ const d = line.charCodeAt(i);
175
+ if (d !== CC_DOT && d !== CC_RPAREN) return null;
176
+ ordered = true;
177
+ ordinal = Number(line.slice(start, i));
178
+ delimiter = line[i];
179
+ markerEnd = i + 1;
180
+ }
181
+ else {
182
+ return null;
183
+ }
184
+ const after = line.charCodeAt(markerEnd);
185
+ if (!Number.isNaN(after) && after !== CC_SPACE && after !== 0x09) return null;
186
+ // Content begins after the marker and 1–4 following spaces; more
187
+ // than 4 (or a blank rest) means content at marker + 1 (indented
188
+ // code / empty item semantics).
189
+ let content = markerEnd;
190
+ while (content < line.length && line.charCodeAt(content) === CC_SPACE) content++;
191
+ const gap = content - markerEnd;
192
+ if (content >= line.length || gap > 4) content = markerEnd + 1;
193
+ return { ordered, bullet, start: ordinal, delimiter, contentOffset: content };
194
+ }
195
+
196
+ /**
197
+ * Setext underline under an open paragraph: `=` run (depth 1) or `-`
198
+ * run (depth 2), possibly space-padded. Returns 0 when neither.
199
+ * @param {string} line
200
+ * @param {number} start first non-space offset
201
+ * @returns {number}
202
+ */
203
+ export function scanSetextUnderline(line, start) {
204
+ const marker = line.charCodeAt(start);
205
+ if (marker !== CC_EQ && marker !== CC_MINUS) return 0;
206
+ let i = start;
207
+ while (i < line.length && line.charCodeAt(i) === marker) i++;
208
+ while (i < line.length) {
209
+ if (!isSpaceCode(line.charCodeAt(i))) return 0;
210
+ i++;
211
+ }
212
+ return marker === CC_EQ ? 1 : 2;
213
+ }
214
+
215
+ /**
216
+ * GFM table delimiter row: cells of `---`, `:--`, `--:`, `:-:` split
217
+ * by pipes. Returns the alignment array, or null.
218
+ * @param {string} line
219
+ * @returns {(string|null)[] | null}
220
+ */
221
+ export function scanTableDelimiter(line) {
222
+ const cells = splitTableRow(line);
223
+ if (cells === null || cells.length === 0) return null;
224
+ /** @type {(string|null)[]} */
225
+ const align = [];
226
+ for (let i = 0; i < cells.length; i++) {
227
+ const cell = cells[i].trim();
228
+ if (cell.length === 0) return null;
229
+ const left = cell.charCodeAt(0) === 0x3A;
230
+ const right = cell.charCodeAt(cell.length - 1) === 0x3A;
231
+ const dashes = cell.slice(left ? 1 : 0, right ? cell.length - 1 : cell.length);
232
+ if (dashes.length === 0) return null;
233
+ for (let d = 0; d < dashes.length; d++) {
234
+ if (dashes.charCodeAt(d) !== CC_MINUS) return null;
235
+ }
236
+ align.push(left && right ? 'center' : right ? 'right' : left ? 'left' : null);
237
+ }
238
+ return align;
239
+ }
240
+
241
+ /**
242
+ * Split a table row into raw cell strings on unescaped `|`, honoring
243
+ * `\|` and pipes inside backtick code spans. Leading and trailing
244
+ * empty cells from outer pipes are dropped. Returns null when the line
245
+ * contains no pipe at all.
246
+ * @param {string} line
247
+ * @returns {string[] | null}
248
+ */
249
+ export function splitTableRow(line) {
250
+ let text = line.trim();
251
+ if (text.indexOf('|') === -1) return null;
252
+ /** @type {string[]} */
253
+ const cells = [];
254
+ let cell = '';
255
+ let start = 0;
256
+ let i = 0;
257
+ while (i < text.length) {
258
+ const c = text.charCodeAt(i);
259
+ if (c === 0x5C /* \ */ && text.charCodeAt(i + 1) === CC_PIPE) {
260
+ cell += text.slice(start, i) + '|';
261
+ i += 2;
262
+ start = i;
263
+ continue;
264
+ }
265
+ if (c === CC_BACKTICK) {
266
+ // Skip the code span verbatim so pipes inside it do not split.
267
+ let run = i;
268
+ while (run < text.length && text.charCodeAt(run) === CC_BACKTICK) run++;
269
+ const fence = text.slice(i, run);
270
+ const close = text.indexOf(fence, run);
271
+ if (close !== -1) {
272
+ let closeEnd = close;
273
+ while (closeEnd < text.length && text.charCodeAt(closeEnd) === CC_BACKTICK) closeEnd++;
274
+ if (closeEnd - close === fence.length) {
275
+ i = closeEnd;
276
+ continue;
277
+ }
278
+ }
279
+ i = run;
280
+ continue;
281
+ }
282
+ if (c === CC_PIPE) {
283
+ cells.push(cell + text.slice(start, i));
284
+ cell = '';
285
+ i++;
286
+ start = i;
287
+ continue;
288
+ }
289
+ i++;
290
+ }
291
+ cells.push(cell + text.slice(start));
292
+ if (cells.length > 0 && cells[0].trim() === '' && text.charCodeAt(0) === CC_PIPE) {
293
+ cells.shift();
294
+ }
295
+ if (cells.length > 0 && cells[cells.length - 1].trim() === ''
296
+ && text.charCodeAt(text.length - 1) === CC_PIPE) {
297
+ cells.pop();
298
+ }
299
+ return cells;
300
+ }
301
+
302
+ /** HTML block openers, CommonMark types 1–7 (compiled once). */
303
+ const RE_HTML_TYPE1 = /^<(?:script|pre|style|textarea)(?:\s|>|$)/i;
304
+ const RE_HTML_TYPE6 = /^<\/?(?:address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h1|h2|h3|h4|h5|h6|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|nav|noframes|ol|optgroup|option|p|param|section|source|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul)(?:\s|\/?>|$)/i;
305
+ const RE_HTML_TYPE7 = /^<(?:[a-zA-Z][a-zA-Z0-9-]*(?:\s+[a-zA-Z_:][a-zA-Z0-9_.:-]*(?:\s*=\s*(?:[^\s"'=<>`]+|'[^']*'|"[^"]*"))?)*\s*\/?>|\/[a-zA-Z][a-zA-Z0-9-]*\s*>)\s*$/;
306
+
307
+ /** Closers for html block types 1–5 (type ↦ substring that ends it). */
308
+ const RE_HTML_END = [
309
+ /$^/, // unused index 0
310
+ /<\/(?:script|pre|style|textarea)>/i,
311
+ /-->/,
312
+ /\?>/,
313
+ />/,
314
+ /\]\]>/,
315
+ ];
316
+
317
+ /**
318
+ * Classify an HTML block opener at `start` (CommonMark types 1–7);
319
+ * 0 means no HTML block starts here. Type 7 is only valid when no
320
+ * paragraph is open — the caller passes `paragraphOpen`.
321
+ * @param {string} line
322
+ * @param {number} start first non-space offset
323
+ * @param {boolean} paragraphOpen
324
+ * @returns {number}
325
+ */
326
+ export function scanHtmlBlockStart(line, start, paragraphOpen) {
327
+ if (line.charCodeAt(start) !== CC_LT) return 0;
328
+ const rest = start === 0 ? line : line.slice(start);
329
+ if (RE_HTML_TYPE1.test(rest)) return 1;
330
+ if (rest.startsWith('<!--')) return 2;
331
+ if (rest.startsWith('<?')) return 3;
332
+ if (/^<![a-zA-Z]/.test(rest)) return 4;
333
+ if (rest.startsWith('<![CDATA[')) return 5;
334
+ if (RE_HTML_TYPE6.test(rest)) return 6;
335
+ if (!paragraphOpen && RE_HTML_TYPE7.test(rest)) return 7;
336
+ return 0;
337
+ }
338
+
339
+ /**
340
+ * Does this line end an HTML block of `kind`? Types 6/7 end on the
341
+ * following blank line (the parser checks that); types 1–5 end on a
342
+ * content condition, which may sit on the opening line itself.
343
+ * @param {number} kind
344
+ * @param {string} line
345
+ * @returns {boolean}
346
+ */
347
+ export function scanHtmlBlockEnd(kind, line) {
348
+ if (kind >= 6) return false;
349
+ return RE_HTML_END[kind].test(line);
350
+ }
351
+
352
+ /**
353
+ * Is this char code a space or tab?
354
+ * @param {number} c
355
+ * @returns {boolean}
356
+ */
357
+ export function isSpaceCode(c) {
358
+ return c === CC_SPACE || c === 0x09;
359
+ }
360
+
361
+ /**
362
+ * Link reference definition at the start of a closed paragraph's text:
363
+ * `[label]: destination "title"` (title optional, may be single-,
364
+ * double- or paren-quoted; destination may be `<>`-wrapped). Returns
365
+ * the definition and the offset after it, or null.
366
+ * @param {string} text the paragraph's raw text
367
+ * @param {number} pos
368
+ * @returns {{ label: string, url: string, title: string|null, end: number } | null}
369
+ */
370
+ export function scanLinkDefinition(text, pos) {
371
+ if (text.charCodeAt(pos) !== 0x5B /* [ */) return null;
372
+ let i = pos + 1;
373
+ const labelStart = i;
374
+ while (i < text.length) {
375
+ const c = text.charCodeAt(i);
376
+ // A backslash escape delimits but does not RESOLVE here: labels
377
+ // match on the text as written, so `[foo\!]` and `[foo!]` are two
378
+ // different definitions (and the reference side reads it the same
379
+ // way).
380
+ if (c === 0x5C) { i += 2; continue; }
381
+ if (c === 0x5D /* ] */) break;
382
+ if (c === 0x5B) return null;
383
+ i++;
384
+ }
385
+ const label = text.slice(labelStart, Math.min(i, text.length));
386
+ if (i >= text.length || label.trim() === '' || label.length > 999) return null;
387
+ if (text.charCodeAt(i + 1) !== 0x3A /* : */) return null;
388
+ i += 2;
389
+ while (i < text.length && (isSpaceCode(text.charCodeAt(i)) || text.charCodeAt(i) === 0x0A)) i++;
390
+ const wrapped = text.charCodeAt(i) === 0x3C /* < */;
391
+ const dest = scanLinkDestination(text, i);
392
+ // `<>` names an empty destination on purpose; nothing at all does not.
393
+ if (dest === null || (dest.url === '' && !wrapped)) return null;
394
+ i = dest.end;
395
+ let j = i;
396
+ while (j < text.length && isSpaceCode(text.charCodeAt(j))) j++;
397
+ const sawNewline = text.charCodeAt(j) === 0x0A;
398
+ if (sawNewline) j++;
399
+ while (j < text.length && isSpaceCode(text.charCodeAt(j))) j++;
400
+ // The title must be separated from the destination by whitespace, so
401
+ // `[foo]: <bar>(baz)` is not a definition at all — it is a paragraph.
402
+ const title = j > i ? scanLinkTitle(text, j) : null;
403
+ if (title !== null) {
404
+ let k = title.end;
405
+ while (k < text.length && isSpaceCode(text.charCodeAt(k))) k++;
406
+ if (k >= text.length || text.charCodeAt(k) === 0x0A) {
407
+ return { label: normalizeLabel(label), url: dest.url, title: title.title, end: k + 1 };
408
+ }
409
+ }
410
+ // No (valid) title: the definition ends at its own line end.
411
+ while (i < text.length && isSpaceCode(text.charCodeAt(i))) i++;
412
+ if (i < text.length && text.charCodeAt(i) !== 0x0A) return null;
413
+ return { label: normalizeLabel(label), url: dest.url, title: null, end: i + 1 };
414
+ }
415
+
416
+ /**
417
+ * Scan a link destination at `pos`: `<...>` wrapped or a run of
418
+ * non-space characters with balanced parens.
419
+ * @param {string} text
420
+ * @param {number} pos
421
+ * @returns {{ url: string, end: number } | null}
422
+ */
423
+ export function scanLinkDestination(text, pos) {
424
+ // The raw range is decoded in ONE pass at the end (escapes and
425
+ // character references together): unescaping while scanning would let
426
+ // a backslash-escaped `&` start an entity in the next pass.
427
+ if (text.charCodeAt(pos) === CC_LT) {
428
+ let i = pos + 1;
429
+ while (i < text.length) {
430
+ const c = text.charCodeAt(i);
431
+ if (c === CC_GT) return { url: decodeReferences(text.slice(pos + 1, i)), end: i + 1 };
432
+ if (c === CC_LT || c === 0x0A) return null;
433
+ i += c === 0x5C && i + 1 < text.length ? 2 : 1;
434
+ }
435
+ return null;
436
+ }
437
+ let i = pos;
438
+ let depth = 0;
439
+ while (i < text.length) {
440
+ const c = text.charCodeAt(i);
441
+ if (c <= 0x20) break;
442
+ if (c === 0x5C && i + 1 < text.length) {
443
+ i += 2;
444
+ continue;
445
+ }
446
+ if (c === 0x28 /* ( */) depth++;
447
+ if (c === CC_RPAREN) {
448
+ if (depth === 0) break;
449
+ depth--;
450
+ }
451
+ i++;
452
+ }
453
+ if (depth !== 0) return null;
454
+ return i === pos ? null : { url: decodeReferences(text.slice(pos, i)), end: i };
455
+ }
456
+
457
+ /**
458
+ * Scan a link title at `pos`: `"..."`, `'...'` or `(...)`.
459
+ * @param {string} text
460
+ * @param {number} pos
461
+ * @returns {{ title: string, end: number } | null}
462
+ */
463
+ export function scanLinkTitle(text, pos) {
464
+ const open = text.charCodeAt(pos);
465
+ if (open !== 0x22 && open !== 0x27 && open !== 0x28) return null;
466
+ const close = open === 0x28 ? CC_RPAREN : open;
467
+ let i = pos + 1;
468
+ while (i < text.length) {
469
+ const c = text.charCodeAt(i);
470
+ if (c === close) return { title: decodeReferences(text.slice(pos + 1, i)), end: i + 1 };
471
+ if (open === 0x28 && c === 0x28) return null;
472
+ i += c === 0x5C && i + 1 < text.length ? 2 : 1;
473
+ }
474
+ return null;
475
+ }
476
+
477
+ // ------------------------------------------------------------------
478
+ // Character classes (the flanking rules and the autolink grammar)
479
+ // ------------------------------------------------------------------
480
+
481
+ /** ASCII punctuation membership (emphasis flanking, backslash escapes). */
482
+ export const ASCII_PUNCT = new Uint8Array(128);
483
+ for (const ch of '!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~') ASCII_PUNCT[ch.charCodeAt(0)] = 1;
484
+
485
+ /**
486
+ * The flanking rules — and the autolink grammar's notion of a host
487
+ * character — are defined over UNICODE classes, not ASCII: a
488
+ * "whitespace character" is Zs plus tab/LF/FF/CR (so a no-break space
489
+ * ends a delimiter run), and a "punctuation character" is anything in
490
+ * P* **or** S* (so `£` and `€` are punctuation, while a letter is not).
491
+ * Both are consulted only for code points outside ASCII, which the table
492
+ * above answers without allocating.
493
+ */
494
+ const RE_UNICODE_WS = /[\p{Zs}\t\n\f\r]/u;
495
+ const RE_UNICODE_PUNCT = /[\p{P}\p{S}]/u;
496
+
497
+ /**
498
+ * Is this code point a whitespace character in the spec's sense?
499
+ * @param {number} point
500
+ * @returns {boolean}
501
+ */
502
+ export function isUnicodeWhitespace(point) {
503
+ if (point < 128) return point === 0x20 || point === 0x0A || point === 0x09;
504
+ return RE_UNICODE_WS.test(String.fromCodePoint(point));
505
+ }
506
+
507
+ /**
508
+ * Is this code point a punctuation character in the spec's sense (P* or S*)?
509
+ * @param {number} point
510
+ * @returns {boolean}
511
+ */
512
+ export function isUnicodePunctuation(point) {
513
+ if (point < 128) return ASCII_PUNCT[point] === 1;
514
+ return RE_UNICODE_PUNCT.test(String.fromCodePoint(point));
515
+ }
516
+
517
+ /**
518
+ * The whole code point ending at `pos`, so a run preceded by an astral
519
+ * symbol classifies on the symbol and not on a surrogate half.
520
+ * @param {string} src @param {number} pos
521
+ * @returns {number}
522
+ */
523
+ export function codePointBefore(src, pos) {
524
+ const low = src.charCodeAt(pos - 1);
525
+ if (low >= 0xdc00 && low <= 0xdfff && pos >= 2) {
526
+ const high = src.charCodeAt(pos - 2);
527
+ if (high >= 0xd800 && high <= 0xdbff) return (high - 0xd800) * 0x400 + low - 0xdc00 + 0x10000;
528
+ }
529
+ return low;
530
+ }
531
+
532
+ /**
533
+ * Normalize a link label for matching: trim, collapse internal
534
+ * whitespace runs to one space, and case fold.
535
+ *
536
+ * The fold is lower→upper→lower, not `toLowerCase()`: the spec asks for
537
+ * Unicode case folding, under which `ẞ` matches `SS`, while lower-casing
538
+ * alone maps `ẞ` to `ß` and never meets `ss`. The round trip routes both
539
+ * spellings through the same expansion (`ẞ`→`ß`→`SS`→`ss`, and `fi`→`fi`),
540
+ * which is as close to the full fold as a zero-dependency package gets
541
+ * without shipping the table.
542
+ * @param {string} label
543
+ * @returns {string}
544
+ */
545
+ export function normalizeLabel(label) {
546
+ return label.trim().replace(/[ \t\n]+/g, ' ').toLowerCase().toUpperCase().toLowerCase();
547
+ }
548
+
549
+ // ------------------------------------------------------------------
550
+ // GFM footnotes
551
+ // ------------------------------------------------------------------
552
+
553
+ /**
554
+ * A footnote label: `[^` + one or more characters that are not `]`, `[`
555
+ * or whitespace. The no-whitespace rule is the reference
556
+ * implementation's and it applies to BOTH sides — a definition and a
557
+ * reference are recognized by the same grammar, so `[^my note]` is
558
+ * neither, rather than one without the other (MD-FORMAT.md §4.6).
559
+ * Returns the offset of the `]`, or -1.
560
+ * @param {string} text
561
+ * @param {number} start offset of the `[`
562
+ * @returns {number}
563
+ */
564
+ function scanFootnoteLabel(text, start) {
565
+ if (text.charCodeAt(start) !== 0x5B /* [ */ || text.charCodeAt(start + 1) !== 0x5E /* ^ */) {
566
+ return -1;
567
+ }
568
+ let i = start + 2;
569
+ while (i < text.length) {
570
+ const c = text.charCodeAt(i);
571
+ if (c === 0x5D /* ] */) return i > start + 2 ? i : -1;
572
+ if (c === 0x5B /* [ */ || c === 0x20 || c === 0x09 || c === 0x0A) return -1;
573
+ i++;
574
+ }
575
+ return -1;
576
+ }
577
+
578
+ /**
579
+ * Footnote definition opener: `[^label]:` and the spaces after it.
580
+ * @param {string} line
581
+ * @param {number} start first non-space offset
582
+ * @returns {{ label: string, contentOffset: number } | null}
583
+ */
584
+ export function scanFootnoteDefinition(line, start) {
585
+ const close = scanFootnoteLabel(line, start);
586
+ if (close === -1 || line.charCodeAt(close + 1) !== 0x3A /* : */) return null;
587
+ let i = close + 2;
588
+ while (i < line.length && isSpaceCode(line.charCodeAt(i))) i++;
589
+ return { label: line.slice(start + 2, close), contentOffset: i };
590
+ }
591
+
592
+ /**
593
+ * Footnote reference: `[^label]` in inline text.
594
+ * @param {string} text
595
+ * @param {number} start offset of the `[`
596
+ * @returns {{ label: string, end: number } | null}
597
+ */
598
+ export function scanFootnoteReference(text, start) {
599
+ const close = scanFootnoteLabel(text, start);
600
+ return close === -1 ? null : { label: text.slice(start + 2, close), end: close + 1 };
601
+ }
602
+
603
+ // ------------------------------------------------------------------
604
+ // GFM autolink literals
605
+ // ------------------------------------------------------------------
606
+
607
+ /** Is this an ASCII letter or digit? */
608
+ function isAsciiAlnum(c) {
609
+ return (c >= 0x30 && c <= 0x39) || (c >= 0x41 && c <= 0x5A) || (c >= 0x61 && c <= 0x7A);
610
+ }
611
+
612
+ /**
613
+ * A character that may carry a domain: alphanumerics, `-` and `_`, plus
614
+ * any non-ASCII character that is neither whitespace nor punctuation
615
+ * (so an internationalized domain autolinks and an em dash after one
616
+ * does not).
617
+ * @param {number} c
618
+ * @returns {boolean}
619
+ */
620
+ function isDomainChar(c) {
621
+ if (c < 128) return isAsciiAlnum(c) || c === 0x2D /* - */ || c === 0x5F /* _ */;
622
+ return !isUnicodeWhitespace(c) && !isUnicodePunctuation(c);
623
+ }
624
+
625
+ /** An email local-part character: alphanumeric, `.`, `-`, `_` or `+`. */
626
+ function isEmailLocalChar(c) {
627
+ return isAsciiAlnum(c)
628
+ || c === 0x2E /* . */ || c === 0x2D /* - */ || c === 0x5F /* _ */ || c === 0x2B /* + */;
629
+ }
630
+
631
+ /**
632
+ * A literal autolink may only begin at the start of the text, after
633
+ * whitespace, or after one of `*`, `_`, `~`, `(` (GFM §Autolinks). The
634
+ * start of a text node counts: what precedes it is a sibling node, not a
635
+ * character, and a `www.` there is as unambiguous as one after a space.
636
+ * @param {string} text @param {number} pos
637
+ * @returns {boolean}
638
+ */
639
+ function isAutolinkStart(text, pos) {
640
+ if (pos === 0) return true;
641
+ const c = text.charCodeAt(pos - 1);
642
+ return c === 0x20 || c === 0x09 || c === 0x0A
643
+ || c === 0x2A /* * */ || c === 0x5F /* _ */ || c === 0x7E /* ~ */ || c === 0x28 /* ( */;
644
+ }
645
+
646
+ /**
647
+ * A valid domain at `pos`: segments of domain characters separated by
648
+ * periods, at least one period, no underscore in the last two segments.
649
+ * A trailing period is not part of the domain. Returns the end offset,
650
+ * or -1.
651
+ *
652
+ * `underscores` relaxes the last-two-segments rule for the email
653
+ * grammar, which states only that the last character may not be `-` or
654
+ * `_` — the two grammars really do differ, and `foo@a_b.example` is a
655
+ * link while `www.a_b.example` is not.
656
+ * @param {string} text @param {number} pos @param {boolean} underscores
657
+ * @returns {number}
658
+ */
659
+ function scanAutolinkDomain(text, pos, underscores) {
660
+ let i = pos;
661
+ while (i < text.length) {
662
+ const c = text.charCodeAt(i);
663
+ if (c !== 0x2E /* . */ && !isDomainChar(c)) break;
664
+ i++;
665
+ }
666
+ while (i > pos && text.charCodeAt(i - 1) === 0x2E) i--;
667
+ if (i === pos) return -1;
668
+ const segments = text.slice(pos, i).split('.');
669
+ if (segments.length < 2 || segments[segments.length - 1] === '') return -1;
670
+ if (!underscores) {
671
+ if (segments[segments.length - 1].indexOf('_') !== -1) return -1;
672
+ if (segments[segments.length - 2].indexOf('_') !== -1) return -1;
673
+ }
674
+ return i;
675
+ }
676
+
677
+ /**
678
+ * Extended autolink path validation (GFM §Autolinks): pull trailing
679
+ * punctuation back out of the link. `?!.,:*_~` always; a `)` only while
680
+ * the link holds more of them than `(`; a `;` only when it closes an
681
+ * entity-shaped tail (`&copy;`), which is why the whole `&…;` goes and
682
+ * not just the semicolon.
683
+ * @param {string} text @param {number} start @param {number} end
684
+ * @returns {number}
685
+ */
686
+ function trimAutolinkEnd(text, start, end) {
687
+ while (end > start) {
688
+ const c = text.charCodeAt(end - 1);
689
+ if (c === 0x3F || c === 0x21 || c === 0x2E || c === 0x2C
690
+ || c === 0x3A || c === 0x2A || c === 0x5F || c === 0x7E) {
691
+ end--;
692
+ continue;
693
+ }
694
+ if (c === 0x3B /* ; */) {
695
+ let j = end - 2;
696
+ while (j > start && isAsciiAlnum(text.charCodeAt(j))) j--;
697
+ if (j < end - 2 && text.charCodeAt(j) === 0x26 /* & */) {
698
+ end = j;
699
+ continue;
700
+ }
701
+ break;
702
+ }
703
+ if (c === 0x29 /* ) */) {
704
+ let open = 0;
705
+ let close = 0;
706
+ for (let k = start; k < end; k++) {
707
+ const d = text.charCodeAt(k);
708
+ if (d === 0x28) open++;
709
+ else if (d === 0x29) close++;
710
+ }
711
+ if (close <= open) break;
712
+ end--;
713
+ continue;
714
+ }
715
+ break;
716
+ }
717
+ return end;
718
+ }
719
+
720
+ /** The URL tail after a domain: any run of non-space, non-`<` characters. */
721
+ function scanAutolinkTail(text, pos) {
722
+ let i = pos;
723
+ while (i < text.length) {
724
+ const c = text.charCodeAt(i);
725
+ if (c === 0x20 || c === 0x09 || c === 0x0A || c === 0x3C /* < */) break;
726
+ i++;
727
+ }
728
+ return i;
729
+ }
730
+
731
+ /**
732
+ * The schemes that open an extended url autolink. Matching is
733
+ * case-SENSITIVE, here and for `www.`: the reference implementation
734
+ * compares bytes, so `WWW.EXAMPLE.COM` is not a link on GitHub either,
735
+ * and this package's promise is that a document renders the same in
736
+ * both places — not that it renders more.
737
+ */
738
+ const AUTOLINK_SCHEMES = ['http://', 'https://', 'ftp://'];
739
+
740
+ /**
741
+ * Find every extended autolink in one text value (GFM §Autolinks): bare
742
+ * `www.…`, `http://…`, `https://…`, `ftp://…` and email addresses.
743
+ * Returns the matches in order, or `null` when there are none — the
744
+ * common answer, and the one that costs nothing.
745
+ *
746
+ * This works on a TEXT VALUE and not on the source, which is what makes
747
+ * the entity rule meaningful: `&copy;` has already become `©` by the
748
+ * time we look, so the only `&…;` left to exclude is one that was never
749
+ * an entity in the first place.
750
+ * @param {string} value
751
+ * @returns {{ start: number, end: number, url: string }[] | null}
752
+ */
753
+ export function scanAutolinkLiterals(value) {
754
+ /** @type {{ start: number, end: number, url: string }[] | null} */
755
+ let out = null;
756
+ let i = 0;
757
+ let floor = 0;
758
+ while (i < value.length) {
759
+ const c = value.charCodeAt(i);
760
+ /** @type {{ start: number, end: number, url: string } | null} */
761
+ let hit = null;
762
+ if (c === 0x40 /* @ */) {
763
+ hit = matchEmail(value, i, floor);
764
+ }
765
+ else if (c === 0x77 /* w */ && isAutolinkStart(value, i)) {
766
+ hit = matchWww(value, i);
767
+ }
768
+ else if ((c === 0x68 /* h */ || c === 0x66 /* f */) && isAutolinkStart(value, i)) {
769
+ hit = matchScheme(value, i);
770
+ }
771
+ if (hit === null) {
772
+ i++;
773
+ continue;
774
+ }
775
+ if (out === null) out = [];
776
+ out.push(hit);
777
+ i = hit.end;
778
+ floor = hit.end;
779
+ }
780
+ return out;
781
+ }
782
+
783
+ /**
784
+ * `www.` + a valid domain + a path tail; the scheme is inserted, so the
785
+ * AST holds the destination a browser would follow and no consumer has
786
+ * to re-derive it.
787
+ * @param {string} value @param {number} pos
788
+ */
789
+ function matchWww(value, pos) {
790
+ if (!value.startsWith('www.', pos)) return null;
791
+ const domain = scanAutolinkDomain(value, pos + 4, false);
792
+ if (domain === -1) return null;
793
+ const end = trimAutolinkEnd(value, pos, scanAutolinkTail(value, domain));
794
+ if (end <= pos + 4) return null;
795
+ return { start: pos, end, url: 'http://' + value.slice(pos, end) };
796
+ }
797
+
798
+ /**
799
+ * `http://`, `https://` or `ftp://` + a valid domain + a path tail.
800
+ * @param {string} value @param {number} pos
801
+ */
802
+ function matchScheme(value, pos) {
803
+ for (let s = 0; s < AUTOLINK_SCHEMES.length; s++) {
804
+ const scheme = AUTOLINK_SCHEMES[s];
805
+ if (!value.startsWith(scheme, pos)) continue;
806
+ const domain = scanAutolinkDomain(value, pos + scheme.length, false);
807
+ if (domain === -1) continue;
808
+ const end = trimAutolinkEnd(value, pos, scanAutolinkTail(value, domain));
809
+ if (end <= pos + scheme.length) continue;
810
+ return { start: pos, end, url: value.slice(pos, end) };
811
+ }
812
+ return null;
813
+ }
814
+
815
+ /**
816
+ * An email address around the `@` at `at`. The local part is found by
817
+ * walking BACK — the address is the only autolink whose start is left of
818
+ * its trigger — and never back past `floor`, the end of the previous
819
+ * match. The path-validation trim does not apply: the grammar rejects an
820
+ * address ending in `-` or `_` outright rather than shortening it.
821
+ * @param {string} value @param {number} at @param {number} floor
822
+ */
823
+ function matchEmail(value, at, floor) {
824
+ let start = at;
825
+ while (start > floor && isEmailLocalChar(value.charCodeAt(start - 1))) start--;
826
+ if (start === at || !isAutolinkStart(value, start)) return null;
827
+ const end = scanAutolinkDomain(value, at + 1, true);
828
+ if (end === -1) return null;
829
+ const last = value.charCodeAt(end - 1);
830
+ if (last === 0x2D /* - */ || last === 0x5F /* _ */) return null;
831
+ return { start, end, url: 'mailto:' + value.slice(start, end) };
832
+ }