@abide/abide 0.107.0 → 0.109.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/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # @abide/abide
2
2
 
3
+ ## 0.109.0
4
+
5
+ ### Minor Changes
6
+
7
+ - f969612: claudeCode.bridgeCommand and claudeCode.bridge take tools, and the command they mint grants them after its -- ([`d20a962`](https://github.com/briancray/abide/commit/d20a962523f91161cb731e766863256fd5d90208))
8
+
9
+ ## 0.108.0
10
+
11
+ ### Minor Changes
12
+
13
+ - fda92c3: every MCP tool call writes a debug line on abide:mcp, and every assistant conversation one on abide:assistant ([`6c4d8c7`](https://github.com/briancray/abide/commit/6c4d8c745491308c0208eb18e900473fb8ee9e03))
14
+
15
+ ### Patch Changes
16
+
17
+ - fda92c3: a back or forward that reloads no longer writes the departed page's scroll into the entry being loaded ([`294c0e6`](https://github.com/briancray/abide/commit/294c0e6ed806c38e80a1f872e7be6ac5579c7382))
18
+ - fda92c3: a site holds a dropped bridge pairing for 60 seconds, past when the bridge notices a silent drop ([`ac8e1d3`](https://github.com/briancray/abide/commit/ac8e1d313b7ea54414cd1034b56eb4cfa88ab31b))
19
+ - fda92c3: bind:value on a <select> selects an option added after the value was written ([`cd592b2`](https://github.com/briancray/abide/commit/cd592b2ccdc617c5cfed2a6acf5f5a458bc9986b))
20
+ - fda92c3: markup the client parses for a raw value, an {#if} arm or a {#for} row inside an <svg> or <math> is parsed in that namespace ([`f8ba81e`](https://github.com/briancray/abide/commit/f8ba81e94e0583858811e3c403535c0c0632b87c))
21
+
3
22
  ## 0.107.0
4
23
 
5
24
  ### Minor Changes
package/docs/REGISTRY.md CHANGED
@@ -338,10 +338,10 @@ type Gated<Gate, Accepted> = [Schema<Accepted>] extends [Gate] ? never : Failed<
338
338
 
339
339
  | Name | Signature | Meaning | Rules |
340
340
  | --- | --- | --- | --- |
341
- | `claudeCode` | `(config?: ClaudeCodeConfig) => Provider` | The visitor's own Claude Code, reached through the bridge paired with their browser, as a provider. | 44.5, 44.13, 44.17, 44.18, 44.19, 44.24, 44.25, 44.28 |
341
+ | `claudeCode` | `(config?: ClaudeCodeConfig) => Provider` | The visitor's own Claude Code, reached through the bridge paired with their browser, as a provider. | 44.5, 44.13, 44.17, 44.18, 44.19, 44.24, 44.25, 44.28, 44.31 |
342
342
  | `ClaudeCodeConfig` | `{ model?: string; fallbackModel?: string; systemPrompt?: string; appendSystemPrompt?: string; effort?: 'low' \| 'medium' \| 'high' \| 'xhigh' \| 'max'; maxBudgetUsd?: number; betas?: string[]; disallowedTools?: string[]; tools?: string[] }` | The Claude Code options a site may set for a turn, and the built-in tools it asks the visitor for. | 44.16, 44.30 |
343
- | `claudeCode.bridgeCommand` | `() => { command: string; expires: number }` | The command that pairs a bridge with this browser. | 44.14 |
344
- | `claudeCode.bridge` | `() => AsyncGenerator<BridgeStatus>` | This browser's pairing, as it changes. | 44.20, 44.22 |
343
+ | `claudeCode.bridgeCommand` | `(options?: { tools?: string[] }) => { command: string; expires: number }` | The command that pairs a bridge with this browser, granting the built-in tools it is given. | 44.14, 44.33 |
344
+ | `claudeCode.bridge` | `(options?: { tools?: string[] }) => AsyncGenerator<BridgeStatus>` | This browser's pairing, as it changes. | 44.20, 44.22, 44.33 |
345
345
  | `BridgeStatus` | `{ paired: boolean; command: string \| undefined }` | Whether a bridge is paired, and the command that pairs one where none is. | 44.20 |
346
346
  | `/__abide/bridge` | `GET`, `POST` | Where a bridge pairs, and is relayed its turns. | 36.7, 44.14, 44.15, 44.19, 44.23, 44.26, 44.27, 16.78 |
347
347
 
@@ -464,7 +464,8 @@ type Gated<Gate, Accepted> = [Schema<Accepted>] extends [Gate] ? never : Failed<
464
464
  | `abide:lifecycle` | `debug` `error` | A lifecycle that ran. | 28.8 |
465
465
  | `abide:principal` | `debug` `warning` | A principal set or cleared. | 28.8 |
466
466
  | `abide:health` | `warning` | An `onHealth` that threw or answered a non-object. | 25.2, 28.8 |
467
- | `abide:mcp` | `debug` `warning` | A handler withheld from MCP, or a tool published with no description. | 20.3, 20.9, 28.8 |
467
+ | `abide:mcp` | `debug` `warning` | A handler withheld from MCP, a tool call answered, or a tool published with no description. | 20.3, 20.9, 20.18, 28.8 |
468
+ | `abide:assistant` | `debug` | One line per conversation, once it ends. | 44.32 |
468
469
  | `abide:openapi` | `warning` | A handler whose schema had no JSON Schema export. | 20.11, 28.8 |
469
470
  | `abide:render` | `warning` | An `error.abide` that itself failed. | 28.8 |
470
471
  | `abide:refuse` | `warning` | A message formatter that threw. | 15.13, 28.8 |
@@ -527,7 +528,7 @@ type Gated<Gate, Accepted> = [Schema<Accepted>] extends [Gate] ? never : Failed<
527
528
  | --- | --- | --- |
528
529
  | `{expr}` | Reactive text, escaped. | 32.1, 32.23 |
529
530
  | formatting whitespace | What a run of whitespace renders as. | 33.17, 33.18 |
530
- | `{raw(...)}` | Raw HTML. | 32.2, 32.25, 32.26, 32.30, 32.31 |
531
+ | `{raw(...)}` | Raw HTML. | 32.2, 32.25, 32.26, 32.30, 32.31, 32.70 |
531
532
  | `name={expr}` | A reactive attribute or property. | 32.3, 32.20, 32.23, 32.37 |
532
533
  | `on<event>={fn}` | A native listener on an element, and an ordinary prop on a component. | 32.4 |
533
534
  | `name="…{expr}…"` | An interpolated quoted value. | 32.3 |
@@ -547,15 +548,15 @@ type Gated<Gate, Accepted> = [Schema<Accepted>] extends [Gate] ? never : Failed<
547
548
 
548
549
  | Name | Signature | Meaning | Rules |
549
550
  | --- | --- | --- | --- |
550
- | `raw` | `(value: unknown) => Raw` | The branding a value takes to render as markup rather than as text. | 32.2, 32.24, 32.26, 32.30, 32.31 |
551
+ | `raw` | `(value: unknown) => Raw` | The branding a value takes to render as markup rather than as text. | 32.2, 32.24, 32.26, 32.30, 32.31, 32.70 |
551
552
  | `Raw` | `class Raw { html: string }` | What `raw` hands back. | 32.24 |
552
553
 
553
554
  ### Control flow
554
555
 
555
556
  | Spelling | Branches | Meaning | Rules |
556
557
  | --- | --- | --- | --- |
557
- | `{#if cond}` | `{:else if cond}`, `{:else}` | Conditional markup. | 32.12, 32.21, 32.63, 32.64, 32.65, 41.24 |
558
- | `{#for item, index of list by key}` | — | Repeats markup over a list, or over a cursor as it produces. | 32.13, 32.14, 32.15, 32.22, 32.29, 32.32, 32.33, 32.63, 32.64, 32.67, 41.8, 41.14, 41.15 |
558
+ | `{#if cond}` | `{:else if cond}`, `{:else}` | Conditional markup. | 32.12, 32.21, 32.63, 32.64, 32.65, 32.70, 41.24 |
559
+ | `{#for item, index of list by key}` | — | Repeats markup over a list, or over a cursor as it produces. | 32.13, 32.14, 32.15, 32.22, 32.29, 32.32, 32.33, 32.63, 32.64, 32.67, 32.70, 41.8, 41.14, 41.15 |
559
560
  | `{#try}` | `{:catch e}`, `{:finally}` | A render-time boundary and a region. | 32.16, 32.56, 32.63, 32.64 |
560
561
 
561
562
  ### Components
package/docs/RULEBOOK.md CHANGED
@@ -1312,6 +1312,11 @@ where that schema is an object, and MUST declare none otherwise.
1312
1312
  20.17 A `tools/call` that `/__abide/mcp` answers for a tool declaring an `outputSchema` MUST carry
1313
1313
  the value as `structuredContent` beside its text, and a refused call MUST carry none.
1314
1314
 
1315
+ 20.18 Every tool call the MCP surface answers MUST write one `debug` line on `abide:mcp` naming the
1316
+ tool, the status it was answered with and how long it took, whether `/__abide/mcp` or a `Toolbox`
1317
+ made the call — a call naming a tool it does not publish answered 404, and one whose answer threw
1318
+ 500.
1319
+
1315
1320
  # 21. Generated headers
1316
1321
 
1317
1322
  21.1 Every response MUST carry `x-content-type-options: nosniff`.
@@ -1941,6 +1946,7 @@ each one a list holds under 32.62.
1941
1946
 
1942
1947
  32.62 A `bind:value` on a `<select multiple>` MUST hold a list: each option is selected where the list
1943
1948
  holds its value as text, and a change MUST write the values of the selected options, in order.
1949
+ On any `<select>`, an option added after the value was written MUST be selected where it holds it.
1944
1950
 
1945
1951
  32.63 A `{#if}`, `{#for}`, `{#try}` or `{#component Name(pattern)}` not closed by its own close tag
1946
1952
  before the end of the file, or before the close tag of the element it was opened in, MUST be a build
@@ -1965,6 +1971,9 @@ reading layout, querying its descendants or calling `focus()` acts on a connecte
1965
1971
 
1966
1972
  32.69 `bind:element` on a component MUST be a build error.
1967
1973
 
1974
+ 32.70 Markup the client parses for a slot holding a value branded by `raw`, a `{#if}` arm or a
1975
+ `{#for}` row MUST be parsed as the document's parser parses it inside the element it goes into.
1976
+
1968
1977
  # 33. Props
1969
1978
 
1970
1979
  33.1 `props` MUST be callable from a `<script>` block only, and calling it from a `<script module>`
@@ -3097,9 +3106,9 @@ its lines MUST be relayed.
3097
3106
  carrying it was not taken by `/__abide/bridge`, or where no reply arrived within 120 seconds.
3098
3107
 
3099
3108
  44.25 A bridge whose stream from `/__abide/bridge` ends unasked MUST redial it with its pairing's
3100
- token, each wait twice the last, until it is refused or 30 seconds have passed.
3109
+ token, each wait twice the last, until it is refused or 60 seconds have passed.
3101
3110
 
3102
- 44.26 `/__abide/bridge` MUST hold a pairing whose stream dropped for 30 seconds, and a `GET` bearing
3111
+ 44.26 `/__abide/bridge` MUST hold a pairing whose stream dropped for 60 seconds, and a `GET` bearing
3103
3112
  its token within them MUST resume the stream with every frame the dropped one had not sent.
3104
3113
 
3105
3114
  44.27 `/__abide/bridge` MUST send a comment on a pairing's stream every 15 seconds for as long as
@@ -3114,3 +3123,13 @@ and its `input` event, with the same `id`, once the call is written.
3114
3123
  44.30 A built-in tool MUST be available to a turn only where the site's `tools` names it and the
3115
3124
  visitor's `--tools` does, MUST be approved without asking unless the visitor passed `--allowedTools`,
3116
3125
  and the bridge MUST say once each tool the site asked for that the visitor did not grant.
3126
+
3127
+ 44.31 A bridge stopped by `SIGTERM`, `SIGINT` or `SIGHUP` MUST end every Claude Code run it started
3128
+ before it exits.
3129
+
3130
+ 44.32 A conversation MUST write one `debug` line on `abide:assistant` when its events end, naming
3131
+ its stop reason, the transcript's length, the tool calls it made, its usage and how long it took,
3132
+ and naming it `unfinished` where it ended without a `done`.
3133
+
3134
+ 44.33 A command `claudeCode.bridgeCommand` or `claudeCode.bridge` mints with `tools` MUST grant
3135
+ those built-in tools after its `--` as `--tools`, and one minted without `tools` MUST carry no `--`.
package/docs/abide.md CHANGED
@@ -5130,7 +5130,7 @@ test('a model lists the handler as a tool, and calls it', async () => {
5130
5130
  })
5131
5131
  ```
5132
5132
 
5133
- Every handler the app publishes is a tool, a read as much as a write. The model picks a tool by its `description`, the same sentence the OpenAPI operation carries, so write one on every handler. A tool with none warns on `abide:mcp`.
5133
+ Every handler the app publishes is a tool, a read as much as a write. The model picks a tool by its `description`, the same sentence the OpenAPI operation carries, so write one on every handler. A tool with none warns on `abide:mcp`, and with `DEBUG=abide:mcp` every call writes a line naming the tool, its status and how long it took.
5134
5134
 
5135
5135
  The endpoint runs inside the app's own middleware. The rung that decides what a browser may call decides what a model may call, and a declared refusal reaches the model by its name, so it can tell the person why it stopped. Read on: [Who sees what](/guide/decide-who-sees-what.md)
5136
5136
 
@@ -5351,6 +5351,7 @@ test('the model calls the CRM search, through the app', async () => {
5351
5351
  | `Provider` | What runs the conversation: one method, `run`. Write one for another model, or a recording for a test. |
5352
5352
  | `Toolbox` | What a provider is handed: `list` of the tools, and `call` to run one as the asker. |
5353
5353
  | `ToolDefinition` | One tool in `list`: its name, description and input schema. |
5354
+ | `abide:assistant` | The log namespace that writes one `debug` line per conversation once it ends: the stop reason, the transcript's length, the tool calls, the tokens and the time. |
5354
5355
 
5355
5356
  ## `claudeApi` sends the request body you wrote
5356
5357
 
@@ -5632,6 +5633,8 @@ test('unpaired: asking is refused, the bridge shows a command', async () => {
5632
5633
  bunx @abide/bridge <site> <code> -- --tools WebSearch,WebFetch
5633
5634
  ```
5634
5635
 
5636
+ Pass the same `tools` to `claudeCode.bridge` or `claudeCode.bridgeCommand` and the command it mints carries that grant, so a visitor who runs it as shown gives the tools. One minted without them grants none.
5637
+
5635
5638
  The visitor pairs once per visit by running a command, which dials out to the site. Asking with nothing paired is refused 409.
5636
5639
 
5637
5640
  | Name | What it does for the CRM |
@@ -7835,9 +7838,9 @@ Generated surfaces: what is written in place rather than imported.
7835
7838
 
7836
7839
  | Name | Signature | Meaning | Rules |
7837
7840
  | --- | --- | --- | --- |
7838
- | `claudeCode` | `(config?: ClaudeCodeConfig) => Provider` | The visitor's own Claude Code, reached through the bridge paired with their browser, as a provider. | [44.5](/rulebook#44.5), [44.13](/rulebook#44.13), [44.17](/rulebook#44.17), [44.18](/rulebook#44.18), [44.19](/rulebook#44.19), [44.24](/rulebook#44.24), [44.25](/rulebook#44.25), [44.28](/rulebook#44.28) |
7839
- | `claudeCode.bridgeCommand` | `() => { command: string; expires: number }` | The command that pairs a bridge with this browser. | [44.14](/rulebook#44.14) |
7840
- | `claudeCode.bridge` | `() => AsyncGenerator<BridgeStatus>` | This browser's pairing, as it changes. | [44.20](/rulebook#44.20), [44.22](/rulebook#44.22) |
7841
+ | `claudeCode` | `(config?: ClaudeCodeConfig) => Provider` | The visitor's own Claude Code, reached through the bridge paired with their browser, as a provider. | [44.5](/rulebook#44.5), [44.13](/rulebook#44.13), [44.17](/rulebook#44.17), [44.18](/rulebook#44.18), [44.19](/rulebook#44.19), [44.24](/rulebook#44.24), [44.25](/rulebook#44.25), [44.28](/rulebook#44.28), [44.31](/rulebook#44.31) |
7842
+ | `claudeCode.bridgeCommand` | `(options?: { tools?: string[] }) => { command: string; expires: number }` | The command that pairs a bridge with this browser, granting the built-in tools it is given. | [44.14](/rulebook#44.14), [44.33](/rulebook#44.33) |
7843
+ | `claudeCode.bridge` | `(options?: { tools?: string[] }) => AsyncGenerator<BridgeStatus>` | This browser's pairing, as it changes. | [44.20](/rulebook#44.20), [44.22](/rulebook#44.22), [44.33](/rulebook#44.33) |
7841
7844
  | `/__abide/bridge` | `GET`, `POST` | Where a bridge pairs, and is relayed its turns. | [36.7](/rulebook#36.7), [44.14](/rulebook#44.14), [44.15](/rulebook#44.15), [44.19](/rulebook#44.19), [44.23](/rulebook#44.23), [44.26](/rulebook#44.26), [44.27](/rulebook#44.27), [16.78](/rulebook#16.78) |
7842
7845
 
7843
7846
  ## Taught in
@@ -8293,7 +8296,8 @@ Generated surfaces: what is written in place rather than imported.
8293
8296
  | `abide:lifecycle` | `debug` `error` | A lifecycle that ran. | [28.8](/rulebook#28.8) |
8294
8297
  | `abide:principal` | `debug` `warning` | A principal set or cleared. | [28.8](/rulebook#28.8) |
8295
8298
  | `abide:health` | `warning` | An `onHealth` that threw or answered a non-object. | [25.2](/rulebook#25.2), [28.8](/rulebook#28.8) |
8296
- | `abide:mcp` | `debug` `warning` | A handler withheld from MCP, or a tool published with no description. | [20.3](/rulebook#20.3), [20.9](/rulebook#20.9), [28.8](/rulebook#28.8) |
8299
+ | `abide:mcp` | `debug` `warning` | A handler withheld from MCP, a tool call answered, or a tool published with no description. | [20.3](/rulebook#20.3), [20.9](/rulebook#20.9), [20.18](/rulebook#20.18), [28.8](/rulebook#28.8) |
8300
+ | `abide:assistant` | `debug` | One line per conversation, once it ends. | [44.32](/rulebook#44.32) |
8297
8301
  | `abide:openapi` | `warning` | A handler whose schema had no JSON Schema export. | [20.11](/rulebook#20.11), [28.8](/rulebook#28.8) |
8298
8302
  | `abide:render` | `warning` | An `error.abide` that itself failed. | [28.8](/rulebook#28.8) |
8299
8303
  | `abide:refuse` | `warning` | A message formatter that threw. | [15.13](/rulebook#15.13), [28.8](/rulebook#28.8) |
@@ -8607,7 +8611,7 @@ Generated surfaces: what is written in place rather than imported.
8607
8611
  | --- | --- | --- |
8608
8612
  | `{expr}` | Reactive text, escaped. | [32.1](/rulebook#32.1), [32.23](/rulebook#32.23) |
8609
8613
  | formatting whitespace | What a run of whitespace renders as. | [33.17](/rulebook#33.17), [33.18](/rulebook#33.18) |
8610
- | `{raw(...)}` | Raw HTML. | [32.2](/rulebook#32.2), [32.25](/rulebook#32.25), [32.26](/rulebook#32.26), [32.30](/rulebook#32.30), [32.31](/rulebook#32.31) |
8614
+ | `{raw(...)}` | Raw HTML. | [32.2](/rulebook#32.2), [32.25](/rulebook#32.25), [32.26](/rulebook#32.26), [32.30](/rulebook#32.30), [32.31](/rulebook#32.31), [32.70](/rulebook#32.70) |
8611
8615
  | `name={expr}` | A reactive attribute or property. | [32.3](/rulebook#32.3), [32.20](/rulebook#32.20), [32.23](/rulebook#32.23), [32.37](/rulebook#32.37) |
8612
8616
  | `on<event>={fn}` | A native listener on an element, and an ordinary prop on a component. | [32.4](/rulebook#32.4) |
8613
8617
  | `name="…{expr}…"` | An interpolated quoted value. | [32.3](/rulebook#32.3) |
@@ -8627,8 +8631,8 @@ Generated surfaces: what is written in place rather than imported.
8627
8631
 
8628
8632
  | Spelling | Branches | Meaning | Rules |
8629
8633
  | --- | --- | --- | --- |
8630
- | `{#if cond}` | `{:else if cond}`, `{:else}` | Conditional markup. | [32.12](/rulebook#32.12), [32.21](/rulebook#32.21), [32.63](/rulebook#32.63), [32.64](/rulebook#32.64), [32.65](/rulebook#32.65), [41.24](/rulebook#41.24) |
8631
- | `{#for item, index of list by key}` | — | Repeats markup over a list, or over a cursor as it produces. | [32.13](/rulebook#32.13), [32.14](/rulebook#32.14), [32.15](/rulebook#32.15), [32.22](/rulebook#32.22), [32.29](/rulebook#32.29), [32.32](/rulebook#32.32), [32.33](/rulebook#32.33), [32.63](/rulebook#32.63), [32.64](/rulebook#32.64), [32.67](/rulebook#32.67), [41.8](/rulebook#41.8), [41.14](/rulebook#41.14), [41.15](/rulebook#41.15) |
8634
+ | `{#if cond}` | `{:else if cond}`, `{:else}` | Conditional markup. | [32.12](/rulebook#32.12), [32.21](/rulebook#32.21), [32.63](/rulebook#32.63), [32.64](/rulebook#32.64), [32.65](/rulebook#32.65), [32.70](/rulebook#32.70), [41.24](/rulebook#41.24) |
8635
+ | `{#for item, index of list by key}` | — | Repeats markup over a list, or over a cursor as it produces. | [32.13](/rulebook#32.13), [32.14](/rulebook#32.14), [32.15](/rulebook#32.15), [32.22](/rulebook#32.22), [32.29](/rulebook#32.29), [32.32](/rulebook#32.32), [32.33](/rulebook#32.33), [32.63](/rulebook#32.63), [32.64](/rulebook#32.64), [32.67](/rulebook#32.67), [32.70](/rulebook#32.70), [41.8](/rulebook#41.8), [41.14](/rulebook#41.14), [41.15](/rulebook#41.15) |
8632
8636
  | `{#try}` | `{:catch e}`, `{:finally}` | A render-time boundary and a region. | [32.16](/rulebook#32.16), [32.56](/rulebook#32.56), [32.63](/rulebook#32.63), [32.64](/rulebook#32.64) |
8633
8637
 
8634
8638
  ## Components
@@ -8670,7 +8674,7 @@ Generated surfaces: what is written in place rather than imported.
8670
8674
 
8671
8675
  | Name | Signature | Meaning | Rules |
8672
8676
  | --- | --- | --- | --- |
8673
- | `raw` | `(value: unknown) => Raw` | The branding a value takes to render as markup rather than as text. | [32.2](/rulebook#32.2), [32.24](/rulebook#32.24), [32.26](/rulebook#32.26), [32.30](/rulebook#32.30), [32.31](/rulebook#32.31) |
8677
+ | `raw` | `(value: unknown) => Raw` | The branding a value takes to render as markup rather than as text. | [32.2](/rulebook#32.2), [32.24](/rulebook#32.24), [32.26](/rulebook#32.26), [32.30](/rulebook#32.30), [32.31](/rulebook#32.31), [32.70](/rulebook#32.70) |
8674
8678
 
8675
8679
  ## Taught in
8676
8680
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abide/abide",
3
- "version": "0.107.0",
3
+ "version": "0.109.0",
4
4
  "type": "module",
5
5
  "description": "Isomorphic type-safe framework for reactive async interfaces for humans and machines",
6
6
  "license": "MIT",
@@ -244,7 +244,7 @@ export function emitClient(
244
244
  let fills = contributed
245
245
  if (applied !== '' || builds) {
246
246
  const build = builds
247
- ? `function __build(__document) {\n ${emitMaker(page, lowering)}\n}\n`
247
+ ? `function __build(__parent) {\n ${emitMaker(page, lowering)}\n}\n`
248
248
  : ''
249
249
  // THE HEAD GOES BEFORE THE FILL, so the guard is written out where there is one to apply.
250
250
  fills +=
@@ -453,7 +453,7 @@ function clientComponent(
453
453
  const fill =
454
454
  sink.positions.length === 0
455
455
  ? ''
456
- : `function __build(__document) {\n ${emitMaker(sink, lowering)}\n}
456
+ : `function __build(__parent) {\n ${emitMaker(sink, lowering)}\n}
457
457
  ${lowering.use('fillEmpty')}(${REGION_ROOT}, __build)\n`
458
458
  const captured = sink.defersSetup
459
459
  ? `const ${SHARE_AT} = ${lowering.use('currentShare')}()\n`
@@ -837,7 +837,9 @@ const WRITES = {
837
837
  // instance OPENS (`shareScope`): a page that shares nothing still carries this for a child that does.
838
838
  const SHARE_AT = '__shareAt'
839
839
 
840
- // The document a region's maker is handed and builds its nodes from.
840
+ // The parent a region's maker is handed, which its nodes are made for and go into (`parseMarkup`).
841
+ const PARENT = '__parent'
842
+ // The document a built region makes its nodes with, read off `PARENT` once a build.
841
843
  const DOCUMENT = '__document'
842
844
 
843
845
  // A run's anchors while it holds markup (32.30), as the JS string literals a built run parses with.
@@ -845,9 +847,9 @@ const RAW_OPEN_MARKUP = JSON.stringify(anchorMarkup(RAW_OPEN))
845
847
  const RAW_CLOSE_MARKUP = JSON.stringify(anchorMarkup(RAW_CLOSE))
846
848
 
847
849
  /**
848
- * The body of the function that makes a region's nodes from a `__document`.
850
+ * The body of the function that makes a region's nodes for a `__parent`.
849
851
  *
850
- * WHAT MAKES A REGION'S NODES, as the body of a function taking the region's `__document`: the
852
+ * WHAT MAKES A REGION'S NODES, as the body of a function taking the region's `__parent`: the
851
853
  * row, the arm, the slot's fallback, the caller's children and the page that fills itself are all
852
854
  * made the same way. BUILT from the positions the markup walk placed (`emitBuild`) — or, where the
853
855
  * region holds content only the parser makes right (`isParserOwned`), PARSED from the very markup
@@ -868,7 +870,7 @@ function emitMaker(
868
870
  let parsed = !sink.repeated
869
871
  for (const at of sink.positions) parsed ||= at.parserOwned
870
872
  if (parsed)
871
- return `return ${lowering.use('markupNodes')}(${DOCUMENT}, ${markup()})`
873
+ return `return ${lowering.use('markupNodes')}(${PARENT}, ${markup()})`
872
874
  return emitBuild(sink, anchored, lowering)
873
875
  }
874
876
 
@@ -903,7 +905,10 @@ function emitMaker(
903
905
  * (`fillEmpty`), and a component or a slot is the same pair filled by the child.
904
906
  */
905
907
  function emitBuild(row: Cursor, anchored: boolean, lowering: Lowering): string {
906
- const lines: string[] = ['const __built = []']
908
+ const lines: string[] = [
909
+ `const ${DOCUMENT} = ${PARENT}.ownerDocument`,
910
+ 'const __built = []',
911
+ ]
907
912
  if (anchored)
908
913
  lines.push(
909
914
  `__built.push(${DOCUMENT}.createComment(${JSON.stringify(ROW_OPEN)}))`,
@@ -1000,7 +1005,7 @@ function builtRun(
1000
1005
  return lines
1001
1006
  }
1002
1007
  lines.push(
1003
- `if (${raw.join(' || ')}) for (const __made of ${lowering.use('markupNodes')}(${DOCUMENT}, ${RAW_OPEN_MARKUP} + ${markup.join(' + ')} + ${RAW_CLOSE_MARKUP})) ${into(at, '__made')}`,
1008
+ `if (${raw.join(' || ')}) for (const __made of ${lowering.use('markupNodes')}(${at.within === null ? PARENT : at.within.name}, ${RAW_OPEN_MARKUP} + ${markup.join(' + ')} + ${RAW_CLOSE_MARKUP})) ${into(at, '__made')}`,
1004
1009
  )
1005
1010
  if (!at.blank) {
1006
1011
  lines.push(`else ${make(whole)}`)
@@ -1075,7 +1080,7 @@ function emitFillable(
1075
1080
  const lowering = region.lowering
1076
1081
  const filled = emitRegion(sink, region.nested())
1077
1082
  region.helpers.push(
1078
- `function ${name}_build(__document) {\n ${emitMaker(sink, lowering)}\n}`,
1083
+ `function ${name}_build(__parent) {\n ${emitMaker(sink, lowering)}\n}`,
1079
1084
  )
1080
1085
  region.helpers.push(
1081
1086
  mountFunction(
@@ -1339,7 +1344,7 @@ function emitArms(
1339
1344
  : arm === 1 && caught !== undefined
1340
1345
  ? caught
1341
1346
  : ''
1342
- const builders = `const ${own}_build = function (__document) {\n${emitMaker(sink, lowering)}\n}\n${mountFunction(own, REGION_ROOT, body, `${fillRegion}(${REGION_ROOT}, ${own}_build)`, lowering, sink.defersSetup, setup !== null)}`
1347
+ const builders = `const ${own}_build = function (__parent) {\n${emitMaker(sink, lowering)}\n}\n${mountFunction(own, REGION_ROOT, body, `${fillRegion}(${REGION_ROOT}, ${own}_build)`, lowering, sink.defersSetup, setup !== null)}`
1343
1348
  if (local === '') {
1344
1349
  helpers.push(
1345
1350
  `${builders}\nconst ${own}_arm = { build: ${own}_build, mount: ${own}_mount }`,
@@ -1425,7 +1430,7 @@ function emitList(
1425
1430
  // MADE FROM THE ITEM'S VALUE: every slot reading the item alone is written before the row is
1426
1431
  // inserted, so making a row writes the document once per node (32.67).
1427
1432
  const valued = `const ${item} = ${ROW_ROOT}.data`
1428
- const make = `const ${name}_make = (__document, ${ROW_ROOT}, ${at}) => {\n ${valued}\n ${emitMaker(row, lowering, () => rowMarkup(row, !single, lowering, CLIENT_LANE), !single)}\n}`
1433
+ const make = `const ${name}_make = (__parent, ${ROW_ROOT}, ${at}) => {\n ${valued}\n ${emitMaker(row, lowering, () => rowMarkup(row, !single, lowering, CLIENT_LANE), !single)}\n}`
1429
1434
  const walk = rowRegion.body()
1430
1435
  // A PATTERN READ OFF THE RECORD is read through one picker a name, the list's rather than the
1431
1436
  // row's, so a row allocates nothing for it.
@@ -1450,7 +1455,7 @@ function emitList(
1450
1455
  // rebuilds whole: a node that refused is the row disagreeing, and re-making it is the
1451
1456
  // answer the page does not have.
1452
1457
  opened,
1453
- `${lowering.use('replaceRow')}(${ROW_ROOT}, ${name}_make(${ROW_ROOT}.node.ownerDocument, ${ROW_ROOT}, ${at}))`,
1458
+ `${lowering.use('replaceRow')}(${ROW_ROOT}, ${name}_make, ${at})`,
1454
1459
  lowering,
1455
1460
  row.defersSetup,
1456
1461
  block.setup !== null,
@@ -1,3 +1,3 @@
1
1
  // The `abide-bridge` release a pairing command names, so a visitor's `bunx` runs the relay this
2
2
  // server speaks. `packages/abide/tests/claudeCode.test.ts` holds it to the bridge package's own.
3
- export const BRIDGE_VERSION = '0.3.0'
3
+ export const BRIDGE_VERSION = '0.3.2'
@@ -10,6 +10,7 @@
10
10
  // another request's scope — `claudeCode` answers one from the bridge's — and the call is still this
11
11
  // asker's.
12
12
 
13
+ import { namedLogger } from '#shared/log.ts'
13
14
  import { refuse } from '#shared/refuse.ts'
14
15
  import { type McpReply, McpSurface } from './generatedSurfaces.ts'
15
16
  import { request, within } from './requestScope.ts'
@@ -64,6 +65,7 @@ export type Provider = {
64
65
  export type AssistantOptions = { mcp?: boolean; maxTurns?: number }
65
66
 
66
67
  const MAX_TURNS = 10
68
+ const ASSISTANT_LOG = namedLogger('abide:assistant')
67
69
  // 44.7 — the caller's credentials, and nothing else of their request.
68
70
  const CARRIED_HEADERS = ['cookie', 'authorization']
69
71
 
@@ -83,10 +85,39 @@ export async function* assistant(
83
85
  options.mcp === true ? appSurface() : NO_SURFACE,
84
86
  askerOf(request()),
85
87
  )
86
- yield* provider.run(messages, tools, {
88
+ const events = provider.run(messages, tools, {
87
89
  maxTurns: options.maxTurns ?? MAX_TURNS,
88
90
  signal: request().signal,
89
91
  })
92
+ // 28.7 — a closed namespace runs the provider's stream untouched.
93
+ yield* ASSISTANT_LOG.enabled() ? traced(events, messages.length) : events
94
+ }
95
+
96
+ /** `events`, and one line on `abide:assistant` once they end, however they end. 44.32. */
97
+ async function* traced(
98
+ events: AsyncGenerator<AssistantEvent>,
99
+ messageCount: number,
100
+ ): AsyncGenerator<AssistantEvent> {
101
+ const started = performance.now()
102
+ let calls = 0
103
+ let done: Extract<AssistantEvent, { type: 'done' }> | undefined
104
+ try {
105
+ for await (const event of events) {
106
+ if (event.type === 'tool') calls += 1
107
+ else if (event.type === 'done') done = event
108
+ yield event
109
+ }
110
+ } finally {
111
+ ASSISTANT_LOG.debug(
112
+ done === undefined ? 'unfinished' : done.stop,
113
+ `${messageCount} messages`,
114
+ `${calls} tool calls`,
115
+ done === undefined
116
+ ? 'no usage'
117
+ : `${done.usage.input}/${done.usage.output} tokens`,
118
+ `${(performance.now() - started).toFixed(1)}ms`,
119
+ )
120
+ }
90
121
  }
91
122
 
92
123
  /** Where an MCP request is answered, as JSON-RPC's `result` or `error`. */
@@ -52,7 +52,7 @@ const CODE_LIFETIME = 10 * 60_000
52
52
  // How long a bridge outlives the last page watching it, so a reload does not end it.
53
53
  const IDLE_GRACE = 30_000
54
54
  /** How long a pairing whose stream dropped waits to be resumed. 44.26. */
55
- export const RESUME_MILLISECONDS = 30_000
55
+ export const RESUME_MILLISECONDS = 60_000
56
56
  /** How often a pairing's stream carries a comment while it has nothing else to send. 44.27. */
57
57
  export const HEARTBEAT_MILLISECONDS = 15_000
58
58
  const HEARTBEAT = new TextEncoder().encode(EVENT_STREAM_OPENER)
@@ -42,6 +42,9 @@ export type ClaudeCodeConfig = {
42
42
  tools?: string[]
43
43
  }
44
44
 
45
+ /** The built-in tools a pairing command grants, as the visitor would after its `--`. 44.33. */
46
+ type PairingOptions = Pick<ClaudeCodeConfig, 'tools'>
47
+
45
48
  /** Whether this browser's bridge is connected, and the command that pairs one where it is not. */
46
49
  export type BridgeStatus = { paired: boolean; command: string | undefined }
47
50
 
@@ -123,12 +126,19 @@ function askingCaller(): string {
123
126
  return caller
124
127
  }
125
128
 
126
- /** The command that pairs a bridge with this browser, and when its code stops standing. 44.14. */
127
- function bridgeCommand(): { command: string; expires: number } {
129
+ /**
130
+ * The command that pairs a bridge with this browser, and when its code stops standing. 44.14. The
131
+ * `tools` it is given are written after its `--`, so the visitor running it grants them (44.30, 44.33).
132
+ */
133
+ function bridgeCommand({ tools }: PairingOptions = {}): {
134
+ command: string
135
+ expires: number
136
+ } {
128
137
  const { code, expires } = mintCode(askingCaller())
129
138
  const site = `${appOrigin() ?? new URL(request().url).origin}${mounted('')}`
139
+ const grant = tools?.length ? ` -- --tools ${tools.join(',')}` : ''
130
140
  return {
131
- command: `bunx @abide/bridge@${BRIDGE_VERSION} ${site} ${code}`,
141
+ command: `bunx @abide/bridge@${BRIDGE_VERSION} ${site} ${code}${grant}`,
132
142
  expires,
133
143
  }
134
144
  }
@@ -137,7 +147,9 @@ function bridgeCommand(): { command: string; expires: number } {
137
147
  * This browser's pairing as it changes: unpaired with the command to run, then paired the moment a
138
148
  * bridge connects, and unpaired again if it goes. 44.20.
139
149
  */
140
- async function* bridge(): AsyncGenerator<BridgeStatus> {
150
+ async function* bridge(
151
+ options: PairingOptions = {},
152
+ ): AsyncGenerator<BridgeStatus> {
141
153
  const caller = askingCaller()
142
154
  const signal = request().signal
143
155
  // A change landing while the loop was not waiting — paused at its `yield` — stays queued, so it
@@ -156,7 +168,7 @@ async function* bridge(): AsyncGenerator<BridgeStatus> {
156
168
  !paired &&
157
169
  (pairing === undefined || pairing.expires <= Date.now())
158
170
  )
159
- pairing = bridgeCommand()
171
+ pairing = bridgeCommand(options)
160
172
  const status: BridgeStatus = {
161
173
  paired,
162
174
  command: paired ? undefined : pairing?.command,
@@ -304,11 +304,13 @@ export class McpSurface {
304
304
  asker: Request,
305
305
  ): Promise<{ text: string; failed: boolean }> {
306
306
  const tool = this.#tool(name)
307
- if (tool === undefined)
307
+ if (tool === undefined) {
308
+ logCall(String(name), 404, undefined)
308
309
  return Promise.resolve({
309
310
  text: `No tool named ${name}.`,
310
311
  failed: true,
311
312
  })
313
+ }
312
314
  return this.#called(tool, args as Record<string, unknown>, asker)
313
315
  }
314
316
 
@@ -321,15 +323,23 @@ export class McpSurface {
321
323
  args: Record<string, unknown> | undefined,
322
324
  asker: Request,
323
325
  ): Promise<{ text: string; failed: boolean }> {
326
+ // 28.7 — a closed namespace does not pay for the line it would have written.
327
+ const started = MCP_LOG.enabled() ? performance.now() : undefined
324
328
  let answer: Response
325
329
  try {
326
330
  answer = await this.#answer(callOf(asker, one, args))
327
331
  } catch (thrown) {
328
332
  // 15.8 — a path parameter no address carries, refused as the wire's 422 is (`rpcRequest`).
329
- if (!isFailed(thrown)) throw thrown
333
+ if (!isFailed(thrown)) {
334
+ logCall(one.name, 500, started)
335
+ throw thrown
336
+ }
330
337
  answer = answered(thrown)
331
338
  }
332
- return { text: await answer.text(), failed: answer.status >= 400 }
339
+ // The body is the call's too: a handler that streams has only begun when its head arrives.
340
+ const text = await answer.text()
341
+ logCall(one.name, answer.status, started)
342
+ return { text, failed: answer.status >= 400 }
333
343
  }
334
344
 
335
345
  /** One MCP request, made as `asker`, as JSON-RPC's `result` or `error`. */
@@ -353,11 +363,13 @@ export class McpSurface {
353
363
  return { result: { tools: this.list() } }
354
364
  case 'tools/call': {
355
365
  const tool = this.#tool(params?.name)
356
- if (tool === undefined)
366
+ if (tool === undefined) {
367
+ logCall(String(params?.name), 404, undefined)
357
368
  return mcpFailure(
358
369
  -32602,
359
370
  `No tool named ${String(params?.name)}.`,
360
371
  )
372
+ }
361
373
  const { text, failed } = await this.#called(
362
374
  tool,
363
375
  params?.arguments as Record<string, unknown> | undefined,
@@ -382,6 +394,23 @@ export class McpSurface {
382
394
  }
383
395
  }
384
396
 
397
+ // 20.18 — ONE LINE PER CALL, whoever framed it: this endpoint or a provider's `Toolbox`, and a call
398
+ // to a tool that is not published (404) or whose answer threw (500) included. `started` is unset
399
+ // where nothing ran, or where the namespace is closed (28.7).
400
+ function logCall(
401
+ name: string,
402
+ status: number,
403
+ started: number | undefined,
404
+ ): void {
405
+ if (!MCP_LOG.enabled()) return
406
+ MCP_LOG.debug(
407
+ 'call',
408
+ name,
409
+ status,
410
+ `${started === undefined ? '0.0' : (performance.now() - started).toFixed(1)}ms`,
411
+ )
412
+ }
413
+
385
414
  /**
386
415
  * Whether a tool declares an `outputSchema`: MCP's structured content is an object, so only a
387
416
  * result schema that is one. 20.16.
@@ -17,7 +17,7 @@ import { Scope, type Stoppable } from './Scope.ts'
17
17
 
18
18
  /** One arm: what makes its nodes, and what binds them. A `build` of `undefined` makes none. */
19
19
  export type Arm = {
20
- build: ((document: Document) => Node[]) | undefined
20
+ build: ((parent: Node) => Node[]) | undefined
21
21
  mount: (root: Region) => Stoppable | undefined
22
22
  }
23
23
 
package/src/ui/RawRun.ts CHANGED
@@ -60,7 +60,7 @@ export function textToMarkup(text: Text, markup: string): RawRun {
60
60
  const open = document.createComment(RAW_OPEN)
61
61
  const close = document.createComment(RAW_CLOSE)
62
62
  parent.insertBefore(open, text)
63
- parent.insertBefore(parseMarkup(text, markup), text)
63
+ parent.insertBefore(parseMarkup(parent, markup), text)
64
64
  parent.replaceChild(close, text)
65
65
  return new RawRun(open, close, markup)
66
66
  }
@@ -70,7 +70,7 @@ export function refillRun(run: RawRun, markup: string): void {
70
70
  const parent = run.close.parentNode
71
71
  if (parent === null) throw detached()
72
72
  clearRun(run)
73
- parent.insertBefore(parseMarkup(run.close, markup), run.close)
73
+ parent.insertBefore(parseMarkup(parent, markup), run.close)
74
74
  run.html = markup
75
75
  }
76
76
 
@@ -68,11 +68,12 @@ function bindValue(bound: Bound, operand: unknown, scope: Scope): void {
68
68
  ) {
69
69
  // 32.62 — the list of what is selected, read and written per option.
70
70
  const select = bound as unknown as HTMLSelectElement
71
- scope.watch(() => {
72
- const value = boundValue(operand)
71
+ const choose = (value: unknown) => {
73
72
  for (const option of select.options)
74
73
  option.selected = selectHolds(value, option.value)
75
- })
74
+ }
75
+ scope.watch(() => choose(boundValue(operand)))
76
+ chooseOnNewOptions(select, () => choose(heldValue(operand)), scope)
76
77
  select.addEventListener('change', () => {
77
78
  const chosen: string[] = []
78
79
  for (const option of select.selectedOptions)
@@ -84,8 +85,7 @@ function bindValue(bound: Bound, operand: unknown, scope: Scope): void {
84
85
  // 32.58 — NUMERIC ONCE, NUMERIC FROM THEN ON: a blank field writes `undefined`, and the
85
86
  // keystroke after it still has to know the value was a number.
86
87
  let numeric = bound.type === 'number' || bound.type === 'range'
87
- scope.watch(() => {
88
- const value = boundValue(operand)
88
+ const show = (value: unknown) => {
89
89
  if (typeof value === 'number') numeric = true
90
90
  // 32.59 — text that already reads as the value is the reader's spelling of it: a trailing
91
91
  // decimal point or zero is mid-typing, and rewriting it to the number's own text takes the
@@ -94,7 +94,8 @@ function bindValue(bound: Bound, operand: unknown, scope: Scope): void {
94
94
  return
95
95
  const text = value === undefined || value === null ? '' : String(value)
96
96
  if (bound.value !== text) bound.value = text
97
- })
97
+ }
98
+ scope.watch(() => show(boundValue(operand)))
98
99
  const write = () => {
99
100
  const typed = bound.value
100
101
  if (!numeric) {
@@ -108,6 +109,7 @@ function bindValue(bound: Bound, operand: unknown, scope: Scope): void {
108
109
  }
109
110
  // A `<select>` settles on `change`; everything else that takes text reports each keystroke.
110
111
  if (bound.tagName === 'SELECT') {
112
+ chooseOnNewOptions(bound, () => show(heldValue(operand)), scope)
111
113
  bound.addEventListener('change', write)
112
114
  return
113
115
  }
@@ -125,3 +127,16 @@ function bindValue(bound: Bound, operand: unknown, scope: Scope): void {
125
127
  if (!composing) write()
126
128
  })
127
129
  }
130
+
131
+ // 32.62 — AN OPTION THAT ARRIVES AFTER THE VALUE IS SELECTED WHEN IT IS THE VALUE. The value's
132
+ // watch does not read the options, so a `{#for}` that grows them later wakes nothing of its own, and
133
+ // the select would stay on whatever the browser picked when it changed.
134
+ function chooseOnNewOptions(
135
+ select: Element,
136
+ choose: () => void,
137
+ scope: Scope,
138
+ ): void {
139
+ const observer = new MutationObserver(choose)
140
+ observer.observe(select, { childList: true, subtree: true })
141
+ scope.stops.push(() => observer.disconnect())
142
+ }
@@ -36,6 +36,9 @@ export function listenForRoutes(router: Router): () => void {
36
36
  // mounted: the browser's own restoration runs before that page exists.
37
37
  const onPop = (): void => {
38
38
  if (router.match(location.pathname) === undefined) {
39
+ // The bar already stands at the entry being loaded, so the `pagehide` this reload
40
+ // fires would write the departed page's scroll into it: the listeners go first.
41
+ listening.abort()
39
42
  location.reload()
40
43
  return
41
44
  }
@@ -1,4 +1,4 @@
1
- // MARKUP PARSED IN THE DOCUMENT `near` BELONGS TO, not the global one. A page mounted into a second
1
+ // MARKUP PARSED IN THE DOCUMENT `parent` BELONGS TO, not the global one. A page mounted into a second
2
2
  // document (an example `<iframe>`) parsed its `{#if}` arms in the right one and its `{#for}` rows in
3
3
  // the page's while the two were separate copies of this; one copy is one answer.
4
4
  //
@@ -6,16 +6,67 @@
6
6
  // every caller moves those nodes out before it parses again.
7
7
  const TEMPLATES = new WeakMap<Document, HTMLTemplateElement>()
8
8
 
9
- /** Parses markup in `near`'s document; the fragment is reused by the next parse. */
10
- export function parseMarkup(near: Node, markup: string): DocumentFragment {
11
- const document = near.ownerDocument ?? (near as Document)
9
+ const SVG = 'http://www.w3.org/2000/svg'
10
+ const MATHML = 'http://www.w3.org/1998/Math/MathML'
11
+
12
+ // The foreign elements whose children the parser reads as HTML again — the integration points —
13
+ // so markup going into one parses the way it would at the template's own top level.
14
+ const INTEGRATION_POINTS = new Set([
15
+ 'foreignObject',
16
+ 'desc',
17
+ 'title',
18
+ 'mi',
19
+ 'mo',
20
+ 'mn',
21
+ 'ms',
22
+ 'mtext',
23
+ ])
24
+
25
+ /** Whether the parser reads `element`'s children as HTML again, though it is SVG or MathML. */
26
+ function isIntegrationPoint(element: Element): boolean {
27
+ if (INTEGRATION_POINTS.has(element.localName)) return true
28
+ // The HTML standard makes an `<annotation-xml>` one only by the encoding it declares.
29
+ if (element.localName !== 'annotation-xml') return false
30
+ const encoding = element.getAttribute('encoding')?.toLowerCase()
31
+ return encoding === 'text/html' || encoding === 'application/xhtml+xml'
32
+ }
33
+
34
+ /**
35
+ * Parses markup for `parent` — in its document, and in its NAMESPACE; the fragment is reused by the
36
+ * next parse.
37
+ *
38
+ * IN THE PARENT'S NAMESPACE because the server's parser read the same bytes inside it: a template's
39
+ * own children are HTML, so a `<circle>` parsed there alone is an `HTMLUnknownElement` that draws
40
+ * nothing in the `<svg>` it lands in. Markup bound for an SVG or MathML parent is parsed inside an
41
+ * `<svg>` or a `<math>` and the wrapper unwrapped — what the parser closes the wrapper on (a `<p>`
42
+ * breaks out of foreign content) stays, beside what was inside it, in order.
43
+ */
44
+ export function parseMarkup(parent: Node, markup: string): DocumentFragment {
45
+ const document = parent.ownerDocument ?? (parent as Document)
12
46
  let template = TEMPLATES.get(document)
13
47
  if (template === undefined) {
14
48
  template = document.createElement('template')
15
49
  TEMPLATES.set(document, template)
16
50
  }
17
- template.innerHTML = markup
18
- return template.content
51
+ const space = (parent as Element).namespaceURI
52
+ const wrapper =
53
+ (space !== SVG && space !== MATHML) ||
54
+ isIntegrationPoint(parent as Element)
55
+ ? undefined
56
+ : space === SVG
57
+ ? 'svg'
58
+ : 'math'
59
+ if (wrapper === undefined) {
60
+ template.innerHTML = markup
61
+ return template.content
62
+ }
63
+ template.innerHTML = `<${wrapper}>${markup}</${wrapper}>`
64
+ const content = template.content
65
+ const wrapped = content.firstChild as Element
66
+ while (wrapped.firstChild !== null)
67
+ content.insertBefore(wrapped.firstChild, wrapped)
68
+ wrapped.remove()
69
+ return content
19
70
  }
20
71
 
21
72
  /**
@@ -23,10 +74,10 @@ export function parseMarkup(near: Node, markup: string): DocumentFragment {
23
74
  *
24
75
  * Indexed rather than spread, so no iterator runs per row.
25
76
  */
26
- export function markupNodes(near: Node, markup: string): Node[] {
77
+ export function markupNodes(parent: Node, markup: string): Node[] {
27
78
  const nodes: Node[] = []
28
79
  for (
29
- let node = parseMarkup(near, markup).firstChild;
80
+ let node = parseMarkup(parent, markup).firstChild;
30
81
  node !== null;
31
82
  node = node.nextSibling
32
83
  )
package/src/ui/regions.ts CHANGED
@@ -86,7 +86,7 @@ export function isEmptyRegion(region: Region): boolean {
86
86
  */
87
87
  export function fillRegion(
88
88
  region: Region,
89
- build?: (document: Document) => Node[],
89
+ build?: (parent: Node) => Node[],
90
90
  ): void {
91
91
  // THE PARENT IS READ BEFORE ANYTHING IS REMOVED, and the removal only happens if there is
92
92
  // one. Written as an unguarded removal followed by a `?.`-guarded insert, a region whose
@@ -101,7 +101,7 @@ export function fillRegion(
101
101
  if (build === undefined) return
102
102
  beginLayer(LAYER_FILL)
103
103
  try {
104
- const made = build(region.close.ownerDocument as Document)
104
+ const made = build(parent)
105
105
  for (const one of made) parent.insertBefore(one, region.close)
106
106
  } finally {
107
107
  endLayer(LAYER_FILL)
@@ -119,7 +119,7 @@ export function fillRegion(
119
119
  */
120
120
  export function fillEmpty(
121
121
  region: Region,
122
- build?: (document: Document) => Node[],
122
+ build?: (parent: Node) => Node[],
123
123
  ): boolean {
124
124
  if (!isEmptyRegion(region)) return false
125
125
  fillRegion(region, build)
package/src/ui/rows.ts CHANGED
@@ -69,11 +69,11 @@ export type Row = {
69
69
  */
70
70
  export type RowBuilders = {
71
71
  /**
72
- * A row's nodes, BUILT in the document the list stands in — handed over by the reconcile
73
- * rather than found by the row, which is the one place a row's document is decided. Every slot
72
+ * A row's nodes, BUILT for the parent the list stands in — handed over by the reconcile rather
73
+ * than found by the row, which is the one place a row's document and namespace are decided. Every slot
74
74
  * reading the item alone is written here, before the nodes are inserted.
75
75
  */
76
- make: (document: Document, row: Row, at: number) => Node[]
76
+ make: (parent: Node, row: Row, at: number) => Node[]
77
77
  mount: (row: Row, at: number) => Scope
78
78
  /** 32.67 — rewrites, from `row.data`, the slots `make` wrote from the item alone. */
79
79
  update: (row: Row, at: number) => void
@@ -172,15 +172,20 @@ export function rowsIn(region: Region, anchored: boolean): ChildNode[] {
172
172
  }
173
173
 
174
174
  /**
175
- * Rebuilds a row in place from `next`, keeping its record.
175
+ * Rebuilds a row in place with `make`, keeping its record.
176
176
  *
177
177
  * 41.10 — a row that cannot be hydrated is rebuilt: the fresh nodes go where the row stood and the
178
178
  * row's record takes the new first node, so the reconcile still reaches all of it.
179
179
  */
180
- export function replaceRow(row: Row, next: Node[]): void {
180
+ export function replaceRow(
181
+ row: Row,
182
+ make: RowBuilders['make'],
183
+ at: number,
184
+ ): void {
181
185
  const first = row.node
182
186
  const parent = first.parentNode
183
187
  if (parent === null) return
188
+ const next = make(parent, row, at)
184
189
  for (const node of next) parent.insertBefore(node, first)
185
190
  removeRow(first)
186
191
  row.node = next[0] as ChildNode
@@ -201,7 +206,7 @@ export function makeRow(
201
206
  // 14.8 — set up ONCE for the row, and the same builders make it and mount it.
202
207
  const builders = setUp(row, at)
203
208
  row.update = builders.update
204
- const nodes = builders.make(parent.ownerDocument as Document, row, at)
209
+ const nodes = builders.make(parent, row, at)
205
210
  for (const node of nodes) parent.insertBefore(node, before)
206
211
  row.node = nodes[0] as ChildNode
207
212
  row.scope = builders.mount(row, at)