@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
@@ -0,0 +1,117 @@
1
+ import { relative, resolve } from 'node:path' // bun exposes no path helpers of its own
2
+ import { COMPONENT_NAMESPACE, type Compilation } from './abideModules.ts'
3
+ import { COMPONENT_QUERY } from './MODULE_QUERIES.ts'
4
+ import {
5
+ decodeMappings,
6
+ encodeMappings,
7
+ placeAt,
8
+ type Segment,
9
+ } from './sourceMappings.ts'
10
+
11
+ const NAMESPACED = `${COMPONENT_NAMESPACE}:`
12
+
13
+ // THE PARSER IS IMPORTED ON THE FIRST MAP ASKED FOR, not with the dev worker each reload boots:
14
+ // only a debugger reads one.
15
+ let parser: Promise<typeof import('#compiler/parseAbide.ts')> | undefined
16
+
17
+ /**
18
+ * 38.97 — a client chunk's source map, with every position in a module compiled from a `.abide`
19
+ * placed in the `.abide` instead.
20
+ *
21
+ * Bun maps the chunk to the modules it bundled and reads no map those carry, so the second step is
22
+ * taken here. A position in the module's own scaffolding — what no `.abide` line wrote — stays in
23
+ * the compiled module, which is still listed, so a debugger steps through it rather than losing it.
24
+ */
25
+ export async function withAbideSources(
26
+ mapText: string,
27
+ compilation: Compilation,
28
+ root: string,
29
+ ): Promise<string> {
30
+ const map = JSON.parse(mapText) as {
31
+ sources: string[]
32
+ sourcesContent?: (string | null)[]
33
+ mappings: string
34
+ }
35
+ const sources = map.sources
36
+ const contents = map.sourcesContent ?? []
37
+ // Per bundled source, its module's map and the index its `.abide` is listed at.
38
+ const placed: ({ lines: Segment[][]; index: number } | undefined)[] = []
39
+ const listed = new Map<string, number>()
40
+ const reads: Promise<void>[] = []
41
+ const originals = sources.length
42
+ for (let index = 0; index < originals; index += 1) {
43
+ // Bun names a bundled source from where the command runs, and a component module — which
44
+ // the plugin served at its `.abide`'s path — by its namespace and that path.
45
+ const named = sources[index] as string
46
+ const component = named.startsWith(NAMESPACED)
47
+ const abide = resolve(
48
+ component ? named.slice(NAMESPACED.length) : named,
49
+ )
50
+ const mappings = component
51
+ ? compilation.files[relative(root, abide)]?.clientMappings
52
+ : undefined
53
+ if (mappings === undefined) {
54
+ placed.push(undefined)
55
+ continue
56
+ }
57
+ // The module bun bundled is named apart from the `.abide` it is placed in, which shares
58
+ // its path.
59
+ sources[index] = `${relative(process.cwd(), abide)}${COMPONENT_QUERY}`
60
+ let at = listed.get(abide)
61
+ if (at === undefined) {
62
+ at = sources.length
63
+ listed.set(abide, at)
64
+ // Named as bun names its own, from where the command runs; read from where it is.
65
+ sources.push(relative(process.cwd(), abide))
66
+ const slot = at
67
+ parser ??= import('#compiler/parseAbide.ts')
68
+ reads.push(
69
+ Promise.all([Bun.file(abide).text(), parser]).then(
70
+ ([text, { parseAbide }]) => {
71
+ contents[slot] = withoutServerBlocks(text, parseAbide)
72
+ },
73
+ ),
74
+ )
75
+ }
76
+ placed.push({ lines: decodeMappings(mappings), index: at })
77
+ }
78
+ if (listed.size === 0) return mapText
79
+ await Promise.all(reads)
80
+ for (let index = contents.length; index < sources.length; index += 1)
81
+ contents[index] ??= null
82
+ const lines = decodeMappings(map.mappings)
83
+ for (const segments of lines)
84
+ for (const segment of segments) {
85
+ const module = placed[segment[1]]
86
+ if (module === undefined) continue
87
+ const from = placeAt(module.lines, segment[2], segment[3])
88
+ if (from === undefined) continue
89
+ segment[1] = module.index
90
+ segment[2] = from[0]
91
+ segment[3] = from[1]
92
+ }
93
+ map.sourcesContent = contents
94
+ map.mappings = encodeMappings(lines)
95
+ return JSON.stringify(map)
96
+ }
97
+
98
+ /**
99
+ * 16.28, 34.19 — the `.abide`'s text with every `<script server>` body blanked: the map is served
100
+ * to a browser, which reads none of one. Each line is kept, so every line the map places is still
101
+ * the line it names.
102
+ */
103
+ function withoutServerBlocks(
104
+ text: string,
105
+ parseAbide: typeof import('#compiler/parseAbide.ts').parseAbide,
106
+ ): string {
107
+ const file = parseAbide(text)
108
+ let blanked = ''
109
+ let at = 0
110
+ for (let index = 0; index < file.servers.length; index += 1) {
111
+ const start = file.origins.servers[index] as number
112
+ const end = start + (file.servers[index] as string).length
113
+ blanked += `${text.slice(at, start)}${text.slice(start, end).replace(/[^\n]/g, '')}`
114
+ at = end
115
+ }
116
+ return blanked + text.slice(at)
117
+ }
@@ -7,32 +7,32 @@
7
7
  /**
8
8
  * The namespace every framework address lives under.
9
9
  *
10
- * UNDER `/__abide`, which is the namespace the framework's own addresses live in (19.1, 20.5,
11
- * 25.1). That is what makes a route table and an asset table incapable of colliding: a page is a
12
- * directory under `pages/` and no directory there can be named `__abide` without an author
13
- * writing it, so nothing has to be checked at build time and nothing can be shadowed at run time.
14
- * 38.21 is the clause.
10
+ * UNDER `/__abide`, which is the namespace the framework's own addresses live in (20.5, 25.1).
11
+ * That is what keeps the app's routes and abide's own addresses from colliding: a route whose first
12
+ * segment is `__abide` is refused at build (23.17), so nothing can be shadowed at run time. 38.20
13
+ * is the clause.
15
14
  */
16
15
  export const ABIDE_ADDRESS = '/__abide'
17
16
 
18
17
  /**
19
18
  * Where the browser bundle is served.
20
19
  *
21
- * The bundle's own address below `ABIDE_ADDRESS` (38.17, 38.21). One request path answers
20
+ * The bundle's own address below `ABIDE_ADDRESS` (38.16, 38.20). One request path answers
22
21
  * everything under `ABIDE_ADDRESS` and the command supplying the app decides what is there, so a
23
- * command with a second address of its own — the reload client `abide dev` serves (38.18) — adds
22
+ * command with a second address of its own — the reload client `abide dev` serves (38.17) — adds
24
23
  * one rather than a lane.
25
24
  */
26
25
  export const CLIENT_ADDRESS = `${ABIDE_ADDRESS}/client`
27
26
 
27
+ /** Where a private handler is answered, below its file's path and then its name (16.70). */
28
+ export const PRIVATE_HANDLERS_ADDRESS = `${ABIDE_ADDRESS}/handlers`
29
+
28
30
  // 20.5, 20.9, 36.7 — the generated surfaces, which the CLI reaches as well as the server answers.
29
31
 
30
32
  /** The generated OpenAPI document. */
31
33
  export const OPENAPI_ADDRESS = `${ABIDE_ADDRESS}/openapi.json`
32
34
  /** The MCP endpoint. */
33
35
  export const MCP_ADDRESS = `${ABIDE_ADDRESS}/mcp`
34
- /** The stream of log records. */
35
- export const LOGS_ADDRESS = `${ABIDE_ADDRESS}/logs`
36
36
  /** The health check. */
37
37
  export const HEALTH_ADDRESS = `${ABIDE_ADDRESS}/health`
38
38
  /** Where a visitor's Claude Code bridge pairs, and is relayed turns (45.13). */
@@ -41,19 +41,11 @@ export const BRIDGE_ADDRESS = `${ABIDE_ADDRESS}/bridge`
41
41
  /**
42
42
  * The socket the dev reload client holds open.
43
43
  *
44
- * 38.18 — the LISTENER upgrades it on the main thread and the dev WORKER serves the script that
44
+ * 38.17 — the LISTENER upgrades it on the main thread and the dev WORKER serves the script that
45
45
  * opens it.
46
46
  */
47
47
  export const DEV_ADDRESS = `${ABIDE_ADDRESS}/dev`
48
48
 
49
- /**
50
- * Where a `server/rpc` handler is answered over http.
51
- *
52
- * 16.2 — its path below that directory, then its export name (19.1). The build writes the address
53
- * into the browser's wrapper and the server answers at it.
54
- */
55
- export const RPC_ADDRESS = `${ABIDE_ADDRESS}/rpc`
56
-
57
49
  /**
58
50
  * Where the one web socket a page follows its `GET`s over is served, below the app's root.
59
51
  *
@@ -0,0 +1,7 @@
1
+ /**
2
+ * The `<meta>` the server writes the app's name into, and a browser's logger reads it back from, so
3
+ * `log.named` prefixes the same name on both sides.
4
+ *
5
+ * 28.4.
6
+ */
7
+ export const APP_META = 'abide-app'
@@ -4,7 +4,7 @@
4
4
  * 18.4 — WHAT A `GET`'s VALUE DECLARES THAT ITS BROWSER COPY HAS TO BEHAVE BY, as the build reads
5
5
  * it off the declaration and writes it into the wrapper: data, because it crosses as text.
6
6
  * `structural` is named rather than carried, being abide's own comparator on both sides; an app's
7
- * own has no serialisation and stays the server's, as a function `by` or `tags` does.
7
+ * own has no serialization and stays the server's, as a function `by` or `tags` does.
8
8
  */
9
9
  export type CopyOptions = {
10
10
  by?: string
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The `<meta>` the server writes `DEBUG` into, and a browser reads it back from.
3
3
  *
4
- * 28.11.
4
+ * 28.10.
5
5
  */
6
6
  export const DEBUG_META = 'abide-debug'
@@ -0,0 +1,9 @@
1
+ // THE DESKTOP LAUNCHER'S FILES. Its page, which calls `onLauncher`, sits at the app's root beside the
2
+ // root layout, a name the framework finds rather than a page (23.21); the icon is a public file,
3
+ // so it is wherever the address it answers at says (36.11).
4
+
5
+ /** The `.abide` the desktop launcher renders, at the root alone. 38.80, 38.82. */
6
+ export const LAUNCHER_PAGE = 'launcher.abide'
7
+
8
+ /** The addresses of the desktop app's icon, the `.icns` first. 38.79. */
9
+ export const ICON_ADDRESSES = { icns: '/icon.icns', png: '/icon.png' } as const
@@ -0,0 +1,24 @@
1
+ // THE FENCES: the four directory names that are never an address (23.15). `server` fences a graph
2
+ // (16.29), `public` is answered as authored (36.11), and `lib` and `tests` hold what is neither an
3
+ // address nor either of those. Every other directory under the root is a segment of the routes of
4
+ // the files directly in it (23.14), and holds nothing else (23.22).
5
+
6
+ /** A module under one only the server loads. */
7
+ export const SERVER_DIRECTORY = 'server'
8
+
9
+ /** A file under one is answered as authored, at the path above it. */
10
+ export const PUBLIC_DIRECTORY = 'public'
11
+
12
+ /** The app's own code that is no address: components, modules, both sides. */
13
+ export const LIB_DIRECTORY = 'lib'
14
+
15
+ /** The app's tests. */
16
+ export const TESTS_DIRECTORY = 'tests'
17
+
18
+ /** All four, as the walk of the app tests a directory against them. */
19
+ export const RESERVED_DIRECTORIES: ReadonlySet<string> = new Set([
20
+ SERVER_DIRECTORY,
21
+ PUBLIC_DIRECTORY,
22
+ LIB_DIRECTORY,
23
+ TESTS_DIRECTORY,
24
+ ])
@@ -185,12 +185,12 @@ export function serializeCookie(init: CookieInit, inBrowser: boolean): string {
185
185
  // A page's own script cannot set an `HttpOnly` cookie, and a browser drops the whole line that
186
186
  // asks to — so the attribute is a server's alone.
187
187
  if (init.httpOnly === true && !inBrowser) line += '; HttpOnly'
188
- line += `; SameSite=${capitalised(init.sameSite ?? 'lax')}`
188
+ line += `; SameSite=${capitalized(init.sameSite ?? 'lax')}`
189
189
  if (init.partitioned === true) line += '; Partitioned'
190
190
  return line
191
191
  }
192
192
 
193
- function capitalised(sameSite: string): string {
193
+ function capitalized(sameSite: string): string {
194
194
  return sameSite.charAt(0).toUpperCase() + sameSite.slice(1)
195
195
  }
196
196
 
package/src/shared/csp.ts CHANGED
@@ -41,7 +41,7 @@ export function documentNonce(): string {
41
41
  * A middleware rung that sets the content security policy, and `csp.nonce()` for the current one.
42
42
  *
43
43
  * THE RUNG IS BUILT ON ITS FIRST REQUEST, because an app's `middleware` can be read before the
44
- * server half has filled `CSP`: `app.ts` is one more module, and nothing orders it after that one.
44
+ * server half has filled `CSP`: the root `layout.abide` is one more module, and nothing orders it after that one.
45
45
  */
46
46
  export const csp = Object.assign(
47
47
  (sources?: Record<string, string[]>): Rung => {
@@ -10,7 +10,7 @@ import { RAW_WRITES, Raw, textOf } from './Raw.ts'
10
10
  // the fallback: a clean string is returned as it arrived, with no allocation.
11
11
  //
12
12
  // THE DETECTION IS THE ENGINE'S, not a `charCodeAt` loop. The hand-written scan is one JS
13
- // iteration per character where this is vectorised, and it lost on the case it was written for:
13
+ // iteration per character where this is vectorized, and it lost on the case it was written for:
14
14
  // 5x on a clean 74-character slot and 14x on a clean 320-character one, over two runs that did
15
15
  // not swap places. The share is named — `escapeText` is 74-86% of a 500-row x 4-slot markup
16
16
  // build, and the whole build moves 1.7x.
@@ -0,0 +1,85 @@
1
+ // A ROUTE'S PATH WITH ITS PARAMETERS WRITTEN IN, for the two things that build an address from a
2
+ // route: `url`, from a page's literal (30.1), and a call, from its handler's route (16.9).
3
+
4
+ import { parseRoutePattern, type Segment } from './routePattern.ts'
5
+
6
+ /**
7
+ * `pattern` with each parameter `params` names written in as a path segment, or `/` for none.
8
+ *
9
+ * `spelled` is how the caller is named where a parameter is refused — `url('/x/[id]')`, or a
10
+ * handler's route — and `refused` what is thrown for it: an `Error` naming the clause, by default.
11
+ */
12
+ export function filledPath(
13
+ pattern: string,
14
+ params: Record<string, unknown>,
15
+ spelled: string,
16
+ refused: (name: string, message: string) => unknown = thrownError,
17
+ ): string {
18
+ return filledSegments(parseRoutePattern(pattern), params, spelled, refused)
19
+ }
20
+
21
+ /** `filledPath` over a route already parsed, for a caller that fills the same route per call. */
22
+ export function filledSegments(
23
+ segments: Segment[],
24
+ params: Record<string, unknown>,
25
+ spelled: string,
26
+ refused: (name: string, message: string) => unknown,
27
+ ): string {
28
+ let address = ''
29
+ for (const segment of segments) {
30
+ if (segment.kind === 'static') {
31
+ address += `/${segment.name}`
32
+ continue
33
+ }
34
+ const value = params[segment.name]
35
+ if (value === undefined || value === null || value === '') {
36
+ // 30.4 — an optional left out omits its segment, and so does an empty rest (30.5).
37
+ if (segment.kind !== 'required') continue
38
+ throw refused(
39
+ segment.name,
40
+ `abide: ${spelled} was given no '${segment.name}', and the route requires it. RULEBOOK 30.3.`,
41
+ )
42
+ }
43
+ if (segment.kind === 'rest' && Array.isArray(value)) {
44
+ for (const each of value)
45
+ address += `/${encodedSegment(spelled, segment.name, each, refused)}`
46
+ continue
47
+ }
48
+ address += `/${encodedSegment(spelled, segment.name, value, refused)}`
49
+ }
50
+ return address === '' ? '/' : address
51
+ }
52
+
53
+ /** The names of `pattern`'s parameters — what a call takes out of its args and into the path (16.66). */
54
+ export function parametersOf(pattern: string): Set<string> {
55
+ const names = new Set<string>()
56
+ for (const segment of parseRoutePattern(pattern))
57
+ if (segment.kind !== 'static') names.add(segment.name)
58
+ return names
59
+ }
60
+
61
+ /**
62
+ * A param value encoded as one path segment; `.` and `..` are refused.
63
+ *
64
+ * 30.19 — A VALUE OF `.` OR `..` HAS NO SPELLING AS A SEGMENT: the URL parser collapses it as a
65
+ * dot-segment, and `%2E` / `%2E%2E` with it, so the address would land on another route. Refused
66
+ * rather than written, as a missing required segment is (30.3).
67
+ */
68
+ function encodedSegment(
69
+ spelled: string,
70
+ name: string,
71
+ value: unknown,
72
+ refused: (name: string, message: string) => unknown,
73
+ ): string {
74
+ const text = String(value)
75
+ if (text === '.' || text === '..')
76
+ throw refused(
77
+ name,
78
+ `abide: ${spelled} was given '${text}' for '${name}', and no address carries it as a segment — the URL parser resolves it to another route. RULEBOOK 30.19.`,
79
+ )
80
+ return encodeURIComponent(text)
81
+ }
82
+
83
+ function thrownError(_name: string, message: string): Error {
84
+ return new Error(message)
85
+ }
@@ -1,6 +1,34 @@
1
1
  // 16.32 — A FRAMED ANSWER, read one JSON value per line: the whole line for jsonl, the `data:` line of
2
2
  // each event for sse. A line that does not carry the prefix — an sse `event:` line, a blank line — is
3
3
  // skipped. One line reader behind both a body read to the end and one read as it lands.
4
+ //
5
+ // 16.68 — AND ITS LAST FRAME MAY BE A REFUSAL: one that reached the stream after it had answered, so
6
+ // after its status was sent. An sse `event: refusal` and the Problem Details on its `data:` line, or a
7
+ // jsonl line `{"$refusal": <Problem Details>}` — read here as the refusal, thrown where it lands.
8
+
9
+ import { failedFrom, isProblem, type Problem } from './Failed.ts'
10
+
11
+ /** The key a jsonl line carries a refusal under (16.68). */
12
+ const REFUSAL_KEY = '$refusal'
13
+
14
+ /** A framed stream's last frame, as the server writes it for a refusal (16.68). */
15
+ export function refusalFrame(problem: Problem, prefix: string): string {
16
+ return prefix === ''
17
+ ? `${JSON.stringify({ [REFUSAL_KEY]: problem })}\n`
18
+ : `event: refusal\n${prefix}${JSON.stringify(problem)}\n\n`
19
+ }
20
+
21
+ // What a read of one framed body carries from one line to the next: whether an sse event named the
22
+ // data line after it a refusal.
23
+ type Reading = { refusal: boolean }
24
+
25
+ // A refusal's frame, read: thrown where it lands among the chunks.
26
+ class Refused {
27
+ problem: Problem
28
+ constructor(problem: Problem) {
29
+ this.problem = problem
30
+ }
31
+ }
4
32
 
5
33
  /**
6
34
  * What precedes a chunk's JSON on its line: nothing for jsonl, `data: ` for sse, and `null` where
@@ -67,14 +95,16 @@ async function* bytesStream(
67
95
  /** The chunks a framed text carried, every line of it complete. */
68
96
  export function framedChunks(text: string, prefix: string): unknown[] {
69
97
  const chunks: unknown[] = []
70
- readLines(text, prefix, true, chunks)
98
+ readLines(text, prefix, true, chunks, { refusal: false })
99
+ for (const chunk of chunks)
100
+ if (chunk instanceof Refused) throw failedFrom(chunk.problem)
71
101
  return chunks
72
102
  }
73
103
 
74
104
  /**
75
105
  * A framed body's chunks, yielded as each line completes.
76
106
  *
77
- * Cancelled when the reader stops reading — a refresh starts a new run and the old one's reader has
107
+ * Canceled when the reader stops reading — a refresh starts a new run and the old one's reader has
78
108
  * nothing left to hand anything.
79
109
  */
80
110
  export async function* framedStream(
@@ -84,6 +114,7 @@ export async function* framedStream(
84
114
  const reader = body.getReader()
85
115
  const decoder = new TextDecoder()
86
116
  const chunks: unknown[] = []
117
+ const reading: Reading = { refusal: false }
87
118
  let buffered = ''
88
119
  try {
89
120
  for (;;) {
@@ -97,8 +128,17 @@ export async function* framedStream(
97
128
  buffered += decoded
98
129
  continue
99
130
  }
100
- buffered = readLines(buffered + decoded, prefix, done, chunks)
101
- yield* chunks
131
+ buffered = readLines(
132
+ buffered + decoded,
133
+ prefix,
134
+ done,
135
+ chunks,
136
+ reading,
137
+ )
138
+ for (const chunk of chunks) {
139
+ if (chunk instanceof Refused) throw failedFrom(chunk.problem)
140
+ yield chunk
141
+ }
102
142
  chunks.length = 0
103
143
  if (done) return
104
144
  }
@@ -117,15 +157,48 @@ function readLines(
117
157
  prefix: string,
118
158
  ended: boolean,
119
159
  chunks: unknown[],
160
+ reading: Reading,
120
161
  ): string {
121
162
  let start = 0
122
163
  for (;;) {
123
164
  const newline = text.indexOf('\n', start)
124
165
  if (newline === -1 && !ended) return text.slice(start)
125
166
  const end = newline === -1 ? text.length : newline
126
- if (end > start && text.startsWith(prefix, start))
127
- chunks.push(JSON.parse(text.slice(start + prefix.length, end)))
167
+ if (prefix !== '' && text.startsWith('event: refusal', start))
168
+ reading.refusal = true
169
+ else if (end > start && text.startsWith(prefix, start)) {
170
+ const value: unknown = JSON.parse(
171
+ text.slice(start + prefix.length, end),
172
+ )
173
+ chunks.push(refusalIn(value, prefix, reading) ?? value)
174
+ reading.refusal = false
175
+ }
128
176
  if (newline === -1) return ''
129
177
  start = newline + 1
130
178
  }
131
179
  }
180
+
181
+ // The refusal a value carries as its frame: the data line an sse event named one, or a jsonl line
182
+ // holding the one reserved key and Problem Details under it.
183
+ function refusalIn(
184
+ value: unknown,
185
+ prefix: string,
186
+ reading: Reading,
187
+ ): Refused | undefined {
188
+ if (prefix !== '')
189
+ return reading.refusal && isProblem(value)
190
+ ? new Refused(value)
191
+ : undefined
192
+ // Per chunk: the reserved key is tested before any key list is built.
193
+ if (
194
+ value === null ||
195
+ typeof value !== 'object' ||
196
+ !Object.hasOwn(value, REFUSAL_KEY)
197
+ )
198
+ return undefined
199
+ const keys = Object.keys(value)
200
+ const problem = (value as Record<string, unknown>)[REFUSAL_KEY]
201
+ return keys.length === 1 && isProblem(problem)
202
+ ? new Refused(problem)
203
+ : undefined
204
+ }
@@ -20,6 +20,8 @@ export function headKeyOf(
20
20
  ): string | undefined {
21
21
  if (lowerTag === 'title' || lowerTag === 'base') return lowerTag
22
22
  if (lowerTag === 'meta') {
23
+ // One encoding a document, so a layout's replaces the default document's.
24
+ if (attribute('charset') != null) return 'meta charset'
23
25
  const named = attribute('name')
24
26
  const key = named === null ? attribute('property') : named
25
27
  return key == null ? undefined : `meta ${key}`
@@ -36,13 +36,14 @@ export { listOf } from './listOf.ts'
36
36
  export { type Logger, type LogRecord, log } from './log.ts'
37
37
  export type { Middleware } from './Middleware.ts'
38
38
  export type { Params } from './matchRoute.ts'
39
- // 38.89 — a desktop app's menu items, exported by the route's layouts and page.
39
+ // 38.88 — a desktop app's menu items, and the hook a route's layouts and page file them with.
40
40
  export {
41
41
  type MenuEntry,
42
42
  type MenuItem,
43
43
  type MenuRole,
44
44
  menuItem,
45
45
  } from './menuItem.ts'
46
+ export { onMenu } from './onMenu.ts'
46
47
  // `navigate` ALONE, of everything routing needs. This barrel is what an app resolves through the
47
48
  // bare `@abide/abide` specifier, and the matcher, the pattern grammar and `routeOf` are reached by path
48
49
  // from the two places that have them — the build and the client router. Putting them here would
package/src/shared/log.ts CHANGED
@@ -1,14 +1,15 @@
1
1
  // SECTION 28 — the logger, on both sides. A line is written to the console AND published to
2
- // `log.records` (28.1), so a subscriber — `abide logs`, a test, a page — sees exactly what the
2
+ // `log.records` (28.1), so a subscriber — a handler, a test, a page — sees exactly what the
3
3
  // terminal saw.
4
4
  //
5
5
  // THE RECORDS ARE BUILT ON FIRST ASK. The reactive core warns through this module, so a
6
6
  // `reactive` built at this module's evaluation would be a cycle evaluated in the wrong order;
7
7
  // built on the first line or the first read of `log.records`, the core has long since loaded.
8
8
  //
9
- // 28.11, 28.12 — IN A BROWSER, `DEBUG` IS THE DOCUMENT'S: the server names it in a `<meta>` it writes
9
+ // 28.10, 28.11 — IN A BROWSER, `DEBUG` IS THE DOCUMENT'S: the server names it in a `<meta>` it writes
10
10
  // into the head, and the browser's own stored `debug` answers only where the document carries none.
11
11
 
12
+ import { APP_META } from './APP_META.ts'
12
13
  import { DEBUG_META } from './DEBUG_META.ts'
13
14
  import { paint } from './paint.ts'
14
15
  import { type Reactive, reactive } from './reactive/index.ts'
@@ -53,7 +54,7 @@ function recordsValue(): Reactive<LogRecord[]> {
53
54
  Number.isInteger(configured) && configured >= 0
54
55
  ? configured
55
56
  : DEFAULT_BUFFER
56
- // 28.1 — one array for the process, which `/__abide/logs` follows (28.2).
57
+ // 28.1 — one array for the process.
57
58
  records = reactive<LogRecord[]>([], { global: true })
58
59
  }
59
60
  return records
@@ -62,7 +63,7 @@ function recordsValue(): Reactive<LogRecord[]> {
62
63
  /**
63
64
  * Appends `record` to `log.records`, trimming to the buffer it keeps.
64
65
  *
65
- * THE TRIM IS AMORTISED: grown to twice the buffer and cut back in one move, rather than a `shift`
66
+ * THE TRIM IS AMORTIZED: grown to twice the buffer and cut back in one move, rather than a `shift`
66
67
  * per line that moves every record still held.
67
68
  */
68
69
  function keep(list: LogRecord[], record: LogRecord): void {
@@ -81,14 +82,26 @@ function keep(list: LogRecord[], record: LogRecord): void {
81
82
  */
82
83
  let documentDebugRead: string | undefined
83
84
 
85
+ let documentAppRead: string | undefined
86
+
84
87
  function environment(name: string): string | undefined {
85
88
  if (typeof process !== 'undefined') return process.env[name]
89
+ // 28.4 — the app's name, as the server wrote it into the document.
90
+ if (name === 'APP_NAME') {
91
+ documentAppRead ??=
92
+ (typeof document === 'undefined'
93
+ ? undefined
94
+ : document
95
+ .querySelector(`meta[name="${APP_META}"]`)
96
+ ?.getAttribute('content')) ?? ''
97
+ return documentAppRead === '' ? undefined : documentAppRead
98
+ }
86
99
  if (name !== 'DEBUG') return undefined
87
100
  documentDebugRead ??= documentDebug() ?? ''
88
101
  return documentDebugRead
89
102
  }
90
103
 
91
- /** The document's `DEBUG`, or the browser's own stored one where it carries none (28.11, 28.12). */
104
+ /** The document's `DEBUG`, or the browser's own stored one where it carries none (28.10, 28.11). */
92
105
  export function documentDebug(): string | undefined {
93
106
  if (typeof document === 'undefined') return undefined
94
107
  const named = document
@@ -106,7 +119,7 @@ export function documentDebug(): string | undefined {
106
119
  /**
107
120
  * The compiled `DEBUG` patterns: namespaces included, and namespaces excluded.
108
121
  *
109
- * 28.10 — THE debug-npm GRAMMAR: a list split on commas or whitespace, `*` any run of characters,
122
+ * 28.9 — THE debug-npm GRAMMAR: a list split on commas or whitespace, `*` any run of characters,
110
123
  * and a leading `-` an exclusion that beats every inclusion. Compiled once per distinct `DEBUG`.
111
124
  */
112
125
  type Gate = { include: RegExp[]; exclude: RegExp[] }
@@ -131,7 +144,7 @@ function currentGate(): Gate {
131
144
  return gate
132
145
  }
133
146
 
134
- /** Whether `DEBUG` enables a namespace's gated lines (28.7). */
147
+ /** Whether `DEBUG` enables a namespace's gated lines (28.6). */
135
148
  function isEnabled(namespace: string): boolean {
136
149
  const { include, exclude } = currentGate()
137
150
  for (const pattern of exclude) if (pattern.test(namespace)) return false
@@ -215,12 +228,12 @@ function write(
215
228
  recordsValue().patch((list) => keep(list, record))
216
229
  }
217
230
 
218
- /** A logger under `namespace`, which 28.5 builds from the app's name and 28.9 from `abide:`. */
231
+ /** A logger under `namespace`, which 28.4 builds from the app's name and 28.8 from `abide:`. */
219
232
  export function namedLogger(namespace: string): Logger {
220
233
  return loggerOver(() => namespace)
221
234
  }
222
235
 
223
- /** Both lanes' render logger (28.9): the server's escalations and 32.20's dropped URLs. */
236
+ /** Both lanes' render logger (28.8): the server's escalations and 32.20's dropped URLs. */
224
237
  export const RENDER_LOG = namedLogger('abide:render')
225
238
 
226
239
  /**
@@ -229,7 +242,7 @@ export const RENDER_LOG = namedLogger('abide:render')
229
242
  * The namespace is ASKED per line rather than held, which is all `log` needs over a named logger.
230
243
  */
231
244
  function loggerOver(namespaceOf: () => string): Logger {
232
- // 28.3 — the bare call always writes. 28.4 — so do warnings and errors.
245
+ // 28.2 — the bare call always writes. 28.3 — so do warnings and errors.
233
246
  const logger = ((...args: unknown[]): void => {
234
247
  write(namespaceOf(), 'info', args)
235
248
  }) as Logger
@@ -243,7 +256,7 @@ function loggerOver(namespaceOf: () => string): Logger {
243
256
  }
244
257
  logger.warning = (...args) => write(namespaceOf(), 'warning', args)
245
258
  logger.error = (...args) => write(namespaceOf(), 'error', args)
246
- // 28.5 — another segment, below this one. 28.13 — asked per line too, so a logger named at module
259
+ // 28.4 — another segment, below this one. 28.12 — asked per line too, so a logger named at module
247
260
  // load, before the app's name resolved, writes under the name once it has.
248
261
  logger.named = (name) => loggerOver(() => `${namespaceOf()}:${name}`)
249
262
  logger.enabled = () => isEnabled(namespaceOf())
@@ -254,14 +267,15 @@ function loggerOver(namespaceOf: () => string): Logger {
254
267
  /**
255
268
  * The slot the derived app name is read through, filled by the server's `config`.
256
269
  *
257
- * 28.5 — the app's name is the root every `log.named` hangs off: the environment's, or what 27.3
270
+ * 28.4 — the app's name is the root every `log.named` hangs off: the environment's, or what 27.3
258
271
  * derived, which the server's `config` fills in here.
259
272
  */
260
273
  export const APP_NAME: { derived: () => string | undefined } = {
261
274
  derived: () => undefined,
262
275
  }
263
276
 
264
- function appName(): string {
277
+ /** The name every `log.named` hangs off: the environment's, or the one the app derived (28.4). */
278
+ export function appName(): string {
265
279
  return environment('APP_NAME') ?? APP_NAME.derived() ?? 'app'
266
280
  }
267
281