@reforgium/presentia 2.0.0 → 2.1.1

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,6 +1,6 @@
1
1
  import * as i0 from '@angular/core';
2
- import { InjectionToken, signal, computed, inject, DestroyRef, PLATFORM_ID, afterRenderEffect, Injectable, TemplateRef, ViewContainerRef, effect, Input, Directive, input, ElementRef, Renderer2, Injector, afterNextRender, runInInjectionContext, Pipe, makeEnvironmentProviders, provideAppInitializer, LOCALE_ID } from '@angular/core';
3
- import { getChainedValue, deepEqual, compareRoutes, TRANSLATION, SELECTED_LANG, CHANGE_LANG, SELECTED_THEME, CHANGE_THEME, CURRENT_DEVICE } from '@reforgium/internal';
2
+ import { InjectionToken, signal, computed, inject, DestroyRef, PLATFORM_ID, afterRenderEffect, Injectable, TemplateRef, ViewContainerRef, effect, Input, Directive, makeEnvironmentProviders, provideAppInitializer, input, ElementRef, Renderer2, Injector, afterNextRender, runInInjectionContext, Pipe, LOCALE_ID } from '@angular/core';
3
+ import { LruCache, MemoryStorage, deepEqual, compareRoutes, getChainedValue, TRANSLATION, SELECTED_LANG, CHANGE_LANG, SELECTED_THEME, CHANGE_THEME, CURRENT_DEVICE } from '@reforgium/internal';
4
4
  import { BreakpointObserver } from '@angular/cdk/layout';
5
5
  import { isPlatformBrowser, DOCUMENT } from '@angular/common';
6
6
  import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@@ -73,10 +73,10 @@ const DEVICE_BREAKPOINTS = new InjectionToken('RE_DEVICE_BREAKPOINTS', {
73
73
  */
74
74
  class AdaptiveService {
75
75
  /** @internal Signal of the current device type. */
76
- #device = signal('desktop', ...(ngDevMode ? [{ debugName: "#device" }] : /* istanbul ignore next */ []));
76
+ #device = signal('desktop', ...(ngDevMode ? [{ debugName: "#device" }] : []));
77
77
  /** @internal Signals of the current window width and height. */
78
- #width = signal(0, ...(ngDevMode ? [{ debugName: "#width" }] : /* istanbul ignore next */ []));
79
- #height = signal(0, ...(ngDevMode ? [{ debugName: "#height" }] : /* istanbul ignore next */ []));
78
+ #width = signal(0, ...(ngDevMode ? [{ debugName: "#width" }] : []));
79
+ #height = signal(0, ...(ngDevMode ? [{ debugName: "#height" }] : []));
80
80
  /**
81
81
  * Current device type (reactive signal).
82
82
  * Possible values: `'desktop' | 'tablet' | 'mobile'`.
@@ -99,15 +99,15 @@ class AdaptiveService {
99
99
  * Computed signal indicating whether the current device is a desktop.
100
100
  * Used for conditional rendering or layout configuration.
101
101
  */
102
- isDesktop = computed(() => this.#device() === 'desktop', ...(ngDevMode ? [{ debugName: "isDesktop" }] : /* istanbul ignore next */ []));
103
- isMobile = computed(() => this.#device() === 'mobile', ...(ngDevMode ? [{ debugName: "isMobile" }] : /* istanbul ignore next */ []));
104
- isTablet = computed(() => this.#device() === 'tablet', ...(ngDevMode ? [{ debugName: "isTablet" }] : /* istanbul ignore next */ []));
105
- isDesktopSmall = computed(() => this.#device() === 'desktop-s', ...(ngDevMode ? [{ debugName: "isDesktopSmall" }] : /* istanbul ignore next */ []));
102
+ isDesktop = computed(() => this.#device() === 'desktop', ...(ngDevMode ? [{ debugName: "isDesktop" }] : []));
103
+ isMobile = computed(() => this.#device() === 'mobile', ...(ngDevMode ? [{ debugName: "isMobile" }] : []));
104
+ isTablet = computed(() => this.#device() === 'tablet', ...(ngDevMode ? [{ debugName: "isTablet" }] : []));
105
+ isDesktopSmall = computed(() => this.#device() === 'desktop-s', ...(ngDevMode ? [{ debugName: "isDesktopSmall" }] : []));
106
106
  /**
107
107
  * Computed signal determining whether the screen is in portrait orientation.
108
108
  * Returns `true` if window height is greater than width.
109
109
  */
110
- isPortrait = computed(() => this.#height() > this.#width(), ...(ngDevMode ? [{ debugName: "isPortrait" }] : /* istanbul ignore next */ []));
110
+ isPortrait = computed(() => this.#height() > this.#width(), ...(ngDevMode ? [{ debugName: "isPortrait" }] : []));
111
111
  deviceBreakpoints = inject(DEVICE_BREAKPOINTS);
112
112
  devicePriority = Object.keys(this.deviceBreakpoints);
113
113
  destroyRef = inject(DestroyRef);
@@ -200,10 +200,10 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.4", ngImpor
200
200
  * the template is automatically added or removed from the DOM.
201
201
  */
202
202
  class IfDeviceDirective {
203
- deviceInput = signal(undefined, ...(ngDevMode ? [{ debugName: "deviceInput" }] : /* istanbul ignore next */ []));
204
- atLeastInput = signal(undefined, ...(ngDevMode ? [{ debugName: "atLeastInput" }] : /* istanbul ignore next */ []));
205
- betweenInput = signal(undefined, ...(ngDevMode ? [{ debugName: "betweenInput" }] : /* istanbul ignore next */ []));
206
- inverseInput = signal(false, ...(ngDevMode ? [{ debugName: "inverseInput" }] : /* istanbul ignore next */ []));
203
+ deviceInput = signal(undefined, ...(ngDevMode ? [{ debugName: "deviceInput" }] : []));
204
+ atLeastInput = signal(undefined, ...(ngDevMode ? [{ debugName: "atLeastInput" }] : []));
205
+ betweenInput = signal(undefined, ...(ngDevMode ? [{ debugName: "betweenInput" }] : []));
206
+ inverseInput = signal(false, ...(ngDevMode ? [{ debugName: "inverseInput" }] : []));
207
207
  tpl = inject(TemplateRef);
208
208
  vcr = inject(ViewContainerRef);
209
209
  adaptive = inject(AdaptiveService);
@@ -280,6 +280,61 @@ const innerLangVal = Symbol('reInnerLangVal');
280
280
 
281
281
  const LANG_MISSING_KEY_HANDLER = new InjectionToken('RE_LANG_MISSING_KEY_HANDLER');
282
282
 
283
+ function createPresentiaStorage(strategy, storage) {
284
+ if (storage) {
285
+ return storage;
286
+ }
287
+ switch (strategy ?? 'persist') {
288
+ case 'none':
289
+ return createNoopStorage();
290
+ case 'memory':
291
+ return new MemoryStorage();
292
+ case 'session':
293
+ return createBrowserStringStorage('session');
294
+ case 'lru':
295
+ return new LruCache(1);
296
+ case 'persist':
297
+ default:
298
+ return createBrowserStringStorage('local');
299
+ }
300
+ }
301
+ function createBrowserStringStorage(kind) {
302
+ const getStorage = () => {
303
+ if (typeof globalThis === 'undefined') {
304
+ return null;
305
+ }
306
+ return kind === 'local' ? (globalThis.localStorage ?? null) : (globalThis.sessionStorage ?? null);
307
+ };
308
+ return {
309
+ get length() {
310
+ return getStorage()?.length ?? 0;
311
+ },
312
+ get(key) {
313
+ return getStorage()?.getItem(key) ?? null;
314
+ },
315
+ set(key, value) {
316
+ getStorage()?.setItem(key, value);
317
+ },
318
+ remove(key) {
319
+ getStorage()?.removeItem(key);
320
+ },
321
+ clear() {
322
+ getStorage()?.clear();
323
+ },
324
+ };
325
+ }
326
+ function createNoopStorage() {
327
+ return {
328
+ get length() {
329
+ return 0;
330
+ },
331
+ get: () => null,
332
+ set: () => undefined,
333
+ remove: () => undefined,
334
+ clear: () => undefined,
335
+ };
336
+ }
337
+
283
338
  /**
284
339
  * Optional DI token for a custom persistence adapter used by `LangService`
285
340
  * to store and retrieve the selected language (default key: `'lang'`).
@@ -291,7 +346,10 @@ const LANG_MISSING_KEY_HANDLER = new InjectionToken('RE_LANG_MISSING_KEY_HANDLER
291
346
  * { provide: LANG_PERSISTENCE_ADAPTER, useValue: sessionStorageAdapter }
292
347
  * ```
293
348
  */
294
- const LANG_PERSISTENCE_ADAPTER = new InjectionToken('RE_LANG_PERSISTENCE_ADAPTER');
349
+ const defaultLangPersistenceAdapter = createPresentiaStorage('persist');
350
+ const LANG_PERSISTENCE_ADAPTER = new InjectionToken('RE_LANG_PERSISTENCE_ADAPTER', {
351
+ factory: () => defaultLangPersistenceAdapter,
352
+ });
295
353
 
296
354
  /**
297
355
  * Injection token for providing locale configuration to the language module.
@@ -313,6 +371,160 @@ const LANG_PERSISTENCE_ADAPTER = new InjectionToken('RE_LANG_PERSISTENCE_ADAPTER
313
371
  */
314
372
  const LANG_CONFIG = new InjectionToken('RE_LANG_CONFIG');
315
373
 
374
+ function deepestActivatedRoute(route) {
375
+ let current = route;
376
+ while (current.firstChild) {
377
+ current = current.firstChild;
378
+ }
379
+ return current;
380
+ }
381
+ function deepestRouteSnapshot(snapshot) {
382
+ let current = snapshot;
383
+ while (current.firstChild) {
384
+ current = current.firstChild;
385
+ }
386
+ return current;
387
+ }
388
+ function joinUrl(segments) {
389
+ return segments.length ? segments.map((segment) => segment.path).join('/') : '';
390
+ }
391
+ function snapshotFullPath(snapshot) {
392
+ return snapshot.pathFromRoot
393
+ .map((route) => joinUrl(route.url))
394
+ .filter(Boolean)
395
+ .join('/');
396
+ }
397
+ function snapshotRoutePattern(snapshot) {
398
+ return snapshot.pathFromRoot
399
+ .map((route) => route.routeConfig?.path ?? '')
400
+ .filter(Boolean)
401
+ .join('/');
402
+ }
403
+ function snapshotMergedParams(snapshot) {
404
+ return snapshot.pathFromRoot.reduce((acc, route) => ({ ...acc, ...route.params }), {});
405
+ }
406
+ function snapshotDeepestParams(snapshot) {
407
+ const mergedParams = snapshotMergedParams(snapshot);
408
+ const keys = extractRouteParamKeys(snapshot.routeConfig?.path ?? '');
409
+ if (!keys.length) {
410
+ return {};
411
+ }
412
+ return keys.reduce((acc, key) => {
413
+ const value = mergedParams[key];
414
+ if (value !== undefined) {
415
+ acc[key] = value;
416
+ }
417
+ return acc;
418
+ }, {});
419
+ }
420
+ function snapshotMergedData(snapshot) {
421
+ return snapshot.pathFromRoot.reduce((acc, route) => ({ ...acc, ...route.data }), {});
422
+ }
423
+ function extractRouteParamKeys(path) {
424
+ return path
425
+ .split('/')
426
+ .filter((segment) => segment.startsWith(':'))
427
+ .map((segment) => segment.slice(1))
428
+ .filter(Boolean);
429
+ }
430
+
431
+ /**
432
+ * Reactive snapshot of the current route (the deepest active route).
433
+ * Updates on every `NavigationEnd` event.
434
+ */
435
+ class RouteWatcher {
436
+ router = inject(Router);
437
+ destroyRef = inject(DestroyRef);
438
+ #params = signal({}, ...(ngDevMode ? [{ debugName: "#params" }] : []));
439
+ #deepestParams = signal({}, ...(ngDevMode ? [{ debugName: "#deepestParams" }] : []));
440
+ #query = signal({}, ...(ngDevMode ? [{ debugName: "#query" }] : []));
441
+ #data = signal({}, ...(ngDevMode ? [{ debugName: "#data" }] : []));
442
+ #mergedData = signal({}, ...(ngDevMode ? [{ debugName: "#mergedData" }] : []));
443
+ #url = signal('', ...(ngDevMode ? [{ debugName: "#url" }] : []));
444
+ #routePattern = signal('', ...(ngDevMode ? [{ debugName: "#routePattern" }] : []));
445
+ #fragment = signal(null, ...(ngDevMode ? [{ debugName: "#fragment" }] : []));
446
+ /** Params merged from root to deepest route. */
447
+ params = this.#params.asReadonly();
448
+ /** Params declared on the deepest route only. */
449
+ deepestParams = this.#deepestParams.asReadonly();
450
+ /** Query params from the current navigation. */
451
+ query = this.#query.asReadonly();
452
+ /** Deepest route data only. */
453
+ data = this.#data.asReadonly();
454
+ /** Route data merged from root to deepest route. */
455
+ mergedData = this.#mergedData.asReadonly();
456
+ /** Full current url path assembled from root to deepest route. */
457
+ url = this.#url.asReadonly();
458
+ /** Current route config pattern, e.g. `orgs/:orgId/users/:id`. */
459
+ routePattern = this.#routePattern.asReadonly();
460
+ /** Current url fragment without `#`. */
461
+ fragment = this.#fragment.asReadonly();
462
+ /** Combined computed snapshot. */
463
+ state = computed(() => ({
464
+ params: this.#params(),
465
+ deepestParams: this.#deepestParams(),
466
+ query: this.#query(),
467
+ data: this.#data(),
468
+ mergedData: this.#mergedData(),
469
+ url: this.#url(),
470
+ routePattern: this.#routePattern(),
471
+ fragment: this.#fragment(),
472
+ }), ...(ngDevMode ? [{ debugName: "state" }] : []));
473
+ constructor() {
474
+ const read = () => {
475
+ const snapshot = this.deepestSnapshot();
476
+ const nextUrl = snapshotFullPath(snapshot);
477
+ const nextRoutePattern = snapshotRoutePattern(snapshot);
478
+ const nextParams = snapshotMergedParams(snapshot);
479
+ const nextDeepestParams = snapshotDeepestParams(snapshot);
480
+ const nextMergedData = snapshotMergedData(snapshot);
481
+ const nextQuery = snapshot.queryParams;
482
+ const nextData = snapshot.data;
483
+ const nextFragment = snapshot.fragment ?? null;
484
+ !deepEqual(nextParams, this.#params()) && this.#params.set(nextParams);
485
+ !deepEqual(nextDeepestParams, this.#deepestParams()) && this.#deepestParams.set(nextDeepestParams);
486
+ !deepEqual(nextQuery, this.#query()) && this.#query.set(nextQuery);
487
+ !deepEqual(nextData, this.#data()) && this.#data.set(nextData);
488
+ !deepEqual(nextMergedData, this.#mergedData()) && this.#mergedData.set(nextMergedData);
489
+ this.#url() !== nextUrl && this.#url.set(nextUrl);
490
+ this.#routePattern() !== nextRoutePattern && this.#routePattern.set(nextRoutePattern);
491
+ this.#fragment() !== nextFragment && this.#fragment.set(nextFragment);
492
+ };
493
+ read();
494
+ const subscription = this.router.events.subscribe((event) => {
495
+ if (event instanceof NavigationEnd) {
496
+ read();
497
+ }
498
+ });
499
+ this.destroyRef.onDestroy(() => subscription.unsubscribe());
500
+ }
501
+ selectData(key, strategy = 'deepest') {
502
+ return computed(() => {
503
+ const source = strategy === 'merged' ? this.#mergedData() : this.#data();
504
+ return source[key];
505
+ });
506
+ }
507
+ selectParam(key, strategy = 'merged') {
508
+ return computed(() => {
509
+ const source = strategy === 'deepest' ? this.#deepestParams() : this.#params();
510
+ return source[key];
511
+ });
512
+ }
513
+ matchesPath(path) {
514
+ const current = this.#url();
515
+ return typeof path === 'string' ? current === path : path.test(current);
516
+ }
517
+ deepestSnapshot() {
518
+ return deepestRouteSnapshot(this.router.routerState.snapshot.root);
519
+ }
520
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: RouteWatcher, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
521
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: RouteWatcher, providedIn: 'root' });
522
+ }
523
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: RouteWatcher, decorators: [{
524
+ type: Injectable,
525
+ args: [{ providedIn: 'root' }]
526
+ }], ctorParameters: () => [] });
527
+
316
528
  /**
317
529
  * @deprecated Diagnostics are usually enabled through route preload config, not by consuming this service directly.
318
530
  */
@@ -374,83 +586,204 @@ function normalizeUrlPath$1(url) {
374
586
  return normalized === '/' ? normalized : normalized.replace(/\/{2,}/g, '/');
375
587
  }
376
588
 
377
- /**
378
- * LangService provides functionality for managing and tracking language settings
379
- * and translations in the application. It is designed to handle localization needs,
380
- * loading language resources, caching translations, and dynamically applying translations
381
- * throughout the application.
382
- */
383
- class LangService {
384
- static BUILTIN_LANGS = ['ru', 'kg', 'en'];
385
- static LANG_CODE_RE = /^[a-z]{2,3}(?:-[a-z0-9]{2,8})?$/;
386
- config = inject(LANG_CONFIG);
387
- http = inject(HttpClient);
388
- isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
389
- missingKeyHandler = inject(LANG_MISSING_KEY_HANDLER, { optional: true });
390
- routeNamespaceDiagnostics = inject(RouteNamespaceDiagnosticsService, { optional: true });
391
- supportedLangSet = new Set([
392
- ...LangService.BUILTIN_LANGS,
393
- ...this.normalizeSupportedLangs(this.config.supportedLangs ?? []),
394
- ]);
395
- #lang = signal(this.getStoredLang(), ...(ngDevMode ? [{ debugName: "#lang" }] : /* istanbul ignore next */ []));
396
- #cache = signal({}, ...(ngDevMode ? [{ debugName: "#cache" }] : /* istanbul ignore next */ []));
397
- #loadedNamespaces = new Set();
398
- #pendingLoads = new Map();
399
- #pendingBatchLoads = new Map();
400
- #namespaceLoadedAt = new Map();
401
- #missingKeyFallbacks = new Map();
402
- /**
403
- * Computed property determining the current language setting.
404
- *
405
- * - If private method `#lang` returns 'kg', `config.kgValue` is checked:
406
- * - If `config.kgValue` is defined, the property will return its value.
407
- * - If `config.kgValue` is not defined, the property will return the default value 'kg'.
408
- * - For other languages (e.g. `ru`, `en`) returns source language as-is.
409
- */
410
- currentLang = computed(() => {
411
- const lang = this.#lang();
412
- return lang === 'kg' ? (this.config?.kgValue ?? 'kg') : lang;
413
- }, ...(ngDevMode ? [{ debugName: "currentLang" }] : /* istanbul ignore next */ []));
414
- /**
415
- * Extracts readonly value from private property `#lang` and assigns it to `innerLangVal`.
416
- * Expected that property `#lang` has `asReadonly` method that returns immutable representation.
417
- */
418
- [innerLangVal] = this.#lang.asReadonly();
419
- constructor() {
420
- const preload = this.config.preloadNamespaces ?? [];
421
- if (preload.length) {
422
- queueMicrotask(() => {
423
- void this.loadNamespaces(preload);
424
- });
425
- }
426
- }
427
- /**
428
- * Sets the current language for the application.
429
- *
430
- * @param {Langs | 'ky'} lang - The language to set.
431
- * Accepts predefined type `Langs` or 'ky' to set Kyrgyz language.
432
- * @return {void} Returns no value.
433
- */
434
- setLang(lang) {
435
- const langVal = this.normalizeLang(lang);
436
- if (!langVal || !this.supportedLangSet.has(langVal)) {
437
- return;
438
- }
439
- if (langVal !== this.#lang()) {
440
- this.#lang.set(langVal);
441
- if (this.isBrowser) {
442
- localStorage.setItem('lang', langVal);
589
+ const PRESENTIA_ROUTE_NAMESPACE_PRELOAD_RESOLVE_KEY = '__rePresentiaRouteNamespacePreload';
590
+ const PRESENTIA_ROUTE_NAMESPACES_DATA_KEY = 'presentiaNamespaces';
591
+ function providePresentiaRouteNamespacePreload() {
592
+ return makeEnvironmentProviders([
593
+ provideAppInitializer(() => {
594
+ const router = inject(Router, { optional: true });
595
+ const langConfig = inject(LANG_CONFIG);
596
+ const normalized = normalizeRouteNamespacePreloadConfig(langConfig.routeNamespacePreload);
597
+ if (!router || !normalized) {
598
+ return;
443
599
  }
444
- const namespaces = Array.from(this.#loadedNamespaces.values()).map((key) => this.namespaceFromKey(key));
445
- this.#loadedNamespaces.clear();
446
- this.#namespaceLoadedAt.clear();
447
- void this.loadNamespaces(namespaces);
448
- }
600
+ if (typeof ngDevMode !== 'undefined' && normalized.manifest && Object.keys(normalized.manifest).length === 0) {
601
+ // eslint-disable-next-line no-console
602
+ console.warn('[presentia] routeNamespacePreload.manifest is empty. Route-data preload still works, but manifest mode is effectively disabled.');
603
+ }
604
+ router.resetConfig(patchRoutesWithNamespacePreload(router.config, normalized));
605
+ }),
606
+ ]);
607
+ }
608
+ function normalizeRouteNamespacePreloadConfig(config) {
609
+ if (!config) {
610
+ return null;
449
611
  }
450
- get(query, params) {
451
- const value = this.getChainedValue(query);
452
- const baseValue = value ?? this.resolveMissingValue(query);
453
- if (params) {
612
+ return {
613
+ mode: config.mode ?? 'blocking',
614
+ dataKey: config.dataKey?.trim() || PRESENTIA_ROUTE_NAMESPACES_DATA_KEY,
615
+ manifest: config.manifest,
616
+ mergeStrategy: config.mergeStrategy ?? 'append',
617
+ onError: config.onError ?? 'continue',
618
+ };
619
+ }
620
+ function patchRoutesWithNamespacePreload(routes, config) {
621
+ return routes.map((route) => patchRouteWithNamespacePreload(route, config));
622
+ }
623
+ function patchRouteWithNamespacePreload(route, config) {
624
+ const nextChildren = route.children ? patchRoutesWithNamespacePreload(route.children, config) : route.children;
625
+ if (route.redirectTo) {
626
+ return nextChildren === route.children ? route : { ...route, children: nextChildren };
627
+ }
628
+ const nextResolve = {
629
+ ...(route.resolve ?? {}),
630
+ [PRESENTIA_ROUTE_NAMESPACE_PRELOAD_RESOLVE_KEY]: makeRouteNamespacePreloadResolver(config),
631
+ };
632
+ return {
633
+ ...route,
634
+ ...(nextChildren ? { children: nextChildren } : {}),
635
+ resolve: nextResolve,
636
+ };
637
+ }
638
+ function makeRouteNamespacePreloadResolver(config) {
639
+ return async (route, state) => {
640
+ const lang = inject(LangService);
641
+ const diagnostics = inject(RouteNamespaceDiagnosticsService);
642
+ const namespaces = resolveRouteNamespaces(route, state, config);
643
+ diagnostics.registerRouteNamespaces(state.url, namespaces);
644
+ if (!namespaces.length) {
645
+ return true;
646
+ }
647
+ if (config.mode === 'lazy') {
648
+ queueMicrotask(() => {
649
+ void lang.loadNamespaces(namespaces);
650
+ });
651
+ return true;
652
+ }
653
+ try {
654
+ await lang.loadNamespaces(namespaces);
655
+ }
656
+ catch (error) {
657
+ if (config.onError === 'throw') {
658
+ throw error;
659
+ }
660
+ // Keep navigation alive; runtime lazy loading remains a fallback.
661
+ }
662
+ return true;
663
+ };
664
+ }
665
+ function resolveRouteNamespaces(route, state, config) {
666
+ const dataNamespaces = readNamespacesFromRouteData(route, config.dataKey);
667
+ const manifestNamespaces = readNamespacesFromManifest(route, state, config.manifest);
668
+ if (config.mergeStrategy === 'replace' && dataNamespaces.length) {
669
+ return dataNamespaces;
670
+ }
671
+ return uniqueNamespaces([...manifestNamespaces, ...dataNamespaces]);
672
+ }
673
+ function readNamespacesFromRouteData(route, dataKey) {
674
+ return uniqueNamespaces(route.pathFromRoot.flatMap((snapshot) => {
675
+ const value = snapshot.data?.[dataKey];
676
+ return Array.isArray(value) ? value : [];
677
+ }));
678
+ }
679
+ function readNamespacesFromManifest(route, state, manifest) {
680
+ if (!manifest) {
681
+ return [];
682
+ }
683
+ const actualUrl = normalizeUrlPath(state.url);
684
+ const routePath = snapshotRouteConfigPath(route);
685
+ return uniqueNamespaces(Object.entries(manifest)
686
+ .filter(([key]) => matchesManifestKey(actualUrl, routePath, key))
687
+ .flatMap(([, namespaces]) => namespaces));
688
+ }
689
+ function matchesManifestKey(actualUrl, routePath, manifestKey) {
690
+ const normalizedKey = normalizeUrlPath(manifestKey);
691
+ return compareRoutes(actualUrl, normalizedKey) || (!!routePath && routePath === normalizedKey);
692
+ }
693
+ function snapshotRouteConfigPath(route) {
694
+ const templatePath = route.pathFromRoot
695
+ .map((item) => item.routeConfig?.path ?? '')
696
+ .filter(Boolean)
697
+ .join('/');
698
+ return normalizeUrlPath(templatePath);
699
+ }
700
+ function normalizeUrlPath(url) {
701
+ const [path] = url.split(/[?#]/, 1);
702
+ const normalized = `/${(path ?? '').replace(/^\/+|\/+$/g, '')}`;
703
+ return normalized === '/' ? normalized : normalized.replace(/\/{2,}/g, '/');
704
+ }
705
+ function uniqueNamespaces(namespaces) {
706
+ return Array.from(new Set(namespaces.map((ns) => ns.trim()).filter(Boolean)));
707
+ }
708
+
709
+ /**
710
+ * LangService provides functionality for managing and tracking language settings
711
+ * and translations in the application. It is designed to handle localization needs,
712
+ * loading language resources, caching translations, and dynamically applying translations
713
+ * throughout the application.
714
+ */
715
+ class LangService {
716
+ static BUILTIN_LANGS = ['ru', 'kg', 'en'];
717
+ static LANG_CODE_RE = /^[a-z]{2,3}(?:-[a-z0-9]{2,8})?$/;
718
+ config = inject(LANG_CONFIG);
719
+ http = inject(HttpClient);
720
+ isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
721
+ persistence = inject(LANG_PERSISTENCE_ADAPTER);
722
+ missingKeyHandler = inject(LANG_MISSING_KEY_HANDLER, { optional: true });
723
+ routeNamespaceDiagnostics = inject(RouteNamespaceDiagnosticsService, { optional: true });
724
+ supportedLangSet = new Set([
725
+ ...LangService.BUILTIN_LANGS,
726
+ ...this.normalizeSupportedLangs(this.config.supportedLangs ?? []),
727
+ ]);
728
+ #lang = signal(this.getStoredLang(), ...(ngDevMode ? [{ debugName: "#lang" }] : []));
729
+ #cache = signal({}, ...(ngDevMode ? [{ debugName: "#cache" }] : []));
730
+ #loadedNamespaces = new Set();
731
+ #pendingLoads = new Map();
732
+ #pendingBatchLoads = new Map();
733
+ #namespaceLoadedAt = new Map();
734
+ #missingKeyFallbacks = new Map();
735
+ /**
736
+ * Computed property determining the current language setting.
737
+ *
738
+ * - If private method `#lang` returns 'kg', `config.kgValue` is checked:
739
+ * - If `config.kgValue` is defined, the property will return its value.
740
+ * - If `config.kgValue` is not defined, the property will return the default value 'kg'.
741
+ * - For other languages (e.g. `ru`, `en`) returns source language as-is.
742
+ */
743
+ currentLang = computed(() => {
744
+ const lang = this.#lang();
745
+ return lang === 'kg' ? (this.config?.kgValue ?? 'kg') : lang;
746
+ }, ...(ngDevMode ? [{ debugName: "currentLang" }] : []));
747
+ /**
748
+ * Extracts readonly value from private property `#lang` and assigns it to `innerLangVal`.
749
+ * Expected that property `#lang` has `asReadonly` method that returns immutable representation.
750
+ */
751
+ [innerLangVal] = this.#lang.asReadonly();
752
+ constructor() {
753
+ const preload = this.config.preloadNamespaces ?? [];
754
+ if (preload.length) {
755
+ queueMicrotask(() => {
756
+ void this.loadNamespaces(preload);
757
+ });
758
+ }
759
+ }
760
+ /**
761
+ * Sets the current language for the application.
762
+ *
763
+ * @param {Langs | 'ky'} lang - The language to set.
764
+ * Accepts predefined type `Langs` or 'ky' to set Kyrgyz language.
765
+ * @return {void} Returns no value.
766
+ */
767
+ setLang(lang) {
768
+ const langVal = this.normalizeLang(lang);
769
+ if (!langVal || !this.supportedLangSet.has(langVal)) {
770
+ return;
771
+ }
772
+ if (langVal !== this.#lang()) {
773
+ this.#lang.set(langVal);
774
+ if (this.isBrowser) {
775
+ this.persistence.set('lang', langVal);
776
+ }
777
+ const namespaces = Array.from(this.#loadedNamespaces.values()).map((key) => this.namespaceFromKey(key));
778
+ this.#loadedNamespaces.clear();
779
+ this.#namespaceLoadedAt.clear();
780
+ void this.loadNamespaces(namespaces);
781
+ }
782
+ }
783
+ get(query, params) {
784
+ const value = this.getChainedValue(query);
785
+ const baseValue = value ?? this.resolveMissingValue(query);
786
+ if (params) {
454
787
  return this.applyParams(baseValue, params);
455
788
  }
456
789
  return baseValue;
@@ -660,7 +993,7 @@ class LangService {
660
993
  if (!this.isBrowser) {
661
994
  return defaultLang;
662
995
  }
663
- return normalize(localStorage.getItem('lang')) ?? defaultLang;
996
+ return normalize(this.persistence.get('lang')) ?? defaultLang;
664
997
  }
665
998
  makeUrl(ns, lang) {
666
999
  if (this.config.requestBuilder) {
@@ -853,19 +1186,19 @@ class LangDirective {
853
1186
  * Localization mode: defines which parts of the element will be translated.
854
1187
  * @default 'all'
855
1188
  */
856
- lang = input('all', { ...(ngDevMode ? { debugName: "lang" } : /* istanbul ignore next */ {}), alias: 'reLang' });
1189
+ lang = input('all', { ...(ngDevMode ? { debugName: "lang" } : {}), alias: 'reLang' });
857
1190
  /**
858
1191
  * Explicit key for text content translation.
859
1192
  */
860
- reLangKeySig = signal(undefined, ...(ngDevMode ? [{ debugName: "reLangKeySig" }] : /* istanbul ignore next */ []));
1193
+ reLangKeySig = signal(undefined, ...(ngDevMode ? [{ debugName: "reLangKeySig" }] : []));
861
1194
  /**
862
1195
  * Explicit attribute-to-key map for translation.
863
1196
  */
864
- reLangAttrsSig = signal(undefined, ...(ngDevMode ? [{ debugName: "reLangAttrsSig" }] : /* istanbul ignore next */ []));
1197
+ reLangAttrsSig = signal(undefined, ...(ngDevMode ? [{ debugName: "reLangAttrsSig" }] : []));
865
1198
  /**
866
1199
  * Name of an additional attribute to localize (besides standard `title`, `label`, `placeholder`).
867
1200
  */
868
- langForAttr = input(...(ngDevMode ? [undefined, { debugName: "langForAttr" }] : /* istanbul ignore next */ []));
1201
+ langForAttr = input(...(ngDevMode ? [undefined, { debugName: "langForAttr" }] : []));
869
1202
  el = inject(ElementRef);
870
1203
  renderer = inject(Renderer2);
871
1204
  service = inject(LangService);
@@ -1034,425 +1367,151 @@ class LangDirective {
1034
1367
  mode = raw.mode;
1035
1368
  textKey = raw.textKey;
1036
1369
  attrs = raw.attrs;
1037
- }
1038
- return {
1039
- mode,
1040
- textKey: explicitText ?? textKey,
1041
- attrs: explicitAttrs ?? attrs,
1042
- };
1043
- }
1044
- isMode(value) {
1045
- return (value === 'only-content' ||
1046
- value === 'only-placeholder' ||
1047
- value === 'only-label' ||
1048
- value === 'only-title' ||
1049
- value === 'all');
1050
- }
1051
- /**
1052
- * Asynchronously loads namespace and retrieves localized value by key.
1053
- *
1054
- * @param key localization key in format `namespace.path.to.key`
1055
- * @returns localized string
1056
- */
1057
- async getLangValue(key) {
1058
- const [ns] = key.split('.', 1);
1059
- if (!this.service.isNamespaceLoaded(ns)) {
1060
- await this.service.loadNamespace(ns);
1061
- }
1062
- return this.service.get(key);
1063
- }
1064
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: LangDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
1065
- static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.4", type: LangDirective, isStandalone: true, selector: "[reLang]", inputs: { lang: { classPropertyName: "lang", publicName: "reLang", isSignal: true, isRequired: false, transformFunction: null }, langForAttr: { classPropertyName: "langForAttr", publicName: "langForAttr", isSignal: true, isRequired: false, transformFunction: null }, reLangKey: { classPropertyName: "reLangKey", publicName: "reLangKey", isSignal: false, isRequired: false, transformFunction: null }, reLangAttrs: { classPropertyName: "reLangAttrs", publicName: "reLangAttrs", isSignal: false, isRequired: false, transformFunction: null } }, ngImport: i0 });
1066
- }
1067
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: LangDirective, decorators: [{
1068
- type: Directive,
1069
- args: [{ selector: '[reLang]', standalone: true }]
1070
- }], ctorParameters: () => [], propDecorators: { lang: [{ type: i0.Input, args: [{ isSignal: true, alias: "reLang", required: false }] }], langForAttr: [{ type: i0.Input, args: [{ isSignal: true, alias: "langForAttr", required: false }] }], reLangKey: [{
1071
- type: Input
1072
- }], reLangAttrs: [{
1073
- type: Input
1074
- }] } });
1075
-
1076
- const LANG_PIPE_CONFIG = new InjectionToken('RE_LANG_PIPE_CONFIG');
1077
-
1078
- /**
1079
- * Custom Angular pipe that transforms a language key and additional parameters into a localized string.
1080
- *
1081
- * The pipe is declared as standalone and impure — meaning it can be used without importing a module
1082
- * and will be recalculated when the state changes (for example, when the language is switched).
1083
- *
1084
- * In its operation, the pipe observes and caches language keys to improve performance,
1085
- * using LangService to retrieve translated strings based on the current application language.
1086
- *
1087
- * The transformation involves accepting a key string and optional parameters,
1088
- * forming a cache key, and returning the localized value corresponding to that request.
1089
- *
1090
- * @implements {PipeTransform}
1091
- */
1092
- class LangPipe {
1093
- cache = new Map();
1094
- warnedUnresolved = new Set();
1095
- lang = inject(LangService);
1096
- injector = inject(Injector);
1097
- config;
1098
- ttlMs;
1099
- maxCacheSize;
1100
- constructor() {
1101
- const resolved = inject(LANG_PIPE_CONFIG, { optional: true }) ?? {};
1102
- this.config = resolved;
1103
- this.ttlMs = resolved.ttlMs ?? 5 * 60 * 1000;
1104
- this.maxCacheSize = resolved.maxCacheSize ?? 500;
1105
- runInInjectionContext(this.injector, () => {
1106
- effect(() => {
1107
- // Clear cache on language change to avoid stale signals and unbounded growth.
1108
- this.lang.currentLang();
1109
- this.cache.clear();
1110
- });
1111
- });
1112
- }
1113
- transform(query, params) {
1114
- if (!query) {
1115
- return '';
1116
- }
1117
- const key = this.makeKey(query, params);
1118
- const now = Date.now();
1119
- const existing = this.cache.get(key);
1120
- if (existing) {
1121
- if (now - existing.ts < this.ttlMs) {
1122
- return existing.value();
1123
- }
1124
- this.cache.delete(key);
1125
- }
1126
- if (!this.cache.has(key)) {
1127
- this.cache.set(key, { value: this.lang.observe(query, params ?? undefined), ts: now });
1128
- this.evictIfNeeded();
1129
- }
1130
- const value = this.cache.get(key).value();
1131
- const ns = query.split('.', 1)[0];
1132
- if (ns && !this.lang.isNamespaceLoaded(ns)) {
1133
- const placeholder = this.config.placeholder;
1134
- if (typeof placeholder === 'function') {
1135
- return placeholder(query);
1136
- }
1137
- if (typeof placeholder === 'string') {
1138
- return placeholder;
1139
- }
1140
- }
1141
- if (ns && this.lang.isNamespaceLoaded(ns) && !this.lang.has(query)) {
1142
- this.warnUnresolvedKey(query);
1143
- }
1144
- return value;
1145
- }
1146
- makeKey(query, params) {
1147
- if (!params) {
1148
- return query;
1149
- }
1150
- const entries = Object.keys(params)
1151
- .sort()
1152
- .map((k) => `${k}:${params[k]}`);
1153
- return `${query}::${entries.join('|')}`;
1154
- }
1155
- evictIfNeeded() {
1156
- while (this.cache.size > this.maxCacheSize) {
1157
- const firstKey = this.cache.keys().next().value;
1158
- if (!firstKey) {
1159
- return;
1160
- }
1161
- this.cache.delete(firstKey);
1162
- }
1163
- }
1164
- warnUnresolvedKey(query) {
1165
- if (typeof ngDevMode === 'undefined') {
1166
- return;
1167
- }
1168
- if (this.warnedUnresolved.has(query)) {
1169
- return;
1170
- }
1171
- this.warnedUnresolved.add(query);
1172
- // eslint-disable-next-line no-console
1173
- console.warn(`LangPipe: namespace loaded but key "${query}" is unresolved`);
1174
- }
1175
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: LangPipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe });
1176
- static ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "21.2.4", ngImport: i0, type: LangPipe, isStandalone: true, name: "lang", pure: false });
1177
- }
1178
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: LangPipe, decorators: [{
1179
- type: Pipe,
1180
- args: [{ name: 'lang', standalone: true, pure: false }]
1181
- }], ctorParameters: () => [] });
1182
-
1183
- function deepestActivatedRoute(route) {
1184
- let current = route;
1185
- while (current.firstChild) {
1186
- current = current.firstChild;
1187
- }
1188
- return current;
1189
- }
1190
- function deepestRouteSnapshot(snapshot) {
1191
- let current = snapshot;
1192
- while (current.firstChild) {
1193
- current = current.firstChild;
1194
- }
1195
- return current;
1196
- }
1197
- function joinUrl(segments) {
1198
- return segments.length ? segments.map((segment) => segment.path).join('/') : '';
1199
- }
1200
- function snapshotFullPath(snapshot) {
1201
- return snapshot.pathFromRoot
1202
- .map((route) => joinUrl(route.url))
1203
- .filter(Boolean)
1204
- .join('/');
1205
- }
1206
- function snapshotRoutePattern(snapshot) {
1207
- return snapshot.pathFromRoot
1208
- .map((route) => route.routeConfig?.path ?? '')
1209
- .filter(Boolean)
1210
- .join('/');
1211
- }
1212
- function snapshotMergedParams(snapshot) {
1213
- return snapshot.pathFromRoot.reduce((acc, route) => ({ ...acc, ...route.params }), {});
1214
- }
1215
- function snapshotDeepestParams(snapshot) {
1216
- const mergedParams = snapshotMergedParams(snapshot);
1217
- const keys = extractRouteParamKeys(snapshot.routeConfig?.path ?? '');
1218
- if (!keys.length) {
1219
- return {};
1220
- }
1221
- return keys.reduce((acc, key) => {
1222
- const value = mergedParams[key];
1223
- if (value !== undefined) {
1224
- acc[key] = value;
1225
- }
1226
- return acc;
1227
- }, {});
1228
- }
1229
- function snapshotMergedData(snapshot) {
1230
- return snapshot.pathFromRoot.reduce((acc, route) => ({ ...acc, ...route.data }), {});
1231
- }
1232
- function extractRouteParamKeys(path) {
1233
- return path
1234
- .split('/')
1235
- .filter((segment) => segment.startsWith(':'))
1236
- .map((segment) => segment.slice(1))
1237
- .filter(Boolean);
1238
- }
1239
-
1240
- /**
1241
- * Reactive snapshot of the current route (the deepest active route).
1242
- * Updates on every `NavigationEnd` event.
1243
- */
1244
- class RouteWatcher {
1245
- router = inject(Router);
1246
- destroyRef = inject(DestroyRef);
1247
- #params = signal({}, ...(ngDevMode ? [{ debugName: "#params" }] : /* istanbul ignore next */ []));
1248
- #deepestParams = signal({}, ...(ngDevMode ? [{ debugName: "#deepestParams" }] : /* istanbul ignore next */ []));
1249
- #query = signal({}, ...(ngDevMode ? [{ debugName: "#query" }] : /* istanbul ignore next */ []));
1250
- #data = signal({}, ...(ngDevMode ? [{ debugName: "#data" }] : /* istanbul ignore next */ []));
1251
- #mergedData = signal({}, ...(ngDevMode ? [{ debugName: "#mergedData" }] : /* istanbul ignore next */ []));
1252
- #url = signal('', ...(ngDevMode ? [{ debugName: "#url" }] : /* istanbul ignore next */ []));
1253
- #routePattern = signal('', ...(ngDevMode ? [{ debugName: "#routePattern" }] : /* istanbul ignore next */ []));
1254
- #fragment = signal(null, ...(ngDevMode ? [{ debugName: "#fragment" }] : /* istanbul ignore next */ []));
1255
- /** Params merged from root to deepest route. */
1256
- params = this.#params.asReadonly();
1257
- /** Params declared on the deepest route only. */
1258
- deepestParams = this.#deepestParams.asReadonly();
1259
- /** Query params from the current navigation. */
1260
- query = this.#query.asReadonly();
1261
- /** Deepest route data only. */
1262
- data = this.#data.asReadonly();
1263
- /** Route data merged from root to deepest route. */
1264
- mergedData = this.#mergedData.asReadonly();
1265
- /** Full current url path assembled from root to deepest route. */
1266
- url = this.#url.asReadonly();
1267
- /** Current route config pattern, e.g. `orgs/:orgId/users/:id`. */
1268
- routePattern = this.#routePattern.asReadonly();
1269
- /** Current url fragment without `#`. */
1270
- fragment = this.#fragment.asReadonly();
1271
- /** Combined computed snapshot. */
1272
- state = computed(() => ({
1273
- params: this.#params(),
1274
- deepestParams: this.#deepestParams(),
1275
- query: this.#query(),
1276
- data: this.#data(),
1277
- mergedData: this.#mergedData(),
1278
- url: this.#url(),
1279
- routePattern: this.#routePattern(),
1280
- fragment: this.#fragment(),
1281
- }), ...(ngDevMode ? [{ debugName: "state" }] : /* istanbul ignore next */ []));
1282
- constructor() {
1283
- const read = () => {
1284
- const snapshot = this.deepestSnapshot();
1285
- const nextUrl = snapshotFullPath(snapshot);
1286
- const nextRoutePattern = snapshotRoutePattern(snapshot);
1287
- const nextParams = snapshotMergedParams(snapshot);
1288
- const nextDeepestParams = snapshotDeepestParams(snapshot);
1289
- const nextMergedData = snapshotMergedData(snapshot);
1290
- const nextQuery = snapshot.queryParams;
1291
- const nextData = snapshot.data;
1292
- const nextFragment = snapshot.fragment ?? null;
1293
- !deepEqual(nextParams, this.#params()) && this.#params.set(nextParams);
1294
- !deepEqual(nextDeepestParams, this.#deepestParams()) && this.#deepestParams.set(nextDeepestParams);
1295
- !deepEqual(nextQuery, this.#query()) && this.#query.set(nextQuery);
1296
- !deepEqual(nextData, this.#data()) && this.#data.set(nextData);
1297
- !deepEqual(nextMergedData, this.#mergedData()) && this.#mergedData.set(nextMergedData);
1298
- this.#url() !== nextUrl && this.#url.set(nextUrl);
1299
- this.#routePattern() !== nextRoutePattern && this.#routePattern.set(nextRoutePattern);
1300
- this.#fragment() !== nextFragment && this.#fragment.set(nextFragment);
1301
- };
1302
- read();
1303
- const subscription = this.router.events.subscribe((event) => {
1304
- if (event instanceof NavigationEnd) {
1305
- read();
1306
- }
1307
- });
1308
- this.destroyRef.onDestroy(() => subscription.unsubscribe());
1309
- }
1310
- selectData(key, strategy = 'deepest') {
1311
- return computed(() => {
1312
- const source = strategy === 'merged' ? this.#mergedData() : this.#data();
1313
- return source[key];
1314
- });
1315
- }
1316
- selectParam(key, strategy = 'merged') {
1317
- return computed(() => {
1318
- const source = strategy === 'deepest' ? this.#deepestParams() : this.#params();
1319
- return source[key];
1320
- });
1370
+ }
1371
+ return {
1372
+ mode,
1373
+ textKey: explicitText ?? textKey,
1374
+ attrs: explicitAttrs ?? attrs,
1375
+ };
1321
1376
  }
1322
- matchesPath(path) {
1323
- const current = this.#url();
1324
- return typeof path === 'string' ? current === path : path.test(current);
1377
+ isMode(value) {
1378
+ return (value === 'only-content' ||
1379
+ value === 'only-placeholder' ||
1380
+ value === 'only-label' ||
1381
+ value === 'only-title' ||
1382
+ value === 'all');
1325
1383
  }
1326
- deepestSnapshot() {
1327
- return deepestRouteSnapshot(this.router.routerState.snapshot.root);
1384
+ /**
1385
+ * Asynchronously loads namespace and retrieves localized value by key.
1386
+ *
1387
+ * @param key localization key in format `namespace.path.to.key`
1388
+ * @returns localized string
1389
+ */
1390
+ async getLangValue(key) {
1391
+ const [ns] = key.split('.', 1);
1392
+ if (!this.service.isNamespaceLoaded(ns)) {
1393
+ await this.service.loadNamespace(ns);
1394
+ }
1395
+ return this.service.get(key);
1328
1396
  }
1329
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: RouteWatcher, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1330
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: RouteWatcher, providedIn: 'root' });
1397
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: LangDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
1398
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.4", type: LangDirective, isStandalone: true, selector: "[reLang]", inputs: { lang: { classPropertyName: "lang", publicName: "reLang", isSignal: true, isRequired: false, transformFunction: null }, langForAttr: { classPropertyName: "langForAttr", publicName: "langForAttr", isSignal: true, isRequired: false, transformFunction: null }, reLangKey: { classPropertyName: "reLangKey", publicName: "reLangKey", isSignal: false, isRequired: false, transformFunction: null }, reLangAttrs: { classPropertyName: "reLangAttrs", publicName: "reLangAttrs", isSignal: false, isRequired: false, transformFunction: null } }, ngImport: i0 });
1331
1399
  }
1332
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: RouteWatcher, decorators: [{
1333
- type: Injectable,
1334
- args: [{ providedIn: 'root' }]
1335
- }], ctorParameters: () => [] });
1400
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: LangDirective, decorators: [{
1401
+ type: Directive,
1402
+ args: [{ selector: '[reLang]', standalone: true }]
1403
+ }], ctorParameters: () => [], propDecorators: { lang: [{ type: i0.Input, args: [{ isSignal: true, alias: "reLang", required: false }] }], langForAttr: [{ type: i0.Input, args: [{ isSignal: true, alias: "langForAttr", required: false }] }], reLangKey: [{
1404
+ type: Input
1405
+ }], reLangAttrs: [{
1406
+ type: Input
1407
+ }] } });
1336
1408
 
1337
- const PRESENTIA_ROUTE_NAMESPACE_PRELOAD_RESOLVE_KEY = '__rePresentiaRouteNamespacePreload';
1338
- const PRESENTIA_ROUTE_NAMESPACES_DATA_KEY = 'presentiaNamespaces';
1339
- function providePresentiaRouteNamespacePreload() {
1340
- return makeEnvironmentProviders([
1341
- provideAppInitializer(() => {
1342
- const router = inject(Router, { optional: true });
1343
- const langConfig = inject(LANG_CONFIG);
1344
- const normalized = normalizeRouteNamespacePreloadConfig(langConfig.routeNamespacePreload);
1345
- if (!router || !normalized) {
1346
- return;
1409
+ const LANG_PIPE_CONFIG = new InjectionToken('RE_LANG_PIPE_CONFIG');
1410
+
1411
+ /**
1412
+ * Custom Angular pipe that transforms a language key and additional parameters into a localized string.
1413
+ *
1414
+ * The pipe is declared as standalone and impure — meaning it can be used without importing a module
1415
+ * and will be recalculated when the state changes (for example, when the language is switched).
1416
+ *
1417
+ * In its operation, the pipe observes and caches language keys to improve performance,
1418
+ * using LangService to retrieve translated strings based on the current application language.
1419
+ *
1420
+ * The transformation involves accepting a key string and optional parameters,
1421
+ * forming a cache key, and returning the localized value corresponding to that request.
1422
+ *
1423
+ * @implements {PipeTransform}
1424
+ */
1425
+ class LangPipe {
1426
+ cache = new Map();
1427
+ warnedUnresolved = new Set();
1428
+ lang = inject(LangService);
1429
+ injector = inject(Injector);
1430
+ config;
1431
+ ttlMs;
1432
+ maxCacheSize;
1433
+ constructor() {
1434
+ const resolved = inject(LANG_PIPE_CONFIG, { optional: true }) ?? {};
1435
+ this.config = resolved;
1436
+ this.ttlMs = resolved.ttlMs ?? 5 * 60 * 1000;
1437
+ this.maxCacheSize = resolved.maxCacheSize ?? 500;
1438
+ runInInjectionContext(this.injector, () => {
1439
+ effect(() => {
1440
+ // Clear cache on language change to avoid stale signals and unbounded growth.
1441
+ this.lang.currentLang();
1442
+ this.cache.clear();
1443
+ });
1444
+ });
1445
+ }
1446
+ transform(query, params) {
1447
+ if (!query) {
1448
+ return '';
1449
+ }
1450
+ const key = this.makeKey(query, params);
1451
+ const now = Date.now();
1452
+ const existing = this.cache.get(key);
1453
+ if (existing) {
1454
+ if (now - existing.ts < this.ttlMs) {
1455
+ return existing.value();
1347
1456
  }
1348
- if (typeof ngDevMode !== 'undefined' && normalized.manifest && Object.keys(normalized.manifest).length === 0) {
1349
- // eslint-disable-next-line no-console
1350
- console.warn('[presentia] routeNamespacePreload.manifest is empty. Route-data preload still works, but manifest mode is effectively disabled.');
1457
+ this.cache.delete(key);
1458
+ }
1459
+ if (!this.cache.has(key)) {
1460
+ this.cache.set(key, { value: this.lang.observe(query, params ?? undefined), ts: now });
1461
+ this.evictIfNeeded();
1462
+ }
1463
+ const value = this.cache.get(key).value();
1464
+ const ns = query.split('.', 1)[0];
1465
+ if (ns && !this.lang.isNamespaceLoaded(ns)) {
1466
+ const placeholder = this.config.placeholder;
1467
+ if (typeof placeholder === 'function') {
1468
+ return placeholder(query);
1469
+ }
1470
+ if (typeof placeholder === 'string') {
1471
+ return placeholder;
1351
1472
  }
1352
- router.resetConfig(patchRoutesWithNamespacePreload(router.config, normalized));
1353
- }),
1354
- ]);
1355
- }
1356
- function normalizeRouteNamespacePreloadConfig(config) {
1357
- if (!config) {
1358
- return null;
1359
- }
1360
- return {
1361
- mode: config.mode ?? 'blocking',
1362
- dataKey: config.dataKey?.trim() || PRESENTIA_ROUTE_NAMESPACES_DATA_KEY,
1363
- manifest: config.manifest,
1364
- mergeStrategy: config.mergeStrategy ?? 'append',
1365
- onError: config.onError ?? 'continue',
1366
- };
1367
- }
1368
- function patchRoutesWithNamespacePreload(routes, config) {
1369
- return routes.map((route) => patchRouteWithNamespacePreload(route, config));
1370
- }
1371
- function patchRouteWithNamespacePreload(route, config) {
1372
- const nextChildren = route.children ? patchRoutesWithNamespacePreload(route.children, config) : route.children;
1373
- if (route.redirectTo) {
1374
- return nextChildren === route.children ? route : { ...route, children: nextChildren };
1375
- }
1376
- const nextResolve = {
1377
- ...(route.resolve ?? {}),
1378
- [PRESENTIA_ROUTE_NAMESPACE_PRELOAD_RESOLVE_KEY]: makeRouteNamespacePreloadResolver(config),
1379
- };
1380
- return {
1381
- ...route,
1382
- ...(nextChildren ? { children: nextChildren } : {}),
1383
- resolve: nextResolve,
1384
- };
1385
- }
1386
- function makeRouteNamespacePreloadResolver(config) {
1387
- return async (route, state) => {
1388
- const lang = inject(LangService);
1389
- const diagnostics = inject(RouteNamespaceDiagnosticsService);
1390
- const namespaces = resolveRouteNamespaces(route, state, config);
1391
- diagnostics.registerRouteNamespaces(state.url, namespaces);
1392
- if (!namespaces.length) {
1393
- return true;
1394
1473
  }
1395
- if (config.mode === 'lazy') {
1396
- queueMicrotask(() => {
1397
- void lang.loadNamespaces(namespaces);
1398
- });
1399
- return true;
1474
+ if (ns && this.lang.isNamespaceLoaded(ns) && !this.lang.has(query)) {
1475
+ this.warnUnresolvedKey(query);
1400
1476
  }
1401
- try {
1402
- await lang.loadNamespaces(namespaces);
1477
+ return value;
1478
+ }
1479
+ makeKey(query, params) {
1480
+ if (!params) {
1481
+ return query;
1403
1482
  }
1404
- catch (error) {
1405
- if (config.onError === 'throw') {
1406
- throw error;
1483
+ const entries = Object.keys(params)
1484
+ .sort()
1485
+ .map((k) => `${k}:${params[k]}`);
1486
+ return `${query}::${entries.join('|')}`;
1487
+ }
1488
+ evictIfNeeded() {
1489
+ while (this.cache.size > this.maxCacheSize) {
1490
+ const firstKey = this.cache.keys().next().value;
1491
+ if (!firstKey) {
1492
+ return;
1407
1493
  }
1408
- // Keep navigation alive; runtime lazy loading remains a fallback.
1494
+ this.cache.delete(firstKey);
1409
1495
  }
1410
- return true;
1411
- };
1412
- }
1413
- function resolveRouteNamespaces(route, state, config) {
1414
- const dataNamespaces = readNamespacesFromRouteData(route, config.dataKey);
1415
- const manifestNamespaces = readNamespacesFromManifest(route, state, config.manifest);
1416
- if (config.mergeStrategy === 'replace' && dataNamespaces.length) {
1417
- return dataNamespaces;
1418
1496
  }
1419
- return uniqueNamespaces([...manifestNamespaces, ...dataNamespaces]);
1420
- }
1421
- function readNamespacesFromRouteData(route, dataKey) {
1422
- return uniqueNamespaces(route.pathFromRoot.flatMap((snapshot) => {
1423
- const value = snapshot.data?.[dataKey];
1424
- return Array.isArray(value) ? value : [];
1425
- }));
1426
- }
1427
- function readNamespacesFromManifest(route, state, manifest) {
1428
- if (!manifest) {
1429
- return [];
1497
+ warnUnresolvedKey(query) {
1498
+ if (typeof ngDevMode === 'undefined') {
1499
+ return;
1500
+ }
1501
+ if (this.warnedUnresolved.has(query)) {
1502
+ return;
1503
+ }
1504
+ this.warnedUnresolved.add(query);
1505
+ // eslint-disable-next-line no-console
1506
+ console.warn(`LangPipe: namespace loaded but key "${query}" is unresolved`);
1430
1507
  }
1431
- const actualUrl = normalizeUrlPath(state.url);
1432
- const routePath = snapshotRouteConfigPath(route);
1433
- return uniqueNamespaces(Object.entries(manifest)
1434
- .filter(([key]) => matchesManifestKey(actualUrl, routePath, key))
1435
- .flatMap(([, namespaces]) => namespaces));
1436
- }
1437
- function matchesManifestKey(actualUrl, routePath, manifestKey) {
1438
- const normalizedKey = normalizeUrlPath(manifestKey);
1439
- return compareRoutes(actualUrl, normalizedKey) || (!!routePath && routePath === normalizedKey);
1440
- }
1441
- function snapshotRouteConfigPath(route) {
1442
- const templatePath = route.pathFromRoot
1443
- .map((item) => item.routeConfig?.path ?? '')
1444
- .filter(Boolean)
1445
- .join('/');
1446
- return normalizeUrlPath(templatePath);
1447
- }
1448
- function normalizeUrlPath(url) {
1449
- const [path] = url.split(/[?#]/, 1);
1450
- const normalized = `/${(path ?? '').replace(/^\/+|\/+$/g, '')}`;
1451
- return normalized === '/' ? normalized : normalized.replace(/\/{2,}/g, '/');
1452
- }
1453
- function uniqueNamespaces(namespaces) {
1454
- return Array.from(new Set(namespaces.map((ns) => ns.trim()).filter(Boolean)));
1508
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: LangPipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe });
1509
+ static ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "21.2.4", ngImport: i0, type: LangPipe, isStandalone: true, name: "lang", pure: false });
1455
1510
  }
1511
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: LangPipe, decorators: [{
1512
+ type: Pipe,
1513
+ args: [{ name: 'lang', standalone: true, pure: false }]
1514
+ }], ctorParameters: () => [] });
1456
1515
 
1457
1516
  /**
1458
1517
  * Type-safe mapping of available theme names.
@@ -1507,10 +1566,7 @@ const THEME_CONFIG = new InjectionToken('RE_THEME_CONFIG');
1507
1566
  /**
1508
1567
  * @deprecated Prefer configuring theme persistence through `providePresentia({ theme: { persistence... } })`.
1509
1568
  */
1510
- const defaultThemePersistenceAdapter = {
1511
- getItem: (key) => localStorage.getItem(key),
1512
- setItem: (key, value) => localStorage.setItem(key, value),
1513
- };
1569
+ const defaultThemePersistenceAdapter = createPresentiaStorage('persist');
1514
1570
  /**
1515
1571
  * DI token for the persistence adapter used by `ThemeService`
1516
1572
  * to store and retrieve the selected theme (default key: `'theme'`).
@@ -1552,7 +1608,7 @@ class ThemeService {
1552
1608
  persistence = inject(THEME_PERSISTENCE_ADAPTER);
1553
1609
  isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
1554
1610
  document = inject(DOCUMENT);
1555
- #theme = signal(this.themeDefault, ...(ngDevMode ? [{ debugName: "#theme" }] : /* istanbul ignore next */ []));
1611
+ #theme = signal(this.themeDefault, ...(ngDevMode ? [{ debugName: "#theme" }] : []));
1556
1612
  /**
1557
1613
  * Current active theme (`light` or `dark`).
1558
1614
  *
@@ -1562,13 +1618,13 @@ class ThemeService {
1562
1618
  * <div [class]="themeService.theme()"></div>
1563
1619
  * ```
1564
1620
  */
1565
- theme = computed(() => this.#theme(), ...(ngDevMode ? [{ debugName: "theme" }] : /* istanbul ignore next */ []));
1621
+ theme = computed(() => this.#theme(), ...(ngDevMode ? [{ debugName: "theme" }] : []));
1566
1622
  /**
1567
1623
  * Convenient flag returning `true` if the light theme is active.
1568
1624
  * Suitable for conditional style application or resource selection.
1569
1625
  */
1570
- isLight = computed(() => this.#theme() === themes.light, ...(ngDevMode ? [{ debugName: "isLight" }] : /* istanbul ignore next */ []));
1571
- isDark = computed(() => this.#theme() === themes.dark, ...(ngDevMode ? [{ debugName: "isDark" }] : /* istanbul ignore next */ []));
1626
+ isLight = computed(() => this.#theme() === themes.light, ...(ngDevMode ? [{ debugName: "isLight" }] : []));
1627
+ isDark = computed(() => this.#theme() === themes.dark, ...(ngDevMode ? [{ debugName: "isDark" }] : []));
1572
1628
  constructor() {
1573
1629
  effect(() => {
1574
1630
  if (!this.isBrowser) {
@@ -1614,10 +1670,10 @@ class ThemeService {
1614
1670
  return this.registry.includes(next) ? next : (this.registry[0] ?? themes.light);
1615
1671
  }
1616
1672
  getStoredTheme() {
1617
- return this.persistence.getItem('theme');
1673
+ return this.persistence.get('theme');
1618
1674
  }
1619
1675
  persistTheme(theme) {
1620
- this.persistence.setItem('theme', theme);
1676
+ this.persistence.set('theme', theme);
1621
1677
  }
1622
1678
  applyThemeToDom(theme) {
1623
1679
  const root = this.resolveRootElement();
@@ -1713,6 +1769,7 @@ function buildPresentiaProviders(config) {
1713
1769
  { provide: DEVICE_BREAKPOINTS, useValue: config.breakpoints || defaultBreakpoints },
1714
1770
  { provide: THEME_CONFIG, useValue: config.theme || defaultThemeConfig },
1715
1771
  { provide: THEME_PERSISTENCE_ADAPTER, useValue: defaultThemePersistenceAdapter },
1772
+ { provide: LANG_PERSISTENCE_ADAPTER, useValue: defaultLangPersistenceAdapter },
1716
1773
  { provide: LANG_CONFIG, useValue: config.locale || { defaultValue: '--------' } },
1717
1774
  { provide: LANG_PIPE_CONFIG, useValue: config.langPipe || {} },
1718
1775
  { provide: LANG_MISSING_KEY_HANDLER, useValue: config.langMissingKeyHandler ?? null },
@@ -1728,21 +1785,10 @@ function providePresentia(config) {
1728
1785
  ? { ...routesConfig, diagnostics: diagnosticsEnabled ?? routesConfig?.diagnostics }
1729
1786
  : undefined;
1730
1787
  const extraProviders = [];
1731
- if (config.theme?.persistence === 'none') {
1732
- extraProviders.push({
1733
- provide: THEME_PERSISTENCE_ADAPTER,
1734
- useValue: {
1735
- getItem: () => null,
1736
- setItem: () => undefined,
1737
- },
1738
- });
1739
- }
1740
- else if (config.theme?.persistenceAdapter) {
1741
- extraProviders.push({
1742
- provide: THEME_PERSISTENCE_ADAPTER,
1743
- useValue: config.theme.persistenceAdapter,
1744
- });
1745
- }
1788
+ const langStorage = createPresentiaStorage(config.lang.persistence, config.lang.storage ?? config.lang.persistenceAdapter);
1789
+ const themeStorage = createPresentiaStorage(config.theme?.persistence, config.theme?.storage ?? config.theme?.persistenceAdapter);
1790
+ extraProviders.push({ provide: LANG_PERSISTENCE_ADAPTER, useValue: langStorage });
1791
+ extraProviders.push({ provide: THEME_PERSISTENCE_ADAPTER, useValue: themeStorage });
1746
1792
  return makeEnvironmentProviders([
1747
1793
  buildPresentiaProviders({
1748
1794
  locale: {
@@ -2019,5 +2065,5 @@ function joinBaseUrl(baseUrl, path) {
2019
2065
  * Generated bundle index. Do not edit.
2020
2066
  */
2021
2067
 
2022
- export { AdaptiveService, DEVICE_BREAKPOINTS, IfDeviceDirective, LANG_CONFIG, LANG_MISSING_KEY_HANDLER, LANG_PERSISTENCE_ADAPTER, LANG_PIPE_CONFIG, LangDirective, LangPipe, LangService, PRESENTIA_ROUTE_NAMESPACES_DATA_KEY, RouteNamespaceDiagnosticsService, RouteWatcher, SeoRouteListener, SeoService, THEME_CONFIG, THEME_PERSISTENCE_ADAPTER, ThemeService, darkThemePrefix, defaultBreakpoints, defaultThemeConfig, defaultThemePersistenceAdapter, innerLangVal, providePresentia, providePresentiaRouteNamespacePreload, provideReInit, themes };
2068
+ export { AdaptiveService, DEVICE_BREAKPOINTS, IfDeviceDirective, LANG_CONFIG, LANG_MISSING_KEY_HANDLER, LANG_PERSISTENCE_ADAPTER, LANG_PIPE_CONFIG, LangDirective, LangPipe, LangService, PRESENTIA_ROUTE_NAMESPACES_DATA_KEY, RouteNamespaceDiagnosticsService, RouteWatcher, SeoRouteListener, SeoService, THEME_CONFIG, THEME_PERSISTENCE_ADAPTER, ThemeService, darkThemePrefix, defaultBreakpoints, defaultLangPersistenceAdapter, defaultThemeConfig, defaultThemePersistenceAdapter, innerLangVal, providePresentia, providePresentiaRouteNamespacePreload, provideReInit, themes };
2023
2069
  //# sourceMappingURL=reforgium-presentia.mjs.map