@abide/abide 0.102.0 → 0.103.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/bin/abide.ts +31 -28
  3. package/docs/REGISTRY.md +30 -26
  4. package/docs/RULEBOOK.md +98 -83
  5. package/docs/abide.md +771 -522
  6. package/package.json +2 -1
  7. package/scaffold/AGENTS.md +13 -6
  8. package/src/compiler/TypeSession.ts +110 -35
  9. package/src/compiler/appMap.ts +96 -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 +228 -70
  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 +70 -45
  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 +117 -51
  33. package/src/server/answerRequest.ts +9 -4
  34. package/src/server/answerRpc.ts +18 -16
  35. package/src/server/appTree.ts +83 -24
  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/hooks.ts +17 -4
  42. package/src/server/index.ts +1 -0
  43. package/src/server/installSourceMaps.ts +6 -1
  44. package/src/server/launcherOptions.ts +12 -13
  45. package/src/server/launcherPage.ts +2 -2
  46. package/src/server/nativeMenu.ts +3 -3
  47. package/src/server/preload.ts +13 -0
  48. package/src/server/runDesktop.ts +11 -16
  49. package/src/server/serveDev.ts +8 -2
  50. package/src/server/surfaceOf.ts +2 -0
  51. package/src/server/withAbideSources.ts +1 -1
  52. package/src/shared/ADDRESSES.ts +3 -0
  53. package/src/shared/LAUNCHER_FILES.ts +9 -0
  54. package/src/shared/RESERVED_DIRECTORIES.ts +13 -4
  55. package/src/shared/index.ts +2 -1
  56. package/src/{ui → shared}/menuBridge.ts +1 -1
  57. package/src/shared/menuItem.ts +2 -2
  58. package/src/shared/onMenu.ts +58 -0
  59. package/src/{ui → shared}/reportMenu.ts +7 -9
  60. package/src/shared/routePattern.ts +58 -20
  61. package/src/shared/rpc.ts +1 -1
  62. package/src/ui/Router.ts +0 -6
  63. package/src/compiler/syncTypes.ts +0 -32
  64. package/src/compiler/writeTypes.ts +0 -82
  65. package/src/server/LAUNCHER_FILES.ts +0 -9
  66. /package/scaffold/src/{page.abide → index.abide} +0 -0
  67. /package/src/{compiler → shared}/SPECIFIERS.ts +0 -0
@@ -14,7 +14,7 @@ import { servedTable } from './abideModules.ts'
14
14
  import { type AppSources, answerRequest } from './answerRequest.ts'
15
15
  import { Mux, type MuxConnection } from './answerSocket.ts'
16
16
  import { appDirectories } from './appDirectories.ts'
17
- import { holdsAbide } from './appTree.ts'
17
+ import { holdsApp } from './appTree.ts'
18
18
  import { buildDirectory } from './buildDirectory.ts'
19
19
  import { APP_PACKAGE, type AppPackage, config, packageUnder } from './config.ts'
20
20
  import { McpSurface } from './generatedSurfaces.ts'
@@ -65,21 +65,33 @@ export async function createApp(): Promise<App> {
65
65
  throw new Error(
66
66
  `abide: createApp found no app. Looked for a .abide under ${resolve('src')}, or under ${process.cwd()} where it has no src.`,
67
67
  )
68
- // 42.16 — under `bun test` the app is built for its sources as they stand rather than read off
69
- // whatever `abide build` last left, which a test run would otherwise need first and could find
70
- // stale.
71
- const out =
72
- process.env.NODE_ENV === 'test'
73
- ? await testBuild(root)
74
- : buildDirectory(root)
75
- const { loadBuild, serveBuild } = await import('./serveBuild.ts')
76
- if (!(await serveBuild(root, out)))
68
+ const out = await servedBuild(root)
69
+ if (out === undefined)
77
70
  throw new Error(
78
71
  `abide: createApp found nothing under ${root} to build.`,
79
72
  )
73
+ const { loadBuild } = await import('./serveBuild.ts')
80
74
  return bootApp(originUnder(root), () => loadBuild({ root, out }))
81
75
  }
82
76
 
77
+ /**
78
+ * The build of the app under `root`, served to this process's imports — or `undefined` where
79
+ * nothing under it builds. `fresh` builds it for its sources as they stand.
80
+ *
81
+ * 42.16 — under `bun test` the app is built for its sources as they stand rather than read off
82
+ * whatever `abide build` last left, which a test run would otherwise need first and could find
83
+ * stale. One answer for `createApp` and the test preload (38.109), so a test that boots the app
84
+ * reuses the build its imports were served from.
85
+ */
86
+ export async function servedBuild(
87
+ root: string,
88
+ fresh = process.env.NODE_ENV === 'test',
89
+ ): Promise<string | undefined> {
90
+ const out = fresh ? await testBuild(root) : buildDirectory(root)
91
+ const { serveBuild } = await import('./serveBuild.ts')
92
+ return (await serveBuild(root, out)) ? out : undefined
93
+ }
94
+
83
95
  /**
84
96
  * The app's root — `--root`, else `src` where the working directory holds one, else the working
85
97
  * directory — or `undefined` where it is no app's.
@@ -99,7 +111,7 @@ export function appRoot(given: string | undefined): string | undefined {
99
111
  let packaged = false
100
112
  for (const directory of appDirectories(root))
101
113
  packaged ||= existsSync(`${directory}/package.json`)
102
- return packaged && holdsAbide(root) ? root : undefined
114
+ return packaged && holdsApp(root) ? root : undefined
103
115
  }
104
116
 
105
117
  /**
@@ -378,7 +378,11 @@ async function open(
378
378
  bundleInMemory(
379
379
  root,
380
380
  // 16.28, 16.29 — the browser's view of the server, as `abide build` gives it.
381
- [fenceServer(root), abideModules(root, compilation), ...own],
381
+ [
382
+ fenceServer(root, compilation.mounted),
383
+ abideModules(root, compilation),
384
+ ...own,
385
+ ],
382
386
  paged,
383
387
  publicAddresses,
384
388
  compilation,
@@ -1,7 +1,8 @@
1
1
  // 16.29 — WHAT THE BROWSER'S BUNDLE SEES OF THE SERVER: nothing under a `server` directory, at any
2
2
  // depth, so a secret or a database client cannot ride into the browser by being imported from the
3
- // wrong file. A handler crosses as the wrapper its file's browser module carries in place of the
4
- // server block (34.23), which the compiler wrote; this is the fence around everything else.
3
+ // wrong file. A handler crosses as the wrapper the compiler wrote in its place — a handler file as
4
+ // that file, a private handler in its server block's place (16.28); this is the fence around
5
+ // everything else.
5
6
  //
6
7
  // A PLUGIN ON THE BUNDLE, NOT A REWRITE OF THE PAGES. A rewrite reaches the specifiers the compiler
7
8
  // emitted; the bundle reaches every edge — a helper the page imports, importing a server module,
@@ -9,11 +10,16 @@
9
10
  //
10
11
  // REGISTERED AHEAD OF `abideModules`, AND IN ITS NAMESPACES: a load is answered by the first plugin
11
12
  // that answers it, and a component or a file's text is loaded there rather than as a `file`.
13
+ //
14
+ // BY POSITION, NOT BY PATH (23.19): a directory a file mounts places its files where the app says,
15
+ // so a `server` directory inside it is fenced as the app's own would be, wherever the package lies.
12
16
 
17
+ import { resolve } from 'node:path' // bun exposes no path helpers of its own
13
18
  import { SERVER_DIRECTORY } from '#shared/RESERVED_DIRECTORIES.ts'
14
19
  import { placeOf } from '#shared/routePattern.ts'
15
20
  import {
16
21
  COMPONENT_NAMESPACE,
22
+ type Compilation,
17
23
  STYLE_NAMESPACE,
18
24
  TEXT_NAMESPACE,
19
25
  } from './abideModules.ts'
@@ -25,24 +31,41 @@ const FENCED = ['file', COMPONENT_NAMESPACE, TEXT_NAMESPACE, STYLE_NAMESPACE]
25
31
  /**
26
32
  * The bundler plugin refusing everything under a `server` directory to the browser's bundle.
27
33
  *
28
- * `root` is its real path (`appRoot`), which is how the bundler names a file. Listed before
29
- * `abideModules`.
34
+ * `root` is its real path (`appRoot`), which is how the bundler names a file; `mounted` is the
35
+ * compilation's. Listed before `abideModules`.
30
36
  */
31
- export function fenceServer(root: string): Bun.BunPlugin {
32
- // Anything with a `server` directory in its path below the root, and `placeOf` decides — a load
33
- // it passes on falls through to the next plugin's, or the bundler's own.
34
- const filter = new RegExp(
35
- `^${RegExp.escape(root)}/(?:.*/)?${SERVER_DIRECTORY}/`,
36
- )
37
+ export function fenceServer(
38
+ root: string,
39
+ mounted: Compilation['mounted'],
40
+ ): Bun.BunPlugin {
41
+ // Mounted directories first: one under the root's `node_modules` is the app's at its position.
42
+ const directories: [string, string][] = []
43
+ for (const [directory, at] of Object.entries(mounted))
44
+ directories.push([`${resolve(root, directory)}/`, at])
45
+ const positionOf = (path: string): string | undefined => {
46
+ for (const [directory, at] of directories)
47
+ if (path.startsWith(directory))
48
+ return `${at}${path.slice(directory.length)}`
49
+ return path.startsWith(`${root}/`)
50
+ ? path.slice(root.length + 1)
51
+ : undefined
52
+ }
53
+ // Anything with a `server` directory in its path, and `placeOf` decides by its position — a
54
+ // load it passes on falls through to the next plugin's, or the bundler's own.
55
+ const filter = new RegExp(`/${SERVER_DIRECTORY}/`)
37
56
  return {
38
57
  name: 'abide-fence',
39
58
  setup(build) {
40
59
  for (const namespace of FENCED)
41
60
  build.onLoad({ filter, namespace }, (args) => {
42
- const below = args.path.slice(root.length + 1)
43
- if (placeOf(below) !== 'server') return undefined
61
+ const position = positionOf(args.path)
62
+ if (
63
+ position === undefined ||
64
+ placeOf(position) !== 'server'
65
+ )
66
+ return undefined
44
67
  throw new Error(
45
- `abide: ${below.replace(/::\w+$/, '')} is under a server directory, and the browser's bundle imports it. RULEBOOK 16.29.`,
68
+ `abide: ${position.replace(/::\w+$/, '')} is under a server directory, and the browser's bundle imports it. RULEBOOK 16.29.`,
46
69
  )
47
70
  })
48
71
  },
@@ -1,5 +1,5 @@
1
- // SECTION 37 — THE LIFECYCLE HOOKS, called from a page's or a layout's `<script server>` rather
2
- // than exported from it (37.1). Each call files its function here, at the ROUTE of the file that
1
+ // SECTION 37 — THE LIFECYCLE HOOKS, called from a `.abide`'s `<script server>` or a handler file
2
+ // rather than exported from it (37.1). Each call files its function here, at the ROUTE of the file that
3
3
  // called it, and hands back what unfiles it; `createApp` reads what is filed once every module
4
4
  // holding a server block has run.
5
5
  //
@@ -22,6 +22,7 @@ import {
22
22
  } from '#shared/routePattern.ts'
23
23
  import type { Schema } from '#shared/Schema.ts'
24
24
  import { config } from './config.ts'
25
+ import type { LauncherOptions } from './launcherOptions.ts'
25
26
  import { currentRequestScope } from './requestScope.ts'
26
27
 
27
28
  export type AppRung = Middleware<{ request: Request }, Response>
@@ -51,17 +52,20 @@ export const HOOKS = {
51
52
  options: ConfigOptions | undefined
52
53
  }>[],
53
54
  principal: [] as Filed<(claims: unknown) => unknown | Promise<unknown>>[],
55
+ launcher: [] as Filed<() => LauncherOptions>[],
54
56
  }
55
57
 
56
58
  const ROOT = '/'
57
59
 
58
- // The route a hook called now is filed at — the server block being evaluated, or the root.
60
+ // The route a hook called now is filed at — the server block or handler file being evaluated, or
61
+ // the root.
59
62
  let filing: string | undefined
60
63
 
61
64
  /**
62
65
  * Sets the route the hooks called next are filed at, or the root again for `undefined`.
63
66
  *
64
- * 37.1 — what a compiled server module calls around its `<script server>`; nothing an app writes.
67
+ * 37.1 — what a compiled server module calls around its `<script server>`, and the plugin around a
68
+ * handler file; nothing an app writes.
65
69
  */
66
70
  export function fileRoute(route: string | undefined): void {
67
71
  filing = route
@@ -208,6 +212,15 @@ export function onPrincipal(
208
212
  return filed(HOOKS.principal, resolve)
209
213
  }
210
214
 
215
+ /**
216
+ * Files what the desktop app's window and launcher are; returns what unfiles it.
217
+ *
218
+ * 38.80 — called in `src/launcher.abide` alone (38.107), and read as the app launches.
219
+ */
220
+ export function onLauncher(options: () => LauncherOptions): () => void {
221
+ return filed(HOOKS.launcher, options)
222
+ }
223
+
211
224
  /**
212
225
  * Logs an unexpected error and hands it to every `onError` hook.
213
226
  *
@@ -58,6 +58,7 @@ export {
58
58
  onConfig,
59
59
  onError,
60
60
  onHealth,
61
+ onLauncher,
61
62
  onPrincipal,
62
63
  onStart,
63
64
  onStop,
@@ -7,6 +7,7 @@
7
7
  // rebuild, and a test boots several apps in one process.
8
8
 
9
9
  import { resolve } from 'node:path' // bun exposes no path helpers of its own
10
+ import { HANDLER_FACE } from './MODULE_QUERIES.ts'
10
11
  import { decodeMappings, placeAt, type Segment } from './sourceMappings.ts'
11
12
 
12
13
  /** Per `.abide` below the root, as its server module is served there: the file, and its `mappings`. */
@@ -63,7 +64,11 @@ function placeFrames(error: Error, frames: CallSite[]): string {
63
64
 
64
65
  // A call site's column counts from 0 where the stack bun writes counts from 1.
65
66
  function placed(frame: CallSite): string {
66
- const file = frame.getFileName() ?? '<anonymous>'
67
+ // A handler file's frames name the file the author wrote, not the face it is served as.
68
+ const file = (frame.getFileName() ?? '<anonymous>').replace(
69
+ HANDLER_FACE,
70
+ '',
71
+ )
67
72
  const line = frame.getLineNumber() ?? 0
68
73
  const column = frame.getColumnNumber() ?? 0
69
74
  const registered = registeredFor(file)
@@ -1,12 +1,11 @@
1
- // 38.80 — THE `launcher` A `src/launcher.abide`'S `<script module>` EXPORTS: the window the desktop
2
- // app opens and the forms its launcher offers. 34.32 — read off the module as the app launches,
3
- // never by evaluating it during the bundle.
1
+ // 38.80 — WHAT THE `onLauncher` A `src/launcher.abide` CALLS ANSWERS: the window the desktop app opens
2
+ // and the forms its launcher offers, read as the app launches.
4
3
 
5
4
  import { isRecord } from '#shared/isRecord.ts'
6
5
 
7
6
  /**
8
- * What the `launcher` export may set. 38.81 — turning off both forms is not one of them, so the
9
- * checker refuses it where the export is typed.
7
+ * What `onLauncher` may answer. 38.81 — turning off both forms is not one of them, so the checker
8
+ * refuses it where the answer is typed.
10
9
  */
11
10
  export type LauncherOptions = {
12
11
  width?: number
@@ -29,10 +28,10 @@ const SIZES = ['width', 'height', 'minWidth', 'minHeight'] as const
29
28
  const FORMS = ['start', 'connect'] as const
30
29
 
31
30
  /**
32
- * The launcher `exported` sets, or the refusal naming what is wrong with it — the export read at
33
- * run time, where an untyped one can still name a key it does not take.
31
+ * The launcher what `onLauncher` answered sets, or the refusal naming what is wrong with it — read
32
+ * at run time, where an untyped answer can still name a key it does not take.
34
33
  */
35
- export function launcherOf(exported: unknown): Launcher | string {
34
+ export function launcherOf(answered: unknown): Launcher | string {
36
35
  const launcher: Launcher = {
37
36
  width: 1100,
38
37
  height: 760,
@@ -41,9 +40,9 @@ export function launcherOf(exported: unknown): Launcher | string {
41
40
  start: true,
42
41
  connect: true,
43
42
  }
44
- if (exported === undefined) return launcher
45
- if (!isRecord(exported)) return '`launcher` is an object'
46
- for (const [key, value] of Object.entries(exported)) {
43
+ if (answered === undefined) return launcher
44
+ if (!isRecord(answered)) return '`onLauncher` answers an object'
45
+ for (const [key, value] of Object.entries(answered)) {
47
46
  if (
48
47
  (SIZES as readonly string[]).includes(key) &&
49
48
  typeof value === 'number' &&
@@ -56,10 +55,10 @@ export function launcherOf(exported: unknown): Launcher | string {
56
55
  )
57
56
  launcher[key as (typeof FORMS)[number]] = value
58
57
  else
59
- return `\`launcher.${key}\` is not a launcher option, or ${JSON.stringify(value)} is not a value it takes`
58
+ return `\`${key}\` is not a launcher option, or ${JSON.stringify(value)} is not a value it takes`
60
59
  }
61
60
  // 38.81.
62
61
  if (!launcher.start && !launcher.connect)
63
- return '`launcher` turns off both start and connect, which leaves the launcher nothing to do'
62
+ return '`onLauncher` turns off both start and connect, which leaves the launcher nothing to do'
64
63
  return launcher
65
64
  }
@@ -21,9 +21,9 @@ const SYSTEM_ACCENT = '#0a84ff'
21
21
  export type Launch = {
22
22
  /** The app's name, as its package gives it. */
23
23
  name: string
24
- /** Where the start form posts, or `undefined` where the `launcher` export turns starting off. */
24
+ /** Where the start form posts, or `undefined` where what `onLauncher` answers turns starting off. */
25
25
  start: string | undefined
26
- /** Where the connect form posts, or `undefined` where the `launcher` export turns connecting off. */
26
+ /** Where the connect form posts, or `undefined` where what `onLauncher` answers turns connecting off. */
27
27
  connect: string | undefined
28
28
  fields: ConfigField[]
29
29
  /** The URL the last connect was given. */
@@ -1,5 +1,5 @@
1
- // THE DESKTOP APP'S MENU BAR (38.88): the default, with what the page the window shows exports,
2
- // built as an `NSMenu`.
1
+ // THE DESKTOP APP'S MENU BAR (38.88): the default, with the menus the standing layouts and page file
2
+ // with `onMenu`, built as an `NSMenu`.
3
3
 
4
4
  import { FFIType } from 'bun:ffi'
5
5
  import type { MenuRole } from '#shared/menuItem.ts'
@@ -77,7 +77,7 @@ const NAMED_KEYS: Record<string, string> = {
77
77
  * The menu bar the window shows: the application, File, Edit, View and Window menus, and `added`.
78
78
  *
79
79
  * 38.88 — AN ADDED MENU NAMED AS A DEFAULT ONE GROWS IT, and any other stands before Window, where
80
- * the platform puts an app's own. 38.83 — no Connect… where the `launcher` export turns connecting off.
80
+ * the platform puts an app's own. 38.83 — no Connect… where what `onLauncher` answers turns connecting off.
81
81
  */
82
82
  export function menuBar(
83
83
  appName: string,
@@ -0,0 +1,13 @@
1
+ // 38.109 — `@abide/abide/test`, PRELOADED BY `bun test`: the app's compilation served before any test
2
+ // file is loaded, so a test imports a `.abide` as any module — beside the code it tests, at the top
3
+ // of the file, with no `createApp` run first. Bun loads every import of a file before it evaluates
4
+ // any, so registering the compilation from inside a test is always too late; a preload is the
5
+ // one place that runs first. The build is `createApp`'s own under `bun test` (42.16), so a test that
6
+ // boots the app afterwards reuses it rather than building twice.
7
+
8
+ import { appRoot, servedBuild } from './createApp.ts'
9
+
10
+ const root = appRoot(undefined)
11
+ // A run from a directory that holds no app — a workspace's root — preloads nothing.
12
+ // Fresh whatever `NODE_ENV` says: `bun test` sets it only where nothing else has.
13
+ if (root !== undefined) await servedBuild(root, true)
@@ -26,7 +26,7 @@ import { APP_PACKAGE, configuredMount, dataDirectory } from './config.ts'
26
26
  import { configFields } from './configFields.ts'
27
27
  import { type App, type AppOrigin, bootApp } from './createApp.ts'
28
28
  import { executableName } from './executableName.ts'
29
- import { reportUnexpected } from './hooks.ts'
29
+ import { HOOKS, inOrder, reportUnexpected } from './hooks.ts'
30
30
  import { installServer } from './installServer.ts'
31
31
  import { launcherOf } from './launcherOptions.ts'
32
32
  import { type Launch, launcherPage } from './launcherPage.ts'
@@ -53,15 +53,10 @@ type Failure = {
53
53
 
54
54
  /** What `abide bundle` read of the launcher's files, carried in the executable. */
55
55
  export type Desktop = {
56
- /** 38.80 — `src/launcher.abide`, where the app has one: its `launcher`, and its page. */
56
+ /** 38.82 — `src/launcher.abide`'s page, where the app has one that paints. */
57
57
  launch:
58
- | (() => Promise<{
59
- default: (props: Launch) => Component
60
- launcher?: unknown
61
- }>)
58
+ | (() => Promise<{ default: (props: Launch) => Component }>)
62
59
  | undefined
63
- /** 38.82 — whether that file has markup of its own, which then stands in for the built-in page. */
64
- custom: boolean
65
60
  /** The document the launch page renders into, its stylesheet already in its head (38.84). */
66
61
  document: string
67
62
  /** 38.101 — the app's icon as a data URL, and the accent taken from it. */
@@ -87,23 +82,23 @@ export async function runDesktop(
87
82
  async function launch(
88
83
  origin: AppOrigin,
89
84
  load: () => ReturnType<typeof builtSources>,
90
- { launch: launchModule, custom, document, icon, accent }: Desktop,
85
+ { launch: launchModule, document, icon, accent }: Desktop,
91
86
  ): Promise<void> {
92
87
  installServer()
93
88
  APP_PACKAGE.current = origin.package
94
- // 37.1 — imported for what it files, so the start form reads `onConfig`.
89
+ // 37.1 — imported for what it files, so the start form reads `onConfig` and the launcher's
90
+ // `onLauncher` is filed.
95
91
  await origin.hooks?.()
96
- // 34.32 — the `launcher` export, read off its module as the app launches. 38.85 — one that fails
97
- // to load leaves the built-in page, with its defaults.
98
- const launcherModule = await launchModule?.().catch((thrown: unknown) => {
92
+ // 38.85 — a launcher page that fails to load leaves the built-in page, with its defaults.
93
+ const launchPage = await launchModule?.().catch((thrown: unknown) => {
99
94
  reportUnexpected(thrown)
100
95
  return undefined
101
96
  })
102
- const options = launcherOf(launcherModule?.launcher)
103
- // 38.81 — a `launcher` the checker would have refused, written where nothing checked it.
97
+ // 38.80 — what its `onLauncher` answers.
98
+ const options = launcherOf(inOrder(HOOKS.launcher).at(-1)?.())
99
+ // 38.81 — an `onLauncher` answer the checker would have refused, written where nothing checked it.
104
100
  if (typeof options === 'string')
105
101
  throw new Error(`abide: ${options}. RULEBOOK 38.81.`)
106
- const launchPage = custom ? launcherModule : undefined
107
102
  const appName = executableName(origin.package.name)
108
103
  const directory = dataDirectory(origin.package.name ?? 'app')
109
104
  const stateFile = `${directory}/desktop.json`
@@ -22,6 +22,7 @@ import { watch } from 'node:fs' // bun exposes no file watcher of its own
22
22
  import { dirname, resolve } from 'node:path' // bun exposes no path helpers of its own
23
23
  import {
24
24
  type AppChecker,
25
+ compilationOf,
25
26
  compileApp,
26
27
  openChecker,
27
28
  } from '#compiler/compileApp.ts'
@@ -144,7 +145,7 @@ export async function serveDev(
144
145
  console.warn(formatDiagnosis(warning, process.cwd()))
145
146
  watchDependencies(compiled.files)
146
147
  return {
147
- compilation: { files: compiled.files, tables: compiled.tables },
148
+ compilation: compilationOf(compiled),
148
149
  published: compiled.published,
149
150
  failure: '',
150
151
  }
@@ -154,7 +155,12 @@ export async function serveDev(
154
155
  thrown instanceof Error ? thrown.message : String(thrown)
155
156
  console.error(`abide dev: ${failure}`)
156
157
  return {
157
- compilation: { files: {}, tables: {} },
158
+ compilation: {
159
+ files: {},
160
+ handlers: {},
161
+ tables: {},
162
+ mounted: {},
163
+ },
158
164
  published: new Map(),
159
165
  failure,
160
166
  }
@@ -31,6 +31,8 @@ export function surfaceOf(
31
31
  mount: Mount | Mounted,
32
32
  surface: Surface,
33
33
  ): Listed | undefined {
34
+ // 16.71 — a private handler is its file's alone.
35
+ if (mount.private) return undefined
34
36
  const live = 'handler' in mount ? mount : undefined
35
37
  // 16.37 — what its `clients` says, and the build's default where it says nothing.
36
38
  const carried =
@@ -96,7 +96,7 @@ export async function withAbideSources(
96
96
  }
97
97
 
98
98
  /**
99
- * 16.28, 34.20 — the `.abide`'s text with every `<script server>` body blanked: the map is served
99
+ * 16.28, 34.19 — the `.abide`'s text with every `<script server>` body blanked: the map is served
100
100
  * to a browser, which reads none of one. Each line is kept, so every line the map places is still
101
101
  * the line it names.
102
102
  */
@@ -24,6 +24,9 @@ export const ABIDE_ADDRESS = '/__abide'
24
24
  */
25
25
  export const CLIENT_ADDRESS = `${ABIDE_ADDRESS}/client`
26
26
 
27
+ /** Where a private handler is answered, below its file's path and then its name (16.70). */
28
+ export const PRIVATE_HANDLERS_ADDRESS = `${ABIDE_ADDRESS}/handlers`
29
+
27
30
  // 20.5, 20.9, 36.7 — the generated surfaces, which the CLI reaches as well as the server answers.
28
31
 
29
32
  /** The generated OpenAPI document. */
@@ -0,0 +1,9 @@
1
+ // THE DESKTOP LAUNCHER'S FILES. Its page, which calls `onLauncher`, sits at the app's root beside the
2
+ // root layout, a name the framework finds rather than a page (23.21); the icon is a public file,
3
+ // so it is wherever the address it answers at says (36.11).
4
+
5
+ /** The `.abide` the desktop launcher renders, at the root alone. 38.80, 38.82. */
6
+ export const LAUNCHER_PAGE = 'launcher.abide'
7
+
8
+ /** The addresses of the desktop app's icon, the `.icns` first. 38.79. */
9
+ export const ICON_ADDRESSES = { icns: '/icon.icns', png: '/icon.png' } as const
@@ -1,6 +1,7 @@
1
- // THE TWO DIRECTORY NAMES THAT ARE NEVER AN ADDRESS (23.15): `server` fences a graph (16.29) and
2
- // `public` is answered as authored (36.11). Every other directory under the root is a segment of the
3
- // address of the `page.abide`, `layout.abide` and `error.abide` in it (23.14).
1
+ // THE FENCES: the four directory names that are never an address (23.15). `server` fences a graph
2
+ // (16.29), `public` is answered as authored (36.11), and `lib` and `tests` hold what is neither an
3
+ // address nor either of those. Every other directory under the root is a segment of the routes of
4
+ // the files directly in it (23.14), and holds nothing else (23.22).
4
5
 
5
6
  /** A module under one only the server loads. */
6
7
  export const SERVER_DIRECTORY = 'server'
@@ -8,8 +9,16 @@ export const SERVER_DIRECTORY = 'server'
8
9
  /** A file under one is answered as authored, at the path above it. */
9
10
  export const PUBLIC_DIRECTORY = 'public'
10
11
 
11
- /** Both, as the walk of the app tests a directory against them. */
12
+ /** The app's own code that is no address: components, modules, both sides. */
13
+ export const LIB_DIRECTORY = 'lib'
14
+
15
+ /** The app's tests. */
16
+ export const TESTS_DIRECTORY = 'tests'
17
+
18
+ /** All four, as the walk of the app tests a directory against them. */
12
19
  export const RESERVED_DIRECTORIES: ReadonlySet<string> = new Set([
13
20
  SERVER_DIRECTORY,
14
21
  PUBLIC_DIRECTORY,
22
+ LIB_DIRECTORY,
23
+ TESTS_DIRECTORY,
15
24
  ])
@@ -36,13 +36,14 @@ export { listOf } from './listOf.ts'
36
36
  export { type Logger, type LogRecord, log } from './log.ts'
37
37
  export type { Middleware } from './Middleware.ts'
38
38
  export type { Params } from './matchRoute.ts'
39
- // 38.88 — a desktop app's menu items, exported by the route's layouts and page.
39
+ // 38.88 — a desktop app's menu items, and the hook a route's layouts and page file them with.
40
40
  export {
41
41
  type MenuEntry,
42
42
  type MenuItem,
43
43
  type MenuRole,
44
44
  menuItem,
45
45
  } from './menuItem.ts'
46
+ export { onMenu } from './onMenu.ts'
46
47
  // `navigate` ALONE, of everything routing needs. This barrel is what an app resolves through the
47
48
  // bare `@abide/abide` specifier, and the matcher, the pattern grammar and `routeOf` are reached by path
48
49
  // from the two places that have them — the build and the client router. Putting them here would
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * The channel a desktop app's window opens to the page for its menu, or `undefined` in a browser.
3
3
  *
4
- * ITS OWN MODULE, so the router asks without importing the reporter: `reportMenu` is a chunk only a
4
+ * ITS OWN MODULE, so `onMenu` asks without importing the reporter: `reportMenu` is a chunk only a
5
5
  * page inside a desktop app loads. 38.88.
6
6
  */
7
7
  export function menuBridge():
@@ -1,5 +1,5 @@
1
- // 38.88 — THE DESKTOP APP'S MENU BAR IS THE ROUTE'S: a layout or a page exports `menu` from its
2
- // `<script module>`, and the window shows what the route it stands on exports. An item made by
1
+ // 38.88 — THE DESKTOP APP'S MENU BAR IS THE ROUTE'S: a layout or a page calls `onMenu` in its
2
+ // `<script>`, and the window shows the menus filed while it stands. An item made by
3
3
  // `menuItem` is one a page answers; the rest are an address or a role, which need no page.
4
4
 
5
5
  /** What a menu item does natively, where an address or a page's own answer could not. 38.89. */
@@ -0,0 +1,58 @@
1
+ // 38.88, 38.92 — THE MENUS A LAYOUT OR A PAGE ADDS TO A DESKTOP APP'S MENU BAR, filed while it
2
+ // stands. Called in its `<script>`, so the menus are a function of its state: what `read` reads moving
3
+ // tells the window again, and the component going takes them with it. Outside a desktop app's window
4
+ // there is no menu bar, and nothing is filed.
5
+
6
+ import { menuBridge } from './menuBridge.ts'
7
+ import type { MenuItem } from './menuItem.ts'
8
+ import { watch } from './reactive/index.ts'
9
+
10
+ /** One `onMenu`'s menus, as it last answered, and whether its component has gone. */
11
+ type Filed = { menus: MenuItem[]; gone: boolean }
12
+
13
+ // IN THE ORDER FILED, which is outermost first: a layout's setup runs before the page inside it.
14
+ let filed: Filed[] = []
15
+ let telling = false
16
+
17
+ /** Files the menus `read` answers for as long as the calling component stands; returns what unfiles them. */
18
+ export function onMenu(read: () => readonly MenuItem[]): () => void {
19
+ if (menuBridge() === undefined) return () => {}
20
+ const entry: Filed = { menus: [], gone: false }
21
+ filed.push(entry)
22
+ const stop = watch(() => {
23
+ entry.gone = false
24
+ entry.menus = [...read()]
25
+ tell()
26
+ // A rerun files again at once; a component that went stays gone.
27
+ return () => {
28
+ entry.gone = true
29
+ tell()
30
+ }
31
+ })
32
+ // 12.8 — a `read` that threw stopped the watch with no cleanup to run, so the unfiling is
33
+ // done here too.
34
+ return () => {
35
+ stop()
36
+ entry.gone = true
37
+ tell()
38
+ }
39
+ }
40
+
41
+ // ONCE PER TURN, after every rerun and every component a navigation took has settled.
42
+ function tell(): void {
43
+ if (telling) return
44
+ telling = true
45
+ queueMicrotask(async () => {
46
+ telling = false
47
+ const standing: Filed[] = []
48
+ const menus: MenuItem[] = []
49
+ for (const entry of filed) {
50
+ if (entry.gone) continue
51
+ standing.push(entry)
52
+ menus.push(...entry.menus)
53
+ }
54
+ filed = standing
55
+ const { reportMenu } = await import('./reportMenu.ts')
56
+ reportMenu(menus)
57
+ })
58
+ }
@@ -1,9 +1,9 @@
1
- // 38.88 — A ROUTE'S MENU, TOLD TO THE WINDOW IT IS SHOWN IN. Loaded only inside a desktop app, where
2
- // the window has opened `abideMenu` to the page; anywhere else the router never imports it.
1
+ // 38.88 — A ROUTE'S MENUS, TOLD TO THE WINDOW IT IS SHOWN IN. Loaded only inside a desktop app, where
2
+ // the window has opened `abideMenu` to the page; anywhere else `onMenu` never imports it.
3
3
 
4
- import { MenuEntry, type MenuItem } from '#shared/menuItem.ts'
5
- import { navigate } from '#shared/navigate.ts'
6
4
  import { menuBridge } from './menuBridge.ts'
5
+ import { MenuEntry, type MenuItem } from './menuItem.ts'
6
+ import { navigate } from './navigate.ts'
7
7
 
8
8
  /** A menu item as the window builds it: an item the page answers carries the `id` it is told by. */
9
9
  type Told =
@@ -20,15 +20,13 @@ type Told =
20
20
  let numbered: Exclude<MenuItem, 'separator'>[] = []
21
21
  let listening = false
22
22
 
23
- /** Tells the window the menus `chain` exports, layouts outermost first and the page last. */
24
- export function reportMenu(chain: { menu?: unknown }[]): void {
23
+ /** Tells the window `menus`, the layouts' outermost first and the page's last. */
24
+ export function reportMenu(menus: readonly MenuItem[]): void {
25
25
  const bridge = menuBridge()
26
26
  if (bridge === undefined) return
27
27
  numbered = []
28
28
  const told: Told[] = []
29
- for (const level of chain)
30
- if (Array.isArray(level.menu))
31
- for (const menu of level.menu as MenuItem[]) told.push(tell(menu))
29
+ for (const menu of menus) told.push(tell(menu))
32
30
  if (!listening) {
33
31
  listening = true
34
32
  // 38.90, 38.91 — the window names the item picked; the page does what it means.