@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.
@@ -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
@@ -2219,6 +2219,10 @@ function mirror(cell: Cell, inner: Cell): void {
2219
2219
  // 7.13 through the adoption: the re-settle a rerun of the adopter makes reaches a reload the
2220
2220
  // adopted value owes, rather than finding it unservable and giving up.
2221
2221
  if (inner.owes !== OWES_NOTHING) settleOwed(inner)
2222
+ // 13.7 THROUGH THE ADOPTION TOO: a sweep's mark on a value nothing read is `DIRTY`, and an
2223
+ // adoption is that value's next read. A mirror reached through a rerun finds it resolved
2224
+ // already; the first one `adopt` runs does not, and copied the held value over the mark.
2225
+ if (inner.state !== CLEAN) resolve(inner)
2222
2226
  const adopted = inner.stream
2223
2227
  if (adopted !== null) {
2224
2228
  forwardStream(cell, inner, adopted)
@@ -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
  }
package/src/shared/url.ts CHANGED
@@ -12,26 +12,31 @@ type Parts<P extends string> = P extends `${infer Head}/${infer Tail}`
12
12
  ? Head | Parts<Tail>
13
13
  : P
14
14
 
15
+ // 23.26 — a segment's param is named before any `=`: `[symbol=TICKER]` is `symbol`.
16
+ type ParamName<Body extends string> = Body extends `${infer Name}=${string}`
17
+ ? Name
18
+ : Body
19
+
15
20
  export type RequiredNames<P extends string> =
16
21
  Parts<P> extends infer Part
17
22
  ? Part extends `[${infer Name}]`
18
23
  ? Name extends `[${string}` | `...${string}`
19
24
  ? never
20
- : Name
25
+ : ParamName<Name>
21
26
  : never
22
27
  : never
23
28
 
24
29
  export type OptionalNames<P extends string> =
25
30
  Parts<P> extends infer Part
26
31
  ? Part extends `[[${infer Name}]]`
27
- ? Name
32
+ ? ParamName<Name>
28
33
  : never
29
34
  : never
30
35
 
31
36
  export type RestNames<P extends string> =
32
37
  Parts<P> extends infer Part
33
38
  ? Part extends `[...${infer Name}]`
34
- ? Name
39
+ ? ParamName<Name>
35
40
  : never
36
41
  : never
37
42
 
@@ -51,18 +56,28 @@ export type HasSegments<P extends string> = [
51
56
 
52
57
  export type Query = Record<string, unknown> | URLSearchParams
53
58
 
54
- /** The params a route literal takes, by segment kind. */
59
+ /** The params a route literal takes, by segment kind, and 30.3 — narrowed where `abide check` says. */
55
60
  export type ParamsOf<P extends string> = {
56
61
  [K in RequiredNames<P>]: string | number
57
62
  } & { [K in OptionalNames<P>]?: string | number } & {
58
63
  [K in RestNames<P>]?: string | number | (string | number)[]
59
- }
64
+ } & Narrowed<P>
65
+
66
+ /**
67
+ * 23.9, 30.3 — the params `abide check` declared for `P` beside its key: each whose matcher is an
68
+ * alternation of literals, as their union. Nothing where it declared none, or nothing at all.
69
+ */
70
+ type Narrowed<P extends string> = P extends keyof Routes
71
+ ? Routes[P] extends true
72
+ ? unknown
73
+ : Routes[P]
74
+ : unknown
60
75
 
61
76
  /**
62
77
  * 30.26 — the route literals an app files its pages under, which `abide check` declares for the app
63
78
  * it checks. Where nothing declared them any literal is taken, so a plain `tsc` still compiles.
64
79
  */
65
- // biome-ignore lint/suspicious/noEmptyInterface: filled by declaration merging, per app
80
+ // biome-ignore lint/suspicious/noEmptyInterface: filled by declaration merging, per app — each literal to `true`, or to the params it narrows (23.9)
66
81
  export interface Routes {}
67
82
 
68
83
  /** 30.26 — what `url` takes: one of the app's route literals, or any where none were declared. */
@@ -76,7 +91,9 @@ export type RouteLiteral = [keyof Routes] extends [never]
76
91
  */
77
92
  export type RouteParams<P extends string> = {
78
93
  [K in RequiredNames<P>]: string
79
- } & { [K in OptionalNames<P>]?: string } & { [K in RestNames<P>]: string[] }
94
+ } & { [K in OptionalNames<P>]?: string } & {
95
+ [K in RestNames<P>]: string[]
96
+ } & Narrowed<P>
80
97
 
81
98
  /**
82
99
  * An address from a route literal and its params, resolved against the mount, with an optional