flamefront 0.0.0 → 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/LICENSE.md +110 -0
- package/README.md +58 -0
- package/bin/ff-loader.mjs +18 -0
- package/bin/ff.js +6 -0
- package/package.json +84 -8
- package/src/babel.ts +22 -0
- package/src/cli.ts +98 -0
- package/src/entry.ts +49 -0
- package/src/fetch.ts +280 -0
- package/src/fragment-client.ts +271 -0
- package/src/fragment-hydration-client.tsx +81 -0
- package/src/fragment-protocol.ts +39 -0
- package/src/fragment.tsx +656 -0
- package/src/glob.ts +294 -0
- package/src/identifier-prefix.ts +5 -0
- package/src/index.ts +1168 -0
- package/src/lifecycle.ts +487 -0
- package/src/octane-client-core.ts +141 -0
- package/src/octane-client.ts +48 -0
- package/src/octane-compiler.d.ts +19 -0
- package/src/octane-default-renderer.tsx +124 -0
- package/src/octane-router-document.ts +22 -0
- package/src/octane.tsx +661 -0
- package/src/output.ts +48 -0
- package/src/remix-route-data.ts +76 -0
- package/src/remix-router-core.ts +106 -0
- package/src/remix-router.ts +117 -0
- package/src/remove-exports.ts +148 -0
- package/src/route-data-client.ts +234 -0
- package/src/route-prefetch.ts +79 -0
- package/src/server.ts +338 -0
- package/src/srvx.ts +174 -0
- package/src/static-fragment-artifacts.ts +86 -0
- package/src/typegen.ts +207 -0
- package/src/virtual-remix-routes.d.ts +35 -0
- package/src/vite.ts +1030 -0
- package/readme.md +0 -1
package/src/index.ts
ADDED
|
@@ -0,0 +1,1168 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createMultiMatcher,
|
|
3
|
+
type Match,
|
|
4
|
+
type MatchParams,
|
|
5
|
+
type MultiMatcher,
|
|
6
|
+
} from "@remix-run/route-pattern/match"
|
|
7
|
+
import {
|
|
8
|
+
createHref as createPatternHref,
|
|
9
|
+
type CreateHrefArgs,
|
|
10
|
+
} from "@remix-run/route-pattern/href"
|
|
11
|
+
import type { HydrationInteractionEvents } from "octane/hydration"
|
|
12
|
+
import { createRouteDataClient } from "./route-data-client.ts"
|
|
13
|
+
import { stripFlamefrontProtocolParams } from "./fragment-protocol.ts"
|
|
14
|
+
|
|
15
|
+
export { glob, joinRoutePath } from "./glob.ts"
|
|
16
|
+
export type { GlobFile } from "./glob.ts"
|
|
17
|
+
|
|
18
|
+
export type RenderMode = "client" | "server" | "static"
|
|
19
|
+
|
|
20
|
+
export interface RoutingOptions {
|
|
21
|
+
/** URL pathname prefix shared by app matching and generated routes. */
|
|
22
|
+
readonly basename?: string
|
|
23
|
+
/** Route-data endpoint pathname shared by browser loaders and the server. */
|
|
24
|
+
readonly dataPath?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface NormalizedRoutingOptions {
|
|
28
|
+
readonly basename: string
|
|
29
|
+
readonly dataPath: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface IdleHydration {
|
|
33
|
+
readonly when: "idle"
|
|
34
|
+
readonly timeout?: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface VisibleHydration {
|
|
38
|
+
readonly when: "visible"
|
|
39
|
+
readonly rootMargin?: string
|
|
40
|
+
readonly threshold?: number | readonly number[]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface InteractionHydration {
|
|
44
|
+
readonly when: "interaction"
|
|
45
|
+
readonly events?: HydrationInteractionEvents
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface MediaHydration {
|
|
49
|
+
readonly when: "media"
|
|
50
|
+
readonly query: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type GeneratedHydration =
|
|
54
|
+
IdleHydration | VisibleHydration | InteractionHydration | MediaHydration
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* `full` hydrates with the shell, `deferred` leaves boundaries to the route,
|
|
58
|
+
* `none` keeps server HTML inert, and an object generates one route boundary.
|
|
59
|
+
*/
|
|
60
|
+
export type HydrationMode = "full" | "deferred" | "none" | GeneratedHydration
|
|
61
|
+
|
|
62
|
+
/** App-level fallbacks for routes that do not declare a hydration policy. */
|
|
63
|
+
export interface HydrationDefaults {
|
|
64
|
+
readonly server?: HydrationMode
|
|
65
|
+
readonly static?: HydrationMode
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Fully resolved app-level hydration fallbacks. */
|
|
69
|
+
export interface NormalizedHydrationDefaults {
|
|
70
|
+
readonly server: HydrationMode
|
|
71
|
+
readonly static: HydrationMode
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type RouteNavigationStrategy = "router" | "fragment"
|
|
75
|
+
|
|
76
|
+
export type RouteBoundaryKind = "shell" | "layout" | "route"
|
|
77
|
+
|
|
78
|
+
/** Metadata emitted on generated router nodes for route-aware navigation. */
|
|
79
|
+
export interface GeneratedRouteMetadata {
|
|
80
|
+
readonly id: string
|
|
81
|
+
/** Stable token used by the route-fragment boundary pass. */
|
|
82
|
+
readonly boundary: string
|
|
83
|
+
readonly kind: RouteBoundaryKind
|
|
84
|
+
readonly entry: string
|
|
85
|
+
readonly parent?: string
|
|
86
|
+
readonly path?: string
|
|
87
|
+
readonly render?: RenderMode
|
|
88
|
+
readonly navigation: RouteNavigationStrategy
|
|
89
|
+
readonly hydration?: HydrationMode
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export type RouteContent = "component" | "markdown"
|
|
93
|
+
|
|
94
|
+
export interface RouteOptions {
|
|
95
|
+
/** The route entry's authored content shape. Omit for an Octane component. */
|
|
96
|
+
readonly content?: RouteContent
|
|
97
|
+
readonly render?: RenderMode
|
|
98
|
+
readonly hydration?: HydrationMode
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface RouteDefinition<
|
|
102
|
+
Path extends string = string,
|
|
103
|
+
Entry extends string = string,
|
|
104
|
+
> extends RouteOptions {
|
|
105
|
+
readonly path: Path
|
|
106
|
+
/** Octane/Vite project-root module ID, such as `/src/Home.tsrx`. */
|
|
107
|
+
readonly entry: Entry
|
|
108
|
+
readonly render: RenderMode
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface LayoutDefinition<
|
|
112
|
+
Children extends readonly RouteConfig[] = readonly RouteConfig[],
|
|
113
|
+
> {
|
|
114
|
+
readonly kind: "layout"
|
|
115
|
+
/** Octane/Vite project-root module ID for the pathless layout component. */
|
|
116
|
+
readonly entry: string
|
|
117
|
+
readonly children: Children
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export type RouteConfig = RouteDefinition | LayoutDefinition
|
|
121
|
+
|
|
122
|
+
/** Flatten nested route configuration into the leaf route union. */
|
|
123
|
+
export type RouteLeaf<Config> =
|
|
124
|
+
Config extends LayoutDefinition<infer Children>
|
|
125
|
+
? number extends Children["length"]
|
|
126
|
+
? RouteDefinition
|
|
127
|
+
: RouteLeaf<Children[number]>
|
|
128
|
+
: Config extends RouteDefinition
|
|
129
|
+
? Config
|
|
130
|
+
: never
|
|
131
|
+
|
|
132
|
+
export type RouteLeaves<Configs extends readonly RouteConfig[]> =
|
|
133
|
+
number extends Configs["length"]
|
|
134
|
+
? RouteDefinition
|
|
135
|
+
: RouteLeaf<Configs[number]>
|
|
136
|
+
|
|
137
|
+
export interface MatchRouteOptions {
|
|
138
|
+
readonly render?: RenderMode
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Route modules generated by Flamefront's optional declaration generator.
|
|
143
|
+
* Applications can use the authored route manifest without generated output;
|
|
144
|
+
* the empty base interface keeps that mode conservative.
|
|
145
|
+
*/
|
|
146
|
+
export interface RouteImportMap {}
|
|
147
|
+
|
|
148
|
+
/** Parameters used when a route pattern is not available to the type system. */
|
|
149
|
+
export type BroadRouteParams = Record<string, string | undefined>
|
|
150
|
+
|
|
151
|
+
type RouteImportMapPath = Extract<keyof RouteImportMap, string>
|
|
152
|
+
|
|
153
|
+
/** Route patterns emitted by Flamefront, or `string` before type generation. */
|
|
154
|
+
export type RoutePath = [RouteImportMapPath] extends [never]
|
|
155
|
+
? string
|
|
156
|
+
: RouteImportMapPath
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Parameters captured by a generated route pattern. A stale or missing map
|
|
160
|
+
* deliberately keeps the existing broad record behavior.
|
|
161
|
+
*/
|
|
162
|
+
type RouteParamsForKnownPath<Path extends string> = Path extends string
|
|
163
|
+
? Path extends RouteImportMapPath
|
|
164
|
+
? MatchParams<Path> extends infer Params
|
|
165
|
+
? [Params] extends [never]
|
|
166
|
+
? BroadRouteParams
|
|
167
|
+
: Params
|
|
168
|
+
: BroadRouteParams
|
|
169
|
+
: BroadRouteParams
|
|
170
|
+
: BroadRouteParams
|
|
171
|
+
|
|
172
|
+
export type RouteParams<Path extends string = string> = [
|
|
173
|
+
RouteImportMapPath,
|
|
174
|
+
] extends [never]
|
|
175
|
+
? BroadRouteParams
|
|
176
|
+
: string extends Path
|
|
177
|
+
? BroadRouteParams
|
|
178
|
+
: RouteParamsForKnownPath<Path>
|
|
179
|
+
|
|
180
|
+
/** The route module associated with a generated route pattern. */
|
|
181
|
+
export type RouteModuleFor<Path extends string = string> = [
|
|
182
|
+
RouteImportMapPath,
|
|
183
|
+
] extends [never]
|
|
184
|
+
? unknown
|
|
185
|
+
: Path extends RouteImportMapPath
|
|
186
|
+
? RouteImportMap[Path]
|
|
187
|
+
: unknown
|
|
188
|
+
|
|
189
|
+
type RouteLoaderFunction<Module> = Module extends {
|
|
190
|
+
readonly loader?: infer Loader
|
|
191
|
+
}
|
|
192
|
+
? Loader extends (...args: infer _Args) => infer Result
|
|
193
|
+
? (...args: _Args) => Result
|
|
194
|
+
: never
|
|
195
|
+
: never
|
|
196
|
+
|
|
197
|
+
/** The authored loader function associated with a generated route pattern. */
|
|
198
|
+
export type RouteLoaderFor<Path extends string = string> = RouteLoaderFunction<
|
|
199
|
+
RouteModuleFor<Path>
|
|
200
|
+
>
|
|
201
|
+
|
|
202
|
+
/** The awaited result of a generated route's loader, or `unknown` as fallback. */
|
|
203
|
+
export type RouteLoaderData<Path extends string = string> =
|
|
204
|
+
RouteLoaderFor<Path> extends (...args: infer _Args) => infer Result
|
|
205
|
+
? Awaited<Result>
|
|
206
|
+
: unknown
|
|
207
|
+
|
|
208
|
+
type RoutePageParams<Path extends RouteImportMapPath> =
|
|
209
|
+
keyof RouteParams<Path> extends never
|
|
210
|
+
? BroadRouteParams
|
|
211
|
+
: RouteParams<Path> & Record<string, string | undefined>
|
|
212
|
+
|
|
213
|
+
/** Registration shape for router libraries that expose typed route pages. */
|
|
214
|
+
export type RoutePages = [RouteImportMapPath] extends [never]
|
|
215
|
+
? Record<string, { readonly params: BroadRouteParams }>
|
|
216
|
+
: {
|
|
217
|
+
[Path in RouteImportMapPath]: {
|
|
218
|
+
readonly params: RoutePageParams<Path>
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
declare module "@octanejs/remix-router" {
|
|
223
|
+
interface Register {
|
|
224
|
+
pages: RoutePages
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export interface LoadRouteOptions {
|
|
229
|
+
readonly signal?: AbortSignal
|
|
230
|
+
readonly reload?: boolean
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
type TrimTrailingSlashes<Value extends string> = Value extends "/"
|
|
234
|
+
? Value
|
|
235
|
+
: Value extends `${infer Path}/`
|
|
236
|
+
? TrimTrailingSlashes<Path>
|
|
237
|
+
: Value
|
|
238
|
+
|
|
239
|
+
type RoutePathname<Value extends string> = Value extends `${string}?${string}`
|
|
240
|
+
? Value extends `${infer Path}?${string}`
|
|
241
|
+
? RoutePathname<Path>
|
|
242
|
+
: string
|
|
243
|
+
: Value extends `${string}#${string}`
|
|
244
|
+
? Value extends `${infer Path}#${string}`
|
|
245
|
+
? RoutePathname<Path>
|
|
246
|
+
: string
|
|
247
|
+
: Value extends `/${string}`
|
|
248
|
+
? TrimTrailingSlashes<Value>
|
|
249
|
+
: string
|
|
250
|
+
|
|
251
|
+
type RouteSegments<Value extends string> = Value extends `/${infer Rest}`
|
|
252
|
+
? Rest extends ""
|
|
253
|
+
? []
|
|
254
|
+
: Rest extends `${infer Head}/${infer Tail}`
|
|
255
|
+
? [Head, ...RouteSegments<`/${Tail}`>]
|
|
256
|
+
: [Rest]
|
|
257
|
+
: []
|
|
258
|
+
|
|
259
|
+
type RouteSegmentMatches<
|
|
260
|
+
Pattern extends string,
|
|
261
|
+
Value extends string,
|
|
262
|
+
> = Pattern extends `:${string}`
|
|
263
|
+
? true
|
|
264
|
+
: Pattern extends `*${string}`
|
|
265
|
+
? true
|
|
266
|
+
: Pattern extends `(${infer Optional})`
|
|
267
|
+
? RouteSegmentMatches<Optional, Value>
|
|
268
|
+
: Pattern extends Value
|
|
269
|
+
? true
|
|
270
|
+
: false
|
|
271
|
+
|
|
272
|
+
type RoutePatternMatches<
|
|
273
|
+
Pattern extends readonly string[],
|
|
274
|
+
Value extends readonly string[],
|
|
275
|
+
> = Pattern extends []
|
|
276
|
+
? Value extends []
|
|
277
|
+
? true
|
|
278
|
+
: false
|
|
279
|
+
: Pattern extends [infer Head extends string, ...infer Tail extends string[]]
|
|
280
|
+
? Head extends `*${string}`
|
|
281
|
+
? true
|
|
282
|
+
: Head extends `(${infer Optional})`
|
|
283
|
+
? RoutePatternMatches<[Optional, ...Tail], [...Value]> extends true
|
|
284
|
+
? true
|
|
285
|
+
: RoutePatternMatches<Tail, Value>
|
|
286
|
+
: Value extends [
|
|
287
|
+
infer ValueHead extends string,
|
|
288
|
+
...infer ValueTail extends string[],
|
|
289
|
+
]
|
|
290
|
+
? RouteSegmentMatches<Head, ValueHead> extends true
|
|
291
|
+
? RoutePatternMatches<Tail, ValueTail>
|
|
292
|
+
: false
|
|
293
|
+
: false
|
|
294
|
+
: false
|
|
295
|
+
|
|
296
|
+
type RouteMatchesUrl<
|
|
297
|
+
Pattern extends string,
|
|
298
|
+
Url extends string,
|
|
299
|
+
> = string extends Url
|
|
300
|
+
? true
|
|
301
|
+
: RoutePatternMatches<
|
|
302
|
+
RouteSegments<RoutePathname<Pattern>>,
|
|
303
|
+
RouteSegments<RoutePathname<Url>>
|
|
304
|
+
>
|
|
305
|
+
|
|
306
|
+
type MatchingRoutes<
|
|
307
|
+
Routes extends RouteDefinition,
|
|
308
|
+
Url extends string,
|
|
309
|
+
> = Routes extends RouteDefinition
|
|
310
|
+
? true extends RouteMatchesUrl<Routes["path"], Url>
|
|
311
|
+
? Routes
|
|
312
|
+
: never
|
|
313
|
+
: never
|
|
314
|
+
|
|
315
|
+
type ExactMatchingRoutes<
|
|
316
|
+
Routes extends RouteDefinition,
|
|
317
|
+
Url extends string,
|
|
318
|
+
> = Routes extends RouteDefinition
|
|
319
|
+
? Routes["path"] extends RoutePathname<Url>
|
|
320
|
+
? Routes
|
|
321
|
+
: never
|
|
322
|
+
: never
|
|
323
|
+
|
|
324
|
+
type MatchedRoutes<Routes extends RouteDefinition, Url extends string> = [
|
|
325
|
+
ExactMatchingRoutes<Routes, Url>,
|
|
326
|
+
] extends [never]
|
|
327
|
+
? MatchingRoutes<Routes, Url>
|
|
328
|
+
: ExactMatchingRoutes<Routes, Url>
|
|
329
|
+
|
|
330
|
+
type MatchingImportMapPaths<
|
|
331
|
+
Url extends string,
|
|
332
|
+
Paths extends string = RouteImportMapPath,
|
|
333
|
+
> = Paths extends string
|
|
334
|
+
? true extends RouteMatchesUrl<Paths, Url>
|
|
335
|
+
? Paths
|
|
336
|
+
: never
|
|
337
|
+
: never
|
|
338
|
+
|
|
339
|
+
type ExactImportMapPaths<
|
|
340
|
+
Url extends string,
|
|
341
|
+
Paths extends string = RouteImportMapPath,
|
|
342
|
+
> = Paths extends string
|
|
343
|
+
? Paths extends RoutePathname<Url>
|
|
344
|
+
? Paths
|
|
345
|
+
: never
|
|
346
|
+
: never
|
|
347
|
+
|
|
348
|
+
type ImportMapPathsForUrl<Url extends string> = [
|
|
349
|
+
ExactImportMapPaths<Url>,
|
|
350
|
+
] extends [never]
|
|
351
|
+
? MatchingImportMapPaths<Url>
|
|
352
|
+
: ExactImportMapPaths<Url>
|
|
353
|
+
|
|
354
|
+
type RoutesForUrl<Routes extends RouteDefinition, Url extends string> =
|
|
355
|
+
string extends RoutePathname<Url>
|
|
356
|
+
? Routes
|
|
357
|
+
: [MatchedRoutes<Routes, Url>] extends [never]
|
|
358
|
+
? Routes
|
|
359
|
+
: MatchedRoutes<Routes, Url>
|
|
360
|
+
|
|
361
|
+
/** A matched route whose params retain the authored route pattern. */
|
|
362
|
+
export type RouteMatch<Route extends RouteDefinition = RouteDefinition> = Omit<
|
|
363
|
+
Match<string, Route>,
|
|
364
|
+
"params"
|
|
365
|
+
> & {
|
|
366
|
+
readonly params: RouteParams<Route["path"]>
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
type BroadRouteMatch<Route extends RouteDefinition> = Omit<
|
|
370
|
+
RouteMatch<Route>,
|
|
371
|
+
"params"
|
|
372
|
+
> & {
|
|
373
|
+
readonly params: BroadRouteParams
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Match result selected from a route union and a statically known URL. */
|
|
377
|
+
export type RouteMatchForUrl<
|
|
378
|
+
Routes extends RouteDefinition,
|
|
379
|
+
Url extends string,
|
|
380
|
+
> = string extends Url
|
|
381
|
+
? BroadRouteMatch<Routes>
|
|
382
|
+
: string extends RoutePathname<Url>
|
|
383
|
+
? BroadRouteMatch<Routes>
|
|
384
|
+
: RouteMatch<RoutesForUrl<Routes, Url>>
|
|
385
|
+
|
|
386
|
+
/** Loader data selected from a route union and a statically known URL. */
|
|
387
|
+
export type RouteDataForUrl<
|
|
388
|
+
Routes extends RouteDefinition,
|
|
389
|
+
Url extends string,
|
|
390
|
+
> = string extends Url
|
|
391
|
+
? unknown
|
|
392
|
+
: string extends RoutePathname<Url>
|
|
393
|
+
? unknown
|
|
394
|
+
: [MatchedRoutes<Routes, Url>] extends [never]
|
|
395
|
+
? unknown
|
|
396
|
+
: MatchedRoutes<Routes, Url> extends infer Route
|
|
397
|
+
? Route extends RouteDefinition
|
|
398
|
+
? RouteLoaderData<Route["path"]>
|
|
399
|
+
: unknown
|
|
400
|
+
: unknown
|
|
401
|
+
|
|
402
|
+
/** Loader data selected directly from the generated map and a URL pathname. */
|
|
403
|
+
export type RouteDataForPath<Url extends string> = string extends Url
|
|
404
|
+
? unknown
|
|
405
|
+
: string extends RoutePathname<Url>
|
|
406
|
+
? unknown
|
|
407
|
+
: [RouteImportMapPath] extends [never]
|
|
408
|
+
? unknown
|
|
409
|
+
: [ImportMapPathsForUrl<Url>] extends [never]
|
|
410
|
+
? unknown
|
|
411
|
+
: ImportMapPathsForUrl<Url> extends infer Path
|
|
412
|
+
? Path extends string
|
|
413
|
+
? RouteLoaderData<Path>
|
|
414
|
+
: unknown
|
|
415
|
+
: unknown
|
|
416
|
+
|
|
417
|
+
/** A router destination constrained to generated route patterns when present. */
|
|
418
|
+
export type RouteDestination<Path extends string = RoutePath> =
|
|
419
|
+
| Path
|
|
420
|
+
| {
|
|
421
|
+
readonly pathname: Path
|
|
422
|
+
readonly search?: string
|
|
423
|
+
readonly hash?: string
|
|
424
|
+
readonly state?: unknown
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
export interface AppDefinition<T extends RouteDefinition = RouteDefinition> {
|
|
428
|
+
/** Octane/Vite project-root module ID for the persistent app shell. */
|
|
429
|
+
readonly shell: string
|
|
430
|
+
/** Hydration policy for the persistent shell region. */
|
|
431
|
+
readonly shellHydration: HydrationMode
|
|
432
|
+
readonly routes: readonly T[]
|
|
433
|
+
readonly routeTree: readonly RouteConfig[]
|
|
434
|
+
readonly hydrationDefaults: NormalizedHydrationDefaults
|
|
435
|
+
readonly routing: NormalizedRoutingOptions
|
|
436
|
+
readonly match: {
|
|
437
|
+
<const Url extends string>(
|
|
438
|
+
url: Url,
|
|
439
|
+
options?: MatchRouteOptions,
|
|
440
|
+
): RouteMatchForUrl<T, Url> | null
|
|
441
|
+
(url: URL, options?: MatchRouteOptions): RouteMatchForUrl<T, string> | null
|
|
442
|
+
(
|
|
443
|
+
url: string,
|
|
444
|
+
options?: MatchRouteOptions,
|
|
445
|
+
): RouteMatchForUrl<T, string> | null
|
|
446
|
+
(
|
|
447
|
+
url: string | URL,
|
|
448
|
+
options?: MatchRouteOptions,
|
|
449
|
+
): RouteMatchForUrl<T, string> | null
|
|
450
|
+
}
|
|
451
|
+
/** Load route data using the route's live or static data source. */
|
|
452
|
+
readonly load: {
|
|
453
|
+
<const Url extends string>(
|
|
454
|
+
url: Url,
|
|
455
|
+
options?: LoadRouteOptions,
|
|
456
|
+
): Promise<RouteDataForUrl<T, Url>>
|
|
457
|
+
<Data = unknown>(url: URL, options?: LoadRouteOptions): Promise<Data>
|
|
458
|
+
<Data = unknown>(url: string, options?: LoadRouteOptions): Promise<Data>
|
|
459
|
+
<Data = unknown>(
|
|
460
|
+
url: string | URL,
|
|
461
|
+
options?: LoadRouteOptions,
|
|
462
|
+
): Promise<Data>
|
|
463
|
+
}
|
|
464
|
+
/** Warm the same cache used by generated client route loaders. */
|
|
465
|
+
readonly prefetch: {
|
|
466
|
+
<const Url extends string>(
|
|
467
|
+
url: Url,
|
|
468
|
+
options?: LoadRouteOptions,
|
|
469
|
+
): Promise<void>
|
|
470
|
+
(url: URL, options?: LoadRouteOptions): Promise<void>
|
|
471
|
+
(url: string, options?: LoadRouteOptions): Promise<void>
|
|
472
|
+
(url: string | URL, options?: LoadRouteOptions): Promise<void>
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const defaultRoutingOptions: NormalizedRoutingOptions = Object.freeze({
|
|
477
|
+
basename: "/",
|
|
478
|
+
dataPath: "/__flamefront/data",
|
|
479
|
+
})
|
|
480
|
+
|
|
481
|
+
const defaultHydrationDefaults: NormalizedHydrationDefaults = Object.freeze({
|
|
482
|
+
server: "full",
|
|
483
|
+
static: "full",
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
const defaultShellHydration: HydrationMode = "full"
|
|
487
|
+
|
|
488
|
+
const renderModes: ReadonlySet<unknown> = new Set<RenderMode>([
|
|
489
|
+
"client",
|
|
490
|
+
"server",
|
|
491
|
+
"static",
|
|
492
|
+
])
|
|
493
|
+
const hydrationModes: ReadonlySet<unknown> = new Set([
|
|
494
|
+
"full",
|
|
495
|
+
"deferred",
|
|
496
|
+
"none",
|
|
497
|
+
])
|
|
498
|
+
const interactionEvents: ReadonlySet<string> = new Set([
|
|
499
|
+
"auxclick",
|
|
500
|
+
"beforeinput",
|
|
501
|
+
"click",
|
|
502
|
+
"compositionend",
|
|
503
|
+
"compositionstart",
|
|
504
|
+
"compositionupdate",
|
|
505
|
+
"contextmenu",
|
|
506
|
+
"dblclick",
|
|
507
|
+
"focusin",
|
|
508
|
+
"input",
|
|
509
|
+
"keydown",
|
|
510
|
+
"keyup",
|
|
511
|
+
"mousedown",
|
|
512
|
+
"mouseenter",
|
|
513
|
+
"mouseover",
|
|
514
|
+
"mouseup",
|
|
515
|
+
"pointerdown",
|
|
516
|
+
"pointerenter",
|
|
517
|
+
"pointerover",
|
|
518
|
+
"pointerup",
|
|
519
|
+
"touchend",
|
|
520
|
+
"touchstart",
|
|
521
|
+
])
|
|
522
|
+
const matcherCache = new WeakMap<
|
|
523
|
+
readonly RouteDefinition[],
|
|
524
|
+
Map<RenderMode | undefined, MultiMatcher<RouteDefinition>>
|
|
525
|
+
>()
|
|
526
|
+
|
|
527
|
+
function normalizeRoutingPath(
|
|
528
|
+
value: unknown,
|
|
529
|
+
name: string,
|
|
530
|
+
fallback: string,
|
|
531
|
+
): string {
|
|
532
|
+
const path = value ?? fallback
|
|
533
|
+
|
|
534
|
+
if (typeof path !== "string" || path.length === 0) {
|
|
535
|
+
throw new TypeError(
|
|
536
|
+
`flamefront routing ${name} must be a non-empty string.`,
|
|
537
|
+
)
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
if (!path.startsWith("/")) {
|
|
541
|
+
throw new TypeError(`flamefront routing ${name} must start with '/'.`)
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
if (path.includes("?") || path.includes("#")) {
|
|
545
|
+
throw new TypeError(
|
|
546
|
+
`flamefront routing ${name} must be a pathname without a query or hash.`,
|
|
547
|
+
)
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
return path.replace(/\/+$/, "") || "/"
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function validateHydrationDefault(
|
|
554
|
+
hydration: unknown,
|
|
555
|
+
render: "server" | "static",
|
|
556
|
+
): void {
|
|
557
|
+
validateHydrationMode(hydration, render, `hydrationDefaults.${render}`)
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function normalizeHydrationDefaults(
|
|
561
|
+
options: HydrationDefaults | undefined = undefined,
|
|
562
|
+
): NormalizedHydrationDefaults {
|
|
563
|
+
if (
|
|
564
|
+
options !== undefined &&
|
|
565
|
+
(!options || typeof options !== "object" || Array.isArray(options))
|
|
566
|
+
) {
|
|
567
|
+
throw new TypeError("flamefront hydrationDefaults must be an object.")
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
if (options) {
|
|
571
|
+
const unexpected = Object.keys(options).find(
|
|
572
|
+
(key) => key !== "server" && key !== "static",
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
if (unexpected) {
|
|
576
|
+
throw new TypeError(
|
|
577
|
+
`flamefront hydrationDefaults has an unexpected ${JSON.stringify(unexpected)} option.`,
|
|
578
|
+
)
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const server = options?.server ?? defaultHydrationDefaults.server
|
|
583
|
+
const staticMode = options?.static ?? defaultHydrationDefaults.static
|
|
584
|
+
|
|
585
|
+
validateHydrationDefault(server, "server")
|
|
586
|
+
validateHydrationDefault(staticMode, "static")
|
|
587
|
+
|
|
588
|
+
return Object.freeze({
|
|
589
|
+
server: freezeHydration(server) as HydrationMode,
|
|
590
|
+
static: freezeHydration(staticMode) as HydrationMode,
|
|
591
|
+
})
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function normalizeShellHydration(
|
|
595
|
+
hydration: HydrationMode | undefined,
|
|
596
|
+
): HydrationMode {
|
|
597
|
+
const normalized = hydration ?? defaultShellHydration
|
|
598
|
+
|
|
599
|
+
validateHydrationMode(normalized, "server", "shellHydration")
|
|
600
|
+
return freezeHydration(normalized) as HydrationMode
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
export function normalizeRoutingOptions(
|
|
604
|
+
options: RoutingOptions | undefined = undefined,
|
|
605
|
+
): NormalizedRoutingOptions {
|
|
606
|
+
if (options !== undefined && (!options || typeof options !== "object")) {
|
|
607
|
+
throw new TypeError("flamefront routing options must be an object.")
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
return Object.freeze({
|
|
611
|
+
basename: normalizeRoutingPath(
|
|
612
|
+
options?.basename,
|
|
613
|
+
"basename",
|
|
614
|
+
defaultRoutingOptions.basename,
|
|
615
|
+
),
|
|
616
|
+
dataPath: normalizeRoutingPath(
|
|
617
|
+
options?.dataPath,
|
|
618
|
+
"dataPath",
|
|
619
|
+
defaultRoutingOptions.dataPath,
|
|
620
|
+
),
|
|
621
|
+
})
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/** Remove the normalized app basename from a request pathname. */
|
|
625
|
+
export function stripBasename(
|
|
626
|
+
pathname: string,
|
|
627
|
+
basename: string,
|
|
628
|
+
): string | null {
|
|
629
|
+
if (basename === "/") {
|
|
630
|
+
return pathname
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
if (pathname === basename) {
|
|
634
|
+
return "/"
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
if (!pathname.startsWith(`${basename}/`)) {
|
|
638
|
+
return null
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
return pathname.slice(basename.length) || "/"
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/** Prefix an app-relative route path with the normalized app basename. */
|
|
645
|
+
export function joinBasename(basename: string, pathname: string): string {
|
|
646
|
+
if (basename === "/") {
|
|
647
|
+
return pathname || "/"
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
if (pathname === "/") {
|
|
651
|
+
return basename
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
return `${basename}${pathname.startsWith("/") ? pathname : `/${pathname}`}`
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/** Build a concrete URL from one generated route pattern. */
|
|
658
|
+
export function routeHref<const Path extends RoutePath>(
|
|
659
|
+
path: Path,
|
|
660
|
+
...args: CreateHrefArgs<Path>
|
|
661
|
+
): string {
|
|
662
|
+
return createPatternHref(path, ...args)
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/** Alias for callers that prefer the `create*` naming convention. */
|
|
666
|
+
export const createRouteHref = routeHref
|
|
667
|
+
|
|
668
|
+
function assertString(value: unknown, name: string): asserts value is string {
|
|
669
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
670
|
+
throw new TypeError(`flamefront ${name} must be a non-empty string.`)
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function assertOnlyKeys(
|
|
675
|
+
value: Record<string, unknown>,
|
|
676
|
+
keys: readonly string[],
|
|
677
|
+
location: string,
|
|
678
|
+
): void {
|
|
679
|
+
const allowed = new Set(keys)
|
|
680
|
+
const unexpected = Object.keys(value).find((key) => !allowed.has(key))
|
|
681
|
+
|
|
682
|
+
if (unexpected) {
|
|
683
|
+
throw new TypeError(
|
|
684
|
+
`flamefront route ${location} hydration has an unexpected ${JSON.stringify(unexpected)} option.`,
|
|
685
|
+
)
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
function assertThreshold(value: unknown, location: string): void {
|
|
690
|
+
const thresholds = Array.isArray(value) ? value : [value]
|
|
691
|
+
|
|
692
|
+
if (
|
|
693
|
+
thresholds.length === 0 ||
|
|
694
|
+
thresholds.some(
|
|
695
|
+
(threshold) =>
|
|
696
|
+
typeof threshold !== "number" ||
|
|
697
|
+
!Number.isFinite(threshold) ||
|
|
698
|
+
threshold < 0 ||
|
|
699
|
+
threshold > 1,
|
|
700
|
+
)
|
|
701
|
+
) {
|
|
702
|
+
throw new TypeError(
|
|
703
|
+
`flamefront route ${location} hydration threshold must contain numbers from 0 through 1.`,
|
|
704
|
+
)
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function validateGeneratedHydration(
|
|
709
|
+
hydration: Record<string, unknown>,
|
|
710
|
+
location: string,
|
|
711
|
+
): void {
|
|
712
|
+
switch (hydration.when) {
|
|
713
|
+
case "idle":
|
|
714
|
+
assertOnlyKeys(hydration, ["when", "timeout"], location)
|
|
715
|
+
if (
|
|
716
|
+
hydration.timeout !== undefined &&
|
|
717
|
+
(typeof hydration.timeout !== "number" ||
|
|
718
|
+
!Number.isFinite(hydration.timeout) ||
|
|
719
|
+
hydration.timeout < 0)
|
|
720
|
+
) {
|
|
721
|
+
throw new TypeError(
|
|
722
|
+
`flamefront route ${location} hydration timeout must be a non-negative number.`,
|
|
723
|
+
)
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
return
|
|
727
|
+
case "visible":
|
|
728
|
+
assertOnlyKeys(hydration, ["when", "rootMargin", "threshold"], location)
|
|
729
|
+
if (hydration.rootMargin !== undefined) {
|
|
730
|
+
assertString(
|
|
731
|
+
hydration.rootMargin,
|
|
732
|
+
`route ${location} hydration rootMargin`,
|
|
733
|
+
)
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
if (hydration.threshold !== undefined) {
|
|
737
|
+
assertThreshold(hydration.threshold, location)
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
return
|
|
741
|
+
case "interaction": {
|
|
742
|
+
assertOnlyKeys(hydration, ["when", "events"], location)
|
|
743
|
+
if (hydration.events === undefined) {
|
|
744
|
+
return
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
const events = Array.isArray(hydration.events)
|
|
748
|
+
? hydration.events
|
|
749
|
+
: [hydration.events]
|
|
750
|
+
|
|
751
|
+
if (
|
|
752
|
+
events.length === 0 ||
|
|
753
|
+
events.some(
|
|
754
|
+
(event) => typeof event !== "string" || !interactionEvents.has(event),
|
|
755
|
+
)
|
|
756
|
+
) {
|
|
757
|
+
throw new TypeError(
|
|
758
|
+
`flamefront route ${location} hydration events must be supported Octane interaction events.`,
|
|
759
|
+
)
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
return
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
case "media":
|
|
766
|
+
assertOnlyKeys(hydration, ["when", "query"], location)
|
|
767
|
+
assertString(hydration.query, `route ${location} hydration query`)
|
|
768
|
+
return
|
|
769
|
+
default:
|
|
770
|
+
throw new TypeError(
|
|
771
|
+
`flamefront route ${location} hydration trigger must be 'idle', 'visible', 'interaction', or 'media'.`,
|
|
772
|
+
)
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
function validateHydrationMode(
|
|
777
|
+
hydration: unknown,
|
|
778
|
+
render: RenderMode,
|
|
779
|
+
location: string,
|
|
780
|
+
): void {
|
|
781
|
+
if (hydration === undefined) {
|
|
782
|
+
return
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
if (
|
|
786
|
+
typeof hydration === "object" &&
|
|
787
|
+
hydration !== null &&
|
|
788
|
+
!Array.isArray(hydration)
|
|
789
|
+
) {
|
|
790
|
+
validateGeneratedHydration(
|
|
791
|
+
hydration as unknown as Record<string, unknown>,
|
|
792
|
+
location,
|
|
793
|
+
)
|
|
794
|
+
if (render !== "server" && render !== "static") {
|
|
795
|
+
throw new TypeError(
|
|
796
|
+
`flamefront route ${location} generated hydration requires render: 'server' or 'static'.`,
|
|
797
|
+
)
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
return
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
if (!hydrationModes.has(hydration)) {
|
|
804
|
+
throw new TypeError(
|
|
805
|
+
`flamefront route ${location} hydration must be 'full', 'deferred', 'none', or a trigger object.`,
|
|
806
|
+
)
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
if (render === "client" && hydration !== "full") {
|
|
810
|
+
throw new TypeError(
|
|
811
|
+
`flamefront route ${location} client hydration can only be 'full'.`,
|
|
812
|
+
)
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
function validateHydration(
|
|
817
|
+
routeDefinition: RouteDefinition,
|
|
818
|
+
location: string,
|
|
819
|
+
): void {
|
|
820
|
+
validateHydrationMode(
|
|
821
|
+
routeDefinition.hydration,
|
|
822
|
+
routeDefinition.render,
|
|
823
|
+
location,
|
|
824
|
+
)
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function freezeHydration(
|
|
828
|
+
hydration: HydrationMode | undefined,
|
|
829
|
+
): HydrationMode | undefined {
|
|
830
|
+
if (typeof hydration !== "object" || hydration === null) {
|
|
831
|
+
return hydration
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
if (hydration.when === "visible" && Array.isArray(hydration.threshold)) {
|
|
835
|
+
return Object.freeze({
|
|
836
|
+
...hydration,
|
|
837
|
+
threshold: Object.freeze([...hydration.threshold]),
|
|
838
|
+
})
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
if (hydration.when === "interaction" && Array.isArray(hydration.events)) {
|
|
842
|
+
return Object.freeze({
|
|
843
|
+
...hydration,
|
|
844
|
+
events: Object.freeze([...hydration.events]),
|
|
845
|
+
})
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
return Object.freeze({ ...hydration })
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
function validateRoute(
|
|
852
|
+
routeDefinition: RouteDefinition,
|
|
853
|
+
location: string,
|
|
854
|
+
): void {
|
|
855
|
+
if (!routeDefinition || typeof routeDefinition !== "object") {
|
|
856
|
+
throw new TypeError(`flamefront route ${location} must be an object.`)
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
assertString(routeDefinition.path, `route ${location} path`)
|
|
860
|
+
if (!routeDefinition.path.startsWith("/")) {
|
|
861
|
+
throw new TypeError(
|
|
862
|
+
`flamefront route ${location} path must start with '/'.`,
|
|
863
|
+
)
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
assertString(routeDefinition.entry, `route ${location} entry`)
|
|
867
|
+
|
|
868
|
+
if (
|
|
869
|
+
routeDefinition.content !== undefined &&
|
|
870
|
+
routeDefinition.content !== "component" &&
|
|
871
|
+
routeDefinition.content !== "markdown"
|
|
872
|
+
) {
|
|
873
|
+
throw new TypeError(
|
|
874
|
+
`flamefront route ${location} content must be 'component' or 'markdown'.`,
|
|
875
|
+
)
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
if (!renderModes.has(routeDefinition.render)) {
|
|
879
|
+
throw new TypeError(
|
|
880
|
+
`flamefront route ${location} render must be 'client', 'server', or 'static'.`,
|
|
881
|
+
)
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
validateHydration(routeDefinition, location)
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/** Define one explicit route without relying on a filesystem convention. */
|
|
888
|
+
export function route<const Path extends string, const Entry extends string>(
|
|
889
|
+
path: Path,
|
|
890
|
+
entry: Entry,
|
|
891
|
+
options: RouteOptions = {},
|
|
892
|
+
): RouteDefinition<Path, Entry> {
|
|
893
|
+
const definition: RouteDefinition<Path, Entry> = {
|
|
894
|
+
path,
|
|
895
|
+
entry,
|
|
896
|
+
...options,
|
|
897
|
+
hydration: freezeHydration(options.hydration),
|
|
898
|
+
render: options.render ?? "server",
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
validateRoute(definition, "1")
|
|
902
|
+
return Object.freeze(definition)
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/** Shorthand for a route with `render: "server"`. */
|
|
906
|
+
export function serverRoute<
|
|
907
|
+
const Path extends string,
|
|
908
|
+
const Entry extends string,
|
|
909
|
+
>(
|
|
910
|
+
path: Path,
|
|
911
|
+
entry: Entry,
|
|
912
|
+
options: Omit<RouteOptions, "render"> = {},
|
|
913
|
+
): RouteDefinition<Path, Entry> {
|
|
914
|
+
return route(path, entry, { ...options, render: "server" })
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
/** Shorthand for a route with `render: "static"`. */
|
|
918
|
+
export function staticRoute<
|
|
919
|
+
const Path extends string,
|
|
920
|
+
const Entry extends string,
|
|
921
|
+
>(
|
|
922
|
+
path: Path,
|
|
923
|
+
entry: Entry,
|
|
924
|
+
options: Omit<RouteOptions, "render"> = {},
|
|
925
|
+
): RouteDefinition<Path, Entry> {
|
|
926
|
+
return route(path, entry, { ...options, render: "static" })
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
/** Shorthand for a static route backed by a Sätteri `.md` entry. */
|
|
930
|
+
export function markdownRoute<
|
|
931
|
+
const Path extends string,
|
|
932
|
+
const Entry extends string,
|
|
933
|
+
>(
|
|
934
|
+
path: Path,
|
|
935
|
+
entry: Entry,
|
|
936
|
+
options: Omit<RouteOptions, "content"> = {},
|
|
937
|
+
): RouteDefinition<Path, Entry> & { readonly content: "markdown" } {
|
|
938
|
+
return route(path, entry, {
|
|
939
|
+
...options,
|
|
940
|
+
content: "markdown",
|
|
941
|
+
render: options.render ?? "static",
|
|
942
|
+
}) as RouteDefinition<Path, Entry> & { readonly content: "markdown" }
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/** Shorthand for a route with `render: "client"`. */
|
|
946
|
+
export function clientRoute<
|
|
947
|
+
const Path extends string,
|
|
948
|
+
const Entry extends string,
|
|
949
|
+
>(
|
|
950
|
+
path: Path,
|
|
951
|
+
entry: Entry,
|
|
952
|
+
options: Omit<RouteOptions, "render"> = {},
|
|
953
|
+
): RouteDefinition<Path, Entry> {
|
|
954
|
+
return route(path, entry, { ...options, render: "client" })
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/** Group routes beneath a shared pathless layout without adding a URL segment. */
|
|
958
|
+
export function layout<const Children extends readonly RouteConfig[]>(
|
|
959
|
+
entry: string,
|
|
960
|
+
children: Children,
|
|
961
|
+
): LayoutDefinition<Children> {
|
|
962
|
+
assertString(entry, "layout entry")
|
|
963
|
+
if (!Array.isArray(children)) {
|
|
964
|
+
throw new TypeError("flamefront layout children must be an array.")
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
return Object.freeze({
|
|
968
|
+
kind: "layout" as const,
|
|
969
|
+
entry,
|
|
970
|
+
children: Object.freeze([...children]) as unknown as Children,
|
|
971
|
+
})
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
function isLayoutDefinition(config: RouteConfig): config is LayoutDefinition {
|
|
975
|
+
return "kind" in config && config.kind === "layout"
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
function normalizeRouteTree(
|
|
979
|
+
configs: readonly RouteConfig[],
|
|
980
|
+
seenPaths: Set<string>,
|
|
981
|
+
hydrationDefaults: NormalizedHydrationDefaults,
|
|
982
|
+
location = "",
|
|
983
|
+
): { tree: readonly RouteConfig[]; routes: readonly RouteDefinition[] } {
|
|
984
|
+
const routes: RouteDefinition[] = []
|
|
985
|
+
const tree = configs.map((config, index): RouteConfig => {
|
|
986
|
+
const configLocation = location
|
|
987
|
+
? `${location}.${index + 1}`
|
|
988
|
+
: `${index + 1}`
|
|
989
|
+
|
|
990
|
+
if (!config || typeof config !== "object") {
|
|
991
|
+
throw new TypeError(
|
|
992
|
+
`flamefront route ${configLocation} must be an object.`,
|
|
993
|
+
)
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
if (isLayoutDefinition(config)) {
|
|
997
|
+
assertString(config.entry, `layout ${configLocation} entry`)
|
|
998
|
+
if (!Array.isArray(config.children)) {
|
|
999
|
+
throw new TypeError(
|
|
1000
|
+
`flamefront layout ${configLocation} children must be an array.`,
|
|
1001
|
+
)
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
const normalized = normalizeRouteTree(
|
|
1005
|
+
config.children,
|
|
1006
|
+
seenPaths,
|
|
1007
|
+
hydrationDefaults,
|
|
1008
|
+
configLocation,
|
|
1009
|
+
)
|
|
1010
|
+
|
|
1011
|
+
routes.push(...normalized.routes)
|
|
1012
|
+
return Object.freeze({
|
|
1013
|
+
kind: "layout" as const,
|
|
1014
|
+
entry: config.entry,
|
|
1015
|
+
children: normalized.tree,
|
|
1016
|
+
})
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
const hydration =
|
|
1020
|
+
config.hydration ??
|
|
1021
|
+
(config.render === "client" ? "full" : hydrationDefaults[config.render])
|
|
1022
|
+
const normalizedRoute = {
|
|
1023
|
+
...config,
|
|
1024
|
+
hydration: freezeHydration(hydration),
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
validateRoute(normalizedRoute, configLocation)
|
|
1028
|
+
if (seenPaths.has(config.path)) {
|
|
1029
|
+
throw new TypeError(`flamefront route path is duplicated: ${config.path}`)
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
seenPaths.add(config.path)
|
|
1033
|
+
const frozenRoute = Object.freeze(normalizedRoute)
|
|
1034
|
+
|
|
1035
|
+
routes.push(frozenRoute)
|
|
1036
|
+
return frozenRoute
|
|
1037
|
+
})
|
|
1038
|
+
|
|
1039
|
+
return { tree: Object.freeze(tree), routes: Object.freeze(routes) }
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
function createRouteMatcher<T extends RouteDefinition>(
|
|
1043
|
+
routes: readonly T[],
|
|
1044
|
+
render?: RenderMode,
|
|
1045
|
+
): MultiMatcher<T> {
|
|
1046
|
+
const matcher = createMultiMatcher<T>()
|
|
1047
|
+
|
|
1048
|
+
for (const routeDefinition of routes) {
|
|
1049
|
+
if (render === undefined || routeDefinition.render === render) {
|
|
1050
|
+
matcher.add(routeDefinition.path, routeDefinition)
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
return matcher
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
function matchRoutes<T extends RouteDefinition>(
|
|
1058
|
+
routes: readonly T[],
|
|
1059
|
+
url: string | URL,
|
|
1060
|
+
options: MatchRouteOptions = {},
|
|
1061
|
+
basename = "/",
|
|
1062
|
+
): RouteMatch<T> | null {
|
|
1063
|
+
let matchers = matcherCache.get(routes)
|
|
1064
|
+
|
|
1065
|
+
if (!matchers) {
|
|
1066
|
+
matchers = new Map()
|
|
1067
|
+
matcherCache.set(routes, matchers)
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
let matcher = matchers.get(options.render) as MultiMatcher<T> | undefined
|
|
1071
|
+
|
|
1072
|
+
if (!matcher) {
|
|
1073
|
+
matcher = createRouteMatcher(routes, options.render)
|
|
1074
|
+
matchers.set(options.render, matcher as MultiMatcher<RouteDefinition>)
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
const normalizedUrl = stripFlamefrontProtocolParams(url)
|
|
1078
|
+
const appPathname = stripBasename(normalizedUrl.pathname, basename)
|
|
1079
|
+
|
|
1080
|
+
if (appPathname === null) {
|
|
1081
|
+
return null
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
normalizedUrl.pathname = appPathname
|
|
1085
|
+
if (normalizedUrl.pathname.length > 1) {
|
|
1086
|
+
normalizedUrl.pathname = normalizedUrl.pathname.replace(/\/+$/, "")
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
return matcher.match(normalizedUrl) as RouteMatch<T> | null
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
/** Normalize and validate the application's explicit route graph. */
|
|
1093
|
+
export function defineApp<
|
|
1094
|
+
const T extends {
|
|
1095
|
+
readonly shell: string
|
|
1096
|
+
readonly routes: readonly RouteConfig[]
|
|
1097
|
+
readonly shellHydration?: HydrationMode
|
|
1098
|
+
readonly hydrationDefaults?: HydrationDefaults
|
|
1099
|
+
readonly routing?: RoutingOptions
|
|
1100
|
+
},
|
|
1101
|
+
>(
|
|
1102
|
+
options: T,
|
|
1103
|
+
): Omit<T, "routes" | "routing" | "hydrationDefaults" | "shellHydration"> &
|
|
1104
|
+
AppDefinition<RouteLeaves<T["routes"]>> {
|
|
1105
|
+
if (
|
|
1106
|
+
!options ||
|
|
1107
|
+
typeof options !== "object" ||
|
|
1108
|
+
!Array.isArray(options.routes)
|
|
1109
|
+
) {
|
|
1110
|
+
throw new TypeError("flamefront defineApp() requires a routes array.")
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
assertString(options.shell, "app shell entry")
|
|
1114
|
+
|
|
1115
|
+
const hydrationDefaults = normalizeHydrationDefaults(
|
|
1116
|
+
options.hydrationDefaults,
|
|
1117
|
+
)
|
|
1118
|
+
const shellHydration = normalizeShellHydration(options.shellHydration)
|
|
1119
|
+
const normalized = normalizeRouteTree(
|
|
1120
|
+
options.routes,
|
|
1121
|
+
new Set(),
|
|
1122
|
+
hydrationDefaults,
|
|
1123
|
+
)
|
|
1124
|
+
|
|
1125
|
+
type AppRoute = RouteLeaves<T["routes"]>
|
|
1126
|
+
const frozenRoutes = normalized.routes as readonly AppRoute[]
|
|
1127
|
+
const routing = normalizeRoutingOptions(options.routing)
|
|
1128
|
+
const routeDataClient = createRouteDataClient(routing)
|
|
1129
|
+
const load = ((url: string | URL, loadOptions: LoadRouteOptions = {}) => {
|
|
1130
|
+
const match = matchRoutes(frozenRoutes, url, {}, routing.basename)
|
|
1131
|
+
const source = match?.data.render === "static" ? "static" : "live"
|
|
1132
|
+
|
|
1133
|
+
return routeDataClient.load(url, source, loadOptions)
|
|
1134
|
+
}) as AppDefinition<AppRoute>["load"]
|
|
1135
|
+
|
|
1136
|
+
const app = Object.freeze({
|
|
1137
|
+
...options,
|
|
1138
|
+
shellHydration,
|
|
1139
|
+
hydrationDefaults,
|
|
1140
|
+
routes: frozenRoutes,
|
|
1141
|
+
routeTree: normalized.tree,
|
|
1142
|
+
routing,
|
|
1143
|
+
match: ((url: string | URL, matchOptions?: MatchRouteOptions) =>
|
|
1144
|
+
matchRoutes(
|
|
1145
|
+
frozenRoutes,
|
|
1146
|
+
url,
|
|
1147
|
+
matchOptions,
|
|
1148
|
+
routing.basename,
|
|
1149
|
+
)) as AppDefinition<AppRoute>["match"],
|
|
1150
|
+
load,
|
|
1151
|
+
prefetch: (async (url: string | URL, loadOptions?: LoadRouteOptions) => {
|
|
1152
|
+
const match = matchRoutes(frozenRoutes, url, {}, routing.basename)
|
|
1153
|
+
const source = match?.data.render === "static" ? "static" : "live"
|
|
1154
|
+
|
|
1155
|
+
await routeDataClient.load(url, source, loadOptions)
|
|
1156
|
+
}) as AppDefinition<AppRoute>["prefetch"],
|
|
1157
|
+
}) as Omit<T, "routes" | "routing" | "hydrationDefaults" | "shellHydration"> &
|
|
1158
|
+
AppDefinition<AppRoute>
|
|
1159
|
+
|
|
1160
|
+
matcherCache.set(
|
|
1161
|
+
frozenRoutes,
|
|
1162
|
+
new Map([[undefined, createRouteMatcher(frozenRoutes)]]) as Map<
|
|
1163
|
+
RenderMode | undefined,
|
|
1164
|
+
MultiMatcher<RouteDefinition>
|
|
1165
|
+
>,
|
|
1166
|
+
)
|
|
1167
|
+
return app
|
|
1168
|
+
}
|