@abide/abide 0.117.1 → 0.118.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.
@@ -0,0 +1,147 @@
1
+ // 23.26, 23.27 — WHICH MATCHER EACH SEGMENT NAMES, resolved once at build and carried into the route
2
+ // tables as text, so nothing at run time looks a name up. A segment's `=NAME` is the param file
3
+ // `NAME.param.ts` in the directory holding that segment or the nearest one above it, below the root
4
+ // the file is filed under: the app's root for the app's own, and a mounted directory for the files
5
+ // under it (23.19), so a host's matcher never decides a mounted package's segment.
6
+ //
7
+ // ONE MATCHER PER POSITION across every file filed there (23.27): a layout, a page and a handler
8
+ // directory at `/holdings/[symbol=TICKER]` share the segment, and a page that took `BRK.B` under a
9
+ // layout whose segment refused it would be wrapped by a layout no address of its own reaches.
10
+
11
+ import { parseRoutePattern } from '#shared/routePattern.ts'
12
+ import { matcherOf } from './paramFile.ts'
13
+ import type { Refusals } from './Refusals.ts'
14
+
15
+ /** A directory a file mounts (23.19), where it mounts, and the param files under it, by path. */
16
+ export type MountedTree = { directory: string; at: string; params: string[] }
17
+
18
+ /** A file with a route: its path, the name refusals give it, and the position it answers by. */
19
+ type Routed = { path: string; relative: string; position: string; route: string }
20
+
21
+ /**
22
+ * Each routed file's matchers, by its path: every name its route's segments give, to the anchored
23
+ * source of the param file it resolves to — empty where its route names none.
24
+ *
25
+ * `params` is every param file of the app's own, by path. Refusals land at the file the fault is in:
26
+ * a param file that declares no matcher at itself, a name that resolves to none at the segment's file.
27
+ */
28
+ export async function matchersOf(
29
+ root: string,
30
+ routed: readonly Routed[],
31
+ params: readonly string[],
32
+ trees: readonly MountedTree[],
33
+ refusals: Refusals,
34
+ ): Promise<Map<string, Record<string, string>>> {
35
+ // Every param file's matcher, read once whichever routes name it; `undefined` where 23.24 or
36
+ // 23.25 refused the file, so a segment naming it refuses nothing more.
37
+ const sources = new Map<string, string | undefined>()
38
+ const all = [...params]
39
+ for (const tree of trees) all.push(...tree.params)
40
+ for (const path of all) {
41
+ if (sources.has(path)) continue
42
+ const matcher = matcherOf(labelOf(root, path), await Bun.file(path).text())
43
+ if (matcher instanceof Error) {
44
+ refusals.at(path, matcher)
45
+ sources.set(path, undefined)
46
+ } else sources.set(path, matcher)
47
+ }
48
+ const byPath = new Map<string, Record<string, string>>()
49
+ // 23.27 — what each parameter position resolved to, and the first file that said so.
50
+ const atPosition = new Map<string, { found: string; file: string }>()
51
+ for (const file of routed) {
52
+ const matchers: Record<string, string> = {}
53
+ const tree = treeOf(file.path, trees)
54
+ const positionParts = file.position.split('/')
55
+ let shape = ''
56
+ let at = 0
57
+ for (const segment of parseRoutePattern(file.route)) {
58
+ shape += `/${segment.kind === 'static' ? segment.name : `[${segment.kind}]`}`
59
+ const index = at
60
+ at += 1
61
+ if (segment.kind === 'static') continue
62
+ const found =
63
+ segment.matcher === undefined
64
+ ? ''
65
+ : nearestParam(
66
+ root,
67
+ tree,
68
+ positionParts.slice(0, index),
69
+ segment.matcher,
70
+ sources,
71
+ )
72
+ if (found === undefined) {
73
+ refusals.at(
74
+ file.path,
75
+ new Error(
76
+ `abide: ${file.relative} names the matcher ${segment.matcher}, and no ${segment.matcher}.param.ts is in the directory holding that segment or any directory above it. RULEBOOK 23.26.`,
77
+ ),
78
+ )
79
+ continue
80
+ }
81
+ const other = atPosition.get(shape)
82
+ if (other === undefined) atPosition.set(shape, { found, file: file.relative })
83
+ else if (other.found !== found)
84
+ refusals.at(
85
+ file.path,
86
+ new Error(
87
+ `abide: ${other.file} and ${file.relative} decide the parameter at ${shape} by ${describe(root, other.found)} and by ${describe(root, found)}, and one position takes one matcher. RULEBOOK 23.27.`,
88
+ ),
89
+ )
90
+ const source = found === '' ? undefined : sources.get(found)
91
+ if (segment.matcher !== undefined && source !== undefined)
92
+ matchers[segment.matcher] = source
93
+ }
94
+ byPath.set(file.path, matchers)
95
+ }
96
+ return byPath
97
+ }
98
+
99
+ /**
100
+ * The param file `NAME.param.ts` a segment at `holding` names, nearest first, or `undefined`.
101
+ *
102
+ * `holding` is the position of the directory holding the segment, and the walk stops at the root
103
+ * the segment is filed under: inside a mounted directory, that directory; a segment of the position
104
+ * the mount itself takes is the mounting app's.
105
+ */
106
+ function nearestParam(
107
+ root: string,
108
+ tree: MountedTree | undefined,
109
+ holding: string[],
110
+ name: string,
111
+ sources: Map<string, string | undefined>,
112
+ ): string | undefined {
113
+ const atParts = tree === undefined ? 0 : tree.at.split('/').length - 1
114
+ const inside = tree !== undefined && holding.length >= atParts
115
+ const base = inside ? tree.directory : root
116
+ const below = inside ? holding.slice(atParts) : holding
117
+ for (let depth = below.length; depth >= 0; depth -= 1) {
118
+ const directory = [base, ...below.slice(0, depth)].join('/')
119
+ const candidate = `${directory}/${name}.param.ts`
120
+ if (sources.has(candidate)) return candidate
121
+ }
122
+ return undefined
123
+ }
124
+
125
+ /** The mounted directory `path` lies in, the deepest where two nest, or `undefined` for the app's own. */
126
+ function treeOf(
127
+ path: string,
128
+ trees: readonly MountedTree[],
129
+ ): MountedTree | undefined {
130
+ let deepest: MountedTree | undefined
131
+ for (const tree of trees)
132
+ if (
133
+ path.startsWith(`${tree.directory}/`) &&
134
+ (deepest === undefined ||
135
+ tree.directory.length > deepest.directory.length)
136
+ )
137
+ deepest = tree
138
+ return deepest
139
+ }
140
+
141
+ function labelOf(root: string, path: string): string {
142
+ return path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path
143
+ }
144
+
145
+ function describe(root: string, found: string): string {
146
+ return found === '' ? 'no matcher' : labelOf(root, found)
147
+ }
@@ -31,6 +31,8 @@ type DirectoryMount = {
31
31
  at: string
32
32
  files: Placed[]
33
33
  handlers: Placed[]
34
+ /** 23.26 — the param files under it, by path: what its own segments' matchers resolve in. */
35
+ params: string[]
34
36
  }
35
37
 
36
38
  /**
@@ -85,6 +87,7 @@ export function mountsOf(
85
87
  at,
86
88
  files: found.abide.map(placed),
87
89
  handlers: found.handlers.map(placed),
90
+ params: found.params.map((below) => `${directory}/${below}`),
88
91
  })
89
92
  }
90
93
  return mounts
@@ -0,0 +1,124 @@
1
+ // 23.24, 23.25 — A PARAM FILE, READ AND NEVER RUN. `<NAME>.param.ts` says what a segment naming
2
+ // `NAME` accepts, and the build carries that into both route tables as a regex literal, so the
3
+ // file is read off its tokens: one `export const NAME = /…/`, nothing else. A file that does
4
+ // anything more could only be honoured by running it, and nothing about building a route table is
5
+ // a reason to run app code.
6
+ //
7
+ // 30.36 — THE DOT-SEGMENT FLOOR IS WRITTEN INTO THE SOURCE rather than tested beside it, so the walk
8
+ // holds one test per part whatever the segment names: a matcher can lift the period default (30.34)
9
+ // and still never admit `.` or `..`, which the URL parser collapses into another route.
10
+
11
+ import { SyntaxKind } from 'typescript/unstable/ast'
12
+ import { tokenize } from './tokenize.ts'
13
+
14
+ /** The name a param file declares: its file name before `.param.ts`. */
15
+ export function paramNameOf(path: string): string {
16
+ return path.slice(path.lastIndexOf('/') + 1, -'.param.ts'.length)
17
+ }
18
+
19
+ /**
20
+ * The anchored source of the matcher the param file at `relative` declares, or why it declares none.
21
+ *
22
+ * Anchored as `^(?!\.\.?$)(?:…)$`: the whole part, percent-decoded, and never a dot-segment.
23
+ */
24
+ export function matcherOf(relative: string, text: string): string | Error {
25
+ const name = paramNameOf(relative)
26
+ const tokens = tokenize(text)
27
+ const kinds: SyntaxKind[] = []
28
+ for (const token of tokens)
29
+ if (token.kind !== SyntaxKind.EndOfFile) kinds.push(token.kind)
30
+ const declared =
31
+ kinds.length >= 5 &&
32
+ kinds.length <= 6 &&
33
+ kinds[0] === SyntaxKind.ExportKeyword &&
34
+ kinds[1] === SyntaxKind.ConstKeyword &&
35
+ kinds[2] === SyntaxKind.Identifier &&
36
+ kinds[3] === SyntaxKind.EqualsToken &&
37
+ kinds[4] === SyntaxKind.RegularExpressionLiteral &&
38
+ (kinds.length === 5 || kinds[5] === SyntaxKind.SemicolonToken)
39
+ if (!declared)
40
+ return new Error(
41
+ `abide: ${relative} must hold one \`export const ${name} = /…/\` and nothing else, an import included. RULEBOOK 23.24.`,
42
+ )
43
+ const exported = (tokens[2] as { text: string }).text
44
+ if (exported !== name)
45
+ return new Error(
46
+ `abide: ${relative} exports ${exported}, and a param file exports the name it is filed under, ${name}. RULEBOOK 23.24.`,
47
+ )
48
+ const literal = (tokens[4] as { text: string }).text
49
+ const close = literal.lastIndexOf('/')
50
+ if (close !== literal.length - 1)
51
+ return new Error(
52
+ `abide: ${relative} carries the flag ${literal.slice(close + 1)}, and a matcher carries none: a route, OpenAPI and a JSON Schema's \`pattern\` would each read it differently. RULEBOOK 23.24.`,
53
+ )
54
+ const body = literal.slice(1, close)
55
+ const runaway = runawayIn(body)
56
+ if (runaway !== undefined)
57
+ return new Error(
58
+ `abide: ${relative} carries ${runaway}, which can take time exponential in a part's length on an address made to. RULEBOOK 23.25.`,
59
+ )
60
+ return `^(?!\\.\\.?$)(?:${body})$`
61
+ }
62
+
63
+ /**
64
+ * 23.25 — what in a regex's source can backtrack without bound, or `undefined`.
65
+ *
66
+ * A repetition with no upper bound (`*`, `+`, `{n,}`) of a group that itself holds one, and a
67
+ * backreference. Read character by character rather than parsed: a class and an escape are skipped
68
+ * as single atoms, which is all the shape the two questions need.
69
+ */
70
+ export function runawayIn(body: string): string | undefined {
71
+ // One entry per open group: whether it holds an unbounded repetition.
72
+ const holds: boolean[] = [false]
73
+ // Whether the atom just read is a group holding one.
74
+ let groupHolding = false
75
+ for (let at = 0; at < body.length; at += 1) {
76
+ const char = body[at] as string
77
+ if (char === '\\') {
78
+ const next = body[at + 1] ?? ''
79
+ if ((next >= '1' && next <= '9') || next === 'k')
80
+ return 'a backreference'
81
+ at += 1
82
+ groupHolding = false
83
+ continue
84
+ }
85
+ if (char === '[') {
86
+ at += 1
87
+ while (at < body.length && body[at] !== ']')
88
+ at += body[at] === '\\' ? 2 : 1
89
+ groupHolding = false
90
+ continue
91
+ }
92
+ if (char === '(') {
93
+ holds.push(false)
94
+ groupHolding = false
95
+ continue
96
+ }
97
+ if (char === ')') {
98
+ const inner = holds.pop() ?? false
99
+ if (inner) holds[holds.length - 1] = true
100
+ groupHolding = inner
101
+ continue
102
+ }
103
+ const unbounded =
104
+ char === '*' ||
105
+ char === '+' ||
106
+ (char === '{' && /^\{\d+,\}/.test(body.slice(at)))
107
+ if (unbounded) {
108
+ if (groupHolding) return 'a repetition of a group that repeats'
109
+ holds[holds.length - 1] = true
110
+ groupHolding = false
111
+ continue
112
+ }
113
+ // A bounded `{n}` or `{n,m}` repeats a group a fixed number of times, and keeps what it
114
+ // repeats as the atom a following quantifier would apply to.
115
+ if (char === '{') {
116
+ const end = body.indexOf('}', at)
117
+ if (end !== -1) at = end
118
+ continue
119
+ }
120
+ if (char === '?') continue
121
+ groupHolding = false
122
+ }
123
+ return undefined
124
+ }
@@ -763,7 +763,7 @@ function refuseExport(source: string): void {
763
763
  if (moved !== 0) depth += moved
764
764
  else if (depth === 0 && text === EXPORT && previous !== '.')
765
765
  throw new Error(
766
- 'abide: a .abide cannot export — its component is its one export. A handler another file calls is a handler file (a .ts beside the page, `export default GET(…)`); a value files share is a module under a lib directory. RULEBOOK 34.17.',
766
+ 'abide: a .abide cannot export — its component is its one export. A handler another file calls is a handler file (a <name>.handler.ts beside the page, `export default GET(…)`); a value files share is a module under a lib directory. RULEBOOK 34.17.',
767
767
  )
768
768
  previous = text
769
769
  }
@@ -28,6 +28,7 @@ import {
28
28
  import {
29
29
  type Symbol as CheckedSymbol,
30
30
  type Checker,
31
+ SignatureKind,
31
32
  SymbolFlags,
32
33
  type Type,
33
34
  TypeFlags,
@@ -129,7 +130,9 @@ export async function readsIn(
129
130
  shadowPath: string,
130
131
  spans: ShadowSpans,
131
132
  file: AbideFile,
132
- ): Promise<{ answer: CheckerAnswer; warnings: Finding[] }> {
133
+ /** Where each name the lowered program proved a `Reactive` starts (`bindingsOfReads`). */
134
+ held: ReadonlySet<number>,
135
+ ): Promise<{ answer: CheckerAnswer; warnings: Finding[]; suspects: number[] }> {
133
136
  // A destructuring pattern binds names this does not see, and that is 31.12's case.
134
137
  const names: Node[] = []
135
138
  const spelled = new Set<string>()
@@ -210,6 +213,12 @@ export async function readsIn(
210
213
  ),
211
214
  )
212
215
  await dropValueHolders(checker, names, reactive)
216
+ for (let at = 0; at < names.length; at += 1)
217
+ if (
218
+ reactive[at] === undefined &&
219
+ held.has((names[at] as Node).getStart())
220
+ )
221
+ reactive[at] = symbols[at]
213
222
  // 32.32 — A VALUE WHOSE `reactive(…)` DECLARES `by` keys a `{#for}` over it,
214
223
  // wherever the value is declared.
215
224
  const byValue = await Promise.all(
@@ -296,7 +305,86 @@ export async function readsIn(
296
305
  spans.scripts,
297
306
  ))
298
307
  warnings.push(warning)
299
- return { answer: new CheckerAnswer(reads, statics), warnings }
308
+ const answer = new CheckerAnswer(reads, statics)
309
+ return {
310
+ answer,
311
+ warnings,
312
+ suspects: await bindingsOfReads(
313
+ checker,
314
+ abide,
315
+ names,
316
+ reactive,
317
+ answer,
318
+ ),
319
+ }
320
+ }
321
+
322
+ /**
323
+ * Where each binding starts whose type the projection cannot vouch for, for the lowered program to
324
+ * answer (`TypeSession.classify`).
325
+ *
326
+ * 31.1 — A BINDING TYPED FROM A READ IS TYPED FROM THE `Reactive` IN THE PROJECTION, and the read is
327
+ * of its value. `reactive(route.url.pathname)` asks a `Reactive<URL>` for a `pathname` it does not
328
+ * declare; the argument is the error type, which `reactive`'s overloads resolve to a `Keyed`, and
329
+ * the binding then lowered as no `Reactive` at all — `bookId = 'y'` rebinding the name and nothing
330
+ * waking. Lowered, the argument is `route.url().pathname` and the binding a `Reactive<string>`.
331
+ *
332
+ * ONLY A CALL THAT CAN HAND BACK A `Reactive` IS ASKED: an un-annotated binding, not one already,
333
+ * initialized by a call holding a read, to a callee one of whose signatures returns a `Reactive`.
334
+ * Anything else is a value however its reads are typed, and asking would cost every page that
335
+ * computes from one a second snapshot.
336
+ */
337
+ async function bindingsOfReads(
338
+ checker: Checker,
339
+ abide: Abide,
340
+ names: readonly Node[],
341
+ reactive: readonly (CheckedSymbol | undefined)[],
342
+ answer: CheckerAnswer,
343
+ ): Promise<number[]> {
344
+ const suspects: number[] = []
345
+ const asked: Promise<void>[] = []
346
+ for (let at = 0; at < names.length; at += 1) {
347
+ const name = names[at] as Node
348
+ const declaration = name.parent
349
+ if (
350
+ reactive[at] !== undefined ||
351
+ !isVariableDeclaration(declaration) ||
352
+ declaration.type !== undefined ||
353
+ declaration.initializer === undefined
354
+ )
355
+ continue
356
+ let call: Node = declaration.initializer
357
+ while (TRANSPARENT.has(call.kind))
358
+ call = (call as unknown as { expression: Node }).expression
359
+ if (
360
+ !isCallExpression(call) ||
361
+ !answer.holdsRead({ start: call.getStart(), end: call.end })
362
+ )
363
+ continue
364
+ const callee = call.expression
365
+ asked.push(
366
+ (async () => {
367
+ const type = await checker.getTypeAtLocation(callee)
368
+ if (type === undefined) return
369
+ for (const signature of await checker.getSignaturesOfType(
370
+ type,
371
+ SignatureKind.Call,
372
+ )) {
373
+ const returned =
374
+ await checker.getReturnTypeOfSignature(signature)
375
+ if (
376
+ returned !== undefined &&
377
+ (await isReactiveType(checker, abide, returned, false))
378
+ ) {
379
+ suspects.push(name.getStart())
380
+ return
381
+ }
382
+ }
383
+ })(),
384
+ )
385
+ }
386
+ await Promise.all(asked)
387
+ return suspects
300
388
  }
301
389
 
302
390
  /**
@@ -129,8 +129,8 @@ export async function serveLanguage(
129
129
  documents.get(Bun.pathToFileURL(spelled(path)).href)?.text
130
130
 
131
131
  // An app's checker, opened on first use and kept for the session — and TOLD WHAT MOVED ON DISK,
132
- // or it answers every check from the text it read when it opened: an export added to a `.ts`
133
- // beside the page is "no exported member" until the editor restarts this. A project whose
132
+ // or it answers every check from the text it read when it opened: an export added to a `lib`
133
+ // module the page imports is "no exported member" until the editor restarts this. A project whose
134
134
  // tsconfig or package.json moved is opened again.
135
135
  const checkerOf = async (app: string): Promise<AppChecker> => {
136
136
  let checker = checkers.get(app)
@@ -100,6 +100,8 @@ type ServerRoute = {
100
100
  // 38.28 — the modules a browser loads at this address, the page's then its layouts', as keys
101
101
  // into the manifest `manifestOf` reads.
102
102
  clientModules: string[]
103
+ // 23.26 — each matcher its pattern names, to the anchored source the build resolved it to.
104
+ matchers: Record<string, string>
103
105
  }
104
106
 
105
107
  // 35.2 — an error page, and the directory it answers for.
@@ -122,6 +124,7 @@ type Ranked = RankedError & {
122
124
  */
123
125
  type RouteRow = {
124
126
  pattern: string
127
+ matchers: Record<string, string>
125
128
  segments: Segment[]
126
129
  page: Ranked | undefined
127
130
  handlers: Map<Method, Mounted>
@@ -178,6 +181,16 @@ export type AppSources = {
178
181
  // 41.29 — the seed sink of a render no page hydrates: every call is left to run when it is made.
179
182
  const SEEDS_NOTHING: PageRender['seed'] = () => false
180
183
 
184
+ // 35.19 — what fetch calls a navigation request's destinations, and a script's `fetch`.
185
+ const PAGE_DESTINATIONS = new Set([
186
+ 'document',
187
+ 'embed',
188
+ 'frame',
189
+ 'iframe',
190
+ 'object',
191
+ 'empty',
192
+ ])
193
+
181
194
  /** The `Response` to one request: a handler, a surface of abide's own, a file, or a page. */
182
195
  export async function answerRequest(
183
196
  app: AppSources,
@@ -331,6 +344,17 @@ async function answerMatched(
331
344
  headers: { 'cache-control': 'no-store' },
332
345
  })
333
346
 
347
+ // 35.19 — A REQUEST THAT CANNOT DISPLAY A PAGE, such as the `/favicon.ico` a browser asks every
348
+ // site for where a `[param]` page matches it: refused before setup, so no handler runs for it
349
+ // and no caller is minted. Bare, because an error page would set up too and nothing shows it;
350
+ // `no-store`, because the address answers a navigation with the page.
351
+ const destination = request.headers.get('sec-fetch-dest')
352
+ if (destination !== null && !PAGE_DESTINATIONS.has(destination))
353
+ return new Response(null, {
354
+ status: 404,
355
+ headers: { 'cache-control': 'no-store' },
356
+ })
357
+
334
358
  // 33.4 — a prop is an ACCESSOR, which is the contract `renderComponent` renders a child under
335
359
  // and the one the client router mounts a page under; the params are the record `firstMatch`
336
360
  // hands the client for this same address.
@@ -534,16 +558,22 @@ export async function appSources(parts: SourceParts): Promise<AppSources> {
534
558
  pattern: route.pattern,
535
559
  load: route.load,
536
560
  layouts: route.layouts,
561
+ matchers: route.matchers,
537
562
  document: documentOf(route.clientModules),
538
563
  })
539
564
  // 19.1 — the build refused two files at one route and method, so a route is one row however
540
- // many methods answer at it.
565
+ // many methods answer at it — and 23.27 one matcher per position, so whichever file opens the
566
+ // row brings what every file at it would.
541
567
  const rows = new Map<string, Omit<RouteRow, 'segments'>>()
542
- const rowAt = (pattern: string): Omit<RouteRow, 'segments'> => {
568
+ const rowAt = (
569
+ pattern: string,
570
+ matchers: Record<string, string>,
571
+ ): Omit<RouteRow, 'segments'> => {
543
572
  let row = rows.get(pattern)
544
573
  if (row === undefined) {
545
574
  row = {
546
575
  pattern,
576
+ matchers,
547
577
  page: undefined,
548
578
  handlers: new Map(),
549
579
  answers: new Map(),
@@ -552,12 +582,19 @@ export async function appSources(parts: SourceParts): Promise<AppSources> {
552
582
  }
553
583
  return row
554
584
  }
555
- for (const page of rankRoutes(pages)) rowAt(page.pattern).page = page
585
+ for (const page of rankRoutes(pages))
586
+ rowAt(page.pattern, page.matchers).page = page
556
587
  for (const handler of mounted.handlers.values())
557
- rowAt(handler.address).handlers.set(handler.method, handler)
588
+ rowAt(handler.address, handler.matchers).handlers.set(
589
+ handler.method,
590
+ handler,
591
+ )
558
592
  const errors: RankedError[] = []
559
593
  for (const error of parts.table.errors)
560
- errors.push({ ...error, segments: parseRoutePattern(error.pattern) })
594
+ errors.push({
595
+ ...error,
596
+ segments: parseRoutePattern(error.pattern, error.matchers),
597
+ })
561
598
  errors.sort((a, b) => b.segments.length - a.segments.length)
562
599
  const sources: AppSources = {
563
600
  routes: rankRoutes([...rows.values()]),
@@ -72,6 +72,8 @@ export type Mount = {
72
72
  export: string
73
73
  /** The route its hooks are filed at, which its rungs are read over (37.1, 16.72). */
74
74
  scope: string
75
+ /** 23.26 — each matcher its address names, to the anchored source its row is ranked with. */
76
+ matchers: Record<string, string>
75
77
  /** Whether it is a private handler, listed nowhere (16.71). */
76
78
  private: boolean
77
79
  /** Whether a browser is handed a wrapper for it. 20.4. */
@@ -154,6 +156,7 @@ export function mountedOwn(
154
156
  name,
155
157
  export: name,
156
158
  scope: '/',
159
+ matchers: {},
157
160
  private: false,
158
161
  ui: false,
159
162
  cli: false,
@@ -1,6 +1,6 @@
1
1
  // 23.14 — WHERE AN APP'S ADDRESSES ARE, read off the tree in one walk. A file directly in a directory
2
2
  // below the root is an address — a page, a layout, an error page or a handler file — and nothing
3
- // else is there but its tests (23.22); a fence is never one (23.15): `public` holds files answered as
3
+ // else is there but its tests and the param files its segments name (23.22, 23.24); a fence is never one (23.15): `public` holds files answered as
4
4
  // authored at the path of the directories above it, and `lib` holds the rest, a server file among it
5
5
  // (23.23). So listing a directory lists its routes.
6
6
  //
@@ -22,6 +22,8 @@ import {
22
22
  import {
23
23
  DIRECTORY_FILES,
24
24
  isDeclarationFile,
25
+ isHandlerFile,
26
+ isParamFile,
25
27
  isServerFile,
26
28
  isBenchFile,
27
29
  isTestFile,
@@ -36,6 +38,8 @@ type AppFiles = {
36
38
  abide: string[]
37
39
  /** Every handler file, in path order. */
38
40
  handlers: string[]
41
+ /** 23.24 — every param file directly in an address directory, in path order. */
42
+ params: string[]
39
43
  /** Every module under a `lib` directory, in path order — read for 37.13. */
40
44
  modules: string[]
41
45
  /** Every file under a `public` directory, to the address it is answered at below the mount. */
@@ -52,6 +56,7 @@ export function appFiles(root: string, refusals: Refusals): AppFiles {
52
56
  const files: AppFiles = {
53
57
  abide: [],
54
58
  handlers: [],
59
+ params: [],
55
60
  modules: [],
56
61
  published: new Map(),
57
62
  }
@@ -61,6 +66,7 @@ export function appFiles(root: string, refusals: Refusals): AppFiles {
61
66
  walk(root, '', undefined, files, refusals, packaged)
62
67
  files.abide.sort()
63
68
  files.handlers.sort()
69
+ files.params.sort()
64
70
  files.modules.sort()
65
71
  return files
66
72
  }
@@ -126,13 +132,17 @@ function walk(
126
132
  isDeclarationFile(path)
127
133
  )
128
134
  continue
129
- if (!abide && !isModule(path)) {
135
+ if (isParamFile(path)) {
136
+ files.params.push(path)
137
+ continue
138
+ }
139
+ if (!abide && !isHandlerFile(path)) {
130
140
  if (packaged && relative === '' && !path.endsWith('.ts'))
131
141
  continue
132
142
  refusals.at(
133
143
  path,
134
144
  new Error(
135
- `abide: ${path} is directly in an address directory, which holds pages, layouts, error pages, handler files, test files, bench files and declaration files alone. Move it into a lib directory, beside it or in any directory above it. RULEBOOK 23.22.`,
145
+ `abide: ${path} is directly in an address directory, which holds pages, layouts, error pages, handler files, param files, test files, bench files and declaration files alone. ${path.endsWith('.ts') ? 'Name a handler file <name>.handler.ts, or move anything else' : 'Move it'} into a lib directory, beside it or in any directory above it. RULEBOOK 23.22.`,
136
146
  ),
137
147
  )
138
148
  continue
@@ -211,8 +221,7 @@ export function holdsApp(root: string): boolean {
211
221
  return !appSourcesUnder(root, '').next().done
212
222
  }
213
223
 
214
- // Every `.abide` under `directory`, and every handler file — a module whose place is an address, and
215
- // no server file (23.23).
224
+ // Every `.abide` under `directory`, and every handler file whose place is an address.
216
225
  function* appSourcesUnder(
217
226
  directory: string,
218
227
  relative: string,
@@ -230,9 +239,7 @@ function* appSourcesUnder(
230
239
  yield* appSourcesUnder(`${directory}/${entry.name}`, `${path}/`)
231
240
  else if (
232
241
  path.endsWith('.abide') ||
233
- (isModule(path) &&
234
- !isServerFile(path) &&
235
- placeOf(path) === 'address')
242
+ (isHandlerFile(path) && placeOf(path) === 'address')
236
243
  )
237
244
  yield path
238
245
  }