slicejs-web-framework 4.1.1 → 4.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.
@@ -1,820 +1,982 @@
1
- import { applyRouteMetadata } from './applyRouteMetadata.js';
2
-
3
- /**
4
- * @typedef {Object} RouteConfig
5
- * @property {string} path
6
- * @property {string} component
7
- * @property {RouteConfig[]} [children]
8
- * @property {Object} [metadata]
9
- * @property {string} [fullPath]
10
- * @property {string|null} [parentPath]
11
- * @property {RouteConfig|null} [parentRoute]
12
- */
13
-
14
- /**
15
- * @typedef {Object} RouteInfo
16
- * @property {string} path
17
- * @property {string} component
18
- * @property {Object} params
19
- * @property {Object} query
20
- * @property {Object} metadata
21
- */
22
-
23
- /**
24
- * @typedef {Object} GuardRedirect
25
- * @property {string} path
26
- * @property {boolean} [replace]
27
- */
28
-
29
- /**
30
- * @typedef {Object} RouteMatch
31
- * @property {RouteConfig|null} route
32
- * @property {Object} params
33
- * @property {RouteConfig} [childRoute]
34
- */
35
-
36
- /**
37
- * @callback RouterNext
38
- * @param {void|false|string|{ path: string, replace?: boolean }} [arg]
39
- * @returns {void}
40
- */
41
-
42
- export default class Router {
43
- /**
44
- * @param {RouteConfig[]} routes
45
- */
46
- constructor(routes) {
47
- this.routes = routes;
48
- this.activeRoute = null;
49
- this.pathToRouteMap = this.createPathToRouteMap(routes);
50
-
51
- // O(1) case-insensitive lookup for static routes, so a miss on the exact map
52
- // doesn't scan every route on each navigation. Param routes (which carry a
53
- // precompiled `compiled` regex) are excluded — they go through the param loop.
54
- this._staticLowerIndex = new Map();
55
- for (const [pattern, route] of this.pathToRouteMap.entries()) {
56
- if (!route.compiled) this._staticLowerIndex.set(pattern.toLowerCase(), route);
57
- }
58
-
59
- // Navigation Guards
60
- this._beforeEachGuard = null;
61
- this._afterEachGuard = null;
62
-
63
- // Router state
64
- this._started = false;
65
- this._autoStartTimeout = null;
66
-
67
- // Sistema de caché optimizado
68
- this.routeContainersCache = new Map();
69
- this.lastCacheUpdate = 0;
70
- this.CACHE_DURATION = 100; // ms - caché muy corto pero efectivo
71
-
72
- // Observer para invalidar caché automáticamente
73
- this.setupMutationObserver();
74
- }
75
-
76
- /**
77
- * Inicializa el router
78
- * Si el usuario no llama start() manualmente, se auto-inicia despues de un delay
79
- * @returns {void}
80
- */
81
- init() {
82
- window.addEventListener('popstate', () => {
83
- try {
84
- this.onRouteChange();
85
- } catch (error) {
86
- slice.logger.error('Router', 'Error in popstate handler', error);
87
- }
88
- });
89
-
90
- this._autoStartTimeout = setTimeout(async () => {
91
- if (!this._started) {
92
- try {
93
- slice.logger.info('Router', 'Auto-starting router (no manual start() called)');
94
- await this.start();
95
- } catch (error) {
96
- slice.logger.error('Router', 'Auto-start failed', error);
97
- }
98
- }
99
- }, 50);
100
- }
101
-
102
- /**
103
- * Inicia el router y carga la ruta inicial
104
- * OPCIONAL: Solo necesario si usas guards (beforeEach/afterEach)
105
- * Si no lo llamas, el router se auto-inicia despues de 50ms
106
- * @returns {Promise<void>}
107
- */
108
- async start() {
109
- // Prevenir múltiples llamadas
110
- if (this._started) {
111
- slice.logger.logWarning('Router', 'start() already called');
112
- return;
113
- }
114
-
115
- // Cancelar auto-start si existe
116
- if (this._autoStartTimeout) {
117
- clearTimeout(this._autoStartTimeout);
118
- this._autoStartTimeout = null;
119
- }
120
-
121
- this._started = true;
122
- await this.loadInitialRoute();
123
- }
124
-
125
- // ============================================
126
- // NAVIGATION GUARDS API
127
- // ============================================
128
-
129
- /**
130
- * Registra un guard que se ejecuta ANTES de cada navegacion.
131
- * Puede bloquear o redirigir la navegacion mediante next().
132
- * @param {(to: RouteInfo, from: RouteInfo, next: RouterNext) => void|Promise<void>} guard
133
- * @returns {void}
134
- */
135
- beforeEach(guard) {
136
- if (typeof guard !== 'function') {
137
- slice.logger.logError('Router', 'beforeEach expects a function');
138
- return;
139
- }
140
- this._beforeEachGuard = guard;
141
- }
142
-
143
- /**
144
- * Registra un guard que se ejecuta DESPUES de cada navegacion.
145
- * No puede bloquear la navegacion.
146
- * @param {(to: RouteInfo, from: RouteInfo) => void} guard
147
- * @returns {void}
148
- */
149
- afterEach(guard) {
150
- if (typeof guard !== 'function') {
151
- slice.logger.logError('Router', 'afterEach expects a function');
152
- return;
153
- }
154
- this._afterEachGuard = guard;
155
- }
156
-
157
- /**
158
- * Crea un objeto con información de ruta para los guards
159
- * @param {Object} route - Objeto de ruta
160
- * @param {Object} params - Parámetros de la ruta
161
- * @param {String} requestedPath - Path original solicitado
162
- * @returns {Object} Objeto con path, component, params, query, metadata
163
- */
164
- /**
165
- * Build route info used by guards and events.
166
- * @param {RouteConfig|null} route
167
- * @param {Object} [params]
168
- * @param {string|null} [requestedPath]
169
- * @returns {RouteInfo}
170
- */
171
- _createRouteInfo(route, params = {}, requestedPath = null) {
172
- if (!route) {
173
- return {
174
- path: requestedPath || '/404',
175
- component: 'NotFound',
176
- params: {},
177
- query: this._parseQueryParams(),
178
- metadata: {},
179
- };
180
- }
181
-
182
- return {
183
- path: requestedPath || route.fullPath || route.path,
184
- component: route.parentRoute ? route.parentRoute.component : route.component,
185
- params: params,
186
- query: this._parseQueryParams(),
187
- metadata: route.metadata || {},
188
- };
189
- }
190
-
191
- /**
192
- * Parsea los query parameters de la URL actual
193
- * @returns {Object} Objeto con los query params
194
- */
195
- /**
196
- * Parse query params from current URL.
197
- * @returns {Object}
198
- */
199
- _parseQueryParams() {
200
- const queryString = window.location.search;
201
- if (!queryString) return {};
202
-
203
- const params = {};
204
- const urlParams = new URLSearchParams(queryString);
205
-
206
- for (const [key, value] of urlParams) {
207
- params[key] = value;
208
- }
209
-
210
- return params;
211
- }
212
-
213
- /**
214
- * Ejecuta el beforeEach guard si existe
215
- * @param {Object} to - Información de ruta destino
216
- * @param {Object} from - Información de ruta origen
217
- * @returns {Object|null} Objeto con redirectPath y options, o null si continúa
218
- */
219
- /**
220
- * Execute beforeEach guard if defined.
221
- * @param {RouteInfo} to
222
- * @param {RouteInfo} from
223
- * @returns {Promise<{ path: string|false, options: { replace?: boolean } }|null>}
224
- */
225
- async _executeBeforeEachGuard(to, from) {
226
- if (!this._beforeEachGuard) {
227
- return null;
228
- }
229
-
230
- let redirectPath = null;
231
- let redirectOptions = {};
232
- let nextCalled = false;
233
-
234
- const next = (arg) => {
235
- if (nextCalled) {
236
- slice.logger.logWarning('Router', 'next() called multiple times in guard');
237
- return;
238
- }
239
- nextCalled = true;
240
-
241
- // Caso 1: Sin argumentos - continuar navegación
242
- if (arg === undefined) {
243
- return;
244
- }
245
-
246
- // Caso 2: false - cancelar navegación
247
- if (arg === false) {
248
- redirectPath = false;
249
- return;
250
- }
251
-
252
- // Caso 3: String - redirección simple (backward compatibility)
253
- if (typeof arg === 'string') {
254
- redirectPath = arg;
255
- redirectOptions = { replace: false };
256
- return;
257
- }
258
-
259
- // Caso 4: Objeto - redirección con opciones
260
- if (typeof arg === 'object' && arg.path) {
261
- redirectPath = arg.path;
262
- redirectOptions = {
263
- replace: arg.replace || false,
264
- };
265
- return;
266
- }
267
-
268
- // Argumento inválido
269
- slice.logger.logError(
270
- 'Router',
271
- 'Invalid argument passed to next(). Expected string, object with path, false, or undefined.'
272
- );
273
- };
274
-
275
- try {
276
- await this._beforeEachGuard(to, from, next);
277
-
278
- // Si no se llamó next(), loguear advertencia pero continuar
279
- if (!nextCalled) {
280
- slice.logger.logWarning('Router', 'beforeEach guard did not call next(). Navigation will continue.');
281
- }
282
-
283
- // Retornar tanto el path como las opciones
284
- return redirectPath ? { path: redirectPath, options: redirectOptions } : null;
285
- } catch (error) {
286
- slice.logger.error('Router', `Error in beforeEach guard from "${from?.path}" to "${to?.path}"`, error);
287
- return { path: false, options: {} };
288
- }
289
- }
290
-
291
- /**
292
- * Ejecuta el afterEach guard si existe
293
- * @param {Object} to - Información de ruta destino
294
- * @param {Object} from - Información de ruta origen
295
- */
296
- /**
297
- * Execute afterEach guard if defined.
298
- * @param {RouteInfo} to
299
- * @param {RouteInfo} from
300
- * @returns {void}
301
- */
302
- _executeAfterEachGuard(to, from) {
303
- if (!this._afterEachGuard) {
304
- return;
305
- }
306
-
307
- try {
308
- this._afterEachGuard(to, from);
309
- } catch (error) {
310
- slice.logger.logError('Router', 'Error in afterEach guard', error);
311
- }
312
- }
313
-
314
- // ============================================
315
- // ROUTING CORE (MODIFICADO CON GUARDS)
316
- // ============================================
317
-
318
- /**
319
- * Navigate to a route path (guards run automatically).
320
- * @param {string} path
321
- * @param {{ replace?: boolean }} [options] - `{ replace: true }` replaces history instead of pushing.
322
- * @returns {Promise<void>}
323
- */
324
- async navigate(path, options = {}, legacyOptions) {
325
- // Backward compatibility with the previous signature navigate(path, _redirectChain, _options):
326
- // if the 2nd argument is the internal redirect chain (an array) or a 3rd argument is passed,
327
- // use the 3rd argument as the options object.
328
- if (Array.isArray(options)) {
329
- options = legacyOptions || {};
330
- } else if (legacyOptions !== undefined) {
331
- options = legacyOptions || options;
332
- }
333
- return this._navigateWithGuards(path, options || {}, []);
334
- }
335
-
336
- /**
337
- * Internal navigation that tracks the guard redirection chain (loop protection).
338
- * @param {string} path
339
- * @param {{ replace?: boolean }} options
340
- * @param {string[]} redirectChain
341
- * @returns {Promise<void>}
342
- */
343
- async _navigateWithGuards(path, options, redirectChain) {
344
- const currentPath = window.location.pathname;
345
-
346
- // Detectar loops infinitos: si ya visitamos esta ruta en la cadena de redirecciones
347
- if (redirectChain.includes(path)) {
348
- slice.logger.logError('Router', `Guard redirection loop detected: ${redirectChain.join(' → ')} → ${path}`);
349
- return;
350
- }
351
-
352
- // Límite de seguridad: máximo 10 redirecciones
353
- if (redirectChain.length >= 10) {
354
- slice.logger.logError('Router', `Too many redirections: ${redirectChain.join(' → ')} → ${path}`);
355
- return;
356
- }
357
-
358
- // Obtener información de ruta actual
359
- const { route: fromRoute, params: fromParams } = this.matchRoute(currentPath);
360
- const from = this._createRouteInfo(fromRoute, fromParams, currentPath);
361
-
362
- // Obtener información de ruta destino
363
- const { route: toRoute, params: toParams } = this.matchRoute(path);
364
- const to = this._createRouteInfo(toRoute, toParams, path);
365
-
366
- // EJECUTAR BEFORE EACH GUARD
367
- const guardResult = await this._executeBeforeEachGuard(to, from);
368
-
369
- // Si el guard redirige
370
- if (guardResult && guardResult.path) {
371
- return this._navigateWithGuards(guardResult.path, guardResult.options || {}, [...redirectChain, path]);
372
- }
373
-
374
- // Si el guard cancela la navegación (next(false))
375
- if (guardResult && guardResult.path === false) {
376
- slice.logger.logInfo('Router', 'Navigation cancelled by guard');
377
- return;
378
- }
379
-
380
- // No hay redirección - continuar con la navegación normal
381
- // Usar replace o push según las opciones
382
- if (options.replace) {
383
- window.history.replaceState({}, path, window.location.origin + path);
384
- } else {
385
- window.history.pushState({}, path, window.location.origin + path);
386
- }
387
-
388
- await this._performNavigation(to, from);
389
- }
390
-
391
- /**
392
- * Método interno para ejecutar la navegación después de pasar los guards
393
- * @param {Object} to - Información de ruta destino
394
- * @param {Object} from - Información de ruta origen
395
- */
396
- /**
397
- * Perform navigation after guards.
398
- * @param {RouteInfo} to
399
- * @param {RouteInfo} from
400
- * @returns {Promise<void>}
401
- */
402
- async _performNavigation(to, from) {
403
- // Renderizar la nueva ruta
404
- await this.onRouteChange();
405
-
406
- // EJECUTAR AFTER EACH GUARD
407
- this._executeAfterEachGuard(to, from);
408
-
409
- // Emitir evento de cambio de ruta
410
- this._emitRouteChange(to, from);
411
- }
412
-
413
- /**
414
- * React to URL changes and render routes.
415
- * @returns {Promise<void>}
416
- */
417
- async onRouteChange() {
418
- if (this.routeChangeTimeout) {
419
- clearTimeout(this.routeChangeTimeout);
420
- // This call supersedes a still-pending one; resolve the superseded
421
- // promise so anything awaiting it doesn't hang — the new call does the
422
- // real work.
423
- if (this._routeChangeResolve) {
424
- this._routeChangeResolve();
425
- this._routeChangeResolve = null;
426
- }
427
- }
428
-
429
- // Resolve only AFTER the debounced route handling finishes, so a caller
430
- // that awaits this (e.g. _performNavigation) sees slice.router.activeRoute
431
- // already updated before it emits router:change. (Previously the awaited
432
- // promise resolved the instant the timer was scheduled, so router:change
433
- // fired ~10ms before activeRoute was set.)
434
- return new Promise((resolve) => {
435
- this._routeChangeResolve = resolve;
436
- this.routeChangeTimeout = setTimeout(async () => {
437
- try {
438
- const path = window.location.pathname;
439
- const routeContainersFlag = await this.renderRoutesComponentsInPage();
440
-
441
- if (routeContainersFlag) {
442
- return;
443
- }
444
-
445
- const { route, params } = this.matchRoute(path);
446
- if (route) {
447
- await this.handleRoute(route, params);
448
- }
449
- } catch (error) {
450
- slice.logger.error('Router', `Route change failed for ${window.location.pathname}`, error);
451
- } finally {
452
- this._routeChangeResolve = null;
453
- resolve();
454
- }
455
- }, 10);
456
- });
457
- }
458
-
459
- /**
460
- * Build or update the active route component.
461
- * @param {RouteConfig} route
462
- * @param {Object} params
463
- * @returns {Promise<void>}
464
- */
465
- async handleRoute(route, params) {
466
- // Titles are on by default: without this, a SPA keeps the first page's
467
- // title while the user navigates. `sliceConfig.router.updateTitles: false`
468
- // opts out; a `router:change` listener still wins, because this runs
469
- // before the event.
470
- if (slice.routerConfig?.updateTitles !== false) {
471
- applyRouteMetadata(route?.metadata);
472
- }
473
-
474
- const targetElement = document.querySelector('#app');
475
-
476
- const componentName = route.parentRoute ? route.parentRoute.component : route.component;
477
- const sliceId = `route-${componentName}`;
478
-
479
- if (!targetElement) {
480
- slice.logger.logError('Router', `Cannot render route "${route?.path ?? componentName}": no #app element in the document`);
481
- return;
482
- }
483
-
484
- const existingComponent = slice.controller.getComponent(sliceId);
485
-
486
- if (slice.loading) {
487
- slice.loading.start();
488
- }
489
-
490
- try {
491
- if (existingComponent) {
492
- targetElement.innerHTML = '';
493
- if (existingComponent.update) {
494
- existingComponent.props = { ...existingComponent.props, ...params };
495
- await existingComponent.update();
496
- }
497
- targetElement.appendChild(existingComponent);
498
- await this.renderRoutesInComponent(existingComponent);
499
- } else {
500
- const component = await slice.build(componentName, {
501
- params,
502
- sliceId: sliceId,
503
- });
504
-
505
- // build() returns null whenever the component could not be produced
506
- // (not registered, a resource fetch failed or was aborted, init()
507
- // threw). Appending null raises "parameter 1 is not of type 'Node'",
508
- // which buries the real cause. Report it and keep the current view.
509
- if (!component) {
510
- slice.logger.logError(
511
- 'Router',
512
- `Cannot render route "${route?.path ?? componentName}": slice.build('${componentName}') returned null`
513
- );
514
- return;
515
- }
516
-
517
- targetElement.innerHTML = '';
518
- targetElement.appendChild(component);
519
-
520
- await this.renderRoutesInComponent(component);
521
- }
522
-
523
- // Invalidar caché después de cambios importantes en el DOM
524
- this.invalidateCache();
525
- slice.router.activeRoute = route;
526
- } finally {
527
- if (slice.loading) {
528
- slice.loading.stop();
529
- }
530
- }
531
- }
532
-
533
- /**
534
- * Load initial route and run guards.
535
- * @returns {Promise<void>}
536
- */
537
- async loadInitialRoute() {
538
- const path = window.location.pathname;
539
- const { route, params } = this.matchRoute(path);
540
-
541
- // Para la carga inicial, también ejecutar guards
542
- const from = this._createRouteInfo(null, {}, null);
543
- const to = this._createRouteInfo(route, params, path);
544
-
545
- // EJECUTAR BEFORE EACH GUARD en carga inicial
546
- const guardResult = await this._executeBeforeEachGuard(to, from);
547
-
548
- if (guardResult && guardResult.path) {
549
- return this.navigate(guardResult.path, guardResult.options || {});
550
- }
551
-
552
- // Si el guard cancela la navegación inicial (caso raro pero posible)
553
- if (guardResult && guardResult.path === false) {
554
- slice.logger.logWarning('Router', 'Initial route navigation cancelled by guard');
555
- return;
556
- }
557
-
558
- await this.handleRoute(route, params);
559
-
560
- // EJECUTAR AFTER EACH GUARD en carga inicial
561
- this._executeAfterEachGuard(to, from);
562
-
563
- // Emitir evento de cambio de ruta
564
- this._emitRouteChange(to, from);
565
- }
566
-
567
- /**
568
- * Emitir evento de cambio de ruta
569
- * @param {Object} to
570
- * @param {Object} from
571
- */
572
- /**
573
- * Emit route change event.
574
- * @param {RouteInfo} to
575
- * @param {RouteInfo} from
576
- * @returns {void}
577
- */
578
- _emitRouteChange(to, from) {
579
- const payload = { to, from };
580
-
581
- if (slice.eventsConfig?.enabled && slice.events && typeof slice.events.emit === 'function') {
582
- slice.events.emit('router:change', payload);
583
- return;
584
- }
585
-
586
- window.dispatchEvent(new CustomEvent('router:change', { detail: payload }));
587
- }
588
-
589
- // ============================================
590
- // MÉTODOS EXISTENTES (SIN CAMBIOS)
591
- // ============================================
592
-
593
- setupMutationObserver() {
594
- if (typeof MutationObserver !== 'undefined') {
595
- this.observer = new MutationObserver((mutations) => {
596
- try {
597
- let shouldInvalidateCache = false;
598
-
599
- mutations.forEach((mutation) => {
600
- if (mutation.type === 'childList') {
601
- const addedNodes = Array.from(mutation.addedNodes || []);
602
- const removedNodes = Array.from(mutation.removedNodes || []);
603
-
604
- const hasRouteNodes = [...addedNodes, ...removedNodes].some(
605
- (node) =>
606
- node.nodeType === Node.ELEMENT_NODE &&
607
- (node.tagName === 'SLICE-ROUTE' ||
608
- node.tagName === 'SLICE-MULTI-ROUTE' ||
609
- node.querySelector?.('slice-route, slice-multi-route'))
610
- );
611
-
612
- if (hasRouteNodes) {
613
- shouldInvalidateCache = true;
614
- }
615
- }
616
- });
617
-
618
- if (shouldInvalidateCache) {
619
- this.invalidateCache();
620
- }
621
- } catch (error) {
622
- slice.logger.error('Router', 'Error in MutationObserver callback', error);
623
- }
624
- });
625
-
626
- this.observer.observe(document.body, {
627
- childList: true,
628
- subtree: true,
629
- });
630
- }
631
- }
632
-
633
- invalidateCache() {
634
- this.routeContainersCache.clear();
635
- this.lastCacheUpdate = 0;
636
- }
637
-
638
- createPathToRouteMap(routes, basePath = '', parentRoute = null) {
639
- const pathToRouteMap = new Map();
640
-
641
- for (const route of routes) {
642
- const fullPath = `${basePath}${route.path}`.replace(/\/+/g, '/');
643
-
644
- const routeWithParent = {
645
- ...route,
646
- fullPath,
647
- parentPath: parentRoute ? parentRoute.fullPath : null,
648
- parentRoute: parentRoute,
649
- };
650
-
651
- // Compile parameterized patterns once at map-build time instead of on every
652
- // navigation. Static routes leave `compiled` undefined.
653
- if (fullPath.includes('${')) {
654
- routeWithParent.compiled = this.compilePathPattern(fullPath);
655
- }
656
-
657
- pathToRouteMap.set(fullPath, routeWithParent);
658
-
659
- if (route.children) {
660
- const childPathToRouteMap = this.createPathToRouteMap(route.children, fullPath, routeWithParent);
661
-
662
- for (const [childPath, childRoute] of childPathToRouteMap.entries()) {
663
- pathToRouteMap.set(childPath, childRoute);
664
- }
665
- }
666
- }
667
-
668
- return pathToRouteMap;
669
- }
670
-
671
- /**
672
- * Render any Route/MultiRoute components in a container.
673
- * @param {Document|HTMLElement} [searchContainer]
674
- * @returns {Promise<boolean>}
675
- */
676
- async renderRoutesComponentsInPage(searchContainer = document) {
677
- let routerContainersFlag = false;
678
- const routeContainers = this.getCachedRouteContainers(searchContainer);
679
-
680
- for (const routeContainer of routeContainers) {
681
- try {
682
- if (!routeContainer.isConnected) {
683
- this.invalidateCache();
684
- continue;
685
- }
686
-
687
- let response = await routeContainer.renderIfCurrentRoute();
688
- if (response) {
689
- this.activeRoute = routeContainer.props;
690
- routerContainersFlag = true;
691
- }
692
- } catch (error) {
693
- const containerName = routeContainer?.tagName || routeContainer?.constructor?.name || 'unknown';
694
- slice.logger.error('Router', `Error rendering route container "${containerName}"`, error);
695
- }
696
- }
697
-
698
- return routerContainersFlag;
699
- }
700
-
701
- getCachedRouteContainers(container) {
702
- const containerKey = container === document ? 'document' : container.sliceId || 'anonymous';
703
- const now = Date.now();
704
-
705
- if (this.routeContainersCache.has(containerKey) && now - this.lastCacheUpdate < this.CACHE_DURATION) {
706
- return this.routeContainersCache.get(containerKey);
707
- }
708
-
709
- const routeContainers = this.findAllRouteContainersOptimized(container);
710
- this.routeContainersCache.set(containerKey, routeContainers);
711
- this.lastCacheUpdate = now;
712
-
713
- return routeContainers;
714
- }
715
-
716
- findAllRouteContainersOptimized(container) {
717
- const routeContainers = [];
718
-
719
- const walker = document.createTreeWalker(container, NodeFilter.SHOW_ELEMENT, {
720
- acceptNode: (node) => {
721
- if (node.tagName === 'SLICE-ROUTE' || node.tagName === 'SLICE-MULTI-ROUTE') {
722
- return NodeFilter.FILTER_ACCEPT;
723
- }
724
- return NodeFilter.FILTER_SKIP;
725
- },
726
- });
727
-
728
- let node;
729
- while ((node = walker.nextNode())) {
730
- routeContainers.push(node);
731
- }
732
-
733
- return routeContainers;
734
- }
735
-
736
- /**
737
- * Render route containers inside a component.
738
- * @param {HTMLElement} component
739
- * @returns {Promise<boolean>}
740
- */
741
- async renderRoutesInComponent(component) {
742
- if (!component) {
743
- slice.logger.logWarning('Router', 'No component provided for route rendering');
744
- return false;
745
- }
746
-
747
- return await this.renderRoutesComponentsInPage(component);
748
- }
749
-
750
- /**
751
- * Match a path to a configured route.
752
- * @param {string} path
753
- * @returns {RouteMatch}
754
- */
755
- matchRoute(path) {
756
- // Normalize a trailing slash ('/about/' -> '/about'); keep root '/' as-is.
757
- path = path.length > 1 ? path.replace(/\/+$/, '') : path;
758
- // Exact match first (fast path), then a case-insensitive match on static paths
759
- // so '/About' resolves to a route declared as '/about'.
760
- let exactMatch = this.pathToRouteMap.get(path);
761
- if (!exactMatch) {
762
- exactMatch = this._staticLowerIndex.get(path.toLowerCase());
763
- }
764
- if (exactMatch) {
765
- if (exactMatch.parentRoute) {
766
- return {
767
- route: exactMatch.parentRoute,
768
- params: {},
769
- childRoute: exactMatch,
770
- };
771
- }
772
- return { route: exactMatch, params: {} };
773
- }
774
-
775
- for (const route of this.pathToRouteMap.values()) {
776
- if (route.compiled) {
777
- const { regex, paramNames } = route.compiled;
778
- const match = path.match(regex);
779
- if (match) {
780
- const params = {};
781
- paramNames.forEach((name, i) => {
782
- params[name] = match[i + 1];
783
- });
784
-
785
- if (route.parentRoute) {
786
- return {
787
- route: route.parentRoute,
788
- params: params,
789
- childRoute: route,
790
- };
791
- }
792
-
793
- return { route, params };
794
- }
795
- }
796
- }
797
-
798
- const notFoundRoute = this.pathToRouteMap.get('/404');
799
- return { route: notFoundRoute, params: {} };
800
- }
801
-
802
- /**
803
- * Compile a path pattern with ${param} segments.
804
- * @param {string} pattern
805
- * @returns {{ regex: RegExp, paramNames: string[] }}
806
- */
807
- compilePathPattern(pattern) {
808
- const paramNames = [];
809
- const regexPattern =
810
- '^' +
811
- pattern.replace(/\$\{([^}]+)\}/g, (_, paramName) => {
812
- paramNames.push(paramName);
813
- return '([^/]+)';
814
- }) +
815
- '$';
816
-
817
- // 'i' flag: paths match case-insensitively. Captured param values keep their original case.
818
- return { regex: new RegExp(regexPattern, 'i'), paramNames };
819
- }
820
- }
1
+ import { applyRouteMetadata } from './applyRouteMetadata.js';
2
+
3
+ /**
4
+ * @typedef {Object} RouteConfig
5
+ * @property {string} path
6
+ * @property {string} component
7
+ * @property {RouteConfig[]} [children]
8
+ * @property {Object} [metadata]
9
+ * @property {string} [fullPath]
10
+ * @property {string|null} [parentPath]
11
+ * @property {RouteConfig|null} [parentRoute]
12
+ */
13
+
14
+ /**
15
+ * @typedef {Object} RouteInfo
16
+ * @property {string} path the app path, without locale prefix
17
+ * @property {string} component
18
+ * @property {Object} params
19
+ * @property {Object} query
20
+ * @property {Object} metadata
21
+ * @property {string|null} locale the locale of this navigation (null without slice.translator)
22
+ * @property {string} url what the address bar shows (`/en/pricing` with routing)
23
+ */
24
+
25
+ /**
26
+ * @typedef {Object} GuardRedirect
27
+ * @property {string} path
28
+ * @property {boolean} [replace]
29
+ */
30
+
31
+ /**
32
+ * @typedef {Object} RouteMatch
33
+ * @property {RouteConfig|null} route
34
+ * @property {Object} params
35
+ * @property {RouteConfig} [childRoute]
36
+ */
37
+
38
+ /**
39
+ * @callback RouterNext
40
+ * @param {void|false|string|{ path: string, replace?: boolean }} [arg]
41
+ * @returns {void}
42
+ */
43
+
44
+ export default class Router {
45
+ /**
46
+ * @param {RouteConfig[]} routes
47
+ */
48
+ constructor(routes) {
49
+ this.routes = routes;
50
+ this.activeRoute = null;
51
+ /** @type {Object|null} metadata of the route being shown */
52
+ this.activeMetadata = null;
53
+ this.pathToRouteMap = this.createPathToRouteMap(routes);
54
+
55
+ // O(1) case-insensitive lookup for static routes, so a miss on the exact map
56
+ // doesn't scan every route on each navigation. Param routes (which carry a
57
+ // precompiled `compiled` regex) are excluded — they go through the param loop.
58
+ this._staticLowerIndex = new Map();
59
+ for (const [pattern, route] of this.pathToRouteMap.entries()) {
60
+ if (!route.compiled) this._staticLowerIndex.set(pattern.toLowerCase(), route);
61
+ }
62
+
63
+ // Navigation Guards
64
+ this._beforeEachGuard = null;
65
+ this._afterEachGuard = null;
66
+
67
+ // Router state
68
+ this._started = false;
69
+ this._autoStartTimeout = null;
70
+
71
+ // Sistema de caché optimizado
72
+ this.routeContainersCache = new Map();
73
+ this.lastCacheUpdate = 0;
74
+ this.CACHE_DURATION = 100; // ms - caché muy corto pero efectivo
75
+
76
+ // Observer para invalidar caché automáticamente
77
+ this.setupMutationObserver();
78
+ }
79
+
80
+ /**
81
+ * Inicializa el router
82
+ * Si el usuario no llama start() manualmente, se auto-inicia despues de un delay
83
+ * @returns {void}
84
+ */
85
+ init() {
86
+ window.addEventListener('popstate', () => {
87
+ try {
88
+ this.onRouteChange();
89
+ } catch (error) {
90
+ slice.logger.error('Router', 'Error in popstate handler', error);
91
+ }
92
+ });
93
+
94
+ this._autoStartTimeout = setTimeout(async () => {
95
+ if (!this._started) {
96
+ try {
97
+ slice.logger.info('Router', 'Auto-starting router (no manual start() called)');
98
+ await this.start();
99
+ } catch (error) {
100
+ slice.logger.error('Router', 'Auto-start failed', error);
101
+ }
102
+ }
103
+ }, 50);
104
+ }
105
+
106
+ /**
107
+ * Inicia el router y carga la ruta inicial
108
+ * OPCIONAL: Solo necesario si usas guards (beforeEach/afterEach)
109
+ * Si no lo llamas, el router se auto-inicia despues de 50ms
110
+ * @returns {Promise<void>}
111
+ */
112
+ async start() {
113
+ // Prevenir múltiples llamadas
114
+ if (this._started) {
115
+ slice.logger.logWarning('Router', 'start() already called');
116
+ return;
117
+ }
118
+
119
+ // Cancelar auto-start si existe
120
+ if (this._autoStartTimeout) {
121
+ clearTimeout(this._autoStartTimeout);
122
+ this._autoStartTimeout = null;
123
+ }
124
+
125
+ this._started = true;
126
+ await this.loadInitialRoute();
127
+ }
128
+
129
+ // ============================================
130
+ // URL API — the router is the only reader of the URL
131
+ // ============================================
132
+
133
+ /**
134
+ * The current app path: `window.location.pathname` without the locale prefix
135
+ * (`/en/pricing` → `/pricing`). Components compare this with their routes instead of
136
+ * reading `window.location` themselves.
137
+ * @returns {string}
138
+ */
139
+ get path() {
140
+ return this._appPath(window.location.pathname);
141
+ }
142
+
143
+ /**
144
+ * The URL to put in an `<a href>` for an app path: `/pricing` → `/en/pricing` when the
145
+ * locale is in the URL, `/pricing` otherwise. `{ locale }` links to another language.
146
+ * External URLs, fragments and relative paths are returned as given.
147
+ * @param {string} path
148
+ * @param {{ locale?: string }} [options]
149
+ * @returns {string}
150
+ */
151
+ href(path, options = {}) {
152
+ const translator = slice.translator;
153
+ return typeof translator?.localizePath === 'function' ? translator.localizePath(path, options.locale) : path;
154
+ }
155
+
156
+ /**
157
+ * @param {string} url
158
+ * @returns {string}
159
+ */
160
+ _appPath(url) {
161
+ const translator = slice.translator;
162
+ return typeof translator?.splitPath === 'function' ? translator.splitPath(url).path : url;
163
+ }
164
+
165
+ /**
166
+ * The locale a navigation lands in and the URL it shows.
167
+ * @param {string} appPath
168
+ * @param {string|null} [locale]
169
+ * @returns {{ locale: string|null, url: string }}
170
+ */
171
+ _localeAndUrl(appPath, locale = null) {
172
+ const translator = slice.translator;
173
+ const active = translator?.enabled ? locale || translator.locale : null;
174
+ return { locale: active, url: this.href(appPath, active ? { locale: active } : {}) };
175
+ }
176
+
177
+ // ============================================
178
+ // NAVIGATION GUARDS API
179
+ // ============================================
180
+
181
+ /**
182
+ * Registra un guard que se ejecuta ANTES de cada navegacion.
183
+ * Puede bloquear o redirigir la navegacion mediante next().
184
+ * @param {(to: RouteInfo, from: RouteInfo, next: RouterNext) => void|Promise<void>} guard
185
+ * @returns {void}
186
+ */
187
+ beforeEach(guard) {
188
+ if (typeof guard !== 'function') {
189
+ slice.logger.logError('Router', 'beforeEach expects a function');
190
+ return;
191
+ }
192
+ this._beforeEachGuard = guard;
193
+ }
194
+
195
+ /**
196
+ * Registra un guard que se ejecuta DESPUES de cada navegacion.
197
+ * No puede bloquear la navegacion.
198
+ * @param {(to: RouteInfo, from: RouteInfo) => void} guard
199
+ * @returns {void}
200
+ */
201
+ afterEach(guard) {
202
+ if (typeof guard !== 'function') {
203
+ slice.logger.logError('Router', 'afterEach expects a function');
204
+ return;
205
+ }
206
+ this._afterEachGuard = guard;
207
+ }
208
+
209
+ /**
210
+ * Crea un objeto con información de ruta para los guards
211
+ * @param {Object} route - Objeto de ruta
212
+ * @param {Object} params - Parámetros de la ruta
213
+ * @param {String} requestedPath - Path original solicitado
214
+ * @returns {Object} Objeto con path, component, params, query, metadata
215
+ */
216
+ /**
217
+ * Build route info used by guards and events.
218
+ * @param {RouteConfig|null} route
219
+ * @param {Object} [params]
220
+ * @param {string|null} [requestedPath]
221
+ * @returns {RouteInfo}
222
+ */
223
+ _createRouteInfo(route, params = {}, requestedPath = null, locale = undefined) {
224
+ const path = route ? requestedPath || route.fullPath || route.path : requestedPath || '/404';
225
+ const where = this._localeAndUrl(path, locale);
226
+
227
+ if (!route) {
228
+ return {
229
+ path,
230
+ component: 'NotFound',
231
+ params: {},
232
+ query: this._parseQueryParams(),
233
+ metadata: {},
234
+ locale: where.locale,
235
+ url: where.url,
236
+ };
237
+ }
238
+
239
+ return {
240
+ path,
241
+ component: route.parentRoute ? route.parentRoute.component : route.component,
242
+ params: params,
243
+ query: this._parseQueryParams(),
244
+ metadata: route.metadata || {},
245
+ locale: where.locale,
246
+ url: where.url,
247
+ };
248
+ }
249
+
250
+ /**
251
+ * Parsea los query parameters de la URL actual
252
+ * @returns {Object} Objeto con los query params
253
+ */
254
+ /**
255
+ * Parse query params from current URL.
256
+ * @returns {Object}
257
+ */
258
+ _parseQueryParams() {
259
+ const queryString = window.location.search;
260
+ if (!queryString) return {};
261
+
262
+ const params = {};
263
+ const urlParams = new URLSearchParams(queryString);
264
+
265
+ for (const [key, value] of urlParams) {
266
+ params[key] = value;
267
+ }
268
+
269
+ return params;
270
+ }
271
+
272
+ /**
273
+ * Ejecuta el beforeEach guard si existe
274
+ * @param {Object} to - Información de ruta destino
275
+ * @param {Object} from - Información de ruta origen
276
+ * @returns {Object|null} Objeto con redirectPath y options, o null si continúa
277
+ */
278
+ /**
279
+ * Execute beforeEach guard if defined.
280
+ * @param {RouteInfo} to
281
+ * @param {RouteInfo} from
282
+ * @returns {Promise<{ path: string|false, options: { replace?: boolean } }|null>}
283
+ */
284
+ async _executeBeforeEachGuard(to, from) {
285
+ if (!this._beforeEachGuard) {
286
+ return null;
287
+ }
288
+
289
+ let redirectPath = null;
290
+ let redirectOptions = {};
291
+ let nextCalled = false;
292
+
293
+ const next = (arg) => {
294
+ if (nextCalled) {
295
+ slice.logger.logWarning('Router', 'next() called multiple times in guard');
296
+ return;
297
+ }
298
+ nextCalled = true;
299
+
300
+ // Caso 1: Sin argumentos - continuar navegación
301
+ if (arg === undefined) {
302
+ return;
303
+ }
304
+
305
+ // Caso 2: false - cancelar navegación
306
+ if (arg === false) {
307
+ redirectPath = false;
308
+ return;
309
+ }
310
+
311
+ // Caso 3: String - redirección simple (backward compatibility)
312
+ if (typeof arg === 'string') {
313
+ redirectPath = arg;
314
+ redirectOptions = { replace: false };
315
+ return;
316
+ }
317
+
318
+ // Caso 4: Objeto - redirección con opciones
319
+ if (typeof arg === 'object' && arg.path) {
320
+ redirectPath = arg.path;
321
+ redirectOptions = {
322
+ replace: arg.replace || false,
323
+ };
324
+ return;
325
+ }
326
+
327
+ // Argumento inválido
328
+ slice.logger.logError(
329
+ 'Router',
330
+ 'Invalid argument passed to next(). Expected string, object with path, false, or undefined.'
331
+ );
332
+ };
333
+
334
+ try {
335
+ await this._beforeEachGuard(to, from, next);
336
+
337
+ // Si no se llamó next(), loguear advertencia pero continuar
338
+ if (!nextCalled) {
339
+ slice.logger.logWarning('Router', 'beforeEach guard did not call next(). Navigation will continue.');
340
+ }
341
+
342
+ // Retornar tanto el path como las opciones
343
+ if (redirectPath === false) return { path: false, options: {} };
344
+ return redirectPath ? { path: redirectPath, options: redirectOptions } : null;
345
+ } catch (error) {
346
+ slice.logger.error('Router', `Error in beforeEach guard from "${from?.path}" to "${to?.path}"`, error);
347
+ return { path: false, options: {} };
348
+ }
349
+ }
350
+
351
+ /**
352
+ * Ejecuta el afterEach guard si existe
353
+ * @param {Object} to - Información de ruta destino
354
+ * @param {Object} from - Información de ruta origen
355
+ */
356
+ /**
357
+ * Execute afterEach guard if defined.
358
+ * @param {RouteInfo} to
359
+ * @param {RouteInfo} from
360
+ * @returns {void}
361
+ */
362
+ _executeAfterEachGuard(to, from) {
363
+ if (!this._afterEachGuard) {
364
+ return;
365
+ }
366
+
367
+ try {
368
+ this._afterEachGuard(to, from);
369
+ } catch (error) {
370
+ slice.logger.logError('Router', 'Error in afterEach guard', error);
371
+ }
372
+ }
373
+
374
+ // ============================================
375
+ // ROUTING CORE (MODIFICADO CON GUARDS)
376
+ // ============================================
377
+
378
+ /**
379
+ * Navigate to a route path (guards run automatically).
380
+ *
381
+ * `path` is an app path (`/pricing`); the active locale is added to the URL when the
382
+ * translator has `routing`. `{ locale: 'en' }` navigates to another language: its
383
+ * dictionaries load before the URL changes, so the new page renders translated.
384
+ * @param {string} path
385
+ * @param {{ replace?: boolean, locale?: string }} [options] - `{ replace: true }` replaces history instead of pushing.
386
+ * @returns {Promise<void>}
387
+ */
388
+ async navigate(path, options = {}, legacyOptions) {
389
+ // Backward compatibility with the previous signature navigate(path, _redirectChain, _options):
390
+ // if the 2nd argument is the internal redirect chain (an array) or a 3rd argument is passed,
391
+ // use the 3rd argument as the options object.
392
+ if (Array.isArray(options)) {
393
+ options = legacyOptions || {};
394
+ } else if (legacyOptions !== undefined) {
395
+ options = legacyOptions || options;
396
+ }
397
+ return this._navigateWithGuards(path, options || {}, []);
398
+ }
399
+
400
+ /**
401
+ * Internal navigation that tracks the guard redirection chain (loop protection).
402
+ * @param {string} path
403
+ * @param {{ replace?: boolean }} options
404
+ * @param {string[]} redirectChain
405
+ * @returns {Promise<void>}
406
+ */
407
+ async _navigateWithGuards(path, options, redirectChain) {
408
+ const currentPath = this.path;
409
+ const translator = slice.translator;
410
+
411
+ // A path given with a prefix (`/en/pricing`) is the app path in that locale.
412
+ const split = typeof translator?.splitPath === 'function' ? translator.splitPath(path) : { locale: null, path };
413
+ path = split.path;
414
+ let targetLocale = options.locale || split.locale || null;
415
+ if (targetLocale && translator?.enabled) {
416
+ try {
417
+ targetLocale = await translator.preload(targetLocale);
418
+ } catch (error) {
419
+ slice.logger.logError('Router', `Cannot navigate to "${path}" in "${targetLocale}"`, error);
420
+ return;
421
+ }
422
+ } else {
423
+ targetLocale = null;
424
+ }
425
+
426
+ // Detectar loops infinitos: si ya visitamos esta ruta en la cadena de redirecciones
427
+ if (redirectChain.includes(path)) {
428
+ slice.logger.logError('Router', `Guard redirection loop detected: ${redirectChain.join(' → ')} → ${path}`);
429
+ return;
430
+ }
431
+
432
+ // Límite de seguridad: máximo 10 redirecciones
433
+ if (redirectChain.length >= 10) {
434
+ slice.logger.logError('Router', `Too many redirections: ${redirectChain.join(' → ')} → ${path}`);
435
+ return;
436
+ }
437
+
438
+ // Obtener información de ruta actual
439
+ const { route: fromRoute, params: fromParams } = this.matchRoute(currentPath);
440
+ const from = this._createRouteInfo(fromRoute, fromParams, currentPath);
441
+
442
+ // Obtener información de ruta destino
443
+ const { route: toRoute, params: toParams } = this.matchRoute(path);
444
+ const to = this._createRouteInfo(toRoute, toParams, path, targetLocale);
445
+
446
+ // EJECUTAR BEFORE EACH GUARD
447
+ const guardResult = await this._executeBeforeEachGuard(to, from);
448
+
449
+ // Si el guard redirige
450
+ if (guardResult && guardResult.path) {
451
+ // A guard redirect stays in the language the user was going to.
452
+ const redirectOptions = { ...(guardResult.options || {}) };
453
+ if (targetLocale && !redirectOptions.locale) redirectOptions.locale = targetLocale;
454
+ return this._navigateWithGuards(guardResult.path, redirectOptions, [...redirectChain, path]);
455
+ }
456
+
457
+ // Si el guard cancela la navegación (next(false))
458
+ if (guardResult && guardResult.path === false) {
459
+ slice.logger.logInfo('Router', 'Navigation cancelled by guard');
460
+ return;
461
+ }
462
+
463
+ // No hay redirección - continuar con la navegación normal
464
+ // Usar replace o push según las opciones
465
+ if (options.replace) {
466
+ window.history.replaceState({}, path, window.location.origin + to.url);
467
+ } else {
468
+ window.history.pushState({}, path, window.location.origin + to.url);
469
+ }
470
+
471
+ // Without routing the locale is not in the URL: switch it here. With routing,
472
+ // onRouteChange() follows the URL (dictionaries are already loaded).
473
+ if (targetLocale && !translator?.localePrefix && targetLocale !== translator.locale) {
474
+ await translator.setLocale(targetLocale);
475
+ }
476
+
477
+ await this._performNavigation(to, from);
478
+ }
479
+
480
+ /**
481
+ * Método interno para ejecutar la navegación después de pasar los guards
482
+ * @param {Object} to - Información de ruta destino
483
+ * @param {Object} from - Información de ruta origen
484
+ */
485
+ /**
486
+ * Perform navigation after guards.
487
+ * @param {RouteInfo} to
488
+ * @param {RouteInfo} from
489
+ * @returns {Promise<void>}
490
+ */
491
+ async _performNavigation(to, from) {
492
+ // Renderizar la nueva ruta
493
+ await this.onRouteChange();
494
+
495
+ // EJECUTAR AFTER EACH GUARD
496
+ this._executeAfterEachGuard(to, from);
497
+
498
+ // Emitir evento de cambio de ruta
499
+ this._emitRouteChange(to, from);
500
+ }
501
+
502
+ /**
503
+ * React to URL changes and render routes.
504
+ * @returns {Promise<void>}
505
+ */
506
+ async onRouteChange() {
507
+ if (this.routeChangeTimeout) {
508
+ clearTimeout(this.routeChangeTimeout);
509
+ // This call supersedes a still-pending one; resolve the superseded
510
+ // promise so anything awaiting it doesn't hang — the new call does the
511
+ // real work.
512
+ if (this._routeChangeResolve) {
513
+ this._routeChangeResolve();
514
+ this._routeChangeResolve = null;
515
+ }
516
+ }
517
+
518
+ // Resolve only AFTER the debounced route handling finishes, so a caller
519
+ // that awaits this (e.g. _performNavigation) sees slice.router.activeRoute
520
+ // already updated before it emits router:change. (Previously the awaited
521
+ // promise resolved the instant the timer was scheduled, so router:change
522
+ // fired ~10ms before activeRoute was set.)
523
+ return new Promise((resolve) => {
524
+ this._routeChangeResolve = resolve;
525
+ this.routeChangeTimeout = setTimeout(async () => {
526
+ try {
527
+ await this._followUrlLocale();
528
+ const path = this.path;
529
+ const routeContainersFlag = await this.renderRoutesComponentsInPage();
530
+
531
+ if (routeContainersFlag) {
532
+ // An App Shell (Route/MultiRoute in the page) owns the view, so
533
+ // handleRoute() is skipped. The title still belongs to the
534
+ // matched section: apply its metadata, and keep activeMetadata
535
+ // so the translator can re-apply it on a locale change.
536
+ this._applyMatchedMetadata(path);
537
+ return;
538
+ }
539
+
540
+ const { route, params, childRoute } = this.matchRoute(path);
541
+ if (route) {
542
+ await this.handleRoute(childRoute ? { ...childRoute, parentRoute: route } : route, params);
543
+ }
544
+ } catch (error) {
545
+ slice.logger.error('Router', `Route change failed for ${window.location.pathname}`, error);
546
+ } finally {
547
+ this._routeChangeResolve = null;
548
+ resolve();
549
+ }
550
+ }, 10);
551
+ });
552
+ }
553
+
554
+ /**
555
+ * With `routing`, the URL decides the locale: back/forward from `/en/x` to `/x`, or a
556
+ * navigation to another language, switches it before the route renders. An URL without
557
+ * the prefix "prefix" requires gets it (replaceState).
558
+ * @returns {Promise<void>}
559
+ */
560
+ async _followUrlLocale() {
561
+ const translator = slice.translator;
562
+ if (!translator?.localePrefix) return;
563
+ const urlLocale = translator.localeOfUrl(window.location.pathname);
564
+ if (urlLocale && urlLocale !== translator.locale) {
565
+ try {
566
+ await translator.setLocale(urlLocale);
567
+ } catch (error) {
568
+ slice.logger.logError('Router', `Could not switch to "${urlLocale}" from the URL`, error);
569
+ }
570
+ }
571
+ translator.syncUrl();
572
+ }
573
+
574
+ /**
575
+ * Write a route's metadata (title, description) to the document. `{ "t": "key" }`
576
+ * fields are translated with slice.translator.
577
+ * @param {Object|null} metadata
578
+ * @returns {void}
579
+ */
580
+ applyMetadata(metadata) {
581
+ applyRouteMetadata(metadata);
582
+ }
583
+
584
+ /**
585
+ * Apply the metadata of the route that owns `path`, without rendering it.
586
+ * Used when an App Shell (Route/MultiRoute in the page) owns the view: the
587
+ * section title (and any `{ t }` key) must still follow the URL, and
588
+ * `activeMetadata` is what the translator re-applies after a locale change.
589
+ * @param {string} path
590
+ * @returns {void}
591
+ */
592
+ _applyMatchedMetadata(path) {
593
+ const match = this.matchRoute(path);
594
+ // A nested route renders through its parent shell, but the title belongs
595
+ // to the leaf the user navigated to.
596
+ const route = match.childRoute || match.route;
597
+ if (!route) return;
598
+ this.activeMetadata = route.metadata ?? null;
599
+ if (slice.routerConfig?.updateTitles !== false) {
600
+ this.applyMetadata(this.activeMetadata);
601
+ }
602
+ }
603
+
604
+ /**
605
+ * Build or update the active route component.
606
+ * @param {RouteConfig} route
607
+ * @param {Object} params
608
+ * @returns {Promise<void>}
609
+ */
610
+ async handleRoute(route, params) {
611
+ if (!route) {
612
+ slice.logger.logError('Router', `No route matches "${this.path}" and no /404 route is declared`);
613
+ return;
614
+ }
615
+ // Titles are on by default: without this, a SPA keeps the first page's
616
+ // title while the user navigates. `sliceConfig.router.updateTitles: false`
617
+ // opts out; a `router:change` listener still wins, because this runs
618
+ // before the event.
619
+ // Kept so slice.translator can re-apply it (translated) after a locale change.
620
+ this.activeMetadata = route?.metadata ?? null;
621
+ if (slice.routerConfig?.updateTitles !== false) {
622
+ this.applyMetadata(this.activeMetadata);
623
+ }
624
+
625
+ const targetElement = document.querySelector('#app');
626
+
627
+ const componentName = route.parentRoute ? route.parentRoute.component : route.component;
628
+ const sliceId = `route-${componentName}`;
629
+
630
+ if (!targetElement) {
631
+ slice.logger.logError('Router', `Cannot render route "${route?.path ?? componentName}": no #app element in the document`);
632
+ return;
633
+ }
634
+
635
+ const existingComponent = slice.controller.getComponent(sliceId);
636
+
637
+ if (slice.loading) {
638
+ slice.loading.start();
639
+ }
640
+
641
+ try {
642
+ if (existingComponent) {
643
+ targetElement.innerHTML = '';
644
+ if (existingComponent.update) {
645
+ existingComponent.props = { ...existingComponent.props, ...params };
646
+ await existingComponent.update();
647
+ }
648
+ targetElement.appendChild(existingComponent);
649
+ await this.renderRoutesInComponent(existingComponent);
650
+ } else {
651
+ const component = await slice.build(componentName, {
652
+ params,
653
+ sliceId: sliceId,
654
+ });
655
+
656
+ // build() returns null whenever the component could not be produced
657
+ // (not registered, a resource fetch failed or was aborted, init()
658
+ // threw). Appending null raises "parameter 1 is not of type 'Node'",
659
+ // which buries the real cause. Report it and keep the current view.
660
+ if (!component) {
661
+ slice.logger.logError(
662
+ 'Router',
663
+ `Cannot render route "${route?.path ?? componentName}": slice.build('${componentName}') returned null`
664
+ );
665
+ return;
666
+ }
667
+
668
+ targetElement.innerHTML = '';
669
+ targetElement.appendChild(component);
670
+
671
+ await this.renderRoutesInComponent(component);
672
+ }
673
+
674
+ // Invalidar caché después de cambios importantes en el DOM
675
+ this.invalidateCache();
676
+ slice.router.activeRoute = route;
677
+ } finally {
678
+ if (slice.loading) {
679
+ slice.loading.stop();
680
+ }
681
+ }
682
+ }
683
+
684
+ /**
685
+ * Load initial route and run guards.
686
+ * @returns {Promise<void>}
687
+ */
688
+ async loadInitialRoute() {
689
+ const path = this.path;
690
+ const match = this.matchRoute(path);
691
+ const { route, params } = match;
692
+ if (!route) {
693
+ slice.logger.logError('Router', `No route matches "${path}" and no /404 route is declared`);
694
+ return;
695
+ }
696
+
697
+ // Para la carga inicial, también ejecutar guards
698
+ const from = this._createRouteInfo(null, {}, null);
699
+ const to = this._createRouteInfo(route, params, path);
700
+
701
+ // EJECUTAR BEFORE EACH GUARD en carga inicial
702
+ const guardResult = await this._executeBeforeEachGuard(to, from);
703
+
704
+ if (guardResult && guardResult.path) {
705
+ return this.navigate(guardResult.path, guardResult.options || {});
706
+ }
707
+
708
+ // Si el guard cancela la navegación inicial (caso raro pero posible)
709
+ if (guardResult && guardResult.path === false) {
710
+ slice.logger.logWarning('Router', 'Initial route navigation cancelled by guard');
711
+ return;
712
+ }
713
+
714
+ await this.handleRoute(match.childRoute ? { ...match.childRoute, parentRoute: route } : route, params);
715
+
716
+ // EJECUTAR AFTER EACH GUARD en carga inicial
717
+ this._executeAfterEachGuard(to, from);
718
+
719
+ // Emitir evento de cambio de ruta
720
+ this._emitRouteChange(to, from);
721
+ }
722
+
723
+ /**
724
+ * Emitir evento de cambio de ruta
725
+ * @param {Object} to
726
+ * @param {Object} from
727
+ */
728
+ /**
729
+ * Emit route change event.
730
+ * @param {RouteInfo} to
731
+ * @param {RouteInfo} from
732
+ * @returns {void}
733
+ */
734
+ _emitRouteChange(to, from) {
735
+ const payload = { to, from };
736
+
737
+ if (slice.eventsConfig?.enabled && slice.events && typeof slice.events.emit === 'function') {
738
+ slice.events.emit('router:change', payload);
739
+ return;
740
+ }
741
+
742
+ window.dispatchEvent(new CustomEvent('router:change', { detail: payload }));
743
+ }
744
+
745
+ // ============================================
746
+ // MÉTODOS EXISTENTES (SIN CAMBIOS)
747
+ // ============================================
748
+
749
+ setupMutationObserver() {
750
+ if (typeof MutationObserver !== 'undefined') {
751
+ this.observer = new MutationObserver((mutations) => {
752
+ try {
753
+ let shouldInvalidateCache = false;
754
+
755
+ mutations.forEach((mutation) => {
756
+ if (mutation.type === 'childList') {
757
+ const addedNodes = Array.from(mutation.addedNodes || []);
758
+ const removedNodes = Array.from(mutation.removedNodes || []);
759
+
760
+ const hasRouteNodes = [...addedNodes, ...removedNodes].some(
761
+ (node) =>
762
+ node.nodeType === Node.ELEMENT_NODE &&
763
+ (node.tagName === 'SLICE-ROUTE' ||
764
+ node.tagName === 'SLICE-MULTI-ROUTE' ||
765
+ node.querySelector?.('slice-route, slice-multi-route'))
766
+ );
767
+
768
+ if (hasRouteNodes) {
769
+ shouldInvalidateCache = true;
770
+ }
771
+ }
772
+ });
773
+
774
+ if (shouldInvalidateCache) {
775
+ this.invalidateCache();
776
+ }
777
+ } catch (error) {
778
+ slice.logger.error('Router', 'Error in MutationObserver callback', error);
779
+ }
780
+ });
781
+
782
+ this.observer.observe(document.body, {
783
+ childList: true,
784
+ subtree: true,
785
+ });
786
+ }
787
+ }
788
+
789
+ invalidateCache() {
790
+ this.routeContainersCache.clear();
791
+ this.lastCacheUpdate = 0;
792
+ }
793
+
794
+ createPathToRouteMap(routes, basePath = '', parentRoute = null) {
795
+ const pathToRouteMap = new Map();
796
+
797
+ for (const route of routes) {
798
+ const fullPath = `${basePath}${route.path}`.replace(/\/+/g, '/');
799
+
800
+ const routeWithParent = {
801
+ ...route,
802
+ fullPath,
803
+ parentPath: parentRoute ? parentRoute.fullPath : null,
804
+ parentRoute: parentRoute,
805
+ };
806
+
807
+ // Compile parameterized patterns once at map-build time instead of on every
808
+ // navigation. Static routes leave `compiled` undefined.
809
+ if (fullPath.includes('${')) {
810
+ routeWithParent.compiled = this.compilePathPattern(fullPath);
811
+ }
812
+
813
+ pathToRouteMap.set(fullPath, routeWithParent);
814
+
815
+ if (route.children) {
816
+ const childPathToRouteMap = this.createPathToRouteMap(route.children, fullPath, routeWithParent);
817
+
818
+ for (const [childPath, childRoute] of childPathToRouteMap.entries()) {
819
+ pathToRouteMap.set(childPath, childRoute);
820
+ }
821
+ }
822
+ }
823
+
824
+ return pathToRouteMap;
825
+ }
826
+
827
+ /**
828
+ * Render any Route/MultiRoute components in a container.
829
+ * @param {Document|HTMLElement} [searchContainer]
830
+ * @returns {Promise<boolean>}
831
+ */
832
+ async renderRoutesComponentsInPage(searchContainer = document) {
833
+ let routerContainersFlag = false;
834
+ const routeContainers = this.getCachedRouteContainers(searchContainer);
835
+
836
+ for (const routeContainer of routeContainers) {
837
+ try {
838
+ if (!routeContainer.isConnected) {
839
+ this.invalidateCache();
840
+ continue;
841
+ }
842
+
843
+ let response = await routeContainer.renderIfCurrentRoute();
844
+ if (response) {
845
+ // The container owns the view; the route object (not its props) is what
846
+ // `activeRoute` promises to callers.
847
+ const match = this.matchRoute(this.path);
848
+ this.activeRoute = match.childRoute || match.route || null;
849
+ routerContainersFlag = true;
850
+ }
851
+ } catch (error) {
852
+ const containerName = routeContainer?.tagName || routeContainer?.constructor?.name || 'unknown';
853
+ slice.logger.error('Router', `Error rendering route container "${containerName}"`, error);
854
+ }
855
+ }
856
+
857
+ return routerContainersFlag;
858
+ }
859
+
860
+ getCachedRouteContainers(container) {
861
+ const containerKey = container === document ? 'document' : container.sliceId || 'anonymous';
862
+ const now = Date.now();
863
+
864
+ if (this.routeContainersCache.has(containerKey) && now - this.lastCacheUpdate < this.CACHE_DURATION) {
865
+ return this.routeContainersCache.get(containerKey);
866
+ }
867
+
868
+ const routeContainers = this.findAllRouteContainersOptimized(container);
869
+ this.routeContainersCache.set(containerKey, routeContainers);
870
+ this.lastCacheUpdate = now;
871
+
872
+ return routeContainers;
873
+ }
874
+
875
+ findAllRouteContainersOptimized(container) {
876
+ const routeContainers = [];
877
+
878
+ const walker = document.createTreeWalker(container, NodeFilter.SHOW_ELEMENT, {
879
+ acceptNode: (node) => {
880
+ if (node.tagName === 'SLICE-ROUTE' || node.tagName === 'SLICE-MULTI-ROUTE') {
881
+ return NodeFilter.FILTER_ACCEPT;
882
+ }
883
+ return NodeFilter.FILTER_SKIP;
884
+ },
885
+ });
886
+
887
+ let node;
888
+ while ((node = walker.nextNode())) {
889
+ routeContainers.push(node);
890
+ }
891
+
892
+ return routeContainers;
893
+ }
894
+
895
+ /**
896
+ * Render route containers inside a component.
897
+ * @param {HTMLElement} component
898
+ * @returns {Promise<boolean>}
899
+ */
900
+ async renderRoutesInComponent(component) {
901
+ if (!component) {
902
+ slice.logger.logWarning('Router', 'No component provided for route rendering');
903
+ return false;
904
+ }
905
+
906
+ return await this.renderRoutesComponentsInPage(component);
907
+ }
908
+
909
+ /**
910
+ * Match a path to a configured route.
911
+ * @param {string} path
912
+ * @returns {RouteMatch}
913
+ */
914
+ matchRoute(path) {
915
+ // Routes are declared without locale: drop a known prefix ('/en/about' -> '/about'),
916
+ // and the query / hash, which never take part in matching.
917
+ path = this._appPath(String(path || '/')).split(/[?#]/)[0] || '/';
918
+ // Normalize a trailing slash ('/about/' -> '/about'); keep root '/' as-is.
919
+ path = path.length > 1 ? path.replace(/\/+$/, '') : path;
920
+ // Exact match first (fast path), then a case-insensitive match on static paths
921
+ // so '/About' resolves to a route declared as '/about'.
922
+ let exactMatch = this.pathToRouteMap.get(path);
923
+ if (!exactMatch) {
924
+ exactMatch = this._staticLowerIndex.get(path.toLowerCase());
925
+ }
926
+ if (exactMatch) {
927
+ if (exactMatch.parentRoute) {
928
+ return {
929
+ route: exactMatch.parentRoute,
930
+ params: {},
931
+ childRoute: exactMatch,
932
+ };
933
+ }
934
+ return { route: exactMatch, params: {} };
935
+ }
936
+
937
+ for (const route of this.pathToRouteMap.values()) {
938
+ if (route.compiled) {
939
+ const { regex, paramNames } = route.compiled;
940
+ const match = path.match(regex);
941
+ if (match) {
942
+ const params = {};
943
+ paramNames.forEach((name, i) => {
944
+ params[name] = match[i + 1];
945
+ });
946
+
947
+ if (route.parentRoute) {
948
+ return {
949
+ route: route.parentRoute,
950
+ params: params,
951
+ childRoute: route,
952
+ };
953
+ }
954
+
955
+ return { route, params };
956
+ }
957
+ }
958
+ }
959
+
960
+ const notFoundRoute = this.pathToRouteMap.get('/404');
961
+ return { route: notFoundRoute, params: {} };
962
+ }
963
+
964
+ /**
965
+ * Compile a path pattern with ${param} segments.
966
+ * @param {string} pattern
967
+ * @returns {{ regex: RegExp, paramNames: string[] }}
968
+ */
969
+ compilePathPattern(pattern) {
970
+ const paramNames = [];
971
+ const regexPattern =
972
+ '^' +
973
+ pattern.replace(/\$\{([^}]+)\}/g, (_, paramName) => {
974
+ paramNames.push(paramName);
975
+ return '([^/]+)';
976
+ }) +
977
+ '$';
978
+
979
+ // 'i' flag: paths match case-insensitively. Captured param values keep their original case.
980
+ return { regex: new RegExp(regexPattern, 'i'), paramNames };
981
+ }
982
+ }