kiru 0.50.5 → 0.50.7

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 (59) hide show
  1. package/dist/components/suspense.d.ts +2 -12
  2. package/dist/components/suspense.d.ts.map +1 -1
  3. package/dist/components/suspense.js +4 -78
  4. package/dist/components/suspense.js.map +1 -1
  5. package/dist/dom.d.ts.map +1 -1
  6. package/dist/dom.js +22 -3
  7. package/dist/dom.js.map +1 -1
  8. package/dist/hooks/index.d.ts +1 -0
  9. package/dist/hooks/index.d.ts.map +1 -1
  10. package/dist/hooks/index.js +1 -0
  11. package/dist/hooks/index.js.map +1 -1
  12. package/dist/hooks/usePromise.d.ts +13 -0
  13. package/dist/hooks/usePromise.d.ts.map +1 -0
  14. package/dist/hooks/usePromise.js +79 -0
  15. package/dist/hooks/usePromise.js.map +1 -0
  16. package/dist/recursiveRender.d.ts.map +1 -1
  17. package/dist/recursiveRender.js +12 -3
  18. package/dist/recursiveRender.js.map +1 -1
  19. package/dist/router/context.d.ts +4 -0
  20. package/dist/router/context.d.ts.map +1 -1
  21. package/dist/router/context.js.map +1 -1
  22. package/dist/router/fileRouter.js +1 -1
  23. package/dist/router/fileRouter.js.map +1 -1
  24. package/dist/router/fileRouterController.d.ts +3 -4
  25. package/dist/router/fileRouterController.d.ts.map +1 -1
  26. package/dist/router/fileRouterController.js +33 -159
  27. package/dist/router/fileRouterController.js.map +1 -1
  28. package/dist/router/link.d.ts +9 -0
  29. package/dist/router/link.d.ts.map +1 -1
  30. package/dist/router/link.js +21 -3
  31. package/dist/router/link.js.map +1 -1
  32. package/dist/router/types.internal.d.ts +14 -0
  33. package/dist/router/types.internal.d.ts.map +1 -1
  34. package/dist/router/utils/index.d.ts +15 -0
  35. package/dist/router/utils/index.d.ts.map +1 -0
  36. package/dist/router/utils/index.js +148 -0
  37. package/dist/router/utils/index.js.map +1 -0
  38. package/dist/utils/dom.d.ts +2 -0
  39. package/dist/utils/dom.d.ts.map +1 -0
  40. package/dist/utils/dom.js +9 -0
  41. package/dist/utils/dom.js.map +1 -0
  42. package/dist/utils/index.d.ts +1 -0
  43. package/dist/utils/index.d.ts.map +1 -1
  44. package/dist/utils/index.js +1 -0
  45. package/dist/utils/index.js.map +1 -1
  46. package/package.json +1 -1
  47. package/src/components/suspense.ts +6 -125
  48. package/src/dom.ts +26 -2
  49. package/src/hooks/index.ts +1 -0
  50. package/src/hooks/usePromise.ts +121 -0
  51. package/src/recursiveRender.ts +14 -2
  52. package/src/router/context.ts +8 -0
  53. package/src/router/fileRouter.ts +1 -1
  54. package/src/router/fileRouterController.ts +47 -216
  55. package/src/router/link.ts +39 -2
  56. package/src/router/types.internal.ts +16 -0
  57. package/src/router/utils/index.ts +206 -0
  58. package/src/utils/dom.ts +10 -0
  59. package/src/utils/index.ts +1 -0
@@ -1,7 +1,6 @@
1
1
  import { Signal } from "../signals/base.js"
2
2
  import { flushSync } from "../scheduler.js"
3
3
  import { __DEV__ } from "../env.js"
4
- import { createElement } from "../element.js"
5
4
  import { type FileRouterContextType } from "./context.js"
6
5
  import { FileRouterDataLoadError } from "./errors.js"
7
6
  import { fileRouterInstance } from "./globals.js"
@@ -14,26 +13,26 @@ import type {
14
13
  RouterState,
15
14
  } from "./types.js"
16
15
  import type {
17
- DefaultComponentModule,
16
+ FormattedViteImportMap,
18
17
  PageModule,
19
18
  ViteImportMap,
20
19
  } from "./types.internal.js"
21
-
22
- interface FormattedViteImportMap {
23
- [key: string]: {
24
- load: () => Promise<DefaultComponentModule>
25
- specificity: number
26
- segments: string[]
27
- filePath?: string
28
- }
29
- }
20
+ import {
21
+ formatViteImportMap,
22
+ matchLayouts,
23
+ matchRoute,
24
+ normalizePrefixPath,
25
+ parseQuery,
26
+ wrapWithLayouts,
27
+ } from "./utils/index.js"
30
28
 
31
29
  export class FileRouterController {
30
+ public contextValue: FileRouterContextType
32
31
  private enableTransitions: boolean
33
32
  private pages: FormattedViteImportMap
34
33
  private layouts: FormattedViteImportMap
35
34
  private abortController: AbortController
36
- private currentPage: Signal<{
35
+ private routeState: Signal<{
37
36
  component: Kiru.FC<any>
38
37
  config?: PageConfig
39
38
  route: string
@@ -41,7 +40,6 @@ export class FileRouterController {
41
40
  private currentPageProps: Signal<PageProps<PageConfig>>
42
41
  private currentLayouts: Signal<Kiru.FC[]>
43
42
  private state: RouterState
44
- private contextValue: FileRouterContextType
45
43
  private cleanups: (() => void)[] = []
46
44
  private filePathToPageRoute?: Map<
47
45
  string,
@@ -55,7 +53,7 @@ export class FileRouterController {
55
53
  this.pages = {}
56
54
  this.layouts = {}
57
55
  this.abortController = new AbortController()
58
- this.currentPage = new Signal(null)
56
+ this.routeState = new Signal(null)
59
57
  this.currentPageProps = new Signal({})
60
58
  this.currentLayouts = new Signal([])
61
59
  this.state = {
@@ -64,18 +62,17 @@ export class FileRouterController {
64
62
  query: {},
65
63
  signal: this.abortController.signal,
66
64
  }
67
-
68
65
  const __this = this
69
66
  this.contextValue = {
70
67
  get state() {
71
68
  return __this.state
72
69
  },
73
70
  navigate: this.navigate.bind(this),
74
- setQuery: this.setQuery.bind(this),
71
+ prefetchRouteModules: this.prefetchRouteModules.bind(this),
75
72
  reload: (options?: { transition?: boolean }) =>
76
73
  this.loadRoute(void 0, void 0, options?.transition),
74
+ setQuery: this.setQuery.bind(this),
77
75
  }
78
-
79
76
  if (__DEV__) {
80
77
  this.filePathToPageRoute = new Map()
81
78
  this.pageRouteToConfig = new Map()
@@ -119,7 +116,7 @@ export class FileRouterController {
119
116
  this.filePathToPageRoute?.set(fp, { route, config })
120
117
  return
121
118
  }
122
- const curPage = this.currentPage.value
119
+ const curPage = this.routeState.value
123
120
  if (curPage?.route === existing.route && config.loader) {
124
121
  const p = this.currentPageProps.value
125
122
  let transition = this.enableTransitions
@@ -142,104 +139,20 @@ export class FileRouterController {
142
139
  this.pageRouteToConfig?.set(existing.route, config)
143
140
  }
144
141
 
145
- public getContextValue() {
146
- return this.contextValue
147
- }
148
-
149
142
  public getChildren() {
150
- const page = this.currentPage.value,
151
- props = this.currentPageProps.value,
152
- layouts = this.currentLayouts.value
143
+ const page = this.routeState.value
144
+ if (!page) return null
153
145
 
154
- if (page) {
155
- // Wrap component with layouts (outermost first)
156
- return layouts.reduceRight(
157
- (children, Layout) => createElement(Layout, { children }),
158
- createElement(page.component, props)
159
- )
160
- }
146
+ const props = this.currentPageProps.value,
147
+ layouts = this.currentLayouts.value
161
148
 
162
- return null
149
+ return wrapWithLayouts(layouts, page.component, props)
163
150
  }
164
151
 
165
152
  public dispose() {
166
153
  this.cleanups.forEach((cleanup) => cleanup())
167
154
  }
168
155
 
169
- private matchRoute(pathSegments: string[]) {
170
- const matches: Array<{
171
- route: string
172
- pageEntry: FormattedViteImportMap[string]
173
- params: Record<string, string>
174
- routeSegments: string[]
175
- }> = []
176
-
177
- // Find all matching routes
178
- outer: for (const [route, pageEntry] of Object.entries(this.pages)) {
179
- const routeSegments = pageEntry.segments
180
- const pathMatchingSegments = routeSegments.filter(
181
- (seg) => !seg.startsWith("(") && !seg.endsWith(")")
182
- )
183
-
184
- const params: Record<string, string> = {}
185
- let hasCatchall = false
186
-
187
- // Check if route matches
188
- for (
189
- let i = 0;
190
- i < pathMatchingSegments.length && i < pathSegments.length;
191
- i++
192
- ) {
193
- const routeSeg = pathMatchingSegments[i]
194
-
195
- if (routeSeg.startsWith(":")) {
196
- const key = routeSeg.slice(1)
197
-
198
- if (routeSeg.endsWith("*")) {
199
- // Catchall route - matches remaining segments
200
- hasCatchall = true
201
- const catchallKey = key.slice(0, -1) // Remove the *
202
- params[catchallKey] = pathSegments.slice(i).join("/")
203
- break
204
- } else {
205
- // Regular dynamic segment
206
- if (i >= pathSegments.length) {
207
- continue outer
208
- }
209
- params[key] = pathSegments[i]
210
- }
211
- } else {
212
- // Static segment
213
- if (routeSeg !== pathSegments[i]) {
214
- continue outer
215
- }
216
- }
217
- }
218
-
219
- // For non-catchall routes, ensure exact length match
220
- if (!hasCatchall && pathMatchingSegments.length !== pathSegments.length) {
221
- continue
222
- }
223
-
224
- matches.push({
225
- route,
226
- pageEntry,
227
- params,
228
- routeSegments,
229
- })
230
- }
231
-
232
- // Sort by specificity (highest first) and return the best match
233
- if (matches.length === 0) {
234
- return null
235
- }
236
-
237
- matches.sort((a, b) => b.pageEntry.specificity - a.pageEntry.specificity)
238
- const bestMatch = matches[0]
239
-
240
- return bestMatch
241
- }
242
-
243
156
  private async loadRoute(
244
157
  path: string = window.location.pathname,
245
158
  props: PageProps<PageConfig> = {},
@@ -250,10 +163,10 @@ export class FileRouterController {
250
163
 
251
164
  try {
252
165
  const pathSegments = path.split("/").filter(Boolean)
253
- const routeMatch = this.matchRoute(pathSegments)
166
+ const routeMatch = matchRoute(this.pages, pathSegments)
254
167
 
255
168
  if (!routeMatch) {
256
- const _404 = this.matchRoute(["404"])
169
+ const _404 = matchRoute(this.pages, ["404"])
257
170
  if (!_404) {
258
171
  if (__DEV__) {
259
172
  console.error(
@@ -275,23 +188,16 @@ See https://kirujs.dev/docs/api/file-router#404 for more information.`
275
188
  this.currentRoute = route
276
189
  const pagePromise = pageEntry.load()
277
190
 
278
- const layoutPromises = ["/", ...routeSegments].reduce((acc, _, i) => {
279
- const layoutPath = "/" + routeSegments.slice(0, i).join("/")
280
- const layout = this.layouts[layoutPath]
281
-
282
- if (!layout) {
283
- return acc
284
- }
285
-
286
- return [...acc, layout.load()]
287
- }, [] as Promise<DefaultComponentModule>[])
191
+ const layoutPromises = matchLayouts(this.layouts, routeSegments).map(
192
+ (layoutEntry) => layoutEntry.load()
193
+ )
288
194
 
289
- const query = parseQuery(window.location.search)
290
195
  const [page, ...layouts] = await Promise.all([
291
196
  pagePromise,
292
197
  ...layoutPromises,
293
198
  ])
294
199
 
200
+ const query = parseQuery(window.location.search)
295
201
  this.currentRoute = null
296
202
  if (signal.aborted) return
297
203
 
@@ -322,7 +228,7 @@ See https://kirujs.dev/docs/api/file-router#404 for more information.`
322
228
 
323
229
  this.state = routerState
324
230
  handleStateTransition(signal, enableTransition, () => {
325
- this.currentPage.value = {
231
+ this.routeState.value = {
326
232
  component: page.default,
327
233
  config,
328
234
  route: "/" + routeSegments.join("/"),
@@ -334,7 +240,7 @@ See https://kirujs.dev/docs/api/file-router#404 for more information.`
334
240
  })
335
241
  } catch (error) {
336
242
  console.error("[kiru/router]: Failed to load route component:", error)
337
- this.currentPage.value = null
243
+ this.routeState.value = null
338
244
  }
339
245
  }
340
246
 
@@ -382,10 +288,28 @@ See https://kirujs.dev/docs/api/file-router#404 for more information.`
382
288
  ) {
383
289
  const f = options?.replace ? "replaceState" : "pushState"
384
290
  window.history[f]({}, "", path)
385
- window.dispatchEvent(new PopStateEvent("popstate", { state: {} }))
386
291
  return this.loadRoute(path, options?.props, options?.transition)
387
292
  }
388
293
 
294
+ private async prefetchRouteModules(path: string) {
295
+ try {
296
+ const routeMatch = matchRoute(this.pages, path.split("/").filter(Boolean))
297
+ if (!routeMatch) {
298
+ throw new Error(`No route defined (path: ${path}).`)
299
+ }
300
+ const { pageEntry, route } = routeMatch
301
+ this.currentRoute = route
302
+ const pagePromise = pageEntry.load()
303
+ const layoutPromises = matchLayouts(this.layouts, route.split("/")).map(
304
+ (layoutEntry) => layoutEntry.load()
305
+ )
306
+ await Promise.all([pagePromise, ...layoutPromises])
307
+ this.currentRoute = null
308
+ } catch (error) {
309
+ console.error("[kiru/router]: Failed to prefetch route:", error)
310
+ }
311
+ }
312
+
389
313
  private setQuery(query: RouteQuery) {
390
314
  const queryString = buildQueryString(query)
391
315
  const newUrl = `${this.state.path}${queryString ? `?${queryString}` : ""}`
@@ -395,28 +319,6 @@ See https://kirujs.dev/docs/api/file-router#404 for more information.`
395
319
  }
396
320
  }
397
321
 
398
- function parseQuery(
399
- search: string
400
- ): Record<string, string | string[] | undefined> {
401
- const params = new URLSearchParams(search)
402
- const query: Record<string, string | string[] | undefined> = {}
403
-
404
- for (const [key, value] of params.entries()) {
405
- if (query[key]) {
406
- // Convert to array if multiple values
407
- if (Array.isArray(query[key])) {
408
- ;(query[key] as string[]).push(value)
409
- } else {
410
- query[key] = [query[key] as string, value]
411
- }
412
- } else {
413
- query[key] = value
414
- }
415
- }
416
-
417
- return query
418
- }
419
-
420
322
  function buildQueryString(
421
323
  query: Record<string, string | string[] | undefined>
422
324
  ): string {
@@ -435,77 +337,6 @@ function buildQueryString(
435
337
  return params.toString()
436
338
  }
437
339
 
438
- function formatViteImportMap(
439
- map: ViteImportMap,
440
- dir: string,
441
- baseUrl: string
442
- ): FormattedViteImportMap {
443
- return Object.keys(map).reduce<FormattedViteImportMap>((acc, key) => {
444
- const dirIndex = key.indexOf(dir)
445
- if (dirIndex === -1) {
446
- return acc
447
- }
448
-
449
- let specificity = 0
450
- let k = key.slice(dirIndex + dir.length)
451
- while (k.startsWith("/")) {
452
- k = k.slice(1)
453
- }
454
- const segments: string[] = []
455
- const parts = k.split("/").slice(0, -1)
456
-
457
- for (let i = 0; i < parts.length; i++) {
458
- const part = parts[i]
459
- if (part.startsWith("[...") && part.endsWith("]")) {
460
- if (i !== parts.length - 1) {
461
- throw new Error(
462
- `[kiru/router]: Catchall must be the folder name. Got "${key}"`
463
- )
464
- }
465
- segments.push(`:${part.slice(4, -1)}*`)
466
- specificity += 1
467
- break
468
- }
469
- if (part.startsWith("[") && part.endsWith("]")) {
470
- segments.push(`:${part.slice(1, -1)}`)
471
- specificity += 10
472
- continue
473
- }
474
- specificity += 100
475
- segments.push(part)
476
- }
477
-
478
- const value: FormattedViteImportMap[string] = {
479
- load: map[key],
480
- specificity,
481
- segments,
482
- }
483
-
484
- if (__DEV__) {
485
- value.filePath = key
486
- }
487
-
488
- return {
489
- ...acc,
490
- [baseUrl + segments.join("/")]: value,
491
- }
492
- }, {})
493
- }
494
-
495
- function normalizePrefixPath(path: string) {
496
- while (path.startsWith(".")) {
497
- path = path.slice(1)
498
- }
499
- path = `/${path}/`
500
- while (path.startsWith("//")) {
501
- path = path.slice(1)
502
- }
503
- while (path.endsWith("//")) {
504
- path = path.slice(0, -1)
505
- }
506
- return path
507
- }
508
-
509
340
  function handleStateTransition(
510
341
  signal: AbortSignal,
511
342
  enableTransition: boolean,
@@ -6,26 +6,57 @@ import { useFileRouter } from "./context.js"
6
6
  export interface LinkProps extends ElementProps<"a"> {
7
7
  /**
8
8
  * The path to navigate to
9
+ * @example
10
+ * <Link to="/about">About</Link>
9
11
  */
10
12
  to: string
11
13
  /**
12
14
  * Whether to replace the current history entry
15
+ * @default false
13
16
  */
14
17
  replace?: boolean
15
18
  /**
16
19
  * Whether to trigger a view transition
20
+ * @default false (overrides transition from config)
17
21
  */
18
22
  transition?: boolean
23
+ /**
24
+ * Whether to prefetch the route's javascript dependencies when hovered or focused
25
+ * @default true
26
+ */
27
+ prefetchJs?: boolean
19
28
  }
20
29
 
21
30
  export const Link: Kiru.FC<LinkProps> = ({
22
31
  to,
23
32
  onclick,
33
+ onmouseover,
34
+ onfocus,
24
35
  replace,
25
36
  transition,
37
+ prefetchJs,
26
38
  ...props
27
39
  }) => {
28
- const { navigate } = useFileRouter()
40
+ const { navigate, prefetchRouteModules } = useFileRouter()
41
+
42
+ const handleMouseOver = useCallback(
43
+ (e: Kiru.MouseEvent<HTMLAnchorElement>) => {
44
+ if (prefetchJs !== false) {
45
+ prefetchRouteModules(to)
46
+ }
47
+ onmouseover?.(e)
48
+ },
49
+ [onmouseover]
50
+ )
51
+ const handleFocus = useCallback(
52
+ (e: Kiru.FocusEvent<HTMLAnchorElement>) => {
53
+ if (prefetchJs !== false) {
54
+ prefetchRouteModules(to)
55
+ }
56
+ onfocus?.(e)
57
+ },
58
+ [onfocus]
59
+ )
29
60
 
30
61
  const handleClick = useCallback(
31
62
  (e: Kiru.MouseEvent<HTMLAnchorElement>) => {
@@ -37,5 +68,11 @@ export const Link: Kiru.FC<LinkProps> = ({
37
68
  [to, navigate, onclick, replace]
38
69
  )
39
70
 
40
- return createElement("a", { href: to, onclick: handleClick, ...props })
71
+ return createElement("a", {
72
+ href: to,
73
+ onclick: handleClick,
74
+ onmouseover: handleMouseOver,
75
+ onfocus: handleFocus,
76
+ ...props,
77
+ })
41
78
  }
@@ -12,3 +12,19 @@ export interface PageModule {
12
12
  export interface ViteImportMap {
13
13
  [fp: string]: () => Promise<DefaultComponentModule>
14
14
  }
15
+
16
+ export interface FormattedViteImportMap {
17
+ [key: string]: {
18
+ load: () => Promise<DefaultComponentModule>
19
+ specificity: number
20
+ segments: string[]
21
+ filePath?: string
22
+ }
23
+ }
24
+
25
+ export interface RouteMatch {
26
+ route: string
27
+ pageEntry: FormattedViteImportMap[string]
28
+ params: Record<string, string>
29
+ routeSegments: string[]
30
+ }
@@ -0,0 +1,206 @@
1
+ import { createElement } from "../../element.js"
2
+ import { __DEV__ } from "../../env.js"
3
+ import type {
4
+ FormattedViteImportMap,
5
+ RouteMatch,
6
+ ViteImportMap,
7
+ } from "../types.internal"
8
+ import { PageConfig, PageProps } from "../types.js"
9
+
10
+ export {
11
+ formatViteImportMap,
12
+ matchRoute,
13
+ matchLayouts,
14
+ normalizePrefixPath,
15
+ parseQuery,
16
+ wrapWithLayouts,
17
+ }
18
+
19
+ function formatViteImportMap(
20
+ map: ViteImportMap,
21
+ dir: string,
22
+ baseUrl: string
23
+ ): FormattedViteImportMap {
24
+ return Object.keys(map).reduce<FormattedViteImportMap>((acc, key) => {
25
+ const dirIndex = key.indexOf(dir)
26
+ if (dirIndex === -1) {
27
+ console.warn(`[kiru/router]: File "${key}" does not start with "${dir}".`)
28
+ return acc
29
+ }
30
+
31
+ let specificity = 0
32
+ let k = key.slice(dirIndex + dir.length)
33
+ while (k.startsWith("/")) {
34
+ k = k.slice(1)
35
+ }
36
+ const segments: string[] = []
37
+ const parts = k.split("/").slice(0, -1)
38
+
39
+ for (let i = 0; i < parts.length; i++) {
40
+ const part = parts[i]
41
+ if (part.startsWith("[...") && part.endsWith("]")) {
42
+ if (i !== parts.length - 1) {
43
+ throw new Error(
44
+ `[kiru/router]: Catchall must be the folder name. Got "${key}"`
45
+ )
46
+ }
47
+ segments.push(`:${part.slice(4, -1)}*`)
48
+ specificity += 1
49
+ break
50
+ }
51
+ if (part.startsWith("[") && part.endsWith("]")) {
52
+ segments.push(`:${part.slice(1, -1)}`)
53
+ specificity += 10
54
+ continue
55
+ }
56
+ specificity += 100
57
+ segments.push(part)
58
+ }
59
+
60
+ const value: FormattedViteImportMap[string] = {
61
+ load: map[key],
62
+ specificity,
63
+ segments,
64
+ }
65
+
66
+ if (__DEV__) {
67
+ value.filePath = key
68
+ }
69
+
70
+ return {
71
+ ...acc,
72
+ [baseUrl + segments.join("/")]: value,
73
+ }
74
+ }, {})
75
+ }
76
+
77
+ function matchRoute(
78
+ pages: FormattedViteImportMap,
79
+ pathSegments: string[]
80
+ ): RouteMatch | null {
81
+ const matches: RouteMatch[] = []
82
+ outer: for (const [route, pageEntry] of Object.entries(pages)) {
83
+ const routeSegments = pageEntry.segments
84
+ const pathMatchingSegments = routeSegments.filter(
85
+ (seg) => !seg.startsWith("(") && !seg.endsWith(")")
86
+ )
87
+
88
+ const params: Record<string, string> = {}
89
+ let hasCatchall = false
90
+
91
+ // Check if route matches
92
+ for (
93
+ let i = 0;
94
+ i < pathMatchingSegments.length && i < pathSegments.length;
95
+ i++
96
+ ) {
97
+ const routeSeg = pathMatchingSegments[i]
98
+
99
+ if (routeSeg.startsWith(":")) {
100
+ const key = routeSeg.slice(1)
101
+
102
+ if (routeSeg.endsWith("*")) {
103
+ // Catchall route - matches remaining segments
104
+ hasCatchall = true
105
+ const catchallKey = key.slice(0, -1) // Remove the *
106
+ params[catchallKey] = pathSegments.slice(i).join("/")
107
+ break
108
+ } else {
109
+ // Regular dynamic segment
110
+ if (i >= pathSegments.length) {
111
+ continue outer
112
+ }
113
+ params[key] = pathSegments[i]
114
+ }
115
+ } else {
116
+ // Static segment
117
+ if (routeSeg !== pathSegments[i]) {
118
+ continue outer
119
+ }
120
+ }
121
+ }
122
+
123
+ // For non-catchall routes, ensure exact length match
124
+ if (!hasCatchall && pathMatchingSegments.length !== pathSegments.length) {
125
+ continue
126
+ }
127
+
128
+ matches.push({
129
+ route,
130
+ pageEntry,
131
+ params,
132
+ routeSegments,
133
+ })
134
+ }
135
+
136
+ // Sort by specificity (highest first) and return the best match
137
+ if (matches.length === 0) {
138
+ return null
139
+ }
140
+
141
+ matches.sort((a, b) => b.pageEntry.specificity - a.pageEntry.specificity)
142
+ return matches[0] || null
143
+ }
144
+
145
+ function matchLayouts(
146
+ layouts: FormattedViteImportMap,
147
+ routeSegments: string[]
148
+ ) {
149
+ return ["/", ...routeSegments].reduce((acc, _, i) => {
150
+ const layoutPath = "/" + routeSegments.slice(0, i).join("/")
151
+ const layout = layouts[layoutPath]
152
+
153
+ if (!layout) {
154
+ return acc
155
+ }
156
+
157
+ return [...acc, layout]
158
+ }, [] as FormattedViteImportMap[string][])
159
+ }
160
+
161
+ function normalizePrefixPath(path: string) {
162
+ while (path.startsWith(".")) {
163
+ path = path.slice(1)
164
+ }
165
+ path = `/${path}/`
166
+ while (path.startsWith("//")) {
167
+ path = path.slice(1)
168
+ }
169
+ while (path.endsWith("//")) {
170
+ path = path.slice(0, -1)
171
+ }
172
+ return path
173
+ }
174
+
175
+ function parseQuery(
176
+ search: string
177
+ ): Record<string, string | string[] | undefined> {
178
+ const params = new URLSearchParams(search)
179
+ const query: Record<string, string | string[] | undefined> = {}
180
+
181
+ for (const [key, value] of params.entries()) {
182
+ if (query[key]) {
183
+ // Convert to array if multiple values
184
+ if (Array.isArray(query[key])) {
185
+ ;(query[key] as string[]).push(value)
186
+ } else {
187
+ query[key] = [query[key] as string, value]
188
+ }
189
+ } else {
190
+ query[key] = value
191
+ }
192
+ }
193
+
194
+ return query
195
+ }
196
+
197
+ function wrapWithLayouts(
198
+ layouts: Kiru.FC[],
199
+ page: Kiru.FC,
200
+ props: PageProps<PageConfig>
201
+ ) {
202
+ return layouts.reduceRight(
203
+ (children, Layout) => createElement(Layout, { children }),
204
+ createElement(page, props)
205
+ )
206
+ }
@@ -0,0 +1,10 @@
1
+ export function isPrimitiveChild(value: unknown): value is JSX.PrimitiveChild {
2
+ return (
3
+ typeof value === "string" ||
4
+ typeof value === "number" ||
5
+ typeof value === "bigint" ||
6
+ typeof value === "boolean" ||
7
+ value === undefined ||
8
+ value === null
9
+ )
10
+ }
@@ -1,4 +1,5 @@
1
1
  export * from "./compare.js"
2
+ export * from "./dom.js"
2
3
  export * from "./format.js"
3
4
  export * from "./runtime.js"
4
5
  export * from "./vdom.js"