@abide/abide 0.101.0 → 0.103.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 (161) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/bin/abide.ts +226 -164
  3. package/docs/REGISTRY.md +730 -0
  4. package/docs/RULEBOOK.md +2768 -0
  5. package/docs/abide.md +13127 -0
  6. package/package.json +6 -3
  7. package/scaffold/AGENTS.md +38 -0
  8. package/scaffold/CLAUDE.md +1 -0
  9. package/scaffold/src/layout.abide +18 -0
  10. package/src/compiler/Lowering.ts +78 -9
  11. package/src/compiler/ShadowMap.ts +2 -2
  12. package/src/compiler/TypeSession.ts +341 -71
  13. package/src/compiler/appMap.ts +96 -0
  14. package/src/compiler/argsSchemaOf.ts +1 -1
  15. package/src/compiler/checkApp.ts +53 -8
  16. package/src/compiler/checkedShadow.ts +73 -6
  17. package/src/compiler/classifyRead.ts +6 -4
  18. package/src/compiler/classifyTemplate.ts +1 -1
  19. package/src/compiler/compileApp.ts +598 -200
  20. package/src/compiler/compiledEntry.ts +48 -40
  21. package/src/compiler/componentGraph.ts +57 -71
  22. package/src/compiler/declaredExports.ts +26 -9
  23. package/src/compiler/emitClient.ts +142 -72
  24. package/src/compiler/emitComponent.ts +41 -12
  25. package/src/compiler/emitSource.ts +102 -13
  26. package/src/compiler/handlersUnder.ts +164 -76
  27. package/src/compiler/hooksCalled.ts +35 -0
  28. package/src/compiler/index.ts +7 -0
  29. package/src/compiler/mountsOf.ts +150 -0
  30. package/src/compiler/nearest.ts +14 -0
  31. package/src/compiler/parseAbide.ts +157 -20
  32. package/src/compiler/projectShadow.ts +250 -19
  33. package/src/compiler/reactiveTypes.ts +20 -7
  34. package/src/compiler/readSources.ts +15 -2
  35. package/src/compiler/readsIn.ts +62 -8
  36. package/src/compiler/renderMarkup.ts +3 -3
  37. package/src/compiler/scaffold.ts +37 -15
  38. package/src/compiler/serveLanguage.ts +101 -34
  39. package/src/compiler/sharedModuleWrites.ts +2 -2
  40. package/src/compiler/sourceMapOf.ts +193 -0
  41. package/src/compiler/splitImports.ts +205 -83
  42. package/src/compiler/tokenize.ts +14 -0
  43. package/src/compiler/writeDefaultProject.ts +29 -2
  44. package/src/server/CLIENT_BUILD.ts +11 -13
  45. package/src/server/DEFAULT_PORT.ts +1 -1
  46. package/src/server/MODULE_QUERIES.ts +27 -0
  47. package/src/server/TABLES.ts +7 -0
  48. package/src/server/VIRTUAL_DIRECTORY.ts +4 -5
  49. package/src/server/abideModules.ts +457 -0
  50. package/src/server/answerFailure.ts +16 -6
  51. package/src/server/answerRequest.ts +241 -107
  52. package/src/server/answerRpc.ts +107 -33
  53. package/src/server/answerSocket.ts +65 -20
  54. package/src/server/answerValue.ts +22 -9
  55. package/src/server/appDirectories.ts +1 -1
  56. package/src/server/appPlugins.ts +1 -1
  57. package/src/server/appTree.ts +238 -0
  58. package/src/server/argsFor.ts +2 -2
  59. package/src/server/assistant.ts +2 -2
  60. package/src/server/bridgeRelay.ts +6 -8
  61. package/src/server/claudeCode.ts +0 -6
  62. package/src/server/clientBuildOptions.ts +8 -3
  63. package/src/server/coerceArgs.ts +1 -1
  64. package/src/server/commands.ts +38 -37
  65. package/src/server/componentModules.d.ts +2 -1
  66. package/src/server/compressAnswer.ts +4 -4
  67. package/src/server/config.ts +25 -29
  68. package/src/server/configFields.ts +31 -25
  69. package/src/server/createApp.ts +77 -79
  70. package/src/server/devWorker.ts +118 -181
  71. package/src/server/executableName.ts +4 -0
  72. package/src/server/fenceServer.ts +57 -32
  73. package/src/server/generatedSurfaces.ts +99 -66
  74. package/src/server/hooks.ts +137 -52
  75. package/src/server/iconAccent.ts +191 -0
  76. package/src/server/index.ts +12 -7
  77. package/src/server/installSourceMaps.ts +93 -0
  78. package/src/server/launcherOptions.ts +33 -31
  79. package/src/server/launcherPage.ts +107 -64
  80. package/src/server/manifestOf.ts +16 -13
  81. package/src/server/namesUnder.ts +2 -2
  82. package/src/server/nativeMenu.ts +6 -6
  83. package/src/server/negotiateEncoding.ts +1 -1
  84. package/src/server/preload.ts +13 -0
  85. package/src/server/principal.ts +10 -5
  86. package/src/server/realOrAsGiven.ts +10 -0
  87. package/src/server/refuseAddressCollision.ts +21 -14
  88. package/src/server/remote.ts +49 -52
  89. package/src/server/render.ts +65 -94
  90. package/src/server/renderComponent.ts +5 -0
  91. package/src/server/reportHealth.ts +2 -2
  92. package/src/server/requestScope.ts +12 -1
  93. package/src/server/runBuild.ts +24 -0
  94. package/src/server/runCompiled.ts +5 -5
  95. package/src/server/runDesktop.ts +48 -28
  96. package/src/server/serveBuild.ts +43 -52
  97. package/src/server/serveDev.ts +79 -37
  98. package/src/server/shell.ts +7 -22
  99. package/src/server/sourceMappings.ts +139 -0
  100. package/src/server/surfaceOf.ts +20 -3
  101. package/src/server/testBuild.ts +1 -19
  102. package/src/server/withAbideSources.ts +117 -0
  103. package/src/shared/ADDRESSES.ts +10 -18
  104. package/src/shared/APP_META.ts +7 -0
  105. package/src/shared/CopyOptions.ts +1 -1
  106. package/src/shared/DEBUG_META.ts +1 -1
  107. package/src/shared/LAUNCHER_FILES.ts +9 -0
  108. package/src/shared/RESERVED_DIRECTORIES.ts +24 -0
  109. package/src/shared/cookies.ts +2 -2
  110. package/src/shared/csp.ts +1 -1
  111. package/src/shared/escapeText.ts +1 -1
  112. package/src/shared/filledPath.ts +85 -0
  113. package/src/shared/framing.ts +79 -6
  114. package/src/shared/headKeyOf.ts +2 -0
  115. package/src/shared/index.ts +2 -1
  116. package/src/shared/log.ts +27 -13
  117. package/src/shared/matchRoute.ts +23 -4
  118. package/src/{ui → shared}/menuBridge.ts +2 -2
  119. package/src/shared/menuItem.ts +4 -4
  120. package/src/shared/onMenu.ts +58 -0
  121. package/src/shared/reactive/Keyed.ts +2 -2
  122. package/src/shared/reactive/NotLanded.ts +1 -1
  123. package/src/shared/reactive/Reactive.ts +43 -43
  124. package/src/shared/reactive/argsContract.ts +4 -4
  125. package/src/shared/reactive/canonicalKey.ts +4 -4
  126. package/src/shared/reactive/chunksOfStream.ts +6 -0
  127. package/src/shared/reactive/index.ts +1 -1
  128. package/src/shared/reactive/recordWrites.ts +103 -5
  129. package/src/shared/reactive/structural.ts +1 -1
  130. package/src/shared/reactive/tracking.ts +1 -1
  131. package/src/shared/reactive/watch.ts +3 -3
  132. package/src/shared/refuse.ts +1 -1
  133. package/src/{ui → shared}/reportMenu.ts +8 -10
  134. package/src/shared/routePattern.ts +122 -48
  135. package/src/shared/rpc.ts +37 -8
  136. package/src/shared/rpcRequest.ts +27 -3
  137. package/src/shared/url.ts +4 -44
  138. package/src/shared/writePath.ts +1 -1
  139. package/src/ui/LAYER_NAMES.ts +1 -1
  140. package/src/ui/List.ts +3 -3
  141. package/src/ui/MOUNTING_LEVEL.ts +18 -0
  142. package/src/ui/Mismatch.ts +1 -1
  143. package/src/ui/RawRun.ts +1 -1
  144. package/src/ui/Router.ts +37 -36
  145. package/src/ui/Scope.ts +1 -1
  146. package/src/ui/TryRegion.ts +2 -2
  147. package/src/ui/applyHead.ts +33 -0
  148. package/src/ui/index.ts +1 -1
  149. package/src/ui/moveNode.ts +1 -1
  150. package/src/ui/reconcile.ts +2 -2
  151. package/src/ui/routing.ts +2 -2
  152. package/src/ui/setAttribute.ts +1 -1
  153. package/src/ui/setText.ts +1 -1
  154. package/src/ui/socketWire.ts +9 -7
  155. package/src/ui/wire.ts +1 -1
  156. package/scaffold/src/server/app.ts +0 -6
  157. package/scaffold/src/shared/.gitkeep +0 -0
  158. package/scaffold/src/ui/app.html +0 -8
  159. package/src/server/componentImports.ts +0 -56
  160. /package/scaffold/src/{ui/pages/page.abide → index.abide} +0 -0
  161. /package/src/{compiler → shared}/SPECIFIERS.ts +0 -0
@@ -5,8 +5,9 @@
5
5
  //
6
6
  // ONLY ARRAYS AND PLAIN OBJECTS ARE WRAPPED. A `Map`, a `Set` or a class instance keeps internal
7
7
  // slots a proxy cannot stand in for — `Map.prototype.set` refuses a proxy as its receiver — so one
8
- // reached is marked written the moment it is read, path and all. That over-reports, and an
9
- // over-report costs a row remade where an under-report shows a stale one.
8
+ // reached is marked written the moment it is read, path and all, and each object a `Map` or a `Set`
9
+ // hands out is marked as it leaves (`heldView`). That over-reports, and an over-report costs a row
10
+ // remade where an under-report shows a stale one.
10
11
  //
11
12
  // 18.11 — AND WHERE A SINK IS HANDED IN, each write is kept as data too, as JSON Patch (RFC 6902) at
12
13
  // the JSON Pointer it was reached by: a member set is `add`, an index `replace` (or `add` one past
@@ -49,7 +50,15 @@ export function recordWrites<Value>(
49
50
  sink?: { ops: PatchOp[]; lost: boolean },
50
51
  ): void {
51
52
  if (!isWrappable(root)) {
52
- mutate(root)
53
+ // A root no proxy can stand in for is written as a whole: what the callback reads out of it
54
+ // reaches the rows that show it with no proxy to say so.
55
+ if (root === null || typeof root !== 'object') {
56
+ mutate(root)
57
+ return
58
+ }
59
+ PATCHES.epoch += 1
60
+ PATCHES.written.set(root, PATCHES.epoch)
61
+ mutate(heldView(root, PATCHES.epoch) as Value)
53
62
  return
54
63
  }
55
64
  PATCHES.epoch += 1
@@ -217,9 +226,10 @@ export function recordWrites<Value>(
217
226
  }
218
227
  if (entry.proxy !== null) return entry.proxy
219
228
  mark(value)
229
+ entry.view ??= heldView(value, epoch)
220
230
  // Reached, and outside what a proxy can watch: whatever it does next is not kept.
221
231
  if (sink !== undefined) sink.lost = true
222
- return value
232
+ return entry.view
223
233
  },
224
234
  set(held, property, value) {
225
235
  mark(held)
@@ -363,7 +373,7 @@ function pointerOf(path: (string | number)[]): string {
363
373
  /**
364
374
  * The JSON Patch an array method makes, read off the array BEFORE it runs.
365
375
  *
366
- * A `splice`'s start and count are normalised the way the method does, so the `remove`s and `add`s
376
+ * A `splice`'s start and count are normalized the way the method does, so the `remove`s and `add`s
367
377
  * name the indices it touches.
368
378
  */
369
379
  function opsOfCall(
@@ -421,10 +431,13 @@ class Reached {
421
431
  * Written in this patch, directly or below — `PATCHES.written` holds this patch's epoch for it.
422
432
  */
423
433
  marked: boolean
434
+ /** What one no proxy can stand in for is handed out as: `heldView` of it. */
435
+ view: object | undefined
424
436
  constructor(proxy: object | null, parent: object | Parents | null) {
425
437
  this.proxy = proxy
426
438
  this.parent = parent
427
439
  this.marked = false
440
+ this.view = undefined
428
441
  }
429
442
  }
430
443
 
@@ -463,6 +476,91 @@ class Parents {
463
476
  }
464
477
  }
465
478
 
479
+ /**
480
+ * `container` as the callback is handed it: a `Map` or a `Set` that marks written each object key,
481
+ * value or member it hands out, and anything else as it is.
482
+ *
483
+ * A row over `[...map.values()]` reads one of them, and a write the callback makes to it through
484
+ * `map.get(id)` reaches it with nothing on the way to record it — so each one taken out is taken as
485
+ * written. AS IT LEAVES rather than all of them up front, which would make a patch of one entry cost
486
+ * the whole container. One level down: a row reads its item.
487
+ */
488
+ function heldView(container: object, epoch: number): object {
489
+ if (!(container instanceof Map) && !(container instanceof Set))
490
+ return container
491
+ return new Proxy(container, new HeldHandler(epoch))
492
+ }
493
+
494
+ // `Map`'s and `Set`'s own methods run on the container itself, which is the receiver their internal
495
+ // slots answer to; the ones that hand an object out mark it first.
496
+ class HeldHandler {
497
+ epoch: number
498
+ constructor(epoch: number) {
499
+ this.epoch = epoch
500
+ }
501
+ get(
502
+ held: Map<unknown, unknown> | Set<unknown>,
503
+ property: string | symbol,
504
+ ): unknown {
505
+ if (property === TARGET) return held
506
+ const value = Reflect.get(held, property, held)
507
+ if (typeof value !== 'function') return value
508
+ const epoch = this.epoch
509
+ if (property === 'get')
510
+ return (key: unknown) =>
511
+ markedOut((held as Map<unknown, unknown>).get(key), epoch)
512
+ if (property === 'forEach')
513
+ return (
514
+ each: (value: unknown, key: unknown, set: unknown) => void,
515
+ self?: unknown,
516
+ ) => {
517
+ for (const [key, member] of held.entries())
518
+ each.call(
519
+ self,
520
+ markedOut(member, epoch),
521
+ markedOut(key, epoch),
522
+ held,
523
+ )
524
+ }
525
+ if (
526
+ property === 'values' ||
527
+ property === 'keys' ||
528
+ property === 'entries' ||
529
+ property === Symbol.iterator
530
+ )
531
+ // A Map's own iterator is its entries; a Set's is its values.
532
+ return () =>
533
+ markedEach(
534
+ (value as () => Iterator<unknown>).call(held),
535
+ property === 'entries' ||
536
+ (property === Symbol.iterator && held instanceof Map),
537
+ epoch,
538
+ )
539
+ return (value as (...args: unknown[]) => unknown).bind(held)
540
+ }
541
+ }
542
+
543
+ function* markedEach(
544
+ each: Iterator<unknown>,
545
+ pairs: boolean,
546
+ epoch: number,
547
+ ): Generator<unknown> {
548
+ for (let next = each.next(); next.done !== true; next = each.next()) {
549
+ if (pairs) {
550
+ const [key, value] = next.value as [unknown, unknown]
551
+ markedOut(key, epoch)
552
+ markedOut(value, epoch)
553
+ } else markedOut(next.value, epoch)
554
+ yield next.value
555
+ }
556
+ }
557
+
558
+ function markedOut<Value>(value: Value, epoch: number): Value {
559
+ if (typeof value === 'object' && value !== null)
560
+ PATCHES.written.set(value, epoch)
561
+ return value
562
+ }
563
+
466
564
  /** Whether a proxy can stand in for `value`: an array or a plain object. */
467
565
  function isWrappable(value: unknown): value is object {
468
566
  if (value === null || typeof value !== 'object') return false
@@ -2,7 +2,7 @@
2
2
  * The by-value comparator an app passes as `identity`: deep equality over arrays and plain objects.
3
3
  *
4
4
  * Not the default: the default is the reference, and this is what a value whose producer
5
- * RE-MATERIALISES declares — `reactive(() => getInvoice({ id }), { identity: structural })`. A
5
+ * RE-MATERIALIZES declares — `reactive(() => getInvoice({ id }), { identity: structural })`. A
6
6
  * fresh-but-equal payload waking every reader is why it exists at all; what it stops being is the
7
7
  * price every other value pays.
8
8
  *
@@ -255,7 +255,7 @@ export function dropProbeReader(source: Tracked, reader: Subscriber): void {
255
255
  * sweep below takes it out.
256
256
  *
257
257
  * SWEPT ONCE HALF THE LIST IS DEAD, which keeps a walk to at most twice the live readers and the
258
- * sweep amortised O(1) a drop — and keeps the invariant every `length` test leans on: a list with
258
+ * sweep amortized O(1) a drop — and keeps the invariant every `length` test leans on: a list with
259
259
  * entries has a LIVE one, since a list that is all dead is at least half dead. HALF, NOT MORE THAN
260
260
  * HALF: a keyed replace ends on exactly half, n old rows dead behind n new ones, and a sweep that
261
261
  * waited for one more drop kept the old rows — and through each one's `notify`, its scope and its
@@ -203,7 +203,7 @@ export function watchOver(
203
203
  // UNTRACKED, AND THE CLEAR IS WHAT MAKES IT SO. The sources are given rather
204
204
  // than discovered, but the effect still RUNS wherever it was reached from — a
205
205
  // `s.watch` opened inside a `watch` had its reads attributed to the OUTER
206
- // reader, which then re-ran on a value it never read. A mirror is the same
206
+ // reader, which then reran on a value it never read. A mirror is the same
207
207
  // shape one turn up: it runs inside the mark walk, and 4.7 has the `transform`
208
208
  // it calls there running untracked.
209
209
  const previous = TRACKING.reader
@@ -219,7 +219,7 @@ export function watchOver(
219
219
  // subscribed to, so its landing is what runs this again.
220
220
  if (thrown instanceof NotLanded) return
221
221
  // 12.8: one that throws once usually throws every run, and a watch
222
- // re-running into the same throw is a loop with a log line per iteration.
222
+ // rerunning into the same throw is a loop with a log line per iteration.
223
223
  reader.alive = false
224
224
  detach(reader)
225
225
  effectThrew(thrown)
@@ -242,7 +242,7 @@ export function watchOver(
242
242
  }
243
243
 
244
244
  /**
245
- * The bare form: runs `effect` as a tracked context, re-running when anything it read changes.
245
+ * The bare form: runs `effect` as a tracked context, rerunning when anything it read changes.
246
246
  *
247
247
  * UNOWNED — the caller holds the stop. `watch` is this handed to its owners (`owned`); a template
248
248
  * binding's `Scope` and a `{#try}`'s retry hold their own, and call this direct.
@@ -15,7 +15,7 @@ import type { JsonValue } from './JsonValue.ts'
15
15
  import { namedLogger } from './log.ts'
16
16
  import { type Found, runSchema, type Schema } from './Schema.ts'
17
17
 
18
- /** The `abide:refuse` logger (28.9) — 15.13 and 15.15's namespace. */
18
+ /** The `abide:refuse` logger (28.8) — 15.13 and 15.15's namespace. */
19
19
  const REFUSE_LOG = namedLogger('abide:refuse')
20
20
 
21
21
  /**
@@ -1,9 +1,9 @@
1
- // 38.89 — A ROUTE'S MENU, TOLD TO THE WINDOW IT IS SHOWN IN. Loaded only inside a desktop app, where
2
- // the window has opened `abideMenu` to the page; anywhere else the router never imports it.
1
+ // 38.88 — A ROUTE'S MENUS, TOLD TO THE WINDOW IT IS SHOWN IN. Loaded only inside a desktop app, where
2
+ // the window has opened `abideMenu` to the page; anywhere else `onMenu` never imports it.
3
3
 
4
- import { MenuEntry, type MenuItem } from '#shared/menuItem.ts'
5
- import { navigate } from '#shared/navigate.ts'
6
4
  import { menuBridge } from './menuBridge.ts'
5
+ import { MenuEntry, type MenuItem } from './menuItem.ts'
6
+ import { navigate } from './navigate.ts'
7
7
 
8
8
  /** A menu item as the window builds it: an item the page answers carries the `id` it is told by. */
9
9
  type Told =
@@ -20,18 +20,16 @@ type Told =
20
20
  let numbered: Exclude<MenuItem, 'separator'>[] = []
21
21
  let listening = false
22
22
 
23
- /** Tells the window the menus `chain` exports, layouts outermost first and the page last. */
24
- export function reportMenu(chain: { menu?: unknown }[]): void {
23
+ /** Tells the window `menus`, the layouts' outermost first and the page's last. */
24
+ export function reportMenu(menus: readonly MenuItem[]): void {
25
25
  const bridge = menuBridge()
26
26
  if (bridge === undefined) return
27
27
  numbered = []
28
28
  const told: Told[] = []
29
- for (const level of chain)
30
- if (Array.isArray(level.menu))
31
- for (const menu of level.menu as MenuItem[]) told.push(tell(menu))
29
+ for (const menu of menus) told.push(tell(menu))
32
30
  if (!listening) {
33
31
  listening = true
34
- // 38.91, 38.92 — the window names the item picked; the page does what it means.
32
+ // 38.90, 38.91 — the window names the item picked; the page does what it means.
35
33
  addEventListener('abide:menu', (event) => {
36
34
  const item = numbered[(event as CustomEvent<number>).detail]
37
35
  if (item === undefined) return
@@ -1,5 +1,5 @@
1
- // A ROUTE LITERAL, PARSED. One grammar for two things that would otherwise drift: the directory a
2
- // page is filed under (`ui/pages/invoices/[id]/page.abide`) and the literal an author writes
1
+ // A ROUTE LITERAL, PARSED. One grammar for two things that would otherwise drift: the file a
2
+ // page is filed as (`invoices/[id].abide`) and the literal an author writes
3
3
  // (`'/invoices/[id]'`). The bracket spelling is the file system's, so the address is read off the
4
4
  // tree rather than declared beside it and the two cannot disagree.
5
5
  //
@@ -7,8 +7,11 @@
7
7
  // required takes a value, optional may be left out and omits its segment with it, and rest takes
8
8
  // an array as well as a scalar.
9
9
  //
10
- // A LEAF: no imports, because the compiler, the client router and a server router all read it and
11
- // a constant crossing a seam lives where nothing pulls a graph in behind it.
10
+ // A LEAF: nothing imported but another leaf, because the compiler, the client router and a server
11
+ // router all read it and a constant crossing a seam lives where nothing pulls a graph in behind it.
12
+
13
+ import { LAUNCHER_PAGE } from './LAUNCHER_FILES.ts'
14
+ import { RESERVED_DIRECTORIES } from './RESERVED_DIRECTORIES.ts'
12
15
 
13
16
  type SegmentKind = 'static' | 'required' | 'optional' | 'rest'
14
17
 
@@ -40,57 +43,111 @@ export function parseRoutePattern(pattern: string): Segment[] {
40
43
  return out
41
44
  }
42
45
 
43
- const ROUTES = 'ui/pages/'
44
-
45
46
  /** Whether a source is a page, a layout, an error page or a component. */
46
47
  export type FileKind = 'page' | 'layout' | 'error' | 'component'
47
48
 
48
49
  /**
49
50
  * Whether the `.abide` at `path` is a page, a layout, an error page or a component.
50
51
  *
51
- * AN ADDRESS RATHER THAN A NAME. Both halves are load-bearing: the file name is the address and
52
- * the directory is what says the address is a ROUTE rather than a component filed anywhere. One
53
- * test for every name (35.1 reads `layout.abide` the same way), so the directory half cannot be
54
- * got right for one and wrong for another — and one test for every reader: the build's routes,
55
- * the checker's `Routes` and the `route.params` a page is typed by.
52
+ * 23.21 — WHERE IT SITS, THEN ITS NAME. Every `.abide` directly in an address directory is a page
53
+ * but a `layout.abide`, an `error.abide` and the root's `launcher.abide`; one under a fence is a
54
+ * component whatever it is called.
55
+ * One test for every reader: the build's routes, the checker's `Routes` and the `route.params` a
56
+ * page is typed by.
56
57
  *
57
- * 23.3 — under `ui/pages/` and nowhere else: a `page.abide` filed in some other `pages/`
58
- * directory is a component, as it is to the `Routes` the checker declares. `path` may be relative
59
- * to the app or the project, or absolute.
58
+ * `path` is RELATIVE TO THE APP'S ROOT: its directories are its route, so an absolute path would
59
+ * read the machine's directories as the app's.
60
60
  */
61
61
  export function fileKind(path: string): FileKind {
62
- if (routesAt(path) === -1) return 'component'
63
- // The WHOLE basename: `homepage.abide` ends with `page.abide` and is a component.
62
+ if (placeOf(path) !== 'address' || path === LAUNCHER_PAGE)
63
+ return 'component'
64
64
  const name = path.slice(path.lastIndexOf('/') + 1)
65
- if (name === 'page.abide') return 'page'
66
65
  if (name === 'layout.abide') return 'layout'
67
- return name === 'error.abide' ? 'error' : 'component'
66
+ if (name === 'error.abide') return 'error'
67
+ return 'page'
68
68
  }
69
69
 
70
+ /** Where a file sits in an app, which is what it is to every reader (`placeOf`). */
71
+ export type Place =
72
+ | 'address'
73
+ | 'server'
74
+ | 'public'
75
+ | 'lib'
76
+ | 'tests'
77
+ | 'dependency'
78
+ | 'skipped'
79
+
70
80
  /**
71
- * The route literal a page file is filed under, read off its path below `ui/pages/`.
81
+ * Where the file at `path`, relative to the app's root, sits — THE ONE ANSWER every walk, fence and
82
+ * emit reads, so no two of them disagree about a path.
72
83
  *
73
- * `page.abide` is an address rather than a name (CLAUDE.md), so the directories above it ARE the
74
- * route and the file itself contributes nothing.
84
+ * - `dependency` — 34.24: a package's, outside the root or under `node_modules`, whose directories
85
+ * are its package's layout and none of the app's;
86
+ * - `skipped` — under a dot directory, which no walk of the app enters;
87
+ * - `server`, `public`, `lib` or `tests` — 23.15: under that fence, the nearest to the root deciding
88
+ * (23.16 refuses one inside another);
89
+ * - `address` — 23.14: everywhere else, its directories the segments of its route.
90
+ */
91
+ export function placeOf(path: string): Place {
92
+ if (path.startsWith('../') || path.startsWith('/')) return 'dependency'
93
+ const parts = path.split('/')
94
+ // The last part is the file itself, which is never a directory.
95
+ for (let index = 0; index < parts.length - 1; index += 1) {
96
+ const part = parts[index] as string
97
+ if (part === 'node_modules') return 'dependency'
98
+ if (part.startsWith('.')) return 'skipped'
99
+ // Each fence's place is its name.
100
+ if (RESERVED_DIRECTORIES.has(part)) return part as Place
101
+ }
102
+ return 'address'
103
+ }
104
+
105
+ /** Whether a walk of the app passes over the entry `name`: a dot file or directory, or `node_modules`. */
106
+ export function skipped(name: string): boolean {
107
+ return name.startsWith('.') || name === 'node_modules'
108
+ }
109
+
110
+ /**
111
+ * The route a file answers at — its path below the root without its extension.
75
112
  *
76
- * 23.3 — BELOW THE FIRST `ui/pages/`, which is the routes directory, and not below the last `pages/`:
77
- * a section an app files as `ui/pages/pages/` is the address `/pages`, and cutting at the last one
78
- * served `ui/pages/pages/add-a-page` at `/add-a-page` and gave `/` two pages.
113
+ * 23.14 — `media/[id].abide` and `media/getGrid.ts` are `/media/[id]` and `/media/getGrid`; an
114
+ * `index.abide`, a `layout.abide` and an `error.abide` are their directory's. `path` is relative to
115
+ * the app's root, as `fileKind`'s is.
79
116
  */
80
- export function routeOf(pagePath: string): string {
81
- const at = routesAt(pagePath)
82
- const tail = at === -1 ? pagePath : pagePath.slice(at + ROUTES.length)
83
- const parts: string[] = []
84
- for (const part of tail.split('/'))
85
- if (part !== '' && !part.endsWith('.abide')) parts.push(part)
86
- return `/${parts.join('/')}`
117
+ export function routeOf(path: string): string {
118
+ const parts = path.split('/')
119
+ const file = parts.pop() as string
120
+ // The extension alone: `[...rest].abide` carries dots of its own.
121
+ if (!DIRECTORY_FILES.has(file)) parts.push(file.replace(EXTENSION, ''))
122
+ return `/${parts.filter((part) => part !== '').join('/')}`
87
123
  }
88
124
 
89
- /** Where the first `ui/pages/` in `path` begins, as whole directories, or -1. */
90
- function routesAt(path: string): number {
91
- if (path.startsWith(ROUTES)) return 0
92
- const at = path.indexOf(`/${ROUTES}`)
93
- return at === -1 ? -1 : at + 1
125
+ const EXTENSION = /\.(?:abide|ts)$/
126
+
127
+ /** 23.14, 23.18 — the names whose route is their directory's, and so a route's wherever they stand. */
128
+ export const DIRECTORY_FILES = new Set([
129
+ 'index.abide',
130
+ 'layout.abide',
131
+ 'error.abide',
132
+ ])
133
+
134
+ /**
135
+ * The route the hooks and private handlers of the file at `path` are filed at.
136
+ *
137
+ * 37.1 — an address's own route; under a `lib` or a `tests` directory, the route of the directory
138
+ * holding it, so a component an admin page renders runs under the admin route's rungs; under a
139
+ * `server` or a `public` directory, and for a dependency's file, the root's.
140
+ */
141
+ export function scopeOf(path: string): string {
142
+ // 38.82 — the launcher answers at no route, and files what it calls at the root's.
143
+ if (path === LAUNCHER_PAGE) return '/'
144
+ const place = placeOf(path)
145
+ if (place === 'address') return routeOf(path)
146
+ if (place !== 'lib' && place !== 'tests') return '/'
147
+ const parts = path.split('/')
148
+ // The fence `placeOf` named is the first one, and its name is its place.
149
+ const fence = parts.indexOf(place)
150
+ return `/${parts.slice(0, fence).join('/')}`
94
151
  }
95
152
 
96
153
  /**
@@ -107,27 +164,44 @@ function routesAt(path: string): number {
107
164
  * themselves, and `/x` ranked BELOW `/[[a]]/[[b]]` for the path `/x`: a static segment beaten by
108
165
  * two optionals, which is 30.9 exactly inverted. Both were live on the scalar this replaced.
109
166
  *
110
- * THE SHORTER PATTERN WINS A TIE, and only an exhausted prefix can produce one: every segment the
111
- * two share is the same kind, so what is left is the extra segments of the longer, which can only
112
- * be optional or rest or the two would not match one path. `/a` admits one address and
113
- * `/a/[[b]]` admits two, so the shorter is the narrower claim.
167
+ * WHERE ONE PATTERN ENDS, ITS END RANKS AS A SEGMENT: below a static or a required one and above an
168
+ * optional or a rest. `/a` admits one address and `/a/[[b]]` admits two, so the shorter is the
169
+ * narrower claim; but `/[[lang]]` and `/[[lang]]/about` both match `/about`, the longer by a static
170
+ * segment where the shorter spends `about` on an optional, so there the longer is.
114
171
  */
115
172
  export function compareRoutes(a: Segment[], b: Segment[]): number {
116
- const shared = a.length < b.length ? a.length : b.length
117
- for (let at = 0; at < shared; at += 1) {
118
- const kind =
119
- specificity(b[at] as Segment) - specificity(a[at] as Segment)
173
+ const longest = a.length > b.length ? a.length : b.length
174
+ for (let at = 0; at < longest; at += 1) {
175
+ const kind = specificity(b[at]) - specificity(a[at])
120
176
  if (kind !== 0) return kind
121
177
  }
122
- return a.length - b.length
178
+ return 0
123
179
  }
124
180
 
125
- function specificity(segment: Segment): number {
126
- if (segment.kind === 'static') return 3
127
- if (segment.kind === 'required') return 2
181
+ function specificity(segment: Segment | undefined): number {
182
+ if (segment === undefined) return 2
183
+ if (segment.kind === 'static') return 4
184
+ if (segment.kind === 'required') return 3
128
185
  return segment.kind === 'optional' ? 1 : 0
129
186
  }
130
187
 
188
+ /**
189
+ * Whether `outer` is the front of `route`: a static segment by name, a parameter by kind.
190
+ *
191
+ * 35.1, 37.2 — what a layout wraps and what a route's hooks run over: the routes at or below its
192
+ * own, a parameter's name aside, so `/media/[id]` covers `/media/[slug]/edit`.
193
+ */
194
+ export function coversRoute(outer: Segment[], route: Segment[]): boolean {
195
+ if (outer.length > route.length) return false
196
+ for (let at = 0; at < outer.length; at += 1) {
197
+ const one = outer[at] as Segment
198
+ const two = route[at] as Segment
199
+ if (one.kind !== two.kind) return false
200
+ if (one.kind === 'static' && one.name !== two.name) return false
201
+ }
202
+ return true
203
+ }
204
+
131
205
  /**
132
206
  * A route table compiled for matching: each row with its pattern parsed, most specific first.
133
207
  *
package/src/shared/rpc.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  // A HANDLER IS A `reactive` OR BECOMES ONE (16.1), so an `Rpc` is not a second kind of value: the
7
7
  // call hands back the handler's own `Reactive` (16.3), and every probe, trigger and failure on it is
8
8
  // the reactive core's — the `Rpc`'s own `busy`, `ready`, `refresh` and `invalidate` are the
9
- // handler's, passed through. What this file adds is the normalising — a plain function or a value becomes the
9
+ // handler's, passed through. What this file adds is the normalizing — a plain function or a value becomes the
10
10
  // `reactive` it would have been (16.6) — and the method.
11
11
  //
12
12
  // A HANDLER DECLARING `middleware` IS REACHED THROUGH ITS RUNGS (16.11), in process as over the wire:
@@ -14,6 +14,7 @@
14
14
  // handler's own.
15
15
 
16
16
  import { type Failed, isFailedNamed } from './Failed.ts'
17
+ import { filledSegments } from './filledPath.ts'
17
18
  import { GLOBAL_GETS } from './GLOBAL_GETS.ts'
18
19
  import { compose, type Middleware } from './Middleware.ts'
19
20
  import { principalRotated } from './principalRotated.ts'
@@ -23,7 +24,7 @@ import {
23
24
  type Gated,
24
25
  gateOf,
25
26
  keyOf,
26
- normalised,
27
+ normalized,
27
28
  } from './reactive/argsContract.ts'
28
29
  import { callLoad } from './reactive/callLoad.ts'
29
30
  import {
@@ -48,7 +49,8 @@ import { MUTATION } from './reactive/MUTATION.ts'
48
49
  import { SELECTION_MEMBERS } from './reactive/SELECTION_MEMBERS.ts'
49
50
  import { isForwarder } from './reactive/scoped.ts'
50
51
  import { checkArgs } from './refuse.ts'
51
- import { rpcRequest } from './rpcRequest.ts'
52
+ import { parseRoutePattern, type Segment } from './routePattern.ts'
53
+ import { pathRefusal, rpcRequest } from './rpcRequest.ts'
52
54
  import type { Schema } from './Schema.ts'
53
55
 
54
56
  export type Method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
@@ -131,7 +133,7 @@ export type RpcContext<Args = unknown> = { request: Request; args: () => Args }
131
133
  * The context key holding the args as sent.
132
134
  *
133
135
  * 11.26 — THE ARGS AS SENT, carried on the context under a key no rung spells, so the reach at the
134
- * onion's centre keys the value by them rather than by what the gate made of them.
136
+ * onion's center keys the value by them rather than by what the gate made of them.
135
137
  */
136
138
  const SENT: unique symbol = Symbol('abide.sent')
137
139
 
@@ -331,10 +333,10 @@ export function declare(method: Method): Declare {
331
333
  args: argsOnce(gateOf(contract), args),
332
334
  [SENT]: args,
333
335
  }) as RpcContext
334
- // 16.32 BEHIND RUNGS — A STREAM'S CHUNKS ARE FORWARDED, not collected. The centre of the
336
+ // 16.32 BEHIND RUNGS — A STREAM'S CHUNKS ARE FORWARDED, not collected. The center of the
335
337
  // onion is an async function, and a `Reactive` is thenable (1.4), so handing a streaming value
336
338
  // out through it would wait out the whole run and answer the accumulation once it closed. So
337
- // the centre waits only until the value settles or turns out to stream (9.6), and hands a
339
+ // the center waits only until the value settles or turns out to stream (9.6), and hands a
338
340
  // stream on as its chunks — a generator, which is not thenable. The value the call reaches
339
341
  // resolves to it and is a stream of its own from then (9.6), so a second call joining it
340
342
  // (1.8) reads the same accumulation. A refusal a rung answers with is the value's refusal.
@@ -369,8 +371,35 @@ export function declare(method: Method): Declare {
369
371
  // this handler and these args is then answered from what this one reached, rather than by
370
372
  // running the handler again. In a browser nothing fills the slot, so the report is one load
371
373
  // of a `null`.
374
+ //
375
+ // 15.8 — AND REFUSED AHEAD OF THE HANDLER where the args leave a parameter of its route no
376
+ // spelling (30.3, 30.19), as the wire refuses them: a render and the browser's call then
377
+ // reach the same refusal, and there is no request for the render to report. The route is
378
+ // parsed once per address, which is stamped after the declaration (16.10).
379
+ let routed: string | undefined
380
+ let routeSegments: Segment[] | null = null
372
381
  const call = (given?: unknown, options?: CallOptions): unknown => {
373
- const args = normalised(contract, given)
382
+ const args = normalized(contract, given)
383
+ const address = rpc.address
384
+ if (address !== routed) {
385
+ routed = address
386
+ routeSegments =
387
+ address?.includes('[') === true
388
+ ? parseRoutePattern(address)
389
+ : null
390
+ }
391
+ if (routeSegments !== null) {
392
+ try {
393
+ filledSegments(
394
+ routeSegments,
395
+ (args ?? {}) as Record<string, unknown>,
396
+ routed as string,
397
+ pathRefusal,
398
+ )
399
+ } catch (refused) {
400
+ return reactive(() => refused)
401
+ }
402
+ }
374
403
  const reached = callLoad(
375
404
  reachThrough,
376
405
  args,
@@ -472,7 +501,7 @@ export const DELETE: Declare = declare('DELETE')
472
501
  function addressOf(rpc: { address: string | undefined }): string {
473
502
  if (rpc.address === undefined)
474
503
  throw new Error(
475
- 'abide: this handler has no address yet. A handler is given one where it is mounted, under src/server/rpc (16.2).',
504
+ "abide: this handler has no address yet. A handler is given one where it is mounted: a handler file's default (16.2), or a `const` in a <script server> (16.70).",
476
505
  )
477
506
  return rpc.address
478
507
  }
@@ -4,9 +4,12 @@
4
4
  // that agreement: method, URL and body, which is what makes two requests the same one.
5
5
  //
6
6
  // A `GET` or `DELETE` carries its args in the URL, in `queryText`'s spelling, and a mutation in its
7
- // body; 19.7 is where text becomes the declared type again.
7
+ // body; 19.7 is where text becomes the declared type again. 16.9 — whatever the method, a parameter
8
+ // of the handler's route rides in the path, and the server puts it back among the args (16.65).
8
9
 
10
+ import { filledPath, parametersOf } from './filledPath.ts'
9
11
  import { queryText } from './queryText.ts'
12
+ import { validationError } from './refuse.ts'
10
13
  import type { Method } from './rpc.ts'
11
14
 
12
15
  export type RpcRequest = {
@@ -32,10 +35,26 @@ export function argsInURL(method: Method): boolean {
32
35
  export function rpcRequest(
33
36
  method: Method,
34
37
  address: string,
35
- args: Record<string, unknown> | undefined,
38
+ given: Record<string, unknown> | undefined,
36
39
  ): RpcRequest {
40
+ let args = given
37
41
  let url = address
38
42
  let body: string | undefined
43
+ // A route with parameters: they are written into the path and leave the args, so the query or
44
+ // body never carries one twice (16.66). 15.8 — one no address carries (30.3, 30.19) is refused as
45
+ // args a gate refused, so every caller answers it as the wire answers a 422.
46
+ if (address.includes('[')) {
47
+ const params = given ?? {}
48
+ url = filledPath(address, params, address, pathRefusal)
49
+ const inPath = parametersOf(address)
50
+ let rest: Record<string, unknown> | undefined
51
+ for (const name in params) {
52
+ if (inPath.has(name)) continue
53
+ rest ??= {}
54
+ rest[name] = params[name]
55
+ }
56
+ args = rest
57
+ }
39
58
  if (argsInURL(method)) {
40
59
  if (args !== undefined) {
41
60
  let text = queryText(args)
@@ -47,7 +66,7 @@ export function rpcRequest(
47
66
  const bare = bareName(name)
48
67
  text = text === '' ? bare : `${text}&${bare}`
49
68
  }
50
- if (text !== '') url = `${address}?${text}`
69
+ if (text !== '') url = `${url}?${text}`
51
70
  }
52
71
  } else if (args !== undefined) body = JSON.stringify(args)
53
72
  // 18.14 — A COPY IS ITS OWN CACHE and keeps its own `max-age`. What it asks the server is asked
@@ -67,6 +86,11 @@ export function rpcRequest(
67
86
  return { method, url, body, init, key: `${method} ${url} ${body ?? ''}` }
68
87
  }
69
88
 
89
+ /** 15.8 — a parameter no address carries, refused as args a gate refused. */
90
+ export function pathRefusal(name: string, message: string): unknown {
91
+ return validationError<Record<string, unknown>>({ [name]: [message] })
92
+ }
93
+
70
94
  /** A query member's name in the encoding `URLSearchParams` gives it, without its `=`. */
71
95
  export function bareName(name: string): string {
72
96
  return new URLSearchParams([[name, '']]).toString().slice(0, -1)