@texuli/core 0.4.0 → 0.6.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 (53) hide show
  1. package/README.md +30 -6
  2. package/dist/editor/document.js +8 -1
  3. package/dist/editor/extensions.d.ts +64 -0
  4. package/dist/editor/extensions.js +116 -0
  5. package/dist/editor/inline.d.ts +5 -3
  6. package/dist/editor/inline.js +24 -12
  7. package/dist/editor/load.d.ts +10 -0
  8. package/dist/editor/load.js +16 -0
  9. package/dist/editor/markdown.d.ts +10 -1
  10. package/dist/editor/markdown.js +340 -100
  11. package/dist/editor/model.d.ts +3 -1
  12. package/dist/editor/model.js +3 -1
  13. package/dist/editor/transforms.d.ts +1 -1
  14. package/dist/editor/tree.d.ts +11 -1
  15. package/dist/editor/tree.js +11 -2
  16. package/dist/editor/types.d.ts +10 -0
  17. package/dist/editor/types.js +1 -0
  18. package/dist/focus-scope/focus-scope.d.ts +7 -0
  19. package/dist/focus-scope/focus-scope.js +24 -0
  20. package/dist/index.d.ts +1 -0
  21. package/dist/index.js +1 -0
  22. package/dist/inert/inert.d.ts +11 -0
  23. package/dist/inert/inert.js +13 -4
  24. package/dist/markup/index.d.ts +5 -0
  25. package/dist/markup/index.js +5 -0
  26. package/dist/markup/markup.d.ts +72 -0
  27. package/dist/markup/markup.js +233 -0
  28. package/dist/motion/animate.d.ts +109 -0
  29. package/dist/motion/animate.js +225 -0
  30. package/dist/motion/flip.d.ts +58 -0
  31. package/dist/motion/flip.js +167 -0
  32. package/dist/motion/index.d.ts +15 -0
  33. package/dist/motion/index.js +15 -0
  34. package/dist/motion/preferences.d.ts +68 -0
  35. package/dist/motion/preferences.js +111 -0
  36. package/dist/motion/presence.d.ts +61 -0
  37. package/dist/motion/presence.js +231 -0
  38. package/dist/motion/sequence.d.ts +70 -0
  39. package/dist/motion/sequence.js +159 -0
  40. package/dist/motion/shared.d.ts +45 -0
  41. package/dist/motion/shared.js +144 -0
  42. package/dist/motion/spring.d.ts +109 -0
  43. package/dist/motion/spring.js +166 -0
  44. package/dist/overlay/core.js +16 -3
  45. package/dist/url-policy/filter-image-srcset.d.ts +11 -0
  46. package/dist/url-policy/filter-image-srcset.js +60 -0
  47. package/dist/url-policy/index.d.ts +1 -0
  48. package/dist/url-policy/index.js +1 -0
  49. package/dist/zip/index.d.ts +5 -0
  50. package/dist/zip/index.js +5 -0
  51. package/dist/zip/zip.d.ts +35 -0
  52. package/dist/zip/zip.js +190 -0
  53. package/package.json +6 -2
@@ -1,4 +1,5 @@
1
1
  import { normalizeDocument, paragraph } from "./document.js";
2
+ import { acceptsExtensionId, EDITOR_EXTENSION_LIMITS, isExtensionIdChar, toInlineExtensions, } from "./extensions.js";
2
3
  import { markKey, normalizeInline, sortMarks } from "./inline.js";
3
4
  import { LoadBudget } from "./load.js";
4
5
  /**
@@ -14,16 +15,62 @@ export const MARKDOWN_CODEC_LIMITS = Object.freeze({
14
15
  /** Nested emphasis and links. */
15
16
  inlineDepth: 6,
16
17
  });
17
- const FENCE = /^ {0,3}(`{3,}|~{3,})\s*([a-z][a-z0-9+-]{0,31})?\s*$/i;
18
- const HEADING = /^ {0,3}(#{1,6})(?:[ \t]+(.*?))?[ \t]*#*[ \t]*$/;
18
+ // Line patterns stay linear on hostile lines: no two neighboring quantifiers
19
+ // can match the same characters, and `.` uses the `s` flag so U+2028 and
20
+ // U+2029 inside a line never make a `(.*)$` tail backtrack. The fence info
21
+ // string and the heading closing sequence are read by hand (their regex
22
+ // forms backtracked quadratically, and cubically, on runs of spaces).
23
+ const FENCE_OPEN = /^ {0,3}(`{3,}|~{3,})/;
24
+ const FENCE_LANG = /^[a-z][a-z0-9+-]{0,31}$/i;
25
+ const HEADING_OPEN = /^ {0,3}(#+)/;
19
26
  const RULE = /^ {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*$/;
20
- const QUOTE = /^ {0,3}> ?(.*)$/;
21
- const BULLET = /^( {0,3})([-*+])[ \t]+(.*)$/;
22
- const ORDERED = /^( {0,3})(\d{1,9})[.)][ \t]+(.*)$/;
27
+ const QUOTE = /^ {0,3}> ?(.*)$/s;
28
+ const BULLET = /^( {0,3})([-*+])[ \t]+(.*)$/s;
29
+ const ORDERED = /^( {0,3})(\d{1,9})[.)][ \t]+(.*)$/s;
23
30
  const TASK = /^\[([ xX])\][ \t]+/;
24
31
  const IMAGE_ONLY = /^!\[((?:[^\]\\\n]|\\.)*)\]\(\s*<?([^\s()<>]+)>?\s*\)$/;
25
32
  const ESCAPABLE = /[\\`*_{}[\]()#+\-.!~<>|]/;
26
33
  const isBlank = (line) => line.trim() === '';
34
+ const isSpace = (char) => char === ' ' || char === '\t';
35
+ /** A fence opening line: its marker and the optional language. */
36
+ function readFence(line) {
37
+ const open = FENCE_OPEN.exec(line);
38
+ if (!open)
39
+ return null;
40
+ const info = line.slice(open[0].length).trim();
41
+ if (info !== '' && !FENCE_LANG.test(info))
42
+ return null;
43
+ return info
44
+ ? { marker: open[1], lang: info.toLowerCase() }
45
+ : { marker: open[1] };
46
+ }
47
+ /**
48
+ * An ATX heading line: up to 6 `#` (a 7th or later reads as part of the
49
+ * closing sequence), then text after a space or tab, with the closing
50
+ * sequence of spaces, `#` and spaces dropped from the end.
51
+ */
52
+ function readHeading(line) {
53
+ const open = HEADING_OPEN.exec(line);
54
+ if (!open)
55
+ return null;
56
+ const hashes = open[1];
57
+ const level = Math.min(hashes.length, 6);
58
+ const rest = line.slice(open[0].length - hashes.length + level);
59
+ let start = 0;
60
+ while (isSpace(rest[start]))
61
+ start += 1;
62
+ let end = rest.length;
63
+ while (end > start && isSpace(rest[end - 1]))
64
+ end -= 1;
65
+ while (end > start && rest[end - 1] === '#')
66
+ end -= 1;
67
+ while (end > start && isSpace(rest[end - 1]))
68
+ end -= 1;
69
+ // Without a space or tab after the opening, only a closing sequence may follow.
70
+ if (start === 0 && end > 0)
71
+ return null;
72
+ return { level, text: rest.slice(start, end) };
73
+ }
27
74
  function listMarker(line) {
28
75
  const bullet = BULLET.exec(line);
29
76
  if (bullet)
@@ -33,8 +80,8 @@ function listMarker(line) {
33
80
  return { ordered: true, start: Number(ordered[2]), content: ordered[3] ?? '' };
34
81
  return null;
35
82
  }
36
- const startsBlock = (line) => FENCE.test(line) ||
37
- HEADING.test(line) ||
83
+ const startsBlock = (line) => readFence(line) !== null ||
84
+ readHeading(line) !== null ||
38
85
  RULE.test(line) ||
39
86
  QUOTE.test(line) ||
40
87
  listMarker(line) !== null;
@@ -84,9 +131,9 @@ class Reader {
84
131
  i += 1;
85
132
  continue;
86
133
  }
87
- const fence = FENCE.exec(line);
134
+ const fence = readFence(line);
88
135
  if (fence) {
89
- const marker = fence[1] ?? '```';
136
+ const marker = fence.marker;
90
137
  const body = [];
91
138
  i += 1;
92
139
  while (i < lines.length && !(lines[i] ?? '').trim().startsWith(marker)) {
@@ -94,7 +141,7 @@ class Reader {
94
141
  i += 1;
95
142
  }
96
143
  i += 1;
97
- const lang = fence[2]?.toLowerCase();
144
+ const lang = fence.lang;
98
145
  push(() => {
99
146
  const code = this.budget.text(this.budget.clean(body.join('\n'), '\n\t'));
100
147
  return lang
@@ -103,16 +150,16 @@ class Reader {
103
150
  });
104
151
  continue;
105
152
  }
106
- const heading = HEADING.exec(line);
153
+ const heading = readHeading(line);
107
154
  if (heading) {
108
- const level = (heading[1] ?? '#').length;
155
+ const level = heading.level;
109
156
  if (level > 4)
110
157
  this.budget.report('heading-clamped', 'Headings deeper than level 4 became level 4.');
111
158
  push(() => ({
112
159
  id: this.budget.newId(),
113
160
  type: 'heading',
114
161
  level: Math.min(level, 4),
115
- content: trimEdges(this.inline((heading[2] ?? '').trim())),
162
+ content: trimEdges(this.inline(heading.text.trim())),
116
163
  }));
117
164
  i += 1;
118
165
  continue;
@@ -253,9 +300,51 @@ class Scan {
253
300
  source;
254
301
  closers = new Map();
255
302
  brackets = null;
303
+ tokens = null;
256
304
  constructor(source) {
257
305
  this.source = source;
258
306
  }
307
+ /**
308
+ * The extension token `<sigil>{id|label}` whose sigil is at `at`, or null.
309
+ * Two linear passes the first time (the id-character run from each index,
310
+ * and the next unescaped `}` before a newline), then constant time.
311
+ */
312
+ extension(at) {
313
+ const { source } = this;
314
+ if (source[at + 1] !== '{')
315
+ return null;
316
+ if (!this.tokens) {
317
+ const n = source.length;
318
+ const idRun = new Int32Array(n + 1);
319
+ for (let j = n - 1; j >= 0; j -= 1)
320
+ idRun[j] = isExtensionIdChar(source.charCodeAt(j)) ? idRun[j + 1] + 1 : 0;
321
+ const escaped = new Uint8Array(n);
322
+ for (let j = 0; j < n; j += 1) {
323
+ if (source[j] === '\\') {
324
+ if (j + 1 < n)
325
+ escaped[j + 1] = 1;
326
+ j += 1;
327
+ }
328
+ }
329
+ const close = new Int32Array(n + 1).fill(-1);
330
+ for (let j = n - 1; j >= 0; j -= 1) {
331
+ const c = source[j];
332
+ close[j] = c === '\n' ? -1 : c === '}' && !escaped[j] ? j : close[j + 1];
333
+ }
334
+ this.tokens = { idRun, close };
335
+ }
336
+ const idStart = at + 2;
337
+ const idEnd = idStart + (this.tokens.idRun[idStart] ?? 0);
338
+ if (idEnd === idStart || idEnd - idStart > EDITOR_EXTENSION_LIMITS.id)
339
+ return null;
340
+ if (source[idEnd] !== '|')
341
+ return null;
342
+ const labelStart = idEnd + 1;
343
+ const labelEnd = this.tokens.close[labelStart] ?? -1;
344
+ if (labelEnd <= labelStart || labelEnd - labelStart > EDITOR_EXTENSION_LIMITS.label)
345
+ return null;
346
+ return { idStart, idEnd, labelStart, labelEnd, end: labelEnd + 1 };
347
+ }
259
348
  /** The first valid closer of `marker` at or after `from`, or -1. */
260
349
  closer(marker, from) {
261
350
  let table = this.closers.get(marker);
@@ -327,16 +416,31 @@ class Scan {
327
416
  /** Inline Markdown into runs; `marks` are the marks open around `source`. */
328
417
  function parseInline(source, depth, marks, inLink, out, budget) {
329
418
  let buffer = '';
419
+ // Spaces at the end of `buffer`, kept as it grows: reading the buffer's
420
+ // last characters would flatten the string being built on every soft line
421
+ // break (quadratic in a long paragraph).
422
+ let spaces = 0;
423
+ const append = (text) => {
424
+ buffer += text;
425
+ let end = text.length;
426
+ while (end > 0 && text.charCodeAt(end - 1) === 32)
427
+ end -= 1;
428
+ spaces = end === 0 ? spaces + text.length : text.length - end;
429
+ };
330
430
  const flush = () => {
331
431
  if (buffer)
332
432
  out.push(...budget.inline(buffer.replace(/\n/g, ' '), marks));
333
433
  buffer = '';
434
+ spaces = 0;
334
435
  };
335
436
  const push = (text, extra) => {
336
437
  flush();
337
438
  out.push(...budget.inline(text, sortMarks([...marks, ...extra])));
338
439
  };
339
440
  const scan = new Scan(source);
441
+ const extensions = budget.extensions;
442
+ // The characters that can start an inline construct, registered sigils included.
443
+ const special = extensions.special;
340
444
  let i = 0;
341
445
  while (i < source.length) {
342
446
  const ch = source[i] ?? '';
@@ -350,23 +454,36 @@ function parseInline(source, depth, marks, inLink, out, budget) {
350
454
  continue;
351
455
  }
352
456
  if (ESCAPABLE.test(next)) {
353
- buffer += next;
457
+ append(next);
354
458
  i += 2;
355
459
  continue;
356
460
  }
357
461
  }
358
462
  if (ch === '\n') {
359
- if (/ {2,}$/.test(buffer)) {
360
- buffer = buffer.replace(/ +$/, '');
463
+ // Two or more trailing spaces make a hard break.
464
+ if (spaces >= 2) {
465
+ buffer = buffer.slice(0, buffer.length - spaces);
361
466
  flush();
362
467
  out.push({ type: 'break' });
363
468
  budget.chars += 1;
364
469
  }
365
470
  else
366
- buffer += '\n';
471
+ append('\n');
367
472
  i += 1;
368
473
  continue;
369
474
  }
475
+ const extension = inLink ? undefined : extensions.bySigil.get(ch);
476
+ if (extension) {
477
+ const token = scan.extension(i);
478
+ const id = token ? source.slice(token.idStart, token.idEnd) : '';
479
+ if (token && acceptsExtensionId(extensions, extension.name, id)) {
480
+ push(unescapeMarkdown(source.slice(token.labelStart, token.labelEnd)), [
481
+ { type: 'extension', name: extension.name, id },
482
+ ]);
483
+ i = token.end;
484
+ continue;
485
+ }
486
+ }
370
487
  if (ch === '`') {
371
488
  const run = /^`+/.exec(source.slice(i, i + 64))?.[0] ?? '`';
372
489
  const close = source.indexOf(run, i + run.length);
@@ -376,7 +493,7 @@ function parseInline(source, depth, marks, inLink, out, budget) {
376
493
  i = close + run.length;
377
494
  continue;
378
495
  }
379
- buffer += run;
496
+ append(run);
380
497
  i += run.length;
381
498
  continue;
382
499
  }
@@ -392,7 +509,7 @@ function parseInline(source, depth, marks, inLink, out, budget) {
392
509
  if (ch === '!' && source[i + 1] === '[') {
393
510
  const image = matchLink(scan, i + 1);
394
511
  if (image) {
395
- buffer += unescapeMarkdown(source.slice(image.labelStart, image.labelEnd));
512
+ append(unescapeMarkdown(source.slice(image.labelStart, image.labelEnd)));
396
513
  i = image.end;
397
514
  continue;
398
515
  }
@@ -418,8 +535,12 @@ function parseInline(source, depth, marks, inLink, out, budget) {
418
535
  }
419
536
  }
420
537
  }
421
- buffer += ch;
422
- i += 1;
538
+ // Plain text up to the next character that can open a construct is
539
+ // copied in one slice, not one character at a time.
540
+ special.lastIndex = i + 1;
541
+ const stop = special.test(source) ? special.lastIndex - 1 : source.length;
542
+ append(source.slice(i, stop));
543
+ i = stop;
423
544
  }
424
545
  flush();
425
546
  }
@@ -453,19 +574,92 @@ function matchLink(scan, at) {
453
574
  const labelEnd = scan.bracket(at);
454
575
  if (labelEnd === -1 || source[labelEnd + 1] !== '(')
455
576
  return null;
456
- const target = /^\(\s*<?((?:[^\s()<>]|\([^\s()<>]*\))*)>?(?:\s+"[^"\n]*")?\s*\)/.exec(source.slice(labelEnd + 1, labelEnd + 1 + 2_100));
577
+ // A target is read from at most 2,100 characters after the label.
578
+ const target = readLinkTarget(source.slice(labelEnd + 1, labelEnd + 1 + 2_100), 0);
457
579
  if (!target)
458
580
  return null;
459
- return {
460
- labelStart: at + 1,
461
- labelEnd,
462
- href: target[1] ?? '',
463
- end: labelEnd + 1 + target[0].length,
581
+ return { labelStart: at + 1, labelEnd, href: target.href, end: labelEnd + 1 + target.end };
582
+ }
583
+ const isWhitespace = (char) => char !== undefined && /\s/.test(char);
584
+ /** A target character: not whitespace, a parenthesis or an angle bracket. */
585
+ const isTargetChar = (char) => char !== undefined && !/[\s()<>]/.test(char);
586
+ /**
587
+ * Reads a link target `(<href> "title")` whose `(` is at `open`, by hand.
588
+ * It returns what `/\(\s*<?((?:[^\s()<>]|\([^\s()<>]*\))*)>?(?:\s+"[^"\n]*")?\s*\)/y`
589
+ * returned (the href and the index after `)`), but in linear time: in that
590
+ * regex `\(\s*`, `\s+"` and `\s*\)` competed for one whitespace run, so
591
+ * `[a](` + 3,990 spaces + `x` took about 250 ms.
592
+ */
593
+ function readLinkTarget(source, open) {
594
+ // The optional title and the closing parenthesis from `from`: the index
595
+ // after `)`, or -1.
596
+ const close = (from) => {
597
+ let at = from;
598
+ while (isWhitespace(source[at]))
599
+ at += 1;
600
+ if (source[at] === ')')
601
+ return at + 1;
602
+ if (at === from || source[at] !== '"')
603
+ return -1;
604
+ at += 1;
605
+ while (at < source.length && source[at] !== '"' && source[at] !== '\n')
606
+ at += 1;
607
+ if (source[at] !== '"')
608
+ return -1;
609
+ at += 1;
610
+ while (isWhitespace(source[at]))
611
+ at += 1;
612
+ return source[at] === ')' ? at + 1 : -1;
464
613
  };
614
+ let at = open + 1;
615
+ while (isWhitespace(source[at]))
616
+ at += 1;
617
+ const afterSpace = at;
618
+ if (source[at] === '<')
619
+ at += 1;
620
+ const hrefStart = at;
621
+ // Plain characters and balanced `(…)` groups without whitespace inside.
622
+ for (;;) {
623
+ if (isTargetChar(source[at]))
624
+ at += 1;
625
+ else if (source[at] === '(') {
626
+ let inner = at + 1;
627
+ while (isTargetChar(source[inner]))
628
+ inner += 1;
629
+ if (source[inner] !== ')')
630
+ break;
631
+ at = inner + 1;
632
+ }
633
+ else
634
+ break;
635
+ }
636
+ const href = source.slice(hrefStart, at);
637
+ if (source[at] === '>')
638
+ at += 1;
639
+ const end = close(at);
640
+ if (end !== -1)
641
+ return { href, end };
642
+ // The regex backtracked into the leading whitespace: `( "title")` is an
643
+ // empty href with a title.
644
+ if (afterSpace > open + 1) {
645
+ const titled = close(open + 1);
646
+ if (titled !== -1)
647
+ return { href: '', end: titled };
648
+ }
649
+ return null;
465
650
  }
466
651
  // ---------------------------------------------------------------------------
467
652
  // Writing
468
653
  const escapeText = (text) => text.replace(/[\\`*_[\]~<!]/g, '\\$&');
654
+ /** Plain text: a registered sigil before `{` would read as a token, so `{` is escaped. */
655
+ const escapePlain = (text, extensions) => extensions.literal ? escapeText(text).replace(extensions.literal, '$1\\{') : escapeText(text);
656
+ /** An extension token: the label escaped so no closer or delimiter inside it counts. */
657
+ function extensionToken(mark, label, extensions) {
658
+ const extension = extensions.byName.get(mark.name);
659
+ if (!extension)
660
+ return null;
661
+ return `${extension.sigil}{${mark.id}|${escapeText(label).replace(/[{}]/g, '\\$&')}}`;
662
+ }
469
663
  /** A line that would start another block gets its first marker escaped. */
470
664
  function escapeLineStart(line) {
471
665
  const ordered = /^( {0,3}\d{1,9})([.)])([ \t]|$)/.exec(line);
@@ -483,69 +677,98 @@ function codeSpan(text) {
483
677
  const pad = text.startsWith('`') || text.endsWith('`') || /^ .* $/.test(text) ? ' ' : '';
484
678
  return `${fence}${pad}${text}${pad}${fence}`;
485
679
  }
486
- /** Move whitespace and trailing backslashes out of strong, em and strike spans (Markdown cannot open or close there). */
680
+ /**
681
+ * Move whitespace and trailing backslashes out of strong, em and strike spans
682
+ * (Markdown cannot open or close there). Works on runs, splitting a run only
683
+ * where a span edge moves: one pass per mark type, no object per character.
684
+ */
487
685
  function prepareMarks(content) {
488
- const chars = [];
489
- for (const node of content) {
490
- if (node.type === 'break')
491
- chars.push(null);
492
- else
493
- for (const ch of node.text)
494
- chars.push({ ch, marks: node.marks });
495
- }
686
+ let runs = content.flatMap((node) => node.type === 'break' ? [null] : node.text ? [{ text: node.text, marks: node.marks }] : []);
496
687
  for (const type of ['strong', 'em', 'strike']) {
688
+ const marked = (run) => run?.marks.some((m) => m.type === type) === true;
689
+ const strip = (run, text) => ({
690
+ text,
691
+ marks: run.marks.filter((m) => m.type !== type),
692
+ });
693
+ const out = [];
497
694
  let i = 0;
498
- const marked = (c) => c?.marks.some((m) => m.type === type) === true;
499
- // A break inside a span (marked text on both sides) does not end it.
500
- const has = (k) => {
501
- if (chars[k] !== null)
502
- return marked(chars[k]);
503
- let left = k - 1;
504
- while (chars[left] === null)
505
- left -= 1;
506
- let right = k + 1;
507
- while (chars[right] === null)
508
- right += 1;
509
- return left >= 0 && marked(chars[left]) && marked(chars[right]);
510
- };
511
- while (i < chars.length) {
512
- if (!has(i)) {
695
+ while (i < runs.length) {
696
+ if (!marked(runs[i])) {
697
+ out.push(runs[i]);
513
698
  i += 1;
514
699
  continue;
515
700
  }
701
+ // A span: marked text, and breaks with marked text on both sides.
516
702
  let end = i;
517
- while (has(end + 1))
518
- end += 1;
519
- const drop = (k) => {
520
- const c = chars[k];
521
- chars[k] = { ch: c.ch, marks: c.marks.filter((m) => m.type !== type) };
522
- };
523
- let a = i;
524
- while (a <= end && chars[a] && /\s/.test(chars[a].ch))
525
- drop(a++);
526
- let b = end;
527
- while (b >= a && chars[b] && /[\s\\]/.test(chars[b].ch))
528
- drop(b--);
703
+ for (let k = i + 1; k < runs.length; k += 1) {
704
+ if (runs[k] === null)
705
+ continue;
706
+ if (!marked(runs[k]))
707
+ break;
708
+ end = k;
709
+ }
710
+ const span = runs.slice(i, end + 1);
711
+ // Leading whitespace leaves the span, up to the first other character
712
+ // or break; then trailing whitespace and backslashes, from the end.
713
+ const head = [];
714
+ const tail = [];
715
+ let a = 0;
716
+ let b = span.length - 1;
717
+ for (; a <= b; a += 1) {
718
+ const run = span[a];
719
+ if (!run)
720
+ break;
721
+ let cut = 0;
722
+ while (/\s/.test(run.text[cut] ?? ''))
723
+ cut += 1;
724
+ if (cut)
725
+ head.push(strip(run, run.text.slice(0, cut)));
726
+ if (cut < run.text.length) {
727
+ span[a] = { text: run.text.slice(cut), marks: run.marks };
728
+ break;
729
+ }
730
+ }
731
+ for (; b >= a; b -= 1) {
732
+ const run = span[b];
733
+ if (!run)
734
+ break;
735
+ let cut = run.text.length;
736
+ while (/[\s\\]/.test(run.text[cut - 1] ?? ''))
737
+ cut -= 1;
738
+ if (cut < run.text.length)
739
+ tail.push(strip(run, run.text.slice(cut)));
740
+ if (cut) {
741
+ span[b] = { text: run.text.slice(0, cut), marks: run.marks };
742
+ break;
743
+ }
744
+ }
745
+ // One push per run: a spread of a long span could overflow the call stack.
746
+ for (const run of [...head, ...span.slice(a, b + 1), ...tail.reverse()])
747
+ out.push(run);
529
748
  i = end + 1;
530
749
  }
750
+ runs = out;
531
751
  }
532
- const out = [];
533
- for (const c of chars)
534
- out.push(c ? { type: 'text', text: c.ch, marks: c.marks } : { type: 'break' });
535
- return normalizeInline(out);
752
+ return normalizeInline(runs.map((run) => (run ? { type: 'text', ...run } : { type: 'break' })));
536
753
  }
537
- function writeInline(content, em) {
754
+ function writeInline(content, em, extensions) {
538
755
  const open = { strong: '**', em, strike: '~~' };
539
- const texts = content.flatMap((node, index) => (node.type === 'text' ? [{ node, index }] : []));
756
+ const texts = content.flatMap((node) => (node.type === 'text' ? [{ node }] : []));
540
757
  const stack = [];
541
- const outer = (marks) => marks.filter((m) => m.type !== 'code');
542
- /** Where the span of `mark` starting at text `t` ends (marks that end later nest outside). */
543
- const spanEnd = (t, mark) => {
544
- let end = t;
545
- while (texts[end + 1]?.node.marks.some((m) => markKey(m) === markKey(mark)))
546
- end += 1;
547
- return end;
548
- };
758
+ // Code spans and extension tokens are leaves: other marks nest around them.
759
+ const outer = (marks) => marks.filter((m) => m.type !== 'code' && m.type !== 'extension');
760
+ // Where the span of each mark starting at text `t` ends (marks that end
761
+ // later nest outside), computed once from the end so long spans stay linear.
762
+ const ends = new Array(texts.length);
763
+ for (let t = texts.length - 1; t >= 0; t -= 1) {
764
+ const here = new Map();
765
+ for (const mark of texts[t].node.marks) {
766
+ const key = markKey(mark);
767
+ here.set(key, ends[t + 1]?.get(key) ?? t);
768
+ }
769
+ ends[t] = here;
770
+ }
771
+ const spanEnd = (t, mark) => ends[t]?.get(markKey(mark)) ?? t;
549
772
  const ordered = (t) => {
550
773
  const marks = outer(texts[t].node.marks);
551
774
  const opened = (mark) => {
@@ -562,7 +785,7 @@ function writeInline(content, em) {
562
785
  const close = (to) => {
563
786
  while (stack.length > to) {
564
787
  const mark = stack.pop();
565
- out += mark.type === 'link' ? `](${encodeRef(mark.href)})` : open[mark.type];
788
+ out += mark.type === 'link' ? `](${encodeRef(mark.href)})` : (open[mark.type] ?? '');
566
789
  }
567
790
  };
568
791
  const keepCommon = (wanted) => {
@@ -575,10 +798,11 @@ function writeInline(content, em) {
575
798
  return common;
576
799
  };
577
800
  let t = 0;
578
- content.forEach((node, index) => {
801
+ content.forEach((node) => {
579
802
  if (node.type === 'break') {
580
803
  // Marks the next text keeps stay open across the break.
581
- const next = texts.findIndex((entry) => entry.index > index);
804
+ // `t` texts are written, so the next one is `texts[t]`.
805
+ const next = t < texts.length ? t : -1;
582
806
  keepCommon(next === -1 ? [] : ordered(next).filter((m) => stack.some((s) => markKey(s) === markKey(m))));
583
807
  out += '\\\n';
584
808
  return;
@@ -586,10 +810,16 @@ function writeInline(content, em) {
586
810
  const wanted = ordered(t);
587
811
  const common = keepCommon(wanted);
588
812
  for (const mark of wanted.slice(common)) {
589
- out += mark.type === 'link' ? '[' : open[mark.type];
813
+ out += mark.type === 'link' ? '[' : (open[mark.type] ?? '');
590
814
  stack.push(mark);
591
815
  }
592
- out += node.marks.some((m) => m.type === 'code') ? codeSpan(node.text) : escapeText(node.text);
816
+ const entity = node.marks.find((m) => m.type === 'extension');
817
+ const token = entity ? extensionToken(entity, node.text, extensions) : null;
818
+ out +=
819
+ token ??
820
+ (node.marks.some((m) => m.type === 'code')
821
+ ? codeSpan(node.text)
822
+ : escapePlain(node.text, extensions));
593
823
  t += 1;
594
824
  });
595
825
  close(0);
@@ -603,8 +833,8 @@ const sameInline = (a, b) => a.length === b.length &&
603
833
  return (node.text === other.text && node.marks.map(markKey).join() === other.marks.map(markKey).join());
604
834
  });
605
835
  /** Read inline Markdown back without caps or policy changes (for the write check). */
606
- function reread(markdown) {
607
- const budget = new LoadBudget({ trust: 'developer' });
836
+ function reread(markdown, extensions) {
837
+ const budget = new LoadBudget({ trust: 'developer', extensions });
608
838
  const out = [];
609
839
  parseInline(markdown, 0, [], false, out, budget);
610
840
  return normalizeInline(out);
@@ -614,11 +844,11 @@ function reread(markdown) {
614
844
  * combination has no faithful spelling in the subset, the result drops the
615
845
  * least significant marks (em, then strike, then strong) until it does.
616
846
  */
617
- function inlineMarkdown(content, lineStart) {
618
- let prepared = prepareMarks(content);
847
+ function inlineMarkdown(content, lineStart, extensions) {
848
+ let prepared = prepareMarks(withRegistered(content, extensions));
619
849
  const attempt = (em) => {
620
- const text = writeInline(prepared, em).split('\n').map(lineStart).join('\n');
621
- return sameInline(reread(text), prepared) ? text : null;
850
+ const text = writeInline(prepared, em, extensions).split('\n').map(lineStart).join('\n');
851
+ return sameInline(reread(text, extensions), prepared) ? text : null;
622
852
  };
623
853
  for (const drop of [null, 'em', 'strike', 'strong']) {
624
854
  if (drop)
@@ -627,25 +857,34 @@ function inlineMarkdown(content, lineStart) {
627
857
  if (text !== null)
628
858
  return text;
629
859
  }
630
- return writeInline(prepared, '*').split('\n').map(lineStart).join('\n');
860
+ return writeInline(prepared, '*', extensions).split('\n').map(lineStart).join('\n');
861
+ }
862
+ /** Extension marks of unregistered names leave (their label stays plain text). */
863
+ function withRegistered(content, extensions) {
864
+ const foreign = (mark) => mark.type === 'extension' && !extensions.byName.has(mark.name);
865
+ if (!content.some((n) => n.type === 'text' && n.marks.some(foreign)))
866
+ return content;
867
+ return normalizeInline(content.map((n) => n.type === 'text' && n.marks.some(foreign)
868
+ ? { ...n, marks: n.marks.filter((m) => !foreign(m)) }
869
+ : n));
631
870
  }
632
871
  const indent = (text, by) => text
633
872
  .split('\n')
634
873
  .map((line) => (line ? by + line : line))
635
874
  .join('\n');
636
- function writeBlocks(blocks) {
875
+ function writeBlocks(blocks, extensions) {
637
876
  return blocks
638
- .map(writeBlock)
877
+ .map((block) => writeBlock(block, extensions))
639
878
  .filter((text) => text !== '')
640
879
  .join('\n\n');
641
880
  }
642
- function writeBlock(block) {
881
+ function writeBlock(block, extensions) {
643
882
  switch (block.type) {
644
883
  case 'paragraph':
645
- return inlineMarkdown(trimEdges(block.content), escapeLineStart);
884
+ return inlineMarkdown(trimEdges(block.content), escapeLineStart, extensions);
646
885
  case 'heading': {
647
886
  const flat = block.content.map((n) => (n.type === 'break' ? { type: 'text', text: ' ', marks: [] } : n));
648
- const text = inlineMarkdown(trimEdges(normalizeInline(flat)), (line) => line);
887
+ const text = inlineMarkdown(trimEdges(normalizeInline(flat)), (line) => line, extensions);
649
888
  return `${'#'.repeat(block.level)}${text ? ` ${text}` : ''}${text.endsWith('#') ? ' #' : ''}`;
650
889
  }
651
890
  case 'code': {
@@ -662,13 +901,13 @@ function writeBlock(block) {
662
901
  ? `- [${item.checked ? 'x' : ' '}] `
663
902
  : '- ';
664
903
  const [first, ...rest] = item.blocks;
665
- const head = first ? indent(writeBlock(first), ' ').trimStart() : '';
666
- const tail = writeBlocks(rest);
904
+ const head = first ? indent(writeBlock(first, extensions), ' ').trimStart() : '';
905
+ const tail = writeBlocks(rest, extensions);
667
906
  return `${marker}${head}${tail ? `\n\n${indent(tail, ' ')}` : ''}`;
668
907
  })
669
908
  .join('\n');
670
909
  case 'quote':
671
- return writeBlocks(block.blocks)
910
+ return writeBlocks(block.blocks, extensions)
672
911
  .split('\n')
673
912
  .map((line) => (line ? `> ${line}` : '>'))
674
913
  .join('\n');
@@ -685,7 +924,8 @@ function trimEdges(content) {
685
924
  out.shift();
686
925
  while (out.at(-1)?.type === 'break')
687
926
  out.pop();
688
- const plain = (node) => node?.type === 'text' && !node.marks.some((mark) => mark.type === 'code');
927
+ const plain = (node) => node?.type === 'text' &&
928
+ !node.marks.some((mark) => mark.type === 'code' || mark.type === 'extension');
689
929
  const first = out[0];
690
930
  if (plain(first))
691
931
  out[0] = { ...first, text: first.text.trimStart() };
@@ -700,6 +940,6 @@ function trimEdges(content) {
700
940
  * paragraphs, breaks in headings (written as spaces), breaks at the edges of
701
941
  * a block, and whitespace at the edges of a paragraph or a mark.
702
942
  */
703
- export function toMarkdown(doc) {
704
- return writeBlocks(doc.blocks);
943
+ export function toMarkdown(doc, options = {}) {
944
+ return writeBlocks(doc.blocks, toInlineExtensions(options.extensions));
705
945
  }