@texuli/core 0.5.0 → 0.7.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 (45) hide show
  1. package/README.md +29 -5
  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 +193 -83
  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/inert/inert.d.ts +11 -0
  19. package/dist/inert/inert.js +5 -3
  20. package/dist/markup/index.d.ts +5 -0
  21. package/dist/markup/index.js +5 -0
  22. package/dist/markup/markup.d.ts +72 -0
  23. package/dist/markup/markup.js +233 -0
  24. package/dist/motion/animate.d.ts +109 -0
  25. package/dist/motion/animate.js +225 -0
  26. package/dist/motion/flip.d.ts +58 -0
  27. package/dist/motion/flip.js +167 -0
  28. package/dist/motion/index.d.ts +15 -0
  29. package/dist/motion/index.js +15 -0
  30. package/dist/motion/preferences.d.ts +68 -0
  31. package/dist/motion/preferences.js +111 -0
  32. package/dist/motion/presence.d.ts +61 -0
  33. package/dist/motion/presence.js +231 -0
  34. package/dist/motion/sequence.d.ts +70 -0
  35. package/dist/motion/sequence.js +159 -0
  36. package/dist/motion/shared.d.ts +45 -0
  37. package/dist/motion/shared.js +144 -0
  38. package/dist/motion/spring.d.ts +109 -0
  39. package/dist/motion/spring.js +166 -0
  40. package/dist/overlay/core.js +14 -3
  41. package/dist/zip/index.d.ts +5 -0
  42. package/dist/zip/index.js +5 -0
  43. package/dist/zip/zip.d.ts +35 -0
  44. package/dist/zip/zip.js +190 -0
  45. 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
  /**
@@ -299,9 +300,51 @@ class Scan {
299
300
  source;
300
301
  closers = new Map();
301
302
  brackets = null;
303
+ tokens = null;
302
304
  constructor(source) {
303
305
  this.source = source;
304
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
+ }
305
348
  /** The first valid closer of `marker` at or after `from`, or -1. */
306
349
  closer(marker, from) {
307
350
  let table = this.closers.get(marker);
@@ -373,16 +416,31 @@ class Scan {
373
416
  /** Inline Markdown into runs; `marks` are the marks open around `source`. */
374
417
  function parseInline(source, depth, marks, inLink, out, budget) {
375
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
+ };
376
430
  const flush = () => {
377
431
  if (buffer)
378
432
  out.push(...budget.inline(buffer.replace(/\n/g, ' '), marks));
379
433
  buffer = '';
434
+ spaces = 0;
380
435
  };
381
436
  const push = (text, extra) => {
382
437
  flush();
383
438
  out.push(...budget.inline(text, sortMarks([...marks, ...extra])));
384
439
  };
385
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;
386
444
  let i = 0;
387
445
  while (i < source.length) {
388
446
  const ch = source[i] ?? '';
@@ -396,17 +454,13 @@ function parseInline(source, depth, marks, inLink, out, budget) {
396
454
  continue;
397
455
  }
398
456
  if (ESCAPABLE.test(next)) {
399
- buffer += next;
457
+ append(next);
400
458
  i += 2;
401
459
  continue;
402
460
  }
403
461
  }
404
462
  if (ch === '\n') {
405
- // Count trailing spaces by hand: an unanchored `/ {2,}$/` rescans the
406
- // whole buffer on every soft line break (quadratic in a long paragraph).
407
- let spaces = 0;
408
- while (buffer.charCodeAt(buffer.length - 1 - spaces) === 32)
409
- spaces += 1;
463
+ // Two or more trailing spaces make a hard break.
410
464
  if (spaces >= 2) {
411
465
  buffer = buffer.slice(0, buffer.length - spaces);
412
466
  flush();
@@ -414,10 +468,22 @@ function parseInline(source, depth, marks, inLink, out, budget) {
414
468
  budget.chars += 1;
415
469
  }
416
470
  else
417
- buffer += '\n';
471
+ append('\n');
418
472
  i += 1;
419
473
  continue;
420
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
+ }
421
487
  if (ch === '`') {
422
488
  const run = /^`+/.exec(source.slice(i, i + 64))?.[0] ?? '`';
423
489
  const close = source.indexOf(run, i + run.length);
@@ -427,7 +493,7 @@ function parseInline(source, depth, marks, inLink, out, budget) {
427
493
  i = close + run.length;
428
494
  continue;
429
495
  }
430
- buffer += run;
496
+ append(run);
431
497
  i += run.length;
432
498
  continue;
433
499
  }
@@ -443,7 +509,7 @@ function parseInline(source, depth, marks, inLink, out, budget) {
443
509
  if (ch === '!' && source[i + 1] === '[') {
444
510
  const image = matchLink(scan, i + 1);
445
511
  if (image) {
446
- buffer += unescapeMarkdown(source.slice(image.labelStart, image.labelEnd));
512
+ append(unescapeMarkdown(source.slice(image.labelStart, image.labelEnd)));
447
513
  i = image.end;
448
514
  continue;
449
515
  }
@@ -469,8 +535,12 @@ function parseInline(source, depth, marks, inLink, out, budget) {
469
535
  }
470
536
  }
471
537
  }
472
- buffer += ch;
473
- 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;
474
544
  }
475
545
  flush();
476
546
  }
@@ -581,6 +651,15 @@ function readLinkTarget(source, open) {
581
651
  // ---------------------------------------------------------------------------
582
652
  // Writing
583
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
+ }
584
663
  /** A line that would start another block gets its first marker escaped. */
585
664
  function escapeLineStart(line) {
586
665
  const ordered = /^( {0,3}\d{1,9})([.)])([ \t]|$)/.exec(line);
@@ -598,71 +677,86 @@ function codeSpan(text) {
598
677
  const pad = text.startsWith('`') || text.endsWith('`') || /^ .* $/.test(text) ? ' ' : '';
599
678
  return `${fence}${pad}${text}${pad}${fence}`;
600
679
  }
601
- /** 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
+ */
602
685
  function prepareMarks(content) {
603
- const chars = [];
604
- for (const node of content) {
605
- if (node.type === 'break')
606
- chars.push(null);
607
- else
608
- for (const ch of node.text)
609
- chars.push({ ch, marks: node.marks });
610
- }
611
- // The nearest character on each side of every position, so a run of
612
- // breaks is crossed in constant time (linear, not quadratic, in breaks).
613
- const previous = new Array(chars.length);
614
- const next = new Array(chars.length);
615
- for (let k = 0, last = -1; k < chars.length; k += 1) {
616
- previous[k] = last;
617
- if (chars[k] !== null)
618
- last = k;
619
- }
620
- for (let k = chars.length - 1, last = chars.length; k >= 0; k -= 1) {
621
- next[k] = last;
622
- if (chars[k] !== null)
623
- last = k;
624
- }
686
+ let runs = content.flatMap((node) => node.type === 'break' ? [null] : node.text ? [{ text: node.text, marks: node.marks }] : []);
625
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 = [];
626
694
  let i = 0;
627
- const marked = (c) => c?.marks.some((m) => m.type === type) === true;
628
- // A break inside a span (marked text on both sides) does not end it.
629
- const has = (k) => {
630
- if (chars[k] !== null)
631
- return marked(chars[k]);
632
- const left = previous[k];
633
- return left >= 0 && marked(chars[left]) && marked(chars[next[k]]);
634
- };
635
- while (i < chars.length) {
636
- if (!has(i)) {
695
+ while (i < runs.length) {
696
+ if (!marked(runs[i])) {
697
+ out.push(runs[i]);
637
698
  i += 1;
638
699
  continue;
639
700
  }
701
+ // A span: marked text, and breaks with marked text on both sides.
640
702
  let end = i;
641
- while (has(end + 1))
642
- end += 1;
643
- const drop = (k) => {
644
- const c = chars[k];
645
- chars[k] = { ch: c.ch, marks: c.marks.filter((m) => m.type !== type) };
646
- };
647
- let a = i;
648
- while (a <= end && chars[a] && /\s/.test(chars[a].ch))
649
- drop(a++);
650
- let b = end;
651
- while (b >= a && chars[b] && /[\s\\]/.test(chars[b].ch))
652
- 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);
653
748
  i = end + 1;
654
749
  }
750
+ runs = out;
655
751
  }
656
- const out = [];
657
- for (const c of chars)
658
- out.push(c ? { type: 'text', text: c.ch, marks: c.marks } : { type: 'break' });
659
- return normalizeInline(out);
752
+ return normalizeInline(runs.map((run) => (run ? { type: 'text', ...run } : { type: 'break' })));
660
753
  }
661
- function writeInline(content, em) {
754
+ function writeInline(content, em, extensions) {
662
755
  const open = { strong: '**', em, strike: '~~' };
663
756
  const texts = content.flatMap((node) => (node.type === 'text' ? [{ node }] : []));
664
757
  const stack = [];
665
- const outer = (marks) => marks.filter((m) => m.type !== 'code');
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');
666
760
  // Where the span of each mark starting at text `t` ends (marks that end
667
761
  // later nest outside), computed once from the end so long spans stay linear.
668
762
  const ends = new Array(texts.length);
@@ -691,7 +785,7 @@ function writeInline(content, em) {
691
785
  const close = (to) => {
692
786
  while (stack.length > to) {
693
787
  const mark = stack.pop();
694
- out += mark.type === 'link' ? `](${encodeRef(mark.href)})` : open[mark.type];
788
+ out += mark.type === 'link' ? `](${encodeRef(mark.href)})` : (open[mark.type] ?? '');
695
789
  }
696
790
  };
697
791
  const keepCommon = (wanted) => {
@@ -716,10 +810,16 @@ function writeInline(content, em) {
716
810
  const wanted = ordered(t);
717
811
  const common = keepCommon(wanted);
718
812
  for (const mark of wanted.slice(common)) {
719
- out += mark.type === 'link' ? '[' : open[mark.type];
813
+ out += mark.type === 'link' ? '[' : (open[mark.type] ?? '');
720
814
  stack.push(mark);
721
815
  }
722
- 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));
723
823
  t += 1;
724
824
  });
725
825
  close(0);
@@ -733,8 +833,8 @@ const sameInline = (a, b) => a.length === b.length &&
733
833
  return (node.text === other.text && node.marks.map(markKey).join() === other.marks.map(markKey).join());
734
834
  });
735
835
  /** Read inline Markdown back without caps or policy changes (for the write check). */
736
- function reread(markdown) {
737
- const budget = new LoadBudget({ trust: 'developer' });
836
+ function reread(markdown, extensions) {
837
+ const budget = new LoadBudget({ trust: 'developer', extensions });
738
838
  const out = [];
739
839
  parseInline(markdown, 0, [], false, out, budget);
740
840
  return normalizeInline(out);
@@ -744,11 +844,11 @@ function reread(markdown) {
744
844
  * combination has no faithful spelling in the subset, the result drops the
745
845
  * least significant marks (em, then strike, then strong) until it does.
746
846
  */
747
- function inlineMarkdown(content, lineStart) {
748
- let prepared = prepareMarks(content);
847
+ function inlineMarkdown(content, lineStart, extensions) {
848
+ let prepared = prepareMarks(withRegistered(content, extensions));
749
849
  const attempt = (em) => {
750
- const text = writeInline(prepared, em).split('\n').map(lineStart).join('\n');
751
- 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;
752
852
  };
753
853
  for (const drop of [null, 'em', 'strike', 'strong']) {
754
854
  if (drop)
@@ -757,25 +857,34 @@ function inlineMarkdown(content, lineStart) {
757
857
  if (text !== null)
758
858
  return text;
759
859
  }
760
- 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));
761
870
  }
762
871
  const indent = (text, by) => text
763
872
  .split('\n')
764
873
  .map((line) => (line ? by + line : line))
765
874
  .join('\n');
766
- function writeBlocks(blocks) {
875
+ function writeBlocks(blocks, extensions) {
767
876
  return blocks
768
- .map(writeBlock)
877
+ .map((block) => writeBlock(block, extensions))
769
878
  .filter((text) => text !== '')
770
879
  .join('\n\n');
771
880
  }
772
- function writeBlock(block) {
881
+ function writeBlock(block, extensions) {
773
882
  switch (block.type) {
774
883
  case 'paragraph':
775
- return inlineMarkdown(trimEdges(block.content), escapeLineStart);
884
+ return inlineMarkdown(trimEdges(block.content), escapeLineStart, extensions);
776
885
  case 'heading': {
777
886
  const flat = block.content.map((n) => (n.type === 'break' ? { type: 'text', text: ' ', marks: [] } : n));
778
- const text = inlineMarkdown(trimEdges(normalizeInline(flat)), (line) => line);
887
+ const text = inlineMarkdown(trimEdges(normalizeInline(flat)), (line) => line, extensions);
779
888
  return `${'#'.repeat(block.level)}${text ? ` ${text}` : ''}${text.endsWith('#') ? ' #' : ''}`;
780
889
  }
781
890
  case 'code': {
@@ -792,13 +901,13 @@ function writeBlock(block) {
792
901
  ? `- [${item.checked ? 'x' : ' '}] `
793
902
  : '- ';
794
903
  const [first, ...rest] = item.blocks;
795
- const head = first ? indent(writeBlock(first), ' ').trimStart() : '';
796
- const tail = writeBlocks(rest);
904
+ const head = first ? indent(writeBlock(first, extensions), ' ').trimStart() : '';
905
+ const tail = writeBlocks(rest, extensions);
797
906
  return `${marker}${head}${tail ? `\n\n${indent(tail, ' ')}` : ''}`;
798
907
  })
799
908
  .join('\n');
800
909
  case 'quote':
801
- return writeBlocks(block.blocks)
910
+ return writeBlocks(block.blocks, extensions)
802
911
  .split('\n')
803
912
  .map((line) => (line ? `> ${line}` : '>'))
804
913
  .join('\n');
@@ -815,7 +924,8 @@ function trimEdges(content) {
815
924
  out.shift();
816
925
  while (out.at(-1)?.type === 'break')
817
926
  out.pop();
818
- 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');
819
929
  const first = out[0];
820
930
  if (plain(first))
821
931
  out[0] = { ...first, text: first.text.trimStart() };
@@ -830,6 +940,6 @@ function trimEdges(content) {
830
940
  * paragraphs, breaks in headings (written as spaces), breaks at the edges of
831
941
  * a block, and whitespace at the edges of a paragraph or a mark.
832
942
  */
833
- export function toMarkdown(doc) {
834
- return writeBlocks(doc.blocks);
943
+ export function toMarkdown(doc, options = {}) {
944
+ return writeBlocks(doc.blocks, toInlineExtensions(options.extensions));
835
945
  }
@@ -2,9 +2,11 @@
2
2
  * `@texuli/core/editor-model`: the rich text editor document model (ADR-003).
3
3
  * Types and caps, the closed vocabularies, constructors, normalization,
4
4
  * validation, the untrusted loader, selection helpers, grapheme and word
5
- * boundaries, and the nested inline interchange shape. Framework-free.
5
+ * boundaries, the nested inline interchange shape, and host inline
6
+ * extensions (mentions, references). Framework-free.
6
7
  */
7
8
  export { blockLength, blockText, createEditorIds, type EditorAncestor, type EditorIdSource, type EditorLocation, editorBlockCount, editorCharCount, editorLeaves, editorPlainText, emptyEditorDocument, isTextBlock, isVoidBlock, locateBlock, nextEditorId, normalizeDocument, paragraph, removeBlocks, replaceBlock, replaceItem, validateEditorDocument, } from './document.js';
9
+ export { acceptsExtensionId, createInlineExtensions, EDITOR_EXTENSION_LIMITS, EDITOR_EXTENSION_SIGILS, type EditorExtensionSigil, type EditorInlineExtension, type EditorInlineExtensions, extensionHref, toInlineExtensions, } from './extensions.js';
8
10
  export { graphemeBoundaries, nextGrapheme, nextWord, previousGrapheme, previousWord, snapToGrapheme, } from './graphemes.js';
9
11
  export { cleanText, hasMark, inheritedMarks, inlineLength, inlineText, markKey, normalizeInline, sliceInline, sortMarks, textToInline, } from './inline.js';
10
12
  export { type EditorLoadOptions, type EditorLoadResult, loadEditorDocument } from './load.js';
@@ -2,9 +2,11 @@
2
2
  * `@texuli/core/editor-model`: the rich text editor document model (ADR-003).
3
3
  * Types and caps, the closed vocabularies, constructors, normalization,
4
4
  * validation, the untrusted loader, selection helpers, grapheme and word
5
- * boundaries, and the nested inline interchange shape. Framework-free.
5
+ * boundaries, the nested inline interchange shape, and host inline
6
+ * extensions (mentions, references). Framework-free.
6
7
  */
7
8
  export { blockLength, blockText, createEditorIds, editorBlockCount, editorCharCount, editorLeaves, editorPlainText, emptyEditorDocument, isTextBlock, isVoidBlock, locateBlock, nextEditorId, normalizeDocument, paragraph, removeBlocks, replaceBlock, replaceItem, validateEditorDocument, } from "./document.js";
9
+ export { acceptsExtensionId, createInlineExtensions, EDITOR_EXTENSION_LIMITS, EDITOR_EXTENSION_SIGILS, extensionHref, toInlineExtensions, } from "./extensions.js";
8
10
  export { graphemeBoundaries, nextGrapheme, nextWord, previousGrapheme, previousWord, snapToGrapheme, } from "./graphemes.js";
9
11
  export { cleanText, hasMark, inheritedMarks, inlineLength, inlineText, markKey, normalizeInline, sliceInline, sortMarks, textToInline, } from "./inline.js";
10
12
  export { loadEditorDocument } from "./load.js";
@@ -50,7 +50,7 @@ export declare function toggleQuote(state: EditorState, options?: EditorTransfor
50
50
  * it). At a caret it toggles the stored marks for the next typed text. Code
51
51
  * text takes only `link` besides `code`.
52
52
  */
53
- export declare function toggleMark(state: EditorState, type: Exclude<EditorMarkType, 'link'>, options?: EditorTransformOptions): EditorTransformResult;
53
+ export declare function toggleMark(state: EditorState, type: Exclude<EditorMarkType, 'link' | 'extension'>, options?: EditorTransformOptions): EditorTransformResult;
54
54
  export interface SetLinkOptions extends EditorTransformOptions {
55
55
  /** URL policy tier. Default `untrusted`; pasted and typed URLs stay untrusted. */
56
56
  trust?: HrefTrust;
@@ -20,8 +20,18 @@ export type EditorInlineNode = {
20
20
  type: 'link';
21
21
  href: string;
22
22
  children: EditorInlineNode[];
23
+ }
24
+ /** A host inline extension entity (`name`, `id`) around its label. */
25
+ | {
26
+ type: 'extension';
27
+ name: string;
28
+ id: string;
29
+ children: EditorInlineNode[];
23
30
  };
24
- /** Fold runs into nested nodes, outermost mark first (`link`, `strong`, `em`, `strike`). */
31
+ /**
32
+ * Fold runs into nested nodes, outermost mark first (`link`, `strong`, `em`,
33
+ * `strike`, then `extension` innermost around its label).
34
+ */
25
35
  export declare function inlineToTree(content: readonly EditorInline[]): EditorInlineNode[];
26
36
  /**
27
37
  * Flatten a nested inline tree (this shape, the catalog's or the blocks
@@ -1,6 +1,9 @@
1
1
  import { markKey, normalizeInline, sortMarks } from "./inline.js";
2
2
  import { LoadBudget } from "./load.js";
3
- /** Fold runs into nested nodes, outermost mark first (`link`, `strong`, `em`, `strike`). */
3
+ /**
4
+ * Fold runs into nested nodes, outermost mark first (`link`, `strong`, `em`,
5
+ * `strike`, then `extension` innermost around its label).
6
+ */
4
7
  export function inlineToTree(content) {
5
8
  const root = [];
6
9
  const stack = [];
@@ -22,7 +25,9 @@ export function inlineToTree(content) {
22
25
  for (const mark of wanted.slice(common)) {
23
26
  const node = mark.type === 'link'
24
27
  ? { type: 'link', href: mark.href, children: [] }
25
- : { type: mark.type, children: [] };
28
+ : mark.type === 'extension'
29
+ ? { type: 'extension', name: mark.name, id: mark.id, children: [] }
30
+ : { type: mark.type, children: [] };
26
31
  children().push(node);
27
32
  stack.push({ mark, node });
28
33
  }
@@ -90,6 +95,10 @@ export function inlineFromTree(nodes, options = {}) {
90
95
  const href = budget.href(node.href);
91
96
  visit(node.children, href ? sortMarks([...marks, { type: 'link', href }]) : marks, depth + 1);
92
97
  }
98
+ else if (type === 'extension') {
99
+ const extension = budget.extension(node.name, node.id);
100
+ visit(node.children, extension ? sortMarks([...marks, extension]) : marks, depth + 1);
101
+ }
93
102
  else {
94
103
  budget.report('unknown-node', 'Unknown inline nodes kept only their text.');
95
104
  if (text !== undefined)
@@ -18,6 +18,16 @@ export type EditorMark = {
18
18
  } | {
19
19
  type: 'link';
20
20
  href: string;
21
+ }
22
+ /**
23
+ * A host inline extension entity (a mention, a reference) over its label
24
+ * text: `name` is a registered extension, `id` the entity (G48). Excludes
25
+ * `link` and `code` on the same run.
26
+ */
27
+ | {
28
+ type: 'extension';
29
+ name: string;
30
+ id: string;
21
31
  };
22
32
  export type EditorMarkType = EditorMark['type'];
23
33
  export type EditorInline = {
@@ -27,6 +27,7 @@ export const EDITOR_MARK_ORDER = [
27
27
  'em',
28
28
  'strike',
29
29
  'code',
30
+ 'extension',
30
31
  ];
31
32
  export const EDITOR_BLOCK_TYPES = [
32
33
  'paragraph',
@@ -34,10 +34,21 @@ export interface InertExemptionInput {
34
34
  element: Element;
35
35
  /** Optional debug label (`toast-region`, `announcer`, …). */
36
36
  label?: string;
37
+ /**
38
+ * Whether the region stays above overlays opened after it (default `true`):
39
+ * when it is open as a popover, the overlay engine re-shows it in the top
40
+ * layer each time another overlay opens, so the toast region keeps painting
41
+ * on top. Pass `false` for exemptions that belong under later overlays: a
42
+ * dialog backdrop, or a menu or listbox opened inside a modal, which must
43
+ * never cover the overlay opened from it.
44
+ */
45
+ raise?: boolean;
37
46
  }
38
47
  export interface InertExemption {
39
48
  readonly element: Element;
40
49
  readonly label?: string;
50
+ /** `false` when the region must not be re-raised above later overlays (omitted means `true`). */
51
+ readonly raise?: false;
41
52
  }
42
53
  export interface InertExemptionHandle {
43
54
  /** Stop exempting this element (idempotent). Open scopes recompute. */
@@ -142,9 +142,11 @@ export function createInertRegistry() {
142
142
  if (!isElement(input.element)) {
143
143
  throw new TypeError('InertRegistry.exempt: element must be a DOM Element');
144
144
  }
145
- const entry = input.label === undefined
146
- ? { element: input.element }
147
- : { element: input.element, label: input.label };
145
+ const entry = {
146
+ element: input.element,
147
+ ...(input.label === undefined ? {} : { label: input.label }),
148
+ ...(input.raise === false ? { raise: false } : {}),
149
+ };
148
150
  exemptions.push(entry);
149
151
  apply();
150
152
  let released = false;
@@ -0,0 +1,5 @@
1
+ /**
2
+ * @texuli/core/markup: escaped XML and HTML builders (ADR-016). Typed nodes
3
+ * only, no raw-markup API; URL attributes go through the URL policy.
4
+ */
5
+ export { el, escapeMarkupAttribute, escapeMarkupText, HTML_ELEMENTS, HTML_URL_ATTRIBUTES, type HtmlSerializeOptions, imageUrl, isMarkupUrl, linkUrl, type MarkupAttribute, type MarkupAttributeValue, type MarkupChild, type MarkupElement, type MarkupNode, type MarkupText, type MarkupUrl, sanitizeMarkupChars, serializeHtml, serializeXml, text, type XmlSerializeOptions, } from './markup.js';
@@ -0,0 +1,5 @@
1
+ /**
2
+ * @texuli/core/markup: escaped XML and HTML builders (ADR-016). Typed nodes
3
+ * only, no raw-markup API; URL attributes go through the URL policy.
4
+ */
5
+ export { el, escapeMarkupAttribute, escapeMarkupText, HTML_ELEMENTS, HTML_URL_ATTRIBUTES, imageUrl, isMarkupUrl, linkUrl, sanitizeMarkupChars, serializeHtml, serializeXml, text, } from "./markup.js";