tova 0.7.0 → 0.9.4
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/bin/tova.js +1312 -139
- package/package.json +8 -1
- package/src/analyzer/analyzer.js +539 -11
- package/src/analyzer/browser-analyzer.js +56 -8
- package/src/analyzer/deploy-analyzer.js +44 -0
- package/src/analyzer/scope.js +7 -0
- package/src/analyzer/server-analyzer.js +33 -1
- package/src/codegen/base-codegen.js +1296 -23
- package/src/codegen/browser-codegen.js +725 -20
- package/src/codegen/codegen.js +87 -5
- package/src/codegen/deploy-codegen.js +49 -0
- package/src/codegen/server-codegen.js +54 -6
- package/src/codegen/shared-codegen.js +5 -0
- package/src/codegen/theme-codegen.js +69 -0
- package/src/codegen/wasm-codegen.js +6 -0
- package/src/config/edit-toml.js +6 -2
- package/src/config/git-resolver.js +128 -0
- package/src/config/lock-file.js +57 -0
- package/src/config/module-cache.js +58 -0
- package/src/config/module-entry.js +37 -0
- package/src/config/module-path.js +63 -0
- package/src/config/pkg-errors.js +62 -0
- package/src/config/resolve.js +26 -0
- package/src/config/resolver.js +139 -0
- package/src/config/search.js +28 -0
- package/src/config/semver.js +72 -0
- package/src/config/toml.js +61 -6
- package/src/deploy/deploy.js +217 -0
- package/src/deploy/infer.js +218 -0
- package/src/deploy/provision.js +315 -0
- package/src/diagnostics/security-scorecard.js +111 -0
- package/src/lexer/lexer.js +18 -3
- package/src/lsp/server.js +482 -0
- package/src/parser/animate-ast.js +45 -0
- package/src/parser/ast.js +39 -0
- package/src/parser/browser-ast.js +19 -1
- package/src/parser/browser-parser.js +221 -4
- package/src/parser/concurrency-ast.js +15 -0
- package/src/parser/concurrency-parser.js +236 -0
- package/src/parser/deploy-ast.js +37 -0
- package/src/parser/deploy-parser.js +132 -0
- package/src/parser/parser.js +42 -5
- package/src/parser/select-ast.js +39 -0
- package/src/parser/theme-ast.js +29 -0
- package/src/parser/theme-parser.js +70 -0
- package/src/registry/plugins/concurrency-plugin.js +32 -0
- package/src/registry/plugins/deploy-plugin.js +33 -0
- package/src/registry/plugins/theme-plugin.js +20 -0
- package/src/registry/register-all.js +6 -0
- package/src/runtime/charts.js +547 -0
- package/src/runtime/embedded.js +6 -2
- package/src/runtime/reactivity.js +60 -0
- package/src/runtime/router.js +703 -295
- package/src/runtime/table.js +606 -33
- package/src/stdlib/inline.js +365 -10
- package/src/stdlib/runtime-bridge.js +152 -0
- package/src/stdlib/string.js +84 -2
- package/src/stdlib/validation.js +1 -1
- package/src/version.js +1 -1
package/src/runtime/router.js
CHANGED
|
@@ -1,191 +1,662 @@
|
|
|
1
|
-
// Client-side router for Tova —
|
|
2
|
-
// Route changes are reactive: components that read route
|
|
1
|
+
// Client-side router for Tova — class-based, signal-integrated, production-grade.
|
|
2
|
+
// Route changes are reactive: components that read route signals auto-update.
|
|
3
3
|
|
|
4
4
|
import { createSignal, tova_el, tova_fragment, createEffect, onCleanup } from './reactivity.js';
|
|
5
5
|
|
|
6
|
-
// ───
|
|
7
|
-
|
|
8
|
-
// will automatically re-run when the route changes.
|
|
9
|
-
|
|
10
|
-
const [route, setRoute] = createSignal({
|
|
11
|
-
path: '/',
|
|
12
|
-
pattern: null,
|
|
13
|
-
component: null,
|
|
14
|
-
params: {},
|
|
15
|
-
query: {},
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
let routeDefinitions = [];
|
|
19
|
-
let routeChangeCallbacks = [];
|
|
20
|
-
let notFoundComponent = null;
|
|
21
|
-
let beforeNavigateHooks = [];
|
|
22
|
-
let afterNavigateHooks = [];
|
|
6
|
+
// ─── Active Router Instance ──────────────────────────────
|
|
7
|
+
let _activeRouter = null;
|
|
23
8
|
|
|
24
9
|
// ─── Path Validation ─────────────────────────────────────
|
|
25
|
-
// Reject absolute URLs, protocol-relative URLs, and javascript: URIs
|
|
26
|
-
// to prevent open redirects and XSS.
|
|
27
|
-
|
|
28
10
|
function isValidPath(path) {
|
|
29
11
|
if (typeof path !== 'string') return false;
|
|
30
|
-
// Reject protocol-relative URLs (//evil.com)
|
|
31
12
|
if (path.startsWith('//')) return false;
|
|
32
|
-
// Reject absolute URLs with schemes (http:, javascript:, data:, etc.)
|
|
33
13
|
if (/^[a-zA-Z][a-zA-Z0-9+\-.]*:/.test(path)) return false;
|
|
34
|
-
// Must start with / or be a relative path segment
|
|
35
14
|
return true;
|
|
36
15
|
}
|
|
37
16
|
|
|
38
|
-
// ───
|
|
17
|
+
// ─── Query String Helpers ────────────────────────────────
|
|
18
|
+
function parseQueryString(search) {
|
|
19
|
+
const query = {};
|
|
20
|
+
if (!search || search === '?') return query;
|
|
21
|
+
const str = search.startsWith('?') ? search.slice(1) : search;
|
|
22
|
+
for (const pair of str.split('&')) {
|
|
23
|
+
const [key, ...rest] = pair.split('=');
|
|
24
|
+
const value = rest.join('=');
|
|
25
|
+
if (key) {
|
|
26
|
+
const decodedKey = decodeURIComponent(key);
|
|
27
|
+
const decodedValue = value !== undefined ? decodeURIComponent(value) : '';
|
|
28
|
+
// Support repeated keys as arrays
|
|
29
|
+
if (decodedKey in query) {
|
|
30
|
+
if (Array.isArray(query[decodedKey])) {
|
|
31
|
+
query[decodedKey].push(decodedValue);
|
|
32
|
+
} else {
|
|
33
|
+
query[decodedKey] = [query[decodedKey], decodedValue];
|
|
34
|
+
}
|
|
35
|
+
} else {
|
|
36
|
+
query[decodedKey] = decodedValue;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return query;
|
|
41
|
+
}
|
|
39
42
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
-
for (const [
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
notFoundComponent = component;
|
|
48
|
-
// Catch-all '*' still gets a regex pattern for matching
|
|
49
|
-
if (path === '*') {
|
|
50
|
-
routeDefinitions.push({ path, pattern: /^(.*)$/, component, isCatchAll: true, children: null });
|
|
43
|
+
function serializeQuery(query) {
|
|
44
|
+
if (!query || typeof query !== 'object') return '';
|
|
45
|
+
const parts = [];
|
|
46
|
+
for (const [key, value] of Object.entries(query)) {
|
|
47
|
+
if (Array.isArray(value)) {
|
|
48
|
+
for (const v of value) {
|
|
49
|
+
parts.push(encodeURIComponent(key) + '=' + encodeURIComponent(v));
|
|
51
50
|
}
|
|
52
|
-
|
|
51
|
+
} else if (value === true) {
|
|
52
|
+
parts.push(encodeURIComponent(key));
|
|
53
|
+
} else if (value !== undefined && value !== null && value !== false) {
|
|
54
|
+
parts.push(encodeURIComponent(key) + '=' + encodeURIComponent(value));
|
|
53
55
|
}
|
|
56
|
+
}
|
|
57
|
+
return parts.length ? '?' + parts.join('&') : '';
|
|
58
|
+
}
|
|
54
59
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
relativePath: childPath,
|
|
70
|
-
pattern: pathToRegex(fullPath),
|
|
71
|
-
component: childComponent,
|
|
72
|
-
isCatchAll: false,
|
|
73
|
-
children: null,
|
|
74
|
-
});
|
|
75
|
-
}
|
|
60
|
+
// ─── Path → Regex Conversion ─────────────────────────────
|
|
61
|
+
function pathToRegex(path, prefixMatch) {
|
|
62
|
+
// Split on dynamic segments (:param, :param?, *) and escape static parts
|
|
63
|
+
const segments = path.split(/(:[\w]+\??|\*)/);
|
|
64
|
+
let pattern = '';
|
|
65
|
+
for (const segment of segments) {
|
|
66
|
+
if (!segment) continue;
|
|
67
|
+
if (segment === '*') {
|
|
68
|
+
pattern += '(.*)';
|
|
69
|
+
} else if (segment.startsWith(':')) {
|
|
70
|
+
if (segment.endsWith('?')) {
|
|
71
|
+
pattern += '([^/]*)?';
|
|
72
|
+
} else {
|
|
73
|
+
pattern += '([^/]+)';
|
|
76
74
|
}
|
|
77
|
-
routeDefinitions.push(parentDef);
|
|
78
75
|
} else {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
pattern: pathToRegex(path),
|
|
82
|
-
component: value,
|
|
83
|
-
isCatchAll: false,
|
|
84
|
-
children: null,
|
|
85
|
-
});
|
|
76
|
+
// Escape regex special chars in static path segments
|
|
77
|
+
pattern += segment.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
|
|
86
78
|
}
|
|
87
79
|
}
|
|
88
|
-
|
|
89
|
-
|
|
80
|
+
if (prefixMatch) {
|
|
81
|
+
return new RegExp('^' + pattern + '(?:/.*)?$');
|
|
82
|
+
}
|
|
83
|
+
return new RegExp('^' + pattern + '$');
|
|
90
84
|
}
|
|
91
85
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
if (!
|
|
95
|
-
|
|
96
|
-
|
|
86
|
+
function extractParams(routePath, match) {
|
|
87
|
+
const params = {};
|
|
88
|
+
if (!match) return params;
|
|
89
|
+
const paramNames = (routePath.match(/:([a-zA-Z_]+)\??/g) || [])
|
|
90
|
+
.map(p => p.replace(/^:/, '').replace(/\?$/, ''));
|
|
91
|
+
for (let i = 0; i < paramNames.length; i++) {
|
|
92
|
+
const val = match[i + 1];
|
|
93
|
+
if (val !== undefined && val !== '') {
|
|
94
|
+
params[paramNames[i]] = decodeURIComponent(val);
|
|
97
95
|
}
|
|
98
|
-
return;
|
|
99
96
|
}
|
|
100
|
-
//
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
97
|
+
// Handle catch-all wildcard
|
|
98
|
+
if (routePath.includes('*') && match[paramNames.length + 1] !== undefined) {
|
|
99
|
+
params['*'] = match[paramNames.length + 1];
|
|
100
|
+
}
|
|
101
|
+
return params;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ─── Lazy Route Marker ───────────────────────────────────
|
|
105
|
+
export function lazy(importFn) {
|
|
106
|
+
return { __lazy: true, load: importFn, _cached: null, _error: null };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ─── TovRouter Class ─────────────────────────────────────
|
|
110
|
+
class TovRouter {
|
|
111
|
+
constructor(config = {}) {
|
|
112
|
+
// Config
|
|
113
|
+
this.base = (config.base || '').replace(/\/$/, '');
|
|
114
|
+
this.scrollBehavior = config.scroll || 'auto';
|
|
115
|
+
this.loadingComponent = config.loading || null;
|
|
116
|
+
this.errorComponent = config.error || null;
|
|
117
|
+
|
|
118
|
+
// Signals
|
|
119
|
+
const [routeSignal, setRouteSignal] = createSignal({
|
|
120
|
+
path: '/',
|
|
121
|
+
pattern: null,
|
|
122
|
+
component: null,
|
|
123
|
+
params: {},
|
|
124
|
+
query: {},
|
|
125
|
+
meta: {},
|
|
126
|
+
});
|
|
127
|
+
this._route = routeSignal;
|
|
128
|
+
this._setRoute = setRouteSignal;
|
|
129
|
+
|
|
130
|
+
const [childRoute, setChildRoute] = createSignal(null);
|
|
131
|
+
this._childRoute = childRoute;
|
|
132
|
+
this._setChildRoute = setChildRoute;
|
|
133
|
+
|
|
134
|
+
const [isLoadingSignal, setIsLoadingSignal] = createSignal(false);
|
|
135
|
+
this._isLoading = isLoadingSignal;
|
|
136
|
+
this._setIsLoading = setIsLoadingSignal;
|
|
137
|
+
|
|
138
|
+
// Route definitions
|
|
139
|
+
this._routeDefinitions = [];
|
|
140
|
+
this._notFoundComponent = null;
|
|
141
|
+
|
|
142
|
+
// Hooks
|
|
143
|
+
this._beforeHooks = [];
|
|
144
|
+
this._afterHooks = [];
|
|
145
|
+
this._onChangeCallbacks = [];
|
|
146
|
+
|
|
147
|
+
// Scroll positions (keyed by history state key or path)
|
|
148
|
+
this._scrollPositions = new Map();
|
|
149
|
+
this._historyIndex = 0;
|
|
150
|
+
|
|
151
|
+
// Redirect loop protection
|
|
152
|
+
this._redirectCount = 0;
|
|
153
|
+
this._redirectWindowStart = 0;
|
|
154
|
+
|
|
155
|
+
// Bound handlers for cleanup
|
|
156
|
+
this._popstateHandler = null;
|
|
157
|
+
this._clickHandler = null;
|
|
158
|
+
|
|
159
|
+
// Process routes if provided
|
|
160
|
+
if (config.routes) {
|
|
161
|
+
this._processRoutes(config.routes);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Setup browser listeners
|
|
165
|
+
this._setupBrowserListeners();
|
|
166
|
+
|
|
167
|
+
// Register as active router
|
|
168
|
+
_activeRouter = this;
|
|
169
|
+
|
|
170
|
+
// Match initial route
|
|
171
|
+
this._handleRouteChange();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ─── Signal Getters (reactive) ───────────────────────────
|
|
175
|
+
get path() { return () => this._route().path; }
|
|
176
|
+
get params() { return () => this._route().params; }
|
|
177
|
+
get query() { return () => this._route().query; }
|
|
178
|
+
get meta() { return () => this._route().meta; }
|
|
179
|
+
get route() { return this._route; }
|
|
180
|
+
get loading() { return this._isLoading; }
|
|
181
|
+
|
|
182
|
+
// ─── Navigation ──────────────────────────────────────────
|
|
183
|
+
navigate(path, options = {}) {
|
|
184
|
+
if (!isValidPath(path)) {
|
|
185
|
+
if (typeof console !== 'undefined') {
|
|
186
|
+
console.warn('Tova router: Blocked navigation to unsafe path: ' + path);
|
|
112
187
|
}
|
|
113
|
-
handleRouteChange();
|
|
114
188
|
return;
|
|
115
189
|
}
|
|
190
|
+
|
|
191
|
+
// Parse path for query string
|
|
192
|
+
let normalizedPath = path.startsWith('/') ? path : '/' + path;
|
|
193
|
+
let queryString = '';
|
|
194
|
+
|
|
195
|
+
// Extract query from path if present
|
|
196
|
+
const qIdx = normalizedPath.indexOf('?');
|
|
197
|
+
if (qIdx !== -1) {
|
|
198
|
+
queryString = normalizedPath.slice(qIdx);
|
|
199
|
+
normalizedPath = normalizedPath.slice(0, qIdx);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Merge query option
|
|
203
|
+
if (options.query) {
|
|
204
|
+
const serialized = serializeQuery(options.query);
|
|
205
|
+
queryString = serialized || queryString;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Extract hash
|
|
209
|
+
let hash = '';
|
|
210
|
+
const hashIdx = normalizedPath.indexOf('#');
|
|
211
|
+
if (hashIdx !== -1) {
|
|
212
|
+
hash = normalizedPath.slice(hashIdx);
|
|
213
|
+
normalizedPath = normalizedPath.slice(0, hashIdx);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Run beforeNavigate hooks
|
|
217
|
+
const from = this._route();
|
|
218
|
+
const to = normalizedPath;
|
|
219
|
+
for (const hook of this._beforeHooks) {
|
|
220
|
+
const result = hook(from, to);
|
|
221
|
+
if (result === false) return;
|
|
222
|
+
if (typeof result === 'string' && isValidPath(result)) {
|
|
223
|
+
// Redirect
|
|
224
|
+
this._pushState(result, options);
|
|
225
|
+
this._handleRouteChange();
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Save scroll position before navigating
|
|
231
|
+
this._saveScrollPosition();
|
|
232
|
+
|
|
233
|
+
// Push or replace history state
|
|
234
|
+
const fullUrl = this.base + normalizedPath + queryString + hash;
|
|
235
|
+
const state = { __tova_idx: ++this._historyIndex, ...(options.state || {}) };
|
|
236
|
+
|
|
237
|
+
if (typeof window !== 'undefined' && window.history) {
|
|
238
|
+
if (options.replace) {
|
|
239
|
+
window.history.replaceState(state, '', fullUrl);
|
|
240
|
+
} else {
|
|
241
|
+
window.history.pushState(state, '', fullUrl);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
this._handleRouteChange();
|
|
246
|
+
|
|
247
|
+
// Restore or reset scroll
|
|
248
|
+
if (!options.replace) {
|
|
249
|
+
this._restoreScrollPosition(normalizedPath, false);
|
|
250
|
+
}
|
|
116
251
|
}
|
|
117
252
|
|
|
118
|
-
|
|
119
|
-
|
|
253
|
+
back() {
|
|
254
|
+
if (typeof window !== 'undefined' && window.history) {
|
|
255
|
+
window.history.back();
|
|
256
|
+
}
|
|
120
257
|
}
|
|
121
|
-
handleRouteChange();
|
|
122
|
-
}
|
|
123
258
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
259
|
+
forward() {
|
|
260
|
+
if (typeof window !== 'undefined' && window.history) {
|
|
261
|
+
window.history.forward();
|
|
262
|
+
}
|
|
263
|
+
}
|
|
127
264
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
265
|
+
// ─── Active State ────────────────────────────────────────
|
|
266
|
+
isActive(path, exact = false) {
|
|
267
|
+
return () => {
|
|
268
|
+
const currentPath = this._route().path;
|
|
269
|
+
if (exact) return currentPath === path;
|
|
270
|
+
if (path === '/') return currentPath === '/';
|
|
271
|
+
return currentPath === path || currentPath.startsWith(path + '/');
|
|
272
|
+
};
|
|
273
|
+
}
|
|
131
274
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
275
|
+
// ─── Navigation Guards ───────────────────────────────────
|
|
276
|
+
beforeNavigate(callback) {
|
|
277
|
+
this._beforeHooks.push(callback);
|
|
278
|
+
return () => {
|
|
279
|
+
const idx = this._beforeHooks.indexOf(callback);
|
|
280
|
+
if (idx !== -1) this._beforeHooks.splice(idx, 1);
|
|
281
|
+
};
|
|
282
|
+
}
|
|
135
283
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
284
|
+
afterNavigate(callback) {
|
|
285
|
+
this._afterHooks.push(callback);
|
|
286
|
+
return () => {
|
|
287
|
+
const idx = this._afterHooks.indexOf(callback);
|
|
288
|
+
if (idx !== -1) this._afterHooks.splice(idx, 1);
|
|
289
|
+
};
|
|
290
|
+
}
|
|
139
291
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
292
|
+
onRouteChange(callback) {
|
|
293
|
+
this._onChangeCallbacks.push(callback);
|
|
294
|
+
return () => {
|
|
295
|
+
const idx = this._onChangeCallbacks.indexOf(callback);
|
|
296
|
+
if (idx !== -1) this._onChangeCallbacks.splice(idx, 1);
|
|
297
|
+
};
|
|
298
|
+
}
|
|
144
299
|
|
|
145
|
-
// ───
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
300
|
+
// ─── Cleanup ─────────────────────────────────────────────
|
|
301
|
+
destroy() {
|
|
302
|
+
if (typeof window !== 'undefined' && typeof window.removeEventListener === 'function') {
|
|
303
|
+
if (this._popstateHandler) window.removeEventListener('popstate', this._popstateHandler);
|
|
304
|
+
if (this._clickHandler && typeof document !== 'undefined' && typeof document.removeEventListener === 'function') {
|
|
305
|
+
document.removeEventListener('click', this._clickHandler);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
this._routeDefinitions = [];
|
|
309
|
+
this._notFoundComponent = null;
|
|
310
|
+
this._beforeHooks = [];
|
|
311
|
+
this._afterHooks = [];
|
|
312
|
+
this._onChangeCallbacks = [];
|
|
313
|
+
this._scrollPositions.clear();
|
|
314
|
+
if (_activeRouter === this) _activeRouter = null;
|
|
315
|
+
}
|
|
149
316
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
317
|
+
// ─── Route Processing ────────────────────────────────────
|
|
318
|
+
_processRoutes(routeMap) {
|
|
319
|
+
this._routeDefinitions = [];
|
|
320
|
+
this._notFoundComponent = null;
|
|
321
|
+
const entries = Object.entries(routeMap);
|
|
322
|
+
for (const [path, value] of entries) {
|
|
323
|
+
// Handle lazy routes
|
|
324
|
+
const isLazy = value && value.__lazy;
|
|
325
|
+
const isObjectConfig = typeof value === 'object' && value !== null && !isLazy;
|
|
326
|
+
|
|
327
|
+
// Special 404 route
|
|
328
|
+
if (path === '404' || path === '*') {
|
|
329
|
+
const component = (isObjectConfig && value.component) ? value.component : value;
|
|
330
|
+
this._notFoundComponent = component;
|
|
331
|
+
if (path === '*') {
|
|
332
|
+
this._routeDefinitions.push({
|
|
333
|
+
path, pattern: /^(.*)$/, component, isCatchAll: true,
|
|
334
|
+
children: null, meta: (isObjectConfig && value.meta) || {},
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (isObjectConfig && value.component) {
|
|
341
|
+
// Nested or metadata route
|
|
342
|
+
const meta = value.meta || {};
|
|
343
|
+
const parentDef = {
|
|
344
|
+
path,
|
|
345
|
+
pattern: pathToRegex(path, !!value.children),
|
|
346
|
+
component: value.component,
|
|
347
|
+
isCatchAll: false,
|
|
348
|
+
children: [],
|
|
349
|
+
meta,
|
|
350
|
+
};
|
|
351
|
+
if (value.children) {
|
|
352
|
+
for (const [childPath, childValue] of Object.entries(value.children)) {
|
|
353
|
+
const fullPath = childPath === '/' ? path : path + childPath;
|
|
354
|
+
const childIsLazy = childValue && childValue.__lazy;
|
|
355
|
+
const childIsObj = typeof childValue === 'object' && childValue !== null && !childIsLazy;
|
|
356
|
+
parentDef.children.push({
|
|
357
|
+
path: fullPath,
|
|
358
|
+
relativePath: childPath,
|
|
359
|
+
pattern: pathToRegex(fullPath),
|
|
360
|
+
component: childIsObj ? childValue.component : childValue,
|
|
361
|
+
isCatchAll: false,
|
|
362
|
+
children: null,
|
|
363
|
+
meta: (childIsObj && childValue.meta) || {},
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
this._routeDefinitions.push(parentDef);
|
|
368
|
+
} else {
|
|
369
|
+
// Simple route or lazy route
|
|
370
|
+
this._routeDefinitions.push({
|
|
371
|
+
path,
|
|
372
|
+
pattern: pathToRegex(path),
|
|
373
|
+
component: value,
|
|
374
|
+
isCatchAll: false,
|
|
375
|
+
children: null,
|
|
376
|
+
meta: {},
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// ─── Route Matching ──────────────────────────────────────
|
|
383
|
+
_matchRoute(path) {
|
|
384
|
+
for (const def of this._routeDefinitions) {
|
|
385
|
+
if (def.children && def.children.length > 0) {
|
|
386
|
+
for (const child of def.children) {
|
|
387
|
+
const childMatch = child.pattern.exec(path);
|
|
388
|
+
if (childMatch) {
|
|
389
|
+
const childParams = extractParams(child.path, childMatch);
|
|
390
|
+
this._setChildRoute({ component: child.component, params: childParams, meta: child.meta });
|
|
391
|
+
const parentMatch = def.pattern.exec(path);
|
|
392
|
+
const parentParams = extractParams(def.path, parentMatch || []);
|
|
393
|
+
return {
|
|
394
|
+
path: def.path,
|
|
395
|
+
component: def.component,
|
|
396
|
+
params: { ...parentParams, ...childParams },
|
|
397
|
+
meta: { ...def.meta, ...child.meta },
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
const parentMatch = def.pattern.exec(path);
|
|
402
|
+
if (parentMatch) {
|
|
403
|
+
this._setChildRoute(null);
|
|
404
|
+
const params = extractParams(def.path, parentMatch);
|
|
405
|
+
return { path: def.path, component: def.component, params, meta: def.meta };
|
|
406
|
+
}
|
|
407
|
+
} else {
|
|
408
|
+
const match = def.pattern.exec(path);
|
|
409
|
+
if (match) {
|
|
410
|
+
this._setChildRoute(null);
|
|
411
|
+
const params = extractParams(def.path, match);
|
|
412
|
+
return { path: def.path, component: def.component, params, meta: def.meta };
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
this._setChildRoute(null);
|
|
417
|
+
return null;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// ─── Route Change Handler ────────────────────────────────
|
|
421
|
+
_handleRouteChange() {
|
|
422
|
+
let path = '/';
|
|
423
|
+
let query = {};
|
|
424
|
+
if (typeof window !== 'undefined' && window.location) {
|
|
425
|
+
path = window.location.pathname;
|
|
426
|
+
query = parseQueryString(window.location.search);
|
|
427
|
+
// Strip base prefix
|
|
428
|
+
if (this.base && path.startsWith(this.base)) {
|
|
429
|
+
path = path.slice(this.base.length) || '/';
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const matched = this._matchRoute(path);
|
|
434
|
+
|
|
435
|
+
if (matched) {
|
|
436
|
+
// Handle lazy routes
|
|
437
|
+
if (matched.component && matched.component.__lazy) {
|
|
438
|
+
this._loadLazyRoute(matched, query);
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
this._setRoute({ ...matched, query });
|
|
442
|
+
} else if (this._notFoundComponent) {
|
|
443
|
+
this._setRoute({ path, pattern: null, component: this._notFoundComponent, params: {}, query, meta: {} });
|
|
444
|
+
} else {
|
|
445
|
+
this._setRoute({ path, pattern: null, component: null, params: {}, query, meta: {} });
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Fire callbacks
|
|
449
|
+
for (const cb of this._onChangeCallbacks) {
|
|
450
|
+
cb(matched);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Run afterNavigate hooks
|
|
454
|
+
const currentRoute = this._route();
|
|
455
|
+
for (const hook of this._afterHooks) {
|
|
456
|
+
hook(currentRoute);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// ─── Lazy Loading ────────────────────────────────────────
|
|
461
|
+
_loadLazyRoute(matched, query) {
|
|
462
|
+
const lazyDef = matched.component;
|
|
463
|
+
|
|
464
|
+
// If already cached, use it
|
|
465
|
+
if (lazyDef._cached) {
|
|
466
|
+
this._setRoute({ ...matched, component: lazyDef._cached, query });
|
|
467
|
+
this._firePostNavigateHooks(matched);
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Show loading state
|
|
472
|
+
this._setIsLoading(true);
|
|
473
|
+
if (this.loadingComponent) {
|
|
474
|
+
this._setRoute({ ...matched, component: this.loadingComponent, query });
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const loadPromise = lazyDef.load();
|
|
478
|
+
loadPromise.then((module) => {
|
|
479
|
+
const component = module.default || module.Page || module;
|
|
480
|
+
lazyDef._cached = component;
|
|
481
|
+
this._setIsLoading(false);
|
|
482
|
+
this._setRoute({ ...matched, component, query });
|
|
483
|
+
this._firePostNavigateHooks(matched);
|
|
484
|
+
}).catch((err) => {
|
|
485
|
+
lazyDef._error = err;
|
|
486
|
+
this._setIsLoading(false);
|
|
487
|
+
if (this.errorComponent) {
|
|
488
|
+
this._setRoute({ ...matched, component: this.errorComponent, query });
|
|
489
|
+
} else {
|
|
490
|
+
if (typeof console !== 'undefined') {
|
|
491
|
+
console.error('Tova router: Failed to load lazy route:', err);
|
|
492
|
+
}
|
|
493
|
+
this._setRoute({ ...matched, component: null, query });
|
|
494
|
+
}
|
|
495
|
+
this._firePostNavigateHooks(matched);
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
_firePostNavigateHooks(matched) {
|
|
500
|
+
for (const cb of this._onChangeCallbacks) {
|
|
501
|
+
cb(matched);
|
|
502
|
+
}
|
|
503
|
+
const currentRoute = this._route();
|
|
504
|
+
for (const hook of this._afterHooks) {
|
|
505
|
+
hook(currentRoute);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// ─── Scroll Restoration ──────────────────────────────────
|
|
510
|
+
_saveScrollPosition() {
|
|
511
|
+
if (typeof window === 'undefined' || !window.location) return;
|
|
512
|
+
const key = window.location.pathname + window.location.search;
|
|
513
|
+
this._scrollPositions.set(key, { x: window.scrollX, y: window.scrollY });
|
|
514
|
+
// Cap stored positions to prevent memory leak
|
|
515
|
+
if (this._scrollPositions.size > 200) {
|
|
516
|
+
const firstKey = this._scrollPositions.keys().next().value;
|
|
517
|
+
this._scrollPositions.delete(firstKey);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
_restoreScrollPosition(path, isBack) {
|
|
522
|
+
if (typeof window === 'undefined') return;
|
|
523
|
+
if (this.scrollBehavior === 'none') return;
|
|
524
|
+
|
|
525
|
+
const raf = typeof requestAnimationFrame === 'function' ? requestAnimationFrame : (fn) => fn();
|
|
526
|
+
const scrollTo = typeof window.scrollTo === 'function' ? (x, y) => window.scrollTo(x, y) : () => {};
|
|
527
|
+
|
|
528
|
+
if (typeof this.scrollBehavior === 'function') {
|
|
529
|
+
const saved = this._scrollPositions.get(path) || null;
|
|
530
|
+
const result = this.scrollBehavior({ savedPosition: saved, to: path });
|
|
531
|
+
if (result) {
|
|
532
|
+
raf(() => scrollTo(result.x || 0, result.y || 0));
|
|
533
|
+
}
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// Default "auto" behavior
|
|
538
|
+
if (isBack) {
|
|
539
|
+
const saved = this._scrollPositions.get(path);
|
|
540
|
+
if (saved) {
|
|
541
|
+
raf(() => scrollTo(saved.x, saved.y));
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
// New navigation: scroll to top
|
|
546
|
+
raf(() => scrollTo(0, 0));
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// ─── Browser Listeners ───────────────────────────────────
|
|
550
|
+
_setupBrowserListeners() {
|
|
551
|
+
if (typeof window === 'undefined' || typeof window.addEventListener !== 'function') return;
|
|
552
|
+
|
|
553
|
+
this._popstateHandler = () => {
|
|
554
|
+
const from = this._route();
|
|
555
|
+
const toPath = window.location.pathname;
|
|
556
|
+
|
|
557
|
+
for (const hook of this._beforeHooks) {
|
|
558
|
+
const result = hook(from, toPath);
|
|
559
|
+
if (result === false) {
|
|
560
|
+
// Cancel: restore the previous URL without triggering another popstate
|
|
561
|
+
window.history.pushState({}, '', this.base + from.path);
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
this._handleRouteChange();
|
|
567
|
+
// Restore scroll on back/forward
|
|
568
|
+
let path = toPath;
|
|
569
|
+
if (this.base && path.startsWith(this.base)) {
|
|
570
|
+
path = path.slice(this.base.length) || '/';
|
|
571
|
+
}
|
|
572
|
+
this._restoreScrollPosition(path, true);
|
|
573
|
+
};
|
|
574
|
+
window.addEventListener('popstate', this._popstateHandler);
|
|
575
|
+
|
|
576
|
+
this._clickHandler = (e) => {
|
|
577
|
+
const link = e.target.closest('a[href]');
|
|
578
|
+
if (!link) return;
|
|
579
|
+
if (!link.href.startsWith(window.location.origin)) return;
|
|
580
|
+
if (link.target === '_blank') return;
|
|
581
|
+
if (typeof link.hasAttribute === 'function' && link.hasAttribute('download')) return;
|
|
582
|
+
if (typeof link.getAttribute === 'function') {
|
|
583
|
+
const rel = link.getAttribute('rel');
|
|
584
|
+
if (rel && rel.includes('external')) return;
|
|
585
|
+
}
|
|
586
|
+
// Skip if meta/ctrl/shift click (open in new tab)
|
|
587
|
+
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
|
588
|
+
|
|
589
|
+
e.preventDefault();
|
|
590
|
+
try {
|
|
591
|
+
const url = new URL(link.href);
|
|
592
|
+
let navPath = url.pathname;
|
|
593
|
+
// Strip base prefix for internal navigation
|
|
594
|
+
if (this.base && navPath.startsWith(this.base)) {
|
|
595
|
+
navPath = navPath.slice(this.base.length) || '/';
|
|
596
|
+
}
|
|
597
|
+
this.navigate(navPath + (url.search || '') + (url.hash || ''));
|
|
598
|
+
} catch (_) {
|
|
599
|
+
const href = typeof link.getAttribute === 'function' ? link.getAttribute('href') : link.href;
|
|
600
|
+
if (href && isValidPath(href)) this.navigate(href);
|
|
601
|
+
}
|
|
602
|
+
};
|
|
603
|
+
document.addEventListener('click', this._clickHandler);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// ─── History State Helper ────────────────────────────────
|
|
607
|
+
_pushState(path, options = {}) {
|
|
608
|
+
if (typeof window === 'undefined' || !window.history) return;
|
|
609
|
+
const fullUrl = this.base + (path.startsWith('/') ? path : '/' + path);
|
|
610
|
+
const state = { __tova_idx: ++this._historyIndex, ...(options.state || {}) };
|
|
611
|
+
if (options.replace) {
|
|
612
|
+
window.history.replaceState(state, '', fullUrl);
|
|
613
|
+
} else {
|
|
614
|
+
window.history.pushState(state, '', fullUrl);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
157
617
|
}
|
|
158
618
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
//
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
619
|
+
// ─── createRouter Factory ────────────────────────────────
|
|
620
|
+
export function createRouter(config) {
|
|
621
|
+
// Destroy previous router if exists
|
|
622
|
+
if (_activeRouter) {
|
|
623
|
+
_activeRouter.destroy();
|
|
624
|
+
}
|
|
625
|
+
return new TovRouter(config);
|
|
166
626
|
}
|
|
167
627
|
|
|
168
|
-
// ───
|
|
169
|
-
|
|
170
|
-
|
|
628
|
+
// ─── resetRouter (for testing) ───────────────────────────
|
|
629
|
+
export function resetRouter() {
|
|
630
|
+
if (_activeRouter) {
|
|
631
|
+
_activeRouter.destroy();
|
|
632
|
+
}
|
|
633
|
+
_activeRouter = null;
|
|
634
|
+
}
|
|
171
635
|
|
|
636
|
+
// ─── Router Component ────────────────────────────────────
|
|
172
637
|
export function Router() {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
638
|
+
return {
|
|
639
|
+
__tova: true,
|
|
640
|
+
tag: '__dynamic',
|
|
641
|
+
compute: () => {
|
|
642
|
+
const router = _activeRouter;
|
|
643
|
+
if (!router) return null;
|
|
644
|
+
const r = router._route();
|
|
645
|
+
if (r && r.component) {
|
|
646
|
+
return typeof r.component === 'function' ? r.component(r.params) : r.component;
|
|
647
|
+
}
|
|
648
|
+
return null;
|
|
649
|
+
},
|
|
650
|
+
props: {},
|
|
651
|
+
children: [],
|
|
652
|
+
};
|
|
178
653
|
}
|
|
179
654
|
|
|
180
655
|
// ─── Outlet Component ────────────────────────────────────
|
|
181
|
-
// Renders the matched child route's component inside a parent layout.
|
|
182
|
-
// Usage: <Outlet /> inside a layout component
|
|
183
|
-
// Child route is stored as a signal for reactivity and isolation.
|
|
184
|
-
|
|
185
|
-
const [currentChildRoute, setCurrentChildRoute] = createSignal(null);
|
|
186
|
-
|
|
187
656
|
export function Outlet() {
|
|
188
|
-
const
|
|
657
|
+
const router = _activeRouter;
|
|
658
|
+
if (!router) return null;
|
|
659
|
+
const child = router._childRoute();
|
|
189
660
|
if (child && child.component) {
|
|
190
661
|
const comp = child.component;
|
|
191
662
|
const params = child.params || {};
|
|
@@ -194,197 +665,134 @@ export function Outlet() {
|
|
|
194
665
|
return null;
|
|
195
666
|
}
|
|
196
667
|
|
|
197
|
-
// ─── Link Component
|
|
198
|
-
|
|
199
|
-
|
|
668
|
+
// ─── Link Component ──────────────────────────────────────
|
|
669
|
+
export function Link({ href, children, activeClass, exactActiveClass, ...rest }) {
|
|
670
|
+
const router = _activeRouter;
|
|
671
|
+
const baseHref = router ? router.base + href : href;
|
|
200
672
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
href,
|
|
673
|
+
const props = {
|
|
674
|
+
href: baseHref,
|
|
204
675
|
onClick: (e) => {
|
|
205
676
|
e.preventDefault();
|
|
206
|
-
|
|
677
|
+
if (router) {
|
|
678
|
+
router.navigate(href);
|
|
679
|
+
}
|
|
207
680
|
},
|
|
208
681
|
...rest,
|
|
209
|
-
}
|
|
682
|
+
};
|
|
683
|
+
|
|
684
|
+
// Active class computation — returns a function for reactive binding
|
|
685
|
+
if ((activeClass || exactActiveClass) && router) {
|
|
686
|
+
const baseClass = rest.class || '';
|
|
687
|
+
props.class = () => {
|
|
688
|
+
const currentPath = router._route().path;
|
|
689
|
+
const isExact = currentPath === href;
|
|
690
|
+
const isPrefix = href === '/' ? isExact : (currentPath === href || currentPath.startsWith(href + '/'));
|
|
691
|
+
let cls = baseClass;
|
|
692
|
+
if (exactActiveClass && isExact) {
|
|
693
|
+
cls = cls ? cls + ' ' + exactActiveClass : exactActiveClass;
|
|
694
|
+
} else if (activeClass && isPrefix) {
|
|
695
|
+
cls = cls ? cls + ' ' + activeClass : activeClass;
|
|
696
|
+
}
|
|
697
|
+
return cls;
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
return tova_el('a', props, children || []);
|
|
210
702
|
}
|
|
211
703
|
|
|
212
704
|
// ─── Redirect Component ──────────────────────────────────
|
|
213
|
-
// Immediately navigates to a different path when rendered.
|
|
214
|
-
// Usage: <Redirect to="/login" />
|
|
215
|
-
// Loop protection: max 10 redirects in 1 second to prevent infinite loops.
|
|
216
|
-
|
|
217
|
-
let _redirectCount = 0;
|
|
218
|
-
let _redirectWindowStart = 0;
|
|
219
705
|
const _MAX_REDIRECTS = 10;
|
|
220
706
|
const _REDIRECT_WINDOW_MS = 1000;
|
|
221
707
|
|
|
222
708
|
export function Redirect({ to }) {
|
|
223
709
|
if (typeof window !== 'undefined') {
|
|
224
710
|
queueMicrotask(() => {
|
|
711
|
+
const router = _activeRouter;
|
|
712
|
+
if (!router) return;
|
|
225
713
|
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
226
|
-
if (now - _redirectWindowStart > _REDIRECT_WINDOW_MS) {
|
|
227
|
-
_redirectCount = 0;
|
|
228
|
-
_redirectWindowStart = now;
|
|
714
|
+
if (now - router._redirectWindowStart > _REDIRECT_WINDOW_MS) {
|
|
715
|
+
router._redirectCount = 0;
|
|
716
|
+
router._redirectWindowStart = now;
|
|
229
717
|
}
|
|
230
|
-
_redirectCount++;
|
|
231
|
-
if (_redirectCount > _MAX_REDIRECTS) {
|
|
232
|
-
console.error(
|
|
718
|
+
router._redirectCount++;
|
|
719
|
+
if (router._redirectCount > _MAX_REDIRECTS) {
|
|
720
|
+
console.error('Tova router: Redirect loop detected (>' + _MAX_REDIRECTS + ' redirects in ' + _REDIRECT_WINDOW_MS + 'ms). Aborting redirect to "' + to + '".');
|
|
233
721
|
return;
|
|
234
722
|
}
|
|
235
|
-
navigate(to);
|
|
723
|
+
router.navigate(to);
|
|
236
724
|
});
|
|
237
725
|
}
|
|
238
726
|
return null;
|
|
239
727
|
}
|
|
240
728
|
|
|
241
|
-
// ───
|
|
729
|
+
// ─── Backward-Compatible Module-Level API ────────────────
|
|
730
|
+
// These functions delegate to the active router instance.
|
|
731
|
+
// Existing code using defineRoutes/navigate/getPath/etc. continues working.
|
|
242
732
|
|
|
243
|
-
function
|
|
244
|
-
|
|
245
|
-
if (!search || search === '?') return query;
|
|
246
|
-
const str = search.startsWith('?') ? search.slice(1) : search;
|
|
247
|
-
for (const pair of str.split('&')) {
|
|
248
|
-
const [key, ...rest] = pair.split('=');
|
|
249
|
-
const value = rest.join('=');
|
|
250
|
-
if (key) {
|
|
251
|
-
query[decodeURIComponent(key)] = value !== undefined ? decodeURIComponent(value) : '';
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
return query;
|
|
733
|
+
export function defineRoutes(routeMap) {
|
|
734
|
+
createRouter({ routes: routeMap });
|
|
255
735
|
}
|
|
256
736
|
|
|
257
|
-
function
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
737
|
+
export function navigate(path, options) {
|
|
738
|
+
if (_activeRouter) {
|
|
739
|
+
_activeRouter.navigate(path, options);
|
|
740
|
+
} else {
|
|
741
|
+
// Auto-create a router if none exists
|
|
742
|
+
createRouter({});
|
|
743
|
+
_activeRouter.navigate(path, options);
|
|
263
744
|
}
|
|
745
|
+
}
|
|
264
746
|
|
|
265
|
-
|
|
747
|
+
export function getCurrentRoute() {
|
|
748
|
+
if (!_activeRouter) return () => ({ path: '/', params: {}, query: {}, meta: {} });
|
|
749
|
+
return _activeRouter._route;
|
|
750
|
+
}
|
|
266
751
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
}
|
|
752
|
+
export function getParams() {
|
|
753
|
+
return () => {
|
|
754
|
+
if (!_activeRouter) return {};
|
|
755
|
+
return _activeRouter._route().params;
|
|
756
|
+
};
|
|
757
|
+
}
|
|
274
758
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
759
|
+
export function getPath() {
|
|
760
|
+
return () => {
|
|
761
|
+
if (!_activeRouter) return '/';
|
|
762
|
+
return _activeRouter._route().path;
|
|
763
|
+
};
|
|
764
|
+
}
|
|
278
765
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
}
|
|
766
|
+
export function getQuery() {
|
|
767
|
+
return () => {
|
|
768
|
+
if (!_activeRouter) return {};
|
|
769
|
+
return _activeRouter._route().query;
|
|
770
|
+
};
|
|
284
771
|
}
|
|
285
772
|
|
|
286
|
-
function
|
|
287
|
-
|
|
288
|
-
if (
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
if (childMatch) {
|
|
292
|
-
const childParams = extractParams(child.path, childMatch);
|
|
293
|
-
setCurrentChildRoute({ component: child.component, params: childParams });
|
|
294
|
-
const parentMatch = def.pattern.exec(path);
|
|
295
|
-
const parentParams = extractParams(def.path, parentMatch || []);
|
|
296
|
-
return { path: def.path, component: def.component, params: { ...parentParams, ...childParams } };
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
const parentMatch = def.pattern.exec(path);
|
|
300
|
-
if (parentMatch) {
|
|
301
|
-
setCurrentChildRoute(null);
|
|
302
|
-
const params = extractParams(def.path, parentMatch);
|
|
303
|
-
return { path: def.path, component: def.component, params };
|
|
304
|
-
}
|
|
305
|
-
} else {
|
|
306
|
-
const match = def.pattern.exec(path);
|
|
307
|
-
if (match) {
|
|
308
|
-
setCurrentChildRoute(null);
|
|
309
|
-
const params = extractParams(def.path, match);
|
|
310
|
-
return { path: def.path, component: def.component, params };
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
setCurrentChildRoute(null);
|
|
315
|
-
return null;
|
|
773
|
+
export function getMeta() {
|
|
774
|
+
return () => {
|
|
775
|
+
if (!_activeRouter) return {};
|
|
776
|
+
return _activeRouter._route().meta;
|
|
777
|
+
};
|
|
316
778
|
}
|
|
317
779
|
|
|
318
|
-
function
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
// Handle catch-all: * becomes (.*)
|
|
322
|
-
const pattern = path
|
|
323
|
-
.replace(/:([a-zA-Z_]+)\?/g, '([^/]*)?') // optional params
|
|
324
|
-
.replace(/:([a-zA-Z_]+)/g, '([^/]+)') // required params
|
|
325
|
-
.replace(/\*/g, '(.*)'); // catch-all
|
|
326
|
-
// For parent routes with children, match as prefix
|
|
327
|
-
if (prefixMatch) {
|
|
328
|
-
return new RegExp('^' + pattern + '(?:/.*)?$');
|
|
329
|
-
}
|
|
330
|
-
return new RegExp('^' + pattern + '$');
|
|
780
|
+
export function onRouteChange(callback) {
|
|
781
|
+
if (_activeRouter) return _activeRouter.onRouteChange(callback);
|
|
782
|
+
return () => {};
|
|
331
783
|
}
|
|
332
784
|
|
|
333
|
-
function
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
// Match both required (:name) and optional (:name?) params
|
|
337
|
-
const paramNames = (routePath.match(/:([a-zA-Z_]+)\??/g) || [])
|
|
338
|
-
.map(p => p.replace(/^:/, '').replace(/\?$/, ''));
|
|
339
|
-
paramNames.forEach((name, index) => {
|
|
340
|
-
const val = match[index + 1];
|
|
341
|
-
if (val !== undefined && val !== '') {
|
|
342
|
-
params[name] = val;
|
|
343
|
-
}
|
|
344
|
-
});
|
|
345
|
-
return params;
|
|
785
|
+
export function beforeNavigate(callback) {
|
|
786
|
+
if (_activeRouter) return _activeRouter.beforeNavigate(callback);
|
|
787
|
+
return () => {};
|
|
346
788
|
}
|
|
347
789
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
const result = hook(from, toPath);
|
|
357
|
-
if (result === false) {
|
|
358
|
-
// Cancel: push the previous path back
|
|
359
|
-
window.history.pushState({}, '', from.path);
|
|
360
|
-
return;
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
handleRouteChange();
|
|
364
|
-
});
|
|
365
|
-
|
|
366
|
-
// Intercept link clicks for client-side navigation
|
|
367
|
-
document.addEventListener('click', (e) => {
|
|
368
|
-
const link = e.target.closest('a[href]');
|
|
369
|
-
if (!link) return;
|
|
370
|
-
// Use the resolved href for origin comparison (not raw attribute)
|
|
371
|
-
if (!link.href.startsWith(window.location.origin)) return;
|
|
372
|
-
// Skip links with target, download, or external rel
|
|
373
|
-
if (link.target === '_blank') return;
|
|
374
|
-
if (typeof link.hasAttribute === 'function' && link.hasAttribute('download')) return;
|
|
375
|
-
if (typeof link.getAttribute === 'function') {
|
|
376
|
-
const rel = link.getAttribute('rel');
|
|
377
|
-
if (rel && rel.includes('external')) return;
|
|
378
|
-
}
|
|
379
|
-
e.preventDefault();
|
|
380
|
-
// Use pathname from the resolved URL for safe navigation
|
|
381
|
-
try {
|
|
382
|
-
const url = new URL(link.href);
|
|
383
|
-
navigate(url.pathname + (url.search || '') + (url.hash || ''));
|
|
384
|
-
} catch (_) {
|
|
385
|
-
// Fallback for environments without URL constructor
|
|
386
|
-
const href = typeof link.getAttribute === 'function' ? link.getAttribute('href') : link.href;
|
|
387
|
-
if (href && isValidPath(href)) navigate(href);
|
|
388
|
-
}
|
|
389
|
-
});
|
|
790
|
+
export function afterNavigate(callback) {
|
|
791
|
+
if (_activeRouter) return _activeRouter.afterNavigate(callback);
|
|
792
|
+
return () => {};
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// ─── Utility Exports ─────────────────────────────────────
|
|
796
|
+
export function getRouter() {
|
|
797
|
+
return _activeRouter;
|
|
390
798
|
}
|