@reforgium/presentia 1.5.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,13 +1,13 @@
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, LOCALE_ID } from '@angular/core';
3
- import { getChainedValue, TRANSLATION, SELECTED_LANG, CHANGE_LANG, SELECTED_THEME, CHANGE_THEME, CURRENT_DEVICE, deepEqual } from '@reforgium/internal';
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';
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';
7
7
  import { HttpClient } from '@angular/common/http';
8
8
  import { firstValueFrom, tap } from 'rxjs';
9
+ import { Router, NavigationEnd } from '@angular/router';
9
10
  import { Title, Meta } from '@angular/platform-browser';
10
- import { Router, ActivatedRoute, NavigationEnd } from '@angular/router';
11
11
 
12
12
  /**
13
13
  * Default breakpoints for device type detection.
@@ -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" }] : []));
76
+ #device = signal('desktop', ...(ngDevMode ? [{ debugName: "#device" }] : /* istanbul ignore next */ []));
77
77
  /** @internal Signals of the current window width and height. */
78
- #width = signal(0, ...(ngDevMode ? [{ debugName: "#width" }] : []));
79
- #height = signal(0, ...(ngDevMode ? [{ debugName: "#height" }] : []));
78
+ #width = signal(0, ...(ngDevMode ? [{ debugName: "#width" }] : /* istanbul ignore next */ []));
79
+ #height = signal(0, ...(ngDevMode ? [{ debugName: "#height" }] : /* istanbul ignore next */ []));
80
80
  /**
81
81
  * Current device type (reactive signal).
82
82
  * Possible values: `'desktop' | 'tablet' | 'mobile'`.
@@ -99,12 +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" }] : []));
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 */ []));
103
106
  /**
104
107
  * Computed signal determining whether the screen is in portrait orientation.
105
108
  * Returns `true` if window height is greater than width.
106
109
  */
107
- isPortrait = computed(() => this.#height() > this.#width(), ...(ngDevMode ? [{ debugName: "isPortrait" }] : []));
110
+ isPortrait = computed(() => this.#height() > this.#width(), ...(ngDevMode ? [{ debugName: "isPortrait" }] : /* istanbul ignore next */ []));
108
111
  deviceBreakpoints = inject(DEVICE_BREAKPOINTS);
109
112
  devicePriority = Object.keys(this.deviceBreakpoints);
110
113
  destroyRef = inject(DestroyRef);
@@ -148,10 +151,28 @@ class AdaptiveService {
148
151
  }
149
152
  });
150
153
  }
151
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: AdaptiveService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
152
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: AdaptiveService, providedIn: 'root' });
154
+ breakpoint() {
155
+ return this.#device();
156
+ }
157
+ is(device) {
158
+ const allowed = Array.isArray(device) ? device : [device];
159
+ return allowed.includes(this.#device());
160
+ }
161
+ isAtLeast(device) {
162
+ return this.deviceRank(this.#device()) >= this.deviceRank(device);
163
+ }
164
+ isBetween(min, max) {
165
+ const current = this.deviceRank(this.#device());
166
+ return current >= this.deviceRank(min) && current <= this.deviceRank(max);
167
+ }
168
+ deviceRank(device) {
169
+ const index = this.devicePriority.indexOf(device);
170
+ return index >= 0 ? index : Number.MAX_SAFE_INTEGER;
171
+ }
172
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: AdaptiveService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
173
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: AdaptiveService, providedIn: 'root' });
153
174
  }
154
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: AdaptiveService, decorators: [{
175
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: AdaptiveService, decorators: [{
155
176
  type: Injectable,
156
177
  args: [{
157
178
  providedIn: 'root',
@@ -179,8 +200,10 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.1", ngImpor
179
200
  * the template is automatically added or removed from the DOM.
180
201
  */
181
202
  class IfDeviceDirective {
182
- deviceInput = signal(undefined, ...(ngDevMode ? [{ debugName: "deviceInput" }] : []));
183
- inverseInput = signal(false, ...(ngDevMode ? [{ debugName: "inverseInput" }] : []));
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 */ []));
184
207
  tpl = inject(TemplateRef);
185
208
  vcr = inject(ViewContainerRef);
186
209
  adaptive = inject(AdaptiveService);
@@ -190,20 +213,37 @@ class IfDeviceDirective {
190
213
  constructor() {
191
214
  effect(() => {
192
215
  const device = this.deviceInput();
216
+ const atLeast = this.atLeastInput();
217
+ const between = this.betweenInput();
193
218
  if (device) {
194
219
  this.allowedDevices = Array.isArray(device) ? device : [device];
195
220
  }
221
+ else if (!atLeast && !between) {
222
+ this.allowedDevices = [];
223
+ }
196
224
  this.updateView();
197
225
  });
198
226
  }
199
227
  set reIfDevice(value) {
200
228
  this.deviceInput.set(value);
201
229
  }
230
+ set reIfDeviceAtLeast(value) {
231
+ this.atLeastInput.set(value);
232
+ }
233
+ set reIfDeviceBetween(value) {
234
+ this.betweenInput.set(value);
235
+ }
202
236
  set inverse(value) {
203
237
  this.inverseInput.set(!!value);
204
238
  }
205
239
  updateView() {
206
- const isAllowed = this.allowedDevices.includes(this.currentDevice());
240
+ const between = this.betweenInput();
241
+ const atLeast = this.atLeastInput();
242
+ const isAllowed = between
243
+ ? this.adaptive.isBetween(between[0], between[1])
244
+ : atLeast
245
+ ? this.adaptive.isAtLeast(atLeast)
246
+ : this.allowedDevices.includes(this.currentDevice());
207
247
  const shouldShow = this.inverseInput() ? !isAllowed : isAllowed;
208
248
  if (shouldShow && !this.hasView) {
209
249
  this.vcr.createEmbeddedView(this.tpl);
@@ -214,18 +254,24 @@ class IfDeviceDirective {
214
254
  this.hasView = false;
215
255
  }
216
256
  }
217
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: IfDeviceDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
218
- static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.1.1", type: IfDeviceDirective, isStandalone: true, selector: "[reIfDevice]", inputs: { reIfDevice: "reIfDevice", inverse: "inverse" }, ngImport: i0 });
257
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: IfDeviceDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
258
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.2.4", type: IfDeviceDirective, isStandalone: true, selector: "[reIfDevice],[reIfDeviceAtLeast],[reIfDeviceBetween]", inputs: { reIfDevice: "reIfDevice", reIfDeviceAtLeast: "reIfDeviceAtLeast", reIfDeviceBetween: "reIfDeviceBetween", inverse: "inverse" }, ngImport: i0 });
219
259
  }
220
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: IfDeviceDirective, decorators: [{
260
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: IfDeviceDirective, decorators: [{
221
261
  type: Directive,
222
262
  args: [{
223
- selector: '[reIfDevice]',
263
+ selector: '[reIfDevice],[reIfDeviceAtLeast],[reIfDeviceBetween]',
224
264
  standalone: true,
225
265
  }]
226
266
  }], ctorParameters: () => [], propDecorators: { reIfDevice: [{
227
267
  type: Input,
228
268
  args: ['reIfDevice']
269
+ }], reIfDeviceAtLeast: [{
270
+ type: Input,
271
+ args: ['reIfDeviceAtLeast']
272
+ }], reIfDeviceBetween: [{
273
+ type: Input,
274
+ args: ['reIfDeviceBetween']
229
275
  }], inverse: [{
230
276
  type: Input
231
277
  }] } });
@@ -234,6 +280,19 @@ const innerLangVal = Symbol('reInnerLangVal');
234
280
 
235
281
  const LANG_MISSING_KEY_HANDLER = new InjectionToken('RE_LANG_MISSING_KEY_HANDLER');
236
282
 
283
+ /**
284
+ * Optional DI token for a custom persistence adapter used by `LangService`
285
+ * to store and retrieve the selected language (default key: `'lang'`).
286
+ *
287
+ * When not provided the service falls back to `localStorage` directly.
288
+ *
289
+ * Example:
290
+ * ```ts
291
+ * { provide: LANG_PERSISTENCE_ADAPTER, useValue: sessionStorageAdapter }
292
+ * ```
293
+ */
294
+ const LANG_PERSISTENCE_ADAPTER = new InjectionToken('RE_LANG_PERSISTENCE_ADAPTER');
295
+
237
296
  /**
238
297
  * Injection token for providing locale configuration to the language module.
239
298
  *
@@ -254,6 +313,67 @@ const LANG_MISSING_KEY_HANDLER = new InjectionToken('RE_LANG_MISSING_KEY_HANDLER
254
313
  */
255
314
  const LANG_CONFIG = new InjectionToken('RE_LANG_CONFIG');
256
315
 
316
+ /**
317
+ * @deprecated Diagnostics are usually enabled through route preload config, not by consuming this service directly.
318
+ */
319
+ class RouteNamespaceDiagnosticsService {
320
+ router = inject(Router, { optional: true });
321
+ enabled = !!inject(LANG_CONFIG).routeNamespacePreload?.diagnostics;
322
+ warned = new Set();
323
+ currentUrl = '';
324
+ currentNamespaces = new Set();
325
+ navigationSettled = false;
326
+ constructor() {
327
+ if (!this.enabled || !this.router) {
328
+ return;
329
+ }
330
+ this.router.events.subscribe((event) => {
331
+ if (event instanceof NavigationEnd) {
332
+ const url = normalizeUrlPath$1(event.urlAfterRedirects || event.url);
333
+ if (url === this.currentUrl) {
334
+ this.navigationSettled = true;
335
+ }
336
+ }
337
+ });
338
+ }
339
+ registerRouteNamespaces(url, namespaces) {
340
+ if (!this.enabled) {
341
+ return;
342
+ }
343
+ this.currentUrl = normalizeUrlPath$1(url);
344
+ this.currentNamespaces = new Set(namespaces.map((ns) => ns.trim()).filter(Boolean));
345
+ this.navigationSettled = false;
346
+ }
347
+ warnLateNamespaceLoad(namespace) {
348
+ if (!this.enabled || !this.navigationSettled || typeof ngDevMode === 'undefined') {
349
+ return;
350
+ }
351
+ const normalizedNamespace = namespace.trim();
352
+ if (!normalizedNamespace || this.currentNamespaces.has(normalizedNamespace)) {
353
+ return;
354
+ }
355
+ const key = `${this.currentUrl}|${normalizedNamespace}`;
356
+ if (this.warned.has(key)) {
357
+ return;
358
+ }
359
+ this.warned.add(key);
360
+ // eslint-disable-next-line no-console
361
+ console.warn(`[presentia] Namespace "${normalizedNamespace}" was loaded after route activation for "${this.currentUrl}". ` +
362
+ 'Add it to routeNamespacePreload manifest or route data to avoid raw i18n keys on first paint.');
363
+ }
364
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: RouteNamespaceDiagnosticsService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
365
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: RouteNamespaceDiagnosticsService, providedIn: 'root' });
366
+ }
367
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: RouteNamespaceDiagnosticsService, decorators: [{
368
+ type: Injectable,
369
+ args: [{ providedIn: 'root' }]
370
+ }], ctorParameters: () => [] });
371
+ function normalizeUrlPath$1(url) {
372
+ const [path] = url.split(/[?#]/, 1);
373
+ const normalized = `/${(path ?? '').replace(/^\/+|\/+$/g, '')}`;
374
+ return normalized === '/' ? normalized : normalized.replace(/\/{2,}/g, '/');
375
+ }
376
+
257
377
  /**
258
378
  * LangService provides functionality for managing and tracking language settings
259
379
  * and translations in the application. It is designed to handle localization needs,
@@ -267,12 +387,13 @@ class LangService {
267
387
  http = inject(HttpClient);
268
388
  isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
269
389
  missingKeyHandler = inject(LANG_MISSING_KEY_HANDLER, { optional: true });
390
+ routeNamespaceDiagnostics = inject(RouteNamespaceDiagnosticsService, { optional: true });
270
391
  supportedLangSet = new Set([
271
392
  ...LangService.BUILTIN_LANGS,
272
393
  ...this.normalizeSupportedLangs(this.config.supportedLangs ?? []),
273
394
  ]);
274
- #lang = signal(this.getStoredLang(), ...(ngDevMode ? [{ debugName: "#lang" }] : []));
275
- #cache = signal({}, ...(ngDevMode ? [{ debugName: "#cache" }] : []));
395
+ #lang = signal(this.getStoredLang(), ...(ngDevMode ? [{ debugName: "#lang" }] : /* istanbul ignore next */ []));
396
+ #cache = signal({}, ...(ngDevMode ? [{ debugName: "#cache" }] : /* istanbul ignore next */ []));
276
397
  #loadedNamespaces = new Set();
277
398
  #pendingLoads = new Map();
278
399
  #pendingBatchLoads = new Map();
@@ -289,7 +410,7 @@ class LangService {
289
410
  currentLang = computed(() => {
290
411
  const lang = this.#lang();
291
412
  return lang === 'kg' ? (this.config?.kgValue ?? 'kg') : lang;
292
- }, ...(ngDevMode ? [{ debugName: "currentLang" }] : []));
413
+ }, ...(ngDevMode ? [{ debugName: "currentLang" }] : /* istanbul ignore next */ []));
293
414
  /**
294
415
  * Extracts readonly value from private property `#lang` and assigns it to `innerLangVal`.
295
416
  * Expected that property `#lang` has `asReadonly` method that returns immutable representation.
@@ -357,6 +478,7 @@ class LangService {
357
478
  if (this.isNamespaceValid(key)) {
358
479
  return;
359
480
  }
481
+ this.routeNamespaceDiagnostics?.warnLateNamespaceLoad(ns);
360
482
  this.tryExpireNamespace(key);
361
483
  if (this.#pendingLoads.has(key)) {
362
484
  return this.#pendingLoads.get(key);
@@ -411,14 +533,27 @@ class LangService {
411
533
  await Promise.all(toLoad.map((ns) => this.loadNamespace(ns)));
412
534
  return;
413
535
  }
414
- const batchKey = this.makeBatchKey(requestedLang, toLoad);
536
+ const maxBatchSize = this.normalizeMaxBatchSize(this.config.maxBatchSize);
537
+ const chunks = maxBatchSize && toLoad.length > maxBatchSize ? this.chunkNamespaces(toLoad, maxBatchSize) : [toLoad];
538
+ await Promise.all(chunks.map((chunk) => {
539
+ if (chunk.length < 2) {
540
+ return this.loadNamespace(chunk[0]);
541
+ }
542
+ return this.loadNamespaceBatch(chunk, requestedLang);
543
+ }));
544
+ }
545
+ async loadNamespaceBatch(namespaces, requestedLang) {
546
+ for (const ns of namespaces) {
547
+ this.routeNamespaceDiagnostics?.warnLateNamespaceLoad(ns);
548
+ }
549
+ const batchKey = this.makeBatchKey(requestedLang, namespaces);
415
550
  if (this.#pendingBatchLoads.has(batchKey)) {
416
551
  return this.#pendingBatchLoads.get(batchKey);
417
552
  }
418
553
  const promise = (async () => {
419
554
  try {
420
555
  const context = {
421
- namespaces: toLoad,
556
+ namespaces,
422
557
  lang: requestedLang,
423
558
  isFromAssets: this.config.isFromAssets,
424
559
  baseUrl: this.config.url,
@@ -430,7 +565,7 @@ class LangService {
430
565
  }
431
566
  const payloads = this.config.batchResponseAdapter(response, this.toBatchResponseContext(context));
432
567
  const merged = {};
433
- for (const ns of toLoad) {
568
+ for (const ns of namespaces) {
434
569
  const nsPayload = payloads[ns];
435
570
  if (!nsPayload) {
436
571
  continue;
@@ -450,6 +585,19 @@ class LangService {
450
585
  this.#pendingBatchLoads.set(batchKey, promise);
451
586
  return promise;
452
587
  }
588
+ normalizeMaxBatchSize(value) {
589
+ if (!value || value < 1) {
590
+ return null;
591
+ }
592
+ return Math.floor(value);
593
+ }
594
+ chunkNamespaces(namespaces, size) {
595
+ const chunks = [];
596
+ for (let i = 0; i < namespaces.length; i += size) {
597
+ chunks.push(namespaces.slice(i, i + size));
598
+ }
599
+ return chunks;
600
+ }
453
601
  evictNamespace(ns) {
454
602
  const key = this.makeNamespaceKey(ns);
455
603
  if (!this.#loadedNamespaces.has(key)) {
@@ -664,10 +812,10 @@ class LangService {
664
812
  .map((lang) => lang.trim().toLowerCase())
665
813
  .filter((lang) => !!lang && LangService.LANG_CODE_RE.test(lang));
666
814
  }
667
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: LangService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
668
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: LangService, providedIn: 'root' });
815
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: LangService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
816
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: LangService, providedIn: 'root' });
669
817
  }
670
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: LangService, decorators: [{
818
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: LangService, decorators: [{
671
819
  type: Injectable,
672
820
  args: [{ providedIn: 'root' }]
673
821
  }], ctorParameters: () => [] });
@@ -705,19 +853,19 @@ class LangDirective {
705
853
  * Localization mode: defines which parts of the element will be translated.
706
854
  * @default 'all'
707
855
  */
708
- lang = input('all', { ...(ngDevMode ? { debugName: "lang" } : {}), alias: 'reLang' });
856
+ lang = input('all', { ...(ngDevMode ? { debugName: "lang" } : /* istanbul ignore next */ {}), alias: 'reLang' });
709
857
  /**
710
858
  * Explicit key for text content translation.
711
859
  */
712
- reLangKeySig = signal(undefined, ...(ngDevMode ? [{ debugName: "reLangKeySig" }] : []));
860
+ reLangKeySig = signal(undefined, ...(ngDevMode ? [{ debugName: "reLangKeySig" }] : /* istanbul ignore next */ []));
713
861
  /**
714
862
  * Explicit attribute-to-key map for translation.
715
863
  */
716
- reLangAttrsSig = signal(undefined, ...(ngDevMode ? [{ debugName: "reLangAttrsSig" }] : []));
864
+ reLangAttrsSig = signal(undefined, ...(ngDevMode ? [{ debugName: "reLangAttrsSig" }] : /* istanbul ignore next */ []));
717
865
  /**
718
866
  * Name of an additional attribute to localize (besides standard `title`, `label`, `placeholder`).
719
867
  */
720
- langForAttr = input(...(ngDevMode ? [undefined, { debugName: "langForAttr" }] : []));
868
+ langForAttr = input(...(ngDevMode ? [undefined, { debugName: "langForAttr" }] : /* istanbul ignore next */ []));
721
869
  el = inject(ElementRef);
722
870
  renderer = inject(Renderer2);
723
871
  service = inject(LangService);
@@ -913,10 +1061,10 @@ class LangDirective {
913
1061
  }
914
1062
  return this.service.get(key);
915
1063
  }
916
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: LangDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
917
- static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.1.1", 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 });
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 });
918
1066
  }
919
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: LangDirective, decorators: [{
1067
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: LangDirective, decorators: [{
920
1068
  type: Directive,
921
1069
  args: [{ selector: '[reLang]', standalone: true }]
922
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: [{
@@ -1024,14 +1172,288 @@ class LangPipe {
1024
1172
  // eslint-disable-next-line no-console
1025
1173
  console.warn(`LangPipe: namespace loaded but key "${query}" is unresolved`);
1026
1174
  }
1027
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: LangPipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe });
1028
- static ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "21.1.1", ngImport: i0, type: LangPipe, isStandalone: true, name: "lang", pure: false });
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 });
1029
1177
  }
1030
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: LangPipe, decorators: [{
1178
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: LangPipe, decorators: [{
1031
1179
  type: Pipe,
1032
1180
  args: [{ name: 'lang', standalone: true, pure: false }]
1033
1181
  }], ctorParameters: () => [] });
1034
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
+ });
1321
+ }
1322
+ matchesPath(path) {
1323
+ const current = this.#url();
1324
+ return typeof path === 'string' ? current === path : path.test(current);
1325
+ }
1326
+ deepestSnapshot() {
1327
+ return deepestRouteSnapshot(this.router.routerState.snapshot.root);
1328
+ }
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' });
1331
+ }
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: () => [] });
1336
+
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;
1347
+ }
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.');
1351
+ }
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
+ }
1395
+ if (config.mode === 'lazy') {
1396
+ queueMicrotask(() => {
1397
+ void lang.loadNamespaces(namespaces);
1398
+ });
1399
+ return true;
1400
+ }
1401
+ try {
1402
+ await lang.loadNamespaces(namespaces);
1403
+ }
1404
+ catch (error) {
1405
+ if (config.onError === 'throw') {
1406
+ throw error;
1407
+ }
1408
+ // Keep navigation alive; runtime lazy loading remains a fallback.
1409
+ }
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
+ }
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 [];
1430
+ }
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)));
1455
+ }
1456
+
1035
1457
  /**
1036
1458
  * Type-safe mapping of available theme names.
1037
1459
  *
@@ -1053,27 +1475,12 @@ const themes = {
1053
1475
  dark: 'dark',
1054
1476
  };
1055
1477
  /**
1056
- * CSS class prefix used for dark theme styling.
1057
- *
1058
- * This constant defines the prefix applied to HTML elements when the dark theme is active.
1059
- * It is typically added to the root element or specific components to enable dark theme styles.
1060
- *
1061
- * @example
1062
- * ```typescript
1063
- * document.body.classList.add(darkThemePrefix); // Applies 're-dark' class
1064
- * ```
1478
+ * @deprecated Prefer `theme.dom.darkClassName` via `providePresentia(...)`.
1065
1479
  */
1066
1480
  const darkThemePrefix = 're-dark';
1067
1481
 
1068
1482
  /**
1069
- * Default theme configuration object.
1070
- *
1071
- * Defines the initial theme settings for the application.
1072
- * By default, sets the light theme as the active theme.
1073
- *
1074
- * This configuration can be overridden when providing `THEME_CONFIG` token
1075
- * at the module or application level.
1076
- * ```
1483
+ * @deprecated Prefer configuring themes through `providePresentia({ theme: ... })`.
1077
1484
  */
1078
1485
  const defaultThemeConfig = {
1079
1486
  defaultTheme: themes.light,
@@ -1097,6 +1504,28 @@ const defaultThemeConfig = {
1097
1504
  */
1098
1505
  const THEME_CONFIG = new InjectionToken('RE_THEME_CONFIG');
1099
1506
 
1507
+ /**
1508
+ * @deprecated Prefer configuring theme persistence through `providePresentia({ theme: { persistence... } })`.
1509
+ */
1510
+ const defaultThemePersistenceAdapter = {
1511
+ getItem: (key) => localStorage.getItem(key),
1512
+ setItem: (key, value) => localStorage.setItem(key, value),
1513
+ };
1514
+ /**
1515
+ * DI token for the persistence adapter used by `ThemeService`
1516
+ * to store and retrieve the selected theme (default key: `'theme'`).
1517
+ *
1518
+ * By default `presentia` provides a `localStorage`-backed adapter.
1519
+ *
1520
+ * Example:
1521
+ * ```ts
1522
+ * { provide: THEME_PERSISTENCE_ADAPTER, useValue: sessionStorageAdapter }
1523
+ * ```
1524
+ */
1525
+ const THEME_PERSISTENCE_ADAPTER = new InjectionToken('RE_THEME_PERSISTENCE_ADAPTER', {
1526
+ factory: () => defaultThemePersistenceAdapter,
1527
+ });
1528
+
1100
1529
  /**
1101
1530
  * Service for managing application theme.
1102
1531
  *
@@ -1112,11 +1541,18 @@ const THEME_CONFIG = new InjectionToken('RE_THEME_CONFIG');
1112
1541
  */
1113
1542
  class ThemeService {
1114
1543
  config = inject(THEME_CONFIG);
1115
- themeDefault = this.config?.defaultTheme || themes.light;
1116
- darkPrefix = this.config?.darkThemePrefix || darkThemePrefix;
1544
+ registry = this.resolveRegistry(this.config?.registry);
1545
+ themeDefault = this.resolveInitialTheme(this.config?.defaultTheme);
1546
+ domStrategy = this.config?.dom?.strategy ?? 'root-class';
1547
+ darkPrefix = this.config?.dom?.darkThemePrefix || this.config?.darkThemePrefix || darkThemePrefix;
1548
+ attributeName = this.config?.dom?.attributeName || 'data-theme';
1549
+ themeClassPrefix = this.config?.dom?.themeClassPrefix;
1550
+ classNameBuilder = this.config?.dom?.classNameBuilder;
1551
+ rootSelector = this.config?.dom?.rootSelector;
1552
+ persistence = inject(THEME_PERSISTENCE_ADAPTER);
1117
1553
  isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
1118
1554
  document = inject(DOCUMENT);
1119
- #theme = signal(this.themeDefault, ...(ngDevMode ? [{ debugName: "#theme" }] : []));
1555
+ #theme = signal(this.themeDefault, ...(ngDevMode ? [{ debugName: "#theme" }] : /* istanbul ignore next */ []));
1120
1556
  /**
1121
1557
  * Current active theme (`light` or `dark`).
1122
1558
  *
@@ -1126,26 +1562,22 @@ class ThemeService {
1126
1562
  * <div [class]="themeService.theme()"></div>
1127
1563
  * ```
1128
1564
  */
1129
- theme = computed(() => this.#theme(), ...(ngDevMode ? [{ debugName: "theme" }] : []));
1565
+ theme = computed(() => this.#theme(), ...(ngDevMode ? [{ debugName: "theme" }] : /* istanbul ignore next */ []));
1130
1566
  /**
1131
1567
  * Convenient flag returning `true` if the light theme is active.
1132
1568
  * Suitable for conditional style application or resource selection.
1133
1569
  */
1134
- isLight = computed(() => this.#theme() === themes.light, ...(ngDevMode ? [{ debugName: "isLight" }] : []));
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 */ []));
1135
1572
  constructor() {
1136
1573
  effect(() => {
1137
1574
  if (!this.isBrowser) {
1138
1575
  this.#theme.set(this.themeDefault);
1139
1576
  return;
1140
1577
  }
1141
- const theme = this.resolveTheme(localStorage.getItem('theme'));
1578
+ const theme = this.resolveTheme(this.getStoredTheme());
1142
1579
  this.switch(theme);
1143
1580
  });
1144
- effect(() => {
1145
- if (this.isBrowser) {
1146
- localStorage.setItem('theme', this.#theme());
1147
- }
1148
- });
1149
1581
  }
1150
1582
  /**
1151
1583
  * Switches theme.
@@ -1157,28 +1589,116 @@ class ThemeService {
1157
1589
  */
1158
1590
  switch(theme) {
1159
1591
  const requestedTheme = theme ? this.resolveTheme(theme) : undefined;
1160
- const newTheme = requestedTheme ?? (this.#theme() === themes.light ? themes.dark : themes.light);
1592
+ const newTheme = requestedTheme ?? this.nextTheme();
1161
1593
  if (this.isBrowser) {
1162
- const html = this.document.documentElement;
1163
- newTheme === themes.dark && html.classList.add(this.darkPrefix);
1164
- newTheme === themes.light && html.classList.remove(this.darkPrefix);
1594
+ this.applyThemeToDom(newTheme);
1595
+ this.persistTheme(newTheme);
1165
1596
  }
1166
1597
  this.#theme.set(newTheme);
1167
1598
  }
1599
+ is(theme) {
1600
+ const allowed = Array.isArray(theme) ? theme : [theme];
1601
+ return allowed.includes(this.#theme());
1602
+ }
1168
1603
  resolveTheme(theme) {
1169
- return theme === themes.dark ? themes.dark : themes.light;
1604
+ if (theme && this.registry.includes(theme)) {
1605
+ return theme;
1606
+ }
1607
+ return this.themeDefault;
1608
+ }
1609
+ resolveRegistry(registry) {
1610
+ return registry?.length ? registry : [themes.light, themes.dark];
1611
+ }
1612
+ resolveInitialTheme(theme) {
1613
+ const next = theme ?? themes.light;
1614
+ return this.registry.includes(next) ? next : (this.registry[0] ?? themes.light);
1615
+ }
1616
+ getStoredTheme() {
1617
+ return this.persistence.getItem('theme');
1618
+ }
1619
+ persistTheme(theme) {
1620
+ this.persistence.setItem('theme', theme);
1621
+ }
1622
+ applyThemeToDom(theme) {
1623
+ const root = this.resolveRootElement();
1624
+ if (!root) {
1625
+ return;
1626
+ }
1627
+ if (this.domStrategy === 'data-attribute') {
1628
+ root.setAttribute(this.attributeName, theme);
1629
+ return;
1630
+ }
1631
+ this.clearThemeClasses(root);
1632
+ if (theme === themes.dark) {
1633
+ root.classList.add(this.darkPrefix);
1634
+ }
1635
+ else {
1636
+ root.classList.remove(this.darkPrefix);
1637
+ }
1638
+ const className = this.resolveThemeClassName(theme);
1639
+ if (className) {
1640
+ root.classList.add(className);
1641
+ }
1642
+ }
1643
+ nextTheme() {
1644
+ const currentIndex = this.registry.indexOf(this.#theme());
1645
+ if (currentIndex < 0) {
1646
+ return this.themeDefault;
1647
+ }
1648
+ return this.registry[(currentIndex + 1) % this.registry.length] ?? this.themeDefault;
1649
+ }
1650
+ resolveRootElement() {
1651
+ if (!this.rootSelector) {
1652
+ return this.document.documentElement;
1653
+ }
1654
+ return this.document.querySelector(this.rootSelector);
1655
+ }
1656
+ resolveThemeClassName(theme) {
1657
+ if (this.classNameBuilder) {
1658
+ return this.classNameBuilder(theme);
1659
+ }
1660
+ if (this.themeClassPrefix) {
1661
+ return `${this.themeClassPrefix}${theme}`;
1662
+ }
1663
+ return null;
1664
+ }
1665
+ clearThemeClasses(root) {
1666
+ if (!this.classNameBuilder && !this.themeClassPrefix) {
1667
+ return;
1668
+ }
1669
+ for (const registeredTheme of this.registry) {
1670
+ const className = this.resolveThemeClassName(registeredTheme);
1671
+ if (className) {
1672
+ root.classList.remove(className);
1673
+ }
1674
+ }
1170
1675
  }
1171
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: ThemeService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1172
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: ThemeService, providedIn: 'root' });
1676
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: ThemeService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1677
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: ThemeService, providedIn: 'root' });
1173
1678
  }
1174
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: ThemeService, decorators: [{
1679
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: ThemeService, decorators: [{
1175
1680
  type: Injectable,
1176
1681
  args: [{
1177
1682
  providedIn: 'root',
1178
1683
  }]
1179
1684
  }], ctorParameters: () => [] });
1180
1685
 
1686
+ let warnedLegacyProvider = false;
1687
+ /**
1688
+ * @deprecated Prefer `providePresentia(...)` for new integrations.
1689
+ */
1181
1690
  function provideReInit(config) {
1691
+ if (typeof ngDevMode !== 'undefined' && !warnedLegacyProvider) {
1692
+ warnedLegacyProvider = true;
1693
+ // eslint-disable-next-line no-console
1694
+ console.warn('[presentia] provideReInit(...) is the legacy 1.x provider. Prefer providePresentia(...) for the grouped v2 config API.');
1695
+ }
1696
+ return buildPresentiaProviders(config);
1697
+ }
1698
+ function __resetPresentiaProviderWarningsForTests() {
1699
+ warnedLegacyProvider = false;
1700
+ }
1701
+ function buildPresentiaProviders(config) {
1182
1702
  return makeEnvironmentProviders([
1183
1703
  { provide: TRANSLATION, deps: [LangService], useFactory: (ls) => ls },
1184
1704
  { provide: SELECTED_LANG, deps: [LangService], useFactory: (ls) => ls[innerLangVal] },
@@ -1192,13 +1712,88 @@ function provideReInit(config) {
1192
1712
  { provide: CURRENT_DEVICE, deps: [AdaptiveService], useFactory: (ls) => ls.device },
1193
1713
  { provide: DEVICE_BREAKPOINTS, useValue: config.breakpoints || defaultBreakpoints },
1194
1714
  { provide: THEME_CONFIG, useValue: config.theme || defaultThemeConfig },
1715
+ { provide: THEME_PERSISTENCE_ADAPTER, useValue: defaultThemePersistenceAdapter },
1195
1716
  { provide: LANG_CONFIG, useValue: config.locale || { defaultValue: '--------' } },
1196
1717
  { provide: LANG_PIPE_CONFIG, useValue: config.langPipe || {} },
1197
1718
  { provide: LANG_MISSING_KEY_HANDLER, useValue: config.langMissingKeyHandler ?? null },
1198
1719
  { provide: LOCALE_ID, useValue: config.locale.defaultLang ?? 'ru' },
1720
+ providePresentiaRouteNamespacePreload(),
1721
+ ]);
1722
+ }
1723
+
1724
+ function providePresentia(config) {
1725
+ const routesConfig = config.lang.preload?.routes;
1726
+ const diagnosticsEnabled = config.lang.diagnostics?.lateNamespaceLoads;
1727
+ const routeNamespacePreload = routesConfig || diagnosticsEnabled
1728
+ ? { ...routesConfig, diagnostics: diagnosticsEnabled ?? routesConfig?.diagnostics }
1729
+ : undefined;
1730
+ 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
+ }
1746
+ return makeEnvironmentProviders([
1747
+ buildPresentiaProviders({
1748
+ locale: {
1749
+ url: config.lang.source.url,
1750
+ isFromAssets: config.lang.source.fromAssets,
1751
+ defaultLang: config.lang.source.defaultLang,
1752
+ fallbackLang: config.lang.source.fallbackLang,
1753
+ supportedLangs: config.lang.source.supportedLangs,
1754
+ kgValue: config.lang.source.kgValue,
1755
+ defaultValue: config.lang.rendering?.missingValue,
1756
+ preloadNamespaces: config.lang.preload?.global,
1757
+ requestBuilder: config.lang.transport?.requestBuilder,
1758
+ requestOptionsFactory: config.lang.transport?.requestOptionsFactory,
1759
+ responseAdapter: config.lang.transport?.responseAdapter,
1760
+ batchRequestBuilder: config.lang.transport?.batchRequestBuilder,
1761
+ batchResponseAdapter: config.lang.transport?.batchResponseAdapter,
1762
+ namespaceCache: config.lang.cache,
1763
+ routeNamespacePreload,
1764
+ maxBatchSize: config.lang.transport?.maxBatchSize,
1765
+ },
1766
+ theme: config.theme
1767
+ ? {
1768
+ registry: config.theme.registry,
1769
+ defaultTheme: config.theme.defaultTheme,
1770
+ darkThemePrefix: config.theme.dom?.darkClassName,
1771
+ dom: config.theme.dom
1772
+ ? {
1773
+ strategy: config.theme.dom.strategy,
1774
+ rootSelector: config.theme.dom.rootSelector,
1775
+ darkThemePrefix: config.theme.dom.darkClassName,
1776
+ attributeName: config.theme.dom.attributeName,
1777
+ themeClassPrefix: config.theme.dom.classPrefix,
1778
+ classNameBuilder: config.theme.dom.classNameBuilder,
1779
+ }
1780
+ : undefined,
1781
+ }
1782
+ : undefined,
1783
+ breakpoints: config.adaptive?.breakpoints,
1784
+ langPipe: {
1785
+ placeholder: config.lang.rendering?.placeholder,
1786
+ },
1787
+ langMissingKeyHandler: config.lang.missingKeyHandler,
1788
+ }),
1789
+ ...extraProviders,
1199
1790
  ]);
1200
1791
  }
1201
1792
 
1793
+ /**
1794
+ * @deprecated Prefer `providePresentia(...)` for new integrations.
1795
+ */
1796
+
1202
1797
  /**
1203
1798
  * Service for managing page SEO metadata.
1204
1799
  *
@@ -1326,10 +1921,10 @@ class SeoService {
1326
1921
  link.href = href;
1327
1922
  }
1328
1923
  }
1329
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: SeoService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1330
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: SeoService, providedIn: 'root' });
1924
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: SeoService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1925
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: SeoService, providedIn: 'root' });
1331
1926
  }
1332
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: SeoService, decorators: [{
1927
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: SeoService, decorators: [{
1333
1928
  type: Injectable,
1334
1929
  args: [{ providedIn: 'root' }]
1335
1930
  }] });
@@ -1355,7 +1950,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.1", ngImpor
1355
1950
  class SeoRouteListener {
1356
1951
  router = inject(Router);
1357
1952
  seo = inject(SeoService);
1358
- ar = inject(ActivatedRoute);
1359
1953
  destroyRef = inject(DestroyRef);
1360
1954
  initialized = false;
1361
1955
  baseUrl = '';
@@ -1369,9 +1963,9 @@ class SeoRouteListener {
1369
1963
  init(baseUrl) {
1370
1964
  this.baseUrl = baseUrl.replace(/\/+$/, '');
1371
1965
  const applyRouteSeo = () => {
1372
- const route = this.deepest(this.ar);
1373
- const data = route.snapshot.data;
1374
- const url = data.canonical ?? this.baseUrl + this.router.url;
1966
+ const snapshot = deepestRouteSnapshot(this.router.routerState.snapshot.root);
1967
+ const data = snapshotMergedData(snapshot);
1968
+ const url = resolveCanonicalUrl(data.canonical, this.baseUrl, snapshotFullPath(snapshot));
1375
1969
  data.title && this.seo.setTitle(data.title);
1376
1970
  data.description && this.seo.setDescription(data.description);
1377
1971
  data.twitter && this.seo.setTwitter(data.twitter);
@@ -1397,123 +1991,33 @@ class SeoRouteListener {
1397
1991
  });
1398
1992
  this.destroyRef.onDestroy(() => subscription.unsubscribe());
1399
1993
  }
1400
- /**
1401
- * Recursively finds the deepest (most nested) activated route in the route tree.
1402
- * This is used to extract route data from the currently active leaf route.
1403
- *
1404
- * @param r - The root activated route to start traversing from.
1405
- * @returns The deepest child route in the hierarchy.
1406
- */
1407
- deepest(r) {
1408
- let cur = r;
1409
- while (cur.firstChild) {
1410
- cur = cur.firstChild;
1411
- }
1412
- return cur;
1413
- }
1414
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: SeoRouteListener, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1415
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: SeoRouteListener, providedIn: 'root' });
1994
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: SeoRouteListener, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1995
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: SeoRouteListener, providedIn: 'root' });
1416
1996
  }
1417
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: SeoRouteListener, decorators: [{
1997
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: SeoRouteListener, decorators: [{
1418
1998
  type: Injectable,
1419
1999
  args: [{ providedIn: 'root' }]
1420
2000
  }] });
1421
-
1422
- /**
1423
- * Reactive snapshot of the current route (the deepest active route).
1424
- * Updates on every `NavigationEnd` event.
1425
- *
1426
- * Provides:
1427
- * - `params` / `query` — strings (as in Angular Router)
1428
- * - `data` — arbitrary data (from route configuration/resolvers)
1429
- * - `url` — string assembled from `UrlSegment[]`
1430
- * - `fragment` — hash (#section)
1431
- * - `selectData(key)` — type-safe selector for `data`
1432
- * - `state` — combined computed object (convenient for single subscriber)
1433
- */
1434
- class RouteWatcher {
1435
- router = inject(Router);
1436
- destroyRef = inject(DestroyRef);
1437
- #params = signal({}, ...(ngDevMode ? [{ debugName: "#params" }] : []));
1438
- #query = signal({}, ...(ngDevMode ? [{ debugName: "#query" }] : []));
1439
- #data = signal({}, ...(ngDevMode ? [{ debugName: "#data" }] : []));
1440
- #url = signal('', ...(ngDevMode ? [{ debugName: "#url" }] : []));
1441
- #fragment = signal(null, ...(ngDevMode ? [{ debugName: "#fragment" }] : []));
1442
- /** Signal for tracking and retrieving URL parameters */
1443
- params = this.#params.asReadonly();
1444
- /** Signal for tracking and retrieving query parameters */
1445
- query = this.#query.asReadonly();
1446
- /** Signal for tracking and retrieving route data */
1447
- data = this.#data.asReadonly();
1448
- /** Signal for tracking and retrieving URL */
1449
- url = this.#url.asReadonly();
1450
- /** Signal for tracking and retrieving URL fragment */
1451
- fragment = this.#fragment.asReadonly();
1452
- /** Combined computed snapshot (to avoid multiple effects) */
1453
- state = computed(() => ({
1454
- params: this.#params(),
1455
- query: this.#query(),
1456
- data: this.#data(),
1457
- url: this.#url(),
1458
- fragment: this.#fragment(),
1459
- }), ...(ngDevMode ? [{ debugName: "state" }] : []));
1460
- constructor() {
1461
- const read = () => {
1462
- const snap = this.deepestSnapshot();
1463
- const url = snapshotFullPath(snap);
1464
- const params = snapshotMergedParams(snap);
1465
- !deepEqual(params, this.#params()) && this.#params.set(params);
1466
- !deepEqual(snap.queryParams, this.#query()) && this.#query.set(snap.queryParams);
1467
- !deepEqual(snap.data, this.#data()) && this.#data.set(snap.data);
1468
- this.#url() !== url && this.#url.set(url);
1469
- this.#fragment() !== snap.fragment && this.#fragment.set(snap.fragment ?? null);
1470
- };
1471
- read();
1472
- const subscription = this.router.events.subscribe((event) => {
1473
- if (event instanceof NavigationEnd) {
1474
- read();
1475
- }
1476
- });
1477
- this.destroyRef.onDestroy(() => subscription.unsubscribe());
2001
+ function resolveCanonicalUrl(canonical, baseUrl, path) {
2002
+ if (!canonical) {
2003
+ return joinBaseUrl(baseUrl, path);
1478
2004
  }
1479
- /** Convenient selector for a data key with type-safe casting */
1480
- selectData(key) {
1481
- return computed(() => this.#data()[key]);
2005
+ if (/^https?:\/\//i.test(canonical)) {
2006
+ return canonical;
1482
2007
  }
1483
- deepestSnapshot() {
1484
- // work with snapshot — we need a "frozen" point at NavigationEnd moment
1485
- let snap = this.router.routerState.snapshot.root;
1486
- while (snap.firstChild) {
1487
- snap = snap.firstChild;
1488
- }
1489
- return snap;
1490
- }
1491
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: RouteWatcher, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1492
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: RouteWatcher, providedIn: 'root' });
1493
- }
1494
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: RouteWatcher, decorators: [{
1495
- type: Injectable,
1496
- args: [{ providedIn: 'root' }]
1497
- }], ctorParameters: () => [] });
1498
- /** Joins `UrlSegment[]` into a path string */
1499
- function joinUrl(segments) {
1500
- return segments.length ? segments.map((s) => s.path).join('/') : '';
2008
+ return joinBaseUrl(baseUrl, canonical);
1501
2009
  }
1502
- /** Builds a full route path from root to current snapshot */
1503
- function snapshotFullPath(snap) {
1504
- return snap.pathFromRoot
1505
- .map((s) => joinUrl(s.url))
1506
- .filter(Boolean)
1507
- .join('/');
1508
- }
1509
- /** Merges params from root to the deepest snapshot (child keys override parent). */
1510
- function snapshotMergedParams(snap) {
1511
- return snap.pathFromRoot.reduce((acc, route) => ({ ...acc, ...route.params }), {});
2010
+ function joinBaseUrl(baseUrl, path) {
2011
+ const normalizedPath = path.replace(/^\/+/, '');
2012
+ if (!baseUrl) {
2013
+ return normalizedPath ? `/${normalizedPath}` : '/';
2014
+ }
2015
+ return normalizedPath ? `${baseUrl}/${normalizedPath}` : baseUrl;
1512
2016
  }
1513
2017
 
1514
2018
  /**
1515
2019
  * Generated bundle index. Do not edit.
1516
2020
  */
1517
2021
 
1518
- export { AdaptiveService, DEVICE_BREAKPOINTS, IfDeviceDirective, LANG_CONFIG, LANG_MISSING_KEY_HANDLER, LANG_PIPE_CONFIG, LangDirective, LangPipe, LangService, RouteWatcher, SeoRouteListener, SeoService, THEME_CONFIG, ThemeService, darkThemePrefix, defaultBreakpoints, defaultThemeConfig, innerLangVal, provideReInit, themes };
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 };
1519
2023
  //# sourceMappingURL=reforgium-presentia.mjs.map