@furystack/shades 13.2.1 → 14.0.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.
Files changed (111) hide show
  1. package/CHANGELOG.md +150 -0
  2. package/esm/components/define-nested-routes.d.ts +27 -0
  3. package/esm/components/define-nested-routes.d.ts.map +1 -0
  4. package/esm/components/define-nested-routes.js +26 -0
  5. package/esm/components/define-nested-routes.js.map +1 -0
  6. package/esm/components/define-nested-routes.spec.d.ts +2 -0
  7. package/esm/components/define-nested-routes.spec.d.ts.map +1 -0
  8. package/esm/components/define-nested-routes.spec.js +81 -0
  9. package/esm/components/define-nested-routes.spec.js.map +1 -0
  10. package/esm/components/index.d.ts +3 -3
  11. package/esm/components/index.d.ts.map +1 -1
  12. package/esm/components/index.js +3 -3
  13. package/esm/components/index.js.map +1 -1
  14. package/esm/components/nested-hooks.d.ts +62 -0
  15. package/esm/components/nested-hooks.d.ts.map +1 -0
  16. package/esm/components/nested-hooks.js +73 -0
  17. package/esm/components/nested-hooks.js.map +1 -0
  18. package/esm/components/nested-hooks.spec.d.ts +2 -0
  19. package/esm/components/nested-hooks.spec.d.ts.map +1 -0
  20. package/esm/components/nested-hooks.spec.js +211 -0
  21. package/esm/components/nested-hooks.spec.js.map +1 -0
  22. package/esm/components/nested-navigate.d.ts +52 -21
  23. package/esm/components/nested-navigate.d.ts.map +1 -1
  24. package/esm/components/nested-navigate.js +32 -17
  25. package/esm/components/nested-navigate.js.map +1 -1
  26. package/esm/components/nested-navigate.spec.js +102 -12
  27. package/esm/components/nested-navigate.spec.js.map +1 -1
  28. package/esm/components/nested-replace.d.ts +36 -0
  29. package/esm/components/nested-replace.d.ts.map +1 -0
  30. package/esm/components/nested-replace.js +38 -0
  31. package/esm/components/nested-replace.js.map +1 -0
  32. package/esm/components/nested-replace.spec.d.ts +2 -0
  33. package/esm/components/nested-replace.spec.d.ts.map +1 -0
  34. package/esm/components/nested-replace.spec.js +59 -0
  35. package/esm/components/nested-replace.spec.js.map +1 -0
  36. package/esm/components/nested-route-link.d.ts +36 -26
  37. package/esm/components/nested-route-link.d.ts.map +1 -1
  38. package/esm/components/nested-route-link.js +17 -19
  39. package/esm/components/nested-route-link.js.map +1 -1
  40. package/esm/components/nested-route-link.spec.js +61 -11
  41. package/esm/components/nested-route-link.spec.js.map +1 -1
  42. package/esm/components/nested-route-types.d.ts +79 -2
  43. package/esm/components/nested-route-types.d.ts.map +1 -1
  44. package/esm/components/nested-router.d.ts +77 -7
  45. package/esm/components/nested-router.d.ts.map +1 -1
  46. package/esm/components/nested-router.js +137 -11
  47. package/esm/components/nested-router.js.map +1 -1
  48. package/esm/components/nested-router.spec.js +265 -34
  49. package/esm/components/nested-router.spec.js.map +1 -1
  50. package/esm/services/location-service.spec.js +33 -0
  51. package/esm/services/location-service.spec.js.map +1 -1
  52. package/esm/services/route-match-service.spec.js +4 -0
  53. package/esm/services/route-match-service.spec.js.map +1 -1
  54. package/esm/services/route-meta-utils.d.ts +28 -6
  55. package/esm/services/route-meta-utils.d.ts.map +1 -1
  56. package/esm/services/route-meta-utils.js +18 -4
  57. package/esm/services/route-meta-utils.js.map +1 -1
  58. package/esm/services/route-meta-utils.spec.js +57 -2
  59. package/esm/services/route-meta-utils.spec.js.map +1 -1
  60. package/esm/vnode.d.ts +1 -1
  61. package/esm/vnode.js +1 -1
  62. package/package.json +9 -9
  63. package/src/components/define-nested-routes.spec.ts +91 -0
  64. package/src/components/define-nested-routes.ts +27 -0
  65. package/src/components/index.ts +3 -3
  66. package/src/components/nested-hooks.spec.ts +254 -0
  67. package/src/components/nested-hooks.ts +109 -0
  68. package/src/components/nested-navigate.spec.ts +128 -25
  69. package/src/components/nested-navigate.ts +75 -26
  70. package/src/components/nested-replace.spec.ts +88 -0
  71. package/src/components/nested-replace.ts +46 -0
  72. package/src/components/nested-route-link.spec.tsx +84 -16
  73. package/src/components/nested-route-link.tsx +53 -28
  74. package/src/components/nested-route-types.ts +82 -2
  75. package/src/components/nested-router.spec.tsx +316 -33
  76. package/src/components/nested-router.tsx +182 -22
  77. package/src/services/location-service.spec.ts +39 -0
  78. package/src/services/route-match-service.spec.ts +4 -0
  79. package/src/services/route-meta-utils.spec.ts +75 -7
  80. package/src/services/route-meta-utils.ts +44 -15
  81. package/src/vnode.ts +1 -1
  82. package/esm/components/link-to-route.d.ts +0 -10
  83. package/esm/components/link-to-route.d.ts.map +0 -1
  84. package/esm/components/link-to-route.js +0 -16
  85. package/esm/components/link-to-route.js.map +0 -1
  86. package/esm/components/link-to-route.spec.d.ts +0 -2
  87. package/esm/components/link-to-route.spec.d.ts.map +0 -1
  88. package/esm/components/link-to-route.spec.js +0 -30
  89. package/esm/components/link-to-route.spec.js.map +0 -1
  90. package/esm/components/route-link.d.ts +0 -10
  91. package/esm/components/route-link.d.ts.map +0 -1
  92. package/esm/components/route-link.js +0 -25
  93. package/esm/components/route-link.js.map +0 -1
  94. package/esm/components/route-link.spec.d.ts +0 -2
  95. package/esm/components/route-link.spec.d.ts.map +0 -1
  96. package/esm/components/route-link.spec.js +0 -34
  97. package/esm/components/route-link.spec.js.map +0 -1
  98. package/esm/components/router.d.ts +0 -44
  99. package/esm/components/router.d.ts.map +0 -1
  100. package/esm/components/router.js +0 -66
  101. package/esm/components/router.js.map +0 -1
  102. package/esm/components/router.spec.d.ts +0 -2
  103. package/esm/components/router.spec.d.ts.map +0 -1
  104. package/esm/components/router.spec.js +0 -161
  105. package/esm/components/router.spec.js.map +0 -1
  106. package/src/components/link-to-route.spec.tsx +0 -45
  107. package/src/components/link-to-route.tsx +0 -26
  108. package/src/components/route-link.spec.tsx +0 -44
  109. package/src/components/route-link.tsx +0 -29
  110. package/src/components/router.spec.tsx +0 -217
  111. package/src/components/router.tsx +0 -103
@@ -11,9 +11,9 @@ import type { ConcatPaths, ExtractRouteParams, ExtractRoutePaths, UrlTree } from
11
11
  import type { NestedRoute } from './nested-router.js'
12
12
 
13
13
  // Minimal route type for type-level tests. Using Pick avoids the
14
- // `children?: Record<string, NestedRoute<any>>` from NestedRoute<unknown>
14
+ // `children?: Record<string, NestedRoute<any, any, any>>` from NestedRoute<unknown>
15
15
  // which would widen literal keys in intersections.
16
- type TestRoute = Pick<NestedRoute<unknown>, 'component'>
16
+ type TestRoute = Pick<NestedRoute<unknown, any, any>, 'component'>
17
17
 
18
18
  describe('NestedRouteLink', () => {
19
19
  beforeEach(() => {
@@ -31,7 +31,7 @@ describe('NestedRouteLink', () => {
31
31
  injector,
32
32
  rootElement,
33
33
  jsxElement: (
34
- <NestedRouteLink id="link" href="/buttons">
34
+ <NestedRouteLink id="link" path="/buttons">
35
35
  Buttons
36
36
  </NestedRouteLink>
37
37
  ),
@@ -54,7 +54,7 @@ describe('NestedRouteLink', () => {
54
54
  injector,
55
55
  rootElement,
56
56
  jsxElement: (
57
- <NestedRouteLink id="link" href="/buttons">
57
+ <NestedRouteLink id="link" path="/buttons">
58
58
  Buttons
59
59
  </NestedRouteLink>
60
60
  ),
@@ -75,7 +75,7 @@ describe('NestedRouteLink', () => {
75
75
  injector,
76
76
  rootElement,
77
77
  jsxElement: (
78
- <NestedRouteLink id="link" href="/users/:id" params={{ id: '42' }}>
78
+ <NestedRouteLink id="link" path="/users/:id" params={{ id: '42' }}>
79
79
  User 42
80
80
  </NestedRouteLink>
81
81
  ),
@@ -95,7 +95,7 @@ describe('NestedRouteLink', () => {
95
95
  injector,
96
96
  rootElement,
97
97
  jsxElement: (
98
- <NestedRouteLink id="link" href="/users/:userId/posts/:postId" params={{ userId: '1', postId: '99' }}>
98
+ <NestedRouteLink id="link" path="/users/:userId/posts/:postId" params={{ userId: '1', postId: '99' }}>
99
99
  Post
100
100
  </NestedRouteLink>
101
101
  ),
@@ -106,6 +106,46 @@ describe('NestedRouteLink', () => {
106
106
  )
107
107
  })
108
108
  })
109
+
110
+ it('Should append a serialized query string to the rendered href', async () => {
111
+ await usingAsync(new Injector(), async (injector) => {
112
+ const rootElement = document.getElementById('root') as HTMLDivElement
113
+
114
+ initializeShadeRoot({
115
+ injector,
116
+ rootElement,
117
+ jsxElement: (
118
+ <NestedRouteLink id="link" path="/buttons" query={{ page: 2 }}>
119
+ Buttons
120
+ </NestedRouteLink>
121
+ ),
122
+ })
123
+ await flushUpdates()
124
+
125
+ const link = document.getElementById('link') as HTMLAnchorElement
126
+ expect(link.getAttribute('href')?.startsWith('/buttons?')).toBe(true)
127
+ })
128
+ })
129
+
130
+ it('Should append the hash segment to the rendered href', async () => {
131
+ await usingAsync(new Injector(), async (injector) => {
132
+ const rootElement = document.getElementById('root') as HTMLDivElement
133
+
134
+ initializeShadeRoot({
135
+ injector,
136
+ rootElement,
137
+ jsxElement: (
138
+ <NestedRouteLink id="link" path="/buttons" hash="overview">
139
+ Buttons
140
+ </NestedRouteLink>
141
+ ),
142
+ })
143
+ await flushUpdates()
144
+
145
+ const link = document.getElementById('link') as HTMLAnchorElement
146
+ expect(link.getAttribute('href')).toBe('/buttons#overview')
147
+ })
148
+ })
109
149
  })
110
150
 
111
151
  describe('Type utilities', () => {
@@ -219,13 +259,13 @@ describe('Type utilities', () => {
219
259
  describe('TypedNestedRouteLinkProps', () => {
220
260
  it('Should make params optional for paths without parameters', () => {
221
261
  type Props = TypedNestedRouteLinkProps<'/buttons'>
222
- expectTypeOf<Props['href']>().toEqualTypeOf<'/buttons'>()
262
+ expectTypeOf<Props['path']>().toEqualTypeOf<'/buttons'>()
223
263
  expectTypeOf<Props>().toExtend<{ params?: Record<string, string> }>()
224
264
  })
225
265
 
226
266
  it('Should require params for parameterized paths', () => {
227
267
  type Props = TypedNestedRouteLinkProps<'/users/:id'>
228
- expectTypeOf<Props['href']>().toEqualTypeOf<'/users/:id'>()
268
+ expectTypeOf<Props['path']>().toEqualTypeOf<'/users/:id'>()
229
269
  expectTypeOf<Props>().toExtend<{ params: { id: string } }>()
230
270
  })
231
271
 
@@ -236,20 +276,20 @@ describe('Type utilities', () => {
236
276
  })
237
277
 
238
278
  describe('NestedRouteLink param inference', () => {
239
- it('Should infer params as optional when href has no parameters', () => {
279
+ it('Should infer params as optional when path has no parameters', () => {
240
280
  expectTypeOf(NestedRouteLink).parameter(0).toHaveProperty('params')
241
281
  expectTypeOf(NestedRouteLink<'/buttons'>)
242
282
  .parameter(0)
243
283
  .toExtend<{ params?: Record<string, string> }>()
244
284
  })
245
285
 
246
- it('Should infer params as required when href has a parameter', () => {
286
+ it('Should infer params as required when path has a parameter', () => {
247
287
  expectTypeOf(NestedRouteLink<'/users/:id'>)
248
288
  .parameter(0)
249
289
  .toExtend<{ params: { id: string } }>()
250
290
  })
251
291
 
252
- it('Should infer multiple params from href', () => {
292
+ it('Should infer multiple params from path', () => {
253
293
  expectTypeOf(NestedRouteLink<'/users/:userId/posts/:postId'>)
254
294
  .parameter(0)
255
295
  .toExtend<{ params: { userId: string; postId: string } }>()
@@ -257,7 +297,7 @@ describe('Type utilities', () => {
257
297
  })
258
298
 
259
299
  describe('createNestedRouteLink', () => {
260
- it('Should constrain href to valid route paths', () => {
300
+ it('Should constrain path to valid route paths', () => {
261
301
  type Routes = {
262
302
  '/': TestRoute & {
263
303
  children: {
@@ -267,7 +307,7 @@ describe('Type utilities', () => {
267
307
  }
268
308
 
269
309
  const AppLink = createNestedRouteLink<Routes>()
270
- expectTypeOf(AppLink).parameter(0).toHaveProperty('href')
310
+ expectTypeOf(AppLink).parameter(0).toHaveProperty('path')
271
311
  })
272
312
 
273
313
  it('Should reject invalid paths', () => {
@@ -281,7 +321,7 @@ describe('Type utilities', () => {
281
321
 
282
322
  const AppLink = createNestedRouteLink<Routes>()
283
323
  // @ts-expect-error -- '/nonexistent' is not a valid route path
284
- AppLink({ href: '/nonexistent' })
324
+ AppLink({ path: '/nonexistent' })
285
325
  })
286
326
 
287
327
  it('Should require params for parameterized routes in the tree', () => {
@@ -343,13 +383,41 @@ describe('Type utilities', () => {
343
383
  '/users/:userId': usersRoute,
344
384
  },
345
385
  },
346
- } satisfies Record<string, NestedRoute<any>>
386
+ } satisfies Record<string, NestedRoute<any, any, any>>
347
387
 
348
388
  const AppLink = createNestedRouteLink<typeof routes>()
349
- expectTypeOf(AppLink).parameter(0).toHaveProperty('href')
389
+ expectTypeOf(AppLink).parameter(0).toHaveProperty('path')
350
390
  expectTypeOf(AppLink<'/users/:userId'>)
351
391
  .parameter(0)
352
392
  .toExtend<{ params: { userId: string } }>()
353
393
  })
394
+
395
+ it('Should enforce a declared required query shape on the link', () => {
396
+ const routes = {
397
+ '/list': {
398
+ component: () => null as unknown as JSX.Element,
399
+ query: (raw): { page: number } | null => (typeof raw.page === 'number' ? { page: raw.page } : null),
400
+ },
401
+ } satisfies Record<string, NestedRoute<any, any, any>>
402
+
403
+ const AppLink = createNestedRouteLink<typeof routes>()
404
+ expectTypeOf(AppLink<'/list'>)
405
+ .parameter(0)
406
+ .toExtend<{ query: { page: number } }>()
407
+ })
408
+
409
+ it('Should narrow hash on the link to the declared literal tuple', () => {
410
+ const routes = {
411
+ '/tabs': {
412
+ component: () => null as unknown as JSX.Element,
413
+ hash: ['overview', 'details'] as const,
414
+ },
415
+ } satisfies Record<string, NestedRoute<any, any, any>>
416
+
417
+ const AppLink = createNestedRouteLink<typeof routes>()
418
+ expectTypeOf(AppLink<'/tabs'>)
419
+ .parameter(0)
420
+ .toExtend<{ hash?: 'overview' | 'details' }>()
421
+ })
354
422
  })
355
423
  })
@@ -1,31 +1,58 @@
1
- import { compileRoute } from '../compile-route.js'
2
1
  import type { ChildrenList } from '../models/children-list.js'
3
2
  import type { PartialElement } from '../models/partial-element.js'
4
3
  import { LocationService } from '../services/location-service.js'
5
4
  import { createComponent } from '../shade-component.js'
6
5
  import { Shade } from '../shade.js'
7
- import type { ExtractRouteParams, ExtractRoutePaths } from './nested-route-types.js'
6
+ import { buildNestedNavigateUrl } from './nested-navigate.js'
7
+ import type {
8
+ ExtractRouteHash,
9
+ ExtractRouteQuery,
10
+ ExtractRoutePaths,
11
+ RouteAt,
12
+ TypedHashArg,
13
+ TypedParamsArg,
14
+ TypedQueryArg,
15
+ } from './nested-route-types.js'
8
16
  import type { NestedRoute } from './nested-router.js'
9
17
 
10
18
  /**
11
19
  * Props for the NestedRouteLink component.
12
- * Combines SPA navigation from RouteLink with parameter compilation from LinkToRoute.
20
+ * Combines SPA navigation with parameter compilation for nested routes,
21
+ * plus optional query string and hash fragment composition.
13
22
  */
14
23
  export type NestedRouteLinkProps = {
15
- href: string
24
+ path: string
16
25
  params?: Record<string, string>
26
+ query?: Record<string, unknown>
27
+ hash?: string
17
28
  } & PartialElement<Omit<HTMLAnchorElement, 'onclick' | 'href'>>
18
29
 
19
30
  /**
20
31
  * Props for a type-safe nested route link.
21
32
  * When the path contains parameters (e.g. `:id`), the `params` prop becomes required.
33
+ * `query` and `hash` remain loose (untyped) on this variant; use
34
+ * {@link createNestedRouteLink} to narrow them against a specific route tree.
22
35
  * @typeParam TPath - A specific route path string
23
36
  */
24
37
  export type TypedNestedRouteLinkProps<TPath extends string> = {
25
- href: TPath
26
- } & (string extends keyof ExtractRouteParams<TPath>
27
- ? { params?: Record<string, string> }
28
- : { params: ExtractRouteParams<TPath> }) &
38
+ path: TPath
39
+ query?: Record<string, unknown>
40
+ hash?: string
41
+ } & TypedParamsArg<TPath> &
42
+ PartialElement<Omit<HTMLAnchorElement, 'onclick' | 'href'>>
43
+
44
+ /**
45
+ * Props for a route-tree-aware nested route link. Narrows `query` and `hash`
46
+ * against the specific route at `TPath` in `TRoutes`.
47
+ * @typeParam TRoutes - The route tree
48
+ * @typeParam TPath - A composed route path within `TRoutes`
49
+ */
50
+ export type TreeAwareNestedRouteLinkProps<
51
+ TRoutes extends Record<string, NestedRoute<any, any, any>>,
52
+ TPath extends string,
53
+ > = { path: TPath } & TypedParamsArg<TPath> &
54
+ TypedQueryArg<ExtractRouteQuery<RouteAt<TRoutes, TPath>>> &
55
+ TypedHashArg<ExtractRouteHash<RouteAt<TRoutes, TPath>>> &
29
56
  PartialElement<Omit<HTMLAnchorElement, 'onclick' | 'href'>>
30
57
 
31
58
  const _NestedRouteLink = Shade<NestedRouteLinkProps>({
@@ -37,8 +64,8 @@ const _NestedRouteLink = Shade<NestedRouteLinkProps>({
37
64
  textDecoration: 'inherit',
38
65
  },
39
66
  render: ({ children, props, injector, useHostProps }) => {
40
- const { href, params } = props
41
- const resolvedUrl = params ? compileRoute(href, params) : href
67
+ const { path, params, query, hash } = props
68
+ const resolvedUrl = buildNestedNavigateUrl({ path, params, query, hash })
42
69
 
43
70
  useHostProps({
44
71
  href: resolvedUrl,
@@ -58,13 +85,15 @@ const _NestedRouteLink = Shade<NestedRouteLinkProps>({
58
85
  * type-safe route parameter compilation.
59
86
  *
60
87
  * Intercepts click events to use `history.pushState` for client-side navigation,
61
- * and compiles parameterized routes (e.g. `/users/:id`) when `params` is provided.
88
+ * compiles parameterized routes (e.g. `/users/:id`) when `params` is provided,
89
+ * serializes `query` to the URL search string and appends `hash` when set.
62
90
  *
63
- * Route parameters are automatically inferred from the `href` pattern:
64
- * - `href="/buttons"` — `params` is optional
65
- * - `href="/users/:id"` — `params: { id: string }` is required
91
+ * Route parameters are automatically inferred from the `path` pattern:
92
+ * - `path="/buttons"` — `params` is optional
93
+ * - `path="/users/:id"` — `params: { id: string }` is required
66
94
  *
67
- * For additional URL validation against a route tree, use {@link createNestedRouteLink}.
95
+ * For additional URL validation against a route tree (including `query` and
96
+ * `hash` narrowing), use {@link createNestedRouteLink}.
68
97
  */
69
98
  export const NestedRouteLink = _NestedRouteLink as unknown as <TPath extends string = string>(
70
99
  props: TypedNestedRouteLinkProps<TPath>,
@@ -72,9 +101,10 @@ export const NestedRouteLink = _NestedRouteLink as unknown as <TPath extends str
72
101
  ) => JSX.Element
73
102
 
74
103
  /**
75
- * Creates a type-safe wrapper around NestedRouteLink constrained to a specific route tree.
76
- * The returned component has the same runtime behavior but narrows `href` to only accept
77
- * valid route paths, and requires `params` when the route has parameters.
104
+ * Creates a type-safe wrapper around NestedRouteLink constrained to a specific
105
+ * route tree. The returned component has the same runtime behavior but narrows
106
+ * `path` to only accept valid route paths, requires `params` when the route
107
+ * has parameters, and enforces the route's declared `query` and `hash` schemas.
78
108
  *
79
109
  * @typeParam TRoutes - The route tree type (use `typeof yourRoutes`)
80
110
  * @returns A narrowed NestedRouteLink component
@@ -83,19 +113,14 @@ export const NestedRouteLink = _NestedRouteLink as unknown as <TPath extends str
83
113
  * ```typescript
84
114
  * const AppLink = createNestedRouteLink<typeof appRoutes>()
85
115
  *
86
- * // Type-safe: only valid paths accepted
87
- * <AppLink href="/buttons">Buttons</AppLink>
88
- *
89
- * // TypeScript error: invalid path
90
- * <AppLink href="/nonexistent">Error!</AppLink>
91
- *
92
- * // Params required for parameterized routes
93
- * <AppLink href="/users/:id" params={{ id: '123' }}>User</AppLink>
116
+ * <AppLink path="/buttons">Buttons</AppLink>
117
+ * <AppLink path="/users/:id" params={{ id: '123' }}>User</AppLink>
118
+ * <AppLink path="/users/:id" params={{ id: '1' }} query={{ tab: 'profile' }} hash="notes">User</AppLink>
94
119
  * ```
95
120
  */
96
- export const createNestedRouteLink = <TRoutes extends Record<string, NestedRoute<any>>>() => {
121
+ export const createNestedRouteLink = <TRoutes extends Record<string, NestedRoute<any, any, any>>>() => {
97
122
  return _NestedRouteLink as unknown as <TPath extends ExtractRoutePaths<TRoutes>>(
98
- props: TypedNestedRouteLinkProps<TPath>,
123
+ props: TreeAwareNestedRouteLinkProps<TRoutes, TPath>,
99
124
  children?: ChildrenList,
100
125
  ) => JSX.Element
101
126
  }
@@ -12,10 +12,10 @@ export type ConcatPaths<Parent extends string, Child extends string> = Parent ex
12
12
  * Recursively extracts all valid full URL paths from a nested route tree.
13
13
  * @typeParam T - A record of route patterns to NestedRoute definitions
14
14
  */
15
- export type ExtractRoutePaths<T extends Record<string, NestedRoute<any>>> = {
15
+ export type ExtractRoutePaths<T extends Record<string, NestedRoute<any, any, any>>> = {
16
16
  [K in keyof T & string]:
17
17
  | K
18
- | (T[K] extends { children: infer C extends Record<string, NestedRoute<any>> }
18
+ | (T[K] extends { children: infer C extends Record<string, NestedRoute<any, any, any>> }
19
19
  ? ConcatPaths<K, ExtractRoutePaths<C> & string>
20
20
  : never)
21
21
  }[keyof T & string]
@@ -40,3 +40,83 @@ export type ExtractRouteParams<T extends string> = T extends `${string}:${infer
40
40
  export type UrlTree<TPaths extends string> = {
41
41
  [key: string]: TPaths | UrlTree<TPaths>
42
42
  }
43
+
44
+ /**
45
+ * A query string validator function for a nested route. Receives the deserialized
46
+ * query string object and returns either the typed query value or `null` when
47
+ * the URL's query does not match the route's expected shape.
48
+ * @typeParam TQuery - The typed query shape the route expects
49
+ */
50
+ export type QueryValidator<TQuery> = (raw: Record<string, unknown>) => TQuery | null
51
+
52
+ /**
53
+ * A readonly tuple of string literals describing the allowed URL hash values
54
+ * for a nested route.
55
+ */
56
+ export type HashLiterals = readonly string[]
57
+
58
+ /**
59
+ * Extracts the typed query shape declared by a {@link NestedRoute}'s `query` validator.
60
+ * Resolves to `never` when the route has no `query` validator.
61
+ * @typeParam R - A NestedRoute value type
62
+ */
63
+ export type ExtractRouteQuery<R> = R extends { query: QueryValidator<infer Q> } ? Q : never
64
+
65
+ /**
66
+ * Extracts the typed hash literal union declared by a {@link NestedRoute}'s `hash` tuple.
67
+ * Resolves to `never` when the route has no `hash` declaration.
68
+ * @typeParam R - A NestedRoute value type
69
+ */
70
+ export type ExtractRouteHash<R> = R extends { hash: infer H extends HashLiterals } ? H[number] : never
71
+
72
+ /**
73
+ * Walks a nested route tree and resolves the route value at the given composed path.
74
+ * Used to surface per-route `query` / `hash` metadata to call-site helpers.
75
+ * @typeParam TRoutes - The route tree
76
+ * @typeParam TPath - A full composed route path (as produced by {@link ExtractRoutePaths})
77
+ */
78
+ export type RouteAt<TRoutes extends Record<string, NestedRoute<any, any, any>>, TPath extends string> = {
79
+ [K in keyof TRoutes & string]: TPath extends K
80
+ ? TRoutes[K]
81
+ : K extends '/'
82
+ ? TRoutes[K] extends { children: infer C extends Record<string, NestedRoute<any, any, any>> }
83
+ ? RouteAt<C, TPath>
84
+ : never
85
+ : TPath extends `${K}${infer Rest}`
86
+ ? TRoutes[K] extends { children: infer C extends Record<string, NestedRoute<any, any, any>> }
87
+ ? RouteAt<C, Rest>
88
+ : never
89
+ : never
90
+ }[keyof TRoutes & string]
91
+
92
+ /**
93
+ * Builds the `params` portion of a typed navigation / link argument.
94
+ * Resolves to `{ params?: Record<string, string> }` when the path has no `:param`
95
+ * segments and `{ params: {...} }` when at least one is required.
96
+ * @typeParam TPath - A URL pattern string
97
+ */
98
+ export type TypedParamsArg<TPath extends string> = string extends keyof ExtractRouteParams<TPath>
99
+ ? { params?: Record<string, string> }
100
+ : { params: ExtractRouteParams<TPath> }
101
+
102
+ /**
103
+ * Builds the `query` portion of a typed navigation / link argument.
104
+ * - `never` (route declared no schema) → property is forbidden.
105
+ * - All-optional schema → property itself is optional.
106
+ * - Any required key → property is required.
107
+ * @typeParam TQuery - The route's typed query shape
108
+ */
109
+ export type TypedQueryArg<TQuery> = [TQuery] extends [never]
110
+ ? { query?: never }
111
+ : // eslint-disable-next-line @typescript-eslint/no-empty-object-type -- `{} extends T` is the idiomatic way to detect "all keys optional" on an arbitrary object shape.
112
+ {} extends TQuery
113
+ ? { query?: TQuery }
114
+ : { query: TQuery }
115
+
116
+ /**
117
+ * Builds the `hash` portion of a typed navigation / link argument.
118
+ * Hash is always optional at the call site (URL fragments are never required
119
+ * for correctness of navigation).
120
+ * @typeParam THash - The route's hash literal union
121
+ */
122
+ export type TypedHashArg<THash> = [THash] extends [never] ? { hash?: never } : { hash?: THash }