@abide/abide 0.102.0 → 0.104.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 (73) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/bin/abide.ts +31 -28
  3. package/docs/REGISTRY.md +40 -36
  4. package/docs/RULEBOOK.md +113 -92
  5. package/docs/abide.md +788 -554
  6. package/package.json +2 -1
  7. package/scaffold/AGENTS.md +13 -6
  8. package/src/compiler/TypeSession.ts +108 -35
  9. package/src/compiler/appMap.ts +91 -0
  10. package/src/compiler/argsSchemaOf.ts +1 -1
  11. package/src/compiler/checkApp.ts +35 -18
  12. package/src/compiler/checkedShadow.ts +40 -7
  13. package/src/compiler/compileApp.ts +271 -76
  14. package/src/compiler/compiledEntry.ts +10 -6
  15. package/src/compiler/componentGraph.ts +6 -7
  16. package/src/compiler/declaredExports.ts +3 -3
  17. package/src/compiler/emitClient.ts +3 -3
  18. package/src/compiler/emitComponent.ts +9 -9
  19. package/src/compiler/emitSource.ts +5 -9
  20. package/src/compiler/handlersUnder.ts +114 -73
  21. package/src/compiler/hooksCalled.ts +35 -0
  22. package/src/compiler/mountsOf.ts +30 -18
  23. package/src/compiler/parseAbide.ts +12 -12
  24. package/src/compiler/projectShadow.ts +71 -46
  25. package/src/compiler/readsIn.ts +2 -2
  26. package/src/compiler/scaffold.ts +24 -21
  27. package/src/compiler/serveLanguage.ts +0 -24
  28. package/src/compiler/sharedModuleWrites.ts +2 -2
  29. package/src/compiler/splitImports.ts +4 -4
  30. package/src/compiler/writeDefaultProject.ts +28 -1
  31. package/src/server/MODULE_QUERIES.ts +12 -3
  32. package/src/server/abideModules.ts +156 -53
  33. package/src/server/answerRequest.ts +9 -4
  34. package/src/server/answerRpc.ts +18 -16
  35. package/src/server/appTree.ts +92 -25
  36. package/src/server/commands.ts +5 -4
  37. package/src/server/componentModules.d.ts +1 -2
  38. package/src/server/createApp.ts +23 -11
  39. package/src/server/devWorker.ts +5 -1
  40. package/src/server/fenceServer.ts +36 -13
  41. package/src/server/generatedSurfaces.ts +12 -53
  42. package/src/server/hooks.ts +17 -4
  43. package/src/server/index.ts +1 -0
  44. package/src/server/installSourceMaps.ts +6 -1
  45. package/src/server/launcherOptions.ts +12 -13
  46. package/src/server/launcherPage.ts +2 -2
  47. package/src/server/nativeMenu.ts +3 -3
  48. package/src/server/preload.ts +13 -0
  49. package/src/server/runDesktop.ts +11 -16
  50. package/src/server/serveDev.ts +8 -2
  51. package/src/server/surfaceOf.ts +2 -0
  52. package/src/server/versionOf.ts +1 -2
  53. package/src/server/withAbideSources.ts +1 -1
  54. package/src/shared/ADDRESSES.ts +3 -0
  55. package/src/shared/LAUNCHER_FILES.ts +9 -0
  56. package/src/shared/RESERVED_DIRECTORIES.ts +19 -4
  57. package/src/shared/index.ts +2 -1
  58. package/src/{ui → shared}/menuBridge.ts +1 -1
  59. package/src/shared/menuItem.ts +2 -2
  60. package/src/shared/onMenu.ts +58 -0
  61. package/src/shared/reactive/Cursor.ts +66 -123
  62. package/src/shared/reactive/Reactive.ts +74 -28
  63. package/src/shared/reactive/callLoad.ts +4 -18
  64. package/src/shared/reactive/chunksOfStream.ts +10 -34
  65. package/src/{ui → shared}/reportMenu.ts +7 -9
  66. package/src/shared/routePattern.ts +74 -23
  67. package/src/shared/rpc.ts +1 -1
  68. package/src/ui/Router.ts +0 -6
  69. package/src/compiler/syncTypes.ts +0 -32
  70. package/src/compiler/writeTypes.ts +0 -82
  71. package/src/server/LAUNCHER_FILES.ts +0 -9
  72. /package/scaffold/src/{page.abide → index.abide} +0 -0
  73. /package/src/{compiler → shared}/SPECIFIERS.ts +0 -0
@@ -12,6 +12,7 @@ import { COMPONENT_QUERY } from '#server/MODULE_QUERIES.ts'
12
12
  import { ROUTES_TABLE, SERVER_TABLE } from '#server/TABLES.ts'
13
13
  import { VIRTUAL_DIRECTORY } from '#server/VIRTUAL_DIRECTORY.ts'
14
14
  import { CLIENT_ADDRESS } from '#shared/ADDRESSES.ts'
15
+ import { LAUNCHER_PAGE } from '#shared/LAUNCHER_FILES.ts'
15
16
  import {
16
17
  coversRoute,
17
18
  parseRoutePattern,
@@ -19,10 +20,21 @@ import {
19
20
  routeOf,
20
21
  type Segment,
21
22
  } from '#shared/routePattern.ts'
23
+ import {
24
+ ROUTING_SPECIFIER,
25
+ SHARED_SPECIFIER,
26
+ WIRE_SPECIFIER,
27
+ } from '#shared/SPECIFIERS.ts'
22
28
  import { abideImportsOf, appGraph } from './componentGraph.ts'
23
29
  import { emitSource } from './emitSource.ts'
24
30
  import { formatDiagnosis } from './formatDiagnosis.ts'
25
- import { handlersUnder } from './handlersUnder.ts'
31
+ import {
32
+ type HandlerFile,
33
+ handlersUnder,
34
+ type Refuse,
35
+ THROW,
36
+ } from './handlersUnder.ts'
37
+ import { hooksCalled } from './hooksCalled.ts'
26
38
  import { mountsOf } from './mountsOf.ts'
27
39
  import { nearest } from './nearest.ts'
28
40
  import type { AbideFile } from './parseAbide.ts'
@@ -33,15 +45,9 @@ import {
33
45
  readSources,
34
46
  refusal,
35
47
  } from './readSources.ts'
36
- import {
37
- ROUTING_SPECIFIER,
38
- SHARED_SPECIFIER,
39
- WIRE_SPECIFIER,
40
- } from './SPECIFIERS.ts'
41
- import { respecified } from './splitImports.ts'
48
+ import { importStatements, respecified } from './splitImports.ts'
42
49
  import { type Diagnosis, TypeSession } from './TypeSession.ts'
43
50
  import { writeDefaultProject } from './writeDefaultProject.ts'
44
- import { writeTypes } from './writeTypes.ts'
45
51
 
46
52
  // AN APP'S `.abide` SOURCES, COMPILED — every file keyed by its path below the root, and loaded
47
53
  // there through its `?component` face (`abideModules`). Nothing here touches the output directory:
@@ -222,18 +228,35 @@ async function sourcesAt(
222
228
  /**
223
229
  * `parsed` and every `.abide` outside them that one of them imports — a dependency's, or a linked
224
230
  * package's — parsed, and every one those import in turn; and every `.abide` under a directory one
225
- * of them mounts, at the position the mount gives it (23.19).
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.
226
234
  *
227
- * 34.27 — COMPILED WITH THE APP, by the app's checker, so a component a package ships as source is
235
+ * 34.23 — COMPILED WITH THE APP, by the app's checker, so a component a package ships as source is
228
236
  * one of the app's in every way but its address: it is never routed (`placeOf`) unless mounted.
229
237
  */
230
238
  export async function withDependencies(
231
239
  root: string,
232
240
  parsed: readonly ParsedSource[],
241
+ own: readonly string[],
233
242
  resolve: (specifier: string, from: string) => string | undefined,
234
243
  refused?: Diagnosis[],
235
- ): Promise<ParsedSource[]> {
244
+ ): Promise<{
245
+ sources: ParsedSource[]
246
+ handlers: HandlerFile[]
247
+ mounted: Compilation['mounted']
248
+ }> {
236
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'] = {}
237
260
  const byPath = new Map<string, ParsedSource>()
238
261
  for (const source of all) byPath.set(source.path, source)
239
262
  // 23.20 — a directory mounts once, and by whom.
@@ -270,6 +293,17 @@ export async function withDependencies(
270
293
  continue
271
294
  }
272
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
+ }
273
307
  for (const { path, position: placed } of mount.files) {
274
308
  // A file a component import reached first takes its mount's position all the same.
275
309
  const known = byPath.get(path)
@@ -288,7 +322,20 @@ export async function withDependencies(
288
322
  if (!byPath.has(target)) await add({ path: target })
289
323
  }
290
324
  }
291
- return all
325
+ return { sources: all, handlers, mounted }
326
+ }
327
+
328
+ /**
329
+ * The `Compilation` alone out of `app`, which is what is written to disk and served: the rest of a
330
+ * compiled app is the commands' to read.
331
+ */
332
+ export function compilationOf(app: CompiledApp): Compilation {
333
+ return {
334
+ files: app.files,
335
+ handlers: app.handlers,
336
+ tables: app.tables,
337
+ mounted: app.mounted,
338
+ }
292
339
  }
293
340
 
294
341
  /**
@@ -301,14 +348,18 @@ export async function compileApp(
301
348
  kept?: AppChecker,
302
349
  ): Promise<CompiledApp> {
303
350
  const root = realpathSync(appRoot)
304
- // ONE WALK for the sources and the public files, refusing a tree 23.16-23.18 forbids before
305
- // anything is read.
306
- const { abide, published } = appFiles(root)
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)
307
355
  const sources = await sourcesAt(root, abide)
308
- if (sources.length === 0)
356
+ // 16.2 — an app of handler files alone is still an app.
357
+ if (sources.length === 0 && handlers.length === 0)
309
358
  return {
310
359
  files: {},
311
360
  tables: {},
361
+ handlers: {},
362
+ mounted: {},
312
363
  routes: [],
313
364
  mounts: [],
314
365
  sources: 0,
@@ -321,9 +372,12 @@ export async function compileApp(
321
372
  return withChecker(root, kept, async (session) => {
322
373
  // A BUILD STOPS AT THE FIRST REFUSAL, so neither step is handed a list to report it in.
323
374
  const own = parseSources(sources)
324
- await writeTypes(root, own)
325
375
  const resolve = resolverIn()
326
- const parsed = await withDependencies(root, own, resolve)
376
+ const {
377
+ sources: parsed,
378
+ handlers: handlerFiles,
379
+ mounted,
380
+ } = await withDependencies(root, own, handlers, resolve)
327
381
  const { read, warnings } = await readSources(session, parsed)
328
382
  const { ambient, unkeyed } = await session.lints(root, parsed)
329
383
  // 11.37 — a build error, every read named, rather than a first one and a second run.
@@ -333,9 +387,16 @@ export async function compileApp(
333
387
  lines.push(formatDiagnosis(read, process.cwd()))
334
388
  throw new Error(lines.join('\n'))
335
389
  }
336
- const emitted = await emitApp(root, session, read, resolve)
390
+ const emitted = await emitApp(
391
+ root,
392
+ session,
393
+ read,
394
+ resolve,
395
+ handlerFiles,
396
+ )
337
397
  return {
338
398
  ...emitted,
399
+ mounted,
339
400
  published,
340
401
  sources: sources.length,
341
402
  // 11.30 — warned on, and the build goes on.
@@ -356,8 +417,15 @@ export async function emitApp(
356
417
  sources: readonly ReadSource[],
357
418
  /** The resolver the sources' dependencies were found with, whose answers it keeps. */
358
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,
359
424
  ): Promise<
360
- Pick<CompiledApp, 'files' | 'tables' | 'routes' | 'mounts' | 'warnings'>
425
+ Pick<
426
+ CompiledApp,
427
+ 'files' | 'tables' | 'handlers' | 'routes' | 'mounts' | 'warnings'
428
+ >
361
429
  > {
362
430
  const files: Record<string, CompiledFile> = {}
363
431
  const tables: Record<string, string> = {}
@@ -380,10 +448,15 @@ export async function emitApp(
380
448
  const route = routeOf(entry.position)
381
449
  const held = entry.kind === 'layout' ? layoutAt : errorAt
382
450
  const other = held.get(shapeOf(route))
383
- if (other !== undefined)
384
- throw new Error(
385
- `abide: ${other} and ${entry.relative} are both the ${entry.kind}.abide at ${route}. RULEBOOK 35.12.`,
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
+ ),
386
457
  )
458
+ continue
459
+ }
387
460
  held.set(shapeOf(route), entry.relative)
388
461
  if (entry.kind === 'layout' && entry.file.template.length > 0)
389
462
  layouts.push({
@@ -395,8 +468,8 @@ export async function emitApp(
395
468
  const errors: AppRoute[] = []
396
469
  for (const entry of parsed) {
397
470
  if (entry.kind !== 'page' && entry.kind !== 'error') continue
398
- // 23.3, 35.14 — a `page.abide` or an `error.abide` with nothing to paint is its route's
399
- // handlers and hooks alone.
471
+ // 23.3, 35.14 — a page or an `error.abide` with nothing to paint is its route's hooks
472
+ // alone.
400
473
  if (!paints(entry.file)) continue
401
474
  ;(entry.kind === 'page' ? routes : errors).push({
402
475
  pattern: entry.route as string,
@@ -406,14 +479,22 @@ export async function emitApp(
406
479
  }
407
480
 
408
481
  // 16.2 AND 16.28 — THE HANDLERS, read before a module is emitted: the server mounts each one,
409
- // and the browser's module of the file declaring it carries its wrapper in the server block's
410
- // place (34.23). Written whether or not there is a page: a handler is answered over http to a
411
- // caller that has none.
412
- const { mounts, warnings } = await handlersUnder(session, parsed)
413
- refuseSharedRoutes(routes, mounts)
482
+ // and the browser holds its wrapper in its place — a handler file's as that file, a private
483
+ // handler's in the server block's place in its own file's module. Written whether or not there
484
+ // is a page: a handler is answered over http to a caller that has none.
485
+ const { mounts, warnings } = await handlersUnder(
486
+ session,
487
+ parsed,
488
+ handlerFiles,
489
+ refuse,
490
+ )
491
+ refuseSharedRoutes(routes, mounts, refuse)
492
+ refuseStrayLauncher(parsed, handlerFiles, refuse)
414
493
  const standIns = standInsOf(mounts)
415
- // 37.1 — every module a server block runs in, for the boot to import before `onStart`.
494
+ // 37.1 — every module a server block or a handler file runs in, for the boot to import before
495
+ // `onStart`.
416
496
  const servers: string[] = []
497
+ for (const file of handlerFiles) servers.push(file.relative)
417
498
  let transitions = false
418
499
  for (const entry of parsed) {
419
500
  // 35.16 — of the files with an address, the root layout alone is outermost in every render.
@@ -421,10 +502,15 @@ export async function emitApp(
421
502
  entry.file.document !== undefined &&
422
503
  entry.kind !== 'component' &&
423
504
  !(entry.kind === 'layout' && routeOf(entry.position) === '/')
424
- )
425
- throw new Error(
426
- `abide: ${entry.relative} writes <html>, and of the pages, layouts and error pages only the root layout.abide writes the document. RULEBOOK 35.16.`,
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
+ ),
427
511
  )
512
+ continue
513
+ }
428
514
  const emitted = emitSource(
429
515
  entry.relative,
430
516
  entry.file,
@@ -436,7 +522,7 @@ export async function emitApp(
436
522
  )
437
523
  if (entry.file.servers.length > 0) servers.push(entry.relative)
438
524
  transitions ||= emitted.transitions
439
- // 34.30 — a dependency's module runs on the app's runtime, whatever the package carries.
525
+ // 34.26 — a dependency's module runs on the app's runtime, whatever the package carries.
440
526
  const onAppRuntime =
441
527
  placeOf(entry.relative) === 'dependency'
442
528
  ? (text: string) => onAbideOf(root, entry.path, text)
@@ -451,7 +537,7 @@ export async function emitApp(
451
537
  }
452
538
  }
453
539
  // THE ROUTE TABLE, as the build's own knowledge rather than a list an app maintains. Which
454
- // addresses exist is a fact about the file tree (a `page.abide` is an address, 23.3),
540
+ // addresses exist is a fact about the file tree (a page is an address, 23.3),
455
541
  // so a route that is not a file cannot be written and a file that is one cannot be
456
542
  // forgotten. The loaders are dynamic imports, which is what keeps a destination's
457
543
  // markup and bindings off the entry page's bundle until somebody navigates there.
@@ -462,6 +548,15 @@ export async function emitApp(
462
548
  // page too — an app of handlers alone still answers — and the client's, with the entry
463
549
  // that boots it, only where there is a page to boot.
464
550
  tables[SERVER_TABLE] = serverTable(routes, errors, servers, mounts)
551
+ // 16.74 — what the browser holds of each handler file, and the route its hooks are filed at:
552
+ // read by the module's load, which runs before the server table is imported.
553
+ const handlers: Compilation['handlers'] = {}
554
+ for (const mount of mounts)
555
+ if (!mount.private)
556
+ handlers[mount.source] = {
557
+ scope: mount.scope,
558
+ client: standIns.get(mount.source) ?? '',
559
+ }
465
560
  if (routes.length > 0) {
466
561
  tables[ROUTES_TABLE] = clientTable(routes, transitions)
467
562
  // The entry's SOURCE carries the name the bundle is written under — `entry-<hash>.js`
@@ -471,6 +566,7 @@ export async function emitApp(
471
566
  return {
472
567
  files,
473
568
  tables,
569
+ handlers,
474
570
  routes,
475
571
  mounts,
476
572
  warnings,
@@ -481,7 +577,7 @@ export async function emitApp(
481
577
  * `text`, a module compiled from the dependency's `.abide` at `path`, with each of abide's own
482
578
  * specifiers written as the path to the file the app's root resolves it to.
483
579
  *
484
- * 34.30 — RELATIVE, so the compilation still serves once the app and its `node_modules` are moved
580
+ * 34.26 — RELATIVE, so the compilation still serves once the app and its `node_modules` are moved
485
581
  * together, and THE FILE the app's own code loads, so the two are one module instance. A path and
486
582
  * not a specifier a plugin answers: bun's runtime hands a plugin no bare specifier that names no
487
583
  * file, so a mark on `@abide/abide/server` was never read.
@@ -489,11 +585,7 @@ export async function emitApp(
489
585
  function onAbideOf(root: string, path: string, text: string): string {
490
586
  const from = dirname(realpathSync(path))
491
587
  return respecified(text, (specifier) => {
492
- if (
493
- specifier !== SHARED_SPECIFIER &&
494
- !specifier.startsWith(`${SHARED_SPECIFIER}/`)
495
- )
496
- return undefined
588
+ if (!isAbideSpecifier(specifier)) return undefined
497
589
  let target: string
498
590
  try {
499
591
  target = resolvedFile(specifier, root)
@@ -517,7 +609,11 @@ function placing(mappings: string | undefined): string | undefined {
517
609
  * the module the file compiles to.
518
610
  */
519
611
  function tableImport(source: string): string {
520
- return JSON.stringify(`../${source}${COMPONENT_QUERY}`)
612
+ return JSON.stringify(
613
+ source.endsWith('.abide')
614
+ ? `../${source}${COMPONENT_QUERY}`
615
+ : `../${source}`,
616
+ )
521
617
  }
522
618
 
523
619
  // A layout and the route it covers, parsed.
@@ -527,7 +623,7 @@ type AppLayout = { source: string; segments: Segment[] }
527
623
  * The layouts a page renders inside, outermost first.
528
624
  *
529
625
  * 35.1 — every `layout.abide` whose route lies ON THE WAY DOWN to the page's wraps it and no other
530
- * does: `src/layout.abide` wraps the page `src/media/page.abide` serves at `/media`, and so does
626
+ * does: `src/layout.abide` wraps the page `src/media/index.abide` serves at `/media`, and so does
531
627
  * `src/[section]/layout.abide`. So the test is a prefix of the route, kind by kind, and the nesting
532
628
  * order is the route's depth.
533
629
  */
@@ -556,25 +652,106 @@ function shapeOf(route: string): string {
556
652
  }
557
653
 
558
654
  /**
559
- * Throws, naming both, where two files answer one method at one route.
655
+ * Refuses, naming both, where two files answer one method at one route — at the second.
560
656
  *
561
657
  * 19.1 — a page answers `GET` at its own, and a handler its method at its address.
562
658
  */
563
- function refuseSharedRoutes(routes: AppRoute[], mounts: Mount[]): void {
659
+ function refuseSharedRoutes(
660
+ routes: AppRoute[],
661
+ mounts: Mount[],
662
+ refuse: Refuse,
663
+ ): void {
564
664
  const owners = new Map<string, string>()
565
665
  const claim = (method: string, route: string, source: string): void => {
566
666
  const key = `${method} ${shapeOf(route)}`
567
667
  const owner = owners.get(key)
568
- if (owner !== undefined)
569
- throw new Error(
570
- `abide: ${owner} and ${source} both answer ${method} at ${route}. RULEBOOK 19.1.`,
668
+ if (owner === undefined) owners.set(key, source)
669
+ else
670
+ refuse(
671
+ source,
672
+ new Error(
673
+ `abide: ${owner} and ${source} both answer ${method} at ${route}. RULEBOOK 19.1.`,
674
+ ),
571
675
  )
572
- owners.set(key, source)
573
676
  }
574
677
  for (const route of routes) claim('GET', route.pattern, route.source)
575
678
  for (const mount of mounts) claim(mount.method, mount.address, mount.source)
576
679
  }
577
680
 
681
+ /**
682
+ * Refuses, naming the file, where anything but the root's `launcher.abide` calls `onLauncher`.
683
+ *
684
+ * 38.107 — the launch loads that file alone, so a call anywhere else is never read.
685
+ */
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,
718
+ ): 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,
733
+ 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.`,
735
+ ),
736
+ )
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
+
578
755
  /**
579
756
  * Resolves a specifier from a directory as bun does, or `undefined` where it does not resolve.
580
757
  *
@@ -714,7 +891,8 @@ function serverTable(
714
891
  let handlers = ''
715
892
  for (const mount of mounts) {
716
893
  const fields = JSON.stringify(mount)
717
- // The module the handler is declared in — the file's server module, the route table's own.
894
+ // The module the handler is declared in — the handler file, or the `.abide`'s server
895
+ // module, the route table's own.
718
896
  handlers += ` { load: () => import(${tableImport(mount.source)}), ${fields.slice(1, -1)} },\n`
719
897
  }
720
898
  return `// Written by \`abide build\`. The addresses this app answers, and what paints each.
@@ -749,41 +927,58 @@ function clientModules(route: AppRoute): string {
749
927
  }
750
928
 
751
929
  /**
752
- * The wrappers the browser's module of each file exports in its server block's place, by source.
930
+ * The wrappers the browser holds in each handler's place, by source.
753
931
  *
754
- * 16.28, 34.23 — WHAT A BROWSER IMPORTS WHERE THE APP WROTE A HANDLER: the same export name, the
755
- * same call over the wire. The address is written RELATIVE to the bundle's own directory and
756
- * resolved against the chunk at run time (16.10), so an app mounted under a sub-path calls its own
757
- * handlers rather than the root's.
932
+ * 16.28 — WHAT A BROWSER HOLDS WHERE THE APP WROTE A PRIVATE HANDLER: the same name, the same call
933
+ * over the wire, bound in its `.abide`'s server block's place. 16.74 — a handler file's is its own
934
+ * module, its default, answered in the file's place.
758
935
  */
759
936
  function standInsOf(mounts: Mount[]): Map<string, string> {
760
- const lines = new Map<string, string>()
937
+ const lines = new Map<string, string[]>()
761
938
  for (const mount of mounts) {
762
- // 20.4 — withheld from the browser: no wrapper, so an import of it names nothing there.
939
+ // 20.4 — withheld from the browser: no wrapper, so a call of it there names nothing.
763
940
  if (!mount.ui) continue
764
- const at = JSON.stringify(posix.relative(CLIENT_ADDRESS, mount.address))
765
- // 18.4 — what the handler's value declares that the browser's copy behaves by.
766
- // 11.29 — and the defaults its parameter writes, which the copy keys by.
767
- const copy =
768
- mount.defaults !== undefined
769
- ? `, ${JSON.stringify(mount.copy)}, ${JSON.stringify(mount.defaults)}`
770
- : mount.copy === undefined
771
- ? ''
772
- : `, ${JSON.stringify(mount.copy)}`
773
- const line = `export const ${mount.name} = __wire(${JSON.stringify(mount.method)}, new URL(${at}, import.meta.url).pathname${copy})\n`
774
- lines.set(mount.source, (lines.get(mount.source) ?? '') + line)
941
+ const held = lines.get(mount.source) ?? []
942
+ held.push(
943
+ wireLine(
944
+ mount,
945
+ mount.private ? `const ${mount.name} =` : 'export default',
946
+ ),
947
+ )
948
+ lines.set(mount.source, held)
775
949
  }
776
950
  const standIns = new Map<string, string>()
777
- for (const [source, body] of lines)
778
- standIns.set(
779
- source,
780
- `// 34.23 — what a browser holds in place of the server block.
781
- import { wire as __wire } from '${WIRE_SPECIFIER}'
782
- ${body}`,
783
- )
951
+ for (const [source, held] of lines) standIns.set(source, wireModule(held))
784
952
  return standIns
785
953
  }
786
954
 
955
+ /**
956
+ * One handler's wrapper, bound as `binding` writes it.
957
+ *
958
+ * The address is written RELATIVE to the bundle's own directory and resolved against the chunk at
959
+ * run time (16.10), so an app mounted under a sub-path calls its own handlers rather than the
960
+ * root's.
961
+ */
962
+ function wireLine(mount: Mount, binding: string): string {
963
+ const at = JSON.stringify(posix.relative(CLIENT_ADDRESS, mount.address))
964
+ // 18.4 — what the handler's value declares that the browser's copy behaves by.
965
+ // 11.29 — and the defaults its parameter writes, which the copy keys by.
966
+ const copy =
967
+ mount.defaults !== undefined
968
+ ? `, ${JSON.stringify(mount.copy)}, ${JSON.stringify(mount.defaults)}`
969
+ : mount.copy === undefined
970
+ ? ''
971
+ : `, ${JSON.stringify(mount.copy)}`
972
+ return `${binding} __wire(${JSON.stringify(mount.method)}, new URL(${at}, import.meta.url).pathname${copy})\n`
973
+ }
974
+
975
+ /** A module of wrappers: the import they share, then each line. */
976
+ function wireModule(lines: string[]): string {
977
+ return `// 16.28 — what a browser holds in place of a handler.
978
+ import { wire as __wire } from '${WIRE_SPECIFIER}'
979
+ ${lines.join('')}`
980
+ }
981
+
787
982
  /** Whether a page or an error page has anything to paint: a template, or a head. 23.3, 35.14. */
788
983
  export function paints(file: AbideFile): boolean {
789
984
  return file.template.length > 0 || file.head.length > 0
@@ -33,7 +33,14 @@ const PACKAGE = Bun.fileURLToPath(new URL('../../', import.meta.url))
33
33
  function abideEntries(): Map<string, string> {
34
34
  const specifiers = new Map<string, string>()
35
35
  for (const [entry, path] of Object.entries(abidePackage.exports)) {
36
- if (entry === './compiler' || !path.endsWith('.ts')) continue
36
+ // The compiler, and the preload a test run builds the app with (38.109), are no part of an
37
+ // app that is already built.
38
+ if (
39
+ entry === './compiler' ||
40
+ entry === './test' ||
41
+ !path.endsWith('.ts')
42
+ )
43
+ continue
37
44
  specifiers.set(
38
45
  entry === '.'
39
46
  ? abidePackage.name
@@ -59,13 +66,11 @@ function abideEntries(): Map<string, string> {
59
66
  export function compiledEntry(app: {
60
67
  /**
61
68
  * 38.70 — what `abide bundle` read of the launcher's files: `launch` the path of its
62
- * `launcher.abide`, and `custom` whether that file has markup of its own; `undefined` for
63
- * `abide compile`.
69
+ * `launcher.abide` where that file has markup of its own; `undefined` for `abide compile`.
64
70
  */
65
71
  desktop:
66
72
  | {
67
73
  launch: string | undefined
68
- custom: boolean
69
74
  document: string
70
75
  icon: string | undefined
71
76
  accent: string | undefined
@@ -107,7 +112,6 @@ import * as table from ${JSON.stringify(tablePath(app.root, SERVER_TABLE))}
107
112
  ? ''
108
113
  : `
109
114
  {
110
- custom: ${app.desktop.custom},
111
115
  launch: ${app.desktop.launch === undefined ? 'undefined' : `() => import(${JSON.stringify(app.desktop.launch)})`},
112
116
  document: ${JSON.stringify(app.desktop.document)},
113
117
  icon: ${JSON.stringify(app.desktop.icon)},
@@ -116,7 +120,7 @@ import * as table from ${JSON.stringify(tablePath(app.root, SERVER_TABLE))}
116
120
  return `// Written by \`abide compile\`. The whole app, as what one executable holds.
117
121
  ${imports}
118
122
  // 38.56 — a file declaring handlers as a script names it through the app's one alias:
119
- // \`#src/invoices/page.abide\`, to the module its server block compiled into.
123
+ // \`#src/invoices/getInvoice.ts\`, to the module it is.
120
124
  const declared = {}
121
125
  for (const row of table.handlers) declared[\`#src/\${row.source}\`] = row.load
122
126
 
@@ -89,26 +89,25 @@ export function appGraph<
89
89
  const nodes: AppNode<Source>[] = []
90
90
  for (const source of sources) {
91
91
  // Below the root for the app's own files, and above it — `../node_modules/…` — for a
92
- // dependency's (34.28).
92
+ // dependency's (34.24).
93
93
  const relative = relativeTo(root, source.path)
94
94
  const position = source.position ?? relative
95
- // 34.21 — a server block of the app's own under a `server` or `public` directory has no
96
- // address: refused here, before anything reads one off it. A dependency's has none of its own
97
- // (34.31); the app mounts what it declares where the app re-exports it.
95
+ // 34.20 — a server block of the app's own under a `server` or `public` directory, refused
96
+ // here before anything reads its handlers: neither directory holds a component that runs.
98
97
  const place = placeOf(position)
99
98
  if (
100
99
  source.file.servers.length > 0 &&
101
100
  (place === 'server' || place === 'public')
102
101
  )
103
102
  throw new Error(
104
- `abide: ${relative} has a <script server>, and it is under a ${place} directory, where nothing has an address. Move the file out of it, or the code into a module under a server directory. RULEBOOK 34.21.`,
103
+ `abide: ${relative} has a <script server>, and it is under a ${place} directory, where nothing has an address. Move the file out of it, or the code into a module under a server directory. RULEBOOK 34.20.`,
105
104
  )
106
- // 34.25 — a file imported as text is found at build, not when its module first loads.
105
+ // 34.21 — a file imported as text is found at build, not when its module first loads.
107
106
  for (const script of scriptBodies(source.file))
108
107
  for (const { specifier } of attributedImports(script, 'text'))
109
108
  if (resolve(specifier, dirname(source.path)) === undefined)
110
109
  throw new Error(
111
- `abide: ${relative} imports ${specifier} as text, and there is no such file. RULEBOOK 34.25.`,
110
+ `abide: ${relative} imports ${specifier} as text, and there is no such file. RULEBOOK 34.21.`,
112
111
  )
113
112
  const kind = fileKind(position)
114
113
  nodes.push({