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