@abide/abide 0.105.1 → 0.106.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.
Files changed (99) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/bin/abide.ts +4 -1
  3. package/docs/REGISTRY.md +156 -44
  4. package/docs/RULEBOOK.md +375 -87
  5. package/docs/abide.md +7243 -10256
  6. package/package.json +4 -1
  7. package/scaffold/AGENTS.md +2 -0
  8. package/scaffold/src/getGreeting.ts +6 -0
  9. package/scaffold/src/index.abide +3 -1
  10. package/scaffold/src/tests/getGreeting.test.ts +6 -0
  11. package/scaffold/src/tests/index.test.ts +18 -0
  12. package/src/compiler/DOCS_SITE.ts +2 -0
  13. package/src/compiler/Lowering.ts +234 -14
  14. package/src/compiler/PositionTable.ts +14 -7
  15. package/src/compiler/PositionedRefusal.ts +13 -0
  16. package/src/compiler/Refusals.ts +18 -11
  17. package/src/compiler/SHARED_NAMES.ts +2 -0
  18. package/src/compiler/ShadowMap.ts +37 -0
  19. package/src/compiler/TEMPLATE_BLOCKS.ts +12 -0
  20. package/src/compiler/TypeSession.ts +410 -31
  21. package/src/compiler/appLayout.ts +18 -2
  22. package/src/compiler/argsSchemaOf.ts +55 -9
  23. package/src/compiler/checkApp.ts +33 -0
  24. package/src/compiler/checkedShadow.ts +36 -22
  25. package/src/compiler/citedClauses.ts +14 -0
  26. package/src/compiler/classifyRead.ts +6 -0
  27. package/src/compiler/classifyTemplate.ts +2 -1
  28. package/src/compiler/compileApp.ts +2 -2
  29. package/src/compiler/declaredExports.ts +27 -6
  30. package/src/compiler/emitClient.ts +304 -103
  31. package/src/compiler/handlersUnder.ts +4 -0
  32. package/src/compiler/invocationProps.ts +1 -1
  33. package/src/compiler/isIgnored.ts +27 -0
  34. package/src/compiler/parseAbide.ts +176 -76
  35. package/src/compiler/projectShadow.ts +30 -6
  36. package/src/compiler/readSources.ts +1 -1
  37. package/src/compiler/readsIn.ts +100 -1
  38. package/src/compiler/renderMarkup.ts +36 -5
  39. package/src/compiler/scaffold.ts +19 -6
  40. package/src/compiler/serveLanguage.ts +156 -21
  41. package/src/compiler/templateContext.ts +150 -0
  42. package/src/compiler/tokenize.ts +6 -0
  43. package/src/server/BRIDGE_VERSION.ts +1 -1
  44. package/src/server/Inbox.ts +8 -0
  45. package/src/server/MCP_PROTOCOL_VERSION.ts +1 -1
  46. package/src/server/MOUNTABLE.ts +6 -0
  47. package/src/server/abideModules.ts +8 -1
  48. package/src/server/answerRequest.ts +3 -1
  49. package/src/server/answerRpc.ts +26 -12
  50. package/src/server/answerSocket.ts +26 -1
  51. package/src/server/argsFor.ts +4 -3
  52. package/src/server/assistant.ts +19 -13
  53. package/src/server/bridgeRelay.ts +108 -20
  54. package/src/server/claudeApi.ts +7 -7
  55. package/src/server/claudeCode.ts +11 -11
  56. package/src/server/commands.ts +73 -9
  57. package/src/server/createApp.ts +41 -7
  58. package/src/server/devWorker.ts +3 -3
  59. package/src/server/generatedSurfaces.ts +37 -4
  60. package/src/server/muxUpgrade.ts +47 -0
  61. package/src/server/preload.ts +13 -2
  62. package/src/server/render.ts +4 -1
  63. package/src/server/runCompiled.ts +1 -0
  64. package/src/server/seedBuffer.ts +31 -1
  65. package/src/server/serveDev.ts +83 -72
  66. package/src/server/surfaceOf.ts +3 -0
  67. package/src/server/testMount.ts +53 -0
  68. package/src/shared/ADDRESSES.ts +1 -1
  69. package/src/shared/ERROR_META.ts +3 -0
  70. package/src/shared/Schema.ts +1 -1
  71. package/src/shared/WORK.ts +14 -12
  72. package/src/shared/applyPatch.ts +1 -1
  73. package/src/shared/boundValue.ts +81 -0
  74. package/src/shared/index.ts +7 -2
  75. package/src/shared/jsonSchema.ts +25 -2
  76. package/src/shared/log.ts +11 -1
  77. package/src/shared/reactive/Keyed.ts +30 -6
  78. package/src/shared/reactive/PATCHES.ts +1 -1
  79. package/src/shared/reactive/Reactive.ts +118 -60
  80. package/src/shared/reactive/canonicalKey.ts +1 -1
  81. package/src/shared/reactive/index.ts +13 -5
  82. package/src/shared/reactive/recordWrites.ts +1 -1
  83. package/src/shared/reactive/watch.ts +26 -4
  84. package/src/shared/rpc.ts +6 -1
  85. package/src/shared/url.ts +7 -3
  86. package/src/shared/validateJson.ts +1 -1
  87. package/src/ui/ArmRegion.ts +2 -2
  88. package/src/ui/List.ts +9 -9
  89. package/src/ui/RawRun.ts +1 -1
  90. package/src/ui/Router.ts +194 -36
  91. package/src/ui/Scope.ts +29 -0
  92. package/src/ui/adoptWalk.ts +3 -3
  93. package/src/ui/applyHead.ts +25 -3
  94. package/src/ui/bindProperty.ts +70 -22
  95. package/src/ui/index.ts +1 -1
  96. package/src/ui/listenForRoutes.ts +14 -3
  97. package/src/ui/reconcile.ts +23 -28
  98. package/src/ui/regions.ts +9 -2
  99. package/src/ui/rows.ts +108 -28
@@ -31,7 +31,7 @@ import { DEV_ADDRESS } from '#shared/ADDRESSES.ts'
31
31
  import type { Compilation } from './abideModules.ts'
32
32
  import { DEFAULT_PORT } from './DEFAULT_PORT.ts'
33
33
  import type { FromWorker, ToWorker } from './devWorker.ts'
34
- import { upgraded } from './muxUpgrade.ts'
34
+ import { muxSocketOptions, sendOrClose, upgraded } from './muxUpgrade.ts'
35
35
  import { placeOf } from './placement.ts'
36
36
  import { plainAnswer } from './plainAnswer.ts'
37
37
  import { askOver } from './responseRelay.ts'
@@ -79,7 +79,7 @@ export async function serveDev(
79
79
  // 38.24, 38.35 — every request, asked of the worker there is and streamed back a chunk per read.
80
80
  // 16.22 — a call's idle timeout raised on the request bun handed this listener.
81
81
  const relay = askOver((request, seconds) =>
82
- server.timeout(request, seconds),
82
+ served?.timeout(request, seconds),
83
83
  )
84
84
  // Every socket this listener holds: the reload client's, with no data, and the app mux's, carrying
85
85
  // the connection id the worker knows it by.
@@ -88,6 +88,7 @@ export async function serveDev(
88
88
  let nextConnection = 0
89
89
  let worker: DevWorker | undefined
90
90
  let ready = false
91
+ let served: Bun.Server<Carried> | undefined
91
92
 
92
93
  // ONE COMPILE AT A TIME, and the worker is started from the LAST one. A save landing while a
93
94
  // compile runs marks it stale rather than starting a second: two in flight each started a
@@ -201,9 +202,12 @@ export async function serveDev(
201
202
  stdio: ['inherit', 'inherit', 'inherit'],
202
203
  serialization: 'advanced',
203
204
  ipc(message: FromWorker) {
204
- if (message.kind === 'ready') ready = true
205
- else if (message.kind === 'socket-send')
206
- muxed.get(message.connection)?.send(message.text)
205
+ if (message.kind === 'ready') {
206
+ ready = true
207
+ muxOptions = message.muxOptions
208
+ served?.reload(serving(undefined))
209
+ } else if (message.kind === 'socket-send')
210
+ sendOrClose(muxed.get(message.connection), message.text)
207
211
  else relay.take(message)
208
212
  },
209
213
  // 38.41 — AN APP THAT EXITS ON ITS OWN is replaced, and says so: a listener still
@@ -290,86 +294,93 @@ export async function serveDev(
290
294
  void closeChecker().finally(() => process.kill(process.pid, signal))
291
295
  })
292
296
 
293
- const listen = (port: number | undefined) =>
294
- Bun.serve<Carried>({
295
- ...(port === undefined ? {} : { port }),
296
- // 16.54, as `App.listen` lifts it: the worker behind this is where the ceiling is read.
297
- maxRequestBodySize: Number.MAX_SAFE_INTEGER,
298
- async fetch(request, listening) {
299
- const asked = new URL(request.url).pathname
300
- // THE MUX STANDS UNDER THE MOUNT, which is the app's configuration and so the worker's to
301
- // read: every upgrade but the reload client's is asked of it, and the worker says
302
- // whether it is the mux's (`muxUpgrade`) — a status alone is not, since any rung at any
303
- // address can answer one.
304
- const mux =
305
- asked !== DEV_ADDRESS &&
306
- request.headers.get('upgrade') === 'websocket'
307
- if (!mux && asked !== DEV_ADDRESS) return relay.ask(request)
308
- // 37.3 — THE MUX'S UPGRADE IS ASKED OF THE APP FIRST, whose lane runs in the worker.
309
- if (mux && ready) {
310
- const answered = await relay.ask(request)
311
- if (!relay.admitted(answered)) return answered
297
+ // 18.28 — THE MUX'S LIMITS ARE THE APP'S CONFIGURATION, and so the worker's to resolve: each
298
+ // worker reports them on `ready` and the listener is reloaded with them, or `abide dev` would
299
+ // hold a socket to a limit `onConfig` moved in production and not here. Bun applies a reload's
300
+ // `maxPayloadLength` to every connection opened after it.
301
+ let muxOptions = muxSocketOptions()
302
+ const serving = (port: number | undefined): Bun.Serve.Options<Carried> => ({
303
+ ...(port === undefined ? {} : { port }),
304
+ // 16.54, as `App.listen` lifts it: the worker behind this is where the ceiling is read.
305
+ maxRequestBodySize: Number.MAX_SAFE_INTEGER,
306
+ async fetch(request, listening) {
307
+ const asked = new URL(request.url).pathname
308
+ // THE MUX STANDS UNDER THE MOUNT, which is the app's configuration and so the worker's to
309
+ // read: every upgrade but the reload client's is asked of it, and the worker says
310
+ // whether it is the mux's (`muxUpgrade`) — a status alone is not, since any rung at any
311
+ // address can answer one.
312
+ const mux =
313
+ asked !== DEV_ADDRESS &&
314
+ request.headers.get('upgrade') === 'websocket'
315
+ if (!mux && asked !== DEV_ADDRESS) return relay.ask(request)
316
+ // 37.3 — THE MUX'S UPGRADE IS ASKED OF THE APP FIRST, whose lane runs in the worker.
317
+ if (mux && ready) {
318
+ const answered = await relay.ask(request)
319
+ if (!relay.admitted(answered)) return answered
320
+ }
321
+ // NOT WHILE THE APP IS COMPILING. The reload client and the browser's mux both read a
322
+ // refusal as "still rebuilding" and retry, which is what keeps a reload from racing
323
+ // the worker it is meant to show.
324
+ if (!ready) return plainAnswer('rebuilding', 503)
325
+ let data: Carried
326
+ if (mux) {
327
+ nextConnection += 1
328
+ data = {
329
+ connection: nextConnection,
330
+ url: request.url,
331
+ headers: [...request.headers.entries()],
312
332
  }
313
- // NOT WHILE THE APP IS COMPILING. The reload client and the browser's mux both read a
314
- // refusal as "still rebuilding" and retry, which is what keeps a reload from racing
315
- // the worker it is meant to show.
316
- if (!ready) return plainAnswer('rebuilding', 503)
317
- let data: Carried
318
- if (mux) {
319
- nextConnection += 1
320
- data = {
321
- connection: nextConnection,
322
- url: request.url,
323
- headers: [...request.headers.entries()],
324
- }
333
+ }
334
+ return upgraded(listening, request, data)
335
+ },
336
+ websocket: {
337
+ ...muxOptions,
338
+ open(socket) {
339
+ // A RELOAD LANDED between the upgrade and this open: the worker it was meant for is
340
+ // gone and the next one would never hear of it, so it is closed and retried instead.
341
+ if (!ready) {
342
+ socket.terminate()
343
+ return
325
344
  }
326
- return upgraded(listening, request, data)
345
+ sockets.add(socket)
346
+ const carried = socket.data
347
+ if (carried === undefined) return
348
+ muxed.set(carried.connection, socket)
349
+ worker?.postMessage({ kind: 'socket-open', ...carried })
327
350
  },
328
- websocket: {
329
- open(socket) {
330
- // A RELOAD LANDED between the upgrade and this open: the worker it was meant for is
331
- // gone and the next one would never hear of it, so it is closed and retried instead.
332
- if (!ready) {
333
- socket.terminate()
334
- return
335
- }
336
- sockets.add(socket)
337
- const carried = socket.data
338
- if (carried === undefined) return
339
- muxed.set(carried.connection, socket)
340
- worker?.postMessage({ kind: 'socket-open', ...carried })
341
- },
342
- close(socket) {
343
- sockets.delete(socket)
344
- const carried = socket.data
345
- if (carried === undefined) return
346
- // A socket a reload terminated is the old worker's, and the new one never knew it.
347
- if (muxed.delete(carried.connection))
348
- worker?.postMessage({
349
- kind: 'socket-close',
350
- connection: carried.connection,
351
- })
352
- },
353
- // The reload client says nothing; an app socket's frames go to the mux in the worker.
354
- message(socket, text) {
355
- const carried = socket.data
356
- if (carried === undefined) return
351
+ close(socket) {
352
+ sockets.delete(socket)
353
+ const carried = socket.data
354
+ if (carried === undefined) return
355
+ // A socket a reload terminated is the old worker's, and the new one never knew it.
356
+ if (muxed.delete(carried.connection))
357
357
  worker?.postMessage({
358
- kind: 'socket-message',
358
+ kind: 'socket-close',
359
359
  connection: carried.connection,
360
- text: String(text),
361
360
  })
362
- },
363
361
  },
364
- })
362
+ // The reload client says nothing; an app socket's frames go to the mux in the worker.
363
+ message(socket, text) {
364
+ const carried = socket.data
365
+ if (carried === undefined) return
366
+ worker?.postMessage({
367
+ kind: 'socket-message',
368
+ connection: carried.connection,
369
+ text: String(text),
370
+ })
371
+ },
372
+ },
373
+ })
374
+ const listen = (port: number | undefined) =>
375
+ Bun.serve<Carried>(serving(port))
365
376
 
366
377
  // 38.64 — A PORT SOMEONE NAMED IS THE PORT, and one taken is the failure. `Bun.serve` given
367
378
  // none reads `PORT` itself.
368
- const server =
379
+ served =
369
380
  options.port !== undefined || process.env.PORT
370
381
  ? listen(options.port)
371
382
  : listenOnFirstFree(listen)
372
- return server as unknown as Bun.Server<undefined>
383
+ return served as unknown as Bun.Server<undefined>
373
384
  }
374
385
 
375
386
  // 38.63 — NAMED BY NOBODY, the first free port from 3000. A port taken is the next one tried, and
@@ -15,6 +15,8 @@ export type Listed = {
15
15
  description: string | undefined
16
16
  /** Its args' JSON Schema, where it has one. 19.9, 20.11. */
17
17
  schema: unknown
18
+ /** What it answers, as JSON Schema, where its type says. 19.21. */
19
+ result: unknown
18
20
  }
19
21
 
20
22
  /**
@@ -47,6 +49,7 @@ export function surfaceOf(
47
49
  description:
48
50
  live === undefined ? mount.description : live.handler.description,
49
51
  schema: live === undefined ? mount.args : live.argsDocument,
52
+ result: mount.result,
50
53
  }
51
54
  }
52
55
 
@@ -0,0 +1,53 @@
1
+ // 38.119 — `mount` from `@abide/abide/test`: a component an app's test imported and bound, rendered
2
+ // into the document's body as the server writes it and hydrated there by its browser module — the
3
+ // same two halves a page load runs, in one process, so a test can click it and read the work.
4
+
5
+ import { flushSync } from '#shared/reactive/Reactive.ts'
6
+ import { MOUNTABLE } from './MOUNTABLE.ts'
7
+ import { type Component, render } from './render.ts'
8
+
9
+ type Mountable = {
10
+ accessors: Record<string, () => unknown>
11
+ client: () => Promise<{
12
+ default: (
13
+ region: unknown,
14
+ props: Record<string, () => unknown>,
15
+ ) => { stop(): void } | undefined
16
+ }>
17
+ }
18
+
19
+ /** A mounted component: `stop` ends every watch its hydration opened. */
20
+ export type Mounted = { stop(): void; [Symbol.dispose](): void }
21
+
22
+ /**
23
+ * Renders `component` into `document.body` and hydrates it there, with every effect its mount woke
24
+ * already run.
25
+ */
26
+ export async function mount(component: Component): Promise<Mounted> {
27
+ const mountable = (component as Record<symbol, Mountable | undefined>)[
28
+ MOUNTABLE
29
+ ]
30
+ if (mountable === undefined)
31
+ throw new Error(
32
+ 'abide: mount takes a component a test imported from a `.abide` and bound — `mount(Counter({ start: 2 }))`. RULEBOOK 38.119.',
33
+ )
34
+ if (typeof document === 'undefined')
35
+ throw new Error(
36
+ 'abide: mount renders into a DOM, and this process has none. Preload `@abide/harness/preload` ahead of `@abide/abide/test` in bunfig.toml. RULEBOOK 38.119.',
37
+ )
38
+ const html = new TextDecoder().decode(await render(component))
39
+ // After the `<body …>` open tag, whatever attributes the component wrote onto it (35.15).
40
+ document.body.innerHTML = html.slice(
41
+ html.indexOf('>', html.indexOf('<body')) + 1,
42
+ html.indexOf('</body>'),
43
+ )
44
+ // LOADED HERE, not at the top: the preload this module is part of runs in processes with no DOM.
45
+ const [{ default: hydrate }, { pageRegion }] = await Promise.all([
46
+ mountable.client(),
47
+ import('#ui/regions.ts'),
48
+ ])
49
+ const scope = hydrate(pageRegion(document.body), mountable.accessors)
50
+ flushSync()
51
+ const stop = (): void => scope?.stop()
52
+ return { stop, [Symbol.dispose]: stop }
53
+ }
@@ -35,7 +35,7 @@ export const OPENAPI_ADDRESS = `${ABIDE_ADDRESS}/openapi.json`
35
35
  export const MCP_ADDRESS = `${ABIDE_ADDRESS}/mcp`
36
36
  /** The health check. */
37
37
  export const HEALTH_ADDRESS = `${ABIDE_ADDRESS}/health`
38
- /** Where a visitor's Claude Code bridge pairs, and is relayed turns (45.13). */
38
+ /** Where a visitor's Claude Code bridge pairs, and is relayed turns (44.13). */
39
39
  export const BRIDGE_ADDRESS = `${ABIDE_ADDRESS}/bridge`
40
40
 
41
41
  /**
@@ -0,0 +1,3 @@
1
+ // 35.18 — the meta an error page's document carries, so the browser does not hydrate the page its
2
+ // address names over markup an error page painted.
3
+ export const ERROR_META = 'abide-error'
@@ -161,7 +161,7 @@ function runSchemaTracked(schema: unknown, value: unknown): Checked {
161
161
  // rather than returning it. A real `Schema<boolean>` only ever sees booleans.
162
162
  if (typeof returned === 'boolean' && typeof value !== 'boolean')
163
163
  throw new Error(
164
- 'A predicate is not a Schema: it answered true/false where the value was expected. A Schema returns what it accepts (RULEBOOK 19.2) and throws what it refuses (19.4).',
164
+ 'A predicate is not a Schema: it answered true/false where the value was expected. A Schema returns what it accepts (RULEBOOK 19.2) and throws what it refuses (RULEBOOK 19.4).',
165
165
  )
166
166
  return accepted(returned)
167
167
  }
@@ -1,14 +1,11 @@
1
1
  /**
2
2
  * The three work counters the measurement lane reads, and the record they are published on.
3
3
  *
4
- * A leaf — no imports of its own — because both seams increment it and `harness/measure` reads it
5
- * off `globalThis` rather than importing it: that lane may have no abide in its graph at all
6
- * (44.1), so the two halves cannot share a declaration and the key is spelled on both sides.
7
- *
8
- * WHY ALL THREE OR NONE. `armCase()` zeroes every field it reads, so a field abide declares and
9
- * never increments is created at 0 and read back at 0 — and every gate asserting it passes
10
- * against a counter that was never wired (44.21). Publishing two of three is worse than
11
- * publishing none, because the third looks measured.
4
+ * A leaf — no imports of its own — because both seams increment it and `@abide/harness/measure`
5
+ * reads it off `globalThis` rather than importing it: that lane may have nothing under test in its
6
+ * graph at all (43.1), so the two halves cannot share a declaration and the key is spelled on both
7
+ * sides. `@abide/harness/abide`'s `measure` reads it back by these field names, and throws naming
8
+ * one the record lacks (43.20).
12
9
  *
13
10
  * WHAT IT COSTS is one integer increment on the propagation walk and one per effect run, and it
14
11
  * is UNDER THE NOISE FLOOR rather than priced: two runs of `bun run bench:reactive` put the
@@ -31,7 +28,12 @@ export const WORK = {
31
28
  descents: 0,
32
29
  }
33
30
 
34
- // The name is spelled here and again in `harness/measure`'s own leaf. Two copies on purpose: the
35
- // lane checks the SHAPE at the read rather than sharing a type, so a rename breaks a test rather
36
- // than silently publishing to a key nothing looks at.
37
- ;(globalThis as unknown as Record<string, unknown>).__ABIDE_WORK__ = WORK
31
+ // The key is spelled here and again in the harness's own leaf. Two copies on purpose: the lane
32
+ // checks the SHAPE at the read rather than sharing a type, so a rename breaks a test rather than
33
+ // silently publishing to a key nothing looks at.
34
+ type HarnessGlobal = {
35
+ __HARNESS_WORK__?: Record<string, Record<string, number>>
36
+ }
37
+ const published = (globalThis as HarnessGlobal).__HARNESS_WORK__ ?? {}
38
+ published.abide = WORK
39
+ ;(globalThis as HarnessGlobal).__HARNESS_WORK__ = published
@@ -1,6 +1,6 @@
1
1
  // 18.11 — WHAT A PATCH WROTE, WRITTEN AGAIN HERE: a JSON Patch (RFC 6902) applied in order, each op's
2
2
  // `path` a JSON Pointer (RFC 6901). Called inside the value's own `s.patch`, so the rows it reaches are
3
- // the rows 32.33 remakes. A browser replays the server's writes.
3
+ // the rows 32.33 updates. A browser replays the server's writes.
4
4
  //
5
5
  // A pointer walks own properties alone, and an array step is an index or, for `add`, `-`. Every op a
6
6
  // patch records passes, so the check costs a browser replaying its server nothing, and an op naming
@@ -56,3 +56,84 @@ export function boundProp(hold: () => unknown): (held?: true) => unknown {
56
56
  export function groupHolds(group: unknown, value: unknown): boolean {
57
57
  return Array.isArray(group) ? group.includes(value) : group === value
58
58
  }
59
+
60
+ /**
61
+ * Whether a `<select>`'s bound value selects an option: it is the option's value, or a list
62
+ * holding it.
63
+ *
64
+ * 32.61, 32.62 — compared as TEXT, unlike `groupHolds`: a select's value is written to the
65
+ * property as its text, so a number selects the option whose value spells it.
66
+ */
67
+ export function selectHolds(value: unknown, option: string): boolean {
68
+ if (!Array.isArray(value))
69
+ return value !== undefined && value !== null && String(value) === option
70
+ for (const member of value) if (String(member) === option) return true
71
+ return false
72
+ }
73
+
74
+ // An `<option` open tag: the name, then attributes up to the `>` that ends it, a quoted value
75
+ // carrying any `>` it holds.
76
+ const OPTION_TAG = /<option(?=[\s>])((?:[^>"']|"[^"]*"|'[^']*')*)>/gi
77
+ const VALUE_ATTRIBUTE = /\svalue\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/i
78
+ // What ends an option's text where it has no `value`: the next tag that is not a comment.
79
+ const OPTION_TEXT_END = /<(?!!--)/
80
+ const COMMENT = /<!--[\s\S]*?-->/g
81
+ const REFERENCE = /&(?:#(\d+)|#x([\da-f]+)|(amp|lt|gt|quot|apos));/gi
82
+ const NAMED: Record<string, string> = {
83
+ amp: '&',
84
+ lt: '<',
85
+ gt: '>',
86
+ quot: '"',
87
+ apos: "'",
88
+ }
89
+
90
+ /**
91
+ * A `<select>`'s rendered options with `selected` written on each its bound value selects.
92
+ *
93
+ * 32.61 — THE SERVER'S HALF OF THE PROPERTY: a select's value is no attribute of its own, so the
94
+ * painted page carries it on the options. Read off the markup rather than the template, because an
95
+ * option may come from a `{#for}`, an arm or a component, and every one of them has rendered by
96
+ * now. A single select marks the first match, which is the one assigning `value` selects.
97
+ */
98
+ export function selectedMarkup(markup: string, value: unknown): string {
99
+ if (value === undefined || value === null) return markup
100
+ const multiple = Array.isArray(value)
101
+ let out = ''
102
+ let cut = 0
103
+ let marked = false
104
+ for (const match of markup.matchAll(OPTION_TAG)) {
105
+ if (marked && !multiple) break
106
+ const end = (match.index as number) + match[0].length
107
+ const named = VALUE_ATTRIBUTE.exec(match[1] as string)
108
+ let text: string
109
+ if (named !== null)
110
+ text = decoded(named[1] ?? named[2] ?? named[3] ?? '')
111
+ else {
112
+ // The option's text, as the browser reads `value` from it: comments out,
113
+ // whitespace collapsed.
114
+ const rest = markup.slice(end)
115
+ const stop = rest.search(OPTION_TEXT_END)
116
+ text = decoded(
117
+ (stop === -1 ? rest : rest.slice(0, stop)).replace(COMMENT, ''),
118
+ )
119
+ .replace(/\s+/g, ' ')
120
+ .trim()
121
+ }
122
+ if (!selectHolds(value, text)) continue
123
+ out += `${markup.slice(cut, end - 1)} selected>`
124
+ cut = end
125
+ marked = true
126
+ }
127
+ return cut === 0 ? markup : out + markup.slice(cut)
128
+ }
129
+
130
+ function decoded(text: string): string {
131
+ if (!text.includes('&')) return text
132
+ return text.replace(REFERENCE, (whole, decimal, hex, name) =>
133
+ decimal !== undefined
134
+ ? String.fromCodePoint(Number(decimal))
135
+ : hex !== undefined
136
+ ? String.fromCodePoint(Number.parseInt(hex, 16))
137
+ : (NAMED[(name as string).toLowerCase()] ?? whole),
138
+ )
139
+ }
@@ -15,7 +15,12 @@ export type { Attributes } from './Attributes.ts'
15
15
  // 32.37 — what a value writes into an attribute, on both sides.
16
16
  export { attributeMarkup, attributeText } from './attributeText.ts'
17
17
  // EMITTED CODE'S: the value a `bind:` operand holds, as the server writes it into the tag (32.5).
18
- export { boundProp, boundValue, groupHolds } from './boundValue.ts'
18
+ export {
19
+ boundProp,
20
+ boundValue,
21
+ groupHolds,
22
+ selectedMarkup,
23
+ } from './boundValue.ts'
19
24
  export { type Cookies, cookies } from './cookies.ts'
20
25
  export { csp } from './csp.ts'
21
26
  export {
@@ -43,7 +48,6 @@ export {
43
48
  type MenuRole,
44
49
  menuItem,
45
50
  } from './menuItem.ts'
46
- export { onMenu } from './onMenu.ts'
47
51
  // `navigate` ALONE, of everything routing needs. This barrel is what an app resolves through the
48
52
  // bare `@abide/abide` specifier, and the matcher, the pattern grammar and `routeOf` are reached by path
49
53
  // from the two places that have them — the build and the client router. Putting them here would
@@ -51,6 +55,7 @@ export { onMenu } from './onMenu.ts'
51
55
  // modules into the static closure of the entry every page imports.
52
56
  export { navigate } from './navigate.ts'
53
57
  export { online } from './online.ts'
58
+ export { onMenu } from './onMenu.ts'
54
59
  export { type Principal, principal } from './principal.ts'
55
60
  // EMITTED CODE'S: a prop the child writes (33.6, 33.7).
56
61
  export {
@@ -14,8 +14,17 @@ import { validateJson } from './validateJson.ts'
14
14
  * `validateJson`'s own, split at the dots `runSchema` joins it back at. And the document is
15
15
  * exposed where a Standard Schema exposes its JSON Schema, so 20.11's one probe publishes it with
16
16
  * no case of its own.
17
+ *
18
+ * 19.23 — WITH NO TYPE ARGUMENT IT IS `Untyped`, whose `validate` answers `any`: assignable to every
19
+ * slot, and a source the checker draws NO candidate from. A document says nothing the checker can
20
+ * read, so the value's type has to come from what the schema gates. Typed `StandardSchemaV1<never>`
21
+ * instead, `never` was a candidate nested inside the schema, and a candidate that is not top level
22
+ * stops a literal widening: `reactive(0, { schema })` was `Reactive<0>`. Typed
23
+ * `StandardSchemaV1<any>`, `any` was a candidate and every gated value became `any`.
17
24
  */
18
- export function jsonSchema<T>(document: JsonValue): StandardSchemaV1<T> {
25
+ export function jsonSchema<T = Untyped>(
26
+ document: JsonValue,
27
+ ): [T] extends [Untyped] ? UntypedSchema : StandardSchemaV1<T> {
19
28
  return {
20
29
  '~standard': {
21
30
  version: 1,
@@ -46,5 +55,19 @@ export function jsonSchema<T>(document: JsonValue): StandardSchemaV1<T> {
46
55
  output: () => document,
47
56
  },
48
57
  },
49
- } as StandardSchemaV1<T>
58
+ } as [T] extends [Untyped] ? UntypedSchema : StandardSchemaV1<T>
59
+ }
60
+
61
+ declare const UNTYPED: unique symbol
62
+
63
+ /** The type argument a bare `jsonSchema` takes: no output was named. */
64
+ type Untyped = typeof UNTYPED
65
+
66
+ /** A Standard Schema whose output is whatever the slot it gates says (19.23). */
67
+ type UntypedSchema = {
68
+ readonly '~standard': {
69
+ readonly version: 1
70
+ readonly vendor: string
71
+ readonly validate: (value: unknown) => any
72
+ }
50
73
  }
package/src/shared/log.ts CHANGED
@@ -144,6 +144,14 @@ function currentGate(): Gate {
144
144
  return gate
145
145
  }
146
146
 
147
+ /**
148
+ * Whether `DEBUG` could enable any namespace at all: with no inclusion nothing passes, so a gated
149
+ * line returns before its namespace is built (28.12 still reads it on every line that can write).
150
+ */
151
+ function anyEnabled(): boolean {
152
+ return currentGate().include.length !== 0
153
+ }
154
+
147
155
  /** Whether `DEBUG` enables a namespace's gated lines (28.6). */
148
156
  function isEnabled(namespace: string): boolean {
149
157
  const { include, exclude } = currentGate()
@@ -247,10 +255,12 @@ function loggerOver(namespaceOf: () => string): Logger {
247
255
  write(namespaceOf(), 'info', args)
248
256
  }) as Logger
249
257
  logger.info = (...args) => {
258
+ if (!anyEnabled()) return
250
259
  const namespace = namespaceOf()
251
260
  if (isEnabled(namespace)) write(namespace, 'info', args)
252
261
  }
253
262
  logger.debug = (...args) => {
263
+ if (!anyEnabled()) return
254
264
  const namespace = namespaceOf()
255
265
  if (isEnabled(namespace)) write(namespace, 'debug', args)
256
266
  }
@@ -259,7 +269,7 @@ function loggerOver(namespaceOf: () => string): Logger {
259
269
  // 28.4 — another segment, below this one. 28.12 — asked per line too, so a logger named at module
260
270
  // load, before the app's name resolved, writes under the name once it has.
261
271
  logger.named = (name) => loggerOver(() => `${namespaceOf()}:${name}`)
262
- logger.enabled = () => isEnabled(namespaceOf())
272
+ logger.enabled = () => anyEnabled() && isEnabled(namespaceOf())
263
273
  Object.defineProperty(logger, 'records', { get: recordsValue })
264
274
  return logger
265
275
  }
@@ -107,7 +107,7 @@ type Follower = {
107
107
  * RECENCY IS THE ORDER OF A MAP. A production moves its key to the end by deleting and re-setting
108
108
  * it, so the oldest entry is always the first one and 11.48's "armed at the oldest" is one
109
109
  * iterator step rather than a scan — and the sweep stops at the first entry still inside its
110
- * life, the way `dropExpired` stops at the first live production.
110
+ * life.
111
111
  *
112
112
  * THE PRODUCTION IS HEARD BY A MIRROR ON EACH ENTRY, which is what the refusal that stood here
113
113
  * said could not be had without a hook on every cell in the design. A mirror is a reader, so only
@@ -117,6 +117,13 @@ type Follower = {
117
117
  * those apart: every one of them leaves nothing to serve, where a production always leaves
118
118
  * something.
119
119
  */
120
+ function end(follower: Follower): void {
121
+ follower.reader.alive = false
122
+ dropReader(follower.cell, follower.reader)
123
+ detachProbes(follower.reader)
124
+ follower.evict()
125
+ }
126
+
120
127
  class Lifetime {
121
128
  ms: number
122
129
  followed: Map<string, Follower>
@@ -178,13 +185,30 @@ class Lifetime {
178
185
  // 11.69 — PARKED, NOT EVICTED: a call reaching it joins the load, and the load's end
179
186
  // touches it back into the order.
180
187
  if (follower.cell.loading) continue
181
- follower.reader.alive = false
182
- dropReader(follower.cell, follower.reader)
183
- detachProbes(follower.reader)
184
- follower.evict()
188
+ end(follower)
185
189
  }
186
190
  this.arm()
187
191
  }
192
+
193
+ /**
194
+ * Evicts `key`'s entry where its life has run out and no load holds it, and says whether it did.
195
+ *
196
+ * THE REACH'S HALF of the sweep, as 6.5 is the read's half of 6.3: a timer that has not fired
197
+ * yet — a `ttl` of 0 is past as the entry produces — leaves an entry a reach would otherwise
198
+ * join. 16.7's mutation called twice in one turn is the case: the second call is a second write.
199
+ */
200
+ outlived(key: string): boolean {
201
+ const follower = this.followed.get(key)
202
+ if (
203
+ follower === undefined ||
204
+ follower.cell.loading ||
205
+ Date.now() - follower.at < this.ms
206
+ )
207
+ return false
208
+ this.followed.delete(key)
209
+ end(follower)
210
+ return true
211
+ }
188
212
  }
189
213
 
190
214
  /**
@@ -354,7 +378,7 @@ export function createKeyed<
354
378
  function reach(given: Args): Reactive<Stored, Accepted, Failures> {
355
379
  const args = normalized(self, given)
356
380
  const key = canonicalKey(args)
357
- const standing = entries.get(key)
381
+ const standing = lifetime?.outlived(key) ? undefined : entries.get(key)
358
382
  // 1.8: reaching an instance that exists joins the standing work — 15.19, unless that work
359
383
  // failed transiently, which this reach runs again.
360
384
  if (standing !== undefined) {
@@ -2,7 +2,7 @@
2
2
  * Which objects an `s.patch` wrote to, and when.
3
3
  *
4
4
  * 32.33. `epoch` moves once per patch and `written` holds the epoch an object was last written at,
5
- * so a row made at epoch `e` is stale exactly where its item's entry is past `e`. A leaf, because
5
+ * so a row that took its item at epoch `e` is stale exactly where its item's entry is past `e`. A leaf, because
6
6
  * `reconcile` reads it from the `@abide/abide/runtime` barrel.
7
7
  */
8
8
  export const PATCHES: { epoch: number; written: WeakMap<object, number> } = {