@abide/abide 0.112.0 → 0.113.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,19 @@
1
1
  # @abide/abide
2
2
 
3
+ ## 0.113.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 3c32bf6: a Store may watch — each change it reports re-reads get without running the body, and the watch stops when the value is released ([`a5539eb`](https://github.com/briancray/abide/commit/a5539eb9d1673a3b4dd98c0e35cc8d8dfb8c5e96))
8
+ - 3c32bf6: App.run takes a principal — the claims its scope resolves from through onPrincipal, with no cookie written ([`c0db93c`](https://github.com/briancray/abide/commit/c0db93cb8f41533137a7cb3f833627bebbe9d65f))
9
+ - 3c32bf6: under bun test with the test preload, an app that declares no ABIDE_PRINCIPAL_SECRET signs in with one the preload supplies ([`db09b9d`](https://github.com/briancray/abide/commit/db09b9d3aceeffe3d385ae295601ca9158dc2b86))
10
+
11
+ ### Patch Changes
12
+
13
+ - 3c32bf6: an element the client builds takes the namespace the parser gives it there — a component inside an svg draws — and an xlink:href it writes is XLink's; the harness counts createElementNS and setAttributeNS ([`0dd560a`](https://github.com/briancray/abide/commit/0dd560a35d54a86d8ff5d3bc7762ed6c6968c71f))
14
+ - 3c32bf6: a Keyed holds no entry for args its gate refuses, and the gate runs once per entry rather than on every refresh ([`1560749`](https://github.com/briancray/abide/commit/1560749419b1c571130c812fb0f6c3e2bac2a021))
15
+ - 3c32bf6: abide check takes a url literal naming one of the framework's own fetched addresses — openapi, mcp, health, principal ([`c8af3dc`](https://github.com/briancray/abide/commit/c8af3dc746766fc5cbe322dbde551b15137c7330))
16
+
3
17
  ## 0.112.0
4
18
 
5
19
  ### Minor Changes
package/docs/REGISTRY.md CHANGED
@@ -68,7 +68,7 @@ Accepted, Failures>` is the inner value's own `Accepted`. Then `Failures`, then
68
68
  | `reactive` | `<Accepted = undefined, Shaped = Accepted, Gate = Schema<Accepted>>(initial: undefined, options?: Omit<ReactiveOptions<Accepted, Kept<Shaped>, never>, 'schema'> & { transform?: (value: Accepted) => Shaped; schema?: Gate & Schema<Accepted> }) => Reactive<Kept<Shaped>, Accepted, Refused<Shaped> \| Gated<Gate, Accepted>>` | The undefined overload, for a value opening at `undefined`. | 4.15, 4.16 |
69
69
  | `reactive` | `<Accepted>() => Reactive<Kept<Accepted>, Accepted, Refused<Accepted>>` | The empty overload, for a value nothing has written yet. | 4.14 |
70
70
  | `Transformer` | `<Accepted, Stored, Failures = never>(value: Accepted) => Stored \| Failures` | The shape of a `transform`. | 4.6, 4.7, 4.8 |
71
- | `Store` | `{ get: () => Stored \| undefined \| Promise<Stored \| undefined>; set: (value: Stored, retention: { ttl: number }) => void \| Promise<void> }` | Where a value lives when this process does not. | 8.1, 8.2, 8.3, 8.4, 8.5, 8.6, 8.7, 8.8, 8.9, 8.10, 8.11, 8.12, 8.13, 8.14, 8.15, 8.16, 8.17, 8.18, 8.19 |
71
+ | `Store` | `{ get: () => Stored \| undefined \| Promise<Stored \| undefined>; set: (value: Stored, retention: { ttl: number }) => void \| Promise<void>; watch?: (changed: () => void) => () => void }` | Where a value lives when this process does not. | 8.1, 8.2, 8.3, 8.4, 8.5, 8.6, 8.7, 8.8, 8.9, 8.10, 8.11, 8.12, 8.13, 8.14, 8.15, 8.16, 8.17, 8.18, 8.19, 8.20, 8.21 |
72
72
  | `Shared` | `interface Shared {}` | The app's declaration-merged registry of shared keys. | 10.3 |
73
73
  | `reactive.share` | `<Key extends string, Value>(key: Key, create: () => Reactive<Value>) => Key extends keyof Shared ? Reactive<Shared[Key]> : Reactive<Value>` | Get-or-create a reactive value in the component's scope by key. | 10.1, 10.2, 10.4, 10.5, 10.6, 10.7 |
74
74
 
@@ -94,7 +94,7 @@ type Gated<Gate, Accepted> = [Schema<Accepted>] extends [Gate] ? never : Failed<
94
94
  | `structural` | `(next: unknown, previous: unknown) => boolean` | The by-value comparator an app declares on `identity`. | 5.4, 5.5, 18.4 |
95
95
  | `by` | `(keyof Element & string) \| ((element: Element) => unknown)`, over the `Element` a `Stored` array holds | What tells one element of the value from another. | 5.22, 18.4, 18.15, 32.32 |
96
96
  | `ttl` | `number` | The life of a production, or of a keyed entry. | 6.1, 6.2, 6.3, 6.4, 6.5, 6.6, 6.7, 6.8, 6.9, 6.10, 11.6, 11.35, 11.45, 11.48, 11.49, 11.69 |
97
- | `args` | `Schema<Args>` | The gate on a keyed value's args. | 9.4, 11.28, 11.30, 11.40, 11.41, 11.56, 11.60, 11.68 |
97
+ | `args` | `Schema<Args>` | The gate on a keyed value's args. | 9.4, 11.28, 11.30, 11.40, 11.41, 11.56, 11.60, 11.68, 11.72 |
98
98
  | `global` | `boolean` | Opts out of the default scope into one cache shared by every caller in the process. | 11.34, 11.35, 11.36, 11.37, 11.38, 11.39, 11.53, 11.67 |
99
99
  | `tags` | `string[] \| ((args: Args) => string[])` | What a `Selection` matches on. | 11.43, 11.44, 18.4, 18.15 |
100
100
  | `throttle` | `number` | A ceiling on how often the value changes. | 5.8, 5.9, 5.11, 5.12, 5.14, 5.15, 5.17, 5.18, 5.19, 5.20, 5.21, 5.23, 5.24 |
@@ -529,7 +529,7 @@ type Gated<Gate, Accepted> = [Schema<Accepted>] extends [Gate] ? never : Failed<
529
529
  | `{expr}` | Reactive text, escaped. | 32.1, 32.23 |
530
530
  | formatting whitespace | What a run of whitespace renders as. | 33.17, 33.18 |
531
531
  | `{raw(...)}` | Raw HTML. | 32.2, 32.25, 32.26, 32.30, 32.31, 32.70 |
532
- | `name={expr}` | A reactive attribute or property. | 32.3, 32.20, 32.23, 32.37, 32.71 |
532
+ | `name={expr}` | A reactive attribute or property. | 32.3, 32.20, 32.23, 32.37, 32.70, 32.71 |
533
533
  | `on<event>={fn}` | A native listener on an element, and an ordinary prop on a component. | 32.4 |
534
534
  | `name="…{expr}…"` | An interpolated quoted value. | 32.3, 32.71 |
535
535
  | `bind:value` | Two-way bind on an element. | 5.16, 32.5, 32.58, 32.59, 32.60, 32.61, 32.62, 32.72 |
@@ -564,10 +564,10 @@ type Gated<Gate, Accepted> = [Schema<Accepted>] extends [Gate] ? never : Failed<
564
564
  | Spelling | Meaning | Rules |
565
565
  | --- | --- | --- |
566
566
  | `{#component Name(pattern)}` | An inline component. | 32.39, 32.40, 32.41, 32.42, 32.43, 32.44, 32.45, 32.46, 32.47, 32.48, 32.49, 32.50, 32.51, 32.52, 32.53, 32.54, 32.55, 32.63, 32.64 |
567
- | `<Name/>` | A component invocation. | 32.17, 32.34, 32.35, 32.36, 33.19, 33.21, 41.24, 41.28 |
567
+ | `<Name/>` | A component invocation. | 32.17, 32.34, 32.35, 32.36, 32.70, 33.19, 33.21, 41.24, 41.28 |
568
568
  | `<slot/>` | The position children render into. | 32.18 |
569
569
  | `<slot>fallback</slot>` | Renders children, or a fallback. | 32.18 |
570
- | `<Tag>…</Tag>` | Children passed to a component's slot. | 32.18 |
570
+ | `<Tag>…</Tag>` | Children passed to a component's slot. | 32.18, 32.70 |
571
571
 
572
572
  ## Props
573
573
 
@@ -629,7 +629,7 @@ type Gated<Gate, Accepted> = [Schema<Accepted>] extends [Gate] ? never : Failed<
629
629
  | `APP_NAME` | `string` | The app's name, and `log`'s default namespace. | 27.3, 28.4 |
630
630
  | `APP_VERSION` | `string` | The version beside that name. | 27.3 |
631
631
  | `APP_DATA_DIR` | `string` | The platform's per-user data directory. | 27.3, 36.4 |
632
- | `ABIDE_PRINCIPAL_SECRET` | `string \| null` | What seals the principal cookie. | 26.11, 36.5 |
632
+ | `ABIDE_PRINCIPAL_SECRET` | `string \| null` | What seals the principal cookie. | 26.11, 36.5, 38.120 |
633
633
  | `ABIDE_PRINCIPAL_TTL` | `number` | The principal cookie's life, in ms. | 26.15, 26.16 |
634
634
  | `ABIDE_APP_TOKEN` | `string \| null` | The bearer the remote CLI sends. | 36.6 |
635
635
  | `ABIDE_APP_URL` | `string \| null` | Names an app somewhere else, for the remote CLI. | 36.6 |
@@ -706,7 +706,7 @@ type Gated<Gate, Accepted> = [Schema<Accepted>] extends [Gate] ? never : Failed<
706
706
  | `createApp` | `() => Promise<App>` | The app built and booted, with no socket bound. | 42.1, 42.2, 42.3, 42.4, 42.16, 37.4, 37.6, 38.100 |
707
707
  | `App` | `interface App` | What a host, a test or a script holds the app as. | 42.12, 42.13 |
708
708
  | `App.fetch` | `(request: Request) => Promise<Response>` | The whole request path, entered in process. | 42.5, 42.6 |
709
- | `App.run` | `<Result>(fn: () => Result \| Promise<Result>, options?: { request?: Request }) => Promise<Result>` | One scope around a function, and nothing else. | 42.7, 42.8, 11.38 |
709
+ | `App.run` | `<Result>(fn: () => Result \| Promise<Result>, options?: { request?: Request; principal?: Record<string, unknown> }) => Promise<Result>` | One scope around a function, and nothing else. | 42.7, 42.8, 42.19, 11.38 |
710
710
  | `App.listen` | `(options?: { port?: number; hostname?: string; unix?: string }) => Promise<void>` | The socket, bound. | 42.9, 16.23, 16.56, 18.28, 18.29, 18.30 |
711
711
  | `App.stop` | `() => Promise<void>` | The drain and the teardown. | 42.10, 42.11, 42.15, 42.17, 37.4 |
712
712
  | `App[Symbol.asyncDispose]` | `() => Promise<void>` | `App.stop`, under the name `await using` calls. | 42.14 |
package/docs/RULEBOOK.md CHANGED
@@ -413,6 +413,12 @@ the value of that write standing in its place.
413
413
 
414
414
  8.19 A `Store`'s `get` answering a miss with nothing left to serve MUST land an error on `s.error`.
415
415
 
416
+ 8.20 A `Store`'s `watch` MUST be called once, where the `Reactive` is constructed, and each call of
417
+ the function it is handed MUST run `get` again as a restore, per 8.18, and MUST NOT run the body.
418
+
419
+ 8.21 What a `Store`'s `watch` returns MUST be called once the `Reactive` is released: a `Keyed`
420
+ entry evicted, or the setup or request that built it, or its `Keyed`, ended.
421
+
416
422
  # 9. Streams and wired values
417
423
 
418
424
  9.1 A streaming producer's `Stored` MUST be the chunks accumulated so far, materialized on the
@@ -641,6 +647,9 @@ start again when the load ends.
641
647
  11.71 An unkeyed `reactive` with a `store`, built on a server outside every request, MUST NOT run
642
648
  the `store`'s `get` before it is reached.
643
649
 
650
+ 11.72 A `Keyed` MUST NOT hold an entry for an `Args` its `args` gate refuses, and the gate MUST run
651
+ once per entry.
652
+
644
653
  # 12. Effects
645
654
 
646
655
  12.1 A `Disposer` MUST run before each rerun of its `Effect`, and once more at teardown.
@@ -1725,8 +1734,8 @@ the page shown, and MUST NOT where only its query or fragment does.
1725
1734
  30.25 A `navigate` that does not remount the page MUST NOT move the scroll, except to the element a
1726
1735
  fragment it carries names.
1727
1736
 
1728
- 30.26 `abide check` MUST refuse a `url` literal that no page route of the app (23.3) is filed under
1729
- and no public file of the app is served at.
1737
+ 30.26 `abide check` MUST refuse a `url` literal that no page route of the app (23.3) is filed under,
1738
+ no public file of the app is served at, and that is none of the four addresses 21.4 names.
1730
1739
 
1731
1740
  30.27 A `navigate` whose request fails, or whose route's chunks fail to load, MUST leave the address
1732
1741
  to the document, save where 30.32 holds.
@@ -2001,7 +2010,9 @@ reading layout, querying its descendants or calling `focus()` acts on a connecte
2001
2010
  32.69 `bind:element` on a component MUST be a build error.
2002
2011
 
2003
2012
  32.70 Markup the client parses for a slot holding a value branded by `raw`, a `{#if}` arm or a
2004
- `{#for}` row MUST be parsed as the document's parser parses it inside the element it goes into.
2013
+ `{#for}` row MUST be parsed as the document's parser parses it inside the element it goes into, and
2014
+ an element the client builds — a component's own, or children a caller hands one — MUST take the
2015
+ namespace the document's parser gives it there, as MUST an attribute the client writes on it.
2005
2016
 
2006
2017
  32.71 A `class` or `style` attribute beside a `class:name` or `style:prop` MUST keep moving (32.3)
2007
2018
  and MUST write only its own classes or declarations; a class either of them holds MUST stay on the
@@ -2672,6 +2683,10 @@ it, MUST hydrate it there with that component's browser module, and MUST run eve
2672
2683
  hydration woke before it settles; it MUST throw where the component was not imported from a
2673
2684
  `.abide` and bound, or where the process has no DOM.
2674
2685
 
2686
+ 38.120 Under `bun test` with `@abide/abide/test` preloaded, an app that declares no
2687
+ `ABIDE_PRINCIPAL_SECRET` MUST seal and verify the principal cookie with a secret the preload
2688
+ supplies, and `abide start` MUST NOT supply that secret.
2689
+
2675
2690
  # 39. Head and view transitions
2676
2691
 
2677
2692
  39.1 A `<head>` element in a `.abide` file MUST contribute its children to the document head.
@@ -2990,6 +3005,10 @@ connection still answering, and MUST then run `onStop` without waiting on what i
2990
3005
 
2991
3006
  42.18 `ABIDE_STOP_TIMEOUT` MUST default to 5000.
2992
3007
 
3008
+ 42.19 A `principal` on `App.run` MUST be the claims its scope's `principal` resolves from, through
3009
+ `onPrincipal` as a sealed cookie's are, MUST NOT write the principal cookie, and MUST give the scope
3010
+ a request where `request` is not given.
3011
+
2993
3012
  # 43. The measurement harness
2994
3013
 
2995
3014
  43.1 `@abide/harness/measure`, `@abide/harness/engine` and `@abide/harness/server` MUST each be
package/docs/abide.md CHANGED
@@ -2382,6 +2382,8 @@ test('the view picked is the view the next visit opens on', async () => {
2382
2382
 
2383
2383
  A `Store` is a `get` and a `set`. The value reads the store when it is built and falls back to `'board'` on a miss, and every change is written back. The reads and writes in the markup do not change.
2384
2384
 
2385
+ Where the stored value can change from outside the app, such as a file another program edits, the store adds `watch`. It is handed a function to call when that happens, and returns the function that stops watching. The value reads `get` again, and the body does not run.
2386
+
2385
2387
  This store goes through two handlers, so the server and the browser read the same preference and the first paint is already right. A store over `localStorage` works too, but only in a browser, so the server paints the fallback first. After a write, `set` invalidates the read it made, so the next read asks the server rather than this tab's copy.
2386
2388
 
2387
2389
  ## Next
@@ -3718,7 +3720,6 @@ export default POST(() => principal.clear(), { description: 'Signs out.' })
3718
3720
  import { expect, test } from 'bun:test'
3719
3721
  import { principal } from '@abide/abide'
3720
3722
  import { createApp } from '@abide/abide/server'
3721
- import { asMember } from './signedIn.ts'
3722
3723
 
3723
3724
  const signIn = (email: string) =>
3724
3725
  new Request('http://crm.invalid/account/signIn', {
@@ -3727,17 +3728,28 @@ const signIn = (email: string) =>
3727
3728
  body: JSON.stringify({ email }),
3728
3729
  })
3729
3730
 
3730
- test('signing in seals the caller into an HttpOnly cookie', async () => {
3731
+ test('signing in seals the id into an HttpOnly cookie', async () => {
3731
3732
  await using app = await createApp()
3732
3733
  const answer = await app.fetch(signIn('maya@crm.example'))
3733
3734
  const [sealed = ''] = answer.headers.getSetCookie()
3734
3735
  expect(sealed).toStartWith('abide-principal=')
3735
3736
  expect(sealed).toContain('HttpOnly')
3737
+
3738
+ // The seal is a JWT; its payload is the id, and the times abide adds.
3739
+ const token = sealed.split(';')[0]?.split('=')[1] ?? ''
3740
+ const payload = JSON.parse(
3741
+ Buffer.from(token.split('.')[1] ?? '', 'base64url').toString(),
3742
+ )
3743
+ expect(Object.keys(payload).sort()).toEqual(['exp', 'iat', 'id'])
3736
3744
  })
3737
3745
 
3738
3746
  test('a request carrying the cookie is signed in', async () => {
3739
3747
  await using app = await createApp()
3740
- const request = await asMember(app, 'maya@crm.example')
3748
+ const answer = await app.fetch(signIn('maya@crm.example'))
3749
+ const [sealed = ''] = answer.headers.getSetCookie()
3750
+ const request = new Request('http://crm.invalid/', {
3751
+ headers: { cookie: sealed.split(';')[0] ?? '' },
3752
+ })
3741
3753
  const signedIn = await app.run(async () => await principal.authenticated, {
3742
3754
  request,
3743
3755
  })
@@ -3749,7 +3761,7 @@ test('a request carrying the cookie is signed in', async () => {
3749
3761
 
3750
3762
  The answer also tells the browser the caller changed. Every tab of the site reads `principal` again, so the form's own button flips with nothing passed to it.
3751
3763
 
3752
- The seal is a JWT signed with `ABIDE_PRINCIPAL_SECRET`, and it lives for `ABIDE_PRINCIPAL_TTL` milliseconds, a week unless you set it. The chapter app keeps a development secret in its `.env`, so set your own wherever the app is deployed. A comma-separated list rotates a secret in: the first signs, and every one verifies.
3764
+ The seal is a JWT signed with `ABIDE_PRINCIPAL_SECRET`, and it lives for `ABIDE_PRINCIPAL_TTL` milliseconds, a week unless you set it. The chapter app keeps a development secret in its `.env`, so set your own wherever the app is deployed. A comma-separated list rotates a secret in: the first signs, and every one verifies. Under `bun test` an app that sets no secret is given one, so a test signs in from a fresh clone.
3753
3765
 
3754
3766
  | Read | What it is |
3755
3767
  | --- | --- |
@@ -3808,21 +3820,11 @@ onPrincipal((claims) => {
3808
3820
  import { expect, test } from 'bun:test'
3809
3821
  import { principal } from '@abide/abide'
3810
3822
  import { createApp } from '@abide/abide/server'
3811
- import { asMember } from './signedIn.ts'
3812
3823
 
3813
- test('the cookie holds an id, and the request reads the member', async () => {
3824
+ test('the claims resolve to the member they name', async () => {
3814
3825
  await using app = await createApp()
3815
- const request = await asMember(app, 'theo@crm.example')
3816
-
3817
- // The seal is a JWT; its payload is the id, and the times abide adds.
3818
- const sealed = request.headers.get('cookie') ?? ''
3819
- const payload = JSON.parse(
3820
- Buffer.from(sealed.split('.')[1] ?? '', 'base64url').toString(),
3821
- )
3822
- expect(Object.keys(payload).sort()).toEqual(['exp', 'iat', 'id'])
3823
-
3824
3826
  const member = await app.run(async () => await principal.resolved, {
3825
- request,
3827
+ principal: { id: 'theo' },
3826
3828
  })
3827
3829
  expect(member.name).toBe('Theo Park')
3828
3830
  expect(member.role).toBe('sales')
@@ -3831,6 +3833,8 @@ test('the cookie holds an id, and the request reads the member', async () => {
3831
3833
 
3832
3834
  `onPrincipal` is handed the claims and returns your half of the caller. It runs on the server, at most once per request, where the team records are. So the cookie stays small, and a rename shows on the next request rather than at the next sign-in. Filed in any handler file or `.abide`, it resolves the caller for every route.
3833
3835
 
3836
+ A test signs in without the cookie. `app.run(fn, { principal: { id: 'theo' } })` hands those claims to `onPrincipal` as a sealed cookie's are, and the card's test reads the member they name.
3837
+
3834
3838
  What it returns is read as `principal.resolved`, typed by `Principal`. Declare your fields on that interface, as the app's `lib/TEAM.server.ts` does beside the team it reads. abide's own three fields are `authenticated`, `expiresAt` and `error`, and they win any collision, so a resolver cannot sign a caller in by returning `authenticated: true`. A resolver that throws fills `error` and leaves the caller signed out.
3835
3839
 
3836
3840
  The tabs on this card, the deal-value card and the export card are `lib/SignInAs.abide`, which calls the same `signIn` handler.
@@ -3934,19 +3938,20 @@ export default GET(
3934
3938
  import { expect, test } from 'bun:test'
3935
3939
  import { createApp } from '@abide/abide/server'
3936
3940
  import getContact from '../getContact.ts'
3937
- import { asMember } from './signedIn.ts'
3938
3941
 
3939
3942
  test('an admin reads the deal value', async () => {
3940
3943
  await using app = await createApp()
3941
- const request = await asMember(app, 'maya@crm.example')
3942
- const contact = await app.run(async () => await getContact(), { request })
3944
+ const contact = await app.run(async () => await getContact(), {
3945
+ principal: { id: 'maya' },
3946
+ })
3943
3947
  expect(contact.dealValue).toBe('$48,000')
3944
3948
  })
3945
3949
 
3946
3950
  test('sales reads the rest of the contact', async () => {
3947
3951
  await using app = await createApp()
3948
- const request = await asMember(app, 'theo@crm.example')
3949
- const contact = await app.run(async () => await getContact(), { request })
3952
+ const contact = await app.run(async () => await getContact(), {
3953
+ principal: { id: 'theo' },
3954
+ })
3950
3955
  expect(contact.name).toBe('Grace Hopper')
3951
3956
  expect(contact.dealValue).toBeUndefined()
3952
3957
  })
@@ -4009,23 +4014,24 @@ export default POST(async () => CONTACTS, {
4009
4014
  import { expect, test } from 'bun:test'
4010
4015
  import { createApp } from '@abide/abide/server'
4011
4016
  import exportContacts from '../exportContacts.ts'
4012
- import { asMember } from './signedIn.ts'
4013
4017
 
4014
4018
  test('an admin exports every contact', async () => {
4015
4019
  await using app = await createApp()
4016
- const request = await asMember(app, 'maya@crm.example')
4017
4020
  const contacts = await app.run(async () => await exportContacts(), {
4018
- request,
4021
+ principal: { id: 'maya' },
4019
4022
  })
4020
4023
  expect(contacts).toHaveLength(3)
4021
4024
  })
4022
4025
 
4023
4026
  test('sales and a signed-out caller are refused 403', async () => {
4024
4027
  await using app = await createApp()
4025
- for (const email of ['theo@crm.example', undefined]) {
4026
- const request = await asMember(app, email)
4028
+ const callers = [
4029
+ { principal: { id: 'theo' } },
4030
+ { request: new Request('http://crm.invalid/') },
4031
+ ]
4032
+ for (const caller of callers) {
4027
4033
  try {
4028
- await app.run(async () => await exportContacts(), { request })
4034
+ await app.run(async () => await exportContacts(), caller)
4029
4035
  throw new Error('the export was not refused')
4030
4036
  } catch (refusal) {
4031
4037
  if (!exportContacts.isError(refusal, 'HttpError')) throw refusal
@@ -4961,13 +4967,13 @@ A handler file already carries an address, a method, an argument type and a desc
4961
4967
 
4962
4968
  ```abide src/assistant/lib/OpenApiOperation.abide
4963
4969
  <script>
4964
- import { reactive, type RouteLiteral, url } from '@abide/abide'
4970
+ import { reactive, url } from '@abide/abide'
4965
4971
 
4966
4972
  type Operation = { description: string; parameters: object[] }
4967
4973
  let operation = reactive('')
4968
4974
 
4969
4975
  async function readDocument() {
4970
- const answer = await fetch(url('/__abide/openapi.json' as RouteLiteral))
4976
+ const answer = await fetch(url('/__abide/openapi.json'))
4971
4977
  const document: { paths: Record<string, { get: Operation }> } =
4972
4978
  await answer.json()
4973
4979
  for (const [path, { get }] of Object.entries(document.paths))
@@ -5046,7 +5052,7 @@ abide openapi > openapi.json
5046
5052
 
5047
5053
  ```abide src/assistant/lib/ModelToolCall.abide
5048
5054
  <script>
5049
- import { reactive, type RouteLiteral, url } from '@abide/abide'
5055
+ import { reactive, url } from '@abide/abide'
5050
5056
 
5051
5057
  let tool = reactive('—')
5052
5058
  let description = reactive('—')
@@ -5054,7 +5060,7 @@ let answer = reactive('—')
5054
5060
 
5055
5061
  // One JSON-RPC message to the app's MCP endpoint, as a model's client sends it.
5056
5062
  async function ask(method: string, params: object) {
5057
- const reply = await fetch(url('/__abide/mcp' as RouteLiteral), {
5063
+ const reply = await fetch(url('/__abide/mcp'), {
5058
5064
  method: 'POST',
5059
5065
  headers: { 'content-type': 'application/json' },
5060
5066
  body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
@@ -5147,14 +5153,14 @@ abide mcp --url https://crm.example
5147
5153
 
5148
5154
  ```abide src/assistant/lib/WithheldExport.abide
5149
5155
  <script>
5150
- import { reactive, type RouteLiteral, url } from '@abide/abide'
5156
+ import { reactive, url } from '@abide/abide'
5151
5157
  import exportContacts from '../exportContacts.ts'
5152
5158
 
5153
5159
  let listed = reactive('—')
5154
5160
  let answers = reactive('—')
5155
5161
 
5156
5162
  async function check() {
5157
- const reply = await fetch(url('/__abide/mcp' as RouteLiteral), {
5163
+ const reply = await fetch(url('/__abide/mcp'), {
5158
5164
  method: 'POST',
5159
5165
  headers: { 'content-type': 'application/json' },
5160
5166
  body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }),
@@ -5743,7 +5749,7 @@ test('the summary answers in process as it does on the wire', async () => {
5743
5749
  | --- | --- |
5744
5750
  | `App` | what `createApp` hands back, to a test, a script or a host |
5745
5751
  | `App.fetch` | one request, through the whole app, in this process |
5746
- | `App.run` | runs a function inside one request's scope, so two reads of one key load once |
5752
+ | `App.run` | runs a function inside one request's scope, so two reads of one key load once, signed in as the `principal` claims you give it |
5747
5753
  | `App.listen` | binds the socket, for a test that needs a real `WebSocket` |
5748
5754
  | `App.stop` | drains what is in flight, then runs `onStop` |
5749
5755
  | `App[Symbol.asyncDispose]` | `stop`, under the name `await using` calls |
@@ -6827,7 +6833,7 @@ Arrive here knowing which name you want. Each page is read off the registry: the
6827
6833
 
6828
6834
  | Name | Signature | Meaning | Rules |
6829
6835
  | --- | --- | --- | --- |
6830
- | `Store` | `{ get: () => Stored \| undefined \| Promise<Stored \| undefined>; set: (value: Stored, retention: { ttl: number }) => void \| Promise<void> }` | Where a value lives when this process does not. | [8.1](/rulebook#8.1), [8.2](/rulebook#8.2), [8.3](/rulebook#8.3), [8.4](/rulebook#8.4), [8.5](/rulebook#8.5), [8.6](/rulebook#8.6), [8.7](/rulebook#8.7), [8.8](/rulebook#8.8), [8.9](/rulebook#8.9), [8.10](/rulebook#8.10), [8.11](/rulebook#8.11), [8.12](/rulebook#8.12), [8.13](/rulebook#8.13), [8.14](/rulebook#8.14), [8.15](/rulebook#8.15), [8.16](/rulebook#8.16), [8.17](/rulebook#8.17), [8.18](/rulebook#8.18), [8.19](/rulebook#8.19) |
6836
+ | `Store` | `{ get: () => Stored \| undefined \| Promise<Stored \| undefined>; set: (value: Stored, retention: { ttl: number }) => void \| Promise<void>; watch?: (changed: () => void) => () => void }` | Where a value lives when this process does not. | [8.1](/rulebook#8.1), [8.2](/rulebook#8.2), [8.3](/rulebook#8.3), [8.4](/rulebook#8.4), [8.5](/rulebook#8.5), [8.6](/rulebook#8.6), [8.7](/rulebook#8.7), [8.8](/rulebook#8.8), [8.9](/rulebook#8.9), [8.10](/rulebook#8.10), [8.11](/rulebook#8.11), [8.12](/rulebook#8.12), [8.13](/rulebook#8.13), [8.14](/rulebook#8.14), [8.15](/rulebook#8.15), [8.16](/rulebook#8.16), [8.17](/rulebook#8.17), [8.18](/rulebook#8.18), [8.19](/rulebook#8.19), [8.20](/rulebook#8.20), [8.21](/rulebook#8.21) |
6831
6837
 
6832
6838
  ## Taught in
6833
6839
 
@@ -6869,7 +6875,7 @@ Arrive here knowing which name you want. Each page is read off the registry: the
6869
6875
  | `identity` | `(next: Stored, previous: Stored) => unknown` | What makes it the same value. | [5.1](/rulebook#5.1), [5.2](/rulebook#5.2), [5.3](/rulebook#5.3), [5.4](/rulebook#5.4), [5.5](/rulebook#5.5), [9.3](/rulebook#9.3), [11.17](/rulebook#11.17), [18.4](/rulebook#18.4) |
6870
6876
  | `by` | `(keyof Element & string) \| ((element: Element) => unknown)`, over the `Element` a `Stored` array holds | What tells one element of the value from another. | [5.22](/rulebook#5.22), [18.4](/rulebook#18.4), [18.15](/rulebook#18.15), [32.32](/rulebook#32.32) |
6871
6877
  | `ttl` | `number` | The life of a production, or of a keyed entry. | [6.1](/rulebook#6.1), [6.2](/rulebook#6.2), [6.3](/rulebook#6.3), [6.4](/rulebook#6.4), [6.5](/rulebook#6.5), [6.6](/rulebook#6.6), [6.7](/rulebook#6.7), [6.8](/rulebook#6.8), [6.9](/rulebook#6.9), [6.10](/rulebook#6.10), [11.6](/rulebook#11.6), [11.35](/rulebook#11.35), [11.45](/rulebook#11.45), [11.48](/rulebook#11.48), [11.49](/rulebook#11.49), [11.69](/rulebook#11.69) |
6872
- | `args` | `Schema<Args>` | The gate on a keyed value's args. | [9.4](/rulebook#9.4), [11.28](/rulebook#11.28), [11.30](/rulebook#11.30), [11.40](/rulebook#11.40), [11.41](/rulebook#11.41), [11.56](/rulebook#11.56), [11.60](/rulebook#11.60), [11.68](/rulebook#11.68) |
6878
+ | `args` | `Schema<Args>` | The gate on a keyed value's args. | [9.4](/rulebook#9.4), [11.28](/rulebook#11.28), [11.30](/rulebook#11.30), [11.40](/rulebook#11.40), [11.41](/rulebook#11.41), [11.56](/rulebook#11.56), [11.60](/rulebook#11.60), [11.68](/rulebook#11.68), [11.72](/rulebook#11.72) |
6873
6879
  | `global` | `boolean` | Opts out of the default scope into one cache shared by every caller in the process. | [11.34](/rulebook#11.34), [11.35](/rulebook#11.35), [11.36](/rulebook#11.36), [11.37](/rulebook#11.37), [11.38](/rulebook#11.38), [11.39](/rulebook#11.39), [11.53](/rulebook#11.53), [11.67](/rulebook#11.67) |
6874
6880
  | `tags` | `string[] \| ((args: Args) => string[])` | What a `Selection` matches on. | [11.43](/rulebook#11.43), [11.44](/rulebook#11.44), [18.4](/rulebook#18.4), [18.15](/rulebook#18.15) |
6875
6881
  | `throttle` | `number` | A ceiling on how often the value changes. | [5.8](/rulebook#5.8), [5.9](/rulebook#5.9), [5.11](/rulebook#5.11), [5.12](/rulebook#5.12), [5.14](/rulebook#5.14), [5.15](/rulebook#5.15), [5.17](/rulebook#5.17), [5.18](/rulebook#5.18), [5.19](/rulebook#5.19), [5.20](/rulebook#5.20), [5.21](/rulebook#5.21), [5.23](/rulebook#5.23), [5.24](/rulebook#5.24) |
@@ -8628,7 +8634,7 @@ Generated surfaces: what is written in place rather than imported.
8628
8634
  | `{expr}` | Reactive text, escaped. | [32.1](/rulebook#32.1), [32.23](/rulebook#32.23) |
8629
8635
  | formatting whitespace | What a run of whitespace renders as. | [33.17](/rulebook#33.17), [33.18](/rulebook#33.18) |
8630
8636
  | `{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) |
8631
- | `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), [32.71](/rulebook#32.71) |
8637
+ | `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), [32.70](/rulebook#32.70), [32.71](/rulebook#32.71) |
8632
8638
  | `on<event>={fn}` | A native listener on an element, and an ordinary prop on a component. | [32.4](/rulebook#32.4) |
8633
8639
  | `name="…{expr}…"` | An interpolated quoted value. | [32.3](/rulebook#32.3), [32.71](/rulebook#32.71) |
8634
8640
  | `bind:value` | Two-way bind on an element. | [5.16](/rulebook#5.16), [32.5](/rulebook#32.5), [32.58](/rulebook#32.58), [32.59](/rulebook#32.59), [32.60](/rulebook#32.60), [32.61](/rulebook#32.61), [32.62](/rulebook#32.62), [32.72](/rulebook#32.72) |
@@ -8656,10 +8662,10 @@ Generated surfaces: what is written in place rather than imported.
8656
8662
  | Spelling | Meaning | Rules |
8657
8663
  | --- | --- | --- |
8658
8664
  | `{#component Name(pattern)}` | An inline component. | [32.39](/rulebook#32.39), [32.40](/rulebook#32.40), [32.41](/rulebook#32.41), [32.42](/rulebook#32.42), [32.43](/rulebook#32.43), [32.44](/rulebook#32.44), [32.45](/rulebook#32.45), [32.46](/rulebook#32.46), [32.47](/rulebook#32.47), [32.48](/rulebook#32.48), [32.49](/rulebook#32.49), [32.50](/rulebook#32.50), [32.51](/rulebook#32.51), [32.52](/rulebook#32.52), [32.53](/rulebook#32.53), [32.54](/rulebook#32.54), [32.55](/rulebook#32.55), [32.63](/rulebook#32.63), [32.64](/rulebook#32.64) |
8659
- | `<Name/>` | A component invocation. | [32.17](/rulebook#32.17), [32.34](/rulebook#32.34), [32.35](/rulebook#32.35), [32.36](/rulebook#32.36), [33.19](/rulebook#33.19), [33.21](/rulebook#33.21), [41.24](/rulebook#41.24), [41.28](/rulebook#41.28) |
8665
+ | `<Name/>` | A component invocation. | [32.17](/rulebook#32.17), [32.34](/rulebook#32.34), [32.35](/rulebook#32.35), [32.36](/rulebook#32.36), [32.70](/rulebook#32.70), [33.19](/rulebook#33.19), [33.21](/rulebook#33.21), [41.24](/rulebook#41.24), [41.28](/rulebook#41.28) |
8660
8666
  | `<slot/>` | The position children render into. | [32.18](/rulebook#32.18) |
8661
8667
  | `<slot>fallback</slot>` | Renders children, or a fallback. | [32.18](/rulebook#32.18) |
8662
- | `<Tag>…</Tag>` | Children passed to a component's slot. | [32.18](/rulebook#32.18) |
8668
+ | `<Tag>…</Tag>` | Children passed to a component's slot. | [32.18](/rulebook#32.18), [32.70](/rulebook#32.70) |
8663
8669
 
8664
8670
  ## Script / style blocks
8665
8671
 
@@ -8837,7 +8843,7 @@ Configuration: what is written in place rather than imported.
8837
8843
  | `APP_NAME` | `string` | The app's name, and `log`'s default namespace. | [27.3](/rulebook#27.3), [28.4](/rulebook#28.4) |
8838
8844
  | `APP_VERSION` | `string` | The version beside that name. | [27.3](/rulebook#27.3) |
8839
8845
  | `APP_DATA_DIR` | `string` | The platform's per-user data directory. | [27.3](/rulebook#27.3), [36.4](/rulebook#36.4) |
8840
- | `ABIDE_PRINCIPAL_SECRET` | `string \| null` | What seals the principal cookie. | [26.11](/rulebook#26.11), [36.5](/rulebook#36.5) |
8846
+ | `ABIDE_PRINCIPAL_SECRET` | `string \| null` | What seals the principal cookie. | [26.11](/rulebook#26.11), [36.5](/rulebook#36.5), [38.120](/rulebook#38.120) |
8841
8847
  | `ABIDE_PRINCIPAL_TTL` | `number` | The principal cookie's life, in ms. | [26.15](/rulebook#26.15), [26.16](/rulebook#26.16) |
8842
8848
  | `ABIDE_APP_TOKEN` | `string \| null` | The bearer the remote CLI sends. | [36.6](/rulebook#36.6) |
8843
8849
  | `ABIDE_APP_URL` | `string \| null` | Names an app somewhere else, for the remote CLI. | [36.6](/rulebook#36.6) |
@@ -9177,7 +9183,7 @@ Configuration: what is written in place rather than imported.
9177
9183
  | --- | --- | --- | --- |
9178
9184
  | `App` | `interface App` | What a host, a test or a script holds the app as. | [42.12](/rulebook#42.12), [42.13](/rulebook#42.13) |
9179
9185
  | `App.fetch` | `(request: Request) => Promise<Response>` | The whole request path, entered in process. | [42.5](/rulebook#42.5), [42.6](/rulebook#42.6) |
9180
- | `App.run` | `<Result>(fn: () => Result \| Promise<Result>, options?: { request?: Request }) => Promise<Result>` | One scope around a function, and nothing else. | [42.7](/rulebook#42.7), [42.8](/rulebook#42.8), [11.38](/rulebook#11.38) |
9186
+ | `App.run` | `<Result>(fn: () => Result \| Promise<Result>, options?: { request?: Request; principal?: Record<string, unknown> }) => Promise<Result>` | One scope around a function, and nothing else. | [42.7](/rulebook#42.7), [42.8](/rulebook#42.8), [42.19](/rulebook#42.19), [11.38](/rulebook#11.38) |
9181
9187
  | `App.listen` | `(options?: { port?: number; hostname?: string; unix?: string }) => Promise<void>` | The socket, bound. | [42.9](/rulebook#42.9), [16.23](/rulebook#16.23), [16.56](/rulebook#16.56), [18.28](/rulebook#18.28), [18.29](/rulebook#18.29), [18.30](/rulebook#18.30) |
9182
9188
  | `App.stop` | `() => Promise<void>` | The drain and the teardown. | [42.10](/rulebook#42.10), [42.11](/rulebook#42.11), [42.15](/rulebook#42.15), [42.17](/rulebook#42.17), [37.4](/rulebook#37.4) |
9183
9189
  | `App[Symbol.asyncDispose]` | `() => Promise<void>` | `App.stop`, under the name `await using` calls. | [42.14](/rulebook#42.14) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abide/abide",
3
- "version": "0.112.0",
3
+ "version": "0.113.0",
4
4
  "type": "module",
5
5
  "description": "Isomorphic type-safe framework for reactive async interfaces for humans and machines",
6
6
  "license": "MIT",
@@ -1,6 +1,12 @@
1
1
  import { relative as relativeTo } from 'node:path' // bun exposes no path helpers of its own
2
2
  import { appFiles } from '#server/appTree.ts'
3
3
  import { type Placement, placement, routeOf } from '#server/placement.ts'
4
+ import {
5
+ HEALTH_ADDRESS,
6
+ MCP_ADDRESS,
7
+ OPENAPI_ADDRESS,
8
+ PRINCIPAL_ADDRESS,
9
+ } from '#shared/ADDRESSES.ts'
4
10
  import { LAUNCHER_PAGE } from '#shared/LAUNCHER_FILES.ts'
5
11
  import {
6
12
  coversRoute,
@@ -76,7 +82,7 @@ export type AppLayout = {
76
82
  errors: AppRoute[]
77
83
  /** Every public file's source path to its address. 36.11. */
78
84
  published: Map<string, string>
79
- /** 30.26 — every address a link may name: a page's, and a public file's. */
85
+ /** 30.26 — every address a link may name: a page's, a public file's, and 21.4's four. */
80
86
  addresses: string[]
81
87
  /** What the tree refuses, each at its file; a build stops naming every one. */
82
88
  refused: Diagnosis[]
@@ -150,6 +156,7 @@ export async function appLayout(root: string): Promise<AppLayout> {
150
156
  const addresses = new Set<string>()
151
157
  for (const route of routes) addresses.add(route.pattern)
152
158
  for (const address of published.values()) addresses.add(address)
159
+ for (const address of FETCHED_ADDRESSES) addresses.add(address)
153
160
  return {
154
161
  root,
155
162
  sources,
@@ -165,6 +172,14 @@ export async function appLayout(root: string): Promise<AppLayout> {
165
172
  }
166
173
  }
167
174
 
175
+ // 30.26, 21.4 — the framework's own addresses an app fetches, which every app answers at.
176
+ const FETCHED_ADDRESSES = [
177
+ OPENAPI_ADDRESS,
178
+ MCP_ADDRESS,
179
+ HEALTH_ADDRESS,
180
+ PRINCIPAL_ADDRESS,
181
+ ]
182
+
168
183
  /** The file at each of `paths`, read. The reads overlap. */
169
184
  function textsAt(
170
185
  paths: readonly string[],
@@ -532,11 +532,11 @@ export function isSingleRooted(children: TemplateNode[]): boolean {
532
532
  )
533
533
  }
534
534
 
535
- // CONTENT ONLY THE PARSER MAKES RIGHT, which is what a row the browser BUILDS cannot reproduce with
536
- // `createElement` and `appendChild`: an `<svg>` or `<math>` subtree lives in another namespace (and
537
- // the parser fixes the case of `linearGradient`), a raw-text element's contents are not decoded
538
- // the way body text is, and a `<template>`'s children go into its `content`. A row that touches any
539
- // of it is left to the parser — the general path, as the fallback it is.
535
+ // CONTENT A ROW THE BROWSER BUILDS LEAVES TO THE PARSER: an `<svg>` or `<math>` that opens its
536
+ // namespace inside the row (a build only carries one in, off the parent it goes into — 32.70), a
537
+ // raw-text element's contents, which are not decoded the way body text is, and a `<template>`'s
538
+ // children, which go into its `content`. A row that touches any of it is left to the parser — the
539
+ // general path, as the fallback it is.
540
540
  const PARSER_OWNED = new Set([
541
541
  'svg',
542
542
  'math',
@@ -7,6 +7,11 @@ import {
7
7
  } from '#shared/ANCHORS.ts'
8
8
  import { anchorMarkup } from '#shared/anchorMarkup.ts'
9
9
  import { CHILDREN } from '#shared/CHILDREN.ts'
10
+ import { FOREIGN_ATTRIBUTES } from '#shared/FOREIGN_ATTRIBUTES.ts'
11
+ import {
12
+ HTML_NAMESPACE,
13
+ INTEGRATION_POINTS,
14
+ } from '#shared/INTEGRATION_POINTS.ts'
10
15
  import { RUNTIME_SPECIFIER, SHARED_SPECIFIER } from '#shared/SPECIFIERS.ts'
11
16
  import { type CheckerAnswer, UNCHECKED } from './CheckerAnswer.ts'
12
17
  import {
@@ -880,6 +885,9 @@ const SHARE_AT = '__shareAt'
880
885
  const PARENT = '__parent'
881
886
  // The document a built region makes its nodes with, read off `PARENT` once a build.
882
887
  const DOCUMENT = '__document'
888
+ // The namespace a built region's top-level elements take, read off `PARENT` once a build.
889
+ const SPACE = '__space'
890
+ const HTML_SPACE = JSON.stringify(HTML_NAMESPACE)
883
891
 
884
892
  // A run's anchors while it holds markup (32.30), as the JS string literals a built run parses with.
885
893
  const RAW_OPEN_MARKUP = JSON.stringify(anchorMarkup(RAW_OPEN))
@@ -942,17 +950,29 @@ function emitMaker(
942
950
  *
943
951
  * A NESTED REGION IS AN EMPTY ANCHOR PAIR, filled by its own machinery on the mount's first run
944
952
  * (`fillEmpty`), and a component or a slot is the same pair filled by the child.
953
+ *
954
+ * 32.70 — AN ELEMENT IS BUILT IN THE NAMESPACE THE PARSER WOULD GIVE IT, read off the parent it goes
955
+ * into (`namespaceUnder`) rather than decided here: a component's root does not know it is invoked
956
+ * inside an `<svg>`, and a `<g>` made with `createElement` is an HTML element that draws nothing.
957
+ * Read once a build, and again only below an `<annotation-xml>`, which reads its children as HTML by
958
+ * the encoding it declares; below any other integration point (`INTEGRATION_POINTS`) a child is
959
+ * HTML's, and everywhere else it takes its parent's. `createElementNS` with
960
+ * HTML's namespace costs what `createElement` does (Chromium, 2026-10-22), and a build that makes
961
+ * no top-level element reads none.
945
962
  */
946
963
  function emitBuild(row: Cursor, anchored: boolean, lowering: Lowering): string {
947
964
  const lines: string[] = [
948
965
  `const ${DOCUMENT} = ${PARENT}.ownerDocument`,
949
966
  'const __built = []',
950
967
  ]
968
+ let readsSpace = false
951
969
  if (anchored)
952
970
  lines.push(
953
971
  `__built.push(${DOCUMENT}.createComment(${JSON.stringify(ROW_OPEN)}))`,
954
972
  )
955
973
  const render = buildRender(lowering)
974
+ // The expression each built element's namespace was, for its children to take.
975
+ const spaceOf = new Map<PlacedNode, string>()
956
976
  const into = (at: PlacedNode, node: string): string =>
957
977
  at.within === null
958
978
  ? `__built.push(${node})`
@@ -975,8 +995,19 @@ function emitBuild(row: Cursor, anchored: boolean, lowering: Lowering): string {
975
995
  for (const line of builtRun(at, lowering, into)) lines.push(line)
976
996
  continue
977
997
  }
998
+ const within = at.within
999
+ const space =
1000
+ within === null
1001
+ ? SPACE
1002
+ : INTEGRATION_POINTS.has(within.tag)
1003
+ ? HTML_SPACE
1004
+ : within.tag === 'annotation-xml'
1005
+ ? `${lowering.use('namespaceUnder')}(${within.name})`
1006
+ : (spaceOf.get(within) ?? SPACE)
1007
+ if (space === SPACE) readsSpace = true
1008
+ spaceOf.set(at, space)
978
1009
  lines.push(
979
- `const ${at.name} = ${DOCUMENT}.createElement(${JSON.stringify(at.tag)})`,
1010
+ `const ${at.name} = ${DOCUMENT}.createElementNS(${space}, ${JSON.stringify(at.tag)})`,
980
1011
  )
981
1012
  for (const one of classifyAttributes(
982
1013
  at.holder as ElementNode,
@@ -984,7 +1015,13 @@ function emitBuild(row: Cursor, anchored: boolean, lowering: Lowering): string {
984
1015
  )) {
985
1016
  const value = builtAttribute(one, lowering, render)
986
1017
  if (value === null) continue
987
- const set = `${at.name}.setAttribute(${JSON.stringify(one.kind === 'merged' ? one.group?.name : one.attribute.name)}, `
1018
+ const name =
1019
+ one.kind === 'merged' ? one.group?.name : one.attribute.name
1020
+ // 32.70 — one the parser would put in a namespace is written in it (`addAttribute`).
1021
+ const set =
1022
+ name !== undefined && FOREIGN_ATTRIBUTES.has(name)
1023
+ ? `${lowering.use('addAttribute')}(${at.name}, ${JSON.stringify(name)}, `
1024
+ : `${at.name}.setAttribute(${JSON.stringify(name)}, `
988
1025
  // 32.37 — a whole-value attribute may be absent, which the server wrote as nothing.
989
1026
  lines.push(
990
1027
  one.kind === 'dynamic'
@@ -994,6 +1031,12 @@ function emitBuild(row: Cursor, anchored: boolean, lowering: Lowering): string {
994
1031
  }
995
1032
  lines.push(into(at, at.name))
996
1033
  }
1034
+ if (readsSpace)
1035
+ lines.splice(
1036
+ 1,
1037
+ 0,
1038
+ `const ${SPACE} = ${lowering.use('namespaceUnder')}(${PARENT})`,
1039
+ )
997
1040
  lines.push('return __built')
998
1041
  return lines.join('\n ')
999
1042
  }
@@ -6,6 +6,7 @@
6
6
  import { resolve } from 'node:path' // bun exposes no path helpers of its own
7
7
  import { parseArgs } from 'node:util' // bun exposes no argv parser of its own yet
8
8
  import { MCP_ADDRESS, OPENAPI_ADDRESS } from '#shared/ADDRESSES.ts'
9
+ import { IN_PROCESS_ORIGIN } from '#shared/IN_PROCESS_ORIGIN.ts'
9
10
  import { banner } from './banner.ts'
10
11
  import { type App, type AppHandle, enterApp } from './createApp.ts'
11
12
  import { stopOnSignalOrFault } from './faultPolicy.ts'
@@ -494,7 +495,7 @@ async function surfaceAt(
494
495
  const booted = await booting(host, app, given)
495
496
  if (typeof booted === 'number') return booted
496
497
  return (init) =>
497
- booted.fetch(new Request(`http://abide.local${address}`, init))
498
+ booted.fetch(new Request(`${IN_PROCESS_ORIGIN}${address}`, init))
498
499
  }
499
500
 
500
501
  /**
@@ -49,6 +49,16 @@ export interface Config {
49
49
  APP_DATA_DIR: string
50
50
  }
51
51
 
52
+ // What a preload derives beside 27.3's three — the test preload's principal secret (38.120). Declared
53
+ // above `config`, which resolves as it is built — and so is invalidated by an addition.
54
+ const ADDED: Record<string, string> = {}
55
+
56
+ /** Adds `values` to the derived layer, so every `onConfig` default and the env still win (27.2). */
57
+ export function addDerived(values: Record<string, string>): void {
58
+ Object.assign(ADDED, values)
59
+ config.invalidate()
60
+ }
61
+
52
62
  /** The resolved configuration: abide's derived values, then `onConfig`'s defaults, then the env. */
53
63
  export const config = reactive(resolve, { global: true })
54
64
 
@@ -100,7 +110,8 @@ function coerce(text: string, standing: unknown): unknown {
100
110
  }
101
111
 
102
112
  /**
103
- * The app's name, version and data directory, off its own `package.json` and the platform.
113
+ * The app's name, version and data directory, off its own `package.json` and the platform, and
114
+ * what `addDerived` added.
104
115
  *
105
116
  * 27.3 — a layer under the other two, so a config or an environment that carries one wins.
106
117
  */
@@ -108,6 +119,7 @@ function derived(): Record<string, string> {
108
119
  const manifest = APP_PACKAGE.current
109
120
  const name = manifest.name ?? 'app'
110
121
  return {
122
+ ...ADDED,
111
123
  APP_NAME: name,
112
124
  APP_VERSION: manifest.version ?? '0.0.0',
113
125
  // 36.4 — A PATH, and never created: an app that wants the directory makes it.
@@ -7,6 +7,7 @@ import { existsSync } from 'node:fs' // bun exposes no sync existence check of i
7
7
  import { resolve } from 'node:path' // bun exposes no path helpers of its own
8
8
  import { FAILURE_STATUS_TEXT } from '#shared/FAILURE_STATUS_TEXT.ts'
9
9
  import { framingOf } from '#shared/framing.ts'
10
+ import { IN_PROCESS_ORIGIN } from '#shared/IN_PROCESS_ORIGIN.ts'
10
11
  import { LONGEST_DELAY } from '#shared/LONGEST_DELAY.ts'
11
12
  import { namedLogger } from '#shared/log.ts'
12
13
  import { compose } from '#shared/Middleware.ts'
@@ -32,6 +33,7 @@ import { IDLE_TIMEOUT_SECONDS } from './IDLE_TIMEOUT_SECONDS.ts'
32
33
  import { installServer } from './installServer.ts'
33
34
  import { admitsMux, muxSocketOptions, upgraded } from './muxUpgrade.ts'
34
35
  import { plainAnswer } from './plainAnswer.ts'
36
+ import { presumePrincipal } from './principal.ts'
35
37
  import { realOrAsGiven } from './realOrAsGiven.ts'
36
38
  import {
37
39
  type AnsweringApp,
@@ -60,7 +62,7 @@ export interface App {
60
62
  fetch(request: Request): Promise<Response>
61
63
  run<Result>(
62
64
  fn: () => Result | Promise<Result>,
63
- options?: { request?: Request },
65
+ options?: { request?: Request; principal?: Record<string, unknown> },
64
66
  ): Promise<Result>
65
67
  listen(options?: {
66
68
  port?: number
@@ -289,11 +291,21 @@ export async function bootEntered(
289
291
  let stopping = false
290
292
  let bound: Bun.Server<unknown> | undefined
291
293
 
292
- const run = async <Result>(
293
- fn: () => Result | Promise<Result>,
294
- options?: { request?: Request },
295
- ): Promise<Result> =>
296
- inReleasedScope(options?.request, async () => await fn(), answering)
294
+ const run: App['run'] = async (fn, options) => {
295
+ const claims = options?.principal
296
+ // 42.19 — a principal is a request's, so one given alone is given a request to be.
297
+ const request =
298
+ options?.request ??
299
+ (claims === undefined ? undefined : new Request(IN_PROCESS_ORIGIN))
300
+ return inReleasedScope(
301
+ request,
302
+ async () => {
303
+ if (claims !== undefined) presumePrincipal(claims)
304
+ return await fn()
305
+ },
306
+ answering,
307
+ )
308
+ }
297
309
 
298
310
  const fetch = async (given: Request): Promise<Response> => {
299
311
  if (stopping) return plainAnswer('the app is stopping', 503)
@@ -5,8 +5,13 @@
5
5
  // one place that runs first. The build is `createApp`'s own under `bun test` (42.16), so a test that
6
6
  // boots the app afterwards reuses it rather than building twice.
7
7
 
8
+ import { addDerived } from './config.ts'
8
9
  import { appRoot, servedBuild } from './createApp.ts'
9
10
 
11
+ // 38.120 — a sign-in seals under test where the app declared no secret: derived, so the app's
12
+ // `onConfig` default and the env both still win (27.2), where an env value would beat the default.
13
+ addDerived({ ABIDE_PRINCIPAL_SECRET: 'abide-test-principal-secret' })
14
+
10
15
  const root = appRoot(undefined)
11
16
  // A run from a directory that holds no app preloads nothing — nor from a workspace's root, whose
12
17
  // packages' `.abide` files are each their own app's rather than one app laid out across them.
@@ -195,6 +195,20 @@ function resolvePrincipal(): Promise<Principal> {
195
195
  return scope.principal
196
196
  }
197
197
 
198
+ /**
199
+ * This request's principal, resolved from `claims` as a sealed cookie's are, with no cookie written.
200
+ *
201
+ * 42.19 — `App.run`'s `principal`, which is why it throws where `principal.set` does.
202
+ */
203
+ export function presumePrincipal(claims: Record<string, unknown>): void {
204
+ const scope = within('App.run principal')
205
+ if (!isRecord(claims))
206
+ throw new Error(
207
+ 'abide: App.run takes a principal of claims, as principal.set does. RULEBOOK 26.11.',
208
+ )
209
+ scope.principal = documentOf(claims, Date.now() + ttl())
210
+ }
211
+
198
212
  /**
199
213
  * Signs this caller in with `claims`, sealing them into the cookie.
200
214
  *
@@ -0,0 +1,24 @@
1
+ const XLINK = 'http://www.w3.org/1999/xlink'
2
+ const XML = 'http://www.w3.org/XML/1998/namespace'
3
+ const XMLNS = 'http://www.w3.org/2000/xmlns/'
4
+
5
+ /**
6
+ * The attributes the HTML parser puts in a namespace on an SVG or MathML element — its "adjust
7
+ * foreign attributes" table — each to that namespace. Every other attribute it writes has none.
8
+ *
9
+ * A leaf, because the compiler reads it to know which built attribute needs the client's
10
+ * `addAttribute`, and the client reads it to write one.
11
+ */
12
+ export const FOREIGN_ATTRIBUTES: ReadonlyMap<string, string> = new Map([
13
+ ['xlink:actuate', XLINK],
14
+ ['xlink:arcrole', XLINK],
15
+ ['xlink:href', XLINK],
16
+ ['xlink:role', XLINK],
17
+ ['xlink:show', XLINK],
18
+ ['xlink:title', XLINK],
19
+ ['xlink:type', XLINK],
20
+ ['xml:lang', XML],
21
+ ['xml:space', XML],
22
+ ['xmlns', XMLNS],
23
+ ['xmlns:xlink', XMLNS],
24
+ ])
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The SVG and MathML elements whose children the HTML parser reads as HTML again — the
3
+ * integration points — less `annotation-xml`, which is one only by the encoding it declares.
4
+ *
5
+ * A leaf, because the client's parse and build read it (`namespaceUnder`) and the compiler reads
6
+ * it to know a built element below one is HTML's, whatever its parent's namespace.
7
+ */
8
+ export const INTEGRATION_POINTS: ReadonlySet<string> = new Set([
9
+ 'foreignObject',
10
+ 'desc',
11
+ 'title',
12
+ 'mi',
13
+ 'mo',
14
+ 'mn',
15
+ 'ms',
16
+ 'mtext',
17
+ ])
18
+
19
+ /** HTML's namespace: what the parser gives an element outside SVG and MathML, and below an integration point. */
20
+ export const HTML_NAMESPACE = 'http://www.w3.org/1999/xhtml'
@@ -0,0 +1,5 @@
1
+ // The origin a request made in process is addressed to, where no listener names a real one: an
2
+ // rpc's `raw` answered by the app this process holds, a CLI command asking the app it booted, and
3
+ // an `App.run` given a principal and no request (42.19). A leaf, because the server and the shared
4
+ // rpc both build one.
5
+ export const IN_PROCESS_ORIGIN = 'http://app.internal'
@@ -34,6 +34,7 @@ import {
34
34
  refuseBothCaps,
35
35
  signalMoved,
36
36
  startDeferred,
37
+ unwatchStore,
37
38
  } from './Reactive.ts'
38
39
  import {
39
40
  detach,
@@ -46,6 +47,7 @@ import {
46
47
  type Tracked,
47
48
  trackProbe,
48
49
  } from './tracking.ts'
50
+ import { owned, owning } from './watch.ts'
49
51
 
50
52
  /**
51
53
  * The keyed face: one `Reactive` per `Args` key, plus the probes and triggers over its entries.
@@ -262,27 +264,22 @@ class StartWindow {
262
264
  }
263
265
 
264
266
  /**
265
- * `body` behind the `args` gate: validate, then run the body with what the gate accepted.
266
- *
267
- * 11.27's LAST TWO STEPS as one body. The gate runs on every run of the entry — a refresh
268
- * re-validates — and throws the refusal, which is what a body failing already does: the entry's
269
- * `error` holds it and its read throws it (2.2), and the body the gate stands in front of is never
270
- * called. What it accepts is what the body is handed (11.68).
267
+ * `body` handed what the `args` gate accepted for one entry (11.68), rather than the args it was
268
+ * reached with.
271
269
  *
272
270
  * A STREAM STAYS A STREAM FROM CONSTRUCTION. 9.6 reads the body's KIND, so the wrapper around an
273
271
  * async generator is an async generator, or a gated keyed stream would be one only from its first
274
272
  * run.
275
273
  */
276
- function gated<Args>(
274
+ function handed<Args>(
277
275
  body: (args: Args) => unknown,
278
- gate: Schema<Args>,
276
+ accepted: Args,
279
277
  ): (args: Args) => unknown {
280
- const check = (args: Args): Args => checkArgs(gate, args)
281
278
  if (isStreamBody(body))
282
- return async function* (args: Args) {
283
- yield* (body as (args: Args) => AsyncIterable<unknown>)(check(args))
279
+ return async function* () {
280
+ yield* (body as (args: Args) => AsyncIterable<unknown>)(accepted)
284
281
  }
285
- return (args: Args): unknown => body(check(args))
282
+ return (): unknown => body(accepted)
286
283
  }
287
284
 
288
285
  /**
@@ -348,7 +345,7 @@ export function createKeyed<
348
345
  (throttle ?? debounce) as number,
349
346
  )
350
347
  const lifetime = ttl === undefined ? null : new Lifetime(ttl)
351
- const entryBody = heldOnce(gate === undefined ? body : gated(body, gate))
348
+ const entryBody = heldOnce(body)
352
349
  // 11.27, 11.29 — the defaults are applied before the key is taken, so the entry is one for a
353
350
  // call that wrote them and one that left them to the parameter. Read once, here: a body with
354
351
  // none pays a `null` test per reach.
@@ -358,6 +355,12 @@ export function createKeyed<
358
355
  // there were, so the key that starts loading next — search-as-you-type — never woke it. One
359
356
  // signal per `Keyed`, moved where an entry is reached and where one is released.
360
357
  const membership = newTracked()
358
+ // 8.21 — A `Keyed` BUILT IN A SETUP OR A REQUEST ends with it, and every store its entries
359
+ // watch is stopped then: the entries are the cache's, so no owner of their own stops them.
360
+ if (options?.store !== undefined && owning())
361
+ owned(() => {
362
+ for (const entry of entries.values()) unwatchStore(entry)
363
+ })
361
364
 
362
365
  function askMembership(): void {
363
366
  const reader = TRACKING.reader
@@ -372,6 +375,7 @@ export function createKeyed<
372
375
  ): void {
373
376
  entries.delete(key)
374
377
  leaveSelections(created)
378
+ unwatchStore(created)
375
379
  signalMoved(membership)
376
380
  }
377
381
 
@@ -385,9 +389,32 @@ export function createKeyed<
385
389
  if (failedTransiently(standing)) standing.refresh()
386
390
  return standing
387
391
  }
392
+ // 11.27's VALIDATE, ONCE PER ENTRY AND BEFORE IT IS FILED: a refused `Args` is answered by a
393
+ // value holding the refusal (2.2) that no map keeps, so args nobody should have sent never
394
+ // take a place among the entries — on a `global` value, a place nothing but `invalidate`
395
+ // would take back (11.35). Built with none of the options: a `store` would restore a value
396
+ // over the refusal and watch for an entry nothing releases.
397
+ let run: (args: Args) => unknown = entryBody
398
+ if (gate !== undefined) {
399
+ let accepted: Args
400
+ try {
401
+ accepted = checkArgs(gate, args)
402
+ } catch (refusal) {
403
+ return createReactive<Stored, Accepted, Failures>(
404
+ `${label}(${key})`,
405
+ () => {
406
+ throw refusal
407
+ },
408
+ args,
409
+ undefined,
410
+ undefined,
411
+ )
412
+ }
413
+ run = handed(entryBody, accepted)
414
+ }
388
415
  const created = createReactive<Stored, Accepted, Failures>(
389
416
  `${label}(${key})`,
390
- entryBody as (args: unknown) => unknown,
417
+ run as (args: unknown) => unknown,
391
418
  args,
392
419
  undefined,
393
420
  entryOptions as ReactiveOptions<Accepted, Stored, Failures>,
@@ -75,6 +75,7 @@ export type Transformer<Accepted, Stored, Failures = never> = (
75
75
  export type Store<Stored> = {
76
76
  get: () => Stored | undefined | Promise<Stored | undefined>
77
77
  set: (value: Stored, retention: { ttl: number }) => void | Promise<void>
78
+ watch?: (changed: () => void) => () => void
78
79
  }
79
80
 
80
81
  /**
@@ -366,6 +367,8 @@ type Persisted = {
366
367
  * what `pending` sends next, and what 8.18 serves a re-read in place of calling `get`.
367
368
  */
368
369
  unconfirmed: unknown
370
+ /** What stops the store's `watch`, where it has one (8.21). */
371
+ unwatch: (() => void) | null
369
372
  }
370
373
 
371
374
  /**
@@ -891,6 +894,7 @@ class Cell {
891
894
  writing: false,
892
895
  pending: false,
893
896
  unconfirmed: undefined,
897
+ unwatch: null,
894
898
  }
895
899
  this.by = (options?.by ?? null) as
896
900
  | string
@@ -3801,6 +3805,32 @@ function rereadStore(cell: Cell, persisted: Persisted): void {
3801
3805
  )
3802
3806
  }
3803
3807
 
3808
+ /**
3809
+ * Follows what the store says changed outside this process, re-reading it each time.
3810
+ *
3811
+ * 8.20 — A RE-READ, NEVER THE BODY: an edit made where the value lives is the store's news, so it
3812
+ * is read back as a refresh of a value whose only producer is its store would read it, and 8.18
3813
+ * still has a write the app made stand over it. 8.21 — stopped with the value: by its owner here,
3814
+ * and by its `Keyed` through `unwatchStore` where an entry is evicted.
3815
+ */
3816
+ function watchStore(cell: Cell, persisted: Persisted, cached: boolean): void {
3817
+ const watch = persisted.store.watch
3818
+ if (watch === undefined) return
3819
+ persisted.unwatch = watch(() => {
3820
+ rereadStore(cell, persisted)
3821
+ })
3822
+ if (!cached && owning()) owned(() => unwatchStore(cell.callable))
3823
+ }
3824
+
3825
+ /** Stops the store's `watch` on `value`, once (8.21). */
3826
+ export function unwatchStore(value: unknown): void {
3827
+ const persisted = (value as Callable)[CELL].store
3828
+ if (persisted === null || persisted.unwatch === null) return
3829
+ const unwatch = persisted.unwatch
3830
+ persisted.unwatch = null
3831
+ unwatch()
3832
+ }
3833
+
3804
3834
  /**
3805
3835
  * Lands a store re-read, letting an unconfirmed write win.
3806
3836
  *
@@ -4693,8 +4723,10 @@ export function createReactive<
4693
4723
  // (2.2, 4.14) rather than serving an `undefined` its own type says is a `Stored`.
4694
4724
  // 8.3 AND 8.14: a store answers first, where the value is constructed, and whatever it misses
4695
4725
  // falls back to what the value would have done without one.
4696
- if (cell.store !== null) restoreFirst(cell, cell.store, initial)
4697
- else if (body === null) {
4726
+ if (cell.store !== null) {
4727
+ restoreFirst(cell, cell.store, initial)
4728
+ watchStore(cell, cell.store, cached)
4729
+ } else if (body === null) {
4698
4730
  if (initial === undefined) landOpening(cell)
4699
4731
  else if (initial !== NO_INITIAL) open(cell, initial)
4700
4732
  } else if (deferred) cell.loading = true
package/src/shared/rpc.ts CHANGED
@@ -16,6 +16,7 @@
16
16
  import { type Failed, isFailedNamed } from './Failed.ts'
17
17
  import { GLOBAL_GETS } from './GLOBAL_GETS.ts'
18
18
  import { HandlerRoute } from './HandlerRoute.ts'
19
+ import { IN_PROCESS_ORIGIN } from './IN_PROCESS_ORIGIN.ts'
19
20
  import { compose, type Middleware } from './Middleware.ts'
20
21
  import { principalRotated } from './principalRotated.ts'
21
22
  import { RPC_SERVER } from './RPC_SERVER.ts'
@@ -442,7 +443,7 @@ export function declare(method: Method): Declare {
442
443
  if (RPC_SERVER.raw !== null)
443
444
  return RPC_SERVER.raw(
444
445
  new Request(
445
- new URL(asked.url, 'http://app.internal'),
446
+ new URL(asked.url, IN_PROCESS_ORIGIN),
446
447
  given,
447
448
  ),
448
449
  )
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { spreadEntries } from '#shared/spread.ts'
6
6
  import type { Scope } from './Scope.ts'
7
+ import { addAttribute } from './setAttribute.ts'
7
8
 
8
9
  /** Keeps an element's spread attributes and `on<event>` listeners written as the value changes. */
9
10
  export function bindSpread(
@@ -19,8 +20,9 @@ export function bindSpread(
19
20
  const next = new Set<string>()
20
21
  for (const [name, text] of spreadEntries(read(), explicit, wanted)) {
21
22
  next.add(name)
22
- if (element.getAttribute(name) !== text)
23
- element.setAttribute(name, text)
23
+ const standing = element.getAttribute(name)
24
+ if (standing === null) addAttribute(element, name, text)
25
+ else if (standing !== text) element.setAttribute(name, text)
24
26
  }
25
27
  for (const name of written)
26
28
  if (!next.has(name)) element.removeAttribute(name)
package/src/ui/index.ts CHANGED
@@ -48,7 +48,7 @@ export { escapeless } from './escapeless.ts'
48
48
  export { contributeHead } from './Level.ts'
49
49
  export { listAt, updateList } from './List.ts'
50
50
  export { warnMismatch } from './Mismatch.ts'
51
- export { markupNodes } from './parseMarkup.ts'
51
+ export { markupNodes, namespaceUnder } from './parseMarkup.ts'
52
52
  export {
53
53
  fillEmpty,
54
54
  fillRegion,
@@ -58,7 +58,7 @@ export {
58
58
  export { rowKeyOf } from './rowKeyOf.ts'
59
59
  export { itemOf, noUpdate, replaceRow, rowCell } from './rows.ts'
60
60
  export { EMPTY_SCOPE, Scope } from './Scope.ts'
61
- export { setAttribute } from './setAttribute.ts'
61
+ export { addAttribute, setAttribute } from './setAttribute.ts'
62
62
  export { setClass } from './setClass.ts'
63
63
  export { setClassGroup } from './setClassGroup.ts'
64
64
  export { setMarkup } from './setMarkup.ts'
@@ -1,3 +1,8 @@
1
+ import {
2
+ HTML_NAMESPACE,
3
+ INTEGRATION_POINTS,
4
+ } from '#shared/INTEGRATION_POINTS.ts'
5
+
1
6
  // MARKUP PARSED IN THE DOCUMENT `parent` BELONGS TO, not the global one. A page mounted into a second
2
7
  // document (an example `<iframe>`) parsed its `{#if}` arms in the right one and its `{#for}` rows in
3
8
  // the page's while the two were separate copies of this; one copy is one answer.
@@ -9,26 +14,22 @@ const TEMPLATES = new WeakMap<Document, HTMLTemplateElement>()
9
14
  const SVG = 'http://www.w3.org/2000/svg'
10
15
  const MATHML = 'http://www.w3.org/1998/Math/MathML'
11
16
 
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
17
+ /**
18
+ * The namespace an element the parser makes inside `parent` takes: SVG's or MathML's under an
19
+ * element of either, and HTML's under anything else — an HTML element, a document, a fragment, and
20
+ * an integration point (`INTEGRATION_POINTS`), whose children the parser reads as HTML again.
21
+ */
22
+ export function namespaceUnder(parent: Node): string {
23
+ const element = parent as Element
24
+ const space = element.namespaceURI
25
+ if (space !== SVG && space !== MATHML) return HTML_NAMESPACE
26
+ if (INTEGRATION_POINTS.has(element.localName)) return HTML_NAMESPACE
28
27
  // The HTML standard makes an `<annotation-xml>` one only by the encoding it declares.
29
- if (element.localName !== 'annotation-xml') return false
28
+ if (element.localName !== 'annotation-xml') return space
30
29
  const encoding = element.getAttribute('encoding')?.toLowerCase()
31
30
  return encoding === 'text/html' || encoding === 'application/xhtml+xml'
31
+ ? HTML_NAMESPACE
32
+ : space
32
33
  }
33
34
 
34
35
  /**
@@ -48,14 +49,8 @@ export function parseMarkup(parent: Node, markup: string): DocumentFragment {
48
49
  template = document.createElement('template')
49
50
  TEMPLATES.set(document, template)
50
51
  }
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'
52
+ const space = namespaceUnder(parent)
53
+ const wrapper = space === HTML_NAMESPACE ? undefined : space === SVG ? 'svg' : 'math'
59
54
  if (wrapper === undefined) {
60
55
  template.innerHTML = markup
61
56
  return template.content
@@ -1,4 +1,6 @@
1
1
  import { attributeText } from '#shared/attributeText.ts'
2
+ import { FOREIGN_ATTRIBUTES } from '#shared/FOREIGN_ATTRIBUTES.ts'
3
+ import { HTML_NAMESPACE } from '#shared/INTEGRATION_POINTS.ts'
2
4
  import { WORK } from '#shared/WORK.ts'
3
5
 
4
6
  /**
@@ -33,5 +35,27 @@ export function setAttribute(
33
35
  if (element.hasAttribute(name)) element.removeAttribute(name)
34
36
  return
35
37
  }
36
- if (element.getAttribute(name) !== next) element.setAttribute(name, next)
38
+ const standing = element.getAttribute(name)
39
+ if (standing === next) return
40
+ if (standing === null) addAttribute(element, name, next)
41
+ else element.setAttribute(name, next)
42
+ }
43
+
44
+ /**
45
+ * Writes an attribute `element` does not hold yet, in the namespace the parser gives it there.
46
+ *
47
+ * 32.70 — an `xlink:href` on an SVG element is XLink's (`FOREIGN_ATTRIBUTES`), and one
48
+ * `setAttribute` makes has none, so a `<use>` it names draws nothing. Only an attribute's CREATION
49
+ * decides its namespace: `setAttribute` and `getAttribute` find a standing one by its qualified
50
+ * name, whatever namespace it is in.
51
+ */
52
+ export function addAttribute(
53
+ element: Element,
54
+ name: string,
55
+ text: string,
56
+ ): void {
57
+ const space = FOREIGN_ATTRIBUTES.get(name)
58
+ if (space === undefined || element.namespaceURI === HTML_NAMESPACE)
59
+ element.setAttribute(name, text)
60
+ else element.setAttributeNS(space, name, text)
37
61
  }