@abide/abide 0.50.0 → 0.51.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +580 -459
- package/CHANGELOG.md +16 -0
- package/README.md +173 -205
- package/package.json +1 -1
- package/src/lib/ui/compile/compileShadow.ts +49 -11
- package/src/lib/ui/compile/lowerScript.ts +18 -0
- package/src/lib/ui/compile/wrapReactionCellSources.ts +85 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# abide
|
|
2
2
|
|
|
3
|
+
## 0.51.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- aec503f: make watch(cell, handler) reactive ([`4db4e3d`](https://github.com/briancray/abide/commit/4db4e3d21cb04dcad5463831621140ec00ccbbe2))
|
|
8
|
+
|
|
9
|
+
### Patch Changes
|
|
10
|
+
|
|
11
|
+
- aec503f: correct README/AGENTS and lead the async story with the bare call ([`c96e936`](https://github.com/briancray/abide/commit/c96e93682cc8e05762bcde7bb808850dfdb40b6f))
|
|
12
|
+
|
|
13
|
+
## 0.50.1
|
|
14
|
+
|
|
15
|
+
### Patch Changes
|
|
16
|
+
|
|
17
|
+
- ef143f5: regenerate README + AGENTS surface map ([`8665efa`](https://github.com/briancray/abide/commit/8665efa7eb9f4106882cf246738c08e465d7e8d7))
|
|
18
|
+
|
|
3
19
|
## 0.50.0
|
|
4
20
|
|
|
5
21
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -1,103 +1,83 @@
|
|
|
1
1
|
# abide
|
|
2
2
|
|
|
3
|
-
**One typed declaration, every surface:
|
|
4
|
-
a CLI, an MCP tool, and an OpenAPI operation — and the same callable runs
|
|
5
|
-
in-process on the server and over fetch in the browser.**
|
|
3
|
+
**One typed declaration, every surface: SSR, browser fetch, MCP, CLI, and OpenAPI from a single Bun runtime.**
|
|
6
4
|
|
|
7
|
-
abide is an isomorphic
|
|
8
|
-
in a single Bun runtime: file-path routing, Standard Schema validation, and a
|
|
9
|
-
compiled `.abide` UI layer that server-renders, streams, and hydrates the same
|
|
10
|
-
components. The bundler swaps each callable's runtime per side — same name,
|
|
11
|
-
same behavior, no client/server forks in app code.
|
|
5
|
+
abide is an isomorphic HTTP framework where a typed RPC you declare once fans out to an in-process SSR call, a browser fetch, an MCP tool, a CLI subcommand, and an OpenAPI operation — the bundler swaps the runtime per side, so the same callable behaves the same in-process on the server and over `fetch` in the browser. It is built for humans and machines: the same schema that types your code projects the tool, the flag, and the spec.
|
|
12
6
|
|
|
13
|
-
- One direct dependency (TypeScript)
|
|
7
|
+
- One direct dependency (TypeScript), one runtime (Bun ≥ 1.3.0).
|
|
8
|
+
- No barrels — every public name is its own module path (`@abide/abide/server/GET`, `@abide/abide/ui/state`, …). The namespace marks the side: `server/*` runs server-side, `ui/*` client-side, `shared/*` isomorphic.
|
|
14
9
|
|
|
15
10
|
## Quick start
|
|
16
11
|
|
|
17
12
|
```sh
|
|
18
|
-
|
|
13
|
+
# Scaffold a project from the bundled template, install it, and start dev.
|
|
14
|
+
bunx abide scaffold my-app
|
|
19
15
|
```
|
|
20
16
|
|
|
21
|
-
Or
|
|
17
|
+
Or clone the kitchen-sink example, which exercises the whole surface:
|
|
22
18
|
|
|
23
19
|
```sh
|
|
24
20
|
git clone https://github.com/briancray/abide
|
|
25
|
-
cd abide
|
|
26
|
-
|
|
21
|
+
cd abide/examples/kitchen-sink
|
|
22
|
+
bun install
|
|
23
|
+
bun run dev
|
|
27
24
|
```
|
|
28
25
|
|
|
29
26
|
## RPCs
|
|
30
27
|
|
|
31
|
-
An RPC is one
|
|
32
|
-
is the URL. The export wraps the handler in an HTTP-method helper (`GET`,
|
|
33
|
-
`POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`); a Standard Schema `schemas.input`
|
|
34
|
-
(zod, valibot, arktype — no adapter) validates the args and projects the CLI
|
|
35
|
-
flags, the MCP tool, and the OpenAPI operation from the one declaration.
|
|
28
|
+
An RPC is one export per file under `src/server/rpc/`. The file path is the URL; the declared method helper (`GET`/`POST`/`PUT`/`PATCH`/`DELETE`/`HEAD`) picks the verb. Attach a `schemas.input` (any Standard Schema — zod, valibot, or arktype, unadapted) and the same schema validates the args and projects the MCP tool, the CLI flags, and the OpenAPI operation.
|
|
36
29
|
|
|
37
30
|
```ts
|
|
38
31
|
// src/server/rpc/getMessages.ts
|
|
39
32
|
import { GET } from '@abide/abide/server/GET'
|
|
40
33
|
import { json } from '@abide/abide/server/json'
|
|
41
34
|
import { z } from 'zod'
|
|
42
|
-
import {
|
|
43
|
-
|
|
44
|
-
export const getMessages = GET(
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
limit: z.coerce.number().default(50),
|
|
51
|
-
}),
|
|
52
|
-
},
|
|
35
|
+
import { load } from '$server/db.ts'
|
|
36
|
+
|
|
37
|
+
export const getMessages = GET((args) => json(load(args.channel, args.limit)), {
|
|
38
|
+
schemas: {
|
|
39
|
+
input: z.object({
|
|
40
|
+
channel: z.string().default('general'),
|
|
41
|
+
limit: z.number().max(100).default(20),
|
|
42
|
+
}),
|
|
53
43
|
},
|
|
54
|
-
)
|
|
44
|
+
})
|
|
55
45
|
```
|
|
56
46
|
|
|
57
|
-
|
|
47
|
+
One declaration, five surfaces:
|
|
58
48
|
|
|
59
49
|
```text
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
50
|
+
export const getMessages = GET(fn, { schemas })
|
|
51
|
+
│
|
|
52
|
+
┌───────────┬──────────────┼──────────────┬─────────────┐
|
|
53
|
+
▼ ▼ ▼ ▼ ▼
|
|
54
|
+
SSR call browser fetch MCP tool CLI subcmd OpenAPI op
|
|
55
|
+
(bare, (same call, (read-only abide-cli /openapi.json
|
|
56
|
+
in-proc) swap to fetch) from type) getMessages
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
A typed input unlocks the CLI on any RPC, and MCP for read-only methods (`GET`/`HEAD`) — the handler's input type is projected to JSON Schema at build (ADR-0030), so a plainly-typed handler auto-exposes with no hand-written `schemas.input` (declaring one adds runtime validation on top, it isn't what flips the surfaces on). A mutating method never auto-exposes to MCP — it opts in explicitly:
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
// src/server/rpc/sendMessage.ts
|
|
63
|
+
import { POST } from '@abide/abide/server/POST'
|
|
64
|
+
import { json } from '@abide/abide/server/json'
|
|
65
|
+
import { z } from 'zod'
|
|
66
|
+
import { append } from '$server/db.ts'
|
|
67
|
+
|
|
68
|
+
export const sendMessage = POST((args) => json(append(args.channel, args.body)), {
|
|
69
|
+
schemas: { input: z.object({ channel: z.string(), body: z.string() }) },
|
|
70
|
+
clients: { mcp: true }, // a mutating RPC must opt into MCP by hand
|
|
71
|
+
})
|
|
68
72
|
```
|
|
69
73
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
auto-
|
|
73
|
-
|
|
74
|
-
The bare call **is** the smart read — cached, coalesced, reactive,
|
|
75
|
-
stale-while-revalidate — and it is isomorphic: the same callable resolves
|
|
76
|
-
in-process during SSR (its value baked into the HTML for warm hydration) and
|
|
77
|
-
over `fetch` in the browser. Around it:
|
|
78
|
-
|
|
79
|
-
- `getMessages.raw(args, init?)` — the raw `Response`; per-call transport
|
|
80
|
-
options (`signal`, `headers`, …) live here
|
|
81
|
-
- `getMessages.refresh(args?)` — refetch, keeping the stale value visible
|
|
82
|
-
- `getMessages.patch(args?, updater)` — mutate the retained value locally,
|
|
83
|
-
no network
|
|
84
|
-
- `getMessages.peek(args?)` — read the retained value synchronously
|
|
85
|
-
- `getMessages.pending(args?)` / `.refreshing(args?)` / `.error(args?)` —
|
|
86
|
-
reactive probes
|
|
87
|
-
- a handler that returns `jsonl()`/`sse()` makes the bare call return a
|
|
88
|
-
`NamedAsyncIterable` you `for await` — detected at build time, nothing to declare
|
|
89
|
-
|
|
90
|
-
> Query args travel as strings — use `z.coerce.*` for numbers and booleans on
|
|
91
|
-
> GET/HEAD. The per-RPC `timeout` option is a server-side handler deadline
|
|
92
|
-
> (504, enforced on every surface); `ABIDE_CLIENT_TIMEOUT` bounds browser calls
|
|
93
|
-
> and is separate.
|
|
74
|
+
Consume forms are isomorphic. The **bare call `fn(args)` is the smart read** — cached, coalesced, reactive, resolved in-process during SSR and over `fetch` in the browser (there is no `cache()` wrapper; the bare call carries the caching). Alongside it: `fn.raw(args, init?)` for the raw `Response`, and the mutators/probes `fn.refresh()`, `fn.patch(...)`, `fn.peek()`, `fn.pending()`, `fn.refreshing()`, and `fn.error()`. A streaming handler (`jsonl`/`sse`) makes the bare call return a `Subscribable` you iterate.
|
|
75
|
+
|
|
76
|
+
> Query and path args auto-coerce from the endpoint's typed shape — a numeric field arrives as a number, no `z.coerce` needed. The per-RPC `timeout` (a 504 on every surface) is distinct from the client-wide `ABIDE_CLIENT_TIMEOUT`.
|
|
94
77
|
|
|
95
78
|
## Sockets
|
|
96
79
|
|
|
97
|
-
A socket is one broadcast topic per file under `src/server/sockets
|
|
98
|
-
export name is the topic. A `Socket<T>` is an isomorphic `AsyncIterable<T>`:
|
|
99
|
-
server code iterates and broadcasts in-process; browser code gets the same
|
|
100
|
-
shape over one multiplexed WebSocket at `/__abide/sockets`.
|
|
80
|
+
A socket is one broadcast topic per file under `src/server/sockets/`. A `Socket<T>` is an isomorphic `AsyncIterable<T>` — the same value you `for await` on both sides — and every socket multiplexes onto one WebSocket at `/__abide/sockets`.
|
|
101
81
|
|
|
102
82
|
```ts
|
|
103
83
|
// src/server/sockets/chat.ts
|
|
@@ -105,184 +85,172 @@ import { socket } from '@abide/abide/server/socket'
|
|
|
105
85
|
import { z } from 'zod'
|
|
106
86
|
|
|
107
87
|
export const chat = socket({
|
|
108
|
-
schema: z.object({ author: z.string(),
|
|
109
|
-
tail:
|
|
110
|
-
ttl: 60_000, // retained frames
|
|
111
|
-
clientPublish: true, // allow browser publishes (off by default)
|
|
88
|
+
schema: z.object({ author: z.string(), body: z.string() }),
|
|
89
|
+
tail: 20, // retain the last 20 frames for late joiners and reconnects
|
|
90
|
+
ttl: 60_000, // evict retained frames older than 60s
|
|
112
91
|
})
|
|
113
92
|
```
|
|
114
93
|
|
|
115
|
-
`chat.publish(
|
|
116
|
-
server, and client publishes are gated by `clientPublish`. `for await (const
|
|
117
|
-
msg of chat)` is the live stream; `chat.peek()` reads the latest retained
|
|
118
|
-
frame; `chat.refresh()` re-pulls the server tail after a reconnect. A schema
|
|
119
|
-
also advertises the topic to MCP and the CLI.
|
|
120
|
-
|
|
121
|
-
Every exposed socket has an HTTP face at `/__abide/sockets/<name>`: `GET`
|
|
122
|
-
returns the retained tail as JSON (or a live SSE stream under
|
|
123
|
-
`Accept: text/event-stream`), `POST` publishes — gated by `clientPublish`.
|
|
94
|
+
Publish with `chat.publish(frame)`; seed a late reader with `chat.tail(count)`. Each socket also has an HTTP face at `/__abide/sockets/<name>`: `GET` returns the retained tail, and `POST` publishes when `clientPublish` is set.
|
|
124
95
|
|
|
125
96
|
## Components
|
|
126
97
|
|
|
127
|
-
|
|
128
|
-
`layout.abide`; a `[id]` folder becomes a route param read via `props()`). A
|
|
129
|
-
component is HTML plus `{expr}` bindings and `{#…}` control-flow blocks.
|
|
130
|
-
Reactive state comes from imported primitives the compiler lowers, so inside a
|
|
131
|
-
component you read and write state as plain variables. The same file renders on
|
|
132
|
-
the server — blocking awaits inline, streaming awaits out of order, cached
|
|
133
|
-
reads seeded warm — and hydrates in the browser.
|
|
98
|
+
A `.abide` component is HTML with a leading `<script>`. The example below is one page that imports the RPC and socket above and exercises the whole template grammar. Reactive primitives are imported by their own module paths and called bare: `state(0)` is a writable cell you read and reassign as a plain variable (`count`, `count += 1`) — the compiler desugars those to the cell's `.value`; `state.computed(fn)` is read-only derived, `state.linked(fn)` is writable and reseeded from a thunk. `watch(source, handler)` is the single reaction primitive — over a cell, a socket, or an RPC. `props()` is the prop reader, imported from `abide/ui/props`.
|
|
134
99
|
|
|
135
|
-
|
|
136
|
-
prop of type `Snippet` — and renders wherever the component calls
|
|
137
|
-
`{children()}`:
|
|
100
|
+
Async data has no ceremony: **the bare call in an interpolation — `{getMessages({ limit })}` — is the way to read it.** It's a _peek_ that reads `undefined` while pending (and auto-streams on SSR), so it composes with `?? fallback`, `?.`, `{#if}`, and attributes with no wrapping block; pair it with the `.pending()` / `.error()` probes for loading and error affordances, and use `{await getMessages(...)}` to block SSR until the value is in the initial HTML. The `{#await}…{:then}…{:catch}…{:finally}…{/await}` block is the explicit opt-in — reach for it only when you want a distinct pending branch, a local `{:catch}`, or `{:then}` type-narrowing.
|
|
138
101
|
|
|
139
102
|
```html
|
|
140
103
|
<script>
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
104
|
+
import { getMessages } from '$server/rpc/getMessages.ts'
|
|
105
|
+
import { sendMessage } from '$server/rpc/sendMessage.ts'
|
|
106
|
+
import { chat } from '$server/sockets/chat.ts'
|
|
107
|
+
import MessageCard from '$ui/components/MessageCard.abide'
|
|
108
|
+
import { state } from '@abide/abide/ui/state'
|
|
109
|
+
import { watch } from '@abide/abide/ui/watch'
|
|
110
|
+
import { html } from '@abide/abide/ui/html'
|
|
111
|
+
import { props } from '@abide/abide/ui/props'
|
|
146
112
|
|
|
147
|
-
|
|
148
|
-
<h2>{title}</h2>
|
|
149
|
-
{#if children}{children()}{:else}<p>Nothing here yet.</p>{/if}
|
|
150
|
-
</section>
|
|
113
|
+
const { title = 'Chat', ...rest } = props()
|
|
151
114
|
|
|
152
|
-
|
|
153
|
-
section {
|
|
154
|
-
border: 1px solid gray;
|
|
155
|
-
}
|
|
156
|
-
</style>
|
|
157
|
-
```
|
|
115
|
+
type Message = { id: string; author: string; body: string }
|
|
158
116
|
|
|
159
|
-
|
|
160
|
-
|
|
117
|
+
let channel = state('general')
|
|
118
|
+
let draft = state('')
|
|
119
|
+
let pinned = state(false)
|
|
120
|
+
let limit = state(20)
|
|
161
121
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
import { getMessages } from '$server/rpc/getMessages'
|
|
165
|
-
import { sendMessage } from '$server/rpc/sendMessage' // POST, declared like getMessages
|
|
166
|
-
import { chat } from '$server/sockets/chat'
|
|
167
|
-
import { state } from '@abide/abide/ui/state'
|
|
168
|
-
import { watch } from '@abide/abide/ui/watch'
|
|
169
|
-
import { html } from '@abide/abide/ui/html'
|
|
170
|
-
import { props } from '@abide/abide/ui/props'
|
|
171
|
-
import Panel from '$ui/components/Panel.abide'
|
|
122
|
+
let trimmed = state.computed(() => draft.trim())
|
|
123
|
+
let live = state.linked(() => limit)
|
|
172
124
|
|
|
173
|
-
|
|
125
|
+
const badge = html`<sup class="ml-1 text-xs text-emerald-600">live</sup>`
|
|
174
126
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
let volume = state(5, (next, previous) => (Number.isFinite(next) ? next : previous))
|
|
180
|
-
let shout = state.computed(() => draft.toUpperCase())
|
|
181
|
-
let roomDraft = state.linked(() => room)
|
|
127
|
+
watch(trimmed, (value) => console.log('draft is now', value))
|
|
128
|
+
watch(chat, (frame) => {
|
|
129
|
+
live = live + 1
|
|
130
|
+
})
|
|
182
131
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
132
|
+
async function submit(event: SubmitEvent) {
|
|
133
|
+
event.preventDefault()
|
|
134
|
+
if (trimmed === '') return
|
|
135
|
+
await sendMessage({ channel, body: trimmed })
|
|
136
|
+
draft = ''
|
|
137
|
+
}
|
|
186
138
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
139
|
+
function autofocus(node: HTMLInputElement) {
|
|
140
|
+
node.focus()
|
|
141
|
+
return () => {}
|
|
142
|
+
}
|
|
190
143
|
|
|
191
|
-
|
|
192
|
-
const panelProps = { title: 'Live feed' }
|
|
193
|
-
const draftAttrs = { placeholder: 'say something', autocomplete: 'off' }
|
|
144
|
+
const rowProps = { class: 'flex gap-2' }
|
|
194
145
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
}
|
|
146
|
+
// Derived two-way binding: read a string, coerce writes back into the numeric cell.
|
|
147
|
+
const get = () => String(limit)
|
|
148
|
+
const set = (next: string) => (limit = Number(next))
|
|
199
149
|
</script>
|
|
200
150
|
|
|
201
|
-
{
|
|
202
|
-
<
|
|
203
|
-
{/snippet}
|
|
204
|
-
|
|
205
|
-
<Panel {...panelProps}>
|
|
206
|
-
<h1 class:muted={!notify} style:opacity={notify ? '1' : '0.6'}>
|
|
207
|
-
{room} — {shout}
|
|
208
|
-
</h1>
|
|
151
|
+
<section {...rest} class:pinned={pinned} style:opacity={pinned ? '1' : '0.85'}>
|
|
152
|
+
<h1>{title} {badge}</h1>
|
|
209
153
|
|
|
210
|
-
|
|
211
|
-
|
|
154
|
+
<form onsubmit={submit}>
|
|
155
|
+
<input bind:value={draft} attach={autofocus} placeholder="Say something" />
|
|
156
|
+
<label><input type="checkbox" bind:checked={pinned} /> pin</label>
|
|
157
|
+
<label><input type="radio" bind:group={channel} value="general" /> general</label>
|
|
158
|
+
<label><input type="radio" bind:group={channel} value="random" /> random</label>
|
|
159
|
+
<input bind:value={{ get, set }} />
|
|
160
|
+
<button type="submit">Send</button>
|
|
161
|
+
</form>
|
|
212
162
|
|
|
213
|
-
{#
|
|
214
|
-
<
|
|
163
|
+
{#snippet row(message: Message, index: number)}
|
|
164
|
+
<MessageCard {...rowProps} name={message.author} onclick={() => console.log(index)}>
|
|
165
|
+
<p>{message.body}</p>
|
|
166
|
+
</MessageCard>
|
|
167
|
+
{/snippet}
|
|
168
|
+
|
|
169
|
+
{#if live > 100}
|
|
170
|
+
<p>Busy channel</p>
|
|
171
|
+
{:else if live > 0}
|
|
172
|
+
<p>{live} updates</p>
|
|
173
|
+
{:else}
|
|
174
|
+
<script>
|
|
175
|
+
let seenAt = state(Date.now())
|
|
176
|
+
let ageLabel = state.computed(() => `waiting since ${seenAt}`)
|
|
177
|
+
</script>
|
|
178
|
+
<style>
|
|
179
|
+
p { color: gray; }
|
|
180
|
+
</style>
|
|
181
|
+
<p>{ageLabel}</p>
|
|
182
|
+
{/if}
|
|
183
|
+
|
|
184
|
+
<!-- The default: a bare peek — `undefined` while pending, composes with `?.`/`??`; probes for affordances. -->
|
|
185
|
+
<p>
|
|
186
|
+
{getMessages({ limit })?.length ?? 0} messages
|
|
187
|
+
{#if getMessages.pending({ limit })} · loading…{/if}
|
|
188
|
+
{#if getMessages.error({ limit })} · failed{/if}
|
|
189
|
+
</p>
|
|
190
|
+
|
|
191
|
+
<!-- {await fn()} blocks SSR so the value is in the initial HTML (no streamed/pending pass). -->
|
|
192
|
+
<p>newest: {await getMessages({ limit }).then((list) => list.at(-1)?.author ?? '—')}</p>
|
|
193
|
+
|
|
194
|
+
<!-- The explicit opt-in: a distinct pending branch, a local {:catch}, and {:then} narrowing. -->
|
|
195
|
+
{#await getMessages({ limit })}
|
|
196
|
+
<p>Loading…</p>
|
|
215
197
|
{:then messages}
|
|
216
|
-
{#
|
|
217
|
-
|
|
218
|
-
{
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
{line(message)}
|
|
222
|
-
{/for}
|
|
223
|
-
</ul>
|
|
224
|
-
{:else}
|
|
225
|
-
<script>
|
|
226
|
-
// branch-local state, re-seeded each time this branch mounts
|
|
227
|
-
let offset = state(0)
|
|
228
|
-
</script>
|
|
229
|
-
<p>huge room — showing from {offset}</p>
|
|
230
|
-
<button onclick={() => (offset = offset + 100)}>next</button>
|
|
231
|
-
<style>
|
|
232
|
-
p {
|
|
233
|
-
font-variant-numeric: tabular-nums;
|
|
234
|
-
}
|
|
235
|
-
</style>
|
|
236
|
-
{/if}
|
|
237
|
-
{:catch error}
|
|
238
|
-
<p>failed to load: {error.message}</p>
|
|
198
|
+
{#for message, i of messages by message.id}
|
|
199
|
+
{row(message, i)}
|
|
200
|
+
{/for}
|
|
201
|
+
{:catch problem}
|
|
202
|
+
<p>Failed: {problem.message}</p>
|
|
239
203
|
{:finally}
|
|
240
|
-
<
|
|
204
|
+
<hr />
|
|
241
205
|
{/await}
|
|
242
206
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
{/for}
|
|
247
|
-
</ol>
|
|
207
|
+
{#for await frame of chat}
|
|
208
|
+
<p class="text-sm opacity-70">{frame.author}: {frame.body}</p>
|
|
209
|
+
{/for}
|
|
248
210
|
|
|
249
|
-
{#switch
|
|
250
|
-
{:case '
|
|
251
|
-
<
|
|
211
|
+
{#switch channel}
|
|
212
|
+
{:case 'general'}
|
|
213
|
+
<span>General channel</span>
|
|
214
|
+
{:case 'random'}
|
|
215
|
+
<span>Random channel</span>
|
|
252
216
|
{:default}
|
|
253
|
-
<
|
|
217
|
+
<span>Unknown channel</span>
|
|
254
218
|
{/switch}
|
|
255
219
|
|
|
256
220
|
{#try}
|
|
257
|
-
<
|
|
221
|
+
<MessageCard name="system">
|
|
222
|
+
<p>System notice</p>
|
|
223
|
+
</MessageCard>
|
|
258
224
|
{:catch}
|
|
259
|
-
<p>
|
|
225
|
+
<p>Card crashed</p>
|
|
226
|
+
{:finally}
|
|
227
|
+
<span class="sr-only">done</span>
|
|
260
228
|
{/try}
|
|
261
|
-
|
|
262
|
-
<form onsubmit={(event) => { event.preventDefault(); send() }}>
|
|
263
|
-
<input attach={focus} bind:value={draft} {...draftAttrs} />
|
|
264
|
-
<input bind:value={{ get, set }} />
|
|
265
|
-
<label><input type="checkbox" bind:checked={notify} /> notify</label>
|
|
266
|
-
<label><input type="radio" bind:group={tone} value="friendly" /> friendly</label>
|
|
267
|
-
<label><input type="radio" bind:group={tone} value="blunt" /> blunt</label>
|
|
268
|
-
<input type="range" bind:value={volume} min="0" max="10" />
|
|
269
|
-
<select bind:value={author}>
|
|
270
|
-
<option>anon</option>
|
|
271
|
-
<option>me</option>
|
|
272
|
-
</select>
|
|
273
|
-
<button disabled={draft === ''}>send</button>
|
|
274
|
-
</form>
|
|
275
|
-
</Panel>
|
|
229
|
+
</section>
|
|
276
230
|
|
|
277
231
|
<style>
|
|
278
|
-
|
|
279
|
-
display:
|
|
232
|
+
section {
|
|
233
|
+
display: grid;
|
|
280
234
|
gap: 0.5rem;
|
|
281
235
|
}
|
|
282
|
-
.muted {
|
|
283
|
-
color: gray;
|
|
284
|
-
}
|
|
285
236
|
</style>
|
|
286
237
|
```
|
|
287
238
|
|
|
239
|
+
The capitalised `MessageCard` renders its passed content where it calls `{children()}`. The `<slot>` element was removed — `{children()}` is the single fill point, and `{#if children}{children()}{:else}…{/if}` is the fallback form.
|
|
240
|
+
|
|
241
|
+
```html
|
|
242
|
+
<script>
|
|
243
|
+
import { props } from '@abide/abide/ui/props'
|
|
244
|
+
import type { Snippet } from '@abide/abide/shared/snippet'
|
|
245
|
+
const { name, children, ...rest } = props<{ name: string; children?: Snippet }>()
|
|
246
|
+
</script>
|
|
247
|
+
|
|
248
|
+
<article {...rest}>
|
|
249
|
+
<strong>{name}</strong>
|
|
250
|
+
{#if children} {children()} {:else}
|
|
251
|
+
<em>No content</em>
|
|
252
|
+
{/if}
|
|
253
|
+
</article>
|
|
254
|
+
```
|
|
255
|
+
|
|
288
256
|
MIT
|
package/package.json
CHANGED
|
@@ -39,9 +39,6 @@ function shadowPreamble(importedReactives: ReadonlySet<string>): string {
|
|
|
39
39
|
importedReactives.has('effect')
|
|
40
40
|
? undefined
|
|
41
41
|
: `import { effect } from '${ABIDE_PACKAGE_NAME}/ui/effect'`,
|
|
42
|
-
importedReactives.has('watch')
|
|
43
|
-
? undefined
|
|
44
|
-
: `import { watch } from '${ABIDE_PACKAGE_NAME}/ui/watch'`,
|
|
45
42
|
`import { snippet } from '${ABIDE_PACKAGE_NAME}/shared/snippet'`,
|
|
46
43
|
`import { scope } from '${ABIDE_PACKAGE_NAME}/ui/currentScope'`,
|
|
47
44
|
importedReactives.has('state')
|
|
@@ -86,8 +83,16 @@ export function compileShadow(
|
|
|
86
83
|
const scriptStart = leadingScript ? source.indexOf('>', leadingScript.index) + 1 : 0
|
|
87
84
|
const templateStart = leadingScript ? (leadingScript.index ?? 0) + leadingScript[0].length : 0
|
|
88
85
|
|
|
89
|
-
const {
|
|
90
|
-
|
|
86
|
+
const {
|
|
87
|
+
imports,
|
|
88
|
+
types,
|
|
89
|
+
scope,
|
|
90
|
+
propsShapes,
|
|
91
|
+
diagnostics,
|
|
92
|
+
importedReactives,
|
|
93
|
+
propsLocalName,
|
|
94
|
+
watchLocalName,
|
|
95
|
+
} = analyzeScript(scriptBody, scriptStart)
|
|
91
96
|
builder.raw(shadowPreamble(importedReactives))
|
|
92
97
|
/* The peek helpers the async-interpolation wrap targets (ADR-0032): `$$peek` unwraps a promise
|
|
93
98
|
sub-expression to its resolved value (`undefined` while pending) and `$$peekStream` reads an
|
|
@@ -112,15 +117,30 @@ export function compileShadow(
|
|
|
112
117
|
if (propsLocalName !== undefined) {
|
|
113
118
|
builder.raw(`declare function ${propsLocalName}<T = {}>(): (${propsType}) & T\n`)
|
|
114
119
|
}
|
|
120
|
+
/* `watch` is re-typed with a trailing value-source overload: the real overloads
|
|
121
|
+
(`State`/socket/rpc/thunk) intersected with `<T>(source: T, handler: (value: T) => void)`.
|
|
122
|
+
A `watch(cell, handler)` source is projected to the cell's VALUE type (like every read),
|
|
123
|
+
so it never matches the real `State<T>` overload — the value-source form catches it and
|
|
124
|
+
types `handler`'s parameter as that value. Ordered after the real overloads (intersection
|
|
125
|
+
left-to-right), so a socket/rpc source still resolves to its specific frame/return type.
|
|
126
|
+
The real type is referenced through `typeof import(...)` (no value import to keep live), and
|
|
127
|
+
the binding uses the author's local alias — or the `watch` fallback for a bare/nested use
|
|
128
|
+
that isn't author-imported. */
|
|
129
|
+
const watchSpecifier = `${ABIDE_PACKAGE_NAME}/ui/watch`
|
|
130
|
+
builder.raw(
|
|
131
|
+
`declare const ${watchLocalName ?? 'watch'}: typeof import('${watchSpecifier}').watch & (<__WatchT>(source: __WatchT, handler: (value: __WatchT) => void) => () => void)\n`,
|
|
132
|
+
)
|
|
115
133
|
const propsSpecifier = `${ABIDE_PACKAGE_NAME}/ui/props`
|
|
116
134
|
for (const line of imports) {
|
|
117
|
-
/* The `props`
|
|
118
|
-
emitting
|
|
135
|
+
/* The `props`/`watch` imports are replaced by the contextual declarations above;
|
|
136
|
+
emitting one too would be a duplicate-identifier error. Matched by EXACT specifier
|
|
119
137
|
(either quote style, not a loose suffix match) so an unrelated user module merely
|
|
120
|
-
named `.../ui/props` survives verbatim. */
|
|
138
|
+
named `.../ui/props` (or `.../ui/watch`) survives verbatim. */
|
|
121
139
|
if (
|
|
122
140
|
line.text.includes(`from '${propsSpecifier}'`) ||
|
|
123
|
-
line.text.includes(`from "${propsSpecifier}"`)
|
|
141
|
+
line.text.includes(`from "${propsSpecifier}"`) ||
|
|
142
|
+
line.text.includes(`from '${watchSpecifier}'`) ||
|
|
143
|
+
line.text.includes(`from "${watchSpecifier}"`)
|
|
124
144
|
) {
|
|
125
145
|
continue
|
|
126
146
|
}
|
|
@@ -331,6 +351,11 @@ type ScriptAnalysis = {
|
|
|
331
351
|
for the canonical import, `p` for `props as p`), or undefined when not imported. The
|
|
332
352
|
`declare function` for `props` must target this name, not the canonical `'props'`. */
|
|
333
353
|
propsLocalName: string | undefined
|
|
354
|
+
/* The LOCAL binding name the author's `watch` import is bound to (alias-safe), or undefined
|
|
355
|
+
when not imported. The shadow re-types `watch` under this name with a value-source
|
|
356
|
+
overload so the `watch(cell, handler)` form type-checks (the cell is projected to its
|
|
357
|
+
value, so the real State/socket/rpc overloads never match it). */
|
|
358
|
+
watchLocalName: string | undefined
|
|
334
359
|
}
|
|
335
360
|
|
|
336
361
|
/* Pushes a diagnostic for every author binding whose name starts with the reserved `$$`
|
|
@@ -451,6 +476,7 @@ function analyzeScript(scriptBody: string, scriptStart: number): ScriptAnalysis
|
|
|
451
476
|
diagnostics,
|
|
452
477
|
importedReactives: new Set(),
|
|
453
478
|
propsLocalName: undefined,
|
|
479
|
+
watchLocalName: undefined,
|
|
454
480
|
}
|
|
455
481
|
}
|
|
456
482
|
const file = ts.createSourceFile('script.ts', scriptBody, ts.ScriptTarget.Latest, true)
|
|
@@ -462,10 +488,13 @@ function analyzeScript(scriptBody: string, scriptStart: number): ScriptAnalysis
|
|
|
462
488
|
/* The local name bound to `props` (alias-safe): the key whose value is the canonical
|
|
463
489
|
`'props'`. At most one — a file imports `props` from one specifier. */
|
|
464
490
|
let propsLocalName: string | undefined
|
|
491
|
+
let watchLocalName: string | undefined
|
|
465
492
|
for (const [local, canonical] of bindings.direct) {
|
|
466
493
|
if (canonical === 'props') {
|
|
467
494
|
propsLocalName = local
|
|
468
|
-
|
|
495
|
+
}
|
|
496
|
+
if (canonical === 'watch') {
|
|
497
|
+
watchLocalName = local
|
|
469
498
|
}
|
|
470
499
|
}
|
|
471
500
|
/* The `$$` prefix is reserved for the compiler's injected runtime (`$$each`, `$$model`,
|
|
@@ -507,7 +536,16 @@ function analyzeScript(scriptBody: string, scriptStart: number): ScriptAnalysis
|
|
|
507
536
|
scope.push(scopeLineFor(declaration, propsShapes, verbatim, span, bindings))
|
|
508
537
|
}
|
|
509
538
|
}
|
|
510
|
-
return {
|
|
539
|
+
return {
|
|
540
|
+
imports,
|
|
541
|
+
types,
|
|
542
|
+
scope,
|
|
543
|
+
propsShapes,
|
|
544
|
+
diagnostics,
|
|
545
|
+
importedReactives,
|
|
546
|
+
propsLocalName,
|
|
547
|
+
watchLocalName,
|
|
548
|
+
}
|
|
511
549
|
}
|
|
512
550
|
|
|
513
551
|
/* Value-projects a nested control-flow `<script>` body the way `analyzeScript`
|
|
@@ -5,9 +5,11 @@ import { desugarSignals } from './desugarSignals.ts'
|
|
|
5
5
|
import { identifierReferencePattern } from './identifierReferencePattern.ts'
|
|
6
6
|
import { docAccessTransformer } from './lowerDocAccess.ts'
|
|
7
7
|
import { signalRefsTransformer } from './renameSignalRefs.ts'
|
|
8
|
+
import { reactiveImportBindings } from './resolveReactiveExport.ts'
|
|
8
9
|
import { stripEffectsTransformer } from './stripEffects.ts'
|
|
9
10
|
import { TS_PRINTER } from './TS_PRINTER.ts'
|
|
10
11
|
import type { SeedTypeClassifier } from './types/SeedTypeClassifier.ts'
|
|
12
|
+
import { wrapReactionCellSources } from './wrapReactionCellSources.ts'
|
|
11
13
|
|
|
12
14
|
/* The `abide/ui/*` modules the reactive surface is imported from. An author's import of
|
|
13
15
|
one is compiler-recognised and lowered, so its binding is often fully consumed — a plain
|
|
@@ -100,8 +102,24 @@ export function lowerScript(
|
|
|
100
102
|
seedClassify,
|
|
101
103
|
scriptBase,
|
|
102
104
|
)
|
|
105
|
+
/* The local names bound to `watch` (alias-safe), and the full set of read-rewritten cell
|
|
106
|
+
names — together they let `wrapReactionCellSources` fold a `watch(cell, handler)` into
|
|
107
|
+
the thunk form BEFORE the read-lowering turns the cell reference into a value read. */
|
|
108
|
+
const watchLocalNames = new Set<string>()
|
|
109
|
+
for (const [local, canonical] of reactiveImportBindings(source).direct) {
|
|
110
|
+
if (canonical === 'watch') {
|
|
111
|
+
watchLocalNames.add(local)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const cellNames = new Set<string>([
|
|
115
|
+
...stateNames,
|
|
116
|
+
...derivedNames,
|
|
117
|
+
...computedNames,
|
|
118
|
+
...cellReadNames,
|
|
119
|
+
])
|
|
103
120
|
const result = ts.transform(source, [
|
|
104
121
|
transformer,
|
|
122
|
+
wrapReactionCellSources(cellNames, watchLocalNames),
|
|
105
123
|
signalRefsTransformer(
|
|
106
124
|
stateNames,
|
|
107
125
|
derivedNames,
|