@abide/abide 0.101.0 → 0.102.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 (157) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/bin/abide.ts +223 -164
  3. package/docs/REGISTRY.md +726 -0
  4. package/docs/RULEBOOK.md +2753 -0
  5. package/docs/abide.md +12878 -0
  6. package/package.json +5 -3
  7. package/scaffold/AGENTS.md +31 -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 +262 -67
  13. package/src/compiler/argsSchemaOf.ts +1 -1
  14. package/src/compiler/checkApp.ts +43 -15
  15. package/src/compiler/checkedShadow.ts +39 -5
  16. package/src/compiler/classifyRead.ts +6 -4
  17. package/src/compiler/classifyTemplate.ts +1 -1
  18. package/src/compiler/compileApp.ts +433 -193
  19. package/src/compiler/compiledEntry.ts +43 -39
  20. package/src/compiler/componentGraph.ts +58 -71
  21. package/src/compiler/declaredExports.ts +24 -7
  22. package/src/compiler/emitClient.ts +141 -71
  23. package/src/compiler/emitComponent.ts +37 -8
  24. package/src/compiler/emitSource.ts +106 -13
  25. package/src/compiler/handlersUnder.ts +106 -59
  26. package/src/compiler/index.ts +7 -0
  27. package/src/compiler/mountsOf.ts +138 -0
  28. package/src/compiler/nearest.ts +14 -0
  29. package/src/compiler/parseAbide.ts +157 -20
  30. package/src/compiler/projectShadow.ts +213 -7
  31. package/src/compiler/reactiveTypes.ts +20 -7
  32. package/src/compiler/readSources.ts +15 -2
  33. package/src/compiler/readsIn.ts +61 -7
  34. package/src/compiler/renderMarkup.ts +3 -3
  35. package/src/compiler/scaffold.ts +30 -11
  36. package/src/compiler/serveLanguage.ts +125 -34
  37. package/src/compiler/sourceMapOf.ts +193 -0
  38. package/src/compiler/splitImports.ts +205 -83
  39. package/src/compiler/syncTypes.ts +32 -0
  40. package/src/compiler/tokenize.ts +14 -0
  41. package/src/compiler/writeDefaultProject.ts +1 -1
  42. package/src/compiler/writeTypes.ts +82 -0
  43. package/src/server/CLIENT_BUILD.ts +11 -13
  44. package/src/server/DEFAULT_PORT.ts +1 -1
  45. package/src/server/LAUNCHER_FILES.ts +9 -0
  46. package/src/server/MODULE_QUERIES.ts +18 -0
  47. package/src/server/TABLES.ts +7 -0
  48. package/src/server/VIRTUAL_DIRECTORY.ts +4 -5
  49. package/src/server/abideModules.ts +391 -0
  50. package/src/server/answerFailure.ts +16 -6
  51. package/src/server/answerRequest.ts +235 -106
  52. package/src/server/answerRpc.ts +102 -30
  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 +179 -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 +37 -37
  65. package/src/server/componentModules.d.ts +3 -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 +59 -73
  70. package/src/server/devWorker.ts +114 -181
  71. package/src/server/executableName.ts +4 -0
  72. package/src/server/fenceServer.ts +37 -35
  73. package/src/server/generatedSurfaces.ts +99 -66
  74. package/src/server/hooks.ts +124 -52
  75. package/src/server/iconAccent.ts +191 -0
  76. package/src/server/index.ts +11 -7
  77. package/src/server/installSourceMaps.ts +88 -0
  78. package/src/server/launcherOptions.ts +34 -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 +5 -5
  83. package/src/server/negotiateEncoding.ts +1 -1
  84. package/src/server/principal.ts +10 -5
  85. package/src/server/realOrAsGiven.ts +10 -0
  86. package/src/server/refuseAddressCollision.ts +21 -14
  87. package/src/server/remote.ts +49 -52
  88. package/src/server/render.ts +65 -94
  89. package/src/server/renderComponent.ts +5 -0
  90. package/src/server/reportHealth.ts +2 -2
  91. package/src/server/requestScope.ts +12 -1
  92. package/src/server/runBuild.ts +24 -0
  93. package/src/server/runCompiled.ts +5 -5
  94. package/src/server/runDesktop.ts +52 -27
  95. package/src/server/serveBuild.ts +43 -52
  96. package/src/server/serveDev.ts +73 -37
  97. package/src/server/shell.ts +7 -22
  98. package/src/server/sourceMappings.ts +139 -0
  99. package/src/server/surfaceOf.ts +18 -3
  100. package/src/server/testBuild.ts +1 -19
  101. package/src/server/withAbideSources.ts +117 -0
  102. package/src/shared/ADDRESSES.ts +7 -18
  103. package/src/shared/APP_META.ts +7 -0
  104. package/src/shared/CopyOptions.ts +1 -1
  105. package/src/shared/DEBUG_META.ts +1 -1
  106. package/src/shared/RESERVED_DIRECTORIES.ts +15 -0
  107. package/src/shared/cookies.ts +2 -2
  108. package/src/shared/csp.ts +1 -1
  109. package/src/shared/escapeText.ts +1 -1
  110. package/src/shared/filledPath.ts +85 -0
  111. package/src/shared/framing.ts +79 -6
  112. package/src/shared/headKeyOf.ts +2 -0
  113. package/src/shared/index.ts +1 -1
  114. package/src/shared/log.ts +27 -13
  115. package/src/shared/matchRoute.ts +23 -4
  116. package/src/shared/menuItem.ts +3 -3
  117. package/src/shared/reactive/Keyed.ts +2 -2
  118. package/src/shared/reactive/NotLanded.ts +1 -1
  119. package/src/shared/reactive/Reactive.ts +43 -43
  120. package/src/shared/reactive/argsContract.ts +4 -4
  121. package/src/shared/reactive/canonicalKey.ts +4 -4
  122. package/src/shared/reactive/chunksOfStream.ts +6 -0
  123. package/src/shared/reactive/index.ts +1 -1
  124. package/src/shared/reactive/recordWrites.ts +103 -5
  125. package/src/shared/reactive/structural.ts +1 -1
  126. package/src/shared/reactive/tracking.ts +1 -1
  127. package/src/shared/reactive/watch.ts +3 -3
  128. package/src/shared/refuse.ts +1 -1
  129. package/src/shared/routePattern.ts +82 -46
  130. package/src/shared/rpc.ts +37 -8
  131. package/src/shared/rpcRequest.ts +27 -3
  132. package/src/shared/url.ts +4 -44
  133. package/src/shared/writePath.ts +1 -1
  134. package/src/ui/LAYER_NAMES.ts +1 -1
  135. package/src/ui/List.ts +3 -3
  136. package/src/ui/MOUNTING_LEVEL.ts +18 -0
  137. package/src/ui/Mismatch.ts +1 -1
  138. package/src/ui/RawRun.ts +1 -1
  139. package/src/ui/Router.ts +38 -31
  140. package/src/ui/Scope.ts +1 -1
  141. package/src/ui/TryRegion.ts +2 -2
  142. package/src/ui/applyHead.ts +33 -0
  143. package/src/ui/index.ts +1 -1
  144. package/src/ui/menuBridge.ts +1 -1
  145. package/src/ui/moveNode.ts +1 -1
  146. package/src/ui/reconcile.ts +2 -2
  147. package/src/ui/reportMenu.ts +2 -2
  148. package/src/ui/routing.ts +2 -2
  149. package/src/ui/setAttribute.ts +1 -1
  150. package/src/ui/setText.ts +1 -1
  151. package/src/ui/socketWire.ts +9 -7
  152. package/src/ui/wire.ts +1 -1
  153. package/scaffold/src/server/app.ts +0 -6
  154. package/scaffold/src/shared/.gitkeep +0 -0
  155. package/scaffold/src/ui/app.html +0 -8
  156. package/src/server/componentImports.ts +0 -56
  157. /package/scaffold/src/{ui/pages/page.abide → page.abide} +0 -0
@@ -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]/page.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,10 @@
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 { PUBLIC_DIRECTORY, SERVER_DIRECTORY } from './RESERVED_DIRECTORIES.ts'
12
14
 
13
15
  type SegmentKind = 'static' | 'required' | 'optional' | 'rest'
14
16
 
@@ -40,26 +42,23 @@ export function parseRoutePattern(pattern: string): Segment[] {
40
42
  return out
41
43
  }
42
44
 
43
- const ROUTES = 'ui/pages/'
44
-
45
45
  /** Whether a source is a page, a layout, an error page or a component. */
46
46
  export type FileKind = 'page' | 'layout' | 'error' | 'component'
47
47
 
48
48
  /**
49
49
  * Whether the `.abide` at `path` is a page, a layout, an error page or a component.
50
50
  *
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.
51
+ * AN ADDRESS RATHER THAN A NAME. The file name says which of the three it is, and only a file
52
+ * whose place is an `address` (`placeOf`) is one: a `page.abide` under a `server` directory is a
53
+ * component.
54
+ * One test for every reader: the build's routes, the checker's `Routes` and the `route.params` a
55
+ * page is typed by.
56
56
  *
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.
57
+ * `path` is RELATIVE TO THE APP'S ROOT: its directories are its route, so an absolute path would
58
+ * read the machine's directories as the app's.
60
59
  */
61
60
  export function fileKind(path: string): FileKind {
62
- if (routesAt(path) === -1) return 'component'
61
+ if (placeOf(path) !== 'address') return 'component'
63
62
  // The WHOLE basename: `homepage.abide` ends with `page.abide` and is a component.
64
63
  const name = path.slice(path.lastIndexOf('/') + 1)
65
64
  if (name === 'page.abide') return 'page'
@@ -67,30 +66,50 @@ export function fileKind(path: string): FileKind {
67
66
  return name === 'error.abide' ? 'error' : 'component'
68
67
  }
69
68
 
69
+ /** Where a file sits in an app, which is what it is to every reader (`placeOf`). */
70
+ export type Place = 'address' | 'server' | 'public' | 'dependency' | 'skipped'
71
+
70
72
  /**
71
- * The route literal a page file is filed under, read off its path below `ui/pages/`.
72
- *
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.
73
+ * Where the file at `path`, relative to the app's root, sits — THE ONE ANSWER every walk, fence and
74
+ * emit reads, so no two of them disagree about a path.
75
75
  *
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.
76
+ * - `dependency` — 34.28: a package's, outside the root or under `node_modules`, whose directories
77
+ * are its package's layout and none of the app's;
78
+ * - `skipped` — under a dot directory, which no walk of the app enters;
79
+ * - `server` or `public` — 23.15: under that reserved directory, the nearest to the root deciding
80
+ * (23.16 refuses one inside the other);
81
+ * - `address` — 23.14: everywhere else, its directories the segments of its route.
79
82
  */
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('/')}`
83
+ export function placeOf(path: string): Place {
84
+ if (path.startsWith('../') || path.startsWith('/')) return 'dependency'
85
+ const parts = path.split('/')
86
+ // The last part is the file itself, which is never a directory.
87
+ for (let index = 0; index < parts.length - 1; index += 1) {
88
+ const part = parts[index] as string
89
+ if (part === 'node_modules') return 'dependency'
90
+ if (part.startsWith('.')) return 'skipped'
91
+ if (part === SERVER_DIRECTORY) return 'server'
92
+ if (part === PUBLIC_DIRECTORY) return 'public'
93
+ }
94
+ return 'address'
95
+ }
96
+
97
+ /** Whether a walk of the app passes over the entry `name`: a dot file or directory, or `node_modules`. */
98
+ export function skipped(name: string): boolean {
99
+ return name.startsWith('.') || name === 'node_modules'
87
100
  }
88
101
 
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
102
+ /**
103
+ * The route a file answers at — its DIRECTORY's below the root, whatever its name.
104
+ *
105
+ * 23.14 — `media/[id]/page.abide` is `/media/[id]`, and so is every other file beside it, which is
106
+ * what lets a directory's handlers sit in as many files as an author likes (16.2). `path` is
107
+ * relative to the app's root, as `fileKind`'s is.
108
+ */
109
+ export function routeOf(path: string): string {
110
+ const parts = path.split('/')
111
+ parts.pop()
112
+ return `/${parts.filter((part) => part !== '').join('/')}`
94
113
  }
95
114
 
96
115
  /**
@@ -107,27 +126,44 @@ function routesAt(path: string): number {
107
126
  * themselves, and `/x` ranked BELOW `/[[a]]/[[b]]` for the path `/x`: a static segment beaten by
108
127
  * two optionals, which is 30.9 exactly inverted. Both were live on the scalar this replaced.
109
128
  *
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.
129
+ * WHERE ONE PATTERN ENDS, ITS END RANKS AS A SEGMENT: below a static or a required one and above an
130
+ * optional or a rest. `/a` admits one address and `/a/[[b]]` admits two, so the shorter is the
131
+ * narrower claim; but `/[[lang]]` and `/[[lang]]/about` both match `/about`, the longer by a static
132
+ * segment where the shorter spends `about` on an optional, so there the longer is.
114
133
  */
115
134
  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)
135
+ const longest = a.length > b.length ? a.length : b.length
136
+ for (let at = 0; at < longest; at += 1) {
137
+ const kind = specificity(b[at]) - specificity(a[at])
120
138
  if (kind !== 0) return kind
121
139
  }
122
- return a.length - b.length
140
+ return 0
123
141
  }
124
142
 
125
- function specificity(segment: Segment): number {
126
- if (segment.kind === 'static') return 3
127
- if (segment.kind === 'required') return 2
143
+ function specificity(segment: Segment | undefined): number {
144
+ if (segment === undefined) return 2
145
+ if (segment.kind === 'static') return 4
146
+ if (segment.kind === 'required') return 3
128
147
  return segment.kind === 'optional' ? 1 : 0
129
148
  }
130
149
 
150
+ /**
151
+ * Whether `outer` is the front of `route`: a static segment by name, a parameter by kind.
152
+ *
153
+ * 35.1, 37.2 — what a layout wraps and what a route's hooks run over: the routes at or below its
154
+ * own, a parameter's name aside, so `/media/[id]` covers `/media/[slug]/edit`.
155
+ */
156
+ export function coversRoute(outer: Segment[], route: Segment[]): boolean {
157
+ if (outer.length > route.length) return false
158
+ for (let at = 0; at < outer.length; at += 1) {
159
+ const one = outer[at] as Segment
160
+ const two = route[at] as Segment
161
+ if (one.kind !== two.kind) return false
162
+ if (one.kind === 'static' && one.name !== two.name) return false
163
+ }
164
+ return true
165
+ }
166
+
131
167
  /**
132
168
  * A route table compiled for matching: each row with its pattern parsed, most specific first.
133
169
  *
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, exported from a <script server> (16.2).',
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)
package/src/shared/url.ts CHANGED
@@ -4,9 +4,9 @@
4
4
  //
5
5
  // 16.10 — RESOLVED AGAINST THE MOUNT at the call, so the same build serves at the root and below it.
6
6
 
7
+ import { filledPath } from './filledPath.ts'
7
8
  import { mounted } from './mount.ts'
8
9
  import { queryText } from './queryText.ts'
9
- import { parseRoutePattern } from './routePattern.ts'
10
10
 
11
11
  type Parts<P extends string> = P extends `${infer Head}/${infer Tail}`
12
12
  ? Head | Parts<Tail>
@@ -86,29 +86,9 @@ export function url<P extends RouteLiteral>(
86
86
  ): string {
87
87
  const params = (rest[0] ?? {}) as Record<string, unknown>
88
88
  const query = rest[1]
89
- let address = ''
90
- for (const segment of parseRoutePattern(pattern ?? '/')) {
91
- if (segment.kind === 'static') {
92
- address += `/${segment.name}`
93
- continue
94
- }
95
- const value = params[segment.name]
96
- if (value === undefined || value === null || value === '') {
97
- // 30.4 — an optional left out omits its segment, and so does an empty rest (30.5).
98
- if (segment.kind !== 'required') continue
99
- throw new Error(
100
- `abide: url('${pattern}') was given no '${segment.name}', and the route requires it. RULEBOOK 30.3.`,
101
- )
102
- }
103
- if (segment.kind === 'rest' && Array.isArray(value)) {
104
- for (const each of value)
105
- address += `/${encodedSegment(pattern, segment.name, each)}`
106
- continue
107
- }
108
- address += `/${encodedSegment(pattern, segment.name, value)}`
109
- }
110
- if (address === '') address = '/'
111
- address = mounted(address)
89
+ const address = mounted(
90
+ filledPath(pattern ?? '/', params, `url('${pattern}')`),
91
+ )
112
92
  // 30.6 — the query half, last, in the spelling `rpcRequest` writes.
113
93
  const search =
114
94
  query === undefined
@@ -118,23 +98,3 @@ export function url<P extends RouteLiteral>(
118
98
  : queryText(query)
119
99
  return search === '' ? address : `${address}?${search}`
120
100
  }
121
-
122
- /**
123
- * A param value encoded as one path segment; `.` and `..` are refused.
124
- *
125
- * 30.19 — A VALUE OF `.` OR `..` HAS NO SPELLING AS A SEGMENT: the URL parser collapses it as a
126
- * dot-segment, and `%2E` / `%2E%2E` with it, so the address would land on another route. Refused
127
- * rather than written, as a missing required segment is (30.3).
128
- */
129
- function encodedSegment(
130
- pattern: string | undefined,
131
- name: string,
132
- value: unknown,
133
- ): string {
134
- const text = String(value)
135
- if (text === '.' || text === '..')
136
- throw new Error(
137
- `abide: url('${pattern}') was given '${text}' for '${name}', and no address carries it as a segment — the URL parser resolves it to another route. RULEBOOK 30.19.`,
138
- )
139
- return encodeURIComponent(text)
140
- }
@@ -4,7 +4,7 @@ import { putOwn } from './putOwn.ts'
4
4
  * A copy of `value` with `next` written at `path`, each level down the path copied.
5
5
  *
6
6
  * 31.8 — a write through a member path COPIES DOWN THE PATH AND THEN WRITES, and the copy is not
7
- * optimised away. The copy is the whole point: a `Reactive` wakes its readers on IDENTITY, so
7
+ * optimized away. The copy is the whole point: a `Reactive` wakes its readers on IDENTITY, so
8
8
  * `user.name = 'x'` done as a mutation of the held object writes the right value and wakes nobody
9
9
  * — the page renders correctly on load and then never moves again, with nothing about it saying
10
10
  * so.
@@ -2,7 +2,7 @@
2
2
  // code that EMITS the measure to the spec that reads it, and a string typed twice is a
3
3
  // `shares()` call that throws "found no measure named" against a layer that ran.
4
4
  //
5
- // `.` RATHER THAN `:`. `abide:hydrate` and `abide:navigate` are WARNING channels (28.9) and these
5
+ // `.` RATHER THAN `:`. `abide:hydrate` and `abide:navigate` are WARNING channels (28.8) and these
6
6
  // are performance entries — two namespaces that would otherwise be one grep.
7
7
  //
8
8
  // ONE NAME PER OP SOMEBODY WAITS ON, never per row. `shares()` sums every measure of a layer
package/src/ui/List.ts CHANGED
@@ -18,7 +18,7 @@ export class List {
18
18
  region: Region
19
19
  rows: Row[]
20
20
  /**
21
- * The async iterable last started, or null — so a re-run with the same iterable is not a new
21
+ * The async iterable last started, or null — so a rerun with the same iterable is not a new
22
22
  * source.
23
23
  */
24
24
  pumping: unknown
@@ -209,8 +209,8 @@ function settleList(
209
209
  * async subject writes no rows inline and the region arrives empty. The pump fills it.
210
210
  */
211
211
  function appendRows(list: List, source: AsyncIterable<unknown>): void {
212
- // A RE-RUN IS NOT A NEW SOURCE. The subject is read inside a watch, so any other reactive
213
- // the expression touches re-runs it with the SAME iterable — and emptying the list to pump an
212
+ // A RERUN IS NOT A NEW SOURCE. The subject is read inside a watch, so any other reactive
213
+ // the expression touches reruns it with the SAME iterable — and emptying the list to pump an
214
214
  // async generator that has already run to completion left the region blank for good.
215
215
  if (list.pumping === source) return
216
216
  // A NEW SOURCE IS A NEW LIST. What the last one appended is stopped and removed, or its
@@ -0,0 +1,18 @@
1
+ // 39.1, 39.6 — THE ROUTER'S LEVEL BEING MOUNTED, set by the router around a page's or a layout's
2
+ // setup and markup: a component mounted inside one adds its `<head>` to that level's
3
+ // (`contributeHead`), and one mounted later — a row a list grew by — finds none and leaves the
4
+ // document's head alone. `unapplied` is the head the components built on the client contributed
5
+ // while it was set, which the router applies once it is unset rather than once a component.
6
+ export const MOUNTING_LEVEL: {
7
+ current:
8
+ | {
9
+ head: (() => string) | undefined
10
+ /** Every component head joined to the level's own, in the order they mounted. */
11
+ contributed: (() => string)[] | undefined
12
+ }
13
+ | undefined
14
+ unapplied: string
15
+ } = {
16
+ current: undefined,
17
+ unapplied: '',
18
+ }
@@ -1,6 +1,6 @@
1
1
  import { namedLogger } from '#shared/log.ts'
2
2
 
3
- /** The browser lane's hydration logger (28.9). */
3
+ /** The browser lane's hydration logger (28.8). */
4
4
  export const HYDRATE_LOG = namedLogger('abide:hydrate')
5
5
 
6
6
  /**
package/src/ui/RawRun.ts CHANGED
@@ -11,7 +11,7 @@ import { removeUntil } from './regions.ts'
11
11
  *
12
12
  * `html` IS UNDEFINED FOR A RUN THE SERVER PAINTED. What is between the anchors is what the server
13
13
  * parsed from the same value, so the first write keeps it (`setMarkup`) the way a hydrated arm keeps
14
- * the arm it was painted with — reading markup back out of nodes is a serialisation, and not the
14
+ * the arm it was painted with — reading markup back out of nodes is a serialization, and not the
15
15
  * string the server wrote.
16
16
  */
17
17
  export class RawRun {
package/src/ui/Router.ts CHANGED
@@ -8,8 +8,9 @@ import { arrive, route } from '#shared/route.ts'
8
8
  import { rankRoutes, type Segment } from '#shared/routePattern.ts'
9
9
  import { setUpLevels } from '#shared/setUpLevels.ts'
10
10
  import { paintedCallsLanded } from '#shared/takeEarlyCall.ts'
11
- import { withdrawHead } from './applyHead.ts'
11
+ import { applyHead, withdrawHead } from './applyHead.ts'
12
12
  import { LAYER_MOUNT, LAYER_NAVIGATE } from './LAYER_NAMES.ts'
13
+ import { MOUNTING_LEVEL } from './MOUNTING_LEVEL.ts'
13
14
  import { beginLayer, endLayer } from './measureLayer.ts'
14
15
  import { menuBridge } from './menuBridge.ts'
15
16
  import { fillRegion, pageRegion, type Region } from './regions.ts'
@@ -73,6 +74,8 @@ type Level = {
73
74
  * wrote and the first navigation away from it withdraws that head like any other.
74
75
  */
75
76
  head: (() => string) | undefined
77
+ /** The heads of the components mounted in this level, joined to `head` (`contributeHead`). */
78
+ contributed: (() => string)[] | undefined
76
79
  /**
77
80
  * 41.19 — this level's markup, left by its mount once its setup has run, for the router to run
78
81
  * once every level below has been set up too; `undefined` once it has run, or where the module
@@ -144,20 +147,8 @@ export class Router {
144
147
  * Read off the compiled `<style>` blocks. Nothing here looks at the document's stylesheets.
145
148
  */
146
149
  transitions: boolean
147
- /**
148
- * The shell's keyed head entries, as the build read them off the shell (`keyedHead`).
149
- *
150
- * 39.4, 39.14 — THE OUTERMOST CONTRIBUTOR, and the one the document cannot say: the server
151
- * merged out every entry of it a level overrode.
152
- */
153
- shellHead: string
154
150
 
155
- constructor(
156
- region: Region,
157
- routes: Compiled[],
158
- transitions: boolean,
159
- shellHead: string,
160
- ) {
151
+ constructor(region: Region, routes: Compiled[], transitions: boolean) {
161
152
  this.region = region
162
153
  this.routes = routes
163
154
  this.levels = []
@@ -167,7 +158,6 @@ export class Router {
167
158
  this.navigation = 0
168
159
  this.prefetched = new Set()
169
160
  this.transitions = transitions
170
- this.shellHead = shellHead
171
161
  }
172
162
 
173
163
  /**
@@ -186,7 +176,7 @@ export class Router {
186
176
  /**
187
177
  * Loads a route's chunks ahead of a navigation the reader has signaled.
188
178
  *
189
- * 30.14 — A NAVIGATION THE READER HAS SIGNALLED, LOADED BEFORE IT HAPPENS: the route's chunks
179
+ * 30.14 — A NAVIGATION THE READER HAS SIGNALED, LOADED BEFORE IT HAPPENS: the route's chunks
190
180
  * are asked for on intent, so the click finds them in the module cache and setup — and with it
191
181
  * every call the page makes — starts at once. Only the CODE: a call run on a hover would be a
192
182
  * call nobody asked for, and for anything but a `GET` one with an effect. A failed load is left
@@ -385,11 +375,9 @@ export class Router {
385
375
  // outermost first; a kept layout is not mounted again, so neither is its head.
386
376
  this.mountChain(target, found, arriving, shared, into)
387
377
  // 39.14 — what the leaving levels keyed and the arriving ones did not goes back to the
388
- // nearest kept layout's, else the shell's, else leaves.
389
- withdrawHead(
390
- leaving,
391
- this.heads(shared, this.levels.length),
392
- () => this.shellHead + this.heads(0, shared),
378
+ // nearest kept layout's, else leaves.
379
+ withdrawHead(leaving, this.heads(shared, this.levels.length), () =>
380
+ this.heads(0, shared),
393
381
  )
394
382
  // The top of the page, or — as the browser does on a load — the fragment's element.
395
383
  if (options.keepScroll !== true) scrollToFragment(target.hash)
@@ -480,6 +468,7 @@ export class Router {
480
468
  scope: undefined,
481
469
  slot: undefined,
482
470
  head: undefined,
471
+ contributed: undefined,
483
472
  markup: undefined,
484
473
  share: null,
485
474
  }
@@ -505,7 +494,9 @@ export class Router {
505
494
  // runs; a module mounted by the router leaves its markup rather than using this one.
506
495
  // A deferred level answers the scope its setup made, so the watches that setup opened are
507
496
  // stopped with the level even where its markup never runs — a slot that never opens.
508
- level.scope = module.default(region, props, level)
497
+ level.scope = mounting(level, () =>
498
+ module.default(region, props, level),
499
+ )
509
500
  return level.share
510
501
  }
511
502
 
@@ -515,7 +506,7 @@ export class Router {
515
506
  const markup = level.markup
516
507
  if (markup === undefined) return
517
508
  level.markup = undefined
518
- level.scope = markup(region)
509
+ level.scope = mounting(level, () => markup(region))
519
510
  }
520
511
 
521
512
  /**
@@ -577,7 +568,7 @@ export class Router {
577
568
  ): void {
578
569
  arrive(target.href, found.route.pattern, found.params)
579
570
  this.chain = modules
580
- // 38.89 — inside a desktop app, the window shows the menus this chain exports.
571
+ // 38.88 — inside a desktop app, the window shows the menus this chain exports.
581
572
  if (menuBridge() !== undefined)
582
573
  void import('./reportMenu.ts').then((loaded) =>
583
574
  loaded.reportMenu(modules as { menu?: unknown }[]),
@@ -685,14 +676,30 @@ export function startRouter(
685
676
  routes: Route[],
686
677
  host: Element = document.body,
687
678
  transitions = false,
688
- shellHead = '',
689
679
  ): Router {
690
- const router = new Router(
691
- pageRegion(host),
692
- rankRoutes(routes),
693
- transitions,
694
- shellHead,
695
- )
680
+ const router = new Router(pageRegion(host), rankRoutes(routes), transitions)
696
681
  CURRENT_ROUTER.current = router
697
682
  return router
698
683
  }
684
+
685
+ /**
686
+ * `run`, with `level` the one its components add their `<head>` to (39.1) — and what those built on
687
+ * the client contributed applied once it returns, rather than once a component.
688
+ */
689
+ function mounting<Result>(level: Level, run: () => Result): Result {
690
+ const back = MOUNTING_LEVEL.current
691
+ const backUnapplied = MOUNTING_LEVEL.unapplied
692
+ MOUNTING_LEVEL.current = level
693
+ MOUNTING_LEVEL.unapplied = ''
694
+ let unapplied: string
695
+ let result: Result
696
+ try {
697
+ result = run()
698
+ } finally {
699
+ unapplied = MOUNTING_LEVEL.unapplied
700
+ MOUNTING_LEVEL.current = back
701
+ MOUNTING_LEVEL.unapplied = backUnapplied
702
+ }
703
+ applyHead(unapplied)
704
+ return result
705
+ }