@abide/abide 0.40.2 → 0.41.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.
- package/AGENTS.md +327 -326
- package/CHANGELOG.md +62 -0
- package/README.md +87 -78
- package/package.json +1 -1
- package/src/lib/ui/compile/SSR_ESCAPE.ts +3 -1
- package/src/lib/ui/compile/compileShadow.ts +5 -0
- package/src/lib/ui/compile/destructureBindingNames.ts +38 -0
- package/src/lib/ui/compile/generateBuild.ts +96 -12
- package/src/lib/ui/compile/generateSSR.ts +26 -9
- package/src/lib/ui/compile/lowerContext.ts +29 -9
- package/src/lib/ui/compile/parseTemplate.ts +2 -0
- package/src/lib/ui/compile/renameSignalRefs.ts +19 -2
- package/src/lib/ui/compile/skeletonContext.ts +67 -79
- package/src/lib/ui/compile/templateAnchorAdapter.ts +61 -0
- package/src/lib/ui/compile/templateElementAdapter.ts +44 -0
- package/src/lib/ui/compile/types/TemplateNode.ts +2 -0
- package/src/lib/ui/compile/walkAnchorOrder.ts +57 -0
- package/src/lib/ui/compile/walkElementOrder.ts +60 -0
- package/src/lib/ui/dom/appendSnippet.ts +9 -8
- package/src/lib/ui/dom/applyResolved.ts +3 -4
- package/src/lib/ui/dom/awaitBlock.ts +106 -46
- package/src/lib/ui/dom/buildDetachedRange.ts +30 -0
- package/src/lib/ui/dom/depthZeroNodes.ts +34 -0
- package/src/lib/ui/dom/domAnchorAdapter.ts +29 -0
- package/src/lib/ui/dom/domElementAdapter.ts +20 -0
- package/src/lib/ui/dom/each.ts +33 -17
- package/src/lib/ui/dom/eachAsync.ts +34 -26
- package/src/lib/ui/dom/isElement.ts +6 -0
- package/src/lib/ui/dom/markerDepthDelta.ts +19 -0
- package/src/lib/ui/dom/mountRange.ts +4 -3
- package/src/lib/ui/dom/mountSlot.ts +4 -3
- package/src/lib/ui/dom/on.ts +6 -1
- package/src/lib/ui/dom/replaceRange.ts +24 -0
- package/src/lib/ui/dom/skeleton.ts +35 -92
- package/src/lib/ui/dom/switchBlock.ts +13 -10
- package/src/lib/ui/dom/types/EachRow.ts +9 -2
- package/src/lib/ui/dom/when.ts +13 -10
- package/src/lib/ui/runtime/RANGE_MARKER.ts +16 -0
- package/src/lib/ui/runtime/batch.ts +22 -0
- package/src/lib/ui/runtime/clientPage.ts +3 -8
- package/src/lib/ui/runtime/createDoc.ts +9 -13
- package/src/lib/ui/seedResolved.ts +28 -0
- package/src/lib/ui/startClient.ts +6 -4
- package/src/lib/ui/types/ResolvedFrame.ts +15 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,67 @@
|
|
|
1
1
|
# abide
|
|
2
2
|
|
|
3
|
+
## 0.41.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- [`74f5312`](https://github.com/briancray/abide/commit/74f531260e17f48162397142fb19b3df9e081b2c) - shared replaceRange region-update seam ([`184cf41`](https://github.com/briancray/abide/commit/184cf41157ef0a5340a4a916d6cfc4f4e09b6c71))
|
|
8
|
+
|
|
9
|
+
- [`74f5312`](https://github.com/briancray/abide/commit/74f531260e17f48162397142fb19b3df9e081b2c) - single-source the marker-depth and isElement helpers ([`188c6ff`](https://github.com/briancray/abide/commit/188c6ff29fcaff2c9c206b971a2f04daf9a18cb3))
|
|
10
|
+
|
|
11
|
+
- [`74f5312`](https://github.com/briancray/abide/commit/74f531260e17f48162397142fb19b3df9e081b2c) - guard against double-dispose on reentrant branch swap ([`40600c7`](https://github.com/briancray/abide/commit/40600c7921c9fee9b6be947472c770af795f60c6))
|
|
12
|
+
|
|
13
|
+
- [`74f5312`](https://github.com/briancray/abide/commit/74f531260e17f48162397142fb19b3df9e081b2c) - await block on marker ranges, not a node array ([`5d12e83`](https://github.com/briancray/abide/commit/5d12e83b7313a04a925df3464ef5c7669bcbc1e6))
|
|
14
|
+
|
|
15
|
+
- [`74f5312`](https://github.com/briancray/abide/commit/74f531260e17f48162397142fb19b3df9e081b2c) - refresh AGENTS surface map, README, and kitchen-sink example ([`6cd7a3f`](https://github.com/briancray/abide/commit/6cd7a3f5686a69f4dd5faf87026d7f1ab0362bdb))
|
|
16
|
+
|
|
17
|
+
- [`74f5312`](https://github.com/briancray/abide/commit/74f531260e17f48162397142fb19b3df9e081b2c) - single client warm-seed intake (seedResolved) ([`8c3c19e`](https://github.com/briancray/abide/commit/8c3c19ed0a4d4244cb2634c025ef9ff88b7c9670))
|
|
18
|
+
|
|
19
|
+
- [`74f5312`](https://github.com/briancray/abide/commit/74f531260e17f48162397142fb19b3df9e081b2c) - centralize comment-marker sentinels and detached-range builder ([`99d4499`](https://github.com/briancray/abide/commit/99d44991c499b96fc016c3d204cb4dafa340cbe1))
|
|
20
|
+
|
|
21
|
+
- [`74f5312`](https://github.com/briancray/abide/commit/74f531260e17f48162397142fb19b3df9e081b2c) - unify skeleton positional model on shared walks ([`cbb9daa`](https://github.com/briancray/abide/commit/cbb9daaa6f31532331df44136dd029f3e32cdfd7))
|
|
22
|
+
|
|
23
|
+
- [`f3e0a13`](https://github.com/briancray/abide/commit/f3e0a132f9f9fbb020a8c783a0b54d3b37457e39) - Coalesce reactive writes inside event handlers
|
|
24
|
+
|
|
25
|
+
Event handlers now batch their writes: a handler that sets several signals re-runs
|
|
26
|
+
each dependent effect/computed/DOM-binding once on the handler's exit instead of once
|
|
27
|
+
per write (the previous default flushed eagerly per write). The change stays fully
|
|
28
|
+
synchronous — the flush runs at handler-end before it returns — so the causal stack
|
|
29
|
+
(`dispatch → handler → effects`) is intact and server/client scheduling stays identical.
|
|
30
|
+
|
|
31
|
+
Factored the batch idiom `createDoc`/`clientPage` inlined into a shared, nesting-safe
|
|
32
|
+
`batch()` (flushes only on the depth-0 exit) and migrated both onto it, so a handler
|
|
33
|
+
that triggers a doc patch now coalesces end-to-end rather than flushing mid-handler.
|
|
34
|
+
|
|
35
|
+
A handler writing N fields cuts dependent re-runs N× (bench: an 8-field form handler
|
|
36
|
+
drops aggregate re-runs 8× and runs ~4.8× faster). Single-write handlers and navigation
|
|
37
|
+
(already batched) are unchanged. New contract, pinned by tests: a handler that writes
|
|
38
|
+
then synchronously reads the bound DOM sees the pre-write value until it returns; signal
|
|
39
|
+
reads stay current.
|
|
40
|
+
|
|
41
|
+
- [`f26cba2`](https://github.com/briancray/abide/commit/f26cba22b92683f7316cc7212c5fe10500bdad13) - `abide check`: declare the `each` `index` binding in the type-check shadow
|
|
42
|
+
|
|
43
|
+
`index="i"` (0.41.0) was bound in the build and SSR passes but not in the shadow the
|
|
44
|
+
type-checker reads, so `{i}` in a row body false-positived "Cannot find name 'i'". The
|
|
45
|
+
shadow now declares the index as a `number` inside the loop body, matching the runtime.
|
|
46
|
+
|
|
47
|
+
## 0.41.0
|
|
48
|
+
|
|
49
|
+
### Minor Changes
|
|
50
|
+
|
|
51
|
+
- [`ba08127`](https://github.com/briancray/abide/commit/ba081270c4721d64655457681e4ebcc79681f1d4) - `index="i"` on `<template each>` — bind the row's reactive position
|
|
52
|
+
|
|
53
|
+
`<template each={list} as="item" key="item.id" index="i">` binds the iteration index to a name. In a keyed `each` the index is reactive: a reorder/insert/remove that shifts a surviving row repaints its `{i}` in place (same DOM, no rebuild), riding the same `Object.is` cell-write path as the item binding. SSR renders the index via `entries()` so hydration stays congruent; async `each await` carries the stream arrival ordinal.
|
|
54
|
+
|
|
55
|
+
- [`ba08127`](https://github.com/briancray/abide/commit/ba081270c4721d64655457681e4ebcc79681f1d4) - In-place reactive updates for `<template await>` `then` values and keyed `each` items
|
|
56
|
+
|
|
57
|
+
A re-settling `await` block and a re-keyed `each` row no longer rebuild their subtree — they update through a reactive value cell instead, so a live cache patch updates only what changed and never flashes the surrounding DOM.
|
|
58
|
+
|
|
59
|
+
- `awaitBlock` keeps the mounted `then`-branch across a re-run, setting a reactive value cell rather than detaching + rebuilding. A revalidation now keeps the stale branch visible and patches in place; the branch is rebuilt only across a pending/catch ↔ then kind change.
|
|
60
|
+
- The `then` binding is lowered to read that cell reactively — both a single identifier (`then="value"`) and a destructure (`then="[a, b]"` / `{ x, y }`), where each leaf is derived per-read so only the leaves whose value changed propagate.
|
|
61
|
+
- Keyed `each` holds each row's item in a reactive cell; a re-key with a changed value (same key, new object) writes the cell through `Object.is`, re-running only that row's effects with no DOM rebuild. The row `render` now receives `State<T>` (the compiler binds the `as` name to read it).
|
|
62
|
+
- A destructuring `each … as="[a, b]"` (or `{ x, y }`) with no explicit `key` now defaults the key to the row's raw item identity, like a plain `as`. Previously the default key re-emitted the destructure pattern, allocating a fresh array/object per reconcile, so keys never matched and every row rebuilt on any list change.
|
|
63
|
+
- A block value binding (`then="x"` / `each … as="x"`) now correctly shadows a same-named component `state`/`computed`/`derived`: it is a nearer lexical scope, so `{x}` in the block reads the resolved/row cell rather than the component signal. A destructure pattern's default/computed-key initializer is also lowered, so `then="{ id, label = fallback }"` resolves `fallback` against the component scope instead of emitting it raw.
|
|
64
|
+
|
|
3
65
|
## 0.40.2
|
|
4
66
|
|
|
5
67
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -1,24 +1,24 @@
|
|
|
1
1
|
# abide
|
|
2
2
|
|
|
3
|
-
**One typed declaration fans out to HTTP, a CLI, an MCP tool, and an
|
|
4
|
-
spec —
|
|
3
|
+
**One typed declaration fans out to HTTP, a CLI, an MCP tool, and an
|
|
4
|
+
OpenAPI spec — the bundler swaps the runtime per side.**
|
|
5
5
|
|
|
6
|
-
abide is an isomorphic framework on Bun
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
abide is an isomorphic framework on Bun where you write a function once and
|
|
7
|
+
it serves every consumer: a browser fetch, an in-process SSR call, a CLI
|
|
8
|
+
subcommand, an MCP tool, an OpenAPI operation. The same callable keeps its
|
|
9
|
+
name and behaviour on both sides — the bundler decides whether it runs the
|
|
10
|
+
real handler or a network proxy. Built for humans _and_ machines.
|
|
10
11
|
|
|
11
|
-
- One direct dependency
|
|
12
|
-
|
|
13
|
-
(≥ 1.3.0); no second toolchain.
|
|
12
|
+
- One direct dependency (`typescript`); `tailwindcss` + `bun-plugin-tailwind`
|
|
13
|
+
are optional peers. Single runtime: Bun ≥ 1.3.0.
|
|
14
14
|
|
|
15
15
|
## Quick start
|
|
16
16
|
|
|
17
17
|
```sh
|
|
18
|
-
bunx abide scaffold my-app #
|
|
18
|
+
bunx abide scaffold my-app # scaffolds, installs deps, and starts dev
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
-
Or
|
|
21
|
+
Or read the full feature tour in the kitchen-sink example:
|
|
22
22
|
|
|
23
23
|
```sh
|
|
24
24
|
git clone https://github.com/briancray/abide
|
|
@@ -29,134 +29,143 @@ bun run dev
|
|
|
29
29
|
|
|
30
30
|
## RPCs
|
|
31
31
|
|
|
32
|
-
An RPC is one export per file under `src/server/rpc/`.
|
|
33
|
-
URL
|
|
34
|
-
|
|
35
|
-
|
|
32
|
+
An RPC is one export per file under `src/server/rpc/`. The file path is the
|
|
33
|
+
URL; the export name is the verb. A Standard Schema (zod / valibot / arktype,
|
|
34
|
+
unadapted) validates the args and projects the same shape into the MCP tool,
|
|
35
|
+
the CLI flags, and the OpenAPI operation.
|
|
36
36
|
|
|
37
37
|
```ts
|
|
38
|
-
// src/server/rpc/getMessages.ts
|
|
38
|
+
// src/server/rpc/getMessages.ts
|
|
39
39
|
import { GET } from '@abide/abide/server/GET'
|
|
40
40
|
import { json } from '@abide/abide/server/json'
|
|
41
41
|
import { z } from 'zod'
|
|
42
|
+
import { recent } from '../../chatState.ts'
|
|
42
43
|
|
|
43
|
-
const inputSchema = z.object({ room: z.string() })
|
|
44
|
+
const inputSchema = z.object({ room: z.string(), limit: z.coerce.number().default(20) })
|
|
44
45
|
|
|
45
|
-
export const getMessages = GET(({ room }) => json(
|
|
46
|
+
export const getMessages = GET(({ room, limit }) => json(recent(room).slice(-limit)), {
|
|
47
|
+
inputSchema,
|
|
48
|
+
})
|
|
46
49
|
```
|
|
47
50
|
|
|
48
|
-
One declaration,
|
|
51
|
+
One declaration, every surface:
|
|
49
52
|
|
|
50
53
|
```text
|
|
51
54
|
getMessages = GET(fn, { inputSchema })
|
|
52
55
|
│
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
+
┌─────────────┬─────────┼──────────┬──────────────┐
|
|
57
|
+
▼ ▼ ▼ ▼ ▼
|
|
58
|
+
SSR call browser MCP tool CLI sub- OpenAPI
|
|
59
|
+
cache(fn)() fetch (read) command operation
|
|
60
|
+
proxy
|
|
56
61
|
```
|
|
57
62
|
|
|
58
|
-
A schema unlocks the CLI
|
|
59
|
-
verb never auto-exposes to MCP — it needs explicit
|
|
60
|
-
Consume the verb four ways: `cache(getMessages)(
|
|
61
|
-
|
|
62
|
-
`getMessages.raw(args)`
|
|
63
|
-
`getMessages.stream(args)`
|
|
63
|
+
A schema unlocks the CLI for every verb and MCP for read-only verbs (`GET` /
|
|
64
|
+
`HEAD`); a mutating verb never auto-exposes to MCP — it needs an explicit
|
|
65
|
+
`clients: { mcp: true }`. Consume the verb four ways: `cache(getMessages)(args)`
|
|
66
|
+
in-process (warm SSR hydration), the swapped `fetch` proxy in the browser,
|
|
67
|
+
`getMessages.raw(args)` for the untouched `Response`, and
|
|
68
|
+
`getMessages.stream(args)` to iterate a `jsonl`/`sse` body.
|
|
64
69
|
|
|
65
|
-
> Query args
|
|
66
|
-
> `timeout` (504 on every surface) is distinct from
|
|
70
|
+
> Query args arrive as strings — wrap numeric/boolean fields in `z.coerce.*`.
|
|
71
|
+
> The per-verb `timeout` (504 on every surface) is distinct from the
|
|
72
|
+
> client-side `ABIDE_CLIENT_TIMEOUT`.
|
|
67
73
|
|
|
68
74
|
## Sockets
|
|
69
75
|
|
|
70
76
|
A socket is one broadcast topic per file under `src/server/sockets/`. A
|
|
71
|
-
`Socket<T>` is an isomorphic `AsyncIterable<T
|
|
72
|
-
|
|
77
|
+
`Socket<T>` is an isomorphic `AsyncIterable<T>`; every socket multiplexes onto
|
|
78
|
+
one WebSocket at `/__abide/sockets`.
|
|
73
79
|
|
|
74
80
|
```ts
|
|
75
|
-
// src/server/sockets/
|
|
81
|
+
// src/server/sockets/chat.ts
|
|
76
82
|
import { socket } from '@abide/abide/server/socket'
|
|
77
83
|
import { z } from 'zod'
|
|
78
84
|
|
|
79
|
-
const schema = z.object({ id: z.string(),
|
|
85
|
+
const schema = z.object({ id: z.string(), from: z.string(), text: z.string(), at: z.number() })
|
|
80
86
|
|
|
81
|
-
// retain the last
|
|
82
|
-
export const
|
|
83
|
-
export type
|
|
87
|
+
// retain the last 100 frames; evict any older than an hour
|
|
88
|
+
export const chat = socket({ schema, tail: 100, ttl: 3_600_000 })
|
|
89
|
+
export type ChatMessage = z.infer<typeof schema>
|
|
84
90
|
```
|
|
85
91
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
publishes — gated by `clientPublish` (off
|
|
92
|
+
It also has an HTTP face for clients that can't speak the multiplex (the CLI
|
|
93
|
+
and MCP): `GET /__abide/sockets/chat` returns the retained tail, and
|
|
94
|
+
`POST /__abide/sockets/chat` publishes — gated by `clientPublish` (default off,
|
|
95
|
+
so browsers publish through a validating verb instead).
|
|
89
96
|
|
|
90
|
-
## Components
|
|
97
|
+
## Components — the full template
|
|
91
98
|
|
|
92
|
-
A `.abide` component
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
`scope().computed()` is read-only. `props()` and `effect()` are in-scope, no
|
|
96
|
-
import.
|
|
99
|
+
A `.abide` component pulls the verb and the socket above into one page and
|
|
100
|
+
exercises the template grammar. `scope`, `props`, `effect`, `html`, and
|
|
101
|
+
`snippet` are ambient — no import needed.
|
|
97
102
|
|
|
103
|
+
<!-- prettier-ignore -->
|
|
98
104
|
```html
|
|
99
105
|
<script>
|
|
100
106
|
import { cache } from '@abide/abide/shared/cache'
|
|
101
107
|
import { tail } from '@abide/abide/ui/tail'
|
|
102
108
|
import { getMessages } from '$server/rpc/getMessages.ts'
|
|
103
|
-
import {
|
|
104
|
-
import {
|
|
109
|
+
import { publishChat } from '$server/rpc/publishChat.ts'
|
|
110
|
+
import { chat } from '$server/sockets/chat.ts'
|
|
105
111
|
import Avatar from '$ui/Avatar.abide'
|
|
106
112
|
|
|
107
|
-
const { room
|
|
113
|
+
const { room } = props()
|
|
108
114
|
|
|
109
|
-
//
|
|
110
|
-
const
|
|
111
|
-
const
|
|
115
|
+
// warm on the server, live on the client
|
|
116
|
+
const history = scope().computed(() => cache(getMessages)({ room }))
|
|
117
|
+
const latest = scope().computed(() => tail(chat))
|
|
112
118
|
|
|
113
119
|
let from = scope().state('alice')
|
|
114
120
|
let text = scope().state('')
|
|
115
121
|
let pinned = scope().state(false)
|
|
116
|
-
let
|
|
122
|
+
let view = scope().state('all')
|
|
117
123
|
|
|
118
124
|
async function send() {
|
|
119
|
-
await
|
|
125
|
+
await publishChat({ from, text })
|
|
120
126
|
text = ''
|
|
121
127
|
}
|
|
122
128
|
</script>
|
|
123
129
|
|
|
124
|
-
<template name="
|
|
125
|
-
<li
|
|
130
|
+
<template name="line" args={message}>
|
|
131
|
+
<li><Avatar name={message.from} /> <b>{message.from}</b>: {message.text}</li>
|
|
126
132
|
</template>
|
|
127
133
|
|
|
128
|
-
<form onsubmit={send}
|
|
129
|
-
<input bind:value={from}
|
|
130
|
-
<input bind:value={text} placeholder="message"
|
|
134
|
+
<form onsubmit={send}>
|
|
135
|
+
<input bind:value={from} placeholder="name" />
|
|
136
|
+
<input bind:value={text} placeholder="message" />
|
|
131
137
|
<label><input type="checkbox" bind:checked={pinned} /> pin</label>
|
|
132
|
-
<label><input type="radio" bind:group={
|
|
133
|
-
<
|
|
138
|
+
<label><input type="radio" bind:group={view} value="all" /> all</label>
|
|
139
|
+
<label><input type="radio" bind:group={view} value="mine" /> mine</label>
|
|
140
|
+
<button disabled={!text}>send</button>
|
|
134
141
|
</form>
|
|
135
142
|
|
|
136
|
-
<template if={
|
|
137
|
-
<p
|
|
138
|
-
|
|
143
|
+
<template if={latest}>
|
|
144
|
+
<p>latest from {latest.from}</p>
|
|
145
|
+
<template else>
|
|
146
|
+
<p>no messages yet</p>
|
|
139
147
|
</template>
|
|
140
148
|
|
|
141
|
-
<template switch={
|
|
142
|
-
|
|
143
|
-
|
|
149
|
+
<template switch={view}>
|
|
150
|
+
<template case="mine"><p>showing your messages</p></template>
|
|
151
|
+
<template default><p>showing every message</p></template>
|
|
144
152
|
</template>
|
|
145
153
|
|
|
146
|
-
<template await={
|
|
147
|
-
<p
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
<
|
|
154
|
+
<template await={history}>
|
|
155
|
+
<p>loading…</p>
|
|
156
|
+
<template then="data">
|
|
157
|
+
<ul>
|
|
158
|
+
<template each={data} as="message" key="message.id" index="i">
|
|
159
|
+
{i}. {line(message)}
|
|
160
|
+
</template>
|
|
161
|
+
</ul>
|
|
162
|
+
<template catch="reason">
|
|
163
|
+
<p>failed: {reason.message}</p>
|
|
164
|
+
</template>
|
|
156
165
|
</template>
|
|
157
166
|
|
|
158
167
|
<style>
|
|
159
|
-
|
|
168
|
+
form { display: flex; gap: 0.5rem; }
|
|
160
169
|
</style>
|
|
161
170
|
```
|
|
162
171
|
|
package/package.json
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { SNIPPET_CLOSE, SNIPPET_OPEN } from '../runtime/RANGE_MARKER.ts'
|
|
2
|
+
|
|
1
3
|
/*
|
|
2
4
|
Source text for the `$esc` / `$attr` / `$spread` / `$text` / `$snip` helpers injected
|
|
3
5
|
into every SSR render body. `$esc` escapes the five HTML-significant characters. `$attr`
|
|
@@ -30,6 +32,6 @@ export const SSR_ESCAPE =
|
|
|
30
32
|
"const $SNIP = Symbol.for('abide.snippet');\n" +
|
|
31
33
|
'const $snip = (s) => ({ [$SNIP]: s });\n' +
|
|
32
34
|
'const $text = (v) => (v !== null && typeof v === "object" && $SNIP in v) ' +
|
|
33
|
-
|
|
35
|
+
`? ('<!--${SNIPPET_OPEN}-->' + v[$SNIP] + '<!--${SNIPPET_CLOSE}-->') ` +
|
|
34
36
|
': (v !== null && typeof v === "object" && $RAW in v) ' +
|
|
35
37
|
"? ('<!--abide:html-->' + v[$RAW] + '<!--/abide:html-->') : $esc(v);"
|
|
@@ -417,6 +417,11 @@ function emitNode(node: TemplateNode, builder: Builder): void {
|
|
|
417
417
|
if (node.key !== undefined) {
|
|
418
418
|
builder.raw(`void (${node.key});\n`)
|
|
419
419
|
}
|
|
420
|
+
/* `index="i"` binds the row's position — always a number (the row ordinal,
|
|
421
|
+
or an async stream's arrival count). Declare it so body references check. */
|
|
422
|
+
if (node.index !== undefined) {
|
|
423
|
+
builder.raw(`const ${node.index}: number = 0;\n`)
|
|
424
|
+
}
|
|
420
425
|
emitNodes(node.children, builder)
|
|
421
426
|
builder.raw('}\n')
|
|
422
427
|
return
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import ts from 'typescript'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
The leaf binding names a destructuring pattern introduces, in source order —
|
|
5
|
+
`[a, b]` → `a, b`; `{ x, y: z }` → `x, z`; `[a, ...rest]` → `a, rest`; nested
|
|
6
|
+
patterns flatten. Used to re-bind an `await` `then` destructure as per-leaf
|
|
7
|
+
reactive reads of the resolved-value cell, so a re-settle updates each leaf in
|
|
8
|
+
place instead of rebuilding the branch.
|
|
9
|
+
*/
|
|
10
|
+
export function destructureBindingNames(pattern: string): string[] {
|
|
11
|
+
const source = ts.createSourceFile(
|
|
12
|
+
'pattern.ts',
|
|
13
|
+
`const ${pattern} = $;`,
|
|
14
|
+
ts.ScriptTarget.Latest,
|
|
15
|
+
true,
|
|
16
|
+
)
|
|
17
|
+
const names: string[] = []
|
|
18
|
+
const collect = (name: ts.BindingName): void => {
|
|
19
|
+
if (ts.isIdentifier(name)) {
|
|
20
|
+
names.push(name.text)
|
|
21
|
+
return
|
|
22
|
+
}
|
|
23
|
+
// Object/array pattern: each element binds; array holes are OmittedExpression, not BindingElement.
|
|
24
|
+
for (const element of name.elements) {
|
|
25
|
+
if (ts.isBindingElement(element)) {
|
|
26
|
+
collect(element.name)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
for (const statement of source.statements) {
|
|
31
|
+
if (ts.isVariableStatement(statement)) {
|
|
32
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
33
|
+
collect(declaration.name)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return names
|
|
38
|
+
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { assertExhaustive } from '../../shared/assertExhaustive.ts'
|
|
2
2
|
import { HOLE_ATTRIBUTE } from '../runtime/HOLE_ATTRIBUTE.ts'
|
|
3
3
|
import { OUTLET_TAG } from '../runtime/OUTLET_TAG.ts'
|
|
4
|
+
import { ANCHOR } from '../runtime/RANGE_MARKER.ts'
|
|
4
5
|
import { asOutlet } from './asOutlet.ts'
|
|
5
6
|
import { bindListenEvent } from './bindListenEvent.ts'
|
|
6
7
|
import { composeProps } from './composeProps.ts'
|
|
8
|
+
import { destructureBindingNames } from './destructureBindingNames.ts'
|
|
7
9
|
import { groupBindParts } from './groupBindParts.ts'
|
|
8
10
|
import { isControlFlow } from './isControlFlow.ts'
|
|
9
11
|
import { isWhitespaceText } from './isWhitespaceText.ts'
|
|
@@ -17,6 +19,12 @@ import { staticTextPart } from './staticTextPart.ts'
|
|
|
17
19
|
import type { TemplateNode } from './types/TemplateNode.ts'
|
|
18
20
|
import { VOID_TAGS } from './VOID_TAGS.ts'
|
|
19
21
|
|
|
22
|
+
/* The skeleton positioning anchor a control-flow block / component / slot / outlet
|
|
23
|
+
stamps into its skeleton markup, sourced from the same `ANCHOR` wire-alphabet constant
|
|
24
|
+
the client's anchor scan (`skeleton`) matches — so the markup the build clones and the
|
|
25
|
+
markup the scan reads can never drift on a literal. */
|
|
26
|
+
const ANCHOR_COMMENT = `<!--${ANCHOR}-->`
|
|
27
|
+
|
|
20
28
|
/*
|
|
21
29
|
Generates the build statements for a parsed template: element creation, static
|
|
22
30
|
attributes, reactive `attr`/`text` bindings, `on` listeners, keyed `each`, and
|
|
@@ -85,10 +93,50 @@ export function generateBuild(
|
|
|
85
93
|
expression: lowerExpression,
|
|
86
94
|
statement: lowerStatement,
|
|
87
95
|
withNestedScripts,
|
|
96
|
+
withLocalDerived,
|
|
88
97
|
bindRead,
|
|
89
98
|
bindWrite,
|
|
90
99
|
} = lowerContext(stateNames, derivedNames, computedNames)
|
|
91
100
|
|
|
101
|
+
/* A value binding the runtime can update in place (an `await` `then` value, a keyed `each`
|
|
102
|
+
item) is passed as a reactive `.value` cell; the consuming branch/row reads its NAME(s)
|
|
103
|
+
as a deref so it re-runs in place when a re-settle/re-key sets the cell. */
|
|
104
|
+
const isPlainIdentifier = (name: string | undefined): name is string =>
|
|
105
|
+
name !== undefined && /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name)
|
|
106
|
+
|
|
107
|
+
/* How a reactive value param binds: `param` is the thunk's value parameter (the cell),
|
|
108
|
+
`prefix` declares any per-leaf readers, `localNames` enter the deref scope for the body.
|
|
109
|
+
A plain identifier reads the cell directly (`item` → `item.value`); a destructure
|
|
110
|
+
re-applies over the cell per read so each leaf stays reactive (JS handles
|
|
111
|
+
defaults/rest/rename/nesting). The caller lowers the body with `localNames` derived. */
|
|
112
|
+
function reactiveBinding(authorParam: string): {
|
|
113
|
+
param: string
|
|
114
|
+
prefix: string
|
|
115
|
+
localNames: string[]
|
|
116
|
+
} {
|
|
117
|
+
if (isPlainIdentifier(authorParam)) {
|
|
118
|
+
return { param: authorParam, prefix: '', localNames: [authorParam] }
|
|
119
|
+
}
|
|
120
|
+
const cellParam = nextVar('aw')
|
|
121
|
+
const deriveVar = nextVar('ad')
|
|
122
|
+
const leaves = destructureBindingNames(authorParam)
|
|
123
|
+
/* Lower the destructure declaration so a default/computed-key initializer that
|
|
124
|
+
references a component signal (`{ label = fallback }`, `{ [key]: v }`) is rewritten
|
|
125
|
+
to its `model`/cell form — the bound leaf names are name-slots and stay untouched.
|
|
126
|
+
A pattern with no such initializer lowers to itself, so the common case is unchanged
|
|
127
|
+
and SSR (which lowers the same declaration) stays congruent. */
|
|
128
|
+
const declaration = lowerStatement(`const ${authorParam} = ${cellParam}.value`)
|
|
129
|
+
const prefix =
|
|
130
|
+
`const ${deriveVar} = { get value() { ${declaration} return { ${leaves.join(', ')} }; } };\n` +
|
|
131
|
+
leaves
|
|
132
|
+
.map(
|
|
133
|
+
(leaf) =>
|
|
134
|
+
`const ${leaf} = { get value() { return ${deriveVar}.value.${leaf}; } };\n`,
|
|
135
|
+
)
|
|
136
|
+
.join('')
|
|
137
|
+
return { param: cellParam, prefix, localNames: leaves }
|
|
138
|
+
}
|
|
139
|
+
|
|
92
140
|
/* Emits the wiring for one non-static attribute against an already-obtained skeleton
|
|
93
141
|
element var — reactive `attr`, `on` listener, `attach`, or a two-way `bind`. */
|
|
94
142
|
function dynamicAttr(
|
|
@@ -172,7 +220,7 @@ export function generateBuild(
|
|
|
172
220
|
binds.push(
|
|
173
221
|
`appendTextAt(${skVar}.an[${holeIndex(anIndex, part)}], ${namedThunk('text', `return (${lowerExpression(part.code)})`)});\n`,
|
|
174
222
|
)
|
|
175
|
-
return
|
|
223
|
+
return ANCHOR_COMMENT
|
|
176
224
|
})
|
|
177
225
|
.join('')
|
|
178
226
|
}
|
|
@@ -185,7 +233,7 @@ export function generateBuild(
|
|
|
185
233
|
element — so its root lays out as a true direct child of `anchor.parentNode`. */
|
|
186
234
|
const anchorVar = anchorVarAt(node, skVar, binds)
|
|
187
235
|
binds.push(generateChild(node, `${anchorVar}.parentNode`, `anchorCursor(${anchorVar})`))
|
|
188
|
-
return
|
|
236
|
+
return ANCHOR_COMMENT
|
|
189
237
|
}
|
|
190
238
|
if (node.kind === 'script') {
|
|
191
239
|
/* A nested `<script>` (scoped reactive block) emits no markup — its lowered body
|
|
@@ -210,7 +258,7 @@ export function generateBuild(
|
|
|
210
258
|
No wrapper element — the filled child lays out as a direct child of the parent. */
|
|
211
259
|
const anchorVar = anchorVarAt(node, skVar, binds)
|
|
212
260
|
binds.push(`outlet(${anchorVar}.parentNode, anchorCursor(${anchorVar}));\n`)
|
|
213
|
-
return
|
|
261
|
+
return ANCHOR_COMMENT
|
|
214
262
|
}
|
|
215
263
|
if (node.tag === 'slot') {
|
|
216
264
|
/* A `<slot>` outlet at its position: an `<!--a-->` anchor, the slot's content
|
|
@@ -220,7 +268,7 @@ export function generateBuild(
|
|
|
220
268
|
binds.push(
|
|
221
269
|
`mountSlot(${anchorVar}.parentNode, (${hostVar}) => {\n${generateSlot(node, hostVar)}}, anchorCursor(${anchorVar}));\n`,
|
|
222
270
|
)
|
|
223
|
-
return
|
|
271
|
+
return ANCHOR_COMMENT
|
|
224
272
|
}
|
|
225
273
|
const hasReactiveAttr = node.attrs.some((attr) => attr.kind !== 'static')
|
|
226
274
|
const reactiveTextChild = node.children.find(
|
|
@@ -498,13 +546,21 @@ export function generateBuild(
|
|
|
498
546
|
const pending = node.blocking
|
|
499
547
|
? []
|
|
500
548
|
: node.children.filter((child) => child.kind !== 'branch')
|
|
549
|
+
/* The resolved value is reactive: a re-settle updates it in place rather than
|
|
550
|
+
rebuilding the branch (see awaitBlock). The branch reads it as a `.value` cell. */
|
|
501
551
|
const thenThunk = node.blocking
|
|
502
552
|
? branchThunk(
|
|
503
553
|
node.children.filter((child) => child.kind !== 'branch'),
|
|
504
554
|
node.as ?? '_value',
|
|
505
555
|
finallyChildren,
|
|
556
|
+
true,
|
|
557
|
+
)
|
|
558
|
+
: branchThunk(
|
|
559
|
+
thenBranch?.children ?? [],
|
|
560
|
+
thenBranch?.as ?? '_value',
|
|
561
|
+
finallyChildren,
|
|
562
|
+
true,
|
|
506
563
|
)
|
|
507
|
-
: branchThunk(thenBranch?.children ?? [], thenBranch?.as ?? '_value', finallyChildren)
|
|
508
564
|
/* Neither catch nor finally → pass `undefined` so awaitBlock re-throws the
|
|
509
565
|
rejection (surfacing it) instead of rendering an empty branch. A finally-only
|
|
510
566
|
block keeps a catch thunk that renders just finally. */
|
|
@@ -536,18 +592,29 @@ export function generateBuild(
|
|
|
536
592
|
children: TemplateNode[],
|
|
537
593
|
valueParam?: string,
|
|
538
594
|
finallyChildren: TemplateNode[] = [],
|
|
595
|
+
reactiveValue = false,
|
|
539
596
|
): string {
|
|
540
597
|
const parentParam = nextVar('p')
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
const
|
|
598
|
+
/* A reactive value (an `await` `then`) arrives as a `.value` cell the runtime can set
|
|
599
|
+
in place, so the branch re-runs in place on a re-settle instead of being rebuilt. */
|
|
600
|
+
const binding =
|
|
601
|
+
reactiveValue && valueParam !== undefined ? reactiveBinding(valueParam) : undefined
|
|
602
|
+
const param = binding?.param ?? valueParam
|
|
603
|
+
const prefix = binding?.prefix ?? ''
|
|
604
|
+
const localNames = binding?.localNames ?? []
|
|
605
|
+
const head = param === undefined ? `(${parentParam})` : `(${parentParam}, ${param})`
|
|
606
|
+
const body = withNestedScripts(children, () =>
|
|
607
|
+
localNames.length === 0
|
|
608
|
+
? generateChildren(children, parentParam)
|
|
609
|
+
: withLocalDerived(localNames, () => generateChildren(children, parentParam)),
|
|
610
|
+
)
|
|
544
611
|
const finallyBody =
|
|
545
612
|
finallyChildren.length > 0
|
|
546
613
|
? withNestedScripts(finallyChildren, () =>
|
|
547
614
|
generateChildren(finallyChildren, parentParam),
|
|
548
615
|
)
|
|
549
616
|
: ''
|
|
550
|
-
return `${head} => {\n${body}${finallyBody}}`
|
|
617
|
+
return `${head} => {\n${prefix}${body}${finallyBody}}`
|
|
551
618
|
}
|
|
552
619
|
|
|
553
620
|
/* True when a branch has content worth a render thunk — vs an absent/empty branch
|
|
@@ -626,13 +693,30 @@ export function generateBuild(
|
|
|
626
693
|
before: string,
|
|
627
694
|
): string {
|
|
628
695
|
const rowParam = nextVar('p')
|
|
696
|
+
/* The item is a reactive `.value` cell so a re-key with a changed value updates the row
|
|
697
|
+
in place (no rebuild). `keyOf` receives the RAW item; the key expression is lowered
|
|
698
|
+
with the author name plain — derive it BEFORE the row body puts that name in the
|
|
699
|
+
deref scope. With no explicit `key`, default the key to the item's own identity: a
|
|
700
|
+
plain `as` returns its name; a destructuring `as` binds a fresh param and returns
|
|
701
|
+
THAT, not the pattern re-wrapped (`[i,crumb]` → `[i,crumb]` would allocate a fresh
|
|
702
|
+
array per reconcile, so keys never match and every row rebuilds). An explicit `key`
|
|
703
|
+
destructures the item via `node.as` to read its leaves. */
|
|
704
|
+
const rawItemParam = isPlainIdentifier(node.as) ? node.as : nextVar('k')
|
|
705
|
+
const keyParam = node.key === undefined ? rawItemParam : node.as
|
|
706
|
+
const keyExpression = node.key === undefined ? rawItemParam : lowerExpression(node.key)
|
|
707
|
+
const binding = reactiveBinding(node.as)
|
|
708
|
+
/* `index="i"` binds the row's position as a third reactive cell param (the runtime
|
|
709
|
+
always passes it). It is a plain identifier — read as `i.value` — so it enters the
|
|
710
|
+
body's deref scope alongside the item's leaf names; an unnamed param when absent. */
|
|
711
|
+
const indexParam = node.index === undefined ? '' : `, ${node.index}`
|
|
712
|
+
const bodyLocalNames =
|
|
713
|
+
node.index === undefined ? binding.localNames : [...binding.localNames, node.index]
|
|
629
714
|
/* The row body builds its children (a `<script>` declares per-row local signals,
|
|
630
715
|
emitted in document order) into the row parent. A `<template catch>` child is
|
|
631
716
|
consumed by the async-each, not the row — `generateChildren` skips it. */
|
|
632
717
|
const rowBody = withNestedScripts(node.children, () =>
|
|
633
|
-
generateChildren(node.children, rowParam),
|
|
718
|
+
withLocalDerived(bodyLocalNames, () => generateChildren(node.children, rowParam)),
|
|
634
719
|
)
|
|
635
|
-
const keyExpression = node.key === undefined ? node.as : lowerExpression(node.key)
|
|
636
720
|
/* `await` → the AsyncIterable runtime, drained row-by-row on the client, with an
|
|
637
721
|
optional `<template catch>` branch rendered (after the streamed rows) when the
|
|
638
722
|
iterator rejects. Absent → `undefined`, so the rejection surfaces instead. */
|
|
@@ -643,7 +727,7 @@ export function generateBuild(
|
|
|
643
727
|
: ''
|
|
644
728
|
return (
|
|
645
729
|
`${fn}(${parentVar}, () => (${lowerExpression(node.items)}), ` +
|
|
646
|
-
`(${
|
|
730
|
+
`(${keyParam}) => (${keyExpression}), (${rowParam}, ${binding.param}${indexParam}) => {\n${binding.prefix}${rowBody}}${catchArg}, ${before});\n`
|
|
647
731
|
)
|
|
648
732
|
}
|
|
649
733
|
|