@abide/abide 0.104.0 → 0.105.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 (83) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/bin/abide.ts +80 -173
  3. package/docs/REGISTRY.md +17 -17
  4. package/docs/RULEBOOK.md +50 -12
  5. package/docs/abide.md +9 -9
  6. package/package.json +1 -1
  7. package/src/compiler/Lowering.ts +15 -5
  8. package/src/compiler/Refusals.ts +57 -0
  9. package/src/compiler/TypeSession.ts +45 -86
  10. package/src/compiler/appLayout.ts +447 -0
  11. package/src/compiler/appMap.ts +21 -20
  12. package/src/compiler/checkApp.ts +61 -97
  13. package/src/compiler/checkedShadow.ts +11 -9
  14. package/src/compiler/classifyRead.ts +3 -6
  15. package/src/compiler/compileApp.ts +180 -512
  16. package/src/compiler/componentGraph.ts +2 -71
  17. package/src/compiler/declaredExports.ts +79 -60
  18. package/src/compiler/emitClient.ts +11 -13
  19. package/src/compiler/emitComponent.ts +21 -6
  20. package/src/compiler/emitSource.ts +20 -20
  21. package/src/compiler/handlersUnder.ts +46 -36
  22. package/src/compiler/hookCalls.ts +152 -0
  23. package/src/compiler/mountsOf.ts +31 -54
  24. package/src/compiler/readSources.ts +27 -42
  25. package/src/compiler/readsIn.ts +5 -16
  26. package/src/compiler/resolverIn.ts +77 -0
  27. package/src/server/abideModules.ts +92 -123
  28. package/src/server/answerRequest.ts +18 -4
  29. package/src/server/answerSocket.ts +136 -76
  30. package/src/server/answerValue.ts +28 -15
  31. package/src/server/appTree.ts +49 -71
  32. package/src/server/claudeApi.ts +2 -5
  33. package/src/server/clientBundle.ts +198 -0
  34. package/src/server/coerceArgs.ts +1 -1
  35. package/src/server/commands.ts +93 -57
  36. package/src/server/createApp.ts +90 -16
  37. package/src/server/deferredBuilds.ts +175 -0
  38. package/src/server/devWorker.ts +107 -319
  39. package/src/server/faceOf.ts +120 -0
  40. package/src/server/fenceServer.ts +10 -18
  41. package/src/server/index.ts +3 -0
  42. package/src/server/placement.ts +167 -0
  43. package/src/server/remote.ts +14 -42
  44. package/src/server/render.ts +25 -17
  45. package/src/server/renderComponent.ts +9 -5
  46. package/src/server/requestScope.ts +10 -4
  47. package/src/server/responseRelay.ts +389 -0
  48. package/src/server/runCompiled.ts +9 -12
  49. package/src/server/runDesktop.ts +6 -9
  50. package/src/server/serveBuild.ts +26 -73
  51. package/src/server/serveDev.ts +19 -176
  52. package/src/server/streamEvent.ts +29 -0
  53. package/src/server/withAbideSources.ts +2 -1
  54. package/src/shared/HandlerRoute.ts +68 -0
  55. package/src/shared/REFUSAL_KEY.ts +5 -0
  56. package/src/shared/answerOf.ts +105 -78
  57. package/src/shared/filledPath.ts +1 -9
  58. package/src/shared/framing.ts +127 -128
  59. package/src/shared/queryText.ts +15 -7
  60. package/src/shared/reactive/Cursor.ts +4 -2
  61. package/src/shared/reactive/Reactive.ts +285 -197
  62. package/src/shared/reactive/index.ts +11 -8
  63. package/src/shared/reactive/pairReactive.ts +1 -1
  64. package/src/shared/reactive/scoped.ts +4 -3
  65. package/src/shared/reactive/share.ts +11 -1
  66. package/src/shared/routePattern.ts +0 -123
  67. package/src/shared/rpc.ts +17 -24
  68. package/src/shared/rpcRequest.ts +18 -47
  69. package/src/shared/takeEarlyCall.ts +12 -37
  70. package/src/shared/url.ts +1 -1
  71. package/src/ui/Level.ts +276 -0
  72. package/src/ui/List.ts +17 -17
  73. package/src/ui/Router.ts +20 -87
  74. package/src/ui/applyHead.ts +92 -79
  75. package/src/ui/index.ts +4 -2
  76. package/src/ui/reconcile.ts +4 -110
  77. package/src/ui/rows.ts +123 -2
  78. package/src/ui/wire.ts +12 -27
  79. package/src/compiler/hooksCalled.ts +0 -35
  80. package/src/server/clientBuildOptions.ts +0 -54
  81. package/src/server/refuseAddressCollision.ts +0 -37
  82. package/src/shared/reactive/chunksOfStream.ts +0 -33
  83. package/src/ui/MOUNTING_LEVEL.ts +0 -18
@@ -0,0 +1,152 @@
1
+ import { SHARED_SPECIFIER } from '#shared/SPECIFIERS.ts'
2
+ import { importStatements } from './splitImports.ts'
3
+ import { type Token, tokenize } from './tokenize.ts'
4
+
5
+ // WHICH OF ABIDE'S HOOKS A MODULE CALLS, read off its tokens and its imports — not run. What `abide
6
+ // routes` lists (38.108), what the build refuses where a call is never read (37.13, 38.107), and
7
+ // which files the checker types `Config` off (27.13) all ask this one question.
8
+
9
+ /** Every hook abide exports, by name. */
10
+ export const HOOK_NAMES: ReadonlySet<string> = new Set([
11
+ 'middleware',
12
+ 'onStart',
13
+ 'onStop',
14
+ 'onError',
15
+ 'onConfig',
16
+ 'onHealth',
17
+ 'onPrincipal',
18
+ 'onLauncher',
19
+ 'onMenu',
20
+ ])
21
+
22
+ /** One call of one of abide's hooks. */
23
+ export type HookCall = {
24
+ hook: string
25
+ /** Where the callee starts — the hook's name, or the namespace it is a member of. */
26
+ at: number
27
+ /** Where the callee ends: the `(` follows. */
28
+ end: number
29
+ /** Whether the call stands as a statement at the module's top level. */
30
+ topLevel: boolean
31
+ }
32
+
33
+ /**
34
+ * What `scripts`' imports from abide bind: each local name, a value's, to the name it imports — `*`
35
+ * for a namespace. Every script of one file, since a `<script module>`'s import reaches the blocks
36
+ * below it.
37
+ */
38
+ export function abideBindings(
39
+ scripts: readonly string[],
40
+ ): ReadonlyMap<string, string> {
41
+ const bound = new Map<string, string>()
42
+ for (const script of scripts) {
43
+ if (!script.includes(SHARED_SPECIFIER)) continue
44
+ for (const statement of importStatements(script))
45
+ if (isAbideSpecifier(statement.specifier))
46
+ for (const binding of statement.bindings)
47
+ if (!binding.typeOnly)
48
+ bound.set(binding.local, binding.imported)
49
+ }
50
+ return bound
51
+ }
52
+
53
+ /**
54
+ * Each of abide's hooks `scripts` call — every script of one file — in the order written. Block by
55
+ * block: one block's last token would decide how the next one's first reads.
56
+ */
57
+ export function hooksIn(scripts: readonly string[]): string[] {
58
+ const bindings = abideBindings(scripts)
59
+ const hooks: string[] = []
60
+ for (const script of scripts)
61
+ for (const call of hookCalls(script, bindings)) hooks.push(call.hook)
62
+ return hooks
63
+ }
64
+
65
+ /**
66
+ * Each of abide's hooks `source` calls, in the order written.
67
+ *
68
+ * A HOOK IS WHAT ABIDE HANDED THE MODULE: a name `bindings` imports from abide, under whatever local
69
+ * name, or a member of a namespace it does — `server.onStart(…)` is the hook, and `emitter.onError(…)`
70
+ * in a module importing abide for `reactive` is not.
71
+ */
72
+ export function hookCalls(
73
+ source: string,
74
+ bindings: ReadonlyMap<string, string> = abideBindings([source]),
75
+ ): HookCall[] {
76
+ const calls: HookCall[] = []
77
+ // Untokenized where no name a hook is bound to — itself, or the namespace holding it — is in it.
78
+ let mentioned = false
79
+ for (const [local, imported] of bindings)
80
+ if (
81
+ (imported === '*' || HOOK_NAMES.has(imported)) &&
82
+ source.includes(local)
83
+ ) {
84
+ mentioned = true
85
+ break
86
+ }
87
+ if (!mentioned) return calls
88
+ const tokens = tokenize(source)
89
+ let depth = 0
90
+ for (let index = 0; index < tokens.length; index += 1) {
91
+ const token = tokens[index] as Token
92
+ const text = token.text
93
+ if (text === '(' || text === '[' || text === '{') depth += 1
94
+ else if (text === ')' || text === ']' || text === '}') depth -= 1
95
+ if (tokens[index + 1]?.text !== '(') continue
96
+ const previous = tokens[index - 1]
97
+ let hook: string | undefined
98
+ let first = index
99
+ if (previous?.text === '.') {
100
+ const owner = tokens[index - 2]
101
+ if (
102
+ HOOK_NAMES.has(text) &&
103
+ owner !== undefined &&
104
+ bindings.get(owner.text) === '*' &&
105
+ tokens[index - 3]?.text !== '.'
106
+ ) {
107
+ hook = text
108
+ first = index - 2
109
+ }
110
+ } else if (previous?.text !== 'function') {
111
+ const imported = bindings.get(text)
112
+ if (imported !== undefined && HOOK_NAMES.has(imported))
113
+ hook = imported
114
+ }
115
+ if (hook === undefined) continue
116
+ const callee = tokens[first] as Token
117
+ calls.push({
118
+ hook,
119
+ at: callee.start,
120
+ end: token.end,
121
+ topLevel: depth === 0 && opensStatement(source, tokens, first),
122
+ })
123
+ }
124
+ return calls
125
+ }
126
+
127
+ // Whether the token at `index` begins a statement: the first, after a `;` or a `}`, or on a line of
128
+ // its own after one that ended an expression — where a formatted module writes one.
129
+ function opensStatement(
130
+ source: string,
131
+ tokens: readonly Token[],
132
+ index: number,
133
+ ): boolean {
134
+ const previous = tokens[index - 1]
135
+ if (previous === undefined) return true
136
+ if (previous.text === ';' || previous.text === '}') return true
137
+ return (
138
+ source
139
+ .slice(previous.end, (tokens[index] as Token).start)
140
+ .includes('\n') && ENDS_EXPRESSION.test(previous.text)
141
+ )
142
+ }
143
+
144
+ const ENDS_EXPRESSION = /^(?:[\w$]+|[)\]]|['"`].*)$/s
145
+
146
+ /** `@abide/abide` or one of its subpaths. */
147
+ export function isAbideSpecifier(specifier: string): boolean {
148
+ return (
149
+ specifier === SHARED_SPECIFIER ||
150
+ specifier.startsWith(`${SHARED_SPECIFIER}/`)
151
+ )
152
+ }
@@ -6,15 +6,16 @@
6
6
  //
7
7
  // A FILE KEEPS ITS PATH AND GAINS A POSITION. It is compiled, served and keyed by where it lies, as
8
8
  // any dependency's is (34.23); its POSITION — the path it would have below the root — is what
9
- // `placeOf`, `fileKind` and `routeOf` read, so a mounted page is a page by the same test as the app's.
9
+ // `placement` reads, so a mounted page is a page by the same test as the app's.
10
10
 
11
- import { readFileSync, realpathSync, statSync } from 'node:fs' // bun exposes no sync read, stat or realpath of its own
11
+ import { realpathSync, statSync } from 'node:fs' // bun exposes no sync stat or realpath of its own
12
12
  import { dirname, relative, resolve } from 'node:path' // bun exposes no path helpers of its own
13
13
  import { appFiles } from '#server/appTree.ts'
14
- import { placeOf } from '#shared/routePattern.ts'
14
+ import { placeOf } from '#server/placement.ts'
15
15
  import { nearest } from './nearest.ts'
16
- import { type AbideFile, parseAbide, scriptBodies } from './parseAbide.ts'
17
- import { attributedImports, IMPORT_ATTRIBUTES } from './splitImports.ts'
16
+ import { type AbideFile, scriptBodies } from './parseAbide.ts'
17
+ import { Refusals } from './Refusals.ts'
18
+ import { attributedImports } from './splitImports.ts'
18
19
 
19
20
  /** A `.abide` on disk, and the position it takes in the app. */
20
21
  type Placed = { path: string; position: string }
@@ -33,8 +34,8 @@ type DirectoryMount = {
33
34
  }
34
35
 
35
36
  /**
36
- * Every directory the file at `path` mounts, its position in the app being `position` — or the
37
- * refusal naming what one of them cannot be.
37
+ * Every directory the file at `path` mounts, its position in the app being `position` — or none,
38
+ * where one of them cannot be mounted and `refusals` is told what.
38
39
  *
39
40
  * `root` is the app's, real: a directory under it is the app's own already, with a position of its
40
41
  * own, and mounting it would give its files a second (23.20).
@@ -44,7 +45,8 @@ export function mountsOf(
44
45
  path: string,
45
46
  position: string,
46
47
  file: AbideFile,
47
- ): DirectoryMount[] | string {
48
+ refusals: Refusals,
49
+ ): DirectoryMount[] {
48
50
  const mounts: DirectoryMount[] = []
49
51
  const at = position.includes('/')
50
52
  ? `${position.slice(0, position.lastIndexOf('/'))}/`
@@ -52,12 +54,27 @@ export function mountsOf(
52
54
  for (const script of scriptBodies(file))
53
55
  for (const { specifier } of attributedImports(script, 'routes')) {
54
56
  const directory = mountedDirectory(specifier, dirname(path))
55
- if (directory === undefined)
56
- return `abide: ${position} mounts ${specifier}, and no directory answers to it. RULEBOOK 23.19.`
57
- if (ownDirectory(root, directory))
58
- return `abide: ${position} mounts ${specifier}, which is the app's own directory already. RULEBOOK 23.20.`
57
+ // A file that mounts what it may not mounts nothing.
58
+ if (directory === undefined) {
59
+ refusals.at(
60
+ path,
61
+ new Error(
62
+ `abide: ${position} mounts ${specifier}, and no directory answers to it. RULEBOOK 23.19.`,
63
+ ),
64
+ )
65
+ return []
66
+ }
67
+ if (ownDirectory(root, directory)) {
68
+ refusals.at(
69
+ path,
70
+ new Error(
71
+ `abide: ${position} mounts ${specifier}, which is the app's own directory already. RULEBOOK 23.20.`,
72
+ ),
73
+ )
74
+ return []
75
+ }
59
76
  // The package's tree is the package's to refuse; a mount places what it finds.
60
- const found = appFiles(directory, () => {})
77
+ const found = appFiles(directory, new Refusals(directory, true))
61
78
  const placed = (below: string): Placed => ({
62
79
  path: `${directory}/${below}`,
63
80
  position: `${at}${below}`,
@@ -73,51 +90,11 @@ export function mountsOf(
73
90
  return mounts
74
91
  }
75
92
 
76
- /**
77
- * Every file mounted under the app at `root` whose own `.abide` are `relatives`, and every one those
78
- * mount in turn, at its position — read for nothing but its mounts, and refusing nothing: the
79
- * build is what refuses a mount (`withDependencies`).
80
- *
81
- * THE CHECKER'S `Routes` (30.26) asks this on every check, so a file is parsed only where its text
82
- * names a mount at all.
83
- */
84
- export function mountedUnder(
85
- root: string,
86
- relatives: readonly string[],
87
- ): Placed[] {
88
- const queue: Placed[] = []
89
- for (const relative of relatives)
90
- queue.push({ path: `${root}/${relative}`, position: relative })
91
- const found: Placed[] = []
92
- const seen = new Set<string>()
93
- for (let at = 0; at < queue.length; at += 1) {
94
- const { path, position } = queue[at] as Placed
95
- const text = readFileSync(path, 'utf8')
96
- if (!IMPORT_ATTRIBUTES.routes.test(text)) continue
97
- let file: AbideFile
98
- try {
99
- file = parseAbide(text)
100
- } catch {
101
- continue
102
- }
103
- const mounts = mountsOf(root, path, position, file)
104
- if (typeof mounts === 'string') continue
105
- for (const mount of mounts)
106
- for (const placed of mount.files) {
107
- if (seen.has(placed.path)) continue
108
- seen.add(placed.path)
109
- found.push(placed)
110
- queue.push(placed)
111
- }
112
- }
113
- return found
114
- }
115
-
116
93
  // Under the root and not a dependency's there: one of the app's own, with positions of its own.
117
94
  function ownDirectory(root: string, directory: string): boolean {
118
95
  if (directory === root) return true
119
96
  const below = relative(root, directory)
120
- return !below.startsWith('..') && placeOf(`${below}/-`) !== 'dependency'
97
+ return !below.startsWith('..') && placeOf(`${below}/`) !== 'dependency'
121
98
  }
122
99
 
123
100
  /**
@@ -1,11 +1,12 @@
1
1
  import { type CheckerAnswer, UNCHECKED } from './CheckerAnswer.ts'
2
- import { diagnosisAt } from './lineOf.ts'
3
2
  import { type AbideFile, parseAbide } from './parseAbide.ts'
3
+ import type { Refusals } from './Refusals.ts'
4
4
  import type { Diagnosis, TypeSession } from './TypeSession.ts'
5
5
 
6
6
  // EVERY COMPILE OF A `.abide` STARTS THE SAME WAY: the text parsed, then every file read by the
7
7
  // checker in one snapshot. `abide build`, `abide check` and an editor's one file differ only in
8
- // what a refusal does — the build stops at it, and a check reports it and goes on with the rest.
8
+ // what a refusal does (`Refusals`) — the build stops at it, and a check reports it and goes on with
9
+ // the rest.
9
10
 
10
11
  /** One `.abide`, parsed. `quiet` is read for its props alone and reported on by nobody (33.4). */
11
12
  export type ParsedSource = {
@@ -13,27 +14,21 @@ export type ParsedSource = {
13
14
  text: string
14
15
  file: AbideFile
15
16
  quiet: boolean
16
- /** 23.19 — where a mounted package's file stands in the app; `undefined` for one at its own path. */
17
- position: string | undefined
18
17
  }
19
18
 
20
19
  /** One `.abide` as the emitters and the checked lowering take it: parsed, and answered. */
21
- export type ReadSource = ParsedSource & { answer: CheckerAnswer }
20
+ export type ReadSource<Source extends ParsedSource = ParsedSource> = Source & {
21
+ answer: CheckerAnswer
22
+ }
22
23
 
23
- /**
24
- * Each source parsed.
25
- *
26
- * WITH `refused`, A SOURCE THE PARSER REFUSES IS A FINDING, at its first line, and the rest are
27
- * parsed around it — a quiet one's is nobody's. Without it the refusal is thrown, as a build stops.
28
- */
24
+ /** Each source parsed, around a source the parser refuses — a quiet one's refusal is nobody's. */
29
25
  export function parseSources(
30
26
  sources: readonly {
31
27
  path: string
32
28
  text: string
33
29
  quiet?: boolean
34
- position?: string
35
30
  }[],
36
- refused?: Diagnosis[],
31
+ refusals: Refusals,
37
32
  ): ParsedSource[] {
38
33
  const parsed: ParsedSource[] = []
39
34
  for (const source of sources) {
@@ -42,8 +37,7 @@ export function parseSources(
42
37
  try {
43
38
  file = parseAbide(source.text)
44
39
  } catch (thrown) {
45
- if (refused === undefined) throw thrown
46
- if (!quiet) refused.push(refusal(source.path, thrown))
40
+ if (!quiet) refusals.at(source.path, thrown)
47
41
  continue
48
42
  }
49
43
  parsed.push({
@@ -51,7 +45,6 @@ export function parseSources(
51
45
  text: source.text,
52
46
  file,
53
47
  quiet,
54
- position: source.position,
55
48
  })
56
49
  }
57
50
  return parsed
@@ -60,22 +53,25 @@ export function parseSources(
60
53
  /**
61
54
  * Each parsed source with the checker's answer, and the compiler's warnings — in one snapshot.
62
55
  *
63
- * ONE SNAPSHOT FOR THE LOT, which is the whole of the compiler's build time (`readsFor`). WITH
64
- * `refused`, a batch that refuses is asked again a file at a time, so which file it was is known
65
- * and the others still answer; without it the refusal is thrown.
56
+ * ONE SNAPSHOT FOR THE LOT, which is the whole of the compiler's build time (`readsFor`). Where
57
+ * `refusals` collects, a batch that refuses is asked again a file at a time, so which file it was is
58
+ * known and the others still answer; where it throws, so does the batch.
66
59
  */
67
- export async function readSources(
60
+ export async function readSources<Source extends ParsedSource>(
68
61
  session: TypeSession,
69
- parsed: readonly ParsedSource[],
70
- refused?: Diagnosis[],
71
- ): Promise<{ read: ReadSource[]; warnings: Diagnosis[] }> {
62
+ parsed: readonly Source[],
63
+ refusals: Refusals,
64
+ ): Promise<{
65
+ read: ReadSource<Source>[]
66
+ warnings: Diagnosis[]
67
+ }> {
72
68
  try {
73
69
  const { answers, warnings } = await session.readsFor(parsed)
74
70
  return { read: answered(parsed, answers), warnings }
75
71
  } catch (thrown) {
76
- if (refused === undefined) throw thrown
72
+ if (!refusals.collecting) throw thrown
77
73
  }
78
- const read: ReadSource[] = []
74
+ const read: ReadSource<Source>[] = []
79
75
  const warnings: Diagnosis[] = []
80
76
  for (const source of parsed)
81
77
  try {
@@ -83,31 +79,20 @@ export async function readSources(
83
79
  read.push(...answered([source], alone.answers))
84
80
  warnings.push(...alone.warnings)
85
81
  } catch (thrown) {
86
- if (!source.quiet) refused.push(refusal(source.path, thrown))
82
+ if (!source.quiet) refusals.at(source.path, thrown)
87
83
  }
88
84
  return { read, warnings }
89
85
  }
90
86
 
91
- function answered(
92
- parsed: readonly ParsedSource[],
87
+ function answered<Source extends ParsedSource>(
88
+ parsed: readonly Source[],
93
89
  answers: readonly CheckerAnswer[],
94
- ): ReadSource[] {
95
- const read: ReadSource[] = []
90
+ ): ReadSource<Source>[] {
91
+ const read: ReadSource<Source>[] = []
96
92
  for (let at = 0; at < parsed.length; at += 1)
97
93
  read.push({
98
- ...(parsed[at] as ParsedSource),
94
+ ...(parsed[at] as Source),
99
95
  answer: answers[at] ?? UNCHECKED,
100
96
  })
101
97
  return read
102
98
  }
103
-
104
- /** A refusal as a finding at the first line of the file it was refused in. */
105
- export function refusal(path: string, thrown: unknown): Diagnosis {
106
- return diagnosisAt(
107
- path,
108
- undefined,
109
- 0,
110
- 'error',
111
- thrown instanceof Error ? thrown.message : String(thrown),
112
- )
113
- }
@@ -30,7 +30,6 @@ import {
30
30
  type Type,
31
31
  TypeFlags,
32
32
  } from 'typescript/unstable/async'
33
- import { fileKind, routeOf } from '#shared/routePattern.ts'
34
33
  import { aliasedSymbol } from './aliasedSymbol.ts'
35
34
  import { CheckerAnswer } from './CheckerAnswer.ts'
36
35
  import {
@@ -125,8 +124,6 @@ export async function readsIn(
125
124
  sourceFile: SourceFile,
126
125
  shadowPath: string,
127
126
  spans: ShadowSpans,
128
- // The file below the app's root, which its route is read off (23.14).
129
- routedPath: string,
130
127
  file: AbideFile,
131
128
  ): Promise<{ answer: CheckerAnswer; warnings: Finding[] }> {
132
129
  // A destructuring pattern binds names this does not see, and that is 31.12's case.
@@ -188,7 +185,7 @@ export async function readsIn(
188
185
  // Awaited after the round trips below, so a 33.14 refusal would reject with nothing
189
186
  // listening and be reported unhandled; `await props` still throws it.
190
187
  props.catch(() => {})
191
- const params = routeReads(checker, abide, sourceFile, routedPath, reads)
188
+ const params = routeReads(checker, abide, sourceFile, reads)
192
189
  const configs = configReads(
193
190
  checker,
194
191
  abide,
@@ -693,21 +690,17 @@ const ROUTE_MEMBERS = new Set(['url', 'params', 'name', 'navigating'])
693
690
  * READ AS A CALL RATHER THAN THROUGH THE CHECKER'S VALUES RECORD (`local`): the record is keyed by
694
691
  * a name, and this read is spelled as a member.
695
692
  *
696
- * 23.9 — AND `route.params` IN A PAGE OR A LAYOUT IS WHAT THAT FILE'S OWN ROUTE PROMISES. The file
697
- * is the route: `media/[id].abide` only ever renders where `id` matched, so its
698
- * `route.params` has an `id`, and a layout's has its own segments and whatever a page below adds.
699
- * Only the checker reads that — the running module reads the one `route` every page shares.
693
+ * 23.9 — AND `route.params` IS MARKED, for a checked lowering of a page or a layout to type by what
694
+ * the file's own route promises (`Lowering`): the file is the route, and the running module reads
695
+ * the one `route` every page shares.
700
696
  */
701
697
  async function routeReads(
702
698
  checker: Checker,
703
699
  abide: Abide,
704
700
  sourceFile: Node,
705
- routedPath: string,
706
701
  reads: Read[],
707
702
  ): Promise<void> {
708
703
  if (abide.route === undefined) return
709
- const routed = fileKind(routedPath)
710
- const kind = routed === 'page' || routed === 'layout' ? routed : undefined
711
704
  const accesses: Node[] = []
712
705
  const owners: Node[] = []
713
706
  const visit = (node: Node): void => {
@@ -725,7 +718,6 @@ async function routeReads(
725
718
  sourceFile.forEachChild(visit)
726
719
  if (owners.length === 0) return
727
720
  const symbols = await checker.getSymbolAtLocation(owners)
728
- const pattern = routeOf(routedPath)
729
721
  for (let at = 0; at < accesses.length; at += 1) {
730
722
  const symbol = symbols[at]
731
723
  if (symbol === undefined) continue
@@ -735,11 +727,8 @@ async function routeReads(
735
727
  const read = classifyRead(access)
736
728
  if (read === undefined) continue
737
729
  read.local = true
738
- if (
739
- kind !== undefined &&
730
+ read.routeParams =
740
731
  (access as { name?: { text: string } }).name?.text === 'params'
741
- )
742
- read.routeParams = [pattern, kind]
743
732
  reads.push(read)
744
733
  }
745
734
  }
@@ -0,0 +1,77 @@
1
+ import { existsSync, readFileSync } from 'node:fs' // bun exposes no sync existence check or read of its own
2
+ import { dirname, posix } from 'node:path' // bun exposes no path helpers of its own
3
+ import { resolvedFile } from '#server/abideModules.ts'
4
+ import { nearest } from './nearest.ts'
5
+
6
+ /**
7
+ * Resolves a specifier from a directory as bun does, or `undefined` where it does not resolve.
8
+ *
9
+ * A COMPONENT IMPORT RESOLVES THE WAY THE APP'S OWN IMPORTS DO — its import map, from the file
10
+ * that wrote it. One that does not resolve is left as written, and the bundle names it. THE
11
+ * ANSWER IS A REAL PATH, as every root handed to the compiler is.
12
+ */
13
+ export function resolverIn(): (
14
+ specifier: string,
15
+ from: string,
16
+ ) => string | undefined {
17
+ // ONE ANSWER PER SPECIFIER AND DIRECTORY for as long as the resolver lives: a compile resolves
18
+ // every import once for its dependencies and again for its graph, and hands both one resolver.
19
+ const answered = new Map<string, string | undefined>()
20
+ return (specifier, from) => {
21
+ const key = `${from}\0${specifier}`
22
+ if (answered.has(key)) return answered.get(key)
23
+ const found = resolveFrom(specifier, from)
24
+ answered.set(key, found)
25
+ return found
26
+ }
27
+ }
28
+
29
+ function resolveFrom(specifier: string, from: string): string | undefined {
30
+ try {
31
+ return resolvedFile(specifier, from)
32
+ } catch {
33
+ // 38.65 — BUN KEEPS A DIRECTORY'S LISTING FOR THE LIFE OF THE PROCESS, and looks a `#` specifier up
34
+ // in it with no second look — where a relative one that misses reads the directory again.
35
+ // `abide dev` compiles in one process for its whole session, so a component created since
36
+ // its directory was listed never resolved, and was read as uninvoked. The `imports` map is
37
+ // read here instead, and the file it names looked for on disk.
38
+ return specifier.startsWith('#')
39
+ ? throughImports(specifier, from)
40
+ : undefined
41
+ }
42
+ }
43
+
44
+ /** A `#` specifier resolved through the nearest `package.json`'s `imports`, to a file that exists. */
45
+ function throughImports(specifier: string, from: string): string | undefined {
46
+ const manifest = nearest(from, 'package.json')
47
+ if (manifest === undefined) return undefined
48
+ const imports = (
49
+ JSON.parse(readFileSync(manifest, 'utf8')) as {
50
+ imports?: Record<string, unknown>
51
+ }
52
+ ).imports
53
+ if (imports === undefined) return undefined
54
+ const base = dirname(manifest)
55
+ for (const [key, targets] of Object.entries(imports)) {
56
+ const star = key.indexOf('*')
57
+ let matched: string | undefined
58
+ if (star === -1) matched = key === specifier ? '' : undefined
59
+ else if (
60
+ specifier.startsWith(key.slice(0, star)) &&
61
+ specifier.endsWith(key.slice(star + 1)) &&
62
+ specifier.length >= key.length - 1
63
+ )
64
+ matched = specifier.slice(
65
+ star,
66
+ specifier.length - (key.length - star - 1),
67
+ )
68
+ if (matched === undefined) continue
69
+ // A conditions object names no path of its own, and is passed over.
70
+ for (const target of [targets].flat()) {
71
+ if (typeof target !== 'string') continue
72
+ const path = posix.join(base, target.replace('*', matched))
73
+ if (existsSync(path)) return path
74
+ }
75
+ }
76
+ return undefined
77
+ }