@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.
- package/CHANGELOG.md +11 -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/TypeSession.ts +39 -6
- 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/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/routePattern.ts +66 -14
- package/src/shared/url.ts +24 -7
- /package/scaffold/src/{getGreeting.ts → getGreeting.handler.ts} +0 -0
|
@@ -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 = (
|
|
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))
|
|
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(
|
|
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({
|
|
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()]),
|
package/src/server/answerRpc.ts
CHANGED
|
@@ -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,
|
package/src/server/appTree.ts
CHANGED
|
@@ -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 (
|
|
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
|
|
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
|
-
(
|
|
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:
|
|
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
|
|
@@ -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
|
}
|