@abide/abide 0.105.1 → 0.106.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 (99) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/bin/abide.ts +4 -1
  3. package/docs/REGISTRY.md +156 -44
  4. package/docs/RULEBOOK.md +375 -87
  5. package/docs/abide.md +7243 -10256
  6. package/package.json +4 -1
  7. package/scaffold/AGENTS.md +2 -0
  8. package/scaffold/src/getGreeting.ts +6 -0
  9. package/scaffold/src/index.abide +3 -1
  10. package/scaffold/src/tests/getGreeting.test.ts +6 -0
  11. package/scaffold/src/tests/index.test.ts +18 -0
  12. package/src/compiler/DOCS_SITE.ts +2 -0
  13. package/src/compiler/Lowering.ts +234 -14
  14. package/src/compiler/PositionTable.ts +14 -7
  15. package/src/compiler/PositionedRefusal.ts +13 -0
  16. package/src/compiler/Refusals.ts +18 -11
  17. package/src/compiler/SHARED_NAMES.ts +2 -0
  18. package/src/compiler/ShadowMap.ts +37 -0
  19. package/src/compiler/TEMPLATE_BLOCKS.ts +12 -0
  20. package/src/compiler/TypeSession.ts +410 -31
  21. package/src/compiler/appLayout.ts +18 -2
  22. package/src/compiler/argsSchemaOf.ts +55 -9
  23. package/src/compiler/checkApp.ts +33 -0
  24. package/src/compiler/checkedShadow.ts +36 -22
  25. package/src/compiler/citedClauses.ts +14 -0
  26. package/src/compiler/classifyRead.ts +6 -0
  27. package/src/compiler/classifyTemplate.ts +2 -1
  28. package/src/compiler/compileApp.ts +2 -2
  29. package/src/compiler/declaredExports.ts +27 -6
  30. package/src/compiler/emitClient.ts +304 -103
  31. package/src/compiler/handlersUnder.ts +4 -0
  32. package/src/compiler/invocationProps.ts +1 -1
  33. package/src/compiler/isIgnored.ts +27 -0
  34. package/src/compiler/parseAbide.ts +176 -76
  35. package/src/compiler/projectShadow.ts +30 -6
  36. package/src/compiler/readSources.ts +1 -1
  37. package/src/compiler/readsIn.ts +100 -1
  38. package/src/compiler/renderMarkup.ts +36 -5
  39. package/src/compiler/scaffold.ts +19 -6
  40. package/src/compiler/serveLanguage.ts +156 -21
  41. package/src/compiler/templateContext.ts +150 -0
  42. package/src/compiler/tokenize.ts +6 -0
  43. package/src/server/BRIDGE_VERSION.ts +1 -1
  44. package/src/server/Inbox.ts +8 -0
  45. package/src/server/MCP_PROTOCOL_VERSION.ts +1 -1
  46. package/src/server/MOUNTABLE.ts +6 -0
  47. package/src/server/abideModules.ts +8 -1
  48. package/src/server/answerRequest.ts +3 -1
  49. package/src/server/answerRpc.ts +26 -12
  50. package/src/server/answerSocket.ts +26 -1
  51. package/src/server/argsFor.ts +4 -3
  52. package/src/server/assistant.ts +19 -13
  53. package/src/server/bridgeRelay.ts +108 -20
  54. package/src/server/claudeApi.ts +7 -7
  55. package/src/server/claudeCode.ts +11 -11
  56. package/src/server/commands.ts +73 -9
  57. package/src/server/createApp.ts +41 -7
  58. package/src/server/devWorker.ts +3 -3
  59. package/src/server/generatedSurfaces.ts +37 -4
  60. package/src/server/muxUpgrade.ts +47 -0
  61. package/src/server/preload.ts +13 -2
  62. package/src/server/render.ts +4 -1
  63. package/src/server/runCompiled.ts +1 -0
  64. package/src/server/seedBuffer.ts +31 -1
  65. package/src/server/serveDev.ts +83 -72
  66. package/src/server/surfaceOf.ts +3 -0
  67. package/src/server/testMount.ts +53 -0
  68. package/src/shared/ADDRESSES.ts +1 -1
  69. package/src/shared/ERROR_META.ts +3 -0
  70. package/src/shared/Schema.ts +1 -1
  71. package/src/shared/WORK.ts +14 -12
  72. package/src/shared/applyPatch.ts +1 -1
  73. package/src/shared/boundValue.ts +81 -0
  74. package/src/shared/index.ts +7 -2
  75. package/src/shared/jsonSchema.ts +25 -2
  76. package/src/shared/log.ts +11 -1
  77. package/src/shared/reactive/Keyed.ts +30 -6
  78. package/src/shared/reactive/PATCHES.ts +1 -1
  79. package/src/shared/reactive/Reactive.ts +118 -60
  80. package/src/shared/reactive/canonicalKey.ts +1 -1
  81. package/src/shared/reactive/index.ts +13 -5
  82. package/src/shared/reactive/recordWrites.ts +1 -1
  83. package/src/shared/reactive/watch.ts +26 -4
  84. package/src/shared/rpc.ts +6 -1
  85. package/src/shared/url.ts +7 -3
  86. package/src/shared/validateJson.ts +1 -1
  87. package/src/ui/ArmRegion.ts +2 -2
  88. package/src/ui/List.ts +9 -9
  89. package/src/ui/RawRun.ts +1 -1
  90. package/src/ui/Router.ts +194 -36
  91. package/src/ui/Scope.ts +29 -0
  92. package/src/ui/adoptWalk.ts +3 -3
  93. package/src/ui/applyHead.ts +25 -3
  94. package/src/ui/bindProperty.ts +70 -22
  95. package/src/ui/index.ts +1 -1
  96. package/src/ui/listenForRoutes.ts +14 -3
  97. package/src/ui/reconcile.ts +23 -28
  98. package/src/ui/regions.ts +9 -2
  99. package/src/ui/rows.ts +108 -28
@@ -302,6 +302,8 @@ function renderPosition(
302
302
 
303
303
  const parts: string[] = []
304
304
  let open = `<${node.tag}`
305
+ // 32.61 — a `<textarea>`'s or `<select>`'s bound value, which is written inside the tag.
306
+ let within: string | undefined
305
307
  // WHAT IS NOT MARKUP IS NOT WRITTEN: a listener, `bind:element`, a refused URL, a bind whose
306
308
  // value is not an attribute, and every merge member after the one the group is written at.
307
309
  for (const one of classifyAttributes(node, lowering)) {
@@ -310,7 +312,10 @@ function renderPosition(
310
312
  continue
311
313
  }
312
314
  if (one.kind === 'bound') {
313
- if (one.reflects === undefined) continue
315
+ if (one.reflects === undefined) {
316
+ if (one.bind === 'value') within = boundRead(one, lowering)
317
+ continue
318
+ }
314
319
  parts.push(JSON.stringify(open), boundMarkup(one, lowering))
315
320
  open = ''
316
321
  continue
@@ -349,7 +354,25 @@ function renderPosition(
349
354
  // `<br>`, and a close tag written for one is markup no browser accepts.
350
355
  if (node.children.length === 0 && VOID_ELEMENTS.has(node.lowerTag))
351
356
  return parts.join(' + ')
357
+ // 32.61 — THE VALUE IN PLACE OF THE AUTHOR'S TEXT, led by the newline the parser drops after
358
+ // `<textarea>` so a value opening on one keeps it.
359
+ if (
360
+ within !== undefined &&
361
+ node.lowerTag === 'textarea' &&
362
+ node.children.length === 0
363
+ )
364
+ return [
365
+ ...parts,
366
+ `'\\n' + ${lowering.use('escapeAttribute')}(${within})`,
367
+ JSON.stringify(`</${node.tag}>`),
368
+ ].join(' + ')
352
369
  const children = renderLevel(at.children, lowering, lane)
370
+ if (within !== undefined && node.lowerTag === 'select')
371
+ return [
372
+ ...parts,
373
+ `${lowering.use('selectedMarkup')}(${concat(children)}, ${within})`,
374
+ JSON.stringify(`</${node.tag}>`),
375
+ ].join(' + ')
353
376
  return [...parts, ...children, JSON.stringify(`</${node.tag}>`)].join(' + ')
354
377
  }
355
378
 
@@ -586,10 +609,7 @@ function boundMarkup(
586
609
  written: ClassifiedAttribute & { kind: 'bound' },
587
610
  lowering: Lowering,
588
611
  ): string {
589
- const read = slotValueOf(
590
- lowering,
591
- `${lowering.use('boundValue')}(${lowering.reference(written.attribute)})`,
592
- )
612
+ const read = boundRead(written, lowering)
593
613
  if (written.bind === 'value')
594
614
  return `' value="' + ${lowering.use('escapeAttribute')}(${read}) + '"'`
595
615
  const on =
@@ -599,6 +619,17 @@ function boundMarkup(
599
619
  return `(${on} ? ' ${written.reflects}' : '')`
600
620
  }
601
621
 
622
+ /** A bind's value at render, under 41.4's guard: `undefined` while it is in flight. */
623
+ function boundRead(
624
+ written: ClassifiedAttribute & { kind: 'bound' },
625
+ lowering: Lowering,
626
+ ): string {
627
+ return slotValueOf(
628
+ lowering,
629
+ `${lowering.use('boundValue')}(${lowering.reference(written.attribute)})`,
630
+ )
631
+ }
632
+
602
633
  /**
603
634
  * A static attribute's markup: the bare name, or the name and its value's bytes (`staticBytes`).
604
635
  *
@@ -1,6 +1,6 @@
1
1
  // 38.1 — `abide scaffold <name>`: THE LAYOUT EVERY ABIDE APP HAS, written into `<name>`. There is
2
- // nothing to choose, because what is scaffolded is a root layout and a page — the files the commands
3
- // read — and the test preload.
2
+ // nothing to choose, because what is scaffolded is a root layout, a page, a handler file the page
3
+ // calls, a test of that handler, and a test that mounts the page and counts the work a click does.
4
4
  // A file that is already there is left as it is and reported skipped, so a scaffold run over a
5
5
  // half-made project finishes it rather than overwriting it.
6
6
 
@@ -22,15 +22,22 @@ const ABIDE_DEPENDENCY = PACKAGE_ROOT.includes('/node_modules/')
22
22
  ? `^${abidePackage.version}`
23
23
  : `file:${PACKAGE_ROOT}`
24
24
 
25
+ // THE HARNESS BESIDE IT, by the same rule: from a registry its latest, whose peer range on abide is
26
+ // what keeps the two in step; from a checkout the checkout's own harness.
27
+ const HARNESS_DEPENDENCY = PACKAGE_ROOT.includes('/node_modules/')
28
+ ? 'latest'
29
+ : `file:${PACKAGE_ROOT.slice(0, PACKAGE_ROOT.lastIndexOf('/'))}/harness`
30
+
25
31
  // The `#src` alias, written as the package's `imports` and again as the checker's `paths`.
26
32
  const SOURCE_ALIAS = { '#src/*': ['./src/*', './src/*.ts'] }
27
33
 
28
34
  /**
29
- * 38.109 — the starter's `bunfig.toml`: a test imports the app's `.abide` files as modules, with
35
+ * 38.109, 38.119 — the starter's `bunfig.toml`: the harness's abide preload, which is a DOM a test
36
+ * can mount into and measure, then abide's own — the app's `.abide` files imported as modules, with
30
37
  * nothing booted first.
31
38
  */
32
39
  export const TEST_PRELOAD =
33
- '# 38.109 — a test imports the app\'s `.abide` files as modules, with nothing booted first.\n[test]\npreload = ["@abide/abide/test"]\n'
40
+ '# 38.109, 38.119 — a DOM to mount into and measure, then the app\'s `.abide` files as modules.\n[test]\npreload = ["@abide/harness/abide/preload"]\n'
34
41
 
35
42
  // WRITTEN, NOT COPIED, beside the manifests: a packer drops a `bunfig.toml` or a `.gitignore` from
36
43
  // the tarball (`bun pm pack` both, `npm pack` the second), so neither survives as a starter file.
@@ -51,6 +58,8 @@ function filesFor(name: string): Record<string, string> {
51
58
  start: 'abide start',
52
59
  // 38.108 — every address, and the file answering it.
53
60
  routes: 'abide routes',
61
+ // 38.112 — the starter's own test, under the 38.109 preload.
62
+ test: 'bun test',
54
63
  },
55
64
  // ONE ALIAS FOR THE SOURCE, and two targets for it, because an extensionless
56
65
  // specifier is how an import is written and only bun probes for the extension:
@@ -62,8 +71,12 @@ function filesFor(name: string): Record<string, string> {
62
71
  imports: SOURCE_ALIAS,
63
72
  dependencies: { '@abide/abide': ABIDE_DEPENDENCY },
64
73
  // `Bun`, `bun:sqlite` and the rest of the runtime a server module reaches for are
65
- // typed by these, and `abide check` reports every one untyped without them.
66
- devDependencies: { '@types/bun': 'latest' },
74
+ // typed by these, and `abide check` reports every one untyped without them. The
75
+ // harness is what the starter's tests measure the work a click does with (38.119).
76
+ devDependencies: {
77
+ '@abide/harness': HARNESS_DEPENDENCY,
78
+ '@types/bun': 'latest',
79
+ },
67
80
  },
68
81
  null,
69
82
  4,
@@ -1,8 +1,11 @@
1
1
  import { existsSync, type FSWatcher, watch } from 'node:fs' // bun exposes no file watcher or sync existence check of its own
2
2
  import { dirname } from 'node:path' // bun exposes no path helpers of its own
3
3
  import { realOrAsGiven } from '#server/realOrAsGiven.ts'
4
- import { checkSource } from './checkApp.ts'
4
+ import { checkSource, lowerApp } from './checkApp.ts'
5
+ import { citedClauses } from './citedClauses.ts'
5
6
  import { type AppChecker, openChecker } from './compileApp.ts'
7
+ import { DOCS_SITE } from './DOCS_SITE.ts'
8
+ import { isNamedAlone } from './isNamedAlone.ts'
6
9
  import { nearest } from './nearest.ts'
7
10
  import type { Diagnosis, Inspection, Place } from './TypeSession.ts'
8
11
 
@@ -12,9 +15,9 @@ import type { Diagnosis, Inspection, Place } from './TypeSession.ts'
12
15
  //
13
16
  // WHAT IT SPEAKS is the part of the protocol a diagnostic needs: the handshake, a document opened,
14
17
  // changed and closed, and `publishDiagnostics` back. The document is synced whole (`Full`), so what
15
- // is checked is exactly the editor's text, saved or not. A hover and a jump to a definition are
16
- // asked of the program that check registered (`TypeSession.inspect`), so they answer about the
17
- // same text.
18
+ // is checked is exactly the editor's text, saved or not. A hover, a jump to a definition, a
19
+ // completion, the references to a name and a rename of it are asked of the program that check
20
+ // registered (`TypeSession`), so they answer about the same text.
18
21
  //
19
22
  // THE APP IS THE DOCUMENT'S. An editor starts this at the root of whatever it has open, which in a
20
23
  // monorepo is not an app at all, so each document is checked against the root of the NEAREST
@@ -111,7 +114,7 @@ export async function serveLanguage(
111
114
  },
112
115
  severity: SEVERITY[diagnosis.category],
113
116
  source: 'abide',
114
- ...(diagnosis.code === 0 ? {} : { code: diagnosis.code }),
117
+ ...codeOf(diagnosis),
115
118
  message: diagnosis.message,
116
119
  })
117
120
  }
@@ -121,6 +124,10 @@ export async function serveLanguage(
121
124
  })
122
125
  }
123
126
 
127
+ // The text the editor holds for a file, where it holds one.
128
+ const held = (path: string): string | undefined =>
129
+ documents.get(Bun.pathToFileURL(spelled(path)).href)?.text
130
+
124
131
  // An app's checker, opened on first use and kept for the session — and TOLD WHAT MOVED ON DISK,
125
132
  // or it answers every check from the text it read when it opened: an export added to a `.ts`
126
133
  // beside the page is "no exported member" until the editor restarts this. A project whose
@@ -155,6 +162,18 @@ export async function serveLanguage(
155
162
  return checker
156
163
  }
157
164
 
165
+ // An app with no page open is checked again only when one opens, and its checker is a process
166
+ // holding the whole program — closed rather than kept, or every app the editor ever visited
167
+ // keeps one for the session. Queued behind the checks, so none is in flight when it closes.
168
+ const closeIfUnopened = async (app: string): Promise<void> => {
169
+ for (const uri of documents.keys()) if (placed(uri)?.app === app) return
170
+ watchers.get(app)?.close()
171
+ watchers.delete(app)
172
+ const checker = checkers.get(app)
173
+ checkers.delete(app)
174
+ await checker?.close().catch(() => {})
175
+ }
176
+
158
177
  const run = async (uri: string): Promise<void> => {
159
178
  const document = documents.get(uri)
160
179
  if (document === undefined) return
@@ -169,8 +188,7 @@ export async function serveLanguage(
169
188
  await checkerOf(app),
170
189
  path,
171
190
  document.text,
172
- (held) =>
173
- documents.get(Bun.pathToFileURL(spelled(held)).href)?.text,
191
+ held,
174
192
  )
175
193
  } catch (thrown) {
176
194
  // The checker's own failure, not the page's: the next check opens another.
@@ -210,14 +228,22 @@ export async function serveLanguage(
210
228
  // A question about a document is about the text the editor shows, so a check still settling
211
229
  // runs now rather than after the answer; and it is answered in the queue, after that check.
212
230
  const inspect = (params: unknown): Promise<Inspection | undefined> =>
213
- ask(params, (session, path, text, offset) =>
214
- session.inspect(path, text, offset),
231
+ ask(params, (checker, path, text, offset) =>
232
+ checker.session.inspect(path, text, offset),
215
233
  )
216
234
 
235
+ // 38.116 — asked after every page of the app is lowered, which is what a page this one never
236
+ // imports needs to be among the references.
237
+ const references = (params: unknown) =>
238
+ ask(params, async (checker, path, text, offset) => {
239
+ await lowerApp(checker, held)
240
+ return checker.session.references(path, text, offset)
241
+ })
242
+
217
243
  const ask = <Answer>(
218
244
  params: unknown,
219
245
  question: (
220
- session: AppChecker['session'],
246
+ checker: AppChecker,
221
247
  path: string,
222
248
  text: string,
223
249
  offset: number,
@@ -238,7 +264,7 @@ export async function serveLanguage(
238
264
  const at = placed(uri)
239
265
  if (document === undefined || at === undefined) return undefined
240
266
  return question(
241
- (await checkerOf(at.app)).session,
267
+ await checkerOf(at.app),
242
268
  at.path,
243
269
  document.text,
244
270
  offsetOf(document.text, position),
@@ -262,7 +288,11 @@ export async function serveLanguage(
262
288
  textDocumentSync: 1,
263
289
  hoverProvider: true,
264
290
  definitionProvider: true,
265
- completionProvider: { triggerCharacters: ['.'] },
291
+ referencesProvider: true,
292
+ renameProvider: { prepareProvider: true },
293
+ completionProvider: {
294
+ triggerCharacters: ['.', '<', '#', ':', '/'],
295
+ },
266
296
  },
267
297
  serverInfo: { name: 'abide' },
268
298
  },
@@ -327,8 +357,8 @@ export async function serveLanguage(
327
357
  const trigger = (
328
358
  params?.context as { triggerCharacter?: string } | undefined
329
359
  )?.triggerCharacter
330
- const found = await ask(params, (session, path, text, offset) =>
331
- session.complete(path, text, offset, trigger),
360
+ const found = await ask(params, (checker, path, text, offset) =>
361
+ checker.session.complete(path, text, offset, trigger),
332
362
  )
333
363
  const items: object[] = []
334
364
  for (const entry of found?.entries ?? [])
@@ -358,12 +388,84 @@ export async function serveLanguage(
358
388
  send({ id: message.id, result: locations })
359
389
  return true
360
390
  }
391
+ case 'textDocument/references': {
392
+ const found = await references(params)
393
+ const declarations = (
394
+ params?.context as
395
+ | { includeDeclaration?: boolean }
396
+ | undefined
397
+ )?.includeDeclaration
398
+ const locations: object[] = []
399
+ for (const place of found?.occurrences ?? [])
400
+ if (declarations !== false || !place.declaration)
401
+ locations.push(locationOf(spelled(place.path), place))
402
+ send({ id: message.id, result: locations })
403
+ return true
404
+ }
405
+ case 'textDocument/prepareRename': {
406
+ const found = await references(params)
407
+ send(
408
+ found === undefined
409
+ ? { id: message.id, result: null }
410
+ : !found.renamable
411
+ ? refusedRename(message.id, found.written)
412
+ : {
413
+ id: message.id,
414
+ result: {
415
+ range: rangeOf(found.name),
416
+ placeholder: found.written,
417
+ },
418
+ },
419
+ )
420
+ return true
421
+ }
422
+ // A rename is every reference given the new name — and a shorthand the half it renames,
423
+ // with the other written out beside it.
424
+ case 'textDocument/rename': {
425
+ const renamed = params?.newName as string
426
+ if (!isNamedAlone(renamed)) {
427
+ send({
428
+ id: message.id,
429
+ error: {
430
+ code: -32602,
431
+ message: `\`${renamed}\` is not a name.`,
432
+ },
433
+ })
434
+ return true
435
+ }
436
+ const found = await references(params)
437
+ if (found !== undefined && !found.renamable) {
438
+ send(refusedRename(message.id, found.written))
439
+ return true
440
+ }
441
+ const changes: Record<string, object[]> = {}
442
+ for (const place of found?.occurrences ?? []) {
443
+ const uri = Bun.pathToFileURL(spelled(place.path)).href
444
+ const newText =
445
+ place.shorthand === 'local'
446
+ ? `${found?.written}: ${renamed}`
447
+ : place.shorthand === 'property'
448
+ ? `${renamed}: ${found?.written}`
449
+ : renamed
450
+ const edits = changes[uri] ?? []
451
+ edits.push({ range: rangeOf(place), newText })
452
+ changes[uri] = edits
453
+ }
454
+ send({
455
+ id: message.id,
456
+ result: found === undefined ? null : { changes },
457
+ })
458
+ return true
459
+ }
361
460
  case 'textDocument/didClose': {
362
461
  const closed = params?.textDocument as { uri: string }
363
462
  if (!documents.delete(closed.uri)) return true
364
463
  clearTimeout(timers.get(closed.uri))
365
464
  timers.delete(closed.uri)
366
465
  publish(closed.uri, [], '')
466
+ const at = placed(closed.uri)
467
+ if (at !== undefined)
468
+ queue = queue.then(() => closeIfUnopened(at.app))
367
469
  return true
368
470
  }
369
471
  }
@@ -459,14 +561,47 @@ function offsetOf(
459
561
 
460
562
  /** A `Place` at `path`, lines and columns from 1, as the protocol's `Location`, from 0. */
461
563
  function locationOf(path: string, place: Place): object {
564
+ return { uri: Bun.pathToFileURL(path).href, range: rangeOf(place) }
565
+ }
566
+
567
+ /** A `Place`'s span, lines and columns from 1, as the protocol's `Range`, from 0. */
568
+ function rangeOf(place: Place): object {
569
+ return {
570
+ start: {
571
+ line: place.start.line - 1,
572
+ character: place.start.column - 1,
573
+ },
574
+ end: { line: place.end.line - 1, character: place.end.column - 1 },
575
+ }
576
+ }
577
+
578
+ /** 38.116 — a rename refused, for a name declared where no author writes. */
579
+ function refusedRename(id: Message['id'], name: string): object {
462
580
  return {
463
- uri: Bun.pathToFileURL(path).href,
464
- range: {
465
- start: {
466
- line: place.start.line - 1,
467
- character: place.start.column - 1,
468
- },
469
- end: { line: place.end.line - 1, character: place.end.column - 1 },
581
+ id,
582
+ error: {
583
+ code: -32803,
584
+ message: `\`${name}\` is declared in a library, and a library is not renamed.`,
470
585
  },
471
586
  }
472
587
  }
588
+
589
+ /**
590
+ * The protocol's `code` for a finding: the checker's own number, or the clause an abide finding
591
+ * cites, with the rulebook's page for it.
592
+ *
593
+ * 38.118.
594
+ */
595
+ function codeOf(diagnosis: Diagnosis): {
596
+ code?: number | string
597
+ codeDescription?: { href: string }
598
+ } {
599
+ if (diagnosis.code !== 0) return { code: diagnosis.code }
600
+ const [clause] = citedClauses(diagnosis.message)
601
+ return clause === undefined
602
+ ? {}
603
+ : {
604
+ code: clause,
605
+ codeDescription: { href: `${DOCS_SITE}/rulebook#${clause}` },
606
+ }
607
+ }
@@ -0,0 +1,150 @@
1
+ import { importedNames } from './splitImports.ts'
2
+ import { TEMPLATE_BLOCKS } from './TEMPLATE_BLOCKS.ts'
3
+
4
+ /**
5
+ * What the markup at a cursor is waiting for: a block keyword after `{#`, `{:` or `{/`, a component
6
+ * tag after `<`, or an attribute inside a component's tag — `typed` being what is written of it so
7
+ * far. `undefined` in a script, a style, an expression, or text, which is the checker's to answer.
8
+ */
9
+ export type TemplateContext =
10
+ | { kind: 'block'; typed: string; offered: string[] }
11
+ | { kind: 'tag'; typed: string; offered: string[] }
12
+ | { kind: 'attribute'; tag: string; typed: string; written: string[] }
13
+
14
+ /**
15
+ * 38.117 — the template completion a cursor at `offset` in a `.abide`'s `text` stands in.
16
+ *
17
+ * READ OFF THE TEXT, NOT A PARSE: a tag or a block being typed is exactly the text the parser
18
+ * refuses, so the markup is scanned from the last script or style before the cursor.
19
+ */
20
+ export function templateContext(
21
+ text: string,
22
+ offset: number,
23
+ ): TemplateContext | undefined {
24
+ const markup = markupStart(text, offset)
25
+ if (markup === -1) return undefined
26
+ let depth = 0
27
+ let braceAt = -1
28
+ let tagAt = -1
29
+ for (let at = markup; at < offset; at += 1) {
30
+ const char = text[at]
31
+ if (depth > 0) {
32
+ if (char === '{') depth += 1
33
+ else if (char === '}') depth -= 1
34
+ else if (char === '"' || char === "'" || char === '`')
35
+ at = quoteEnd(text, at, offset)
36
+ continue
37
+ }
38
+ if (char === '{') {
39
+ depth = 1
40
+ braceAt = at
41
+ } else if (tagAt !== -1) {
42
+ if (char === '>') tagAt = -1
43
+ else if (char === '"' || char === "'")
44
+ at = quoteEnd(text, at, offset)
45
+ } else if (char === '<' && /[A-Za-z/]/.test(text[at + 1] ?? '')) {
46
+ tagAt = at
47
+ } else if (char === '<' && at + 1 === offset) tagAt = at
48
+ }
49
+ if (depth > 0) {
50
+ const block = /^\{([#:/])([a-z ]*)$/.exec(text.slice(braceAt, offset))
51
+ if (block === null) return undefined
52
+ return {
53
+ kind: 'block',
54
+ typed: block[2] as string,
55
+ offered: blocksAfter(block[1] as string, text, braceAt),
56
+ }
57
+ }
58
+ if (tagAt === -1) return undefined
59
+ const tag = text.slice(tagAt, offset)
60
+ const name = /^<([A-Za-z][\w$.]*)?$/.exec(tag)
61
+ if (name !== null)
62
+ return {
63
+ kind: 'tag',
64
+ typed: name[1] ?? '',
65
+ offered: componentsIn(text),
66
+ }
67
+ const component = /^<([A-Z][\w$]*)\s/.exec(tag)
68
+ const typing = /\s([\w$-]*)$/.exec(tag)
69
+ if (component === null || typing === null) return undefined
70
+ const written: string[] = []
71
+ // The values written so far, taken out, leave the names.
72
+ const names = tag
73
+ .slice(component[0].length, tag.length - (typing[1] as string).length)
74
+ .replace(/"[^"]*"|'[^']*'|\{[^{}]*\}/g, ' ')
75
+ for (const found of names.matchAll(/[A-Za-z_$][\w$:-]*/g))
76
+ written.push(found[0])
77
+ return {
78
+ kind: 'attribute',
79
+ tag: component[1] as string,
80
+ typed: typing[1] as string,
81
+ written,
82
+ }
83
+ }
84
+
85
+ /** Where the markup holding `offset` starts, or -1 where `offset` is inside a script or a style. */
86
+ function markupStart(text: string, offset: number): number {
87
+ let start = 0
88
+ const lower = text.toLowerCase()
89
+ const opened = /<(script|style)\b[^>]*>/gi
90
+ for (;;) {
91
+ const open = opened.exec(text)
92
+ if (open === null || open.index >= offset) return start
93
+ const body = open.index + open[0].length
94
+ const close = lower.indexOf(
95
+ `</${(open[1] as string).toLowerCase()}`,
96
+ body,
97
+ )
98
+ if (offset >= body && (close === -1 || offset <= close)) return -1
99
+ if (close === -1) return -1
100
+ start = text.indexOf('>', close) + 1
101
+ opened.lastIndex = start
102
+ }
103
+ }
104
+
105
+ /** The offset of the quote closing the one at `at`, or the last one before `limit`. */
106
+ function quoteEnd(text: string, at: number, limit: number): number {
107
+ const quote = text[at]
108
+ for (let next = at + 1; next < limit; next += 1) {
109
+ if (text[next] === '\\') next += 1
110
+ else if (text[next] === quote) return next
111
+ }
112
+ return limit
113
+ }
114
+
115
+ /**
116
+ * The keywords that may follow `sigil`: every block after `{#`, and after `{:` and `{/` the arms and
117
+ * the close of the innermost block still open before `at` — every one where none is.
118
+ */
119
+ function blocksAfter(sigil: string, text: string, at: number): string[] {
120
+ const blocks = Object.keys(TEMPLATE_BLOCKS)
121
+ if (sigil === '#') return blocks
122
+ const open: string[] = []
123
+ const marks = new RegExp(`\\{([#/])(${blocks.join('|')})(?![\\w$])`, 'g')
124
+ for (const mark of text.slice(0, at).matchAll(marks)) {
125
+ if (mark[1] === '#') open.push(mark[2] as string)
126
+ else if (open[open.length - 1] === mark[2]) open.pop()
127
+ }
128
+ const innermost = open[open.length - 1]
129
+ if (sigil === '/') return innermost === undefined ? blocks : [innermost]
130
+ if (innermost !== undefined) return [...(TEMPLATE_BLOCKS[innermost] ?? [])]
131
+ const arms: string[] = []
132
+ for (const block of blocks) arms.push(...(TEMPLATE_BLOCKS[block] ?? []))
133
+ return arms
134
+ }
135
+
136
+ /**
137
+ * The components a tag in `text` may name: each TitleCase name its scripts import, and each
138
+ * `{#component}` it declares (32.41).
139
+ */
140
+ function componentsIn(text: string): string[] {
141
+ const names = new Set<string>()
142
+ for (const script of text.matchAll(
143
+ /<script\b[^>]*>([\s\S]*?)(?:<\/script|$)/gi,
144
+ ))
145
+ for (const { name } of importedNames(script[1] as string))
146
+ if (/^[A-Z]/.test(name)) names.add(name)
147
+ for (const found of text.matchAll(/\{#component\s+([A-Z][\w$]*)/g))
148
+ names.add(found[1] as string)
149
+ return [...names]
150
+ }
@@ -186,6 +186,12 @@ function scan(
186
186
  while (true) {
187
187
  let kind = scanner.scan()
188
188
  if (kind === SyntaxKind.EndOfFile) return
189
+ // A `#` NAMING NOTHING scans as an empty private name and the scanner does not move past
190
+ // it, so `{#` typed at the end of a file never finished parsing. It is stepped over.
191
+ if (scanner.getTokenEnd() === scanner.getTokenStart()) {
192
+ scanner.resetTokenState(scanner.getTokenEnd() + 1)
193
+ continue
194
+ }
189
195
  if (
190
196
  expectsOperand &&
191
197
  (kind === SyntaxKind.SlashToken ||
@@ -1,3 +1,3 @@
1
1
  // The `abide-bridge` release a pairing command names, so a visitor's `bunx` runs the relay this
2
2
  // server speaks. `packages/abide/tests/claudeCode.test.ts` holds it to the bridge package's own.
3
- export const BRIDGE_VERSION = '0.1.0'
3
+ export const BRIDGE_VERSION = '0.2.0'
@@ -20,6 +20,14 @@ export class Inbox<Item> implements AsyncIterable<Item> {
20
20
  this.#wake?.()
21
21
  }
22
22
 
23
+ /** Closes this inbox and hands what nothing has read yet to a new one, for a new reader. */
24
+ handOff(): Inbox<Item> {
25
+ const next = new Inbox<Item>()
26
+ next.#items = this.#items.splice(0)
27
+ this.close()
28
+ return next
29
+ }
30
+
23
31
  async *[Symbol.asyncIterator](): AsyncGenerator<Item> {
24
32
  for (;;) {
25
33
  if (this.#items.length > 0) {
@@ -1,3 +1,3 @@
1
1
  // The MCP protocol version abide's servers answer `initialize` with: `/__abide/mcp`, and the one a
2
- // paired bridge's Claude Code reaches through the relay (45.17).
2
+ // paired bridge's Claude Code reaches through the relay (44.17).
3
3
  export const MCP_PROTOCOL_VERSION = '2025-06-18'
@@ -0,0 +1,6 @@
1
+ /**
2
+ * The key a bound component carries what `mount` needs under: its props as accessors, and a loader
3
+ * for its browser module. A registered symbol, because the module that sets it is generated text
4
+ * (`boundModule`) and spells it by its description.
5
+ */
6
+ export const MOUNTABLE = Symbol.for('abide.mountable')
@@ -43,6 +43,7 @@ import {
43
43
  STYLE_QUERY,
44
44
  TEXT_QUERY,
45
45
  } from './MODULE_QUERIES.ts'
46
+ import { MOUNTABLE } from './MOUNTABLE.ts'
46
47
  import { realOrAsGiven } from './realOrAsGiven.ts'
47
48
  import { SERVER_TABLE } from './TABLES.ts'
48
49
  import { VIRTUAL_DIRECTORY } from './VIRTUAL_DIRECTORY.ts'
@@ -449,6 +450,7 @@ function compiledAt(served: Held, path: string): CompiledFile {
449
450
  // 34.16 — BOUND, NOT RAW. The compiled module is a function of accessors answering its markup (33.4);
450
451
  // what an app's own module calls is a function of plain props answering a `Component` (35.4), which
451
452
  // `render` invokes with nothing. The component is the module's one export (34.17).
453
+ // 38.119 — and it carries what `mount` hydrates it with: the same accessors, and its browser module.
452
454
  function boundModule(path: string): string {
453
455
  return `import component from ${JSON.stringify(path)}
454
456
  export default (props = {}) => {
@@ -457,7 +459,12 @@ export default (props = {}) => {
457
459
  const value = props[key]
458
460
  accessors[key] = () => value
459
461
  }
460
- return () => component(accessors)
462
+ const bound = () => component(accessors)
463
+ bound[Symbol.for(${JSON.stringify(MOUNTABLE.description)})] = {
464
+ accessors,
465
+ client: () => import(${JSON.stringify(`${path}${CLIENT_QUERY}`)}),
466
+ }
467
+ return bound
461
468
  }
462
469
  `
463
470
  }
@@ -18,6 +18,7 @@ import {
18
18
  import { APP_META } from '#shared/APP_META.ts'
19
19
  import { DEBUG_META } from '#shared/DEBUG_META.ts'
20
20
  import { decodedOrKept } from '#shared/decodedOrKept.ts'
21
+ import { ERROR_META } from '#shared/ERROR_META.ts'
21
22
  import { escapeAttribute } from '#shared/escapeText.ts'
22
23
  import { type Failed, isFailed } from '#shared/Failed.ts'
23
24
  import { HandlerRoute } from '#shared/HandlerRoute.ts'
@@ -514,7 +515,8 @@ export async function appSources(parts: SourceParts): Promise<AppSources> {
514
515
  const sources: AppSources = {
515
516
  routes: rankRoutes([...rows.values()]),
516
517
  errors,
517
- errorDocument: documentOf([]),
518
+ // 35.18 — marked, so the browser hydrates no page over what an error page painted.
519
+ errorDocument: intoHead(documentOf([]), `<meta name="${ERROR_META}">`),
518
520
  framework: client.framework,
519
521
  published,
520
522
  explained: parts.explained,