kiru 0.50.0-preview.0 → 0.50.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.
- package/dist/components/lazy.d.ts.map +1 -1
- package/dist/components/lazy.js +11 -136
- package/dist/components/lazy.js.map +1 -1
- package/dist/components/suspense.d.ts +7 -6
- package/dist/components/suspense.d.ts.map +1 -1
- package/dist/components/suspense.js +1 -4
- package/dist/components/suspense.js.map +1 -1
- package/dist/constants.d.ts +1 -2
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +1 -2
- package/dist/constants.js.map +1 -1
- package/dist/hmr.d.ts +1 -0
- package/dist/hmr.d.ts.map +1 -1
- package/dist/hmr.js +7 -0
- package/dist/hmr.js.map +1 -1
- package/dist/recursiveRender.d.ts +6 -0
- package/dist/recursiveRender.d.ts.map +1 -0
- package/dist/recursiveRender.js +109 -0
- package/dist/recursiveRender.js.map +1 -0
- package/dist/renderToString.d.ts.map +1 -1
- package/dist/renderToString.js +6 -112
- package/dist/renderToString.js.map +1 -1
- package/dist/router/config.d.ts +3 -0
- package/dist/router/config.d.ts.map +1 -0
- package/dist/router/config.js +13 -0
- package/dist/router/config.js.map +1 -0
- package/dist/router/context.d.ts +15 -0
- package/dist/router/context.d.ts.map +1 -0
- package/dist/router/context.js +11 -0
- package/dist/router/context.js.map +1 -0
- package/dist/router/errors.d.ts +4 -0
- package/dist/router/errors.d.ts.map +1 -0
- package/dist/router/errors.js +7 -0
- package/dist/router/errors.js.map +1 -0
- package/dist/router/fileRouter.d.ts +48 -0
- package/dist/router/fileRouter.d.ts.map +1 -0
- package/dist/router/fileRouter.js +311 -0
- package/dist/router/fileRouter.js.map +1 -0
- package/dist/router/globals.d.ts +5 -0
- package/dist/router/globals.d.ts.map +1 -0
- package/dist/router/globals.js +4 -0
- package/dist/router/globals.js.map +1 -0
- package/dist/router/index.d.ts +7 -0
- package/dist/router/index.d.ts.map +1 -0
- package/dist/router/index.js +7 -0
- package/dist/router/index.js.map +1 -0
- package/dist/router/link.d.ts +8 -0
- package/dist/router/link.d.ts.map +1 -0
- package/dist/router/link.js +15 -0
- package/dist/router/link.js.map +1 -0
- package/dist/router/types.d.ts +63 -0
- package/dist/router/types.d.ts.map +1 -0
- package/dist/router/types.internal.d.ts +12 -0
- package/dist/router/types.internal.d.ts.map +1 -0
- package/dist/router/types.internal.js +2 -0
- package/dist/router/types.internal.js.map +1 -0
- package/dist/router/types.js +2 -0
- package/dist/router/types.js.map +1 -0
- package/dist/ssr/server.d.ts.map +1 -1
- package/dist/ssr/server.js +5 -109
- package/dist/ssr/server.js.map +1 -1
- package/dist/types.d.ts +2 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/types.utils.d.ts +1 -8
- package/dist/types.utils.d.ts.map +1 -1
- package/dist/utils/vdom.d.ts.map +1 -1
- package/dist/utils/vdom.js +2 -5
- package/dist/utils/vdom.js.map +1 -1
- package/package.json +4 -8
- package/src/components/lazy.ts +12 -169
- package/src/components/suspense.ts +8 -10
- package/src/constants.ts +0 -2
- package/src/hmr.ts +8 -0
- package/src/recursiveRender.ts +127 -0
- package/src/renderToString.ts +7 -137
- package/src/router/config.ts +15 -0
- package/src/router/context.ts +23 -0
- package/src/router/errors.ts +6 -0
- package/src/router/fileRouter.ts +475 -0
- package/src/router/globals.ts +5 -0
- package/src/router/index.ts +6 -0
- package/src/router/link.ts +32 -0
- package/src/router/types.internal.ts +14 -0
- package/src/router/types.ts +81 -0
- package/src/ssr/server.ts +6 -136
- package/src/types.ts +0 -2
- package/src/types.utils.ts +1 -14
- package/src/utils/vdom.ts +1 -5
- package/dist/components/router/index.d.ts +0 -3
- package/dist/components/router/index.d.ts.map +0 -1
- package/dist/components/router/index.js +0 -3
- package/dist/components/router/index.js.map +0 -1
- package/dist/components/router/route.d.ts +0 -46
- package/dist/components/router/route.d.ts.map +0 -1
- package/dist/components/router/route.js +0 -8
- package/dist/components/router/route.js.map +0 -1
- package/dist/components/router/router.d.ts +0 -62
- package/dist/components/router/router.d.ts.map +0 -1
- package/dist/components/router/router.js +0 -177
- package/dist/components/router/router.js.map +0 -1
- package/dist/components/router/routerUtils.d.ts +0 -5
- package/dist/components/router/routerUtils.d.ts.map +0 -1
- package/dist/components/router/routerUtils.js +0 -39
- package/dist/components/router/routerUtils.js.map +0 -1
- package/dist/ssr/hydrationBoundary.d.ts +0 -27
- package/dist/ssr/hydrationBoundary.d.ts.map +0 -1
- package/dist/ssr/hydrationBoundary.js +0 -30
- package/dist/ssr/hydrationBoundary.js.map +0 -1
- package/dist/ssr/index.d.ts +0 -2
- package/dist/ssr/index.d.ts.map +0 -1
- package/dist/ssr/index.js +0 -2
- package/dist/ssr/index.js.map +0 -1
- package/src/components/router/index.ts +0 -2
- package/src/components/router/route.ts +0 -51
- package/src/components/router/router.ts +0 -273
- package/src/components/router/routerUtils.ts +0 -49
- package/src/ssr/hydrationBoundary.ts +0 -63
- package/src/ssr/index.ts +0 -1
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
import { Signal, computed, flushSync } from "../index.js"
|
|
2
|
+
import { __DEV__ } from "../env.js"
|
|
3
|
+
import { createElement } from "../element.js"
|
|
4
|
+
import { useState, useEffect } from "../hooks/index.js"
|
|
5
|
+
import { RouterContext, type FileRouterContextType } from "./context.js"
|
|
6
|
+
import { FileRouterDataLoadError } from "./errors.js"
|
|
7
|
+
import { fileRouterInstance } from "./globals.js"
|
|
8
|
+
import type {
|
|
9
|
+
ErrorPageProps,
|
|
10
|
+
FileRouterConfig,
|
|
11
|
+
PageConfig,
|
|
12
|
+
PageProps,
|
|
13
|
+
RouteQuery,
|
|
14
|
+
RouterState,
|
|
15
|
+
} from "./types.js"
|
|
16
|
+
import type {
|
|
17
|
+
DefaultComponentModule,
|
|
18
|
+
PageModule,
|
|
19
|
+
ViteImportMap,
|
|
20
|
+
} from "./types.internal.js"
|
|
21
|
+
|
|
22
|
+
export class FileRouterController {
|
|
23
|
+
private enableTransitions: boolean
|
|
24
|
+
private pages: ViteImportMap
|
|
25
|
+
private layouts: ViteImportMap
|
|
26
|
+
private abortController: AbortController
|
|
27
|
+
private currentPage: Signal<{
|
|
28
|
+
component: Kiru.FC<any>
|
|
29
|
+
config?: PageConfig
|
|
30
|
+
route: string
|
|
31
|
+
} | null>
|
|
32
|
+
private currentPageProps: Signal<PageProps<PageConfig>>
|
|
33
|
+
private currentLayouts: Signal<Kiru.FC[]>
|
|
34
|
+
private loading: Signal<boolean>
|
|
35
|
+
private state: Signal<RouterState>
|
|
36
|
+
private contextValue: Signal<FileRouterContextType>
|
|
37
|
+
private cleanups: (() => void)[] = []
|
|
38
|
+
private filePathToPageRoute: Map<
|
|
39
|
+
string,
|
|
40
|
+
{
|
|
41
|
+
route: string
|
|
42
|
+
config: PageConfig
|
|
43
|
+
}
|
|
44
|
+
>
|
|
45
|
+
private pageRouteToConfig: Map<string, PageConfig>
|
|
46
|
+
private currentRoute: string | null
|
|
47
|
+
|
|
48
|
+
constructor(props: FileRouterProps) {
|
|
49
|
+
fileRouterInstance.current = this
|
|
50
|
+
this.pages = {}
|
|
51
|
+
this.layouts = {}
|
|
52
|
+
this.abortController = new AbortController()
|
|
53
|
+
this.currentPage = new Signal(null)
|
|
54
|
+
this.currentPageProps = new Signal({})
|
|
55
|
+
this.currentLayouts = new Signal([])
|
|
56
|
+
this.loading = new Signal(true)
|
|
57
|
+
this.state = new Signal<RouterState>({
|
|
58
|
+
path: window.location.pathname,
|
|
59
|
+
params: {},
|
|
60
|
+
query: {},
|
|
61
|
+
signal: this.abortController.signal,
|
|
62
|
+
})
|
|
63
|
+
this.contextValue = computed<FileRouterContextType>(() => ({
|
|
64
|
+
state: this.state.value,
|
|
65
|
+
navigate: this.navigate.bind(this),
|
|
66
|
+
setQuery: this.setQuery.bind(this),
|
|
67
|
+
reload: (options?: { transition?: boolean }) =>
|
|
68
|
+
this.loadRoute(void 0, void 0, options?.transition),
|
|
69
|
+
}))
|
|
70
|
+
this.filePathToPageRoute = new Map()
|
|
71
|
+
this.pageRouteToConfig = new Map()
|
|
72
|
+
this.currentRoute = null
|
|
73
|
+
|
|
74
|
+
const {
|
|
75
|
+
pages,
|
|
76
|
+
layouts,
|
|
77
|
+
dir = "/pages",
|
|
78
|
+
baseUrl = "/",
|
|
79
|
+
transition,
|
|
80
|
+
} = props.config
|
|
81
|
+
this.enableTransitions = !!transition
|
|
82
|
+
const [normalizedDir, normalizedBaseUrl] = [
|
|
83
|
+
normalizePrefixPath(dir),
|
|
84
|
+
normalizePrefixPath(baseUrl),
|
|
85
|
+
]
|
|
86
|
+
debugger
|
|
87
|
+
this.pages = formatViteImportMap(
|
|
88
|
+
pages as ViteImportMap,
|
|
89
|
+
normalizedDir,
|
|
90
|
+
normalizedBaseUrl
|
|
91
|
+
)
|
|
92
|
+
this.layouts = formatViteImportMap(
|
|
93
|
+
layouts as ViteImportMap,
|
|
94
|
+
normalizedDir,
|
|
95
|
+
normalizedBaseUrl
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
this.loadRoute()
|
|
99
|
+
|
|
100
|
+
const handlePopState = () => this.loadRoute()
|
|
101
|
+
window.addEventListener("popstate", handlePopState)
|
|
102
|
+
this.cleanups.push(() =>
|
|
103
|
+
window.removeEventListener("popstate", handlePopState)
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
public onPageConfigDefined<T extends PageConfig>(fp: string, config: T) {
|
|
108
|
+
const existing = this.filePathToPageRoute.get(fp)
|
|
109
|
+
if (existing === undefined) {
|
|
110
|
+
const route = this.currentRoute
|
|
111
|
+
if (!route) return
|
|
112
|
+
this.filePathToPageRoute.set(fp, { route, config })
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
const curPage = this.currentPage.value
|
|
116
|
+
if (curPage?.route === existing.route && config.loader) {
|
|
117
|
+
const p = this.currentPageProps.value
|
|
118
|
+
let transition = this.enableTransitions
|
|
119
|
+
if (config.loader.transition !== undefined) {
|
|
120
|
+
transition = config.loader.transition
|
|
121
|
+
}
|
|
122
|
+
const props = {
|
|
123
|
+
...p,
|
|
124
|
+
loading: true,
|
|
125
|
+
data: null,
|
|
126
|
+
error: null,
|
|
127
|
+
}
|
|
128
|
+
handleStateTransition(this.state.value.signal, transition, () => {
|
|
129
|
+
this.currentPageProps.value = props
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
this.loadRouteData(config.loader, props, this.state.value, transition)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
this.pageRouteToConfig.set(existing.route, config)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
public getContextValue() {
|
|
139
|
+
return this.contextValue.value
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
public getChildren() {
|
|
143
|
+
const page = this.currentPage.value,
|
|
144
|
+
props = this.currentPageProps.value,
|
|
145
|
+
layouts = this.currentLayouts.value
|
|
146
|
+
|
|
147
|
+
if (page) {
|
|
148
|
+
// Wrap component with layouts (outermost first)
|
|
149
|
+
return layouts.reduceRight(
|
|
150
|
+
(children, Layout) => createElement(Layout, { children }),
|
|
151
|
+
createElement(page.component, props)
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return null
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
public dispose() {
|
|
159
|
+
this.cleanups.forEach((cleanup) => cleanup())
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private matchRoute(pathSegments: string[]) {
|
|
163
|
+
outer: for (const [route, pageModuleLoader] of Object.entries(this.pages)) {
|
|
164
|
+
const routeSegments = route.split("/").filter(Boolean)
|
|
165
|
+
|
|
166
|
+
const pathMatchingSegments = routeSegments.filter(
|
|
167
|
+
(seg) => !seg.startsWith("(") && !seg.endsWith(")")
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
if (pathMatchingSegments.length !== pathSegments.length) {
|
|
171
|
+
continue
|
|
172
|
+
}
|
|
173
|
+
const params: Record<string, string> = {}
|
|
174
|
+
|
|
175
|
+
for (let i = 0; i < pathMatchingSegments.length; i++) {
|
|
176
|
+
const routeSeg = pathMatchingSegments[i]
|
|
177
|
+
if (routeSeg.startsWith(":")) {
|
|
178
|
+
const key = routeSeg.slice(1)
|
|
179
|
+
params[key] = pathSegments[i]
|
|
180
|
+
continue
|
|
181
|
+
}
|
|
182
|
+
if (routeSeg !== pathSegments[i]) {
|
|
183
|
+
continue outer
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return { route, pageModuleLoader, params, routeSegments }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return null
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private async loadRoute(
|
|
194
|
+
path: string = window.location.pathname,
|
|
195
|
+
props: PageProps<PageConfig> = {},
|
|
196
|
+
enableTransition = this.enableTransitions
|
|
197
|
+
): Promise<void> {
|
|
198
|
+
this.loading.value = true
|
|
199
|
+
this.abortController?.abort()
|
|
200
|
+
|
|
201
|
+
const query = parseQuery(window.location.search)
|
|
202
|
+
const controller = (this.abortController = new AbortController())
|
|
203
|
+
const signal = controller.signal
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
const pathSegments = path.split("/").filter(Boolean)
|
|
207
|
+
const routeMatch = this.matchRoute(pathSegments)
|
|
208
|
+
|
|
209
|
+
if (!routeMatch) {
|
|
210
|
+
const _404 = this.matchRoute(["404"])
|
|
211
|
+
if (!_404) {
|
|
212
|
+
if (__DEV__) {
|
|
213
|
+
console.error(
|
|
214
|
+
`No 404 route defined (path: ${path}). See https://kirujs.dev/404 for more information.`
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
return
|
|
218
|
+
}
|
|
219
|
+
const errorProps = {
|
|
220
|
+
source: { path },
|
|
221
|
+
} satisfies ErrorPageProps
|
|
222
|
+
|
|
223
|
+
return this.navigate("/404", { replace: true, props: errorProps })
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const { route, pageModuleLoader, params, routeSegments } = routeMatch
|
|
227
|
+
|
|
228
|
+
this.currentRoute = route
|
|
229
|
+
const pagePromise = pageModuleLoader()
|
|
230
|
+
|
|
231
|
+
const layoutPromises = ["/", ...routeSegments].reduce((acc, _, i) => {
|
|
232
|
+
const layoutPath = "/" + routeSegments.slice(0, i).join("/")
|
|
233
|
+
const layoutLoad = this.layouts[layoutPath]
|
|
234
|
+
|
|
235
|
+
if (!layoutLoad) {
|
|
236
|
+
return acc
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return [...acc, layoutLoad()]
|
|
240
|
+
}, [] as Promise<DefaultComponentModule>[])
|
|
241
|
+
|
|
242
|
+
const [page, ...layouts] = await Promise.all([
|
|
243
|
+
pagePromise,
|
|
244
|
+
...layoutPromises,
|
|
245
|
+
])
|
|
246
|
+
|
|
247
|
+
this.currentRoute = null
|
|
248
|
+
if (controller.signal.aborted) return
|
|
249
|
+
|
|
250
|
+
if (typeof page.default !== "function") {
|
|
251
|
+
throw new Error("Route component must be a default exported function")
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const routerState: RouterState = {
|
|
255
|
+
path,
|
|
256
|
+
params,
|
|
257
|
+
query,
|
|
258
|
+
signal,
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
let config = (page as unknown as PageModule).config
|
|
262
|
+
if (this.pageRouteToConfig.has(route)) {
|
|
263
|
+
config = this.pageRouteToConfig.get(route)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (config?.loader) {
|
|
267
|
+
props = { ...props, loading: true, data: null, error: null }
|
|
268
|
+
this.loadRouteData(config.loader, props, routerState, enableTransition)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
handleStateTransition(signal, enableTransition, () => {
|
|
272
|
+
this.currentPage.value = {
|
|
273
|
+
component: page.default,
|
|
274
|
+
config,
|
|
275
|
+
route: "/" + routeSegments.join("/"),
|
|
276
|
+
}
|
|
277
|
+
this.state.value = routerState
|
|
278
|
+
this.currentPageProps.value = props
|
|
279
|
+
this.currentLayouts.value = layouts
|
|
280
|
+
.filter((m) => typeof m.default === "function")
|
|
281
|
+
.map((m) => m.default)
|
|
282
|
+
})
|
|
283
|
+
} catch (error) {
|
|
284
|
+
console.error("Failed to load route component:", error)
|
|
285
|
+
this.currentPage.value = null
|
|
286
|
+
} finally {
|
|
287
|
+
this.loading.value = false
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
private async loadRouteData(
|
|
292
|
+
loader: NonNullable<PageConfig["loader"]>,
|
|
293
|
+
props: PageProps<PageConfig>,
|
|
294
|
+
routerState: RouterState,
|
|
295
|
+
enableTransition = this.enableTransitions
|
|
296
|
+
) {
|
|
297
|
+
loader
|
|
298
|
+
.load(routerState)
|
|
299
|
+
.then(
|
|
300
|
+
(data) => ({ data, error: null }),
|
|
301
|
+
(error) => ({
|
|
302
|
+
data: null,
|
|
303
|
+
error: new FileRouterDataLoadError(error),
|
|
304
|
+
})
|
|
305
|
+
)
|
|
306
|
+
.then(({ data, error }) => {
|
|
307
|
+
if (routerState.signal.aborted) return
|
|
308
|
+
|
|
309
|
+
let transition = enableTransition
|
|
310
|
+
if (loader.transition !== undefined) {
|
|
311
|
+
transition = loader.transition
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
handleStateTransition(routerState.signal, transition, () => {
|
|
315
|
+
this.currentPageProps.value = {
|
|
316
|
+
...props,
|
|
317
|
+
loading: false,
|
|
318
|
+
data,
|
|
319
|
+
error,
|
|
320
|
+
}
|
|
321
|
+
})
|
|
322
|
+
})
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
private async navigate(
|
|
326
|
+
path: string,
|
|
327
|
+
options?: {
|
|
328
|
+
replace?: boolean
|
|
329
|
+
transition?: boolean
|
|
330
|
+
props?: Record<string, unknown>
|
|
331
|
+
}
|
|
332
|
+
) {
|
|
333
|
+
const f = options?.replace ? "replaceState" : "pushState"
|
|
334
|
+
window.history[f]({}, "", path)
|
|
335
|
+
window.dispatchEvent(new PopStateEvent("popstate", { state: {} }))
|
|
336
|
+
return this.loadRoute(path, options?.props, options?.transition)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
private setQuery(query: RouteQuery) {
|
|
340
|
+
const queryString = buildQueryString(query)
|
|
341
|
+
const newUrl = `${this.state.value.path}${
|
|
342
|
+
queryString ? `?${queryString}` : ""
|
|
343
|
+
}`
|
|
344
|
+
window.history.pushState(null, "", newUrl)
|
|
345
|
+
this.state.value = { ...this.state.value, query }
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export interface FileRouterProps {
|
|
350
|
+
/**
|
|
351
|
+
* The router configuration
|
|
352
|
+
* @example
|
|
353
|
+
* ```ts
|
|
354
|
+
*<FileRouter
|
|
355
|
+
config={{
|
|
356
|
+
dir: "/fbr-app", // optional, defaults to "/pages"
|
|
357
|
+
baseUrl: "/app", // optional, defaults to "/"
|
|
358
|
+
pages: import.meta.glob("/∗∗/index.tsx"),
|
|
359
|
+
layouts: import.meta.glob("/∗∗/layout.tsx"),
|
|
360
|
+
transition: true
|
|
361
|
+
}}
|
|
362
|
+
/>
|
|
363
|
+
* ```
|
|
364
|
+
*/
|
|
365
|
+
config: FileRouterConfig
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export function FileRouter(props: FileRouterProps): JSX.Element {
|
|
369
|
+
const [controller] = useState(() => new FileRouterController(props))
|
|
370
|
+
useEffect(() => () => controller.dispose(), [controller])
|
|
371
|
+
|
|
372
|
+
return createElement(
|
|
373
|
+
RouterContext.Provider,
|
|
374
|
+
{ value: controller.getContextValue() },
|
|
375
|
+
controller.getChildren()
|
|
376
|
+
)
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Utility functions
|
|
380
|
+
|
|
381
|
+
function parseQuery(
|
|
382
|
+
search: string
|
|
383
|
+
): Record<string, string | string[] | undefined> {
|
|
384
|
+
const params = new URLSearchParams(search)
|
|
385
|
+
const query: Record<string, string | string[] | undefined> = {}
|
|
386
|
+
|
|
387
|
+
for (const [key, value] of params.entries()) {
|
|
388
|
+
if (query[key]) {
|
|
389
|
+
// Convert to array if multiple values
|
|
390
|
+
if (Array.isArray(query[key])) {
|
|
391
|
+
;(query[key] as string[]).push(value)
|
|
392
|
+
} else {
|
|
393
|
+
query[key] = [query[key] as string, value]
|
|
394
|
+
}
|
|
395
|
+
} else {
|
|
396
|
+
query[key] = value
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return query
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function buildQueryString(
|
|
404
|
+
query: Record<string, string | string[] | undefined>
|
|
405
|
+
): string {
|
|
406
|
+
const params = new URLSearchParams()
|
|
407
|
+
|
|
408
|
+
for (const [key, value] of Object.entries(query)) {
|
|
409
|
+
if (value !== undefined) {
|
|
410
|
+
if (Array.isArray(value)) {
|
|
411
|
+
value.forEach((v) => params.append(key, v))
|
|
412
|
+
} else {
|
|
413
|
+
params.set(key, value)
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
return params.toString()
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function formatViteImportMap(
|
|
422
|
+
map: ViteImportMap,
|
|
423
|
+
dir: string,
|
|
424
|
+
baseUrl: string
|
|
425
|
+
): ViteImportMap {
|
|
426
|
+
return Object.keys(map).reduce((acc, key) => {
|
|
427
|
+
let k = key
|
|
428
|
+
const dirIndex = k.indexOf(dir)
|
|
429
|
+
if (dirIndex === -1) {
|
|
430
|
+
return acc
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
k = k.slice(dirIndex + dir.length)
|
|
434
|
+
while (k.startsWith("/")) {
|
|
435
|
+
k = k.slice(1)
|
|
436
|
+
}
|
|
437
|
+
k = k.split("/").slice(0, -1).join("/") // remove filename
|
|
438
|
+
k = k.replace(/\[([^\]]+)\]/g, ":$1") // replace [param] with :param
|
|
439
|
+
|
|
440
|
+
return {
|
|
441
|
+
...acc,
|
|
442
|
+
[baseUrl + k]: map[key],
|
|
443
|
+
}
|
|
444
|
+
}, {})
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function normalizePrefixPath(path: string) {
|
|
448
|
+
while (path.startsWith(".")) {
|
|
449
|
+
path = path.slice(1)
|
|
450
|
+
}
|
|
451
|
+
path = `/${path}/`
|
|
452
|
+
while (path.startsWith("//")) {
|
|
453
|
+
path = path.slice(1)
|
|
454
|
+
}
|
|
455
|
+
while (path.endsWith("//")) {
|
|
456
|
+
path = path.slice(0, -1)
|
|
457
|
+
}
|
|
458
|
+
return path
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function handleStateTransition(
|
|
462
|
+
signal: AbortSignal,
|
|
463
|
+
enableTransition: boolean,
|
|
464
|
+
callback: () => void
|
|
465
|
+
) {
|
|
466
|
+
if (!enableTransition || typeof document.startViewTransition !== "function") {
|
|
467
|
+
return callback()
|
|
468
|
+
}
|
|
469
|
+
const vt = document.startViewTransition(() => {
|
|
470
|
+
callback()
|
|
471
|
+
flushSync()
|
|
472
|
+
})
|
|
473
|
+
|
|
474
|
+
signal.addEventListener("abort", () => vt.skipTransition())
|
|
475
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ElementProps } from "../types"
|
|
2
|
+
import { createElement } from "../element.js"
|
|
3
|
+
import { useCallback } from "../hooks/index.js"
|
|
4
|
+
import { useFileRouter } from "./context.js"
|
|
5
|
+
|
|
6
|
+
export interface LinkProps extends ElementProps<"a"> {
|
|
7
|
+
to: string
|
|
8
|
+
replace?: boolean
|
|
9
|
+
transition?: boolean
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const Link: Kiru.FC<LinkProps> = ({
|
|
13
|
+
to,
|
|
14
|
+
onclick,
|
|
15
|
+
replace,
|
|
16
|
+
transition,
|
|
17
|
+
...props
|
|
18
|
+
}) => {
|
|
19
|
+
const { navigate } = useFileRouter()
|
|
20
|
+
|
|
21
|
+
const handleClick = useCallback(
|
|
22
|
+
(e: Kiru.MouseEvent<HTMLAnchorElement>) => {
|
|
23
|
+
onclick?.(e)
|
|
24
|
+
if (e.defaultPrevented) return
|
|
25
|
+
e.preventDefault()
|
|
26
|
+
navigate(to, { replace, transition })
|
|
27
|
+
},
|
|
28
|
+
[to, navigate, onclick, replace]
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
return createElement("a", { href: to, onclick: handleClick, ...props })
|
|
32
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { PageConfig } from "./types"
|
|
2
|
+
|
|
3
|
+
export interface DefaultComponentModule {
|
|
4
|
+
default: Kiru.FC
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface PageModule {
|
|
8
|
+
default: DefaultComponentModule
|
|
9
|
+
config?: PageConfig
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ViteImportMap {
|
|
13
|
+
[fp: string]: () => Promise<DefaultComponentModule>
|
|
14
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { AsyncTaskState } from "../types.utils"
|
|
2
|
+
import { FileRouterDataLoadError } from "./errors"
|
|
3
|
+
|
|
4
|
+
export interface FileRouterConfig {
|
|
5
|
+
/**
|
|
6
|
+
* The directory to load routes from
|
|
7
|
+
* @default "/pages"
|
|
8
|
+
*/
|
|
9
|
+
dir?: string
|
|
10
|
+
/**
|
|
11
|
+
* The import map to use for loading pages
|
|
12
|
+
* @example
|
|
13
|
+
* ```tsx
|
|
14
|
+
* <FileRouter config={{ pages: import.meta.glob("/∗∗/index.tsx"), ... }} />
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
pages: Record<string, unknown>
|
|
18
|
+
/**
|
|
19
|
+
* The import map to use for loading layouts
|
|
20
|
+
* @example
|
|
21
|
+
* ```tsx
|
|
22
|
+
* <FileRouter config={{ pages: import.meta.glob("/∗∗/layout.tsx"), ... }} />
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
layouts: Record<string, unknown>
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The base url to use as a prefix for route matching
|
|
29
|
+
* @default "/"
|
|
30
|
+
*/
|
|
31
|
+
baseUrl?: string
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Enable transitions for all routes and loading states
|
|
35
|
+
* @default false
|
|
36
|
+
*/
|
|
37
|
+
transition?: boolean
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface RouteParams {
|
|
41
|
+
[key: string]: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface RouteQuery {
|
|
45
|
+
[key: string]: string | string[] | undefined
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface RouterState {
|
|
49
|
+
path: string
|
|
50
|
+
params: RouteParams
|
|
51
|
+
query: RouteQuery
|
|
52
|
+
signal: AbortSignal
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
type PageDataLoaderContext = RouterState & {}
|
|
56
|
+
|
|
57
|
+
export interface PageDataLoaderConfig<T = unknown> {
|
|
58
|
+
load: (context: PageDataLoaderContext) => Promise<T>
|
|
59
|
+
transition?: boolean
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface PageConfig {
|
|
63
|
+
loader?: PageDataLoaderConfig
|
|
64
|
+
// title?: string
|
|
65
|
+
// description?: string
|
|
66
|
+
// meta?: Record<string, string>
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export type PageProps<T extends PageConfig> =
|
|
70
|
+
T["loader"] extends PageDataLoaderConfig
|
|
71
|
+
? AsyncTaskState<
|
|
72
|
+
Awaited<ReturnType<T["loader"]["load"]>>,
|
|
73
|
+
FileRouterDataLoadError
|
|
74
|
+
>
|
|
75
|
+
: {}
|
|
76
|
+
|
|
77
|
+
export interface ErrorPageProps {
|
|
78
|
+
source?: {
|
|
79
|
+
path: string
|
|
80
|
+
}
|
|
81
|
+
}
|