@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.
- package/README.md +67 -43
- package/dist/formula/node.d.ts.map +1 -1
- package/dist/formula/node.js +17 -6
- package/dist/formula/node.js.map +1 -1
- package/dist/index.d.ts +1 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -1
- package/dist/index.js.map +1 -1
- package/dist/markdown/ast.d.ts +80 -6
- package/dist/markdown/ast.d.ts.map +1 -1
- package/dist/markdown/ast.js +13 -4
- package/dist/markdown/ast.js.map +1 -1
- package/dist/markdown/expressions.d.ts +27 -0
- package/dist/markdown/expressions.d.ts.map +1 -0
- package/dist/markdown/expressions.js +203 -0
- package/dist/markdown/expressions.js.map +1 -0
- package/dist/markdown/index.d.ts +61 -2
- package/dist/markdown/index.d.ts.map +1 -1
- package/dist/markdown/index.js +101 -2
- package/dist/markdown/index.js.map +1 -1
- package/dist/markdown/parse.d.ts +1 -1
- package/dist/markdown/parse.d.ts.map +1 -1
- package/dist/markdown/parse.js +163 -15
- package/dist/markdown/parse.js.map +1 -1
- package/dist/markdown/spans.d.ts.map +1 -1
- package/dist/markdown/spans.js +4 -0
- package/dist/markdown/spans.js.map +1 -1
- package/dist/markdown/tags.d.ts +35 -0
- package/dist/markdown/tags.d.ts.map +1 -0
- package/dist/markdown/tags.js +213 -0
- package/dist/markdown/tags.js.map +1 -0
- package/dist/richtext/node.d.ts.map +1 -1
- package/dist/richtext/node.js +7 -2
- package/dist/richtext/node.js.map +1 -1
- package/dist/table/index.d.ts +15 -0
- package/dist/table/index.d.ts.map +1 -1
- package/dist/table/index.js +95 -11
- package/dist/table/index.js.map +1 -1
- package/dist/tree/index.d.ts.map +1 -1
- package/dist/tree/index.js +77 -2
- package/dist/tree/index.js.map +1 -1
- package/package.json +4 -8
- package/src/formula/node.ts +22 -34
- package/src/index.ts +4 -18
- package/src/markdown/ast.ts +85 -9
- package/src/markdown/expressions.ts +223 -0
- package/src/markdown/index.ts +190 -5
- package/src/markdown/parse.ts +230 -15
- package/src/markdown/spans.ts +4 -0
- package/src/markdown/tags.ts +240 -0
- package/src/richtext/node.ts +7 -2
- package/src/table/index.ts +114 -10
- package/src/tree/index.ts +77 -2
- package/dist/desktop-calendar/eds.d.ts +0 -102
- package/dist/desktop-calendar/eds.d.ts.map +0 -1
- package/dist/desktop-calendar/eds.js +0 -356
- package/dist/desktop-calendar/eds.js.map +0 -1
- package/dist/desktop-calendar/ical.d.ts +0 -63
- package/dist/desktop-calendar/ical.d.ts.map +0 -1
- package/dist/desktop-calendar/ical.js +0 -57
- package/dist/desktop-calendar/ical.js.map +0 -1
- package/dist/desktop-calendar/index.d.ts +0 -72
- package/dist/desktop-calendar/index.d.ts.map +0 -1
- package/dist/desktop-calendar/index.js +0 -213
- package/dist/desktop-calendar/index.js.map +0 -1
- package/src/desktop-calendar/eds.ts +0 -484
- package/src/desktop-calendar/ical.ts +0 -119
- package/src/desktop-calendar/index.ts +0 -287
package/src/markdown/parse.ts
CHANGED
|
@@ -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
|
|
26
|
-
//
|
|
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(
|
|
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(
|
|
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(
|
|
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 (
|
|
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 (
|
|
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(
|
|
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(
|
|
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);
|
package/src/markdown/spans.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/richtext/node.ts
CHANGED
|
@@ -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
|
-
|
|
422
|
-
|
|
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),
|