@granularjs/core 1.0.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 (70) hide show
  1. package/README.md +576 -0
  2. package/dist/granular.min.js +2 -0
  3. package/dist/granular.min.js.map +7 -0
  4. package/package.json +54 -0
  5. package/src/core/bootstrap.js +63 -0
  6. package/src/core/collections/observable-array.js +204 -0
  7. package/src/core/component/function-component.js +82 -0
  8. package/src/core/context.js +172 -0
  9. package/src/core/dom/dom.js +25 -0
  10. package/src/core/dom/element.js +725 -0
  11. package/src/core/dom/error-boundary.js +111 -0
  12. package/src/core/dom/input-format.js +82 -0
  13. package/src/core/dom/list.js +185 -0
  14. package/src/core/dom/portal.js +57 -0
  15. package/src/core/dom/tags.js +182 -0
  16. package/src/core/dom/virtual-list.js +242 -0
  17. package/src/core/dom/when.js +138 -0
  18. package/src/core/events/event-hub.js +97 -0
  19. package/src/core/forms/form.js +127 -0
  20. package/src/core/internal/symbols.js +5 -0
  21. package/src/core/network/websocket.js +165 -0
  22. package/src/core/query/query-client.js +529 -0
  23. package/src/core/reactivity/after-flush.js +20 -0
  24. package/src/core/reactivity/computed.js +51 -0
  25. package/src/core/reactivity/concat.js +89 -0
  26. package/src/core/reactivity/dirty-host.js +162 -0
  27. package/src/core/reactivity/observe.js +421 -0
  28. package/src/core/reactivity/persist.js +180 -0
  29. package/src/core/reactivity/resolve.js +8 -0
  30. package/src/core/reactivity/signal.js +97 -0
  31. package/src/core/reactivity/state.js +294 -0
  32. package/src/core/renderable/render-string.js +51 -0
  33. package/src/core/renderable/renderable.js +21 -0
  34. package/src/core/renderable/renderer.js +66 -0
  35. package/src/core/router/router.js +865 -0
  36. package/src/core/runtime.js +28 -0
  37. package/src/index.js +42 -0
  38. package/types/core/bootstrap.d.ts +11 -0
  39. package/types/core/collections/observable-array.d.ts +25 -0
  40. package/types/core/component/function-component.d.ts +14 -0
  41. package/types/core/context.d.ts +29 -0
  42. package/types/core/dom/dom.d.ts +13 -0
  43. package/types/core/dom/element.d.ts +10 -0
  44. package/types/core/dom/error-boundary.d.ts +8 -0
  45. package/types/core/dom/input-format.d.ts +6 -0
  46. package/types/core/dom/list.d.ts +8 -0
  47. package/types/core/dom/portal.d.ts +8 -0
  48. package/types/core/dom/tags.d.ts +114 -0
  49. package/types/core/dom/virtual-list.d.ts +8 -0
  50. package/types/core/dom/when.d.ts +13 -0
  51. package/types/core/events/event-hub.d.ts +48 -0
  52. package/types/core/forms/form.d.ts +9 -0
  53. package/types/core/internal/symbols.d.ts +4 -0
  54. package/types/core/network/websocket.d.ts +18 -0
  55. package/types/core/query/query-client.d.ts +73 -0
  56. package/types/core/reactivity/after-flush.d.ts +4 -0
  57. package/types/core/reactivity/computed.d.ts +1 -0
  58. package/types/core/reactivity/concat.d.ts +1 -0
  59. package/types/core/reactivity/dirty-host.d.ts +42 -0
  60. package/types/core/reactivity/observe.d.ts +10 -0
  61. package/types/core/reactivity/persist.d.ts +1 -0
  62. package/types/core/reactivity/resolve.d.ts +1 -0
  63. package/types/core/reactivity/signal.d.ts +11 -0
  64. package/types/core/reactivity/state.d.ts +14 -0
  65. package/types/core/renderable/render-string.d.ts +2 -0
  66. package/types/core/renderable/renderable.d.ts +15 -0
  67. package/types/core/renderable/renderer.d.ts +38 -0
  68. package/types/core/router/router.d.ts +57 -0
  69. package/types/core/runtime.d.ts +26 -0
  70. package/types/index.d.ts +2 -0
@@ -0,0 +1,865 @@
1
+ import { createComment, clearBetween } from '../dom/dom.js';
2
+ import { Renderer } from '../renderable/renderer.js';
3
+ import { state } from '../reactivity/state.js';
4
+ import { after } from '../reactivity/observe.js';
5
+
6
+ function normalizeBase(basePath) {
7
+ if (!basePath) return '';
8
+ let base = basePath.trim();
9
+ if (!base.startsWith('/')) base = `/${base}`;
10
+ if (base.length > 1 && base.endsWith('/')) base = base.slice(0, -1);
11
+ return base;
12
+ }
13
+
14
+ function normalizePathname(pathname, trailingSlash) {
15
+ let path = pathname || '/';
16
+ if (!path.startsWith('/')) path = `/${path}`;
17
+ if (path.length > 1 && path.endsWith('/') && trailingSlash !== 'preserve') {
18
+ path = path.slice(0, -1);
19
+ }
20
+ return path;
21
+ }
22
+
23
+ function escapeRegex(text) {
24
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
25
+ }
26
+
27
+ function compilePath(path, { caseSensitive, trailingSlash }) {
28
+ const input = path === '' ? '/' : path;
29
+ const normalized = normalizePathname(input, 'preserve');
30
+ if (normalized === '/*' || normalized === '*') {
31
+ return {
32
+ regex: /^.*$/,
33
+ keys: ['*'],
34
+ score: 0,
35
+ };
36
+ }
37
+
38
+ const segments = normalized.split('/').filter(Boolean);
39
+ const keys = [];
40
+ let score = 0;
41
+ let pattern = '^';
42
+
43
+ for (const seg of segments) {
44
+ if (seg === '*') {
45
+ keys.push('*');
46
+ pattern += '(?:/(.*))?';
47
+ score += 1;
48
+ continue;
49
+ }
50
+
51
+ if (seg.startsWith(':')) {
52
+ const raw = seg.slice(1);
53
+ const isOptional = raw.endsWith('?');
54
+ const name = isOptional ? raw.slice(0, -1) : raw;
55
+ keys.push(name);
56
+ if (isOptional) {
57
+ pattern += '(?:/([^/]+))?';
58
+ } else {
59
+ pattern += '/([^/]+)';
60
+ }
61
+ score += 2;
62
+ continue;
63
+ }
64
+
65
+ pattern += `/${escapeRegex(seg)}`;
66
+ score += 3;
67
+ }
68
+
69
+ if (segments.length === 0) pattern += '/?';
70
+
71
+ if (trailingSlash === 'preserve') {
72
+ pattern += '$';
73
+ } else {
74
+ pattern += '/?$';
75
+ }
76
+
77
+ const flags = caseSensitive ? '' : 'i';
78
+ return {
79
+ regex: new RegExp(pattern, flags),
80
+ keys,
81
+ score,
82
+ };
83
+ }
84
+
85
+ function parseQuery(search) {
86
+ const out = {};
87
+ if (!search) return out;
88
+ const params = new URLSearchParams(search.startsWith('?') ? search : `?${search}`);
89
+ for (const [k, v] of params.entries()) {
90
+ if (Object.prototype.hasOwnProperty.call(out, k)) {
91
+ const prev = out[k];
92
+ out[k] = Array.isArray(prev) ? prev.concat(v) : [prev, v];
93
+ } else {
94
+ out[k] = v;
95
+ }
96
+ }
97
+ return out;
98
+ }
99
+
100
+ function toSearch(query) {
101
+ if (!query || typeof query !== 'object') return '';
102
+ const params = new URLSearchParams();
103
+ for (const [k, v] of Object.entries(query)) {
104
+ if (Array.isArray(v)) {
105
+ for (const item of v) params.append(k, String(item));
106
+ } else if (v != null) {
107
+ params.set(k, String(v));
108
+ }
109
+ }
110
+ const str = params.toString();
111
+ return str ? `?${str}` : '';
112
+ }
113
+
114
+ function buildUrl({ pathname, search, hash }) {
115
+ const q = search || '';
116
+ const h = hash || '';
117
+ return `${pathname}${q}${h}`;
118
+ }
119
+
120
+ function joinPaths(parentPath, childPath) {
121
+ const child = childPath == null ? '' : String(childPath).trim();
122
+ if (child.startsWith('/')) return child || '/';
123
+ const base = parentPath && parentPath !== '/' ? parentPath : '';
124
+ if (!child) return base || '/';
125
+ return `${base}/${child}`;
126
+ }
127
+
128
+ function resolveTarget(target) {
129
+ if (typeof target === 'string') return document.querySelector(target);
130
+ return target;
131
+ }
132
+
133
+ function isPageDefinition(value) {
134
+ return typeof value === 'function';
135
+ }
136
+
137
+ function isPromise(value) {
138
+ return !!value && typeof value.then === 'function';
139
+ }
140
+
141
+ export class Router {
142
+ #routes = [];
143
+ #routeSeq = 0;
144
+ #options;
145
+ #mountParent = null;
146
+ #mountStart = null;
147
+ #mountEnd = null;
148
+ #current = null;
149
+ #listening = false;
150
+ #navToken = 0;
151
+ #beforeEach = new Set();
152
+ #afterEach = new Set();
153
+ #memory = null;
154
+
155
+ constructor(options = {}) {
156
+ this.#options = {
157
+ mode: options.mode || 'history',
158
+ basePath: normalizeBase(options.basePath || ''),
159
+ caseSensitive: !!options.caseSensitive,
160
+ trailingSlash: options.trailingSlash || 'ignore',
161
+ maxRedirects: options.maxRedirects ?? 8,
162
+ scrollRestoration: options.scrollRestoration ?? true,
163
+ transition: options.transition || null,
164
+ errorPage: options.errorPage || null,
165
+ };
166
+
167
+ if (this.#options.mode === 'memory') {
168
+ const initial = options.initialUrl || '/';
169
+ this.#memory = {
170
+ stack: [this.#parseUrl(initial)],
171
+ index: 0,
172
+ };
173
+ }
174
+ }
175
+
176
+ add(pathOrConfig, PageClass, options = {}) {
177
+ let config = null;
178
+ if (typeof pathOrConfig === 'string') {
179
+ config = { path: pathOrConfig, page: PageClass, ...options };
180
+ } else if (isPageDefinition(pathOrConfig)) {
181
+ const route = pathOrConfig.route || pathOrConfig.path || null;
182
+ if (route && typeof route === 'object') {
183
+ config = { ...route, page: pathOrConfig, ...options };
184
+ } else {
185
+ config = { path: route, page: pathOrConfig, ...options };
186
+ }
187
+ } else if (pathOrConfig && typeof pathOrConfig === 'object') {
188
+ config = { ...pathOrConfig };
189
+ }
190
+
191
+ if (!config || config.path == null) {
192
+ throw new Error('Router.add: invalid route config');
193
+ }
194
+
195
+ return this.#addRouteConfig(config, null);
196
+ }
197
+
198
+ #addRouteConfig(config, parent) {
199
+ const hasChildren = Array.isArray(config.children) && config.children.length > 0;
200
+ const hasTarget = !!config.page || !!config.load || !!config.redirect;
201
+ if (!hasTarget && !hasChildren && !config.layout) {
202
+ throw new Error(`Router.add: route "${config.path}" must provide page, load, redirect, layout, or children`);
203
+ }
204
+
205
+ const fullPath = parent ? joinPaths(parent.path, config.path) : joinPaths('', config.path);
206
+ const route = {
207
+ id: `${++this.#routeSeq}_${Math.random().toString(36).slice(2)}`,
208
+ name: config.name || null,
209
+ path: fullPath,
210
+ rawPath: config.path,
211
+ parent: parent || null,
212
+ meta: config.meta || null,
213
+ redirect: config.redirect || null,
214
+ loader: config.loader || null,
215
+ guards: config.guards || null,
216
+ beforeEnter: config.beforeEnter || null,
217
+ beforeLeave: config.beforeLeave || null,
218
+ props: config.props || null,
219
+ reuse: config.reuse ?? null,
220
+ transition: config.transition || null,
221
+ errorPage: config.errorPage || null,
222
+ load: config.load || null,
223
+ page: config.page || null,
224
+ layout: config.layout || null,
225
+ children: [],
226
+ };
227
+
228
+ if (hasTarget) {
229
+ const compiled = compilePath(route.path, this.#options);
230
+ route.regex = compiled.regex;
231
+ route.keys = compiled.keys;
232
+ route.score = compiled.score;
233
+ this.#routes.push(route);
234
+ this.#routes.sort((a, b) => b.score - a.score);
235
+ }
236
+
237
+ if (hasChildren) {
238
+ for (const child of config.children) {
239
+ const childRoute = this.#addRouteConfig(child, route);
240
+ if (childRoute) route.children.push(childRoute);
241
+ }
242
+ }
243
+
244
+ return route;
245
+ }
246
+
247
+ beforeEach(fn) {
248
+ this.#beforeEach.add(fn);
249
+ return () => this.#beforeEach.delete(fn);
250
+ }
251
+
252
+ afterEach(fn) {
253
+ this.#afterEach.add(fn);
254
+ return () => this.#afterEach.delete(fn);
255
+ }
256
+
257
+ mount(target) {
258
+ const el = resolveTarget(target);
259
+ if (!el) throw new Error('Router.mount: target not found');
260
+ if (this.#mountParent) return;
261
+ this.#mountParent = el;
262
+ this.#mountStart = createComment('zb:route:start', 'router');
263
+ this.#mountEnd = createComment('zb:route:end', 'router');
264
+ el.appendChild(this.#mountStart);
265
+ el.appendChild(this.#mountEnd);
266
+ this.start();
267
+ }
268
+
269
+ unmount() {
270
+ this.stop();
271
+ if (this.#current) {
272
+ this.#teardownCurrent();
273
+ this.#current = null;
274
+ }
275
+ if (this.#mountStart && this.#mountEnd) {
276
+ clearBetween(this.#mountStart, this.#mountEnd);
277
+ this.#mountStart.remove();
278
+ this.#mountEnd.remove();
279
+ }
280
+ this.#mountStart = null;
281
+ this.#mountEnd = null;
282
+ this.#mountParent = null;
283
+ }
284
+
285
+ start() {
286
+ if (this.#listening) return;
287
+ this.#listening = true;
288
+ if (this.#options.mode === 'history') {
289
+ window.addEventListener('popstate', this.#handlePop);
290
+ } else if (this.#options.mode === 'hash') {
291
+ window.addEventListener('hashchange', this.#handlePop);
292
+ window.addEventListener('popstate', this.#handlePop);
293
+ }
294
+ this.#handleLocationChange({ source: 'start' });
295
+ }
296
+
297
+ stop() {
298
+ if (!this.#listening) return;
299
+ this.#listening = false;
300
+ window.removeEventListener('popstate', this.#handlePop);
301
+ window.removeEventListener('hashchange', this.#handlePop);
302
+ }
303
+
304
+ navigate(to, options = {}) {
305
+ return this.#goTo(to, { ...options, replace: false });
306
+ }
307
+
308
+ replace(to, options = {}) {
309
+ return this.#goTo(to, { ...options, replace: true });
310
+ }
311
+
312
+ back() {
313
+ if (this.#options.mode === 'memory') {
314
+ this.#memoryBack();
315
+ return;
316
+ }
317
+ history.back();
318
+ }
319
+
320
+ forward() {
321
+ if (this.#options.mode === 'memory') {
322
+ this.#memoryForward();
323
+ return;
324
+ }
325
+ history.forward();
326
+ }
327
+
328
+ go(delta) {
329
+ if (this.#options.mode === 'memory') {
330
+ this.#memoryGo(delta);
331
+ return;
332
+ }
333
+ history.go(delta);
334
+ }
335
+
336
+ resolve(path) {
337
+ if (typeof path === 'string') {
338
+ const url = new URL(path, window.location.origin);
339
+ let pathname = normalizePathname(url.pathname, this.#options.trailingSlash);
340
+ const base = this.#options.basePath;
341
+ if (base && !pathname.startsWith(base)) pathname = `${base}${pathname}`;
342
+ return `${pathname}${url.search || ''}${url.hash || ''}`;
343
+ }
344
+ const pathname = normalizePathname(path.pathname || '/', this.#options.trailingSlash);
345
+ const search = path.search || toSearch(path.query);
346
+ const hash = path.hash || '';
347
+ const base = this.#options.basePath;
348
+ const fullPath = base && !pathname.startsWith(base) ? `${base}${pathname}` : pathname;
349
+ return `${fullPath}${search}${hash}`;
350
+ }
351
+
352
+ parse(url) {
353
+ const loc = this.#parseUrl(url);
354
+ const match = this.#match(loc.pathname);
355
+ if (!match) return { location: loc, match: null };
356
+ return { location: loc, match };
357
+ }
358
+
359
+ get current() {
360
+ return this.#current;
361
+ }
362
+
363
+ async checkGuards() {
364
+ if (!this.#current) return true;
365
+
366
+ const ctx = {
367
+ router: this,
368
+ route: this.#current.route,
369
+ chain: this.#current.chain,
370
+ params: this.#current.params,
371
+ query: this.#current.query,
372
+ location: this.#current.location,
373
+ state: this.#current.location?.state ?? null,
374
+ source: 'revalidate',
375
+ };
376
+
377
+ const redirectChain = new Set();
378
+ const ok = await this.#runGuards(this.#current.chain, ctx, redirectChain);
379
+ return ok;
380
+ }
381
+
382
+ queryParameters(options = {}) {
383
+ const replace = options.replace ?? true;
384
+ const preserveHash = options.preserveHash ?? true;
385
+ const q = state(this.#readLocation()?.query || {});
386
+ let lastSerialized = toSearch(q.get());
387
+ let syncing = false;
388
+
389
+ const applyFromLocation = (location) => {
390
+ const nextQuery = location?.query || {};
391
+ const nextSerialized = toSearch(nextQuery);
392
+ if (nextSerialized === lastSerialized) return;
393
+ lastSerialized = nextSerialized;
394
+ q.set(nextQuery);
395
+ };
396
+
397
+ const unsubRoute = this.afterEach(({ location }) => {
398
+ if (syncing) {
399
+ syncing = false;
400
+ return;
401
+ }
402
+ applyFromLocation(location);
403
+ });
404
+
405
+ const unsubState = after(q).change((next) => {
406
+ const nextSerialized = toSearch(next);
407
+ if (nextSerialized === lastSerialized) return;
408
+ lastSerialized = nextSerialized;
409
+ syncing = true;
410
+ const current = this.#readLocation();
411
+ const pathname = current?.pathname || '/';
412
+ const hash = preserveHash ? current?.hash || '' : '';
413
+ const target = { pathname, query: next, hash };
414
+ if (replace) {
415
+ this.replace(target);
416
+ } else {
417
+ this.navigate(target);
418
+ }
419
+ });
420
+
421
+ Object.defineProperty(q, 'dispose', {
422
+ value: () => {
423
+ if (typeof unsubRoute === 'function') unsubRoute();
424
+ if (typeof unsubState === 'function') unsubState();
425
+ },
426
+ enumerable: false,
427
+ });
428
+
429
+ return q;
430
+ }
431
+
432
+ #handlePop = () => {
433
+ this.#handleLocationChange({ source: 'pop' });
434
+ };
435
+
436
+ #readLocation() {
437
+ if (this.#options.mode === 'memory') {
438
+ return this.#memory.stack[this.#memory.index];
439
+ }
440
+ if (this.#options.mode === 'hash') {
441
+ const raw = window.location.hash ? window.location.hash.slice(1) : '/';
442
+ return this.#parseUrl(raw);
443
+ }
444
+ return this.#parseUrl(window.location.href);
445
+ }
446
+
447
+ #parseUrl(input) {
448
+ const base = window.location.origin;
449
+ const url = new URL(input, base);
450
+ const pathname = normalizePathname(url.pathname, this.#options.trailingSlash);
451
+ const basePath = this.#options.basePath;
452
+ const stripped =
453
+ basePath && pathname.startsWith(basePath) ? pathname.slice(basePath.length) || '/' : pathname;
454
+ return {
455
+ pathname: normalizePathname(stripped, this.#options.trailingSlash),
456
+ search: url.search || '',
457
+ hash: url.hash || '',
458
+ query: parseQuery(url.search),
459
+ state: history.state ?? null,
460
+ url: buildUrl({ pathname: url.pathname, search: url.search, hash: url.hash }),
461
+ };
462
+ }
463
+
464
+ #memoryBack() {
465
+ if (this.#memory.index <= 0) return;
466
+ this.#memory.index -= 1;
467
+ this.#handleLocationChange({ source: 'pop' });
468
+ }
469
+
470
+ #memoryForward() {
471
+ if (this.#memory.index >= this.#memory.stack.length - 1) return;
472
+ this.#memory.index += 1;
473
+ this.#handleLocationChange({ source: 'pop' });
474
+ }
475
+
476
+ #memoryGo(delta) {
477
+ const next = this.#memory.index + delta;
478
+ if (next < 0 || next >= this.#memory.stack.length) return;
479
+ this.#memory.index = next;
480
+ this.#handleLocationChange({ source: 'pop' });
481
+ }
482
+
483
+ async #goTo(to, { replace, state, redirectChain } = {}) {
484
+ const nextInput = typeof to === 'string' ? to : this.resolve(to);
485
+ const next = this.#parseUrl(nextInput);
486
+ next.state = state ?? null;
487
+
488
+ const token = ++this.#navToken;
489
+ const ok = await this.#runNavigation(next, { token, source: 'navigate', redirectChain });
490
+ if (!ok) return;
491
+
492
+ if (this.#options.mode === 'memory') {
493
+ if (replace) {
494
+ this.#memory.stack[this.#memory.index] = { ...next, state: state ?? null };
495
+ } else {
496
+ this.#memory.stack = this.#memory.stack.slice(0, this.#memory.index + 1);
497
+ this.#memory.stack.push({ ...next, state: state ?? null });
498
+ this.#memory.index = this.#memory.stack.length - 1;
499
+ }
500
+ return;
501
+ }
502
+
503
+ const full = this.resolve(next.pathname) + (next.search || '') + (next.hash || '');
504
+ if (this.#options.mode === 'hash') {
505
+ const url = `#${full}`;
506
+ history[replace ? 'replaceState' : 'pushState'](state ?? null, '', url);
507
+ } else {
508
+ history[replace ? 'replaceState' : 'pushState'](state ?? null, '', full);
509
+ }
510
+ }
511
+
512
+ async #handleLocationChange({ source, redirectChain } = {}) {
513
+ if (!this.#mountParent || !this.#mountStart || !this.#mountEnd) return;
514
+ const token = ++this.#navToken;
515
+ const loc = this.#readLocation();
516
+ const chain = redirectChain || new Set();
517
+ await this.#runNavigation(loc, { token, source, redirectChain: chain });
518
+ }
519
+
520
+ #match(pathname) {
521
+ for (const route of this.#routes) {
522
+ const m = route.regex.exec(pathname);
523
+ if (!m) continue;
524
+ const params = {};
525
+ for (let i = 0; i < route.keys.length; i++) {
526
+ const key = route.keys[i];
527
+ params[key] = m[i + 1] ? decodeURIComponent(m[i + 1]) : undefined;
528
+ }
529
+ const chain = [];
530
+ let cur = route;
531
+ while (cur) {
532
+ chain.unshift(cur);
533
+ cur = cur.parent;
534
+ }
535
+ return { route, params, chain };
536
+ }
537
+ return null;
538
+ }
539
+
540
+ async #runNavigation(location, { token, source, redirectChain }) {
541
+ if (token !== this.#navToken) return;
542
+
543
+ const match = this.#match(location.pathname);
544
+ if (!match) return false;
545
+
546
+ const { route, params, chain } = match;
547
+ const sameRoute = this.#current && this.#current.route === route;
548
+ const reuse = route.reuse ?? route.page?.reuse ?? true;
549
+ const transition = route.transition || route.page?.transition || this.#options.transition;
550
+
551
+ const ctx = {
552
+ router: this,
553
+ route,
554
+ chain,
555
+ params,
556
+ query: location.query || {},
557
+ location,
558
+ state: location.state ?? null,
559
+ source,
560
+ };
561
+
562
+ try {
563
+ const redirect = await this.#resolveRedirect(chain, ctx, redirectChain);
564
+ if (redirect) return false;
565
+
566
+ const ok = await this.#runGuards(chain, ctx, redirectChain);
567
+ if (!ok) {
568
+ if (source === 'pop') this.#restoreCurrentUrl();
569
+ return false;
570
+ }
571
+
572
+ const data = await this.#runLoader(chain, ctx);
573
+ if (token !== this.#navToken) return false;
574
+
575
+ ctx.data = data?.leaf ?? data;
576
+ ctx.routeData = data?.map ?? {};
577
+
578
+ if (sameRoute && this.#current?.page && reuse) {
579
+ this.#updateCurrent(ctx);
580
+ return true;
581
+ }
582
+
583
+ const pageClass = await this.#resolvePage(route, ctx);
584
+ if (token !== this.#navToken || !pageClass) return false;
585
+
586
+ await this.#swapPage(pageClass, ctx, transition);
587
+ return true;
588
+ } catch (err) {
589
+ return await this.#handleError(err, ctx, transition);
590
+ }
591
+ }
592
+
593
+ async #resolveRedirect(chain, ctx, redirectChain) {
594
+ for (const route of chain) {
595
+ let target = null;
596
+ if (typeof route.redirect === 'string') target = route.redirect;
597
+ if (typeof route.redirect === 'function') target = route.redirect({ ...ctx, route });
598
+ if (typeof target === 'string') {
599
+ return this.#redirectTo(target, redirectChain);
600
+ }
601
+ if (isPromise(target)) {
602
+ const next = await target;
603
+ if (typeof next === 'string') return this.#redirectTo(next, redirectChain);
604
+ }
605
+ }
606
+ return false;
607
+ }
608
+
609
+ async #runGuards(chain, ctx, redirectChain) {
610
+ for (const fn of this.#beforeEach) {
611
+ const res = await fn(ctx);
612
+ if (await this.#handleGuardResult(res, redirectChain)) return false;
613
+ }
614
+
615
+ for (const route of chain) {
616
+ const guards = [];
617
+ if (Array.isArray(route.guards)) guards.push(...route.guards);
618
+ if (typeof route.guards === 'function') guards.push(route.guards);
619
+ if (typeof route.beforeEnter === 'function') guards.push(route.beforeEnter);
620
+ if (typeof route.page?.guards === 'function') guards.push(route.page.guards);
621
+ if (Array.isArray(route.page?.guards)) guards.push(...route.page.guards);
622
+ if (typeof route.page?.beforeEnter === 'function') guards.push(route.page.beforeEnter);
623
+
624
+ for (const fn of guards) {
625
+ const res = await fn({ ...ctx, route });
626
+ if (await this.#handleGuardResult(res, redirectChain)) return false;
627
+ }
628
+ }
629
+ return true;
630
+ }
631
+
632
+ async #handleGuardResult(result, redirectChain) {
633
+ if (result === false) return true;
634
+ if (typeof result === 'string') return this.#redirectTo(result, redirectChain);
635
+ if (result && typeof result === 'object' && typeof result.redirect === 'string') {
636
+ return this.#redirectTo(result.redirect, redirectChain);
637
+ }
638
+ if (isPromise(result)) {
639
+ const r = await result;
640
+ return this.#handleGuardResult(r, redirectChain);
641
+ }
642
+ return false;
643
+ }
644
+
645
+ async #runLoader(chain, ctx) {
646
+ const out = {};
647
+ let leafData = undefined;
648
+ for (const route of chain) {
649
+ const loader = route.loader || route.page?.loader;
650
+ if (typeof loader !== 'function') continue;
651
+ const data = await loader({ ...ctx, route });
652
+ out[route.id] = data;
653
+ if (route === chain[chain.length - 1]) leafData = data;
654
+ }
655
+ return { map: out, leaf: leafData };
656
+ }
657
+
658
+ async #resolvePage(route, ctx) {
659
+ if (route.page && isPageDefinition(route.page)) return route.page;
660
+ if (typeof route.load === 'function') {
661
+ const loaded = await route.load(ctx);
662
+ if (loaded?.default && isPageDefinition(loaded.default)) return loaded.default;
663
+ if (isPageDefinition(loaded)) return loaded;
664
+ }
665
+ return null;
666
+ }
667
+
668
+ async #swapPage(PageClass, ctx, transition) {
669
+ const props = {
670
+ params: ctx.params,
671
+ query: ctx.query,
672
+ location: ctx.location,
673
+ data: ctx.data,
674
+ state: ctx.state,
675
+ router: this,
676
+ route: ctx.route,
677
+ ...(typeof ctx.route.props === 'function' ? ctx.route.props(ctx) : {}),
678
+ };
679
+
680
+ let page;
681
+ let isClassBased = false;
682
+
683
+ // Check if it's a class (not an arrow function and has prototype methods)
684
+ if (PageClass.prototype && PageClass.prototype.constructor === PageClass && !PageClass.__zbFactory) {
685
+ try {
686
+ page = new PageClass(props);
687
+ isClassBased = true;
688
+ } catch (e) {
689
+ page = PageClass(props);
690
+ }
691
+ } else {
692
+ page = PageClass(props);
693
+ }
694
+
695
+ // Only set router/route/etc on class instances, not on renderables
696
+ if (isClassBased && page && typeof page === 'object') {
697
+ page.router = this;
698
+ page.route = ctx.route;
699
+ page.params = ctx.params;
700
+ page.query = ctx.query;
701
+ page.location = ctx.location;
702
+ page.data = ctx.data;
703
+ page.state = ctx.state;
704
+ }
705
+
706
+ const prev = this.#current;
707
+ if (prev?.page) {
708
+ const leaveCtx = { ...ctx, from: prev };
709
+ prev.page.emitBefore?.('routeLeave', leaveCtx, { router: this, page: prev.page });
710
+ prev.page.emitAfter?.('routeLeave', leaveCtx, { router: this, page: prev.page });
711
+ }
712
+
713
+ page.emitBefore?.('routeEnter', ctx, { router: this, page });
714
+
715
+ const rootRenderable = this.#buildLayoutTree(page, ctx);
716
+ const mountedValues = Renderer.normalize(rootRenderable);
717
+ for (const r of mountedValues) {
718
+ if (Renderer.isRenderable(r)) {
719
+ r.mountInto(this.#mountParent, this.#mountEnd);
720
+ } else if (Renderer.isDomNode(r)) {
721
+ this.#mountEnd.parentNode.insertBefore(r, this.#mountEnd);
722
+ }
723
+ }
724
+
725
+ page.emitAfter?.('routeEnter', ctx, { router: this, page });
726
+
727
+ if (prev) this.#teardownCurrent();
728
+
729
+ this.#current = {
730
+ route: ctx.route,
731
+ chain: ctx.chain,
732
+ page,
733
+ mounted: mountedValues,
734
+ params: ctx.params,
735
+ query: ctx.query,
736
+ location: ctx.location,
737
+ data: ctx.data,
738
+ routeData: ctx.routeData,
739
+ };
740
+
741
+ for (const fn of this.#afterEach) fn({ ...ctx, page });
742
+ this.#applyScrollRestoration(ctx);
743
+ }
744
+
745
+ #updateCurrent(ctx) {
746
+ const current = this.#current;
747
+ if (!current?.page) return;
748
+ current.chain = ctx.chain;
749
+ current.params = ctx.params;
750
+ current.query = ctx.query;
751
+ current.location = ctx.location;
752
+ current.data = ctx.data;
753
+ current.page.params = ctx.params;
754
+ current.page.query = ctx.query;
755
+ current.page.location = ctx.location;
756
+ current.page.data = ctx.data;
757
+ current.page.state = ctx.state;
758
+ current.page.emitBefore?.('routeUpdate', ctx, { router: this, page: current.page });
759
+ current.page.emitAfter?.('routeUpdate', ctx, { router: this, page: current.page });
760
+ for (const fn of this.#afterEach) fn({ ...ctx, page: current.page });
761
+ this.#applyScrollRestoration(ctx);
762
+ }
763
+
764
+ async #applyTransition(prevView, nextView, transition) {
765
+ if (!transition || !prevView) return;
766
+ const enter = transition.enterClass || 'zb-route-enter';
767
+ const enterActive = transition.enterActiveClass || 'zb-route-enter-active';
768
+ const exit = transition.exitClass || 'zb-route-exit';
769
+ const exitActive = transition.exitActiveClass || 'zb-route-exit-active';
770
+ const duration = transition.duration ?? 180;
771
+
772
+ nextView.classList.add(enter);
773
+ prevView.classList.add(exit);
774
+
775
+ await new Promise((r) => requestAnimationFrame(r));
776
+ nextView.classList.add(enterActive);
777
+ prevView.classList.add(exitActive);
778
+
779
+ await new Promise((r) => setTimeout(r, duration));
780
+ nextView.classList.remove(enter, enterActive);
781
+ prevView.classList.remove(exit, exitActive);
782
+ }
783
+
784
+ #teardownCurrent() {
785
+ const current = this.#current;
786
+ if (!current) return;
787
+ if (Array.isArray(current.mounted)) {
788
+ for (const r of current.mounted) Renderer.unmount(r);
789
+ }
790
+ }
791
+
792
+ #buildLayoutTree(page, ctx) {
793
+ let outlet = page;
794
+ const chain = ctx.chain || [];
795
+ for (let i = chain.length - 1; i >= 0; i--) {
796
+ const route = chain[i];
797
+ if (typeof route.layout === 'function') {
798
+ outlet = route.layout(outlet, { ...ctx, route });
799
+ }
800
+ }
801
+ return outlet;
802
+ }
803
+
804
+ async #redirectTo(target, redirectChain) {
805
+ if (redirectChain.size >= this.#options.maxRedirects) {
806
+ throw new Error('Router: too many redirects');
807
+ }
808
+ if (redirectChain.has(target)) {
809
+ throw new Error(`Router: redirect loop to "${target}"`);
810
+ }
811
+ redirectChain.add(target);
812
+ await this.#goTo(target, { replace: true, redirectChain });
813
+ return true;
814
+ }
815
+
816
+ async #handleError(err, ctx, transition) {
817
+ const errorPage = ctx.route.errorPage || this.#options.errorPage;
818
+ if (!errorPage) throw err;
819
+ const errorCtx = { ...ctx, error: err };
820
+ await this.#swapPage(errorPage, errorCtx, transition);
821
+ const page = this.#current?.page;
822
+ if (page) {
823
+ page.emitBefore('routeError', errorCtx, { router: this, page });
824
+ page.emitAfter('routeError', errorCtx, { router: this, page });
825
+ }
826
+ return true;
827
+ }
828
+
829
+ #applyScrollRestoration(ctx) {
830
+ if (!this.#options.scrollRestoration) return;
831
+ const hash = ctx.location?.hash;
832
+ if (hash && hash.length > 1) {
833
+ const id = hash.slice(1);
834
+ const el = document.getElementById(id);
835
+ if (el) {
836
+ el.scrollIntoView();
837
+ return;
838
+ }
839
+ }
840
+ window.scrollTo(0, 0);
841
+ }
842
+
843
+ #restoreCurrentUrl() {
844
+ const current = this.#current?.location;
845
+ if (!current) return;
846
+ const full = this.resolve(current.pathname) + (current.search || '') + (current.hash || '');
847
+ if (this.#options.mode === 'hash') {
848
+ history.replaceState(current.state ?? null, '', `#${full}`);
849
+ return;
850
+ }
851
+ history.replaceState(current.state ?? null, '', full);
852
+ }
853
+ }
854
+
855
+ export function createRouter(options) {
856
+ const router = new Router(options);
857
+ if (options?.routes && Array.isArray(options.routes)) {
858
+ for (const route of options.routes) {
859
+ router.add(route);
860
+ }
861
+ }
862
+ return router;
863
+ }
864
+
865
+ export const router = new Router();