@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.
- package/CHANGELOG.md +18 -0
- package/docs/REGISTRY.md +16 -14
- package/docs/RULEBOOK.md +49 -10
- package/docs/abide.md +246 -156
- package/package.json +1 -1
- package/scaffold/AGENTS.md +1 -1
- package/scaffold/src/getGreeting.test.ts +1 -1
- package/scaffold/src/index.abide +1 -1
- package/src/compiler/ShadowMap.ts +1 -1
- package/src/compiler/TypeSession.ts +177 -51
- package/src/compiler/appLayout.ts +52 -2
- package/src/compiler/appMap.ts +15 -0
- package/src/compiler/compileApp.ts +1 -1
- package/src/compiler/compiledEntry.ts +1 -1
- package/src/compiler/handlersUnder.ts +5 -2
- package/src/compiler/matchersOf.ts +147 -0
- package/src/compiler/mountsOf.ts +3 -0
- package/src/compiler/paramFile.ts +124 -0
- package/src/compiler/projectShadow.ts +1 -1
- package/src/compiler/readsIn.ts +90 -2
- package/src/compiler/serveLanguage.ts +2 -2
- package/src/server/answerRequest.ts +42 -5
- package/src/server/answerRpc.ts +3 -0
- package/src/server/appTree.ts +15 -8
- package/src/server/generatedSurfaces.ts +75 -7
- package/src/server/placement.ts +12 -2
- package/src/server/remote.ts +6 -2
- package/src/server/surfaceOf.ts +5 -1
- package/src/shared/filledPath.ts +16 -10
- package/src/shared/matchRoute.ts +16 -4
- package/src/shared/reactive/Reactive.ts +4 -0
- package/src/shared/routePattern.ts +66 -14
- package/src/shared/url.ts +24 -7
- /package/scaffold/src/{getGreeting.ts → getGreeting.handler.ts} +0 -0
|
@@ -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:
|
|
142
|
-
|
|
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(
|
|
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(
|
|
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.
|
package/src/server/placement.ts
CHANGED
|
@@ -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([
|
package/src/server/remote.ts
CHANGED
|
@@ -115,11 +115,15 @@ function handlerAt(
|
|
|
115
115
|
address: string,
|
|
116
116
|
method: string | undefined,
|
|
117
117
|
): Mount | undefined {
|
|
118
|
-
const rows: {
|
|
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
|
}
|
package/src/server/surfaceOf.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
)
|
package/src/shared/filledPath.ts
CHANGED
|
@@ -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
|
|
45
|
+
address += `/${encodedSegment(spelled, segment, each, refused)}`
|
|
46
46
|
continue
|
|
47
47
|
}
|
|
48
|
-
address += `/${encodedSegment(spelled, segment
|
|
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
|
|
54
|
+
* A param value encoded as one path segment, refused where no address can carry it.
|
|
55
55
|
*
|
|
56
|
-
* 30.19 — A VALUE
|
|
57
|
-
*
|
|
58
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
}
|
package/src/shared/matchRoute.ts
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
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'
|
|
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
|
|
26
|
-
* `
|
|
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(
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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<
|
|
103
|
-
|
|
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({
|
|
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 } & {
|
|
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
|
|
File without changes
|