@abide/abide 0.43.1 → 0.44.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 (206) hide show
  1. package/AGENTS.md +293 -219
  2. package/CHANGELOG.md +63 -0
  3. package/README.md +191 -67
  4. package/package.json +2 -2
  5. package/src/abideLsp.ts +8 -4
  6. package/src/abideResolverPlugin.ts +17 -2
  7. package/src/build.ts +9 -0
  8. package/src/buildDisconnected.ts +2 -0
  9. package/src/devEntry.ts +44 -9
  10. package/src/lib/bundle/probeAbideServer.ts +1 -1
  11. package/src/lib/cli/parseArgvForRpc.ts +25 -26
  12. package/src/lib/cli/runCli.ts +9 -24
  13. package/src/lib/cli/tokenizeArgvFlags.ts +84 -0
  14. package/src/lib/cli/types/CliManifest.ts +6 -4
  15. package/src/lib/mcp/annotationsForMethod.ts +1 -1
  16. package/src/lib/mcp/toolResultFromResponse.ts +1 -1
  17. package/src/lib/mcp/types/JsonRpcRequest.ts +3 -3
  18. package/src/lib/server/AppModule.ts +1 -1
  19. package/src/lib/server/cli/handleCliDownload.ts +7 -5
  20. package/src/lib/server/cli/installScript.ts +3 -3
  21. package/src/lib/server/prompts/promptRegistry.ts +1 -1
  22. package/src/lib/server/prompts/types/Prompt.ts +2 -1
  23. package/src/lib/server/rpc/defineRpc.ts +9 -1
  24. package/src/lib/server/rpc/dispatchRpcInProcess.ts +2 -2
  25. package/src/lib/server/rpc/types/RpcHelper.ts +1 -1
  26. package/src/lib/server/runtime/createPublicAssetServer.ts +2 -2
  27. package/src/lib/server/runtime/createRouteDispatcher.ts +1 -1
  28. package/src/lib/server/runtime/createServer.ts +50 -12
  29. package/src/lib/server/runtime/createUiPageRenderer.ts +7 -11
  30. package/src/lib/server/runtime/crossOriginForbidden.ts +2 -2
  31. package/src/lib/server/runtime/crossOriginGate.ts +1 -1
  32. package/src/lib/server/runtime/devHotModuleResponse.ts +3 -3
  33. package/src/lib/server/runtime/disableIdleTimeoutForStream.ts +1 -1
  34. package/src/lib/server/runtime/gzipResponse.ts +14 -16
  35. package/src/lib/server/runtime/logExposedSurfaces.ts +3 -4
  36. package/src/lib/server/runtime/runWithRequestScope.ts +4 -1
  37. package/src/lib/server/runtime/serializeCacheSnapshot.ts +3 -8
  38. package/src/lib/server/runtime/snapshotEntryFromCache.ts +10 -7
  39. package/src/lib/server/runtime/streamFromIterator.ts +2 -2
  40. package/src/lib/server/runtime/types/InspectorRpc.ts +2 -2
  41. package/src/lib/server/runtime/types/RequestStore.ts +16 -0
  42. package/src/lib/server/runtime/withResponseDefaults.ts +8 -3
  43. package/src/lib/server/socket.ts +1 -1
  44. package/src/lib/server/sockets/createSocketDispatcher.ts +2 -2
  45. package/src/lib/server/sockets/defineSocket.ts +5 -1
  46. package/src/lib/server/sockets/socketRegistry.ts +2 -2
  47. package/src/lib/server/sockets/types/SocketClientFrame.ts +3 -3
  48. package/src/lib/shared/CACHE_WRAPPED.ts +3 -2
  49. package/src/lib/shared/REMOTE_FUNCTION.ts +1 -1
  50. package/src/lib/shared/RPC_ARGS_TYPE.ts +7 -0
  51. package/src/lib/shared/STREAMING_CONTENT_TYPES.ts +3 -2
  52. package/src/lib/shared/activeCacheStore.ts +5 -14
  53. package/src/lib/shared/activePage.ts +5 -19
  54. package/src/lib/shared/augmentModule.ts +18 -0
  55. package/src/lib/shared/basePath.ts +6 -5
  56. package/src/lib/shared/baseResolver.ts +10 -0
  57. package/src/lib/shared/baseSlot.ts +6 -12
  58. package/src/lib/shared/bodyValueForKind.ts +35 -0
  59. package/src/lib/shared/cache.ts +22 -2
  60. package/src/lib/shared/cacheEntryFromSnapshot.ts +23 -18
  61. package/src/lib/shared/cacheStoreResolver.ts +12 -0
  62. package/src/lib/shared/cacheStoreSlot.ts +5 -13
  63. package/src/lib/shared/changeAffectsClient.ts +35 -0
  64. package/src/lib/shared/createPushIterator.ts +1 -1
  65. package/src/lib/shared/createResolverSlot.ts +37 -0
  66. package/src/lib/shared/debugGate.ts +90 -0
  67. package/src/lib/shared/decodeResponse.ts +11 -8
  68. package/src/lib/shared/emitLogRecord.ts +18 -4
  69. package/src/lib/shared/globalCacheStoreResolver.ts +12 -0
  70. package/src/lib/shared/globalCacheStoreSlot.ts +6 -11
  71. package/src/lib/shared/hasReplayableRequest.ts +17 -0
  72. package/src/lib/shared/html.ts +7 -26
  73. package/src/lib/shared/isDebugEnabled.ts +3 -10
  74. package/src/lib/shared/isDebugNegated.ts +4 -6
  75. package/src/lib/shared/manifestModule.ts +2 -2
  76. package/src/lib/shared/online.ts +1 -1
  77. package/src/lib/shared/outboxProbeSlot.ts +1 -1
  78. package/src/lib/shared/pageResolver.ts +16 -0
  79. package/src/lib/shared/pageSlot.ts +5 -14
  80. package/src/lib/shared/pageUrlForFile.ts +1 -1
  81. package/src/lib/shared/requestScopeResolver.ts +12 -0
  82. package/src/lib/shared/requestScopeSlot.ts +7 -12
  83. package/src/lib/shared/responseBodyKind.ts +35 -0
  84. package/src/lib/shared/routeParamsShape.ts +20 -0
  85. package/src/lib/shared/setBaseResolver.ts +2 -4
  86. package/src/lib/shared/setCacheStoreResolver.ts +3 -5
  87. package/src/lib/shared/setGlobalCacheStoreResolver.ts +3 -5
  88. package/src/lib/shared/setPageResolver.ts +2 -5
  89. package/src/lib/shared/setRequestScopeResolver.ts +3 -5
  90. package/src/lib/shared/snapshotShippable.ts +22 -0
  91. package/src/lib/shared/types/CacheEntry.ts +1 -1
  92. package/src/lib/shared/types/CacheInvalidation.ts +3 -3
  93. package/src/lib/shared/types/ClientFlags.ts +5 -3
  94. package/src/lib/shared/types/DebugGate.ts +10 -0
  95. package/src/lib/shared/types/LastConnection.ts +1 -1
  96. package/src/lib/shared/types/ResolverSlot.ts +10 -0
  97. package/src/lib/shared/url.ts +4 -1
  98. package/src/lib/shared/withBase.ts +1 -2
  99. package/src/lib/shared/writeDts.ts +10 -3
  100. package/src/lib/shared/writePublicAssetsDts.ts +5 -9
  101. package/src/lib/shared/writeRoutesDts.ts +14 -46
  102. package/src/lib/shared/writeRpcDts.ts +13 -12
  103. package/src/lib/shared/writeTestRpcDts.ts +11 -11
  104. package/src/lib/shared/writeTestSocketsDts.ts +9 -9
  105. package/src/lib/test/createTestSocketChannel.ts +3 -3
  106. package/src/lib/ui/COMPONENT_WRAPPER_PREFIX.ts +1 -1
  107. package/src/lib/ui/compile/ABIDE_SEMANTIC_TOKENS_LEGEND.ts +40 -6
  108. package/src/lib/ui/compile/REACTIVE_CALLEES.ts +1 -1
  109. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +49 -37
  110. package/src/lib/ui/compile/abideUiPlugin.ts +10 -8
  111. package/src/lib/ui/compile/assertRuntimeHelpersBound.ts +27 -13
  112. package/src/lib/ui/compile/awaitPlan.ts +14 -2
  113. package/src/lib/ui/compile/catchBinding.ts +8 -0
  114. package/src/lib/ui/compile/classStyleMergePlan.ts +108 -0
  115. package/src/lib/ui/compile/collectAbideDiagnostics.ts +11 -0
  116. package/src/lib/ui/compile/compileComponent.ts +4 -4
  117. package/src/lib/ui/compile/compileModule.ts +56 -21
  118. package/src/lib/ui/compile/compileSSR.ts +12 -10
  119. package/src/lib/ui/compile/compileShadow.ts +163 -12
  120. package/src/lib/ui/compile/composeProps.ts +3 -3
  121. package/src/lib/ui/compile/createShadowLanguageService.ts +58 -4
  122. package/src/lib/ui/compile/createShadowProgram.ts +2 -1
  123. package/src/lib/ui/compile/createShadowScope.ts +42 -0
  124. package/src/lib/ui/compile/decodeHtmlEntities.ts +4 -0
  125. package/src/lib/ui/compile/desugarSignals.ts +12 -7
  126. package/src/lib/ui/compile/eachPlan.ts +61 -0
  127. package/src/lib/ui/compile/elementPlan.ts +55 -0
  128. package/src/lib/ui/compile/encodeSemanticTokens.ts +24 -1
  129. package/src/lib/ui/compile/generateBuild.ts +156 -127
  130. package/src/lib/ui/compile/generateSSR.ts +112 -73
  131. package/src/lib/ui/compile/ifPlan.ts +7 -1
  132. package/src/lib/ui/compile/interpolatedTemplateLiteral.ts +25 -0
  133. package/src/lib/ui/compile/isPlainIdentifier.ts +7 -0
  134. package/src/lib/ui/compile/isTextLeaf.ts +8 -7
  135. package/src/lib/ui/compile/lowerContext.ts +36 -46
  136. package/src/lib/ui/compile/lowerDocAccess.ts +2 -2
  137. package/src/lib/ui/compile/lowerScript.ts +1 -1
  138. package/src/lib/ui/compile/markupTokens.ts +313 -0
  139. package/src/lib/ui/compile/pagePropsType.ts +23 -0
  140. package/src/lib/ui/compile/parseTemplate.ts +135 -62
  141. package/src/lib/ui/compile/reactiveBinding.ts +47 -0
  142. package/src/lib/ui/compile/renameSignalRefs.ts +44 -10
  143. package/src/lib/ui/compile/skeletonable.ts +5 -5
  144. package/src/lib/ui/compile/snippetPlan.ts +28 -0
  145. package/src/lib/ui/compile/spreadExcludedNames.ts +4 -4
  146. package/src/lib/ui/compile/structuralBlockTokens.ts +3 -4
  147. package/src/lib/ui/compile/switchPlan.ts +7 -1
  148. package/src/lib/ui/compile/tryPlan.ts +8 -1
  149. package/src/lib/ui/compile/types/AnalyzedComponent.ts +2 -2
  150. package/src/lib/ui/compile/types/Binding.ts +24 -0
  151. package/src/lib/ui/compile/types/CompiledShadow.ts +13 -1
  152. package/src/lib/ui/compile/types/ElementPlan.ts +43 -0
  153. package/src/lib/ui/compile/types/ShadowKind.ts +9 -0
  154. package/src/lib/ui/compile/types/ShadowScope.ts +19 -0
  155. package/src/lib/ui/compile/types/TemplateAttr.ts +7 -0
  156. package/src/lib/ui/compile/types/TemplateNode.ts +9 -9
  157. package/src/lib/ui/compile/withBindings.ts +45 -0
  158. package/src/lib/ui/createScope.ts +2 -2
  159. package/src/lib/ui/dom/MATHML_NAMESPACE.ts +2 -2
  160. package/src/lib/ui/dom/SVG_NAMESPACE.ts +3 -2
  161. package/src/lib/ui/dom/appendText.ts +1 -1
  162. package/src/lib/ui/dom/applyResolved.ts +1 -1
  163. package/src/lib/ui/dom/buildDetachedRange.ts +4 -3
  164. package/src/lib/ui/dom/cloneStatic.ts +3 -2
  165. package/src/lib/ui/dom/each.ts +26 -7
  166. package/src/lib/ui/dom/foreignWrapperTag.ts +2 -2
  167. package/src/lib/ui/dom/mountChild.ts +8 -1
  168. package/src/lib/ui/dom/mountSwappableRange.ts +79 -0
  169. package/src/lib/ui/dom/readCall.ts +6 -4
  170. package/src/lib/ui/dom/scopeLabel.ts +3 -3
  171. package/src/lib/ui/dom/skeleton.ts +10 -1
  172. package/src/lib/ui/dom/switchBlock.ts +17 -63
  173. package/src/lib/ui/dom/types/EachRow.ts +4 -0
  174. package/src/lib/ui/dom/types/SkeletonHoles.ts +4 -2
  175. package/src/lib/ui/dom/when.ts +11 -64
  176. package/src/lib/ui/history.ts +1 -2
  177. package/src/lib/ui/html.ts +28 -0
  178. package/src/lib/ui/installHotBridge.ts +0 -2
  179. package/src/lib/ui/renderChain.ts +16 -3
  180. package/src/lib/ui/router.ts +24 -3
  181. package/src/lib/ui/rpcOutbox/createOutboxQueue.ts +1 -1
  182. package/src/lib/ui/runtime/CHILD_PRESENT.ts +8 -0
  183. package/src/lib/ui/runtime/OUTLET_MARKER.ts +2 -2
  184. package/src/lib/ui/runtime/RANGE_MARKER.ts +1 -1
  185. package/src/lib/ui/runtime/REACTIVE_CONTEXT.ts +9 -3
  186. package/src/lib/ui/runtime/captureModelDoc.ts +1 -1
  187. package/src/lib/ui/runtime/createDoc.ts +107 -13
  188. package/src/lib/ui/runtime/createEffectNode.ts +5 -5
  189. package/src/lib/ui/runtime/flushEffects.ts +22 -9
  190. package/src/lib/ui/runtime/historyEntries.ts +1 -1
  191. package/src/lib/ui/runtime/track.ts +4 -2
  192. package/src/lib/ui/runtime/trigger.ts +34 -20
  193. package/src/lib/ui/runtime/types/Route.ts +2 -2
  194. package/src/lib/ui/runtime/types/UiComponent.ts +3 -2
  195. package/src/lib/ui/runtime/types/UiProps.ts +9 -12
  196. package/src/lib/ui/runtime/walkPath.ts +15 -3
  197. package/src/lib/ui/startClient.ts +41 -14
  198. package/src/lib/ui/state.ts +3 -3
  199. package/src/lib/ui/types/Scope.ts +3 -3
  200. package/src/serverBuildPlugins.ts +11 -5
  201. package/src/zodCjsPlugin.ts +51 -0
  202. package/template/.zed/settings.json +7 -1
  203. package/template/package.json +3 -0
  204. package/template/src/ui/pages/layout.abide +3 -5
  205. package/src/lib/shared/matchesDebugPattern.ts +0 -16
  206. package/src/lib/shared/parseDebugPatterns.ts +0 -21
@@ -2,12 +2,24 @@ import ts from 'typescript'
2
2
  import { ABIDE_PACKAGE_NAME } from '../../shared/ABIDE_PACKAGE_NAME.ts'
3
3
  import { parseTemplate } from './parseTemplate.ts'
4
4
  import { REACTIVE_CALLEES } from './REACTIVE_CALLEES.ts'
5
- import type { CompiledShadow, ShadowMapping } from './types/CompiledShadow.ts'
5
+ import type { CompiledShadow, ShadowDiagnostic, ShadowMapping } from './types/CompiledShadow.ts'
6
6
  import type { TemplateNode } from './types/TemplateNode.ts'
7
7
 
8
+ /*
9
+ Runtime helpers published as package subpaths solely so generated component code
10
+ can import them; an author must reach them through their namespaced surface
11
+ instead. The module resolver can't tell codegen from user source (same specifier),
12
+ so the ban can only live in the check — map each forbidden specifier to the
13
+ guidance shown when an author imports it.
14
+ */
15
+ const INTERNAL_ONLY_IMPORTS: Record<string, string> = {
16
+ [`${ABIDE_PACKAGE_NAME}/ui/effect`]:
17
+ '`effect` is compiler-internal — author it as `scope().effect(...)`',
18
+ }
19
+
8
20
  /*
9
21
  Framework callables the `.abide` loader injects into a component's scope. `effect`,
10
- `html`, `snippet`, and `scope` keep their real published types via imports so author
22
+ `snippet`, and `scope` keep their real published types via imports so author
11
23
  calls type-check — `scope()` is the authored reactive surface (`scope().state(...)` /
12
24
  `.computed(...)` / `.undo()` …), so it must resolve like any import. `state`/`linked`/
13
25
  `computed` are ALSO declared ambiently as a fallback for the rare bare/nested use the
@@ -18,14 +30,13 @@ argument (default `Record<string, any>`) so each binding inherits its prop type
18
30
  `= default` narrows.
19
31
  */
20
32
  const SHADOW_PREAMBLE = `import { effect } from '${ABIDE_PACKAGE_NAME}/ui/effect'
21
- import { html } from '${ABIDE_PACKAGE_NAME}/shared/html'
22
33
  import { snippet } from '${ABIDE_PACKAGE_NAME}/shared/snippet'
23
34
  import { scope } from '${ABIDE_PACKAGE_NAME}/ui/scope'
24
35
  declare function state<T>(initial?: T, transform?: (next: T, previous: T) => T): { value: T }
25
36
  declare function linked<T>(seed: () => T, transform?: (next: T, previous: T) => T): { value: T }
26
37
  declare function computed<T>(compute: () => T): { readonly value: T }
27
- declare function props<T = Record<string, any>>(): T
28
- void [effect, html, snippet, scope]
38
+ declare const children: (() => void) | undefined
39
+ void [effect, snippet, scope]
29
40
  `
30
41
 
31
42
  /*
@@ -43,7 +54,7 @@ The script's signal surface is rewritten to value types:
43
54
  Everything else (functions, plain consts, imports) is emitted verbatim, so
44
55
  expressions inside it (e.g. a computed's compute body) are checked and mapped too.
45
56
  */
46
- export function compileShadow(source: string): CompiledShadow {
57
+ export function compileShadow(source: string, propsType = 'Record<string, any>'): CompiledShadow {
47
58
  const builder = createBuilder()
48
59
  const leadingScript = source.match(/^\s*<script[^>]*>([\s\S]*?)<\/script>/)
49
60
  const scriptBody = leadingScript?.[1] ?? ''
@@ -51,8 +62,14 @@ export function compileShadow(source: string): CompiledShadow {
51
62
  const scriptStart = leadingScript ? source.indexOf('>', leadingScript.index) + 1 : 0
52
63
  const templateStart = leadingScript ? (leadingScript.index ?? 0) + leadingScript[0].length : 0
53
64
 
54
- const { imports, types, scope, propsShapes } = analyzeScript(scriptBody, scriptStart)
65
+ const { imports, types, scope, propsShapes, diagnostics } = analyzeScript(
66
+ scriptBody,
67
+ scriptStart,
68
+ )
55
69
  builder.raw(SHADOW_PREAMBLE)
70
+ /* `props()` defaults to the file's type — a page/layout's route param shape, else
71
+ `Record<string, any>` — so `const { id } = props()` infers from the route. */
72
+ builder.raw(`declare function props<T = ${propsType}>(): T\n`)
56
73
  for (const line of imports) {
57
74
  builder.flush(line)
58
75
  }
@@ -80,9 +97,13 @@ export function compileShadow(source: string): CompiledShadow {
80
97
  for (const line of scope) {
81
98
  builder.flush(line)
82
99
  }
83
- emitNodes(parseTemplate(source.slice(templateStart), templateStart).nodes, builder)
100
+ const templateNodes = parseTemplate(source.slice(templateStart), templateStart).nodes
101
+ /* Nested `<script>` blocks inline into the synchronous `build()` too, so a top-level
102
+ await in one is the same build-breaker — flag it, mapped via the node's body offset. */
103
+ collectNestedScriptAwaitDiagnostics(templateNodes, diagnostics)
104
+ emitNodes(templateNodes, builder)
84
105
  builder.raw('}\n')
85
- return builder.result()
106
+ return { ...builder.result(), diagnostics }
86
107
  }
87
108
 
88
109
  /* The shadow text builder: `raw` appends synthesised scaffolding (no mapping),
@@ -162,6 +183,106 @@ type ScriptAnalysis = {
162
183
  types: ScopeLine[]
163
184
  scope: ScopeLine[]
164
185
  propsShapes: string[]
186
+ diagnostics: ShadowDiagnostic[]
187
+ }
188
+
189
+ /* Pushes a diagnostic for every author binding whose name starts with the reserved `$$`
190
+ prefix — variable/function/class declarations, parameters, and destructuring leaves.
191
+ Read structurally off the parsed script; the message points at the offending name. */
192
+ function collectReservedNameDiagnostics(
193
+ file: ts.SourceFile,
194
+ scriptStart: number,
195
+ diagnostics: ShadowDiagnostic[],
196
+ ): void {
197
+ const flag = (name: ts.Node | undefined): void => {
198
+ if (name !== undefined && ts.isIdentifier(name) && name.text.startsWith('$$')) {
199
+ diagnostics.push({
200
+ start: scriptStart + name.getStart(file),
201
+ length: name.getEnd() - name.getStart(file),
202
+ message: `\`${name.text}\` is reserved — the \`$$\` prefix is the compiler's injected runtime namespace; rename this binding.`,
203
+ })
204
+ }
205
+ }
206
+ const visit = (node: ts.Node): void => {
207
+ if (
208
+ ts.isVariableDeclaration(node) ||
209
+ ts.isFunctionDeclaration(node) ||
210
+ ts.isClassDeclaration(node) ||
211
+ ts.isParameter(node) ||
212
+ ts.isBindingElement(node)
213
+ ) {
214
+ flag(node.name)
215
+ }
216
+ ts.forEachChild(node, visit)
217
+ }
218
+ visit(file)
219
+ }
220
+
221
+ /* Pushes a diagnostic for every `await` sitting at the script's top level — outside any
222
+ nested function. The generated `build()` runs the leading script synchronously, so a
223
+ top-level await transpiles to `await` in a non-async function and breaks the bundle; the
224
+ shadow's render fn is async, so `tsc` alone never flags it (a check/runtime parity gap).
225
+ Stops descending at function boundaries — their own async-ness is the author's concern —
226
+ and catches both `await expr` and `for await (… of …)`, flagging the `await` keyword. */
227
+ function collectTopLevelAwaitDiagnostics(
228
+ file: ts.SourceFile,
229
+ scriptStart: number,
230
+ diagnostics: ShadowDiagnostic[],
231
+ ): void {
232
+ const message =
233
+ 'top-level `await` is not allowed in a `<script>` — the component build runs synchronously. Use `{#await expr}…{:then value}…{/await}` markup for blocking data, or `await` inside an async event handler.'
234
+ const visit = (node: ts.Node): void => {
235
+ /* A nested function introduces its own (possibly async) scope; its awaits are legal. */
236
+ if (
237
+ ts.isFunctionDeclaration(node) ||
238
+ ts.isFunctionExpression(node) ||
239
+ ts.isArrowFunction(node) ||
240
+ ts.isMethodDeclaration(node) ||
241
+ ts.isGetAccessorDeclaration(node) ||
242
+ ts.isSetAccessorDeclaration(node) ||
243
+ ts.isConstructorDeclaration(node)
244
+ ) {
245
+ return
246
+ }
247
+ /* The `await` keyword token: the AwaitExpression's first child, or a `for await`'s
248
+ await modifier. */
249
+ const keyword = ts.isAwaitExpression(node)
250
+ ? node.getChildAt(0, file)
251
+ : ts.isForOfStatement(node)
252
+ ? node.awaitModifier
253
+ : undefined
254
+ if (keyword !== undefined) {
255
+ diagnostics.push({
256
+ start: scriptStart + keyword.getStart(file),
257
+ length: keyword.getEnd() - keyword.getStart(file),
258
+ message,
259
+ })
260
+ }
261
+ ts.forEachChild(node, visit)
262
+ }
263
+ visit(file)
264
+ }
265
+
266
+ /* Recursively visits every nested `<script>` in the template and flags a top-level await in
267
+ its body — the same trap as the leading script (it inlines into the sync `build()`), mapped
268
+ through the node's body offset (`loc`). Walks `children` on every node kind that carries
269
+ them, so a script buried in an `{#if}`/`{#for}`/`{#await}` branch is still reached. */
270
+ function collectNestedScriptAwaitDiagnostics(
271
+ nodes: TemplateNode[],
272
+ diagnostics: ShadowDiagnostic[],
273
+ ): void {
274
+ for (const node of nodes) {
275
+ if (node === undefined) {
276
+ continue
277
+ }
278
+ if (node.kind === 'script' && node.loc !== undefined) {
279
+ const file = ts.createSourceFile('nested.ts', node.code, ts.ScriptTarget.Latest, true)
280
+ collectTopLevelAwaitDiagnostics(file, node.loc, diagnostics)
281
+ }
282
+ if ('children' in node) {
283
+ collectNestedScriptAwaitDiagnostics(node.children, diagnostics)
284
+ }
285
+ }
165
286
  }
166
287
 
167
288
  /* Walks the leading `<script>` and produces the shadow's module imports, the
@@ -173,10 +294,18 @@ function analyzeScript(scriptBody: string, scriptStart: number): ScriptAnalysis
173
294
  const types: ScopeLine[] = []
174
295
  const scope: ScopeLine[] = []
175
296
  const propsShapes: string[] = []
297
+ const diagnostics: ShadowDiagnostic[] = []
176
298
  if (scriptBody.trim() === '') {
177
- return { imports, types, scope, propsShapes }
299
+ return { imports, types, scope, propsShapes, diagnostics }
178
300
  }
179
301
  const file = ts.createSourceFile('script.ts', scriptBody, ts.ScriptTarget.Latest, true)
302
+ /* The `$$` prefix is reserved for the compiler's injected runtime (`$$each`, `$$model`,
303
+ `$$scope`, …), so an author binding may not start with it — that's the contract that
304
+ lets a user freely name a variable after any helper. Flag every such declaration. */
305
+ collectReservedNameDiagnostics(file, scriptStart, diagnostics)
306
+ /* The leading script runs in the synchronous `build()`, so a top-level await breaks the
307
+ bundle — catch it here with a legible message instead of an opaque transpile error. */
308
+ collectTopLevelAwaitDiagnostics(file, scriptStart, diagnostics)
180
309
  /* A verbatim span: original text + the segment mapping it back, relative to the
181
310
  line start (the caller rebases shadowStart onto the running shadow length). */
182
311
  const span = (node: ts.Node, prefixLength: number): ScopeLine['segments'][number] => ({
@@ -188,6 +317,20 @@ function analyzeScript(scriptBody: string, scriptStart: number): ScriptAnalysis
188
317
 
189
318
  for (const statement of file.statements) {
190
319
  if (ts.isImportDeclaration(statement)) {
320
+ const specifier = ts.isStringLiteral(statement.moduleSpecifier)
321
+ ? statement.moduleSpecifier.text
322
+ : undefined
323
+ const banned = specifier !== undefined ? INTERNAL_ONLY_IMPORTS[specifier] : undefined
324
+ if (banned !== undefined) {
325
+ /* Flag and drop it — leaving it out keeps `effect` resolving via the
326
+ preamble (no duplicate-identifier noise on top of our message). */
327
+ diagnostics.push({
328
+ start: scriptStart + statement.getStart(file),
329
+ length: statement.getEnd() - statement.getStart(file),
330
+ message: banned,
331
+ })
332
+ continue
333
+ }
191
334
  /* Emit verbatim with a span so hover/go-to resolve on the imported names. */
192
335
  imports.push({ text: verbatim(statement), segments: [span(statement, 0)] })
193
336
  continue
@@ -207,7 +350,7 @@ function analyzeScript(scriptBody: string, scriptStart: number): ScriptAnalysis
207
350
  scope.push(scopeLineFor(declaration, propsShapes, verbatim, span))
208
351
  }
209
352
  }
210
- return { imports, types, scope, propsShapes }
353
+ return { imports, types, scope, propsShapes, diagnostics }
211
354
  }
212
355
 
213
356
  /* Value-projects a nested control-flow `<script>` body the way `analyzeScript`
@@ -363,7 +506,15 @@ function emitNode(node: TemplateNode, builder: Builder): void {
363
506
  return
364
507
  case 'element':
365
508
  for (const attr of node.attrs) {
366
- if (attr.kind !== 'static') {
509
+ /* An interpolated value checks each `{expr}` part on its own offset; every
510
+ other dynamic attribute checks its single `code`. */
511
+ if (attr.kind === 'interpolated') {
512
+ for (const part of attr.parts) {
513
+ if (part.kind === 'expression') {
514
+ builder.stmt(part.code, part.loc)
515
+ }
516
+ }
517
+ } else if (attr.kind !== 'static') {
367
518
  builder.stmt(attr.code, attr.loc)
368
519
  }
369
520
  }
@@ -9,7 +9,7 @@ The props-bag source expression a child mount/render receives, shared by the bui
9
9
  and SSR back-ends so their last-wins layering stays byte-identical — the invariant
10
10
  SSR/client prop congruence rests on. No spread → the plain object literal of named
11
11
  value thunks (+ the trailing slot). With a `{...expr}` spread → a `mergeProps` of
12
- ordered layers — explicit-prop runs, `spreadProps(expr)` spreads, the slot —
12
+ ordered layers — explicit-prop runs, `$$spreadProps(expr)` spreads, the slot —
13
13
  resolved last-wins per key, so source order decides overrides (like JSX).
14
14
  `lowerExpression` is the caller's expression lowering; `slotPart` is its `$children`
15
15
  layer (a host-taking builder for the client, a string-returning thunk for SSR) or
@@ -40,7 +40,7 @@ export function composeProps(
40
40
  for (const prop of props) {
41
41
  if (prop.spread) {
42
42
  flushRun()
43
- layers.push(`spreadProps(() => (${lowerExpression(prop.code)}))`)
43
+ layers.push(`$$spreadProps(() => (${lowerExpression(prop.code)}))`)
44
44
  } else {
45
45
  run.push(propThunk(prop))
46
46
  }
@@ -49,5 +49,5 @@ export function composeProps(
49
49
  if (slotPart !== undefined) {
50
50
  layers.push(`{ ${slotPart} }`)
51
51
  }
52
- return `mergeProps([${layers.join(', ')}])`
52
+ return `$$mergeProps([${layers.join(', ')}])`
53
53
  }
@@ -1,10 +1,11 @@
1
1
  import { resolve } from 'node:path'
2
2
  import ts from 'typescript'
3
3
  import { messageFromError } from '../../shared/messageFromError.ts'
4
- import { mapTsClassification } from './ABIDE_SEMANTIC_TOKENS_LEGEND.ts'
4
+ import { mapSyntacticClassification, mapTsClassification } from './ABIDE_SEMANTIC_TOKENS_LEGEND.ts'
5
5
  import { assetModulesFile } from './assetModulesFile.ts'
6
6
  import { compileShadow } from './compileShadow.ts'
7
7
  import { loadShadowTsConfig } from './loadShadowTsConfig.ts'
8
+ import { pagePropsType } from './pagePropsType.ts'
8
9
  import { remapShadowDiagnostic } from './remapShadowDiagnostic.ts'
9
10
  import { resolveAbideImports } from './resolveAbideImports.ts'
10
11
  import { shadowNaming } from './shadowNaming.ts'
@@ -46,6 +47,21 @@ export function createShadowLanguageService(cwd: string): ShadowLanguageService
46
47
  const versions = new Map<string, number>()
47
48
  const shadows = new Map<string, CompiledShadow>()
48
49
  const parseErrors = new Map<string, string>()
50
+ /* Memo of `shadowText` output keyed by source path, tagged with the shadow
51
+ version it was compiled at; a stale tag forces recompilation. `update`/`close`
52
+ bump that version, so the next read recompiles. */
53
+ const compiledAt = new Map<string, { version: number; code: string }>()
54
+ /* Memo of `pagePropsType` (route re-parse) per source path; the path → props
55
+ type mapping is immutable, so no version tag is needed. */
56
+ const propsTypes = new Map<string, string | undefined>()
57
+
58
+ /* The route props type for a component, parsed once and reused. */
59
+ const propsTypeOf = (abidePath: string): string | undefined => {
60
+ if (!propsTypes.has(abidePath)) {
61
+ propsTypes.set(abidePath, pagePropsType(abidePath))
62
+ }
63
+ return propsTypes.get(abidePath)
64
+ }
49
65
 
50
66
  /* Ambient declarations for bundler-handled asset imports (`*.css`, …). */
51
67
  const assets = assetModulesFile(cwd)
@@ -54,18 +70,29 @@ export function createShadowLanguageService(cwd: string): ShadowLanguageService
54
70
  overlays.has(abidePath) || ts.sys.fileExists(abidePath)
55
71
 
56
72
  /* Compiles (and caches) a component's shadow from its overlay or disk text; a
57
- template parse error yields a minimal valid module + a recorded message. */
73
+ template parse error yields a minimal valid module + a recorded message.
74
+ Memoised against the shadow's version (bumped by `update`/`close`), so
75
+ repeated reads at the same version skip the ~700-line compile and the
76
+ `shadows`/`parseErrors` caches stay current. */
58
77
  const shadowText = (abidePath: string): string => {
78
+ const version = versions.get(suffixed(abidePath)) ?? 0
79
+ const memo = compiledAt.get(abidePath)
80
+ if (memo !== undefined && memo.version === version) {
81
+ return memo.code
82
+ }
59
83
  const source = overlays.get(abidePath) ?? ts.sys.readFile(abidePath) ?? ''
60
84
  try {
61
- const compiled = compileShadow(source)
85
+ const compiled = compileShadow(source, propsTypeOf(abidePath))
62
86
  shadows.set(abidePath, compiled)
63
87
  parseErrors.delete(abidePath)
88
+ compiledAt.set(abidePath, { version, code: compiled.code })
64
89
  return compiled.code
65
90
  } catch (error) {
66
91
  shadows.set(abidePath, { code: '', mappings: [] })
67
92
  parseErrors.set(abidePath, messageFromError(error))
68
- return 'export default function (): void {}\n'
93
+ const code = 'export default function (): void {}\n'
94
+ compiledAt.set(abidePath, { version, code })
95
+ return code
69
96
  }
70
97
  }
71
98
 
@@ -240,6 +267,33 @@ export function createShadowLanguageService(cwd: string): ShadowLanguageService
240
267
  modifiers: mapped.modifiers,
241
268
  })
242
269
  }
270
+ /* Literal syntactic tokens (string/number/regex) the semantic classifier
271
+ never emits — so a template-literal string inside `{…}` gets colored. */
272
+ const syntactic = service.getEncodedSyntacticClassifications(fileName, {
273
+ start: 0,
274
+ length: shadow.code.length,
275
+ })
276
+ for (let index = 0; index + 2 < syntactic.spans.length; index += 3) {
277
+ const spanStart = syntactic.spans[index]
278
+ const spanLength = syntactic.spans[index + 1]
279
+ const classification = syntactic.spans[index + 2]
280
+ if (
281
+ spanStart === undefined ||
282
+ spanLength === undefined ||
283
+ classification === undefined
284
+ ) {
285
+ continue
286
+ }
287
+ const type = mapSyntacticClassification(classification)
288
+ if (type === undefined) {
289
+ continue
290
+ }
291
+ const located = remapShadowDiagnostic(shadow.mappings, spanStart, spanLength)
292
+ if (located === undefined) {
293
+ continue
294
+ }
295
+ tokens.push({ start: located.start, length: located.length, type, modifiers: [] })
296
+ }
243
297
  return tokens
244
298
  },
245
299
  }
@@ -4,6 +4,7 @@ import { messageFromError } from '../../shared/messageFromError.ts'
4
4
  import { assetModulesFile } from './assetModulesFile.ts'
5
5
  import { compileShadow } from './compileShadow.ts'
6
6
  import { loadShadowTsConfig } from './loadShadowTsConfig.ts'
7
+ import { pagePropsType } from './pagePropsType.ts'
7
8
  import { resolveAbideImports } from './resolveAbideImports.ts'
8
9
  import { shadowNaming } from './shadowNaming.ts'
9
10
  import type { CompiledShadow } from './types/CompiledShadow.ts'
@@ -46,7 +47,7 @@ export function createShadowProgram(cwd: string, abidePaths?: string[]): ShadowP
46
47
  const shadowText = (abidePath: string): string => {
47
48
  const source = ts.sys.readFile(abidePath) ?? ''
48
49
  try {
49
- const compiled = compileShadow(source)
50
+ const compiled = compileShadow(source, pagePropsType(abidePath))
50
51
  shadows.set(abidePath, compiled)
51
52
  parseErrors.delete(abidePath)
52
53
  return compiled.code
@@ -0,0 +1,42 @@
1
+ import type { ShadowKind } from './types/ShadowKind.ts'
2
+ import type { ShadowScope } from './types/ShadowScope.ts'
3
+
4
+ /*
5
+ Builds a fresh `ShadowScope` — the typed, auto-popping branch-local shadow stack. One
6
+ `Set` per `ShadowKind`, reached only through `withShadow` (push on entry, pop in a
7
+ `finally`), never directly. The `finally` is the safety move: the old hand-written
8
+ pop ran after the body returned, so any throw inside the body (e.g. SSR's `await then`
9
+ TDZ path) leaked the branch's shadows into every later sibling. Here the pop is
10
+ unconditional, so a branch's shadows cannot outlive the branch.
11
+ */
12
+ export function createShadowScope(): ShadowScope {
13
+ const byKind: Record<ShadowKind, Set<string>> = {
14
+ derived: new Set<string>(),
15
+ plain: new Set<string>(),
16
+ }
17
+
18
+ function withShadow<T>(names: Iterable<string>, kind: ShadowKind, body: () => T): T {
19
+ const scope = byKind[kind]
20
+ /* Track only the names this call newly added, so a name already shadowing from an
21
+ outer branch survives this branch's pop. */
22
+ const added: string[] = []
23
+ for (const name of names) {
24
+ if (!scope.has(name)) {
25
+ scope.add(name)
26
+ added.push(name)
27
+ }
28
+ }
29
+ try {
30
+ return body()
31
+ } finally {
32
+ for (const name of added) {
33
+ scope.delete(name)
34
+ }
35
+ }
36
+ }
37
+
38
+ return {
39
+ withShadow,
40
+ names: (kind) => byKind[kind],
41
+ }
42
+ }
@@ -15,6 +15,10 @@ const NAMED_ENTITIES: Record<string, string> = {
15
15
  gt: '>',
16
16
  quot: '"',
17
17
  apos: "'",
18
+ /* A literal brace where `{` would otherwise open an interpolation (text or
19
+ attribute value); written `&lbrace;` / `&rbrace;`. */
20
+ lbrace: '{',
21
+ rbrace: '}',
18
22
  nbsp: ' ',
19
23
  copy: '©',
20
24
  reg: '®',
@@ -152,11 +152,12 @@ export function desugarSignals(source: ts.SourceFile): {
152
152
  return root
153
153
  }
154
154
  const statements: ts.Statement[] = []
155
- /* A shared `model = scope()` host for the state slots, prepended once. */
155
+ /* A shared `$$model = scope()` host for the state slots, prepended once. The doc
156
+ base is `$$`-reserved so a user variable named `model` can never collide. */
156
157
  if (stateNames.size > 0) {
157
158
  statements.push(
158
159
  constDeclaration(
159
- 'model',
160
+ '$$model',
160
161
  factory.createCallExpression(factory.createIdentifier('scope'), undefined, []),
161
162
  ),
162
163
  )
@@ -425,10 +426,14 @@ function propsStatements(statement: ts.Statement): ts.Statement[] | undefined {
425
426
  statements.push(
426
427
  constDeclaration(
427
428
  rest,
428
- factory.createCallExpression(factory.createIdentifier('restProps'), undefined, [
429
- factory.createIdentifier('$props'),
430
- factory.createArrayLiteralExpression(consumed),
431
- ]),
429
+ factory.createCallExpression(
430
+ factory.createIdentifier('$$restProps'),
431
+ undefined,
432
+ [
433
+ factory.createIdentifier('$props'),
434
+ factory.createArrayLiteralExpression(consumed),
435
+ ],
436
+ ),
432
437
  ),
433
438
  )
434
439
  }
@@ -457,7 +462,7 @@ function stateAssignmentStatements(statement: ts.Statement): ts.Statement[] | un
457
462
  factory.createExpressionStatement(
458
463
  factory.createAssignment(
459
464
  factory.createPropertyAccessExpression(
460
- factory.createIdentifier('model'),
465
+ factory.createIdentifier('$$model'),
461
466
  declaration.name.text,
462
467
  ),
463
468
  initial,
@@ -0,0 +1,61 @@
1
+ import { catchBinding } from './catchBinding.ts'
2
+ import { resolveBranches } from './resolveBranches.ts'
3
+ import type { Binding } from './types/Binding.ts'
4
+ import type { TemplateNode } from './types/TemplateNode.ts'
5
+
6
+ /* The structural shape of an `each`/`for` loop, resolved once so the build and SSR back-ends
7
+ share one reading of it and only own emission. Both previously read the item/index/key
8
+ names, the `async` split, and the row children straight off the node; build additionally
9
+ resolved the async-each `catch` branch. The names the row introduces are now classified
10
+ ONCE here as `bindings` (item + index, both `reactive`) and `catchBindings` (the async-each
11
+ `catch` error, `plain`) — the single source both back-ends register through `withBindings`. */
12
+ export type EachPlan = {
13
+ /* The list expression — an Array (sync) or AsyncIterable (`async`). */
14
+ items: string
15
+ /* The item binding pattern (`node.as`): a plain identifier or a destructuring pattern. */
16
+ as: string
17
+ /* `by={k}` key expression, or undefined → key on the item's own identity. */
18
+ key: string | undefined
19
+ /* `index="i"` → the row's position bound to this name; undefined → unbound. */
20
+ index: string | undefined
21
+ /* `await` on the tag → `items` is an AsyncIterable, drained on the client. */
22
+ async: boolean
23
+ /* The row content. */
24
+ children: TemplateNode[]
25
+ /* The async-each `catch` content + its bound name (`_error` default); empty when absent.
26
+ Build renders it after the streamed rows on iterator rejection; SSR renders no async
27
+ rows so it ignores this. */
28
+ catchChildren: TemplateNode[]
29
+ catchAs: string
30
+ /* No catch → the rejection surfaces instead of rendering an empty branch. */
31
+ hasCatch: boolean
32
+ /* The row body's bindings, classified once: the item (`as`) and, when present, the
33
+ `index`, both `reactive` (a `.value` cell on the client). */
34
+ bindings: Binding[]
35
+ /* The async-each `catch` branch's binding (`catchAs`, `plain`); empty when no catch. */
36
+ catchBindings: Binding[]
37
+ }
38
+
39
+ /* Resolves an `each` node's structure into the shared structural plan. */
40
+ export function eachPlan(node: Extract<TemplateNode, { kind: 'each' }>): EachPlan {
41
+ const [catchBranch] = resolveBranches(node, 'catch')
42
+ const catchAs = catchBranch?.as ?? '_error'
43
+ return {
44
+ items: node.items,
45
+ as: node.as,
46
+ key: node.key,
47
+ index: node.index,
48
+ async: node.async,
49
+ children: node.children,
50
+ catchChildren: catchBranch?.children ?? [],
51
+ catchAs,
52
+ hasCatch: catchBranch !== undefined,
53
+ bindings: [
54
+ { name: node.as, classification: 'reactive' },
55
+ ...(node.index === undefined
56
+ ? []
57
+ : [{ name: node.index, classification: 'reactive' } as const]),
58
+ ],
59
+ catchBindings: catchBinding(catchAs, catchBranch !== undefined),
60
+ }
61
+ }
@@ -0,0 +1,55 @@
1
+ import { classStyleMergePlan } from './classStyleMergePlan.ts'
2
+ import type { ElementPlan, ElementPlanAttr } from './types/ElementPlan.ts'
3
+ import type { TemplateAttr } from './types/TemplateAttr.ts'
4
+ import type { TemplateNode } from './types/TemplateNode.ts'
5
+ import { VOID_TAGS } from './VOID_TAGS.ts'
6
+
7
+ /*
8
+ The single decision site for one element's attribute emission — lifted out of the two
9
+ hand-mirrored code-gen back-ends (`generateBuild`, `generateSSR`) so the load-bearing
10
+ agreement on which attribute is which kind, which class/style parts merge, and which tag
11
+ is void lives ONCE (ADR-0013, the locality milestone). Each back-end RENDERS the plan
12
+ differently — build wires live effects/thunks, SSR pushes escaped strings — but consults
13
+ the same classification, so the markup the client clones and the server emits cannot drift.
14
+
15
+ `classStyleMergePlan` folds in as the class/style branch. Each attr is tagged `mergedBuild`
16
+ /`mergedSSR` from the two merge triggers (build merges only on an interpolated base, SSR on
17
+ any directive), so a back-end skips exactly the attrs it folded into its merged class/style
18
+ attribute. The positional holes/anchors stay in `skeletonContext`; this plan layers over it.
19
+ */
20
+ export function elementPlan(
21
+ node: Extract<TemplateNode, { kind: 'element' }>,
22
+ lower: (code: string) => string,
23
+ ): ElementPlan {
24
+ const merge = classStyleMergePlan(node.attrs, lower)
25
+ /* True when this attr is folded into the build back-end's merged class/style effect — the
26
+ interpolated base or any directive of a merged property (a static base stays surgical). */
27
+ const isMergedBuild = (attr: TemplateAttr): boolean =>
28
+ (merge.mergeClassBuild && (attr === merge.classBase || attr.kind === 'class')) ||
29
+ (merge.mergeStyleBuild && (attr === merge.styleBase || attr.kind === 'style'))
30
+ /* True when this attr is folded into the SSR back-end's merged class/style attribute,
31
+ whenever a directive exists. A `class:`/`style:` directive always folds; a static or
32
+ interpolated base folds by NAME (so a duplicate `class`/`style` base is folded too,
33
+ never double-emitted next to the merged attribute — the old SSR by-name skip). */
34
+ const isMergedSSR = (attr: TemplateAttr): boolean => {
35
+ if (attr.kind === 'class') {
36
+ return merge.mergeClass
37
+ }
38
+ if (attr.kind === 'style') {
39
+ return merge.mergeStyle
40
+ }
41
+ if (attr.kind === 'static' || attr.kind === 'interpolated') {
42
+ return (
43
+ (attr.name === 'class' && merge.mergeClass) ||
44
+ (attr.name === 'style' && merge.mergeStyle)
45
+ )
46
+ }
47
+ return false
48
+ }
49
+ const attrs: ElementPlanAttr[] = node.attrs.map((attr) => ({
50
+ attr,
51
+ mergedBuild: isMergedBuild(attr),
52
+ mergedSSR: isMergedSSR(attr),
53
+ }))
54
+ return { attrs, merge, isVoid: VOID_TAGS.has(node.tag) }
55
+ }
@@ -10,8 +10,31 @@ by start; tokens with an unknown legend type, and any token overlapping the one
10
10
  before it, are dropped — the protocol requires a strictly non-overlapping,
11
11
  positionally-ordered stream.
12
12
  */
13
+ /*
14
+ A token may span newlines (a multiline template-literal string), but the protocol
15
+ forbids a token crossing a line — so split it into one segment per covered line,
16
+ dropping empty lines. A single-line token returns unchanged.
17
+ */
18
+ function splitByLine(text: string, token: SemanticToken): SemanticToken[] {
19
+ const raw = text.slice(token.start, token.start + token.length)
20
+ if (!raw.includes('\n')) {
21
+ return [token]
22
+ }
23
+ const segments: SemanticToken[] = []
24
+ let offset = token.start
25
+ for (const line of raw.split('\n')) {
26
+ if (line.length > 0) {
27
+ segments.push({ ...token, start: offset, length: line.length })
28
+ }
29
+ offset += line.length + 1
30
+ }
31
+ return segments
32
+ }
33
+
13
34
  export function encodeSemanticTokens(text: string, tokens: SemanticToken[]): number[] {
14
- const sorted = [...tokens].sort((a, b) => a.start - b.start || a.length - b.length)
35
+ const sorted = tokens
36
+ .flatMap((token) => splitByLine(text, token))
37
+ .sort((a, b) => a.start - b.start || a.length - b.length)
15
38
  const data: number[] = []
16
39
  let previousLine = 0
17
40
  let previousCharacter = 0