@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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,47 @@
1
1
  # abide
2
2
 
3
+ ## 0.40.1
4
+
5
+ ### Patch Changes
6
+
7
+ - [`32d6cb9`](https://github.com/briancray/abide/commit/32d6cb97a9ce2ad63b3fd768c02379e708c6d599) - share a single TS printer across compile passes ([`0626e68`](https://github.com/briancray/abide/commit/0626e68500b6cc8e7e2c770184ec4aa0fd06106d))
8
+
9
+ - [`32d6cb9`](https://github.com/briancray/abide/commit/32d6cb97a9ce2ad63b3fd768c02379e708c6d599) - bind-check generated modules and collect identifiers via AST ([`1e9d350`](https://github.com/briancray/abide/commit/1e9d3506873121ffd978039653f920edbed6c9ce))
10
+
11
+ - [`32d6cb9`](https://github.com/briancray/abide/commit/32d6cb97a9ce2ad63b3fd768c02379e708c6d599) - value-memoised push-pull reactivity (CLEAN/CHECK/DIRTY) ([`2ae8f14`](https://github.com/briancray/abide/commit/2ae8f141da5873bea0eb31a9a59d81182ca5a12a))
12
+
13
+ - [`32d6cb9`](https://github.com/briancray/abide/commit/32d6cb97a9ce2ad63b3fd768c02379e708c6d599) - syntax fuzz corpus for the identifier-rewrite passes ([`2e7d604`](https://github.com/briancray/abide/commit/2e7d6043aeeb9ca894bdb45f3f27893fbc5bf2f8))
14
+
15
+ - [`32d6cb9`](https://github.com/briancray/abide/commit/32d6cb97a9ce2ad63b3fd768c02379e708c6d599) - build control-flow subtrees untracked ([`33c0d8e`](https://github.com/briancray/abide/commit/33c0d8e834e5145a84d872ff496df122bbb0da95))
16
+
17
+ - [`32d6cb9`](https://github.com/briancray/abide/commit/32d6cb97a9ce2ad63b3fd768c02379e708c6d599) - lower the component script in a single parse ([`53098f8`](https://github.com/briancray/abide/commit/53098f8c2fe36e46e61b41d8a617aba6ac75e96f))
18
+
19
+ - [`32d6cb9`](https://github.com/briancray/abide/commit/32d6cb97a9ce2ad63b3fd768c02379e708c6d599) - regenerate README and AGENTS surface map ([`6bb6a39`](https://github.com/briancray/abide/commit/6bb6a3906d5aba522a4fe33bffae0911adf0b0fe))
20
+
21
+ - [`32d6cb9`](https://github.com/briancray/abide/commit/32d6cb97a9ce2ad63b3fd768c02379e708c6d599) - spread reactive page.params before postMessage in inspector bridge ([`7c99395`](https://github.com/briancray/abide/commit/7c993959e9dfc0c7f7038f694f95b59093a10dfc))
22
+
23
+ - [`32d6cb9`](https://github.com/briancray/abide/commit/32d6cb97a9ce2ad63b3fd768c02379e708c6d599) - make signal-rewrite parent-independent ([`868717a`](https://github.com/briancray/abide/commit/868717a6f7ff824b24ef8a96e5e5f59add5ceed1))
24
+
25
+ - [`32d6cb9`](https://github.com/briancray/abide/commit/32d6cb97a9ce2ad63b3fd768c02379e708c6d599) - pick runtime imports by tokenizing the output, not substring grep ([`a102986`](https://github.com/briancray/abide/commit/a102986bef90609297246ea001528a32e111be53))
26
+
27
+ - [`32d6cb9`](https://github.com/briancray/abide/commit/32d6cb97a9ce2ad63b3fd768c02379e708c6d599) - re-apply saved scroll offset until async content grows the page ([`a813b47`](https://github.com/briancray/abide/commit/a813b47387289c0049e63997a72a6805e461b18c))
28
+
29
+ - [`32d6cb9`](https://github.com/briancray/abide/commit/32d6cb97a9ce2ad63b3fd768c02379e708c6d599) - aliased import mangled when its name collides with a signal ([`abfe25d`](https://github.com/briancray/abide/commit/abfe25dcf7c00e61156b5d36fe82f290fc6c4cfb))
30
+
31
+ - [`32d6cb9`](https://github.com/briancray/abide/commit/32d6cb97a9ce2ad63b3fd768c02379e708c6d599) - granular reactive page snapshot + in-place same-route navigation ([`b561eb5`](https://github.com/briancray/abide/commit/b561eb52439577d3a75a7872befee4b9f0e98267))
32
+
33
+ - [`32d6cb9`](https://github.com/briancray/abide/commit/32d6cb97a9ce2ad63b3fd768c02379e708c6d599) - lower the component script in a literal single parse ([`b72b238`](https://github.com/briancray/abide/commit/b72b238377ff6545de549589d36b49ad4ecebb2d))
34
+
35
+ - [`32d6cb9`](https://github.com/briancray/abide/commit/32d6cb97a9ce2ad63b3fd768c02379e708c6d599) - chain per-expression lowering over a single parse ([`d808317`](https://github.com/briancray/abide/commit/d808317d817ae8a414695b7bfb037b8aff7017c3))
36
+
37
+ - [`32d6cb9`](https://github.com/briancray/abide/commit/32d6cb97a9ce2ad63b3fd768c02379e708c6d599) - classify signal-rewrite positions at visit time ([`d99577e`](https://github.com/briancray/abide/commit/d99577e2ac77d8742088608fba554ff5c2e799a4))
38
+
39
+ - [`32d6cb9`](https://github.com/briancray/abide/commit/32d6cb97a9ce2ad63b3fd768c02379e708c6d599) - make template-node generators exhaustive ([`e0f246d`](https://github.com/briancray/abide/commit/e0f246dfbbf4ea4f2dc481fc543a6e9f0a6fa43b))
40
+
41
+ - [`32d6cb9`](https://github.com/briancray/abide/commit/32d6cb97a9ce2ad63b3fd768c02379e708c6d599) - lower ++/-- on a doc path; guard the whole generated module ([`f49c122`](https://github.com/briancray/abide/commit/f49c122354fc4ef65f4c44db820d703078dd57f4))
42
+
43
+ - [`32d6cb9`](https://github.com/briancray/abide/commit/32d6cb97a9ce2ad63b3fd768c02379e708c6d599) - fold SSR effect-stripping into the single-parse chain ([`fa4515d`](https://github.com/briancray/abide/commit/fa4515d07164765ea0ee609cf1847535a41f95ed))
44
+
3
45
  ## 0.40.0
4
46
 
5
47
  ### Minor Changes
package/README.md CHANGED
@@ -1,159 +1,157 @@
1
1
  # abide
2
2
 
3
- **One typed RPC declaration fans out to an HTTP route, a typed browser proxy, a CLI subcommand, an MCP tool, and an OpenAPI operation — built for humans and machines.**
3
+ **One typed declaration fans out to HTTP, a CLI, an MCP tool, and an OpenAPI
4
+ spec — and the bundler swaps the runtime per side.**
4
5
 
5
- abide is an isomorphic framework on Bun: the same callable has the same name and behaviour on both sides, and the bundler swaps the runtime per side — a server handler in `src/server`, a `fetch` proxy in the browser. Server-render and hydrate the same `.abide` components, broadcast over multiplexed sockets, and expose every read to an agent — all in a single Bun runtime.
6
+ abide is an isomorphic framework on Bun and web standards: you declare a verb,
7
+ a socket, or a component once, and the same callable runs server-side, in the
8
+ browser, from the terminal, and from an agent — the bundler decides which
9
+ runtime each side gets. Built for humans *and* machines.
6
10
 
7
- - One dependency (`typescript`, for the type-checking shadow); `tailwindcss` + `bun-plugin-tailwind` are optional peers.
8
- - Runs on Bun `>= 1.3.0`. No bundler config, no server framework, no client router to wire up.
11
+ - One direct dependency the TypeScript compiler, used by `abide check`.
12
+ Tailwind is an optional peer. Everything runs on a single Bun runtime
13
+ (≥ 1.3.0); no second toolchain.
9
14
 
10
15
  ## Quick start
11
16
 
12
17
  ```sh
13
- bunx abide scaffold my-app # scaffolds, installs deps, and (in a TTY) starts dev
18
+ bunx abide scaffold my-app # copies the template, installs, starts dev
14
19
  ```
15
20
 
16
- Or read the exhaustive demo:
21
+ Or clone the kitchen-sink and run it:
17
22
 
18
23
  ```sh
19
24
  git clone https://github.com/briancray/abide
20
- cd abide && bun install
21
- cd examples/kitchen-sink && bun dev
25
+ cd abide/examples/kitchen-sink
26
+ bun install
27
+ bun run dev
22
28
  ```
23
29
 
24
- The whole public surface — every export, CLI command, route, env var, and the `.abide` grammar — is mapped in [`AGENTS.md`](./AGENTS.md).
25
-
26
30
  ## RPCs
27
31
 
28
- An RPC is one export per file under `src/server/rpc/`. The file path is the URL (`getMessages.ts` → `/rpc/getMessages`), and a [Standard Schema](https://standardschema.dev) (zod / valibot / arktype, unadapted) validates the args and projects every other surface.
32
+ An RPC is one export per file under `src/server/rpc/`. **The file path is the
33
+ URL.** The schema validates arguments and projects the MCP tool, the CLI flags,
34
+ and the OpenAPI operation. The contract is Standard Schema — zod, valibot, or
35
+ arktype, unadapted.
29
36
 
30
37
  ```ts
31
- // src/server/rpc/getMessages.ts
38
+ // src/server/rpc/getMessages.ts — file path is the route: GET /getMessages
32
39
  import { GET } from '@abide/abide/server/GET'
33
40
  import { json } from '@abide/abide/server/json'
34
41
  import { z } from 'zod'
35
- import { history } from '../../store.ts'
36
42
 
37
43
  const inputSchema = z.object({ room: z.string() })
38
44
 
39
- export const getMessages = GET(({ room }) => json({ messages: history(room) }), { inputSchema })
45
+ export const getMessages = GET(({ room }) => json(board(room)), { inputSchema })
40
46
  ```
41
47
 
42
- That one declaration fans out:
48
+ One declaration, five surfaces:
43
49
 
44
50
  ```text
45
- getMessages (one declaration)
46
-
47
- ┌──────────┬─────────┼─────────┬──────────────┐
48
- ▼ ▼ ▼ ▼ ▼
49
- SSR call browser MCP tool CLI sub- OpenAPI
50
- cache(fn)() fetch (read-only) command operation
51
+ getMessages = GET(fn, { inputSchema })
52
+
53
+ ┌───────────┬─────────────┼────────────┬──────────────┐
54
+ SSR call browser fetch MCP tool CLI command OpenAPI op
55
+ cache(fn)() typed proxy() (read-only) abide ... cli /openapi.json
51
56
  ```
52
57
 
53
- The `inputSchema` unlocks the CLI and because a `GET`/`HEAD` is read-only — an MCP tool. A mutating verb (`POST`/`PUT`/`PATCH`/`DELETE`) never auto-exposes to an agent; opt in with `clients: { mcp: true }`. Consume the verb four ways, all typed against the declaration: `cache(getMessages)({ room })` for an SSR-memoized in-process read, the bundler-swapped `getMessages({ room })` `fetch` proxy in the browser, `.raw(args)` for the untouched `Response`, and `.stream(args)` for a frame-by-frame `jsonl`/`sse` body.
58
+ A schema unlocks the CLI everywhere and MCP for read-only verbs; a mutating
59
+ verb never auto-exposes to MCP — it needs explicit `clients: { mcp: true }`.
60
+ Consume the verb four ways: `cache(getMessages)({ room })` resolves in-process
61
+ during SSR, the same call hits a swapped `fetch` in the browser,
62
+ `getMessages.raw(args)` returns the undecoded `Response`, and
63
+ `getMessages.stream(args)` is an iterable view of the body.
54
64
 
55
- > Query args travel as strings — use `z.coerce.*` for numbers and booleans. The per-verb `timeout` option fires a `504` on every surface and is distinct from the client-wide `ABIDE_CLIENT_TIMEOUT`.
65
+ > Query args travel as strings — validate with `z.coerce.*`. The per-verb
66
+ > `timeout` (504 on every surface) is distinct from `ABIDE_CLIENT_TIMEOUT`.
56
67
 
57
68
  ## Sockets
58
69
 
59
- A socket is one broadcast topic per file under `src/server/sockets/`. A `Socket<T>` is an isomorphic `AsyncIterable<T>` — `for await` it on the server, or read it reactively with `tail()` in a component — and every socket multiplexes onto one WebSocket at `/__abide/sockets`.
70
+ A socket is one broadcast topic per file under `src/server/sockets/`. A
71
+ `Socket<T>` is an isomorphic `AsyncIterable<T>`, and every socket multiplexes
72
+ onto one websocket at `/__abide/sockets`.
60
73
 
61
74
  ```ts
62
- // src/server/sockets/messages.ts
75
+ // src/server/sockets/messages.ts — topic name = file name: `messages`
63
76
  import { socket } from '@abide/abide/server/socket'
64
77
  import { z } from 'zod'
65
78
 
66
- const schema = z.object({
67
- id: z.string(),
68
- room: z.string(),
69
- from: z.string(),
70
- text: z.string(),
71
- at: z.number(),
72
- })
79
+ const schema = z.object({ id: z.string(), room: z.string(), from: z.string(), text: z.string() })
73
80
 
74
- // retain the last 100 frames; lazily evict any older than an hour
75
- export const messages = socket({ schema, tail: 100, ttl: 3_600_000 })
81
+ // retain the last 50 frames; evict any older than an hour
82
+ export const messages = socket({ schema, tail: 50, ttl: 3_600_000 })
76
83
  export type Message = z.infer<typeof schema>
77
84
  ```
78
85
 
79
- The schema validates every publish and flips on the read surfaces (a `messages-tail` MCP tool / CLI command). For clients that can't speak the ws multiplex, each socket has an HTTP face at `/__abide/sockets/messages`: `GET` returns the retained tail, `POST` publishes — gated by `clientPublish` (default `false`, so browsers publish through a validating verb instead).
86
+ The socket also has an HTTP face at `/__abide/sockets/messages`, for clients
87
+ that can't speak the ws multiplex: `GET` returns the retained tail, `POST`
88
+ publishes — gated by `clientPublish` (off by default, so the POST 403s).
80
89
 
81
90
  ## Components
82
91
 
83
- A component is an `.abide` file: valid HTML with a `<script>`, native `<template>` control flow, `{expr}` bindings, and a component-scoped `<style>`. `scope()` is the sole reactive surface; `props`, `effect`, and `html` are in scope without import. This one imports the verb and socket above:
92
+ A `.abide` component is the payoff: it imports the verb and the socket above
93
+ and ties them together with the native `<template>` grammar. Reactive state is
94
+ reached only through `scope()` — `scope().state()` is writable,
95
+ `scope().computed()` is read-only. `props()` and `effect()` are in-scope, no
96
+ import.
84
97
 
85
98
  ```html
86
99
  <script>
87
100
  import { cache } from '@abide/abide/shared/cache'
88
101
  import { tail } from '@abide/abide/ui/tail'
89
102
  import { getMessages } from '$server/rpc/getMessages.ts'
90
- import { sendMessage } from '$server/rpc/sendMessage.ts'
103
+ import { postMessage } from '$server/rpc/postMessage.ts'
91
104
  import { messages } from '$server/sockets/messages.ts'
92
105
  import Avatar from '$ui/Avatar.abide'
93
106
 
94
- const { room } = props<{ room: string }>()
107
+ const { room = 'lobby' } = props<{ room?: string }>()
108
+
109
+ // SSR-warm history; live frames then stream over the ws
110
+ const seed = cache(getMessages)({ room })
111
+ const live = scope().computed(() => tail(messages, { last: 50 }))
95
112
 
96
- let from = scope().state('alice') // a writable cell — read/assign as a plain var
97
- let draft = scope().state('')
113
+ let from = scope().state('alice')
114
+ let text = scope().state('')
98
115
  let pinned = scope().state(false)
99
- let filter = scope().state('all')
100
- const live = scope().computed(() => tail(messages)) // re-renders on every new frame
101
-
102
- async function send(event: SubmitEvent) {
103
- event.preventDefault()
104
- if (draft.trim() === '') return
105
- await sendMessage({ room, from, text: draft }) // a mutating verb
106
- draft = ''
116
+ let sort = scope().state('newest')
117
+
118
+ async function send() {
119
+ await postMessage({ room, from, text })
120
+ text = ''
107
121
  }
108
122
  </script>
109
123
 
110
- <!-- a named snippet: a reusable builder, rendered like a function -->
111
124
  <template name="bubble" args={msg}>
112
- <li><Avatar name={msg.from} /> <b>{msg.from}</b>: {msg.text}</li>
125
+ <li class="flex gap-2"><Avatar name={msg.from} /> <b>{msg.from}</b> {msg.text}</li>
113
126
  </template>
114
127
 
115
- <h1>#{room}</h1>
116
-
117
- <form onsubmit={send}>
118
- <input bind:value={from} />
119
- <input bind:value={draft} placeholder="message" />
128
+ <form onsubmit={send} class="flex gap-2">
129
+ <input bind:value={from} class="border px-2" />
130
+ <input bind:value={text} placeholder="message" class="flex-1 border px-2" />
120
131
  <label><input type="checkbox" bind:checked={pinned} /> pin</label>
121
- <label><input type="radio" bind:group={filter} value="all" /> all</label>
122
- <label><input type="radio" bind:group={filter} value="mine" /> mine</label>
123
- <button type="submit" disabled={draft.trim() === ''}>send</button>
132
+ <label><input type="radio" bind:group={sort} value="newest" /> newest</label>
133
+ <button disabled={!text} class="border px-3">send</button>
124
134
  </form>
125
135
 
126
- <template if={live}>
127
- <p class="live">latest: {live.text}</p>
128
- <template else>
129
- <p>no messages yet</p>
130
- </template>
131
- </template>
132
-
133
- <template switch={filter}>
134
- <template case={'all'}><p>showing everyone</p></template>
135
- <template case={'mine'}><p>showing {from}</p></template>
136
- <template default><p>—</p></template>
136
+ <template switch={sort}>
137
+ <template case={'newest'}><p class="text-xs">newest first</p></template>
138
+ <template default><p class="text-xs">oldest first</p></template>
137
139
  </template>
138
140
 
139
- <template await={cache(getMessages)({ room })}>
140
- <p>loading…</p>
141
+ <template await={seed}>
142
+ <p class="text-xs text-slate-500">loading…</p>
141
143
  <template then="history">
142
- <ul>
143
- <template each={history.messages} as="msg" key="msg.id">
144
+ <ul class="mt-3 space-y-1">
145
+ <template each={live ?? history} as="msg" key="msg.id">
144
146
  {bubble(msg)}
145
147
  </template>
146
148
  </ul>
147
149
  </template>
148
- <template catch="error">
149
- <p>failed: {error.message}</p>
150
- </template>
150
+ <template catch="err"><p class="text-rose-700">{err.message}</p></template>
151
151
  </template>
152
152
 
153
153
  <style>
154
- .live {
155
- font-weight: 600;
156
- }
154
+ li { font-size: 0.875rem; }
157
155
  </style>
158
156
  ```
159
157
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abide/abide",
3
- "version": "0.40.0",
3
+ "version": "0.40.1",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "description": "Isomorphic multimodal HTTP framework built for humans and machines in a single Bun runtime",
@@ -0,0 +1,14 @@
1
+ /*
2
+ Exhaustiveness guard for a discriminated-union dispatch. When every variant is
3
+ handled, the fall-through value narrows to `never`, so passing it here type-checks;
4
+ adding a new variant without a branch makes this a COMPILE error AND, if it slips
5
+ past types, throws at runtime naming the variant — converting a silent mis-dispatch
6
+ (a new template-node kind routed to the wrong branch) into a loud failure. The
7
+ enumeration's completeness becomes the type checker's job instead of inspection's.
8
+ */
9
+ export function assertExhaustive(value: never, context = 'variant'): never {
10
+ const kind = (value as { kind?: unknown })?.kind
11
+ throw new Error(
12
+ `[abide] non-exhaustive ${context}${kind === undefined ? '' : `: ${String(kind)}`}`,
13
+ )
14
+ }
@@ -0,0 +1,7 @@
1
+ import ts from 'typescript'
2
+
3
+ /* The shared TypeScript printer every compile pass prints its transformed tree with.
4
+ A `ts.Printer` is stateless and reusable, so the passes share one instance rather
5
+ than each re-creating the same `{ newLine: LineFeed }` printer. LineFeed keeps the
6
+ emitted source `\n`-delimited regardless of the host platform. */
7
+ export const TS_PRINTER = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed })
@@ -1,5 +1,4 @@
1
- import { desugarSignals } from './desugarSignals.ts'
2
- import { lowerDocAccess } from './lowerDocAccess.ts'
1
+ import { lowerScript } from './lowerScript.ts'
3
2
  import { parseTemplate } from './parseTemplate.ts'
4
3
  import { scopeCss } from './scopeCss.ts'
5
4
  import type { AnalyzedComponent } from './types/AnalyzedComponent.ts'
@@ -30,19 +29,18 @@ export function analyzeComponent(source: string, scopeSeed?: string): AnalyzedCo
30
29
  const scriptBody = (scriptMatch?.[1] ?? '').trim()
31
30
  const template = source.replace(/^\s*<script[^>]*>[\s\S]*?<\/script>/, '').trim()
32
31
 
33
- const { code: desugared, stateNames, derivedNames, computedNames } = desugarSignals(scriptBody)
34
- const lowered = desugared.trim() === '' ? '' : lowerDocAccess(desugared, 'model')
35
- /* Hoist top-level import statements (e.g. child components) out of the script
36
- so the module wrapper can place them at module scope they can't live
37
- inside the mount callback / render function. */
38
- const imports: string[] = []
39
- const script = lowered
40
- .replace(/^[ \t]*import\s[^\n]*$/gm, (line) => {
41
- imports.push(line.trim())
42
- return ''
43
- })
44
- .replace(/\n{2,}/g, '\n')
45
- .trim()
32
+ /* `lowerScript` parses the script ONCE and chains signal desugaring, reference
33
+ renaming, and doc-access lowering over that single tree, then hoists top-level
34
+ imports off the tree structurally imports live at module scope, not inside the
35
+ mount/render function the body becomes. It returns the collected signal name sets. */
36
+ const {
37
+ body: script,
38
+ ssrBody: ssrScript,
39
+ imports,
40
+ stateNames,
41
+ derivedNames,
42
+ computedNames,
43
+ } = lowerScript(scriptBody)
46
44
  /* The parser keeps each `<style>` as an in-place node (one inside an expression
47
45
  is text, never a node). `annotateScopes` mutates the tree — assigning each
48
46
  style its scope attribute and stamping covered elements — and returns the
@@ -51,7 +49,8 @@ export function analyzeComponent(source: string, scopeSeed?: string): AnalyzedCo
51
49
  const styles = annotateScopes(nodes, [], scopeSeed, { count: 0 })
52
50
  return {
53
51
  script,
54
- imports: imports.join('\n'),
52
+ ssrScript,
53
+ imports,
55
54
  stateNames,
56
55
  derivedNames,
57
56
  computedNames,
@@ -0,0 +1,66 @@
1
+ import ts from 'typescript'
2
+ import { UI_RUNTIME_IMPORTS } from './UI_RUNTIME_IMPORTS.ts'
3
+
4
+ /*
5
+ Independent backstop on the per-component dead-import filter (`compileModule`). The filter
6
+ decides which runtime helpers to import by reading the generated output's identifiers; if it
7
+ ever undercounts — as a raw token scan once did, mis-reading the tail of a module after a
8
+ `${…}` template substitution — a helper gets CALLED but never imported, and the bundle throws
9
+ `ReferenceError` the instant `build()` runs. The router escalates that into a reload loop, so
10
+ the failure is both opaque and unrecoverable.
11
+
12
+ This re-derives the same question a DIFFERENT way (so it can't share the filter's blind spot):
13
+ walk the final module's AST, find every call whose callee is a bare runtime-helper identifier,
14
+ and require that name to be imported or locally bound. A helper name inside a string / template /
15
+ comment is not a `CallExpression` callee, so a docs component quoting framework code
16
+ (`mount(...)` in a snippet) never false-positives. Compile-time only; never on the hot path.
17
+ */
18
+ export function assertRuntimeHelpersBound(module: string, context: string): void {
19
+ const helperNames = new Set(UI_RUNTIME_IMPORTS.map((entry) => entry.name))
20
+ const source = ts.createSourceFile(
21
+ 'module.ts',
22
+ module,
23
+ ts.ScriptTarget.Latest,
24
+ /* setParentNodes */ true,
25
+ )
26
+ /* Names a bare call can resolve to: every import binding plus every declared name.
27
+ Collected generously (any identifier in a binding position) — over-approximating the
28
+ bound set only risks missing a defect, never raising a false alarm on valid output. */
29
+ const bound = new Set<string>()
30
+ const calledHelpers: { name: string; position: number }[] = []
31
+ const visit = (node: ts.Node): void => {
32
+ if (
33
+ (ts.isVariableDeclaration(node) ||
34
+ ts.isFunctionDeclaration(node) ||
35
+ ts.isParameter(node) ||
36
+ ts.isBindingElement(node) ||
37
+ ts.isImportSpecifier(node) ||
38
+ ts.isImportClause(node) ||
39
+ ts.isNamespaceImport(node)) &&
40
+ node.name !== undefined &&
41
+ ts.isIdentifier(node.name)
42
+ ) {
43
+ bound.add(node.name.text)
44
+ }
45
+ if (
46
+ ts.isCallExpression(node) &&
47
+ ts.isIdentifier(node.expression) &&
48
+ helperNames.has(node.expression.text)
49
+ ) {
50
+ calledHelpers.push({
51
+ name: node.expression.text,
52
+ position: node.expression.getStart(source),
53
+ })
54
+ }
55
+ node.forEachChild(visit)
56
+ }
57
+ visit(source)
58
+
59
+ const unbound = calledHelpers.find((call) => !bound.has(call.name))
60
+ if (unbound !== undefined) {
61
+ const { line, character } = source.getLineAndCharacterOfPosition(unbound.position)
62
+ throw new Error(
63
+ `[abide] ${context} calls runtime helper \`${unbound.name}\` at line ${line + 1}:${character + 1} but never imports it — the dead-import filter dropped it. Please report this with the component source.\nOutput:\n${module}`,
64
+ )
65
+ }
66
+ }
@@ -0,0 +1,19 @@
1
+ /*
2
+ Fail-loud guard for generated code. The compile passes build modules as strings and
3
+ AST — a corruption (an un-handled rewrite position, a generator bug, a stale lowering)
4
+ otherwise ships as a broken bundle that fails opaquely at load. Transpiling the output
5
+ turns it into a compile-time error naming the stage and showing the offending source.
6
+ `context` labels which stage produced it. Compile-time only; never on the hot path.
7
+ */
8
+
9
+ const transpiler = new Bun.Transpiler({ loader: 'ts' })
10
+
11
+ export function assertTranspiles(code: string, context: string): void {
12
+ try {
13
+ transpiler.transformSync(code)
14
+ } catch (error) {
15
+ throw new Error(
16
+ `[abide] ${context} produced invalid syntax — please report this with the component source. Output:\n${code}\n\n${String(error)}`,
17
+ )
18
+ }
19
+ }
@@ -1,5 +1,8 @@
1
+ import ts from 'typescript'
1
2
  import { ABIDE_PACKAGE_NAME } from '../../shared/ABIDE_PACKAGE_NAME.ts'
2
3
  import { analyzeComponent } from './analyzeComponent.ts'
4
+ import { assertRuntimeHelpersBound } from './assertRuntimeHelpersBound.ts'
5
+ import { assertTranspiles } from './assertTranspiles.ts'
3
6
  import { compileComponent } from './compileComponent.ts'
4
7
  import { compileSSR } from './compileSSR.ts'
5
8
  import { UI_RUNTIME_IMPORTS } from './UI_RUNTIME_IMPORTS.ts'
@@ -58,14 +61,6 @@ if (!hotReplace(${id}, component)) location.reload()
58
61
  }
59
62
 
60
63
  const ssrBody = indent(compileSSR(source, isLayout, options.moduleId, analyzed))
61
- /* Per-component dead-import elimination: emit only the runtime names this module
62
- actually references. A component that uses no `each`/`await`/`html` shouldn't
63
- drag those modules into its chunk. The package isn't globally side-effect-free
64
- (the dev/runtime entries register globals), so a bundler can't tree-shake the
65
- unused imports for us — but the generated code is the one place that knows
66
- exactly which names it emitted, so it filters here. A name absent from the body
67
- is unreferenced; erring toward inclusion (a stray match in user script) only
68
- keeps a harmless unused import, never drops a needed one. */
69
64
  const moduleBody = `function build(host, $props) {
70
65
  ${body}
71
66
  }
@@ -89,37 +84,37 @@ component.hydrate = hydrateInto
89
84
  component.build = build
90
85
  component.hydratable = ${analyzed.hydratable}
91
86
  ${options.moduleId === undefined ? '' : `component.__abideId = ${JSON.stringify(options.moduleId)}\n`}`
92
- /* Scope each name to the surface that genuinely references it. The SSR body
93
- carries fixed boilerplate ($attr/$text helpers, `<!--abide:html-->` markers,
94
- the `{ html, state, awaits }` return shape) whose substrings would falsely
95
- match value/DOM imports so client helpers are matched against the client
96
- build, render-pass helpers against the SSR body, and the two wrapper calls
97
- (mount/hydrate) are always present. */
98
- const clientSurface = `${userImports}\n${body}`
99
- const isReferenced = (entry: { name: string; specifier: string }): boolean => {
100
- if (entry.name === 'mount' || entry.name === 'hydrate') {
101
- return true
102
- }
103
- /* The SSR render always brackets itself with a per-render scope (compileSSR),
104
- so these are referenced whenever the `render` export is — i.e. always. */
105
- if (entry.name === 'enterScope' || entry.name === 'exitScope') {
106
- return true
107
- }
108
- /* Render-pass helpers are emitted by both back-ends (e.g. client and server
109
- await/try blocks both call nextBlockId); their names are distinctive enough
110
- that scanning both surfaces adds no false match. */
111
- const surface = entry.specifier.startsWith('ui/runtime/')
112
- ? `${clientSurface}\n${ssrBody}`
113
- : clientSurface
114
- return new RegExp(`\\b${entry.name}\\b`).test(surface)
115
- }
116
- const importBlock = UI_RUNTIME_IMPORTS.filter(isReferenced)
87
+ /* Per-component dead-import elimination: emit only the runtime helpers this module
88
+ references. A component using no `each`/`await`/`html` shouldn't drag those modules
89
+ into its chunk, and the package isn't globally side-effect-free (the dev/runtime
90
+ entries register globals), so a bundler can't tree-shake them for us.
91
+ Which to keep: tokenize the generated body and keep the names it genuinely
92
+ references. Reading the ACTUAL output (not hand-tracking emit sites) is safe by
93
+ construction it can never drop a needed import, the way a missed emit-site tag
94
+ could. Tokenizing rather than substring-matching (`\bname\b`) means a name inside a
95
+ string/comment/HTML literal (e.g. an `on`-attribute in static markup) no longer
96
+ forces a spurious import, so no per-surface scoping is needed: a client-only helper
97
+ simply doesn't appear as an identifier in the SSR body. */
98
+ const referenced = collectIdentifiers(`${userImports}\n${body}\n${ssrBody}\n${moduleBody}`)
99
+ const importBlock = UI_RUNTIME_IMPORTS.filter((entry) => referenced.has(entry.name))
117
100
  .map((entry) => `import { ${entry.name} } from '${ABIDE_PACKAGE_NAME}/${entry.specifier}'`)
118
101
  .join('\n')
119
- return `${importBlock}
102
+ const module = `${importBlock}
120
103
  ${userImports}
121
104
 
122
105
  ${moduleBody}`
106
+ /* Fail-loud over the WHOLE module, not just the script: the `generateBuild` /
107
+ `generateSSR` back-ends emit the build and render bodies as string-codegen, the
108
+ largest un-typed surface in the pipeline. A corruption there (a bad emit on a
109
+ template shape no test exercises) otherwise ships as a broken bundle; this surfaces
110
+ it as a located compile error for every component. */
111
+ assertTranspiles(module, 'component module generation')
112
+ /* `assertTranspiles` only proves the output PARSES — a call to an un-imported helper is
113
+ valid syntax, so it slips through. This second guard proves the output is BOUND: every
114
+ runtime helper it calls is actually imported (an independent check of the dead-import
115
+ filter above), turning a runtime `ReferenceError` into a located compile error. */
116
+ assertRuntimeHelpersBound(module, 'component module generation')
117
+ return module
123
118
  }
124
119
 
125
120
  /* Indents a body block for embedding inside a wrapper function. Lines whose start
@@ -152,3 +147,30 @@ function unescapedBacktickCount(line: string): number {
152
147
  }
153
148
  return count
154
149
  }
150
+
151
+ /* The identifier names a generated module references — every Identifier node, walked
152
+ from the real output's AST so string / comment / HTML-literal contents are excluded.
153
+ Used to decide which runtime helpers to import; reading the output (vs hand-tracking
154
+ emit sites) can never drop a needed import. A full parse (not a raw token scan) is what
155
+ keeps template literals honest: a `${…}` substitution — e.g. `navigate(`/p?ts=${Date.now()}`)`
156
+ in a handler — leaves the scanner unable to find the substitution's closing `}` without
157
+ the parser's re-scan, so a token-only pass mis-reads the rest of the module as template
158
+ text and drops every helper referenced after it (an `import`-less `effect`/`mountChild`
159
+ → a `ReferenceError` at mount → the router's reload fallback → a refresh loop). */
160
+ function collectIdentifiers(code: string): Set<string> {
161
+ const source = ts.createSourceFile(
162
+ 'module.ts',
163
+ code,
164
+ ts.ScriptTarget.Latest,
165
+ /* setParentNodes */ false,
166
+ )
167
+ const names = new Set<string>()
168
+ const visit = (node: ts.Node): void => {
169
+ if (ts.isIdentifier(node)) {
170
+ names.add(node.text)
171
+ }
172
+ node.forEachChild(visit)
173
+ }
174
+ visit(source)
175
+ return names
176
+ }
@@ -1,7 +1,6 @@
1
1
  import { analyzeComponent } from './analyzeComponent.ts'
2
2
  import { generateSSR } from './generateSSR.ts'
3
3
  import { SSR_ESCAPE } from './SSR_ESCAPE.ts'
4
- import { stripEffects } from './stripEffects.ts'
5
4
  import type { AnalyzedComponent } from './types/AnalyzedComponent.ts'
6
5
 
7
6
  /*
@@ -44,8 +43,7 @@ export function compileSSR(
44
43
  scopeSeed?: string,
45
44
  analyzed: AnalyzedComponent = analyzeComponent(source, scopeSeed),
46
45
  ): string {
47
- const { script, stateNames, derivedNames, computedNames, nodes } = analyzed
48
- const lowered = stripEffects(script)
46
+ const { ssrScript: lowered, stateNames, derivedNames, computedNames, nodes } = analyzed
49
47
  const ssr = generateSSR(nodes, stateNames, derivedNames, computedNames, isLayout)
50
48
  /* No `<style>` in the markup — the scoped CSS is bundled into the entry stylesheet
51
49
  the shell links (see `abideUiPlugin`), so SSR output is styled by that sheet. The