@avelonjs/next 0.1.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/src/routing.ts ADDED
@@ -0,0 +1,275 @@
1
+ import {
2
+ Invalid,
3
+ NotFound,
4
+ type ControllerClass,
5
+ type HttpMethod,
6
+ type RouteDefinition,
7
+ type RouteManifest,
8
+ } from '@avelonjs/core'
9
+
10
+ const RESOURCE_ACTIONS = ['index', 'create', 'store', 'show', 'edit', 'update', 'destroy'] as const
11
+ type ResourceAction = (typeof RESOURCE_ACTIONS)[number]
12
+
13
+ interface MutableRoute extends RouteDefinition<ControllerClass> {
14
+ middleware: string[]
15
+ bindings: Record<string, string>
16
+ }
17
+
18
+ function singular(name: string): string {
19
+ return name.endsWith('s') ? name.slice(0, -1) : name
20
+ }
21
+
22
+ function joinPath(prefix: string, path: string): string {
23
+ if (prefix.length === 0) return path
24
+ const base = prefix.startsWith('/') ? prefix : `/${prefix}`
25
+ return `${base.replace(/\/$/, '')}${path.startsWith('/') ? path : `/${path}`}`
26
+ }
27
+
28
+ /** Fluent builder for one route entry. */
29
+ export class RouteBuilder {
30
+ constructor(private readonly definition: MutableRoute) {}
31
+
32
+ /** Sets the stable route name used by `route()`. */
33
+ name(name: string): this {
34
+ this.definition.name = name
35
+ return this
36
+ }
37
+
38
+ /** Appends middleware aliases. */
39
+ middleware(...names: string[]): this {
40
+ this.definition.middleware.push(...names)
41
+ return this
42
+ }
43
+
44
+ /** Marks a path parameter for route-model binding. */
45
+ bind(param: string, model: string | { readonly name: string }): this {
46
+ this.definition.bindings[param] = typeof model === 'string' ? model : model.name
47
+ return this
48
+ }
49
+
50
+ /** Additionally generates a JSON route handler for this route. */
51
+ api(): this {
52
+ this.definition.api = true
53
+ return this
54
+ }
55
+ }
56
+
57
+ /** Fluent resource expander returned by {@link Registrar.resource}. */
58
+ export class ResourceBuilder {
59
+ constructor(
60
+ private readonly registrar: Registrar,
61
+ private readonly members: MutableRoute[],
62
+ ) {}
63
+
64
+ /** Marks member parameters for route-model binding. */
65
+ bind(param: string, model: string | { readonly name: string }): this {
66
+ const modelName = typeof model === 'string' ? model : model.name
67
+ for (const definition of this.members) {
68
+ if (definition.path.includes(`{${param}}`)) definition.bindings[param] = modelName
69
+ }
70
+ return this
71
+ }
72
+
73
+ /** Keeps only the named resource actions. */
74
+ only(...actions: ResourceAction[]): this {
75
+ const keep = new Set(actions)
76
+ this.#filter((action) => keep.has(action))
77
+ return this
78
+ }
79
+
80
+ /** Drops the named resource actions. */
81
+ except(...actions: ResourceAction[]): this {
82
+ const drop = new Set(actions)
83
+ this.#filter((action) => !drop.has(action))
84
+ return this
85
+ }
86
+
87
+ /** Additionally generates JSON handlers for every remaining resource route. */
88
+ api(): this {
89
+ for (const definition of this.members) definition.api = true
90
+ return this
91
+ }
92
+
93
+ #filter(keep: (action: ResourceAction) => boolean): void {
94
+ const removed = new Set(
95
+ this.members.filter((definition) => !keep(definition.action as ResourceAction)),
96
+ )
97
+ for (const definition of removed) this.registrar.drop(definition)
98
+ const remaining = this.members.filter((definition) => !removed.has(definition))
99
+ this.members.length = 0
100
+ this.members.push(...remaining)
101
+ }
102
+ }
103
+
104
+ class Registrar {
105
+ readonly #definitions: MutableRoute[] = []
106
+ #middleware: string[] = []
107
+ #prefix = ''
108
+
109
+ #push(
110
+ method: HttpMethod,
111
+ path: string,
112
+ controller: ControllerClass,
113
+ action: string,
114
+ name?: string,
115
+ ): MutableRoute {
116
+ const definition: MutableRoute = {
117
+ name: name ?? `${controller.name}.${action}`,
118
+ method,
119
+ path: joinPath(this.#prefix, path.startsWith('/') ? path : `/${path}`),
120
+ controller,
121
+ action,
122
+ middleware: [...this.#middleware],
123
+ bindings: {},
124
+ api: false,
125
+ }
126
+ this.#definitions.push(definition)
127
+ return definition
128
+ }
129
+
130
+ /** Removes a definition. Used by resource `only` / `except`. */
131
+ drop(definition: MutableRoute): void {
132
+ const index = this.#definitions.indexOf(definition)
133
+ if (index >= 0) this.#definitions.splice(index, 1)
134
+ }
135
+
136
+ /** Registers a GET route. */
137
+ get(path: string, controller: ControllerClass, action = 'index'): RouteBuilder {
138
+ return new RouteBuilder(this.#push('GET', path, controller, action))
139
+ }
140
+
141
+ /** Registers a POST route. */
142
+ post(path: string, controller: ControllerClass, action = 'store'): RouteBuilder {
143
+ return new RouteBuilder(this.#push('POST', path, controller, action))
144
+ }
145
+
146
+ /** Registers a PUT route. */
147
+ put(path: string, controller: ControllerClass, action = 'update'): RouteBuilder {
148
+ return new RouteBuilder(this.#push('PUT', path, controller, action))
149
+ }
150
+
151
+ /** Registers a PATCH route. */
152
+ patch(path: string, controller: ControllerClass, action = 'update'): RouteBuilder {
153
+ return new RouteBuilder(this.#push('PATCH', path, controller, action))
154
+ }
155
+
156
+ /** Registers a DELETE route. */
157
+ delete(path: string, controller: ControllerClass, action = 'destroy'): RouteBuilder {
158
+ return new RouteBuilder(this.#push('DELETE', path, controller, action))
159
+ }
160
+
161
+ /**
162
+ * Expands to the seven resource routes: index, create, store, show, edit, update, destroy.
163
+ */
164
+ resource(name: string, controller: ControllerClass): ResourceBuilder {
165
+ const param = singular(name)
166
+ const base = `/${name}`
167
+ const member = `${base}/{${param}}`
168
+ const members = [
169
+ this.#push('GET', base, controller, 'index', `${name}.index`),
170
+ this.#push('GET', `${base}/create`, controller, 'create', `${name}.create`),
171
+ this.#push('POST', base, controller, 'store', `${name}.store`),
172
+ this.#push('GET', member, controller, 'show', `${name}.show`),
173
+ this.#push('GET', `${member}/edit`, controller, 'edit', `${name}.edit`),
174
+ this.#push('PUT', member, controller, 'update', `${name}.update`),
175
+ this.#push('DELETE', member, controller, 'destroy', `${name}.destroy`),
176
+ ]
177
+ return new ResourceBuilder(this, members)
178
+ }
179
+
180
+ /** Applies middleware to every route registered inside `group`. */
181
+ middleware(...names: string[]): { group: (callback: () => void) => void } {
182
+ return {
183
+ group: (callback) => {
184
+ const previous = this.#middleware
185
+ this.#middleware = [...previous, ...names]
186
+ callback()
187
+ this.#middleware = previous
188
+ },
189
+ }
190
+ }
191
+
192
+ /** Prefixes every route registered inside `group`. */
193
+ prefix(prefix: string): { group: (callback: () => void) => void } {
194
+ return {
195
+ group: (callback) => {
196
+ const previous = this.#prefix
197
+ this.#prefix = joinPath(previous, prefix.startsWith('/') ? prefix : `/${prefix}`)
198
+ callback()
199
+ this.#prefix = previous
200
+ },
201
+ }
202
+ }
203
+
204
+ /** Returns registered routes in declaration order. */
205
+ all(): readonly RouteDefinition<ControllerClass>[] {
206
+ return this.#definitions
207
+ }
208
+
209
+ /** Builds a mountable manifest from the current registrations. */
210
+ manifest(version = '1'): RouteManifest<ControllerClass> {
211
+ return { version, routes: this.all() }
212
+ }
213
+
214
+ /** Finds a named route or throws. */
215
+ find(name: string): RouteDefinition<ControllerClass> {
216
+ const match = this.#definitions.find((definition) => definition.name === name)
217
+ if (match === undefined) {
218
+ throw new NotFound(`Route ${name} was not found.`, {
219
+ metadata: { resource: 'route', identifier: name },
220
+ })
221
+ }
222
+ return match
223
+ }
224
+
225
+ /** Clears registrations. Intended for tests. */
226
+ reset(): void {
227
+ this.#definitions.length = 0
228
+ this.#middleware = []
229
+ this.#prefix = ''
230
+ }
231
+ }
232
+
233
+ /** Process-wide route registrar used by `routes/web.ts`. */
234
+ export const Route = new Registrar()
235
+
236
+ /**
237
+ * Resolves a named route to a path, filling `{param}` placeholders.
238
+ *
239
+ * `route('posts.show', id)` fills the first parameter. Pass an object when a route has several.
240
+ */
241
+ export function route(
242
+ name: string,
243
+ params?: string | number | Readonly<Record<string, string | number>>,
244
+ ): string {
245
+ const definition = Route.find(name)
246
+ const values =
247
+ params === undefined ? [] : typeof params === 'object' ? Object.values(params) : [params]
248
+ let index = 0
249
+ const path = definition.path.replace(/\{(\w+)\}/g, () => {
250
+ const value = values[index++]
251
+ if (value === undefined) {
252
+ throw new Invalid(`Missing parameter for route ${name}.`, {
253
+ metadata: { fields: { route: [`${name} is missing a path parameter.`] } },
254
+ })
255
+ }
256
+ return String(value)
257
+ })
258
+ return path
259
+ }
260
+
261
+ /** Stable generated export name for a write route. */
262
+ export function exportName(definition: RouteDefinition): string {
263
+ return definition.name.replace(/[^A-Za-z0-9]+/g, '_')
264
+ }
265
+
266
+ /** `/posts/{post}/edit` -> `posts/[post]/edit`. */
267
+ export function uriToSegments(uri: string): string {
268
+ return uri
269
+ .split('/')
270
+ .filter(Boolean)
271
+ .map((segment) =>
272
+ segment.startsWith('{') && segment.endsWith('}') ? `[${segment.slice(1, -1)}]` : segment,
273
+ )
274
+ .join('/')
275
+ }