@abide/abide 0.42.0 → 0.43.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 (146) hide show
  1. package/AGENTS.md +234 -300
  2. package/CHANGELOG.md +41 -0
  3. package/README.md +50 -93
  4. package/package.json +3 -2
  5. package/src/abideResolverPlugin.ts +104 -14
  6. package/src/buildCli.ts +1 -1
  7. package/src/discoveryEntry.ts +3 -3
  8. package/src/lib/bundle/BundleMenuItem.ts +1 -1
  9. package/src/lib/cli/createClient.ts +12 -12
  10. package/src/lib/cli/dispatchCommand.ts +1 -1
  11. package/src/lib/cli/printSessionStatus.ts +1 -1
  12. package/src/lib/cli/resolveCliTarget.ts +1 -1
  13. package/src/lib/cli/runCli.ts +1 -1
  14. package/src/lib/cli/types/CliManifest.ts +1 -1
  15. package/src/lib/cli/types/CliManifestEntry.ts +2 -2
  16. package/src/lib/mcp/annotationsForMethod.ts +5 -5
  17. package/src/lib/mcp/createMcpServer.ts +3 -3
  18. package/src/lib/mcp/mcpSurface.ts +14 -14
  19. package/src/lib/mcp/types/McpServerOptions.ts +1 -1
  20. package/src/lib/server/DELETE.ts +4 -4
  21. package/src/lib/server/GET.ts +4 -4
  22. package/src/lib/server/HEAD.ts +4 -4
  23. package/src/lib/server/PATCH.ts +4 -4
  24. package/src/lib/server/POST.ts +4 -4
  25. package/src/lib/server/PUT.ts +4 -4
  26. package/src/lib/server/agent.ts +4 -4
  27. package/src/lib/server/env.ts +1 -1
  28. package/src/lib/server/error.ts +49 -7
  29. package/src/lib/server/json.ts +1 -1
  30. package/src/lib/server/rpc/buildErrorConstructors.ts +19 -0
  31. package/src/lib/server/rpc/{defineVerb.ts → defineRpc.ts} +52 -34
  32. package/src/lib/server/rpc/{dispatchVerbInProcess.ts → dispatchRpcInProcess.ts} +3 -3
  33. package/src/lib/server/rpc/fieldErrorsFromIssues.ts +23 -0
  34. package/src/lib/server/rpc/{findVerbByCommandName.ts → findRpcByCommandName.ts} +5 -5
  35. package/src/lib/server/rpc/parseArgs.ts +6 -6
  36. package/src/lib/server/rpc/readBodyWithinLimit.ts +2 -2
  37. package/src/lib/server/rpc/registerRpc.ts +6 -0
  38. package/src/lib/server/rpc/{verbRegistry.ts → rpcRegistry.ts} +4 -4
  39. package/src/lib/server/rpc/{runWithVerbTimeout.ts → runWithRpcTimeout.ts} +4 -4
  40. package/src/lib/server/rpc/types/RemoteHandler.ts +9 -3
  41. package/src/lib/server/rpc/types/{VerbHelper.ts → RpcHelper.ts} +57 -23
  42. package/src/lib/server/rpc/types/{VerbRegistryEntry.ts → RpcRegistryEntry.ts} +3 -3
  43. package/src/lib/server/rpc/types/TypedResponse.ts +2 -2
  44. package/src/lib/server/rpc/unprocessed.ts +6 -7
  45. package/src/lib/server/rpc/validationError.ts +17 -0
  46. package/src/lib/server/runtime/buildInspectorSurface.ts +7 -7
  47. package/src/lib/server/runtime/buildOpenApiSpec.ts +6 -6
  48. package/src/lib/server/runtime/createRouteDispatcher.ts +7 -7
  49. package/src/lib/server/runtime/createServer.ts +4 -4
  50. package/src/lib/server/runtime/crossOriginForbidden.ts +1 -1
  51. package/src/lib/server/runtime/crossOriginGate.ts +4 -4
  52. package/src/lib/server/runtime/logExposedSurfaces.ts +4 -4
  53. package/src/lib/server/runtime/registryManifests.ts +2 -2
  54. package/src/lib/server/runtime/types/InspectorCacheEntry.ts +1 -1
  55. package/src/lib/server/runtime/types/InspectorRpc.ts +27 -0
  56. package/src/lib/server/runtime/types/InspectorSurface.ts +4 -4
  57. package/src/lib/server/runtime/types/RequestStore.ts +1 -1
  58. package/src/lib/server/runtime/warnUnguardedMcp.ts +2 -2
  59. package/src/lib/server/sockets/createSocketDispatcher.ts +15 -14
  60. package/src/lib/server/sockets/types/SocketOperation.ts +2 -2
  61. package/src/lib/shared/HttpError.ts +15 -1
  62. package/src/lib/shared/UNREACHABLE_STATUSES.ts +13 -0
  63. package/src/lib/shared/buildRpcRequest.ts +5 -5
  64. package/src/lib/shared/cache.ts +50 -168
  65. package/src/lib/shared/carriesBodyArgs.ts +4 -4
  66. package/src/lib/shared/createCacheStore.ts +12 -0
  67. package/src/lib/shared/createRemoteFunction.ts +11 -6
  68. package/src/lib/shared/decodeResponse.ts +2 -2
  69. package/src/lib/shared/detectRpcMethod.ts +17 -0
  70. package/src/lib/shared/emitLogRecord.ts +3 -3
  71. package/src/lib/shared/extraForwardHeaders.ts +1 -1
  72. package/src/lib/shared/findExportCallSite.ts +30 -273
  73. package/src/lib/shared/forwardHeaders.ts +2 -2
  74. package/src/lib/shared/httpErrorFor.ts +26 -0
  75. package/src/lib/shared/isAsciiWhitespace.ts +5 -0
  76. package/src/lib/shared/isIdentPart.ts +9 -0
  77. package/src/lib/shared/isIdentStart.ts +8 -0
  78. package/src/lib/shared/isReadOnlyMethod.ts +4 -4
  79. package/src/lib/shared/keyForRemoteCall.ts +4 -4
  80. package/src/lib/shared/log.ts +1 -1
  81. package/src/lib/shared/outboxProbeSlot.ts +20 -0
  82. package/src/lib/shared/pending.ts +4 -1
  83. package/src/lib/shared/prepareRpcModule.ts +96 -14
  84. package/src/lib/shared/probeRegistries.ts +17 -4
  85. package/src/lib/shared/queryStringFromArgs.ts +1 -1
  86. package/src/lib/shared/remoteMetaStore.ts +2 -2
  87. package/src/lib/shared/resolveClientFlags.ts +1 -1
  88. package/src/lib/shared/skipNonCode.ts +230 -0
  89. package/src/lib/shared/streamResponse.ts +9 -6
  90. package/src/lib/shared/stripImport.ts +3 -3
  91. package/src/lib/shared/types/CacheEntry.ts +2 -4
  92. package/src/lib/shared/types/CacheOnContext.ts +1 -11
  93. package/src/lib/shared/types/CacheStore.ts +13 -7
  94. package/src/lib/shared/types/ClientFlags.ts +1 -1
  95. package/src/lib/shared/types/ErrorConstructors.ts +17 -0
  96. package/src/lib/shared/types/ErrorDescriptor.ts +10 -0
  97. package/src/lib/shared/types/ErrorSpec.ts +11 -0
  98. package/src/lib/shared/types/HttpMethod.ts +1 -0
  99. package/src/lib/shared/types/Outbox.ts +9 -0
  100. package/src/lib/shared/types/OutboxEntry.ts +27 -0
  101. package/src/lib/shared/types/RawRemoteFunction.ts +2 -2
  102. package/src/lib/shared/types/RemoteCallable.ts +2 -2
  103. package/src/lib/shared/types/RemoteFunction.ts +21 -6
  104. package/src/lib/shared/types/RequestScopeInfo.ts +1 -1
  105. package/src/lib/shared/types/RpcErrorGuard.ts +40 -0
  106. package/src/lib/shared/types/RpcInvoker.ts +1 -1
  107. package/src/lib/shared/types/StandardSchemaV1.ts +1 -1
  108. package/src/lib/shared/types/ValidationErrorData.ts +20 -0
  109. package/src/lib/shared/url.ts +3 -3
  110. package/src/lib/shared/writeRpcDts.ts +7 -7
  111. package/src/lib/shared/writeTestRpcDts.ts +3 -3
  112. package/src/lib/test/createTestApp.ts +10 -10
  113. package/src/lib/ui/compile/awaitPlan.ts +48 -0
  114. package/src/lib/ui/compile/compileShadow.ts +89 -21
  115. package/src/lib/ui/compile/desugarSignals.ts +42 -6
  116. package/src/lib/ui/compile/generateBuild.ts +43 -60
  117. package/src/lib/ui/compile/generateSSR.ts +84 -54
  118. package/src/lib/ui/compile/ifPlan.ts +30 -0
  119. package/src/lib/ui/compile/parseTemplate.ts +41 -11
  120. package/src/lib/ui/compile/stripEffects.ts +17 -9
  121. package/src/lib/ui/compile/switchPlan.ts +22 -0
  122. package/src/lib/ui/compile/tryPlan.ts +29 -0
  123. package/src/lib/ui/compile/types/TemplateAttr.ts +8 -2
  124. package/src/lib/ui/compile/types/TemplateNode.ts +6 -2
  125. package/src/lib/ui/createScope.ts +20 -2
  126. package/src/lib/ui/dom/disposeRange.ts +6 -10
  127. package/src/lib/ui/dom/hydrate.ts +2 -5
  128. package/src/lib/ui/dom/mount.ts +1 -2
  129. package/src/lib/ui/dom/withScope.ts +6 -9
  130. package/src/lib/ui/effect.ts +4 -2
  131. package/src/lib/ui/navigate.ts +40 -13
  132. package/src/lib/ui/outbox.ts +30 -110
  133. package/src/lib/ui/remoteProxy.ts +160 -10
  134. package/src/lib/ui/router.ts +45 -15
  135. package/src/lib/ui/rpcOutbox/createOutboxQueue.ts +281 -0
  136. package/src/lib/ui/rpcOutbox/outboxRegistry.ts +69 -0
  137. package/src/lib/ui/socketChannel.ts +7 -1
  138. package/src/lib/ui/types/Scope.ts +20 -5
  139. package/template/CLAUDE.md +1 -1
  140. package/template/src/server/rpc/getHello.ts +3 -3
  141. package/template/test/app.test.ts +2 -2
  142. package/src/lib/server/rpc/registerVerb.ts +0 -6
  143. package/src/lib/server/runtime/types/InspectorVerb.ts +0 -27
  144. package/src/lib/shared/detectVerbMethod.ts +0 -17
  145. package/src/lib/shared/types/HttpVerb.ts +0 -1
  146. package/src/lib/ui/types/Outbox.ts +0 -14
@@ -4,20 +4,56 @@ import { REACTIVE_CALLEES } from './REACTIVE_CALLEES.ts'
4
4
  const factory = ts.factory
5
5
 
6
6
  /* The reactive primitives that must be reached through a scope. A bare call to one of
7
- these is a compile error: reactive state is owned by a scope and the surface must show
8
- it (`scope().state(...)`), so a reader always sees the scope interaction. */
9
- const SCOPE_PRIMITIVES: ReadonlySet<string> = new Set(['state', 'linked', 'computed'])
7
+ these is a compile error: a reactive primitive is owned by a scope and the surface must
8
+ show it (`scope().state(...)`), so a reader always sees the scope interaction. `effect`
9
+ is here too a reaction is scope-owned (it tears down with the scope), so it joins the
10
+ one surface; unlike the cells it stays a runtime call (`scope().effect(...)` passes
11
+ through to the `effect` helper), not a doc slot. */
12
+ const SCOPE_PRIMITIVES: ReadonlySet<string> = new Set(['state', 'linked', 'computed', 'effect'])
13
+
14
+ /* The primitive names a top-level `const { state, computed } = scope()` destructure binds.
15
+ Such a name is scope-bound — its bare call below is the destructured method, not a stray
16
+ global — so it is exempt from the bare-primitive error. Only a destructure of a `scope()`
17
+ call counts (receiver-agnostic on the callee name, matching signalCallee); an aliased
18
+ binding (`{ state: s }`) is not recognised, so the canonical name must be kept. */
19
+ function scopeDestructuredPrimitives(source: ts.SourceFile): Set<string> {
20
+ const bound = new Set<string>()
21
+ for (const statement of source.statements) {
22
+ if (!ts.isVariableStatement(statement)) {
23
+ continue
24
+ }
25
+ for (const declaration of statement.declarationList.declarations) {
26
+ if (
27
+ signalCallee(declaration) === 'scope' &&
28
+ ts.isObjectBindingPattern(declaration.name)
29
+ ) {
30
+ for (const element of declaration.name.elements) {
31
+ if (
32
+ element.propertyName === undefined &&
33
+ ts.isIdentifier(element.name) &&
34
+ SCOPE_PRIMITIVES.has(element.name.text)
35
+ ) {
36
+ bound.add(element.name.text)
37
+ }
38
+ }
39
+ }
40
+ }
41
+ }
42
+ return bound
43
+ }
10
44
 
11
45
  /* Throws on a bare scope primitive (`state(0)` instead of `scope().state(0)`) or on the
12
46
  removed `prop(...)` reader — props are now read by destructuring `props()`. Walks all
13
- calls, so a stray one nested in a function is caught too, not just top-level declarations. */
47
+ calls, so a stray one nested in a function is caught too, not just top-level declarations.
48
+ A primitive destructured from `scope()` at the top is scope-bound and exempt. */
14
49
  function assertScopedPrimitives(source: ts.SourceFile): void {
50
+ const scopeBound = scopeDestructuredPrimitives(source)
15
51
  const visit = (node: ts.Node): void => {
16
52
  if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
17
53
  const name = node.expression.text
18
- if (SCOPE_PRIMITIVES.has(name)) {
54
+ if (SCOPE_PRIMITIVES.has(name) && !scopeBound.has(name)) {
19
55
  throw new Error(
20
- `abide: bare \`${name}(...)\` is not allowed — reactive state lives on a scope. Use \`scope().${name}(...)\` (or a captured handle: \`const s = scope(); s.${name}(...)\`).`,
56
+ `abide: bare \`${name}(...)\` is not allowed — a reactive primitive lives on a scope. Use \`scope().${name}(...)\` (or a captured handle: \`const s = scope(); s.${name}(...)\`).`,
21
57
  )
22
58
  }
23
59
  if (name === 'prop') {
@@ -3,10 +3,12 @@ import { HOLE_ATTRIBUTE } from '../runtime/HOLE_ATTRIBUTE.ts'
3
3
  import { OUTLET_TAG } from '../runtime/OUTLET_TAG.ts'
4
4
  import { ANCHOR } from '../runtime/RANGE_MARKER.ts'
5
5
  import { asOutlet } from './asOutlet.ts'
6
+ import { awaitPlan } from './awaitPlan.ts'
6
7
  import { bindListenEvent } from './bindListenEvent.ts'
7
8
  import { composeProps } from './composeProps.ts'
8
9
  import { destructureBindingNames } from './destructureBindingNames.ts'
9
10
  import { groupBindParts } from './groupBindParts.ts'
11
+ import { ifPlan } from './ifPlan.ts'
10
12
  import { isControlFlow } from './isControlFlow.ts'
11
13
  import { isWhitespaceText } from './isWhitespaceText.ts'
12
14
  import { lowerContext } from './lowerContext.ts'
@@ -17,6 +19,8 @@ import { skeletonContext } from './skeletonContext.ts'
17
19
  import { spreadExcludedNames } from './spreadExcludedNames.ts'
18
20
  import { staticAttr } from './staticAttr.ts'
19
21
  import { staticTextPart } from './staticTextPart.ts'
22
+ import { switchPlan } from './switchPlan.ts'
23
+ import { tryPlan } from './tryPlan.ts'
20
24
  import type { TemplateNode } from './types/TemplateNode.ts'
21
25
  import { VOID_TAGS } from './VOID_TAGS.ts'
22
26
 
@@ -143,7 +147,7 @@ export function generateBuild(
143
147
  node: Extract<TemplateNode, { kind: 'element' }>,
144
148
  attr: Extract<
145
149
  (typeof node.attrs)[number],
146
- { kind: 'expression' | 'event' | 'attach' | 'bind' }
150
+ { kind: 'expression' | 'event' | 'attach' | 'bind' | 'class' | 'style' }
147
151
  >,
148
152
  varName: string,
149
153
  ): string {
@@ -156,6 +160,15 @@ export function generateBuild(
156
160
  if (attr.kind === 'attach') {
157
161
  return `attach(${varName}, (${lowerExpression(attr.code)}));\n`
158
162
  }
163
+ /* `class:<name>` — toggle the class by truthiness; surgical, no element re-render.
164
+ Layers on top of any static `class="…"` in the skeleton (classList is additive). */
165
+ if (attr.kind === 'class') {
166
+ return `effect(${namedThunk(`class_${attr.name}`, `${varName}.classList.toggle(${JSON.stringify(attr.name)}, !!(${lowerExpression(attr.code)}));`)});\n`
167
+ }
168
+ /* `style:<property>` — write one inline style / custom property reactively. */
169
+ if (attr.kind === 'style') {
170
+ return `effect(${namedThunk(`style_${attr.property}`, `${varName}.style.setProperty(${JSON.stringify(attr.property)}, String(${lowerExpression(attr.code)}));`)});\n`
171
+ }
159
172
  if (attr.property === 'group') {
160
173
  /* Grouped two-way: radio binds the path to the single checked `value`;
161
174
  checkbox treats the path as an array, adding/removing `value` on toggle.
@@ -471,11 +484,8 @@ export function generateBuild(
471
484
  parentVar: string,
472
485
  before: string,
473
486
  ): string {
474
- const cases = node.children
475
- .filter(
476
- (child): child is Extract<TemplateNode, { kind: 'case' }> => child.kind === 'case',
477
- )
478
- .map((branch) => {
487
+ const cases = switchPlan(node)
488
+ .cases.map((branch) => {
479
489
  const match =
480
490
  branch.match === undefined
481
491
  ? 'undefined'
@@ -534,45 +544,26 @@ export function generateBuild(
534
544
  parentVar: string,
535
545
  before: string,
536
546
  ): string {
537
- const [thenBranch, catchBranch, finallyBranch] = resolveBranches(
538
- node,
539
- 'then',
540
- 'catch',
541
- 'finally',
542
- )
543
- const finallyChildren = finallyBranch?.children ?? []
544
- /* Blocking: no pending, the children are the resolved branch bound to `node.as`.
545
- Streaming: pending is the non-branch children, resolved is the `then` child. */
546
- const pending = node.blocking
547
- ? []
548
- : node.children.filter((child) => child.kind !== 'branch')
547
+ const plan = awaitPlan(node)
549
548
  /* The resolved value is reactive: a re-settle updates it in place rather than
550
- rebuilding the branch (see awaitBlock). The branch reads it as a `.value` cell. */
551
- const thenThunk = node.blocking
552
- ? branchThunk(
553
- node.children.filter((child) => child.kind !== 'branch'),
554
- node.as ?? '_value',
555
- finallyChildren,
556
- true,
557
- )
558
- : branchThunk(
559
- thenBranch?.children ?? [],
560
- thenBranch?.as ?? '_value',
561
- finallyChildren,
562
- true,
563
- )
549
+ rebuilding the branch (see awaitBlock). The branch reads it as a `.value` cell.
550
+ Build settles blocking and streaming uniformly through `awaitBlock`, so the plan's
551
+ resolved content/binding feeds one thunk regardless of mode. */
552
+ const thenThunk = branchThunk(
553
+ plan.resolvedChildren,
554
+ plan.resolvedAs,
555
+ plan.finallyChildren,
556
+ true,
557
+ )
564
558
  /* Neither catch nor finally → pass `undefined` so awaitBlock re-throws the
565
559
  rejection (surfacing it) instead of rendering an empty branch. A finally-only
566
560
  block keeps a catch thunk that renders just finally. */
567
- const catchThunk =
568
- catchBranch === undefined && finallyChildren.length === 0
569
- ? 'undefined'
570
- : branchThunk(
571
- catchBranch?.children ?? [],
572
- catchBranch?.as ?? '_error',
573
- finallyChildren,
574
- )
575
- const pendingThunk = hasRenderableContent(pending) ? branchThunk(pending) : 'undefined'
561
+ const catchThunk = plan.surfaceRejection
562
+ ? 'undefined'
563
+ : branchThunk(plan.catchChildren, plan.catchAs, plan.finallyChildren)
564
+ const pendingThunk = hasRenderableContent(plan.pending)
565
+ ? branchThunk(plan.pending)
566
+ : 'undefined'
576
567
  return (
577
568
  `awaitBlock(${parentVar}, nextBlockId(), () => (${lowerExpression(node.promise)}), ` +
578
569
  `${pendingThunk}, ` +
@@ -638,14 +629,11 @@ export function generateBuild(
638
629
  parentVar: string,
639
630
  before: string,
640
631
  ): string {
641
- const [catchBranch, finallyBranch] = resolveBranches(node, 'catch', 'finally')
642
- const finallyChildren = finallyBranch?.children ?? []
643
- const guarded = node.children.filter((child) => child.kind !== 'branch')
644
- const tryThunk = branchThunk(guarded, undefined, finallyChildren)
645
- const catchThunk =
646
- catchBranch === undefined
647
- ? 'undefined'
648
- : branchThunk(catchBranch.children, catchBranch.as ?? '_error', finallyChildren)
632
+ const plan = tryPlan(node)
633
+ const tryThunk = branchThunk(plan.guarded, undefined, plan.finallyChildren)
634
+ const catchThunk = plan.hasCatch
635
+ ? branchThunk(plan.catchChildren, plan.catchAs, plan.finallyChildren)
636
+ : 'undefined'
649
637
  return `tryBlock(${parentVar}, nextBlockId(), ${tryThunk}, ${catchThunk}, ${before});\n`
650
638
  }
651
639
 
@@ -658,25 +646,20 @@ export function generateBuild(
658
646
  ): string {
659
647
  /* The `case` children are the chain's `elseif`/`else` branches in source order;
660
648
  the rest are the `then` content. */
661
- const branches = node.children.filter(
662
- (child): child is Extract<TemplateNode, { kind: 'case' }> => child.kind === 'case',
663
- )
664
- const thenChildren = node.children.filter((child) => child.kind !== 'case')
665
- const hasElseif = branches.some((branch) => branch.condition !== undefined)
649
+ const plan = ifPlan(node)
666
650
  /* Fast path: a plain `if` (with optional `else`) is the binary `when` runtime. */
667
- if (!hasElseif) {
668
- const elseBranch = branches.find((branch) => branch.condition === undefined)
669
- const thenThunk = branchThunk(thenChildren)
651
+ if (!plan.hasElseif) {
652
+ const thenThunk = branchThunk(plan.thenChildren)
670
653
  const elseThunk =
671
- elseBranch === undefined ? 'undefined' : branchThunk(elseBranch.children)
654
+ plan.elseBranch === undefined ? 'undefined' : branchThunk(plan.elseBranch.children)
672
655
  return `when(${parentVar}, () => (${lowerExpression(node.condition)}), ${thenThunk}, ${elseThunk}, ${before});\n`
673
656
  }
674
657
  /* if/elseif/else is a cond-chain — reuse `switchBlock` over a constant `true`
675
658
  subject with `Boolean`-coerced match thunks, so the first truthy branch wins
676
659
  (`else` is the match-less default). */
677
660
  const entries = [
678
- `{ match: () => Boolean(${lowerExpression(node.condition)}), render: ${branchThunk(thenChildren)} }`,
679
- ...branches.map((branch) =>
661
+ `{ match: () => Boolean(${lowerExpression(node.condition)}), render: ${branchThunk(plan.thenChildren)} }`,
662
+ ...plan.branches.map((branch) =>
680
663
  branch.condition !== undefined
681
664
  ? `{ match: () => Boolean(${lowerExpression(branch.condition)}), render: ${branchThunk(branch.children)} }`
682
665
  : `{ match: undefined, render: ${branchThunk(branch.children)} }`,
@@ -8,18 +8,22 @@ import {
8
8
  RANGE_OPEN as RANGE_OPEN_DATA,
9
9
  } from '../runtime/RANGE_MARKER.ts'
10
10
  import { asOutlet } from './asOutlet.ts'
11
+ import { awaitPlan } from './awaitPlan.ts'
11
12
  import { composeProps } from './composeProps.ts'
12
13
  import { groupBindParts } from './groupBindParts.ts'
14
+ import { ifPlan } from './ifPlan.ts'
13
15
  import { isAnchorPositioned } from './isAnchorPositioned.ts'
14
16
  import { lowerContext } from './lowerContext.ts'
15
17
  import { makeVarNamer } from './makeVarNamer.ts'
16
- import { resolveBranches } from './resolveBranches.ts'
17
18
  import { scopeAttr } from './scopeAttr.ts'
18
19
  import { skeletonContext } from './skeletonContext.ts'
19
20
  import { spreadExcludedNames } from './spreadExcludedNames.ts'
20
21
  import { staticAttr } from './staticAttr.ts'
21
22
  import { staticTextPart } from './staticTextPart.ts'
22
23
  import { stripEffects } from './stripEffects.ts'
24
+ import { switchPlan } from './switchPlan.ts'
25
+ import { tryPlan } from './tryPlan.ts'
26
+ import type { TemplateAttr } from './types/TemplateAttr.ts'
23
27
  import type { TemplateNode } from './types/TemplateNode.ts'
24
28
  import { VOID_TAGS } from './VOID_TAGS.ts'
25
29
 
@@ -215,12 +219,9 @@ export function generateSSR(
215
219
  /* `case` children are the `elseif`/`else` branches in source order; the rest are
216
220
  the `then` content. Each `elseif` becomes an `else if`, the match-less `else`
217
221
  the trailing `else`. */
218
- const branches = node.children.filter(
219
- (child): child is Extract<TemplateNode, { kind: 'case' }> => child.kind === 'case',
220
- )
221
- const thenChildren = node.children.filter((child) => child.kind !== 'case')
222
- let code = `if (${lowerExpression(node.condition)}) {\n${branchContent(thenChildren, target)}}`
223
- for (const branch of branches) {
222
+ const plan = ifPlan(node)
223
+ let code = `if (${lowerExpression(node.condition)}) {\n${branchContent(plan.thenChildren, target)}}`
224
+ for (const branch of plan.branches) {
224
225
  code +=
225
226
  branch.condition !== undefined
226
227
  ? ` else if (${lowerExpression(branch.condition)}) {\n${branchContent(branch.children, target)}}`
@@ -229,20 +230,17 @@ export function generateSSR(
229
230
  return `${anchor}${openRange(target)}${code}\n${closeRange(target)}`
230
231
  }
231
232
  if (node.kind === 'switch') {
232
- const cases = node.children.filter(
233
- (child): child is Extract<TemplateNode, { kind: 'case' }> => child.kind === 'case',
234
- )
233
+ const plan = switchPlan(node)
235
234
  let code = `{ const $s = (${lowerExpression(node.subject)});\n`
236
235
  let started = false
237
- for (const branch of cases) {
236
+ for (const branch of plan.cases) {
238
237
  if (branch.match !== undefined) {
239
238
  code += `${started ? 'else ' : ''}if ($s === (${lowerExpression(branch.match)})) {\n${branchContent(branch.children, target)}}\n`
240
239
  started = true
241
240
  }
242
241
  }
243
- const fallback = cases.find((branch) => branch.match === undefined)
244
- if (fallback !== undefined) {
245
- code += `${started ? 'else ' : ''}{\n${branchContent(fallback.children, target)}}\n`
242
+ if (plan.fallback !== undefined) {
243
+ code += `${started ? 'else ' : ''}{\n${branchContent(plan.fallback.children, target)}}\n`
246
244
  }
247
245
  return `${anchor}${openRange(target)}${code}}\n${closeRange(target)}`
248
246
  }
@@ -371,8 +369,25 @@ export function generateSSR(
371
369
  for (const scope of node.scopes ?? []) {
372
370
  code += push(target, scopeAttr(scope))
373
371
  }
372
+ /* `class:`/`style:` directives collapse with any static `class`/`style` into a SINGLE
373
+ merged attribute (a duplicate attribute would be invalid and ignored). The client's
374
+ classList.toggle / style.setProperty effects re-apply the same values on hydrate, so
375
+ the merged SSR value already matches the post-mount DOM — no flash, no desync. */
376
+ const classDirectives = node.attrs.filter(
377
+ (attr): attr is Extract<TemplateAttr, { kind: 'class' }> => attr.kind === 'class',
378
+ )
379
+ const styleDirectives = node.attrs.filter(
380
+ (attr): attr is Extract<TemplateAttr, { kind: 'style' }> => attr.kind === 'style',
381
+ )
374
382
  for (const attr of node.attrs) {
375
383
  if (attr.kind === 'static') {
384
+ /* A static class/style is folded into the merge below when directives exist. */
385
+ if (
386
+ (attr.name === 'class' && classDirectives.length > 0) ||
387
+ (attr.name === 'style' && styleDirectives.length > 0)
388
+ ) {
389
+ continue
390
+ }
376
391
  code += push(target, staticAttr(attr.name, attr.value))
377
392
  } else if (attr.kind === 'expression') {
378
393
  /* present/absent semantics matching the client `attr` binding:
@@ -400,6 +415,36 @@ export function generateSSR(
400
415
  code += `${target}.push(${JSON.stringify(` ${attr.property}="`)} + $esc(${bindRead(attr.code)}) + '"');\n`
401
416
  }
402
417
  }
418
+ /* Merged class: static value + each directive's name when its expression is truthy. */
419
+ if (classDirectives.length > 0) {
420
+ const staticClass = node.attrs.find(
421
+ (attr): attr is Extract<TemplateAttr, { kind: 'static' }> =>
422
+ attr.kind === 'static' && attr.name === 'class',
423
+ )
424
+ const parts = [
425
+ ...(staticClass ? [JSON.stringify(staticClass.value)] : []),
426
+ ...classDirectives.map(
427
+ (dir) => `((${lowerExpression(dir.code)}) ? ${JSON.stringify(dir.name)} : "")`,
428
+ ),
429
+ ]
430
+ code += `${target}.push(' class="' + $esc([${parts.join(', ')}].filter(Boolean).join(' ')) + '"');\n`
431
+ }
432
+ /* Merged style: static value + each directive's `prop:value` (String()-coerced, matching
433
+ the client's style.setProperty). */
434
+ if (styleDirectives.length > 0) {
435
+ const staticStyle = node.attrs.find(
436
+ (attr): attr is Extract<TemplateAttr, { kind: 'static' }> =>
437
+ attr.kind === 'static' && attr.name === 'style',
438
+ )
439
+ const parts = [
440
+ ...(staticStyle ? [JSON.stringify(staticStyle.value)] : []),
441
+ ...styleDirectives.map(
442
+ (dir) =>
443
+ `(${JSON.stringify(`${dir.property}:`)} + String(${lowerExpression(dir.code)}))`,
444
+ ),
445
+ ]
446
+ code += `${target}.push(' style="' + $esc([${parts.join(', ')}].filter(Boolean).join(';')) + '"');\n`
447
+ }
403
448
  code += push(target, '>')
404
449
  if (!VOID_TAGS.has(node.tag)) {
405
450
  /* Each child's skeleton position (whether its reactive text interleaves into an
@@ -464,27 +509,23 @@ export function generateSSR(
464
509
  node: Extract<TemplateNode, { kind: 'await' }>,
465
510
  target: string,
466
511
  ): string {
467
- const [catchBranch, finallyBranch] = resolveBranches(node, 'catch', 'finally')
468
- const finallyChildren = finallyBranch?.children ?? []
469
- const resolvedChildren = node.children.filter((child) => child.kind !== 'branch')
470
- const resolvedAs = node.as ?? '_value'
471
- const errorAs = catchBranch?.as ?? '_error'
512
+ const plan = awaitPlan(node)
472
513
  const id = nextVar('$aid')
473
514
  let code = `const ${id} = $ctx.next++;\n`
474
515
  code += `${target}.push("<!--abide:await:" + ${id} + "-->");\n`
475
516
  code += `try {\n`
476
- code += `const ${resolvedAs} = await (${lowerExpression(node.promise)});\n`
477
- code += branchContent(resolvedChildren, target)
478
- code += branchContent(finallyChildren, target)
479
- code += `$resume[${id}] = { ok: true, value: ${resolvedAs} };\n`
480
- if (catchBranch === undefined && finallyChildren.length === 0) {
517
+ code += `const ${plan.resolvedAs} = await (${lowerExpression(node.promise)});\n`
518
+ code += branchContent(plan.resolvedChildren, target)
519
+ code += branchContent(plan.finallyChildren, target)
520
+ code += `$resume[${id}] = { ok: true, value: ${plan.resolvedAs} };\n`
521
+ if (plan.surfaceRejection) {
481
522
  /* No catch/finally → let the rejection surface instead of an empty branch. */
482
523
  code += `} catch (_error) { throw _error; }\n`
483
524
  } else {
484
- code += `} catch (${errorAs}) {\n`
485
- code += branchContent(catchBranch?.children ?? [], target)
486
- code += branchContent(finallyChildren, target)
487
- code += `$resume[${id}] = { ok: false, error: String(${errorAs}) };\n`
525
+ code += `} catch (${plan.catchAs}) {\n`
526
+ code += branchContent(plan.catchChildren, target)
527
+ code += branchContent(plan.finallyChildren, target)
528
+ code += `$resume[${id}] = { ok: false, error: String(${plan.catchAs}) };\n`
488
529
  code += `}\n`
489
530
  }
490
531
  code += `${target}.push("<!--/abide:await:" + ${id} + "-->");\n`
@@ -502,29 +543,21 @@ export function generateSSR(
502
543
  node: Extract<TemplateNode, { kind: 'await' }>,
503
544
  target: string,
504
545
  ): string {
505
- const [thenBranch, catchBranch, finallyBranch] = resolveBranches(
506
- node,
507
- 'then',
508
- 'catch',
509
- 'finally',
510
- )
511
- const finallyChildren = finallyBranch?.children ?? []
512
- const pending = node.children.filter((child) => child.kind !== 'branch')
546
+ const plan = awaitPlan(node)
513
547
  const id = nextVar('$aid')
514
548
  let code = `const ${id} = $ctx.next++;\n`
515
549
  code += `${target}.push("<!--abide:await:" + ${id} + "-->");\n`
516
- code += branchContent(pending, target)
550
+ code += branchContent(plan.pending, target)
517
551
  code += `${target}.push("<!--/abide:await:" + ${id} + "-->");\n`
518
552
  const settled = (binding: string, children: TemplateNode[]) =>
519
- `async (${binding}) => { const $o = []; ${branchContent(children, '$o')}${branchContent(finallyChildren, '$o')}return $o.join(''); }`
520
- const catchProp =
521
- catchBranch === undefined && finallyChildren.length === 0
522
- ? ''
523
- : `catch: ${settled(catchBranch?.as ?? '_error', catchBranch?.children ?? [])} `
553
+ `async (${binding}) => { const $o = []; ${branchContent(children, '$o')}${branchContent(plan.finallyChildren, '$o')}return $o.join(''); }`
554
+ const catchProp = plan.surfaceRejection
555
+ ? ''
556
+ : `catch: ${settled(plan.catchAs, plan.catchChildren)} `
524
557
  code +=
525
558
  `$awaits.push({ id: ${id}, ` +
526
559
  `promise: () => (${lowerExpression(node.promise)}), ` +
527
- `then: ${settled(thenBranch?.as ?? '_value', thenBranch?.children ?? [])}, ` +
560
+ `then: ${settled(plan.resolvedAs, plan.resolvedChildren)}, ` +
528
561
  `${catchProp}});\n`
529
562
  return code
530
563
  }
@@ -536,24 +569,21 @@ export function generateSSR(
536
569
  an enclosing boundary / the 500 / the stream). Boundary comments let hydration
537
570
  discard the server content if the client adoption fails. */
538
571
  function generateTry(node: Extract<TemplateNode, { kind: 'try' }>, target: string): string {
539
- const [catchBranch, finallyBranch] = resolveBranches(node, 'catch', 'finally')
540
- const finallyChildren = finallyBranch?.children ?? []
541
- const guarded = node.children.filter((child) => child.kind !== 'branch')
542
- const errName = catchBranch?.as ?? '_error'
572
+ const plan = tryPlan(node)
543
573
  const id = nextVar('$tid')
544
574
  const mark = nextVar('$trim')
545
575
  let code = `const ${id} = $ctx.next++;\n`
546
576
  code += `${target}.push("<!--abide:try:" + ${id} + "-->");\n`
547
577
  code += `const ${mark} = ${target}.length;\n`
548
578
  code += `try {\n`
549
- code += branchContent(guarded, target)
550
- code += branchContent(finallyChildren, target)
551
- code += `} catch (${errName}) {\n${target}.length = ${mark};\n`
552
- if (catchBranch !== undefined) {
553
- code += branchContent(catchBranch.children, target)
554
- code += branchContent(finallyChildren, target)
579
+ code += branchContent(plan.guarded, target)
580
+ code += branchContent(plan.finallyChildren, target)
581
+ code += `} catch (${plan.catchAs}) {\n${target}.length = ${mark};\n`
582
+ if (plan.hasCatch) {
583
+ code += branchContent(plan.catchChildren, target)
584
+ code += branchContent(plan.finallyChildren, target)
555
585
  } else {
556
- code += `throw ${errName};\n`
586
+ code += `throw ${plan.catchAs};\n`
557
587
  }
558
588
  code += `}\n`
559
589
  code += `${target}.push("<!--/abide:try:" + ${id} + "-->");\n`
@@ -0,0 +1,30 @@
1
+ import type { TemplateNode } from './types/TemplateNode.ts'
2
+
3
+ type Case = Extract<TemplateNode, { kind: 'case' }>
4
+
5
+ /* The structural shape of an `if` chain, resolved once so the build and SSR back-ends share
6
+ one partition of its children and only own emission. Both previously recomputed the
7
+ then-content / branch split and the elseif/else classification. */
8
+ export type IfPlan = {
9
+ /* The `then` content — every child that is not an `elseif`/`else` branch. */
10
+ thenChildren: TemplateNode[]
11
+ /* The `elseif`/`else` branches in source order (each a `case` node: `condition` set for
12
+ `elseif`, both unset for `else`). */
13
+ branches: Case[]
14
+ /* Any branch carries a condition → a cond-chain (the build lowers it through `switchBlock`);
15
+ otherwise a binary `if`/`else`. */
16
+ hasElseif: boolean
17
+ /* The match-less `else` branch, if present. */
18
+ elseBranch: Case | undefined
19
+ }
20
+
21
+ /* Partitions an `if` node's children into the shared structural plan. */
22
+ export function ifPlan(node: Extract<TemplateNode, { kind: 'if' }>): IfPlan {
23
+ const branches = node.children.filter((child): child is Case => child.kind === 'case')
24
+ return {
25
+ thenChildren: node.children.filter((child) => child.kind !== 'case'),
26
+ branches,
27
+ hasElseif: branches.some((branch) => branch.condition !== undefined),
28
+ elseBranch: branches.find((branch) => branch.condition === undefined),
29
+ }
30
+ }
@@ -453,6 +453,7 @@ export function parseTemplate(source: string, baseOffset = 0): { nodes: Template
453
453
  continue
454
454
  }
455
455
  let name = ''
456
+ const nameLoc = baseOffset + cursor // absolute offset of the attribute name
456
457
  while (cursor < source.length && !/[\s=>/]/.test(source.charAt(cursor))) {
457
458
  name += source.charAt(cursor)
458
459
  cursor += 1
@@ -461,7 +462,7 @@ export function parseTemplate(source: string, baseOffset = 0): { nodes: Template
461
462
  cursor += 1
462
463
  }
463
464
  if (source.charAt(cursor) !== '=') {
464
- attrs.push({ kind: 'static', name, value: '', bare: true }) // boolean attribute
465
+ attrs.push({ kind: 'static', name, value: '', bare: true, nameLoc }) // boolean attribute
465
466
  continue
466
467
  }
467
468
  cursor += 1 // past '='
@@ -474,10 +475,14 @@ export function parseTemplate(source: string, baseOffset = 0): { nodes: Template
474
475
  attrs.push({ kind: 'event', event: name.slice(2), code, loc })
475
476
  } else if (name.startsWith('bind:')) {
476
477
  attrs.push({ kind: 'bind', property: name.slice(5), code, loc })
478
+ } else if (name.startsWith('class:')) {
479
+ attrs.push({ kind: 'class', name: name.slice(6), code, loc })
480
+ } else if (name.startsWith('style:')) {
481
+ attrs.push({ kind: 'style', property: name.slice(6), code, loc })
477
482
  } else if (name === 'attach') {
478
483
  attrs.push({ kind: 'attach', code, loc })
479
484
  } else {
480
- attrs.push({ kind: 'expression', name, code, loc })
485
+ attrs.push({ kind: 'expression', name, code, loc, nameLoc })
481
486
  }
482
487
  } else if (source.charAt(cursor) === '"' || source.charAt(cursor) === "'") {
483
488
  const quote = source.charAt(cursor)
@@ -488,7 +493,7 @@ export function parseTemplate(source: string, baseOffset = 0): { nodes: Template
488
493
  cursor += 1
489
494
  }
490
495
  cursor += 1 // past closing quote
491
- attrs.push({ kind: 'static', name, value })
496
+ attrs.push({ kind: 'static', name, value, nameLoc })
492
497
  } else {
493
498
  /* Unquoted value (`<input type=text>`): runs to the next whitespace or
494
499
  `>`, per the HTML unquoted-attribute rule. No delimiter to consume. */
@@ -512,6 +517,7 @@ export function parseTemplate(source: string, baseOffset = 0): { nodes: Template
512
517
  function readElement(): TemplateNode {
513
518
  cursor += 1 // past '<'
514
519
  let tag = ''
520
+ const tagStart = cursor // absolute (baseOffset-relative) offset of the tag name
515
521
  while (cursor < source.length && !/[\s>/]/.test(source.charAt(cursor))) {
516
522
  tag += source.charAt(cursor)
517
523
  cursor += 1
@@ -549,7 +555,13 @@ export function parseTemplate(source: string, baseOffset = 0): { nodes: Template
549
555
  its children become slot content (rendered where the child puts <slot>). */
550
556
  if (/^[A-Z]/.test(tag)) {
551
557
  const slotted = selfClosing ? [] : readChildren(tag)
552
- return { kind: 'component', name: tag, props: toProps(attrs), children: slotted }
558
+ return {
559
+ kind: 'component',
560
+ name: tag,
561
+ loc: baseOffset + tagStart,
562
+ props: toProps(attrs),
563
+ children: slotted,
564
+ }
553
565
  }
554
566
  /* `{...expr}` spreads onto a component (its props) or a native element (its
555
567
  attributes), but a `<template>` directive has no such bag — reject it there. */
@@ -874,7 +886,7 @@ function rejectMisplacedBranchContent(
874
886
  letting a prop hold any value, functions included (e.g. an `onclick` callback). */
875
887
  function toProps(
876
888
  attrs: TemplateAttr[],
877
- ): { name: string; code: string; loc?: number; spread?: boolean }[] {
889
+ ): { name: string; code: string; loc?: number; nameLoc?: number; spread?: boolean }[] {
878
890
  return attrs.map((attr) => {
879
891
  /* A `{...expr}` spread carries no name — its keys merge in at runtime
880
892
  (`mergeProps`/`spreadProps`); `spread: true` marks it for the back-ends. */
@@ -885,19 +897,31 @@ function toProps(
885
897
  /* A bare attribute (`<Toggle on />`) is a boolean flag: coerce it to
886
898
  `true` so the prop reads as a boolean, not the empty string a native
887
899
  element would serialise. An explicit `on=""` stays the empty string. */
888
- return { name: attr.name, code: attr.bare ? 'true' : JSON.stringify(attr.value) }
900
+ return {
901
+ name: attr.name,
902
+ code: attr.bare ? 'true' : JSON.stringify(attr.value),
903
+ nameLoc: attr.nameLoc,
904
+ }
889
905
  }
890
906
  /* Every non-static kind keeps its `code`/`loc`; only the prop name differs —
891
- a directive (`event`/`bind`/`attach`) round-trips to its written name. */
907
+ a directive (`event`/`bind`/`class`/`style`/`attach`) round-trips to its written
908
+ name as a passthrough prop. */
892
909
  const name =
893
910
  attr.kind === 'event'
894
911
  ? `on${attr.event}`
895
912
  : attr.kind === 'bind'
896
913
  ? `bind:${attr.property}`
897
- : attr.kind === 'attach'
898
- ? 'attach'
899
- : attr.name
900
- return { name, code: attr.code, loc: attr.loc }
914
+ : attr.kind === 'class'
915
+ ? `class:${attr.name}`
916
+ : attr.kind === 'style'
917
+ ? `style:${attr.property}`
918
+ : attr.kind === 'attach'
919
+ ? 'attach'
920
+ : attr.name
921
+ /* Only `expression` carries a name offset; event/bind/attach are framework-handled
922
+ passthrough excluded from the strict prop check, so theirs is unused. */
923
+ const nameLoc = attr.kind === 'expression' ? attr.nameLoc : undefined
924
+ return { name, code: attr.code, loc: attr.loc, nameLoc }
901
925
  })
902
926
  }
903
927
 
@@ -930,6 +954,12 @@ function attrName(attr: TemplateAttr): string {
930
954
  if (attr.kind === 'attach') {
931
955
  return 'attach'
932
956
  }
957
+ if (attr.kind === 'class') {
958
+ return `class:${attr.name}`
959
+ }
960
+ if (attr.kind === 'style') {
961
+ return `style:${attr.property}`
962
+ }
933
963
  /* A spread has no name. `attrName` only feeds `<template>` directive lookups, and a
934
964
  spread on a `<template>` is rejected at parse, so this branch is unreachable in
935
965
  practice. (Spread itself IS supported — on components as props and on native