@abide/abide 0.104.0 → 0.105.1

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 (85) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/bin/abide.ts +80 -173
  3. package/docs/REGISTRY.md +17 -17
  4. package/docs/RULEBOOK.md +50 -12
  5. package/docs/abide.md +9 -9
  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 +45 -86
  10. package/src/compiler/appLayout.ts +447 -0
  11. package/src/compiler/appMap.ts +21 -20
  12. package/src/compiler/checkApp.ts +61 -97
  13. package/src/compiler/checkedShadow.ts +11 -9
  14. package/src/compiler/classifyRead.ts +3 -6
  15. package/src/compiler/compileApp.ts +180 -512
  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/readSources.ts +27 -42
  25. package/src/compiler/readsIn.ts +5 -16
  26. package/src/compiler/resolverIn.ts +77 -0
  27. package/src/server/abideModules.ts +92 -123
  28. package/src/server/answerRequest.ts +18 -4
  29. package/src/server/answerSocket.ts +136 -76
  30. package/src/server/answerValue.ts +28 -15
  31. package/src/server/appTree.ts +49 -71
  32. package/src/server/claudeApi.ts +2 -5
  33. package/src/server/clientBundle.ts +198 -0
  34. package/src/server/coerceArgs.ts +1 -1
  35. package/src/server/commands.ts +93 -57
  36. package/src/server/createApp.ts +90 -16
  37. package/src/server/deferredBuilds.ts +175 -0
  38. package/src/server/devWorker.ts +107 -319
  39. package/src/server/faceOf.ts +120 -0
  40. package/src/server/fenceServer.ts +10 -18
  41. package/src/server/index.ts +3 -0
  42. package/src/server/placement.ts +167 -0
  43. package/src/server/remote.ts +14 -42
  44. package/src/server/render.ts +25 -17
  45. package/src/server/renderComponent.ts +9 -5
  46. package/src/server/requestScope.ts +10 -4
  47. package/src/server/responseRelay.ts +389 -0
  48. package/src/server/runCompiled.ts +9 -12
  49. package/src/server/runDesktop.ts +6 -9
  50. package/src/server/serveBuild.ts +26 -73
  51. package/src/server/serveDev.ts +19 -176
  52. package/src/server/streamEvent.ts +29 -0
  53. package/src/server/withAbideSources.ts +2 -1
  54. package/src/shared/HandlerRoute.ts +68 -0
  55. package/src/shared/REFUSAL_KEY.ts +5 -0
  56. package/src/shared/answerOf.ts +105 -78
  57. package/src/shared/filledPath.ts +1 -9
  58. package/src/shared/framing.ts +127 -128
  59. package/src/shared/queryText.ts +15 -7
  60. package/src/shared/reactive/Cursor.ts +4 -2
  61. package/src/shared/reactive/Reactive.ts +285 -197
  62. package/src/shared/reactive/index.ts +11 -8
  63. package/src/shared/reactive/pairReactive.ts +1 -1
  64. package/src/shared/reactive/scoped.ts +4 -3
  65. package/src/shared/reactive/share.ts +11 -1
  66. package/src/shared/routePattern.ts +0 -123
  67. package/src/shared/rpc.ts +17 -24
  68. package/src/shared/rpcRequest.ts +18 -47
  69. package/src/shared/takeEarlyCall.ts +12 -37
  70. package/src/shared/url.ts +1 -1
  71. package/src/ui/Level.ts +276 -0
  72. package/src/ui/List.ts +17 -17
  73. package/src/ui/Router.ts +20 -87
  74. package/src/ui/applyHead.ts +92 -79
  75. package/src/ui/index.ts +4 -2
  76. package/src/ui/leave.ts +5 -0
  77. package/src/ui/reconcile.ts +23 -104
  78. package/src/ui/regions.ts +27 -2
  79. package/src/ui/rows.ts +123 -2
  80. package/src/ui/wire.ts +12 -27
  81. package/src/compiler/hooksCalled.ts +0 -35
  82. package/src/server/clientBuildOptions.ts +0 -54
  83. package/src/server/refuseAddressCollision.ts +0 -37
  84. package/src/shared/reactive/chunksOfStream.ts +0 -33
  85. package/src/ui/MOUNTING_LEVEL.ts +0 -18
@@ -1,51 +1,32 @@
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'
48
- import { importStatements, respecified } from './splitImports.ts'
26
+ import { projectShadow } from './projectShadow.ts'
27
+ import { Refusals } from './Refusals.ts'
28
+ import { type ReadSource, readSources } from './readSources.ts'
29
+ import { respecified } from './splitImports.ts'
49
30
  import { type Diagnosis, TypeSession } from './TypeSession.ts'
50
31
  import { writeDefaultProject } from './writeDefaultProject.ts'
51
32
 
@@ -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,186 +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, modules, published } = appFiles(root)
354
- refuseModuleHooks(root, modules)
355
- const sources = await sourcesAt(root, abide)
356
- // 16.2 — an app of handler files alone is still an app.
357
- if (sources.length === 0 && handlers.length === 0)
358
- return {
359
- files: {},
360
- tables: {},
361
- handlers: {},
362
- mounted: {},
363
- routes: [],
364
- mounts: [],
365
- sources: 0,
366
- warnings: [],
367
- published,
368
- }
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: [] }
369
229
 
370
- // 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
371
231
  // rather than per file.
372
232
  return withChecker(root, kept, async (session) => {
373
- // A BUILD STOPS AT THE FIRST REFUSAL, so neither step is handed a list to report it in.
374
- const own = parseSources(sources)
375
- const resolve = resolverIn()
376
- const {
377
- sources: parsed,
378
- handlers: handlerFiles,
379
- mounted,
380
- } = await withDependencies(root, own, handlers, resolve)
381
- const { read, warnings } = await readSources(session, parsed)
382
- const { ambient, unkeyed } = await session.lints(root, parsed)
383
- // 11.37 — a build error, every read named, rather than a first one and a second run.
384
- if (ambient.length > 0) {
385
- const lines: string[] = []
386
- for (const read of ambient)
387
- lines.push(formatDiagnosis(read, process.cwd()))
388
- throw new Error(lines.join('\n'))
389
- }
390
- const emitted = await emitApp(
391
- 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(
392
244
  session,
393
- read,
394
- resolve,
395
- handlerFiles,
245
+ projected,
246
+ refusals,
396
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]
397
278
  return {
398
- ...emitted,
399
- mounted,
400
- published,
401
- sources: sources.length,
402
- // 11.30 — warned on, and the build goes on.
403
- warnings: [...warnings, ...unkeyed, ...emitted.warnings],
279
+ app,
280
+ findings: [
281
+ ...refusals.found,
282
+ ...unkeyed,
283
+ ...warnings,
284
+ ...emitWarnings,
285
+ ...found,
286
+ ],
404
287
  }
405
288
  })
406
289
  }
407
290
 
408
- /**
409
- * The emit half of `compileApp`, over sources already parsed and read.
410
- *
411
- * `abide check` has done both by then, file by file, and asks this for only what the whole app can
412
- * refuse: two handlers at one address, or a template the emit cannot lower.
413
- */
414
- export async function emitApp(
415
- 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
+ >,
416
297
  session: TypeSession,
417
- sources: readonly ReadSource[],
418
- /** The resolver the sources' dependencies were found with, whose answers it keeps. */
419
- resolve: (specifier: string, from: string) => string | undefined,
420
- /** The handler files, the app's own and each mounted one (`withDependencies`). */
421
- handlerFiles: readonly HandlerFile[],
422
- /** Told of each refusal at its file; the build's throws the first. */
423
- refuse: Refuse = THROW,
298
+ sources: readonly ReadSource<PlacedSource>[],
299
+ refusals: Refusals,
424
300
  ): Promise<
425
301
  Pick<
426
302
  CompiledApp,
427
303
  'files' | 'tables' | 'handlers' | 'routes' | 'mounts' | 'warnings'
428
304
  >
429
305
  > {
306
+ const { root, routes, errors } = layout
430
307
  const files: Record<string, CompiledFile> = {}
431
308
  const tables: Record<string, string> = {}
432
- const routes: AppRoute[] = []
433
- // 32.17 AND 34.14 — WHICH FILES ARE INVOKED AS COMPONENTS, and where each import lands. A
434
- // caller's client module imports its child's, so a child with nothing to hydrate still
435
- // needs one to exist (`emitClient`'s no-op), and an invoked file carries its own markup
436
- // (`emitClient`). Built before the emit loop because a page may invoke a component the
437
- // walk reaches later.
438
- const graph = appGraph(root, sources, resolve)
439
- const parsed = graph.nodes
440
-
441
- // 35.1, 35.12 — every `layout.abide` by the route it covers, one to a route; 35.13 — and one
442
- // with no template wraps nothing, being the route's hooks alone.
443
- const layouts: AppLayout[] = []
444
- const layoutAt = new Map<string, string>()
445
- const errorAt = new Map<string, string>()
446
- for (const entry of parsed) {
447
- if (entry.kind !== 'layout' && entry.kind !== 'error') continue
448
- const route = routeOf(entry.position)
449
- const held = entry.kind === 'layout' ? layoutAt : errorAt
450
- const other = held.get(shapeOf(route))
451
- if (other !== undefined) {
452
- refuse(
453
- entry.relative,
454
- new Error(
455
- `abide: ${other} and ${entry.relative} are both the ${entry.kind}.abide at ${route}. RULEBOOK 35.12.`,
456
- ),
457
- )
458
- continue
459
- }
460
- held.set(shapeOf(route), entry.relative)
461
- if (entry.kind === 'layout' && entry.file.template.length > 0)
462
- layouts.push({
463
- source: entry.relative,
464
- segments: parseRoutePattern(route),
465
- })
466
- }
467
- // 35.2 — every `error.abide`, with the route it answers for and the layouts around it.
468
- const errors: AppRoute[] = []
469
- for (const entry of parsed) {
470
- if (entry.kind !== 'page' && entry.kind !== 'error') continue
471
- // 23.3, 35.14 — a page or an `error.abide` with nothing to paint is its route's hooks
472
- // alone.
473
- if (!paints(entry.file)) continue
474
- ;(entry.kind === 'page' ? routes : errors).push({
475
- pattern: entry.route as string,
476
- source: entry.relative,
477
- layouts: layoutsAround(entry.route as string, layouts),
478
- })
479
- }
480
-
481
309
  // 16.2 AND 16.28 — THE HANDLERS, read before a module is emitted: the server mounts each one,
482
310
  // and the browser holds its wrapper in its place — a handler file's as that file, a private
483
311
  // handler's in the server block's place in its own file's module. Written whether or not there
484
312
  // is a page: a handler is answered over http to a caller that has none.
485
313
  const { mounts, warnings } = await handlersUnder(
486
314
  session,
487
- parsed,
488
- handlerFiles,
489
- refuse,
315
+ sources,
316
+ layout.handlers,
317
+ refusals,
490
318
  )
491
- refuseSharedRoutes(routes, mounts, refuse)
492
- refuseStrayLauncher(parsed, handlerFiles, refuse)
319
+ refuseSharedRoutes(routes, mounts, refusals)
320
+ refuseAddressCollision(routes, mounts, layout.published, refusals)
493
321
  const standIns = standInsOf(mounts)
494
322
  // 37.1 — every module a server block or a handler file runs in, for the boot to import before
495
323
  // `onStart`.
496
324
  const servers: string[] = []
497
- for (const file of handlerFiles) servers.push(file.relative)
325
+ for (const file of layout.handlers) servers.push(file.relative)
498
326
  let transitions = false
499
- for (const entry of parsed) {
500
- // 35.16 — of the files with an address, the root layout alone is outermost in every render.
501
- if (
502
- entry.file.document !== undefined &&
503
- entry.kind !== 'component' &&
504
- !(entry.kind === 'layout' && routeOf(entry.position) === '/')
505
- ) {
506
- refuse(
507
- entry.relative,
508
- new Error(
509
- `abide: ${entry.relative} writes <html>, and of the pages, layouts and error pages only the root layout.abide writes the document. RULEBOOK 35.16.`,
510
- ),
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,
511
334
  )
335
+ } catch (thrown) {
336
+ refusals.at(entry.path, thrown)
512
337
  continue
513
338
  }
514
- const emitted = emitSource(
515
- entry.relative,
516
- entry.file,
517
- entry.answer,
518
- graph.invoked.has(entry.path),
519
- standIns.get(entry.relative),
520
- entry.text,
521
- entry.position,
522
- )
523
339
  if (entry.file.servers.length > 0) servers.push(entry.relative)
524
340
  transitions ||= emitted.transitions
525
341
  // 34.26 — a dependency's module runs on the app's runtime, whatever the package carries.
526
- const onAppRuntime =
527
- placeOf(entry.relative) === 'dependency'
528
- ? (text: string) => onAbideOf(root, entry.path, text)
529
- : (text: string) => text
342
+ const onAppRuntime = entry.dependency
343
+ ? (text: string) => onAbideOf(root, entry.path, text)
344
+ : (text: string) => text
530
345
  files[entry.relative] = {
531
346
  server: onAppRuntime(emitted.server),
532
347
  client: onAppRuntime(emitted.client),
@@ -542,11 +357,10 @@ export async function emitApp(
542
357
  // forgotten. The loaders are dynamic imports, which is what keeps a destination's
543
358
  // markup and bindings off the entry page's bundle until somebody navigates there.
544
359
  //
545
- // 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
546
361
  // either way, which is what keeps the pair from being two walks that can disagree. The
547
- // server's is written with no
548
- // page too — an app of handlers alone still answers — and the client's, with the entry
549
- // 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.
550
364
  tables[SERVER_TABLE] = serverTable(routes, errors, servers, mounts)
551
365
  // 16.74 — what the browser holds of each handler file, and the route its hooks are filed at:
552
366
  // read by the module's load, which runs before the server table is imported.
@@ -616,41 +430,6 @@ function tableImport(source: string): string {
616
430
  )
617
431
  }
618
432
 
619
- // A layout and the route it covers, parsed.
620
- type AppLayout = { source: string; segments: Segment[] }
621
-
622
- /**
623
- * The layouts a page renders inside, outermost first.
624
- *
625
- * 35.1 — every `layout.abide` whose route lies ON THE WAY DOWN to the page's wraps it and no other
626
- * does: `src/layout.abide` wraps the page `src/media/index.abide` serves at `/media`, and so does
627
- * `src/[section]/layout.abide`. So the test is a prefix of the route, kind by kind, and the nesting
628
- * order is the route's depth.
629
- */
630
- function layoutsAround(route: string, layouts: AppLayout[]): string[] {
631
- const page = parseRoutePattern(route)
632
- const chain: AppLayout[] = []
633
- for (const layout of layouts)
634
- if (coversRoute(layout.segments, page)) chain.push(layout)
635
- // A shorter route is an outer one: they are prefixes of each other by construction.
636
- chain.sort((one, two) => one.segments.length - two.segments.length)
637
- const sources: string[] = []
638
- for (const layout of chain) sources.push(layout.source)
639
- return sources
640
- }
641
-
642
- /**
643
- * A route with its parameters' names erased: what two routes answering one address share.
644
- *
645
- * 19.1 — `/media/[id]` and `/media/[slug]` are one route.
646
- */
647
- function shapeOf(route: string): string {
648
- let shape = ''
649
- for (const segment of parseRoutePattern(route))
650
- shape += `/${segment.kind === 'static' ? segment.name : `[${segment.kind}]`}`
651
- return shape === '' ? '/' : shape
652
- }
653
-
654
433
  /**
655
434
  * Refuses, naming both, where two files answer one method at one route — at the second.
656
435
  *
@@ -659,7 +438,7 @@ function shapeOf(route: string): string {
659
438
  function refuseSharedRoutes(
660
439
  routes: AppRoute[],
661
440
  mounts: Mount[],
662
- refuse: Refuse,
441
+ refusals: Refusals,
663
442
  ): void {
664
443
  const owners = new Map<string, string>()
665
444
  const claim = (method: string, route: string, source: string): void => {
@@ -667,7 +446,7 @@ function refuseSharedRoutes(
667
446
  const owner = owners.get(key)
668
447
  if (owner === undefined) owners.set(key, source)
669
448
  else
670
- refuse(
449
+ refusals.at(
671
450
  source,
672
451
  new Error(
673
452
  `abide: ${owner} and ${source} both answer ${method} at ${route}. RULEBOOK 19.1.`,
@@ -679,150 +458,44 @@ function refuseSharedRoutes(
679
458
  }
680
459
 
681
460
  /**
682
- * 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.
683
463
  *
684
- * 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.
685
470
  */
686
- function refuseStrayLauncher(
687
- sources: readonly { relative: string; position: string; file: AbideFile }[],
688
- handlerFiles: readonly HandlerFile[],
689
- refuse: Refuse,
690
- ): void {
691
- const stray = (relative: string): void =>
692
- refuse(
693
- relative,
694
- new Error(
695
- `abide: ${relative} calls onLauncher, which is read from src/${LAUNCHER_PAGE} alone. Move the call there. RULEBOOK 38.107.`,
696
- ),
697
- )
698
- for (const source of sources)
699
- if (
700
- source.position !== LAUNCHER_PAGE &&
701
- source.file.servers.some(callsLauncher)
702
- )
703
- stray(source.relative)
704
- for (const file of handlerFiles)
705
- if (callsLauncher(readFileSync(file.path, 'utf8'))) stray(file.relative)
706
- }
707
-
708
- /**
709
- * 37.13 — a hook called in a module that is neither a `.abide` nor a handler file runs as the module
710
- * is imported, before any file's route is set, and not at all where nothing imports it. A hook is a
711
- * name the module imports from abide, or a member of a namespace it does, so `emitter.onError(…)` in
712
- * a module that imports abide for `reactive` is no hook.
713
- */
714
- export function refuseModuleHooks(
715
- root: string,
716
- modules: readonly string[],
717
- refuse: Refuse = THROW,
471
+ function refuseAddressCollision(
472
+ routes: AppRoute[],
473
+ mounts: Mount[],
474
+ published: Map<string, string>,
475
+ refusals: Refusals,
718
476
  ): void {
719
- for (const module of modules) {
720
- const source = readFileSync(`${root}/${module}`, 'utf8')
721
- if (!source.includes(SHARED_SPECIFIER)) continue
722
- const imported = new Set<string>()
723
- for (const statement of importStatements(source))
724
- if (isAbideSpecifier(statement.specifier))
725
- for (const binding of statement.bindings)
726
- if (!binding.typeOnly) imported.add(binding.imported)
727
- const hook = hooksCalled(source).find(
728
- (name) => imported.has(name) || imported.has('*'),
729
- )
730
- if (hook !== undefined)
731
- refuse(
732
- module,
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,
733
486
  new Error(
734
- `abide: ${module} calls ${hook}, and it is neither a .abide nor a handler file, so the call has no route. Export what it is handed and call ${hook} in the page, layout or handler file it serves. RULEBOOK 37.13.`,
487
+ `abide: ${source} and the route ${address} claim the same address. RULEBOOK 36.13.`,
735
488
  ),
736
489
  )
737
- }
738
- }
739
-
740
- /** `@abide/abide` or one of its subpaths. */
741
- function isAbideSpecifier(specifier: string): boolean {
742
- return (
743
- specifier === SHARED_SPECIFIER ||
744
- specifier.startsWith(`${SHARED_SPECIFIER}/`)
745
- )
746
- }
747
-
748
- function callsLauncher(source: string): boolean {
749
- return (
750
- source.includes('onLauncher') &&
751
- hooksCalled(source).includes('onLauncher')
752
- )
753
- }
754
-
755
- /**
756
- * Resolves a specifier from a directory as bun does, or `undefined` where it does not resolve.
757
- *
758
- * A COMPONENT IMPORT RESOLVES THE WAY THE APP'S OWN IMPORTS DO — its import map, from the file
759
- * that wrote it. One that does not resolve is left as written, and the bundle names it. THE
760
- * ANSWER IS A REAL PATH, as every root handed to the compiler is.
761
- */
762
- export function resolverIn(): (
763
- specifier: string,
764
- from: string,
765
- ) => string | undefined {
766
- // ONE ANSWER PER SPECIFIER AND DIRECTORY for as long as the resolver lives: a compile resolves
767
- // every import once for its dependencies and again for its graph, and hands both one resolver.
768
- const answered = new Map<string, string | undefined>()
769
- return (specifier, from) => {
770
- const key = `${from}\0${specifier}`
771
- if (answered.has(key)) return answered.get(key)
772
- const found = resolveFrom(specifier, from)
773
- answered.set(key, found)
774
- return found
775
- }
776
- }
777
-
778
- function resolveFrom(specifier: string, from: string): string | undefined {
779
- try {
780
- return resolvedFile(specifier, from)
781
- } catch {
782
- // 38.65 — BUN KEEPS A DIRECTORY'S LISTING FOR THE LIFE OF THE PROCESS, and looks a `#` specifier up
783
- // in it with no second look — where a relative one that misses reads the directory again.
784
- // `abide dev` compiles in one process for its whole session, so a component created since
785
- // its directory was listed never resolved, and was read as uninvoked. The `imports` map is
786
- // read here instead, and the file it names looked for on disk.
787
- return specifier.startsWith('#')
788
- ? throughImports(specifier, from)
789
- : undefined
790
- }
791
- }
792
-
793
- /** A `#` specifier resolved through the nearest `package.json`'s `imports`, to a file that exists. */
794
- function throughImports(specifier: string, from: string): string | undefined {
795
- const manifest = nearest(from, 'package.json')
796
- if (manifest === undefined) return undefined
797
- const imports = (
798
- JSON.parse(readFileSync(manifest, 'utf8')) as {
799
- imports?: Record<string, unknown>
800
- }
801
- ).imports
802
- if (imports === undefined) return undefined
803
- const base = dirname(manifest)
804
- for (const [key, targets] of Object.entries(imports)) {
805
- const star = key.indexOf('*')
806
- let matched: string | undefined
807
- if (star === -1) matched = key === specifier ? '' : undefined
808
- else if (
809
- specifier.startsWith(key.slice(0, star)) &&
810
- specifier.endsWith(key.slice(star + 1)) &&
811
- specifier.length >= key.length - 1
812
- )
813
- matched = specifier.slice(
814
- star,
815
- specifier.length - (key.length - star - 1),
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
+ ),
816
496
  )
817
- if (matched === undefined) continue
818
- // A conditions object names no path of its own, and is passed over.
819
- for (const target of [targets].flat()) {
820
- if (typeof target !== 'string') continue
821
- const path = posix.join(base, target.replace('*', matched))
822
- if (existsSync(path)) return path
823
- }
497
+ else claimed.set(address, source)
824
498
  }
825
- return undefined
826
499
  }
827
500
 
828
501
  /** The rows of either route table, one per route, `column` appending the fields only one carries. */
@@ -979,11 +652,6 @@ import { wire as __wire } from '${WIRE_SPECIFIER}'
979
652
  ${lines.join('')}`
980
653
  }
981
654
 
982
- /** Whether a page or an error page has anything to paint: a template, or a head. 23.3, 35.14. */
983
- export function paints(file: AbideFile): boolean {
984
- return file.template.length > 0 || file.head.length > 0
985
- }
986
-
987
655
  // THE ENTRY. An app does not write it: which module boots the client is the build's knowledge,
988
656
  // exactly as the route table is, and a hand-written entry is one more file to keep in step with a
989
657
  // layout that moves.