@abide/abide 0.41.0 → 0.42.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 (61) hide show
  1. package/AGENTS.md +333 -326
  2. package/CHANGELOG.md +92 -0
  3. package/README.md +91 -83
  4. package/package.json +1 -1
  5. package/src/abideLsp.ts +46 -8
  6. package/src/abideResolverPlugin.ts +3 -5
  7. package/src/lib/server/runtime/devClientFingerprint.ts +2 -1
  8. package/src/lib/shared/escapeRegex.ts +9 -0
  9. package/src/lib/shared/fileName.ts +9 -0
  10. package/src/lib/shared/fileStem.ts +3 -1
  11. package/src/lib/shared/programNameForPackage.ts +3 -1
  12. package/src/lib/shared/stripImport.ts +3 -1
  13. package/src/lib/ui/compile/ABIDE_SEMANTIC_TOKENS_LEGEND.ts +45 -0
  14. package/src/lib/ui/compile/SSR_ESCAPE.ts +3 -1
  15. package/src/lib/ui/compile/abideUiPlugin.ts +2 -1
  16. package/src/lib/ui/compile/compileShadow.ts +46 -9
  17. package/src/lib/ui/compile/createShadowLanguageService.ts +48 -0
  18. package/src/lib/ui/compile/encodeSemanticTokens.ts +37 -0
  19. package/src/lib/ui/compile/generateBuild.ts +13 -6
  20. package/src/lib/ui/compile/generateSSR.ts +23 -14
  21. package/src/lib/ui/compile/makeVarNamer.ts +10 -0
  22. package/src/lib/ui/compile/offsetToPosition.ts +9 -0
  23. package/src/lib/ui/compile/parseTemplate.ts +524 -104
  24. package/src/lib/ui/compile/skeletonContext.ts +67 -79
  25. package/src/lib/ui/compile/structuralBlockTokens.ts +101 -0
  26. package/src/lib/ui/compile/templateAnchorAdapter.ts +61 -0
  27. package/src/lib/ui/compile/templateElementAdapter.ts +44 -0
  28. package/src/lib/ui/compile/types/SemanticToken.ts +11 -0
  29. package/src/lib/ui/compile/types/TemplateNode.ts +9 -0
  30. package/src/lib/ui/compile/walkAnchorOrder.ts +57 -0
  31. package/src/lib/ui/compile/walkElementOrder.ts +60 -0
  32. package/src/lib/ui/dom/appendSnippet.ts +9 -8
  33. package/src/lib/ui/dom/appendText.ts +1 -5
  34. package/src/lib/ui/dom/applyResolved.ts +4 -9
  35. package/src/lib/ui/dom/awaitBlock.ts +37 -31
  36. package/src/lib/ui/dom/buildDetachedRange.ts +30 -0
  37. package/src/lib/ui/dom/depthZeroNodes.ts +34 -0
  38. package/src/lib/ui/dom/domAnchorAdapter.ts +29 -0
  39. package/src/lib/ui/dom/domElementAdapter.ts +20 -0
  40. package/src/lib/ui/dom/each.ts +7 -11
  41. package/src/lib/ui/dom/eachAsync.ts +12 -17
  42. package/src/lib/ui/dom/isComment.ts +6 -0
  43. package/src/lib/ui/dom/isElement.ts +6 -0
  44. package/src/lib/ui/dom/markerDepthDelta.ts +19 -0
  45. package/src/lib/ui/dom/mountRange.ts +4 -3
  46. package/src/lib/ui/dom/mountSlot.ts +4 -3
  47. package/src/lib/ui/dom/on.ts +6 -1
  48. package/src/lib/ui/dom/replaceRange.ts +24 -0
  49. package/src/lib/ui/dom/skeleton.ts +35 -92
  50. package/src/lib/ui/dom/switchBlock.ts +13 -10
  51. package/src/lib/ui/dom/when.ts +13 -10
  52. package/src/lib/ui/runtime/RANGE_MARKER.ts +16 -0
  53. package/src/lib/ui/runtime/batch.ts +22 -0
  54. package/src/lib/ui/runtime/clientPage.ts +3 -8
  55. package/src/lib/ui/runtime/createDoc.ts +12 -16
  56. package/src/lib/ui/runtime/pathSegments.ts +10 -0
  57. package/src/lib/ui/seedResolved.ts +28 -0
  58. package/src/lib/ui/startClient.ts +6 -4
  59. package/src/lib/ui/types/ResolvedFrame.ts +15 -0
  60. package/template/.zed/settings.json +4 -0
  61. package/template/src/ui/pages/page.abide +4 -4
@@ -90,11 +90,323 @@ export function parseTemplate(source: string, baseOffset = 0): { nodes: Template
90
90
  return { kind: 'style', css }
91
91
  }
92
92
 
93
+ /* True when the cursor sits on a block-directive open `{#`. A `{:`/`{/` is only
94
+ valid INSIDE a block (consumed by readBlockChildren), so at top level it would be
95
+ a stray-branch error — handled where blocks are read, not here. */
96
+ function atBlock(): boolean {
97
+ return source.charAt(cursor) === '{' && source.charAt(cursor + 1) === '#'
98
+ }
99
+
100
+ /* Reads a `{#…}` / `{:…}` / `{/…}` token starting on `{`. Tracks string literals
101
+ and nested braces (same scan as readBracedExpression) so a brace/quote inside an
102
+ expression (`{#if obj.has('}')}`) doesn't end the token early. Returns the sigil,
103
+ the trimmed directive body WITHOUT the sigil, and the absolute loc of the body's
104
+ first post-trim char. */
105
+ function readBlockToken(): { sigil: '#' | ':' | '/'; body: string; loc: number } {
106
+ const sigil = source.charAt(cursor + 1) as '#' | ':' | '/'
107
+ cursor += 2 // past `{` and the sigil
108
+ const start = cursor
109
+ let depth = 1
110
+ while (cursor < source.length && depth > 0) {
111
+ const char = source.charAt(cursor)
112
+ if (char === '"' || char === "'" || char === '`') {
113
+ cursor += 1
114
+ while (cursor < source.length && source.charAt(cursor) !== char) {
115
+ if (source.charAt(cursor) === '\\') {
116
+ cursor += 1
117
+ }
118
+ cursor += 1
119
+ }
120
+ } else if (char === '{') {
121
+ depth += 1
122
+ } else if (char === '}') {
123
+ depth -= 1
124
+ }
125
+ cursor += 1
126
+ }
127
+ const raw = source.slice(start, cursor - 1)
128
+ const leading = raw.length - raw.trimStart().length
129
+ return { sigil, body: raw.trim(), loc: baseOffset + start + leading }
130
+ }
131
+
132
+ /* The leading keyword of a directive body (`if`, `for`, `await`, `switch`, `try`,
133
+ `else`, `then`, `catch`, `finally`, `case`, `default`). */
134
+ function headKeyword(body: string): string {
135
+ const match = body.match(/^\s*(\S+)/)
136
+ return match?.[1] ?? ''
137
+ }
138
+
139
+ /* Reads a `{#…}` control block: the open token, its children up to a continuation
140
+ `{:…}` (a branch) or close `{/…}`, recursing. Emits the same nodes toControlFlow
141
+ does today (if/each/await/switch/try + case/branch children). */
142
+ function readBlock(): TemplateNode {
143
+ const open = readBlockToken() // sigil is '#'
144
+ const keyword = headKeyword(open.body)
145
+ if (keyword === 'if') {
146
+ const start = open.body.indexOf('if') + 2
147
+ const condition = open.body.slice(start).trim()
148
+ if (condition === '') {
149
+ throw new Error('[abide] {#if} requires a condition expression')
150
+ }
151
+ const children = readBlockChildren('if')
152
+ return { kind: 'if', condition, children, loc: exprLoc(open.loc, open.body, start) }
153
+ }
154
+ if (keyword === 'for') {
155
+ const head = parseForHead(open.body, open.loc)
156
+ const children = readBlockChildren('for')
157
+ return {
158
+ kind: 'each',
159
+ items: head.items,
160
+ as: head.as,
161
+ index: head.index,
162
+ key: head.key,
163
+ async: head.async,
164
+ children,
165
+ loc: head.loc,
166
+ asLoc: head.asLoc,
167
+ keyLoc: head.keyLoc,
168
+ indexLoc: head.indexLoc,
169
+ }
170
+ }
171
+ if (keyword === 'await') {
172
+ const start = open.body.indexOf('await') + 5
173
+ const afterAwait = open.body.slice(start)
174
+ const thenAt = keywordAtDepthZero(afterAwait, 'then')
175
+ const promise = (thenAt === -1 ? afterAwait : afterAwait.slice(0, thenAt)).trim()
176
+ if (promise === '') {
177
+ throw new Error('[abide] {#await} requires a promise expression')
178
+ }
179
+ const as =
180
+ thenAt === -1
181
+ ? undefined
182
+ : afterAwait.slice(thenAt + 'then'.length).trim() || undefined
183
+ const children = readBlockChildren('await')
184
+ return {
185
+ kind: 'await',
186
+ promise,
187
+ blocking: thenAt !== -1,
188
+ as,
189
+ children,
190
+ loc: exprLoc(open.loc, open.body, start),
191
+ asLoc:
192
+ as === undefined
193
+ ? undefined
194
+ : exprLoc(open.loc, open.body, start + thenAt + 'then'.length),
195
+ }
196
+ }
197
+ if (keyword === 'switch') {
198
+ const start = open.body.indexOf('switch') + 6
199
+ const subject = open.body.slice(start).trim()
200
+ if (subject === '') {
201
+ throw new Error('[abide] {#switch} requires a subject expression')
202
+ }
203
+ const children = readBlockChildren('switch')
204
+ return { kind: 'switch', subject, children, loc: exprLoc(open.loc, open.body, start) }
205
+ }
206
+ if (keyword === 'try') {
207
+ const children = readBlockChildren('try')
208
+ return { kind: 'try', children }
209
+ }
210
+ throw new Error(
211
+ `[abide] unknown control block {#${keyword}} — expected if/for/await/switch/try`,
212
+ )
213
+ }
214
+
215
+ /* Shared scan body for block/branch child loops. Reads one node at the current
216
+ cursor position and pushes it onto `nodes`. Returns false when the caller's
217
+ terminator fires (caller decides what to do next); returns true while scanning
218
+ continues. `onBranch` fires when `{:` is seen — block-children consume it as a
219
+ new branch node; branch-children let the caller exit instead. */
220
+ function scanNode(nodes: TemplateNode[], onBranch: (() => boolean) | undefined): boolean {
221
+ /* Branch/close tokens — delegate to the caller's terminator logic. */
222
+ if (source.startsWith('{/', cursor)) {
223
+ return false
224
+ }
225
+ if (source.startsWith('{:', cursor)) {
226
+ return onBranch === undefined ? false : onBranch()
227
+ }
228
+ if (atBlock()) {
229
+ nodes.push(readBlock())
230
+ } else if (source.startsWith('<!--', cursor)) {
231
+ skipComment()
232
+ } else if (atStyleTag()) {
233
+ nodes.push(readStyle())
234
+ } else if (source.charAt(cursor) === '<') {
235
+ nodes.push(readElement())
236
+ } else {
237
+ nodes.push(readText())
238
+ }
239
+ return true
240
+ }
241
+
242
+ /* Reads children of a block until its close `{/<keyword>}`. A continuation token
243
+ `{:…}` ends the current branch's children and starts a new `case`/`branch` node
244
+ (per construct). The leading children (before the first `{:…}`) are the block's
245
+ own children (the `if`/`await`/`try` then-content). Returns the full children list
246
+ INCLUDING the case/branch nodes, matching toControlFlow's output. */
247
+ function readBlockChildren(keyword: string): TemplateNode[] {
248
+ const nodes: TemplateNode[] = []
249
+ while (cursor < source.length) {
250
+ const keepGoing = scanNode(nodes, () => {
251
+ nodes.push(readBranch(keyword))
252
+ return true
253
+ })
254
+ if (!keepGoing) {
255
+ const close = readBlockToken() // consume the close `{/keyword}`
256
+ const closeKeyword = headKeyword(close.body)
257
+ /* The close must name its open block — a mismatch (`{#if}…{/for}`) or
258
+ crossed nesting (`{#if}{#for}…{/if}{/for}`) would otherwise silently
259
+ mis-parse into a structurally wrong tree. */
260
+ if (closeKeyword !== keyword) {
261
+ throw new Error(
262
+ `[abide] {/${closeKeyword}} does not close the open {#${keyword}} — expected {/${keyword}}`,
263
+ )
264
+ }
265
+ return nodes
266
+ }
267
+ }
268
+ throw new Error(`[abide] unterminated {#${keyword}} block — missing {/${keyword}}`)
269
+ }
270
+
271
+ /* Reads a continuation token `{:…}` and the children up to the NEXT continuation or
272
+ close, returning the branch node for the parent construct. Handles `if`-chain
273
+ branches (else / else if) and `for await` catch branches. */
274
+ function readBranch(parentKeyword: string): TemplateNode {
275
+ const token = readBlockToken() // sigil ':'
276
+ const keyword = headKeyword(token.body)
277
+ const branchChildren = readBranchChildren()
278
+ if (parentKeyword === 'if') {
279
+ if (keyword === 'else' && headKeyword(token.body.slice(4).trim()) === 'if') {
280
+ const start = token.body.indexOf('if') + 2
281
+ const condition = token.body.slice(start).trim()
282
+ if (condition === '') {
283
+ throw new Error('[abide] {:else if} requires a condition expression')
284
+ }
285
+ return {
286
+ kind: 'case',
287
+ match: undefined,
288
+ condition,
289
+ children: branchChildren,
290
+ loc: exprLoc(token.loc, token.body, start),
291
+ }
292
+ }
293
+ if (keyword === 'else') {
294
+ return { kind: 'case', match: undefined, children: branchChildren }
295
+ }
296
+ }
297
+ if (parentKeyword === 'for' && keyword === 'catch') {
298
+ const as = token.body.slice(token.body.indexOf('catch') + 5).trim() || undefined
299
+ return {
300
+ kind: 'branch',
301
+ branch: 'catch',
302
+ as,
303
+ children: branchChildren,
304
+ asLoc:
305
+ as === undefined
306
+ ? undefined
307
+ : exprLoc(token.loc, token.body, token.body.indexOf('catch') + 5),
308
+ }
309
+ }
310
+ if (parentKeyword === 'switch') {
311
+ if (keyword === 'case') {
312
+ const start = token.body.indexOf('case') + 4
313
+ const match = token.body.slice(start).trim()
314
+ if (match === '') {
315
+ throw new Error('[abide] {:case} requires a value expression')
316
+ }
317
+ return {
318
+ kind: 'case',
319
+ match,
320
+ children: branchChildren,
321
+ loc: exprLoc(token.loc, token.body, start),
322
+ }
323
+ }
324
+ if (keyword === 'default') {
325
+ return { kind: 'case', match: undefined, children: branchChildren }
326
+ }
327
+ }
328
+ if (parentKeyword === 'await') {
329
+ if (keyword === 'then') {
330
+ const as = token.body.slice(token.body.indexOf('then') + 4).trim() || undefined
331
+ return {
332
+ kind: 'branch',
333
+ branch: 'then',
334
+ as,
335
+ children: branchChildren,
336
+ asLoc:
337
+ as === undefined
338
+ ? undefined
339
+ : exprLoc(token.loc, token.body, token.body.indexOf('then') + 4),
340
+ }
341
+ }
342
+ if (keyword === 'catch') {
343
+ const as = token.body.slice(token.body.indexOf('catch') + 5).trim() || undefined
344
+ return {
345
+ kind: 'branch',
346
+ branch: 'catch',
347
+ as,
348
+ children: branchChildren,
349
+ asLoc:
350
+ as === undefined
351
+ ? undefined
352
+ : exprLoc(token.loc, token.body, token.body.indexOf('catch') + 5),
353
+ }
354
+ }
355
+ if (keyword === 'finally') {
356
+ return {
357
+ kind: 'branch',
358
+ branch: 'finally',
359
+ as: undefined,
360
+ children: branchChildren,
361
+ }
362
+ }
363
+ }
364
+ if (parentKeyword === 'try') {
365
+ if (keyword === 'catch') {
366
+ const as = token.body.slice(token.body.indexOf('catch') + 5).trim() || undefined
367
+ return {
368
+ kind: 'branch',
369
+ branch: 'catch',
370
+ as,
371
+ children: branchChildren,
372
+ asLoc:
373
+ as === undefined
374
+ ? undefined
375
+ : exprLoc(token.loc, token.body, token.body.indexOf('catch') + 5),
376
+ }
377
+ }
378
+ if (keyword === 'finally') {
379
+ return {
380
+ kind: 'branch',
381
+ branch: 'finally',
382
+ as: undefined,
383
+ children: branchChildren,
384
+ }
385
+ }
386
+ }
387
+ throw new Error(`[abide] {:${keyword}} is not valid inside {#${parentKeyword}}`)
388
+ }
389
+
390
+ /* Children of a branch: read until the next `{:…}` or `{/…}` WITHOUT consuming it
391
+ (the caller's readBlockChildren loop handles those). */
392
+ function readBranchChildren(): TemplateNode[] {
393
+ const nodes: TemplateNode[] = []
394
+ /* Pass undefined for onBranch so scanNode returns false on `{:`, leaving it unconsumed. */
395
+ while (cursor < source.length && scanNode(nodes, undefined)) {
396
+ // continue
397
+ }
398
+ return nodes
399
+ }
400
+
93
401
  function readText(): TemplateNode {
94
402
  const parts: TextPart[] = []
95
403
  let literal = ''
96
404
  while (cursor < source.length && source.charAt(cursor) !== '<') {
97
405
  if (source.charAt(cursor) === '{') {
406
+ const next = source.charAt(cursor + 1)
407
+ if (next === '#' || next === ':' || next === '/') {
408
+ break // a block/continuation/close token — not interpolation
409
+ }
98
410
  if (literal !== '') {
99
411
  parts.push({ kind: 'static', value: decodeHtmlEntities(literal) })
100
412
  literal = ''
@@ -246,11 +558,23 @@ export function parseTemplate(source: string, baseOffset = 0): { nodes: Template
246
558
  }
247
559
  const children = selfClosing || VOID_TAGS.has(tag) ? [] : readChildren(tag)
248
560
  if (tag === 'template') {
249
- return toControlFlow(attrs, children)
561
+ return toSnippetOrTemplate(attrs, children)
250
562
  }
251
563
  return { kind: 'element', tag, attrs, children }
252
564
  }
253
565
 
566
+ /* A `{:…}`/`{/…}` reached OUTSIDE a block (the root scan or an element's children —
567
+ not readBlockChildren/readBranchChildren, which consume their own) is a continuation
568
+ or close with no open `{#…}`. Surface it as an error: readText breaks on `{:`/`{/`
569
+ without advancing, so falling through would loop forever. */
570
+ function throwIfStrayBranch(): void {
571
+ if (source.startsWith('{:', cursor) || source.startsWith('{/', cursor)) {
572
+ const end = source.indexOf('}', cursor)
573
+ const token = source.slice(cursor, end === -1 ? source.length : end + 1)
574
+ throw new Error(`[abide] ${token} has no open {#…} block`)
575
+ }
576
+ }
577
+
254
578
  function readChildren(closeTag: string): TemplateNode[] {
255
579
  const nodes: TemplateNode[] = []
256
580
  while (cursor < source.length) {
@@ -258,10 +582,13 @@ export function parseTemplate(source: string, baseOffset = 0): { nodes: Template
258
582
  cursor = source.indexOf('>', cursor) + 1
259
583
  break
260
584
  }
585
+ throwIfStrayBranch()
261
586
  if (source.startsWith('<!--', cursor)) {
262
587
  skipComment()
263
588
  } else if (atStyleTag()) {
264
589
  nodes.push(readStyle())
590
+ } else if (atBlock()) {
591
+ nodes.push(readBlock())
265
592
  } else if (source.charAt(cursor) === '<') {
266
593
  nodes.push(readElement())
267
594
  } else {
@@ -273,10 +600,13 @@ export function parseTemplate(source: string, baseOffset = 0): { nodes: Template
273
600
 
274
601
  const roots: TemplateNode[] = []
275
602
  while (cursor < source.length) {
603
+ throwIfStrayBranch()
276
604
  if (source.startsWith('<!--', cursor)) {
277
605
  skipComment()
278
606
  } else if (atStyleTag()) {
279
607
  roots.push(readStyle())
608
+ } else if (atBlock()) {
609
+ roots.push(readBlock())
280
610
  } else if (source.charAt(cursor) === '<') {
281
611
  roots.push(readElement())
282
612
  } else {
@@ -287,6 +617,165 @@ export function parseTemplate(source: string, baseOffset = 0): { nodes: Template
287
617
  return { nodes: roots }
288
618
  }
289
619
 
620
+ /* Finds the index of a ` <token> ` keyword (` of `, ` by `) at brace/paren/bracket
621
+ depth 0, scanning left to right, skipping string literals. Returns -1 if absent. */
622
+ function indexOfKeywordAtDepthZero(text: string, keyword: string): number {
623
+ let depth = 0
624
+ let i = 0
625
+ while (i < text.length) {
626
+ const char = text.charAt(i)
627
+ if (char === '"' || char === "'" || char === '`') {
628
+ i += 1
629
+ while (i < text.length && text.charAt(i) !== char) {
630
+ if (text.charAt(i) === '\\') {
631
+ i += 1
632
+ }
633
+ i += 1
634
+ }
635
+ } else if (char === '{' || char === '(' || char === '[') {
636
+ depth += 1
637
+ } else if (char === '}' || char === ')' || char === ']') {
638
+ depth -= 1
639
+ } else if (depth === 0 && text.startsWith(keyword, i)) {
640
+ return i
641
+ }
642
+ i += 1
643
+ }
644
+ return -1
645
+ }
646
+
647
+ /* Index of a bare keyword (`then`) at brace/paren/bracket depth 0, requiring whitespace
648
+ on both sides — so it isn't matched inside an identifier (`q.then(x)`) and MAY sit
649
+ terminally. Unlike ` of `/` by `, an await `then` can end the head: `{#await p then}`
650
+ is a blocking await with no value binding, which a trailing-space match would miss
651
+ (folding `then` into the promise). Skips string literals. -1 if absent. */
652
+ function keywordAtDepthZero(text: string, word: string): number {
653
+ let depth = 0
654
+ let i = 0
655
+ while (i < text.length) {
656
+ const char = text.charAt(i)
657
+ if (char === '"' || char === "'" || char === '`') {
658
+ i += 1
659
+ while (i < text.length && text.charAt(i) !== char) {
660
+ if (text.charAt(i) === '\\') {
661
+ i += 1
662
+ }
663
+ i += 1
664
+ }
665
+ } else if (char === '{' || char === '(' || char === '[') {
666
+ depth += 1
667
+ } else if (char === '}' || char === ')' || char === ']') {
668
+ depth -= 1
669
+ } else if (
670
+ depth === 0 &&
671
+ text.startsWith(word, i) &&
672
+ i > 0 &&
673
+ /\s/.test(text.charAt(i - 1)) &&
674
+ (i + word.length === text.length || /\s/.test(text.charAt(i + word.length)))
675
+ ) {
676
+ return i
677
+ }
678
+ i += 1
679
+ }
680
+ return -1
681
+ }
682
+
683
+ /* The depth-0 comma index in a binding (`{id, title}, i` → the comma after `}`),
684
+ so a destructuring pattern's inner commas don't split the binding from its index. */
685
+ function bindingCommaAtDepthZero(text: string): number {
686
+ let depth = 0
687
+ let i = 0
688
+ while (i < text.length) {
689
+ const char = text.charAt(i)
690
+ if (char === '{' || char === '(' || char === '[') {
691
+ depth += 1
692
+ } else if (char === '}' || char === ')' || char === ']') {
693
+ depth -= 1
694
+ } else if (char === ',' && depth === 0) {
695
+ return i
696
+ }
697
+ i += 1
698
+ }
699
+ return -1
700
+ }
701
+
702
+ /* Absolute source offset of the trimmed expression beginning at `start` within a
703
+ directive `body` whose first char is at absolute `bodyLoc` — skips the leading
704
+ whitespace `.trim()` drops so the offset points at the expression's first real char
705
+ (the shadow source-map invariant: source text at `loc` equals the emitted code). */
706
+ function exprLoc(bodyLoc: number, body: string, start: number): number {
707
+ const raw = body.slice(start)
708
+ return bodyLoc + start + (raw.length - raw.trimStart().length)
709
+ }
710
+
711
+ /* Parses `for [await] <binding>[, <index>] of <iterable> [by <key>]`. Offsets are
712
+ tracked against the original `body` (not trimmed slices) so `loc` points at the
713
+ iterable expression's first char — `bodyLoc` is the absolute offset of `body[0]`. */
714
+ function parseForHead(
715
+ body: string,
716
+ bodyLoc: number,
717
+ ): {
718
+ items: string
719
+ as: string
720
+ index: string | undefined
721
+ key: string | undefined
722
+ async: boolean
723
+ loc: number
724
+ asLoc: number | undefined
725
+ keyLoc: number | undefined
726
+ indexLoc: number | undefined
727
+ } {
728
+ const leadingSpace = (text: string): number => text.length - text.trimStart().length
729
+ const skipSpace = (i: number): number => {
730
+ let at = i
731
+ while (at < body.length && /\s/.test(body.charAt(at))) {
732
+ at += 1
733
+ }
734
+ return at
735
+ }
736
+ let bindingStart = skipSpace(body.indexOf('for') + 3)
737
+ const isAsync = /^await\b/.test(body.slice(bindingStart))
738
+ if (isAsync) {
739
+ bindingStart = skipSpace(bindingStart + 'await'.length)
740
+ }
741
+ const region = body.slice(bindingStart)
742
+ const ofAt = indexOfKeywordAtDepthZero(region, ' of ')
743
+ if (ofAt === -1) {
744
+ throw new Error('[abide] {#for} requires `<binding> of <iterable>`')
745
+ }
746
+ const left = region.slice(0, ofAt).trim()
747
+ const itemsStart = skipSpace(bindingStart + ofAt + ' of '.length)
748
+ let itemsRegion = body.slice(itemsStart)
749
+ const byAt = indexOfKeywordAtDepthZero(itemsRegion, ' by ')
750
+ const keyRaw = byAt === -1 ? '' : itemsRegion.slice(byAt + ' by '.length)
751
+ const key = byAt === -1 ? undefined : keyRaw.trim()
752
+ const keyLoc =
753
+ byAt === -1 ? undefined : bodyLoc + itemsStart + byAt + ' by '.length + leadingSpace(keyRaw)
754
+ if (byAt !== -1) {
755
+ itemsRegion = itemsRegion.slice(0, byAt)
756
+ }
757
+ const commaAt = bindingCommaAtDepthZero(left)
758
+ const as = (commaAt === -1 ? left : left.slice(0, commaAt)).trim()
759
+ const indexRaw = commaAt === -1 ? '' : left.slice(commaAt + 1)
760
+ const index = commaAt === -1 ? undefined : indexRaw.trim()
761
+ /* `left` is trimmed and starts at `bindingStart` (skipSpace'd), so the binding
762
+ name begins exactly there; the index sits past the comma. */
763
+ return {
764
+ items: itemsRegion.trim(),
765
+ as: as === '' ? '_item' : as,
766
+ index,
767
+ key,
768
+ async: isAsync,
769
+ loc: bodyLoc + itemsStart,
770
+ asLoc: as === '' ? undefined : bodyLoc + bindingStart,
771
+ keyLoc,
772
+ indexLoc:
773
+ commaAt === -1
774
+ ? undefined
775
+ : bodyLoc + bindingStart + commaAt + 1 + leadingSpace(indexRaw),
776
+ }
777
+ }
778
+
290
779
  /* A `case` node (`<template else>`/`<template case>`/`<template default>`) is valid
291
780
  only as a direct child of its `<template if>`/`<template switch>`; a `branch`
292
781
  (`then`/`catch`/`finally`) only inside its `<template await>`/`<template try>`.
@@ -452,10 +941,39 @@ function attrName(attr: TemplateAttr): string {
452
941
  }
453
942
 
454
943
  /* Turns a `<template>` directive into a control node (if/each/await + then/catch). */
455
- function toControlFlow(attrs: TemplateAttr[], children: TemplateNode[]): TemplateNode {
944
+ /* The control-flow attribute names that used to drive `<template>` directives — now
945
+ moved to `{#…}` blocks. A `<template>` carrying one is a migration error. */
946
+ const CONTROL_DIRECTIVES = new Set([
947
+ 'if',
948
+ 'elseif',
949
+ 'else',
950
+ 'each',
951
+ 'await',
952
+ 'then',
953
+ 'catch',
954
+ 'finally',
955
+ 'switch',
956
+ 'case',
957
+ 'default',
958
+ 'try',
959
+ ])
960
+
961
+ /* A `<template>` is now ONLY a snippet declaration (`name`) or a plain inert
962
+ `<template>` element. Control flow moved to `{#…}` blocks; a directive attribute
963
+ (`if`/`each`/`await`/…) is a migration error pointing at the block form. `name`
964
+ makes the element callable; without it, it stays an inert reusable fragment. */
965
+ function toSnippetOrTemplate(attrs: TemplateAttr[], children: TemplateNode[]): TemplateNode {
456
966
  const find = (name: string) => attrs.find((attr) => attrName(attr) === name)
457
- /* `<template name="row" args={item}>` declares a snippet — a named builder, not
458
- a control branch. `args` (its parameter list) rides the `{…}` expression slot. */
967
+ const directive = attrs.find((attr) => CONTROL_DIRECTIVES.has(attrName(attr)))
968
+ if (directive !== undefined) {
969
+ const name = attrName(directive)
970
+ const block = name === 'elseif' || name === 'else' ? 'if' : name
971
+ throw new Error(
972
+ `[abide] <template ${name}> control flow was removed — use the {#${block}…} block instead`,
973
+ )
974
+ }
975
+ /* `<template name="row" args={item}>` declares a snippet — a named builder. `args`
976
+ (its parameter list) rides the `{…}` expression slot. */
459
977
  const snippet = find('name')
460
978
  if (snippet !== undefined) {
461
979
  const name = attrText(snippet)
@@ -471,104 +989,6 @@ function toControlFlow(attrs: TemplateAttr[], children: TemplateNode[]): Templat
471
989
  loc: attrLoc(params),
472
990
  }
473
991
  }
474
- /* `<template try>` is a synchronous error boundary: its children are the guarded
475
- subtree; `catch`/`finally` branches handle a throw while building them. */
476
- if (find('try') !== undefined) {
477
- return { kind: 'try', children }
478
- }
479
- /* `await` alongside `each` is the async-list switch (handled in the each branch
480
- below), not an await block. */
481
- const promise = find('await')
482
- if (promise !== undefined && find('each') === undefined) {
483
- const promiseCode = attrText(promise)
484
- if (promiseCode === undefined) {
485
- throw new Error('[abide] <template await> requires a promise expression')
486
- }
487
- /* A `then` attribute ON the await tag is the blocking switch: children become
488
- the resolved content bound to its value (a `then` *child* is a streaming
489
- branch, handled separately below when its own tag is parsed). */
490
- const boundThen = find('then')
491
- return {
492
- kind: 'await',
493
- promise: promiseCode,
494
- blocking: boundThen !== undefined,
495
- as: boundThen === undefined ? undefined : attrText(boundThen) || undefined,
496
- children,
497
- loc: attrLoc(promise),
498
- }
499
- }
500
- const thenAttr = find('then')
501
- if (thenAttr !== undefined) {
502
- return { kind: 'branch', branch: 'then', as: attrText(thenAttr) || undefined, children }
503
- }
504
- const catchAttr = find('catch')
505
- if (catchAttr !== undefined) {
506
- return { kind: 'branch', branch: 'catch', as: attrText(catchAttr) || undefined, children }
507
- }
508
- /* `<template finally>` renders after settle on BOTH outcomes — outcome-agnostic,
509
- so it binds no value. */
510
- if (find('finally') !== undefined) {
511
- return { kind: 'branch', branch: 'finally', as: undefined, children }
512
- }
513
- const subject = find('switch')
514
- if (subject !== undefined) {
515
- const subjectCode = attrText(subject)
516
- if (subjectCode === undefined) {
517
- throw new Error('[abide] <template switch> requires a subject expression')
518
- }
519
- return { kind: 'switch', subject: subjectCode, children, loc: attrLoc(subject) }
520
- }
521
- const caseAttr = find('case')
522
- if (caseAttr !== undefined) {
523
- const matchCode = attrText(caseAttr)
524
- if (matchCode === undefined) {
525
- throw new Error('[abide] <template case> requires a value expression')
526
- }
527
- return { kind: 'case', match: matchCode, children, loc: attrLoc(caseAttr) }
528
- }
529
- /* `<template elseif={c}>` is a match-less case carrying a condition — a branch of the
530
- enclosing `<template if>` chain, truthy-tested in source order. */
531
- const elseif = find('elseif')
532
- if (elseif !== undefined) {
533
- const conditionCode = attrText(elseif)
534
- if (conditionCode === undefined) {
535
- throw new Error('[abide] <template elseif> requires a condition expression')
536
- }
537
- return {
538
- kind: 'case',
539
- match: undefined,
540
- condition: conditionCode,
541
- children,
542
- loc: attrLoc(elseif),
543
- }
544
- }
545
- if (find('default') !== undefined || find('else') !== undefined) {
546
- return { kind: 'case', match: undefined, children } // default (switch) / else (if)
547
- }
548
- const condition = find('if')
549
- if (condition !== undefined) {
550
- const conditionCode = attrText(condition)
551
- if (conditionCode === undefined) {
552
- throw new Error('[abide] <template if> requires a condition expression')
553
- }
554
- return { kind: 'if', condition: conditionCode, children, loc: attrLoc(condition) }
555
- }
556
- const items = find('each')
557
- const itemsCode = items === undefined ? undefined : attrText(items)
558
- if (itemsCode === undefined) {
559
- throw new Error('[abide] <template> without a supported directive (if/each)')
560
- }
561
- const as = find('as')
562
- const key = find('key')
563
- const index = find('index')
564
- return {
565
- kind: 'each',
566
- items: itemsCode,
567
- as: (as === undefined ? undefined : attrText(as)) ?? '_item',
568
- key: key === undefined ? undefined : attrText(key),
569
- index: index === undefined ? undefined : attrText(index),
570
- async: find('await') !== undefined, // `<template each await>` over an AsyncIterable
571
- children,
572
- loc: attrLoc(items),
573
- }
992
+ /* A plain inert `<template>` element (e.g. client-side cloning) keep as an element. */
993
+ return { kind: 'element', tag: 'template', attrs, children }
574
994
  }