@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abide/abide",
3
- "version": "0.117.2",
3
+ "version": "0.118.0",
4
4
  "type": "module",
5
5
  "description": "Isomorphic type-safe framework for reactive async interfaces for humans and machines",
6
6
  "license": "MIT",
@@ -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 `.ts` beside the pages is a handler: `src/invoices/getInvoice.ts` holds
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')
@@ -1,6 +1,6 @@
1
1
  <script>
2
2
  import { reactive } from '@abide/abide'
3
- import getGreeting from '#src/getGreeting.ts'
3
+ import getGreeting from '#src/getGreeting.handler.ts'
4
4
 
5
5
  let count = reactive(0)
6
6
  const greeting = reactive(() => getGreeting({ name: 'world' }))
@@ -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
- /** 30.26 — each of `addresses` as a key of abide's `Routes`. */
1574
- function routesDeclaration(addresses: readonly string[]): string {
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(root, refusals)
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 }
@@ -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
+ }
@@ -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 `.ts`
133
- // beside the page is "no exported member" until the editor restarts this. A project whose
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)