@abide/abide 0.39.0 → 0.40.1

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 (71) hide show
  1. package/AGENTS.md +318 -146
  2. package/CHANGELOG.md +89 -0
  3. package/README.md +77 -79
  4. package/package.json +1 -1
  5. package/src/lib/server/rpc/parseArgs.ts +10 -1
  6. package/src/lib/server/rpc/runWithVerbTimeout.ts +7 -9
  7. package/src/lib/server/rpc/types/VerbHelper.ts +18 -21
  8. package/src/lib/server/runtime/SSR_SWAP_SCRIPT.ts +8 -7
  9. package/src/lib/server/sockets/createSocketDispatcher.ts +12 -3
  10. package/src/lib/server/sockets/defineSocket.ts +3 -1
  11. package/src/lib/shared/REF_JSON_HEADER.ts +9 -0
  12. package/src/lib/shared/REF_JSON_TAGS.ts +31 -0
  13. package/src/lib/shared/assertExhaustive.ts +14 -0
  14. package/src/lib/shared/buildRpcRequest.ts +8 -1
  15. package/src/lib/shared/decodeRefJson.ts +110 -0
  16. package/src/lib/shared/encodeRefJson.ts +106 -0
  17. package/src/lib/test/createTestSocketChannel.ts +6 -2
  18. package/src/lib/ui/compile/TS_PRINTER.ts +7 -0
  19. package/src/lib/ui/compile/analyzeComponent.ts +15 -16
  20. package/src/lib/ui/compile/assertRuntimeHelpersBound.ts +66 -0
  21. package/src/lib/ui/compile/assertTranspiles.ts +19 -0
  22. package/src/lib/ui/compile/compileModule.ts +56 -34
  23. package/src/lib/ui/compile/compileSSR.ts +1 -3
  24. package/src/lib/ui/compile/compileShadow.ts +14 -7
  25. package/src/lib/ui/compile/desugarSignals.ts +168 -107
  26. package/src/lib/ui/compile/generateBuild.ts +49 -59
  27. package/src/lib/ui/compile/generateSSR.ts +30 -21
  28. package/src/lib/ui/compile/hoistCells.ts +2 -2
  29. package/src/lib/ui/compile/isWhitespaceText.ts +11 -0
  30. package/src/lib/ui/compile/lowerContext.ts +23 -10
  31. package/src/lib/ui/compile/lowerDocAccess.ts +29 -3
  32. package/src/lib/ui/compile/lowerScript.ts +64 -0
  33. package/src/lib/ui/compile/parseTemplate.ts +74 -0
  34. package/src/lib/ui/compile/renameSignalRefs.ts +160 -90
  35. package/src/lib/ui/compile/resolveBranches.ts +21 -0
  36. package/src/lib/ui/compile/stripEffects.ts +22 -17
  37. package/src/lib/ui/compile/types/AnalyzedComponent.ts +5 -0
  38. package/src/lib/ui/compile/types/TemplateNode.ts +12 -2
  39. package/src/lib/ui/createScope.ts +14 -0
  40. package/src/lib/ui/dom/appendText.ts +2 -3
  41. package/src/lib/ui/dom/appendTextAt.ts +2 -3
  42. package/src/lib/ui/dom/applyResolved.ts +5 -8
  43. package/src/lib/ui/dom/awaitBlock.ts +22 -2
  44. package/src/lib/ui/dom/each.ts +4 -0
  45. package/src/lib/ui/dom/on.ts +7 -0
  46. package/src/lib/ui/dom/parseRawNodes.ts +17 -0
  47. package/src/lib/ui/dom/switchBlock.ts +4 -0
  48. package/src/lib/ui/dom/when.ts +4 -0
  49. package/src/lib/ui/installInspectorBridge.ts +3 -1
  50. package/src/lib/ui/navigate.ts +28 -3
  51. package/src/lib/ui/renderToStream.ts +17 -9
  52. package/src/lib/ui/resumeSeedScript.ts +16 -6
  53. package/src/lib/ui/router.ts +108 -15
  54. package/src/lib/ui/runtime/NODE_STATE.ts +22 -0
  55. package/src/lib/ui/runtime/RESUME.ts +13 -6
  56. package/src/lib/ui/runtime/clientPage.ts +114 -9
  57. package/src/lib/ui/runtime/createComputedNode.ts +5 -3
  58. package/src/lib/ui/runtime/createEffectNode.ts +3 -1
  59. package/src/lib/ui/runtime/createSignalNode.ts +4 -2
  60. package/src/lib/ui/runtime/flushEffects.ts +8 -5
  61. package/src/lib/ui/runtime/historyEntries.ts +39 -1
  62. package/src/lib/ui/runtime/localStoragePersistence.ts +14 -8
  63. package/src/lib/ui/runtime/readNode.ts +8 -7
  64. package/src/lib/ui/runtime/runNode.ts +18 -2
  65. package/src/lib/ui/runtime/scope.ts +12 -1
  66. package/src/lib/ui/runtime/trigger.ts +40 -24
  67. package/src/lib/ui/runtime/types/ReactiveNode.ts +4 -1
  68. package/src/lib/ui/runtime/updateIfNecessary.ts +40 -0
  69. package/src/lib/ui/socketChannel.ts +5 -2
  70. package/src/lib/ui/tryEncodeResume.ts +20 -0
  71. package/src/lib/ui/types/Scope.ts +9 -3
@@ -1,3 +1,4 @@
1
+ import { assertExhaustive } from '../../shared/assertExhaustive.ts'
1
2
  import { OUTLET_CLOSE, OUTLET_OPEN } from '../runtime/OUTLET_MARKER.ts'
2
3
  import { OUTLET_TAG } from '../runtime/OUTLET_TAG.ts'
3
4
  import { asOutlet } from './asOutlet.ts'
@@ -5,6 +6,7 @@ import { composeProps } from './composeProps.ts'
5
6
  import { groupBindParts } from './groupBindParts.ts'
6
7
  import { isAnchorPositioned } from './isAnchorPositioned.ts'
7
8
  import { lowerContext } from './lowerContext.ts'
9
+ import { resolveBranches } from './resolveBranches.ts'
8
10
  import { scopeAttr } from './scopeAttr.ts'
9
11
  import { skeletonContext } from './skeletonContext.ts'
10
12
  import { spreadExcludedNames } from './spreadExcludedNames.ts'
@@ -21,17 +23,6 @@ import { VOID_TAGS } from './VOID_TAGS.ts'
21
23
  const RANGE_OPEN = '<!--[-->'
22
24
  const RANGE_CLOSE = '<!--]-->'
23
25
 
24
- /* The `then`/`catch`/`finally` branch child of an await/try block, or undefined. */
25
- function branchNamed(
26
- children: TemplateNode[],
27
- which: 'then' | 'catch' | 'finally',
28
- ): Extract<TemplateNode, { kind: 'branch' }> | undefined {
29
- return children.find(
30
- (child): child is Extract<TemplateNode, { kind: 'branch' }> =>
31
- child.kind === 'branch' && child.branch === which,
32
- )
33
- }
34
-
35
26
  /*
36
27
  Server code generator: turns the parsed template into statements that push HTML
37
28
  fragments onto an output array, reading the document synchronously (no DOM, no
@@ -212,11 +203,19 @@ export function generateSSR(
212
203
  .join('')
213
204
  }
214
205
  if (node.kind === 'if') {
215
- const elseBranch = node.children.find((child) => child.kind === 'case')
206
+ /* `case` children are the `elseif`/`else` branches in source order; the rest are
207
+ the `then` content. Each `elseif` becomes an `else if`, the match-less `else`
208
+ the trailing `else`. */
209
+ const branches = node.children.filter(
210
+ (child): child is Extract<TemplateNode, { kind: 'case' }> => child.kind === 'case',
211
+ )
216
212
  const thenChildren = node.children.filter((child) => child.kind !== 'case')
217
213
  let code = `if (${lowerExpression(node.condition)}) {\n${branchContent(thenChildren, target)}}`
218
- if (elseBranch !== undefined && elseBranch.kind === 'case') {
219
- code += ` else {\n${branchContent(elseBranch.children, target)}}`
214
+ for (const branch of branches) {
215
+ code +=
216
+ branch.condition !== undefined
217
+ ? ` else if (${lowerExpression(branch.condition)}) {\n${branchContent(branch.children, target)}}`
218
+ : ` else {\n${branchContent(branch.children, target)}}`
220
219
  }
221
220
  return `${anchor}${openRange(target)}${code}\n${closeRange(target)}`
222
221
  }
@@ -343,6 +342,12 @@ export function generateSSR(
343
342
  }
344
343
  return generateSlot(node, target, anchor)
345
344
  }
345
+ /* Every non-element kind returned above; a kind reaching the element builder
346
+ that isn't an element is a compiler gap — fail loud rather than emit it as a
347
+ bogus `<undefined>` tag. (`node` narrows to `never` in this branch.) */
348
+ if (node.kind !== 'element') {
349
+ return assertExhaustive(node, 'template node kind')
350
+ }
346
351
  let code = push(target, `<${node.tag}`)
347
352
  /* Every `<style>` active at this element (own siblings + ancestors) — same set
348
353
  the client stamps, so server and client markup carry identical attributes. */
@@ -442,8 +447,8 @@ export function generateSSR(
442
447
  node: Extract<TemplateNode, { kind: 'await' }>,
443
448
  target: string,
444
449
  ): string {
445
- const catchBranch = branchNamed(node.children, 'catch')
446
- const finallyChildren = branchNamed(node.children, 'finally')?.children ?? []
450
+ const [catchBranch, finallyBranch] = resolveBranches(node, 'catch', 'finally')
451
+ const finallyChildren = finallyBranch?.children ?? []
447
452
  const resolvedChildren = node.children.filter((child) => child.kind !== 'branch')
448
453
  const resolvedAs = node.as ?? '_value'
449
454
  const errorAs = catchBranch?.as ?? '_error'
@@ -480,9 +485,13 @@ export function generateSSR(
480
485
  node: Extract<TemplateNode, { kind: 'await' }>,
481
486
  target: string,
482
487
  ): string {
483
- const catchBranch = branchNamed(node.children, 'catch')
484
- const finallyChildren = branchNamed(node.children, 'finally')?.children ?? []
485
- const thenBranch = branchNamed(node.children, 'then')
488
+ const [thenBranch, catchBranch, finallyBranch] = resolveBranches(
489
+ node,
490
+ 'then',
491
+ 'catch',
492
+ 'finally',
493
+ )
494
+ const finallyChildren = finallyBranch?.children ?? []
486
495
  const pending = node.children.filter((child) => child.kind !== 'branch')
487
496
  const id = nextVar('$aid')
488
497
  let code = `const ${id} = $ctx.next++;\n`
@@ -510,8 +519,8 @@ export function generateSSR(
510
519
  an enclosing boundary / the 500 / the stream). Boundary comments let hydration
511
520
  discard the server content if the client adoption fails. */
512
521
  function generateTry(node: Extract<TemplateNode, { kind: 'try' }>, target: string): string {
513
- const catchBranch = branchNamed(node.children, 'catch')
514
- const finallyChildren = branchNamed(node.children, 'finally')?.children ?? []
522
+ const [catchBranch, finallyBranch] = resolveBranches(node, 'catch', 'finally')
523
+ const finallyChildren = finallyBranch?.children ?? []
515
524
  const guarded = node.children.filter((child) => child.kind !== 'branch')
516
525
  const errName = catchBranch?.as ?? '_error'
517
526
  const id = nextVar('$tid')
@@ -1,4 +1,5 @@
1
1
  import ts from 'typescript'
2
+ import { TS_PRINTER } from './TS_PRINTER.ts'
2
3
 
3
4
  /*
4
5
  The perf pass that follows `lowerDocAccess`. Within a render scope, a static path
@@ -35,8 +36,7 @@ export function hoistCells(code: string, docName: string): string {
35
36
  }
36
37
 
37
38
  const result = ts.transform(source, [hoistTransformer(docName, cellIdForPath)])
38
- const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed })
39
- const output = printer.printFile(result.transformed[0] as ts.SourceFile)
39
+ const output = TS_PRINTER.printFile(result.transformed[0] as ts.SourceFile)
40
40
  result.dispose()
41
41
  return output
42
42
  }
@@ -0,0 +1,11 @@
1
+ import type { TemplateNode } from './types/TemplateNode.ts'
2
+
3
+ /* A text node that is purely whitespace (no interpolation, only blank static
4
+ parts). Both back-ends drop it, so it neither contributes markup nor breaks a
5
+ static clone run — it stays transparent so `<a/>\n<b/>` still coalesces. */
6
+ export function isWhitespaceText(node: TemplateNode): boolean {
7
+ return (
8
+ node.kind === 'text' &&
9
+ node.parts.every((part) => part.kind === 'static' && part.value.trim() === '')
10
+ )
11
+ }
@@ -1,6 +1,8 @@
1
- import { lowerDocAccess } from './lowerDocAccess.ts'
1
+ import ts from 'typescript'
2
+ import { docAccessTransformer } from './lowerDocAccess.ts'
2
3
  import { nestedBindingNames } from './prepareNestedScript.ts'
3
- import { renameSignalRefs } from './renameSignalRefs.ts'
4
+ import { signalRefsTransformer } from './renameSignalRefs.ts'
5
+ import { TS_PRINTER } from './TS_PRINTER.ts'
4
6
  import type { TemplateNode } from './types/TemplateNode.ts'
5
7
  import { unwrapParens } from './unwrapParens.ts'
6
8
 
@@ -27,19 +29,30 @@ export function lowerContext(
27
29
  const derefScope = (): ReadonlySet<string> =>
28
30
  localDerived.size === 0 ? derivedNames : new Set([...derivedNames, ...localDerived])
29
31
 
30
- /* Rewrites signal refs, then lowers a single expression (no trailing `;`).
31
- Wrapped in parens so a bare object literal (`{ a: 1 }`) parses as an
32
- expression, not a block of labeled statements, through both rewrite passes;
33
- the wrapper is then peeled back off. */
32
+ /* Parse `code` once and chain the reference rename and doc-access lowering over the
33
+ one tree the two string passes would each parse + reprint. `derefScope()` is read
34
+ per call so a nested-`<script>` binding pushed mid-compile is honoured. */
35
+ function lowerOnce(code: string): string {
36
+ const source = ts.createSourceFile('expr.ts', code, ts.ScriptTarget.Latest, true)
37
+ const result = ts.transform(source, [
38
+ signalRefsTransformer(stateNames, derefScope(), computedNames),
39
+ docAccessTransformer('model'),
40
+ ])
41
+ const output = TS_PRINTER.printFile(result.transformed[0] as ts.SourceFile).trim()
42
+ result.dispose()
43
+ return output
44
+ }
45
+
46
+ /* Lowers a single expression (no trailing `;`). Wrapped in parens so a bare object
47
+ literal (`{ a: 1 }`) parses as an expression, not a block of labeled statements,
48
+ through the rewrite; the wrapper is then peeled back off. */
34
49
  function expression(code: string): string {
35
- const renamed = renameSignalRefs(`(${code})`, stateNames, derefScope(), computedNames)
36
- return unwrapParens(lowerDocAccess(renamed, 'model').trim().replace(/;$/, ''))
50
+ return unwrapParens(lowerOnce(`(${code})`).replace(/;$/, ''))
37
51
  }
38
52
 
39
53
  /* As above but keeps the trailing `;` for a statement/handler body. */
40
54
  function statement(code: string): string {
41
- const renamed = renameSignalRefs(code, stateNames, derefScope(), computedNames)
42
- return lowerDocAccess(renamed, 'model').trim()
55
+ return lowerOnce(code)
43
56
  }
44
57
 
45
58
  /* A two-way bind target is either an LVALUE (`count`, `model.lines[i]`) — reads as
@@ -1,5 +1,6 @@
1
1
  import ts from 'typescript'
2
2
  import { escapeKey } from '../runtime/escapeKey.ts'
3
+ import { TS_PRINTER } from './TS_PRINTER.ts'
3
4
 
4
5
  /*
5
6
  The linchpin compiler pass. Rewrites idiomatic data access on a reactive document
@@ -27,8 +28,7 @@ used as an index lowers too.
27
28
  export function lowerDocAccess(code: string, docName: string): string {
28
29
  const source = ts.createSourceFile('component.ts', code, ts.ScriptTarget.Latest, true)
29
30
  const result = ts.transform(source, [docAccessTransformer(docName)])
30
- const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed })
31
- const output = printer.printFile(result.transformed[0] as ts.SourceFile)
31
+ const output = TS_PRINTER.printFile(result.transformed[0] as ts.SourceFile)
32
32
  result.dispose()
33
33
  return output
34
34
  }
@@ -49,7 +49,7 @@ const COMPOUND_OPERATORS = new Map<ts.SyntaxKind, ts.BinaryOperator>([
49
49
  [ts.SyntaxKind.QuestionQuestionEqualsToken, ts.SyntaxKind.QuestionQuestionToken],
50
50
  ])
51
51
 
52
- function docAccessTransformer(docName: string): ts.TransformerFactory<ts.SourceFile> {
52
+ export function docAccessTransformer(docName: string): ts.TransformerFactory<ts.SourceFile> {
53
53
  return (context) => (root) => {
54
54
  /* Collects the path of an access chain rooted at `docName`, visiting any
55
55
  dynamic index so reads inside it lower too. undefined if not our doc. */
@@ -107,6 +107,32 @@ function docAccessTransformer(docName: string): ts.TransformerFactory<ts.SourceF
107
107
  }
108
108
  }
109
109
  }
110
+ /* `path++` / `++path` / `path--` on a doc path → a replace patch (the same
111
+ shape as `+= 1`). A bare `++` would otherwise survive onto the lowered read
112
+ (`model.read("n")++` / `cell.get()++`) — invalid, since a call result is not
113
+ an lvalue. abide handlers are statements, so the postfix/prefix value is
114
+ discarded and both lower identically. Only `++`/`--` are handled here; other
115
+ unary operators (`!path`, `-path`) fall through so their operand lowers as a
116
+ normal read. */
117
+ if (
118
+ (ts.isPostfixUnaryExpression(node) || ts.isPrefixUnaryExpression(node)) &&
119
+ (node.operator === ts.SyntaxKind.PlusPlusToken ||
120
+ node.operator === ts.SyntaxKind.MinusMinusToken)
121
+ ) {
122
+ const segments = pathSegments(node.operand)
123
+ if (segments) {
124
+ const step =
125
+ node.operator === ts.SyntaxKind.PlusPlusToken
126
+ ? ts.SyntaxKind.PlusToken
127
+ : ts.SyntaxKind.MinusToken
128
+ const next = ts.factory.createBinaryExpression(
129
+ docCall(docName, 'read', [buildPath(segments)]),
130
+ step,
131
+ ts.factory.createNumericLiteral(1),
132
+ )
133
+ return docCall(docName, 'replace', [buildPath(segments), next])
134
+ }
135
+ }
110
136
  /* doc array `.push(a, b, …)` → one `add` patch per argument at the array's `-`
111
137
  slot, matching native multi-arg push (each lands at the end in order). */
112
138
  if (
@@ -0,0 +1,64 @@
1
+ import ts from 'typescript'
2
+ import { assertTranspiles } from './assertTranspiles.ts'
3
+ import { desugarSignals } from './desugarSignals.ts'
4
+ import { docAccessTransformer } from './lowerDocAccess.ts'
5
+ import { signalRefsTransformer } from './renameSignalRefs.ts'
6
+ import { stripEffectsTransformer } from './stripEffects.ts'
7
+ import { TS_PRINTER } from './TS_PRINTER.ts'
8
+
9
+ /*
10
+ The component-script lowering, done in ONE parse. The script is parsed once, then
11
+ `desugarSignals` (signal declarations → `model` slots / `scope().derive`), reference
12
+ renaming (`count` → `model.count`), and doc-access lowering (`model.count` → patch/read)
13
+ run as a chained `ts.transform` over the SAME tree — each as a standalone string pass
14
+ would parse + reprint. desugar returns a transformer rather than rebuilt source text
15
+ precisely so it can chain here; the name sets it collects feed the rename transformer.
16
+
17
+ Imports are partitioned off the transformed tree structurally (`ts.isImportDeclaration`),
18
+ not by regex, so a multi-line import hoists correctly regardless of formatting. The
19
+ reassembled output is transpiled as a fail-loud guard: if a future un-handled rewrite
20
+ position corrupts the script (the failure mode the syntax fuzz corpus also guards), it
21
+ surfaces here as a located compile error instead of shipping a broken bundle.
22
+ */
23
+
24
+ export function lowerScript(scriptBody: string): {
25
+ body: string
26
+ imports: string
27
+ ssrBody: string
28
+ stateNames: Set<string>
29
+ derivedNames: Set<string>
30
+ computedNames: Set<string>
31
+ } {
32
+ const source = ts.createSourceFile('component.ts', scriptBody, ts.ScriptTarget.Latest, true)
33
+ const { transformer, stateNames, derivedNames, computedNames } = desugarSignals(source)
34
+ const result = ts.transform(source, [
35
+ transformer,
36
+ signalRefsTransformer(stateNames, derivedNames, computedNames),
37
+ docAccessTransformer('model'),
38
+ ])
39
+ const transformed = result.transformed[0] as ts.SourceFile
40
+ /* Top-level imports must live at module scope, not inside the mount/render
41
+ function the script body becomes — hoist them off the tree. */
42
+ const importStatements = transformed.statements.filter(ts.isImportDeclaration)
43
+ const bodyStatements = transformed.statements.filter(
44
+ (statement) => !ts.isImportDeclaration(statement),
45
+ )
46
+ const imports = importStatements
47
+ .map((statement) => TS_PRINTER.printNode(ts.EmitHint.Unspecified, statement, transformed))
48
+ .join('\n')
49
+ const bodyFile = ts.factory.updateSourceFile(transformed, bodyStatements)
50
+ const body = TS_PRINTER.printFile(bodyFile).trim()
51
+ /* The SSR variant strips client-only `effect(...)` calls — run over the SAME lowered
52
+ tree (one extra transform + print, no reparse) instead of re-parsing the printed
53
+ script downstream. */
54
+ const ssrResult = ts.transform(bodyFile, [stripEffectsTransformer()])
55
+ const ssrBody = TS_PRINTER.printFile(ssrResult.transformed[0] as ts.SourceFile).trim()
56
+ ssrResult.dispose()
57
+ result.dispose()
58
+
59
+ assertTranspiles(
60
+ [imports, body].filter((part) => part !== '').join('\n'),
61
+ 'component script lowering',
62
+ )
63
+ return { body, imports, ssrBody, stateNames, derivedNames, computedNames }
64
+ }
@@ -1,4 +1,5 @@
1
1
  import { decodeHtmlEntities } from './decodeHtmlEntities.ts'
2
+ import { isWhitespaceText } from './isWhitespaceText.ts'
2
3
  import type { TemplateAttr } from './types/TemplateAttr.ts'
3
4
  import type { TemplateNode } from './types/TemplateNode.ts'
4
5
  import type { TextPart } from './types/TextPart.ts'
@@ -303,6 +304,13 @@ function rejectStrayBranches(
303
304
  '[abide] <template else>/<template case> must be nested inside its <template if>/<template switch> — a sibling branch is not supported',
304
305
  )
305
306
  }
307
+ /* `elseif` is an `if`-chain branch; inside a `switch` it would silently read as the
308
+ default (match-less), so reject the cross-construct mix. */
309
+ if (node.kind === 'case' && node.condition !== undefined && parentKind === 'switch') {
310
+ throw new Error(
311
+ '[abide] <template elseif> belongs to a <template if> chain, not a <template switch> — use <template case>',
312
+ )
313
+ }
306
314
  if (
307
315
  node.kind === 'branch' &&
308
316
  parentKind !== 'await' &&
@@ -313,12 +321,62 @@ function rejectStrayBranches(
313
321
  '[abide] <template then>/<template catch>/<template finally> must be nested inside its <template await>/<template try>/<template each await>',
314
322
  )
315
323
  }
324
+ if (node.kind === 'if' || node.kind === 'switch') {
325
+ rejectMisplacedBranchContent(node)
326
+ }
316
327
  if ('children' in node) {
317
328
  rejectStrayBranches(node.children, node.kind)
318
329
  }
319
330
  }
320
331
  }
321
332
 
333
+ /* A `<template if>` chain's `then` content precedes its first branch tag
334
+ (`elseif`/`else`); a `<template switch>` renders only its branch tags. So rendered
335
+ content sitting AFTER the first branch in an `if` — or ANYWHERE in a `switch` —
336
+ belongs to no branch: today it silently folds into `then` / is dropped. Reject it so
337
+ the misplacement surfaces. Whitespace stays transparent, and `<script>`/`<style>` are
338
+ directives (scoping, not rendered output), so both remain legal anywhere. */
339
+ function rejectMisplacedBranchContent(
340
+ node: Extract<TemplateNode, { kind: 'if' | 'switch' }>,
341
+ ): void {
342
+ const firstBranch = node.children.findIndex((child) => child.kind === 'case')
343
+ node.children.forEach((child, index) => {
344
+ const isRenderedContent =
345
+ child.kind !== 'case' &&
346
+ child.kind !== 'script' &&
347
+ child.kind !== 'style' &&
348
+ !isWhitespaceText(child)
349
+ /* In a switch nothing but branches renders, so every position is illegal; in an if
350
+ only content past the first branch is (the leading then-content is legal). */
351
+ const illegalPosition =
352
+ node.kind === 'switch' || (firstBranch !== -1 && index > firstBranch)
353
+ if (isRenderedContent && illegalPosition) {
354
+ throw new Error(
355
+ node.kind === 'switch'
356
+ ? '[abide] a <template switch> renders only its <template case>/<template default> branches — move stray content into a branch'
357
+ : '[abide] content after a <template elseif>/<template else> belongs to no branch — the then-content must precede the first branch tag',
358
+ )
359
+ }
360
+ })
361
+ /* In an `if` chain the match-less `<template else>` is the trailing block, so any
362
+ branch after it (a second `else`, or an `elseif`) compiles to invalid `} else {…}
363
+ else if {…}` (SSR/type-shadow) or a silently-shadowed branch (the `switchBlock`
364
+ default wins). Reject so the misordering surfaces here, not as opaque codegen. */
365
+ if (node.kind === 'if') {
366
+ const branches = node.children.filter(
367
+ (child): child is Extract<TemplateNode, { kind: 'case' }> => child.kind === 'case',
368
+ )
369
+ const elseIndex = branches.findIndex(
370
+ (branch) => branch.match === undefined && branch.condition === undefined,
371
+ )
372
+ if (elseIndex !== -1 && elseIndex < branches.length - 1) {
373
+ throw new Error(
374
+ '[abide] <template else> must be the last branch of its <template if> chain — no <template elseif>/<template else> may follow it',
375
+ )
376
+ }
377
+ }
378
+ }
379
+
322
380
  /* Turns a component's attributes into props. A component has no directives —
323
381
  every attribute is a prop under its written name, so `on*`/`bind:`/`attach`
324
382
  round-trip to their original names (the kinds the tag-blind attribute parser
@@ -468,6 +526,22 @@ function toControlFlow(attrs: TemplateAttr[], children: TemplateNode[]): Templat
468
526
  }
469
527
  return { kind: 'case', match: matchCode, children, loc: attrLoc(caseAttr) }
470
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
+ }
471
545
  if (find('default') !== undefined || find('else') !== undefined) {
472
546
  return { kind: 'case', match: undefined, children } // default (switch) / else (if)
473
547
  }