@abide/abide 0.29.0 → 0.31.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 (44) hide show
  1. package/AGENTS.md +6 -4
  2. package/CHANGELOG.md +32 -0
  3. package/package.json +2 -1
  4. package/src/lib/bundle/disconnected.abide +82 -82
  5. package/src/lib/cli/dispatchCommand.ts +3 -2
  6. package/src/lib/cli/resolveCliTarget.ts +2 -3
  7. package/src/lib/cli/runCli.ts +2 -3
  8. package/src/lib/cli/runSession.ts +2 -3
  9. package/src/lib/mcp/dispatchMcpRequest.ts +3 -2
  10. package/src/lib/mcp/mcpSurface.ts +2 -1
  11. package/src/lib/mcp/toolResultFromResponse.ts +2 -1
  12. package/src/lib/server/rpc/parseArgs.ts +1 -3
  13. package/src/lib/server/runtime/streamFromIterator.ts +3 -1
  14. package/src/lib/server/runtime/warnUnguardedMcp.ts +4 -3
  15. package/src/lib/server/sockets/createSocketDispatcher.ts +5 -7
  16. package/src/lib/shared/cacheEntryFromSnapshot.ts +2 -1
  17. package/src/lib/shared/contentTypeOf.ts +6 -0
  18. package/src/lib/shared/decodeResponse.ts +2 -1
  19. package/src/lib/shared/isCompileTarget.ts +7 -1
  20. package/src/lib/shared/isModuleNotFound.ts +3 -1
  21. package/src/lib/shared/isStreamingResponse.ts +2 -1
  22. package/src/lib/shared/messageFromError.ts +6 -0
  23. package/src/lib/shared/streamResponse.ts +2 -1
  24. package/src/lib/ui/compile/REACTIVE_CALLEES.ts +7 -0
  25. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -0
  26. package/src/lib/ui/compile/VOID_TAGS.ts +4 -3
  27. package/src/lib/ui/compile/compileComponent.ts +12 -2
  28. package/src/lib/ui/compile/compileModule.ts +7 -4
  29. package/src/lib/ui/compile/compileSSR.ts +11 -2
  30. package/src/lib/ui/compile/compileShadow.ts +165 -54
  31. package/src/lib/ui/compile/createShadowLanguageService.ts +2 -1
  32. package/src/lib/ui/compile/createShadowProgram.ts +2 -1
  33. package/src/lib/ui/compile/desugarSignals.ts +41 -14
  34. package/src/lib/ui/compile/parseTemplate.ts +21 -26
  35. package/src/lib/ui/compile/prepareNestedScript.ts +4 -2
  36. package/src/lib/ui/derived.ts +25 -4
  37. package/src/lib/ui/dom/awaitBlock.ts +1 -24
  38. package/src/lib/ui/dom/discardBoundary.ts +27 -0
  39. package/src/lib/ui/dom/tryBlock.ts +7 -26
  40. package/src/lib/ui/installHotBridge.ts +2 -0
  41. package/src/lib/ui/linked.ts +34 -0
  42. package/src/lib/ui/router.ts +23 -6
  43. package/src/lib/ui/state.ts +9 -2
  44. package/template/src/ui/pages/page.abide +1 -1
@@ -0,0 +1,7 @@
1
+ /* The callee names the `.abide` compiler recognises as reactive declarations
2
+ (`let x = state(...)`, `linked(...)`, `derived(...)`, `prop(...)`): the shared
3
+ "is this a reactive binding" allowlist read by the desugarer, the nested-script
4
+ scoper, and the type-checking shadow. How each lowers — a serializable doc slot
5
+ vs a `.value` cell — is decided per-site; this is only the membership set, so a
6
+ new primitive is a single edit here. */
7
+ export const REACTIVE_CALLEES: ReadonlySet<string> = new Set(['state', 'linked', 'derived', 'prop'])
@@ -10,6 +10,7 @@ export const UI_RUNTIME_IMPORTS: { name: string; specifier: string }[] = [
10
10
  { name: 'snippet', specifier: 'shared/snippet' },
11
11
  { name: 'doc', specifier: 'ui/doc' },
12
12
  { name: 'state', specifier: 'ui/state' },
13
+ { name: 'linked', specifier: 'ui/linked' },
13
14
  { name: 'derived', specifier: 'ui/derived' },
14
15
  { name: 'effect', specifier: 'ui/effect' },
15
16
  { name: 'mount', specifier: 'ui/dom/mount' },
@@ -1,7 +1,8 @@
1
1
  /*
2
- HTML void elements — they have no closing tag and no children. Shared by the SSR
3
- generator and the static-clone skeleton generator so both emit `<img>` not
4
- `<img></img>`, keeping server markup and the client clone template identical.
2
+ HTML void elements — they have no closing tag and no children. Shared by the
3
+ template parser, the SSR generator, and the static-clone skeleton generator so
4
+ all self-close consistently and the back-ends emit `<img>` not `<img></img>`,
5
+ keeping server markup and the client clone template identical.
5
6
  */
6
7
  export const VOID_TAGS: ReadonlySet<string> = new Set([
7
8
  'area',
@@ -1,6 +1,7 @@
1
1
  import { analyzeComponent } from './analyzeComponent.ts'
2
2
  import { generateBuild } from './generateBuild.ts'
3
3
  import { hoistCells } from './hoistCells.ts'
4
+ import type { AnalyzedComponent } from './types/AnalyzedComponent.ts'
4
5
 
5
6
  /*
6
7
  Compiles a single-file abide component into the body of a client build function.
@@ -9,9 +10,18 @@ template, and hoists static paths to cells. The returned body runs against a
9
10
  `host` element with `doc`/`state`/`derived`/`effect` and the dom bindings in
10
11
  scope and defines `model` itself. `compileModule` wraps it (and the SSR body) into
11
12
  a real module; tests wrap it with `new Function`.
13
+
14
+ `analyzed` is a lazy default: a direct caller (tests) omits it and the front-end
15
+ runs here, but `compileModule` analyzes once and passes the result to both
16
+ back-ends, so the shared front-end runs once per build instead of three times.
12
17
  */
13
- export function compileComponent(source: string, isLayout = false, scopeSeed?: string): string {
14
- const { script, stateNames, derivedNames, nodes } = analyzeComponent(source, scopeSeed)
18
+ export function compileComponent(
19
+ source: string,
20
+ isLayout = false,
21
+ scopeSeed?: string,
22
+ analyzed: AnalyzedComponent = analyzeComponent(source, scopeSeed),
23
+ ): string {
24
+ const { script, stateNames, derivedNames, nodes } = analyzed
15
25
  const build = generateBuild(nodes, 'host', stateNames, derivedNames, isLayout)
16
26
  /* The scoped CSS is bundled into the entry stylesheet (see `abideUiPlugin`), not
17
27
  injected at runtime; the build only needs the `data-a-…` scope attributes on
@@ -22,10 +22,13 @@ export function compileModule(
22
22
  options: { isLayout?: boolean; moduleId?: string; hot?: boolean } = {},
23
23
  ): string {
24
24
  const isLayout = options.isLayout ?? false
25
- /* Component-authored imports (e.g. child components) hoisted to module scope. */
26
- const analyzed = analyzeComponent(source)
25
+ /* Run the shared front-end once and feed it to both back-ends — the analysis is
26
+ pure over (source, moduleId), so the client and SSR builds reuse one parse
27
+ instead of re-running it. `imports` (hoisted child-component imports) and the
28
+ per-element scopes both come from this single pass. */
29
+ const analyzed = analyzeComponent(source, options.moduleId)
27
30
  const userImports = analyzed.imports
28
- const body = indent(compileComponent(source, isLayout, options.moduleId))
31
+ const body = indent(compileComponent(source, isLayout, options.moduleId, analyzed))
29
32
 
30
33
  /* Hot module (dev component HMR): the same client build, but its runtime comes
31
34
  from the live bundle via `window.__abide` — so it shares the one reactive graph
@@ -49,7 +52,7 @@ if (!hotReplace(${id}, component)) location.reload()
49
52
  `
50
53
  }
51
54
 
52
- const ssrBody = indent(compileSSR(source, isLayout, options.moduleId))
55
+ const ssrBody = indent(compileSSR(source, isLayout, options.moduleId, analyzed))
53
56
  /* Per-component dead-import elimination: emit only the runtime names this module
54
57
  actually references. A component that uses no `each`/`await`/`html` shouldn't
55
58
  drag those modules into its chunk. The package isn't globally side-effect-free
@@ -2,6 +2,7 @@ import { analyzeComponent } from './analyzeComponent.ts'
2
2
  import { generateSSR } from './generateSSR.ts'
3
3
  import { SSR_ESCAPE } from './SSR_ESCAPE.ts'
4
4
  import { stripEffects } from './stripEffects.ts'
5
+ import type { AnalyzedComponent } from './types/AnalyzedComponent.ts'
5
6
 
6
7
  /*
7
8
  Compiles a component into the body of a server render function. Runs the shared
@@ -19,9 +20,17 @@ Runs with `doc`/`state`/`derived`/`effect`/`nextBlockId`/`enterRenderPass`/
19
20
  `exitRenderPass` in scope and defines `model`. The body is bracketed by a render
20
21
  pass so the outermost render resets the block-id counter and an inlined child
21
22
  render continues it — keeping await/try ids unique and aligned with the client.
23
+
24
+ `analyzed` is a lazy default: a direct caller (tests) omits it and the front-end
25
+ runs here, but `compileModule` shares one analysis across both back-ends.
22
26
  */
23
- export function compileSSR(source: string, isLayout = false, scopeSeed?: string): string {
24
- const { script, stateNames, derivedNames, nodes } = analyzeComponent(source, scopeSeed)
27
+ export function compileSSR(
28
+ source: string,
29
+ isLayout = false,
30
+ scopeSeed?: string,
31
+ analyzed: AnalyzedComponent = analyzeComponent(source, scopeSeed),
32
+ ): string {
33
+ const { script, stateNames, derivedNames, nodes } = analyzed
25
34
  const ssr = generateSSR(nodes, stateNames, derivedNames, isLayout)
26
35
  /* No `<style>` in the markup — the scoped CSS is bundled into the entry stylesheet
27
36
  the shell links (see `abideUiPlugin`), so SSR output is styled by that sheet. The
@@ -1,6 +1,7 @@
1
1
  import ts from 'typescript'
2
2
  import { ABIDE_PACKAGE_NAME } from '../../shared/ABIDE_PACKAGE_NAME.ts'
3
3
  import { parseTemplate } from './parseTemplate.ts'
4
+ import { REACTIVE_CALLEES } from './REACTIVE_CALLEES.ts'
4
5
  import type { CompiledShadow, ShadowMapping } from './types/CompiledShadow.ts'
5
6
  import type { TemplateNode } from './types/TemplateNode.ts'
6
7
 
@@ -14,13 +15,14 @@ never appears here — every `prop()` declaration is rewritten away. `$props` is
14
15
  legacy untyped prop bag (pre-`prop()` sugar) made available raw.
15
16
  */
16
17
  const SHADOW_PREAMBLE = `import { state } from '${ABIDE_PACKAGE_NAME}/ui/state'
18
+ import { linked } from '${ABIDE_PACKAGE_NAME}/ui/linked'
17
19
  import { derived } from '${ABIDE_PACKAGE_NAME}/ui/derived'
18
20
  import { effect } from '${ABIDE_PACKAGE_NAME}/ui/effect'
19
21
  import { doc } from '${ABIDE_PACKAGE_NAME}/ui/doc'
20
22
  import { html } from '${ABIDE_PACKAGE_NAME}/shared/html'
21
23
  import { snippet } from '${ABIDE_PACKAGE_NAME}/shared/snippet'
22
24
  declare const $props: Record<string, (() => unknown) | undefined>
23
- void [state, derived, effect, doc, html, snippet]
25
+ void [state, linked, derived, effect, doc, html, snippet]
24
26
  `
25
27
 
26
28
  /*
@@ -46,11 +48,18 @@ export function compileShadow(source: string): CompiledShadow {
46
48
  const scriptStart = leadingScript ? source.indexOf('>', leadingScript.index) + 1 : 0
47
49
  const templateStart = leadingScript ? (leadingScript.index ?? 0) + leadingScript[0].length : 0
48
50
 
49
- const { imports, scope, props } = analyzeScript(scriptBody, scriptStart)
51
+ const { imports, types, scope, props } = analyzeScript(scriptBody, scriptStart)
50
52
  builder.raw(SHADOW_PREAMBLE)
51
53
  for (const line of imports) {
52
54
  builder.flush(line)
53
55
  }
56
+ /* Component-local `type`/`interface` declarations are hoisted to module scope —
57
+ above `__Props` so prop annotations referencing them resolve, and still visible
58
+ inside the function body where the rest of the scope and template expressions use
59
+ them. (Emitting them as in-function scope lines would hide them from `__Props`.) */
60
+ for (const line of types) {
61
+ builder.flush(line)
62
+ }
54
63
  builder.raw(`interface __Props {\n${props.join('\n')}\n}\n`)
55
64
  /* async so `await` blocks are legal; never executed, so the return is void. */
56
65
  builder.raw('export default async function (props: __Props): Promise<void> {\n')
@@ -59,9 +68,7 @@ export function compileShadow(source: string): CompiledShadow {
59
68
  for (const line of scope) {
60
69
  builder.flush(line)
61
70
  }
62
- for (const node of parseTemplate(source.slice(templateStart), templateStart).nodes) {
63
- emitNode(node, builder)
64
- }
71
+ emitNodes(parseTemplate(source.slice(templateStart), templateStart).nodes, builder)
65
72
  builder.raw('}\n')
66
73
  return builder.result()
67
74
  }
@@ -75,6 +82,9 @@ type Builder = {
75
82
  expr: (code: string, sourceLoc: number | undefined) => void
76
83
  stmt: (code: string, sourceLoc: number | undefined) => void
77
84
  flush: (line: ScopeLine) => void
85
+ /* A fresh shadow-local binding name (`__<base>_<n>`) — for synthesised bindings
86
+ like an await's resolved value, kept distinct so nested blocks never collide. */
87
+ unique: (base: string) => string
78
88
  result: () => CompiledShadow
79
89
  }
80
90
 
@@ -84,8 +94,12 @@ type ScopeLine = { text: string; segments: ShadowMapping[] }
84
94
 
85
95
  function createBuilder(): Builder {
86
96
  let code = ''
97
+ let uniqueCounter = 0
87
98
  const mappings: ShadowMapping[] = []
88
99
  const builder: Builder = {
100
+ unique(base) {
101
+ return `__${base}_${uniqueCounter++}`
102
+ },
89
103
  raw(text) {
90
104
  code += text
91
105
  },
@@ -117,17 +131,24 @@ function createBuilder(): Builder {
117
131
  return builder
118
132
  }
119
133
 
120
- type ScriptAnalysis = { imports: ScopeLine[]; scope: ScopeLine[]; props: string[] }
134
+ type ScriptAnalysis = {
135
+ imports: ScopeLine[]
136
+ types: ScopeLine[]
137
+ scope: ScopeLine[]
138
+ props: string[]
139
+ }
121
140
 
122
141
  /* Walks the leading `<script>` and produces the shadow's module imports, the
123
- value-typed scope lines, and the Props interface fields. `scriptStart` is the
124
- body's absolute offset in the source, so verbatim spans map back exactly. */
142
+ module-scope type declarations, the value-typed scope lines, and the Props
143
+ interface fields. `scriptStart` is the body's absolute offset in the source, so
144
+ verbatim spans map back exactly. */
125
145
  function analyzeScript(scriptBody: string, scriptStart: number): ScriptAnalysis {
126
146
  const imports: ScopeLine[] = []
147
+ const types: ScopeLine[] = []
127
148
  const scope: ScopeLine[] = []
128
149
  const props: string[] = []
129
150
  if (scriptBody.trim() === '') {
130
- return { imports, scope, props }
151
+ return { imports, types, scope, props }
131
152
  }
132
153
  const file = ts.createSourceFile('script.ts', scriptBody, ts.ScriptTarget.Latest, true)
133
154
  /* A verbatim span: original text + the segment mapping it back, relative to the
@@ -145,6 +166,11 @@ function analyzeScript(scriptBody: string, scriptStart: number): ScriptAnalysis
145
166
  imports.push({ text: verbatim(statement), segments: [span(statement, 0)] })
146
167
  continue
147
168
  }
169
+ if (ts.isTypeAliasDeclaration(statement) || ts.isInterfaceDeclaration(statement)) {
170
+ /* Hoist to module scope (verbatim, mapped) so prop annotations resolve them. */
171
+ types.push({ text: verbatim(statement), segments: [span(statement, 0)] })
172
+ continue
173
+ }
148
174
  const reactive = reactiveDeclarations(statement)
149
175
  if (reactive === undefined) {
150
176
  /* Plain statement (function, const, expression) — emit verbatim, mapped. */
@@ -155,7 +181,7 @@ function analyzeScript(scriptBody: string, scriptStart: number): ScriptAnalysis
155
181
  scope.push(scopeLineFor(declaration, props, verbatim, span))
156
182
  }
157
183
  }
158
- return { imports, scope, props }
184
+ return { imports, types, scope, props }
159
185
  }
160
186
 
161
187
  /* The `state`/`derived`/`prop` declarations in a variable statement, or undefined
@@ -170,14 +196,15 @@ function reactiveDeclarations(statement: ts.Statement): ts.VariableDeclaration[]
170
196
  return reactive.length === declarations.length && reactive.length > 0 ? reactive : undefined
171
197
  }
172
198
 
173
- /* The callee name of a `NAME = state(...)` / `derived(...)` / `prop(...)` decl. */
199
+ /* The callee name of a `NAME = state(...)` / `linked(...)` / `derived(...)` /
200
+ `prop(...)` decl. */
174
201
  function signalCallee(declaration: ts.VariableDeclaration): string | undefined {
175
202
  const initializer = declaration.initializer
176
203
  if (
177
204
  initializer !== undefined &&
178
205
  ts.isCallExpression(initializer) &&
179
206
  ts.isIdentifier(initializer.expression) &&
180
- ['state', 'derived', 'prop'].includes(initializer.expression.text)
207
+ REACTIVE_CALLEES.has(initializer.expression.text)
181
208
  ) {
182
209
  return initializer.expression.text
183
210
  }
@@ -207,9 +234,10 @@ function scopeLineFor(
207
234
  const prefix = `let ${name}${annotation} = (`
208
235
  return { text: `${prefix}${verbatim(init)});`, segments: [span(init, prefix.length)] }
209
236
  }
210
- if (callee === 'derived') {
211
- /* derived<T>(compute): T is the value type — annotate so an explicit
212
- argument isn't lost to inference of the compute's return. */
237
+ if (callee === 'derived' || callee === 'linked') {
238
+ /* derived<T>(compute) / linked<T>(seed): T is the value type — the call's
239
+ first arg is a thunk, so invoking it yields the value. Annotate so an
240
+ explicit type argument isn't lost to inference of the thunk's return. */
213
241
  const typeNode = call.typeArguments?.[0]
214
242
  const annotation = typeNode === undefined ? '' : `: ${verbatim(typeNode)}`
215
243
  const fn = call.arguments[0]
@@ -229,9 +257,56 @@ function scopeLineFor(
229
257
  return { text: `let ${name} = props[${JSON.stringify(keyText)}];`, segments: [] }
230
258
  }
231
259
 
232
- /* Emits a template node's expressions into the shadow's `if (false) {…}` render
233
- body. Control flow introduces its binding so children type-check against it;
234
- every expression is referenced in a statement so a type error surfaces and maps. */
260
+ /* Emits a sibling list. Walks with lookahead so an `if` and its trailing `else`
261
+ (the next meaningful sibling a `case` with no match) fuse into one
262
+ `if (…) {…} else {…}`, giving the else branch the condition's negative narrowing
263
+ instead of being checked bare against the un-narrowed type. Every other node is
264
+ emitted standalone via `emitNode`. */
265
+ function emitNodes(nodes: TemplateNode[], builder: Builder): void {
266
+ for (let index = 0; index < nodes.length; index += 1) {
267
+ const node = nodes[index]
268
+ if (node === undefined) {
269
+ continue
270
+ }
271
+ if (node.kind !== 'if') {
272
+ emitNode(node, builder)
273
+ continue
274
+ }
275
+ builder.raw('if ')
276
+ builder.expr(node.condition, node.loc)
277
+ builder.raw(' {\n')
278
+ emitNodes(node.children, builder)
279
+ builder.raw('}')
280
+ const elseIndex = nextMeaningful(nodes, index + 1)
281
+ const elseNode = elseIndex === -1 ? undefined : nodes[elseIndex]
282
+ if (elseNode?.kind === 'case' && elseNode.match === undefined) {
283
+ builder.raw(' else {\n')
284
+ emitNodes(elseNode.children, builder)
285
+ builder.raw('}')
286
+ index = elseIndex
287
+ }
288
+ builder.raw('\n')
289
+ }
290
+ }
291
+
292
+ /* Index of the next node that isn't whitespace-only text (which separates the `if`
293
+ and `else` tags in source but carries no checkable content); -1 if none remain. */
294
+ function nextMeaningful(nodes: TemplateNode[], from: number): number {
295
+ for (let index = from; index < nodes.length; index += 1) {
296
+ const node = nodes[index]
297
+ const blank =
298
+ node?.kind === 'text' &&
299
+ node.parts.every((part) => part.kind === 'static' && part.value.trim() === '')
300
+ if (!blank) {
301
+ return index
302
+ }
303
+ }
304
+ return -1
305
+ }
306
+
307
+ /* Emits a template node's expressions into the shadow's render body. Control flow
308
+ introduces its binding so children type-check against it; every expression is
309
+ referenced in a statement so a type error surfaces and maps. */
235
310
  function emitNode(node: TemplateNode, builder: Builder): void {
236
311
  switch (node.kind) {
237
312
  case 'text':
@@ -247,9 +322,7 @@ function emitNode(node: TemplateNode, builder: Builder): void {
247
322
  builder.stmt(attr.code, attr.loc)
248
323
  }
249
324
  }
250
- node.children.forEach((child) => {
251
- emitNode(child, builder)
252
- })
325
+ emitNodes(node.children, builder)
253
326
  return
254
327
  case 'component': {
255
328
  /* Check each prop against the child's declared type. The imported tag
@@ -265,79 +338,117 @@ function emitNode(node: TemplateNode, builder: Builder): void {
265
338
  builder.expr(prop.code, prop.loc)
266
339
  builder.raw(');\n')
267
340
  }
268
- node.children.forEach((child) => {
269
- emitNode(child, builder)
270
- })
341
+ emitNodes(node.children, builder)
271
342
  return
272
343
  }
273
344
  case 'if':
345
+ /* Reached only for an `if` emitted outside a sibling list (none today);
346
+ `emitNodes` owns the `if`/`else` fusion. Emit without an else. */
274
347
  builder.raw('if ')
275
348
  builder.expr(node.condition, node.loc)
276
349
  builder.raw(' {\n')
277
- node.children.forEach((child) => {
278
- emitNode(child, builder)
279
- })
350
+ emitNodes(node.children, builder)
280
351
  builder.raw('}\n')
281
352
  return
282
353
  case 'each':
283
- builder.raw(`for (const ${node.as} of `)
354
+ /* `for await` over an async each's AsyncIterable, plain `for…of` otherwise —
355
+ so the item binds to the element type under either iteration protocol. */
356
+ builder.raw(
357
+ node.async ? `for await (const ${node.as} of ` : `for (const ${node.as} of `,
358
+ )
284
359
  builder.expr(node.items, node.loc)
285
360
  builder.raw(') {\n')
286
361
  if (node.key !== undefined) {
287
362
  builder.raw(`void (${node.key});\n`)
288
363
  }
289
- node.children.forEach((child) => {
290
- emitNode(child, builder)
291
- })
364
+ emitNodes(node.children, builder)
292
365
  builder.raw('}\n')
293
366
  return
294
- case 'await':
367
+ case 'await': {
368
+ /* Resolve once into a shadow-local; `then` binds it (carrying the awaited
369
+ type so resolved-content props are checked), `catch` binds the error as
370
+ `any` (statically unknowable), `finally` binds nothing. Blocking: the
371
+ non-branch children are the resolved content, bound to `as`. Streaming:
372
+ they're the pending content, checked without the resolved value. */
373
+ const resolved = builder.unique('awaited')
295
374
  builder.raw('{\n')
296
- builder.raw(node.as !== undefined ? `const ${node.as} = await ` : 'await ')
375
+ builder.raw(`const ${resolved} = await `)
297
376
  builder.expr(node.promise, node.loc)
298
- builder.raw(';\n')
299
- node.children.forEach((child) => {
300
- emitNode(child, builder)
301
- })
377
+ builder.raw(`;\nvoid ${resolved};\n`)
378
+ const pending = node.children.filter((child) => child.kind !== 'branch')
379
+ const branches = node.children.filter((child) => child.kind === 'branch')
380
+ if (node.blocking && node.as !== undefined) {
381
+ builder.raw(`{\nconst ${node.as} = ${resolved};\n`)
382
+ emitNodes(pending, builder)
383
+ builder.raw('}\n')
384
+ } else {
385
+ emitNodes(pending, builder)
386
+ }
387
+ for (const branch of branches) {
388
+ if (branch.kind !== 'branch') {
389
+ continue
390
+ }
391
+ builder.raw('{\n')
392
+ if (branch.branch === 'then' && branch.as !== undefined) {
393
+ builder.raw(`const ${branch.as} = ${resolved};\n`)
394
+ } else if (branch.branch === 'catch' && branch.as !== undefined) {
395
+ builder.raw(`const ${branch.as} = undefined as any;\n`)
396
+ }
397
+ emitNodes(branch.children, builder)
398
+ builder.raw('}\n')
399
+ }
302
400
  builder.raw('}\n')
303
401
  return
402
+ }
304
403
  case 'switch':
305
- builder.stmt(node.subject, node.loc)
306
- node.children.forEach((child) => {
307
- emitNode(child, builder)
308
- })
404
+ /* A real `switch` so a discriminant subject narrows into each case body;
405
+ non-case children (whitespace between cases) carry nothing and are
406
+ skipped. `break` keeps cases independent under `noFallthroughCasesInSwitch`. */
407
+ builder.raw('switch (')
408
+ builder.expr(node.subject, node.loc)
409
+ builder.raw(') {\n')
410
+ for (const child of node.children) {
411
+ if (child.kind !== 'case') {
412
+ continue
413
+ }
414
+ if (child.match !== undefined) {
415
+ builder.raw('case ')
416
+ builder.expr(child.match, child.loc)
417
+ builder.raw(': {\n')
418
+ } else {
419
+ builder.raw('default: {\n')
420
+ }
421
+ emitNodes(child.children, builder)
422
+ builder.raw('break;\n}\n')
423
+ }
424
+ builder.raw('}\n')
309
425
  return
310
426
  case 'case':
427
+ /* Reached only for a stray case outside a switch/if-else (none today); a
428
+ `switch` emits its own cases and `emitNodes` consumes an `else`. */
311
429
  if (node.match !== undefined) {
312
430
  builder.stmt(node.match, node.loc)
313
431
  }
314
- node.children.forEach((child) => {
315
- emitNode(child, builder)
316
- })
432
+ emitNodes(node.children, builder)
317
433
  return
318
434
  case 'branch':
319
- /* then/catch bind the resolved value / error as `any` so children check. */
435
+ /* Reached only for a stray branch outside an await (none today); the await
436
+ handler binds resolved/error types for its own branch children. */
320
437
  builder.raw('{\n')
321
438
  if (node.as !== undefined) {
322
439
  builder.raw(`const ${node.as} = undefined as any;\n`)
323
440
  }
324
- node.children.forEach((child) => {
325
- emitNode(child, builder)
326
- })
441
+ emitNodes(node.children, builder)
327
442
  builder.raw('}\n')
328
443
  return
329
444
  case 'try':
330
445
  builder.raw('{\n')
331
- node.children.forEach((child) => {
332
- emitNode(child, builder)
333
- })
446
+ emitNodes(node.children, builder)
334
447
  builder.raw('}\n')
335
448
  return
336
449
  case 'snippet':
337
450
  builder.raw(`const ${node.name} = (${node.params ?? ''}) => {\n`)
338
- node.children.forEach((child) => {
339
- emitNode(child, builder)
340
- })
451
+ emitNodes(node.children, builder)
341
452
  builder.raw('};\n')
342
453
  return
343
454
  case 'script':
@@ -1,5 +1,6 @@
1
1
  import { resolve } from 'node:path'
2
2
  import ts from 'typescript'
3
+ import { messageFromError } from '../../shared/messageFromError.ts'
3
4
  import { assetModulesFile } from './assetModulesFile.ts'
4
5
  import { compileShadow } from './compileShadow.ts'
5
6
  import { loadShadowTsConfig } from './loadShadowTsConfig.ts'
@@ -59,7 +60,7 @@ export function createShadowLanguageService(cwd: string): ShadowLanguageService
59
60
  return compiled.code
60
61
  } catch (error) {
61
62
  shadows.set(abidePath, { code: '', mappings: [] })
62
- parseErrors.set(abidePath, error instanceof Error ? error.message : String(error))
63
+ parseErrors.set(abidePath, messageFromError(error))
63
64
  return 'export default function (): void {}\n'
64
65
  }
65
66
  }
@@ -1,5 +1,6 @@
1
1
  import { resolve } from 'node:path'
2
2
  import ts from 'typescript'
3
+ import { messageFromError } from '../../shared/messageFromError.ts'
3
4
  import { assetModulesFile } from './assetModulesFile.ts'
4
5
  import { compileShadow } from './compileShadow.ts'
5
6
  import { loadShadowTsConfig } from './loadShadowTsConfig.ts'
@@ -51,7 +52,7 @@ export function createShadowProgram(cwd: string, abidePaths?: string[]): ShadowP
51
52
  return compiled.code
52
53
  } catch (error) {
53
54
  shadows.set(abidePath, { code: '', mappings: [] })
54
- parseErrors.set(abidePath, error instanceof Error ? error.message : String(error))
55
+ parseErrors.set(abidePath, messageFromError(error))
55
56
  return 'export default function (): void {}\n'
56
57
  }
57
58
  }
@@ -1,4 +1,5 @@
1
1
  import ts from 'typescript'
2
+ import { REACTIVE_CALLEES } from './REACTIVE_CALLEES.ts'
2
3
  import { renameSignalRefs } from './renameSignalRefs.ts'
3
4
 
4
5
  /*
@@ -9,14 +10,16 @@ declares reactive state as signals:
9
10
  let items = state([])
10
11
  const total = derived(() => count + items.length)
11
12
 
12
- This collects the `state`/`derived` binding names, turns each `state` declaration
13
+ This collects the binding names, turns each plain `state(initial)` declaration
13
14
  into an initialising assignment on a shared `model` document (in source order, so
14
- a later state can read an earlier one), keeps `derived`/`effect`/functions, then
15
- renames every reference through `renameSignalRefs`. The result is plain `model.x`
16
- access that `lowerDocAccess` lowers to patches/reads — so the signal surface gets
17
- the document substrate's deep, fine-grained, serializable reactivity for free.
18
- No state declarations the script is returned untouched (the explicit
19
- `const model = doc(...)` form still works).
15
+ a later state can read an earlier one), keeps the rest, then renames every
16
+ reference through `renameSignalRefs`. Plain `state` becomes `model.x` access that
17
+ `lowerDocAccess` lowers to patches/reads — the document substrate's deep,
18
+ fine-grained, serializable reactivity for free. `linked`, `derived`, and
19
+ `state(initial, transform)` stay verbatim as runtime `.value` cells (referenced
20
+ as `name.value`): they own no doc slot, so they reseed/recompute on resume rather
21
+ than serialize. No reactive declarations → the script is returned untouched (the
22
+ explicit `const model = doc(...)` form still works).
20
23
  */
21
24
  export function desugarSignals(scriptBody: string): {
22
25
  code: string
@@ -32,13 +35,16 @@ export function desugarSignals(scriptBody: string): {
32
35
  }
33
36
  for (const declaration of statement.declarationList.declarations) {
34
37
  const callee = signalCallee(declaration)
35
- if (callee === 'state' && ts.isIdentifier(declaration.name)) {
38
+ if (!ts.isIdentifier(declaration.name)) {
39
+ continue
40
+ }
41
+ if (isPlainStateSlot(declaration)) {
42
+ /* Plain `state(initial)` → a serializable `model` doc slot. */
36
43
  stateNames.add(declaration.name.text)
37
- } else if (
38
- (callee === 'derived' || callee === 'prop') &&
39
- ts.isIdentifier(declaration.name)
40
- ) {
41
- /* A prop reads like a derived (read-only); both are referenced as `.value`. */
44
+ } else if (callee !== undefined && REACTIVE_CALLEES.has(callee)) {
45
+ /* `.value` cells, referenced as `name.value`: `linked`, `derived`, a
46
+ `prop` (reads like a read-only derived), and `state(initial, transform)`
47
+ (the transform must run on writes, so it can't be a bare doc slot). */
42
48
  derivedNames.add(declaration.name.text)
43
49
  }
44
50
  }
@@ -70,6 +76,24 @@ export function desugarSignals(scriptBody: string): {
70
76
  }
71
77
  }
72
78
 
79
+ /* True for `state(initial, transform)` — the write-coercion transform forces a
80
+ `.value` cell (writes run it) rather than a bare, serializable doc slot. */
81
+ function hasTransform(declaration: ts.VariableDeclaration): boolean {
82
+ const initializer = declaration.initializer
83
+ return (
84
+ initializer !== undefined &&
85
+ ts.isCallExpression(initializer) &&
86
+ initializer.arguments.length >= 2
87
+ )
88
+ }
89
+
90
+ /* A plain `state(initial)` with no transform → a serializable `model` doc slot;
91
+ every other reactive declaration is a `.value` cell. The one rule shared by the
92
+ name-collection pass and the slot lowering. */
93
+ function isPlainStateSlot(declaration: ts.VariableDeclaration): boolean {
94
+ return signalCallee(declaration) === 'state' && !hasTransform(declaration)
95
+ }
96
+
73
97
  /* The callee name of a `NAME = state(...)` / `derived(...)` declaration, else undefined. */
74
98
  function signalCallee(declaration: ts.VariableDeclaration): string | undefined {
75
99
  const initializer = declaration.initializer
@@ -119,7 +143,10 @@ function stateDeclarationAssignments(
119
143
  }
120
144
  const assignments: string[] = []
121
145
  for (const declaration of statement.declarationList.declarations) {
122
- if (signalCallee(declaration) !== 'state' || !ts.isIdentifier(declaration.name)) {
146
+ if (!ts.isIdentifier(declaration.name) || !isPlainStateSlot(declaration)) {
147
+ /* Only a plain `state(initial)` becomes a slot; `state(initial, transform)`
148
+ (and everything else) is a `.value` cell — print it verbatim so the
149
+ runtime call (and its transform) survives. */
123
150
  return undefined
124
151
  }
125
152
  const initial = (declaration.initializer as ts.CallExpression).arguments[0]