@abide/abide 0.117.2 → 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.
@@ -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
  }
@@ -18,7 +18,7 @@ import { essenceOf } from '#shared/framing.ts'
18
18
  import { namedLogger } from '#shared/log.ts'
19
19
  import { mounted } from '#shared/mount.ts'
20
20
  import { type Reactive, reactive } from '#shared/reactive/index.ts'
21
- import { parseRoutePattern } from '#shared/routePattern.ts'
21
+ import { parseRoutePattern, type Segment } from '#shared/routePattern.ts'
22
22
  import { argsInURL, rpcRequest } from '#shared/rpcRequest.ts'
23
23
  import { answered } from './answerFailure.ts'
24
24
  import { bodyWithin, type Mounted } from './answerRpc.ts'
@@ -134,13 +134,19 @@ function openApiOf(handlers: Map<string, Mounted>): {
134
134
  for (const segment of parseRoutePattern(each.address)) {
135
135
  if (segment.kind === 'static') continue
136
136
  inPath.add(segment.name)
137
+ const declared = schema?.properties?.[segment.name] ?? {
138
+ type: 'string',
139
+ }
140
+ // 20.23 — and what the segment takes, save a rest's: its value is the parts joined,
141
+ // which one part's pattern does not describe.
137
142
  const parameter: Record<string, unknown> = {
138
143
  name: segment.name,
139
144
  in: 'path',
140
145
  required: true,
141
- schema: schema?.properties?.[segment.name] ?? {
142
- type: 'string',
143
- },
146
+ schema:
147
+ segment.kind === 'rest'
148
+ ? declared
149
+ : patterned(declared, patternOf(segment, each.matchers)),
144
150
  }
145
151
  if (segment.kind === 'optional')
146
152
  parameter.description = 'May be left out, with its segment.'
@@ -172,8 +178,11 @@ function openApiOf(handlers: Map<string, Mounted>): {
172
178
  },
173
179
  },
174
180
  }
175
- // OpenAPI spells a parameter `{id}` where a route spells it `[id]`.
176
- const path = each.address.replace(/\[+(?:\.\.\.)?([^\]]+)\]+/g, '{$1}')
181
+ // OpenAPI spells a parameter `{id}` where a route spells it `[id]`, and names no matcher.
182
+ const path = each.address.replace(
183
+ /\[+(?:\.\.\.)?([^\]=]+)(?:=[^\]]*)?\]+/g,
184
+ '{$1}',
185
+ )
177
186
  paths[path] ??= {}
178
187
  ;(paths[path] as Record<string, unknown>)[each.method.toLowerCase()] =
179
188
  operation
@@ -291,7 +300,9 @@ export class McpSurface {
291
300
  listing.push({
292
301
  name: tool.name,
293
302
  description: tool.description ?? '',
294
- inputSchema: toolInput(tool.schema),
303
+ inputSchema: toolInput(
304
+ withPathPatterns(tool.schema, tool.address, tool.matchers),
305
+ ),
295
306
  // 20.14 — what the method promises: a `GET` is safe, and a `GET`, a `PUT` and a
296
307
  // `DELETE` idempotent. Any other write may destroy, and nothing says it does not.
297
308
  annotations: {
@@ -454,6 +465,63 @@ function logCall(
454
465
  )
455
466
  }
456
467
 
468
+ /** 20.23 — the anchored pattern a segment takes a part by: its matcher's, or 30.34's default. */
469
+ function patternOf(segment: Segment, matchers: Record<string, string>): string {
470
+ return segment.matcher === undefined
471
+ ? PERIODLESS_PATTERN
472
+ : (matchers[segment.matcher] ?? PERIODLESS_PATTERN)
473
+ }
474
+
475
+ const PERIODLESS_PATTERN = '^[^.]+$'
476
+
477
+ /** `declared` constrained to `pattern` too: beside a pattern it declares itself, not over it. */
478
+ function patterned(declared: unknown, pattern: string): unknown {
479
+ if (declared === null || typeof declared !== 'object') return { pattern }
480
+ return 'pattern' in declared
481
+ ? { allOf: [declared, { pattern }] }
482
+ : { ...declared, pattern }
483
+ }
484
+
485
+ /**
486
+ * 20.23 — `schema` with each route parameter it declares constrained to what its segment takes, a
487
+ * rest's on each of its items; `schema` itself where the route has no parameter it declares.
488
+ */
489
+ function withPathPatterns(
490
+ schema: unknown,
491
+ address: string,
492
+ matchers: Record<string, string>,
493
+ ): unknown {
494
+ if (schema === null || typeof schema !== 'object') return schema
495
+ const properties = (schema as { properties?: Record<string, unknown> })
496
+ .properties
497
+ if (properties === undefined) return schema
498
+ const constrained: Record<string, unknown> = { ...properties }
499
+ let changed = false
500
+ for (const segment of parseRoutePattern(address)) {
501
+ if (segment.kind === 'static') continue
502
+ const declared = properties[segment.name]
503
+ if (declared === undefined) continue
504
+ const pattern = patternOf(segment, matchers)
505
+ constrained[segment.name] =
506
+ segment.kind === 'rest' &&
507
+ declared !== null &&
508
+ typeof declared === 'object' &&
509
+ 'items' in declared
510
+ ? {
511
+ ...declared,
512
+ items: patterned(
513
+ (declared as { items: unknown }).items,
514
+ pattern,
515
+ ),
516
+ }
517
+ : segment.kind === 'rest'
518
+ ? declared
519
+ : patterned(declared, pattern)
520
+ changed = true
521
+ }
522
+ return changed ? { ...schema, properties: constrained } : schema
523
+ }
524
+
457
525
  /**
458
526
  * `schema` as a tool's `inputSchema`, its top-level `type` `object` whatever it declared there, or
459
527
  * `undefined` where no value it admits is an object. 20.19, 20.20.
@@ -104,6 +104,16 @@ export function isServerFile(path: string): boolean {
104
104
 
105
105
  const SERVER_FILE = /[^/]\.server\.[^./]+$/
106
106
 
107
+ /** 16.2 — whether `path` names a handler file, `<name>.handler.ts`, whose default export answers at its route. */
108
+ export function isHandlerFile(path: string): boolean {
109
+ return path.endsWith('.handler.ts')
110
+ }
111
+
112
+ /** 23.24 — whether `path` names a param file, `<NAME>.param.ts`, declaring the matcher `NAME`. */
113
+ export function isParamFile(path: string): boolean {
114
+ return path.endsWith('.param.ts')
115
+ }
116
+
107
117
  /** Whether `path` names a test file, which is never an address nor a module the app runs. */
108
118
  export function isTestFile(path: string): boolean {
109
119
  return path.endsWith('.test.ts')
@@ -127,7 +137,7 @@ export function skipped(name: string): boolean {
127
137
  /**
128
138
  * The route a file answers at — its path below the root without its extension.
129
139
  *
130
- * 23.14 — `media/[id].abide` and `media/getGrid.ts` are `/media/[id]` and `/media/getGrid`; an
140
+ * 23.14 — `media/[id].abide` and `media/getGrid.handler.ts` are `/media/[id]` and `/media/getGrid`; an
131
141
  * `index.abide`, a `layout.abide` and an `error.abide` are their directory's.
132
142
  */
133
143
  export function routeOf(path: string): string {
@@ -138,7 +148,7 @@ export function routeOf(path: string): string {
138
148
  return `/${parts.filter((part) => part !== '').join('/')}`
139
149
  }
140
150
 
141
- const EXTENSION = /\.(?:abide|ts)$/
151
+ const EXTENSION = /\.(?:abide|handler\.ts)$/
142
152
 
143
153
  /** 23.14, 23.18 — the names whose route is their directory's, and so a route's wherever they stand. */
144
154
  export const DIRECTORY_FILES = new Set([
@@ -115,11 +115,15 @@ function handlerAt(
115
115
  address: string,
116
116
  method: string | undefined,
117
117
  ): Mount | undefined {
118
- const rows: { pattern: string; mount: Mount }[] = []
118
+ const rows: {
119
+ pattern: string
120
+ matchers: Record<string, string>
121
+ mount: Mount
122
+ }[] = []
119
123
  for (const mount of handlers) {
120
124
  if (method !== undefined && mount.method !== method) continue
121
125
  if (mount.address === address) return mount
122
- rows.push({ pattern: mount.address, mount })
126
+ rows.push({ pattern: mount.address, matchers: mount.matchers, mount })
123
127
  }
124
128
  return firstMatch(rankRoutes(rows), partsOf(address) ?? [])?.route.mount
125
129
  }
@@ -17,6 +17,8 @@ export type Listed = {
17
17
  schema: unknown
18
18
  /** What it answers, as JSON Schema, where its type says. 19.21. */
19
19
  result: unknown
20
+ /** 23.26 — each matcher its address names, to its anchored source. */
21
+ matchers: Record<string, string>
20
22
  }
21
23
 
22
24
  /**
@@ -50,6 +52,7 @@ export function surfaceOf(
50
52
  live === undefined ? mount.description : live.handler.description,
51
53
  schema: live === undefined ? mount.args : live.argsDocument,
52
54
  result: mount.result,
55
+ matchers: mount.matchers,
53
56
  }
54
57
  }
55
58
 
@@ -60,7 +63,8 @@ export function surfaceOf(
60
63
  * and dots dropped, so `/media/[id]` reads `media_id` rather than `media__id_`.
61
64
  */
62
65
  export function surfaceName(method: Method, route: string): string {
63
- return `${method.toLowerCase()}${route === '/' ? '' : route.replace(/[[\].]/g, '')}`.replace(
66
+ // 19.1 — a matcher is no part of what a route is, so none of its name: `=NAME` goes first.
67
+ return `${method.toLowerCase()}${route === '/' ? '' : route.replace(/=[^\]]*/g, '').replace(/[[\].]/g, '')}`.replace(
64
68
  /[^a-zA-Z0-9-]/g,
65
69
  '_',
66
70
  )
@@ -42,32 +42,38 @@ export function filledSegments(
42
42
  }
43
43
  if (segment.kind === 'rest' && Array.isArray(value)) {
44
44
  for (const each of value)
45
- address += `/${encodedSegment(spelled, segment.name, each, refused)}`
45
+ address += `/${encodedSegment(spelled, segment, each, refused)}`
46
46
  continue
47
47
  }
48
- address += `/${encodedSegment(spelled, segment.name, value, refused)}`
48
+ address += `/${encodedSegment(spelled, segment, value, refused)}`
49
49
  }
50
50
  return address === '' ? '/' : address
51
51
  }
52
52
 
53
53
  /**
54
- * A param value encoded as one path segment; `.` and `..` are refused.
54
+ * A param value encoded as one path segment, refused where no address can carry it.
55
55
  *
56
- * 30.19 — A VALUE OF `.` OR `..` HAS NO SPELLING AS A SEGMENT: the URL parser collapses it as a
57
- * dot-segment, and `%2E` / `%2E%2E` with it, so the address would land on another route. Refused
58
- * rather than written, as a missing required segment is (30.3).
56
+ * 30.19 — A VALUE NO SEGMENT TAKES HAS NO SPELLING: one carrying a period, where the segment names no
57
+ * matcher to lift the default (30.34), and `.` or `..` whatever it names (30.36), which the URL
58
+ * parser collapses as a dot-segment, `%2E` / `%2E%2E` with it. Refused rather than written, as a
59
+ * missing required segment is (30.3). A named matcher is not run here: the literal says a matcher
60
+ * is named, and only a route table holds the regex (23.26).
59
61
  */
60
62
  function encodedSegment(
61
63
  spelled: string,
62
- name: string,
64
+ segment: Segment,
63
65
  value: unknown,
64
66
  refused: (name: string, message: string) => unknown,
65
67
  ): string {
66
68
  const text = String(value)
67
- if (text === '.' || text === '..')
69
+ const carried =
70
+ segment.matcher === undefined
71
+ ? !text.includes('.')
72
+ : text !== '.' && text !== '..'
73
+ if (!carried)
68
74
  throw refused(
69
- name,
70
- `abide: ${spelled} was given '${text}' for '${name}', and no address carries it as a segment — the URL parser resolves it to another route. RULEBOOK 30.19.`,
75
+ segment.name,
76
+ `abide: ${spelled} was given '${text}' for '${segment.name}', and no address carries ${segment.matcher === undefined ? 'a period in a param naming no matcher' : 'a dot-segment'}. RULEBOOK 30.19.`,
71
77
  )
72
78
  return encodeURIComponent(text)
73
79
  }
@@ -56,9 +56,16 @@ function walk(
56
56
  return prefix || at === parts.length ? {} : null
57
57
  const segment = segments[index] as Segment
58
58
  // 30.5 — A REST TAKES THE MOST THE SEGMENTS AFTER IT LEAVE, nothing included, so a handler's
59
- // name after one (`/docs/[...path]/save`) is reached rather than spent on the rest.
59
+ // name after one (`/docs/[...path]/save`) is reached rather than spent on the rest — and 30.35,
60
+ // never past a part it does not accept.
60
61
  if (segment.kind === 'rest') {
61
- for (let end = parts.length; end >= at; end -= 1) {
62
+ let furthest = at
63
+ while (
64
+ furthest < parts.length &&
65
+ segment.accepts.test(parts[furthest] as string)
66
+ )
67
+ furthest += 1
68
+ for (let end = furthest; end >= at; end -= 1) {
62
69
  const params = walk(segments, index + 1, parts, end, prefix)
63
70
  if (params !== null) {
64
71
  params[segment.name] = parts.slice(at, end)
@@ -68,8 +75,12 @@ function walk(
68
75
  return null
69
76
  }
70
77
  const part = parts[at]
78
+ // 30.34, 30.35 — A PARAM TAKES A PART ITS SEGMENT ACCEPTS: the matcher it names, or, naming
79
+ // none, no part carrying a period, which is how a file is named — the `/favicon.ico` a browser
80
+ // asks every site for is no `[param]` page's to answer.
81
+ const takeable = part !== undefined && segment.accepts.test(part)
71
82
  if (segment.kind === 'optional') {
72
- if (part !== undefined) {
83
+ if (takeable) {
73
84
  const taken = walk(segments, index + 1, parts, at + 1, prefix)
74
85
  if (taken !== null) {
75
86
  taken[segment.name] = part
@@ -79,7 +90,8 @@ function walk(
79
90
  return walk(segments, index + 1, parts, at, prefix)
80
91
  }
81
92
  if (part === undefined) return null
82
- if (segment.kind === 'static' && part !== segment.name) return null
93
+ if (segment.kind === 'static' ? part !== segment.name : !takeable)
94
+ return null
83
95
  const params = walk(segments, index + 1, parts, at + 1, prefix)
84
96
  if (params !== null && segment.kind === 'required')
85
97
  params[segment.name] = part
@@ -17,29 +17,78 @@ export type Segment = {
17
17
  kind: SegmentKind
18
18
  /** The literal text for a static segment, the param name for the other three. */
19
19
  name: string
20
+ /** 23.26 — the matcher a dynamic segment names after `=`, or `undefined` where it names none. */
21
+ matcher: string | undefined
22
+ /** 30.34, 30.35 — what a part must match to be taken: the matcher's, or the period default. */
23
+ accepts: RegExp
20
24
  }
21
25
 
26
+ /** 30.34, 30.36 — what a segment naming no matcher takes: a part carrying no period. */
27
+ const PERIODLESS = /^[^.]+$/
28
+
29
+ // A segment naming a matcher, parsed with no table to resolve it in, takes nothing: only a route
30
+ // table's own rows say which regex a name stands for (23.26), and a reader that only builds an
31
+ // address or compares shapes never walks one.
32
+ const UNRESOLVED = /(?!)/
33
+
22
34
  /**
23
35
  * A route literal split into its segments, each classified by kind.
24
36
  *
25
- * `[...rest]`, `[[optional]]`, `[required]`, or a literal. Ordered longest-first: `[[a]]` is a
26
- * `[a]` with another bracket round it, so testing the short form first matches both.
37
+ * `[...rest]`, `[[optional]]`, `[required]`, or a literal, each dynamic one optionally naming a
38
+ * matcher after `=`, compiled from `matchers` — each name's anchored source, as the build wrote it
39
+ * into the row (23.26) — where a route table hands its row's. Ordered
40
+ * longest-first: `[[a]]` is a `[a]` with another bracket round it, so testing the short form first
41
+ * matches both.
27
42
  */
28
- export function parseRoutePattern(pattern: string): Segment[] {
43
+ export function parseRoutePattern(
44
+ pattern: string,
45
+ matchers?: Record<string, string>,
46
+ ): Segment[] {
29
47
  const out: Segment[] = []
30
48
  for (const raw of pattern.split('/')) {
31
49
  if (raw === '') continue
32
- if (raw.startsWith('[[') && raw.endsWith(']]'))
33
- out.push({ kind: 'optional', name: raw.slice(2, -2) })
34
- else if (raw.startsWith('[...') && raw.endsWith(']'))
35
- out.push({ kind: 'rest', name: raw.slice(4, -1) })
36
- else if (raw.startsWith('[') && raw.endsWith(']'))
37
- out.push({ kind: 'required', name: raw.slice(1, -1) })
38
- else out.push({ kind: 'static', name: raw })
50
+ let kind: SegmentKind = 'static'
51
+ let body = raw
52
+ if (raw.startsWith('[[') && raw.endsWith(']]')) {
53
+ kind = 'optional'
54
+ body = raw.slice(2, -2)
55
+ } else if (raw.startsWith('[...') && raw.endsWith(']')) {
56
+ kind = 'rest'
57
+ body = raw.slice(4, -1)
58
+ } else if (raw.startsWith('[') && raw.endsWith(']')) {
59
+ kind = 'required'
60
+ body = raw.slice(1, -1)
61
+ }
62
+ const split = kind === 'static' ? -1 : body.indexOf('=')
63
+ const matcher = split === -1 ? undefined : body.slice(split + 1)
64
+ out.push({
65
+ kind,
66
+ name: split === -1 ? body : body.slice(0, split),
67
+ matcher,
68
+ accepts:
69
+ matcher === undefined
70
+ ? PERIODLESS
71
+ : matchers === undefined
72
+ ? UNRESOLVED
73
+ : resolvedIn(matchers, matcher, pattern),
74
+ })
39
75
  }
40
76
  return out
41
77
  }
42
78
 
79
+ function resolvedIn(
80
+ matchers: Record<string, string>,
81
+ matcher: string,
82
+ pattern: string,
83
+ ): RegExp {
84
+ const source = matchers[matcher]
85
+ if (source === undefined)
86
+ throw new Error(
87
+ `abide: the route table's row for ${pattern} carries no matcher ${matcher}, which the build writes beside every segment naming one. RULEBOOK 23.26.`,
88
+ )
89
+ return new RegExp(source)
90
+ }
91
+
43
92
  /**
44
93
  * A `sort` comparator over two parsed patterns: negative where `a` is the more specific.
45
94
  *
@@ -99,12 +148,15 @@ export function coversRoute(outer: Segment[], route: Segment[]): boolean {
99
148
  * which page an address resolves to, so it is here with `compareRoutes` rather than written out
100
149
  * beside each table: the server's boot and the client router's start compile through one call.
101
150
  */
102
- export function rankRoutes<T extends { pattern: string }>(
103
- rows: T[],
104
- ): (T & { segments: Segment[] })[] {
151
+ export function rankRoutes<
152
+ T extends { pattern: string; matchers?: Record<string, string> },
153
+ >(rows: T[]): (T & { segments: Segment[] })[] {
105
154
  const ranked: (T & { segments: Segment[] })[] = []
106
155
  for (const row of rows)
107
- ranked.push({ ...row, segments: parseRoutePattern(row.pattern) })
156
+ ranked.push({
157
+ ...row,
158
+ segments: parseRoutePattern(row.pattern, row.matchers ?? {}),
159
+ })
108
160
  ranked.sort((a, b) => compareRoutes(a.segments, b.segments))
109
161
  return ranked
110
162
  }