@weave-framework/compiler 0.2.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/dist/parser.js ADDED
@@ -0,0 +1,721 @@
1
+ /**
2
+ * Weave template parser — hand-written, zero dependencies.
3
+ *
4
+ * Parses an HTML-like template into a {@link TemplateNode} tree. Handles
5
+ * elements, text, `{{ expr }}` interpolation, and the binding attribute forms
6
+ * (`name={expr}`, `.prop={expr}`, `on:evt|mod={expr}`, `class:name={expr}`,
7
+ * `bind:name={expr}`, `ref={expr}`). Control-flow blocks (`@if`/`@for`) are
8
+ * added in M4; this parser deliberately treats a leading `@` as an error so the
9
+ * gap is explicit rather than silently mis-parsed.
10
+ */
11
+ const BLOCK_KW = /^@(if|else|for|empty|switch|case|default|let|defer|placeholder|await|then|catch|snippet|render|key)\b/;
12
+ const VOID = new Set([
13
+ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
14
+ 'link', 'meta', 'param', 'source', 'track', 'wbr',
15
+ ]);
16
+ export class ParseError extends Error {
17
+ }
18
+ export function parseTemplate(input) {
19
+ const p = new Parser(input);
20
+ const nodes = p.parseChildren(null);
21
+ return nodes;
22
+ }
23
+ class Parser {
24
+ src;
25
+ pos = 0;
26
+ /** Inner start offset of the most recent {@link readParen} — for block-head expr offsets. */
27
+ parenStart = 0;
28
+ constructor(src) {
29
+ this.src = src;
30
+ }
31
+ /** Offset of `sub`'s first non-whitespace char within `parent` (which starts at `parentStart`). */
32
+ exprOffset(parentStart, parent, sub) {
33
+ const at = parent.indexOf(sub);
34
+ return parentStart + (at < 0 ? 0 : at) + (sub.length - sub.trimStart().length);
35
+ }
36
+ eof() {
37
+ return this.pos >= this.src.length;
38
+ }
39
+ /**
40
+ * Parse children until `</closeTag>` (element), `}` (block body when
41
+ * `stopAtBrace`), or EOF. Does not consume the terminator.
42
+ */
43
+ parseChildren(closeTag, stopAtBrace = false) {
44
+ const out = [];
45
+ while (!this.eof()) {
46
+ if (stopAtBrace && this.peek() === '}')
47
+ return out;
48
+ if (this.src.startsWith('</', this.pos)) {
49
+ if (closeTag === null)
50
+ throw new ParseError(`Unexpected closing tag at ${this.pos}`);
51
+ return out; // caller consumes the close tag
52
+ }
53
+ if (this.src.startsWith('{{', this.pos)) {
54
+ const it = this.readInterp();
55
+ out.push({ type: 'interp', expr: it.expr, offset: it.offset });
56
+ continue;
57
+ }
58
+ if (this.src.startsWith('<!--', this.pos)) {
59
+ this.skipComment();
60
+ continue;
61
+ }
62
+ if (this.peek() === '@' && BLOCK_KW.test(this.src.slice(this.pos))) {
63
+ out.push(this.parseBlock());
64
+ continue;
65
+ }
66
+ if (this.peek() === '<') {
67
+ out.push(this.parseElement());
68
+ continue;
69
+ }
70
+ const text = this.readText(stopAtBrace);
71
+ if (text) {
72
+ // Coalesce with a preceding text node. Two text runs become adjacent when a
73
+ // comment between them is skipped; the browser merges them into one Text node
74
+ // when the emitted template HTML is parsed, so the AST must too — otherwise the
75
+ // child-index paths the codegen computes are off by one for every later sibling.
76
+ const last = out[out.length - 1];
77
+ if (last && last.type === 'text')
78
+ last.value += text;
79
+ else
80
+ out.push({ type: 'text', value: text });
81
+ }
82
+ }
83
+ if (closeTag !== null)
84
+ throw new ParseError(`Unclosed <${closeTag}>`);
85
+ return out;
86
+ }
87
+ /* ──────────── control-flow blocks ──────────── */
88
+ parseBlock() {
89
+ const kw = BLOCK_KW.exec(this.src.slice(this.pos))[1];
90
+ switch (kw) {
91
+ case 'if': return this.parseIf();
92
+ case 'for': return this.parseFor();
93
+ case 'switch': return this.parseSwitch();
94
+ case 'let': return this.parseLet();
95
+ case 'defer': return this.parseDefer();
96
+ case 'await': return this.parseAwait();
97
+ case 'snippet': return this.parseSnippet();
98
+ case 'render': return this.parseRender();
99
+ case 'key': return this.parseKey();
100
+ default:
101
+ throw new ParseError(`Unexpected @${kw} at ${this.pos} (no matching block)`);
102
+ }
103
+ }
104
+ parseIf() {
105
+ this.pos += 3; // @if
106
+ this.skipWs();
107
+ const head = this.readParen();
108
+ const headStart = this.parenStart;
109
+ const branches = [];
110
+ let cond = head;
111
+ let alias;
112
+ const semi = splitTopLevel(head, ';');
113
+ if (semi.length === 2) {
114
+ cond = semi[0].trim();
115
+ const m = /^as\s+([A-Za-z_$][\w$]*)$/.exec(semi[1].trim());
116
+ if (!m)
117
+ throw new ParseError(`Expected 'as <name>' in @if, got '${semi[1].trim()}'`);
118
+ alias = m[1];
119
+ }
120
+ const condOffset = this.exprOffset(headStart, head, cond);
121
+ branches.push({ cond, condOffset, alias, children: this.readBlockBody() });
122
+ // @else if / @else
123
+ for (;;) {
124
+ const save = this.pos;
125
+ this.skipWs();
126
+ if (this.src.startsWith('@else if', this.pos)) {
127
+ this.pos += '@else if'.length;
128
+ this.skipWs();
129
+ const raw = this.readParen();
130
+ const off = this.exprOffset(this.parenStart, raw, raw.trim());
131
+ branches.push({ cond: raw.trim(), condOffset: off, children: this.readBlockBody() });
132
+ }
133
+ else if (this.src.startsWith('@else', this.pos)) {
134
+ this.pos += '@else'.length;
135
+ branches.push({ children: this.readBlockBody() });
136
+ break;
137
+ }
138
+ else {
139
+ this.pos = save;
140
+ break;
141
+ }
142
+ }
143
+ return { type: 'if', branches };
144
+ }
145
+ parseFor() {
146
+ this.pos += 4; // @for
147
+ this.skipWs();
148
+ const head = this.readParen();
149
+ const headStart = this.parenStart;
150
+ const parts = splitTopLevel(head, ';').map((s) => s.trim());
151
+ const m = /^([A-Za-z_$][\w$]*)\s+of\s+([\s\S]+)$/.exec(parts[0]);
152
+ if (!m)
153
+ throw new ParseError(`Expected '@for (item of list)', got '${parts[0]}'`);
154
+ const item = m[1];
155
+ const list = m[2].trim();
156
+ let track;
157
+ for (const extra of parts.slice(1)) {
158
+ const t = /^track\s+([\s\S]+)$/.exec(extra);
159
+ if (t)
160
+ track = t[1].trim();
161
+ }
162
+ const listOffset = this.exprOffset(headStart, head, list);
163
+ const trackOffset = track ? this.exprOffset(headStart, head, track) : undefined;
164
+ const children = this.readBlockBody();
165
+ let empty;
166
+ const save = this.pos;
167
+ this.skipWs();
168
+ if (this.src.startsWith('@empty', this.pos)) {
169
+ this.pos += '@empty'.length;
170
+ empty = this.readBlockBody();
171
+ }
172
+ else {
173
+ this.pos = save;
174
+ }
175
+ return { type: 'for', item, list, listOffset, track, trackOffset, children, empty };
176
+ }
177
+ parseSwitch() {
178
+ this.pos += 7; // @switch
179
+ this.skipWs();
180
+ const rawExpr = this.readParen();
181
+ const exprOffset = this.exprOffset(this.parenStart, rawExpr, rawExpr.trim());
182
+ const expr = rawExpr.trim();
183
+ this.skipWs();
184
+ if (this.peek() !== '{')
185
+ throw new ParseError(`Expected '{' after @switch at ${this.pos}`);
186
+ this.pos++;
187
+ const cases = [];
188
+ for (;;) {
189
+ this.skipWs();
190
+ if (this.peek() === '}') {
191
+ this.pos++;
192
+ break;
193
+ }
194
+ if (this.src.startsWith('@case', this.pos)) {
195
+ this.pos += '@case'.length;
196
+ this.skipWs();
197
+ const rawTest = this.readParen();
198
+ const testOffset = this.exprOffset(this.parenStart, rawTest, rawTest.trim());
199
+ cases.push({ test: rawTest.trim(), testOffset, children: this.readBlockBody() });
200
+ }
201
+ else if (this.src.startsWith('@default', this.pos)) {
202
+ this.pos += '@default'.length;
203
+ cases.push({ children: this.readBlockBody() });
204
+ }
205
+ else {
206
+ throw new ParseError(`Expected @case/@default or '}' in @switch at ${this.pos}`);
207
+ }
208
+ }
209
+ return { type: 'switch', expr, exprOffset, cases };
210
+ }
211
+ parseLet() {
212
+ this.pos += 4; // @let
213
+ this.skipWs();
214
+ const name = this.readName();
215
+ if (!name)
216
+ throw new ParseError(`Expected name after @let at ${this.pos}`);
217
+ this.skipWs();
218
+ if (this.peek() !== '=')
219
+ throw new ParseError(`Expected '=' in @let ${name}`);
220
+ this.pos++;
221
+ const rawStart = this.pos;
222
+ const raw = this.readUntilSemicolon();
223
+ const expr = raw.trim();
224
+ const exprOffset = rawStart + (raw.length - raw.trimStart().length);
225
+ if (this.peek() !== ';')
226
+ throw new ParseError(`Expected ';' ending @let ${name}`);
227
+ this.pos++;
228
+ return { type: 'let', name, expr, exprOffset };
229
+ }
230
+ parseDefer() {
231
+ this.pos += 6; // @defer
232
+ this.skipWs();
233
+ const head = this.readParen();
234
+ const trigger = this.parseDeferTrigger(head, this.parenStart);
235
+ const children = this.readBlockBody();
236
+ let placeholder;
237
+ const save = this.pos;
238
+ this.skipWs();
239
+ if (this.src.startsWith('@placeholder', this.pos)) {
240
+ this.pos += '@placeholder'.length;
241
+ placeholder = this.readBlockBody();
242
+ }
243
+ else {
244
+ this.pos = save;
245
+ }
246
+ return { type: 'defer', trigger, children, placeholder };
247
+ }
248
+ parseAwait() {
249
+ this.pos += 6; // @await
250
+ this.skipWs();
251
+ const rawExpr = this.readParen();
252
+ const expr = rawExpr.trim();
253
+ const exprOffset = this.exprOffset(this.parenStart, rawExpr, expr);
254
+ // Optional pending block: a `{` right after the source (anything else — e.g.
255
+ // `@then` — means there is no pending content).
256
+ let pending;
257
+ const save = this.pos;
258
+ this.skipWs();
259
+ if (this.peek() === '{') {
260
+ this.pos = save;
261
+ pending = this.readBlockBody();
262
+ }
263
+ else {
264
+ this.pos = save;
265
+ }
266
+ const branch = (kw) => {
267
+ const s = this.pos;
268
+ this.skipWs();
269
+ if (this.src.startsWith(kw, this.pos)) {
270
+ this.pos += kw.length;
271
+ const alias = this.maybeAlias();
272
+ return { alias, children: this.readBlockBody() };
273
+ }
274
+ this.pos = s;
275
+ return undefined;
276
+ };
277
+ const thenBranch = branch('@then');
278
+ const catchBranch = branch('@catch');
279
+ return { type: 'await', expr, exprOffset, pending, then: thenBranch, catch: catchBranch };
280
+ }
281
+ parseSnippet() {
282
+ this.pos += '@snippet'.length;
283
+ this.skipWs();
284
+ const name = this.readIdent();
285
+ if (!name)
286
+ throw new ParseError(`Expected a snippet name after @snippet at ${this.pos}`);
287
+ this.skipWs();
288
+ if (this.peek() !== '(')
289
+ throw new ParseError(`Expected '(' after @snippet ${name}`);
290
+ const rawParams = this.readParen();
291
+ const params = splitTopLevel(rawParams, ',').map((s) => s.trim()).filter(Boolean);
292
+ for (const p of params) {
293
+ if (!/^[A-Za-z_$][\w$]*$/.test(p)) {
294
+ throw new ParseError(`Invalid @snippet parameter '${p}' (identifiers only)`);
295
+ }
296
+ }
297
+ const children = this.readBlockBody();
298
+ return { type: 'snippet', name, params, children };
299
+ }
300
+ parseKey() {
301
+ this.pos += '@key'.length;
302
+ this.skipWs();
303
+ const raw = this.readParen();
304
+ const expr = raw.trim();
305
+ if (!expr)
306
+ throw new ParseError(`@key () needs an expression`);
307
+ const exprOffset = this.exprOffset(this.parenStart, raw, expr);
308
+ return { type: 'key', expr, exprOffset, children: this.readBlockBody() };
309
+ }
310
+ parseRender() {
311
+ this.pos += '@render'.length;
312
+ this.skipWs();
313
+ if (this.peek() !== '(')
314
+ throw new ParseError(`Expected '(' after @render at ${this.pos}`);
315
+ const raw = this.readParen();
316
+ const expr = raw.trim();
317
+ if (!expr)
318
+ throw new ParseError(`@render () needs an expression`);
319
+ return { type: 'render', expr, exprOffset: this.exprOffset(this.parenStart, raw, expr) };
320
+ }
321
+ /** Read a JS identifier (`[A-Za-z_$][\w$]*`); '' if none at the cursor. */
322
+ readIdent() {
323
+ const start = this.pos;
324
+ if (this.eof() || !/[A-Za-z_$]/.test(this.peek()))
325
+ return '';
326
+ this.pos++;
327
+ while (!this.eof() && /[\w$]/.test(this.peek()))
328
+ this.pos++;
329
+ return this.src.slice(start, this.pos);
330
+ }
331
+ /** Optional `(name)` alias after `@then`/`@catch`. */
332
+ maybeAlias() {
333
+ const save = this.pos;
334
+ this.skipWs();
335
+ if (this.peek() === '(') {
336
+ const inner = this.readParen().trim();
337
+ if (!/^[A-Za-z_$][\w$]*$/.test(inner)) {
338
+ throw new ParseError(`Expected an identifier alias in @then/@catch, got '${inner}'`);
339
+ }
340
+ return inner;
341
+ }
342
+ this.pos = save;
343
+ return undefined;
344
+ }
345
+ parseDeferTrigger(head, headStart) {
346
+ const h = head.trim();
347
+ const whenM = /^when\s+([\s\S]+)$/.exec(h);
348
+ if (whenM) {
349
+ const expr = whenM[1].trim();
350
+ return { kind: 'when', expr, exprOffset: this.exprOffset(headStart, head, expr) };
351
+ }
352
+ if (h === 'immediate')
353
+ return { kind: 'immediate' };
354
+ const onM = /^on\s+([\s\S]+)$/.exec(h);
355
+ if (onM) {
356
+ const on = onM[1].trim();
357
+ const timerM = /^timer\s*\(\s*([\s\S]+?)\s*\)$/.exec(on);
358
+ if (timerM) {
359
+ const ms = timerM[1].trim();
360
+ return { kind: 'timer', ms, msOffset: this.exprOffset(headStart, head, ms) };
361
+ }
362
+ if (on === 'idle')
363
+ return { kind: 'idle' };
364
+ if (on === 'viewport')
365
+ return { kind: 'viewport' };
366
+ if (on === 'interaction')
367
+ return { kind: 'interaction' };
368
+ if (on === 'hover')
369
+ return { kind: 'hover' };
370
+ throw new ParseError(`Unknown @defer trigger 'on ${on}'`);
371
+ }
372
+ throw new ParseError(`Invalid @defer trigger '${h}' (use 'when <expr>', 'on idle|viewport|interaction|hover', 'on timer(ms)', or 'immediate')`);
373
+ }
374
+ /** `{ children }` */
375
+ readBlockBody() {
376
+ this.skipWs();
377
+ if (this.peek() !== '{')
378
+ throw new ParseError(`Expected '{' at ${this.pos}`);
379
+ this.pos++;
380
+ const children = this.parseChildren(null, true);
381
+ if (this.peek() !== '}')
382
+ throw new ParseError(`Expected '}' closing block at ${this.pos}`);
383
+ this.pos++;
384
+ return children;
385
+ }
386
+ /** Read a balanced `( … )` and return the inner text (parens consumed). */
387
+ readParen() {
388
+ if (this.peek() !== '(')
389
+ throw new ParseError(`Expected '(' at ${this.pos}`);
390
+ this.pos++;
391
+ const start = this.pos;
392
+ let depth = 1;
393
+ while (!this.eof()) {
394
+ const c = this.peek();
395
+ if (c === '"' || c === "'" || c === '`') {
396
+ this.skipString(c);
397
+ continue;
398
+ }
399
+ if (c === '(')
400
+ depth++;
401
+ else if (c === ')') {
402
+ depth--;
403
+ if (depth === 0)
404
+ break;
405
+ }
406
+ this.pos++;
407
+ }
408
+ if (depth !== 0)
409
+ throw new ParseError('Unclosed ( in block head');
410
+ const inner = this.src.slice(start, this.pos);
411
+ this.parenStart = start;
412
+ this.pos++; // )
413
+ return inner;
414
+ }
415
+ /** Read an expression up to a top-level `;` (for @let). */
416
+ readUntilSemicolon() {
417
+ const start = this.pos;
418
+ let depth = 0;
419
+ while (!this.eof()) {
420
+ const c = this.peek();
421
+ if (c === '"' || c === "'" || c === '`') {
422
+ this.skipString(c);
423
+ continue;
424
+ }
425
+ if (c === '(' || c === '[' || c === '{')
426
+ depth++;
427
+ else if (c === ')' || c === ']' || c === '}')
428
+ depth--;
429
+ else if (c === ';' && depth === 0)
430
+ break;
431
+ this.pos++;
432
+ }
433
+ return this.src.slice(start, this.pos);
434
+ }
435
+ peek() {
436
+ return this.src[this.pos];
437
+ }
438
+ readInterp() {
439
+ this.pos += 2; // {{
440
+ const start = this.pos;
441
+ const end = this.src.indexOf('}}', this.pos);
442
+ if (end === -1)
443
+ throw new ParseError('Unclosed {{ interpolation');
444
+ this.pos = end + 2;
445
+ const raw = this.src.slice(start, end);
446
+ return { expr: raw.trim(), offset: start + (raw.length - raw.trimStart().length) };
447
+ }
448
+ skipComment() {
449
+ const end = this.src.indexOf('-->', this.pos);
450
+ this.pos = end === -1 ? this.src.length : end + 3;
451
+ }
452
+ readText(stopAtBrace) {
453
+ let out = '';
454
+ while (!this.eof()) {
455
+ const c = this.peek();
456
+ if (c === '<' || this.src.startsWith('{{', this.pos))
457
+ break;
458
+ if (stopAtBrace && c === '}')
459
+ break;
460
+ if (c === '@') {
461
+ // `@@` is the escape for a literal `@` — lets prose mention block
462
+ // keywords (`@@for`, `@@if`) without the parser treating them as blocks.
463
+ if (this.src[this.pos + 1] === '@') {
464
+ out += '@';
465
+ this.pos += 2;
466
+ continue;
467
+ }
468
+ if (BLOCK_KW.test(this.src.slice(this.pos)))
469
+ break;
470
+ }
471
+ out += c;
472
+ this.pos++;
473
+ }
474
+ return out;
475
+ }
476
+ parseElement() {
477
+ this.pos++; // <
478
+ const tagOffset = this.pos;
479
+ const tag = this.readName();
480
+ if (!tag)
481
+ throw new ParseError(`Expected tag name at ${this.pos}`);
482
+ const attrs = this.parseAttrs();
483
+ this.skipWs();
484
+ let selfClosing = false;
485
+ if (this.peek() === '/') {
486
+ selfClosing = true;
487
+ this.pos++;
488
+ }
489
+ if (this.peek() !== '>')
490
+ throw new ParseError(`Expected '>' for <${tag}> at ${this.pos}`);
491
+ this.pos++; // >
492
+ // Only lowercase HTML tags are void. A capitalized tag is a component (e.g. the
493
+ // router's <Link>, which would otherwise collide with the void <link> element),
494
+ // so it always takes children + a close tag.
495
+ const isVoid = !/^[A-Z]/.test(tag) && VOID.has(tag.toLowerCase());
496
+ if (selfClosing || isVoid) {
497
+ return { type: 'element', tag, tagOffset, attrs, children: [], selfClosing: true };
498
+ }
499
+ const children = this.parseChildren(tag);
500
+ // consume the matching close tag
501
+ if (!this.src.startsWith('</', this.pos))
502
+ throw new ParseError(`Unclosed <${tag}>`);
503
+ this.pos += 2;
504
+ const closeName = this.readName();
505
+ if (closeName !== tag)
506
+ throw new ParseError(`Mismatched </${closeName}>, expected </${tag}>`);
507
+ this.skipWs();
508
+ if (this.peek() !== '>')
509
+ throw new ParseError(`Expected '>' closing </${tag}>`);
510
+ this.pos++;
511
+ return { type: 'element', tag, tagOffset, attrs, children, selfClosing: false };
512
+ }
513
+ parseAttrs() {
514
+ const attrs = [];
515
+ while (!this.eof()) {
516
+ this.skipWs();
517
+ const c = this.peek();
518
+ if (c === '>' || c === '/' || c === undefined)
519
+ break;
520
+ attrs.push(this.parseAttr());
521
+ }
522
+ return attrs;
523
+ }
524
+ parseAttr() {
525
+ const nameStart = this.pos;
526
+ const rawName = this.readAttrName();
527
+ let value = null;
528
+ if (this.peek() === '=') {
529
+ this.pos++; // =
530
+ value = this.readAttrValue();
531
+ }
532
+ const attr = this.classifyAttr(rawName, value);
533
+ // record the directive identifier's offset for `weave check` diagnostics
534
+ if (attr.type === 'use')
535
+ attr.nameOffset = nameStart + 'use:'.length;
536
+ if (attr.type === 'transition') {
537
+ const prefix = attr.mode === 'both' ? 'transition:' : attr.mode === 'in' ? 'in:' : 'out:';
538
+ attr.nameOffset = nameStart + prefix.length;
539
+ }
540
+ return attr;
541
+ }
542
+ classifyAttr(rawName, value) {
543
+ const exprOf = () => {
544
+ if (!value)
545
+ throw new ParseError(`Binding '${rawName}' requires a value`);
546
+ if (value.kind !== 'expr')
547
+ throw new ParseError(`Binding '${rawName}' needs {expr}, got a string`);
548
+ return value.expr;
549
+ };
550
+ const offset = value && value.kind === 'expr' ? value.offset : undefined;
551
+ if (rawName === 'ref' || rawName === 'bind:this') {
552
+ return { type: 'ref', expr: exprOf(), offset };
553
+ }
554
+ if (rawName.startsWith('on:')) {
555
+ const [name, ...modifiers] = rawName.slice(3).split('|');
556
+ return { type: 'event', name, modifiers, expr: exprOf(), offset };
557
+ }
558
+ if (rawName.startsWith('class:')) {
559
+ return { type: 'class', name: rawName.slice(6), expr: exprOf(), offset };
560
+ }
561
+ if (rawName.startsWith('bind:')) {
562
+ return { type: 'bind', name: rawName.slice(5), expr: exprOf(), offset };
563
+ }
564
+ if (rawName.startsWith('use:')) {
565
+ // `use:action` (no arg) or `use:action={arg}`. The arg is optional.
566
+ const name = rawName.slice(4);
567
+ if (!name)
568
+ throw new ParseError(`'use:' requires an action name, e.g. use:tooltip`);
569
+ const expr = value && value.kind === 'expr' ? value.expr : undefined;
570
+ if (value && value.kind === 'static') {
571
+ throw new ParseError(`use:${name} needs {expr}, got a string`);
572
+ }
573
+ return { type: 'use', name, expr, offset };
574
+ }
575
+ if (rawName === 'show') {
576
+ return { type: 'show', expr: exprOf(), offset };
577
+ }
578
+ if (rawName.startsWith('transition:') || rawName.startsWith('in:') || rawName.startsWith('out:')) {
579
+ const mode = rawName.startsWith('transition:') ? 'both' : rawName.startsWith('in:') ? 'in' : 'out';
580
+ const prefix = mode === 'both' ? 'transition:' : mode === 'in' ? 'in:' : 'out:';
581
+ const name = rawName.slice(prefix.length);
582
+ if (!name)
583
+ throw new ParseError(`'${prefix}' requires a transition function, e.g. ${prefix}fade`);
584
+ if (value && value.kind === 'static')
585
+ throw new ParseError(`${rawName} needs {expr} params, got a string`);
586
+ const expr = value && value.kind === 'expr' ? value.expr : undefined;
587
+ return { type: 'transition', name, mode, expr, offset };
588
+ }
589
+ if (rawName.startsWith('.')) {
590
+ return { type: 'prop', name: rawName.slice(1), expr: exprOf(), offset };
591
+ }
592
+ if (value && value.kind === 'expr') {
593
+ return { type: 'attr', name: rawName, expr: value.expr, offset };
594
+ }
595
+ return { type: 'static', name: rawName, value: value ? value.text : '' };
596
+ }
597
+ readName() {
598
+ const start = this.pos;
599
+ // `:` allowed so namespaced tags parse (`<w:element>` — the dynamic element).
600
+ while (!this.eof() && /[A-Za-z0-9\-:]/.test(this.peek()))
601
+ this.pos++;
602
+ return this.src.slice(start, this.pos);
603
+ }
604
+ readAttrName() {
605
+ const start = this.pos;
606
+ while (!this.eof() && /[A-Za-z0-9_\-:.|@]/.test(this.peek()))
607
+ this.pos++;
608
+ return this.src.slice(start, this.pos);
609
+ }
610
+ readAttrValue() {
611
+ const c = this.peek();
612
+ if (c === '{') {
613
+ // Attribute/directive bindings use double braces — `attr={{ expr }}` — matching
614
+ // text interpolation. One syntax everywhere (M10); a single `{` is rejected so
615
+ // the author can't accidentally fall back to the old form.
616
+ if (!this.src.startsWith('{{', this.pos)) {
617
+ throw new ParseError(`Attribute bindings use double braces: write {{ expr }}, not { expr } (at ${this.pos})`);
618
+ }
619
+ const b = this.readDoubleBracedExpr();
620
+ return { kind: 'expr', expr: b.expr, offset: b.offset };
621
+ }
622
+ if (c === '"' || c === "'")
623
+ return { kind: 'static', text: this.readQuoted(c) };
624
+ // unquoted
625
+ const start = this.pos;
626
+ while (!this.eof() && !/[\s>/]/.test(this.peek()))
627
+ this.pos++;
628
+ return { kind: 'static', text: this.src.slice(start, this.pos) };
629
+ }
630
+ readQuoted(quote) {
631
+ this.pos++; // opening quote
632
+ const start = this.pos;
633
+ while (!this.eof() && this.peek() !== quote)
634
+ this.pos++;
635
+ const text = this.src.slice(start, this.pos);
636
+ this.pos++; // closing quote
637
+ return text;
638
+ }
639
+ /** Read a `{{ ... }}` expression, balancing inner braces and skipping string literals. */
640
+ readDoubleBracedExpr() {
641
+ this.pos += 2; // {{
642
+ const start = this.pos;
643
+ let depth = 0; // depth of inner (non-delimiting) braces
644
+ while (!this.eof()) {
645
+ const c = this.peek();
646
+ if (c === '"' || c === "'" || c === '`') {
647
+ this.skipString(c);
648
+ continue;
649
+ }
650
+ if (c === '{')
651
+ depth++;
652
+ else if (c === '}') {
653
+ if (depth > 0)
654
+ depth--;
655
+ else if (this.src[this.pos + 1] === '}')
656
+ break; // closing }}
657
+ }
658
+ this.pos++;
659
+ }
660
+ if (this.eof())
661
+ throw new ParseError('Unclosed {{ expression');
662
+ const raw = this.src.slice(start, this.pos);
663
+ this.pos += 2; // }}
664
+ return { expr: raw.trim(), offset: start + (raw.length - raw.trimStart().length) };
665
+ }
666
+ skipString(quote) {
667
+ this.pos++; // opening
668
+ while (!this.eof()) {
669
+ const c = this.peek();
670
+ if (c === '\\') {
671
+ this.pos += 2;
672
+ continue;
673
+ }
674
+ this.pos++;
675
+ if (c === quote)
676
+ return;
677
+ }
678
+ }
679
+ skipWs() {
680
+ while (!this.eof() && /\s/.test(this.peek()))
681
+ this.pos++;
682
+ }
683
+ }
684
+ /** Split `s` on top-level occurrences of `sep`, respecting (), [], {}, and strings. */
685
+ function splitTopLevel(s, sep) {
686
+ const out = [];
687
+ let depth = 0;
688
+ let last = 0;
689
+ for (let i = 0; i < s.length; i++) {
690
+ const c = s[i];
691
+ if (c === '"' || c === "'" || c === '`') {
692
+ i = skipStr(s, i);
693
+ continue;
694
+ }
695
+ if (c === '(' || c === '[' || c === '{')
696
+ depth++;
697
+ else if (c === ')' || c === ']' || c === '}')
698
+ depth--;
699
+ else if (c === sep && depth === 0) {
700
+ out.push(s.slice(last, i));
701
+ last = i + 1;
702
+ }
703
+ }
704
+ out.push(s.slice(last));
705
+ return out;
706
+ }
707
+ function skipStr(s, start) {
708
+ const q = s[start];
709
+ let i = start + 1;
710
+ while (i < s.length) {
711
+ if (s[i] === '\\') {
712
+ i += 2;
713
+ continue;
714
+ }
715
+ if (s[i] === q)
716
+ return i;
717
+ i++;
718
+ }
719
+ return s.length;
720
+ }
721
+ //# sourceMappingURL=parser.js.map