kiru 0.50.8 → 0.51.0-preview.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.
Files changed (76) hide show
  1. package/dist/constants.d.ts.map +1 -1
  2. package/dist/constants.js +1 -0
  3. package/dist/constants.js.map +1 -1
  4. package/dist/reconciler.d.ts.map +1 -1
  5. package/dist/reconciler.js +4 -12
  6. package/dist/reconciler.js.map +1 -1
  7. package/dist/router/client/index.d.ts +10 -0
  8. package/dist/router/client/index.d.ts.map +1 -0
  9. package/dist/router/client/index.js +73 -0
  10. package/dist/router/client/index.js.map +1 -0
  11. package/dist/router/dev/index.d.ts +2 -0
  12. package/dist/router/dev/index.d.ts.map +1 -0
  13. package/dist/router/dev/index.js +46 -0
  14. package/dist/router/dev/index.js.map +1 -0
  15. package/dist/router/fileRouter.d.ts +1 -1
  16. package/dist/router/fileRouter.d.ts.map +1 -1
  17. package/dist/router/fileRouter.js +10 -2
  18. package/dist/router/fileRouter.js.map +1 -1
  19. package/dist/router/fileRouterController.d.ts +4 -4
  20. package/dist/router/fileRouterController.d.ts.map +1 -1
  21. package/dist/router/fileRouterController.js +118 -47
  22. package/dist/router/fileRouterController.js.map +1 -1
  23. package/dist/router/globals.d.ts +3 -0
  24. package/dist/router/globals.d.ts.map +1 -1
  25. package/dist/router/globals.js +3 -0
  26. package/dist/router/globals.js.map +1 -1
  27. package/dist/router/head.d.ts +38 -0
  28. package/dist/router/head.d.ts.map +1 -0
  29. package/dist/router/head.js +63 -0
  30. package/dist/router/head.js.map +1 -0
  31. package/dist/router/index.d.ts +5 -0
  32. package/dist/router/index.d.ts.map +1 -1
  33. package/dist/router/index.js +5 -0
  34. package/dist/router/index.js.map +1 -1
  35. package/dist/router/pageConfig.d.ts +1 -1
  36. package/dist/router/pageConfig.d.ts.map +1 -1
  37. package/dist/router/pageConfig.js +1 -1
  38. package/dist/router/pageConfig.js.map +1 -1
  39. package/dist/router/server/index.d.ts +17 -0
  40. package/dist/router/server/index.d.ts.map +1 -0
  41. package/dist/router/server/index.js +160 -0
  42. package/dist/router/server/index.js.map +1 -0
  43. package/dist/router/types.d.ts +35 -11
  44. package/dist/router/types.d.ts.map +1 -1
  45. package/dist/router/types.internal.d.ts +6 -2
  46. package/dist/router/types.internal.d.ts.map +1 -1
  47. package/dist/router/utils/index.d.ts +4 -4
  48. package/dist/router/utils/index.d.ts.map +1 -1
  49. package/dist/router/utils/index.js +18 -5
  50. package/dist/router/utils/index.js.map +1 -1
  51. package/dist/types.dom.d.ts +2 -2
  52. package/dist/types.dom.d.ts.map +1 -1
  53. package/dist/types.utils.d.ts +3 -0
  54. package/dist/types.utils.d.ts.map +1 -1
  55. package/dist/utils/vdom.d.ts +2 -1
  56. package/dist/utils/vdom.d.ts.map +1 -1
  57. package/dist/utils/vdom.js +6 -1
  58. package/dist/utils/vdom.js.map +1 -1
  59. package/package.json +17 -1
  60. package/src/constants.ts +1 -0
  61. package/src/reconciler.ts +9 -19
  62. package/src/router/client/index.ts +97 -0
  63. package/src/router/dev/index.ts +63 -0
  64. package/src/router/fileRouter.ts +12 -3
  65. package/src/router/fileRouterController.ts +185 -73
  66. package/src/router/globals.ts +4 -0
  67. package/src/router/head.ts +66 -0
  68. package/src/router/index.ts +7 -0
  69. package/src/router/pageConfig.ts +2 -2
  70. package/src/router/server/index.ts +214 -0
  71. package/src/router/types.internal.ts +6 -2
  72. package/src/router/types.ts +43 -20
  73. package/src/router/utils/index.ts +25 -7
  74. package/src/types.dom.ts +2 -5
  75. package/src/types.utils.ts +4 -0
  76. package/src/utils/vdom.ts +9 -0
@@ -3,11 +3,11 @@ import { flushSync } from "../scheduler.js"
3
3
  import { __DEV__ } from "../env.js"
4
4
  import { type FileRouterContextType } from "./context.js"
5
5
  import { FileRouterDataLoadError } from "./errors.js"
6
- import { fileRouterInstance } from "./globals.js"
6
+ import { fileRouterInstance, fileRouterRoute } from "./globals.js"
7
7
  import type {
8
- ErrorPageProps,
9
8
  FileRouterConfig,
10
9
  PageConfig,
10
+ PageDataLoaderConfig,
11
11
  PageProps,
12
12
  RouteQuery,
13
13
  RouterState,
@@ -21,23 +21,28 @@ import {
21
21
  formatViteImportMap,
22
22
  matchLayouts,
23
23
  matchRoute,
24
+ match404Route,
24
25
  normalizePrefixPath,
25
26
  parseQuery,
26
27
  wrapWithLayouts,
27
28
  } from "./utils/index.js"
28
29
 
30
+ interface PageConfigWithLoader<T = unknown> extends PageConfig {
31
+ loader: PageDataLoaderConfig<T>
32
+ }
33
+
29
34
  export class FileRouterController {
30
35
  public contextValue: FileRouterContextType
31
36
  private enableTransitions: boolean
32
37
  private pages: FormattedViteImportMap
33
38
  private layouts: FormattedViteImportMap
34
39
  private abortController: AbortController
35
- private routeState: Signal<{
40
+ private currentPage: Signal<{
36
41
  component: Kiru.FC<any>
37
42
  config?: PageConfig
38
43
  route: string
39
44
  } | null>
40
- private currentPageProps: Signal<PageProps<PageConfig>>
45
+ private currentPageProps: Signal<Record<string, unknown>>
41
46
  private currentLayouts: Signal<Kiru.FC[]>
42
47
  private state: RouterState
43
48
  private cleanups: (() => void)[] = []
@@ -46,14 +51,13 @@ export class FileRouterController {
46
51
  { route: string; config: PageConfig }
47
52
  >
48
53
  private pageRouteToConfig?: Map<string, PageConfig>
49
- private currentRoute: string | null
50
54
 
51
- constructor(config: FileRouterConfig) {
52
- fileRouterInstance.current = this
55
+ constructor() {
56
+ this.enableTransitions = false
53
57
  this.pages = {}
54
58
  this.layouts = {}
55
59
  this.abortController = new AbortController()
56
- this.routeState = new Signal(null)
60
+ this.currentPage = new Signal(null)
57
61
  this.currentPageProps = new Signal({})
58
62
  this.currentLayouts = new Signal([])
59
63
  this.state = {
@@ -77,46 +81,107 @@ export class FileRouterController {
77
81
  this.filePathToPageRoute = new Map()
78
82
  this.pageRouteToConfig = new Map()
79
83
  }
80
- this.currentRoute = null
81
84
 
82
- const { pages, layouts, dir = "/pages", baseUrl = "/", transition } = config
85
+ const handlePopState = () => this.loadRoute()
86
+ window.addEventListener("popstate", handlePopState)
87
+ this.cleanups.push(() =>
88
+ window.removeEventListener("popstate", handlePopState)
89
+ )
90
+ }
91
+
92
+ public init(config: FileRouterConfig) {
93
+ const {
94
+ pages,
95
+ layouts,
96
+ dir = "/pages",
97
+ baseUrl = "/",
98
+ transition,
99
+ preloaded,
100
+ } = config
83
101
  this.enableTransitions = !!transition
84
102
  const [normalizedDir, normalizedBaseUrl] = [
85
103
  normalizePrefixPath(dir),
86
104
  normalizePrefixPath(baseUrl),
87
105
  ]
88
- this.pages = formatViteImportMap(
89
- pages as ViteImportMap,
90
- normalizedDir,
91
- normalizedBaseUrl
92
- )
93
- if (__DEV__) {
94
- validateRoutes(this.pages)
95
- }
96
- this.layouts = formatViteImportMap(
97
- layouts as ViteImportMap,
98
- normalizedDir,
99
- normalizedBaseUrl
100
- )
101
106
 
102
- this.loadRoute()
107
+ if (preloaded) {
108
+ const {
109
+ pages,
110
+ layouts,
111
+ page,
112
+ pageProps,
113
+ pageLayouts,
114
+ route,
115
+ params,
116
+ query,
117
+ } = preloaded
118
+ this.state = {
119
+ params,
120
+ query,
121
+ path: route,
122
+ signal: this.abortController.signal,
123
+ }
124
+ this.currentPage.value = {
125
+ component: page.default,
126
+ config: page.config,
127
+ route,
128
+ }
129
+ this.currentPageProps.value = pageProps
130
+ this.currentLayouts.value = pageLayouts.map((l) => l.default)
131
+ this.pages = pages
132
+ this.layouts = layouts
133
+ if (__DEV__) {
134
+ if (page.config) {
135
+ this.onPageConfigDefined(route, page.config)
136
+ }
137
+ }
138
+ if (__DEV__) {
139
+ validateRoutes(this.pages)
140
+ }
141
+ const loader = page.config?.loader
142
+ if (__DEV__) {
143
+ if (loader) {
144
+ this.loadRouteData(
145
+ page.config as PageConfigWithLoader,
146
+ pageProps,
147
+ this.state
148
+ )
149
+ }
150
+ } else if (loader && loader.mode !== "static") {
151
+ this.loadRouteData(
152
+ page.config as PageConfigWithLoader,
153
+ pageProps,
154
+ this.state
155
+ )
156
+ }
157
+ } else {
158
+ this.pages = formatViteImportMap(
159
+ pages as ViteImportMap,
160
+ normalizedDir,
161
+ normalizedBaseUrl
162
+ )
103
163
 
104
- const handlePopState = () => this.loadRoute()
105
- window.addEventListener("popstate", handlePopState)
106
- this.cleanups.push(() =>
107
- window.removeEventListener("popstate", handlePopState)
108
- )
164
+ this.layouts = formatViteImportMap(
165
+ layouts as ViteImportMap,
166
+ normalizedDir,
167
+ normalizedBaseUrl
168
+ )
169
+ if (__DEV__) {
170
+ validateRoutes(this.pages)
171
+ }
172
+ this.loadRoute()
173
+ }
109
174
  }
110
175
 
111
- public onPageConfigDefined<T extends PageConfig>(fp: string, config: T) {
176
+ public onPageConfigDefined<T extends PageConfig<any>>(fp: string, config: T) {
112
177
  const existing = this.filePathToPageRoute?.get(fp)
113
178
  if (existing === undefined) {
114
- const route = this.currentRoute
179
+ const route = fileRouterRoute.current
115
180
  if (!route) return
116
181
  this.filePathToPageRoute?.set(fp, { route, config })
117
182
  return
118
183
  }
119
- const curPage = this.routeState.value
184
+ const curPage = this.currentPage.value
120
185
  if (curPage?.route === existing.route && config.loader) {
121
186
  const p = this.currentPageProps.value
122
187
  let transition = this.enableTransitions
@@ -133,14 +198,19 @@ export class FileRouterController {
133
198
  this.currentPageProps.value = props
134
199
  })
135
200
 
136
- this.loadRouteData(config.loader, props, this.state, transition)
201
+ this.loadRouteData(
202
+ config as PageConfigWithLoader,
203
+ props,
204
+ this.state,
205
+ transition
206
+ )
137
207
  }
138
208
 
139
209
  this.pageRouteToConfig?.set(existing.route, config)
140
210
  }
141
211
 
142
212
  public getChildren() {
143
- const page = this.routeState.value
213
+ const page = this.currentPage.value
144
214
  if (!page) return null
145
215
 
146
216
  const props = this.currentPageProps.value,
@@ -150,24 +220,34 @@ export class FileRouterController {
150
220
  }
151
221
 
152
222
  public dispose() {
223
+ this.abortController?.abort()
153
224
  this.cleanups.forEach((cleanup) => cleanup())
225
+ this.cleanups.length = 0
226
+ if (__DEV__) {
227
+ this.filePathToPageRoute?.clear()
228
+ this.pageRouteToConfig?.clear()
229
+ }
230
+ fileRouterRoute.current = null
231
+ fileRouterInstance.current = null
154
232
  }
155
233
 
156
234
  private async loadRoute(
157
235
  path: string = window.location.pathname,
158
- props: PageProps<PageConfig> = {},
159
- enableTransition = this.enableTransitions
236
+ props: Record<string, unknown> = {},
237
+ enableTransition = this.enableTransitions,
238
+ isStatic404 = false
160
239
  ): Promise<void> {
161
240
  this.abortController?.abort()
162
241
  const signal = (this.abortController = new AbortController()).signal
163
242
 
164
243
  try {
165
244
  const pathSegments = path.split("/").filter(Boolean)
166
- const routeMatch = matchRoute(this.pages, pathSegments)
245
+ let routeMatch = matchRoute(this.pages, pathSegments)
167
246
 
168
- if (!routeMatch) {
169
- const _404 = matchRoute(this.pages, ["404"])
170
- if (!_404) {
247
+ if (!routeMatch || isStatic404) {
248
+ // Try to find a 404 page in parent directories
249
+ const _404Match = match404Route(this.pages, pathSegments)
250
+ if (!_404Match) {
171
251
  if (__DEV__) {
172
252
  console.error(
173
253
  `[kiru/router]: No 404 route defined (path: ${path}).
@@ -176,16 +256,12 @@ See https://kirujs.dev/docs/api/file-router#404 for more information.`
176
256
  }
177
257
  return
178
258
  }
179
- const errorProps = {
180
- source: { path },
181
- } satisfies ErrorPageProps
182
-
183
- return this.navigate("/404", { replace: true, props: errorProps })
259
+ routeMatch = _404Match
184
260
  }
185
261
 
186
262
  const { route, pageEntry, params, routeSegments } = routeMatch
187
263
 
188
- this.currentRoute = route
264
+ fileRouterRoute.current = route
189
265
  const pagePromise = pageEntry.load()
190
266
 
191
267
  const layoutPromises = matchLayouts(this.layouts, routeSegments).map(
@@ -193,12 +269,12 @@ See https://kirujs.dev/docs/api/file-router#404 for more information.`
193
269
  )
194
270
 
195
271
  const [page, ...layouts] = await Promise.all([
196
- pagePromise,
272
+ pagePromise as Promise<PageModule>,
197
273
  ...layoutPromises,
198
274
  ])
199
275
 
200
276
  const query = parseQuery(window.location.search)
201
- this.currentRoute = null
277
+ fileRouterRoute.current = null
202
278
  if (signal.aborted) return
203
279
 
204
280
  if (typeof page.default !== "function") {
@@ -214,21 +290,49 @@ See https://kirujs.dev/docs/api/file-router#404 for more information.`
214
290
  signal,
215
291
  }
216
292
 
217
- let config = (page as unknown as PageModule).config
293
+ let config = page.config ?? ({} as PageConfig)
218
294
  if (__DEV__) {
219
295
  if (this.pageRouteToConfig?.has(route)) {
220
- config = this.pageRouteToConfig.get(route)
296
+ config = this.pageRouteToConfig.get(route)!
221
297
  }
222
298
  }
223
299
 
224
- if (config?.loader) {
225
- props = { ...props, loading: true, data: null, error: null }
226
- this.loadRouteData(config.loader, props, routerState, enableTransition)
300
+ const { loader } = config
301
+
302
+ if (loader) {
303
+ if (loader.mode !== "static" || __DEV__) {
304
+ props = {
305
+ ...props,
306
+ loading: true,
307
+ data: null,
308
+ error: null,
309
+ } satisfies PageProps<PageConfig<unknown>>
310
+
311
+ this.loadRouteData(
312
+ config as PageConfigWithLoader,
313
+ props,
314
+ routerState,
315
+ enableTransition
316
+ )
317
+ } else {
318
+ const staticProps = page.__KIRU_STATIC_PROPS__?.[path]
319
+ if (!staticProps) {
320
+ return this.loadRoute(path, props, enableTransition, true)
321
+ }
322
+
323
+ const { data, error } = staticProps
324
+ props = {
325
+ ...props,
326
+ data: data,
327
+ error: error ? new FileRouterDataLoadError(error) : null,
328
+ loading: false,
329
+ } as PageProps<PageConfig<unknown>>
330
+ }
227
331
  }
228
332
 
229
- this.state = routerState
230
333
  handleStateTransition(signal, enableTransition, () => {
231
- this.routeState.value = {
334
+ this.state = routerState
335
+ this.currentPage.value = {
232
336
  component: page.default,
233
337
  config,
234
338
  route: "/" + routeSegments.join("/"),
@@ -240,26 +344,34 @@ See https://kirujs.dev/docs/api/file-router#404 for more information.`
240
344
  })
241
345
  } catch (error) {
242
346
  console.error("[kiru/router]: Failed to load route component:", error)
243
- this.routeState.value = null
347
+ this.currentPage.value = null
244
348
  }
245
349
  }
246
350
 
247
351
  private async loadRouteData(
248
- loader: NonNullable<PageConfig["loader"]>,
249
- props: PageProps<PageConfig>,
352
+ config: PageConfigWithLoader,
353
+ props: Record<string, unknown>,
250
354
  routerState: RouterState,
251
355
  enableTransition = this.enableTransitions
252
356
  ) {
357
+ const { loader } = config
253
358
  loader
254
359
  .load(routerState)
255
360
  .then(
256
- (data) => ({ data, error: null }),
257
- (error) => ({
258
- data: null,
259
- error: new FileRouterDataLoadError(error),
260
- })
361
+ (data) =>
362
+ ({
363
+ data,
364
+ error: null,
365
+ loading: false,
366
+ } satisfies PageProps<PageConfig<unknown>>),
367
+ (error) =>
368
+ ({
369
+ data: null,
370
+ error: new FileRouterDataLoadError(error),
371
+ loading: false,
372
+ } satisfies PageProps<PageConfig<unknown>>)
261
373
  )
262
- .then(({ data, error }) => {
374
+ .then((state) => {
263
375
  if (routerState.signal.aborted) return
264
376
 
265
377
  let transition = enableTransition
@@ -270,10 +382,8 @@ See https://kirujs.dev/docs/api/file-router#404 for more information.`
270
382
  handleStateTransition(routerState.signal, transition, () => {
271
383
  this.currentPageProps.value = {
272
384
  ...props,
273
- loading: false,
274
- data,
275
- error,
276
- }
385
+ ...state,
386
+ } satisfies PageProps<PageConfig<unknown>>
277
387
  })
278
388
  })
279
389
  }
@@ -298,13 +408,13 @@ See https://kirujs.dev/docs/api/file-router#404 for more information.`
298
408
  throw new Error(`No route defined (path: ${path}).`)
299
409
  }
300
410
  const { pageEntry, route } = routeMatch
301
- this.currentRoute = route
411
+ fileRouterRoute.current = route
302
412
  const pagePromise = pageEntry.load()
303
413
  const layoutPromises = matchLayouts(this.layouts, route.split("/")).map(
304
414
  (layoutEntry) => layoutEntry.load()
305
415
  )
306
416
  await Promise.all([pagePromise, ...layoutPromises])
307
- this.currentRoute = null
417
+ fileRouterRoute.current = null
308
418
  } catch (error) {
309
419
  console.error("[kiru/router]: Failed to prefetch route:", error)
310
420
  }
@@ -370,9 +480,11 @@ function validateRoutes(pageMap: FormattedViteImportMap) {
370
480
 
371
481
  if (routeConflicts.length > 0) {
372
482
  let warning = "[kiru/router]: Route conflicts detected:\n"
373
- warning += routeConflicts.map(([route1, route2]) => {
374
- return ` - "${route1.filePath}" conflicts with "${route2.filePath}"\n`
375
- })
483
+ warning += routeConflicts
484
+ .map(([route1, route2]) => {
485
+ return ` - "${route1.filePath}" conflicts with "${route2.filePath}"\n`
486
+ })
487
+ .join("")
376
488
  warning += "Routes are ordered by specificity (higher specificity wins)"
377
489
  console.warn(warning)
378
490
  }
@@ -3,3 +3,7 @@ import type { FileRouterController } from "./fileRouterController"
3
3
  export const fileRouterInstance = {
4
4
  current: null as FileRouterController | null,
5
5
  }
6
+
7
+ export const fileRouterRoute = {
8
+ current: null as string | null,
9
+ }
@@ -0,0 +1,66 @@
1
+ import { createElement } from "../index.js"
2
+ import { Signal } from "../signals/base.js"
3
+ import { isValidTextChild, isVNode } from "../utils/index.js"
4
+
5
+ export { Content, Outlet }
6
+
7
+ /**
8
+ * Used with SSG. Renders content to the document head via a corresponding `<Head.Outlet>` component placed in your `document.tsx`.
9
+ * @example
10
+ * // src/pages/index.tsx
11
+ * export default function Index() {
12
+ * return (
13
+ * <div>
14
+ * <Head.Content>
15
+ * <title>My App - Home</title>
16
+ * </Head.Content>
17
+ * <h1>Home</h1>
18
+ * </div>
19
+ * )
20
+ }
21
+ */
22
+ function Content({ children }: { children: JSX.Children }) {
23
+ if ("window" in globalThis) {
24
+ ;(Array.isArray(children) ? children : [children])
25
+ .filter(isVNode)
26
+ .forEach(({ type, props }) => {
27
+ switch (type) {
28
+ case "title":
29
+ const title = (
30
+ Array.isArray(props.children) ? props.children : [props.children]
31
+ )
32
+ .map((c) => (Signal.isSignal(c) ? c.value : c))
33
+ .filter(isValidTextChild)
34
+ .join("")
35
+ return (document.title = title)
36
+ case "meta":
37
+ return document
38
+ .querySelector(`meta[name="${props.name}"]`)
39
+ ?.setAttribute("content", String(props.content))
40
+ }
41
+ })
42
+ return null
43
+ }
44
+ return createElement("kiru-head-content", { children })
45
+ }
46
+
47
+ /**
48
+ * Used with SSG. Renders content to the document head from a `<Head>` component in the currently rendered page.
49
+ * @example
50
+ * // document.tsx
51
+ * export default function Document() {
52
+ * return (
53
+ * <html lang="en">
54
+ * <head>
55
+ * <meta charset="utf-8" />
56
+ * <meta name="viewport" content="width=device-width, initial-scale=1" />
57
+ * <Head.Outlet />
58
+ * </head>
59
+ * <body>{children}</body>
60
+ * </html>
61
+ * )
62
+ }
63
+ */
64
+ function Outlet() {
65
+ return createElement("kiru-head-outlet")
66
+ }
@@ -4,3 +4,10 @@ export { FileRouter, type FileRouterProps } from "./fileRouter.js"
4
4
  export * from "./link.js"
5
5
  export * from "./pageConfig.js"
6
6
  export type * from "./types.js"
7
+
8
+ import { Content, Outlet } from "./head.js"
9
+
10
+ export const Head = {
11
+ Content,
12
+ Outlet,
13
+ }
@@ -2,8 +2,8 @@ import { __DEV__ } from "../env.js"
2
2
  import { fileRouterInstance } from "./globals.js"
3
3
  import type { PageConfig } from "./types"
4
4
 
5
- export function definePageConfig<T extends PageConfig>(config: T): T {
6
- if (__DEV__) {
5
+ export function definePageConfig<T>(config: PageConfig<T>): PageConfig<T> {
6
+ if (__DEV__ && "window" in globalThis) {
7
7
  const filePath = window.__kiru?.HMRContext?.getCurrentFilePath()
8
8
  const fileRouter = fileRouterInstance.current
9
9
  if (filePath && fileRouter) {