@abide/abide 0.40.0 → 0.40.2
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.
- package/AGENTS.md +319 -146
- package/CHANGELOG.md +48 -0
- package/README.md +80 -77
- package/package.json +1 -1
- package/src/lib/shared/assertExhaustive.ts +14 -0
- package/src/lib/ui/compile/TS_PRINTER.ts +7 -0
- package/src/lib/ui/compile/analyzeComponent.ts +15 -16
- package/src/lib/ui/compile/assertRuntimeHelpersBound.ts +66 -0
- package/src/lib/ui/compile/assertTranspiles.ts +19 -0
- package/src/lib/ui/compile/compileModule.ts +56 -34
- package/src/lib/ui/compile/compileSSR.ts +1 -3
- package/src/lib/ui/compile/desugarSignals.ts +168 -107
- package/src/lib/ui/compile/generateBuild.ts +8 -1
- package/src/lib/ui/compile/generateSSR.ts +7 -0
- package/src/lib/ui/compile/hoistCells.ts +2 -2
- package/src/lib/ui/compile/lowerContext.ts +23 -10
- package/src/lib/ui/compile/lowerDocAccess.ts +29 -3
- package/src/lib/ui/compile/lowerScript.ts +64 -0
- package/src/lib/ui/compile/renameSignalRefs.ts +160 -102
- package/src/lib/ui/compile/stripEffects.ts +22 -17
- package/src/lib/ui/compile/types/AnalyzedComponent.ts +5 -0
- package/src/lib/ui/dom/awaitBlock.ts +8 -1
- package/src/lib/ui/dom/each.ts +4 -0
- package/src/lib/ui/dom/switchBlock.ts +4 -0
- package/src/lib/ui/dom/when.ts +4 -0
- package/src/lib/ui/installInspectorBridge.ts +3 -1
- package/src/lib/ui/router.ts +107 -14
- package/src/lib/ui/runtime/NODE_STATE.ts +22 -0
- package/src/lib/ui/runtime/clientPage.ts +114 -9
- package/src/lib/ui/runtime/createComputedNode.ts +5 -3
- package/src/lib/ui/runtime/createEffectNode.ts +3 -1
- package/src/lib/ui/runtime/createSignalNode.ts +4 -2
- package/src/lib/ui/runtime/flushEffects.ts +8 -5
- package/src/lib/ui/runtime/historyEntries.ts +39 -1
- package/src/lib/ui/runtime/readNode.ts +8 -7
- package/src/lib/ui/runtime/runNode.ts +18 -2
- package/src/lib/ui/runtime/scope.ts +12 -1
- package/src/lib/ui/runtime/trigger.ts +40 -24
- package/src/lib/ui/runtime/types/ReactiveNode.ts +4 -1
- package/src/lib/ui/runtime/updateIfNecessary.ts +40 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,53 @@
|
|
|
1
1
|
# abide
|
|
2
2
|
|
|
3
|
+
## 0.40.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- [`315bc88`](https://github.com/briancray/abide/commit/315bc8859e03a81c845f9919a759b6b1f8fb0ea1) - document the abide lsp command and add an if/else template example ([`98b7555`](https://github.com/briancray/abide/commit/98b755509e4788712b7e0516a1cb05f04355a75b))
|
|
8
|
+
|
|
9
|
+
## 0.40.1
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- [`32d6cb9`](https://github.com/briancray/abide/commit/32d6cb97a9ce2ad63b3fd768c02379e708c6d599) - share a single TS printer across compile passes ([`0626e68`](https://github.com/briancray/abide/commit/0626e68500b6cc8e7e2c770184ec4aa0fd06106d))
|
|
14
|
+
|
|
15
|
+
- [`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))
|
|
16
|
+
|
|
17
|
+
- [`32d6cb9`](https://github.com/briancray/abide/commit/32d6cb97a9ce2ad63b3fd768c02379e708c6d599) - value-memoised push-pull reactivity (CLEAN/CHECK/DIRTY) ([`2ae8f14`](https://github.com/briancray/abide/commit/2ae8f141da5873bea0eb31a9a59d81182ca5a12a))
|
|
18
|
+
|
|
19
|
+
- [`32d6cb9`](https://github.com/briancray/abide/commit/32d6cb97a9ce2ad63b3fd768c02379e708c6d599) - syntax fuzz corpus for the identifier-rewrite passes ([`2e7d604`](https://github.com/briancray/abide/commit/2e7d6043aeeb9ca894bdb45f3f27893fbc5bf2f8))
|
|
20
|
+
|
|
21
|
+
- [`32d6cb9`](https://github.com/briancray/abide/commit/32d6cb97a9ce2ad63b3fd768c02379e708c6d599) - build control-flow subtrees untracked ([`33c0d8e`](https://github.com/briancray/abide/commit/33c0d8e834e5145a84d872ff496df122bbb0da95))
|
|
22
|
+
|
|
23
|
+
- [`32d6cb9`](https://github.com/briancray/abide/commit/32d6cb97a9ce2ad63b3fd768c02379e708c6d599) - lower the component script in a single parse ([`53098f8`](https://github.com/briancray/abide/commit/53098f8c2fe36e46e61b41d8a617aba6ac75e96f))
|
|
24
|
+
|
|
25
|
+
- [`32d6cb9`](https://github.com/briancray/abide/commit/32d6cb97a9ce2ad63b3fd768c02379e708c6d599) - regenerate README and AGENTS surface map ([`6bb6a39`](https://github.com/briancray/abide/commit/6bb6a3906d5aba522a4fe33bffae0911adf0b0fe))
|
|
26
|
+
|
|
27
|
+
- [`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))
|
|
28
|
+
|
|
29
|
+
- [`32d6cb9`](https://github.com/briancray/abide/commit/32d6cb97a9ce2ad63b3fd768c02379e708c6d599) - make signal-rewrite parent-independent ([`868717a`](https://github.com/briancray/abide/commit/868717a6f7ff824b24ef8a96e5e5f59add5ceed1))
|
|
30
|
+
|
|
31
|
+
- [`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))
|
|
32
|
+
|
|
33
|
+
- [`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))
|
|
34
|
+
|
|
35
|
+
- [`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))
|
|
36
|
+
|
|
37
|
+
- [`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))
|
|
38
|
+
|
|
39
|
+
- [`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))
|
|
40
|
+
|
|
41
|
+
- [`32d6cb9`](https://github.com/briancray/abide/commit/32d6cb97a9ce2ad63b3fd768c02379e708c6d599) - chain per-expression lowering over a single parse ([`d808317`](https://github.com/briancray/abide/commit/d808317d817ae8a414695b7bfb037b8aff7017c3))
|
|
42
|
+
|
|
43
|
+
- [`32d6cb9`](https://github.com/briancray/abide/commit/32d6cb97a9ce2ad63b3fd768c02379e708c6d599) - classify signal-rewrite positions at visit time ([`d99577e`](https://github.com/briancray/abide/commit/d99577e2ac77d8742088608fba554ff5c2e799a4))
|
|
44
|
+
|
|
45
|
+
- [`32d6cb9`](https://github.com/briancray/abide/commit/32d6cb97a9ce2ad63b3fd768c02379e708c6d599) - make template-node generators exhaustive ([`e0f246d`](https://github.com/briancray/abide/commit/e0f246dfbbf4ea4f2dc481fc543a6e9f0a6fa43b))
|
|
46
|
+
|
|
47
|
+
- [`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))
|
|
48
|
+
|
|
49
|
+
- [`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))
|
|
50
|
+
|
|
3
51
|
## 0.40.0
|
|
4
52
|
|
|
5
53
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -1,159 +1,162 @@
|
|
|
1
1
|
# abide
|
|
2
2
|
|
|
3
|
-
**One typed
|
|
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
|
|
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
|
|
8
|
-
|
|
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 #
|
|
18
|
+
bunx abide scaffold my-app # copies the template, installs, starts dev
|
|
14
19
|
```
|
|
15
20
|
|
|
16
|
-
Or
|
|
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
|
|
21
|
-
|
|
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
|
|
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(
|
|
45
|
+
export const getMessages = GET(({ room }) => json(board(room)), { inputSchema })
|
|
40
46
|
```
|
|
41
47
|
|
|
42
|
-
|
|
48
|
+
One declaration, five surfaces:
|
|
43
49
|
|
|
44
50
|
```text
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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 —
|
|
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
|
|
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
|
|
75
|
-
export const messages = socket({ schema, tail:
|
|
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
|
|
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
|
|
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 {
|
|
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
|
|
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')
|
|
97
|
-
let
|
|
113
|
+
let from = scope().state('alice')
|
|
114
|
+
let text = scope().state('')
|
|
98
115
|
let pinned = scope().state(false)
|
|
99
|
-
let
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
|
125
|
+
<li class="flex gap-2"><Avatar name={msg.from} /> <b>{msg.from}</b> {msg.text}</li>
|
|
113
126
|
</template>
|
|
114
127
|
|
|
115
|
-
<
|
|
116
|
-
|
|
117
|
-
<
|
|
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={
|
|
122
|
-
<
|
|
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={
|
|
127
|
-
<p class="
|
|
128
|
-
<template else>
|
|
129
|
-
<p>no messages yet</p>
|
|
130
|
-
</template>
|
|
136
|
+
<template if={pinned}>
|
|
137
|
+
<p class="text-xs text-amber-700">room pinned</p>
|
|
138
|
+
<template else><p class="text-xs text-slate-400">not pinned</p></template>
|
|
131
139
|
</template>
|
|
132
140
|
|
|
133
|
-
<template switch={
|
|
134
|
-
<template case={'
|
|
135
|
-
<template
|
|
136
|
-
<template default><p>—</p></template>
|
|
141
|
+
<template switch={sort}>
|
|
142
|
+
<template case={'newest'}><p class="text-xs">newest first</p></template>
|
|
143
|
+
<template default><p class="text-xs">oldest first</p></template>
|
|
137
144
|
</template>
|
|
138
145
|
|
|
139
|
-
<template await={
|
|
140
|
-
<p>loading…</p>
|
|
146
|
+
<template await={seed}>
|
|
147
|
+
<p class="text-xs text-slate-500">loading…</p>
|
|
141
148
|
<template then="history">
|
|
142
|
-
<ul>
|
|
143
|
-
<template each={history
|
|
149
|
+
<ul class="mt-3 space-y-1">
|
|
150
|
+
<template each={live ?? history} as="msg" key="msg.id">
|
|
144
151
|
{bubble(msg)}
|
|
145
152
|
</template>
|
|
146
153
|
</ul>
|
|
147
154
|
</template>
|
|
148
|
-
<template catch="
|
|
149
|
-
<p>failed: {error.message}</p>
|
|
150
|
-
</template>
|
|
155
|
+
<template catch="err"><p class="text-rose-700">{err.message}</p></template>
|
|
151
156
|
</template>
|
|
152
157
|
|
|
153
158
|
<style>
|
|
154
|
-
.
|
|
155
|
-
font-weight: 600;
|
|
156
|
-
}
|
|
159
|
+
li { font-size: 0.875rem; }
|
|
157
160
|
</style>
|
|
158
161
|
```
|
|
159
162
|
|
package/package.json
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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
|
-
/*
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
(
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|