flamefront 0.1.0-alpha.0 → 0.1.1

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 CHANGED
@@ -1,12 +1,20 @@
1
1
  import {
2
2
  createMultiMatcher,
3
3
  type Match,
4
+ type MatchParams,
4
5
  type MultiMatcher,
5
6
  } from "@remix-run/route-pattern/match"
7
+ import {
8
+ createHref as createPatternHref,
9
+ type CreateHrefArgs,
10
+ } from "@remix-run/route-pattern/href"
6
11
  import type { HydrationInteractionEvents } from "octane/hydration"
7
12
  import { createRouteDataClient } from "./route-data-client.ts"
8
13
  import { stripFlamefrontProtocolParams } from "./fragment-protocol.ts"
9
14
 
15
+ export { glob, joinRoutePath } from "./glob.ts"
16
+ export type { GlobFile } from "./glob.ts"
17
+
10
18
  export type RenderMode = "client" | "server" | "static"
11
19
 
12
20
  export interface RoutingOptions {
@@ -51,6 +59,18 @@ export type GeneratedHydration =
51
59
  */
52
60
  export type HydrationMode = "full" | "deferred" | "none" | GeneratedHydration
53
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
+
54
74
  export type RouteNavigationStrategy = "router" | "fragment"
55
75
 
56
76
  export type RouteBoundaryKind = "shell" | "layout" | "route"
@@ -58,7 +78,7 @@ export type RouteBoundaryKind = "shell" | "layout" | "route"
58
78
  /** Metadata emitted on generated router nodes for route-aware navigation. */
59
79
  export interface GeneratedRouteMetadata {
60
80
  readonly id: string
61
- /** Stable token used by the static-fragment boundary pass. */
81
+ /** Stable token used by the route-fragment boundary pass. */
62
82
  readonly boundary: string
63
83
  readonly kind: RouteBoundaryKind
64
84
  readonly entry: string
@@ -69,15 +89,22 @@ export interface GeneratedRouteMetadata {
69
89
  readonly hydration?: HydrationMode
70
90
  }
71
91
 
92
+ export type RouteContent = "component" | "markdown"
93
+
72
94
  export interface RouteOptions {
95
+ /** The route entry's authored content shape. Omit for an Octane component. */
96
+ readonly content?: RouteContent
73
97
  readonly render?: RenderMode
74
98
  readonly hydration?: HydrationMode
75
99
  }
76
100
 
77
- export interface RouteDefinition extends RouteOptions {
78
- readonly path: string
101
+ export interface RouteDefinition<
102
+ Path extends string = string,
103
+ Entry extends string = string,
104
+ > extends RouteOptions {
105
+ readonly path: Path
79
106
  /** Octane/Vite project-root module ID, such as `/src/Home.tsrx`. */
80
- readonly entry: string
107
+ readonly entry: Entry
81
108
  readonly render: RenderMode
82
109
  }
83
110
 
@@ -92,35 +119,358 @@ export interface LayoutDefinition<
92
119
 
93
120
  export type RouteConfig = RouteDefinition | LayoutDefinition
94
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
+
95
137
  export interface MatchRouteOptions {
96
138
  readonly render?: RenderMode
97
139
  }
98
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
+
99
228
  export interface LoadRouteOptions {
100
229
  readonly signal?: AbortSignal
101
230
  readonly reload?: boolean
102
231
  }
103
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
+
104
427
  export interface AppDefinition<T extends RouteDefinition = RouteDefinition> {
105
428
  /** Octane/Vite project-root module ID for the persistent app shell. */
106
429
  readonly shell: string
430
+ /** Hydration policy for the persistent shell region. */
431
+ readonly shellHydration: HydrationMode
107
432
  readonly routes: readonly T[]
108
433
  readonly routeTree: readonly RouteConfig[]
434
+ readonly hydrationDefaults: NormalizedHydrationDefaults
109
435
  readonly routing: NormalizedRoutingOptions
110
- readonly match: (
111
- url: string | URL,
112
- options?: MatchRouteOptions,
113
- ) => Match<string, T> | null
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
+ }
114
451
  /** Load route data using the route's live or static data source. */
115
- readonly load: <Data = unknown>(
116
- url: string | URL,
117
- options?: LoadRouteOptions,
118
- ) => Promise<Data>
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
+ }
119
464
  /** Warm the same cache used by generated client route loaders. */
120
- readonly prefetch: (
121
- url: string | URL,
122
- options?: LoadRouteOptions,
123
- ) => Promise<void>
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
+ }
124
474
  }
125
475
 
126
476
  const defaultRoutingOptions: NormalizedRoutingOptions = Object.freeze({
@@ -128,6 +478,13 @@ const defaultRoutingOptions: NormalizedRoutingOptions = Object.freeze({
128
478
  dataPath: "/__flamefront/data",
129
479
  })
130
480
 
481
+ const defaultHydrationDefaults: NormalizedHydrationDefaults = Object.freeze({
482
+ server: "full",
483
+ static: "full",
484
+ })
485
+
486
+ const defaultShellHydration: HydrationMode = "full"
487
+
131
488
  const renderModes: ReadonlySet<unknown> = new Set<RenderMode>([
132
489
  "client",
133
490
  "server",
@@ -193,6 +550,56 @@ function normalizeRoutingPath(
193
550
  return path.replace(/\/+$/, "") || "/"
194
551
  }
195
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
+
196
603
  export function normalizeRoutingOptions(
197
604
  options: RoutingOptions | undefined = undefined,
198
605
  ): NormalizedRoutingOptions {
@@ -247,6 +654,17 @@ export function joinBasename(basename: string, pathname: string): string {
247
654
  return `${basename}${pathname.startsWith("/") ? pathname : `/${pathname}`}`
248
655
  }
249
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
+
250
668
  function assertString(value: unknown, name: string): asserts value is string {
251
669
  if (typeof value !== "string" || value.length === 0) {
252
670
  throw new TypeError(`flamefront ${name} must be a non-empty string.`)
@@ -355,12 +773,11 @@ function validateGeneratedHydration(
355
773
  }
356
774
  }
357
775
 
358
- function validateHydration(
359
- routeDefinition: RouteDefinition,
776
+ function validateHydrationMode(
777
+ hydration: unknown,
778
+ render: RenderMode,
360
779
  location: string,
361
780
  ): void {
362
- const { hydration, render } = routeDefinition
363
-
364
781
  if (hydration === undefined) {
365
782
  return
366
783
  }
@@ -396,6 +813,17 @@ function validateHydration(
396
813
  }
397
814
  }
398
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
+
399
827
  function freezeHydration(
400
828
  hydration: HydrationMode | undefined,
401
829
  ): HydrationMode | undefined {
@@ -437,6 +865,16 @@ function validateRoute(
437
865
 
438
866
  assertString(routeDefinition.entry, `route ${location} entry`)
439
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
+
440
878
  if (!renderModes.has(routeDefinition.render)) {
441
879
  throw new TypeError(
442
880
  `flamefront route ${location} render must be 'client', 'server', or 'static'.`,
@@ -447,12 +885,12 @@ function validateRoute(
447
885
  }
448
886
 
449
887
  /** Define one explicit route without relying on a filesystem convention. */
450
- export function route(
451
- path: string,
452
- entry: string,
888
+ export function route<const Path extends string, const Entry extends string>(
889
+ path: Path,
890
+ entry: Entry,
453
891
  options: RouteOptions = {},
454
- ): RouteDefinition {
455
- const definition: RouteDefinition = {
892
+ ): RouteDefinition<Path, Entry> {
893
+ const definition: RouteDefinition<Path, Entry> = {
456
894
  path,
457
895
  entry,
458
896
  ...options,
@@ -464,6 +902,58 @@ export function route(
464
902
  return Object.freeze(definition)
465
903
  }
466
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
+
467
957
  /** Group routes beneath a shared pathless layout without adding a URL segment. */
468
958
  export function layout<const Children extends readonly RouteConfig[]>(
469
959
  entry: string,
@@ -488,6 +978,7 @@ function isLayoutDefinition(config: RouteConfig): config is LayoutDefinition {
488
978
  function normalizeRouteTree(
489
979
  configs: readonly RouteConfig[],
490
980
  seenPaths: Set<string>,
981
+ hydrationDefaults: NormalizedHydrationDefaults,
491
982
  location = "",
492
983
  ): { tree: readonly RouteConfig[]; routes: readonly RouteDefinition[] } {
493
984
  const routes: RouteDefinition[] = []
@@ -513,6 +1004,7 @@ function normalizeRouteTree(
513
1004
  const normalized = normalizeRouteTree(
514
1005
  config.children,
515
1006
  seenPaths,
1007
+ hydrationDefaults,
516
1008
  configLocation,
517
1009
  )
518
1010
 
@@ -524,19 +1016,24 @@ function normalizeRouteTree(
524
1016
  })
525
1017
  }
526
1018
 
527
- validateRoute(config, configLocation)
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)
528
1028
  if (seenPaths.has(config.path)) {
529
1029
  throw new TypeError(`flamefront route path is duplicated: ${config.path}`)
530
1030
  }
531
1031
 
532
1032
  seenPaths.add(config.path)
533
- const normalizedRoute = Object.freeze({
534
- ...config,
535
- hydration: freezeHydration(config.hydration),
536
- })
1033
+ const frozenRoute = Object.freeze(normalizedRoute)
537
1034
 
538
- routes.push(normalizedRoute)
539
- return normalizedRoute
1035
+ routes.push(frozenRoute)
1036
+ return frozenRoute
540
1037
  })
541
1038
 
542
1039
  return { tree: Object.freeze(tree), routes: Object.freeze(routes) }
@@ -562,7 +1059,7 @@ function matchRoutes<T extends RouteDefinition>(
562
1059
  url: string | URL,
563
1060
  options: MatchRouteOptions = {},
564
1061
  basename = "/",
565
- ): Match<string, T> | null {
1062
+ ): RouteMatch<T> | null {
566
1063
  let matchers = matcherCache.get(routes)
567
1064
 
568
1065
  if (!matchers) {
@@ -589,7 +1086,7 @@ function matchRoutes<T extends RouteDefinition>(
589
1086
  normalizedUrl.pathname = normalizedUrl.pathname.replace(/\/+$/, "")
590
1087
  }
591
1088
 
592
- return matcher.match(normalizedUrl)
1089
+ return matcher.match(normalizedUrl) as RouteMatch<T> | null
593
1090
  }
594
1091
 
595
1092
  /** Normalize and validate the application's explicit route graph. */
@@ -597,9 +1094,14 @@ export function defineApp<
597
1094
  const T extends {
598
1095
  readonly shell: string
599
1096
  readonly routes: readonly RouteConfig[]
1097
+ readonly shellHydration?: HydrationMode
1098
+ readonly hydrationDefaults?: HydrationDefaults
600
1099
  readonly routing?: RoutingOptions
601
1100
  },
602
- >(options: T): Omit<T, "routes" | "routing"> & AppDefinition {
1101
+ >(
1102
+ options: T,
1103
+ ): Omit<T, "routes" | "routing" | "hydrationDefaults" | "shellHydration"> &
1104
+ AppDefinition<RouteLeaves<T["routes"]>> {
603
1105
  if (
604
1106
  !options ||
605
1107
  typeof options !== "object" ||
@@ -610,32 +1112,50 @@ export function defineApp<
610
1112
 
611
1113
  assertString(options.shell, "app shell entry")
612
1114
 
613
- const normalized = normalizeRouteTree(options.routes, new Set())
614
- const frozenRoutes = normalized.routes
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[]
615
1127
  const routing = normalizeRoutingOptions(options.routing)
616
1128
  const routeDataClient = createRouteDataClient(routing)
617
- const load = <Data = unknown>(
618
- url: string | URL,
619
- loadOptions: LoadRouteOptions = {},
620
- ) => {
1129
+ const load = ((url: string | URL, loadOptions: LoadRouteOptions = {}) => {
621
1130
  const match = matchRoutes(frozenRoutes, url, {}, routing.basename)
622
1131
  const source = match?.data.render === "static" ? "static" : "live"
623
1132
 
624
- return routeDataClient.load<Data>(url, source, loadOptions)
625
- }
1133
+ return routeDataClient.load(url, source, loadOptions)
1134
+ }) as AppDefinition<AppRoute>["load"]
626
1135
 
627
1136
  const app = Object.freeze({
628
1137
  ...options,
1138
+ shellHydration,
1139
+ hydrationDefaults,
629
1140
  routes: frozenRoutes,
630
1141
  routeTree: normalized.tree,
631
1142
  routing,
632
- match: (url: string | URL, matchOptions?: MatchRouteOptions) =>
633
- matchRoutes(frozenRoutes, url, matchOptions, routing.basename),
1143
+ match: ((url: string | URL, matchOptions?: MatchRouteOptions) =>
1144
+ matchRoutes(
1145
+ frozenRoutes,
1146
+ url,
1147
+ matchOptions,
1148
+ routing.basename,
1149
+ )) as AppDefinition<AppRoute>["match"],
634
1150
  load,
635
- prefetch: async (url: string | URL, loadOptions?: LoadRouteOptions) => {
636
- await load(url, loadOptions)
637
- },
638
- }) as Omit<T, "routes" | "routing"> & AppDefinition
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>
639
1159
 
640
1160
  matcherCache.set(
641
1161
  frozenRoutes,