@abide/abide 0.100.0 → 0.102.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 (160) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/bin/abide.ts +227 -163
  3. package/docs/REGISTRY.md +726 -0
  4. package/docs/RULEBOOK.md +2753 -0
  5. package/docs/abide.md +12878 -0
  6. package/package.json +10 -3
  7. package/scaffold/AGENTS.md +31 -0
  8. package/scaffold/CLAUDE.md +1 -0
  9. package/scaffold/src/layout.abide +18 -0
  10. package/src/compiler/Lowering.ts +78 -9
  11. package/src/compiler/ShadowMap.ts +2 -2
  12. package/src/compiler/TypeSession.ts +262 -67
  13. package/src/compiler/argsSchemaOf.ts +1 -1
  14. package/src/compiler/checkApp.ts +43 -15
  15. package/src/compiler/checkedShadow.ts +39 -5
  16. package/src/compiler/classifyRead.ts +6 -4
  17. package/src/compiler/classifyTemplate.ts +1 -1
  18. package/src/compiler/compileApp.ts +433 -193
  19. package/src/compiler/compiledEntry.ts +43 -39
  20. package/src/compiler/componentGraph.ts +58 -71
  21. package/src/compiler/declaredExports.ts +24 -7
  22. package/src/compiler/emitClient.ts +141 -71
  23. package/src/compiler/emitComponent.ts +37 -8
  24. package/src/compiler/emitSource.ts +106 -13
  25. package/src/compiler/handlersUnder.ts +106 -56
  26. package/src/compiler/index.ts +7 -0
  27. package/src/compiler/mountsOf.ts +138 -0
  28. package/src/compiler/nearest.ts +14 -0
  29. package/src/compiler/parseAbide.ts +157 -20
  30. package/src/compiler/projectShadow.ts +213 -7
  31. package/src/compiler/reactiveTypes.ts +20 -7
  32. package/src/compiler/readSources.ts +15 -2
  33. package/src/compiler/readsIn.ts +61 -7
  34. package/src/compiler/renderMarkup.ts +3 -3
  35. package/src/compiler/scaffold.ts +30 -11
  36. package/src/compiler/serveLanguage.ts +125 -34
  37. package/src/compiler/sourceMapOf.ts +193 -0
  38. package/src/compiler/splitImports.ts +205 -83
  39. package/src/compiler/syncTypes.ts +32 -0
  40. package/src/compiler/tokenize.ts +14 -0
  41. package/src/compiler/writeDefaultProject.ts +1 -1
  42. package/src/compiler/writeTypes.ts +82 -0
  43. package/src/server/CLIENT_BUILD.ts +11 -13
  44. package/src/server/DEFAULT_PORT.ts +1 -1
  45. package/src/server/LAUNCHER_FILES.ts +9 -0
  46. package/src/server/MODULE_QUERIES.ts +18 -0
  47. package/src/server/TABLES.ts +7 -0
  48. package/src/server/VIRTUAL_DIRECTORY.ts +4 -5
  49. package/src/server/abideModules.ts +391 -0
  50. package/src/server/answerFailure.ts +16 -6
  51. package/src/server/answerRequest.ts +235 -106
  52. package/src/server/answerRpc.ts +124 -39
  53. package/src/server/answerSocket.ts +141 -48
  54. package/src/server/answerValue.ts +22 -9
  55. package/src/server/appDirectories.ts +1 -1
  56. package/src/server/appPlugins.ts +1 -1
  57. package/src/server/appTree.ts +179 -0
  58. package/src/server/argsFor.ts +2 -2
  59. package/src/server/assistant.ts +2 -2
  60. package/src/server/bridgeRelay.ts +6 -8
  61. package/src/server/claudeCode.ts +0 -6
  62. package/src/server/clientBuildOptions.ts +8 -3
  63. package/src/server/coerceArgs.ts +1 -1
  64. package/src/server/commands.ts +37 -37
  65. package/src/server/componentModules.d.ts +3 -1
  66. package/src/server/compressAnswer.ts +48 -14
  67. package/src/server/config.ts +25 -29
  68. package/src/server/configFields.ts +31 -25
  69. package/src/server/createApp.ts +59 -73
  70. package/src/server/devWorker.ts +144 -183
  71. package/src/server/executableName.ts +4 -0
  72. package/src/server/fenceServer.ts +37 -35
  73. package/src/server/generatedSurfaces.ts +99 -66
  74. package/src/server/hooks.ts +124 -52
  75. package/src/server/iconAccent.ts +191 -0
  76. package/src/server/index.ts +11 -7
  77. package/src/server/installSourceMaps.ts +88 -0
  78. package/src/server/launcherOptions.ts +34 -31
  79. package/src/server/launcherPage.ts +107 -64
  80. package/src/server/manifestOf.ts +16 -13
  81. package/src/server/namesUnder.ts +2 -2
  82. package/src/server/nativeMenu.ts +5 -5
  83. package/src/server/negotiateEncoding.ts +1 -1
  84. package/src/server/principal.ts +10 -5
  85. package/src/server/realOrAsGiven.ts +10 -0
  86. package/src/server/refuseAddressCollision.ts +21 -14
  87. package/src/server/remote.ts +49 -52
  88. package/src/server/render.ts +65 -94
  89. package/src/server/renderComponent.ts +5 -0
  90. package/src/server/reportHealth.ts +2 -2
  91. package/src/server/requestScope.ts +12 -1
  92. package/src/server/runBuild.ts +24 -0
  93. package/src/server/runCompiled.ts +5 -5
  94. package/src/server/runDesktop.ts +52 -27
  95. package/src/server/serveBuild.ts +43 -52
  96. package/src/server/serveDev.ts +81 -39
  97. package/src/server/shell.ts +7 -22
  98. package/src/server/sourceMappings.ts +139 -0
  99. package/src/server/surfaceOf.ts +18 -3
  100. package/src/server/testBuild.ts +1 -19
  101. package/src/server/withAbideSources.ts +117 -0
  102. package/src/shared/ADDRESSES.ts +7 -18
  103. package/src/shared/APP_META.ts +7 -0
  104. package/src/shared/CopyOptions.ts +1 -1
  105. package/src/shared/DEBUG_META.ts +1 -1
  106. package/src/shared/GLOBAL_GETS.ts +9 -3
  107. package/src/shared/RESERVED_DIRECTORIES.ts +15 -0
  108. package/src/shared/RPC_SERVER.ts +2 -2
  109. package/src/shared/cookies.ts +2 -2
  110. package/src/shared/csp.ts +1 -1
  111. package/src/shared/escapeText.ts +1 -1
  112. package/src/shared/filledPath.ts +85 -0
  113. package/src/shared/framing.ts +79 -6
  114. package/src/shared/headKeyOf.ts +2 -0
  115. package/src/shared/index.ts +1 -1
  116. package/src/shared/log.ts +27 -13
  117. package/src/shared/matchRoute.ts +23 -4
  118. package/src/shared/menuItem.ts +3 -3
  119. package/src/shared/reactive/Cursor.ts +3 -9
  120. package/src/shared/reactive/Keyed.ts +4 -3
  121. package/src/shared/reactive/NotLanded.ts +1 -1
  122. package/src/shared/reactive/Reactive.ts +81 -60
  123. package/src/shared/reactive/argsContract.ts +4 -4
  124. package/src/shared/reactive/canonicalKey.ts +4 -4
  125. package/src/shared/reactive/chunksOfStream.ts +6 -0
  126. package/src/shared/reactive/index.ts +1 -1
  127. package/src/shared/reactive/recordWrites.ts +103 -5
  128. package/src/shared/reactive/structural.ts +1 -1
  129. package/src/shared/reactive/tracking.ts +155 -21
  130. package/src/shared/reactive/watch.ts +10 -6
  131. package/src/shared/refuse.ts +1 -1
  132. package/src/shared/routePattern.ts +82 -46
  133. package/src/shared/rpc.ts +47 -13
  134. package/src/shared/rpcRequest.ts +27 -3
  135. package/src/shared/url.ts +4 -44
  136. package/src/shared/writePath.ts +1 -1
  137. package/src/ui/LAYER_NAMES.ts +1 -1
  138. package/src/ui/List.ts +3 -3
  139. package/src/ui/MOUNTING_LEVEL.ts +18 -0
  140. package/src/ui/Mismatch.ts +1 -1
  141. package/src/ui/RawRun.ts +1 -1
  142. package/src/ui/Router.ts +38 -31
  143. package/src/ui/Scope.ts +1 -1
  144. package/src/ui/TryRegion.ts +2 -2
  145. package/src/ui/applyHead.ts +33 -0
  146. package/src/ui/index.ts +1 -1
  147. package/src/ui/menuBridge.ts +1 -1
  148. package/src/ui/moveNode.ts +1 -1
  149. package/src/ui/reconcile.ts +2 -2
  150. package/src/ui/reportMenu.ts +2 -2
  151. package/src/ui/routing.ts +2 -2
  152. package/src/ui/setAttribute.ts +1 -1
  153. package/src/ui/setText.ts +1 -1
  154. package/src/ui/socketWire.ts +9 -7
  155. package/src/ui/wire.ts +1 -1
  156. package/scaffold/src/server/app.ts +0 -6
  157. package/scaffold/src/shared/.gitkeep +0 -0
  158. package/scaffold/src/ui/app.html +0 -8
  159. package/src/server/componentImports.ts +0 -56
  160. /package/scaffold/src/{ui/pages/page.abide → page.abide} +0 -0
package/bin/abide.ts CHANGED
@@ -5,19 +5,27 @@
5
5
  import { existsSync, realpathSync } from 'node:fs' // bun exposes no sync existence check or realpath of its own
6
6
  import { mkdir, rename, rm } from 'node:fs/promises' // bun exposes no directory creation, rename or recursive directory removal of its own
7
7
  import { dirname, resolve } from 'node:path' // bun exposes no path helpers of its own
8
- import { brotliCompress, constants } from 'node:zlib' // bun has gzip and zstd of its own, and no brotli
9
-
8
+ import { brotliCompress, constants } from 'node:zlib' // bun's brotli is `CompressionStream`, which compresses on the main thread — 1390 ms against this pool's 300 ms over the docs bundle
10
9
  import { checkApp } from '#compiler/checkApp.ts'
11
- import { compileApp } from '#compiler/compileApp.ts'
10
+ import { type CompiledApp, compileApp, paints } from '#compiler/compileApp.ts'
12
11
  import { compiledEntry } from '#compiler/compiledEntry.ts'
13
12
  import { formatDiagnosis } from '#compiler/formatDiagnosis.ts'
13
+ import { parseAbide } from '#compiler/parseAbide.ts'
14
14
  import { scaffold } from '#compiler/scaffold.ts'
15
15
  import { serveLanguage } from '#compiler/serveLanguage.ts'
16
+ import { syncTypes } from '#compiler/syncTypes.ts'
17
+ import {
18
+ abideModules,
19
+ type Compilation,
20
+ servedTable,
21
+ serverMapsOf,
22
+ } from '#server/abideModules.ts'
16
23
  import { appPlugins } from '#server/appPlugins.ts'
17
24
  import { banner } from '#server/banner.ts'
18
25
  import { buildDirectory } from '#server/buildDirectory.ts'
19
26
  import {
20
27
  CLIENT_DIRECTORY,
28
+ COMPILATION,
21
29
  ENCODING_SUFFIX,
22
30
  MANIFEST,
23
31
  PUBLIC_DIRECTORY,
@@ -27,25 +35,21 @@ import {
27
35
  stylesheetUnderMount,
28
36
  } from '#server/clientBuildOptions.ts'
29
37
  import { type Given, type Host, runCommands } from '#server/commands.ts'
30
- import { componentImports } from '#server/componentImports.ts'
31
38
  import { type AppPackage, packageUnder } from '#server/config.ts'
32
- import {
33
- appRoot,
34
- createApp,
35
- hooksModuleUnder,
36
- originUnder,
37
- } from '#server/createApp.ts'
39
+ import { appRoot, createApp, originUnder } from '#server/createApp.ts'
40
+ import { executableName } from '#server/executableName.ts'
38
41
  import { fenceServer } from '#server/fenceServer.ts'
39
- import { launcherOptionsUnder } from '#server/launcherOptions.ts'
42
+ import { iconAccent } from '#server/iconAccent.ts'
43
+ import { ICON_ADDRESSES, LAUNCHER_PAGE } from '#server/LAUNCHER_FILES.ts'
40
44
  import { manifestOf, NO_MANIFEST } from '#server/manifestOf.ts'
41
45
  import { namesUnder } from '#server/namesUnder.ts'
42
46
  import { refuseAddressCollision } from '#server/refuseAddressCollision.ts'
43
- import { shellUnder } from '#server/render.ts'
47
+ import { DEFAULT_DOCUMENT, intoHead } from '#server/render.ts'
44
48
  import {
45
- handlersIn,
46
49
  loadBuild,
47
50
  pathsUnder,
48
51
  serveApp,
52
+ serveBuild,
49
53
  } from '#server/serveBuild.ts'
50
54
  import { serveDev } from '#server/serveDev.ts'
51
55
  import { escapeText } from '#shared/escapeText.ts'
@@ -54,94 +58,109 @@ import { escapeText } from '#shared/escapeText.ts'
54
58
  type AppDirectories = { root: string; out: string }
55
59
 
56
60
  /**
57
- * `abide`'s host: the app on disk, found by `--root` (38.26), and every command that needs the
61
+ * `abide`'s host: the app on disk, found by `--root` (38.25), and every command that needs the
58
62
  * source and the compiler.
59
63
  */
60
64
  const DISK: Host = {
61
65
  name: 'abide',
66
+ // A BUILD ALREADY THERE, NEVER ONE MADE FOR THE ASKING: `call` and the shell are aimed at a
67
+ // running app (38.7), which this tree may not even be.
62
68
  handlers: async ({ values }) => {
63
69
  const app = directoriesOf(values)
64
- return app !== undefined && existsSync(`${app.out}/rpc.ts`)
65
- ? await handlersIn(app.out)
66
- : []
70
+ if (
71
+ app === undefined ||
72
+ !(await serveBuild(app.root, app.out, async () => undefined))
73
+ )
74
+ return []
75
+ return (await servedTable(app.root)).handlers
67
76
  },
68
77
  appPackage: () => packageUnder(appRoot(undefined) ?? process.cwd()),
69
78
  boot: createApp,
70
- serve: inApp(({ root, out }, { values: { port } }) =>
71
- serveApp(
79
+ serve: inApp(async ({ root, out }, { values: { port } }) => {
80
+ if (!(await serveBuild(root, out, buildInProcess))) {
81
+ console.error(`abide start: nothing under ${root} to serve.`)
82
+ return 1
83
+ }
84
+ return serveApp(
72
85
  originUnder(root),
73
86
  () => loadBuild({ root, out }),
74
87
  port === undefined ? undefined : Number(port),
75
- ),
76
- ),
88
+ )
89
+ }),
77
90
  source: {
78
91
  scaffold: scaffoldCommand,
79
92
  dev: inApp(dev),
80
93
  build: inApp(build),
81
94
  check: inApp(check),
95
+ sync: inApp(sync),
82
96
  compile: inApp(compile),
83
97
  bundle: inApp(bundle),
84
98
  lsp,
85
99
  },
86
100
  }
87
101
 
102
+ // 38.100 — what `serveBuild` builds an app nobody built with, in this process rather than a child.
103
+ function buildInProcess(root: string, out: string): Promise<unknown> {
104
+ return writeBuild({ root, out })
105
+ }
106
+
88
107
  /** `abide build`: compiles the app, bundles its client and copies its public files into `out`. */
89
108
  async function build(directories: AppDirectories): Promise<number> {
90
109
  await writeBuild(directories)
91
110
  return 0
92
111
  }
93
112
 
94
- /** `build`'s work; answers whether there was anything under `root` to build. */
95
- async function writeBuild({ root, out }: AppDirectories): Promise<boolean> {
113
+ /** `build`'s work; answers the app it built, or `undefined` where there was nothing under `root`. */
114
+ async function writeBuild({
115
+ root,
116
+ out,
117
+ }: AppDirectories): Promise<CompiledApp | undefined> {
96
118
  process.env.NODE_ENV ??= 'production'
97
- // 38.67 — what this build may remove is only what was written before it began.
119
+ // 38.66 — what this build may remove is only what was written before it began.
98
120
  const began = Date.now()
121
+ // 38.100 — a build that fails leaves no build: the previous one's marker goes before anything
122
+ // can fail, so `abide start` builds again and says why rather than booting what is left.
123
+ const marker = `${out}/${COMPILATION}`
124
+ await rm(marker, { force: true })
99
125
  // `--out` is a real flag rather than a constant because two test files each building into
100
126
  // one `.abide/` is the contention CLAUDE.md's "checks" section already records once.
101
- const app = await compileApp(root, out)
127
+ const app = await compileApp(root)
102
128
  for (const warning of app.warnings)
103
129
  console.warn(formatDiagnosis(warning, process.cwd()))
104
- if (app.modules.size === 0) {
130
+ if (Object.keys(app.files).length === 0) {
105
131
  console.log(`abide build: no .abide sources or handlers under ${root}`)
106
- return false
132
+ return undefined
107
133
  }
108
- // WRITTEN TOGETHER. 175 modules for the docs app, each independent of the others, and one
109
- // `await` a time made the emit a queue — the same reason `publicFilesAt` and the dev
110
- // worker's own reads are one `Promise.all` each.
111
- const writes: Promise<void>[] = []
112
- for (const [name, text] of app.modules)
113
- writes.push(writeWhole(`${out}/${name}`, text))
114
- await Promise.all(writes)
115
-
116
- // A MODULE WHOSE SOURCE IS GONE IS REMOVED, not left standing, or the output is the union of
117
- // every build and a grep over it answers about code no build produces — the same class as the
118
- // orphan chunks `bundleClient` prunes, one level up.
119
- //
120
- // PRUNED AGAINST WHAT WAS JUST WRITTEN rather than by emptying `out`: the directory is named
121
- // by `--out` and a flag that deletes a tree is a different kind of thing from one that writes
122
- // into it. `client/` holds `.js` and is pruned where it is built.
123
- await pruneStale(out, '**/*.ts', app.modules, began)
124
- // 36.11 — the path below the directory IS the address. `favicon.ico` answers at
125
- // `/favicon.ico`, and nothing declares that anywhere.
126
- const publicNames = namesUnder(`${root}/ui/${PUBLIC_DIRECTORY}`)
127
- const publicAddresses = publicNames.map((name) => `/${name}`)
134
+ // 38.6 — THE COMPILATION, KEPT WHOLE: `abide start` serves every `.abide` out of it at the
135
+ // file's own path, with no compiler, and a module's imports resolve from the source they name.
136
+ const compilation: Compilation = { files: app.files, tables: app.tables }
137
+ // 36.11 — the directories above `public` and the path below it ARE the address.
138
+ // `src/public/favicon.ico` answers at `/favicon.ico`, and nothing declares that anywhere.
139
+ const publicAddresses = [...app.published.values()]
128
140
  // 36.13 IS CHECKED BEFORE A BYTE IS BUNDLED OR COPIED, through `refuseAddressCollision` — which
129
141
  // `abide dev` runs too. Checking it mid-copy left a partial output behind on the throw, and it
130
142
  // left the dev lane unchecked entirely.
131
- refuseAddressCollision(app.routes, publicAddresses, 'abide build')
143
+ refuseAddressCollision(app.routes, app.mounts, app.published, 'abide build')
132
144
  await bundleClient(
145
+ root,
133
146
  out,
134
- [fenceServer(root, app.modules), ...(await appPlugins(root))],
147
+ [
148
+ fenceServer(root),
149
+ abideModules(root, compilation),
150
+ ...(await appPlugins(root)),
151
+ ],
135
152
  publicAddresses,
136
153
  app.routes.length > 0,
137
154
  began,
138
155
  )
139
156
  // An app's static files are its own whether or not it has a page yet.
140
- const published = await copyPublic(root, out, publicNames)
157
+ const copied = await copyPublic(root, out, app.published, began)
158
+ // LAST, because its presence is what `serveBuild` reads as a finished build (38.100).
159
+ await writeWhole(marker, JSON.stringify(compilation))
141
160
  console.log(
142
- `abide build: ${app.sources} source(s), ${published} public file(s) -> ${out}`,
161
+ `abide build: ${app.sources} source(s), ${copied} public file(s) -> ${out}`,
143
162
  )
144
- return true
163
+ return app
145
164
  }
146
165
 
147
166
  /**
@@ -165,35 +184,35 @@ const SERVER_STYLESHEETS: Bun.BunPlugin = {
165
184
  /**
166
185
  * `abide compile`: builds the app, then one executable per target platform.
167
186
  *
168
- * 38.11 — THE BUILD, AND THEN ONE EXECUTABLE PER TARGET with all of it inside (`compiledEntry`).
187
+ * 38.10 — THE BUILD, AND THEN ONE EXECUTABLE PER TARGET with all of it inside (`compiledEntry`).
169
188
  * `--out` is the executable rather than the build, which goes where `abide build` puts it; with no
170
- * `--out` the executable is the app's own name, in the directory the command was run from (38.47).
189
+ * `--out` the executable is the app's own name, in the directory the command was run from (38.46).
171
190
  */
172
191
  async function compile(
173
192
  { root }: AppDirectories,
174
193
  { values }: Given,
175
194
  ): Promise<number> {
176
- // 38.49.
195
+ // 38.48.
177
196
  if (values.target !== undefined && values.platforms !== undefined) {
178
197
  console.error(
179
198
  'abide compile: --target is one platform and --platforms is several; give one of them.',
180
199
  )
181
200
  return 1
182
201
  }
183
- const entry = await writtenEntry(root, undefined)
184
- if (entry === undefined) {
202
+ const written = await writtenEntry(root, undefined)
203
+ if (written === undefined) {
185
204
  console.error(`abide compile: nothing under ${root} to compile.`)
186
205
  return 1
187
206
  }
188
- const named = resolve(values.out ?? executableName(root))
207
+ const named = resolve(values.out ?? executableName(packageUnder(root).name))
189
208
  const platforms =
190
209
  values.platforms?.split(',') ??
191
210
  (values.target === undefined ? [undefined] : [values.target])
192
211
  for (const platform of platforms) {
193
- // 38.48.
212
+ // 38.47.
194
213
  const outfile =
195
214
  values.platforms === undefined ? named : `${named}-${platform}`
196
- await compileExecutable(root, entry, platform, outfile)
215
+ await compileExecutable(root, written, platform, outfile)
197
216
  console.log(`abide compile: ${outfile}`)
198
217
  }
199
218
  return 0
@@ -202,39 +221,43 @@ async function compile(
202
221
  /**
203
222
  * `abide bundle`: builds the app, then a desktop app for this platform around its executable.
204
223
  *
205
- * 38.11, 38.71 — `compile`'s executable, started from `runDesktop`, inside a `.app` named after the
224
+ * 38.10, 38.70 — `compile`'s executable, started from `runDesktop`, inside a `.app` named after the
206
225
  * app where the command was run, or at `--out`.
207
226
  */
208
227
  async function bundle(
209
228
  { root }: AppDirectories,
210
229
  { values }: Given,
211
230
  ): Promise<number> {
212
- // 38.78.
231
+ // 38.77.
213
232
  if (process.platform !== 'darwin') {
214
233
  console.error(
215
234
  `abide bundle: a desktop app is built for macOS, and this is ${process.platform}. \`abide compile\` builds an executable for any platform.`,
216
235
  )
217
236
  return 1
218
237
  }
219
- const out = resolve(values.out ?? `${executableName(root)}.app`)
238
+ const out = resolve(
239
+ values.out ?? `${executableName(packageUnder(root).name)}.app`,
240
+ )
220
241
  if (!out.endsWith('.app')) {
221
242
  console.error(
222
243
  `abide bundle: --out names the .app to write, and ${out} is not one.`,
223
244
  )
224
245
  return 1
225
246
  }
226
- // 38.81 — REFUSED BEFORE ANYTHING IS BUILT, so a mistyped key costs no build.
227
- const options = await launcherOptionsUnder(root)
228
- if (typeof options === 'string') {
229
- console.error(`abide bundle: ${options}.`)
230
- return 1
231
- }
232
- const entry = await writtenEntry(root, async (out) => {
233
- const launch = `${root}/bundle/launch.abide`
247
+ const entry = await writtenEntry(root, async (app) => {
248
+ const launcherPath = `${root}/${LAUNCHER_PAGE}`
249
+ const hasLauncher = app.files[LAUNCHER_PAGE] !== undefined
250
+ const art = await launcherIcon(root)
234
251
  return {
235
- options,
236
- launch: (await Bun.file(launch).exists()) ? launch : undefined,
237
- shell: await launchShell(root, out),
252
+ // 34.32 — its `launcher` export is read when the app launches, off the module.
253
+ launch: hasLauncher ? launcherPath : undefined,
254
+ // 38.82 — a `launcher.abide` of its script blocks alone keeps the built-in page.
255
+ custom:
256
+ hasLauncher &&
257
+ paints(parseAbide(await Bun.file(launcherPath).text())),
258
+ document: launchDocument(app),
259
+ icon: art?.icon,
260
+ accent: art?.accent,
238
261
  }
239
262
  })
240
263
  if (entry === undefined) {
@@ -242,7 +265,7 @@ async function bundle(
242
265
  return 1
243
266
  }
244
267
  const identity = packageUnder(root)
245
- const name = executableName(root)
268
+ const name = executableName(packageUnder(root).name)
246
269
  await compileExecutable(
247
270
  root,
248
271
  entry,
@@ -252,7 +275,7 @@ async function bundle(
252
275
  const icon = await writeIcon(root, `${out}/Contents/Resources`)
253
276
  await Bun.write(
254
277
  `${out}/Contents/Info.plist`,
255
- infoPlist(name, options.name ?? name, identity, icon),
278
+ infoPlist(name, identity, icon),
256
279
  )
257
280
  // AD-HOC SIGNED, WHOLE: the executable bun wrote is signed on its own, and a bundle around a
258
281
  // signed executable that is not itself signed is one Gatekeeper reads as damaged.
@@ -262,11 +285,6 @@ async function bundle(
262
285
  return 0
263
286
  }
264
287
 
265
- // 38.47 — A SCOPED NAME IS NAMED BY ITS LAST PART: `@acme/ledger` is `ledger`, as `bunx` calls it.
266
- function executableName(root: string): string {
267
- return (packageUnder(root).name ?? 'app').replace(/^@[^/]+\//, '')
268
- }
269
-
270
288
  /**
271
289
  * Builds the app and writes the entry an executable starts from; `undefined` where there is no app.
272
290
  *
@@ -276,58 +294,56 @@ async function writtenEntry(
276
294
  root: string,
277
295
  desktop:
278
296
  | ((
279
- out: string,
297
+ app: CompiledApp,
280
298
  ) => Promise<Parameters<typeof compiledEntry>[0]['desktop']>)
281
299
  | undefined,
282
- ): Promise<string | undefined> {
300
+ ): Promise<{ entry: string; compilation: Compilation } | undefined> {
283
301
  const out = buildDirectory(root)
284
- if (!(await writeBuild({ root, out }))) return undefined
285
- const handlerModules = new Map<string, string>()
286
- for (const { source } of await handlersIn(out))
287
- handlerModules.set(source, `${root}/${source}`)
302
+ const app = await writeBuild({ root, out })
303
+ if (app === undefined) return undefined
288
304
  const entry = `${out}/compiled.ts`
289
305
  await Bun.write(
290
306
  entry,
291
307
  compiledEntry({
292
- desktop: await desktop?.(out),
293
- out,
294
- hooks: hooksModuleUnder(root),
308
+ desktop: await desktop?.(app),
309
+ root,
310
+ sourceMaps: serverMapsOf(app, root),
295
311
  package: packageUnder(root),
296
- shell: await shellUnder(root),
297
312
  manifest: await Bun.file(`${out}/${MANIFEST}`).json(),
298
- handlerModules,
299
313
  client: pathsUnder(`${out}/${CLIENT_DIRECTORY}`),
300
314
  published: pathsUnder(`${out}/${PUBLIC_DIRECTORY}`),
301
315
  }),
302
316
  )
303
- return entry
317
+ return { entry, compilation: app }
304
318
  }
305
319
 
306
- /** One executable from `entry`, for `platform` or this one, written to `outfile`. */
320
+ /** One executable from `written`'s entry, for `platform` or this one, written to `outfile`. */
307
321
  async function compileExecutable(
308
322
  root: string,
309
- entry: string,
323
+ written: { entry: string; compilation: Compilation },
310
324
  platform: string | undefined,
311
325
  outfile: string,
312
326
  ): Promise<void> {
313
327
  const compiled = await Bun.build({
314
- entrypoints: [entry],
328
+ entrypoints: [written.entry],
315
329
  target: 'bun',
316
- // 35.4 — a handler's `.abide` import, as `abide start` lands it.
317
- plugins: [
318
- SERVER_STYLESHEETS,
319
- componentImports(root, buildDirectory(root)),
320
- ],
330
+ // 34.16 — every `.abide` the app imports, as `abide start` serves it.
331
+ plugins: [SERVER_STYLESHEETS, abideModules(root, written.compilation)],
321
332
  // UNMINIFIED, AND MAPPED: the executable is a server, and an error it logs is read by
322
333
  // whoever operates it — a minified line prints as the whole page module it sits in. The
323
334
  // map is embedded in the executable, which reads it from there.
324
335
  sourcemap: 'inline',
336
+ // 38.93 — parsed at build time rather than on every boot. ESM because the entry awaits at
337
+ // its top level, and bun's bytecode default is CommonJS, which refuses that. Nothing in the
338
+ // executable or the build's outputs says bytecode is inside, so no test can see this.
339
+ bytecode: true,
340
+ format: 'esm',
325
341
  compile: {
326
342
  ...(platform === undefined
327
343
  ? {}
328
344
  : { target: `bun-${platform}` as Bun.Build.CompileTarget }),
329
345
  outfile,
330
- // 38.46 — THE EXECUTABLE IS THE BUILD'S, not the machine's: a `bunfig.toml`
346
+ // 38.45 — THE EXECUTABLE IS THE BUILD'S, not the machine's: a `bunfig.toml`
331
347
  // wherever it is started must not change what it runs. `.env` is still read, as
332
348
  // `abide start` reads it — configuration is the environment's (27.8).
333
349
  autoloadBunfig: false,
@@ -345,39 +361,67 @@ async function compileExecutable(
345
361
  }
346
362
 
347
363
  /**
348
- * The document `src/bundle/launch.abide` renders into: `src/bundle/launch.html`, or a bare one.
364
+ * The document `src/launcher.abide` renders into: abide's own, which the page writes its
365
+ * `<html>` onto (35.15).
349
366
  *
350
- * 38.85 — THE PAGE'S OWN `<style>` IS IN ITS HEAD. A component's stylesheet reaches a browser with
367
+ * 38.84 — THE PAGE'S OWN `<style>` IS IN ITS HEAD. A component's stylesheet reaches a browser with
351
368
  * its client module, and a launch page has none: it is rendered before the app it would load from
352
369
  * is served.
353
370
  */
354
- async function launchShell(root: string, out: string): Promise<string> {
355
- const given = Bun.file(`${root}/bundle/launch.html`)
356
- const document = (await given.exists())
357
- ? await given.text()
358
- : '<!doctype html>\n<html lang="en">\n<head>\n<meta charset="utf-8">\n<meta name="viewport" content="width=device-width, initial-scale=1">\n</head>\n<body></body>\n</html>\n'
359
- const sheet = Bun.file(`${out}/bundle/launch.style.css`)
360
- if (!(await sheet.exists())) return document
361
- const style = `<style>\n${await sheet.text()}</style>\n`
362
- const headEnd = document.search(/<\/head>/i)
363
- return headEnd === -1
364
- ? `${style}${document}`
365
- : `${document.slice(0, headEnd)}${style}${document.slice(headEnd)}`
371
+ function launchDocument(app: CompiledApp): string {
372
+ const sheet = app.files[LAUNCHER_PAGE]?.sheet
373
+ return sheet === undefined
374
+ ? DEFAULT_DOCUMENT
375
+ : intoHead(DEFAULT_DOCUMENT, `<style>\n${sheet}</style>`)
366
376
  }
367
377
 
368
378
  /**
369
- * Writes the bundle's icon from `src/bundle/`, and answers whether there was one.
379
+ * The icon the launcher page shows, as a data URL, and the accent it takes from it — or nothing
380
+ * where the app has no icon.
370
381
  *
371
- * 38.80 — `icon.icns` as it is, or `icon.png` drawn at every size an `.icns` holds.
382
+ * 38.101 — drawn small by `sips` first: the accent is a fact about the picture, not its pixels.
383
+ */
384
+ async function launcherIcon(
385
+ root: string,
386
+ ): Promise<{ icon: string; accent: string | undefined } | undefined> {
387
+ const source = await publishedIcon(root)
388
+ if (source === undefined) return undefined
389
+ const drawn = async (size: number): Promise<Uint8Array | undefined> => {
390
+ const path = `${buildDirectory(root)}/icon-${size}.png`
391
+ const code = await spawnedTool('sips', [
392
+ '-s',
393
+ 'format',
394
+ 'png',
395
+ '-z',
396
+ String(size),
397
+ String(size),
398
+ source,
399
+ '--out',
400
+ path,
401
+ ])
402
+ return code === 0 ? await Bun.file(path).bytes() : undefined
403
+ }
404
+ const [shown, sampled] = await Promise.all([drawn(256), drawn(64)])
405
+ if (shown === undefined) return undefined
406
+ return {
407
+ icon: `data:image/png;base64,${shown.toBase64()}`,
408
+ accent: sampled === undefined ? undefined : iconAccent(sampled),
409
+ }
410
+ }
411
+
412
+ /**
413
+ * Writes the bundle's icon from the public files the build copied, and answers whether there was one.
414
+ *
415
+ * 38.79 — the file at `/icon.icns` as it is, or the one at `/icon.png` drawn at every size an
416
+ * `.icns` holds.
372
417
  */
373
418
  async function writeIcon(root: string, resources: string): Promise<boolean> {
374
- const icns = Bun.file(`${root}/bundle/icon.icns`)
375
- if (await icns.exists()) {
376
- await Bun.write(`${resources}/icon.icns`, icns)
419
+ const source = await publishedIcon(root)
420
+ if (source === undefined) return false
421
+ if (source.endsWith(ICON_ADDRESSES.icns)) {
422
+ await Bun.write(`${resources}/icon.icns`, Bun.file(source))
377
423
  return true
378
424
  }
379
- const png = `${root}/bundle/icon.png`
380
- if (!(await Bun.file(png).exists())) return false
381
425
  const iconset = `${buildDirectory(root)}/icon.iconset`
382
426
  await rm(iconset, { recursive: true, force: true })
383
427
  await mkdir(iconset, { recursive: true })
@@ -390,7 +434,7 @@ async function writeIcon(root: string, resources: string): Promise<boolean> {
390
434
  '-z',
391
435
  String(size * scale),
392
436
  String(size * scale),
393
- png,
437
+ source,
394
438
  '--out',
395
439
  `${iconset}/icon_${size}x${size}${scale === 2 ? '@2x' : ''}.png`,
396
440
  ]),
@@ -407,6 +451,15 @@ async function writeIcon(root: string, resources: string): Promise<boolean> {
407
451
  )
408
452
  }
409
453
 
454
+ /** 38.79 — the public file the app's icon is drawn from: `/icon.icns`, else `/icon.png`. */
455
+ async function publishedIcon(root: string): Promise<string | undefined> {
456
+ const published = `${buildDirectory(root)}/${PUBLIC_DIRECTORY}`
457
+ for (const address of [ICON_ADDRESSES.icns, ICON_ADDRESSES.png])
458
+ if (await Bun.file(`${published}${address}`).exists())
459
+ return `${published}${address}`
460
+ return undefined
461
+ }
462
+
410
463
  /** Runs one of macOS's own tools, warning with what it wrote where it fails; answers its exit code. */
411
464
  async function spawnedTool(tool: string, args: string[]): Promise<number> {
412
465
  const child = Bun.spawn([tool, ...args], {
@@ -422,12 +475,7 @@ async function spawnedTool(tool: string, args: string[]): Promise<number> {
422
475
  }
423
476
 
424
477
  /** The `Info.plist` a bundle names its executable, its identity and its icon in. */
425
- function infoPlist(
426
- name: string,
427
- displayName: string,
428
- identity: AppPackage,
429
- icon: boolean,
430
- ): string {
478
+ function infoPlist(name: string, identity: AppPackage, icon: boolean): string {
431
479
  const entries: [string, string][] = [
432
480
  ...(icon
433
481
  ? ([['CFBundleIconFile', '<string>icon</string>']] as [
@@ -440,8 +488,8 @@ function infoPlist(
440
488
  'CFBundleIdentifier',
441
489
  `<string>abide.app.${name.replace(/[^A-Za-z0-9.-]/g, '-')}</string>`,
442
490
  ],
443
- ['CFBundleName', `<string>${escapeText(displayName)}</string>`],
444
- ['CFBundleDisplayName', `<string>${escapeText(displayName)}</string>`],
491
+ ['CFBundleName', `<string>${escapeText(name)}</string>`],
492
+ ['CFBundleDisplayName', `<string>${escapeText(name)}</string>`],
445
493
  ['CFBundlePackageType', '<string>APPL</string>'],
446
494
  [
447
495
  'CFBundleShortVersionString',
@@ -451,7 +499,7 @@ function infoPlist(
451
499
  ['LSMinimumSystemVersion', '<string>13.0</string>'],
452
500
  ['NSHighResolutionCapable', '<true/>'],
453
501
  ['NSPrincipalClass', '<string>NSApplication</string>'],
454
- // 38.72 — the app served on loopback over http, and one connected to wherever it runs.
502
+ // 38.71 — the app served on loopback over http, and one connected to wherever it runs.
455
503
  [
456
504
  'NSAppTransportSecurity',
457
505
  '<dict><key>NSAllowsArbitraryLoads</key><true/></dict>',
@@ -470,30 +518,35 @@ ${body}</dict>
470
518
  }
471
519
 
472
520
  /**
473
- * Copies the app's public files into the build output, and returns how many.
521
+ * Copies the app's public files into the build output, each at its address, and returns how many.
474
522
  *
475
- * 38.19 — `src/ui/public`, copied into the built output. Copied rather than left where it is, so
476
- * `abide start` answers out of what the build produced and an app moving a file after a build
477
- * cannot change what is served.
523
+ * 38.18 — every `public` directory, copied into the built output at the address each file is
524
+ * answered at, so `abide start` reads the address off the output's own layout. Copied rather than
525
+ * left where it is, so `abide start` answers out of what the build produced and an app moving a
526
+ * file after a build cannot change what is served.
478
527
  */
479
528
  async function copyPublic(
480
529
  root: string,
481
530
  out: string,
482
- names: string[],
531
+ published: Map<string, string>,
532
+ began: number,
483
533
  ): Promise<number> {
484
- const from = `${root}/ui/${PUBLIC_DIRECTORY}`
485
-
486
534
  const copies: Promise<void>[] = []
535
+ const produced = new Set<string>()
487
536
  // 36.12 — the bytes as authored. `Bun.write` of a `BunFile` is a copy and not a re-encode.
488
- for (const name of names)
537
+ for (const [source, address] of published) {
538
+ produced.add(address.slice(1))
489
539
  copies.push(
490
540
  writeWhole(
491
- `${out}/${PUBLIC_DIRECTORY}/${name}`,
492
- Bun.file(`${from}/${name}`),
541
+ `${out}/${PUBLIC_DIRECTORY}${address}`,
542
+ Bun.file(`${root}/${source}`),
493
543
  ),
494
544
  )
545
+ }
495
546
  await Promise.all(copies)
496
- return names.length
547
+ // 38.66 — a public file the app no longer has is no longer answered.
548
+ await pruneStale(`${out}/${PUBLIC_DIRECTORY}`, produced, began)
549
+ return published.size
497
550
  }
498
551
 
499
552
  /**
@@ -506,10 +559,11 @@ async function copyPublic(
506
559
  * `--splitting`, so a destination's markup and bindings stay in the chunk its own route loads
507
560
  * (30.11) rather than landing in the entry every page pays for. 38.5 IS THIS FUNCTION: hashed and
508
561
  * minified by the bundler, the manifest off its metafile (the entry's name, and the chunks each
509
- * route module needs for 38.29), and the precompressed copies (`precompressOne`). What the manifest
510
- * does NOT carry yet is 38.19's list of public files.
562
+ * route module needs for 38.28), and the precompressed copies (`precompressOne`). What the manifest
563
+ * does NOT carry yet is 38.18's list of public files.
511
564
  */
512
565
  async function bundleClient(
566
+ root: string,
513
567
  out: string,
514
568
  plugins: Bun.BunPlugin[],
515
569
  publicAddresses: string[],
@@ -520,18 +574,18 @@ async function bundleClient(
520
574
  // AN APP OF HANDLERS ALONE has no page to boot, and is served with no client — the manifest
521
575
  // written anyway, so `abide start` reads one shape whatever the app holds.
522
576
  if (!paged) {
523
- await pruneStale(client, '**/*', new Set(), began)
577
+ await pruneStale(client, new Set(), began)
524
578
  await writeWhole(`${out}/${MANIFEST}`, JSON.stringify(NO_MANIFEST))
525
579
  return
526
580
  }
527
- // 38.67 — STAGED, THEN MOVED IN FILE BY FILE. `Bun.build` writes in place, so a second build
581
+ // 38.66 — STAGED, THEN MOVED IN FILE BY FILE. `Bun.build` writes in place, so a second build
528
582
  // into the same `out` — or `abide start` serving it — read a chunk half written, and emptying
529
583
  // `client/` first took the one it was serving away. Every name but the manifest's is a hash of
530
584
  // what it holds, so two builds of one tree move in the same bytes under the same names.
531
585
  const staging = `${out}/.staging-${crypto.randomUUID()}`
532
586
  try {
533
587
  const built = await Bun.build({
534
- ...clientBuildOptions(out, plugins, publicAddresses),
588
+ ...clientBuildOptions(root, plugins, publicAddresses),
535
589
  outdir: staging,
536
590
  // 38.5 — MINIFIED, and EVERY NAME HASHED, the entry's included: chunks and assets are
537
591
  // already `[name]-[hash]` by default and the entry is not, which left the one file every
@@ -570,16 +624,11 @@ async function bundleClient(
570
624
  )
571
625
  }
572
626
  await Promise.all(moves)
573
- await pruneStale(client, '**/*', produced, began)
574
- // THE REAL PATH, because the metafile names a file on disk with its symlinks resolved — and an
575
- // `--out` under `/tmp` is `/private/tmp` to the bundler, which would key nothing at all.
627
+ await pruneStale(client, produced, began)
576
628
  await writeWhole(
577
629
  `${out}/${MANIFEST}`,
578
630
  JSON.stringify(
579
- manifestOf(
580
- built.metafile as Bun.BuildMetafile,
581
- realpathSync(out),
582
- ),
631
+ manifestOf(built.metafile as Bun.BuildMetafile, root),
583
632
  ),
584
633
  )
585
634
  } finally {
@@ -592,18 +641,17 @@ async function bundleClient(
592
641
  *
593
642
  * A CHUNK WHOSE HASH CHANGED leaves the old one behind otherwise, and the directory is the union of
594
643
  * every build ever run into it — and the cost is not disk: a grep over the built output answers about
595
- * code no page loads. 38.67 — only what was written before this build began: anything newer is a
644
+ * code no page loads. 38.66 — only what was written before this build began: anything newer is a
596
645
  * concurrent build's, and its manifest may name it.
597
646
  */
598
647
  async function pruneStale(
599
648
  directory: string,
600
- pattern: string,
601
- produced: { has(name: string): boolean },
649
+ produced: ReadonlySet<string>,
602
650
  began: number,
603
651
  ): Promise<void> {
604
652
  const stale: Promise<void>[] = []
605
653
  // A file gone since the scan reads as last written in the far future, and is left alone.
606
- for (const name of namesUnder(directory, pattern))
654
+ for (const name of namesUnder(directory))
607
655
  if (
608
656
  !produced.has(name) &&
609
657
  Bun.file(`${directory}/${name}`).lastModified < began
@@ -615,7 +663,7 @@ async function pruneStale(
615
663
  /**
616
664
  * Writes `path` whole: to a name of its own beside it, then renamed over it.
617
665
  *
618
- * 38.67 — a reader, or a second build, finds the last complete file there and never a partial one.
666
+ * 38.66 — a reader, or a second build, finds the last complete file there and never a partial one.
619
667
  */
620
668
  async function writeWhole(path: string, content: string | Blob): Promise<void> {
621
669
  const partial = `${path}.${crypto.randomUUID()}.partial`
@@ -682,7 +730,7 @@ function inApp<Answer>(
682
730
  const app = directoriesOf(given.values)
683
731
  if (app === undefined) {
684
732
  console.error(
685
- `abide ${given.positionals[0]}: no app here. Looked for ui/ in ${process.cwd()} and in ${resolve('src')}. Point --root at the directory ui/ sits under.`,
733
+ `abide ${given.positionals[0]}: no app here. Looked for a .abide under ${resolve('src')}, or under ${process.cwd()} where it has no src. Point --root at the directory your pages sit under.`,
686
734
  )
687
735
  return 1
688
736
  }
@@ -770,10 +818,26 @@ async function check(
770
818
  return errors > 0 ? 1 : 0
771
819
  }
772
820
 
821
+ /**
822
+ * `abide sync`: writes the types a plain `.ts` reads the app's `.abide` files by.
823
+ *
824
+ * 34.24 — parse and projection alone, so it is fast enough to run on every install (the scaffold's
825
+ * `prepare`), where no checker and no build has run.
826
+ */
827
+ async function sync({ root }: AppDirectories): Promise<number> {
828
+ const { typed, refused } = await syncTypes(root)
829
+ for (const diagnosis of refused)
830
+ console.error(formatDiagnosis(diagnosis, process.cwd()))
831
+ console.log(
832
+ `abide sync: ${typed} .abide file${typed === 1 ? '' : 's'} typed`,
833
+ )
834
+ return refused.length === 0 ? 0 : 1
835
+ }
836
+
773
837
  /**
774
838
  * `abide lsp`: the language server over stdio.
775
839
  *
776
- * 38.12 — the language server, for as long as the editor holds stdin open. Nothing is printed: stdout
840
+ * 38.11 — the language server, for as long as the editor holds stdin open. Nothing is printed: stdout
777
841
  * is the protocol.
778
842
  *
779
843
  * Not `inApp`: an editor starts it wherever its workspace is, and each document finds its own app.