@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
package/package.json
CHANGED
package/scaffold/AGENTS.md
CHANGED
|
@@ -36,7 +36,7 @@ before writing code, rather than guessing from Svelte, Vue or React. It installs
|
|
|
36
36
|
- A file's path below `src/` is its address. `src/invoices/index.abide` answers `/invoices`,
|
|
37
37
|
`src/invoices/[id].abide` answers `/invoices/123`, and `layout.abide` wraps the pages below it;
|
|
38
38
|
the outermost one writes `<html>`. Run `bun run routes` to see every address and the file behind it.
|
|
39
|
-
- A
|
|
39
|
+
- A `<name>.handler.ts` beside the pages is a handler: `src/invoices/getInvoice.handler.ts` holds
|
|
40
40
|
`export default GET(…)` and answers `/invoices/getInvoice`. It is the app's public API, for pages,
|
|
41
41
|
scripts, the CLI and MCP alike.
|
|
42
42
|
- A `.abide` cannot export. A `<script server>` in one holds that file's private handlers
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { expect, test } from 'bun:test'
|
|
2
|
-
import getGreeting from './getGreeting.ts'
|
|
2
|
+
import getGreeting from './getGreeting.handler.ts'
|
|
3
3
|
|
|
4
4
|
test('the handler answers with the name it is given', async () => {
|
|
5
5
|
expect(await getGreeting({ name: 'Ada' })).toBe('Hello, Ada')
|
package/scaffold/src/index.abide
CHANGED
|
@@ -21,9 +21,10 @@ import {
|
|
|
21
21
|
type Type,
|
|
22
22
|
} from 'typescript/unstable/async'
|
|
23
23
|
import { placeOf } from '#server/placement.ts'
|
|
24
|
+
import { parseRoutePattern } from '#shared/routePattern.ts'
|
|
24
25
|
import { aliasedSymbol } from './aliasedSymbol.ts'
|
|
25
26
|
import { AMBIENT_SOURCES, ambientReadsIn } from './ambientReadsIn.ts'
|
|
26
|
-
import type { AppLayout, PlacedSource } from './appLayout.ts'
|
|
27
|
+
import type { AppLayout, AppRoute, PlacedSource } from './appLayout.ts'
|
|
27
28
|
import { argsDefaultsIn } from './argsDefaultsIn.ts'
|
|
28
29
|
import { type CheckerAnswer, UNCHECKED } from './CheckerAnswer.ts'
|
|
29
30
|
import {
|
|
@@ -974,7 +975,7 @@ export class TypeSession {
|
|
|
974
975
|
Partial<Pick<PlacedSource, 'kind' | 'route'>> & {
|
|
975
976
|
quiet?: boolean
|
|
976
977
|
})[],
|
|
977
|
-
app: Pick<AppLayout, 'root' | 'addresses' | 'handlers'>,
|
|
978
|
+
app: Pick<AppLayout, 'root' | 'addresses' | 'handlers' | 'routes'>,
|
|
978
979
|
scripts = true,
|
|
979
980
|
): Promise<{ diagnoses: Diagnosis[]; refused: Set<string> }> {
|
|
980
981
|
using _held = this.hold()
|
|
@@ -1033,7 +1034,7 @@ export class TypeSession {
|
|
|
1033
1034
|
// module augments one.
|
|
1034
1035
|
this.register(
|
|
1035
1036
|
`${this.canonical(within)}/${ROUTES_DECLARATION}`,
|
|
1036
|
-
routesDeclaration(app.addresses),
|
|
1037
|
+
routesDeclaration(app.addresses, app.routes),
|
|
1037
1038
|
created,
|
|
1038
1039
|
changed,
|
|
1039
1040
|
)
|
|
@@ -1570,13 +1571,45 @@ function isFile(path: string): boolean {
|
|
|
1570
1571
|
const ROUTES_DECLARATION = 'abide-routes.d.ts'
|
|
1571
1572
|
const URL_SOURCE = Bun.fileURLToPath(new URL('../shared/url', import.meta.url))
|
|
1572
1573
|
|
|
1573
|
-
/**
|
|
1574
|
-
|
|
1574
|
+
/**
|
|
1575
|
+
* 30.26 — each of `addresses` as a key of abide's `Routes`, and 23.9, 30.3 — a page route's params
|
|
1576
|
+
* narrowed where its matcher is an alternation of literals, which is the one matcher a type can say.
|
|
1577
|
+
*/
|
|
1578
|
+
function routesDeclaration(
|
|
1579
|
+
addresses: readonly string[],
|
|
1580
|
+
routes: readonly AppRoute[],
|
|
1581
|
+
): string {
|
|
1582
|
+
const narrowed = new Map<string, string>()
|
|
1583
|
+
for (const route of routes) {
|
|
1584
|
+
let members = ''
|
|
1585
|
+
for (const segment of parseRoutePattern(route.pattern)) {
|
|
1586
|
+
if (segment.kind !== 'required' && segment.kind !== 'optional')
|
|
1587
|
+
continue
|
|
1588
|
+
const literals = literalsOf(
|
|
1589
|
+
segment.matcher === undefined
|
|
1590
|
+
? undefined
|
|
1591
|
+
: route.matchers[segment.matcher],
|
|
1592
|
+
)
|
|
1593
|
+
if (literals === undefined) continue
|
|
1594
|
+
members += ` ${JSON.stringify(segment.name)}${segment.kind === 'optional' ? '?' : ''}: ${literals};`
|
|
1595
|
+
}
|
|
1596
|
+
if (members !== '') narrowed.set(route.pattern, `{${members} }`)
|
|
1597
|
+
}
|
|
1575
1598
|
let keys = ''
|
|
1576
1599
|
for (const route of addresses)
|
|
1577
|
-
keys += ` ${JSON.stringify(route)}: true\n`
|
|
1600
|
+
keys += ` ${JSON.stringify(route)}: ${narrowed.get(route) ?? 'true'}\n`
|
|
1578
1601
|
return `export {}\ndeclare module ${JSON.stringify(URL_SOURCE)} {\n interface Routes {\n${keys} }\n}\n`
|
|
1579
1602
|
}
|
|
1603
|
+
|
|
1604
|
+
/** The union of the literals an anchored matcher source alternates between, or `undefined`. */
|
|
1605
|
+
function literalsOf(source: string | undefined): string | undefined {
|
|
1606
|
+
const body = source?.match(/^\^\(\?!\\\.\\\.\?\$\)\(\?:(.*)\)\$$/)?.[1]
|
|
1607
|
+
if (body === undefined || !/^[\w~-]+(?:\|[\w~-]+)*$/.test(body)) return undefined
|
|
1608
|
+
return body
|
|
1609
|
+
.split('|')
|
|
1610
|
+
.map((literal) => JSON.stringify(literal))
|
|
1611
|
+
.join(' | ')
|
|
1612
|
+
}
|
|
1580
1613
|
const AMBIENT_DECLARATIONS = `declare module '*.css' {}
|
|
1581
1614
|
declare module '*.md' {
|
|
1582
1615
|
const text: string
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
import { abideImportsOf, componentGraph } from './componentGraph.ts'
|
|
17
17
|
import type { HandlerFile } from './handlersUnder.ts'
|
|
18
18
|
import { hookCalls, hooksIn } from './hookCalls.ts'
|
|
19
|
+
import { type MountedTree, matchersOf } from './matchersOf.ts'
|
|
19
20
|
import { mountsOf } from './mountsOf.ts'
|
|
20
21
|
import { type AbideFile, scriptBodies } from './parseAbide.ts'
|
|
21
22
|
import { Refusals } from './Refusals.ts'
|
|
@@ -49,6 +50,8 @@ export type PlacedHandler = HandlerFile & {
|
|
|
49
50
|
scope: string
|
|
50
51
|
text: string
|
|
51
52
|
hooks: string[]
|
|
53
|
+
/** 23.26 — each matcher its route names, to the anchored source the route tables carry. */
|
|
54
|
+
matchers: Record<string, string>
|
|
52
55
|
}
|
|
53
56
|
|
|
54
57
|
/** A page or an error page and the layouts it renders inside. */
|
|
@@ -64,6 +67,8 @@ export type AppRoute = {
|
|
|
64
67
|
* cannot be declared twice.
|
|
65
68
|
*/
|
|
66
69
|
layouts: string[]
|
|
70
|
+
/** 23.26 — each matcher its pattern names, to the anchored source the route tables carry. */
|
|
71
|
+
matchers: Record<string, string>
|
|
67
72
|
}
|
|
68
73
|
|
|
69
74
|
/** The app at a root, laid out (`appLayout`). */
|
|
@@ -105,7 +110,10 @@ export async function appLayout(root: string): Promise<AppLayout> {
|
|
|
105
110
|
// COLLECTED, whoever asks: a layout is kept across checks (`AppChecker`), and a build adopts
|
|
106
111
|
// what it found (`Refusals.adopt`).
|
|
107
112
|
const refusals = new Refusals(root, true)
|
|
108
|
-
const { abide, handlers, modules, published } = appFiles(
|
|
113
|
+
const { abide, handlers, params, modules, published } = appFiles(
|
|
114
|
+
root,
|
|
115
|
+
refusals,
|
|
116
|
+
)
|
|
109
117
|
await refuseModuleHooks(root, modules, refusals)
|
|
110
118
|
const resolve = resolverIn()
|
|
111
119
|
const unparsed = new Refusals(root, true)
|
|
@@ -149,10 +157,43 @@ export async function appLayout(root: string): Promise<AppLayout> {
|
|
|
149
157
|
scope: placement(file.relative, file.position).scope,
|
|
150
158
|
text,
|
|
151
159
|
hooks: hooksIn([text]),
|
|
160
|
+
matchers: {},
|
|
152
161
|
})
|
|
153
162
|
}
|
|
154
163
|
refuseStrayLauncher(sources, placedHandlers, refusals)
|
|
155
164
|
const { routes, errors } = routesOf(sources, refusals)
|
|
165
|
+
// 23.26 — every routed file's matchers, a layout's among them for 23.27's sake though no table
|
|
166
|
+
// carries a layout's own.
|
|
167
|
+
const routed: {
|
|
168
|
+
path: string
|
|
169
|
+
relative: string
|
|
170
|
+
position: string
|
|
171
|
+
route: string
|
|
172
|
+
}[] = []
|
|
173
|
+
const relativeToPath = new Map<string, string>()
|
|
174
|
+
for (const source of sources) {
|
|
175
|
+
if (source.route === undefined) continue
|
|
176
|
+
relativeToPath.set(source.relative, source.path)
|
|
177
|
+
routed.push({
|
|
178
|
+
path: source.path,
|
|
179
|
+
relative: source.relative,
|
|
180
|
+
position: source.position,
|
|
181
|
+
route: source.route,
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
for (const handler of placedHandlers) routed.push(handler)
|
|
185
|
+
const matchers = await matchersOf(
|
|
186
|
+
root,
|
|
187
|
+
routed,
|
|
188
|
+
params.map((path) => `${root}/${path}`),
|
|
189
|
+
found.trees,
|
|
190
|
+
refusals,
|
|
191
|
+
)
|
|
192
|
+
for (const handler of placedHandlers)
|
|
193
|
+
handler.matchers = matchers.get(handler.path) ?? {}
|
|
194
|
+
for (const route of [...routes, ...errors])
|
|
195
|
+
route.matchers =
|
|
196
|
+
matchers.get(relativeToPath.get(route.source) ?? '') ?? {}
|
|
156
197
|
const addresses = new Set<string>()
|
|
157
198
|
for (const route of routes) addresses.add(route.pattern)
|
|
158
199
|
for (const address of published.values()) addresses.add(address)
|
|
@@ -215,6 +256,8 @@ export async function withDependencies(
|
|
|
215
256
|
handlers: HandlerFile[]
|
|
216
257
|
/** 23.19 — each mounted `.abide`'s position, by its path; one at its own path is not here. */
|
|
217
258
|
positions: Map<string, string>
|
|
259
|
+
/** 23.26 — each mounted directory, where it mounts, and the param files under it. */
|
|
260
|
+
trees: MountedTree[]
|
|
218
261
|
}> {
|
|
219
262
|
const all = [...parsed]
|
|
220
263
|
const handlers: HandlerFile[] = []
|
|
@@ -231,6 +274,7 @@ export async function withDependencies(
|
|
|
231
274
|
const positions = new Map<string, string>()
|
|
232
275
|
// 23.20 — a directory mounts once, and by whom.
|
|
233
276
|
const mountedBy = new Map<string, string>()
|
|
277
|
+
const trees: MountedTree[] = []
|
|
234
278
|
const add = async (path: string): Promise<void> => {
|
|
235
279
|
let text: string
|
|
236
280
|
try {
|
|
@@ -268,6 +312,11 @@ export async function withDependencies(
|
|
|
268
312
|
continue
|
|
269
313
|
}
|
|
270
314
|
mountedBy.set(mount.directory, position)
|
|
315
|
+
trees.push({
|
|
316
|
+
directory: mount.directory,
|
|
317
|
+
at: mount.at,
|
|
318
|
+
params: mount.params,
|
|
319
|
+
})
|
|
271
320
|
for (const placed of mount.handlers) {
|
|
272
321
|
// Two mounts can overlap, one directory inside the other; a file is placed once.
|
|
273
322
|
if (handled.has(placed.path)) continue
|
|
@@ -292,7 +341,7 @@ export async function withDependencies(
|
|
|
292
341
|
if (!byPath.has(target)) await add(target)
|
|
293
342
|
}
|
|
294
343
|
}
|
|
295
|
-
return { sources: all, handlers, positions }
|
|
344
|
+
return { sources: all, handlers, positions, trees }
|
|
296
345
|
}
|
|
297
346
|
|
|
298
347
|
/** Whether a page or an error page has anything to paint: a template, or a head. 23.3, 35.14. */
|
|
@@ -450,6 +499,7 @@ function routesOf(
|
|
|
450
499
|
pattern,
|
|
451
500
|
source: source.relative,
|
|
452
501
|
layouts: around,
|
|
502
|
+
matchers: {},
|
|
453
503
|
})
|
|
454
504
|
}
|
|
455
505
|
return { routes, errors }
|
package/src/compiler/appMap.ts
CHANGED
|
@@ -14,6 +14,8 @@ type MappedRoute = {
|
|
|
14
14
|
address: string
|
|
15
15
|
file: string
|
|
16
16
|
kind: 'page' | 'handler' | 'private'
|
|
17
|
+
/** 23.26 — each matcher its address names, to the anchored pattern it takes a part by. */
|
|
18
|
+
matchers: Record<string, string>
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
/** One hook call, the route it is filed at, and the file making it. */
|
|
@@ -39,6 +41,7 @@ export async function appMap(
|
|
|
39
41
|
address: route.pattern,
|
|
40
42
|
file: route.source,
|
|
41
43
|
kind: 'page',
|
|
44
|
+
matchers: route.matchers,
|
|
42
45
|
})
|
|
43
46
|
for (const mount of app.mounts)
|
|
44
47
|
routes.push({
|
|
@@ -46,6 +49,7 @@ export async function appMap(
|
|
|
46
49
|
address: mount.address,
|
|
47
50
|
file: mount.source,
|
|
48
51
|
kind: mount.private ? 'private' : 'handler',
|
|
52
|
+
matchers: mount.matchers,
|
|
49
53
|
})
|
|
50
54
|
routes.sort((one, two) =>
|
|
51
55
|
one.address === two.address
|
|
@@ -79,6 +83,17 @@ export function mapText(map: Awaited<ReturnType<typeof appMap>>): string {
|
|
|
79
83
|
lines.push(
|
|
80
84
|
`${route.method.padEnd(methods)} ${route.address.padEnd(addresses)} ${route.file}${route.kind === 'private' ? ' (private)' : ''}`,
|
|
81
85
|
)
|
|
86
|
+
// 23.26 — each matcher once, whichever routes name it: what a part must match to be taken.
|
|
87
|
+
const matchers = new Map<string, string>()
|
|
88
|
+
for (const route of map.routes)
|
|
89
|
+
for (const name in route.matchers)
|
|
90
|
+
matchers.set(name, route.matchers[name] as string)
|
|
91
|
+
if (matchers.size > 0) {
|
|
92
|
+
lines.push('', 'matchers')
|
|
93
|
+
const names = width([...matchers.keys()])
|
|
94
|
+
for (const [name, pattern] of matchers)
|
|
95
|
+
lines.push(` ${name.padEnd(names)} /${pattern}/`)
|
|
96
|
+
}
|
|
82
97
|
if (map.hooks.length > 0) {
|
|
83
98
|
lines.push('', 'hooks')
|
|
84
99
|
const hooks = width(map.hooks.map((hook) => hook.hook))
|
|
@@ -514,7 +514,7 @@ function tableOf(
|
|
|
514
514
|
(layout) => `() => import(${tableImport(layout)})`,
|
|
515
515
|
)
|
|
516
516
|
rows.push(
|
|
517
|
-
` { pattern: ${JSON.stringify(route.pattern)}, load: () => import(${tableImport(route.source)}), layouts: [${wrapping.join(', ')}]${column(route)} },`,
|
|
517
|
+
` { pattern: ${JSON.stringify(route.pattern)}, load: () => import(${tableImport(route.source)}), layouts: [${wrapping.join(', ')}], matchers: ${JSON.stringify(route.matchers)}${column(route)} },`,
|
|
518
518
|
)
|
|
519
519
|
}
|
|
520
520
|
return rows.join('\n')
|
|
@@ -120,7 +120,7 @@ import * as table from ${JSON.stringify(tablePath(app.root, SERVER_TABLE))}
|
|
|
120
120
|
return `// Written by \`abide compile\`. The whole app, as what one executable holds.
|
|
121
121
|
${imports}
|
|
122
122
|
// 38.56 — a file declaring handlers as a script names it through the app's one alias:
|
|
123
|
-
// \`#src/invoices/getInvoice.ts\`, to the module it is.
|
|
123
|
+
// \`#src/invoices/getInvoice.handler.ts\`, to the module it is.
|
|
124
124
|
const declared = {}
|
|
125
125
|
for (const row of table.handlers) declared[\`#src/\${row.source}\`] = row.load
|
|
126
126
|
|
|
@@ -45,7 +45,7 @@ export async function handlersUnder(
|
|
|
45
45
|
sources: readonly Source[],
|
|
46
46
|
files: readonly Pick<
|
|
47
47
|
PlacedHandler,
|
|
48
|
-
'path' | 'relative' | 'route' | 'scope'
|
|
48
|
+
'path' | 'relative' | 'route' | 'scope' | 'matchers'
|
|
49
49
|
>[],
|
|
50
50
|
refusals: Refusals,
|
|
51
51
|
): Promise<{ mounts: Mount[]; warnings: Diagnosis[] }> {
|
|
@@ -84,6 +84,7 @@ export async function handlersUnder(
|
|
|
84
84
|
relative,
|
|
85
85
|
route: address,
|
|
86
86
|
scope,
|
|
87
|
+
matchers,
|
|
87
88
|
} = files[at] as (typeof files)[number]
|
|
88
89
|
const exported = declared[at]
|
|
89
90
|
if (exported === undefined) continue
|
|
@@ -99,7 +100,7 @@ export async function handlersUnder(
|
|
|
99
100
|
)
|
|
100
101
|
if (handler?.method === undefined)
|
|
101
102
|
throw new Error(
|
|
102
|
-
`abide: ${relative} is a handler file — a .ts directly in an address directory — and exports no handler as its default. Export one (\`export default GET(…)\`), or move the file into a lib directory. RULEBOOK 16.73, 23.22.`,
|
|
103
|
+
`abide: ${relative} is a handler file — a <name>.handler.ts directly in an address directory — and exports no handler as its default. Export one (\`export default GET(…)\`), or move the file into a lib directory. RULEBOOK 16.73, 23.22.`,
|
|
103
104
|
)
|
|
104
105
|
const framework = frameworkRouteRefusal(address, relative)
|
|
105
106
|
if (framework !== undefined) throw framework
|
|
@@ -119,6 +120,7 @@ export async function handlersUnder(
|
|
|
119
120
|
name: address.slice(address.lastIndexOf('/') + 1),
|
|
120
121
|
export: 'default',
|
|
121
122
|
scope,
|
|
123
|
+
matchers,
|
|
122
124
|
private: false,
|
|
123
125
|
ui: handler.ui,
|
|
124
126
|
cli: handler.cli,
|
|
@@ -150,6 +152,7 @@ export async function handlersUnder(
|
|
|
150
152
|
name,
|
|
151
153
|
export: each.name,
|
|
152
154
|
scope,
|
|
155
|
+
matchers: {},
|
|
153
156
|
private: true,
|
|
154
157
|
ui: each.ui,
|
|
155
158
|
cli: false,
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// 23.26, 23.27 — WHICH MATCHER EACH SEGMENT NAMES, resolved once at build and carried into the route
|
|
2
|
+
// tables as text, so nothing at run time looks a name up. A segment's `=NAME` is the param file
|
|
3
|
+
// `NAME.param.ts` in the directory holding that segment or the nearest one above it, below the root
|
|
4
|
+
// the file is filed under: the app's root for the app's own, and a mounted directory for the files
|
|
5
|
+
// under it (23.19), so a host's matcher never decides a mounted package's segment.
|
|
6
|
+
//
|
|
7
|
+
// ONE MATCHER PER POSITION across every file filed there (23.27): a layout, a page and a handler
|
|
8
|
+
// directory at `/holdings/[symbol=TICKER]` share the segment, and a page that took `BRK.B` under a
|
|
9
|
+
// layout whose segment refused it would be wrapped by a layout no address of its own reaches.
|
|
10
|
+
|
|
11
|
+
import { parseRoutePattern } from '#shared/routePattern.ts'
|
|
12
|
+
import { matcherOf } from './paramFile.ts'
|
|
13
|
+
import type { Refusals } from './Refusals.ts'
|
|
14
|
+
|
|
15
|
+
/** A directory a file mounts (23.19), where it mounts, and the param files under it, by path. */
|
|
16
|
+
export type MountedTree = { directory: string; at: string; params: string[] }
|
|
17
|
+
|
|
18
|
+
/** A file with a route: its path, the name refusals give it, and the position it answers by. */
|
|
19
|
+
type Routed = { path: string; relative: string; position: string; route: string }
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Each routed file's matchers, by its path: every name its route's segments give, to the anchored
|
|
23
|
+
* source of the param file it resolves to — empty where its route names none.
|
|
24
|
+
*
|
|
25
|
+
* `params` is every param file of the app's own, by path. Refusals land at the file the fault is in:
|
|
26
|
+
* a param file that declares no matcher at itself, a name that resolves to none at the segment's file.
|
|
27
|
+
*/
|
|
28
|
+
export async function matchersOf(
|
|
29
|
+
root: string,
|
|
30
|
+
routed: readonly Routed[],
|
|
31
|
+
params: readonly string[],
|
|
32
|
+
trees: readonly MountedTree[],
|
|
33
|
+
refusals: Refusals,
|
|
34
|
+
): Promise<Map<string, Record<string, string>>> {
|
|
35
|
+
// Every param file's matcher, read once whichever routes name it; `undefined` where 23.24 or
|
|
36
|
+
// 23.25 refused the file, so a segment naming it refuses nothing more.
|
|
37
|
+
const sources = new Map<string, string | undefined>()
|
|
38
|
+
const all = [...params]
|
|
39
|
+
for (const tree of trees) all.push(...tree.params)
|
|
40
|
+
for (const path of all) {
|
|
41
|
+
if (sources.has(path)) continue
|
|
42
|
+
const matcher = matcherOf(labelOf(root, path), await Bun.file(path).text())
|
|
43
|
+
if (matcher instanceof Error) {
|
|
44
|
+
refusals.at(path, matcher)
|
|
45
|
+
sources.set(path, undefined)
|
|
46
|
+
} else sources.set(path, matcher)
|
|
47
|
+
}
|
|
48
|
+
const byPath = new Map<string, Record<string, string>>()
|
|
49
|
+
// 23.27 — what each parameter position resolved to, and the first file that said so.
|
|
50
|
+
const atPosition = new Map<string, { found: string; file: string }>()
|
|
51
|
+
for (const file of routed) {
|
|
52
|
+
const matchers: Record<string, string> = {}
|
|
53
|
+
const tree = treeOf(file.path, trees)
|
|
54
|
+
const positionParts = file.position.split('/')
|
|
55
|
+
let shape = ''
|
|
56
|
+
let at = 0
|
|
57
|
+
for (const segment of parseRoutePattern(file.route)) {
|
|
58
|
+
shape += `/${segment.kind === 'static' ? segment.name : `[${segment.kind}]`}`
|
|
59
|
+
const index = at
|
|
60
|
+
at += 1
|
|
61
|
+
if (segment.kind === 'static') continue
|
|
62
|
+
const found =
|
|
63
|
+
segment.matcher === undefined
|
|
64
|
+
? ''
|
|
65
|
+
: nearestParam(
|
|
66
|
+
root,
|
|
67
|
+
tree,
|
|
68
|
+
positionParts.slice(0, index),
|
|
69
|
+
segment.matcher,
|
|
70
|
+
sources,
|
|
71
|
+
)
|
|
72
|
+
if (found === undefined) {
|
|
73
|
+
refusals.at(
|
|
74
|
+
file.path,
|
|
75
|
+
new Error(
|
|
76
|
+
`abide: ${file.relative} names the matcher ${segment.matcher}, and no ${segment.matcher}.param.ts is in the directory holding that segment or any directory above it. RULEBOOK 23.26.`,
|
|
77
|
+
),
|
|
78
|
+
)
|
|
79
|
+
continue
|
|
80
|
+
}
|
|
81
|
+
const other = atPosition.get(shape)
|
|
82
|
+
if (other === undefined) atPosition.set(shape, { found, file: file.relative })
|
|
83
|
+
else if (other.found !== found)
|
|
84
|
+
refusals.at(
|
|
85
|
+
file.path,
|
|
86
|
+
new Error(
|
|
87
|
+
`abide: ${other.file} and ${file.relative} decide the parameter at ${shape} by ${describe(root, other.found)} and by ${describe(root, found)}, and one position takes one matcher. RULEBOOK 23.27.`,
|
|
88
|
+
),
|
|
89
|
+
)
|
|
90
|
+
const source = found === '' ? undefined : sources.get(found)
|
|
91
|
+
if (segment.matcher !== undefined && source !== undefined)
|
|
92
|
+
matchers[segment.matcher] = source
|
|
93
|
+
}
|
|
94
|
+
byPath.set(file.path, matchers)
|
|
95
|
+
}
|
|
96
|
+
return byPath
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The param file `NAME.param.ts` a segment at `holding` names, nearest first, or `undefined`.
|
|
101
|
+
*
|
|
102
|
+
* `holding` is the position of the directory holding the segment, and the walk stops at the root
|
|
103
|
+
* the segment is filed under: inside a mounted directory, that directory; a segment of the position
|
|
104
|
+
* the mount itself takes is the mounting app's.
|
|
105
|
+
*/
|
|
106
|
+
function nearestParam(
|
|
107
|
+
root: string,
|
|
108
|
+
tree: MountedTree | undefined,
|
|
109
|
+
holding: string[],
|
|
110
|
+
name: string,
|
|
111
|
+
sources: Map<string, string | undefined>,
|
|
112
|
+
): string | undefined {
|
|
113
|
+
const atParts = tree === undefined ? 0 : tree.at.split('/').length - 1
|
|
114
|
+
const inside = tree !== undefined && holding.length >= atParts
|
|
115
|
+
const base = inside ? tree.directory : root
|
|
116
|
+
const below = inside ? holding.slice(atParts) : holding
|
|
117
|
+
for (let depth = below.length; depth >= 0; depth -= 1) {
|
|
118
|
+
const directory = [base, ...below.slice(0, depth)].join('/')
|
|
119
|
+
const candidate = `${directory}/${name}.param.ts`
|
|
120
|
+
if (sources.has(candidate)) return candidate
|
|
121
|
+
}
|
|
122
|
+
return undefined
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The mounted directory `path` lies in, the deepest where two nest, or `undefined` for the app's own. */
|
|
126
|
+
function treeOf(
|
|
127
|
+
path: string,
|
|
128
|
+
trees: readonly MountedTree[],
|
|
129
|
+
): MountedTree | undefined {
|
|
130
|
+
let deepest: MountedTree | undefined
|
|
131
|
+
for (const tree of trees)
|
|
132
|
+
if (
|
|
133
|
+
path.startsWith(`${tree.directory}/`) &&
|
|
134
|
+
(deepest === undefined ||
|
|
135
|
+
tree.directory.length > deepest.directory.length)
|
|
136
|
+
)
|
|
137
|
+
deepest = tree
|
|
138
|
+
return deepest
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function labelOf(root: string, path: string): string {
|
|
142
|
+
return path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function describe(root: string, found: string): string {
|
|
146
|
+
return found === '' ? 'no matcher' : labelOf(root, found)
|
|
147
|
+
}
|
package/src/compiler/mountsOf.ts
CHANGED
|
@@ -31,6 +31,8 @@ type DirectoryMount = {
|
|
|
31
31
|
at: string
|
|
32
32
|
files: Placed[]
|
|
33
33
|
handlers: Placed[]
|
|
34
|
+
/** 23.26 — the param files under it, by path: what its own segments' matchers resolve in. */
|
|
35
|
+
params: string[]
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
/**
|
|
@@ -85,6 +87,7 @@ export function mountsOf(
|
|
|
85
87
|
at,
|
|
86
88
|
files: found.abide.map(placed),
|
|
87
89
|
handlers: found.handlers.map(placed),
|
|
90
|
+
params: found.params.map((below) => `${directory}/${below}`),
|
|
88
91
|
})
|
|
89
92
|
}
|
|
90
93
|
return mounts
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// 23.24, 23.25 — A PARAM FILE, READ AND NEVER RUN. `<NAME>.param.ts` says what a segment naming
|
|
2
|
+
// `NAME` accepts, and the build carries that into both route tables as a regex literal, so the
|
|
3
|
+
// file is read off its tokens: one `export const NAME = /…/`, nothing else. A file that does
|
|
4
|
+
// anything more could only be honoured by running it, and nothing about building a route table is
|
|
5
|
+
// a reason to run app code.
|
|
6
|
+
//
|
|
7
|
+
// 30.36 — THE DOT-SEGMENT FLOOR IS WRITTEN INTO THE SOURCE rather than tested beside it, so the walk
|
|
8
|
+
// holds one test per part whatever the segment names: a matcher can lift the period default (30.34)
|
|
9
|
+
// and still never admit `.` or `..`, which the URL parser collapses into another route.
|
|
10
|
+
|
|
11
|
+
import { SyntaxKind } from 'typescript/unstable/ast'
|
|
12
|
+
import { tokenize } from './tokenize.ts'
|
|
13
|
+
|
|
14
|
+
/** The name a param file declares: its file name before `.param.ts`. */
|
|
15
|
+
export function paramNameOf(path: string): string {
|
|
16
|
+
return path.slice(path.lastIndexOf('/') + 1, -'.param.ts'.length)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The anchored source of the matcher the param file at `relative` declares, or why it declares none.
|
|
21
|
+
*
|
|
22
|
+
* Anchored as `^(?!\.\.?$)(?:…)$`: the whole part, percent-decoded, and never a dot-segment.
|
|
23
|
+
*/
|
|
24
|
+
export function matcherOf(relative: string, text: string): string | Error {
|
|
25
|
+
const name = paramNameOf(relative)
|
|
26
|
+
const tokens = tokenize(text)
|
|
27
|
+
const kinds: SyntaxKind[] = []
|
|
28
|
+
for (const token of tokens)
|
|
29
|
+
if (token.kind !== SyntaxKind.EndOfFile) kinds.push(token.kind)
|
|
30
|
+
const declared =
|
|
31
|
+
kinds.length >= 5 &&
|
|
32
|
+
kinds.length <= 6 &&
|
|
33
|
+
kinds[0] === SyntaxKind.ExportKeyword &&
|
|
34
|
+
kinds[1] === SyntaxKind.ConstKeyword &&
|
|
35
|
+
kinds[2] === SyntaxKind.Identifier &&
|
|
36
|
+
kinds[3] === SyntaxKind.EqualsToken &&
|
|
37
|
+
kinds[4] === SyntaxKind.RegularExpressionLiteral &&
|
|
38
|
+
(kinds.length === 5 || kinds[5] === SyntaxKind.SemicolonToken)
|
|
39
|
+
if (!declared)
|
|
40
|
+
return new Error(
|
|
41
|
+
`abide: ${relative} must hold one \`export const ${name} = /…/\` and nothing else, an import included. RULEBOOK 23.24.`,
|
|
42
|
+
)
|
|
43
|
+
const exported = (tokens[2] as { text: string }).text
|
|
44
|
+
if (exported !== name)
|
|
45
|
+
return new Error(
|
|
46
|
+
`abide: ${relative} exports ${exported}, and a param file exports the name it is filed under, ${name}. RULEBOOK 23.24.`,
|
|
47
|
+
)
|
|
48
|
+
const literal = (tokens[4] as { text: string }).text
|
|
49
|
+
const close = literal.lastIndexOf('/')
|
|
50
|
+
if (close !== literal.length - 1)
|
|
51
|
+
return new Error(
|
|
52
|
+
`abide: ${relative} carries the flag ${literal.slice(close + 1)}, and a matcher carries none: a route, OpenAPI and a JSON Schema's \`pattern\` would each read it differently. RULEBOOK 23.24.`,
|
|
53
|
+
)
|
|
54
|
+
const body = literal.slice(1, close)
|
|
55
|
+
const runaway = runawayIn(body)
|
|
56
|
+
if (runaway !== undefined)
|
|
57
|
+
return new Error(
|
|
58
|
+
`abide: ${relative} carries ${runaway}, which can take time exponential in a part's length on an address made to. RULEBOOK 23.25.`,
|
|
59
|
+
)
|
|
60
|
+
return `^(?!\\.\\.?$)(?:${body})$`
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 23.25 — what in a regex's source can backtrack without bound, or `undefined`.
|
|
65
|
+
*
|
|
66
|
+
* A repetition with no upper bound (`*`, `+`, `{n,}`) of a group that itself holds one, and a
|
|
67
|
+
* backreference. Read character by character rather than parsed: a class and an escape are skipped
|
|
68
|
+
* as single atoms, which is all the shape the two questions need.
|
|
69
|
+
*/
|
|
70
|
+
export function runawayIn(body: string): string | undefined {
|
|
71
|
+
// One entry per open group: whether it holds an unbounded repetition.
|
|
72
|
+
const holds: boolean[] = [false]
|
|
73
|
+
// Whether the atom just read is a group holding one.
|
|
74
|
+
let groupHolding = false
|
|
75
|
+
for (let at = 0; at < body.length; at += 1) {
|
|
76
|
+
const char = body[at] as string
|
|
77
|
+
if (char === '\\') {
|
|
78
|
+
const next = body[at + 1] ?? ''
|
|
79
|
+
if ((next >= '1' && next <= '9') || next === 'k')
|
|
80
|
+
return 'a backreference'
|
|
81
|
+
at += 1
|
|
82
|
+
groupHolding = false
|
|
83
|
+
continue
|
|
84
|
+
}
|
|
85
|
+
if (char === '[') {
|
|
86
|
+
at += 1
|
|
87
|
+
while (at < body.length && body[at] !== ']')
|
|
88
|
+
at += body[at] === '\\' ? 2 : 1
|
|
89
|
+
groupHolding = false
|
|
90
|
+
continue
|
|
91
|
+
}
|
|
92
|
+
if (char === '(') {
|
|
93
|
+
holds.push(false)
|
|
94
|
+
groupHolding = false
|
|
95
|
+
continue
|
|
96
|
+
}
|
|
97
|
+
if (char === ')') {
|
|
98
|
+
const inner = holds.pop() ?? false
|
|
99
|
+
if (inner) holds[holds.length - 1] = true
|
|
100
|
+
groupHolding = inner
|
|
101
|
+
continue
|
|
102
|
+
}
|
|
103
|
+
const unbounded =
|
|
104
|
+
char === '*' ||
|
|
105
|
+
char === '+' ||
|
|
106
|
+
(char === '{' && /^\{\d+,\}/.test(body.slice(at)))
|
|
107
|
+
if (unbounded) {
|
|
108
|
+
if (groupHolding) return 'a repetition of a group that repeats'
|
|
109
|
+
holds[holds.length - 1] = true
|
|
110
|
+
groupHolding = false
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
// A bounded `{n}` or `{n,m}` repeats a group a fixed number of times, and keeps what it
|
|
114
|
+
// repeats as the atom a following quantifier would apply to.
|
|
115
|
+
if (char === '{') {
|
|
116
|
+
const end = body.indexOf('}', at)
|
|
117
|
+
if (end !== -1) at = end
|
|
118
|
+
continue
|
|
119
|
+
}
|
|
120
|
+
if (char === '?') continue
|
|
121
|
+
groupHolding = false
|
|
122
|
+
}
|
|
123
|
+
return undefined
|
|
124
|
+
}
|
|
@@ -763,7 +763,7 @@ function refuseExport(source: string): void {
|
|
|
763
763
|
if (moved !== 0) depth += moved
|
|
764
764
|
else if (depth === 0 && text === EXPORT && previous !== '.')
|
|
765
765
|
throw new Error(
|
|
766
|
-
'abide: a .abide cannot export — its component is its one export. A handler another file calls is a handler file (a .ts beside the page, `export default GET(…)`); a value files share is a module under a lib directory. RULEBOOK 34.17.',
|
|
766
|
+
'abide: a .abide cannot export — its component is its one export. A handler another file calls is a handler file (a <name>.handler.ts beside the page, `export default GET(…)`); a value files share is a module under a lib directory. RULEBOOK 34.17.',
|
|
767
767
|
)
|
|
768
768
|
previous = text
|
|
769
769
|
}
|
|
@@ -129,8 +129,8 @@ export async function serveLanguage(
|
|
|
129
129
|
documents.get(Bun.pathToFileURL(spelled(path)).href)?.text
|
|
130
130
|
|
|
131
131
|
// An app's checker, opened on first use and kept for the session — and TOLD WHAT MOVED ON DISK,
|
|
132
|
-
// or it answers every check from the text it read when it opened: an export added to a
|
|
133
|
-
//
|
|
132
|
+
// or it answers every check from the text it read when it opened: an export added to a `lib`
|
|
133
|
+
// module the page imports is "no exported member" until the editor restarts this. A project whose
|
|
134
134
|
// tsconfig or package.json moved is opened again.
|
|
135
135
|
const checkerOf = async (app: string): Promise<AppChecker> => {
|
|
136
136
|
let checker = checkers.get(app)
|