what-router 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.d.ts +165 -0
- package/package.json +35 -0
- package/src/index.js +459 -0
package/index.d.ts
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// What Framework Router - TypeScript Definitions
|
|
2
|
+
|
|
3
|
+
import { VNode, VNodeChild, Component, Signal, Computed } from '../core';
|
|
4
|
+
|
|
5
|
+
// --- Route State ---
|
|
6
|
+
|
|
7
|
+
export interface RouteState {
|
|
8
|
+
/** Current full URL */
|
|
9
|
+
readonly url: string;
|
|
10
|
+
/** Current pathname */
|
|
11
|
+
readonly path: string;
|
|
12
|
+
/** Route parameters */
|
|
13
|
+
readonly params: Record<string, string>;
|
|
14
|
+
/** Query parameters */
|
|
15
|
+
readonly query: Record<string, string>;
|
|
16
|
+
/** URL hash */
|
|
17
|
+
readonly hash: string;
|
|
18
|
+
/** Navigation in progress */
|
|
19
|
+
readonly isNavigating: boolean;
|
|
20
|
+
/** Navigation error if any */
|
|
21
|
+
readonly error: Error | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const route: RouteState;
|
|
25
|
+
|
|
26
|
+
// --- Navigation ---
|
|
27
|
+
|
|
28
|
+
export interface NavigateOptions {
|
|
29
|
+
/** Replace current history entry */
|
|
30
|
+
replace?: boolean;
|
|
31
|
+
/** History state object */
|
|
32
|
+
state?: any;
|
|
33
|
+
/** Use View Transitions API */
|
|
34
|
+
transition?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Navigate to a new URL */
|
|
38
|
+
export function navigate(to: string, options?: NavigateOptions): Promise<void>;
|
|
39
|
+
|
|
40
|
+
// --- Route Configuration ---
|
|
41
|
+
|
|
42
|
+
export interface RouteConfig {
|
|
43
|
+
/** URL path pattern */
|
|
44
|
+
path: string;
|
|
45
|
+
/** Page component */
|
|
46
|
+
component: Component<RouteComponentProps>;
|
|
47
|
+
/** Layout wrapper */
|
|
48
|
+
layout?: Component<LayoutProps>;
|
|
49
|
+
/** Loading component */
|
|
50
|
+
loading?: Component<{}>;
|
|
51
|
+
/** Error component */
|
|
52
|
+
error?: Component<{ error: Error }>;
|
|
53
|
+
/** Route middleware */
|
|
54
|
+
middleware?: RouteMiddleware[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface RouteComponentProps {
|
|
58
|
+
params: Record<string, string>;
|
|
59
|
+
query: Record<string, string>;
|
|
60
|
+
route: RouteConfig;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface LayoutProps {
|
|
64
|
+
params: Record<string, string>;
|
|
65
|
+
query: Record<string, string>;
|
|
66
|
+
children?: VNodeChild;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export type RouteMiddleware = (props: RouteComponentProps) => boolean | Promise<boolean>;
|
|
70
|
+
|
|
71
|
+
// --- Router Component ---
|
|
72
|
+
|
|
73
|
+
export interface RouterProps {
|
|
74
|
+
routes: RouteConfig[];
|
|
75
|
+
fallback?: Component<{}>;
|
|
76
|
+
globalLayout?: Component<{ children?: VNodeChild }>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function Router(props: RouterProps): VNode;
|
|
80
|
+
|
|
81
|
+
// --- Link Component ---
|
|
82
|
+
|
|
83
|
+
export interface LinkProps {
|
|
84
|
+
href: string;
|
|
85
|
+
class?: string;
|
|
86
|
+
className?: string;
|
|
87
|
+
replace?: boolean;
|
|
88
|
+
prefetch?: boolean;
|
|
89
|
+
activeClass?: string;
|
|
90
|
+
exactActiveClass?: string;
|
|
91
|
+
transition?: boolean;
|
|
92
|
+
children?: VNodeChild;
|
|
93
|
+
[key: string]: any;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function Link(props: LinkProps): VNode;
|
|
97
|
+
export function NavLink(props: LinkProps): VNode;
|
|
98
|
+
|
|
99
|
+
// --- Route Helpers ---
|
|
100
|
+
|
|
101
|
+
/** Define routes from object config */
|
|
102
|
+
export function defineRoutes(config: Record<string, Component | Partial<RouteConfig>>): RouteConfig[];
|
|
103
|
+
|
|
104
|
+
/** Create nested routes with shared options */
|
|
105
|
+
export function nestedRoutes(
|
|
106
|
+
basePath: string,
|
|
107
|
+
children: RouteConfig[],
|
|
108
|
+
options?: { layout?: Component; loading?: Component; error?: Component }
|
|
109
|
+
): RouteConfig[];
|
|
110
|
+
|
|
111
|
+
/** Group routes without affecting URLs */
|
|
112
|
+
export function routeGroup(
|
|
113
|
+
name: string,
|
|
114
|
+
routes: RouteConfig[],
|
|
115
|
+
options?: { layout?: Component; middleware?: RouteMiddleware[] }
|
|
116
|
+
): RouteConfig[];
|
|
117
|
+
|
|
118
|
+
// --- Redirect ---
|
|
119
|
+
|
|
120
|
+
export function Redirect(props: { to: string }): null;
|
|
121
|
+
|
|
122
|
+
// --- Guards ---
|
|
123
|
+
|
|
124
|
+
/** Create a route guard */
|
|
125
|
+
export function guard(
|
|
126
|
+
check: (props: RouteComponentProps) => boolean,
|
|
127
|
+
fallback: string | Component
|
|
128
|
+
): <P>(component: Component<P>) => Component<P>;
|
|
129
|
+
|
|
130
|
+
/** Create an async route guard */
|
|
131
|
+
export function asyncGuard(
|
|
132
|
+
check: (props: RouteComponentProps) => Promise<boolean>,
|
|
133
|
+
options?: { fallback?: string | Component; loading?: Component }
|
|
134
|
+
): <P>(component: Component<P>) => Component<P>;
|
|
135
|
+
|
|
136
|
+
// --- Prefetch ---
|
|
137
|
+
|
|
138
|
+
export function prefetch(href: string): void;
|
|
139
|
+
|
|
140
|
+
// --- Scroll Restoration ---
|
|
141
|
+
|
|
142
|
+
export function enableScrollRestoration(): void;
|
|
143
|
+
|
|
144
|
+
// --- View Transitions ---
|
|
145
|
+
|
|
146
|
+
export function viewTransitionName(name: string): { style: { viewTransitionName: string } };
|
|
147
|
+
export function setViewTransition(type: string): void;
|
|
148
|
+
|
|
149
|
+
// --- useRoute Hook ---
|
|
150
|
+
|
|
151
|
+
export interface UseRouteResult {
|
|
152
|
+
path: Computed<string>;
|
|
153
|
+
params: Computed<Record<string, string>>;
|
|
154
|
+
query: Computed<Record<string, string>>;
|
|
155
|
+
hash: Computed<string>;
|
|
156
|
+
isNavigating: Computed<boolean>;
|
|
157
|
+
navigate: typeof navigate;
|
|
158
|
+
prefetch: typeof prefetch;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function useRoute(): UseRouteResult;
|
|
162
|
+
|
|
163
|
+
// --- Outlet ---
|
|
164
|
+
|
|
165
|
+
export function Outlet(props: { children?: VNodeChild }): VNodeChild;
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "what-router",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "What Framework - File-based & programmatic router with View Transitions",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"types": "index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./index.d.ts",
|
|
11
|
+
"import": "./src/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"src",
|
|
16
|
+
"index.d.ts"
|
|
17
|
+
],
|
|
18
|
+
"sideEffects": false,
|
|
19
|
+
"keywords": [
|
|
20
|
+
"router",
|
|
21
|
+
"file-based-routing",
|
|
22
|
+
"view-transitions",
|
|
23
|
+
"spa",
|
|
24
|
+
"what-framework"
|
|
25
|
+
],
|
|
26
|
+
"author": "",
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"what-core": "^0.1.0"
|
|
30
|
+
},
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "https://github.com/aspect/what-fw"
|
|
34
|
+
}
|
|
35
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
// What Framework - Router
|
|
2
|
+
// Production-grade file-based routing with nested layouts, loading states,
|
|
3
|
+
// route groups, view transitions, and middleware.
|
|
4
|
+
|
|
5
|
+
import { signal, effect, computed, batch, h } from 'what-core';
|
|
6
|
+
|
|
7
|
+
// --- Route State (global singleton) ---
|
|
8
|
+
|
|
9
|
+
const _url = signal(typeof location !== 'undefined' ? location.pathname + location.search + location.hash : '/');
|
|
10
|
+
const _params = signal({});
|
|
11
|
+
const _query = signal({});
|
|
12
|
+
const _isNavigating = signal(false);
|
|
13
|
+
const _navigationError = signal(null);
|
|
14
|
+
|
|
15
|
+
export const route = {
|
|
16
|
+
get url() { return _url(); },
|
|
17
|
+
get path() { return _url().split('?')[0].split('#')[0]; },
|
|
18
|
+
get params() { return _params(); },
|
|
19
|
+
get query() { return _query(); },
|
|
20
|
+
get hash() {
|
|
21
|
+
const h = _url().split('#')[1];
|
|
22
|
+
return h ? '#' + h : '';
|
|
23
|
+
},
|
|
24
|
+
get isNavigating() { return _isNavigating(); },
|
|
25
|
+
get error() { return _navigationError(); },
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// --- Navigation with View Transitions ---
|
|
29
|
+
|
|
30
|
+
export async function navigate(to, opts = {}) {
|
|
31
|
+
const { replace = false, state = null, transition = true } = opts;
|
|
32
|
+
|
|
33
|
+
// Don't navigate if already on the same URL
|
|
34
|
+
if (to === _url()) return;
|
|
35
|
+
|
|
36
|
+
_isNavigating.set(true);
|
|
37
|
+
_navigationError.set(null);
|
|
38
|
+
|
|
39
|
+
const doNavigation = () => {
|
|
40
|
+
if (replace) {
|
|
41
|
+
history.replaceState(state, '', to);
|
|
42
|
+
} else {
|
|
43
|
+
history.pushState(state, '', to);
|
|
44
|
+
}
|
|
45
|
+
_url.set(to);
|
|
46
|
+
_isNavigating.set(false);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// Use View Transitions API if available and enabled
|
|
50
|
+
if (transition && typeof document !== 'undefined' && document.startViewTransition) {
|
|
51
|
+
try {
|
|
52
|
+
await document.startViewTransition(doNavigation).finished;
|
|
53
|
+
} catch (e) {
|
|
54
|
+
// Transition failed, navigation still happened
|
|
55
|
+
}
|
|
56
|
+
} else {
|
|
57
|
+
doNavigation();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Back/forward support
|
|
62
|
+
if (typeof window !== 'undefined') {
|
|
63
|
+
window.addEventListener('popstate', () => {
|
|
64
|
+
_url.set(location.pathname + location.search + location.hash);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// --- Route Matching ---
|
|
69
|
+
|
|
70
|
+
function compilePath(path) {
|
|
71
|
+
// /users/:id -> regex + param names
|
|
72
|
+
// /posts/* -> catch-all
|
|
73
|
+
// /[slug] -> dynamic (file-based syntax)
|
|
74
|
+
// (group) -> route group (ignored in URL)
|
|
75
|
+
|
|
76
|
+
// Remove route groups from path (they don't affect URL matching)
|
|
77
|
+
const normalized = path
|
|
78
|
+
.replace(/\([\w-]+\)\//g, '') // Remove (group)/ prefixes
|
|
79
|
+
.replace(/\[\.\.\.(\w+)\]/g, (_, name) => `*:${name}`) // Preserve catch-all name
|
|
80
|
+
.replace(/\[(\w+)\]/g, ':$1'); // File-based [param] to :param
|
|
81
|
+
|
|
82
|
+
const paramNames = [];
|
|
83
|
+
let catchAll = null;
|
|
84
|
+
|
|
85
|
+
const regexStr = normalized
|
|
86
|
+
.split('/')
|
|
87
|
+
.map(segment => {
|
|
88
|
+
if (segment.startsWith('*:')) {
|
|
89
|
+
catchAll = segment.slice(2);
|
|
90
|
+
paramNames.push(catchAll);
|
|
91
|
+
return '(.+)';
|
|
92
|
+
}
|
|
93
|
+
if (segment === '*') {
|
|
94
|
+
catchAll = 'rest';
|
|
95
|
+
paramNames.push('rest');
|
|
96
|
+
return '(.+)';
|
|
97
|
+
}
|
|
98
|
+
if (segment.startsWith(':')) {
|
|
99
|
+
paramNames.push(segment.slice(1));
|
|
100
|
+
return '([^/]+)';
|
|
101
|
+
}
|
|
102
|
+
return segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
103
|
+
})
|
|
104
|
+
.join('/');
|
|
105
|
+
|
|
106
|
+
const regex = new RegExp(`^${regexStr}$`);
|
|
107
|
+
return { regex, paramNames, catchAll };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function matchRoute(path, routes) {
|
|
111
|
+
// Sort routes by specificity (more specific first)
|
|
112
|
+
const sorted = [...routes].sort((a, b) => {
|
|
113
|
+
const aSpecific = (a.path.match(/:/g) || []).length + (a.path.includes('*') ? 100 : 0);
|
|
114
|
+
const bSpecific = (b.path.match(/:/g) || []).length + (b.path.includes('*') ? 100 : 0);
|
|
115
|
+
return aSpecific - bSpecific;
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
for (const route of sorted) {
|
|
119
|
+
const { regex, paramNames } = compilePath(route.path);
|
|
120
|
+
const match = path.match(regex);
|
|
121
|
+
if (match) {
|
|
122
|
+
const params = {};
|
|
123
|
+
paramNames.forEach((name, i) => {
|
|
124
|
+
params[name] = decodeURIComponent(match[i + 1]);
|
|
125
|
+
});
|
|
126
|
+
return { route, params };
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function parseQuery(search) {
|
|
133
|
+
const params = {};
|
|
134
|
+
if (!search) return params;
|
|
135
|
+
const qs = search.startsWith('?') ? search.slice(1) : search;
|
|
136
|
+
for (const pair of qs.split('&')) {
|
|
137
|
+
const [key, val] = pair.split('=');
|
|
138
|
+
if (key) params[decodeURIComponent(key)] = val ? decodeURIComponent(val) : '';
|
|
139
|
+
}
|
|
140
|
+
return params;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// --- Nested Layouts ---
|
|
144
|
+
|
|
145
|
+
// Build the layout chain for a route
|
|
146
|
+
function buildLayoutChain(route, routes) {
|
|
147
|
+
const layouts = [];
|
|
148
|
+
|
|
149
|
+
// Check for nested layouts based on path segments
|
|
150
|
+
const segments = route.path.split('/').filter(Boolean);
|
|
151
|
+
let currentPath = '';
|
|
152
|
+
|
|
153
|
+
for (const segment of segments) {
|
|
154
|
+
currentPath += '/' + segment;
|
|
155
|
+
|
|
156
|
+
// Find layout for this path level
|
|
157
|
+
const layoutRoute = routes.find(r =>
|
|
158
|
+
r.layout && r.path === currentPath + '/_layout'
|
|
159
|
+
);
|
|
160
|
+
if (layoutRoute) {
|
|
161
|
+
layouts.push(layoutRoute.layout);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Add route's own layout if specified
|
|
166
|
+
if (route.layout) {
|
|
167
|
+
layouts.push(route.layout);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return layouts;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// --- Router Component ---
|
|
174
|
+
|
|
175
|
+
export function Router({ routes, fallback, globalLayout }) {
|
|
176
|
+
const currentUrl = _url();
|
|
177
|
+
const path = currentUrl.split('?')[0].split('#')[0];
|
|
178
|
+
const search = currentUrl.split('?')[1]?.split('#')[0] || '';
|
|
179
|
+
const isNavigating = _isNavigating();
|
|
180
|
+
|
|
181
|
+
const matched = matchRoute(path, routes);
|
|
182
|
+
|
|
183
|
+
if (matched) {
|
|
184
|
+
batch(() => {
|
|
185
|
+
_params.set(matched.params);
|
|
186
|
+
_query.set(parseQuery(search));
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
const { route: r, params } = matched;
|
|
190
|
+
|
|
191
|
+
// Build element with loading state support
|
|
192
|
+
let element;
|
|
193
|
+
|
|
194
|
+
if (r.loading && isNavigating) {
|
|
195
|
+
element = h(r.loading, {});
|
|
196
|
+
} else {
|
|
197
|
+
element = h(r.component, {
|
|
198
|
+
params,
|
|
199
|
+
query: parseQuery(search),
|
|
200
|
+
route: r,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Wrap with nested layouts (innermost to outermost)
|
|
205
|
+
const layouts = buildLayoutChain(r, routes);
|
|
206
|
+
for (const Layout of layouts.reverse()) {
|
|
207
|
+
element = h(Layout, { params, query: parseQuery(search) }, element);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Global layout wrapper
|
|
211
|
+
if (globalLayout) {
|
|
212
|
+
element = h(globalLayout, {}, element);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return element;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// 404
|
|
219
|
+
if (fallback) return h(fallback, {});
|
|
220
|
+
return h('div', { class: 'what-404' },
|
|
221
|
+
h('h1', null, '404'),
|
|
222
|
+
h('p', null, 'Page not found')
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// --- Link Component ---
|
|
227
|
+
|
|
228
|
+
export function Link({
|
|
229
|
+
href,
|
|
230
|
+
class: cls,
|
|
231
|
+
className,
|
|
232
|
+
children,
|
|
233
|
+
replace: rep,
|
|
234
|
+
prefetch: shouldPrefetch = true,
|
|
235
|
+
activeClass = 'active',
|
|
236
|
+
exactActiveClass = 'exact-active',
|
|
237
|
+
transition = true,
|
|
238
|
+
...rest
|
|
239
|
+
}) {
|
|
240
|
+
const currentPath = route.path;
|
|
241
|
+
const isActive = currentPath.startsWith(href);
|
|
242
|
+
const isExactActive = currentPath === href;
|
|
243
|
+
|
|
244
|
+
const classes = [
|
|
245
|
+
cls || className,
|
|
246
|
+
isActive && activeClass,
|
|
247
|
+
isExactActive && exactActiveClass,
|
|
248
|
+
].filter(Boolean).join(' ') || undefined;
|
|
249
|
+
|
|
250
|
+
return h('a', {
|
|
251
|
+
href,
|
|
252
|
+
class: classes,
|
|
253
|
+
onClick: (e) => {
|
|
254
|
+
// Only intercept left-clicks without modifiers
|
|
255
|
+
if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey || e.button !== 0) return;
|
|
256
|
+
e.preventDefault();
|
|
257
|
+
navigate(href, { replace: rep, transition });
|
|
258
|
+
},
|
|
259
|
+
onMouseenter: shouldPrefetch ? () => prefetch(href) : undefined,
|
|
260
|
+
...rest,
|
|
261
|
+
}, ...(Array.isArray(children) ? children : [children]));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// --- NavLink with active states ---
|
|
265
|
+
|
|
266
|
+
export function NavLink(props) {
|
|
267
|
+
return Link(props);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// --- Define Routes Helper ---
|
|
271
|
+
// Creates route config from a flat object for convenience.
|
|
272
|
+
|
|
273
|
+
export function defineRoutes(config) {
|
|
274
|
+
return Object.entries(config).map(([path, value]) => {
|
|
275
|
+
if (typeof value === 'function') {
|
|
276
|
+
return { path, component: value };
|
|
277
|
+
}
|
|
278
|
+
// Object form with layout, middleware, loading, error, etc.
|
|
279
|
+
return { path, ...value };
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// --- Nested Route Helper ---
|
|
284
|
+
|
|
285
|
+
export function nestedRoutes(basePath, children, options = {}) {
|
|
286
|
+
const { layout, loading, error } = options;
|
|
287
|
+
|
|
288
|
+
return children.map(child => ({
|
|
289
|
+
...child,
|
|
290
|
+
path: basePath + child.path,
|
|
291
|
+
layout: child.layout || layout,
|
|
292
|
+
loading: child.loading || loading,
|
|
293
|
+
error: child.error || error,
|
|
294
|
+
}));
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// --- Route Groups ---
|
|
298
|
+
// Group routes without affecting URL structure
|
|
299
|
+
|
|
300
|
+
export function routeGroup(name, routes, options = {}) {
|
|
301
|
+
const { layout, middleware } = options;
|
|
302
|
+
|
|
303
|
+
return routes.map(route => ({
|
|
304
|
+
...route,
|
|
305
|
+
_group: name,
|
|
306
|
+
layout: route.layout || layout,
|
|
307
|
+
middleware: [...(route.middleware || []), ...(middleware || [])],
|
|
308
|
+
}));
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// --- Redirect ---
|
|
312
|
+
|
|
313
|
+
export function Redirect({ to }) {
|
|
314
|
+
navigate(to, { replace: true });
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// --- Route Guards / Middleware ---
|
|
319
|
+
|
|
320
|
+
export function guard(check, fallback) {
|
|
321
|
+
return (Component) => {
|
|
322
|
+
return function GuardedRoute(props) {
|
|
323
|
+
const result = check(props);
|
|
324
|
+
|
|
325
|
+
// Support async guards
|
|
326
|
+
if (result instanceof Promise) {
|
|
327
|
+
// Return loading while checking
|
|
328
|
+
return h('div', { class: 'what-guard-loading' }, 'Loading...');
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (result) {
|
|
332
|
+
return h(Component, props);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (typeof fallback === 'string') {
|
|
336
|
+
navigate(fallback, { replace: true });
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
return h(fallback, props);
|
|
340
|
+
};
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Async guard with suspense
|
|
345
|
+
export function asyncGuard(check, options = {}) {
|
|
346
|
+
const { fallback = '/login', loading = null } = options;
|
|
347
|
+
|
|
348
|
+
return (Component) => {
|
|
349
|
+
return function AsyncGuardedRoute(props) {
|
|
350
|
+
const status = signal('pending');
|
|
351
|
+
const checkResult = signal(null);
|
|
352
|
+
|
|
353
|
+
effect(() => {
|
|
354
|
+
Promise.resolve(check(props))
|
|
355
|
+
.then(result => {
|
|
356
|
+
checkResult.set(result);
|
|
357
|
+
status.set(result ? 'allowed' : 'denied');
|
|
358
|
+
})
|
|
359
|
+
.catch(() => status.set('denied'));
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
const currentStatus = status();
|
|
363
|
+
|
|
364
|
+
if (currentStatus === 'pending') {
|
|
365
|
+
return loading ? h(loading, {}) : null;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (currentStatus === 'allowed') {
|
|
369
|
+
return h(Component, props);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (typeof fallback === 'string') {
|
|
373
|
+
navigate(fallback, { replace: true });
|
|
374
|
+
return null;
|
|
375
|
+
}
|
|
376
|
+
return h(fallback, props);
|
|
377
|
+
};
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// --- Prefetch ---
|
|
382
|
+
// Hint the browser to prefetch a route's assets.
|
|
383
|
+
|
|
384
|
+
const prefetchedUrls = new Set();
|
|
385
|
+
|
|
386
|
+
export function prefetch(href) {
|
|
387
|
+
if (typeof document === 'undefined') return;
|
|
388
|
+
if (prefetchedUrls.has(href)) return;
|
|
389
|
+
prefetchedUrls.add(href);
|
|
390
|
+
|
|
391
|
+
const link = document.createElement('link');
|
|
392
|
+
link.rel = 'prefetch';
|
|
393
|
+
link.href = href;
|
|
394
|
+
document.head.appendChild(link);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// --- Scroll Restoration ---
|
|
398
|
+
|
|
399
|
+
const scrollPositions = new Map();
|
|
400
|
+
|
|
401
|
+
export function enableScrollRestoration() {
|
|
402
|
+
if (typeof window === 'undefined') return;
|
|
403
|
+
|
|
404
|
+
// Save scroll position before navigation
|
|
405
|
+
window.addEventListener('beforeunload', () => {
|
|
406
|
+
scrollPositions.set(location.pathname, window.scrollY);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
// Restore scroll position after navigation
|
|
410
|
+
effect(() => {
|
|
411
|
+
const path = route.path;
|
|
412
|
+
const savedPosition = scrollPositions.get(path);
|
|
413
|
+
|
|
414
|
+
requestAnimationFrame(() => {
|
|
415
|
+
if (savedPosition !== undefined) {
|
|
416
|
+
window.scrollTo(0, savedPosition);
|
|
417
|
+
} else if (route.hash) {
|
|
418
|
+
const el = document.querySelector(route.hash);
|
|
419
|
+
el?.scrollIntoView();
|
|
420
|
+
} else {
|
|
421
|
+
window.scrollTo(0, 0);
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// --- View Transition Helpers ---
|
|
428
|
+
|
|
429
|
+
export function viewTransitionName(name) {
|
|
430
|
+
return { style: { viewTransitionName: name } };
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Configure view transition types
|
|
434
|
+
export function setViewTransition(type) {
|
|
435
|
+
if (typeof document === 'undefined') return;
|
|
436
|
+
document.documentElement.dataset.transition = type;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// --- useRoute Hook ---
|
|
440
|
+
|
|
441
|
+
export function useRoute() {
|
|
442
|
+
return {
|
|
443
|
+
path: computed(() => route.path),
|
|
444
|
+
params: computed(() => route.params),
|
|
445
|
+
query: computed(() => route.query),
|
|
446
|
+
hash: computed(() => route.hash),
|
|
447
|
+
isNavigating: computed(() => route.isNavigating),
|
|
448
|
+
navigate,
|
|
449
|
+
prefetch,
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// --- Outlet Component ---
|
|
454
|
+
// For nested route rendering
|
|
455
|
+
|
|
456
|
+
export function Outlet({ children }) {
|
|
457
|
+
// Children passed from parent layout
|
|
458
|
+
return children || null;
|
|
459
|
+
}
|