@abide/abide 0.43.1 → 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 +39 -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 +88 -11
  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 +24 -3
  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
  }
@@ -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
+ }
@@ -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