@abide/abide 0.43.0 → 0.44.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 (160) hide show
  1. package/AGENTS.md +293 -219
  2. package/CHANGELOG.md +46 -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 +11 -1
  7. package/src/build.ts +9 -0
  8. package/src/buildDisconnected.ts +2 -0
  9. package/src/devEntry.ts +1 -0
  10. package/src/lib/bundle/probeAbideServer.ts +1 -1
  11. package/src/lib/cli/parseArgvForRpc.ts +4 -2
  12. package/src/lib/cli/types/CliManifest.ts +6 -4
  13. package/src/lib/mcp/annotationsForMethod.ts +1 -1
  14. package/src/lib/mcp/toolResultFromResponse.ts +1 -1
  15. package/src/lib/mcp/types/JsonRpcRequest.ts +3 -3
  16. package/src/lib/server/AppModule.ts +1 -1
  17. package/src/lib/server/cli/handleCliDownload.ts +7 -5
  18. package/src/lib/server/cli/installScript.ts +3 -3
  19. package/src/lib/server/prompts/promptRegistry.ts +1 -1
  20. package/src/lib/server/prompts/types/Prompt.ts +2 -1
  21. package/src/lib/server/rpc/dispatchRpcInProcess.ts +2 -2
  22. package/src/lib/server/rpc/types/RpcHelper.ts +1 -1
  23. package/src/lib/server/runtime/createPublicAssetServer.ts +2 -2
  24. package/src/lib/server/runtime/createRouteDispatcher.ts +1 -1
  25. package/src/lib/server/runtime/createServer.ts +21 -0
  26. package/src/lib/server/runtime/createUiPageRenderer.ts +7 -11
  27. package/src/lib/server/runtime/crossOriginForbidden.ts +2 -2
  28. package/src/lib/server/runtime/crossOriginGate.ts +1 -1
  29. package/src/lib/server/runtime/devHotModuleResponse.ts +2 -2
  30. package/src/lib/server/runtime/disableIdleTimeoutForStream.ts +1 -1
  31. package/src/lib/server/runtime/logExposedSurfaces.ts +3 -4
  32. package/src/lib/server/runtime/serializeCacheSnapshot.ts +3 -8
  33. package/src/lib/server/runtime/snapshotEntryFromCache.ts +10 -7
  34. package/src/lib/server/runtime/streamFromIterator.ts +2 -2
  35. package/src/lib/server/runtime/types/InspectorRpc.ts +2 -2
  36. package/src/lib/server/socket.ts +1 -1
  37. package/src/lib/server/sockets/createSocketDispatcher.ts +2 -2
  38. package/src/lib/server/sockets/socketRegistry.ts +2 -2
  39. package/src/lib/server/sockets/types/SocketClientFrame.ts +3 -3
  40. package/src/lib/shared/CACHE_WRAPPED.ts +3 -2
  41. package/src/lib/shared/REMOTE_FUNCTION.ts +1 -1
  42. package/src/lib/shared/STREAMING_CONTENT_TYPES.ts +3 -2
  43. package/src/lib/shared/bodyValueForKind.ts +35 -0
  44. package/src/lib/shared/cache.ts +1 -1
  45. package/src/lib/shared/cacheEntryFromSnapshot.ts +23 -18
  46. package/src/lib/shared/createPushIterator.ts +1 -1
  47. package/src/lib/shared/decodeResponse.ts +11 -8
  48. package/src/lib/shared/hasReplayableRequest.ts +17 -0
  49. package/src/lib/shared/html.ts +7 -26
  50. package/src/lib/shared/manifestModule.ts +2 -2
  51. package/src/lib/shared/online.ts +1 -1
  52. package/src/lib/shared/outboxProbeSlot.ts +1 -1
  53. package/src/lib/shared/pageUrlForFile.ts +1 -1
  54. package/src/lib/shared/routeParamsShape.ts +20 -0
  55. package/src/lib/shared/snapshotShippable.ts +22 -0
  56. package/src/lib/shared/types/CacheEntry.ts +1 -1
  57. package/src/lib/shared/types/CacheInvalidation.ts +3 -3
  58. package/src/lib/shared/types/ClientFlags.ts +5 -3
  59. package/src/lib/shared/types/LastConnection.ts +1 -1
  60. package/src/lib/shared/url.ts +4 -1
  61. package/src/lib/shared/withBase.ts +1 -2
  62. package/src/lib/shared/writeDts.ts +2 -2
  63. package/src/lib/shared/writeRoutesDts.ts +3 -31
  64. package/src/lib/test/createTestSocketChannel.ts +3 -3
  65. package/src/lib/ui/COMPONENT_WRAPPER_PREFIX.ts +1 -1
  66. package/src/lib/ui/compile/ABIDE_SEMANTIC_TOKENS_LEGEND.ts +40 -6
  67. package/src/lib/ui/compile/REACTIVE_CALLEES.ts +1 -1
  68. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +49 -37
  69. package/src/lib/ui/compile/abideUiPlugin.ts +1 -1
  70. package/src/lib/ui/compile/assertRuntimeHelpersBound.ts +3 -1
  71. package/src/lib/ui/compile/awaitPlan.ts +14 -2
  72. package/src/lib/ui/compile/catchBinding.ts +8 -0
  73. package/src/lib/ui/compile/classStyleMergePlan.ts +108 -0
  74. package/src/lib/ui/compile/collectAbideDiagnostics.ts +11 -0
  75. package/src/lib/ui/compile/compileComponent.ts +4 -4
  76. package/src/lib/ui/compile/compileModule.ts +20 -6
  77. package/src/lib/ui/compile/compileSSR.ts +12 -10
  78. package/src/lib/ui/compile/compileShadow.ts +107 -16
  79. package/src/lib/ui/compile/composeProps.ts +3 -3
  80. package/src/lib/ui/compile/createShadowLanguageService.ts +30 -2
  81. package/src/lib/ui/compile/createShadowProgram.ts +2 -1
  82. package/src/lib/ui/compile/createShadowScope.ts +42 -0
  83. package/src/lib/ui/compile/decodeHtmlEntities.ts +4 -0
  84. package/src/lib/ui/compile/desugarSignals.ts +12 -7
  85. package/src/lib/ui/compile/eachPlan.ts +61 -0
  86. package/src/lib/ui/compile/elementPlan.ts +55 -0
  87. package/src/lib/ui/compile/encodeSemanticTokens.ts +24 -1
  88. package/src/lib/ui/compile/generateBuild.ts +150 -127
  89. package/src/lib/ui/compile/generateSSR.ts +112 -73
  90. package/src/lib/ui/compile/ifPlan.ts +7 -1
  91. package/src/lib/ui/compile/interpolatedTemplateLiteral.ts +25 -0
  92. package/src/lib/ui/compile/isPlainIdentifier.ts +7 -0
  93. package/src/lib/ui/compile/isTextLeaf.ts +8 -7
  94. package/src/lib/ui/compile/lowerContext.ts +36 -46
  95. package/src/lib/ui/compile/lowerDocAccess.ts +2 -2
  96. package/src/lib/ui/compile/lowerScript.ts +1 -1
  97. package/src/lib/ui/compile/markupTokens.ts +313 -0
  98. package/src/lib/ui/compile/pagePropsType.ts +23 -0
  99. package/src/lib/ui/compile/parseTemplate.ts +131 -61
  100. package/src/lib/ui/compile/reactiveBinding.ts +47 -0
  101. package/src/lib/ui/compile/renameSignalRefs.ts +44 -10
  102. package/src/lib/ui/compile/skeletonable.ts +5 -5
  103. package/src/lib/ui/compile/snippetPlan.ts +28 -0
  104. package/src/lib/ui/compile/spreadExcludedNames.ts +4 -4
  105. package/src/lib/ui/compile/structuralBlockTokens.ts +3 -4
  106. package/src/lib/ui/compile/switchPlan.ts +7 -1
  107. package/src/lib/ui/compile/tryPlan.ts +8 -1
  108. package/src/lib/ui/compile/types/AnalyzedComponent.ts +2 -2
  109. package/src/lib/ui/compile/types/Binding.ts +24 -0
  110. package/src/lib/ui/compile/types/CompiledShadow.ts +13 -1
  111. package/src/lib/ui/compile/types/ElementPlan.ts +43 -0
  112. package/src/lib/ui/compile/types/ShadowKind.ts +9 -0
  113. package/src/lib/ui/compile/types/ShadowScope.ts +19 -0
  114. package/src/lib/ui/compile/types/TemplateAttr.ts +7 -0
  115. package/src/lib/ui/compile/types/TemplateNode.ts +8 -8
  116. package/src/lib/ui/compile/withBindings.ts +45 -0
  117. package/src/lib/ui/createScope.ts +2 -2
  118. package/src/lib/ui/dom/MATHML_NAMESPACE.ts +2 -2
  119. package/src/lib/ui/dom/SVG_NAMESPACE.ts +3 -2
  120. package/src/lib/ui/dom/appendText.ts +1 -1
  121. package/src/lib/ui/dom/applyResolved.ts +1 -1
  122. package/src/lib/ui/dom/buildDetachedRange.ts +4 -3
  123. package/src/lib/ui/dom/cloneStatic.ts +3 -2
  124. package/src/lib/ui/dom/each.ts +26 -7
  125. package/src/lib/ui/dom/foreignWrapperTag.ts +2 -2
  126. package/src/lib/ui/dom/mountChild.ts +8 -1
  127. package/src/lib/ui/dom/readCall.ts +6 -4
  128. package/src/lib/ui/dom/scopeLabel.ts +3 -3
  129. package/src/lib/ui/dom/switchBlock.ts +3 -1
  130. package/src/lib/ui/dom/types/EachRow.ts +4 -0
  131. package/src/lib/ui/dom/types/SkeletonHoles.ts +4 -2
  132. package/src/lib/ui/history.ts +1 -2
  133. package/src/lib/ui/html.ts +28 -0
  134. package/src/lib/ui/installHotBridge.ts +0 -2
  135. package/src/lib/ui/renderChain.ts +16 -3
  136. package/src/lib/ui/router.ts +66 -15
  137. package/src/lib/ui/rpcOutbox/createOutboxQueue.ts +1 -1
  138. package/src/lib/ui/runtime/CHILD_PRESENT.ts +8 -0
  139. package/src/lib/ui/runtime/OUTLET_MARKER.ts +2 -2
  140. package/src/lib/ui/runtime/RANGE_MARKER.ts +1 -1
  141. package/src/lib/ui/runtime/REACTIVE_CONTEXT.ts +9 -3
  142. package/src/lib/ui/runtime/captureModelDoc.ts +1 -1
  143. package/src/lib/ui/runtime/createDoc.ts +5 -1
  144. package/src/lib/ui/runtime/createEffectNode.ts +5 -5
  145. package/src/lib/ui/runtime/flushEffects.ts +22 -9
  146. package/src/lib/ui/runtime/historyEntries.ts +1 -1
  147. package/src/lib/ui/runtime/track.ts +4 -2
  148. package/src/lib/ui/runtime/trigger.ts +34 -20
  149. package/src/lib/ui/runtime/types/Route.ts +2 -2
  150. package/src/lib/ui/runtime/types/UiComponent.ts +3 -2
  151. package/src/lib/ui/runtime/types/UiProps.ts +9 -12
  152. package/src/lib/ui/runtime/walkPath.ts +15 -3
  153. package/src/lib/ui/startClient.ts +41 -14
  154. package/src/lib/ui/state.ts +3 -3
  155. package/src/lib/ui/types/Scope.ts +3 -3
  156. package/src/serverBuildPlugins.ts +11 -5
  157. package/src/zodCjsPlugin.ts +36 -0
  158. package/template/.zed/settings.json +7 -1
  159. package/template/package.json +3 -0
  160. package/template/src/ui/pages/layout.abide +3 -5
@@ -6,9 +6,9 @@ import type { AnalyzedComponent } from './types/AnalyzedComponent.ts'
6
6
  /*
7
7
  Compiles a single-file abide component into the body of a client build function.
8
8
  Runs the shared front-end (`analyzeComponent`), generates the DOM build from the
9
- template, and hoists static paths to cells. The returned body runs against a
10
- `host` element with `doc`/`state`/`computed`/`effect` and the dom bindings in
11
- scope and defines `model` itself. `compileModule` wraps it (and the SSR body) into
9
+ template, and hoists static paths to cells. The returned body operates on `host`
10
+ with the `$$`-prefixed dom bindings and `$$model` in scope (the latter emitted by
11
+ `desugarSignals`). `compileModule` wraps it (and the SSR body) into
12
12
  a real module; tests wrap it with `new Function`.
13
13
 
14
14
  `analyzed` is a lazy default: a direct caller (tests) omits it and the front-end
@@ -26,5 +26,5 @@ export function compileComponent(
26
26
  /* The scoped CSS is bundled into the entry stylesheet (see `abideUiPlugin`), not
27
27
  injected at runtime; the build only needs the `data-a-…` scope attributes on
28
28
  elements, which `generateBuild` reads from each node's annotated `scopes`. */
29
- return `${script}\n${hoistCells(build, 'model')}`
29
+ return `${script}\n${hoistCells(build, '$$model')}`
30
30
  }
@@ -44,7 +44,13 @@ export function compileModule(
44
44
  leaf components today; one importing children falls back to a reload (the dev
45
45
  layer decides what to hot-swap). */
46
46
  if (options.hot) {
47
- const names = UI_RUNTIME_IMPORTS.map((entry) => entry.name).join(', ')
47
+ /* Bridge keys are the bare source names; bind each to its emitted local (`$$`
48
+ alias when set), so the hot body's `$$mountChild(...)` resolves. */
49
+ const names = UI_RUNTIME_IMPORTS.map((entry) =>
50
+ entry.alias === undefined || entry.alias === entry.name
51
+ ? entry.name
52
+ : `${entry.name}: ${entry.alias}`,
53
+ ).join(', ')
48
54
  const id = JSON.stringify(options.moduleId)
49
55
  return `const { ${names}, hotReplace } = window.__abide
50
56
  ${userImports}
@@ -52,7 +58,7 @@ function build(host, $props) {
52
58
  ${body}
53
59
  }
54
60
  function component(host, $props) {
55
- return mount(host, build, $props)
61
+ return $$mount(host, build, $props)
56
62
  }
57
63
  component.build = build
58
64
  component.__abideId = ${id}
@@ -66,12 +72,12 @@ ${body}
66
72
  }
67
73
 
68
74
  export default function component(host, $props) {
69
- return mount(host, build, $props)
75
+ return $$mount(host, build, $props)
70
76
  }
71
77
 
72
78
  /* Adopt the server-rendered DOM in place instead of rebuilding it. */
73
79
  export function hydrateInto(host, $props) {
74
- return hydrate(host, build, $props)
80
+ return $$hydrate(host, build, $props)
75
81
  }
76
82
 
77
83
  export function render($props, $ctx) {
@@ -96,8 +102,16 @@ ${options.moduleId === undefined ? '' : `component.__abideId = ${JSON.stringify(
96
102
  forces a spurious import, so no per-surface scoping is needed: a client-only helper
97
103
  simply doesn't appear as an identifier in the SSR body. */
98
104
  const referenced = collectIdentifiers(`${userImports}\n${body}\n${ssrBody}\n${moduleBody}`)
99
- const importBlock = UI_RUNTIME_IMPORTS.filter((entry) => referenced.has(entry.name))
100
- .map((entry) => `import { ${entry.name} } from '${ABIDE_PACKAGE_NAME}/${entry.specifier}'`)
105
+ const importBlock = UI_RUNTIME_IMPORTS.filter((entry) =>
106
+ referenced.has(entry.alias ?? entry.name),
107
+ )
108
+ .map((entry) => {
109
+ const local =
110
+ entry.alias === undefined || entry.alias === entry.name
111
+ ? entry.name
112
+ : `${entry.name} as ${entry.alias}`
113
+ return `import { ${local} } from '${ABIDE_PACKAGE_NAME}/${entry.specifier}'`
114
+ })
101
115
  .join('\n')
102
116
  const module = `${importBlock}
103
117
  ${userImports}
@@ -5,17 +5,20 @@ import type { AnalyzedComponent } from './types/AnalyzedComponent.ts'
5
5
 
6
6
  /*
7
7
  Compiles a component into the body of a server render function. Runs the shared
8
- front-end, then the SSR back-end, and returns `{ html, state, awaits }`:
8
+ front-end, then the SSR back-end, and returns `{ html, state, awaits, resume }`:
9
9
 
10
10
  - `html` — server-rendered markup (await blocks render their pending shell);
11
11
  - `state` — the document snapshot the client adopts on resume;
12
12
  - `awaits` — pending await blocks (id + promise + resolved/error renderers) that
13
- `renderToStream` flushes out of order; empty for a fully synchronous component.
13
+ `renderToStream` flushes out of order; empty for a fully synchronous component;
14
+ - `resume` — blocking await values rendered inline, keyed by boundary id, seeded
15
+ into the hydration manifest.
14
16
 
15
17
  Effects are stripped — they are client lifecycle and emit no HTML, so the server
16
18
  render is a snapshot of the markup before any effect runs.
17
19
 
18
- Defines `model` with `doc`/`state`/`computed`/`effect` in scope. The block-id counter is
20
+ Defines `$$model` via the lowered script; the reactive scope is entered via `$$enterScope`
21
+ and exited in a `finally` block. The block-id counter is
19
22
  the request-local `$ctx` (threaded into child renders), not a module global — a blocking
20
23
  `await` block awaits its promise at its structural position and renders inline, so ids
21
24
  allocate depth-first like the client; render yields at that `await`, and a shared global
@@ -29,10 +32,9 @@ purely synchronous / streaming-await / try-only component returns `SsrRender` di
29
32
  framework always `await`s `render()`, so production is uniform either way; the sync return
30
33
  keeps static pages off the microtask queue and leaves the bulk of the SSR tests synchronous.
31
34
 
32
- The reactive scope brackets the body; it is entered synchronously before any inline `await`,
33
- so `const model = scope()` resolves correctly, and `model` is captured for the rest of the
34
- render (its reads are object-method calls, not scope lookups, so they stay correct across
35
- the awaits).
35
+ The reactive scope brackets the body: `$$enterScope()` runs synchronously before any inline
36
+ `await`, and `$$model` is captured for the rest of the render (its reads are object-method
37
+ calls, not scope lookups, so they stay correct across the awaits).
36
38
 
37
39
  `analyzed` is a lazy default: a direct caller (tests) omits it and the front-end
38
40
  runs here, but `compileModule` shares one analysis across both back-ends.
@@ -51,9 +53,9 @@ export function compileSSR(
51
53
  /* `typeof model` guards a component with no reactive state (a pure-async or
52
54
  static component declares no `model`); its snapshot is then empty. */
53
55
  const body =
54
- `const $scope = enterScope();\ntry {\n${lowered}\n${SSR_ESCAPE}\nconst $out = [];\nconst $awaits = [];\nconst $resume = {};\n${ssr}` +
55
- `return { html: $out.join(''), state: (typeof model !== 'undefined' ? model.snapshot() : {}), awaits: $awaits, resume: $resume };\n` +
56
- `} finally { exitScope($scope); }`
56
+ `const $scope = $$enterScope();\ntry {\n${lowered}\n${SSR_ESCAPE}\nconst $out = [];\nconst $awaits = [];\nconst $resume = {};\n${ssr}` +
57
+ `return { html: $out.join(''), state: (typeof $$model !== 'undefined' ? $$model.snapshot() : {}), awaits: $awaits, resume: $resume };\n` +
58
+ `} finally { $$exitScope($scope); }`
57
59
  /* An inline `await` — a blocking await block, a child render, a slot read, or a
58
60
  top-level `await` in the author script — forces an async wrapper. Match `await` as a
59
61
  standalone token (operator), so a no-space form (`await(x)`, `await[i]`, `` await`t` ``)
@@ -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
  }
@@ -82,7 +99,7 @@ export function compileShadow(source: string): CompiledShadow {
82
99
  }
83
100
  emitNodes(parseTemplate(source.slice(templateStart), templateStart).nodes, builder)
84
101
  builder.raw('}\n')
85
- return builder.result()
102
+ return { ...builder.result(), diagnostics }
86
103
  }
87
104
 
88
105
  /* The shadow text builder: `raw` appends synthesised scaffolding (no mapping),
@@ -162,6 +179,39 @@ type ScriptAnalysis = {
162
179
  types: ScopeLine[]
163
180
  scope: ScopeLine[]
164
181
  propsShapes: string[]
182
+ diagnostics: ShadowDiagnostic[]
183
+ }
184
+
185
+ /* Pushes a diagnostic for every author binding whose name starts with the reserved `$$`
186
+ prefix — variable/function/class declarations, parameters, and destructuring leaves.
187
+ Read structurally off the parsed script; the message points at the offending name. */
188
+ function collectReservedNameDiagnostics(
189
+ file: ts.SourceFile,
190
+ scriptStart: number,
191
+ diagnostics: ShadowDiagnostic[],
192
+ ): void {
193
+ const flag = (name: ts.Node | undefined): void => {
194
+ if (name !== undefined && ts.isIdentifier(name) && name.text.startsWith('$$')) {
195
+ diagnostics.push({
196
+ start: scriptStart + name.getStart(file),
197
+ length: name.getEnd() - name.getStart(file),
198
+ message: `\`${name.text}\` is reserved — the \`$$\` prefix is the compiler's injected runtime namespace; rename this binding.`,
199
+ })
200
+ }
201
+ }
202
+ const visit = (node: ts.Node): void => {
203
+ if (
204
+ ts.isVariableDeclaration(node) ||
205
+ ts.isFunctionDeclaration(node) ||
206
+ ts.isClassDeclaration(node) ||
207
+ ts.isParameter(node) ||
208
+ ts.isBindingElement(node)
209
+ ) {
210
+ flag(node.name)
211
+ }
212
+ ts.forEachChild(node, visit)
213
+ }
214
+ visit(file)
165
215
  }
166
216
 
167
217
  /* Walks the leading `<script>` and produces the shadow's module imports, the
@@ -173,10 +223,15 @@ function analyzeScript(scriptBody: string, scriptStart: number): ScriptAnalysis
173
223
  const types: ScopeLine[] = []
174
224
  const scope: ScopeLine[] = []
175
225
  const propsShapes: string[] = []
226
+ const diagnostics: ShadowDiagnostic[] = []
176
227
  if (scriptBody.trim() === '') {
177
- return { imports, types, scope, propsShapes }
228
+ return { imports, types, scope, propsShapes, diagnostics }
178
229
  }
179
230
  const file = ts.createSourceFile('script.ts', scriptBody, ts.ScriptTarget.Latest, true)
231
+ /* The `$$` prefix is reserved for the compiler's injected runtime (`$$each`, `$$model`,
232
+ `$$scope`, …), so an author binding may not start with it — that's the contract that
233
+ lets a user freely name a variable after any helper. Flag every such declaration. */
234
+ collectReservedNameDiagnostics(file, scriptStart, diagnostics)
180
235
  /* A verbatim span: original text + the segment mapping it back, relative to the
181
236
  line start (the caller rebases shadowStart onto the running shadow length). */
182
237
  const span = (node: ts.Node, prefixLength: number): ScopeLine['segments'][number] => ({
@@ -188,6 +243,20 @@ function analyzeScript(scriptBody: string, scriptStart: number): ScriptAnalysis
188
243
 
189
244
  for (const statement of file.statements) {
190
245
  if (ts.isImportDeclaration(statement)) {
246
+ const specifier = ts.isStringLiteral(statement.moduleSpecifier)
247
+ ? statement.moduleSpecifier.text
248
+ : undefined
249
+ const banned = specifier !== undefined ? INTERNAL_ONLY_IMPORTS[specifier] : undefined
250
+ if (banned !== undefined) {
251
+ /* Flag and drop it — leaving it out keeps `effect` resolving via the
252
+ preamble (no duplicate-identifier noise on top of our message). */
253
+ diagnostics.push({
254
+ start: scriptStart + statement.getStart(file),
255
+ length: statement.getEnd() - statement.getStart(file),
256
+ message: banned,
257
+ })
258
+ continue
259
+ }
191
260
  /* Emit verbatim with a span so hover/go-to resolve on the imported names. */
192
261
  imports.push({ text: verbatim(statement), segments: [span(statement, 0)] })
193
262
  continue
@@ -207,7 +276,7 @@ function analyzeScript(scriptBody: string, scriptStart: number): ScriptAnalysis
207
276
  scope.push(scopeLineFor(declaration, propsShapes, verbatim, span))
208
277
  }
209
278
  }
210
- return { imports, types, scope, propsShapes }
279
+ return { imports, types, scope, propsShapes, diagnostics }
211
280
  }
212
281
 
213
282
  /* Value-projects a nested control-flow `<script>` body the way `analyzeScript`
@@ -363,7 +432,15 @@ function emitNode(node: TemplateNode, builder: Builder): void {
363
432
  return
364
433
  case 'element':
365
434
  for (const attr of node.attrs) {
366
- if (attr.kind !== 'static') {
435
+ /* An interpolated value checks each `{expr}` part on its own offset; every
436
+ other dynamic attribute checks its single `code`. */
437
+ if (attr.kind === 'interpolated') {
438
+ for (const part of attr.parts) {
439
+ if (part.kind === 'expression') {
440
+ builder.stmt(part.code, part.loc)
441
+ }
442
+ }
443
+ } else if (attr.kind !== 'static') {
367
444
  builder.stmt(attr.code, attr.loc)
368
445
  }
369
446
  }
@@ -372,12 +449,15 @@ function emitNode(node: TemplateNode, builder: Builder): void {
372
449
  case 'component': {
373
450
  /* The imported tag resolves (via the shadow host) to the child's
374
451
  `(props: Props) => …` default, so `Parameters<typeof Child>[0]` is its prop
375
- shape. `on*` / `bind:` / `attach` props are framework-handled passthrough
376
- (not part of the declared shape), so they are checked leniently — each value
377
- against its own declared key type — never flagged as excess. */
452
+ shape. `bind:` / `class:` / `style:` / `attach` and `{...spread}` are
453
+ framework directives (not part of the declared shape), so they are checked
454
+ leniently — each value against its own declared key type — never flagged as
455
+ excess. `on*` callbacks ARE ordinary declared props on a component (abide does
456
+ not auto-forward events to the DOM), so they go through the completeness check
457
+ with the rest — otherwise a required `onsave`/`oncancel` reads as missing even
458
+ when passed, and an undeclared handler slips by unflagged. */
378
459
  const handled = (prop: { name: string; spread?: boolean }): boolean =>
379
460
  prop.spread === true ||
380
- prop.name.startsWith('on') ||
381
461
  prop.name.startsWith('bind:') ||
382
462
  prop.name.startsWith('class:') ||
383
463
  prop.name.startsWith('style:') ||
@@ -427,12 +507,23 @@ function emitNode(node: TemplateNode, builder: Builder): void {
427
507
  builder.raw('\n')
428
508
  for (const prop of dataProps) {
429
509
  /* The key mapped to its source name (excess-prop errors land on the key);
430
- the value verbatim-mapped (wrong-type errors land on the value). */
510
+ the value verbatim-mapped (wrong-type errors land on the value).
511
+ Hyphenated names (`aria-label`, `data-*`) aren't valid identifiers, so
512
+ they're wrapped in raw quotes — a string-literal key the parser accepts.
513
+ The quotes are unmapped; an excess-prop error on the literal starts at the
514
+ opening quote and overlaps the mapped name, clamping back onto it. */
515
+ const needsQuoting = !/^[A-Za-z_$][\w$]*$/.test(prop.name)
516
+ if (needsQuoting) {
517
+ builder.raw('"')
518
+ }
431
519
  if (prop.nameLoc !== undefined) {
432
520
  builder.mapped(prop.name, prop.nameLoc)
433
521
  } else {
434
522
  builder.raw(prop.name)
435
523
  }
524
+ if (needsQuoting) {
525
+ builder.raw('"')
526
+ }
436
527
  builder.raw(': ')
437
528
  builder.expr(prop.code, prop.loc)
438
529
  builder.raw(',\n')
@@ -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'
@@ -58,7 +59,7 @@ export function createShadowLanguageService(cwd: string): ShadowLanguageService
58
59
  const shadowText = (abidePath: string): string => {
59
60
  const source = overlays.get(abidePath) ?? ts.sys.readFile(abidePath) ?? ''
60
61
  try {
61
- const compiled = compileShadow(source)
62
+ const compiled = compileShadow(source, pagePropsType(abidePath))
62
63
  shadows.set(abidePath, compiled)
63
64
  parseErrors.delete(abidePath)
64
65
  return compiled.code
@@ -240,6 +241,33 @@ export function createShadowLanguageService(cwd: string): ShadowLanguageService
240
241
  modifiers: mapped.modifiers,
241
242
  })
242
243
  }
244
+ /* Literal syntactic tokens (string/number/regex) the semantic classifier
245
+ never emits — so a template-literal string inside `{…}` gets colored. */
246
+ const syntactic = service.getEncodedSyntacticClassifications(fileName, {
247
+ start: 0,
248
+ length: shadow.code.length,
249
+ })
250
+ for (let index = 0; index + 2 < syntactic.spans.length; index += 3) {
251
+ const spanStart = syntactic.spans[index]
252
+ const spanLength = syntactic.spans[index + 1]
253
+ const classification = syntactic.spans[index + 2]
254
+ if (
255
+ spanStart === undefined ||
256
+ spanLength === undefined ||
257
+ classification === undefined
258
+ ) {
259
+ continue
260
+ }
261
+ const type = mapSyntacticClassification(classification)
262
+ if (type === undefined) {
263
+ continue
264
+ }
265
+ const located = remapShadowDiagnostic(shadow.mappings, spanStart, spanLength)
266
+ if (located === undefined) {
267
+ continue
268
+ }
269
+ tokens.push({ start: located.start, length: located.length, type, modifiers: [] })
270
+ }
243
271
  return tokens
244
272
  },
245
273
  }
@@ -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
+ }