@abide/abide 0.103.0 → 0.105.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 (88) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/bin/abide.ts +80 -173
  3. package/docs/REGISTRY.md +27 -27
  4. package/docs/RULEBOOK.md +68 -24
  5. package/docs/abide.md +31 -46
  6. package/package.json +1 -1
  7. package/src/compiler/Lowering.ts +15 -5
  8. package/src/compiler/Refusals.ts +57 -0
  9. package/src/compiler/TypeSession.ts +46 -89
  10. package/src/compiler/appLayout.ts +447 -0
  11. package/src/compiler/appMap.ts +21 -25
  12. package/src/compiler/checkApp.ts +61 -97
  13. package/src/compiler/checkedShadow.ts +12 -10
  14. package/src/compiler/classifyRead.ts +3 -6
  15. package/src/compiler/compileApp.ts +183 -478
  16. package/src/compiler/componentGraph.ts +2 -71
  17. package/src/compiler/declaredExports.ts +79 -60
  18. package/src/compiler/emitClient.ts +11 -13
  19. package/src/compiler/emitComponent.ts +21 -6
  20. package/src/compiler/emitSource.ts +20 -20
  21. package/src/compiler/handlersUnder.ts +46 -36
  22. package/src/compiler/hookCalls.ts +152 -0
  23. package/src/compiler/mountsOf.ts +31 -54
  24. package/src/compiler/projectShadow.ts +1 -1
  25. package/src/compiler/readSources.ts +27 -42
  26. package/src/compiler/readsIn.ts +5 -16
  27. package/src/compiler/resolverIn.ts +77 -0
  28. package/src/server/abideModules.ts +152 -146
  29. package/src/server/answerRequest.ts +18 -4
  30. package/src/server/answerSocket.ts +136 -76
  31. package/src/server/answerValue.ts +28 -15
  32. package/src/server/appTree.ts +59 -73
  33. package/src/server/claudeApi.ts +2 -5
  34. package/src/server/clientBundle.ts +198 -0
  35. package/src/server/coerceArgs.ts +1 -1
  36. package/src/server/commands.ts +93 -57
  37. package/src/server/createApp.ts +90 -16
  38. package/src/server/deferredBuilds.ts +175 -0
  39. package/src/server/devWorker.ts +107 -319
  40. package/src/server/faceOf.ts +120 -0
  41. package/src/server/fenceServer.ts +10 -18
  42. package/src/server/generatedSurfaces.ts +12 -53
  43. package/src/server/index.ts +3 -0
  44. package/src/server/placement.ts +167 -0
  45. package/src/server/remote.ts +14 -42
  46. package/src/server/render.ts +25 -17
  47. package/src/server/renderComponent.ts +9 -5
  48. package/src/server/requestScope.ts +10 -4
  49. package/src/server/responseRelay.ts +389 -0
  50. package/src/server/runCompiled.ts +9 -12
  51. package/src/server/runDesktop.ts +6 -9
  52. package/src/server/serveBuild.ts +26 -73
  53. package/src/server/serveDev.ts +19 -176
  54. package/src/server/streamEvent.ts +29 -0
  55. package/src/server/versionOf.ts +1 -2
  56. package/src/server/withAbideSources.ts +2 -1
  57. package/src/shared/HandlerRoute.ts +68 -0
  58. package/src/shared/REFUSAL_KEY.ts +5 -0
  59. package/src/shared/RESERVED_DIRECTORIES.ts +6 -0
  60. package/src/shared/answerOf.ts +105 -78
  61. package/src/shared/filledPath.ts +1 -9
  62. package/src/shared/framing.ts +127 -128
  63. package/src/shared/queryText.ts +15 -7
  64. package/src/shared/reactive/Cursor.ts +69 -124
  65. package/src/shared/reactive/Reactive.ts +296 -162
  66. package/src/shared/reactive/callLoad.ts +4 -18
  67. package/src/shared/reactive/index.ts +11 -8
  68. package/src/shared/reactive/pairReactive.ts +1 -1
  69. package/src/shared/reactive/scoped.ts +4 -3
  70. package/src/shared/reactive/share.ts +11 -1
  71. package/src/shared/routePattern.ts +0 -110
  72. package/src/shared/rpc.ts +17 -24
  73. package/src/shared/rpcRequest.ts +18 -47
  74. package/src/shared/takeEarlyCall.ts +12 -37
  75. package/src/shared/url.ts +1 -1
  76. package/src/ui/Level.ts +276 -0
  77. package/src/ui/List.ts +17 -17
  78. package/src/ui/Router.ts +20 -87
  79. package/src/ui/applyHead.ts +92 -79
  80. package/src/ui/index.ts +4 -2
  81. package/src/ui/reconcile.ts +4 -110
  82. package/src/ui/rows.ts +123 -2
  83. package/src/ui/wire.ts +12 -27
  84. package/src/compiler/hooksCalled.ts +0 -35
  85. package/src/server/clientBuildOptions.ts +0 -54
  86. package/src/server/refuseAddressCollision.ts +0 -37
  87. package/src/shared/reactive/chunksOfStream.ts +0 -57
  88. package/src/ui/MOUNTING_LEVEL.ts +0 -18
@@ -1,50 +1,31 @@
1
- import { existsSync, readFileSync, realpathSync } from 'node:fs' // bun exposes no sync existence check, read or realpath of its own
2
- import { dirname, posix, relative as relativeTo } from 'node:path' // bun exposes no path helpers of its own
1
+ import { realpathSync } from 'node:fs' // bun exposes no sync realpath of its own
2
+ import { dirname, posix } from 'node:path' // bun exposes no path helpers of its own
3
3
  import {
4
4
  type Compilation,
5
5
  type CompiledFile,
6
6
  resolvedFile,
7
7
  } from '#server/abideModules.ts'
8
8
  import type { Mount } from '#server/answerRpc.ts'
9
- import { abideUnder, appFiles } from '#server/appTree.ts'
10
9
  import { CLIENT_ENTRY } from '#server/CLIENT_BUILD.ts'
11
10
  import { COMPONENT_QUERY } from '#server/MODULE_QUERIES.ts'
12
11
  import { ROUTES_TABLE, SERVER_TABLE } from '#server/TABLES.ts'
13
12
  import { VIRTUAL_DIRECTORY } from '#server/VIRTUAL_DIRECTORY.ts'
14
13
  import { CLIENT_ADDRESS } from '#shared/ADDRESSES.ts'
15
- import { LAUNCHER_PAGE } from '#shared/LAUNCHER_FILES.ts'
14
+ import { ROUTING_SPECIFIER, WIRE_SPECIFIER } from '#shared/SPECIFIERS.ts'
16
15
  import {
17
- coversRoute,
18
- parseRoutePattern,
19
- placeOf,
20
- routeOf,
21
- type Segment,
22
- } from '#shared/routePattern.ts'
23
- import {
24
- ROUTING_SPECIFIER,
25
- SHARED_SPECIFIER,
26
- WIRE_SPECIFIER,
27
- } from '#shared/SPECIFIERS.ts'
28
- import { abideImportsOf, appGraph } from './componentGraph.ts'
16
+ type AppLayout,
17
+ type AppRoute,
18
+ appLayout,
19
+ type PlacedSource,
20
+ shapeOf,
21
+ } from './appLayout.ts'
29
22
  import { emitSource } from './emitSource.ts'
30
- import { formatDiagnosis } from './formatDiagnosis.ts'
31
- import {
32
- type HandlerFile,
33
- handlersUnder,
34
- type Refuse,
35
- THROW,
36
- } from './handlersUnder.ts'
37
- import { hooksCalled } from './hooksCalled.ts'
38
- import { mountsOf } from './mountsOf.ts'
23
+ import { handlersUnder } from './handlersUnder.ts'
24
+ import { isAbideSpecifier } from './hookCalls.ts'
39
25
  import { nearest } from './nearest.ts'
40
- import type { AbideFile } from './parseAbide.ts'
41
- import {
42
- type ParsedSource,
43
- parseSources,
44
- type ReadSource,
45
- readSources,
46
- refusal,
47
- } from './readSources.ts'
26
+ import { projectShadow } from './projectShadow.ts'
27
+ import { Refusals } from './Refusals.ts'
28
+ import { type ReadSource, readSources } from './readSources.ts'
48
29
  import { respecified } from './splitImports.ts'
49
30
  import { type Diagnosis, TypeSession } from './TypeSession.ts'
50
31
  import { writeDefaultProject } from './writeDefaultProject.ts'
@@ -55,20 +36,6 @@ import { writeDefaultProject } from './writeDefaultProject.ts'
55
36
  // commands serving different markup from the same sources is exactly what one compile in two sinks
56
37
  // makes impossible.
57
38
 
58
- type AppRoute = {
59
- /** The address, in the bracket spelling `routePattern` parses. */
60
- pattern: string
61
- /** The `.abide` this route paints, relative to the app root. */
62
- source: string
63
- /**
64
- * 35.1 — the `layout.abide` files this page renders inside, outermost first.
65
- *
66
- * A fact about the file tree, like the address itself, so a layout cannot be forgotten and
67
- * cannot be declared twice.
68
- */
69
- layouts: string[]
70
- }
71
-
72
39
  /** An app's compiled output: every file and table (`Compilation`), its routes, and its warnings. */
73
40
  export type CompiledApp = Compilation & {
74
41
  routes: AppRoute[]
@@ -100,10 +67,10 @@ export type AppChecker = {
100
67
  */
101
68
  saw: (path: string | undefined) => void
102
69
  /**
103
- * The app's own `.abide`, read (`sourcesUnder`) — kept until `saw` hears anything move, so a
104
- * check asked again and again while an editor types re-reads nothing on disk.
70
+ * The app, laid out (`appLayout`) — kept until `saw` hears anything move, so a check asked again
71
+ * and again while an editor types re-reads nothing on disk.
105
72
  */
106
- sources: () => ReturnType<typeof sourcesUnder>
73
+ layout: () => Promise<AppLayout>
107
74
  /**
108
75
  * Whether the project it was opened on is still the app's; if so, flushes what `saw` held.
109
76
  *
@@ -136,21 +103,21 @@ export async function openChecker(appRoot: string): Promise<AppChecker> {
136
103
  session.root = root
137
104
  const moved = new Set<string>()
138
105
  let movedSomewhere = false
139
- let sources: ReturnType<typeof sourcesUnder> | undefined
106
+ let layout: Promise<AppLayout> | undefined
140
107
  return {
141
108
  session,
142
109
  root,
143
110
  saw: (path) => {
144
- sources = undefined
111
+ layout = undefined
145
112
  if (path === undefined) movedSomewhere = true
146
113
  else moved.add(path)
147
114
  },
148
- sources: () => {
149
- sources ??= sourcesUnder(root).catch((thrown: unknown) => {
150
- sources = undefined
115
+ layout: () => {
116
+ layout ??= appLayout(root).catch((thrown: unknown) => {
117
+ layout = undefined
151
118
  throw thrown
152
119
  })
153
- return sources
120
+ return layout
154
121
  },
155
122
  current: () => {
156
123
  if (projectFiles(root) !== opened) return false
@@ -183,7 +150,7 @@ function projectFiles(root: string): string {
183
150
  * `kept` is one the caller holds across runs and closes itself; one opened here is closed whatever
184
151
  * `run` did.
185
152
  */
186
- export async function withChecker<Result>(
153
+ async function withChecker<Result>(
187
154
  root: string,
188
155
  kept: AppChecker | undefined,
189
156
  run: (session: TypeSession) => Promise<Result>,
@@ -196,135 +163,6 @@ export async function withChecker<Result>(
196
163
  }
197
164
  }
198
165
 
199
- /**
200
- * Every `.abide` of the app's own, read, in path order — and the root as bun resolves it.
201
- *
202
- * `Bun.resolveSync` answers a symlink-free path (`/tmp` is `/private/tmp` on macOS), so a root
203
- * spelled through a link would have every import resolve to a file this walk never listed, and
204
- * every component read as uninvoked. THE READS OVERLAP: nothing a source is read for depends on
205
- * another, and this runs again on every `abide dev` save.
206
- */
207
- export async function sourcesUnder(
208
- appRoot: string,
209
- ): Promise<{ root: string; sources: { path: string; text: string }[] }> {
210
- const root = realpathSync(appRoot)
211
- return { root, sources: await sourcesAt(root, abideUnder(root)) }
212
- }
213
-
214
- /** The `.abide` at each of `relatives`, below `root`, read. */
215
- async function sourcesAt(
216
- root: string,
217
- relatives: readonly string[],
218
- ): Promise<{ path: string; text: string }[]> {
219
- const paths: string[] = []
220
- for (const path of relatives) paths.push(`${root}/${path}`)
221
- const texts = await Promise.all(paths.map((path) => Bun.file(path).text()))
222
- const sources: { path: string; text: string }[] = []
223
- for (let at = 0; at < paths.length; at += 1)
224
- sources.push({ path: paths[at] as string, text: texts[at] as string })
225
- return sources
226
- }
227
-
228
- /**
229
- * `parsed` and every `.abide` outside them that one of them imports — a dependency's, or a linked
230
- * package's — parsed, and every one those import in turn; and every `.abide` under a directory one
231
- * of them mounts, at the position the mount gives it (23.19). With them, the app's own handler files
232
- * — `own`, below `root`, as its walk found them (`appFiles`) — and every one a mounted directory
233
- * holds, each at the position it answers by.
234
- *
235
- * 34.23 — COMPILED WITH THE APP, by the app's checker, so a component a package ships as source is
236
- * one of the app's in every way but its address: it is never routed (`placeOf`) unless mounted.
237
- */
238
- export async function withDependencies(
239
- root: string,
240
- parsed: readonly ParsedSource[],
241
- own: readonly string[],
242
- resolve: (specifier: string, from: string) => string | undefined,
243
- refused?: Diagnosis[],
244
- ): Promise<{
245
- sources: ParsedSource[]
246
- handlers: HandlerFile[]
247
- mounted: Compilation['mounted']
248
- }> {
249
- const all = [...parsed]
250
- const handlers: HandlerFile[] = []
251
- for (const relative of own)
252
- handlers.push({
253
- path: `${root}/${relative}`,
254
- relative,
255
- position: relative,
256
- })
257
- // The mounted handler files placed so far; the app's own is never mounted (23.20).
258
- const handled = new Set<string>()
259
- const mounted: Compilation['mounted'] = {}
260
- const byPath = new Map<string, ParsedSource>()
261
- for (const source of all) byPath.set(source.path, source)
262
- // 23.20 — a directory mounts once, and by whom.
263
- const mountedBy = new Map<string, string>()
264
- const refuse = (path: string, message: string): void => {
265
- if (refused === undefined) throw new Error(message)
266
- refused.push(refusal(path, new Error(message)))
267
- }
268
- const add = async (found: {
269
- path: string
270
- position?: string
271
- }): Promise<void> => {
272
- const text = await Bun.file(found.path).text()
273
- for (const one of parseSources([{ ...found, text }], refused)) {
274
- byPath.set(one.path, one)
275
- all.push(one)
276
- }
277
- }
278
- for (let at = 0; at < all.length; at += 1) {
279
- const source = all[at] as ParsedSource
280
- const position = source.position ?? relativeTo(root, source.path)
281
- const mounts = mountsOf(root, source.path, position, source.file)
282
- if (typeof mounts === 'string') {
283
- refuse(source.path, mounts)
284
- continue
285
- }
286
- for (const mount of mounts) {
287
- const other = mountedBy.get(mount.directory)
288
- if (other !== undefined) {
289
- refuse(
290
- source.path,
291
- `abide: ${other} and ${position} both mount ${mount.specifier}, and a directory answers at one place. RULEBOOK 23.20.`,
292
- )
293
- continue
294
- }
295
- mountedBy.set(mount.directory, position)
296
- mounted[relativeTo(root, mount.directory)] = mount.at
297
- for (const placed of mount.handlers) {
298
- // Two mounts can overlap, one directory inside the other; a file is placed once.
299
- if (handled.has(placed.path)) continue
300
- handled.add(placed.path)
301
- handlers.push({
302
- path: placed.path,
303
- relative: relativeTo(root, placed.path),
304
- position: placed.position,
305
- })
306
- }
307
- for (const { path, position: placed } of mount.files) {
308
- // A file a component import reached first takes its mount's position all the same.
309
- const known = byPath.get(path)
310
- if (known !== undefined) {
311
- known.position = placed
312
- continue
313
- }
314
- await add({ path, position: placed })
315
- }
316
- }
317
- for (const target of abideImportsOf(
318
- source.path,
319
- source.file,
320
- resolve,
321
- ).values()) {
322
- if (!byPath.has(target)) await add({ path: target })
323
- }
324
- }
325
- return { sources: all, handlers, mounted }
326
- }
327
-
328
166
  /**
329
167
  * The `Compilation` alone out of `app`, which is what is written to disk and served: the rest of a
330
168
  * compiled app is the commands' to read.
@@ -339,7 +177,8 @@ export function compilationOf(app: CompiledApp): Compilation {
339
177
  }
340
178
 
341
179
  /**
342
- * Compiles every `.abide` source and handler of the app at `appRoot` (`Compilation`).
180
+ * Compiles every `.abide` source and handler of the app at `appRoot` (`Compilation`), stopping at
181
+ * the first thing it refuses.
343
182
  *
344
183
  * `kept` is a checker the caller holds across compiles and closes itself.
345
184
  */
@@ -347,185 +186,162 @@ export async function compileApp(
347
186
  appRoot: string,
348
187
  kept?: AppChecker,
349
188
  ): Promise<CompiledApp> {
189
+ return (await compiled(appRoot, kept, false)).app
190
+ }
191
+
192
+ /**
193
+ * The app at `appRoot` compiled — THE ONE PIPELINE `abide build` and `abide check` both run, the
194
+ * layout, the reads, the lints and the emit, differing only in what a refusal does (`Refusals`) and
195
+ * in what is checked beside: a build stops at the first refusal, and a check (`checking`) reports
196
+ * every one at its file with what the checker says of each file (38.3), and goes on checking
197
+ * whatever a refusal does not stop (38.33).
198
+ */
199
+ export async function compiled(
200
+ appRoot: string,
201
+ kept: AppChecker | undefined,
202
+ checking: boolean,
203
+ ): Promise<{ app: CompiledApp; findings: Diagnosis[] }> {
350
204
  const root = realpathSync(appRoot)
351
- // ONE WALK for the sources, the handler files and the public files, refusing a tree 23.16-23.18
352
- // forbids before anything is read.
353
- const { abide, handlers, published } = appFiles(root)
354
- const sources = await sourcesAt(root, abide)
355
- // 16.2 — an app of handler files alone is still an app.
356
- if (sources.length === 0 && handlers.length === 0)
357
- return {
358
- files: {},
359
- tables: {},
360
- handlers: {},
361
- mounted: {},
362
- routes: [],
363
- mounts: [],
364
- sources: 0,
365
- warnings: [],
366
- published,
367
- }
205
+ const refusals = new Refusals(root, checking)
206
+ // ONE LAYOUT for the sources, the handler files and the public files, and what the tree refuses
207
+ // is met before the checker is asked anything.
208
+ const layout = await (kept?.layout() ?? appLayout(root))
209
+ refusals.adopt(layout.refused)
210
+ const app: CompiledApp = {
211
+ files: {},
212
+ tables: {},
213
+ handlers: {},
214
+ mounted: layout.mounted,
215
+ routes: [],
216
+ mounts: [],
217
+ sources: layout.own,
218
+ warnings: [],
219
+ published: layout.published,
220
+ }
221
+ // 16.2 — an app of handler files alone is still an app; one of neither has nothing to build, and
222
+ // a check still has its `.ts` to check.
223
+ if (
224
+ !checking &&
225
+ layout.sources.length === 0 &&
226
+ layout.handlers.length === 0
227
+ )
228
+ return { app, findings: [] }
368
229
 
369
- // One session for the whole build: opening it spawns the checker, and the cost is paid once
230
+ // One session for the whole compile: opening it spawns the checker, and the cost is paid once
370
231
  // rather than per file.
371
232
  return withChecker(root, kept, async (session) => {
372
- // A BUILD STOPS AT THE FIRST REFUSAL, so neither step is handed a list to report it in.
373
- const own = parseSources(sources)
374
- const resolve = resolverIn()
375
- const {
376
- sources: parsed,
377
- handlers: handlerFiles,
378
- mounted,
379
- } = await withDependencies(root, own, handlers, resolve)
380
- const { read, warnings } = await readSources(session, parsed)
381
- const { ambient, unkeyed } = await session.lints(root, parsed)
382
- // 11.37 — a build error, every read named, rather than a first one and a second run.
383
- if (ambient.length > 0) {
384
- const lines: string[] = []
385
- for (const read of ambient)
386
- lines.push(formatDiagnosis(read, process.cwd()))
387
- throw new Error(lines.join('\n'))
388
- }
389
- const emitted = await emitApp(
390
- root,
233
+ // 38.33 — A FILE WHOSE PROJECTION IS REFUSED (34.3, 34.17) is refused at its file, found here
234
+ // because every later step projects it and would refuse it for the whole app.
235
+ const projected: PlacedSource[] = []
236
+ for (const source of layout.sources)
237
+ try {
238
+ projectShadow(source.file)
239
+ projected.push(source)
240
+ } catch (thrown) {
241
+ refusals.at(source.path, thrown)
242
+ }
243
+ const { read, warnings } = await readSources(
391
244
  session,
392
- read,
393
- resolve,
394
- handlerFiles,
245
+ projected,
246
+ refusals,
395
247
  )
248
+ let found: Diagnosis[] = []
249
+ let emittable = read
250
+ if (checking) {
251
+ // THE APP'S OWN FILES ARE REPORTED ON: a dependency's is compiled with them (34.23), and
252
+ // what is wrong inside a package is the package's to report.
253
+ const reported: typeof read = []
254
+ for (const source of read)
255
+ if (!source.dependency) reported.push(source)
256
+ const diagnosed = await session.diagnose(reported, layout)
257
+ found = diagnosed.diagnoses
258
+ // A FILE WHOSE LOWERING REFUSED is `found` already, at its line, and is not emitted.
259
+ emittable = []
260
+ for (const source of read)
261
+ if (!diagnosed.refused.has(source.path)) emittable.push(source)
262
+ }
263
+ const { ambient, unkeyed } = await session.lints(root, projected)
264
+ // 11.37 — a build error, every read named.
265
+ refusals.adopt(ambient)
266
+ // What the emit throws with no one file to name is refused at the root; a refusal of a file
267
+ // is refused at it.
268
+ let emitWarnings: Diagnosis[] = []
269
+ try {
270
+ const whole = await emitApp(layout, session, emittable, refusals)
271
+ Object.assign(app, whole)
272
+ emitWarnings = whole.warnings
273
+ } catch (thrown) {
274
+ refusals.at(root, thrown)
275
+ }
276
+ // 11.30 — warned on, and the build goes on.
277
+ app.warnings = [...warnings, ...unkeyed, ...emitWarnings]
396
278
  return {
397
- ...emitted,
398
- mounted,
399
- published,
400
- sources: sources.length,
401
- // 11.30 — warned on, and the build goes on.
402
- warnings: [...warnings, ...unkeyed, ...emitted.warnings],
279
+ app,
280
+ findings: [
281
+ ...refusals.found,
282
+ ...unkeyed,
283
+ ...warnings,
284
+ ...emitWarnings,
285
+ ...found,
286
+ ],
403
287
  }
404
288
  })
405
289
  }
406
290
 
407
- /**
408
- * The emit half of `compileApp`, over sources already parsed and read.
409
- *
410
- * `abide check` has done both by then, file by file, and asks this for only what the whole app can
411
- * refuse: two handlers at one address, or a template the emit cannot lower.
412
- */
413
- export async function emitApp(
414
- root: string,
291
+ /** The emit half of `compiled`, over sources already laid out and read. */
292
+ async function emitApp(
293
+ layout: Pick<
294
+ AppLayout,
295
+ 'root' | 'handlers' | 'routes' | 'errors' | 'published'
296
+ >,
415
297
  session: TypeSession,
416
- sources: readonly ReadSource[],
417
- /** The resolver the sources' dependencies were found with, whose answers it keeps. */
418
- resolve: (specifier: string, from: string) => string | undefined,
419
- /** The handler files, the app's own and each mounted one (`withDependencies`). */
420
- handlerFiles: readonly HandlerFile[],
421
- /** Told of each refusal at its file; the build's throws the first. */
422
- refuse: Refuse = THROW,
298
+ sources: readonly ReadSource<PlacedSource>[],
299
+ refusals: Refusals,
423
300
  ): Promise<
424
301
  Pick<
425
302
  CompiledApp,
426
303
  'files' | 'tables' | 'handlers' | 'routes' | 'mounts' | 'warnings'
427
304
  >
428
305
  > {
306
+ const { root, routes, errors } = layout
429
307
  const files: Record<string, CompiledFile> = {}
430
308
  const tables: Record<string, string> = {}
431
- const routes: AppRoute[] = []
432
- // 32.17 AND 34.14 — WHICH FILES ARE INVOKED AS COMPONENTS, and where each import lands. A
433
- // caller's client module imports its child's, so a child with nothing to hydrate still
434
- // needs one to exist (`emitClient`'s no-op), and an invoked file carries its own markup
435
- // (`emitClient`). Built before the emit loop because a page may invoke a component the
436
- // walk reaches later.
437
- const graph = appGraph(root, sources, resolve)
438
- const parsed = graph.nodes
439
-
440
- // 35.1, 35.12 — every `layout.abide` by the route it covers, one to a route; 35.13 — and one
441
- // with no template wraps nothing, being the route's hooks alone.
442
- const layouts: AppLayout[] = []
443
- const layoutAt = new Map<string, string>()
444
- const errorAt = new Map<string, string>()
445
- for (const entry of parsed) {
446
- if (entry.kind !== 'layout' && entry.kind !== 'error') continue
447
- const route = routeOf(entry.position)
448
- const held = entry.kind === 'layout' ? layoutAt : errorAt
449
- const other = held.get(shapeOf(route))
450
- if (other !== undefined) {
451
- refuse(
452
- entry.relative,
453
- new Error(
454
- `abide: ${other} and ${entry.relative} are both the ${entry.kind}.abide at ${route}. RULEBOOK 35.12.`,
455
- ),
456
- )
457
- continue
458
- }
459
- held.set(shapeOf(route), entry.relative)
460
- if (entry.kind === 'layout' && entry.file.template.length > 0)
461
- layouts.push({
462
- source: entry.relative,
463
- segments: parseRoutePattern(route),
464
- })
465
- }
466
- // 35.2 — every `error.abide`, with the route it answers for and the layouts around it.
467
- const errors: AppRoute[] = []
468
- for (const entry of parsed) {
469
- if (entry.kind !== 'page' && entry.kind !== 'error') continue
470
- // 23.3, 35.14 — a page or an `error.abide` with nothing to paint is its route's hooks
471
- // alone.
472
- if (!paints(entry.file)) continue
473
- ;(entry.kind === 'page' ? routes : errors).push({
474
- pattern: entry.route as string,
475
- source: entry.relative,
476
- layouts: layoutsAround(entry.route as string, layouts),
477
- })
478
- }
479
-
480
309
  // 16.2 AND 16.28 — THE HANDLERS, read before a module is emitted: the server mounts each one,
481
310
  // and the browser holds its wrapper in its place — a handler file's as that file, a private
482
311
  // handler's in the server block's place in its own file's module. Written whether or not there
483
312
  // is a page: a handler is answered over http to a caller that has none.
484
313
  const { mounts, warnings } = await handlersUnder(
485
314
  session,
486
- parsed,
487
- handlerFiles,
488
- refuse,
315
+ sources,
316
+ layout.handlers,
317
+ refusals,
489
318
  )
490
- refuseSharedRoutes(routes, mounts, refuse)
491
- refuseStrayLauncher(parsed, handlerFiles, refuse)
319
+ refuseSharedRoutes(routes, mounts, refusals)
320
+ refuseAddressCollision(routes, mounts, layout.published, refusals)
492
321
  const standIns = standInsOf(mounts)
493
322
  // 37.1 — every module a server block or a handler file runs in, for the boot to import before
494
323
  // `onStart`.
495
324
  const servers: string[] = []
496
- for (const file of handlerFiles) servers.push(file.relative)
325
+ for (const file of layout.handlers) servers.push(file.relative)
497
326
  let transitions = false
498
- for (const entry of parsed) {
499
- // 35.16 — of the files with an address, the root layout alone is outermost in every render.
500
- if (
501
- entry.file.document !== undefined &&
502
- entry.kind !== 'component' &&
503
- !(entry.kind === 'layout' && routeOf(entry.position) === '/')
504
- ) {
505
- refuse(
506
- entry.relative,
507
- new Error(
508
- `abide: ${entry.relative} writes <html>, and of the pages, layouts and error pages only the root layout.abide writes the document. RULEBOOK 35.16.`,
509
- ),
327
+ for (const entry of sources) {
328
+ let emitted: ReturnType<typeof emitSource>
329
+ try {
330
+ emitted = emitSource(
331
+ entry,
332
+ standIns.get(entry.relative),
333
+ entry.text,
510
334
  )
335
+ } catch (thrown) {
336
+ refusals.at(entry.path, thrown)
511
337
  continue
512
338
  }
513
- const emitted = emitSource(
514
- entry.relative,
515
- entry.file,
516
- entry.answer,
517
- graph.invoked.has(entry.path),
518
- standIns.get(entry.relative),
519
- entry.text,
520
- entry.position,
521
- )
522
339
  if (entry.file.servers.length > 0) servers.push(entry.relative)
523
340
  transitions ||= emitted.transitions
524
341
  // 34.26 — a dependency's module runs on the app's runtime, whatever the package carries.
525
- const onAppRuntime =
526
- placeOf(entry.relative) === 'dependency'
527
- ? (text: string) => onAbideOf(root, entry.path, text)
528
- : (text: string) => text
342
+ const onAppRuntime = entry.dependency
343
+ ? (text: string) => onAbideOf(root, entry.path, text)
344
+ : (text: string) => text
529
345
  files[entry.relative] = {
530
346
  server: onAppRuntime(emitted.server),
531
347
  client: onAppRuntime(emitted.client),
@@ -541,11 +357,10 @@ export async function emitApp(
541
357
  // forgotten. The loaders are dynamic imports, which is what keeps a destination's
542
358
  // markup and bindings off the entry page's bundle until somebody navigates there.
543
359
  //
544
- // TWO TABLES OFF ONE WALK, each importing a file's `?component` face — the same addresses
360
+ // TWO TABLES OFF ONE LAYOUT, each importing a file's `?component` face — the same addresses
545
361
  // either way, which is what keeps the pair from being two walks that can disagree. The
546
- // server's is written with no
547
- // page too — an app of handlers alone still answers — and the client's, with the entry
548
- // that boots it, only where there is a page to boot.
362
+ // server's is written with no page too — an app of handlers alone still answers — and the
363
+ // client's, with the entry that boots it, only where there is a page to boot.
549
364
  tables[SERVER_TABLE] = serverTable(routes, errors, servers, mounts)
550
365
  // 16.74 — what the browser holds of each handler file, and the route its hooks are filed at:
551
366
  // read by the module's load, which runs before the server table is imported.
@@ -584,11 +399,7 @@ export async function emitApp(
584
399
  function onAbideOf(root: string, path: string, text: string): string {
585
400
  const from = dirname(realpathSync(path))
586
401
  return respecified(text, (specifier) => {
587
- if (
588
- specifier !== SHARED_SPECIFIER &&
589
- !specifier.startsWith(`${SHARED_SPECIFIER}/`)
590
- )
591
- return undefined
402
+ if (!isAbideSpecifier(specifier)) return undefined
592
403
  let target: string
593
404
  try {
594
405
  target = resolvedFile(specifier, root)
@@ -619,41 +430,6 @@ function tableImport(source: string): string {
619
430
  )
620
431
  }
621
432
 
622
- // A layout and the route it covers, parsed.
623
- type AppLayout = { source: string; segments: Segment[] }
624
-
625
- /**
626
- * The layouts a page renders inside, outermost first.
627
- *
628
- * 35.1 — every `layout.abide` whose route lies ON THE WAY DOWN to the page's wraps it and no other
629
- * does: `src/layout.abide` wraps the page `src/media/index.abide` serves at `/media`, and so does
630
- * `src/[section]/layout.abide`. So the test is a prefix of the route, kind by kind, and the nesting
631
- * order is the route's depth.
632
- */
633
- function layoutsAround(route: string, layouts: AppLayout[]): string[] {
634
- const page = parseRoutePattern(route)
635
- const chain: AppLayout[] = []
636
- for (const layout of layouts)
637
- if (coversRoute(layout.segments, page)) chain.push(layout)
638
- // A shorter route is an outer one: they are prefixes of each other by construction.
639
- chain.sort((one, two) => one.segments.length - two.segments.length)
640
- const sources: string[] = []
641
- for (const layout of chain) sources.push(layout.source)
642
- return sources
643
- }
644
-
645
- /**
646
- * A route with its parameters' names erased: what two routes answering one address share.
647
- *
648
- * 19.1 — `/media/[id]` and `/media/[slug]` are one route.
649
- */
650
- function shapeOf(route: string): string {
651
- let shape = ''
652
- for (const segment of parseRoutePattern(route))
653
- shape += `/${segment.kind === 'static' ? segment.name : `[${segment.kind}]`}`
654
- return shape === '' ? '/' : shape
655
- }
656
-
657
433
  /**
658
434
  * Refuses, naming both, where two files answer one method at one route — at the second.
659
435
  *
@@ -662,7 +438,7 @@ function shapeOf(route: string): string {
662
438
  function refuseSharedRoutes(
663
439
  routes: AppRoute[],
664
440
  mounts: Mount[],
665
- refuse: Refuse,
441
+ refusals: Refusals,
666
442
  ): void {
667
443
  const owners = new Map<string, string>()
668
444
  const claim = (method: string, route: string, source: string): void => {
@@ -670,7 +446,7 @@ function refuseSharedRoutes(
670
446
  const owner = owners.get(key)
671
447
  if (owner === undefined) owners.set(key, source)
672
448
  else
673
- refuse(
449
+ refusals.at(
674
450
  source,
675
451
  new Error(
676
452
  `abide: ${owner} and ${source} both answer ${method} at ${route}. RULEBOOK 19.1.`,
@@ -682,110 +458,44 @@ function refuseSharedRoutes(
682
458
  }
683
459
 
684
460
  /**
685
- * Refuses, naming the file, where anything but the root's `launcher.abide` calls `onLauncher`.
461
+ * Refuses, naming both, where a public file claims a route's address or another public file's — at
462
+ * the public file.
686
463
  *
687
- * 38.107 — the launch loads that file alone, so a call anywhere else is never read.
464
+ * 36.13 — the walks that find routes and public files cannot see each other's collisions from
465
+ * inside either one; this is where they meet, and naming BOTH is the clause. Two `public`
466
+ * directories can claim one address too, since each mounts at the path above it (36.11). IN THE
467
+ * COMPILE, so the build, dev and check all ask it (38.33), and `abide start` trusts the build:
468
+ * `answerRequest` answers a public file AHEAD of any route on the ground that a collision cannot
469
+ * exist at run time, and 30.8 says dev resolves an address the way the built app does.
688
470
  */
689
- function refuseStrayLauncher(
690
- sources: readonly { relative: string; position: string; file: AbideFile }[],
691
- handlerFiles: readonly HandlerFile[],
692
- refuse: Refuse,
471
+ function refuseAddressCollision(
472
+ routes: AppRoute[],
473
+ mounts: Mount[],
474
+ published: Map<string, string>,
475
+ refusals: Refusals,
693
476
  ): void {
694
- const stray = (relative: string): void =>
695
- refuse(
696
- relative,
697
- new Error(
698
- `abide: ${relative} calls onLauncher, which is read from src/${LAUNCHER_PAGE} alone. Move the call there. RULEBOOK 38.107.`,
699
- ),
700
- )
701
- for (const source of sources)
702
- if (
703
- source.position !== LAUNCHER_PAGE &&
704
- source.file.servers.some(callsLauncher)
705
- )
706
- stray(source.relative)
707
- for (const file of handlerFiles)
708
- if (callsLauncher(readFileSync(file.path, 'utf8'))) stray(file.relative)
709
- }
710
-
711
- function callsLauncher(source: string): boolean {
712
- return (
713
- source.includes('onLauncher') &&
714
- hooksCalled(source).includes('onLauncher')
715
- )
716
- }
717
-
718
- /**
719
- * Resolves a specifier from a directory as bun does, or `undefined` where it does not resolve.
720
- *
721
- * A COMPONENT IMPORT RESOLVES THE WAY THE APP'S OWN IMPORTS DO — its import map, from the file
722
- * that wrote it. One that does not resolve is left as written, and the bundle names it. THE
723
- * ANSWER IS A REAL PATH, as every root handed to the compiler is.
724
- */
725
- export function resolverIn(): (
726
- specifier: string,
727
- from: string,
728
- ) => string | undefined {
729
- // ONE ANSWER PER SPECIFIER AND DIRECTORY for as long as the resolver lives: a compile resolves
730
- // every import once for its dependencies and again for its graph, and hands both one resolver.
731
- const answered = new Map<string, string | undefined>()
732
- return (specifier, from) => {
733
- const key = `${from}\0${specifier}`
734
- if (answered.has(key)) return answered.get(key)
735
- const found = resolveFrom(specifier, from)
736
- answered.set(key, found)
737
- return found
738
- }
739
- }
740
-
741
- function resolveFrom(specifier: string, from: string): string | undefined {
742
- try {
743
- return resolvedFile(specifier, from)
744
- } catch {
745
- // 38.65 — BUN KEEPS A DIRECTORY'S LISTING FOR THE LIFE OF THE PROCESS, and looks a `#` specifier up
746
- // in it with no second look — where a relative one that misses reads the directory again.
747
- // `abide dev` compiles in one process for its whole session, so a component created since
748
- // its directory was listed never resolved, and was read as uninvoked. The `imports` map is
749
- // read here instead, and the file it names looked for on disk.
750
- return specifier.startsWith('#')
751
- ? throughImports(specifier, from)
752
- : undefined
753
- }
754
- }
755
-
756
- /** A `#` specifier resolved through the nearest `package.json`'s `imports`, to a file that exists. */
757
- function throughImports(specifier: string, from: string): string | undefined {
758
- const manifest = nearest(from, 'package.json')
759
- if (manifest === undefined) return undefined
760
- const imports = (
761
- JSON.parse(readFileSync(manifest, 'utf8')) as {
762
- imports?: Record<string, unknown>
763
- }
764
- ).imports
765
- if (imports === undefined) return undefined
766
- const base = dirname(manifest)
767
- for (const [key, targets] of Object.entries(imports)) {
768
- const star = key.indexOf('*')
769
- let matched: string | undefined
770
- if (star === -1) matched = key === specifier ? '' : undefined
771
- else if (
772
- specifier.startsWith(key.slice(0, star)) &&
773
- specifier.endsWith(key.slice(star + 1)) &&
774
- specifier.length >= key.length - 1
775
- )
776
- matched = specifier.slice(
777
- star,
778
- specifier.length - (key.length - star - 1),
477
+ const patterns = new Set<string>()
478
+ for (const route of routes) patterns.add(route.pattern)
479
+ for (const mount of mounts) patterns.add(mount.address)
480
+ const claimed = new Map<string, string>()
481
+ for (const [source, address] of published) {
482
+ const other = claimed.get(address)
483
+ if (patterns.has(address))
484
+ refusals.at(
485
+ source,
486
+ new Error(
487
+ `abide: ${source} and the route ${address} claim the same address. RULEBOOK 36.13.`,
488
+ ),
779
489
  )
780
- if (matched === undefined) continue
781
- // A conditions object names no path of its own, and is passed over.
782
- for (const target of [targets].flat()) {
783
- if (typeof target !== 'string') continue
784
- const path = posix.join(base, target.replace('*', matched))
785
- if (existsSync(path)) return path
786
- }
490
+ else if (other !== undefined)
491
+ refusals.at(
492
+ source,
493
+ new Error(
494
+ `abide: ${other} and ${source} are both answered at ${address}. RULEBOOK 36.13.`,
495
+ ),
496
+ )
497
+ else claimed.set(address, source)
787
498
  }
788
- return undefined
789
499
  }
790
500
 
791
501
  /** The rows of either route table, one per route, `column` appending the fields only one carries. */
@@ -942,11 +652,6 @@ import { wire as __wire } from '${WIRE_SPECIFIER}'
942
652
  ${lines.join('')}`
943
653
  }
944
654
 
945
- /** Whether a page or an error page has anything to paint: a template, or a head. 23.3, 35.14. */
946
- export function paints(file: AbideFile): boolean {
947
- return file.template.length > 0 || file.head.length > 0
948
- }
949
-
950
655
  // THE ENTRY. An app does not write it: which module boots the client is the build's knowledge,
951
656
  // exactly as the route table is, and a hand-written entry is one more file to keep in step with a
952
657
  // layout that moves.