@react-x11/components 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 (68) hide show
  1. package/README.md +67 -43
  2. package/dist/formula/node.d.ts.map +1 -1
  3. package/dist/formula/node.js +17 -6
  4. package/dist/formula/node.js.map +1 -1
  5. package/dist/index.d.ts +1 -3
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +0 -1
  8. package/dist/index.js.map +1 -1
  9. package/dist/markdown/ast.d.ts +80 -6
  10. package/dist/markdown/ast.d.ts.map +1 -1
  11. package/dist/markdown/ast.js +13 -4
  12. package/dist/markdown/ast.js.map +1 -1
  13. package/dist/markdown/expressions.d.ts +27 -0
  14. package/dist/markdown/expressions.d.ts.map +1 -0
  15. package/dist/markdown/expressions.js +203 -0
  16. package/dist/markdown/expressions.js.map +1 -0
  17. package/dist/markdown/index.d.ts +61 -2
  18. package/dist/markdown/index.d.ts.map +1 -1
  19. package/dist/markdown/index.js +101 -2
  20. package/dist/markdown/index.js.map +1 -1
  21. package/dist/markdown/parse.d.ts +1 -1
  22. package/dist/markdown/parse.d.ts.map +1 -1
  23. package/dist/markdown/parse.js +163 -15
  24. package/dist/markdown/parse.js.map +1 -1
  25. package/dist/markdown/spans.d.ts.map +1 -1
  26. package/dist/markdown/spans.js +4 -0
  27. package/dist/markdown/spans.js.map +1 -1
  28. package/dist/markdown/tags.d.ts +35 -0
  29. package/dist/markdown/tags.d.ts.map +1 -0
  30. package/dist/markdown/tags.js +213 -0
  31. package/dist/markdown/tags.js.map +1 -0
  32. package/dist/richtext/node.d.ts.map +1 -1
  33. package/dist/richtext/node.js +7 -2
  34. package/dist/richtext/node.js.map +1 -1
  35. package/dist/table/index.d.ts +15 -0
  36. package/dist/table/index.d.ts.map +1 -1
  37. package/dist/table/index.js +95 -11
  38. package/dist/table/index.js.map +1 -1
  39. package/dist/tree/index.d.ts.map +1 -1
  40. package/dist/tree/index.js +77 -2
  41. package/dist/tree/index.js.map +1 -1
  42. package/package.json +4 -8
  43. package/src/formula/node.ts +22 -34
  44. package/src/index.ts +4 -18
  45. package/src/markdown/ast.ts +85 -9
  46. package/src/markdown/expressions.ts +223 -0
  47. package/src/markdown/index.ts +190 -5
  48. package/src/markdown/parse.ts +230 -15
  49. package/src/markdown/spans.ts +4 -0
  50. package/src/markdown/tags.ts +240 -0
  51. package/src/richtext/node.ts +7 -2
  52. package/src/table/index.ts +114 -10
  53. package/src/tree/index.ts +77 -2
  54. package/dist/desktop-calendar/eds.d.ts +0 -102
  55. package/dist/desktop-calendar/eds.d.ts.map +0 -1
  56. package/dist/desktop-calendar/eds.js +0 -356
  57. package/dist/desktop-calendar/eds.js.map +0 -1
  58. package/dist/desktop-calendar/ical.d.ts +0 -63
  59. package/dist/desktop-calendar/ical.d.ts.map +0 -1
  60. package/dist/desktop-calendar/ical.js +0 -57
  61. package/dist/desktop-calendar/ical.js.map +0 -1
  62. package/dist/desktop-calendar/index.d.ts +0 -72
  63. package/dist/desktop-calendar/index.d.ts.map +0 -1
  64. package/dist/desktop-calendar/index.js +0 -213
  65. package/dist/desktop-calendar/index.js.map +0 -1
  66. package/src/desktop-calendar/eds.ts +0 -484
  67. package/src/desktop-calendar/ical.ts +0 -119
  68. package/src/desktop-calendar/index.ts +0 -287
@@ -22,10 +22,14 @@
22
22
  // definitions pass over the whole document, and streamed model output
23
23
  // essentially never uses them.
24
24
  // - **Raw HTML is literal text.** There is no HTML pass anywhere in this
25
- // component, by design; `<Component />` syntax is reserved for a future
26
- // MDX extension (see `ComponentInline` in ast.ts).
25
+ // component, by design. `<Component />` is a component only when
26
+ // `options.isComponent` claims the name the MDX gate (docs/prd-mdx.md).
27
+ // Without it, every `<` here means exactly what it always did, which is
28
+ // how turning MDX on cannot change a document that never asked for it.
29
+ // Block position only in M1; see `ComponentInline` in ast.ts.
27
30
  import type {
28
31
  BlockNode,
32
+ ComponentBlock,
29
33
  Document,
30
34
  InlineNode,
31
35
  ListBlock,
@@ -33,6 +37,11 @@ import type {
33
37
  ParseOptions,
34
38
  TableAlign,
35
39
  } from './ast.js';
40
+ import { closeBrace, scanTag } from './tags.js';
41
+ import type { ScannedTag } from './tags.js';
42
+
43
+ /** Nothing is a component unless the caller says so. */
44
+ const NO_COMPONENTS = (): boolean => false;
36
45
 
37
46
  // --- line-level regexes, compiled once -------------------------------------
38
47
 
@@ -54,16 +63,96 @@ const RE_TABLE_DELIM_PREFIX = /^ {0,3}\|[ \t:|-]*$/;
54
63
 
55
64
  /** Would this line open something other than a paragraph? The test lazy
56
65
  * continuation and list/quote termination share. */
57
- function isBlockStart(line: string): boolean {
66
+ function isBlockStart(
67
+ line: string,
68
+ isComponent: (name: string) => boolean = NO_COMPONENTS,
69
+ expressions = false,
70
+ ): boolean {
58
71
  return (
59
72
  RE_ATX.test(line) ||
60
73
  RE_FENCE_OPEN.test(line) ||
61
74
  RE_HR.test(line) ||
62
75
  RE_QUOTE.test(line) ||
63
- RE_LIST.test(line)
76
+ RE_LIST.test(line) ||
77
+ isComponentBlockLine(line, isComponent, expressions)
64
78
  );
65
79
  }
66
80
 
81
+ /** A line that opens a component block, resolved — speculation does not
82
+ * interrupt a paragraph. */
83
+ function isComponentBlockLine(
84
+ line: string,
85
+ isComponent: (name: string) => boolean,
86
+ expressions: boolean,
87
+ ): boolean {
88
+ const found = componentBlockAt([line], 0, isComponent, expressions);
89
+ return found !== null && found !== 'incomplete';
90
+ }
91
+
92
+ /**
93
+ * The component block opening at `lines[at]`, if one does.
94
+ *
95
+ * A component block owns its whole line — `<Chart/>` with prose after it is
96
+ * a paragraph with a tag in it, which is the inline case and not this one —
97
+ * but it may spend several lines doing so, because a component with six
98
+ * props is written down the page and MDX authors expect that to work. Lines
99
+ * are joined until the tag closes; a blank line gives up, since a tag does
100
+ * not span a paragraph break.
101
+ *
102
+ * `'incomplete'` means "still arriving", which only the live tail acts on.
103
+ */
104
+ function componentBlockAt(
105
+ lines: string[],
106
+ at: number,
107
+ isComponent: (name: string) => boolean,
108
+ expressions = false,
109
+ ): { tag: ScannedTag; lastLine: number } | 'incomplete' | null {
110
+ // The identity check is not an optimisation: `scanTag` reports a bare
111
+ // `<Chart` as "still arriving" before it can know whether anyone claims
112
+ // the name, and a document that never opted in must not have its lazy
113
+ // continuation changed by a line that merely looks like a tag.
114
+ if (isComponent === NO_COMPONENTS) return null;
115
+ const indent = indentOf(lines[at]);
116
+ if (indent > 3 || lines[at][indent] !== '<') return null;
117
+
118
+ let text = lines[at];
119
+ for (let j = at; j < lines.length; j += 1) {
120
+ if (j > at) text += `\n${lines[j]}`;
121
+ const tag = scanTag(text, indent, isComponent, expressions);
122
+ if (tag === null) return null;
123
+ if (tag === 'incomplete') {
124
+ if (j + 1 < lines.length && RE_BLANK.test(lines[j + 1])) return null;
125
+ continue;
126
+ }
127
+ if (tag.kind === 'close') return null;
128
+ return text.slice(tag.end).trim() === '' ? { tag, lastLine: j } : null;
129
+ }
130
+ return 'incomplete';
131
+ }
132
+
133
+ /** The line closing `name`, counting same-name nesting, or -1. */
134
+ function componentCloseLine(
135
+ lines: string[],
136
+ from: number,
137
+ name: string,
138
+ isComponent: (name: string) => boolean,
139
+ ): number {
140
+ let depth = 0;
141
+ for (let i = from; i < lines.length; i += 1) {
142
+ const indent = indentOf(lines[i]);
143
+ if (indent > 3 || lines[i][indent] !== '<') continue;
144
+ const tag = scanTag(lines[i], indent, isComponent);
145
+ if (tag === null || tag === 'incomplete' || tag.name !== name) continue;
146
+ if (lines[i].slice(tag.end).trim() !== '') continue;
147
+ if (tag.kind === 'open') depth += 1;
148
+ else if (tag.kind === 'close') {
149
+ if (depth === 0) return i;
150
+ depth -= 1;
151
+ }
152
+ }
153
+ return -1;
154
+ }
155
+
67
156
  /** Leading tabs advance to 4-column stops; content tabs are left alone. */
68
157
  function expandLeadingTabs(line: string): string {
69
158
  let i = 0;
@@ -93,7 +182,14 @@ export function parse(source: string, options: ParseOptions = {}): Document {
93
182
  const lines = normalized.split('\n').map(expandLeadingTabs);
94
183
  const blocks: BlockNode[] = [];
95
184
  const ranges: Array<[number, number]> = [];
96
- parseBlocks(lines, partial, blocks, ranges);
185
+ parseBlocks(
186
+ lines,
187
+ partial,
188
+ blocks,
189
+ ranges,
190
+ options.isComponent ?? NO_COMPONENTS,
191
+ options.expressions === true,
192
+ );
97
193
  return {
98
194
  blocks,
99
195
  raws: ranges.map(([a, b]) => lines.slice(a, b).join('\n')),
@@ -113,6 +209,8 @@ function parseBlocks(
113
209
  tailOpen: boolean,
114
210
  out: BlockNode[],
115
211
  ranges?: Array<[number, number]>,
212
+ isComponent: (name: string) => boolean = NO_COMPONENTS,
213
+ expressions = false,
116
214
  ): void {
117
215
  let i = 0;
118
216
  const n = lines.length;
@@ -132,7 +230,7 @@ function parseBlocks(
132
230
  para.length = 0;
133
231
  paraStart = -1;
134
232
  // the paragraph owns the live tail iff its last line is the document's
135
- const children = parseInline(text, tailOpen && end === n);
233
+ const children = parseInline(text, tailOpen && end === n, expressions);
136
234
  if (children.length > 0)
137
235
  commit({ type: 'paragraph', children }, start, end);
138
236
  };
@@ -166,7 +264,7 @@ function parseBlocks(
166
264
  {
167
265
  type: 'heading',
168
266
  depth: setext[1][0] === '=' ? 1 : 2,
169
- children: parseInline(text, false),
267
+ children: parseInline(text, false, expressions),
170
268
  },
171
269
  start,
172
270
  i + 1,
@@ -203,7 +301,7 @@ function parseBlocks(
203
301
  {
204
302
  type: 'heading',
205
303
  depth: atx[1].length,
206
- children: parseInline(atx[2] ?? '', held),
304
+ children: parseInline(atx[2] ?? '', held, expressions),
207
305
  },
208
306
  i,
209
307
  i + 1,
@@ -248,6 +346,69 @@ function parseBlocks(
248
346
  continue;
249
347
  }
250
348
 
349
+ // A component standing where a paragraph would (docs/prd-mdx.md). Gated
350
+ // on `isComponent`, so a document that never opted in never gets here.
351
+ const component = componentBlockAt(lines, i, isComponent, expressions);
352
+ if (component === 'incomplete') {
353
+ // A tag still arriving: hold it back rather than flash `<Cha` on the
354
+ // screen, the way a half-arrived link is held. Only the live tail may
355
+ // do this — anywhere else the tag is simply never going to close.
356
+ if (tailOpen) {
357
+ flushPara(i);
358
+ i = n;
359
+ continue;
360
+ }
361
+ } else if (component) {
362
+ const { tag, lastLine } = component;
363
+ flushPara(i);
364
+ const start = i;
365
+ const node = (children: BlockNode[]): ComponentBlock => ({
366
+ type: 'component',
367
+ name: tag.name,
368
+ attributes: tag.attributes,
369
+ children,
370
+ });
371
+
372
+ if (tag.kind === 'self') {
373
+ i = lastLine + 1;
374
+ commit(node([]), start, i);
375
+ continue;
376
+ }
377
+
378
+ const closeAt = componentCloseLine(
379
+ lines,
380
+ lastLine + 1,
381
+ tag.name,
382
+ isComponent,
383
+ );
384
+ if (closeAt !== -1) {
385
+ const children: BlockNode[] = [];
386
+ parseBlocks(
387
+ lines.slice(lastLine + 1, closeAt),
388
+ tailOpen && closeAt >= n,
389
+ children,
390
+ undefined,
391
+ isComponent,
392
+ expressions,
393
+ );
394
+ i = closeAt + 1;
395
+ commit(node(children), start, i);
396
+ continue;
397
+ }
398
+
399
+ // Open, with no close yet. While the document is still arriving that
400
+ // is the ordinary state of an element being typed: hold the tag and
401
+ // let the children render as the markdown they are, so the reader sees
402
+ // the prose rather than nothing. Mounting the component now and
403
+ // appending to its children instead would remount it — and lose its
404
+ // state — on the chunk that finally closes it.
405
+ if (tailOpen) {
406
+ i = lastLine + 1;
407
+ continue;
408
+ }
409
+ // Final document, never closed: the tag is text, so fall through.
410
+ }
411
+
251
412
  const quote = RE_QUOTE.exec(line);
252
413
  if (quote) {
253
414
  flushPara(i);
@@ -266,7 +427,7 @@ function parseBlocks(
266
427
  }
267
428
  if (
268
429
  !RE_BLANK.test(l) &&
269
- !isBlockStart(l) &&
430
+ !isBlockStart(l, isComponent, expressions) &&
270
431
  inner.length > 0 &&
271
432
  !RE_BLANK.test(inner[inner.length - 1])
272
433
  ) {
@@ -277,7 +438,14 @@ function parseBlocks(
277
438
  break;
278
439
  }
279
440
  const children: BlockNode[] = [];
280
- parseBlocks(inner, tailOpen && i === n, children);
441
+ parseBlocks(
442
+ inner,
443
+ tailOpen && i === n,
444
+ children,
445
+ undefined,
446
+ isComponent,
447
+ expressions,
448
+ );
281
449
  if (children.length > 0) commit({ type: 'quote', children }, start, i);
282
450
  continue;
283
451
  }
@@ -288,7 +456,7 @@ function parseBlocks(
288
456
  if (list && !(para.length > 0 && !list[4]) && !(held && !list[4])) {
289
457
  flushPara(i);
290
458
  const start = i;
291
- const block = parseList(lines, i, tailOpen);
459
+ const block = parseList(lines, i, tailOpen, isComponent, expressions);
292
460
  i = block.end;
293
461
  commit(block.list, start, i);
294
462
  continue;
@@ -323,7 +491,12 @@ function parseBlocks(
323
491
  const rows: InlineNode[][][] = [];
324
492
  while (i < n) {
325
493
  const l = lines[i];
326
- if (RE_BLANK.test(l) || !l.includes('|') || isBlockStart(l)) break;
494
+ if (
495
+ RE_BLANK.test(l) ||
496
+ !l.includes('|') ||
497
+ isBlockStart(l, isComponent, expressions)
498
+ )
499
+ break;
327
500
  rows.push(
328
501
  normalizeRow(splitRow(l), align.length, tailOpen && i === n - 1),
329
502
  );
@@ -395,6 +568,8 @@ function parseList(
395
568
  lines: string[],
396
569
  from: number,
397
570
  tailOpen: boolean,
571
+ isComponent: (name: string) => boolean = NO_COMPONENTS,
572
+ expressions = false,
398
573
  ): ParsedList {
399
574
  const n = lines.length;
400
575
  const first = RE_LIST.exec(lines[from]);
@@ -452,7 +627,11 @@ function parseList(
452
627
  i += 1;
453
628
  continue;
454
629
  }
455
- if (pendingBlanks === 0 && !isBlockStart(l) && !RE_BLANK.test(l)) {
630
+ if (
631
+ pendingBlanks === 0 &&
632
+ !isBlockStart(l, isComponent, expressions) &&
633
+ !RE_BLANK.test(l)
634
+ ) {
456
635
  inner.push(l); // lazy paragraph continuation
457
636
  i += 1;
458
637
  continue;
@@ -470,7 +649,14 @@ function parseList(
470
649
  }
471
650
 
472
651
  const children: BlockNode[] = [];
473
- parseBlocks(inner, tailOpen && i >= n, children);
652
+ parseBlocks(
653
+ inner,
654
+ tailOpen && i >= n,
655
+ children,
656
+ undefined,
657
+ isComponent,
658
+ expressions,
659
+ );
474
660
  items.push({ checked, children });
475
661
  }
476
662
 
@@ -603,7 +789,11 @@ function classify(ch: string | undefined): 'ws' | 'punct' | 'other' {
603
789
  * Parse inline markdown. `atDocEnd` marks text whose end is the live end of
604
790
  * a streaming document — only then do the implicit-close rules apply.
605
791
  */
606
- export function parseInline(text: string, atDocEnd: boolean): InlineNode[] {
792
+ export function parseInline(
793
+ text: string,
794
+ atDocEnd: boolean,
795
+ expressions = false,
796
+ ): InlineNode[] {
607
797
  const items: Item[] = [];
608
798
  let buf = '';
609
799
 
@@ -700,6 +890,31 @@ export function parseInline(text: string, atDocEnd: boolean): InlineNode[] {
700
890
  continue;
701
891
  }
702
892
 
893
+ // `{expr}` in the prose — rung 2 only, so a brace in an ordinary
894
+ // document is the character it has always been.
895
+ if (expressions && ch === '{') {
896
+ const end = closeBrace(text, i);
897
+ if (end === -1) {
898
+ // Still arriving: hold the rest of the line back rather than show
899
+ // half an expression. In a final document it is just text.
900
+ if (atDocEnd) {
901
+ flushText();
902
+ i = len;
903
+ continue;
904
+ }
905
+ buf += ch;
906
+ i += 1;
907
+ continue;
908
+ }
909
+ flushText();
910
+ items.push({
911
+ kind: 'node',
912
+ node: { type: 'expression', src: text.slice(i + 1, end - 1).trim() },
913
+ });
914
+ i = end;
915
+ continue;
916
+ }
917
+
703
918
  if (ch === '<') {
704
919
  const rest = text.slice(i);
705
920
  const auto = RE_AUTOLINK.exec(rest) ?? RE_AUTOEMAIL.exec(rest);
@@ -135,6 +135,10 @@ export function plainTextOf(nodes: InlineNode[]): string {
135
135
  case 'break':
136
136
  out += '\n';
137
137
  break;
138
+ case 'expression':
139
+ // Resolved to text before rendering (`<Markdown>`'s `scope`), so a
140
+ // node reaching here was never evaluated and has no text to give.
141
+ break;
138
142
  default:
139
143
  out += plainTextOf(node.children);
140
144
  }
@@ -0,0 +1,240 @@
1
+ // Scanning a JSX-shaped tag, for the MDX direction (docs/prd-mdx.md).
2
+ //
3
+ // Deliberately not a JavaScript parser, and deliberately not an HTML one.
4
+ // What it recognises is `<Name …>`, `</Name>` and `<Name … />` where `Name`
5
+ // is a component the *caller* claims — a tag whose name nobody claims is not
6
+ // a tag, it is the literal text it has always been. That one rule is what
7
+ // lets this exist inside a parser whose documents are full of `<box>` and
8
+ // `{braces}` without changing what any of them mean.
9
+ //
10
+ // Attribute values are read at parse time. A quoted string is a string, a
11
+ // bare name is `true`, and `{…}` is **JSON** — until the caller passes
12
+ // `expressions`, when a brace that is not JSON is kept as *source* for
13
+ // someone else to compile (docs/prd-mdx.md, M2). Nothing is evaluated here
14
+ // on either rung: this file finds where an expression ends, and holding it
15
+ // as text is what lets one parse be rendered against two scopes.
16
+ //
17
+ // Finding where it ends is the only part that has to know about JavaScript,
18
+ // and it knows the least it can: strings (including template literals and
19
+ // their `${…}` holes), comments, and bracket depth. It never parses an
20
+ // expression, the way `src/qml/parse.ts` never does.
21
+ import type { AttributeValue } from './ast.js';
22
+
23
+ /** A tag's shape. `end` is the index just past its `>`. */
24
+ export interface ScannedTag {
25
+ kind: 'open' | 'close' | 'self';
26
+ name: string;
27
+ attributes: Record<string, AttributeValue>;
28
+ end: number;
29
+ }
30
+
31
+ /**
32
+ * What a scan can say. `null` is "not a tag, never will be"; `'incomplete'`
33
+ * is "could still become one if more text arrives", which is what a
34
+ * streaming document needs in order to hold the tail back rather than
35
+ * flashing `<Cha` on the screen.
36
+ */
37
+ export type ScanResult = ScannedTag | 'incomplete' | null;
38
+
39
+ /** A component name, dotted paths included. Deliberately not HTML's. */
40
+ const RE_NAME = /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*/;
41
+ /** An attribute name: JSX's, plus the `-` and `:` that data attributes use. */
42
+ const RE_ATTR = /^[A-Za-z_$][\w$:-]*/;
43
+
44
+ function isSpace(ch: string | undefined): boolean {
45
+ return ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r';
46
+ }
47
+
48
+ /**
49
+ * The index just past the closing quote of the string literal starting at
50
+ * `from`, or -1 if the text ends first. A template literal's `${…}` holes
51
+ * are walked as expressions, so a brace or a quote inside one counts for
52
+ * nothing outside it.
53
+ */
54
+ function skipString(text: string, from: number): number {
55
+ const quote = text[from];
56
+ let i = from + 1;
57
+ while (i < text.length) {
58
+ const ch = text[i];
59
+ if (ch === '\\') {
60
+ i += 2;
61
+ continue;
62
+ }
63
+ if (ch === quote) return i + 1;
64
+ if (quote === '`' && ch === '$' && text[i + 1] === '{') {
65
+ const end = closeBrace(text, i + 1);
66
+ if (end === -1) return -1;
67
+ i = end;
68
+ continue;
69
+ }
70
+ i += 1;
71
+ }
72
+ return -1;
73
+ }
74
+
75
+ /** The index just past a `//` or block comment starting at `from`. */
76
+ function skipComment(text: string, from: number): number {
77
+ if (text[from + 1] === '/') {
78
+ const nl = text.indexOf('\n', from);
79
+ return nl === -1 ? text.length : nl;
80
+ }
81
+ const end = text.indexOf('*/', from + 2);
82
+ return end === -1 ? -1 : end + 2;
83
+ }
84
+
85
+ /**
86
+ * The index just past the `}` that closes the `{` at `from`, or -1 if the
87
+ * text ends first — the one piece here that has to know JavaScript's shape.
88
+ * Strings, template holes and comments are stepped over whole, so a brace
89
+ * inside any of them closes nothing.
90
+ */
91
+ export function closeBrace(text: string, from: number): number {
92
+ let depth = 0;
93
+ let i = from;
94
+ while (i < text.length) {
95
+ const ch = text[i];
96
+ if (ch === '"' || ch === "'" || ch === '`') {
97
+ const end = skipString(text, i);
98
+ if (end === -1) return -1;
99
+ i = end;
100
+ continue;
101
+ }
102
+ if (ch === '/' && (text[i + 1] === '/' || text[i + 1] === '*')) {
103
+ const end = skipComment(text, i);
104
+ if (end === -1) return -1;
105
+ i = end;
106
+ continue;
107
+ }
108
+ if (ch === '{') depth += 1;
109
+ else if (ch === '}') {
110
+ depth -= 1;
111
+ if (depth === 0) return i + 1;
112
+ }
113
+ i += 1;
114
+ }
115
+ return -1;
116
+ }
117
+
118
+ /**
119
+ * Scan the tag starting at `text[at]`, which must be `<`.
120
+ *
121
+ * `isComponent` is asked before anything else is parsed, so a `<` that opens
122
+ * nothing anybody claims costs one regex and a lookup.
123
+ *
124
+ * `expressions` is the second rung: with it, a `{…}` that is not JSON is
125
+ * kept as source instead of making the tag unreadable, and `{...spread}`
126
+ * becomes an attribute. Without it — the default — neither exists, and a
127
+ * document can only hand a component data it wrote down literally.
128
+ */
129
+ export function scanTag(
130
+ text: string,
131
+ at: number,
132
+ isComponent: (name: string) => boolean,
133
+ expressions = false,
134
+ ): ScanResult {
135
+ if (text[at] !== '<') return null;
136
+ let i = at + 1;
137
+
138
+ const close = text[i] === '/';
139
+ if (close) i += 1;
140
+
141
+ const nameMatch = RE_NAME.exec(text.slice(i));
142
+ if (!nameMatch) {
143
+ // `<` at the very end could still grow into a tag; anything else is text.
144
+ return i >= text.length ? 'incomplete' : null;
145
+ }
146
+ const name = nameMatch[0];
147
+ i += name.length;
148
+ // A name that reaches the end may still be growing — `<Cha` could be
149
+ // `<Chart`, and a document that decided too early would flash the wrong
150
+ // thing. Only ask about names we have all of.
151
+ if (i >= text.length) return 'incomplete';
152
+ if (!isComponent(name)) return null;
153
+
154
+ if (close) {
155
+ while (isSpace(text[i])) i += 1;
156
+ if (i >= text.length) return 'incomplete';
157
+ if (text[i] !== '>') return null;
158
+ return { kind: 'close', name, attributes: {}, end: i + 1 };
159
+ }
160
+
161
+ const attributes: Record<string, AttributeValue> = {};
162
+ let spreads = 0;
163
+ for (;;) {
164
+ const hadSpace = isSpace(text[i]);
165
+ while (isSpace(text[i])) i += 1;
166
+ if (i >= text.length) return 'incomplete';
167
+
168
+ if (text[i] === '>') return { kind: 'open', name, attributes, end: i + 1 };
169
+ if (text[i] === '/') {
170
+ i += 1;
171
+ while (isSpace(text[i])) i += 1;
172
+ if (i >= text.length) return 'incomplete';
173
+ if (text[i] !== '>') return null;
174
+ return { kind: 'self', name, attributes, end: i + 1 };
175
+ }
176
+
177
+ // `<Chart data="x"height={2}>` is a typo, not a tag.
178
+ if (!hadSpace) return null;
179
+
180
+ // `{...props}` — an attribute with no name. Its key records where it
181
+ // sat among the named ones, because a spread after `height` overrides
182
+ // it and one before it does not; `...` cannot collide with a real
183
+ // attribute name, which must start with a letter.
184
+ if (text[i] === '{') {
185
+ if (!expressions) return null;
186
+ const end = closeBrace(text, i);
187
+ if (end === -1) return 'incomplete';
188
+ const inner = text.slice(i + 1, end - 1).trim();
189
+ if (!inner.startsWith('...')) return null;
190
+ attributes[`...${spreads}`] = {
191
+ kind: 'expression',
192
+ src: inner.slice(3).trim(),
193
+ };
194
+ spreads += 1;
195
+ i = end;
196
+ continue;
197
+ }
198
+
199
+ const attr = RE_ATTR.exec(text.slice(i));
200
+ if (!attr) return null;
201
+ const key = attr[0];
202
+ i += key.length;
203
+ if (i >= text.length) return 'incomplete';
204
+
205
+ if (text[i] !== '=') {
206
+ // A bare attribute is `true`, as it is in JSX.
207
+ attributes[key] = { kind: 'literal', value: true };
208
+ continue;
209
+ }
210
+ i += 1;
211
+ if (i >= text.length) return 'incomplete';
212
+
213
+ const ch = text[i];
214
+ if (ch === '"' || ch === "'") {
215
+ const end = text.indexOf(ch, i + 1);
216
+ if (end === -1) return 'incomplete';
217
+ attributes[key] = { kind: 'literal', value: text.slice(i + 1, end) };
218
+ i = end + 1;
219
+ continue;
220
+ }
221
+ if (ch === '{') {
222
+ const end = closeBrace(text, i);
223
+ if (end === -1) return 'incomplete';
224
+ const src = text.slice(i + 1, end - 1);
225
+ try {
226
+ attributes[key] = { kind: 'literal', value: JSON.parse(src) };
227
+ } catch {
228
+ // Not JSON. On the rung that compiles, that is an expression and it
229
+ // is kept as source; on the rung that does not, it is unreadable and
230
+ // the tag is text.
231
+ if (!expressions) return null;
232
+ attributes[key] = { kind: 'expression', src: src.trim() };
233
+ }
234
+ i = end;
235
+ continue;
236
+ }
237
+ // An unquoted value is HTML's, not JSX's.
238
+ return null;
239
+ }
240
+ }
@@ -418,8 +418,13 @@ export class RichTextNode extends Node {
418
418
  // arrive from the surface above; `textRangeRects` is what a custom
419
419
  // element and core's own `<text>` both fill, so they cannot drift.
420
420
  const range = this.selectionRange;
421
- if (range && range.end > range.start) {
422
- ctx.fillStyle = this.selectionColor;
421
+ // The colour is the condition, not just the range: core types it
422
+ // `string | null` and defines it as what to fill the rectangles with
423
+ // *while a selection is set*, so a null one is "nothing to draw"
424
+ // rather than a band in the default ink.
425
+ const selectionColor = this.selectionColor;
426
+ if (selectionColor && range && range.end > range.start) {
427
+ ctx.fillStyle = selectionColor;
423
428
  for (const r of this.textRangeRects(range.start, range.end)) {
424
429
  ctx.fillRect(
425
430
  Math.round(r.x),