zero-query 1.1.1 → 1.2.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/LICENSE +21 -21
- package/README.md +2 -0
- package/cli/args.js +33 -33
- package/cli/commands/build-api.js +443 -442
- package/cli/commands/build.js +254 -247
- package/cli/commands/bundle.js +1228 -1224
- package/cli/commands/create.js +137 -121
- package/cli/commands/dev/devtools/index.js +56 -56
- package/cli/commands/dev/devtools/js/components.js +49 -49
- package/cli/commands/dev/devtools/js/core.js +423 -423
- package/cli/commands/dev/devtools/js/elements.js +421 -421
- package/cli/commands/dev/devtools/js/network.js +166 -166
- package/cli/commands/dev/devtools/js/performance.js +73 -73
- package/cli/commands/dev/devtools/js/router.js +105 -105
- package/cli/commands/dev/devtools/js/source.js +132 -132
- package/cli/commands/dev/devtools/js/stats.js +35 -35
- package/cli/commands/dev/devtools/js/tabs.js +79 -79
- package/cli/commands/dev/devtools/panel.html +95 -95
- package/cli/commands/dev/devtools/styles.css +244 -244
- package/cli/commands/dev/index.js +107 -107
- package/cli/commands/dev/logger.js +75 -75
- package/cli/commands/dev/overlay.js +858 -858
- package/cli/commands/dev/server.js +220 -220
- package/cli/commands/dev/validator.js +94 -94
- package/cli/commands/dev/watcher.js +172 -172
- package/cli/help.js +114 -112
- package/cli/index.js +52 -52
- package/cli/scaffold/default/LICENSE +21 -21
- package/cli/scaffold/default/app/app.js +207 -207
- package/cli/scaffold/default/app/components/about.js +201 -201
- package/cli/scaffold/default/app/components/api-demo.js +143 -143
- package/cli/scaffold/default/app/components/contact-card.js +231 -231
- package/cli/scaffold/default/app/components/contacts/contacts.css +706 -706
- package/cli/scaffold/default/app/components/contacts/contacts.html +200 -200
- package/cli/scaffold/default/app/components/contacts/contacts.js +196 -196
- package/cli/scaffold/default/app/components/counter.js +127 -127
- package/cli/scaffold/default/app/components/home.js +249 -249
- package/cli/scaffold/default/app/components/not-found.js +16 -16
- package/cli/scaffold/default/app/components/playground/playground.css +115 -115
- package/cli/scaffold/default/app/components/playground/playground.html +161 -161
- package/cli/scaffold/default/app/components/playground/playground.js +116 -116
- package/cli/scaffold/default/app/components/todos.js +225 -225
- package/cli/scaffold/default/app/components/toolkit/toolkit.css +97 -97
- package/cli/scaffold/default/app/components/toolkit/toolkit.html +146 -146
- package/cli/scaffold/default/app/components/toolkit/toolkit.js +280 -280
- package/cli/scaffold/default/app/routes.js +15 -15
- package/cli/scaffold/default/app/store.js +101 -101
- package/cli/scaffold/default/global.css +552 -552
- package/cli/scaffold/default/index.html +99 -99
- package/cli/scaffold/minimal/app/app.js +85 -85
- package/cli/scaffold/minimal/app/components/about.js +68 -68
- package/cli/scaffold/minimal/app/components/counter.js +122 -122
- package/cli/scaffold/minimal/app/components/home.js +68 -68
- package/cli/scaffold/minimal/app/components/not-found.js +16 -16
- package/cli/scaffold/minimal/app/routes.js +9 -9
- package/cli/scaffold/minimal/app/store.js +36 -36
- package/cli/scaffold/minimal/global.css +300 -300
- package/cli/scaffold/minimal/index.html +44 -44
- package/cli/scaffold/ssr/app/app.js +41 -41
- package/cli/scaffold/ssr/app/components/about.js +55 -55
- package/cli/scaffold/ssr/app/components/blog/index.js +65 -65
- package/cli/scaffold/ssr/app/components/blog/post.js +86 -86
- package/cli/scaffold/ssr/app/components/home.js +37 -37
- package/cli/scaffold/ssr/app/components/not-found.js +15 -15
- package/cli/scaffold/ssr/app/routes.js +8 -8
- package/cli/scaffold/ssr/global.css +228 -228
- package/cli/scaffold/ssr/index.html +37 -37
- package/cli/scaffold/ssr/package.json +8 -8
- package/cli/scaffold/ssr/server/data/posts.js +144 -144
- package/cli/scaffold/ssr/server/index.js +213 -213
- package/cli/scaffold/webrtc/app/app.js +11 -0
- package/cli/scaffold/webrtc/app/components/video-room.js +295 -0
- package/cli/scaffold/webrtc/app/lib/room.js +252 -0
- package/cli/scaffold/webrtc/assets/.gitkeep +0 -0
- package/cli/scaffold/webrtc/global.css +250 -0
- package/cli/scaffold/webrtc/index.html +21 -0
- package/cli/utils.js +305 -287
- package/dist/API.md +661 -0
- package/dist/zquery.dist.zip +0 -0
- package/dist/zquery.js +10313 -6614
- package/dist/zquery.min.js +8 -631
- package/index.d.ts +570 -371
- package/index.js +311 -240
- package/package.json +76 -70
- package/src/component.js +1709 -1691
- package/src/core.js +921 -921
- package/src/diff.js +497 -497
- package/src/errors.js +209 -209
- package/src/expression.js +922 -922
- package/src/http.js +242 -242
- package/src/package.json +1 -1
- package/src/reactive.js +255 -255
- package/src/router.js +843 -843
- package/src/ssr.js +418 -418
- package/src/store.js +318 -318
- package/src/utils.js +515 -515
- package/src/webrtc/e2ee.js +351 -0
- package/src/webrtc/errors.js +116 -0
- package/src/webrtc/ice.js +301 -0
- package/src/webrtc/index.js +131 -0
- package/src/webrtc/joinToken.js +119 -0
- package/src/webrtc/observe.js +172 -0
- package/src/webrtc/peer.js +351 -0
- package/src/webrtc/reactive.js +268 -0
- package/src/webrtc/room.js +625 -0
- package/src/webrtc/sdp.js +302 -0
- package/src/webrtc/sfu/index.js +43 -0
- package/src/webrtc/sfu/livekit.js +131 -0
- package/src/webrtc/sfu/mediasoup.js +150 -0
- package/src/webrtc/signaling.js +373 -0
- package/src/webrtc/turn.js +237 -0
- package/tests/_helpers/webrtcFakes.js +289 -0
- package/tests/audit.test.js +4158 -4158
- package/tests/cli.test.js +1136 -1103
- package/tests/compare.test.js +497 -486
- package/tests/component.test.js +3969 -3938
- package/tests/core.test.js +1910 -1910
- package/tests/dev-server.test.js +489 -489
- package/tests/diff.test.js +1416 -1416
- package/tests/docs.test.js +1664 -1650
- package/tests/electron-features.test.js +864 -864
- package/tests/errors.test.js +619 -619
- package/tests/expression.test.js +1056 -1056
- package/tests/http.test.js +648 -648
- package/tests/reactive.test.js +819 -819
- package/tests/router.test.js +2327 -2327
- package/tests/ssr.test.js +870 -870
- package/tests/store.test.js +830 -830
- package/tests/test-minifier.js +153 -153
- package/tests/test-ssr.js +27 -27
- package/tests/utils.test.js +1377 -1377
- package/tests/webrtc/e2ee.test.js +283 -0
- package/tests/webrtc/ice.test.js +202 -0
- package/tests/webrtc/joinToken.test.js +89 -0
- package/tests/webrtc/observe.test.js +111 -0
- package/tests/webrtc/peer.test.js +373 -0
- package/tests/webrtc/reactive.test.js +235 -0
- package/tests/webrtc/room.test.js +406 -0
- package/tests/webrtc/sdp.test.js +151 -0
- package/tests/webrtc/sfu-livekit.test.js +119 -0
- package/tests/webrtc/sfu.test.js +160 -0
- package/tests/webrtc/signaling.test.js +251 -0
- package/tests/webrtc/turn.test.js +256 -0
- package/types/collection.d.ts +383 -383
- package/types/component.d.ts +186 -186
- package/types/errors.d.ts +135 -135
- package/types/http.d.ts +92 -92
- package/types/misc.d.ts +201 -201
- package/types/reactive.d.ts +98 -98
- package/types/router.d.ts +190 -190
- package/types/ssr.d.ts +102 -102
- package/types/store.d.ts +146 -146
- package/types/utils.d.ts +245 -245
- package/types/webrtc.d.ts +653 -0
package/src/router.js
CHANGED
|
@@ -1,843 +1,843 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* zQuery Router - Client-side SPA router
|
|
3
|
-
*
|
|
4
|
-
* Supports hash mode (#/path) and history mode (/path).
|
|
5
|
-
* Route params, query strings, navigation guards, and lazy loading.
|
|
6
|
-
* Sub-route history substates for in-page UI changes (modals, tabs, etc.).
|
|
7
|
-
*
|
|
8
|
-
* Usage:
|
|
9
|
-
* // HTML: <z-outlet></z-outlet>
|
|
10
|
-
* $.router({
|
|
11
|
-
* routes: [
|
|
12
|
-
* { path: '/', component: 'home-page' },
|
|
13
|
-
* { path: '/user/:id', component: 'user-profile' },
|
|
14
|
-
* { path: '/lazy', load: () => import('./pages/lazy.js'), component: 'lazy-page' },
|
|
15
|
-
* ],
|
|
16
|
-
* fallback: 'not-found'
|
|
17
|
-
* });
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
import { mount, destroy, prefetch } from './component.js';
|
|
21
|
-
import { reportError, ErrorCode } from './errors.js';
|
|
22
|
-
|
|
23
|
-
// Unique marker on history.state to identify zQuery-managed entries
|
|
24
|
-
const _ZQ_STATE_KEY = '__zq';
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Shallow-compare two flat objects (for params / query comparison).
|
|
28
|
-
* Avoids JSON.stringify overhead on every navigation.
|
|
29
|
-
*/
|
|
30
|
-
function _shallowEqual(a, b) {
|
|
31
|
-
if (a === b) return true;
|
|
32
|
-
if (!a || !b) return false;
|
|
33
|
-
const keysA = Object.keys(a);
|
|
34
|
-
const keysB = Object.keys(b);
|
|
35
|
-
if (keysA.length !== keysB.length) return false;
|
|
36
|
-
for (let i = 0; i < keysA.length; i++) {
|
|
37
|
-
const k = keysA[i];
|
|
38
|
-
if (a[k] !== b[k]) return false;
|
|
39
|
-
}
|
|
40
|
-
return true;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
class Router {
|
|
44
|
-
constructor(config = {}) {
|
|
45
|
-
this._el = null;
|
|
46
|
-
// file:// protocol can't use pushState - always force hash mode
|
|
47
|
-
const isFile = typeof location !== 'undefined' && location.protocol === 'file:';
|
|
48
|
-
this._mode = isFile ? 'hash' : (config.mode || 'history');
|
|
49
|
-
|
|
50
|
-
// Keep-alive cache: component name → { container, instance }
|
|
51
|
-
this._keepAliveCache = new Map();
|
|
52
|
-
|
|
53
|
-
// Base path for sub-path deployments
|
|
54
|
-
// Priority: explicit config.base → window.__ZQ_BASE → <base href> tag
|
|
55
|
-
let rawBase = config.base;
|
|
56
|
-
if (rawBase == null) {
|
|
57
|
-
rawBase = (typeof window !== 'undefined' && window.__ZQ_BASE) || '';
|
|
58
|
-
if (!rawBase && typeof document !== 'undefined') {
|
|
59
|
-
const baseEl = document.querySelector('base');
|
|
60
|
-
if (baseEl) {
|
|
61
|
-
try { rawBase = new URL(baseEl.href).pathname; }
|
|
62
|
-
catch { rawBase = baseEl.getAttribute('href') || ''; }
|
|
63
|
-
if (rawBase === '/') rawBase = ''; // root = no sub-path
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
// Normalize: ensure leading /, strip trailing /
|
|
68
|
-
this._base = String(rawBase).replace(/\/+$/, '');
|
|
69
|
-
if (this._base && !this._base.startsWith('/')) this._base = '/' + this._base;
|
|
70
|
-
|
|
71
|
-
this._routes = [];
|
|
72
|
-
this._fallback = config.fallback || null;
|
|
73
|
-
this._current = null; // { route, params, query, path }
|
|
74
|
-
this._guards = { before: [], after: [] };
|
|
75
|
-
this._listeners = new Set();
|
|
76
|
-
this._instance = null; // current mounted component
|
|
77
|
-
this._resolving = false; // re-entrancy guard
|
|
78
|
-
|
|
79
|
-
// Sub-route history substates
|
|
80
|
-
this._substateListeners = []; // [(key, data) => bool|void]
|
|
81
|
-
this._inSubstate = false; // true while substate entries are in the history stack
|
|
82
|
-
|
|
83
|
-
// Set outlet element
|
|
84
|
-
// Priority: explicit config.el → <z-outlet> tag in the DOM
|
|
85
|
-
if (config.el) {
|
|
86
|
-
this._el = typeof config.el === 'string' ? document.querySelector(config.el) : config.el;
|
|
87
|
-
} else if (typeof document !== 'undefined') {
|
|
88
|
-
const outlet = document.querySelector('z-outlet');
|
|
89
|
-
if (outlet) {
|
|
90
|
-
this._el = outlet;
|
|
91
|
-
// Read inline attribute overrides from <z-outlet> (config takes priority)
|
|
92
|
-
if (!config.fallback && outlet.getAttribute('fallback')) {
|
|
93
|
-
this._fallback = outlet.getAttribute('fallback');
|
|
94
|
-
}
|
|
95
|
-
if (!config.mode && outlet.getAttribute('mode')) {
|
|
96
|
-
const attrMode = outlet.getAttribute('mode');
|
|
97
|
-
if (attrMode === 'hash' || attrMode === 'history') {
|
|
98
|
-
this._mode = isFile ? 'hash' : attrMode;
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
if (config.base == null && outlet.getAttribute('base')) {
|
|
102
|
-
let ob = outlet.getAttribute('base');
|
|
103
|
-
ob = String(ob).replace(/\/+$/, '');
|
|
104
|
-
if (ob && !ob.startsWith('/')) ob = '/' + ob;
|
|
105
|
-
this._base = ob;
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// Register routes
|
|
111
|
-
if (config.routes) {
|
|
112
|
-
config.routes.forEach(r => this.add(r));
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// Listen for navigation - store handler references for cleanup in destroy()
|
|
116
|
-
if (this._mode === 'hash') {
|
|
117
|
-
this._onNavEvent = () => this._resolve();
|
|
118
|
-
window.addEventListener('hashchange', this._onNavEvent);
|
|
119
|
-
// Hash mode also needs popstate for substates (pushSubstate uses pushState)
|
|
120
|
-
this._onPopState = (e) => {
|
|
121
|
-
const st = e.state;
|
|
122
|
-
if (st && st[_ZQ_STATE_KEY] === 'substate') {
|
|
123
|
-
const handled = this._fireSubstate(st.key, st.data, 'pop');
|
|
124
|
-
if (handled) return;
|
|
125
|
-
this._resolve().then(() => {
|
|
126
|
-
this._fireSubstate(st.key, st.data, 'pop');
|
|
127
|
-
});
|
|
128
|
-
return;
|
|
129
|
-
} else if (this._inSubstate) {
|
|
130
|
-
this._inSubstate = false;
|
|
131
|
-
this._fireSubstate(null, null, 'reset');
|
|
132
|
-
}
|
|
133
|
-
};
|
|
134
|
-
window.addEventListener('popstate', this._onPopState);
|
|
135
|
-
} else {
|
|
136
|
-
this._onNavEvent = (e) => {
|
|
137
|
-
// Check for substate pop first - if a listener handles it, don't route
|
|
138
|
-
const st = e.state;
|
|
139
|
-
if (st && st[_ZQ_STATE_KEY] === 'substate') {
|
|
140
|
-
const handled = this._fireSubstate(st.key, st.data, 'pop');
|
|
141
|
-
if (handled) return;
|
|
142
|
-
// Unhandled substate — the owning component was likely destroyed
|
|
143
|
-
// (e.g. user navigated away then pressed back). Resolve the route
|
|
144
|
-
// first (which may mount a fresh component that registers a listener),
|
|
145
|
-
// then retry the substate so the new listener can restore the UI.
|
|
146
|
-
this._resolve().then(() => {
|
|
147
|
-
this._fireSubstate(st.key, st.data, 'pop');
|
|
148
|
-
});
|
|
149
|
-
return;
|
|
150
|
-
} else if (this._inSubstate) {
|
|
151
|
-
// Popped past all substates - notify listeners to reset to defaults
|
|
152
|
-
this._inSubstate = false;
|
|
153
|
-
this._fireSubstate(null, null, 'reset');
|
|
154
|
-
}
|
|
155
|
-
this._resolve();
|
|
156
|
-
};
|
|
157
|
-
window.addEventListener('popstate', this._onNavEvent);
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
// Intercept link clicks for SPA navigation
|
|
161
|
-
this._onLinkClick = (e) => {
|
|
162
|
-
// Don't intercept modified clicks (Ctrl/Cmd+click = new tab)
|
|
163
|
-
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
|
164
|
-
const link = e.target.closest('[z-link]');
|
|
165
|
-
if (!link) return;
|
|
166
|
-
if (link.getAttribute('target') === '_blank') return;
|
|
167
|
-
e.preventDefault();
|
|
168
|
-
let href = link.getAttribute('z-link');
|
|
169
|
-
// Reject absolute URLs and dangerous protocols — z-link is for internal routes only
|
|
170
|
-
if (href && /^[a-z][a-z0-9+.-]*:/i.test(href)) return;
|
|
171
|
-
// Support z-link-params for dynamic :param interpolation
|
|
172
|
-
const paramsAttr = link.getAttribute('z-link-params');
|
|
173
|
-
if (paramsAttr) {
|
|
174
|
-
try {
|
|
175
|
-
const params = JSON.parse(paramsAttr);
|
|
176
|
-
href = this._interpolateParams(href, params);
|
|
177
|
-
} catch (err) {
|
|
178
|
-
reportError(ErrorCode.ROUTER_RESOLVE, 'Malformed JSON in z-link-params', { href, paramsAttr }, err);
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
this.navigate(href);
|
|
182
|
-
// z-to-top modifier: scroll to top after navigation
|
|
183
|
-
if (link.hasAttribute('z-to-top')) {
|
|
184
|
-
const scrollBehavior = link.getAttribute('z-to-top') || 'instant';
|
|
185
|
-
window.scrollTo({ top: 0, behavior: scrollBehavior });
|
|
186
|
-
}
|
|
187
|
-
};
|
|
188
|
-
document.addEventListener('click', this._onLinkClick);
|
|
189
|
-
|
|
190
|
-
// Initial resolve
|
|
191
|
-
if (this._el) {
|
|
192
|
-
// Defer to allow all components to register
|
|
193
|
-
queueMicrotask(() => this._resolve());
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
// --- Route management ----------------------------------------------------
|
|
198
|
-
|
|
199
|
-
add(route) {
|
|
200
|
-
// Compile path pattern into regex
|
|
201
|
-
const { regex, keys } = compilePath(route.path);
|
|
202
|
-
this._routes.push({ ...route, _regex: regex, _keys: keys });
|
|
203
|
-
|
|
204
|
-
// Per-route fallback: register an alias path for the same component.
|
|
205
|
-
// e.g. { path: '/docs/:section', fallback: '/docs', component: 'docs-page' }
|
|
206
|
-
// When matched via fallback, missing params are undefined.
|
|
207
|
-
if (route.fallback) {
|
|
208
|
-
const fb = compilePath(route.fallback);
|
|
209
|
-
this._routes.push({ ...route, path: route.fallback, _regex: fb.regex, _keys: fb.keys });
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
return this;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
remove(path) {
|
|
216
|
-
this._routes = this._routes.filter(r => r.path !== path);
|
|
217
|
-
return this;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
// --- Navigation ----------------------------------------------------------
|
|
221
|
-
|
|
222
|
-
/**
|
|
223
|
-
* Interpolate :param placeholders in a path with the given values.
|
|
224
|
-
* @param {string} path - e.g. '/user/:id/posts/:pid'
|
|
225
|
-
* @param {Object} params - e.g. { id: 42, pid: 7 }
|
|
226
|
-
* @returns {string}
|
|
227
|
-
*/
|
|
228
|
-
_interpolateParams(path, params) {
|
|
229
|
-
if (!params || typeof params !== 'object') return path;
|
|
230
|
-
return path.replace(/:([\w]+)/g, (_, key) => {
|
|
231
|
-
const val = params[key];
|
|
232
|
-
return val != null ? encodeURIComponent(String(val)) : ':' + key;
|
|
233
|
-
});
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
/**
|
|
237
|
-
* Get the full current URL (path + hash) for same-URL detection.
|
|
238
|
-
* @returns {string}
|
|
239
|
-
*/
|
|
240
|
-
_currentURL() {
|
|
241
|
-
if (this._mode === 'hash') {
|
|
242
|
-
return window.location.hash.slice(1) || '/';
|
|
243
|
-
}
|
|
244
|
-
const pathname = window.location.pathname || '/';
|
|
245
|
-
const hash = window.location.hash || '';
|
|
246
|
-
return pathname + hash;
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
navigate(path, options = {}) {
|
|
250
|
-
// Interpolate :param placeholders if options.params is provided
|
|
251
|
-
if (options.params) path = this._interpolateParams(path, options.params);
|
|
252
|
-
// Separate hash fragment (e.g. /docs/getting-started#cli-bundler)
|
|
253
|
-
const [cleanPath, fragment] = (path || '').split('#');
|
|
254
|
-
let normalized = this._normalizePath(cleanPath);
|
|
255
|
-
const hash = fragment ? '#' + fragment : '';
|
|
256
|
-
if (this._mode === 'hash') {
|
|
257
|
-
// Hash mode uses the URL hash for routing, so a #fragment can't live
|
|
258
|
-
// in the URL. Store it as a scroll target for the destination component.
|
|
259
|
-
if (fragment) window.__zqScrollTarget = fragment;
|
|
260
|
-
const targetHash = '#' + normalized;
|
|
261
|
-
// Skip if already at this exact hash (prevents duplicate entries)
|
|
262
|
-
if (window.location.hash === targetHash && !options.force) return this;
|
|
263
|
-
window.location.hash = targetHash;
|
|
264
|
-
} else {
|
|
265
|
-
const targetURL = this._base + normalized + hash;
|
|
266
|
-
const currentURL = (window.location.pathname || '/') + (window.location.hash || '');
|
|
267
|
-
|
|
268
|
-
if (targetURL === currentURL && !options.force) {
|
|
269
|
-
// Same full URL (path + hash) - don't push duplicate entry.
|
|
270
|
-
// If only the hash changed to a fragment target, scroll to it.
|
|
271
|
-
if (fragment) {
|
|
272
|
-
const el = document.getElementById(fragment);
|
|
273
|
-
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
274
|
-
}
|
|
275
|
-
return this;
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
// Same route path but different hash fragment - use replaceState
|
|
279
|
-
// so back goes to the previous *route*, not the previous scroll position.
|
|
280
|
-
const targetPathOnly = this._base + normalized;
|
|
281
|
-
const currentPathOnly = window.location.pathname || '/';
|
|
282
|
-
if (targetPathOnly === currentPathOnly && hash && !options.force) {
|
|
283
|
-
window.history.replaceState(
|
|
284
|
-
{ ...options.state, [_ZQ_STATE_KEY]: 'route' },
|
|
285
|
-
'',
|
|
286
|
-
targetURL
|
|
287
|
-
);
|
|
288
|
-
// Scroll to the fragment target
|
|
289
|
-
if (fragment) {
|
|
290
|
-
const el = document.getElementById(fragment);
|
|
291
|
-
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
292
|
-
}
|
|
293
|
-
// Don't re-resolve - same route, just a hash change
|
|
294
|
-
return this;
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
window.history.pushState(
|
|
298
|
-
{ ...options.state, [_ZQ_STATE_KEY]: 'route' },
|
|
299
|
-
'',
|
|
300
|
-
targetURL
|
|
301
|
-
);
|
|
302
|
-
this._resolve();
|
|
303
|
-
}
|
|
304
|
-
return this;
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
replace(path, options = {}) {
|
|
308
|
-
// Interpolate :param placeholders if options.params is provided
|
|
309
|
-
if (options.params) path = this._interpolateParams(path, options.params);
|
|
310
|
-
const [cleanPath, fragment] = (path || '').split('#');
|
|
311
|
-
let normalized = this._normalizePath(cleanPath);
|
|
312
|
-
const hash = fragment ? '#' + fragment : '';
|
|
313
|
-
if (this._mode === 'hash') {
|
|
314
|
-
if (fragment) window.__zqScrollTarget = fragment;
|
|
315
|
-
window.location.replace('#' + normalized);
|
|
316
|
-
} else {
|
|
317
|
-
window.history.replaceState(
|
|
318
|
-
{ ...options.state, [_ZQ_STATE_KEY]: 'route' },
|
|
319
|
-
'',
|
|
320
|
-
this._base + normalized + hash
|
|
321
|
-
);
|
|
322
|
-
this._resolve();
|
|
323
|
-
}
|
|
324
|
-
return this;
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
/**
|
|
328
|
-
* Normalize an app-relative path and guard against double base-prefixing.
|
|
329
|
-
* @param {string} path - e.g. '/docs', 'docs', or '/app/docs' when base is '/app'
|
|
330
|
-
* @returns {string} - always starts with '/'
|
|
331
|
-
*/
|
|
332
|
-
_normalizePath(path) {
|
|
333
|
-
let p = path && path.startsWith('/') ? path : (path ? `/${path}` : '/');
|
|
334
|
-
// Strip base prefix if caller accidentally included it
|
|
335
|
-
if (this._base) {
|
|
336
|
-
if (p === this._base) return '/';
|
|
337
|
-
if (p.startsWith(this._base + '/')) p = p.slice(this._base.length) || '/';
|
|
338
|
-
}
|
|
339
|
-
return p;
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
/**
|
|
343
|
-
* Resolve an app-relative path to a full URL path (including base).
|
|
344
|
-
* Useful for programmatic link generation.
|
|
345
|
-
* @param {string} path
|
|
346
|
-
* @returns {string}
|
|
347
|
-
*/
|
|
348
|
-
resolve(path) {
|
|
349
|
-
const normalized = path && path.startsWith('/') ? path : (path ? `/${path}` : '/');
|
|
350
|
-
return this._base + normalized;
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
back() { window.history.back(); return this; }
|
|
354
|
-
forward() { window.history.forward(); return this; }
|
|
355
|
-
go(n) { window.history.go(n); return this; }
|
|
356
|
-
|
|
357
|
-
// --- Guards --------------------------------------------------------------
|
|
358
|
-
|
|
359
|
-
beforeEach(fn) {
|
|
360
|
-
this._guards.before.push(fn);
|
|
361
|
-
return this;
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
afterEach(fn) {
|
|
365
|
-
this._guards.after.push(fn);
|
|
366
|
-
return this;
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
// --- Events --------------------------------------------------------------
|
|
370
|
-
|
|
371
|
-
onChange(fn) {
|
|
372
|
-
this._listeners.add(fn);
|
|
373
|
-
return () => this._listeners.delete(fn);
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
// --- Sub-route history substates -----------------------------------------
|
|
377
|
-
|
|
378
|
-
/**
|
|
379
|
-
* Push a lightweight history entry for in-component UI state.
|
|
380
|
-
* The URL path does NOT change - only a history entry is added so the
|
|
381
|
-
* back button can undo the UI change (close modal, revert tab, etc.)
|
|
382
|
-
* before navigating away.
|
|
383
|
-
*
|
|
384
|
-
* @param {string} key - identifier (e.g. 'modal', 'tab', 'panel')
|
|
385
|
-
* @param {*} data - arbitrary state (serializable)
|
|
386
|
-
* @returns {Router}
|
|
387
|
-
*
|
|
388
|
-
* @example
|
|
389
|
-
* // Open a modal and push a substate
|
|
390
|
-
* router.pushSubstate('modal', { id: 'confirm-delete' });
|
|
391
|
-
* // User hits back → onSubstate fires → close the modal
|
|
392
|
-
*/
|
|
393
|
-
pushSubstate(key, data) {
|
|
394
|
-
this._inSubstate = true;
|
|
395
|
-
if (this._mode === 'hash') {
|
|
396
|
-
// Hash mode: stash the substate in a global - hashchange will check.
|
|
397
|
-
// We still push a history entry via a sentinel hash suffix.
|
|
398
|
-
const current = window.location.hash || '#/';
|
|
399
|
-
window.history.pushState(
|
|
400
|
-
{ [_ZQ_STATE_KEY]: 'substate', key, data },
|
|
401
|
-
'',
|
|
402
|
-
window.location.href
|
|
403
|
-
);
|
|
404
|
-
} else {
|
|
405
|
-
window.history.pushState(
|
|
406
|
-
{ [_ZQ_STATE_KEY]: 'substate', key, data },
|
|
407
|
-
'',
|
|
408
|
-
window.location.href // keep same URL
|
|
409
|
-
);
|
|
410
|
-
}
|
|
411
|
-
return this;
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
/**
|
|
415
|
-
* Register a listener for substate pops (back button on a substate entry).
|
|
416
|
-
* The callback receives `(key, data)` and should return `true` if it
|
|
417
|
-
* handled the pop (prevents route resolution). If no listener returns
|
|
418
|
-
* `true`, normal route resolution proceeds.
|
|
419
|
-
*
|
|
420
|
-
* @param {(key: string, data: any, action: string) => boolean|void} fn
|
|
421
|
-
* @returns {() => void} unsubscribe function
|
|
422
|
-
*
|
|
423
|
-
* @example
|
|
424
|
-
* const unsub = router.onSubstate((key, data) => {
|
|
425
|
-
* if (key === 'modal') { closeModal(); return true; }
|
|
426
|
-
* });
|
|
427
|
-
*/
|
|
428
|
-
onSubstate(fn) {
|
|
429
|
-
this._substateListeners.push(fn);
|
|
430
|
-
return () => {
|
|
431
|
-
this._substateListeners = this._substateListeners.filter(f => f !== fn);
|
|
432
|
-
};
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
/**
|
|
436
|
-
* Fire substate listeners. Returns true if any listener handled it.
|
|
437
|
-
* @private
|
|
438
|
-
*/
|
|
439
|
-
_fireSubstate(key, data, action) {
|
|
440
|
-
for (const fn of this._substateListeners) {
|
|
441
|
-
try {
|
|
442
|
-
if (fn(key, data, action) === true) return true;
|
|
443
|
-
} catch (err) {
|
|
444
|
-
reportError(ErrorCode.ROUTER_GUARD, 'onSubstate listener threw', { key, data }, err);
|
|
445
|
-
}
|
|
446
|
-
}
|
|
447
|
-
return false;
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
// --- Current state -------------------------------------------------------
|
|
451
|
-
|
|
452
|
-
get current() { return this._current; }
|
|
453
|
-
|
|
454
|
-
/** The detected or configured base path (read-only) */
|
|
455
|
-
get base() { return this._base; }
|
|
456
|
-
|
|
457
|
-
get path() {
|
|
458
|
-
if (this._mode === 'hash') {
|
|
459
|
-
const raw = window.location.hash.slice(1) || '/';
|
|
460
|
-
// If the hash doesn't start with '/', it's an in-page anchor
|
|
461
|
-
// (e.g. #some-heading), not a route. Treat it as a scroll target
|
|
462
|
-
// and resolve to the last known route (or '/').
|
|
463
|
-
if (raw && !raw.startsWith('/')) {
|
|
464
|
-
window.__zqScrollTarget = raw;
|
|
465
|
-
// Restore the route hash silently so the URL stays valid
|
|
466
|
-
const fallbackPath = (this._current && this._current.path) || '/';
|
|
467
|
-
window.location.replace('#' + fallbackPath);
|
|
468
|
-
return fallbackPath;
|
|
469
|
-
}
|
|
470
|
-
return raw;
|
|
471
|
-
}
|
|
472
|
-
let pathname = window.location.pathname || '/';
|
|
473
|
-
// Strip trailing slash for consistency (except root '/')
|
|
474
|
-
if (pathname.length > 1 && pathname.endsWith('/')) {
|
|
475
|
-
pathname = pathname.slice(0, -1);
|
|
476
|
-
}
|
|
477
|
-
if (this._base) {
|
|
478
|
-
// Exact match: /app
|
|
479
|
-
if (pathname === this._base) return '/';
|
|
480
|
-
// Prefix match with boundary: /app/page (but NOT /application)
|
|
481
|
-
if (pathname.startsWith(this._base + '/')) {
|
|
482
|
-
return pathname.slice(this._base.length) || '/';
|
|
483
|
-
}
|
|
484
|
-
}
|
|
485
|
-
return pathname;
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
get query() {
|
|
489
|
-
const search = this._mode === 'hash'
|
|
490
|
-
? (window.location.hash.split('?')[1] || '')
|
|
491
|
-
: window.location.search.slice(1);
|
|
492
|
-
return Object.fromEntries(new URLSearchParams(search));
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
// --- Internal resolve ----------------------------------------------------
|
|
496
|
-
|
|
497
|
-
async _resolve() {
|
|
498
|
-
// Prevent re-entrant calls (e.g. listener triggering navigation)
|
|
499
|
-
if (this._resolving) return;
|
|
500
|
-
this._resolving = true;
|
|
501
|
-
this._redirectCount = 0;
|
|
502
|
-
try {
|
|
503
|
-
await this.__resolve();
|
|
504
|
-
} finally {
|
|
505
|
-
this._resolving = false;
|
|
506
|
-
}
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
async __resolve() {
|
|
510
|
-
// Check if we're landing on a substate entry (e.g. page refresh on a
|
|
511
|
-
// substate bookmark, or hash-mode popstate). Fire listeners and bail
|
|
512
|
-
// if handled - the URL hasn't changed so there's no route to resolve.
|
|
513
|
-
const histState = window.history.state;
|
|
514
|
-
if (histState && histState[_ZQ_STATE_KEY] === 'substate') {
|
|
515
|
-
const handled = this._fireSubstate(histState.key, histState.data, 'resolve');
|
|
516
|
-
if (handled) return;
|
|
517
|
-
// No listener handled it - fall through to normal routing
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
const fullPath = this.path;
|
|
521
|
-
const [pathPart, queryString] = fullPath.split('?');
|
|
522
|
-
const path = pathPart || '/';
|
|
523
|
-
const query = Object.fromEntries(new URLSearchParams(queryString || ''));
|
|
524
|
-
|
|
525
|
-
// Match route
|
|
526
|
-
let matched = null;
|
|
527
|
-
let params = {};
|
|
528
|
-
for (const route of this._routes) {
|
|
529
|
-
const m = path.match(route._regex);
|
|
530
|
-
if (m) {
|
|
531
|
-
matched = route;
|
|
532
|
-
route._keys.forEach((key, i) => { params[key] = m[i + 1]; });
|
|
533
|
-
break;
|
|
534
|
-
}
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
// Fallback
|
|
538
|
-
if (!matched && this._fallback) {
|
|
539
|
-
matched = { component: this._fallback, path: '*', _keys: [], _regex: /.*/ };
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
if (!matched) return;
|
|
543
|
-
|
|
544
|
-
const to = { route: matched, params, query, path };
|
|
545
|
-
const from = this._current;
|
|
546
|
-
|
|
547
|
-
// Same-route optimization: if the resolved route is the same component
|
|
548
|
-
// with the same params, skip the full destroy/mount cycle and just
|
|
549
|
-
// update props. This prevents flashing and unnecessary DOM churn.
|
|
550
|
-
if (from && this._instance && matched.component === from.route.component) {
|
|
551
|
-
const sameParams = _shallowEqual(params, from.params);
|
|
552
|
-
const sameQuery = _shallowEqual(query, from.query);
|
|
553
|
-
if (sameParams && sameQuery) {
|
|
554
|
-
// Identical navigation - nothing to do
|
|
555
|
-
return;
|
|
556
|
-
}
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
// Run before guards
|
|
560
|
-
for (const guard of this._guards.before) {
|
|
561
|
-
try {
|
|
562
|
-
const result = await guard(to, from);
|
|
563
|
-
if (result === false) return; // Cancel
|
|
564
|
-
if (typeof result === 'string') { // Redirect
|
|
565
|
-
if (++this._redirectCount > 10) {
|
|
566
|
-
reportError(ErrorCode.ROUTER_GUARD, 'Too many guard redirects (possible loop)', { to }, null);
|
|
567
|
-
return;
|
|
568
|
-
}
|
|
569
|
-
// Update URL directly and re-resolve (avoids re-entrancy block)
|
|
570
|
-
const [rPath, rFrag] = result.split('#');
|
|
571
|
-
const rNorm = this._normalizePath(rPath || '/');
|
|
572
|
-
const rHash = rFrag ? '#' + rFrag : '';
|
|
573
|
-
if (this._mode === 'hash') {
|
|
574
|
-
if (rFrag) window.__zqScrollTarget = rFrag;
|
|
575
|
-
window.location.replace('#' + rNorm);
|
|
576
|
-
} else {
|
|
577
|
-
window.history.replaceState(
|
|
578
|
-
{ [_ZQ_STATE_KEY]: 'route' },
|
|
579
|
-
'',
|
|
580
|
-
this._base + rNorm + rHash
|
|
581
|
-
);
|
|
582
|
-
}
|
|
583
|
-
return this.__resolve();
|
|
584
|
-
}
|
|
585
|
-
} catch (err) {
|
|
586
|
-
reportError(ErrorCode.ROUTER_GUARD, 'Before-guard threw', { to, from }, err);
|
|
587
|
-
return;
|
|
588
|
-
}
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
// Lazy load module if needed
|
|
592
|
-
if (matched.load) {
|
|
593
|
-
try { await matched.load(); }
|
|
594
|
-
catch (err) {
|
|
595
|
-
reportError(ErrorCode.ROUTER_LOAD, `Failed to load module for route "${matched.path}"`, { path: matched.path }, err);
|
|
596
|
-
return;
|
|
597
|
-
}
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
this._current = to;
|
|
601
|
-
|
|
602
|
-
// Mount component into outlet
|
|
603
|
-
if (this._el && matched.component) {
|
|
604
|
-
// Pre-load external templates/styles so the mount renders synchronously
|
|
605
|
-
// (keeps old content visible during the fetch instead of showing blank)
|
|
606
|
-
if (typeof matched.component === 'string') {
|
|
607
|
-
await prefetch(matched.component);
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
const isKeepAlive = !!matched.keepAlive;
|
|
611
|
-
const componentName = typeof matched.component === 'string' ? matched.component : null;
|
|
612
|
-
|
|
613
|
-
// Deactivate previous keep-alive instance (hide instead of destroy)
|
|
614
|
-
if (this._instance && this._currentKeepAlive && this._currentComponentName) {
|
|
615
|
-
const cached = this._keepAliveCache.get(this._currentComponentName);
|
|
616
|
-
if (cached) {
|
|
617
|
-
cached.container.style.display = 'none';
|
|
618
|
-
// Call deactivated() lifecycle hook
|
|
619
|
-
if (cached.instance._def.deactivated) {
|
|
620
|
-
try { cached.instance._def.deactivated.call(cached.instance); }
|
|
621
|
-
catch (err) { reportError(ErrorCode.COMP_LIFECYCLE, `Component "${this._currentComponentName}" deactivated() threw`, { component: this._currentComponentName }, err); }
|
|
622
|
-
}
|
|
623
|
-
}
|
|
624
|
-
this._instance = null;
|
|
625
|
-
} else if (this._instance) {
|
|
626
|
-
// Destroy previous non-keepAlive instance
|
|
627
|
-
this._instance.destroy();
|
|
628
|
-
this._instance = null;
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
const _routeStart = typeof window !== 'undefined' && window.__zqRenderHook ? performance.now() : 0;
|
|
632
|
-
|
|
633
|
-
// Pass route params and query as props
|
|
634
|
-
const props = { ...params, $route: to, $query: query, $params: params };
|
|
635
|
-
|
|
636
|
-
// Keep-alive: reuse cached instance
|
|
637
|
-
if (isKeepAlive && componentName && this._keepAliveCache.has(componentName)) {
|
|
638
|
-
const cached = this._keepAliveCache.get(componentName);
|
|
639
|
-
// Hide all children, show the cached one
|
|
640
|
-
[...this._el.children].forEach(c => { c.style.display = 'none'; });
|
|
641
|
-
cached.container.style.display = '';
|
|
642
|
-
this._instance = cached.instance;
|
|
643
|
-
this._currentKeepAlive = true;
|
|
644
|
-
this._currentComponentName = componentName;
|
|
645
|
-
// Call activated() lifecycle hook
|
|
646
|
-
if (cached.instance._def.activated) {
|
|
647
|
-
try { cached.instance._def.activated.call(cached.instance); }
|
|
648
|
-
catch (err) { reportError(ErrorCode.COMP_LIFECYCLE, `Component "${componentName}" activated() threw`, { component: componentName }, err); }
|
|
649
|
-
}
|
|
650
|
-
if (_routeStart) window.__zqRenderHook(this._el, performance.now() - _routeStart, 'route', componentName);
|
|
651
|
-
}
|
|
652
|
-
// If component is a string (registered name), mount it
|
|
653
|
-
else if (componentName) {
|
|
654
|
-
// Hide all keep-alive cached children (don't destroy)
|
|
655
|
-
[...this._el.children].forEach(c => {
|
|
656
|
-
if (c.dataset.zqKeepAlive) {
|
|
657
|
-
c.style.display = 'none';
|
|
658
|
-
}
|
|
659
|
-
});
|
|
660
|
-
// Remove non-keep-alive children
|
|
661
|
-
[...this._el.children].forEach(c => {
|
|
662
|
-
if (!c.dataset.zqKeepAlive) c.remove();
|
|
663
|
-
});
|
|
664
|
-
|
|
665
|
-
const container = document.createElement(componentName);
|
|
666
|
-
if (isKeepAlive) container.dataset.zqKeepAlive = componentName;
|
|
667
|
-
this._el.appendChild(container);
|
|
668
|
-
try {
|
|
669
|
-
this._instance = mount(container, componentName, props);
|
|
670
|
-
} catch (err) {
|
|
671
|
-
reportError(ErrorCode.COMP_NOT_FOUND, `Failed to mount component for route "${matched.path}"`, { component: matched.component, path: matched.path }, err);
|
|
672
|
-
return;
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
if (isKeepAlive) {
|
|
676
|
-
this._keepAliveCache.set(componentName, { container, instance: this._instance });
|
|
677
|
-
// Call activated() on first mount
|
|
678
|
-
if (this._instance._def.activated) {
|
|
679
|
-
try { this._instance._def.activated.call(this._instance); }
|
|
680
|
-
catch (err) { reportError(ErrorCode.COMP_LIFECYCLE, `Component "${componentName}" activated() threw`, { component: componentName }, err); }
|
|
681
|
-
}
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
this._currentKeepAlive = isKeepAlive;
|
|
685
|
-
this._currentComponentName = componentName;
|
|
686
|
-
if (_routeStart) window.__zqRenderHook(this._el, performance.now() - _routeStart, 'route', componentName);
|
|
687
|
-
}
|
|
688
|
-
// If component is a render function
|
|
689
|
-
else if (typeof matched.component === 'function') {
|
|
690
|
-
// Clear non-keepAlive content
|
|
691
|
-
[...this._el.children].forEach(c => {
|
|
692
|
-
if (c.dataset.zqKeepAlive) c.style.display = 'none';
|
|
693
|
-
else c.remove();
|
|
694
|
-
});
|
|
695
|
-
const wrapper = document.createElement('div');
|
|
696
|
-
wrapper.innerHTML = matched.component(to);
|
|
697
|
-
while (wrapper.firstChild) this._el.appendChild(wrapper.firstChild);
|
|
698
|
-
this._currentKeepAlive = false;
|
|
699
|
-
this._currentComponentName = null;
|
|
700
|
-
if (_routeStart) window.__zqRenderHook(this._el, performance.now() - _routeStart, 'route', to);
|
|
701
|
-
}
|
|
702
|
-
}
|
|
703
|
-
|
|
704
|
-
// Update z-active-route elements
|
|
705
|
-
this._updateActiveRoutes(path);
|
|
706
|
-
|
|
707
|
-
// Run after guards
|
|
708
|
-
for (const guard of this._guards.after) {
|
|
709
|
-
await guard(to, from);
|
|
710
|
-
}
|
|
711
|
-
|
|
712
|
-
// Notify listeners
|
|
713
|
-
this._listeners.forEach(fn => fn(to, from));
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
// --- Active route class management ----------------------------------------
|
|
717
|
-
|
|
718
|
-
/**
|
|
719
|
-
* Update all elements with z-active-route to toggle their active class
|
|
720
|
-
* based on the current path.
|
|
721
|
-
*
|
|
722
|
-
* Usage:
|
|
723
|
-
* <a z-link="/docs" z-active-route="/docs">Docs</a>
|
|
724
|
-
* <a z-link="/about" z-active-route="/about" z-active-class="selected">About</a>
|
|
725
|
-
* <a z-link="/" z-active-route="/" z-active-exact>Home</a>
|
|
726
|
-
*/
|
|
727
|
-
_updateActiveRoutes(currentPath) {
|
|
728
|
-
if (typeof document === 'undefined') return;
|
|
729
|
-
const els = document.querySelectorAll('[z-active-route]');
|
|
730
|
-
for (let i = 0; i < els.length; i++) {
|
|
731
|
-
const el = els[i];
|
|
732
|
-
const route = el.getAttribute('z-active-route');
|
|
733
|
-
const cls = el.getAttribute('z-active-class') || 'active';
|
|
734
|
-
const exact = el.hasAttribute('z-active-exact');
|
|
735
|
-
const isActive = exact
|
|
736
|
-
? currentPath === route
|
|
737
|
-
: (route === '/' ? currentPath === '/' : currentPath.startsWith(route));
|
|
738
|
-
el.classList.toggle(cls, isActive);
|
|
739
|
-
}
|
|
740
|
-
}
|
|
741
|
-
|
|
742
|
-
// --- Destroy -------------------------------------------------------------
|
|
743
|
-
|
|
744
|
-
destroy() {
|
|
745
|
-
// Remove window/document event listeners to prevent memory leaks
|
|
746
|
-
if (this._onNavEvent) {
|
|
747
|
-
window.removeEventListener(this._mode === 'hash' ? 'hashchange' : 'popstate', this._onNavEvent);
|
|
748
|
-
this._onNavEvent = null;
|
|
749
|
-
}
|
|
750
|
-
if (this._onPopState) {
|
|
751
|
-
window.removeEventListener('popstate', this._onPopState);
|
|
752
|
-
this._onPopState = null;
|
|
753
|
-
}
|
|
754
|
-
if (this._onLinkClick) {
|
|
755
|
-
document.removeEventListener('click', this._onLinkClick);
|
|
756
|
-
this._onLinkClick = null;
|
|
757
|
-
}
|
|
758
|
-
// Destroy all keep-alive cached instances
|
|
759
|
-
for (const [, cached] of this._keepAliveCache) {
|
|
760
|
-
cached.instance.destroy();
|
|
761
|
-
}
|
|
762
|
-
this._keepAliveCache.clear();
|
|
763
|
-
if (this._instance) this._instance.destroy();
|
|
764
|
-
this._listeners.clear();
|
|
765
|
-
this._substateListeners = [];
|
|
766
|
-
this._inSubstate = false;
|
|
767
|
-
this._routes = [];
|
|
768
|
-
this._guards = { before: [], after: [] };
|
|
769
|
-
}
|
|
770
|
-
}
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
// ---------------------------------------------------------------------------
|
|
774
|
-
// Path compilation (shared by Router.add and matchRoute)
|
|
775
|
-
// ---------------------------------------------------------------------------
|
|
776
|
-
|
|
777
|
-
/**
|
|
778
|
-
* Compile a route path pattern into a RegExp and param key list.
|
|
779
|
-
* Supports `:param` segments and `*` wildcard.
|
|
780
|
-
* @param {string} path - e.g. '/user/:id' or '/files/*'
|
|
781
|
-
* @returns {{ regex: RegExp, keys: string[] }}
|
|
782
|
-
*/
|
|
783
|
-
function compilePath(path) {
|
|
784
|
-
const keys = [];
|
|
785
|
-
const pattern = path
|
|
786
|
-
.replace(/:(\w+)/g, (_, key) => { keys.push(key); return '([^/]+)'; })
|
|
787
|
-
.replace(/\*/g, '(.*)');
|
|
788
|
-
return { regex: new RegExp(`^${pattern}$`), keys };
|
|
789
|
-
}
|
|
790
|
-
|
|
791
|
-
// ---------------------------------------------------------------------------
|
|
792
|
-
// Standalone route matcher (DOM-free — usable on server and client)
|
|
793
|
-
// ---------------------------------------------------------------------------
|
|
794
|
-
|
|
795
|
-
/**
|
|
796
|
-
* Match a pathname against an array of route definitions.
|
|
797
|
-
* Returns `{ component, params }`. If no route matches, falls back to the
|
|
798
|
-
* `fallback` component name (default `'not-found'`).
|
|
799
|
-
*
|
|
800
|
-
* This is the same matching logic the client-side router uses internally,
|
|
801
|
-
* extracted so SSR servers can resolve URLs without the DOM.
|
|
802
|
-
*
|
|
803
|
-
* @param {Array<{ path: string, component: string, fallback?: string }>} routes
|
|
804
|
-
* @param {string} pathname - URL path to match, e.g. '/blog/my-post'
|
|
805
|
-
* @param {string} [fallback='not-found'] - Component name when nothing matches
|
|
806
|
-
* @returns {{ component: string, params: Record<string, string> }}
|
|
807
|
-
*/
|
|
808
|
-
export function matchRoute(routes, pathname, fallback = 'not-found') {
|
|
809
|
-
for (const route of routes) {
|
|
810
|
-
const { regex, keys } = compilePath(route.path);
|
|
811
|
-
const m = pathname.match(regex);
|
|
812
|
-
if (m) {
|
|
813
|
-
const params = {};
|
|
814
|
-
keys.forEach((key, i) => { params[key] = m[i + 1]; });
|
|
815
|
-
return { component: route.component, params };
|
|
816
|
-
}
|
|
817
|
-
// Per-route fallback alias (same as Router.add)
|
|
818
|
-
if (route.fallback) {
|
|
819
|
-
const fb = compilePath(route.fallback);
|
|
820
|
-
const fbm = pathname.match(fb.regex);
|
|
821
|
-
if (fbm) {
|
|
822
|
-
const params = {};
|
|
823
|
-
fb.keys.forEach((key, i) => { params[key] = fbm[i + 1]; });
|
|
824
|
-
return { component: route.component, params };
|
|
825
|
-
}
|
|
826
|
-
}
|
|
827
|
-
}
|
|
828
|
-
return { component: fallback, params: {} };
|
|
829
|
-
}
|
|
830
|
-
|
|
831
|
-
// ---------------------------------------------------------------------------
|
|
832
|
-
// Factory
|
|
833
|
-
// ---------------------------------------------------------------------------
|
|
834
|
-
let _activeRouter = null;
|
|
835
|
-
|
|
836
|
-
export function createRouter(config) {
|
|
837
|
-
_activeRouter = new Router(config);
|
|
838
|
-
return _activeRouter;
|
|
839
|
-
}
|
|
840
|
-
|
|
841
|
-
export function getRouter() {
|
|
842
|
-
return _activeRouter;
|
|
843
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* zQuery Router - Client-side SPA router
|
|
3
|
+
*
|
|
4
|
+
* Supports hash mode (#/path) and history mode (/path).
|
|
5
|
+
* Route params, query strings, navigation guards, and lazy loading.
|
|
6
|
+
* Sub-route history substates for in-page UI changes (modals, tabs, etc.).
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* // HTML: <z-outlet></z-outlet>
|
|
10
|
+
* $.router({
|
|
11
|
+
* routes: [
|
|
12
|
+
* { path: '/', component: 'home-page' },
|
|
13
|
+
* { path: '/user/:id', component: 'user-profile' },
|
|
14
|
+
* { path: '/lazy', load: () => import('./pages/lazy.js'), component: 'lazy-page' },
|
|
15
|
+
* ],
|
|
16
|
+
* fallback: 'not-found'
|
|
17
|
+
* });
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { mount, destroy, prefetch } from './component.js';
|
|
21
|
+
import { reportError, ErrorCode } from './errors.js';
|
|
22
|
+
|
|
23
|
+
// Unique marker on history.state to identify zQuery-managed entries
|
|
24
|
+
const _ZQ_STATE_KEY = '__zq';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Shallow-compare two flat objects (for params / query comparison).
|
|
28
|
+
* Avoids JSON.stringify overhead on every navigation.
|
|
29
|
+
*/
|
|
30
|
+
function _shallowEqual(a, b) {
|
|
31
|
+
if (a === b) return true;
|
|
32
|
+
if (!a || !b) return false;
|
|
33
|
+
const keysA = Object.keys(a);
|
|
34
|
+
const keysB = Object.keys(b);
|
|
35
|
+
if (keysA.length !== keysB.length) return false;
|
|
36
|
+
for (let i = 0; i < keysA.length; i++) {
|
|
37
|
+
const k = keysA[i];
|
|
38
|
+
if (a[k] !== b[k]) return false;
|
|
39
|
+
}
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
class Router {
|
|
44
|
+
constructor(config = {}) {
|
|
45
|
+
this._el = null;
|
|
46
|
+
// file:// protocol can't use pushState - always force hash mode
|
|
47
|
+
const isFile = typeof location !== 'undefined' && location.protocol === 'file:';
|
|
48
|
+
this._mode = isFile ? 'hash' : (config.mode || 'history');
|
|
49
|
+
|
|
50
|
+
// Keep-alive cache: component name → { container, instance }
|
|
51
|
+
this._keepAliveCache = new Map();
|
|
52
|
+
|
|
53
|
+
// Base path for sub-path deployments
|
|
54
|
+
// Priority: explicit config.base → window.__ZQ_BASE → <base href> tag
|
|
55
|
+
let rawBase = config.base;
|
|
56
|
+
if (rawBase == null) {
|
|
57
|
+
rawBase = (typeof window !== 'undefined' && window.__ZQ_BASE) || '';
|
|
58
|
+
if (!rawBase && typeof document !== 'undefined') {
|
|
59
|
+
const baseEl = document.querySelector('base');
|
|
60
|
+
if (baseEl) {
|
|
61
|
+
try { rawBase = new URL(baseEl.href).pathname; }
|
|
62
|
+
catch { rawBase = baseEl.getAttribute('href') || ''; }
|
|
63
|
+
if (rawBase === '/') rawBase = ''; // root = no sub-path
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
// Normalize: ensure leading /, strip trailing /
|
|
68
|
+
this._base = String(rawBase).replace(/\/+$/, '');
|
|
69
|
+
if (this._base && !this._base.startsWith('/')) this._base = '/' + this._base;
|
|
70
|
+
|
|
71
|
+
this._routes = [];
|
|
72
|
+
this._fallback = config.fallback || null;
|
|
73
|
+
this._current = null; // { route, params, query, path }
|
|
74
|
+
this._guards = { before: [], after: [] };
|
|
75
|
+
this._listeners = new Set();
|
|
76
|
+
this._instance = null; // current mounted component
|
|
77
|
+
this._resolving = false; // re-entrancy guard
|
|
78
|
+
|
|
79
|
+
// Sub-route history substates
|
|
80
|
+
this._substateListeners = []; // [(key, data) => bool|void]
|
|
81
|
+
this._inSubstate = false; // true while substate entries are in the history stack
|
|
82
|
+
|
|
83
|
+
// Set outlet element
|
|
84
|
+
// Priority: explicit config.el → <z-outlet> tag in the DOM
|
|
85
|
+
if (config.el) {
|
|
86
|
+
this._el = typeof config.el === 'string' ? document.querySelector(config.el) : config.el;
|
|
87
|
+
} else if (typeof document !== 'undefined') {
|
|
88
|
+
const outlet = document.querySelector('z-outlet');
|
|
89
|
+
if (outlet) {
|
|
90
|
+
this._el = outlet;
|
|
91
|
+
// Read inline attribute overrides from <z-outlet> (config takes priority)
|
|
92
|
+
if (!config.fallback && outlet.getAttribute('fallback')) {
|
|
93
|
+
this._fallback = outlet.getAttribute('fallback');
|
|
94
|
+
}
|
|
95
|
+
if (!config.mode && outlet.getAttribute('mode')) {
|
|
96
|
+
const attrMode = outlet.getAttribute('mode');
|
|
97
|
+
if (attrMode === 'hash' || attrMode === 'history') {
|
|
98
|
+
this._mode = isFile ? 'hash' : attrMode;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (config.base == null && outlet.getAttribute('base')) {
|
|
102
|
+
let ob = outlet.getAttribute('base');
|
|
103
|
+
ob = String(ob).replace(/\/+$/, '');
|
|
104
|
+
if (ob && !ob.startsWith('/')) ob = '/' + ob;
|
|
105
|
+
this._base = ob;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Register routes
|
|
111
|
+
if (config.routes) {
|
|
112
|
+
config.routes.forEach(r => this.add(r));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Listen for navigation - store handler references for cleanup in destroy()
|
|
116
|
+
if (this._mode === 'hash') {
|
|
117
|
+
this._onNavEvent = () => this._resolve();
|
|
118
|
+
window.addEventListener('hashchange', this._onNavEvent);
|
|
119
|
+
// Hash mode also needs popstate for substates (pushSubstate uses pushState)
|
|
120
|
+
this._onPopState = (e) => {
|
|
121
|
+
const st = e.state;
|
|
122
|
+
if (st && st[_ZQ_STATE_KEY] === 'substate') {
|
|
123
|
+
const handled = this._fireSubstate(st.key, st.data, 'pop');
|
|
124
|
+
if (handled) return;
|
|
125
|
+
this._resolve().then(() => {
|
|
126
|
+
this._fireSubstate(st.key, st.data, 'pop');
|
|
127
|
+
});
|
|
128
|
+
return;
|
|
129
|
+
} else if (this._inSubstate) {
|
|
130
|
+
this._inSubstate = false;
|
|
131
|
+
this._fireSubstate(null, null, 'reset');
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
window.addEventListener('popstate', this._onPopState);
|
|
135
|
+
} else {
|
|
136
|
+
this._onNavEvent = (e) => {
|
|
137
|
+
// Check for substate pop first - if a listener handles it, don't route
|
|
138
|
+
const st = e.state;
|
|
139
|
+
if (st && st[_ZQ_STATE_KEY] === 'substate') {
|
|
140
|
+
const handled = this._fireSubstate(st.key, st.data, 'pop');
|
|
141
|
+
if (handled) return;
|
|
142
|
+
// Unhandled substate — the owning component was likely destroyed
|
|
143
|
+
// (e.g. user navigated away then pressed back). Resolve the route
|
|
144
|
+
// first (which may mount a fresh component that registers a listener),
|
|
145
|
+
// then retry the substate so the new listener can restore the UI.
|
|
146
|
+
this._resolve().then(() => {
|
|
147
|
+
this._fireSubstate(st.key, st.data, 'pop');
|
|
148
|
+
});
|
|
149
|
+
return;
|
|
150
|
+
} else if (this._inSubstate) {
|
|
151
|
+
// Popped past all substates - notify listeners to reset to defaults
|
|
152
|
+
this._inSubstate = false;
|
|
153
|
+
this._fireSubstate(null, null, 'reset');
|
|
154
|
+
}
|
|
155
|
+
this._resolve();
|
|
156
|
+
};
|
|
157
|
+
window.addEventListener('popstate', this._onNavEvent);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Intercept link clicks for SPA navigation
|
|
161
|
+
this._onLinkClick = (e) => {
|
|
162
|
+
// Don't intercept modified clicks (Ctrl/Cmd+click = new tab)
|
|
163
|
+
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
|
164
|
+
const link = e.target.closest('[z-link]');
|
|
165
|
+
if (!link) return;
|
|
166
|
+
if (link.getAttribute('target') === '_blank') return;
|
|
167
|
+
e.preventDefault();
|
|
168
|
+
let href = link.getAttribute('z-link');
|
|
169
|
+
// Reject absolute URLs and dangerous protocols — z-link is for internal routes only
|
|
170
|
+
if (href && /^[a-z][a-z0-9+.-]*:/i.test(href)) return;
|
|
171
|
+
// Support z-link-params for dynamic :param interpolation
|
|
172
|
+
const paramsAttr = link.getAttribute('z-link-params');
|
|
173
|
+
if (paramsAttr) {
|
|
174
|
+
try {
|
|
175
|
+
const params = JSON.parse(paramsAttr);
|
|
176
|
+
href = this._interpolateParams(href, params);
|
|
177
|
+
} catch (err) {
|
|
178
|
+
reportError(ErrorCode.ROUTER_RESOLVE, 'Malformed JSON in z-link-params', { href, paramsAttr }, err);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
this.navigate(href);
|
|
182
|
+
// z-to-top modifier: scroll to top after navigation
|
|
183
|
+
if (link.hasAttribute('z-to-top')) {
|
|
184
|
+
const scrollBehavior = link.getAttribute('z-to-top') || 'instant';
|
|
185
|
+
window.scrollTo({ top: 0, behavior: scrollBehavior });
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
document.addEventListener('click', this._onLinkClick);
|
|
189
|
+
|
|
190
|
+
// Initial resolve
|
|
191
|
+
if (this._el) {
|
|
192
|
+
// Defer to allow all components to register
|
|
193
|
+
queueMicrotask(() => this._resolve());
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// --- Route management ----------------------------------------------------
|
|
198
|
+
|
|
199
|
+
add(route) {
|
|
200
|
+
// Compile path pattern into regex
|
|
201
|
+
const { regex, keys } = compilePath(route.path);
|
|
202
|
+
this._routes.push({ ...route, _regex: regex, _keys: keys });
|
|
203
|
+
|
|
204
|
+
// Per-route fallback: register an alias path for the same component.
|
|
205
|
+
// e.g. { path: '/docs/:section', fallback: '/docs', component: 'docs-page' }
|
|
206
|
+
// When matched via fallback, missing params are undefined.
|
|
207
|
+
if (route.fallback) {
|
|
208
|
+
const fb = compilePath(route.fallback);
|
|
209
|
+
this._routes.push({ ...route, path: route.fallback, _regex: fb.regex, _keys: fb.keys });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return this;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
remove(path) {
|
|
216
|
+
this._routes = this._routes.filter(r => r.path !== path);
|
|
217
|
+
return this;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// --- Navigation ----------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Interpolate :param placeholders in a path with the given values.
|
|
224
|
+
* @param {string} path - e.g. '/user/:id/posts/:pid'
|
|
225
|
+
* @param {Object} params - e.g. { id: 42, pid: 7 }
|
|
226
|
+
* @returns {string}
|
|
227
|
+
*/
|
|
228
|
+
_interpolateParams(path, params) {
|
|
229
|
+
if (!params || typeof params !== 'object') return path;
|
|
230
|
+
return path.replace(/:([\w]+)/g, (_, key) => {
|
|
231
|
+
const val = params[key];
|
|
232
|
+
return val != null ? encodeURIComponent(String(val)) : ':' + key;
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Get the full current URL (path + hash) for same-URL detection.
|
|
238
|
+
* @returns {string}
|
|
239
|
+
*/
|
|
240
|
+
_currentURL() {
|
|
241
|
+
if (this._mode === 'hash') {
|
|
242
|
+
return window.location.hash.slice(1) || '/';
|
|
243
|
+
}
|
|
244
|
+
const pathname = window.location.pathname || '/';
|
|
245
|
+
const hash = window.location.hash || '';
|
|
246
|
+
return pathname + hash;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
navigate(path, options = {}) {
|
|
250
|
+
// Interpolate :param placeholders if options.params is provided
|
|
251
|
+
if (options.params) path = this._interpolateParams(path, options.params);
|
|
252
|
+
// Separate hash fragment (e.g. /docs/getting-started#cli-bundler)
|
|
253
|
+
const [cleanPath, fragment] = (path || '').split('#');
|
|
254
|
+
let normalized = this._normalizePath(cleanPath);
|
|
255
|
+
const hash = fragment ? '#' + fragment : '';
|
|
256
|
+
if (this._mode === 'hash') {
|
|
257
|
+
// Hash mode uses the URL hash for routing, so a #fragment can't live
|
|
258
|
+
// in the URL. Store it as a scroll target for the destination component.
|
|
259
|
+
if (fragment) window.__zqScrollTarget = fragment;
|
|
260
|
+
const targetHash = '#' + normalized;
|
|
261
|
+
// Skip if already at this exact hash (prevents duplicate entries)
|
|
262
|
+
if (window.location.hash === targetHash && !options.force) return this;
|
|
263
|
+
window.location.hash = targetHash;
|
|
264
|
+
} else {
|
|
265
|
+
const targetURL = this._base + normalized + hash;
|
|
266
|
+
const currentURL = (window.location.pathname || '/') + (window.location.hash || '');
|
|
267
|
+
|
|
268
|
+
if (targetURL === currentURL && !options.force) {
|
|
269
|
+
// Same full URL (path + hash) - don't push duplicate entry.
|
|
270
|
+
// If only the hash changed to a fragment target, scroll to it.
|
|
271
|
+
if (fragment) {
|
|
272
|
+
const el = document.getElementById(fragment);
|
|
273
|
+
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
274
|
+
}
|
|
275
|
+
return this;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Same route path but different hash fragment - use replaceState
|
|
279
|
+
// so back goes to the previous *route*, not the previous scroll position.
|
|
280
|
+
const targetPathOnly = this._base + normalized;
|
|
281
|
+
const currentPathOnly = window.location.pathname || '/';
|
|
282
|
+
if (targetPathOnly === currentPathOnly && hash && !options.force) {
|
|
283
|
+
window.history.replaceState(
|
|
284
|
+
{ ...options.state, [_ZQ_STATE_KEY]: 'route' },
|
|
285
|
+
'',
|
|
286
|
+
targetURL
|
|
287
|
+
);
|
|
288
|
+
// Scroll to the fragment target
|
|
289
|
+
if (fragment) {
|
|
290
|
+
const el = document.getElementById(fragment);
|
|
291
|
+
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
292
|
+
}
|
|
293
|
+
// Don't re-resolve - same route, just a hash change
|
|
294
|
+
return this;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
window.history.pushState(
|
|
298
|
+
{ ...options.state, [_ZQ_STATE_KEY]: 'route' },
|
|
299
|
+
'',
|
|
300
|
+
targetURL
|
|
301
|
+
);
|
|
302
|
+
this._resolve();
|
|
303
|
+
}
|
|
304
|
+
return this;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
replace(path, options = {}) {
|
|
308
|
+
// Interpolate :param placeholders if options.params is provided
|
|
309
|
+
if (options.params) path = this._interpolateParams(path, options.params);
|
|
310
|
+
const [cleanPath, fragment] = (path || '').split('#');
|
|
311
|
+
let normalized = this._normalizePath(cleanPath);
|
|
312
|
+
const hash = fragment ? '#' + fragment : '';
|
|
313
|
+
if (this._mode === 'hash') {
|
|
314
|
+
if (fragment) window.__zqScrollTarget = fragment;
|
|
315
|
+
window.location.replace('#' + normalized);
|
|
316
|
+
} else {
|
|
317
|
+
window.history.replaceState(
|
|
318
|
+
{ ...options.state, [_ZQ_STATE_KEY]: 'route' },
|
|
319
|
+
'',
|
|
320
|
+
this._base + normalized + hash
|
|
321
|
+
);
|
|
322
|
+
this._resolve();
|
|
323
|
+
}
|
|
324
|
+
return this;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Normalize an app-relative path and guard against double base-prefixing.
|
|
329
|
+
* @param {string} path - e.g. '/docs', 'docs', or '/app/docs' when base is '/app'
|
|
330
|
+
* @returns {string} - always starts with '/'
|
|
331
|
+
*/
|
|
332
|
+
_normalizePath(path) {
|
|
333
|
+
let p = path && path.startsWith('/') ? path : (path ? `/${path}` : '/');
|
|
334
|
+
// Strip base prefix if caller accidentally included it
|
|
335
|
+
if (this._base) {
|
|
336
|
+
if (p === this._base) return '/';
|
|
337
|
+
if (p.startsWith(this._base + '/')) p = p.slice(this._base.length) || '/';
|
|
338
|
+
}
|
|
339
|
+
return p;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Resolve an app-relative path to a full URL path (including base).
|
|
344
|
+
* Useful for programmatic link generation.
|
|
345
|
+
* @param {string} path
|
|
346
|
+
* @returns {string}
|
|
347
|
+
*/
|
|
348
|
+
resolve(path) {
|
|
349
|
+
const normalized = path && path.startsWith('/') ? path : (path ? `/${path}` : '/');
|
|
350
|
+
return this._base + normalized;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
back() { window.history.back(); return this; }
|
|
354
|
+
forward() { window.history.forward(); return this; }
|
|
355
|
+
go(n) { window.history.go(n); return this; }
|
|
356
|
+
|
|
357
|
+
// --- Guards --------------------------------------------------------------
|
|
358
|
+
|
|
359
|
+
beforeEach(fn) {
|
|
360
|
+
this._guards.before.push(fn);
|
|
361
|
+
return this;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
afterEach(fn) {
|
|
365
|
+
this._guards.after.push(fn);
|
|
366
|
+
return this;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// --- Events --------------------------------------------------------------
|
|
370
|
+
|
|
371
|
+
onChange(fn) {
|
|
372
|
+
this._listeners.add(fn);
|
|
373
|
+
return () => this._listeners.delete(fn);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// --- Sub-route history substates -----------------------------------------
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Push a lightweight history entry for in-component UI state.
|
|
380
|
+
* The URL path does NOT change - only a history entry is added so the
|
|
381
|
+
* back button can undo the UI change (close modal, revert tab, etc.)
|
|
382
|
+
* before navigating away.
|
|
383
|
+
*
|
|
384
|
+
* @param {string} key - identifier (e.g. 'modal', 'tab', 'panel')
|
|
385
|
+
* @param {*} data - arbitrary state (serializable)
|
|
386
|
+
* @returns {Router}
|
|
387
|
+
*
|
|
388
|
+
* @example
|
|
389
|
+
* // Open a modal and push a substate
|
|
390
|
+
* router.pushSubstate('modal', { id: 'confirm-delete' });
|
|
391
|
+
* // User hits back → onSubstate fires → close the modal
|
|
392
|
+
*/
|
|
393
|
+
pushSubstate(key, data) {
|
|
394
|
+
this._inSubstate = true;
|
|
395
|
+
if (this._mode === 'hash') {
|
|
396
|
+
// Hash mode: stash the substate in a global - hashchange will check.
|
|
397
|
+
// We still push a history entry via a sentinel hash suffix.
|
|
398
|
+
const current = window.location.hash || '#/';
|
|
399
|
+
window.history.pushState(
|
|
400
|
+
{ [_ZQ_STATE_KEY]: 'substate', key, data },
|
|
401
|
+
'',
|
|
402
|
+
window.location.href
|
|
403
|
+
);
|
|
404
|
+
} else {
|
|
405
|
+
window.history.pushState(
|
|
406
|
+
{ [_ZQ_STATE_KEY]: 'substate', key, data },
|
|
407
|
+
'',
|
|
408
|
+
window.location.href // keep same URL
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
return this;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Register a listener for substate pops (back button on a substate entry).
|
|
416
|
+
* The callback receives `(key, data)` and should return `true` if it
|
|
417
|
+
* handled the pop (prevents route resolution). If no listener returns
|
|
418
|
+
* `true`, normal route resolution proceeds.
|
|
419
|
+
*
|
|
420
|
+
* @param {(key: string, data: any, action: string) => boolean|void} fn
|
|
421
|
+
* @returns {() => void} unsubscribe function
|
|
422
|
+
*
|
|
423
|
+
* @example
|
|
424
|
+
* const unsub = router.onSubstate((key, data) => {
|
|
425
|
+
* if (key === 'modal') { closeModal(); return true; }
|
|
426
|
+
* });
|
|
427
|
+
*/
|
|
428
|
+
onSubstate(fn) {
|
|
429
|
+
this._substateListeners.push(fn);
|
|
430
|
+
return () => {
|
|
431
|
+
this._substateListeners = this._substateListeners.filter(f => f !== fn);
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Fire substate listeners. Returns true if any listener handled it.
|
|
437
|
+
* @private
|
|
438
|
+
*/
|
|
439
|
+
_fireSubstate(key, data, action) {
|
|
440
|
+
for (const fn of this._substateListeners) {
|
|
441
|
+
try {
|
|
442
|
+
if (fn(key, data, action) === true) return true;
|
|
443
|
+
} catch (err) {
|
|
444
|
+
reportError(ErrorCode.ROUTER_GUARD, 'onSubstate listener threw', { key, data }, err);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
return false;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// --- Current state -------------------------------------------------------
|
|
451
|
+
|
|
452
|
+
get current() { return this._current; }
|
|
453
|
+
|
|
454
|
+
/** The detected or configured base path (read-only) */
|
|
455
|
+
get base() { return this._base; }
|
|
456
|
+
|
|
457
|
+
get path() {
|
|
458
|
+
if (this._mode === 'hash') {
|
|
459
|
+
const raw = window.location.hash.slice(1) || '/';
|
|
460
|
+
// If the hash doesn't start with '/', it's an in-page anchor
|
|
461
|
+
// (e.g. #some-heading), not a route. Treat it as a scroll target
|
|
462
|
+
// and resolve to the last known route (or '/').
|
|
463
|
+
if (raw && !raw.startsWith('/')) {
|
|
464
|
+
window.__zqScrollTarget = raw;
|
|
465
|
+
// Restore the route hash silently so the URL stays valid
|
|
466
|
+
const fallbackPath = (this._current && this._current.path) || '/';
|
|
467
|
+
window.location.replace('#' + fallbackPath);
|
|
468
|
+
return fallbackPath;
|
|
469
|
+
}
|
|
470
|
+
return raw;
|
|
471
|
+
}
|
|
472
|
+
let pathname = window.location.pathname || '/';
|
|
473
|
+
// Strip trailing slash for consistency (except root '/')
|
|
474
|
+
if (pathname.length > 1 && pathname.endsWith('/')) {
|
|
475
|
+
pathname = pathname.slice(0, -1);
|
|
476
|
+
}
|
|
477
|
+
if (this._base) {
|
|
478
|
+
// Exact match: /app
|
|
479
|
+
if (pathname === this._base) return '/';
|
|
480
|
+
// Prefix match with boundary: /app/page (but NOT /application)
|
|
481
|
+
if (pathname.startsWith(this._base + '/')) {
|
|
482
|
+
return pathname.slice(this._base.length) || '/';
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
return pathname;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
get query() {
|
|
489
|
+
const search = this._mode === 'hash'
|
|
490
|
+
? (window.location.hash.split('?')[1] || '')
|
|
491
|
+
: window.location.search.slice(1);
|
|
492
|
+
return Object.fromEntries(new URLSearchParams(search));
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// --- Internal resolve ----------------------------------------------------
|
|
496
|
+
|
|
497
|
+
async _resolve() {
|
|
498
|
+
// Prevent re-entrant calls (e.g. listener triggering navigation)
|
|
499
|
+
if (this._resolving) return;
|
|
500
|
+
this._resolving = true;
|
|
501
|
+
this._redirectCount = 0;
|
|
502
|
+
try {
|
|
503
|
+
await this.__resolve();
|
|
504
|
+
} finally {
|
|
505
|
+
this._resolving = false;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
async __resolve() {
|
|
510
|
+
// Check if we're landing on a substate entry (e.g. page refresh on a
|
|
511
|
+
// substate bookmark, or hash-mode popstate). Fire listeners and bail
|
|
512
|
+
// if handled - the URL hasn't changed so there's no route to resolve.
|
|
513
|
+
const histState = window.history.state;
|
|
514
|
+
if (histState && histState[_ZQ_STATE_KEY] === 'substate') {
|
|
515
|
+
const handled = this._fireSubstate(histState.key, histState.data, 'resolve');
|
|
516
|
+
if (handled) return;
|
|
517
|
+
// No listener handled it - fall through to normal routing
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const fullPath = this.path;
|
|
521
|
+
const [pathPart, queryString] = fullPath.split('?');
|
|
522
|
+
const path = pathPart || '/';
|
|
523
|
+
const query = Object.fromEntries(new URLSearchParams(queryString || ''));
|
|
524
|
+
|
|
525
|
+
// Match route
|
|
526
|
+
let matched = null;
|
|
527
|
+
let params = {};
|
|
528
|
+
for (const route of this._routes) {
|
|
529
|
+
const m = path.match(route._regex);
|
|
530
|
+
if (m) {
|
|
531
|
+
matched = route;
|
|
532
|
+
route._keys.forEach((key, i) => { params[key] = m[i + 1]; });
|
|
533
|
+
break;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// Fallback
|
|
538
|
+
if (!matched && this._fallback) {
|
|
539
|
+
matched = { component: this._fallback, path: '*', _keys: [], _regex: /.*/ };
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
if (!matched) return;
|
|
543
|
+
|
|
544
|
+
const to = { route: matched, params, query, path };
|
|
545
|
+
const from = this._current;
|
|
546
|
+
|
|
547
|
+
// Same-route optimization: if the resolved route is the same component
|
|
548
|
+
// with the same params, skip the full destroy/mount cycle and just
|
|
549
|
+
// update props. This prevents flashing and unnecessary DOM churn.
|
|
550
|
+
if (from && this._instance && matched.component === from.route.component) {
|
|
551
|
+
const sameParams = _shallowEqual(params, from.params);
|
|
552
|
+
const sameQuery = _shallowEqual(query, from.query);
|
|
553
|
+
if (sameParams && sameQuery) {
|
|
554
|
+
// Identical navigation - nothing to do
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Run before guards
|
|
560
|
+
for (const guard of this._guards.before) {
|
|
561
|
+
try {
|
|
562
|
+
const result = await guard(to, from);
|
|
563
|
+
if (result === false) return; // Cancel
|
|
564
|
+
if (typeof result === 'string') { // Redirect
|
|
565
|
+
if (++this._redirectCount > 10) {
|
|
566
|
+
reportError(ErrorCode.ROUTER_GUARD, 'Too many guard redirects (possible loop)', { to }, null);
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
// Update URL directly and re-resolve (avoids re-entrancy block)
|
|
570
|
+
const [rPath, rFrag] = result.split('#');
|
|
571
|
+
const rNorm = this._normalizePath(rPath || '/');
|
|
572
|
+
const rHash = rFrag ? '#' + rFrag : '';
|
|
573
|
+
if (this._mode === 'hash') {
|
|
574
|
+
if (rFrag) window.__zqScrollTarget = rFrag;
|
|
575
|
+
window.location.replace('#' + rNorm);
|
|
576
|
+
} else {
|
|
577
|
+
window.history.replaceState(
|
|
578
|
+
{ [_ZQ_STATE_KEY]: 'route' },
|
|
579
|
+
'',
|
|
580
|
+
this._base + rNorm + rHash
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
return this.__resolve();
|
|
584
|
+
}
|
|
585
|
+
} catch (err) {
|
|
586
|
+
reportError(ErrorCode.ROUTER_GUARD, 'Before-guard threw', { to, from }, err);
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// Lazy load module if needed
|
|
592
|
+
if (matched.load) {
|
|
593
|
+
try { await matched.load(); }
|
|
594
|
+
catch (err) {
|
|
595
|
+
reportError(ErrorCode.ROUTER_LOAD, `Failed to load module for route "${matched.path}"`, { path: matched.path }, err);
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
this._current = to;
|
|
601
|
+
|
|
602
|
+
// Mount component into outlet
|
|
603
|
+
if (this._el && matched.component) {
|
|
604
|
+
// Pre-load external templates/styles so the mount renders synchronously
|
|
605
|
+
// (keeps old content visible during the fetch instead of showing blank)
|
|
606
|
+
if (typeof matched.component === 'string') {
|
|
607
|
+
await prefetch(matched.component);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const isKeepAlive = !!matched.keepAlive;
|
|
611
|
+
const componentName = typeof matched.component === 'string' ? matched.component : null;
|
|
612
|
+
|
|
613
|
+
// Deactivate previous keep-alive instance (hide instead of destroy)
|
|
614
|
+
if (this._instance && this._currentKeepAlive && this._currentComponentName) {
|
|
615
|
+
const cached = this._keepAliveCache.get(this._currentComponentName);
|
|
616
|
+
if (cached) {
|
|
617
|
+
cached.container.style.display = 'none';
|
|
618
|
+
// Call deactivated() lifecycle hook
|
|
619
|
+
if (cached.instance._def.deactivated) {
|
|
620
|
+
try { cached.instance._def.deactivated.call(cached.instance); }
|
|
621
|
+
catch (err) { reportError(ErrorCode.COMP_LIFECYCLE, `Component "${this._currentComponentName}" deactivated() threw`, { component: this._currentComponentName }, err); }
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
this._instance = null;
|
|
625
|
+
} else if (this._instance) {
|
|
626
|
+
// Destroy previous non-keepAlive instance
|
|
627
|
+
this._instance.destroy();
|
|
628
|
+
this._instance = null;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
const _routeStart = typeof window !== 'undefined' && window.__zqRenderHook ? performance.now() : 0;
|
|
632
|
+
|
|
633
|
+
// Pass route params and query as props
|
|
634
|
+
const props = { ...params, $route: to, $query: query, $params: params };
|
|
635
|
+
|
|
636
|
+
// Keep-alive: reuse cached instance
|
|
637
|
+
if (isKeepAlive && componentName && this._keepAliveCache.has(componentName)) {
|
|
638
|
+
const cached = this._keepAliveCache.get(componentName);
|
|
639
|
+
// Hide all children, show the cached one
|
|
640
|
+
[...this._el.children].forEach(c => { c.style.display = 'none'; });
|
|
641
|
+
cached.container.style.display = '';
|
|
642
|
+
this._instance = cached.instance;
|
|
643
|
+
this._currentKeepAlive = true;
|
|
644
|
+
this._currentComponentName = componentName;
|
|
645
|
+
// Call activated() lifecycle hook
|
|
646
|
+
if (cached.instance._def.activated) {
|
|
647
|
+
try { cached.instance._def.activated.call(cached.instance); }
|
|
648
|
+
catch (err) { reportError(ErrorCode.COMP_LIFECYCLE, `Component "${componentName}" activated() threw`, { component: componentName }, err); }
|
|
649
|
+
}
|
|
650
|
+
if (_routeStart) window.__zqRenderHook(this._el, performance.now() - _routeStart, 'route', componentName);
|
|
651
|
+
}
|
|
652
|
+
// If component is a string (registered name), mount it
|
|
653
|
+
else if (componentName) {
|
|
654
|
+
// Hide all keep-alive cached children (don't destroy)
|
|
655
|
+
[...this._el.children].forEach(c => {
|
|
656
|
+
if (c.dataset.zqKeepAlive) {
|
|
657
|
+
c.style.display = 'none';
|
|
658
|
+
}
|
|
659
|
+
});
|
|
660
|
+
// Remove non-keep-alive children
|
|
661
|
+
[...this._el.children].forEach(c => {
|
|
662
|
+
if (!c.dataset.zqKeepAlive) c.remove();
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
const container = document.createElement(componentName);
|
|
666
|
+
if (isKeepAlive) container.dataset.zqKeepAlive = componentName;
|
|
667
|
+
this._el.appendChild(container);
|
|
668
|
+
try {
|
|
669
|
+
this._instance = mount(container, componentName, props);
|
|
670
|
+
} catch (err) {
|
|
671
|
+
reportError(ErrorCode.COMP_NOT_FOUND, `Failed to mount component for route "${matched.path}"`, { component: matched.component, path: matched.path }, err);
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
if (isKeepAlive) {
|
|
676
|
+
this._keepAliveCache.set(componentName, { container, instance: this._instance });
|
|
677
|
+
// Call activated() on first mount
|
|
678
|
+
if (this._instance._def.activated) {
|
|
679
|
+
try { this._instance._def.activated.call(this._instance); }
|
|
680
|
+
catch (err) { reportError(ErrorCode.COMP_LIFECYCLE, `Component "${componentName}" activated() threw`, { component: componentName }, err); }
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
this._currentKeepAlive = isKeepAlive;
|
|
685
|
+
this._currentComponentName = componentName;
|
|
686
|
+
if (_routeStart) window.__zqRenderHook(this._el, performance.now() - _routeStart, 'route', componentName);
|
|
687
|
+
}
|
|
688
|
+
// If component is a render function
|
|
689
|
+
else if (typeof matched.component === 'function') {
|
|
690
|
+
// Clear non-keepAlive content
|
|
691
|
+
[...this._el.children].forEach(c => {
|
|
692
|
+
if (c.dataset.zqKeepAlive) c.style.display = 'none';
|
|
693
|
+
else c.remove();
|
|
694
|
+
});
|
|
695
|
+
const wrapper = document.createElement('div');
|
|
696
|
+
wrapper.innerHTML = matched.component(to);
|
|
697
|
+
while (wrapper.firstChild) this._el.appendChild(wrapper.firstChild);
|
|
698
|
+
this._currentKeepAlive = false;
|
|
699
|
+
this._currentComponentName = null;
|
|
700
|
+
if (_routeStart) window.__zqRenderHook(this._el, performance.now() - _routeStart, 'route', to);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// Update z-active-route elements
|
|
705
|
+
this._updateActiveRoutes(path);
|
|
706
|
+
|
|
707
|
+
// Run after guards
|
|
708
|
+
for (const guard of this._guards.after) {
|
|
709
|
+
await guard(to, from);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// Notify listeners
|
|
713
|
+
this._listeners.forEach(fn => fn(to, from));
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// --- Active route class management ----------------------------------------
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* Update all elements with z-active-route to toggle their active class
|
|
720
|
+
* based on the current path.
|
|
721
|
+
*
|
|
722
|
+
* Usage:
|
|
723
|
+
* <a z-link="/docs" z-active-route="/docs">Docs</a>
|
|
724
|
+
* <a z-link="/about" z-active-route="/about" z-active-class="selected">About</a>
|
|
725
|
+
* <a z-link="/" z-active-route="/" z-active-exact>Home</a>
|
|
726
|
+
*/
|
|
727
|
+
_updateActiveRoutes(currentPath) {
|
|
728
|
+
if (typeof document === 'undefined') return;
|
|
729
|
+
const els = document.querySelectorAll('[z-active-route]');
|
|
730
|
+
for (let i = 0; i < els.length; i++) {
|
|
731
|
+
const el = els[i];
|
|
732
|
+
const route = el.getAttribute('z-active-route');
|
|
733
|
+
const cls = el.getAttribute('z-active-class') || 'active';
|
|
734
|
+
const exact = el.hasAttribute('z-active-exact');
|
|
735
|
+
const isActive = exact
|
|
736
|
+
? currentPath === route
|
|
737
|
+
: (route === '/' ? currentPath === '/' : currentPath.startsWith(route));
|
|
738
|
+
el.classList.toggle(cls, isActive);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// --- Destroy -------------------------------------------------------------
|
|
743
|
+
|
|
744
|
+
destroy() {
|
|
745
|
+
// Remove window/document event listeners to prevent memory leaks
|
|
746
|
+
if (this._onNavEvent) {
|
|
747
|
+
window.removeEventListener(this._mode === 'hash' ? 'hashchange' : 'popstate', this._onNavEvent);
|
|
748
|
+
this._onNavEvent = null;
|
|
749
|
+
}
|
|
750
|
+
if (this._onPopState) {
|
|
751
|
+
window.removeEventListener('popstate', this._onPopState);
|
|
752
|
+
this._onPopState = null;
|
|
753
|
+
}
|
|
754
|
+
if (this._onLinkClick) {
|
|
755
|
+
document.removeEventListener('click', this._onLinkClick);
|
|
756
|
+
this._onLinkClick = null;
|
|
757
|
+
}
|
|
758
|
+
// Destroy all keep-alive cached instances
|
|
759
|
+
for (const [, cached] of this._keepAliveCache) {
|
|
760
|
+
cached.instance.destroy();
|
|
761
|
+
}
|
|
762
|
+
this._keepAliveCache.clear();
|
|
763
|
+
if (this._instance) this._instance.destroy();
|
|
764
|
+
this._listeners.clear();
|
|
765
|
+
this._substateListeners = [];
|
|
766
|
+
this._inSubstate = false;
|
|
767
|
+
this._routes = [];
|
|
768
|
+
this._guards = { before: [], after: [] };
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
// ---------------------------------------------------------------------------
|
|
774
|
+
// Path compilation (shared by Router.add and matchRoute)
|
|
775
|
+
// ---------------------------------------------------------------------------
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* Compile a route path pattern into a RegExp and param key list.
|
|
779
|
+
* Supports `:param` segments and `*` wildcard.
|
|
780
|
+
* @param {string} path - e.g. '/user/:id' or '/files/*'
|
|
781
|
+
* @returns {{ regex: RegExp, keys: string[] }}
|
|
782
|
+
*/
|
|
783
|
+
function compilePath(path) {
|
|
784
|
+
const keys = [];
|
|
785
|
+
const pattern = path
|
|
786
|
+
.replace(/:(\w+)/g, (_, key) => { keys.push(key); return '([^/]+)'; })
|
|
787
|
+
.replace(/\*/g, '(.*)');
|
|
788
|
+
return { regex: new RegExp(`^${pattern}$`), keys };
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// ---------------------------------------------------------------------------
|
|
792
|
+
// Standalone route matcher (DOM-free — usable on server and client)
|
|
793
|
+
// ---------------------------------------------------------------------------
|
|
794
|
+
|
|
795
|
+
/**
|
|
796
|
+
* Match a pathname against an array of route definitions.
|
|
797
|
+
* Returns `{ component, params }`. If no route matches, falls back to the
|
|
798
|
+
* `fallback` component name (default `'not-found'`).
|
|
799
|
+
*
|
|
800
|
+
* This is the same matching logic the client-side router uses internally,
|
|
801
|
+
* extracted so SSR servers can resolve URLs without the DOM.
|
|
802
|
+
*
|
|
803
|
+
* @param {Array<{ path: string, component: string, fallback?: string }>} routes
|
|
804
|
+
* @param {string} pathname - URL path to match, e.g. '/blog/my-post'
|
|
805
|
+
* @param {string} [fallback='not-found'] - Component name when nothing matches
|
|
806
|
+
* @returns {{ component: string, params: Record<string, string> }}
|
|
807
|
+
*/
|
|
808
|
+
export function matchRoute(routes, pathname, fallback = 'not-found') {
|
|
809
|
+
for (const route of routes) {
|
|
810
|
+
const { regex, keys } = compilePath(route.path);
|
|
811
|
+
const m = pathname.match(regex);
|
|
812
|
+
if (m) {
|
|
813
|
+
const params = {};
|
|
814
|
+
keys.forEach((key, i) => { params[key] = m[i + 1]; });
|
|
815
|
+
return { component: route.component, params };
|
|
816
|
+
}
|
|
817
|
+
// Per-route fallback alias (same as Router.add)
|
|
818
|
+
if (route.fallback) {
|
|
819
|
+
const fb = compilePath(route.fallback);
|
|
820
|
+
const fbm = pathname.match(fb.regex);
|
|
821
|
+
if (fbm) {
|
|
822
|
+
const params = {};
|
|
823
|
+
fb.keys.forEach((key, i) => { params[key] = fbm[i + 1]; });
|
|
824
|
+
return { component: route.component, params };
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
return { component: fallback, params: {} };
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// ---------------------------------------------------------------------------
|
|
832
|
+
// Factory
|
|
833
|
+
// ---------------------------------------------------------------------------
|
|
834
|
+
let _activeRouter = null;
|
|
835
|
+
|
|
836
|
+
export function createRouter(config) {
|
|
837
|
+
_activeRouter = new Router(config);
|
|
838
|
+
return _activeRouter;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
export function getRouter() {
|
|
842
|
+
return _activeRouter;
|
|
843
|
+
}
|