@abide/abide 0.46.0 → 0.47.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 (185) hide show
  1. package/AGENTS.md +365 -320
  2. package/CHANGELOG.md +80 -0
  3. package/README.md +203 -168
  4. package/package.json +12 -8
  5. package/src/abideLsp.ts +11 -12
  6. package/src/abideResolverPlugin.ts +9 -4
  7. package/src/lib/bundle/disconnected.abide +3 -0
  8. package/src/lib/cli/printCommandHelp.ts +43 -0
  9. package/src/lib/cli/printSessionHelp.ts +2 -1
  10. package/src/lib/cli/{printHelp.ts → printTopLevelHelp.ts} +3 -40
  11. package/src/lib/cli/runCli.ts +2 -1
  12. package/src/lib/mcp/buildPrompts.ts +25 -0
  13. package/src/lib/mcp/dispatchMcpRequest.ts +8 -6
  14. package/src/lib/mcp/mcpResourceServerSlot.ts +5 -10
  15. package/src/lib/mcp/mcpSurface.ts +13 -252
  16. package/src/lib/mcp/mcpTools.ts +187 -0
  17. package/src/lib/mcp/renderPrompt.ts +25 -0
  18. package/src/lib/mcp/types/McpSurface.ts +15 -0
  19. package/src/lib/mcp/types/PromptDescriptor.ts +5 -0
  20. package/src/lib/mcp/types/PromptMessage.ts +2 -0
  21. package/src/lib/mcp/types/ToolDescriptor.ts +7 -0
  22. package/src/lib/mcp/types/ToolResult.ts +2 -0
  23. package/src/lib/server/agent.ts +1 -1
  24. package/src/lib/server/jsonl.ts +2 -2
  25. package/src/lib/server/rpc/defineRpc.ts +5 -0
  26. package/src/lib/server/runtime/containsTraversal.ts +21 -17
  27. package/src/lib/server/runtime/createAppAssetServer.ts +11 -16
  28. package/src/lib/server/runtime/createServer.ts +101 -71
  29. package/src/lib/server/runtime/createUiPageRenderer.ts +23 -4
  30. package/src/lib/server/runtime/devClientFingerprint.ts +3 -3
  31. package/src/lib/server/runtime/devHotModuleResponse.ts +2 -1
  32. package/src/lib/server/runtime/finalizeResponse.ts +32 -0
  33. package/src/lib/server/runtime/snapshotEntryFromCache.ts +8 -17
  34. package/src/lib/server/socket.ts +1 -1
  35. package/src/lib/server/sockets/createSocketDispatcher.ts +28 -5
  36. package/src/lib/server/sockets/defineSocket.ts +26 -7
  37. package/src/lib/server/sockets/types/SocketRegistryEntry.ts +1 -1
  38. package/src/lib/server/sockets/types/SocketRoutes.ts +1 -1
  39. package/src/lib/server/sse.ts +2 -2
  40. package/src/lib/shared/DEFER.ts +8 -0
  41. package/src/lib/shared/activeCacheStore.ts +2 -2
  42. package/src/lib/shared/activePage.ts +2 -2
  43. package/src/lib/shared/attachRpcSelectorMethods.ts +42 -0
  44. package/src/lib/shared/attachSocketSelectorMethods.ts +34 -0
  45. package/src/lib/shared/basePath.ts +2 -2
  46. package/src/lib/shared/baseSlot.ts +6 -5
  47. package/src/lib/shared/bodyValueForKind.ts +1 -2
  48. package/src/lib/shared/buildSocketOverChannel.ts +40 -4
  49. package/src/lib/shared/cache.ts +477 -110
  50. package/src/lib/shared/cacheEntryFromSnapshot.ts +3 -22
  51. package/src/lib/shared/cacheStoreSlot.ts +9 -5
  52. package/src/lib/{server/runtime → shared}/createReachable.ts +2 -2
  53. package/src/lib/shared/createRemoteFunction.ts +58 -9
  54. package/src/lib/shared/createResolverSlot.ts +24 -31
  55. package/src/lib/shared/createSocketSubRegistry.ts +2 -2
  56. package/src/lib/shared/decodeRefJson.ts +4 -2
  57. package/src/lib/shared/decodeResponse.ts +8 -6
  58. package/src/lib/shared/done.ts +14 -0
  59. package/src/lib/shared/encodeRefJson.ts +4 -2
  60. package/src/lib/shared/globalCacheStoreSlot.ts +9 -6
  61. package/src/lib/shared/hydratingSlot.ts +12 -0
  62. package/src/lib/shared/isLayoutFile.ts +12 -0
  63. package/src/lib/shared/keyForRemoteCall.ts +2 -1
  64. package/src/lib/shared/keyPrefixForRemote.ts +12 -0
  65. package/src/lib/shared/matchRoute.ts +175 -0
  66. package/src/lib/shared/normalizePathname.ts +11 -0
  67. package/src/lib/shared/pageSlot.ts +14 -5
  68. package/src/lib/shared/pageUrlForFile.ts +3 -3
  69. package/src/lib/shared/parseRouteSegments.ts +16 -7
  70. package/src/lib/shared/patch.ts +41 -0
  71. package/src/lib/shared/peek.ts +35 -0
  72. package/src/lib/shared/prepareRemoteExport.ts +40 -0
  73. package/src/lib/shared/prepareRpcModule.ts +98 -16
  74. package/src/lib/shared/prepareSocketModule.ts +11 -15
  75. package/src/lib/shared/queryStringFromArgs.ts +11 -0
  76. package/src/lib/shared/reachable.ts +102 -0
  77. package/src/lib/shared/refresh.ts +22 -0
  78. package/src/lib/shared/requestScopeSlot.ts +10 -7
  79. package/src/lib/shared/routeParamsShape.ts +9 -9
  80. package/src/lib/shared/rpcErrorRegistry.ts +69 -0
  81. package/src/lib/shared/selectorPrefix.ts +3 -10
  82. package/src/lib/shared/setOwnProperty.ts +19 -0
  83. package/src/lib/shared/snippet.ts +1 -1
  84. package/src/lib/shared/subscribableProbes.ts +111 -0
  85. package/src/lib/shared/tailProbeSlot.ts +8 -1
  86. package/src/lib/shared/types/CacheEntry.ts +9 -15
  87. package/src/lib/shared/types/CacheOptions.ts +8 -0
  88. package/src/lib/shared/types/CacheSnapshotEntry.ts +0 -5
  89. package/src/lib/shared/types/RemoteCallable.ts +25 -7
  90. package/src/lib/shared/types/RemoteFunction.ts +48 -13
  91. package/src/lib/shared/types/ResolverSlot.ts +7 -4
  92. package/src/lib/shared/types/RpcError.ts +26 -0
  93. package/src/lib/shared/types/SmartReadOptions.ts +32 -0
  94. package/src/lib/shared/types/Socket.ts +54 -0
  95. package/src/lib/shared/types/SsrBootState.ts +27 -0
  96. package/src/lib/shared/types/SsrPayload.ts +21 -0
  97. package/src/lib/shared/types/Subscribable.ts +13 -13
  98. package/src/lib/shared/types/TailHooks.ts +2 -1
  99. package/src/lib/shared/url.ts +29 -14
  100. package/src/lib/shared/wakeHydrationPeeks.ts +20 -0
  101. package/src/lib/shared/writeTestSocketsDts.ts +1 -1
  102. package/src/lib/test/createScriptedSurface.ts +1 -1
  103. package/src/lib/test/createTestApp.ts +2 -2
  104. package/src/lib/test/createTestSocketChannel.ts +3 -3
  105. package/src/lib/ui/compile/REACTIVE_CALLEES.ts +5 -6
  106. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +10 -7
  107. package/src/lib/ui/compile/abideUiPlugin.ts +2 -2
  108. package/src/lib/ui/compile/analyzeComponent.ts +28 -6
  109. package/src/lib/ui/compile/compileModule.ts +137 -11
  110. package/src/lib/ui/compile/compileShadow.ts +101 -84
  111. package/src/lib/ui/compile/desugarSignals.ts +120 -107
  112. package/src/lib/ui/compile/generateBuild.ts +48 -16
  113. package/src/lib/ui/compile/generateSSR.ts +117 -14
  114. package/src/lib/ui/compile/identifierReferencePattern.ts +13 -0
  115. package/src/lib/ui/compile/lowerContext.ts +10 -4
  116. package/src/lib/ui/compile/lowerScript.ts +72 -5
  117. package/src/lib/ui/compile/parseTemplate.ts +1 -14
  118. package/src/lib/ui/compile/prepareNestedScript.ts +31 -17
  119. package/src/lib/ui/compile/resolveReactiveExport.ts +95 -0
  120. package/src/lib/ui/compile/signalCallee.ts +24 -0
  121. package/src/lib/ui/compile/stripEffects.ts +14 -11
  122. package/src/lib/ui/compile/types/AnalyzedComponent.ts +5 -0
  123. package/src/lib/ui/compile/types/TemplateNode.ts +0 -5
  124. package/src/lib/ui/dom/appendStatic.ts +9 -1
  125. package/src/lib/ui/dom/appendText.ts +15 -3
  126. package/src/lib/ui/dom/assertClaimedText.ts +20 -0
  127. package/src/lib/ui/dom/awaitBlock.ts +19 -83
  128. package/src/lib/ui/dom/bindSelectValue.ts +48 -0
  129. package/src/lib/ui/dom/each.ts +12 -1
  130. package/src/lib/ui/dom/hydrate.ts +10 -0
  131. package/src/lib/ui/dom/mountChild.ts +2 -5
  132. package/src/lib/ui/dom/mountRange.ts +0 -75
  133. package/src/lib/ui/effect.ts +4 -3
  134. package/src/lib/ui/installHotBridge.ts +4 -2
  135. package/src/lib/ui/remoteProxy.ts +18 -2
  136. package/src/lib/ui/renderToStream.ts +9 -13
  137. package/src/lib/ui/resumeSeedScript.ts +2 -2
  138. package/src/lib/ui/router.ts +21 -2
  139. package/src/lib/ui/runtime/RESUME.ts +0 -6
  140. package/src/lib/ui/runtime/ambientScopeBacking.ts +1 -1
  141. package/src/lib/ui/runtime/types/SsrRender.ts +3 -4
  142. package/src/lib/ui/scope.ts +6 -3
  143. package/src/lib/ui/seedBootState.ts +53 -0
  144. package/src/lib/ui/socketChannel.ts +2 -2
  145. package/src/lib/ui/socketProxy.ts +9 -3
  146. package/src/lib/ui/startClient.ts +16 -30
  147. package/src/lib/ui/state.ts +44 -13
  148. package/src/lib/ui/sync.ts +11 -5
  149. package/src/lib/ui/tryEncodeResume.ts +2 -5
  150. package/src/lib/ui/types/Scope.ts +14 -10
  151. package/src/lib/ui/watch.ts +140 -0
  152. package/src/serverEntry.ts +6 -6
  153. package/template/CLAUDE.md +1 -1
  154. package/template/package.json +1 -1
  155. package/template/src/server/rpc/getHello.ts +3 -3
  156. package/template/src/ui/pages/page.abide +2 -2
  157. package/template/test/app.test.ts +1 -1
  158. package/src/lib/server/reachable.ts +0 -45
  159. package/src/lib/server/sockets/types/Socket.ts +0 -23
  160. package/src/lib/shared/CACHE_WRAPPED.ts +0 -9
  161. package/src/lib/shared/DEFER_MIN_ARRAY_LENGTH.ts +0 -14
  162. package/src/lib/shared/baseResolver.ts +0 -10
  163. package/src/lib/shared/cacheKeyOf.ts +0 -7
  164. package/src/lib/shared/cacheKeyStore.ts +0 -8
  165. package/src/lib/shared/cacheStoreResolver.ts +0 -12
  166. package/src/lib/shared/globalCacheStoreResolver.ts +0 -12
  167. package/src/lib/shared/pageResolver.ts +0 -16
  168. package/src/lib/shared/recordCacheKey.ts +0 -6
  169. package/src/lib/shared/requestScopeResolver.ts +0 -12
  170. package/src/lib/shared/setBaseResolver.ts +0 -4
  171. package/src/lib/shared/setCacheStoreResolver.ts +0 -4
  172. package/src/lib/shared/setGlobalCacheStoreResolver.ts +0 -4
  173. package/src/lib/shared/setPageResolver.ts +0 -4
  174. package/src/lib/shared/setRequestScopeResolver.ts +0 -4
  175. package/src/lib/shared/toBunRoutePattern.ts +0 -28
  176. package/src/lib/shared/types/TailOptions.ts +0 -10
  177. package/src/lib/ui/deferResume.ts +0 -36
  178. package/src/lib/ui/dom/firstElementBetween.ts +0 -14
  179. package/src/lib/ui/matchRoute.ts +0 -106
  180. package/src/lib/ui/runtime/scheduleWake.ts +0 -28
  181. package/src/lib/ui/runtime/whenIdle.ts +0 -21
  182. package/src/lib/ui/runtime/whenVisible.ts +0 -105
  183. package/src/lib/ui/tail.ts +0 -324
  184. /package/src/lib/{server/sockets → shared}/types/SocketClientFrame.ts +0 -0
  185. /package/src/lib/{server/sockets → shared}/types/SocketServerFrame.ts +0 -0
@@ -50,12 +50,17 @@ no bracket segments yields {}, which collapses the params slot away. The
50
50
  catch-all branch recurses on its head too, so `[name]` segments before a
51
51
  `[...rest]` are kept.
52
52
  */
53
- export type PathParams<P extends string> = P extends `${infer Head}[...${infer Rest}]${infer Tail}`
54
- ? PathParams<Head> & { [K in Rest]: string | number } & PathParams<Tail>
55
- : P extends `${string}[${infer Name}]${infer Tail}`
56
- ? { [K in Name]: string | number } & PathParams<Tail>
57
- : // biome-ignore lint/complexity/noBannedTypes: {} is the "no params" base case — keyof {} is never, which collapses the params slot; Record<string, never> would not (keyof is string)
58
- {}
53
+ export type PathParams<P extends string> = P extends `${infer Head}[[${infer Name}]]${infer Tail}`
54
+ ? Name extends `...${infer Rest}`
55
+ ? // `[[...rest]]` normalizes to a plain catch-all (see parseRouteSegments)
56
+ PathParams<Head> & { [K in Rest]: string | number } & PathParams<Tail>
57
+ : PathParams<Head> & { [K in Name]?: string | number } & PathParams<Tail>
58
+ : P extends `${infer Head}[...${infer Rest}]${infer Tail}`
59
+ ? PathParams<Head> & { [K in Rest]: string | number } & PathParams<Tail>
60
+ : P extends `${string}[${infer Name}]${infer Tail}`
61
+ ? { [K in Name]: string | number } & PathParams<Tail>
62
+ : // biome-ignore lint/complexity/noBannedTypes: {} is the "no params" base case — keyof {} is never, which collapses the params slot; Record<string, never> would not (keyof is string)
63
+ {}
59
64
 
60
65
  /*
61
66
  Resolves any in-app URL to its base-correct, typed form — the single chokepoint
@@ -93,22 +98,32 @@ export function url(path: string, first?: Query, second?: Query): string {
93
98
  return appendQuery(prefixed, query)
94
99
  }
95
100
 
96
- /* Substitutes `[name]` / `[...rest]` segments with their stringified, URL-encoded
97
- param values (matching the query side, which encodes too). A `[name]` value is
98
- encoded whole so `/`, `?`, `#`, and spaces can't alter the path structure; a
99
- `[...rest]` catch-all keeps its `/` separators and encodes each sub-segment. */
101
+ /* Substitutes `[name]` / `[[name]]` / `[...rest]` segments with their stringified,
102
+ URL-encoded param values (matching the query side, which encodes too). A `[name]`
103
+ value is encoded whole so `/`, `?`, `#`, and spaces can't alter the path structure;
104
+ a `[...rest]` catch-all keeps its `/` separators and encodes each sub-segment. An
105
+ absent `[[optional]]` — undefined OR empty string, since the matcher never captures
106
+ an empty optional segment — drops its segment (and slash) entirely; a path that
107
+ empties out (`/[[lang]]` with no lang) resolves to the root. Dropping empty here
108
+ also avoids emitting a `//about` that a browser reads as a protocol-relative URL. */
100
109
  function interpolate(segments: RouteSegment[], params: Query): string {
101
- return segments
110
+ const resolved = segments
102
111
  .map((segment) => {
103
112
  if (segment.kind === 'literal') {
104
113
  return segment.value
105
114
  }
106
- const value = String(params[segment.name])
115
+ const value = params[segment.name]
116
+ if (segment.optional && (value === undefined || value === '')) {
117
+ return undefined
118
+ }
119
+ const text = String(value)
107
120
  return segment.catchAll
108
- ? value.split('/').map(encodeURIComponent).join('/')
109
- : encodeURIComponent(value)
121
+ ? text.split('/').map(encodeURIComponent).join('/')
122
+ : encodeURIComponent(text)
110
123
  })
124
+ .filter((part) => part !== undefined)
111
125
  .join('/')
126
+ return resolved === '' ? '/' : resolved
112
127
  }
113
128
 
114
129
  /* Appends a `?`-query built from the same encoder buildRpcRequest uses, or nothing. */
@@ -0,0 +1,20 @@
1
+ import { cacheStores } from './cacheStores.ts'
2
+
3
+ /*
4
+ Fired once when the outermost hydration pass ends (see the router's hydrate branch
5
+ and `hydrate`). During the pass `peek` withheld every snapshot-seeded warm value
6
+ for SSR congruence (see hydratingSlot); now that the pass is over those values are
7
+ congruent to show, so re-run the scopes that read them. A peek scope taps the exact
8
+ call key's lifecycle channel (peek's trackLifecycle), so marking every live entry's
9
+ key wakes them — cheap, one-time, and harmless for non-peek lifecycle readers (they
10
+ recompute the same value). Marks by key, not the bare store-wide channel, because a
11
+ peek(fn, args) scope taps the per-key prefix channel, which a keyless mark misses.
12
+ */
13
+ export function wakeHydrationPeeks(): void {
14
+ const stores = cacheStores()
15
+ for (const store of stores) {
16
+ for (const key of store.entries.keys()) {
17
+ store.markLifecycle(key)
18
+ }
19
+ }
20
+ }
@@ -8,7 +8,7 @@ Emits a `.d.ts` that augments createTestApp's `SocketClient` interface with one
8
8
  entry per $socket, keyed by socket name (the same key `app.sockets.<name>`
9
9
  resolves at runtime). The `socket(...)` helper statically returns `Socket<T>`,
10
10
  so each entry is the export's own type directly — `app.sockets.chat` types as
11
- `Socket<ChatMessage>`, iterable for the live stream with `.tail`/`.publish`.
11
+ `Socket<ChatMessage>`, iterable for the live stream with `.tail`/`.broadcast`.
12
12
  Written to `src/.abide/testSockets.d.ts` like its rpc sibling.
13
13
  */
14
14
  export async function writeTestSocketsDts({
@@ -1,4 +1,4 @@
1
- import type { ToolResult } from '../mcp/mcpSurface.ts'
1
+ import type { ToolResult } from '../mcp/types/ToolResult.ts'
2
2
  import type { AgentSurface } from '../server/agent.ts'
3
3
 
4
4
  type ScriptedTool = {
@@ -31,7 +31,6 @@ import { ensureRegistriesLoaded } from '../server/runtime/registryManifests.ts'
31
31
  import { requestContext } from '../server/runtime/requestContext.ts'
32
32
  import { resolvePageSnapshot } from '../server/runtime/resolvePageSnapshot.ts'
33
33
  import { serverSlot } from '../server/runtime/serverSlot.ts'
34
- import type { Socket } from '../server/sockets/types/Socket.ts'
35
34
  import { baseSlot } from '../shared/baseSlot.ts'
36
35
  import { buildRpcProxy } from '../shared/buildRpcProxy.ts'
37
36
  import { buildRpcRequest } from '../shared/buildRpcRequest.ts'
@@ -43,6 +42,7 @@ import { HEALTH_PATH } from '../shared/HEALTH_PATH.ts'
43
42
  import { pageSlot } from '../shared/pageSlot.ts'
44
43
  import { SOCKETS_PATH } from '../shared/SOCKETS_PATH.ts'
45
44
  import type { RemoteFunction } from '../shared/types/RemoteFunction.ts'
45
+ import type { Socket } from '../shared/types/Socket.ts'
46
46
  import { createTestSocketChannel } from './createTestSocketChannel.ts'
47
47
 
48
48
  /*
@@ -72,7 +72,7 @@ export type TestApp = {
72
72
  /* Rpc calls over HTTP, keyed by command name: `app.rpc.getProduct({ id })`. */
73
73
  rpc: RpcClient
74
74
  /* Sockets keyed by name: `app.sockets.ticker` is the Socket — iterate it for
75
- the live stream, `.tail(n)` to seed, `.publish(m)` to send. */
75
+ the live stream, `.tail(n)` to seed, `.broadcast(m)` to send. */
76
76
  sockets: SocketClient
77
77
  /* The /__abide/health payload, decoded. */
78
78
  health: () => Promise<unknown>
@@ -1,10 +1,10 @@
1
- import type { Socket } from '../server/sockets/types/Socket.ts'
2
- import type { SocketClientFrame } from '../server/sockets/types/SocketClientFrame.ts'
3
- import type { SocketServerFrame } from '../server/sockets/types/SocketServerFrame.ts'
4
1
  import { buildSocketOverChannel } from '../shared/buildSocketOverChannel.ts'
5
2
  import { createSocketSubRegistry } from '../shared/createSocketSubRegistry.ts'
6
3
  import { decodeRefJson } from '../shared/decodeRefJson.ts'
7
4
  import { encodeRefJson } from '../shared/encodeRefJson.ts'
5
+ import type { Socket } from '../shared/types/Socket.ts'
6
+ import type { SocketClientFrame } from '../shared/types/SocketClientFrame.ts'
7
+ import type { SocketServerFrame } from '../shared/types/SocketServerFrame.ts'
8
8
 
9
9
  /*
10
10
  Test-side substitute for the browser socketChannel: one ws to the booted
@@ -1,9 +1,8 @@
1
- /* The callee names the `.abide` compiler recognises as reactive declarations
2
- (`scope().state(...)`, `scope().linked(...)`, `scope().computed(...)`, and the destructuring
3
- `const {…} = props()`): the shared "is this a reactive binding" allowlist read by
4
- the desugarer, the nested-script scoper, and the type-checking shadow. How each
5
- lowers a serializable doc slot vs a `.value` cell is decided per-site; this is
6
- only the membership set, so a new primitive is a single edit here. */
1
+ /* The author-facing reactive primitive names (`state`, `state.linked`, `state.computed`,
2
+ and the destructuring `const {…} = props()`). The compiler no longer reads this —
3
+ recognition is import-resolved (see `resolveReactiveExport.ts`). It remains the canonical
4
+ inventory the docs surface-weighting tool enumerates (`scripts/surfaceWeight.ts`), so the
5
+ grammar's "primitives" bucket stays in sync with the language without hardcoding a list. */
7
6
  export const REACTIVE_CALLEES: ReadonlySet<string> = new Set([
8
7
  'state',
9
8
  'linked',
@@ -13,13 +13,16 @@ flip to its `$$` alias, set `alias` here in lockstep.
13
13
  */
14
14
  export const UI_RUNTIME_IMPORTS: { name: string; specifier: string; alias?: string }[] = [
15
15
  { name: 'snippet', specifier: 'shared/snippet', alias: '$$snippet' },
16
- /* `scope` is author-facing (`scope().state(...)`) but lowers to `$$scope` shadowably:
17
- `referenceFor` rewrites a bare `scope` read to `$$scope` only when not lexically
18
- shadowed, so the aliased import is the binding that emitted code resolves to. */
19
- { name: 'scope', specifier: 'ui/scope', alias: '$$scope' },
20
- { name: 'enterScope', specifier: 'ui/enterScope', alias: '$$enterScope' },
21
- { name: 'exitScope', specifier: 'ui/exitScope', alias: '$$exitScope' },
16
+ /* `scope` is the internal lowering host (no longer author-facing), imported from the
17
+ plumbing `ui/currentScope` path and lowered to the reserved `$$scope` alias:
18
+ `referenceFor` rewrites a bare `scope` read (compiler-generated) to `$$scope` only
19
+ when not lexically shadowed, so the aliased import is the binding emitted code
20
+ resolves to. `enterScope`/`exitScope` bracket an SSR render's scope (plumbing paths). */
21
+ { name: 'scope', specifier: 'ui/currentScope', alias: '$$scope' },
22
+ { name: 'enterScope', specifier: 'ui/enterRenderScope', alias: '$$enterScope' },
23
+ { name: 'exitScope', specifier: 'ui/exitRenderScope', alias: '$$exitScope' },
22
24
  { name: 'effect', specifier: 'ui/effect', alias: '$$effect' },
25
+ { name: 'watch', specifier: 'ui/watch', alias: '$$watch' },
23
26
  { name: 'mount', specifier: 'ui/dom/mount', alias: '$$mount' },
24
27
  { name: 'appendText', specifier: 'ui/dom/appendText', alias: '$$appendText' },
25
28
  { name: 'appendTextAt', specifier: 'ui/dom/appendTextAt', alias: '$$appendTextAt' },
@@ -31,11 +34,11 @@ export const UI_RUNTIME_IMPORTS: { name: string; specifier: string; alias?: stri
31
34
  { name: 'attr', specifier: 'ui/dom/attr', alias: '$$attr' },
32
35
  { name: 'on', specifier: 'ui/dom/on', alias: '$$on' },
33
36
  { name: 'attach', specifier: 'ui/dom/attach', alias: '$$attach' },
37
+ { name: 'bindSelectValue', specifier: 'ui/dom/bindSelectValue', alias: '$$bindSelectValue' },
34
38
  { name: 'each', specifier: 'ui/dom/each', alias: '$$each' },
35
39
  { name: 'eachAsync', specifier: 'ui/dom/eachAsync', alias: '$$eachAsync' },
36
40
  { name: 'when', specifier: 'ui/dom/when', alias: '$$when' },
37
41
  { name: 'awaitBlock', specifier: 'ui/dom/awaitBlock', alias: '$$awaitBlock' },
38
- { name: 'deferResume', specifier: 'ui/deferResume', alias: '$$deferResume' },
39
42
  { name: 'tryBlock', specifier: 'ui/dom/tryBlock', alias: '$$tryBlock' },
40
43
  { name: 'switchBlock', specifier: 'ui/dom/switchBlock', alias: '$$switchBlock' },
41
44
  { name: 'mountSlot', specifier: 'ui/dom/mountSlot', alias: '$$mountSlot' },
@@ -1,6 +1,6 @@
1
1
  import { relative } from 'node:path'
2
2
  import type { BunPlugin } from 'bun'
3
- import { fileName } from '../../shared/fileName.ts'
3
+ import { isLayoutFile } from '../../shared/isLayoutFile.ts'
4
4
  import { messageFromError } from '../../shared/messageFromError.ts'
5
5
  import { AbideCompileError } from './AbideCompileError.ts'
6
6
  import { compileModule } from './compileModule.ts'
@@ -42,7 +42,7 @@ export const abideUiPlugin: BunPlugin = {
42
42
  build.onLoad({ filter: /\.abide$/ }, async (args) => {
43
43
  const source = await Bun.file(args.path).text()
44
44
  const moduleId = relative(nearestProjectRoot(args.path, process.cwd()), args.path)
45
- const isLayout = fileName(args.path) === 'layout.abide'
45
+ const isLayout = isLayoutFile(args.path)
46
46
  /* Bun frames a plugin throw at `<file>:0` regardless of the real spot, so
47
47
  carry the component path + resolved line:col in the message — otherwise a
48
48
  control-flow / compile error reads as `:0` and (in deep imports) can look
@@ -29,6 +29,12 @@ export function analyzeComponent(source: string, scopeSeed?: string): AnalyzedCo
29
29
  const scriptBody = (scriptMatch?.[1] ?? '').trim()
30
30
  const template = source.replace(/^\s*<script[^>]*>[\s\S]*?<\/script>/, '').trim()
31
31
 
32
+ /* Parse the template first so the nested branch `<script>`s are in hand before the
33
+ leading script lowers: their raw code feeds the dead-import usage check, keeping a
34
+ reactive import (`state`) alive when only a nested branch uses it (its
35
+ `state.computed(...)` stays literal, unlike the desugared leading script). */
36
+ const { nodes } = parseTemplate(template)
37
+ const nestedScriptCode = collectNestedScriptCode(nodes)
32
38
  /* `lowerScript` parses the script ONCE and chains signal desugaring, reference
33
39
  renaming, and doc-access lowering over that single tree, then hoists top-level
34
40
  imports off the tree structurally — imports live at module scope, not inside the
@@ -40,17 +46,16 @@ export function analyzeComponent(source: string, scopeSeed?: string): AnalyzedCo
40
46
  stateNames,
41
47
  derivedNames,
42
48
  computedNames,
43
- } = lowerScript(scriptBody)
44
- /* The parser keeps each `<style>` as an in-place node (one inside an expression
45
- is text, never a node). `annotateScopes` mutates the tree — assigning each
46
- style its scope attribute and stamping covered elements — and returns the
47
- scoped CSS per block for the bundler. */
48
- const { nodes } = parseTemplate(template)
49
+ droppedReactiveImports,
50
+ } = lowerScript(scriptBody, nestedScriptCode)
51
+ /* `annotateScopes` mutates the tree — assigning each style its scope attribute and
52
+ stamping covered elements — and returns the scoped CSS per block for the bundler. */
49
53
  const styles = annotateScopes(nodes, [], scopeSeed, { count: 0 })
50
54
  return {
51
55
  script,
52
56
  ssrScript,
53
57
  imports,
58
+ droppedReactiveImports,
54
59
  stateNames,
55
60
  derivedNames,
56
61
  computedNames,
@@ -68,6 +73,23 @@ function childrenOf(node: TemplateNode): TemplateNode[] | undefined {
68
73
  return 'children' in node ? node.children : undefined
69
74
  }
70
75
 
76
+ /* The concatenated source of every nested `<script>` anywhere in the tree — the branch-local
77
+ reactive blocks. Used only to keep the leading script's reactive imports alive when a nested
78
+ branch is their sole consumer; the raw text is enough to spot the identifier reference. */
79
+ function collectNestedScriptCode(nodes: TemplateNode[]): string {
80
+ let code = ''
81
+ for (const node of nodes) {
82
+ if (node.kind === 'script') {
83
+ code += `\n${node.code}`
84
+ }
85
+ const children = childrenOf(node)
86
+ if (children !== undefined) {
87
+ code += collectNestedScriptCode(children)
88
+ }
89
+ }
90
+ return code
91
+ }
92
+
71
93
  /*
72
94
  Walks `nodes` in source order, scoping each `<style>` to its sibling subtree: a
73
95
  style among a sibling list covers every element in that list AND every descendant
@@ -121,6 +121,24 @@ ${options.moduleId === undefined ? '' : `component.__abideId = ${JSON.stringify(
121
121
  /* setParentNodes */ true,
122
122
  )
123
123
  const referenced = collectIdentifiers(bodySource)
124
+ /* Independent backstop on the reactive-import drop (`deadReactiveImport`): it decides an
125
+ import is dead from the leading script's own lowered body plus the nested scripts' raw
126
+ text. This re-checks the SAME question against the FINAL generated output — so a drop that
127
+ stranded a live reference (e.g. a nested branch's literal `state.computed`) surfaces as a
128
+ located compile error, not a runtime `ReferenceError: state is not defined`. Mirrors
129
+ `assertRuntimeHelpersBound`, which guards the parallel runtime-helper drop. Uses VALUE
130
+ references (not every identifier): the SSR return object `{ html, state, awaits, resume }`
131
+ names a property `state`, which is not a use of the reactive binding. */
132
+ if (analyzed.droppedReactiveImports.size > 0) {
133
+ const valueReferences = collectValueReferences(bodySource)
134
+ for (const name of analyzed.droppedReactiveImports) {
135
+ if (valueReferences.has(name)) {
136
+ throw new Error(
137
+ `[abide] component module generation dropped the reactive import \`${name}\` as unused, but the generated output still references it — the dead-import filter undercounted. Please report this with the component source.`,
138
+ )
139
+ }
140
+ }
141
+ }
124
142
  const keptImports = UI_RUNTIME_IMPORTS.filter((entry) =>
125
143
  referenced.has(entry.alias ?? entry.name),
126
144
  )
@@ -168,24 +186,90 @@ function indent(body: string): string {
168
186
  .split('\n')
169
187
  .map((line) => {
170
188
  const indented = insideTemplateLiteral || line === '' ? line : ` ${line}`
171
- if (unescapedBacktickCount(line) % 2 === 1) {
172
- insideTemplateLiteral = !insideTemplateLiteral
173
- }
189
+ insideTemplateLiteral = templateLiteralStateAfter(line, insideTemplateLiteral)
174
190
  return indented
175
191
  })
176
192
  .join('\n')
177
193
  }
178
194
 
179
- /* Counts backticks not preceded by a backslash the template-literal delimiters
180
- on a line, ignoring escaped `\`` inside one. */
181
- function unescapedBacktickCount(line: string): number {
182
- let count = 0
183
- for (let index = 0; index < line.length; index += 1) {
184
- if (line[index] === '`' && line[index - 1] !== '\\') {
185
- count += 1
195
+ /* Whether the line ENDS inside a multi-line template literal, given whether it
196
+ STARTED inside one. A plain backtick-parity count is wrong: a backtick inside a
197
+ double-quoted string literal (the JSON.stringify'd skeleton markup, e.g.
198
+ `$$skeleton(host, "<p>tick \`here</p>")`) is not a template delimiter, but counting
199
+ it would flip the in-literal state and mis-indent a later real template literal's
200
+ significant whitespace. So skip `'…'`/`"…"` strings when outside a template, and treat
201
+ a template's `${…}` interpolation as code (its own strings/backticks don't toggle the
202
+ outer literal). Escapes are honoured throughout. */
203
+ function templateLiteralStateAfter(line: string, startInside: boolean): boolean {
204
+ let inside = startInside
205
+ let index = 0
206
+ while (index < line.length) {
207
+ const char = line[index]
208
+ if (char === '\\') {
209
+ index += 2
210
+ continue
186
211
  }
212
+ if (inside) {
213
+ if (char === '`') {
214
+ inside = false
215
+ } else if (char === '$' && line[index + 1] === '{') {
216
+ index = skipInterpolation(line, index + 2)
217
+ continue
218
+ }
219
+ index += 1
220
+ continue
221
+ }
222
+ if (char === '`') {
223
+ inside = true
224
+ } else if (char === '"' || char === "'") {
225
+ index = skipQuoted(line, index + 1, char)
226
+ continue
227
+ }
228
+ index += 1
187
229
  }
188
- return count
230
+ return inside
231
+ }
232
+
233
+ /* Advances past a `'…'`/`"…"` string opened at `index` (the char after the quote),
234
+ returning the index after the closing quote (or end of line). Emitted JS string
235
+ literals never carry a raw newline, so they always close on their own line. */
236
+ function skipQuoted(line: string, index: number, quote: string): number {
237
+ while (index < line.length) {
238
+ if (line[index] === '\\') {
239
+ index += 2
240
+ continue
241
+ }
242
+ if (line[index] === quote) {
243
+ return index + 1
244
+ }
245
+ index += 1
246
+ }
247
+ return index
248
+ }
249
+
250
+ /* Advances past a template `${…}` interpolation opened at `index` (the char after `{`),
251
+ returning the index after the matching `}` (or end of line). Balances nested braces and
252
+ skips nested strings/templates so their delimiters don't leak into the outer scan. */
253
+ function skipInterpolation(line: string, index: number): number {
254
+ let depth = 1
255
+ while (index < line.length && depth > 0) {
256
+ const char = line[index]
257
+ if (char === '\\') {
258
+ index += 2
259
+ continue
260
+ }
261
+ if (char === '"' || char === "'" || char === '`') {
262
+ index = skipQuoted(line, index + 1, char)
263
+ continue
264
+ }
265
+ if (char === '{') {
266
+ depth += 1
267
+ } else if (char === '}') {
268
+ depth -= 1
269
+ }
270
+ index += 1
271
+ }
272
+ return index
189
273
  }
190
274
 
191
275
  /* The identifier names a generated module references — every Identifier node, walked
@@ -209,3 +293,45 @@ function collectIdentifiers(source: ts.SourceFile): Set<string> {
209
293
  visit(source)
210
294
  return names
211
295
  }
296
+
297
+ /* The identifiers a module references AS VALUES — the subset of `collectIdentifiers` that
298
+ excludes name-only positions: a property name (`obj.state`), an object-literal key
299
+ (`{ state: v }`), and a declaration/import binding name (the definition, not a use). Used by
300
+ the reactive-import backstop, where a bare identifier presence over-counts: the synthesized
301
+ SSR return `{ html, state, awaits, resume }` names a property `state` that is not a use of the
302
+ reactive binding, so the raw-identifier set would false-positive. */
303
+ function collectValueReferences(source: ts.SourceFile): Set<string> {
304
+ const names = new Set<string>()
305
+ const visit = (node: ts.Node): void => {
306
+ if (ts.isIdentifier(node) && isValuePosition(node)) {
307
+ names.add(node.text)
308
+ }
309
+ node.forEachChild(visit)
310
+ }
311
+ visit(source)
312
+ return names
313
+ }
314
+
315
+ /* True when `id` stands in a value position — i.e. a reference to a binding, not a name that
316
+ merely labels a property or declares a binding. */
317
+ function isValuePosition(id: ts.Identifier): boolean {
318
+ const parent = id.parent
319
+ if (ts.isPropertyAccessExpression(parent) && parent.name === id) {
320
+ return false
321
+ }
322
+ if (ts.isPropertyAssignment(parent) && parent.name === id) {
323
+ return false
324
+ }
325
+ if (
326
+ (ts.isVariableDeclaration(parent) ||
327
+ ts.isParameter(parent) ||
328
+ ts.isFunctionDeclaration(parent) ||
329
+ ts.isBindingElement(parent) ||
330
+ ts.isImportSpecifier(parent) ||
331
+ ts.isImportClause(parent)) &&
332
+ parent.name === id
333
+ ) {
334
+ return false
335
+ }
336
+ return true
337
+ }