@abide/abide 0.40.0 → 0.40.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 (40) hide show
  1. package/AGENTS.md +318 -146
  2. package/CHANGELOG.md +42 -0
  3. package/README.md +77 -79
  4. package/package.json +1 -1
  5. package/src/lib/shared/assertExhaustive.ts +14 -0
  6. package/src/lib/ui/compile/TS_PRINTER.ts +7 -0
  7. package/src/lib/ui/compile/analyzeComponent.ts +15 -16
  8. package/src/lib/ui/compile/assertRuntimeHelpersBound.ts +66 -0
  9. package/src/lib/ui/compile/assertTranspiles.ts +19 -0
  10. package/src/lib/ui/compile/compileModule.ts +56 -34
  11. package/src/lib/ui/compile/compileSSR.ts +1 -3
  12. package/src/lib/ui/compile/desugarSignals.ts +168 -107
  13. package/src/lib/ui/compile/generateBuild.ts +8 -1
  14. package/src/lib/ui/compile/generateSSR.ts +7 -0
  15. package/src/lib/ui/compile/hoistCells.ts +2 -2
  16. package/src/lib/ui/compile/lowerContext.ts +23 -10
  17. package/src/lib/ui/compile/lowerDocAccess.ts +29 -3
  18. package/src/lib/ui/compile/lowerScript.ts +64 -0
  19. package/src/lib/ui/compile/renameSignalRefs.ts +160 -102
  20. package/src/lib/ui/compile/stripEffects.ts +22 -17
  21. package/src/lib/ui/compile/types/AnalyzedComponent.ts +5 -0
  22. package/src/lib/ui/dom/awaitBlock.ts +8 -1
  23. package/src/lib/ui/dom/each.ts +4 -0
  24. package/src/lib/ui/dom/switchBlock.ts +4 -0
  25. package/src/lib/ui/dom/when.ts +4 -0
  26. package/src/lib/ui/installInspectorBridge.ts +3 -1
  27. package/src/lib/ui/router.ts +107 -14
  28. package/src/lib/ui/runtime/NODE_STATE.ts +22 -0
  29. package/src/lib/ui/runtime/clientPage.ts +114 -9
  30. package/src/lib/ui/runtime/createComputedNode.ts +5 -3
  31. package/src/lib/ui/runtime/createEffectNode.ts +3 -1
  32. package/src/lib/ui/runtime/createSignalNode.ts +4 -2
  33. package/src/lib/ui/runtime/flushEffects.ts +8 -5
  34. package/src/lib/ui/runtime/historyEntries.ts +39 -1
  35. package/src/lib/ui/runtime/readNode.ts +8 -7
  36. package/src/lib/ui/runtime/runNode.ts +18 -2
  37. package/src/lib/ui/runtime/scope.ts +12 -1
  38. package/src/lib/ui/runtime/trigger.ts +40 -24
  39. package/src/lib/ui/runtime/types/ReactiveNode.ts +4 -1
  40. package/src/lib/ui/runtime/updateIfNecessary.ts +40 -0
@@ -1,6 +1,7 @@
1
1
  import ts from 'typescript'
2
2
  import { REACTIVE_CALLEES } from './REACTIVE_CALLEES.ts'
3
- import { renameSignalRefs } from './renameSignalRefs.ts'
3
+
4
+ const factory = ts.factory
4
5
 
5
6
  /* The reactive primitives that must be reached through a scope. A bare call to one of
6
7
  these is a compile error: reactive state is owned by a scope and the surface must show
@@ -34,28 +35,28 @@ function assertScopedPrimitives(source: ts.SourceFile): void {
34
35
  Desugars the signal surface into the document form. A component's `<script>`
35
36
  declares reactive state as signals:
36
37
 
37
- let count = state(0)
38
- let items = state([])
39
- const total = computed(() => count + items.length)
38
+ let count = scope().state(0)
39
+ let items = scope().state([])
40
+ const total = scope().computed(() => count() + items.length)
40
41
 
41
- This collects the binding names, turns each plain `state(initial)` declaration
42
- into an initialising assignment on a shared `model` document (in source order, so
43
- a later state can read an earlier one), keeps the rest, then renames every
44
- reference through `renameSignalRefs`. Plain `state` becomes `model.x` access that
45
- `lowerDocAccess` lowers to patches/reads the document substrate's deep,
46
- fine-grained, serializable reactivity for free. `linked`, `computed`, and
47
- `state(initial, transform)` stay verbatim as runtime `.value` cells (referenced
48
- as `name.value`): they own no doc slot, so they reseed/recompute on resume rather
49
- than serialize. No reactive declarations the script is returned untouched (the
50
- explicit `const model = doc(...)` form still works).
42
+ This walks the already-parsed script once to collect the binding names, then returns
43
+ a `ts.TransformerFactory` that rewrites the declarations onto a shared `model`
44
+ document: each plain `state(initial)` becomes an initialising assignment (`model.x =
45
+ initial`, in source order so a later state can read an earlier one), each `computed`
46
+ and destructured prop becomes a `scope().derive` slot, and `linked` /
47
+ `state(initial, transform)` stay `.value` cells routed onto the scope. Returning a
48
+ TRANSFORMER (not rebuilt source text) lets the caller (`lowerScript`) chain it with
49
+ reference renaming and doc-access lowering over ONE parsed tree no print-then-reparse
50
+ between passes. Plain `state` becomes `model.x` access that `lowerDocAccess` lowers to
51
+ patches/reads. No reactive declarations an identity transformer (the explicit
52
+ `const model = doc(...)` form still works).
51
53
  */
52
- export function desugarSignals(scriptBody: string): {
53
- code: string
54
+ export function desugarSignals(source: ts.SourceFile): {
55
+ transformer: ts.TransformerFactory<ts.SourceFile>
54
56
  stateNames: Set<string>
55
57
  derivedNames: Set<string>
56
58
  computedNames: Set<string>
57
59
  } {
58
- const source = ts.createSourceFile('script.ts', scriptBody, ts.ScriptTarget.Latest, true)
59
60
  assertScopedPrimitives(source)
60
61
  const stateNames = new Set<string>()
61
62
  const derivedNames = new Set<string>()
@@ -103,43 +104,70 @@ export function desugarSignals(scriptBody: string): {
103
104
  }
104
105
  }
105
106
  }
106
- if (
107
- stateNames.size === 0 &&
108
- derivedNames.size === 0 &&
109
- computedNames.size === 0 &&
110
- !hasPropsDestructure
111
- ) {
112
- return { code: scriptBody, stateNames, derivedNames, computedNames }
113
- }
114
107
 
115
- const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed })
116
- const lines: string[] = []
117
- if (stateNames.size > 0) {
118
- lines.push('const model = scope()')
119
- }
120
- for (const statement of source.statements) {
121
- const stateAssignments = stateDeclarationAssignments(statement, printer, source)
122
- const computedDeclarations = computedDeclarationLines(statement, printer, source)
123
- const propsDestructure = propsDestructureLines(statement, printer, source)
124
- const cellDeclarations = cellDeclarationLines(statement, printer, source)
125
- if (stateAssignments !== undefined) {
126
- lines.push(...stateAssignments)
127
- } else if (computedDeclarations !== undefined) {
128
- lines.push(...computedDeclarations)
129
- } else if (propsDestructure !== undefined) {
130
- lines.push(...propsDestructure)
131
- } else if (cellDeclarations !== undefined) {
132
- lines.push(...cellDeclarations)
133
- } else {
134
- lines.push(printer.printNode(ts.EmitHint.Unspecified, statement, source))
108
+ const hasReactive =
109
+ stateNames.size > 0 ||
110
+ derivedNames.size > 0 ||
111
+ computedNames.size > 0 ||
112
+ hasPropsDestructure
113
+
114
+ const transformer: ts.TransformerFactory<ts.SourceFile> = () => (root) => {
115
+ if (!hasReactive) {
116
+ return root
135
117
  }
118
+ const statements: ts.Statement[] = []
119
+ /* A shared `model = scope()` host for the state slots, prepended once. */
120
+ if (stateNames.size > 0) {
121
+ statements.push(
122
+ constDeclaration(
123
+ 'model',
124
+ factory.createCallExpression(factory.createIdentifier('scope'), undefined, []),
125
+ ),
126
+ )
127
+ }
128
+ for (const statement of root.statements) {
129
+ statements.push(...loweredStatement(statement))
130
+ }
131
+ return factory.updateSourceFile(root, statements)
136
132
  }
137
- return {
138
- code: renameSignalRefs(lines.join('\n'), stateNames, derivedNames, computedNames),
139
- stateNames,
140
- derivedNames,
141
- computedNames,
142
- }
133
+
134
+ return { transformer, stateNames, derivedNames, computedNames }
135
+ }
136
+
137
+ /* The lowered form of a top-level statement: state slots → `model.x = init`
138
+ assignments, computed → `scope().derive` consts, props → derive consts (+ restProps),
139
+ cells → `scope().<callee>(...)` consts; anything else passes through verbatim. The
140
+ per-statement dispatch mirrors the name-collection pass. */
141
+ function loweredStatement(statement: ts.Statement): ts.Statement[] {
142
+ return (
143
+ stateAssignmentStatements(statement) ??
144
+ computedStatements(statement) ??
145
+ propsStatements(statement) ??
146
+ cellStatements(statement) ?? [statement]
147
+ )
148
+ }
149
+
150
+ /* `const NAME = <init>` — a fresh const declaration reusing the original initializer node. */
151
+ function constDeclaration(name: string, initializer: ts.Expression): ts.VariableStatement {
152
+ return factory.createVariableStatement(
153
+ undefined,
154
+ factory.createVariableDeclarationList(
155
+ [factory.createVariableDeclaration(name, undefined, undefined, initializer)],
156
+ ts.NodeFlags.Const,
157
+ ),
158
+ )
159
+ }
160
+
161
+ /* `scope().<method>(...args)` — the reactive method routed onto the ambient scope. */
162
+ function scopeMethodCall(method: string, args: readonly ts.Expression[]): ts.CallExpression {
163
+ return factory.createCallExpression(
164
+ factory.createPropertyAccessExpression(
165
+ factory.createCallExpression(factory.createIdentifier('scope'), undefined, []),
166
+ method,
167
+ ),
168
+ undefined,
169
+ args,
170
+ )
143
171
  }
144
172
 
145
173
  /* True for a read-only computed slot — `computed(compute)` with no write-through
@@ -159,15 +187,11 @@ function isComputedSlot(declaration: ts.VariableDeclaration): boolean {
159
187
  routed onto the scope: `linked(seed)` → `const x = scope().linked(seed)`. The cell
160
188
  stays a standalone non-serializing cell (its refs stay `name.value`); the rewrite only
161
189
  removes the bare runtime import so the sole reactive surface is `scope()`. */
162
- function cellDeclarationLines(
163
- statement: ts.Statement,
164
- printer: ts.Printer,
165
- source: ts.SourceFile,
166
- ): string[] | undefined {
190
+ function cellStatements(statement: ts.Statement): ts.Statement[] | undefined {
167
191
  if (!ts.isVariableStatement(statement)) {
168
192
  return undefined
169
193
  }
170
- const lines: string[] = []
194
+ const statements: ts.Statement[] = []
171
195
  for (const declaration of statement.declarationList.declarations) {
172
196
  const callee = signalCallee(declaration)
173
197
  if (
@@ -178,24 +202,18 @@ function cellDeclarationLines(
178
202
  return undefined
179
203
  }
180
204
  const args = (declaration.initializer as ts.CallExpression).arguments
181
- .map((argument) => printer.printNode(ts.EmitHint.Unspecified, argument, source))
182
- .join(', ')
183
- lines.push(`const ${declaration.name.text} = scope().${callee}(${args})`)
205
+ statements.push(constDeclaration(declaration.name.text, scopeMethodCall(callee, args)))
184
206
  }
185
- return lines
207
+ return statements
186
208
  }
187
209
 
188
210
  /* `let total = computed(compute)` → `const total = scope().derive("total", compute)`
189
211
  — a computed doc slot whose reader the references lower to `total()`. */
190
- function computedDeclarationLines(
191
- statement: ts.Statement,
192
- printer: ts.Printer,
193
- source: ts.SourceFile,
194
- ): string[] | undefined {
212
+ function computedStatements(statement: ts.Statement): ts.Statement[] | undefined {
195
213
  if (!ts.isVariableStatement(statement)) {
196
214
  return undefined
197
215
  }
198
- const lines: string[] = []
216
+ const statements: ts.Statement[] = []
199
217
  for (const declaration of statement.declarationList.declarations) {
200
218
  if (
201
219
  !ts.isIdentifier(declaration.name) ||
@@ -204,16 +222,25 @@ function computedDeclarationLines(
204
222
  ) {
205
223
  return undefined
206
224
  }
207
- const compute = (declaration.initializer as ts.CallExpression).arguments[0]
208
- const computeText =
209
- compute === undefined
210
- ? '() => undefined'
211
- : printer.printNode(ts.EmitHint.Unspecified, compute, source)
212
- lines.push(
213
- `const ${declaration.name.text} = scope().derive(${JSON.stringify(declaration.name.text)}, ${computeText})`,
225
+ const name = declaration.name.text
226
+ const compute =
227
+ (declaration.initializer as ts.CallExpression).arguments[0] ??
228
+ factory.createArrowFunction(
229
+ undefined,
230
+ undefined,
231
+ [],
232
+ undefined,
233
+ factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken),
234
+ factory.createIdentifier('undefined'),
235
+ )
236
+ statements.push(
237
+ constDeclaration(
238
+ name,
239
+ scopeMethodCall('derive', [factory.createStringLiteral(name), compute]),
240
+ ),
214
241
  )
215
242
  }
216
- return lines
243
+ return statements
217
244
  }
218
245
 
219
246
  /* True for `state(initial, transform)` — the write-coercion transform forces a
@@ -308,65 +335,99 @@ function propsBindingKey(element: ts.BindingElement): string {
308
335
 
309
336
  /* If `statement` is a `const {…, ...rest} = props()` destructure, returns one reactive
310
337
  computed per named binding — `scope().derive("name", () => $props["key"]?.() ?? default)`,
311
- read as `name()` — plus a `const rest = restProps($props, [consumed])` line for a rest
312
- binding; otherwise undefined. The `?? default` applies the binding's `= default` fallback
313
- when the prop is absent. */
314
- function propsDestructureLines(
315
- statement: ts.Statement,
316
- printer: ts.Printer,
317
- source: ts.SourceFile,
318
- ): string[] | undefined {
338
+ read as `name()` — plus a `const rest = restProps($props, [consumed])` for a rest
339
+ binding; otherwise undefined. The `?? default` applies the binding's `= default`
340
+ fallback when the prop is absent. */
341
+ function propsStatements(statement: ts.Statement): ts.Statement[] | undefined {
319
342
  if (!ts.isVariableStatement(statement)) {
320
343
  return undefined
321
344
  }
322
- const lines: string[] = []
345
+ const statements: ts.Statement[] = []
323
346
  for (const declaration of statement.declarationList.declarations) {
324
347
  if (signalCallee(declaration) !== 'props' || !ts.isObjectBindingPattern(declaration.name)) {
325
348
  return undefined
326
349
  }
327
350
  const { bindings, rest } = propsDestructure(declaration)
328
351
  for (const { local, key, initializer } of bindings) {
329
- const fallback =
352
+ /* `$props["key"]?.()` — the parent thunk, optionally called. */
353
+ const read = factory.createCallChain(
354
+ factory.createElementAccessExpression(
355
+ factory.createIdentifier('$props'),
356
+ factory.createStringLiteral(key),
357
+ ),
358
+ factory.createToken(ts.SyntaxKind.QuestionDotToken),
359
+ undefined,
360
+ [],
361
+ )
362
+ /* `?? default` only when the binding declared a `= default` fallback. */
363
+ const body =
330
364
  initializer === undefined
331
- ? ''
332
- : ` ?? (${printer.printNode(ts.EmitHint.Unspecified, initializer, source)})`
333
- lines.push(
334
- `const ${local} = scope().derive(${JSON.stringify(local)}, () => $props[${JSON.stringify(key)}]?.()${fallback})`,
365
+ ? read
366
+ : factory.createBinaryExpression(
367
+ read,
368
+ factory.createToken(ts.SyntaxKind.QuestionQuestionToken),
369
+ factory.createParenthesizedExpression(initializer),
370
+ )
371
+ const thunk = factory.createArrowFunction(
372
+ undefined,
373
+ undefined,
374
+ [],
375
+ undefined,
376
+ factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken),
377
+ body,
378
+ )
379
+ statements.push(
380
+ constDeclaration(
381
+ local,
382
+ scopeMethodCall('derive', [factory.createStringLiteral(local), thunk]),
383
+ ),
335
384
  )
336
385
  }
337
386
  /* The rest bag gathers every prop not named above (and not `$children`). */
338
387
  if (rest !== undefined) {
339
- const consumed = bindings.map((binding) => binding.key)
340
- lines.push(`const ${rest} = restProps($props, ${JSON.stringify(consumed)})`)
388
+ const consumed = bindings.map((binding) => factory.createStringLiteral(binding.key))
389
+ statements.push(
390
+ constDeclaration(
391
+ rest,
392
+ factory.createCallExpression(factory.createIdentifier('restProps'), undefined, [
393
+ factory.createIdentifier('$props'),
394
+ factory.createArrayLiteralExpression(consumed),
395
+ ]),
396
+ ),
397
+ )
341
398
  }
342
399
  }
343
- return lines
400
+ return statements
344
401
  }
345
402
 
346
403
  /* If `statement` declares `state(...)` bindings, returns `model.<name> = <init>`
347
- assignment lines (one per declaration); otherwise undefined. */
348
- function stateDeclarationAssignments(
349
- statement: ts.Statement,
350
- printer: ts.Printer,
351
- source: ts.SourceFile,
352
- ): string[] | undefined {
404
+ assignment statements (one per declaration); otherwise undefined. */
405
+ function stateAssignmentStatements(statement: ts.Statement): ts.Statement[] | undefined {
353
406
  if (!ts.isVariableStatement(statement)) {
354
407
  return undefined
355
408
  }
356
- const assignments: string[] = []
409
+ const statements: ts.Statement[] = []
357
410
  for (const declaration of statement.declarationList.declarations) {
358
411
  if (!ts.isIdentifier(declaration.name) || !isPlainStateSlot(declaration)) {
359
412
  /* Only a plain `state(initial)` becomes a slot; `state(initial, transform)`
360
- (and everything else) is a `.value` cell — print it verbatim so the
413
+ (and everything else) is a `.value` cell — pass it through so the
361
414
  runtime call (and its transform) survives. */
362
415
  return undefined
363
416
  }
364
- const initial = (declaration.initializer as ts.CallExpression).arguments[0]
365
- const initialText =
366
- initial === undefined
367
- ? 'undefined'
368
- : printer.printNode(ts.EmitHint.Unspecified, initial, source)
369
- assignments.push(`model.${declaration.name.text} = ${initialText}`)
417
+ const initial =
418
+ (declaration.initializer as ts.CallExpression).arguments[0] ??
419
+ factory.createIdentifier('undefined')
420
+ statements.push(
421
+ factory.createExpressionStatement(
422
+ factory.createAssignment(
423
+ factory.createPropertyAccessExpression(
424
+ factory.createIdentifier('model'),
425
+ declaration.name.text,
426
+ ),
427
+ initial,
428
+ ),
429
+ ),
430
+ )
370
431
  }
371
- return assignments
432
+ return statements
372
433
  }
@@ -1,3 +1,4 @@
1
+ import { assertExhaustive } from '../../shared/assertExhaustive.ts'
1
2
  import { HOLE_ATTRIBUTE } from '../runtime/HOLE_ATTRIBUTE.ts'
2
3
  import { OUTLET_TAG } from '../runtime/OUTLET_TAG.ts'
3
4
  import { asOutlet } from './asOutlet.ts'
@@ -371,7 +372,13 @@ export function generateBuild(
371
372
  if (node.kind === 'snippet') {
372
373
  return generateSnippet(node)
373
374
  }
374
- return generateEach(node, parentVar, before)
375
+ if (node.kind === 'each') {
376
+ return generateEach(node, parentVar, before)
377
+ }
378
+ /* Every TemplateNode kind is handled above; `node` is `never` here. A new kind
379
+ reaching this point is a compiler gap — fail loud instead of silently routing
380
+ it to the wrong branch. */
381
+ return assertExhaustive(node, 'template node kind')
375
382
  }
376
383
 
377
384
  /* Builds a sibling list, coalescing maximal runs of fully-static element subtrees
@@ -1,3 +1,4 @@
1
+ import { assertExhaustive } from '../../shared/assertExhaustive.ts'
1
2
  import { OUTLET_CLOSE, OUTLET_OPEN } from '../runtime/OUTLET_MARKER.ts'
2
3
  import { OUTLET_TAG } from '../runtime/OUTLET_TAG.ts'
3
4
  import { asOutlet } from './asOutlet.ts'
@@ -341,6 +342,12 @@ export function generateSSR(
341
342
  }
342
343
  return generateSlot(node, target, anchor)
343
344
  }
345
+ /* Every non-element kind returned above; a kind reaching the element builder
346
+ that isn't an element is a compiler gap — fail loud rather than emit it as a
347
+ bogus `<undefined>` tag. (`node` narrows to `never` in this branch.) */
348
+ if (node.kind !== 'element') {
349
+ return assertExhaustive(node, 'template node kind')
350
+ }
344
351
  let code = push(target, `<${node.tag}`)
345
352
  /* Every `<style>` active at this element (own siblings + ancestors) — same set
346
353
  the client stamps, so server and client markup carry identical attributes. */
@@ -1,4 +1,5 @@
1
1
  import ts from 'typescript'
2
+ import { TS_PRINTER } from './TS_PRINTER.ts'
2
3
 
3
4
  /*
4
5
  The perf pass that follows `lowerDocAccess`. Within a render scope, a static path
@@ -35,8 +36,7 @@ export function hoistCells(code: string, docName: string): string {
35
36
  }
36
37
 
37
38
  const result = ts.transform(source, [hoistTransformer(docName, cellIdForPath)])
38
- const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed })
39
- const output = printer.printFile(result.transformed[0] as ts.SourceFile)
39
+ const output = TS_PRINTER.printFile(result.transformed[0] as ts.SourceFile)
40
40
  result.dispose()
41
41
  return output
42
42
  }
@@ -1,6 +1,8 @@
1
- import { lowerDocAccess } from './lowerDocAccess.ts'
1
+ import ts from 'typescript'
2
+ import { docAccessTransformer } from './lowerDocAccess.ts'
2
3
  import { nestedBindingNames } from './prepareNestedScript.ts'
3
- import { renameSignalRefs } from './renameSignalRefs.ts'
4
+ import { signalRefsTransformer } from './renameSignalRefs.ts'
5
+ import { TS_PRINTER } from './TS_PRINTER.ts'
4
6
  import type { TemplateNode } from './types/TemplateNode.ts'
5
7
  import { unwrapParens } from './unwrapParens.ts'
6
8
 
@@ -27,19 +29,30 @@ export function lowerContext(
27
29
  const derefScope = (): ReadonlySet<string> =>
28
30
  localDerived.size === 0 ? derivedNames : new Set([...derivedNames, ...localDerived])
29
31
 
30
- /* Rewrites signal refs, then lowers a single expression (no trailing `;`).
31
- Wrapped in parens so a bare object literal (`{ a: 1 }`) parses as an
32
- expression, not a block of labeled statements, through both rewrite passes;
33
- the wrapper is then peeled back off. */
32
+ /* Parse `code` once and chain the reference rename and doc-access lowering over the
33
+ one tree the two string passes would each parse + reprint. `derefScope()` is read
34
+ per call so a nested-`<script>` binding pushed mid-compile is honoured. */
35
+ function lowerOnce(code: string): string {
36
+ const source = ts.createSourceFile('expr.ts', code, ts.ScriptTarget.Latest, true)
37
+ const result = ts.transform(source, [
38
+ signalRefsTransformer(stateNames, derefScope(), computedNames),
39
+ docAccessTransformer('model'),
40
+ ])
41
+ const output = TS_PRINTER.printFile(result.transformed[0] as ts.SourceFile).trim()
42
+ result.dispose()
43
+ return output
44
+ }
45
+
46
+ /* Lowers a single expression (no trailing `;`). Wrapped in parens so a bare object
47
+ literal (`{ a: 1 }`) parses as an expression, not a block of labeled statements,
48
+ through the rewrite; the wrapper is then peeled back off. */
34
49
  function expression(code: string): string {
35
- const renamed = renameSignalRefs(`(${code})`, stateNames, derefScope(), computedNames)
36
- return unwrapParens(lowerDocAccess(renamed, 'model').trim().replace(/;$/, ''))
50
+ return unwrapParens(lowerOnce(`(${code})`).replace(/;$/, ''))
37
51
  }
38
52
 
39
53
  /* As above but keeps the trailing `;` for a statement/handler body. */
40
54
  function statement(code: string): string {
41
- const renamed = renameSignalRefs(code, stateNames, derefScope(), computedNames)
42
- return lowerDocAccess(renamed, 'model').trim()
55
+ return lowerOnce(code)
43
56
  }
44
57
 
45
58
  /* A two-way bind target is either an LVALUE (`count`, `model.lines[i]`) — reads as
@@ -1,5 +1,6 @@
1
1
  import ts from 'typescript'
2
2
  import { escapeKey } from '../runtime/escapeKey.ts'
3
+ import { TS_PRINTER } from './TS_PRINTER.ts'
3
4
 
4
5
  /*
5
6
  The linchpin compiler pass. Rewrites idiomatic data access on a reactive document
@@ -27,8 +28,7 @@ used as an index lowers too.
27
28
  export function lowerDocAccess(code: string, docName: string): string {
28
29
  const source = ts.createSourceFile('component.ts', code, ts.ScriptTarget.Latest, true)
29
30
  const result = ts.transform(source, [docAccessTransformer(docName)])
30
- const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed })
31
- const output = printer.printFile(result.transformed[0] as ts.SourceFile)
31
+ const output = TS_PRINTER.printFile(result.transformed[0] as ts.SourceFile)
32
32
  result.dispose()
33
33
  return output
34
34
  }
@@ -49,7 +49,7 @@ const COMPOUND_OPERATORS = new Map<ts.SyntaxKind, ts.BinaryOperator>([
49
49
  [ts.SyntaxKind.QuestionQuestionEqualsToken, ts.SyntaxKind.QuestionQuestionToken],
50
50
  ])
51
51
 
52
- function docAccessTransformer(docName: string): ts.TransformerFactory<ts.SourceFile> {
52
+ export function docAccessTransformer(docName: string): ts.TransformerFactory<ts.SourceFile> {
53
53
  return (context) => (root) => {
54
54
  /* Collects the path of an access chain rooted at `docName`, visiting any
55
55
  dynamic index so reads inside it lower too. undefined if not our doc. */
@@ -107,6 +107,32 @@ function docAccessTransformer(docName: string): ts.TransformerFactory<ts.SourceF
107
107
  }
108
108
  }
109
109
  }
110
+ /* `path++` / `++path` / `path--` on a doc path → a replace patch (the same
111
+ shape as `+= 1`). A bare `++` would otherwise survive onto the lowered read
112
+ (`model.read("n")++` / `cell.get()++`) — invalid, since a call result is not
113
+ an lvalue. abide handlers are statements, so the postfix/prefix value is
114
+ discarded and both lower identically. Only `++`/`--` are handled here; other
115
+ unary operators (`!path`, `-path`) fall through so their operand lowers as a
116
+ normal read. */
117
+ if (
118
+ (ts.isPostfixUnaryExpression(node) || ts.isPrefixUnaryExpression(node)) &&
119
+ (node.operator === ts.SyntaxKind.PlusPlusToken ||
120
+ node.operator === ts.SyntaxKind.MinusMinusToken)
121
+ ) {
122
+ const segments = pathSegments(node.operand)
123
+ if (segments) {
124
+ const step =
125
+ node.operator === ts.SyntaxKind.PlusPlusToken
126
+ ? ts.SyntaxKind.PlusToken
127
+ : ts.SyntaxKind.MinusToken
128
+ const next = ts.factory.createBinaryExpression(
129
+ docCall(docName, 'read', [buildPath(segments)]),
130
+ step,
131
+ ts.factory.createNumericLiteral(1),
132
+ )
133
+ return docCall(docName, 'replace', [buildPath(segments), next])
134
+ }
135
+ }
110
136
  /* doc array `.push(a, b, …)` → one `add` patch per argument at the array's `-`
111
137
  slot, matching native multi-arg push (each lands at the end in order). */
112
138
  if (
@@ -0,0 +1,64 @@
1
+ import ts from 'typescript'
2
+ import { assertTranspiles } from './assertTranspiles.ts'
3
+ import { desugarSignals } from './desugarSignals.ts'
4
+ import { docAccessTransformer } from './lowerDocAccess.ts'
5
+ import { signalRefsTransformer } from './renameSignalRefs.ts'
6
+ import { stripEffectsTransformer } from './stripEffects.ts'
7
+ import { TS_PRINTER } from './TS_PRINTER.ts'
8
+
9
+ /*
10
+ The component-script lowering, done in ONE parse. The script is parsed once, then
11
+ `desugarSignals` (signal declarations → `model` slots / `scope().derive`), reference
12
+ renaming (`count` → `model.count`), and doc-access lowering (`model.count` → patch/read)
13
+ run as a chained `ts.transform` over the SAME tree — each as a standalone string pass
14
+ would parse + reprint. desugar returns a transformer rather than rebuilt source text
15
+ precisely so it can chain here; the name sets it collects feed the rename transformer.
16
+
17
+ Imports are partitioned off the transformed tree structurally (`ts.isImportDeclaration`),
18
+ not by regex, so a multi-line import hoists correctly regardless of formatting. The
19
+ reassembled output is transpiled as a fail-loud guard: if a future un-handled rewrite
20
+ position corrupts the script (the failure mode the syntax fuzz corpus also guards), it
21
+ surfaces here as a located compile error instead of shipping a broken bundle.
22
+ */
23
+
24
+ export function lowerScript(scriptBody: string): {
25
+ body: string
26
+ imports: string
27
+ ssrBody: string
28
+ stateNames: Set<string>
29
+ derivedNames: Set<string>
30
+ computedNames: Set<string>
31
+ } {
32
+ const source = ts.createSourceFile('component.ts', scriptBody, ts.ScriptTarget.Latest, true)
33
+ const { transformer, stateNames, derivedNames, computedNames } = desugarSignals(source)
34
+ const result = ts.transform(source, [
35
+ transformer,
36
+ signalRefsTransformer(stateNames, derivedNames, computedNames),
37
+ docAccessTransformer('model'),
38
+ ])
39
+ const transformed = result.transformed[0] as ts.SourceFile
40
+ /* Top-level imports must live at module scope, not inside the mount/render
41
+ function the script body becomes — hoist them off the tree. */
42
+ const importStatements = transformed.statements.filter(ts.isImportDeclaration)
43
+ const bodyStatements = transformed.statements.filter(
44
+ (statement) => !ts.isImportDeclaration(statement),
45
+ )
46
+ const imports = importStatements
47
+ .map((statement) => TS_PRINTER.printNode(ts.EmitHint.Unspecified, statement, transformed))
48
+ .join('\n')
49
+ const bodyFile = ts.factory.updateSourceFile(transformed, bodyStatements)
50
+ const body = TS_PRINTER.printFile(bodyFile).trim()
51
+ /* The SSR variant strips client-only `effect(...)` calls — run over the SAME lowered
52
+ tree (one extra transform + print, no reparse) instead of re-parsing the printed
53
+ script downstream. */
54
+ const ssrResult = ts.transform(bodyFile, [stripEffectsTransformer()])
55
+ const ssrBody = TS_PRINTER.printFile(ssrResult.transformed[0] as ts.SourceFile).trim()
56
+ ssrResult.dispose()
57
+ result.dispose()
58
+
59
+ assertTranspiles(
60
+ [imports, body].filter((part) => part !== '').join('\n'),
61
+ 'component script lowering',
62
+ )
63
+ return { body, imports, ssrBody, stateNames, derivedNames, computedNames }
64
+ }