@abide/abide 0.30.0 → 0.31.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/AGENTS.md +4 -3
  2. package/CHANGELOG.md +26 -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 +146 -49
  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 +1 -1
  43. package/src/lib/ui/state.ts +9 -2
  44. package/template/src/ui/pages/page.abide +1 -1
@@ -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
  /*
@@ -66,9 +68,7 @@ export function compileShadow(source: string): CompiledShadow {
66
68
  for (const line of scope) {
67
69
  builder.flush(line)
68
70
  }
69
- for (const node of parseTemplate(source.slice(templateStart), templateStart).nodes) {
70
- emitNode(node, builder)
71
- }
71
+ emitNodes(parseTemplate(source.slice(templateStart), templateStart).nodes, builder)
72
72
  builder.raw('}\n')
73
73
  return builder.result()
74
74
  }
@@ -82,6 +82,9 @@ type Builder = {
82
82
  expr: (code: string, sourceLoc: number | undefined) => void
83
83
  stmt: (code: string, sourceLoc: number | undefined) => void
84
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
85
88
  result: () => CompiledShadow
86
89
  }
87
90
 
@@ -91,8 +94,12 @@ type ScopeLine = { text: string; segments: ShadowMapping[] }
91
94
 
92
95
  function createBuilder(): Builder {
93
96
  let code = ''
97
+ let uniqueCounter = 0
94
98
  const mappings: ShadowMapping[] = []
95
99
  const builder: Builder = {
100
+ unique(base) {
101
+ return `__${base}_${uniqueCounter++}`
102
+ },
96
103
  raw(text) {
97
104
  code += text
98
105
  },
@@ -189,14 +196,15 @@ function reactiveDeclarations(statement: ts.Statement): ts.VariableDeclaration[]
189
196
  return reactive.length === declarations.length && reactive.length > 0 ? reactive : undefined
190
197
  }
191
198
 
192
- /* The callee name of a `NAME = state(...)` / `derived(...)` / `prop(...)` decl. */
199
+ /* The callee name of a `NAME = state(...)` / `linked(...)` / `derived(...)` /
200
+ `prop(...)` decl. */
193
201
  function signalCallee(declaration: ts.VariableDeclaration): string | undefined {
194
202
  const initializer = declaration.initializer
195
203
  if (
196
204
  initializer !== undefined &&
197
205
  ts.isCallExpression(initializer) &&
198
206
  ts.isIdentifier(initializer.expression) &&
199
- ['state', 'derived', 'prop'].includes(initializer.expression.text)
207
+ REACTIVE_CALLEES.has(initializer.expression.text)
200
208
  ) {
201
209
  return initializer.expression.text
202
210
  }
@@ -226,9 +234,10 @@ function scopeLineFor(
226
234
  const prefix = `let ${name}${annotation} = (`
227
235
  return { text: `${prefix}${verbatim(init)});`, segments: [span(init, prefix.length)] }
228
236
  }
229
- if (callee === 'derived') {
230
- /* derived<T>(compute): T is the value type — annotate so an explicit
231
- 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. */
232
241
  const typeNode = call.typeArguments?.[0]
233
242
  const annotation = typeNode === undefined ? '' : `: ${verbatim(typeNode)}`
234
243
  const fn = call.arguments[0]
@@ -248,9 +257,56 @@ function scopeLineFor(
248
257
  return { text: `let ${name} = props[${JSON.stringify(keyText)}];`, segments: [] }
249
258
  }
250
259
 
251
- /* Emits a template node's expressions into the shadow's `if (false) {…}` render
252
- body. Control flow introduces its binding so children type-check against it;
253
- 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. */
254
310
  function emitNode(node: TemplateNode, builder: Builder): void {
255
311
  switch (node.kind) {
256
312
  case 'text':
@@ -266,9 +322,7 @@ function emitNode(node: TemplateNode, builder: Builder): void {
266
322
  builder.stmt(attr.code, attr.loc)
267
323
  }
268
324
  }
269
- node.children.forEach((child) => {
270
- emitNode(child, builder)
271
- })
325
+ emitNodes(node.children, builder)
272
326
  return
273
327
  case 'component': {
274
328
  /* Check each prop against the child's declared type. The imported tag
@@ -278,85 +332,128 @@ function emitNode(node: TemplateNode, builder: Builder): void {
278
332
  offending expression (an annotated target reports the error on the RHS,
279
333
  unlike an object literal which reports it on the key). */
280
334
  for (const prop of node.props) {
335
+ /* Lead with a defensive `;`: this IIFE is the one shadow emission that
336
+ starts with `(`, so without it a preceding scope statement left
337
+ unterminated (a script ending in a call with no trailing semicolon,
338
+ e.g. `effect(() => …)`) merges across the newline into `effect(…)(…)`
339
+ — a spurious "not callable" on the author's last statement. */
281
340
  builder.raw(
282
- `((__prop: Parameters<typeof ${node.name}>[0][${JSON.stringify(prop.name)}]) => {})(`,
341
+ `;((__prop: Parameters<typeof ${node.name}>[0][${JSON.stringify(prop.name)}]) => {})(`,
283
342
  )
284
343
  builder.expr(prop.code, prop.loc)
285
344
  builder.raw(');\n')
286
345
  }
287
- node.children.forEach((child) => {
288
- emitNode(child, builder)
289
- })
346
+ emitNodes(node.children, builder)
290
347
  return
291
348
  }
292
349
  case 'if':
350
+ /* Reached only for an `if` emitted outside a sibling list (none today);
351
+ `emitNodes` owns the `if`/`else` fusion. Emit without an else. */
293
352
  builder.raw('if ')
294
353
  builder.expr(node.condition, node.loc)
295
354
  builder.raw(' {\n')
296
- node.children.forEach((child) => {
297
- emitNode(child, builder)
298
- })
355
+ emitNodes(node.children, builder)
299
356
  builder.raw('}\n')
300
357
  return
301
358
  case 'each':
302
- builder.raw(`for (const ${node.as} of `)
359
+ /* `for await` over an async each's AsyncIterable, plain `for…of` otherwise —
360
+ so the item binds to the element type under either iteration protocol. */
361
+ builder.raw(
362
+ node.async ? `for await (const ${node.as} of ` : `for (const ${node.as} of `,
363
+ )
303
364
  builder.expr(node.items, node.loc)
304
365
  builder.raw(') {\n')
305
366
  if (node.key !== undefined) {
306
367
  builder.raw(`void (${node.key});\n`)
307
368
  }
308
- node.children.forEach((child) => {
309
- emitNode(child, builder)
310
- })
369
+ emitNodes(node.children, builder)
311
370
  builder.raw('}\n')
312
371
  return
313
- case 'await':
372
+ case 'await': {
373
+ /* Resolve once into a shadow-local; `then` binds it (carrying the awaited
374
+ type so resolved-content props are checked), `catch` binds the error as
375
+ `any` (statically unknowable), `finally` binds nothing. Blocking: the
376
+ non-branch children are the resolved content, bound to `as`. Streaming:
377
+ they're the pending content, checked without the resolved value. */
378
+ const resolved = builder.unique('awaited')
314
379
  builder.raw('{\n')
315
- builder.raw(node.as !== undefined ? `const ${node.as} = await ` : 'await ')
380
+ builder.raw(`const ${resolved} = await `)
316
381
  builder.expr(node.promise, node.loc)
317
- builder.raw(';\n')
318
- node.children.forEach((child) => {
319
- emitNode(child, builder)
320
- })
382
+ builder.raw(`;\nvoid ${resolved};\n`)
383
+ const pending = node.children.filter((child) => child.kind !== 'branch')
384
+ const branches = node.children.filter((child) => child.kind === 'branch')
385
+ if (node.blocking && node.as !== undefined) {
386
+ builder.raw(`{\nconst ${node.as} = ${resolved};\n`)
387
+ emitNodes(pending, builder)
388
+ builder.raw('}\n')
389
+ } else {
390
+ emitNodes(pending, builder)
391
+ }
392
+ for (const branch of branches) {
393
+ if (branch.kind !== 'branch') {
394
+ continue
395
+ }
396
+ builder.raw('{\n')
397
+ if (branch.branch === 'then' && branch.as !== undefined) {
398
+ builder.raw(`const ${branch.as} = ${resolved};\n`)
399
+ } else if (branch.branch === 'catch' && branch.as !== undefined) {
400
+ builder.raw(`const ${branch.as} = undefined as any;\n`)
401
+ }
402
+ emitNodes(branch.children, builder)
403
+ builder.raw('}\n')
404
+ }
321
405
  builder.raw('}\n')
322
406
  return
407
+ }
323
408
  case 'switch':
324
- builder.stmt(node.subject, node.loc)
325
- node.children.forEach((child) => {
326
- emitNode(child, builder)
327
- })
409
+ /* A real `switch` so a discriminant subject narrows into each case body;
410
+ non-case children (whitespace between cases) carry nothing and are
411
+ skipped. `break` keeps cases independent under `noFallthroughCasesInSwitch`. */
412
+ builder.raw('switch (')
413
+ builder.expr(node.subject, node.loc)
414
+ builder.raw(') {\n')
415
+ for (const child of node.children) {
416
+ if (child.kind !== 'case') {
417
+ continue
418
+ }
419
+ if (child.match !== undefined) {
420
+ builder.raw('case ')
421
+ builder.expr(child.match, child.loc)
422
+ builder.raw(': {\n')
423
+ } else {
424
+ builder.raw('default: {\n')
425
+ }
426
+ emitNodes(child.children, builder)
427
+ builder.raw('break;\n}\n')
428
+ }
429
+ builder.raw('}\n')
328
430
  return
329
431
  case 'case':
432
+ /* Reached only for a stray case outside a switch/if-else (none today); a
433
+ `switch` emits its own cases and `emitNodes` consumes an `else`. */
330
434
  if (node.match !== undefined) {
331
435
  builder.stmt(node.match, node.loc)
332
436
  }
333
- node.children.forEach((child) => {
334
- emitNode(child, builder)
335
- })
437
+ emitNodes(node.children, builder)
336
438
  return
337
439
  case 'branch':
338
- /* then/catch bind the resolved value / error as `any` so children check. */
440
+ /* Reached only for a stray branch outside an await (none today); the await
441
+ handler binds resolved/error types for its own branch children. */
339
442
  builder.raw('{\n')
340
443
  if (node.as !== undefined) {
341
444
  builder.raw(`const ${node.as} = undefined as any;\n`)
342
445
  }
343
- node.children.forEach((child) => {
344
- emitNode(child, builder)
345
- })
446
+ emitNodes(node.children, builder)
346
447
  builder.raw('}\n')
347
448
  return
348
449
  case 'try':
349
450
  builder.raw('{\n')
350
- node.children.forEach((child) => {
351
- emitNode(child, builder)
352
- })
451
+ emitNodes(node.children, builder)
353
452
  builder.raw('}\n')
354
453
  return
355
454
  case 'snippet':
356
455
  builder.raw(`const ${node.name} = (${node.params ?? ''}) => {\n`)
357
- node.children.forEach((child) => {
358
- emitNode(child, builder)
359
- })
456
+ emitNodes(node.children, builder)
360
457
  builder.raw('};\n')
361
458
  return
362
459
  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]
@@ -2,6 +2,7 @@ import { decodeHtmlEntities } from './decodeHtmlEntities.ts'
2
2
  import type { TemplateAttr } from './types/TemplateAttr.ts'
3
3
  import type { TemplateNode } from './types/TemplateNode.ts'
4
4
  import type { TextPart } from './types/TextPart.ts'
5
+ import { VOID_TAGS } from './VOID_TAGS.ts'
5
6
 
6
7
  /*
7
8
  A minimal compile-time parser for the abide template subset: elements, text with
@@ -19,23 +20,6 @@ text, never mistaken for a real style. Keeping it in the tree lets the front-end
19
20
  scope it to its sibling subtree (`analyzeComponent`); the node emits no DOM/markup.
20
21
  */
21
22
 
22
- const VOID_TAGS = new Set([
23
- 'area',
24
- 'base',
25
- 'br',
26
- 'col',
27
- 'embed',
28
- 'hr',
29
- 'img',
30
- 'input',
31
- 'link',
32
- 'meta',
33
- 'param',
34
- 'source',
35
- 'track',
36
- 'wbr',
37
- ])
38
-
39
23
  /* A braced template expression with the absolute source offset of its first
40
24
  (post-trim) character, so the type-checking shadow can map a diagnostic back. */
41
25
  type Braced = { code: string; loc: number }
@@ -245,18 +229,29 @@ export function parseTemplate(source: string, baseOffset = 0): { nodes: Template
245
229
  return { nodes: roots }
246
230
  }
247
231
 
248
- /* Turns a component's attributes into props: a static value becomes a string
249
- literal, an expression keeps its code (event/bind on components ignored). */
232
+ /* Turns a component's attributes into props. A component has no directives
233
+ every attribute is a prop under its written name, so `on*`/`bind:`/`attach`
234
+ round-trip to their original names (the kinds the tag-blind attribute parser
235
+ assigned) instead of being dropped. A static value becomes a string literal;
236
+ every other kind keeps its `code`, letting a prop hold any value, functions
237
+ included (e.g. an `onclick` callback). */
250
238
  function toProps(attrs: TemplateAttr[]): { name: string; code: string; loc?: number }[] {
251
- const props: { name: string; code: string; loc?: number }[] = []
252
- for (const attr of attrs) {
239
+ return attrs.map((attr) => {
253
240
  if (attr.kind === 'static') {
254
- props.push({ name: attr.name, code: JSON.stringify(attr.value) })
255
- } else if (attr.kind === 'expression') {
256
- props.push({ name: attr.name, code: attr.code, loc: attr.loc })
241
+ return { name: attr.name, code: JSON.stringify(attr.value) }
257
242
  }
258
- }
259
- return props
243
+ /* Every non-static kind keeps its `code`/`loc`; only the prop name differs —
244
+ a directive (`event`/`bind`/`attach`) round-trips to its written name. */
245
+ const name =
246
+ attr.kind === 'event'
247
+ ? `on${attr.event}`
248
+ : attr.kind === 'bind'
249
+ ? `bind:${attr.property}`
250
+ : attr.kind === 'attach'
251
+ ? 'attach'
252
+ : attr.name
253
+ return { name, code: attr.code, loc: attr.loc }
254
+ })
260
255
  }
261
256
 
262
257
  /* The literal text of an attribute (a static value or an expression's code);
@@ -1,8 +1,9 @@
1
1
  import ts from 'typescript'
2
+ import { REACTIVE_CALLEES } from './REACTIVE_CALLEES.ts'
2
3
 
3
4
  /*
4
5
  The signal binding names a `<script>` nested in a control-flow branch declares
5
- (`state`/`derived`/`prop`). The back-end adds them to the deref scope so both the
6
+ (`state`/`linked`/`derived`/`prop`). The back-end adds them to the deref scope so both the
6
7
  script body and the branch's markup rewrite `{a}` → `a.value` — these stay PLAIN
7
8
  signals (local to the branch's render, owned by its scope, re-seeded from the
8
9
  in-scope data each mount), unlike the top-level component script which desugars to
@@ -18,7 +19,8 @@ export function nestedBindingNames(code: string): Set<string> {
18
19
  for (const declaration of statement.declarationList.declarations) {
19
20
  const callee = signalCallee(declaration)
20
21
  if (
21
- (callee === 'state' || callee === 'derived' || callee === 'prop') &&
22
+ callee !== undefined &&
23
+ REACTIVE_CALLEES.has(callee) &&
22
24
  ts.isIdentifier(declaration.name)
23
25
  ) {
24
26
  names.add(declaration.name.text)