@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
@@ -0,0 +1,193 @@
1
+ // 38.97 — WHERE A COMPILED MODULE'S LINES CAME FROM IN ITS `.abide`. The emitters build a module
2
+ // as text around what `Lowering` hands them, so a chunk is placed by FINDING it in what they wrote
3
+ // rather than by threading offsets through every template string that assembles one:
4
+ //
5
+ // - a script block is many lines and lands whole, its imports each left as a `;` on as many lines
6
+ // (`splitImports`), so it is anchored by its longest line found once in the module and then read
7
+ // line for line;
8
+ // - a template expression is one line and may land more than once — a client module writes a slot
9
+ // in its markup and again in the effect that updates it — and every landing is the same source.
10
+ //
11
+ // What no chunk covers is the framework's own scaffolding, and maps to nothing.
12
+
13
+ import { encodeMappings, type Segment } from '#server/sourceMappings.ts'
14
+ import type { LoweredChunk } from './Lowering.ts'
15
+ import type { Origin } from './projectShadow.ts'
16
+ import { mapBack } from './ShadowMap.ts'
17
+
18
+ /** The module's `mappings`, into the `.abide` whose text is `source`. */
19
+ export function sourceMapOf(
20
+ output: string,
21
+ chunks: readonly LoweredChunk[],
22
+ projected: readonly Origin[],
23
+ source: string,
24
+ ): string {
25
+ const sourceStarts = lineStarts(source)
26
+ const outputLines = output.split('\n')
27
+ const outputStarts = lineStarts(output)
28
+ const lines: Segment[][] = []
29
+ for (let index = 0; index < outputLines.length; index += 1) lines.push([])
30
+ // Where a projection offset is in the `.abide`, as a line and a column.
31
+ const place = (line: number, column: number, offset: number): void => {
32
+ const at = mapBack(projected, offset)
33
+ const sourceLine = lineAt(sourceStarts, at)
34
+ ;(lines[line] as Segment[]).push([
35
+ column,
36
+ 0,
37
+ sourceLine,
38
+ at - (sourceStarts[sourceLine] as number),
39
+ ])
40
+ }
41
+ const claimed = new Set<number>()
42
+
43
+ const multiline: LoweredChunk[] = []
44
+ const single: LoweredChunk[] = []
45
+ for (const chunk of chunks)
46
+ (chunk.text.includes('\n') ? multiline : single).push(chunk)
47
+ multiline.sort((one, two) => two.text.length - one.text.length)
48
+
49
+ for (const chunk of multiline) {
50
+ const chunkLines = chunk.text.split('\n')
51
+ const first = anchor(chunkLines, outputLines, outputStarts, output)
52
+ if (first === undefined) continue
53
+ const chunkStarts = lineStarts(chunk.text)
54
+ for (let index = 0; index < chunkLines.length; index += 1) {
55
+ const line = first + index
56
+ const written = outputLines[line]
57
+ const lowered = chunkLines[index] as string
58
+ if (written === undefined || claimed.has(line)) continue
59
+ if (written.trim() !== lowered.trim() || lowered.trim() === '')
60
+ continue
61
+ claimed.add(line)
62
+ const start = chunkStarts[index] as number
63
+ const end = start + lowered.length
64
+ // Lines the emitter re-indented keep their text, and move by the margin it added.
65
+ const shift = written.length - lowered.length
66
+ for (const origin of chunk.origins) {
67
+ const runEnd = origin.at + Math.max(origin.length, 1)
68
+ if (origin.at >= end || runEnd <= start) continue
69
+ const from = Math.max(origin.at, start)
70
+ place(
71
+ line,
72
+ from - start + shift,
73
+ origin.source + (from - origin.at),
74
+ )
75
+ }
76
+ }
77
+ }
78
+
79
+ // LONGEST FIRST, each landing claimed: `count() + 1` is found again inside the handler
80
+ // `() => { count.set(count() + 1) }` was lowered from, and is that handler's there.
81
+ single.sort((one, two) => two.text.trim().length - one.text.trim().length)
82
+ const taken: [number, number][] = []
83
+ const seen = new Set<string>()
84
+ for (const chunk of single) {
85
+ const text = chunk.text.trim()
86
+ if (text.length < 2) continue
87
+ // An expression an emitter asked for twice is one chunk.
88
+ const key = `${chunk.origins[0]?.source}:${text}`
89
+ if (seen.has(key)) continue
90
+ seen.add(key)
91
+ const lead = chunk.text.indexOf(text)
92
+ for (
93
+ let found = output.indexOf(text);
94
+ found !== -1;
95
+ found = output.indexOf(text, found + text.length)
96
+ ) {
97
+ const end = found + text.length
98
+ if (!standsAlone(output, found, end)) continue
99
+ const line = lineAt(outputStarts, found)
100
+ if (claimed.has(line)) continue
101
+ if (taken.some(([from, to]) => found < to && end > from)) continue
102
+ taken.push([found, end])
103
+ const column = found - (outputStarts[line] as number)
104
+ for (const origin of chunk.origins) {
105
+ const at = origin.at - lead
106
+ if (at < 0 || at > text.length) continue
107
+ place(line, column + at, origin.source)
108
+ }
109
+ }
110
+ }
111
+
112
+ // Two runs placed at one column — a pin and the copy after it — keep the later, which is the
113
+ // author's own text.
114
+ for (const segments of lines) {
115
+ segments.sort((one, two) => one[0] - two[0])
116
+ for (let at = segments.length - 1; at > 0; at -= 1)
117
+ if (
118
+ (segments[at - 1] as Segment)[0] ===
119
+ (segments[at] as Segment)[0]
120
+ )
121
+ segments.splice(at - 1, 1)
122
+ }
123
+ return encodeMappings(lines)
124
+ }
125
+
126
+ /**
127
+ * The module line `chunkLines` starts on: its longest line found exactly once, standing where the
128
+ * rest of the chunk agrees — or `undefined` where no line places it.
129
+ */
130
+ function anchor(
131
+ chunkLines: readonly string[],
132
+ outputLines: readonly string[],
133
+ outputStarts: readonly number[],
134
+ output: string,
135
+ ): number | undefined {
136
+ const order: number[] = []
137
+ for (let index = 0; index < chunkLines.length; index += 1) order.push(index)
138
+ order.sort(
139
+ (one, two) =>
140
+ (chunkLines[two] as string).trim().length -
141
+ (chunkLines[one] as string).trim().length,
142
+ )
143
+ for (const index of order) {
144
+ const text = (chunkLines[index] as string).trim()
145
+ if (text.length < 8) return undefined
146
+ const found = output.indexOf(text)
147
+ if (found === -1 || output.indexOf(text, found + 1) !== -1) continue
148
+ const first = lineAt(outputStarts, found) - index
149
+ if (first < 0) continue
150
+ let agreeing = 0
151
+ let written = 0
152
+ for (let at = 0; at < chunkLines.length; at += 1) {
153
+ const lowered = (chunkLines[at] as string).trim()
154
+ if (lowered === '') continue
155
+ written += 1
156
+ if (outputLines[first + at]?.trim() === lowered) agreeing += 1
157
+ }
158
+ if (agreeing * 2 >= written) return first
159
+ }
160
+ return undefined
161
+ }
162
+
163
+ const NAME = /[\w$.]/
164
+
165
+ // Not the middle of a longer name, or a member of one: `count()` inside `__count()` is not it.
166
+ function standsAlone(output: string, start: number, end: number): boolean {
167
+ return (
168
+ !NAME.test(output[start - 1] ?? ' ') &&
169
+ !/[\w$]/.test(output[end] ?? ' ')
170
+ )
171
+ }
172
+
173
+ function lineStarts(text: string): number[] {
174
+ const starts = [0]
175
+ for (
176
+ let at = text.indexOf('\n');
177
+ at !== -1;
178
+ at = text.indexOf('\n', at + 1)
179
+ )
180
+ starts.push(at + 1)
181
+ return starts
182
+ }
183
+
184
+ function lineAt(starts: readonly number[], offset: number): number {
185
+ let low = 0
186
+ let high = starts.length - 1
187
+ while (low < high) {
188
+ const middle = (low + high + 1) >>> 1
189
+ if ((starts[middle] as number) <= offset) low = middle
190
+ else high = middle - 1
191
+ }
192
+ return low
193
+ }
@@ -1,4 +1,5 @@
1
1
  import { SyntaxKind } from 'typescript/unstable/ast'
2
+ import { COMPONENT_QUERY, TEXT_QUERY } from '#server/MODULE_QUERIES.ts'
2
3
  import { isContextualKeyword, type Token, tokenize } from './tokenize.ts'
3
4
 
4
5
  /**
@@ -11,13 +12,12 @@ import { isContextualKeyword, type Token, tokenize } from './tokenize.ts'
11
12
  * WHERE AN IMPORT BEGINS AND ENDS IS READ OFF the one tokenizer, which carries the template
12
13
  * re-scan a bare `` ` `` needs, so an import after a template literal is still found.
13
14
  *
14
- * With `landed`, every specifier the build moved is rewritten where it stands, a static import's
15
- * and an `import('…')`'s alike (`landedSpecifier`) — so the two cannot land in different places.
15
+ * Every specifier naming a face of a `.abide` is rewritten where it stands, a static import's and an
16
+ * `import('…')`'s alike (`facedSpecifier`) — so the two cannot ask for different faces.
16
17
  */
17
18
  export function splitImports(
18
19
  script: string,
19
- lane: '' | '.client' = '',
20
- landed?: ReadonlyMap<string, string>,
20
+ lane: Lane = 'component',
21
21
  ): {
22
22
  imports: string
23
23
  body: string
@@ -29,18 +29,13 @@ export function splitImports(
29
29
  let body = ''
30
30
  let at = 0
31
31
  for (const span of spans) {
32
+ const moved = facedSpecifier(span.specifier, lane)
32
33
  if (span.dynamic) {
33
- if (landed === undefined) continue
34
- const moved = landedSpecifier(span.specifier, lane, landed)
35
34
  if (moved === undefined) continue
36
35
  body += `${script.slice(at, span.literalStart + 1)}${moved}`
37
36
  at = span.end - 1
38
37
  continue
39
38
  }
40
- const moved =
41
- landed === undefined
42
- ? undefined
43
- : landedSpecifier(span.specifier, lane, landed)
44
39
  imports +=
45
40
  moved === undefined
46
41
  ? `${script.slice(span.start, span.end)}\n`
@@ -50,7 +45,9 @@ export function splitImports(
50
45
  // a `[` that automatic semicolon insertion does NOT break — it reads as `f()[2]`, a
51
46
  // different program with nothing about it saying so. The empty statement left in its
52
47
  // place is the separator the import was.
53
- body += `${script.slice(at, span.start)};`
48
+ // 38.97 — and as many lines as it spanned, so the body stays line for line with the
49
+ // script it came from and a source map can read the one off the other.
50
+ body += `${script.slice(at, span.start)};${'\n'.repeat(newlinesIn(script, span.start, span.end))}`
54
51
  at = span.end
55
52
  // The `;` an import may end with would otherwise be left behind beside the one above.
56
53
  if (script[at] === ';') at += 1
@@ -58,6 +55,18 @@ export function splitImports(
58
55
  return { imports, body: body + script.slice(at) }
59
56
  }
60
57
 
58
+ /** How many line breaks `text` holds between `start` and `end`. */
59
+ export function newlinesIn(text: string, start: number, end: number): number {
60
+ let count = 0
61
+ for (
62
+ let at = text.indexOf('\n', start);
63
+ at !== -1 && at < end;
64
+ at = text.indexOf('\n', at + 1)
65
+ )
66
+ count += 1
67
+ return count
68
+ }
69
+
61
70
  // What can stand between `import` and its specifier. A token that cannot is a malformed import,
62
71
  // and stopping there is what keeps one broken line from swallowing the statements after it.
63
72
  //
@@ -252,7 +261,14 @@ export function abideImports(
252
261
  const found: { specifier: string; closingQuote: number }[] = []
253
262
  if (!script.includes('.abide')) return found
254
263
  for (const span of importSpans(script))
255
- if (span.specifier.endsWith('.abide'))
264
+ if (
265
+ span.specifier.endsWith('.abide') &&
266
+ // 34.25 — a `.abide` imported as text is its text, and no component edge.
267
+ (span.dynamic ||
268
+ !IMPORT_ATTRIBUTES.text.test(
269
+ script.slice(span.literalEnd, span.end),
270
+ ))
271
+ )
256
272
  found.push({
257
273
  specifier: span.specifier,
258
274
  closingQuote: span.literalEnd - 1,
@@ -260,40 +276,167 @@ export function abideImports(
260
276
  return found
261
277
  }
262
278
 
279
+ /** One name an import statement binds. */
280
+ export type ImportBinding = {
281
+ /** `default`, `*`, or the name imported — a string literal's text where one is written. */
282
+ imported: string
283
+ local: string
284
+ /** Under a `type` modifier, on the binding or on the whole statement: no value of its own. */
285
+ typeOnly: boolean
286
+ /** The binding as a statement's clause spells it, its own modifier kept: `type a as b`. */
287
+ written: string
288
+ }
289
+
263
290
  /**
264
- * Every relative specifier a script imports, re-exports or `import('…')`s, as written, other than
265
- * a `.abide` one.
291
+ * Every static import statement a script makes: where it stands, what it names, and each binding,
292
+ * read off the one tokenizer — so a comment or a line break inside the clause is trivia, as it is
293
+ * to bun, and never part of a name.
266
294
  *
267
- * 34.14 — what the build rewrites to the path from the compiled module back to the source.
295
+ * `attributes` is the statement's text after its specifier, a `with { … }` or nothing. A statement of
296
+ * no shape this reads answers no bindings.
268
297
  */
269
- export function relativeSpecifiers(script: string): string[] {
270
- const specifiers: string[] = []
271
- if (!script.includes('./')) return specifiers
272
- for (const span of importSpans(script))
298
+ export function importStatements(script: string): {
299
+ start: number
300
+ end: number
301
+ specifier: string
302
+ typeOnly: boolean
303
+ attributes: string
304
+ bindings: ImportBinding[]
305
+ }[] {
306
+ const found: ReturnType<typeof importStatements> = []
307
+ if (!script.includes('import')) return found
308
+ for (const span of importSpans(script)) {
309
+ if (span.dynamic || !script.startsWith('import', span.start)) continue
310
+ const tokens = tokenize(script.slice(span.start, span.literalStart))
311
+ const bindings: ImportBinding[] = []
312
+ let at = 1
313
+ // `type` modifies the statement unless it is the default's own name: `import type from`.
314
+ const typeOnly =
315
+ tokens[at]?.text === 'type' &&
316
+ tokens[at + 1] !== undefined &&
317
+ tokens[at + 1]?.kind !== SyntaxKind.FromKeyword &&
318
+ tokens[at + 1]?.kind !== SyntaxKind.CommaToken
319
+ if (typeOnly) at += 1
320
+ const head = tokens[at]
273
321
  if (
274
- (span.specifier.startsWith('./') ||
275
- span.specifier.startsWith('../')) &&
276
- !span.specifier.endsWith('.abide')
277
- )
278
- specifiers.push(span.specifier)
279
- return specifiers
322
+ head !== undefined &&
323
+ head.kind !== SyntaxKind.OpenBraceToken &&
324
+ head.kind !== SyntaxKind.AsteriskToken &&
325
+ (tokens[at + 1]?.kind === SyntaxKind.FromKeyword ||
326
+ tokens[at + 1]?.kind === SyntaxKind.CommaToken)
327
+ ) {
328
+ bindings.push({
329
+ imported: 'default',
330
+ local: head.text,
331
+ typeOnly,
332
+ written: head.text,
333
+ })
334
+ at += tokens[at + 1]?.kind === SyntaxKind.CommaToken ? 2 : 1
335
+ }
336
+ if (tokens[at]?.kind === SyntaxKind.AsteriskToken) {
337
+ const local = tokens[at + 2]?.text ?? ''
338
+ bindings.push({
339
+ imported: '*',
340
+ local,
341
+ typeOnly,
342
+ written: `* as ${local}`,
343
+ })
344
+ } else if (tokens[at]?.kind === SyntaxKind.OpenBraceToken) {
345
+ // Each element between the braces: `[type] imported [as local]`.
346
+ let element: Token[] = []
347
+ for (at += 1; at < tokens.length; at += 1) {
348
+ const token = tokens[at] as Token
349
+ const closed = token.kind === SyntaxKind.CloseBraceToken
350
+ if (closed || token.kind === SyntaxKind.CommaToken) {
351
+ // `type as t` imports `type`; `type x` and `type x as y` modify.
352
+ const modified =
353
+ element[0]?.text === 'type' &&
354
+ (element.length === 2 || element.length === 4)
355
+ const name = element[modified ? 1 : 0]
356
+ if (name !== undefined) {
357
+ const imported =
358
+ name.kind === SyntaxKind.StringLiteral
359
+ ? name.text.slice(1, -1)
360
+ : name.text
361
+ let written = ''
362
+ for (const part of element)
363
+ written +=
364
+ written === '' ? part.text : ` ${part.text}`
365
+ bindings.push({
366
+ imported,
367
+ local: (element.at(-1) as Token).text,
368
+ typeOnly: typeOnly || modified,
369
+ written,
370
+ })
371
+ }
372
+ element = []
373
+ if (closed) break
374
+ } else element.push(token)
375
+ }
376
+ }
377
+ found.push({
378
+ start: span.start,
379
+ end: span.end,
380
+ specifier: span.specifier,
381
+ typeOnly,
382
+ attributes: script.slice(span.literalEnd, span.end).trim(),
383
+ bindings,
384
+ })
385
+ }
386
+ return found
280
387
  }
281
388
 
389
+ // The attribute a static import is read by: as text (34.25), or as a mount of routes (23.19).
390
+ export const IMPORT_ATTRIBUTES = {
391
+ text: /\btype\s*:\s*['"]text['"]/,
392
+ routes: /\btype\s*:\s*['"]routes['"]/,
393
+ } as const
394
+
282
395
  /**
283
- * Every import declaration in a script: its text and where it starts.
396
+ * Every static import a script makes `with { type }`: the specifier, where the statement and its
397
+ * literal begin, and where the attributes end.
284
398
  *
285
- * `import …` alone — an `export … from` binds nothing in the script it stands in.
399
+ * 34.25 — a text import's literal-to-end span is what the projection writes over with the specifier
400
+ * the checker types a `string` (`SOURCE_SUFFIX`), and the emitters ask for as text
401
+ * (`facedSpecifier`). 23.19 — a routes import's whole statement is what the projection blanks: a
402
+ * mount is read by the build off the file, and neither bun nor the checker has a module to give it.
286
403
  */
287
- function importDeclarations(script: string): { text: string; start: number }[] {
288
- const declarations: { text: string; start: number }[] = []
289
- if (!script.includes('import')) return declarations
404
+ export function attributedImports(
405
+ script: string,
406
+ type: keyof typeof IMPORT_ATTRIBUTES,
407
+ ): ImportSpan[] {
408
+ const found: ImportSpan[] = []
409
+ if (!script.includes(type)) return found
410
+ const attribute = IMPORT_ATTRIBUTES[type]
290
411
  for (const span of importSpans(script))
291
- if (!span.dynamic && script.startsWith('import', span.start))
292
- declarations.push({
293
- text: script.slice(span.start, span.end),
294
- start: span.start,
295
- })
296
- return declarations
412
+ if (
413
+ !span.dynamic &&
414
+ attribute.test(script.slice(span.literalEnd, span.end))
415
+ )
416
+ found.push(span)
417
+ return found
418
+ }
419
+
420
+ /** What a file imported as text is written as once projected: `./X.abide` → `./X.abide.source`. */
421
+ export const SOURCE_SUFFIX = '.source'
422
+
423
+ /**
424
+ * `source` with each module specifier — of a declaration, an `export … from` or an `import('…')` —
425
+ * written as `rewrite` answers for it, and left as it is where `rewrite` answers `undefined`.
426
+ */
427
+ export function respecified(
428
+ source: string,
429
+ rewrite: (specifier: string) => string | undefined,
430
+ ): string {
431
+ let out = ''
432
+ let at = 0
433
+ for (const span of importSpans(source)) {
434
+ const written = rewrite(span.specifier)
435
+ if (written === undefined) continue
436
+ out += `${source.slice(at, span.literalStart + 1)}${written}`
437
+ at = span.literalEnd - 1
438
+ }
439
+ return at === 0 ? source : out + source.slice(at)
297
440
  }
298
441
 
299
442
  /**
@@ -305,62 +448,41 @@ export function importedNames(
305
448
  script: string,
306
449
  ): { name: string; specifier: string }[] {
307
450
  const names: { name: string; specifier: string }[] = []
308
- for (const declaration of importDeclarations(script)) {
309
- const tokens = tokenize(declaration.text)
310
- const last = tokens.findLastIndex(
311
- (token) => token.kind === SyntaxKind.StringLiteral,
312
- )
313
- if (last === -1) continue
314
- const specifier = (tokens[last] as Token).text.slice(1, -1)
315
- for (let at = 1; at < last; at += 1) {
316
- const token = tokens[at] as Token
317
- if (token.kind === SyntaxKind.FromKeyword) break
318
- if (token.kind === SyntaxKind.AsKeyword) continue
319
- if (
320
- token.kind !== SyntaxKind.Identifier &&
321
- !isContextualKeyword(token.kind)
322
- )
323
- continue
324
- const next = tokens[at + 1] as Token
325
- // A name followed by `as` is renamed, and a modifier is followed by what it modifies.
326
- if (next.kind === SyntaxKind.AsKeyword) continue
327
- if (
328
- token.text === 'type' &&
329
- (next.kind === SyntaxKind.Identifier ||
330
- next.kind === SyntaxKind.OpenBraceToken ||
331
- next.kind === SyntaxKind.AsteriskToken ||
332
- isContextualKeyword(next.kind))
333
- )
334
- continue
335
- names.push({ name: token.text, specifier })
336
- }
337
- }
451
+ // `import …` alone — an `export … from` binds nothing in the script it stands in.
452
+ for (const declaration of importStatements(script))
453
+ for (const binding of declaration.bindings)
454
+ names.push({
455
+ name: binding.local,
456
+ specifier: declaration.specifier,
457
+ })
338
458
  return names
339
459
  }
340
460
 
341
461
  /**
342
- * Where the build moved `specifier`: a `.abide` one to the lane's compiled module for it, and a
343
- * relative one to its source — or `undefined` where it stays as written.
462
+ * The face of a `.abide` a specifier asks for, spelled on it (`MODULE_QUERIES`), or `undefined`
463
+ * where the specifier stays as written.
344
464
  *
345
- * 32.17 — A `.abide` IMPORT IS AN IMPORT OF THE MODULE THAT FILE COMPILES TO, and which of the two
346
- * it is is the LANE's business: the server module renders the child, the client module mounts it.
347
- * WHERE that module is, is the build's: `landed` maps a specifier to the path, relative and
348
- * without an extension, the build resolved it to (`componentGraph`) — and a `.abide` specifier it
349
- * does not map keeps the path as written.
465
+ * 34.14 — EVERY OTHER SPECIFIER STAYS AS WRITTEN: the compiled module is served at its `.abide`'s
466
+ * own path, so bun resolves a relative, a `#` or a bare specifier from the file that wrote it.
350
467
  *
351
- * 34.14 — `landed` maps any other relative specifier to the path from the module, in the output
352
- * tree, back to the file the author meant, which the output tree does not hold.
468
+ * 32.17 — a component block's `.abide` import is of the module that file compiles to; a server
469
+ * block's stays bare, for the face a module abide did not compile gets (34.16). 34.25 — a file the
470
+ * projection marked as text is asked for as its text.
353
471
  */
354
- function landedSpecifier(
355
- specifier: string,
356
- lane: '' | '.client',
357
- landed: ReadonlyMap<string, string>,
358
- ): string | undefined {
359
- if (specifier.endsWith('.abide'))
360
- return `${landed.get(specifier) ?? specifier.slice(0, -'.abide'.length)}${lane}.ts`
361
- return landed.get(specifier)
472
+ function facedSpecifier(specifier: string, lane: Lane): string | undefined {
473
+ if (specifier.endsWith(SOURCE_SUFFIX))
474
+ return `${specifier.slice(0, -SOURCE_SUFFIX.length)}${TEXT_QUERY}`
475
+ if (lane === 'component' && specifier.endsWith('.abide'))
476
+ return `${specifier}${COMPONENT_QUERY}`
477
+ return undefined
362
478
  }
363
479
 
480
+ /**
481
+ * Which face a block's imports are written for: a component's — its `<script>` and
482
+ * `<script module>`, in either lane — or a server block's, whose `.abide` imports stay bare (34.16).
483
+ */
484
+ export type Lane = 'component' | 'source'
485
+
364
486
  /**
365
487
  * `imports` with `props` dropped from the abide import that names it.
366
488
  *
@@ -0,0 +1,32 @@
1
+ // 34.24 — THE TYPES A PLAIN `.ts` READS A `.abide` BY, written with no checker and no build: the
2
+ // files parsed and projected, and nothing else. What `abide sync` runs — and the scaffold's `prepare`
3
+ // with it, so a project is typed as soon as it is installed — and what the language server runs as
4
+ // a file is edited.
5
+
6
+ import { sourcesUnder } from './compileApp.ts'
7
+ import { parseSources } from './readSources.ts'
8
+ import type { Diagnosis } from './TypeSession.ts'
9
+ import { writeTypes } from './writeTypes.ts'
10
+
11
+ /**
12
+ * Writes the types of every `.abide` under `appRoot`, `held` standing in for the text on disk of
13
+ * the files an editor has open; answers how many files it typed and what it refused. A file that
14
+ * does not parse, or whose projection is refused, is left as it last was and the rest are typed.
15
+ */
16
+ export async function syncTypes(
17
+ appRoot: string,
18
+ held: ReadonlyMap<string, string> = new Map(),
19
+ ): Promise<{ typed: number; refused: Diagnosis[] }> {
20
+ const { root, sources } = await sourcesUnder(appRoot)
21
+ const read: { path: string; text: string }[] = []
22
+ for (const source of sources)
23
+ read.push({
24
+ path: source.path,
25
+ text: held.get(source.path) ?? source.text,
26
+ })
27
+ const refused: Diagnosis[] = []
28
+ const parsed = parseSources(read, refused)
29
+ const unparsed = refused.length
30
+ await writeTypes(root, parsed, true, refused)
31
+ return { typed: parsed.length - (refused.length - unparsed), refused }
32
+ }
@@ -31,6 +31,20 @@ export function tokenize(source: string): Token[] {
31
31
  return tokens
32
32
  }
33
33
 
34
+ /**
35
+ * Whether a token of the code scanned from `from` begins at `at` — false where `at` is inside a
36
+ * string, a template, a regex or a comment.
37
+ */
38
+ export function startsToken(source: string, from: number, at: number): boolean {
39
+ let found = false
40
+ scan(source, from, (_kind, scanner) => {
41
+ const start = scanner.getTokenStart()
42
+ found = start === at
43
+ return start < at
44
+ })
45
+ return found
46
+ }
47
+
34
48
  /**
35
49
  * How far `kind` moves the bracket depth a top-level separator is found at: a template's
36
50
  * substitutions nest as a call's arguments do, so `${a, b}` holds its comma.
@@ -1,4 +1,4 @@
1
- // 38.31 — AN APP WITH NO tsconfig.json IS TYPED AGAINST ONE OF ABIDE'S, not read untyped. The checker
1
+ // 38.30 — AN APP WITH NO tsconfig.json IS TYPED AGAINST ONE OF ABIDE'S, not read untyped. The checker
2
2
  // is what tells the compiler which names are `Reactive`s (31.3), and with no project to ask every
3
3
  // template position read as static: the server painted an unlanded read by throwing out of the
4
4
  // render (41.4), the client never bound a slot at all, and `props` went unseen — an app that built