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/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
+ }