@kimesh/router-runtime 0.0.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/README.md +3 -0
- package/dist/index.d.mts +1129 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +1298 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +50 -0
- package/src/default-app.vue +7 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1298 @@
|
|
|
1
|
+
import { KeepAlive, Suspense, Transition, computed, createApp, defineComponent, h, inject, isRef, nextTick, onErrorCaptured, reactive, ref, toRef } from "vue";
|
|
2
|
+
import { RouterLink, RouterView, createRouter, createWebHashHistory, createWebHistory, onBeforeRouteLeave, onBeforeRouteUpdate, useLink, useRoute, useRoute as useRoute$1, useRouter, useRouter as useRouter$1 } from "vue-router";
|
|
3
|
+
import { QueryClient, VueQueryPlugin } from "@tanstack/vue-query";
|
|
4
|
+
import { createHooks } from "hookable";
|
|
5
|
+
|
|
6
|
+
//#region src/guards/loader-guard.ts
|
|
7
|
+
/**
|
|
8
|
+
* Safely parse input with schema, returning input on failure
|
|
9
|
+
*/
|
|
10
|
+
function safeParse(schema, input) {
|
|
11
|
+
try {
|
|
12
|
+
return schema.parse(input);
|
|
13
|
+
} catch {
|
|
14
|
+
return input;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Validate search params using the provided schema
|
|
19
|
+
*/
|
|
20
|
+
function validateSearchParams(query, schema) {
|
|
21
|
+
if (!schema) return query;
|
|
22
|
+
if (schema.safeParse) {
|
|
23
|
+
const result = schema.safeParse(query);
|
|
24
|
+
if (result.success && result.data !== void 0) return result.data;
|
|
25
|
+
return safeParse(schema, {});
|
|
26
|
+
}
|
|
27
|
+
const parsed = safeParse(schema, query);
|
|
28
|
+
if (parsed !== query) return parsed;
|
|
29
|
+
const defaults = safeParse(schema, {});
|
|
30
|
+
return defaults !== query ? defaults : query;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Collect loaders from matched route records
|
|
34
|
+
*/
|
|
35
|
+
function collectLoaders(to) {
|
|
36
|
+
const loaders = [];
|
|
37
|
+
for (const record of to.matched) {
|
|
38
|
+
const routeDef = record.meta?.__kimesh;
|
|
39
|
+
if (routeDef?.loader) loaders.push({
|
|
40
|
+
loader: routeDef.loader,
|
|
41
|
+
routeDef
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
return loaders;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Check if error is an AbortError
|
|
48
|
+
*/
|
|
49
|
+
function isAbortError(error) {
|
|
50
|
+
return error instanceof Error && error.name === "AbortError";
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Create the loader navigation guard
|
|
54
|
+
*/
|
|
55
|
+
function createLoaderGuard(context, options = {}) {
|
|
56
|
+
const { onError } = options;
|
|
57
|
+
return async function loaderGuard(to, _from, abortController) {
|
|
58
|
+
const loaders = collectLoaders(to);
|
|
59
|
+
if (loaders.length === 0) return;
|
|
60
|
+
await Promise.all(loaders.map(async ({ loader, routeDef }) => {
|
|
61
|
+
const search = validateSearchParams(to.query, routeDef.validateSearch);
|
|
62
|
+
const loaderContext = {
|
|
63
|
+
params: to.params,
|
|
64
|
+
search,
|
|
65
|
+
context,
|
|
66
|
+
signal: abortController.signal
|
|
67
|
+
};
|
|
68
|
+
try {
|
|
69
|
+
await loader(loaderContext);
|
|
70
|
+
} catch (error) {
|
|
71
|
+
if (isAbortError(error)) throw error;
|
|
72
|
+
console.error("[kimesh] Loader error:", error);
|
|
73
|
+
onError?.(error, to);
|
|
74
|
+
to.meta.__kimeshError = error;
|
|
75
|
+
}
|
|
76
|
+
}));
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Install loader guard on router
|
|
81
|
+
*/
|
|
82
|
+
function installLoaderGuard(router, context, options) {
|
|
83
|
+
let abortController = null;
|
|
84
|
+
const removeBeforeEach = router.beforeEach(() => {
|
|
85
|
+
abortController?.abort();
|
|
86
|
+
abortController = new AbortController();
|
|
87
|
+
});
|
|
88
|
+
const removeBeforeResolve = router.beforeResolve(async (to, from) => {
|
|
89
|
+
if (!abortController) abortController = new AbortController();
|
|
90
|
+
const guard = createLoaderGuard(context, { onError: options?.onError });
|
|
91
|
+
try {
|
|
92
|
+
await guard(to, from, abortController);
|
|
93
|
+
} catch (error) {
|
|
94
|
+
if (error.name === "AbortError") return false;
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
const removeAfterEach = router.afterEach(() => {
|
|
99
|
+
abortController = null;
|
|
100
|
+
});
|
|
101
|
+
return () => {
|
|
102
|
+
removeBeforeEach();
|
|
103
|
+
removeBeforeResolve();
|
|
104
|
+
removeAfterEach();
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
//#endregion
|
|
109
|
+
//#region src/runtime-plugin.ts
|
|
110
|
+
const KIMESH_PLUGIN_INDICATOR = "__kimesh_plugin";
|
|
111
|
+
/**
|
|
112
|
+
* Define a Kimesh runtime plugin
|
|
113
|
+
*
|
|
114
|
+
* @example Function-style plugin
|
|
115
|
+
* ```ts
|
|
116
|
+
* export default defineKimeshRuntimePlugin((app) => {
|
|
117
|
+
* const analytics = new AnalyticsService()
|
|
118
|
+
* app.router.afterEach((to) => analytics.trackPageView(to.path))
|
|
119
|
+
* return { provide: { analytics } }
|
|
120
|
+
* })
|
|
121
|
+
* ```
|
|
122
|
+
*
|
|
123
|
+
* @example Object-style plugin with hooks
|
|
124
|
+
* ```ts
|
|
125
|
+
* export default defineKimeshRuntimePlugin({
|
|
126
|
+
* name: 'my-plugin',
|
|
127
|
+
* enforce: 'pre',
|
|
128
|
+
* hooks: {
|
|
129
|
+
* 'app:mounted': () => console.log('App mounted!')
|
|
130
|
+
* },
|
|
131
|
+
* setup(app) {
|
|
132
|
+
* return { provide: { myService: new MyService() } }
|
|
133
|
+
* }
|
|
134
|
+
* })
|
|
135
|
+
* ```
|
|
136
|
+
*/
|
|
137
|
+
function defineKimeshRuntimePlugin(plugin) {
|
|
138
|
+
if (typeof plugin === "function") {
|
|
139
|
+
plugin[KIMESH_PLUGIN_INDICATOR] = true;
|
|
140
|
+
return plugin;
|
|
141
|
+
}
|
|
142
|
+
const { name, enforce, order, dependsOn, parallel, hooks, setup } = plugin;
|
|
143
|
+
const pluginFn = setup ?? (() => {});
|
|
144
|
+
pluginFn[KIMESH_PLUGIN_INDICATOR] = true;
|
|
145
|
+
pluginFn._name = name;
|
|
146
|
+
pluginFn.meta = {
|
|
147
|
+
name,
|
|
148
|
+
enforce,
|
|
149
|
+
order,
|
|
150
|
+
dependsOn,
|
|
151
|
+
parallel
|
|
152
|
+
};
|
|
153
|
+
pluginFn.hooks = hooks;
|
|
154
|
+
return pluginFn;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Check if a value is a Kimesh runtime plugin
|
|
158
|
+
*/
|
|
159
|
+
function isKimeshRuntimePlugin(value) {
|
|
160
|
+
return typeof value === "function" && value[KIMESH_PLUGIN_INDICATOR] === true;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Get plugin metadata
|
|
164
|
+
*/
|
|
165
|
+
function getPluginMeta(plugin) {
|
|
166
|
+
return plugin.meta ?? {};
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Get plugin name
|
|
170
|
+
*/
|
|
171
|
+
function getPluginName(plugin) {
|
|
172
|
+
return plugin._name ?? plugin.meta?.name ?? "anonymous";
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Get plugin hooks
|
|
176
|
+
*/
|
|
177
|
+
function getPluginHooks(plugin) {
|
|
178
|
+
return plugin.hooks;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
//#endregion
|
|
182
|
+
//#region src/plugin-executor.ts
|
|
183
|
+
const ORDER_MAP = {
|
|
184
|
+
pre: -20,
|
|
185
|
+
default: 0,
|
|
186
|
+
post: 20
|
|
187
|
+
};
|
|
188
|
+
/**
|
|
189
|
+
* Apply a single plugin to the app context
|
|
190
|
+
*/
|
|
191
|
+
async function applyPlugin(app, plugin) {
|
|
192
|
+
const pluginName = getPluginName(plugin);
|
|
193
|
+
try {
|
|
194
|
+
const hooks = getPluginHooks(plugin);
|
|
195
|
+
if (hooks) app.hooks.addHooks(hooks);
|
|
196
|
+
const result = await app.runWithContext(() => plugin(app));
|
|
197
|
+
if (result?.provide) for (const [key, value] of Object.entries(result.provide)) app.provide(key, value);
|
|
198
|
+
} catch (error) {
|
|
199
|
+
console.error(`[Kimesh] Plugin "${pluginName}" failed:`, error);
|
|
200
|
+
await app.hooks.callHook("app:error", error);
|
|
201
|
+
throw error;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Sort plugins by their execution order
|
|
206
|
+
*/
|
|
207
|
+
function sortPluginsByOrder(plugins) {
|
|
208
|
+
return [...plugins].sort((a, b) => {
|
|
209
|
+
const aMeta = getPluginMeta(a);
|
|
210
|
+
const bMeta = getPluginMeta(b);
|
|
211
|
+
return (aMeta.order ?? ORDER_MAP[aMeta.enforce ?? "default"]) - (bMeta.order ?? ORDER_MAP[bMeta.enforce ?? "default"]);
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Mark a plugin as resolved if it has a name
|
|
216
|
+
*/
|
|
217
|
+
function markResolved(resolved, plugin) {
|
|
218
|
+
const name = getPluginName(plugin);
|
|
219
|
+
if (name !== "anonymous") resolved.add(name);
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Remove resolved dependencies from a pending plugin entry
|
|
223
|
+
*/
|
|
224
|
+
function updatePendingDeps(entry, resolved) {
|
|
225
|
+
for (const dep of entry.deps) if (resolved.has(dep)) entry.deps.delete(dep);
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Apply all plugins to the app context with dependency resolution
|
|
229
|
+
*/
|
|
230
|
+
async function applyPlugins(app, plugins) {
|
|
231
|
+
const sortedPlugins = sortPluginsByOrder(plugins);
|
|
232
|
+
const resolved = /* @__PURE__ */ new Set();
|
|
233
|
+
const pending = [];
|
|
234
|
+
async function tryResolvePending() {
|
|
235
|
+
let madeProgress = true;
|
|
236
|
+
while (madeProgress) {
|
|
237
|
+
madeProgress = false;
|
|
238
|
+
const stillPending = [];
|
|
239
|
+
for (const entry of pending) {
|
|
240
|
+
updatePendingDeps(entry, resolved);
|
|
241
|
+
if (entry.deps.size === 0) {
|
|
242
|
+
await applyPlugin(app, entry.plugin);
|
|
243
|
+
markResolved(resolved, entry.plugin);
|
|
244
|
+
madeProgress = true;
|
|
245
|
+
} else stillPending.push(entry);
|
|
246
|
+
}
|
|
247
|
+
pending.length = 0;
|
|
248
|
+
pending.push(...stillPending);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
for (const plugin of sortedPlugins) {
|
|
252
|
+
const unresolvedDeps = getPluginMeta(plugin).dependsOn?.filter((dep) => !resolved.has(dep)) ?? [];
|
|
253
|
+
if (unresolvedDeps.length > 0) pending.push({
|
|
254
|
+
plugin,
|
|
255
|
+
deps: new Set(unresolvedDeps)
|
|
256
|
+
});
|
|
257
|
+
else {
|
|
258
|
+
await applyPlugin(app, plugin);
|
|
259
|
+
markResolved(resolved, plugin);
|
|
260
|
+
await tryResolvePending();
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
for (const { plugin, deps } of pending) console.warn(`[Kimesh] Plugin "${getPluginName(plugin)}" has unresolved dependencies: ${[...deps].join(", ")}`);
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Apply plugins with parallel support.
|
|
267
|
+
* Plugins with parallel: true run concurrently within their group.
|
|
268
|
+
*/
|
|
269
|
+
async function applyPluginsWithParallel(app, plugins) {
|
|
270
|
+
const sortedPlugins = sortPluginsByOrder(plugins);
|
|
271
|
+
const groups = [];
|
|
272
|
+
for (const plugin of sortedPlugins) {
|
|
273
|
+
const isParallel = getPluginMeta(plugin).parallel ?? false;
|
|
274
|
+
const lastGroup = groups[groups.length - 1];
|
|
275
|
+
if (lastGroup?.parallel === isParallel) lastGroup.plugins.push(plugin);
|
|
276
|
+
else groups.push({
|
|
277
|
+
parallel: isParallel,
|
|
278
|
+
plugins: [plugin]
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
for (const group of groups) if (group.parallel) await Promise.all(group.plugins.map((p) => applyPlugin(app, p)));
|
|
282
|
+
else for (const plugin of group.plugins) await applyPlugin(app, plugin);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
//#endregion
|
|
286
|
+
//#region src/types.ts
|
|
287
|
+
/**
|
|
288
|
+
* Injection key for Kimesh context
|
|
289
|
+
*/
|
|
290
|
+
const KIMESH_CONTEXT_KEY = Symbol("kimesh-context");
|
|
291
|
+
|
|
292
|
+
//#endregion
|
|
293
|
+
//#region src/create-app.ts
|
|
294
|
+
const KIMESH_APP_CONTEXT_KEY = Symbol("kimesh-app-context");
|
|
295
|
+
const DEFAULT_SCROLL_BEHAVIOR = (to, _from, savedPosition) => {
|
|
296
|
+
if (savedPosition) return savedPosition;
|
|
297
|
+
if (to.hash) return {
|
|
298
|
+
el: to.hash,
|
|
299
|
+
behavior: "smooth"
|
|
300
|
+
};
|
|
301
|
+
return { top: 0 };
|
|
302
|
+
};
|
|
303
|
+
/**
|
|
304
|
+
* Create a Kimesh application with router, query client, and runtime plugins
|
|
305
|
+
*/
|
|
306
|
+
async function createKimeshApp(options) {
|
|
307
|
+
const { rootComponent, routes, base = "/", history = "web", scrollBehavior = DEFAULT_SCROLL_BEHAVIOR, queryClientConfig, context: userContext, plugins = [], runtimeConfig = {}, layersConfig = {} } = options;
|
|
308
|
+
const queryClient = new QueryClient(queryClientConfig ?? { defaultOptions: { queries: {
|
|
309
|
+
staleTime: 1e3 * 60 * 5,
|
|
310
|
+
gcTime: 1e3 * 60 * 30
|
|
311
|
+
} } });
|
|
312
|
+
const fullContext = {
|
|
313
|
+
queryClient,
|
|
314
|
+
...typeof userContext === "function" ? userContext() : userContext ?? {}
|
|
315
|
+
};
|
|
316
|
+
const router = createRouter({
|
|
317
|
+
history: history === "hash" ? createWebHashHistory(base) : createWebHistory(base),
|
|
318
|
+
routes,
|
|
319
|
+
scrollBehavior
|
|
320
|
+
});
|
|
321
|
+
const removeLoaderGuard = installLoaderGuard(router, fullContext, { onError: (error, to) => console.error(`[Kimesh] Loader error for ${to.path}:`, error) });
|
|
322
|
+
const vueApp = createApp({ render: () => h(rootComponent) });
|
|
323
|
+
const hooks = createHooks();
|
|
324
|
+
const appContext = {
|
|
325
|
+
vueApp,
|
|
326
|
+
router,
|
|
327
|
+
queryClient,
|
|
328
|
+
hooks,
|
|
329
|
+
$config: runtimeConfig,
|
|
330
|
+
$layersConfig: layersConfig,
|
|
331
|
+
isHydrating: false,
|
|
332
|
+
_state: reactive({}),
|
|
333
|
+
provide(name, value) {
|
|
334
|
+
const $name = `$${name}`;
|
|
335
|
+
Object.defineProperty(vueApp.config.globalProperties, $name, {
|
|
336
|
+
get: () => value,
|
|
337
|
+
configurable: true
|
|
338
|
+
});
|
|
339
|
+
vueApp.provide($name, value);
|
|
340
|
+
},
|
|
341
|
+
runWithContext(fn) {
|
|
342
|
+
return vueApp.runWithContext(fn);
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
if (plugins.length > 0) await applyPlugins(appContext, plugins);
|
|
346
|
+
await hooks.callHook("app:created", vueApp);
|
|
347
|
+
vueApp.provide(KIMESH_CONTEXT_KEY, fullContext);
|
|
348
|
+
vueApp.provide(KIMESH_APP_CONTEXT_KEY, appContext);
|
|
349
|
+
vueApp.use(router);
|
|
350
|
+
vueApp.use(VueQueryPlugin, { queryClient });
|
|
351
|
+
router.beforeEach(() => hooks.callHook("page:start"));
|
|
352
|
+
router.afterEach(() => hooks.callHook("page:finish"));
|
|
353
|
+
const kimeshApp = {
|
|
354
|
+
app: vueApp,
|
|
355
|
+
router,
|
|
356
|
+
queryClient,
|
|
357
|
+
context: appContext,
|
|
358
|
+
async mount(container) {
|
|
359
|
+
await hooks.callHook("app:beforeMount", vueApp);
|
|
360
|
+
vueApp.mount(container);
|
|
361
|
+
await hooks.callHook("app:mounted", vueApp);
|
|
362
|
+
return kimeshApp;
|
|
363
|
+
},
|
|
364
|
+
unmount() {
|
|
365
|
+
removeLoaderGuard();
|
|
366
|
+
vueApp.unmount();
|
|
367
|
+
}
|
|
368
|
+
};
|
|
369
|
+
return kimeshApp;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
//#endregion
|
|
373
|
+
//#region src/create-file-route.ts
|
|
374
|
+
/**
|
|
375
|
+
* Create a file-based route definition
|
|
376
|
+
*
|
|
377
|
+
* @example
|
|
378
|
+
* ```ts
|
|
379
|
+
* // In routes/about.vue
|
|
380
|
+
* export const Route = createFileRoute('/about')({
|
|
381
|
+
* meta: { title: 'About Us' },
|
|
382
|
+
* loader: async () => {
|
|
383
|
+
* return { data: await fetchAboutData() }
|
|
384
|
+
* },
|
|
385
|
+
* head: {
|
|
386
|
+
* title: 'About Us',
|
|
387
|
+
* meta: [{ name: 'description', content: 'Learn about us' }]
|
|
388
|
+
* }
|
|
389
|
+
* })
|
|
390
|
+
* ```
|
|
391
|
+
*/
|
|
392
|
+
function createFileRoute(path) {
|
|
393
|
+
return (options = {}) => ({
|
|
394
|
+
path,
|
|
395
|
+
meta: options.meta,
|
|
396
|
+
loader: options.loader,
|
|
397
|
+
validateSearch: options.validateSearch,
|
|
398
|
+
beforeLoad: options.beforeLoad,
|
|
399
|
+
head: options.head,
|
|
400
|
+
transition: options.transition,
|
|
401
|
+
viewTransition: options.viewTransition,
|
|
402
|
+
keepalive: options.keepalive
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Define route for use in Vue SFC
|
|
407
|
+
* Alternative syntax for createFileRoute
|
|
408
|
+
*/
|
|
409
|
+
function defineRoute(options) {
|
|
410
|
+
return options;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
//#endregion
|
|
414
|
+
//#region src/context.ts
|
|
415
|
+
/**
|
|
416
|
+
* Define app context with type inference
|
|
417
|
+
*
|
|
418
|
+
* @example
|
|
419
|
+
* ```ts
|
|
420
|
+
* // src/app.context.ts
|
|
421
|
+
* import { defineContext } from '@kimesh/router-runtime'
|
|
422
|
+
*
|
|
423
|
+
* export default defineContext(() => ({
|
|
424
|
+
* auth: useAuthStore(),
|
|
425
|
+
* i18n: createI18n(),
|
|
426
|
+
* }))
|
|
427
|
+
* ```
|
|
428
|
+
*/
|
|
429
|
+
function defineContext(factory) {
|
|
430
|
+
return factory;
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Use Kimesh context in components
|
|
434
|
+
*
|
|
435
|
+
* @example
|
|
436
|
+
* ```vue
|
|
437
|
+
* <script setup>
|
|
438
|
+
* const ctx = useKimeshContext()
|
|
439
|
+
* ctx.auth.logout()
|
|
440
|
+
* <\/script>
|
|
441
|
+
* ```
|
|
442
|
+
*/
|
|
443
|
+
function useKimeshContext() {
|
|
444
|
+
const context = inject(KIMESH_CONTEXT_KEY);
|
|
445
|
+
if (!context) throw new Error("[Kimesh] Context not found. Make sure you are using this inside a Kimesh app.");
|
|
446
|
+
return context;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
//#endregion
|
|
450
|
+
//#region src/plugins/core.ts
|
|
451
|
+
/**
|
|
452
|
+
* @kimesh/router-runtime - Core Plugins
|
|
453
|
+
*
|
|
454
|
+
* Built-in runtime plugins for Kimesh.
|
|
455
|
+
*/
|
|
456
|
+
/**
|
|
457
|
+
* Router plugin that wires Vue Router guards to Kimesh hook system
|
|
458
|
+
*
|
|
459
|
+
* This plugin runs with `enforce: 'pre'` to ensure router hooks
|
|
460
|
+
* are available to all other plugins.
|
|
461
|
+
*/
|
|
462
|
+
const routerPlugin = defineKimeshRuntimePlugin({
|
|
463
|
+
name: "kimesh:router",
|
|
464
|
+
enforce: "pre",
|
|
465
|
+
setup({ router, hooks }) {
|
|
466
|
+
const callHook = hooks.callHook.bind(hooks);
|
|
467
|
+
router.beforeEach(async (to, from) => {
|
|
468
|
+
const ctx = {
|
|
469
|
+
to,
|
|
470
|
+
from
|
|
471
|
+
};
|
|
472
|
+
try {
|
|
473
|
+
await callHook("navigate:before", ctx);
|
|
474
|
+
} catch (error) {
|
|
475
|
+
await callHook("navigate:error", {
|
|
476
|
+
error,
|
|
477
|
+
to,
|
|
478
|
+
from
|
|
479
|
+
});
|
|
480
|
+
throw error;
|
|
481
|
+
}
|
|
482
|
+
});
|
|
483
|
+
router.afterEach((to, from, failure) => {
|
|
484
|
+
callHook("navigate:after", {
|
|
485
|
+
to,
|
|
486
|
+
from,
|
|
487
|
+
failure: failure ?? void 0
|
|
488
|
+
});
|
|
489
|
+
});
|
|
490
|
+
router.onError((error, to, from) => {
|
|
491
|
+
callHook("navigate:error", {
|
|
492
|
+
error,
|
|
493
|
+
to,
|
|
494
|
+
from
|
|
495
|
+
});
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
});
|
|
499
|
+
/**
|
|
500
|
+
* Query plugin that sets up prefetching on navigation
|
|
501
|
+
*
|
|
502
|
+
* Note: QueryClient is already initialized in createKimeshApp,
|
|
503
|
+
* this plugin adds navigation-based prefetching.
|
|
504
|
+
*/
|
|
505
|
+
const queryPlugin = defineKimeshRuntimePlugin({
|
|
506
|
+
name: "kimesh:query",
|
|
507
|
+
enforce: "pre",
|
|
508
|
+
dependsOn: ["kimesh:router"],
|
|
509
|
+
setup() {}
|
|
510
|
+
});
|
|
511
|
+
/**
|
|
512
|
+
* Get the default core plugins for a Kimesh app
|
|
513
|
+
*/
|
|
514
|
+
function getCorePlugins() {
|
|
515
|
+
return [routerPlugin, queryPlugin];
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
//#endregion
|
|
519
|
+
//#region src/components/utils.ts
|
|
520
|
+
/**
|
|
521
|
+
* Wrap content in Vue KeepAlive component
|
|
522
|
+
*
|
|
523
|
+
* @param config - KeepAlive configuration or boolean
|
|
524
|
+
* @param content - Content to wrap (VNode)
|
|
525
|
+
* @returns Wrapped VNode
|
|
526
|
+
*/
|
|
527
|
+
function wrapInKeepAlive(config, content) {
|
|
528
|
+
if (!config) return content;
|
|
529
|
+
return h(KeepAlive, config === true ? {} : config, { default: () => content });
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* Merge multiple transition props into one
|
|
533
|
+
* Later props override earlier ones
|
|
534
|
+
*
|
|
535
|
+
* @param propsArray - Array of transition props to merge
|
|
536
|
+
* @returns Merged transition props
|
|
537
|
+
*/
|
|
538
|
+
function mergeTransitionProps(propsArray) {
|
|
539
|
+
const merged = {};
|
|
540
|
+
for (const props of propsArray) {
|
|
541
|
+
if (!props || props === true) continue;
|
|
542
|
+
Object.assign(merged, props);
|
|
543
|
+
}
|
|
544
|
+
return merged;
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Check if route is changing based on path and matched components
|
|
548
|
+
* Used to determine if View Transitions API should be triggered
|
|
549
|
+
*
|
|
550
|
+
* @param to - Target route
|
|
551
|
+
* @param from - Current route
|
|
552
|
+
* @returns True if route is actually changing
|
|
553
|
+
*/
|
|
554
|
+
function isChangingPage(to, from) {
|
|
555
|
+
if (to === from) return false;
|
|
556
|
+
if (to.path === from.path) return false;
|
|
557
|
+
return !to.matched.every((comp, index) => comp.components?.default === from.matched[index]?.components?.default);
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* Generate unique route key for transition identification
|
|
561
|
+
* Based on Nuxt's generateRouteKey implementation
|
|
562
|
+
*
|
|
563
|
+
* @param routeProps - RouterView slot props
|
|
564
|
+
* @param override - Optional key override
|
|
565
|
+
* @returns Unique route key
|
|
566
|
+
*/
|
|
567
|
+
const ROUTE_KEY_PARENTHESES_RE = /(:\w+)\([^)]+\)/g;
|
|
568
|
+
const ROUTE_KEY_SYMBOLS_RE = /(:\w+)[?+*]/g;
|
|
569
|
+
const ROUTE_KEY_NORMAL_RE = /:\w+/g;
|
|
570
|
+
function generateRouteKey(routeProps, override) {
|
|
571
|
+
const matchedRoute = routeProps.route.matched.find((m) => m.components?.default === routeProps.Component?.type);
|
|
572
|
+
const source = override ?? matchedRoute?.meta.key ?? (matchedRoute && interpolatePath(routeProps.route, matchedRoute));
|
|
573
|
+
return typeof source === "function" ? source(routeProps.route) : source;
|
|
574
|
+
}
|
|
575
|
+
function interpolatePath(route, match) {
|
|
576
|
+
return match.path.replace(ROUTE_KEY_PARENTHESES_RE, "$1").replace(ROUTE_KEY_SYMBOLS_RE, "$1").replace(ROUTE_KEY_NORMAL_RE, (r) => route.params[r.slice(1)]?.toString() || "");
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
//#endregion
|
|
580
|
+
//#region src/plugins/view-transitions.ts
|
|
581
|
+
/**
|
|
582
|
+
* View Transitions API Plugin
|
|
583
|
+
* Enables native browser View Transitions for smooth page navigation
|
|
584
|
+
*
|
|
585
|
+
* @see https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API
|
|
586
|
+
*
|
|
587
|
+
* @example
|
|
588
|
+
* ```ts
|
|
589
|
+
* import { viewTransitionsPlugin } from '@kimesh/router-runtime/plugins/view-transitions'
|
|
590
|
+
*
|
|
591
|
+
* createKimeshApp({
|
|
592
|
+
* plugins: [viewTransitionsPlugin]
|
|
593
|
+
* })
|
|
594
|
+
* ```
|
|
595
|
+
*/
|
|
596
|
+
const viewTransitionsPlugin = defineKimeshRuntimePlugin({
|
|
597
|
+
name: "kimesh:view-transitions",
|
|
598
|
+
enforce: "pre",
|
|
599
|
+
setup(app) {
|
|
600
|
+
if (typeof document === "undefined" || !document.startViewTransition) {
|
|
601
|
+
if (process.env.NODE_ENV === "development") console.warn("[Kimesh View Transitions] Browser does not support View Transitions API");
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
let transition;
|
|
605
|
+
let hasUAVisualTransition = false;
|
|
606
|
+
let finishTransition;
|
|
607
|
+
let abortTransition;
|
|
608
|
+
const resetTransitionState = () => {
|
|
609
|
+
transition = void 0;
|
|
610
|
+
hasUAVisualTransition = false;
|
|
611
|
+
abortTransition = void 0;
|
|
612
|
+
finishTransition = void 0;
|
|
613
|
+
};
|
|
614
|
+
if (typeof window !== "undefined") window.addEventListener("popstate", (event) => {
|
|
615
|
+
hasUAVisualTransition = event.hasUAVisualTransition || false;
|
|
616
|
+
if (hasUAVisualTransition) transition?.skipTransition();
|
|
617
|
+
});
|
|
618
|
+
app.router.beforeResolve(async (to, from) => {
|
|
619
|
+
const viewTransitionMode = to.meta.__kimesh?.viewTransition;
|
|
620
|
+
const prefersNoTransition = typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches && viewTransitionMode !== "always";
|
|
621
|
+
if (viewTransitionMode === false || prefersNoTransition || hasUAVisualTransition || !isChangingPage(to, from)) return;
|
|
622
|
+
const promise = new Promise((resolve, reject) => {
|
|
623
|
+
finishTransition = resolve;
|
|
624
|
+
abortTransition = reject;
|
|
625
|
+
});
|
|
626
|
+
let changeRoute;
|
|
627
|
+
const ready = new Promise((resolve) => changeRoute = resolve);
|
|
628
|
+
transition = document.startViewTransition(() => {
|
|
629
|
+
changeRoute();
|
|
630
|
+
return promise;
|
|
631
|
+
});
|
|
632
|
+
transition.finished.then(resetTransitionState).catch(resetTransitionState);
|
|
633
|
+
await app.hooks.callHook("page:view-transition:start", transition);
|
|
634
|
+
return ready;
|
|
635
|
+
});
|
|
636
|
+
app.hooks.hook("app:error", () => {
|
|
637
|
+
abortTransition?.();
|
|
638
|
+
resetTransitionState();
|
|
639
|
+
});
|
|
640
|
+
app.hooks.hook("page:finish", () => {
|
|
641
|
+
finishTransition?.();
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
//#endregion
|
|
647
|
+
//#region src/components/KmOutlet.ts
|
|
648
|
+
/**
|
|
649
|
+
* Check if View Transitions API is supported
|
|
650
|
+
*/
|
|
651
|
+
const supportsViewTransitions = () => typeof document !== "undefined" && "startViewTransition" in document;
|
|
652
|
+
/**
|
|
653
|
+
* KmOutlet - Router view wrapper component
|
|
654
|
+
* Renders the matched route component with optional transitions and keepalive
|
|
655
|
+
*
|
|
656
|
+
* Transition options for async pages (useSuspenseQuery):
|
|
657
|
+
*
|
|
658
|
+
* 1. viewTransition: true (Recommended)
|
|
659
|
+
* - Uses View Transitions API (Chrome 111+, Edge 111+, Opera 97+)
|
|
660
|
+
* - Works perfectly with async pages
|
|
661
|
+
* - Falls back to instant transition on unsupported browsers
|
|
662
|
+
*
|
|
663
|
+
* 2. transition: { name: 'fade' } (without mode: 'out-in')
|
|
664
|
+
* - Uses Vue Transition with default mode (cross-fade)
|
|
665
|
+
* - Both pages exist during transition (old fades out, new fades in)
|
|
666
|
+
* - Requires CSS: .page-leave-active { position: absolute }
|
|
667
|
+
*
|
|
668
|
+
* 3. transition: { name: 'fade', mode: 'out-in' }
|
|
669
|
+
* - ⚠️ NOT recommended for async pages - may cause blank screen
|
|
670
|
+
*/
|
|
671
|
+
const KmOutlet = defineComponent({
|
|
672
|
+
name: "KmOutlet",
|
|
673
|
+
props: {
|
|
674
|
+
name: {
|
|
675
|
+
type: String,
|
|
676
|
+
default: "default"
|
|
677
|
+
},
|
|
678
|
+
transition: {
|
|
679
|
+
type: [Boolean, Object],
|
|
680
|
+
default: void 0
|
|
681
|
+
},
|
|
682
|
+
viewTransition: {
|
|
683
|
+
type: Boolean,
|
|
684
|
+
default: false
|
|
685
|
+
},
|
|
686
|
+
keepalive: {
|
|
687
|
+
type: [Boolean, Object],
|
|
688
|
+
default: void 0
|
|
689
|
+
},
|
|
690
|
+
pageKey: {
|
|
691
|
+
type: [Function, String],
|
|
692
|
+
default: null
|
|
693
|
+
}
|
|
694
|
+
},
|
|
695
|
+
setup(props, { slots }) {
|
|
696
|
+
const kimeshApp = inject(KIMESH_APP_CONTEXT_KEY, null);
|
|
697
|
+
const router = useRouter$1();
|
|
698
|
+
let cachedVNode;
|
|
699
|
+
const isPending = ref(false);
|
|
700
|
+
let viewTransitionAbort = null;
|
|
701
|
+
if (props.viewTransition && supportsViewTransitions()) router.beforeResolve(async () => {
|
|
702
|
+
if (viewTransitionAbort) return;
|
|
703
|
+
let resolveTransition;
|
|
704
|
+
const transitionReady = new Promise((resolve) => {
|
|
705
|
+
resolveTransition = resolve;
|
|
706
|
+
});
|
|
707
|
+
const transition = document.startViewTransition(async () => {
|
|
708
|
+
await transitionReady;
|
|
709
|
+
});
|
|
710
|
+
viewTransitionAbort = () => {
|
|
711
|
+
transition.skipTransition();
|
|
712
|
+
viewTransitionAbort = null;
|
|
713
|
+
};
|
|
714
|
+
await nextTick();
|
|
715
|
+
resolveTransition();
|
|
716
|
+
try {
|
|
717
|
+
await transition.finished;
|
|
718
|
+
} finally {
|
|
719
|
+
viewTransitionAbort = null;
|
|
720
|
+
}
|
|
721
|
+
});
|
|
722
|
+
return () => {
|
|
723
|
+
return h(RouterView, { name: props.name }, { default: (routeProps) => {
|
|
724
|
+
if (slots.default) return slots.default(routeProps);
|
|
725
|
+
if (!routeProps.Component) return cachedVNode;
|
|
726
|
+
const routeKey = generateRouteKey(routeProps, props.pageKey) || routeProps.route.path;
|
|
727
|
+
const routeTransition = routeProps.route.meta.__kimesh?.transition;
|
|
728
|
+
const hasVueTransition = !props.viewTransition && !!(props.transition ?? routeTransition ?? false);
|
|
729
|
+
const transitionProps = hasVueTransition ? mergeTransitionProps([
|
|
730
|
+
props.transition,
|
|
731
|
+
routeTransition,
|
|
732
|
+
{ onAfterLeave: () => {
|
|
733
|
+
if (kimeshApp) delete kimeshApp._runningTransition;
|
|
734
|
+
kimeshApp?.hooks.callHook("page:transition:finish");
|
|
735
|
+
} }
|
|
736
|
+
]) : void 0;
|
|
737
|
+
const keepaliveConfig = props.keepalive ?? routeProps.route.meta.__kimesh?.keepalive;
|
|
738
|
+
const componentNode = h(routeProps.Component, { key: routeKey });
|
|
739
|
+
const wrappedNode = wrapInKeepAlive(keepaliveConfig, h(Suspense, {
|
|
740
|
+
suspensible: true,
|
|
741
|
+
onPending: () => {
|
|
742
|
+
isPending.value = true;
|
|
743
|
+
if (hasVueTransition && kimeshApp) kimeshApp._runningTransition = true;
|
|
744
|
+
kimeshApp?.hooks.callHook("page:start");
|
|
745
|
+
},
|
|
746
|
+
onResolve: async () => {
|
|
747
|
+
await nextTick();
|
|
748
|
+
isPending.value = false;
|
|
749
|
+
if (kimeshApp) delete kimeshApp._runningTransition;
|
|
750
|
+
await kimeshApp?.hooks.callHook("page:finish");
|
|
751
|
+
}
|
|
752
|
+
}, { default: () => componentNode }));
|
|
753
|
+
if (props.viewTransition) {
|
|
754
|
+
const pageVNode$1 = h("div", {
|
|
755
|
+
key: routeKey,
|
|
756
|
+
class: "km-outlet-page",
|
|
757
|
+
style: { viewTransitionName: "km-page" }
|
|
758
|
+
}, [wrappedNode]);
|
|
759
|
+
cachedVNode = pageVNode$1;
|
|
760
|
+
return pageVNode$1;
|
|
761
|
+
}
|
|
762
|
+
if (!hasVueTransition || !transitionProps) {
|
|
763
|
+
cachedVNode = wrappedNode;
|
|
764
|
+
return wrappedNode;
|
|
765
|
+
}
|
|
766
|
+
const pageVNode = h("div", {
|
|
767
|
+
key: routeKey,
|
|
768
|
+
class: "km-outlet-page"
|
|
769
|
+
}, [wrappedNode]);
|
|
770
|
+
cachedVNode = pageVNode;
|
|
771
|
+
return h(Transition, {
|
|
772
|
+
...transitionProps,
|
|
773
|
+
appear: transitionProps.appear ?? false
|
|
774
|
+
}, { default: () => pageVNode });
|
|
775
|
+
} });
|
|
776
|
+
};
|
|
777
|
+
}
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
//#endregion
|
|
781
|
+
//#region src/components/KmLink.ts
|
|
782
|
+
/**
|
|
783
|
+
* Resolve path by replacing param placeholders with actual values
|
|
784
|
+
*/
|
|
785
|
+
function resolvePath(path, params) {
|
|
786
|
+
if (!params) return path;
|
|
787
|
+
let resolved = path;
|
|
788
|
+
for (const [key, value] of Object.entries(params)) resolved = resolved.replace(`:${key}`, String(value));
|
|
789
|
+
return resolved;
|
|
790
|
+
}
|
|
791
|
+
/**
|
|
792
|
+
* KmLink - Type-safe router link component
|
|
793
|
+
*
|
|
794
|
+
* @example
|
|
795
|
+
* ```vue
|
|
796
|
+
* <!-- Static route -->
|
|
797
|
+
* <KmLink to="/posts">Posts</KmLink>
|
|
798
|
+
*
|
|
799
|
+
* <!-- Dynamic route with params -->
|
|
800
|
+
* <KmLink to="/posts/:postId" :params="{ postId: '123' }">
|
|
801
|
+
* Post 123
|
|
802
|
+
* </KmLink>
|
|
803
|
+
* ```
|
|
804
|
+
*/
|
|
805
|
+
const KmLink = defineComponent({
|
|
806
|
+
name: "KmLink",
|
|
807
|
+
props: {
|
|
808
|
+
to: {
|
|
809
|
+
type: String,
|
|
810
|
+
required: true
|
|
811
|
+
},
|
|
812
|
+
params: {
|
|
813
|
+
type: Object,
|
|
814
|
+
default: void 0
|
|
815
|
+
},
|
|
816
|
+
replace: {
|
|
817
|
+
type: Boolean,
|
|
818
|
+
default: false
|
|
819
|
+
},
|
|
820
|
+
activeClass: {
|
|
821
|
+
type: String,
|
|
822
|
+
default: "km-link-active"
|
|
823
|
+
},
|
|
824
|
+
exactActiveClass: {
|
|
825
|
+
type: String,
|
|
826
|
+
default: "km-link-exact-active"
|
|
827
|
+
},
|
|
828
|
+
prefetch: {
|
|
829
|
+
type: Boolean,
|
|
830
|
+
default: true
|
|
831
|
+
},
|
|
832
|
+
tag: {
|
|
833
|
+
type: String,
|
|
834
|
+
default: "a"
|
|
835
|
+
}
|
|
836
|
+
},
|
|
837
|
+
setup(props, { slots, attrs }) {
|
|
838
|
+
const route = useRoute$1();
|
|
839
|
+
const resolvedPath = computed(() => resolvePath(props.to, props.params));
|
|
840
|
+
const isActive = computed(() => route.path.startsWith(resolvedPath.value));
|
|
841
|
+
const isExactActive = computed(() => route.path === resolvedPath.value);
|
|
842
|
+
const isCustomTag = computed(() => props.tag !== "a");
|
|
843
|
+
return () => {
|
|
844
|
+
const renderSlotContent = slots.default ? { default: (linkProps) => {
|
|
845
|
+
if (!isCustomTag.value) return slots.default?.(linkProps);
|
|
846
|
+
return h(props.tag, {
|
|
847
|
+
onClick: linkProps.navigate,
|
|
848
|
+
class: {
|
|
849
|
+
[props.activeClass]: isActive.value,
|
|
850
|
+
[props.exactActiveClass]: isExactActive.value
|
|
851
|
+
}
|
|
852
|
+
}, slots.default?.(linkProps));
|
|
853
|
+
} } : void 0;
|
|
854
|
+
return h(RouterLink, {
|
|
855
|
+
to: resolvedPath.value,
|
|
856
|
+
replace: props.replace,
|
|
857
|
+
activeClass: props.activeClass,
|
|
858
|
+
exactActiveClass: props.exactActiveClass,
|
|
859
|
+
custom: isCustomTag.value,
|
|
860
|
+
...attrs
|
|
861
|
+
}, renderSlotContent);
|
|
862
|
+
};
|
|
863
|
+
}
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
//#endregion
|
|
867
|
+
//#region src/components/KmDeferred.ts
|
|
868
|
+
/**
|
|
869
|
+
* KmDeferred - Suspense wrapper with error handling
|
|
870
|
+
*
|
|
871
|
+
* Wraps Vue's <Suspense> with error boundary functionality.
|
|
872
|
+
* Use with useSuspenseQuery for progressive data loading.
|
|
873
|
+
*
|
|
874
|
+
* @example
|
|
875
|
+
* ```vue
|
|
876
|
+
* <KmDeferred :timeout="200">
|
|
877
|
+
* <AsyncDataComponent />
|
|
878
|
+
* <template #fallback>
|
|
879
|
+
* <Skeleton />
|
|
880
|
+
* </template>
|
|
881
|
+
* <template #error="{ error, retry }">
|
|
882
|
+
* <ErrorCard :error="error" @retry="retry" />
|
|
883
|
+
* </template>
|
|
884
|
+
* </KmDeferred>
|
|
885
|
+
* ```
|
|
886
|
+
*/
|
|
887
|
+
const KmDeferred = defineComponent({
|
|
888
|
+
name: "KmDeferred",
|
|
889
|
+
props: { timeout: {
|
|
890
|
+
type: Number,
|
|
891
|
+
default: 0
|
|
892
|
+
} },
|
|
893
|
+
setup(props, { slots, expose }) {
|
|
894
|
+
const error = ref(null);
|
|
895
|
+
const key = ref(0);
|
|
896
|
+
onErrorCaptured((err) => {
|
|
897
|
+
error.value = err instanceof Error ? err : new Error(String(err));
|
|
898
|
+
return false;
|
|
899
|
+
});
|
|
900
|
+
function retry() {
|
|
901
|
+
error.value = null;
|
|
902
|
+
key.value++;
|
|
903
|
+
}
|
|
904
|
+
expose({
|
|
905
|
+
retry,
|
|
906
|
+
error
|
|
907
|
+
});
|
|
908
|
+
return () => {
|
|
909
|
+
if (error.value) {
|
|
910
|
+
if (slots.error) return h("div", { class: "km-deferred-error-wrapper" }, slots.error({
|
|
911
|
+
error: error.value,
|
|
912
|
+
retry
|
|
913
|
+
}));
|
|
914
|
+
return h("div", { class: "km-deferred-error" }, [h("p", error.value.message), h("button", { onClick: retry }, "Retry")]);
|
|
915
|
+
}
|
|
916
|
+
const fallbackContent = slots.fallback ? slots.fallback() : h("div", { class: "km-deferred-loading" }, "Loading...");
|
|
917
|
+
return h(Suspense, {
|
|
918
|
+
key: key.value,
|
|
919
|
+
timeout: props.timeout
|
|
920
|
+
}, {
|
|
921
|
+
default: () => slots.default?.(),
|
|
922
|
+
fallback: () => fallbackContent
|
|
923
|
+
});
|
|
924
|
+
};
|
|
925
|
+
}
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
//#endregion
|
|
929
|
+
//#region src/composables/use-search.ts
|
|
930
|
+
/**
|
|
931
|
+
* Convert an object to a query string record, filtering out null/undefined values
|
|
932
|
+
*/
|
|
933
|
+
function toQueryRecord(obj) {
|
|
934
|
+
const query = {};
|
|
935
|
+
for (const [key, value] of Object.entries(obj)) if (value !== void 0 && value !== null) query[key] = String(value);
|
|
936
|
+
return query;
|
|
937
|
+
}
|
|
938
|
+
/**
|
|
939
|
+
* Parse with schema, falling back to defaults or empty object
|
|
940
|
+
*/
|
|
941
|
+
function parseWithDefaults(schema, input) {
|
|
942
|
+
try {
|
|
943
|
+
return schema.parse(input);
|
|
944
|
+
} catch {
|
|
945
|
+
return {};
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
/**
|
|
949
|
+
* Access and update validated search params
|
|
950
|
+
*
|
|
951
|
+
* @example
|
|
952
|
+
* ```vue
|
|
953
|
+
* <script setup>
|
|
954
|
+
* import { useSearch } from '@kimesh/router-runtime'
|
|
955
|
+
* import { z } from 'zod'
|
|
956
|
+
*
|
|
957
|
+
* const searchSchema = z.object({
|
|
958
|
+
* page: z.coerce.number().default(1),
|
|
959
|
+
* sort: z.enum(['asc', 'desc']).default('desc')
|
|
960
|
+
* })
|
|
961
|
+
*
|
|
962
|
+
* const { search, setSearch } = useSearch(searchSchema)
|
|
963
|
+
* <\/script>
|
|
964
|
+
* ```
|
|
965
|
+
*/
|
|
966
|
+
function useSearch(schema) {
|
|
967
|
+
const route = useRoute$1();
|
|
968
|
+
const router = useRouter$1();
|
|
969
|
+
const defaults = parseWithDefaults(schema, {});
|
|
970
|
+
const search = computed(() => parseWithDefaults(schema, route.query));
|
|
971
|
+
function setSearch(key, value) {
|
|
972
|
+
router.replace({ query: {
|
|
973
|
+
...route.query,
|
|
974
|
+
[key]: String(value)
|
|
975
|
+
} });
|
|
976
|
+
}
|
|
977
|
+
function setAllSearch(params) {
|
|
978
|
+
const merged = {
|
|
979
|
+
...search.value,
|
|
980
|
+
...params
|
|
981
|
+
};
|
|
982
|
+
router.replace({ query: toQueryRecord(merged) });
|
|
983
|
+
}
|
|
984
|
+
function resetSearch() {
|
|
985
|
+
router.replace({ query: toQueryRecord(defaults) });
|
|
986
|
+
}
|
|
987
|
+
return {
|
|
988
|
+
search,
|
|
989
|
+
setSearch,
|
|
990
|
+
setAllSearch,
|
|
991
|
+
resetSearch
|
|
992
|
+
};
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
//#endregion
|
|
996
|
+
//#region src/composables/use-params.ts
|
|
997
|
+
/**
|
|
998
|
+
* Get typed route params (non-reactive snapshot)
|
|
999
|
+
*
|
|
1000
|
+
* @example
|
|
1001
|
+
* ```ts
|
|
1002
|
+
* // In /posts/:postId page
|
|
1003
|
+
* const params = useParams<'/posts/:postId'>()
|
|
1004
|
+
* // params is typed as { postId: string }
|
|
1005
|
+
* console.log(params.postId)
|
|
1006
|
+
* ```
|
|
1007
|
+
*/
|
|
1008
|
+
function useParams() {
|
|
1009
|
+
return useRoute$1().params;
|
|
1010
|
+
}
|
|
1011
|
+
/**
|
|
1012
|
+
* Get reactive typed route params
|
|
1013
|
+
*
|
|
1014
|
+
* @example
|
|
1015
|
+
* ```ts
|
|
1016
|
+
* // In /posts/:postId page
|
|
1017
|
+
* const params = useReactiveParams<'/posts/:postId'>()
|
|
1018
|
+
* // params.value is typed as { postId: string }
|
|
1019
|
+
* watchEffect(() => {
|
|
1020
|
+
* console.log(params.value.postId)
|
|
1021
|
+
* })
|
|
1022
|
+
* ```
|
|
1023
|
+
*/
|
|
1024
|
+
function useReactiveParams() {
|
|
1025
|
+
const route = useRoute$1();
|
|
1026
|
+
return computed(() => route.params);
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
//#endregion
|
|
1030
|
+
//#region src/composables/use-navigate.ts
|
|
1031
|
+
/**
|
|
1032
|
+
* Type-safe navigation composable
|
|
1033
|
+
*
|
|
1034
|
+
* @example
|
|
1035
|
+
* ```ts
|
|
1036
|
+
* const { navigate } = useNavigate()
|
|
1037
|
+
*
|
|
1038
|
+
* // Navigate to static route
|
|
1039
|
+
* navigate({ to: '/posts' })
|
|
1040
|
+
*
|
|
1041
|
+
* // Navigate to dynamic route with params
|
|
1042
|
+
* navigate({ to: '/posts/:postId', params: { postId: '123' } })
|
|
1043
|
+
*
|
|
1044
|
+
* // Replace instead of push
|
|
1045
|
+
* navigate({ to: '/posts', replace: true })
|
|
1046
|
+
* ```
|
|
1047
|
+
*/
|
|
1048
|
+
function useNavigate() {
|
|
1049
|
+
const router = useRouter$1();
|
|
1050
|
+
/**
|
|
1051
|
+
* Navigate to a route with type-safe params
|
|
1052
|
+
*/
|
|
1053
|
+
function navigate(options) {
|
|
1054
|
+
let path = options.to;
|
|
1055
|
+
if (options.params) for (const [key, value] of Object.entries(options.params)) path = path.replace(`:${key}`, String(value));
|
|
1056
|
+
if (options.replace) return router.replace(path);
|
|
1057
|
+
return router.push(path);
|
|
1058
|
+
}
|
|
1059
|
+
/**
|
|
1060
|
+
* Navigate back in history
|
|
1061
|
+
*/
|
|
1062
|
+
function back() {
|
|
1063
|
+
return router.back();
|
|
1064
|
+
}
|
|
1065
|
+
/**
|
|
1066
|
+
* Navigate forward in history
|
|
1067
|
+
*/
|
|
1068
|
+
function forward() {
|
|
1069
|
+
return router.forward();
|
|
1070
|
+
}
|
|
1071
|
+
/**
|
|
1072
|
+
* Navigate to a specific position in history
|
|
1073
|
+
*/
|
|
1074
|
+
function go(delta) {
|
|
1075
|
+
return router.go(delta);
|
|
1076
|
+
}
|
|
1077
|
+
return {
|
|
1078
|
+
navigate,
|
|
1079
|
+
back,
|
|
1080
|
+
forward,
|
|
1081
|
+
go,
|
|
1082
|
+
router
|
|
1083
|
+
};
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
//#endregion
|
|
1087
|
+
//#region src/composables/use-runtime-config.ts
|
|
1088
|
+
let _config;
|
|
1089
|
+
/**
|
|
1090
|
+
* Access runtime configuration.
|
|
1091
|
+
*
|
|
1092
|
+
* Returns the runtime config that was injected at build time via Vite's `define`.
|
|
1093
|
+
* Values can be overridden using `KIMESH_*` environment variables during build.
|
|
1094
|
+
*
|
|
1095
|
+
* @returns Frozen runtime config object
|
|
1096
|
+
*
|
|
1097
|
+
* @example
|
|
1098
|
+
* ```vue
|
|
1099
|
+
* <script setup>
|
|
1100
|
+
* const config = useRuntimeConfig()
|
|
1101
|
+
* console.log(config.apiBase)
|
|
1102
|
+
* console.log(config.debug)
|
|
1103
|
+
* <\/script>
|
|
1104
|
+
* ```
|
|
1105
|
+
*
|
|
1106
|
+
* @example
|
|
1107
|
+
* ```ts
|
|
1108
|
+
* // In a composable
|
|
1109
|
+
* export function useApi() {
|
|
1110
|
+
* const config = useRuntimeConfig()
|
|
1111
|
+
*
|
|
1112
|
+
* async function fetch<T>(endpoint: string): Promise<T> {
|
|
1113
|
+
* const url = `${config.apiBase}${endpoint}`
|
|
1114
|
+
* const response = await globalThis.fetch(url)
|
|
1115
|
+
* return response.json()
|
|
1116
|
+
* }
|
|
1117
|
+
*
|
|
1118
|
+
* return { fetch }
|
|
1119
|
+
* }
|
|
1120
|
+
* ```
|
|
1121
|
+
*/
|
|
1122
|
+
function useRuntimeConfig() {
|
|
1123
|
+
if (!_config) try {
|
|
1124
|
+
_config = Object.freeze({ ...__KIMESH_CONFIG__ ?? {} });
|
|
1125
|
+
} catch {
|
|
1126
|
+
_config = Object.freeze({});
|
|
1127
|
+
}
|
|
1128
|
+
return _config;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
//#endregion
|
|
1132
|
+
//#region src/composables/use-kimesh-app.ts
|
|
1133
|
+
/**
|
|
1134
|
+
* @kimesh/router-runtime - useKimeshApp Composable
|
|
1135
|
+
*
|
|
1136
|
+
* Provides access to the Kimesh app context from within components.
|
|
1137
|
+
*/
|
|
1138
|
+
/**
|
|
1139
|
+
* Get the Kimesh app context
|
|
1140
|
+
*
|
|
1141
|
+
* @example
|
|
1142
|
+
* ```ts
|
|
1143
|
+
* const { router, queryClient, $config } = useKimeshApp()
|
|
1144
|
+
* console.log($config.apiUrl)
|
|
1145
|
+
* ```
|
|
1146
|
+
*
|
|
1147
|
+
* @throws Error if called outside a Kimesh app
|
|
1148
|
+
*/
|
|
1149
|
+
function useKimeshApp() {
|
|
1150
|
+
const context = inject(KIMESH_APP_CONTEXT_KEY);
|
|
1151
|
+
if (!context) throw new Error("[Kimesh] useKimeshApp() must be called within a Kimesh app. Make sure you have created the app with createKimeshApp().");
|
|
1152
|
+
return context;
|
|
1153
|
+
}
|
|
1154
|
+
/**
|
|
1155
|
+
* Try to get the Kimesh app context without throwing
|
|
1156
|
+
*/
|
|
1157
|
+
function tryUseKimeshApp() {
|
|
1158
|
+
return inject(KIMESH_APP_CONTEXT_KEY);
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
//#endregion
|
|
1162
|
+
//#region src/composables/useState.ts
|
|
1163
|
+
/**
|
|
1164
|
+
* @kimesh/router-runtime - useState Composable
|
|
1165
|
+
*
|
|
1166
|
+
* Centralized reactive state management for Kimesh applications.
|
|
1167
|
+
* CSR-optimized alternative to Nuxt's useState.
|
|
1168
|
+
*/
|
|
1169
|
+
const STATE_KEY_PREFIX = "$s_";
|
|
1170
|
+
/**
|
|
1171
|
+
* Get all state keys (without prefix).
|
|
1172
|
+
*/
|
|
1173
|
+
function getKmStateKeys() {
|
|
1174
|
+
const app = tryUseKimeshApp();
|
|
1175
|
+
if (!app?._state) return [];
|
|
1176
|
+
return Object.keys(app._state).filter((k) => k.startsWith(STATE_KEY_PREFIX)).map((k) => k.slice(3));
|
|
1177
|
+
}
|
|
1178
|
+
/**
|
|
1179
|
+
* Check if a state exists.
|
|
1180
|
+
*/
|
|
1181
|
+
function hasKmState(key) {
|
|
1182
|
+
const app = tryUseKimeshApp();
|
|
1183
|
+
if (!app?._state) return false;
|
|
1184
|
+
const prefixedKey = STATE_KEY_PREFIX + key;
|
|
1185
|
+
return prefixedKey in app._state && app._state[prefixedKey] !== void 0;
|
|
1186
|
+
}
|
|
1187
|
+
/**
|
|
1188
|
+
* Clear Kimesh state by key(s) or predicate.
|
|
1189
|
+
* Pass undefined to clear all state, a string for single key,
|
|
1190
|
+
* an array for multiple keys, or a predicate function.
|
|
1191
|
+
*/
|
|
1192
|
+
function clearKmState(keys) {
|
|
1193
|
+
const app = tryUseKimeshApp();
|
|
1194
|
+
if (!app?._state) return;
|
|
1195
|
+
const allStateKeys = getKmStateKeys();
|
|
1196
|
+
const keysToDelete = keys === void 0 ? allStateKeys : typeof keys === "function" ? allStateKeys.filter(keys) : Array.isArray(keys) ? keys : [keys];
|
|
1197
|
+
for (const key of keysToDelete) {
|
|
1198
|
+
const prefixedKey = STATE_KEY_PREFIX + key;
|
|
1199
|
+
if (prefixedKey in app._state) delete app._state[prefixedKey];
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
/**
|
|
1203
|
+
* Get or create reactive state by key.
|
|
1204
|
+
*
|
|
1205
|
+
* @throws TypeError if key is not a non-empty string or init is not a function
|
|
1206
|
+
* @throws Error if _state is not initialized
|
|
1207
|
+
*/
|
|
1208
|
+
function useState(key, init) {
|
|
1209
|
+
if (!key || typeof key !== "string") throw new TypeError("[Kimesh] [useState] key must be a non-empty string");
|
|
1210
|
+
if (init !== void 0 && typeof init !== "function") throw new TypeError("[Kimesh] [useState] init must be a function");
|
|
1211
|
+
const app = useKimeshApp();
|
|
1212
|
+
if (!app._state) throw new Error("[Kimesh] [useState] _state not initialized. Make sure you are using Kimesh v1.0+ with state support.");
|
|
1213
|
+
const prefixedKey = STATE_KEY_PREFIX + key;
|
|
1214
|
+
const state = toRef(app._state, prefixedKey);
|
|
1215
|
+
if (state.value === void 0 && init) {
|
|
1216
|
+
const initialValue = init();
|
|
1217
|
+
if (isRef(initialValue)) {
|
|
1218
|
+
app._state[prefixedKey] = initialValue;
|
|
1219
|
+
return initialValue;
|
|
1220
|
+
}
|
|
1221
|
+
state.value = initialValue;
|
|
1222
|
+
}
|
|
1223
|
+
return state;
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
//#endregion
|
|
1227
|
+
//#region src/composables/use-navigation-middleware.ts
|
|
1228
|
+
/**
|
|
1229
|
+
* Register navigation middleware using the hook system
|
|
1230
|
+
*
|
|
1231
|
+
* @example
|
|
1232
|
+
* ```ts
|
|
1233
|
+
* // In a plugin or component setup
|
|
1234
|
+
* const { remove } = useNavigationMiddleware({
|
|
1235
|
+
* before: ({ to }) => {
|
|
1236
|
+
* if (!isAuthenticated() && to.meta.requiresAuth) {
|
|
1237
|
+
* return false // Cancel navigation
|
|
1238
|
+
* }
|
|
1239
|
+
* },
|
|
1240
|
+
* after: ({ to }) => {
|
|
1241
|
+
* analytics.trackPageView(to.path)
|
|
1242
|
+
* },
|
|
1243
|
+
* error: ({ error }) => {
|
|
1244
|
+
* console.error('Navigation failed:', error)
|
|
1245
|
+
* },
|
|
1246
|
+
* })
|
|
1247
|
+
*
|
|
1248
|
+
* // Cleanup when done
|
|
1249
|
+
* onUnmounted(() => remove())
|
|
1250
|
+
* ```
|
|
1251
|
+
*
|
|
1252
|
+
* @returns Object with remove function to cleanup hooks
|
|
1253
|
+
*/
|
|
1254
|
+
function useNavigationMiddleware(options) {
|
|
1255
|
+
const { hooks } = useKimeshApp();
|
|
1256
|
+
const cleanups = [];
|
|
1257
|
+
if (options.before) cleanups.push(hooks.hook("navigate:before", options.before));
|
|
1258
|
+
if (options.after) cleanups.push(hooks.hook("navigate:after", options.after));
|
|
1259
|
+
if (options.error) cleanups.push(hooks.hook("navigate:error", options.error));
|
|
1260
|
+
return { remove() {
|
|
1261
|
+
for (const cleanup of cleanups) cleanup();
|
|
1262
|
+
} };
|
|
1263
|
+
}
|
|
1264
|
+
/**
|
|
1265
|
+
* Create a navigation guard that runs before navigation
|
|
1266
|
+
*
|
|
1267
|
+
* @example
|
|
1268
|
+
* ```ts
|
|
1269
|
+
* // Auth guard plugin
|
|
1270
|
+
* export default defineKimeshRuntimePlugin({
|
|
1271
|
+
* name: 'auth-guard',
|
|
1272
|
+
* setup(app) {
|
|
1273
|
+
* app.runWithContext(() => {
|
|
1274
|
+
* useNavigationGuard(({ to }) => {
|
|
1275
|
+
* if (to.meta.requiresAuth && !isAuthenticated()) {
|
|
1276
|
+
* return false
|
|
1277
|
+
* }
|
|
1278
|
+
* })
|
|
1279
|
+
* })
|
|
1280
|
+
* }
|
|
1281
|
+
* })
|
|
1282
|
+
* ```
|
|
1283
|
+
*/
|
|
1284
|
+
function useNavigationGuard(guard) {
|
|
1285
|
+
const { hooks } = useKimeshApp();
|
|
1286
|
+
return hooks.hook("navigate:before", guard);
|
|
1287
|
+
}
|
|
1288
|
+
/**
|
|
1289
|
+
* Register a callback for after navigation
|
|
1290
|
+
*/
|
|
1291
|
+
function useAfterNavigation(callback) {
|
|
1292
|
+
const { hooks } = useKimeshApp();
|
|
1293
|
+
return hooks.hook("navigate:after", callback);
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
//#endregion
|
|
1297
|
+
export { KIMESH_APP_CONTEXT_KEY, KIMESH_CONTEXT_KEY, KIMESH_PLUGIN_INDICATOR, KmDeferred, KmLink, KmOutlet, STATE_KEY_PREFIX, applyPlugin, applyPlugins, applyPluginsWithParallel, clearKmState, createFileRoute, createKimeshApp, createLoaderGuard, defineContext, defineKimeshRuntimePlugin, defineRoute, getCorePlugins, getKmStateKeys, getPluginHooks, getPluginMeta, getPluginName, hasKmState, installLoaderGuard, isKimeshRuntimePlugin, onBeforeRouteLeave, onBeforeRouteUpdate, queryPlugin, routerPlugin, tryUseKimeshApp, useAfterNavigation, useKimeshApp, useKimeshContext, useLink, useNavigate, useNavigationGuard, useNavigationMiddleware, useParams, useReactiveParams, useRoute, useRouter, useRuntimeConfig, useSearch, useState };
|
|
1298
|
+
//# sourceMappingURL=index.mjs.map
|