@reforgium/presentia 1.3.0 → 1.4.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,14 +1,14 @@
1
1
  import * as i0 from '@angular/core';
2
- import { InjectionToken, signal, computed, inject, PLATFORM_ID, afterRenderEffect, Injectable, TemplateRef, ViewContainerRef, effect, Input, Directive, input, ElementRef, Renderer2, Injector, afterNextRender, runInInjectionContext, Optional, Inject, Pipe, makeEnvironmentProviders, LOCALE_ID, DestroyRef } 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
3
  import { TRANSLATION, SELECTED_LANG, CHANGE_LANG, SELECTED_THEME, CHANGE_THEME, CURRENT_DEVICE, deepEqual } from '@reforgium/internal';
4
4
  import { BreakpointObserver } from '@angular/cdk/layout';
5
5
  import { isPlatformBrowser, DOCUMENT } from '@angular/common';
6
- import { fromEvent, debounceTime, startWith, filter as filter$1, map } from 'rxjs';
6
+ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
7
+ import { fromEvent, debounceTime, firstValueFrom, tap, filter as filter$1 } from 'rxjs';
7
8
  import { HttpClient } from '@angular/common/http';
8
9
  import { Title, Meta } from '@angular/platform-browser';
9
10
  import { Router, ActivatedRoute, NavigationEnd } from '@angular/router';
10
11
  import { filter } from 'rxjs/operators';
11
- import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
12
12
 
13
13
  /**
14
14
  * Default breakpoints for device type detection.
@@ -108,6 +108,7 @@ class AdaptiveService {
108
108
  isPortrait = computed(() => this.#height() > this.#width(), ...(ngDevMode ? [{ debugName: "isPortrait" }] : []));
109
109
  deviceBreakpoints = inject(DEVICE_BREAKPOINTS);
110
110
  devicePriority = Object.keys(this.deviceBreakpoints);
111
+ destroyRef = inject(DestroyRef);
111
112
  breakpointObserver = inject(BreakpointObserver);
112
113
  isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
113
114
  constructor() {
@@ -119,15 +120,23 @@ class AdaptiveService {
119
120
  this.#width.set(window.innerWidth);
120
121
  this.#height.set(window.innerHeight);
121
122
  });
122
- this.breakpointObserver.observe(Object.values(this.deviceBreakpoints)).subscribe((state) => {
123
- const device = this.devicePriority.find((key) => state.breakpoints[this.deviceBreakpoints[key]]);
124
- this.#device.set(device ?? 'desktop');
123
+ this.breakpointObserver
124
+ .observe(Object.values(this.deviceBreakpoints))
125
+ .pipe(takeUntilDestroyed(this.destroyRef))
126
+ .subscribe((state) => {
127
+ const device = this.devicePriority.find((key) => state.breakpoints[this.deviceBreakpoints[key]]) ?? 'desktop';
128
+ if (this.#device() !== device) {
129
+ this.#device.set(device);
130
+ }
125
131
  });
126
132
  fromEvent(window, 'resize')
127
133
  .pipe(debounceTime(100))
134
+ .pipe(takeUntilDestroyed(this.destroyRef))
128
135
  .subscribe(() => {
129
- this.#width.set(window.innerWidth);
130
- this.#height.set(window.innerHeight);
136
+ const width = window.innerWidth;
137
+ const height = window.innerHeight;
138
+ this.#width() !== width && this.#width.set(width);
139
+ this.#height() !== height && this.#height.set(height);
131
140
  });
132
141
  }
133
142
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: AdaptiveService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
@@ -214,6 +223,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.1", ngImpor
214
223
 
215
224
  const innerLangVal = Symbol('reInnerLangVal');
216
225
 
226
+ const LANG_MISSING_KEY_HANDLER = new InjectionToken('RE_LANG_MISSING_KEY_HANDLER');
227
+
217
228
  /**
218
229
  * Injection token for providing locale configuration to the language module.
219
230
  *
@@ -241,30 +252,48 @@ const LANG_CONFIG = new InjectionToken('RE_LANG_CONFIG');
241
252
  * throughout the application.
242
253
  */
243
254
  class LangService {
255
+ static BUILTIN_LANGS = ['ru', 'kg', 'en'];
256
+ static LANG_CODE_RE = /^[a-z]{2,3}(?:-[a-z0-9]{2,8})?$/;
244
257
  config = inject(LANG_CONFIG);
245
258
  http = inject(HttpClient);
246
259
  isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
260
+ missingKeyHandler = inject(LANG_MISSING_KEY_HANDLER, { optional: true });
261
+ supportedLangSet = new Set([
262
+ ...LangService.BUILTIN_LANGS,
263
+ ...this.normalizeSupportedLangs(this.config.supportedLangs ?? []),
264
+ ]);
247
265
  #lang = signal(this.getStoredLang(), ...(ngDevMode ? [{ debugName: "#lang" }] : []));
248
266
  #cache = signal({}, ...(ngDevMode ? [{ debugName: "#cache" }] : []));
249
267
  #loadedNamespaces = new Set();
250
268
  #pendingLoads = new Map();
269
+ #pendingBatchLoads = new Map();
270
+ #namespaceLoadedAt = new Map();
271
+ #missingKeyFallbacks = new Map();
251
272
  /**
252
273
  * Computed property determining the current language setting.
253
274
  *
254
- * - If private method `#lang` returns 'ru', this property will return 'ru'.
255
- * - If `#lang` returns another value, the `config.kgValue` property is checked:
275
+ * - If private method `#lang` returns 'kg', `config.kgValue` is checked:
256
276
  * - If `config.kgValue` is defined, the property will return its value.
257
277
  * - If `config.kgValue` is not defined, the property will return the default value 'kg'.
278
+ * - For other languages (e.g. `ru`, `en`) returns source language as-is.
258
279
  */
259
280
  currentLang = computed(() => {
260
281
  const lang = this.#lang();
261
- return lang === 'ru' ? 'ru' : (this.config?.kgValue ?? 'kg');
282
+ return lang === 'kg' ? (this.config?.kgValue ?? 'kg') : lang;
262
283
  }, ...(ngDevMode ? [{ debugName: "currentLang" }] : []));
263
284
  /**
264
285
  * Extracts readonly value from private property `#lang` and assigns it to `innerLangVal`.
265
286
  * Expected that property `#lang` has `asReadonly` method that returns immutable representation.
266
287
  */
267
288
  [innerLangVal] = this.#lang.asReadonly();
289
+ constructor() {
290
+ const preload = this.config.preloadNamespaces ?? [];
291
+ if (preload.length) {
292
+ queueMicrotask(() => {
293
+ void this.loadNamespaces(preload);
294
+ });
295
+ }
296
+ }
268
297
  /**
269
298
  * Sets the current language for the application.
270
299
  *
@@ -273,44 +302,32 @@ class LangService {
273
302
  * @return {void} Returns no value.
274
303
  */
275
304
  setLang(lang) {
276
- const langVal = lang === 'ky' ? 'kg' : lang;
305
+ const langVal = this.normalizeLang(lang);
306
+ if (!langVal || !this.supportedLangSet.has(langVal)) {
307
+ return;
308
+ }
277
309
  if (langVal !== this.#lang()) {
278
310
  this.#lang.set(langVal);
279
311
  if (this.isBrowser) {
280
312
  localStorage.setItem('lang', langVal);
281
313
  }
282
- const namespaces = Array.from(this.#loadedNamespaces.values()).map((key) => key.split('.')[1]);
314
+ const namespaces = Array.from(this.#loadedNamespaces.values()).map((key) => this.namespaceFromKey(key));
283
315
  this.#loadedNamespaces.clear();
284
- namespaces.forEach((ns) => void this.loadNamespace(ns));
316
+ this.#namespaceLoadedAt.clear();
317
+ void this.loadNamespaces(namespaces);
285
318
  }
286
319
  }
287
- /**
288
- * Gets value based on a provided query and optionally applies specified parameters.
289
- *
290
- * @param {string} query - Query string used to retrieve desired value.
291
- * @param {LangParams} [params] - Optional parameters to apply to retrieved value.
292
- * @return {string} Retrieved value after optional parameter application,
293
- * or default value if a query is not found.
294
- */
295
320
  get(query, params) {
296
321
  const value = this.getChainedValue(query);
322
+ const baseValue = value ?? this.resolveMissingValue(query);
297
323
  if (params) {
298
- return this.applyParams((value ?? query), params);
324
+ return this.applyParams(baseValue, params);
299
325
  }
300
- return value ?? this.config.defaultValue ?? query;
326
+ return baseValue;
301
327
  }
302
- /**
303
- * Observes changes to a specified translation key and dynamically computes its value.
304
- *
305
- * @param {string} query - Translation key to observe, typically in format "namespace.key".
306
- * @param {LangParams} [params] - Optional parameters for interpolation or
307
- * dynamic content replacement in translation value.
308
- * @return {Signal<string>} Computed value that dynamically updates
309
- * with translation matching a provided query and parameters.
310
- */
311
328
  observe(query, params) {
312
329
  const [ns] = query.split('.');
313
- if (!this.#loadedNamespaces.has(this.makeNamespaceKey(ns))) {
330
+ if (!this.isNamespaceLoaded(ns)) {
314
331
  void this.loadNamespace(ns);
315
332
  }
316
333
  return computed(() => this.get(query, params));
@@ -323,33 +340,117 @@ class LangService {
323
340
  * or rejects when error occurs during a process.
324
341
  */
325
342
  async loadNamespace(ns) {
326
- const key = this.makeNamespaceKey(ns);
327
- if (this.#loadedNamespaces.has(key)) {
343
+ const requestedLang = this.#lang();
344
+ const key = this.makeNamespaceKey(ns, requestedLang);
345
+ if (this.isNamespaceValid(key)) {
328
346
  return;
329
347
  }
348
+ this.tryExpireNamespace(key);
330
349
  if (this.#pendingLoads.has(key)) {
331
350
  return this.#pendingLoads.get(key);
332
351
  }
333
- const promise = new Promise((resolve, reject) => {
334
- this.http.get(this.makeUrl(ns)).subscribe({
335
- next: (res) => {
336
- const resolved = Array.isArray(res) ? this.parseModelToRecord(res) : this.parseAssetToRecord(ns, res);
352
+ const promise = (async () => {
353
+ try {
354
+ await firstValueFrom(this.http.request('GET', this.makeUrl(ns, requestedLang), this.makeRequestOptions(ns, requestedLang)).pipe(tap((res) => {
355
+ // Ignore stale responses from a previously selected language.
356
+ if (this.#lang() !== requestedLang) {
357
+ return;
358
+ }
359
+ const resolved = this.adaptResponse(res, ns, requestedLang);
337
360
  this.#cache.update((existing) => ({ ...existing, ...resolved }));
338
361
  this.#loadedNamespaces.add(key);
339
- resolve();
340
- },
341
- error: (err) => {
342
- this.#pendingLoads.delete(key);
343
- reject(err);
344
- },
345
- complete: () => this.#pendingLoads.delete(key),
346
- });
347
- });
362
+ this.#namespaceLoadedAt.set(key, Date.now());
363
+ this.enforceNamespaceCacheLimit(requestedLang);
364
+ })));
365
+ }
366
+ finally {
367
+ this.#pendingLoads.delete(key);
368
+ }
369
+ })();
348
370
  this.#pendingLoads.set(key, promise);
349
371
  return promise;
350
372
  }
351
373
  isNamespaceLoaded(ns) {
352
- return this.#loadedNamespaces.has(this.makeNamespaceKey(ns));
374
+ const key = this.makeNamespaceKey(ns);
375
+ const isValid = this.isNamespaceValid(key);
376
+ if (!isValid) {
377
+ this.tryExpireNamespace(key);
378
+ }
379
+ return isValid;
380
+ }
381
+ async loadNamespaces(namespaces) {
382
+ const uniqueNamespaces = Array.from(new Set(namespaces.map((ns) => ns.trim()).filter(Boolean)));
383
+ if (!uniqueNamespaces.length) {
384
+ return;
385
+ }
386
+ const requestedLang = this.#lang();
387
+ const toLoad = uniqueNamespaces.filter((ns) => {
388
+ const key = this.makeNamespaceKey(ns, requestedLang);
389
+ const valid = this.isNamespaceValid(key);
390
+ if (!valid) {
391
+ this.tryExpireNamespace(key);
392
+ }
393
+ return !valid;
394
+ });
395
+ if (!toLoad.length) {
396
+ return;
397
+ }
398
+ if (!(this.config.batchRequestBuilder && this.config.batchResponseAdapter) || toLoad.length < 2) {
399
+ await Promise.all(toLoad.map((ns) => this.loadNamespace(ns)));
400
+ return;
401
+ }
402
+ const batchKey = this.makeBatchKey(requestedLang, toLoad);
403
+ if (this.#pendingBatchLoads.has(batchKey)) {
404
+ return this.#pendingBatchLoads.get(batchKey);
405
+ }
406
+ const promise = (async () => {
407
+ try {
408
+ const context = {
409
+ namespaces: toLoad,
410
+ lang: requestedLang,
411
+ isFromAssets: this.config.isFromAssets,
412
+ baseUrl: this.config.url,
413
+ };
414
+ const url = this.config.batchRequestBuilder(context);
415
+ const response = await firstValueFrom(this.http.request('GET', url, this.makeBatchRequestOptions(context)));
416
+ if (this.#lang() !== requestedLang) {
417
+ return;
418
+ }
419
+ const payloads = this.config.batchResponseAdapter(response, this.toBatchResponseContext(context));
420
+ const merged = {};
421
+ for (const ns of toLoad) {
422
+ const nsPayload = payloads[ns];
423
+ if (!nsPayload) {
424
+ continue;
425
+ }
426
+ const adapted = this.adaptNamespacePayload(nsPayload, ns, requestedLang);
427
+ Object.assign(merged, adapted);
428
+ this.#loadedNamespaces.add(this.makeNamespaceKey(ns, requestedLang));
429
+ this.#namespaceLoadedAt.set(this.makeNamespaceKey(ns, requestedLang), Date.now());
430
+ }
431
+ this.#cache.update((existing) => ({ ...existing, ...merged }));
432
+ this.enforceNamespaceCacheLimit(requestedLang);
433
+ }
434
+ finally {
435
+ this.#pendingBatchLoads.delete(batchKey);
436
+ }
437
+ })();
438
+ this.#pendingBatchLoads.set(batchKey, promise);
439
+ return promise;
440
+ }
441
+ evictNamespace(ns) {
442
+ const key = this.makeNamespaceKey(ns);
443
+ if (!this.#loadedNamespaces.has(key)) {
444
+ return;
445
+ }
446
+ this.#loadedNamespaces.delete(key);
447
+ this.#namespaceLoadedAt.delete(key);
448
+ this.removeNamespaceFromCache(ns);
449
+ }
450
+ clearNamespaceCache() {
451
+ this.#loadedNamespaces.clear();
452
+ this.#namespaceLoadedAt.clear();
453
+ this.#cache.set({});
353
454
  }
354
455
  parseModelToRecord(model) {
355
456
  const records = {};
@@ -366,7 +467,7 @@ class LangService {
366
467
  return records;
367
468
  }
368
469
  applyParams(text, params) {
369
- return text.replace(/\{\{(.*?)}}/g, (_, k) => params[k.trim()] ?? '');
470
+ return text.replace(/\{\{(.*?)}}/g, (_, k) => String(params[k.trim()] ?? ''));
370
471
  }
371
472
  getChainedValue(query = '', source = this.#cache()) {
372
473
  if (!query || typeof source !== 'object') {
@@ -387,17 +488,163 @@ class LangService {
387
488
  return acc;
388
489
  }
389
490
  getStoredLang() {
491
+ const normalize = (value) => this.normalizeLang(value);
492
+ const fallbackLang = normalize(this.config.fallbackLang ?? null);
493
+ const defaultLang = normalize(this.config.defaultLang ?? null) ?? fallbackLang ?? 'ru';
390
494
  if (!this.isBrowser) {
391
- return this.config.defaultLang || 'ru';
495
+ return defaultLang;
392
496
  }
393
- return localStorage.getItem('lang') || this.config.defaultLang || 'ru';
497
+ return normalize(localStorage.getItem('lang')) ?? defaultLang;
394
498
  }
395
- makeUrl(ns) {
396
- const suffix = this.config.isFromAssets ? `.${this.#lang()}.json` : `?language=${this.#lang()}`;
499
+ makeUrl(ns, lang) {
500
+ if (this.config.requestBuilder) {
501
+ return this.config.requestBuilder(this.toRequestContext(ns, lang));
502
+ }
503
+ const suffix = this.config.isFromAssets ? `.${lang}.json` : `?language=${lang}`;
397
504
  return `${this.config.url}/${ns}${suffix}`;
398
505
  }
399
- makeNamespaceKey(ns) {
400
- return `${this.#lang()}.${ns}`;
506
+ adaptResponse(response, ns, lang) {
507
+ const context = { ns, lang, isFromAssets: this.config.isFromAssets };
508
+ const adapted = this.config.responseAdapter ? this.config.responseAdapter(response, context) : response;
509
+ return this.adaptNamespacePayload(adapted, ns, lang);
510
+ }
511
+ adaptNamespacePayload(payload, ns, _lang) {
512
+ const adapted = payload;
513
+ if (Array.isArray(adapted)) {
514
+ return this.parseModelToRecord(adapted);
515
+ }
516
+ if (this.isLangModel(adapted)) {
517
+ if (Object.keys(adapted).some((key) => key.startsWith(`${ns}.`))) {
518
+ return adapted;
519
+ }
520
+ return this.parseAssetToRecord(ns, adapted);
521
+ }
522
+ return {};
523
+ }
524
+ makeRequestOptions(ns, lang) {
525
+ if (!this.config.requestOptionsFactory) {
526
+ return { observe: 'body' };
527
+ }
528
+ return { ...this.config.requestOptionsFactory(this.toRequestContext(ns, lang)), observe: 'body' };
529
+ }
530
+ makeBatchRequestOptions(context) {
531
+ if (!this.config.requestOptionsFactory) {
532
+ return { observe: 'body' };
533
+ }
534
+ const options = this.config.requestOptionsFactory({
535
+ ns: context.namespaces.join(','),
536
+ lang: context.lang,
537
+ isFromAssets: context.isFromAssets,
538
+ baseUrl: context.baseUrl,
539
+ });
540
+ return { ...options, observe: 'body' };
541
+ }
542
+ toRequestContext(ns, lang) {
543
+ return { ns, lang, isFromAssets: this.config.isFromAssets, baseUrl: this.config.url };
544
+ }
545
+ toBatchResponseContext(context) {
546
+ return { namespaces: context.namespaces, lang: context.lang, isFromAssets: context.isFromAssets };
547
+ }
548
+ isNamespaceValid(key) {
549
+ if (!this.#loadedNamespaces.has(key)) {
550
+ return false;
551
+ }
552
+ const ttlMs = this.config.namespaceCache?.ttlMs;
553
+ if (!ttlMs || ttlMs <= 0) {
554
+ return true;
555
+ }
556
+ const loadedAt = this.#namespaceLoadedAt.get(key);
557
+ if (!loadedAt) {
558
+ return false;
559
+ }
560
+ return Date.now() - loadedAt < ttlMs;
561
+ }
562
+ tryExpireNamespace(key) {
563
+ if (!this.#loadedNamespaces.has(key)) {
564
+ return;
565
+ }
566
+ const ttlMs = this.config.namespaceCache?.ttlMs;
567
+ if (!ttlMs || ttlMs <= 0) {
568
+ return;
569
+ }
570
+ const loadedAt = this.#namespaceLoadedAt.get(key);
571
+ if (!loadedAt || Date.now() - loadedAt >= ttlMs) {
572
+ this.#loadedNamespaces.delete(key);
573
+ this.#namespaceLoadedAt.delete(key);
574
+ this.removeNamespaceFromCache(this.namespaceFromKey(key));
575
+ }
576
+ }
577
+ enforceNamespaceCacheLimit(currentLang) {
578
+ const maxNamespaces = this.config.namespaceCache?.maxNamespaces;
579
+ if (!maxNamespaces || maxNamespaces <= 0) {
580
+ return;
581
+ }
582
+ const langKeys = Array.from(this.#loadedNamespaces.values()).filter((key) => key.startsWith(`${currentLang}.`));
583
+ if (langKeys.length <= maxNamespaces) {
584
+ return;
585
+ }
586
+ const sorted = langKeys.sort((a, b) => (this.#namespaceLoadedAt.get(a) ?? 0) - (this.#namespaceLoadedAt.get(b) ?? 0));
587
+ const overflow = sorted.slice(0, langKeys.length - maxNamespaces);
588
+ for (const key of overflow) {
589
+ this.#loadedNamespaces.delete(key);
590
+ this.#namespaceLoadedAt.delete(key);
591
+ this.removeNamespaceFromCache(this.namespaceFromKey(key));
592
+ }
593
+ }
594
+ removeNamespaceFromCache(ns) {
595
+ this.#cache.update((existing) => {
596
+ const next = {};
597
+ for (const [key, value] of Object.entries(existing)) {
598
+ if (key === ns || key.startsWith(`${ns}.`)) {
599
+ continue;
600
+ }
601
+ next[key] = value;
602
+ }
603
+ return next;
604
+ });
605
+ }
606
+ namespaceFromKey(key) {
607
+ const idx = key.indexOf('.');
608
+ return idx >= 0 ? key.slice(idx + 1) : key;
609
+ }
610
+ makeBatchKey(lang, namespaces) {
611
+ return `${lang}::${[...namespaces].sort().join('|')}`;
612
+ }
613
+ isLangModel(value) {
614
+ return typeof value === 'object' && value !== null;
615
+ }
616
+ makeNamespaceKey(ns, lang = this.#lang()) {
617
+ return `${lang}.${ns}`;
618
+ }
619
+ resolveMissingValue(query) {
620
+ const fallback = this.config.defaultValue ?? query;
621
+ if (!this.missingKeyHandler) {
622
+ return fallback;
623
+ }
624
+ const cacheKey = `${this.#lang()}::${query}`;
625
+ if (!this.#missingKeyFallbacks.has(cacheKey)) {
626
+ const next = this.missingKeyHandler(query, { lang: this.#lang() });
627
+ if (typeof next === 'string') {
628
+ this.#missingKeyFallbacks.set(cacheKey, next);
629
+ }
630
+ else {
631
+ this.#missingKeyFallbacks.set(cacheKey, null);
632
+ }
633
+ }
634
+ return this.#missingKeyFallbacks.get(cacheKey) ?? fallback;
635
+ }
636
+ normalizeLang(value) {
637
+ const normalized = value?.trim().toLowerCase();
638
+ if (!normalized) {
639
+ return null;
640
+ }
641
+ const resolved = normalized === 'ky' ? 'kg' : normalized;
642
+ return this.supportedLangSet.has(resolved) ? resolved : null;
643
+ }
644
+ normalizeSupportedLangs(langs) {
645
+ return langs
646
+ .map((lang) => lang.trim().toLowerCase())
647
+ .filter((lang) => !!lang && LangService.LANG_CODE_RE.test(lang));
401
648
  }
402
649
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: LangService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
403
650
  static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: LangService, providedIn: 'root' });
@@ -405,7 +652,7 @@ class LangService {
405
652
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: LangService, decorators: [{
406
653
  type: Injectable,
407
654
  args: [{ providedIn: 'root' }]
408
- }] });
655
+ }], ctorParameters: () => [] });
409
656
 
410
657
  /**
411
658
  * **LangDirective** - directive for automatic localization of attributes and text content.
@@ -434,6 +681,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.1", ngImpor
434
681
  * @publicApi
435
682
  */
436
683
  class LangDirective {
684
+ static TEXT_NODE_TYPE = 3;
685
+ warned = new Set();
437
686
  /**
438
687
  * Localization mode: defines which parts of the element will be translated.
439
688
  * @default 'all'
@@ -560,7 +809,7 @@ class LangDirective {
560
809
  }
561
810
  const keyAttr = 'data-re-lang-text';
562
811
  const storedKey = native.getAttribute(keyAttr);
563
- let textNode = Array.from(native.childNodes).find((n) => n.nodeType === Node.TEXT_NODE);
812
+ let textNode = Array.from(native.childNodes).find((n) => n.nodeType === LangDirective.TEXT_NODE_TYPE);
564
813
  const key = (keyOverride ?? storedKey ?? textNode?.nodeValue?.trim() ?? '').trim();
565
814
  if (!this.looksLikeKey(key)) {
566
815
  if (storedKey) {
@@ -575,8 +824,8 @@ class LangDirective {
575
824
  native.setAttribute(keyAttr, key);
576
825
  }
577
826
  if (!textNode) {
578
- textNode = document.createTextNode('');
579
- native.appendChild(textNode);
827
+ textNode = this.renderer.createText('');
828
+ this.renderer.appendChild(native, textNode);
580
829
  }
581
830
  this.getLangValue(key).then((langed) => this.renderer.setValue(textNode, langed));
582
831
  }
@@ -591,6 +840,11 @@ class LangDirective {
591
840
  }
592
841
  warn(message, value) {
593
842
  if (typeof ngDevMode !== 'undefined') {
843
+ const key = `${message}|${String(value ?? '')}`;
844
+ if (this.warned.has(key)) {
845
+ return;
846
+ }
847
+ this.warned.add(key);
594
848
  // eslint-disable-next-line no-console
595
849
  console.warn(message, value);
596
850
  }
@@ -636,7 +890,9 @@ class LangDirective {
636
890
  */
637
891
  async getLangValue(key) {
638
892
  const [ns] = key.split('.', 1);
639
- await this.service.loadNamespace(ns);
893
+ if (!this.service.isNamespaceLoaded(ns)) {
894
+ await this.service.loadNamespace(ns);
895
+ }
640
896
  return this.service.get(key);
641
897
  }
642
898
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: LangDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
@@ -668,19 +924,18 @@ const LANG_PIPE_CONFIG = new InjectionToken('RE_LANG_PIPE_CONFIG');
668
924
  * @implements {PipeTransform}
669
925
  */
670
926
  class LangPipe {
671
- lang;
672
927
  cache = new Map();
928
+ lang = inject(LangService);
929
+ injector = inject(Injector);
673
930
  config;
674
931
  ttlMs;
675
932
  maxCacheSize;
676
- constructor(lang = inject(LangService), config, injector) {
677
- this.lang = lang;
678
- const resolvedInjector = injector ?? inject(Injector);
679
- const resolved = config ?? inject(LANG_PIPE_CONFIG, { optional: true }) ?? {};
933
+ constructor() {
934
+ const resolved = inject(LANG_PIPE_CONFIG, { optional: true }) ?? {};
680
935
  this.config = resolved;
681
936
  this.ttlMs = resolved.ttlMs ?? 5 * 60 * 1000;
682
937
  this.maxCacheSize = resolved.maxCacheSize ?? 500;
683
- runInInjectionContext(resolvedInjector, () => {
938
+ runInInjectionContext(this.injector, () => {
684
939
  effect(() => {
685
940
  // Clear cache on language change to avoid stale signals and unbounded growth.
686
941
  this.lang.currentLang();
@@ -689,6 +944,9 @@ class LangPipe {
689
944
  });
690
945
  }
691
946
  transform(query, params) {
947
+ if (!query) {
948
+ return '';
949
+ }
692
950
  const key = this.makeKey(query, params);
693
951
  const now = Date.now();
694
952
  const existing = this.cache.get(key);
@@ -699,9 +957,10 @@ class LangPipe {
699
957
  this.cache.delete(key);
700
958
  }
701
959
  if (!this.cache.has(key)) {
702
- this.cache.set(key, { value: this.lang.observe(query, params), ts: now });
960
+ this.cache.set(key, { value: this.lang.observe(query, params ?? undefined), ts: now });
703
961
  this.evictIfNeeded();
704
962
  }
963
+ const value = this.cache.get(key).value();
705
964
  const ns = query.split('.', 1)[0];
706
965
  if (ns && !this.lang.isNamespaceLoaded(ns)) {
707
966
  const placeholder = this.config.placeholder;
@@ -712,7 +971,7 @@ class LangPipe {
712
971
  return placeholder;
713
972
  }
714
973
  }
715
- return this.cache.get(key).value();
974
+ return value;
716
975
  }
717
976
  makeKey(query, params) {
718
977
  if (!params) {
@@ -732,23 +991,13 @@ class LangPipe {
732
991
  this.cache.delete(firstKey);
733
992
  }
734
993
  }
735
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: LangPipe, deps: [{ token: LangService }, { token: LANG_PIPE_CONFIG, optional: true }, { token: Injector, optional: true }], target: i0.ɵɵFactoryTarget.Pipe });
994
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: LangPipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe });
736
995
  static ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "21.1.1", ngImport: i0, type: LangPipe, isStandalone: true, name: "lang", pure: false });
737
996
  }
738
997
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: LangPipe, decorators: [{
739
998
  type: Pipe,
740
999
  args: [{ name: 'lang', standalone: true, pure: false }]
741
- }], ctorParameters: () => [{ type: LangService }, { type: undefined, decorators: [{
742
- type: Optional
743
- }, {
744
- type: Inject,
745
- args: [LANG_PIPE_CONFIG]
746
- }] }, { type: i0.Injector, decorators: [{
747
- type: Optional
748
- }, {
749
- type: Inject,
750
- args: [Injector]
751
- }] }] });
1000
+ }], ctorParameters: () => [] });
752
1001
 
753
1002
  /**
754
1003
  * Type-safe mapping of available theme names.
@@ -846,7 +1095,7 @@ class ThemeService {
846
1095
  */
847
1096
  theme = computed(() => this.#theme(), ...(ngDevMode ? [{ debugName: "theme" }] : []));
848
1097
  /**
849
- * Convenient flag returning `true` if light theme is active.
1098
+ * Convenient flag returning `true` if the light theme is active.
850
1099
  * Suitable for conditional style application or resource selection.
851
1100
  */
852
1101
  isLight = computed(() => this.#theme() === themes.light, ...(ngDevMode ? [{ debugName: "isLight" }] : []));
@@ -892,31 +1141,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.1", ngImpor
892
1141
  }]
893
1142
  }], ctorParameters: () => [] });
894
1143
 
895
- /**
896
- * Provides environment-level providers for Presentia application initialization.
897
- *
898
- * This function configures essential application services:
899
- * - Language and localization settings
900
- * - Theme configuration
901
- * - Adaptive/responsive breakpoints
902
- * - Current device detection
903
- *
904
- * @param {AppConfig} config - Configuration object containing locale, theme, and breakpoints settings.
905
- * @returns {EnvironmentProviders} A collection of Angular providers for the application environment.
906
- *
907
- * @example
908
- * ```typescript
909
- * export const appConfig: ApplicationConfig = {
910
- * providers: [
911
- * provideReInit({
912
- * locale: { defaultLang: 'en', supportedLangs: ['en', 'ru'] },
913
- * theme: { defaultTheme: 'dark' },
914
- * breakpoints: { mobile: 768, tablet: 1024 }
915
- * })
916
- * ]
917
- * };
918
- * ```
919
- */
920
1144
  function provideReInit(config) {
921
1145
  return makeEnvironmentProviders([
922
1146
  { provide: TRANSLATION, deps: [LangService], useFactory: (ls) => ls },
@@ -927,8 +1151,9 @@ function provideReInit(config) {
927
1151
  { provide: CURRENT_DEVICE, deps: [AdaptiveService], useFactory: (ls) => ls.device },
928
1152
  { provide: DEVICE_BREAKPOINTS, useValue: config.breakpoints || defaultBreakpoints },
929
1153
  { provide: THEME_CONFIG, useValue: config.theme || defaultThemeConfig },
930
- { provide: LANG_CONFIG, useValue: config.locale || { defaultValue: '████████' } },
1154
+ { provide: LANG_CONFIG, useValue: config.locale || { defaultValue: '--------' } },
931
1155
  { provide: LANG_PIPE_CONFIG, useValue: config.langPipe || {} },
1156
+ { provide: LANG_MISSING_KEY_HANDLER, useValue: config.langMissingKeyHandler ?? null },
932
1157
  { provide: LOCALE_ID, useValue: config.locale.defaultLang ?? 'ru' },
933
1158
  ]);
934
1159
  }
@@ -957,6 +1182,9 @@ class SeoService {
957
1182
  * @param value title text
958
1183
  */
959
1184
  setTitle(value) {
1185
+ if (this.title.getTitle() === value) {
1186
+ return;
1187
+ }
960
1188
  this.title.setTitle(value);
961
1189
  }
962
1190
  /**
@@ -1021,14 +1249,25 @@ class SeoService {
1021
1249
  el.id = id;
1022
1250
  this.document.head.appendChild(el);
1023
1251
  }
1024
- el.text = JSON.stringify(schema);
1252
+ const next = JSON.stringify(schema);
1253
+ if (el.text !== next) {
1254
+ el.text = next;
1255
+ }
1025
1256
  }
1026
1257
  upsert(tag) {
1258
+ const selector = tag.name ? `name='${tag.name}'` : tag.property ? `property='${tag.property}'` : null;
1259
+ if (!selector) {
1260
+ return;
1261
+ }
1262
+ const current = this.meta.getTag(selector);
1263
+ if (current?.getAttribute('content') === tag.content) {
1264
+ return;
1265
+ }
1027
1266
  if (tag.name) {
1028
- this.meta.updateTag(tag, `name='${tag.name}'`);
1267
+ this.meta.updateTag(tag, selector);
1029
1268
  }
1030
1269
  else if (tag.property) {
1031
- this.meta.updateTag(tag, `property='${tag.property}'`);
1270
+ this.meta.updateTag(tag, selector);
1032
1271
  }
1033
1272
  }
1034
1273
  upsertLink(rel, href) {
@@ -1042,7 +1281,9 @@ class SeoService {
1042
1281
  link.rel = rel;
1043
1282
  head.appendChild(link);
1044
1283
  }
1045
- link.href = href;
1284
+ if (link.getAttribute('href') !== href) {
1285
+ link.href = href;
1286
+ }
1046
1287
  }
1047
1288
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: SeoService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1048
1289
  static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.1.1", ngImport: i0, type: SeoService });
@@ -1082,7 +1323,7 @@ class SeoRouteListener {
1082
1323
  * Trailing slashes will be removed automatically.
1083
1324
  */
1084
1325
  init(baseUrl) {
1085
- const sub = this.router.events.pipe(filter((e) => e instanceof NavigationEnd)).subscribe(() => {
1326
+ const applyRouteSeo = () => {
1086
1327
  const route = this.deepest(this.ar);
1087
1328
  const data = route.snapshot.data;
1088
1329
  const url = data.canonical ?? baseUrl.replace(/\/+$/, '') + this.router.url;
@@ -1098,8 +1339,11 @@ class SeoRouteListener {
1098
1339
  url: url,
1099
1340
  ...data.og,
1100
1341
  });
1101
- });
1102
- this.destroyRef.onDestroy(() => sub.unsubscribe());
1342
+ };
1343
+ applyRouteSeo();
1344
+ this.router.events
1345
+ .pipe(filter((e) => e instanceof NavigationEnd), takeUntilDestroyed(this.destroyRef))
1346
+ .subscribe(() => applyRouteSeo());
1103
1347
  }
1104
1348
  /**
1105
1349
  * Recursively finds the deepest (most nested) activated route in the route tree.
@@ -1173,7 +1417,7 @@ class RouteWatcher {
1173
1417
  };
1174
1418
  read();
1175
1419
  this.router.events
1176
- .pipe(startWith(new NavigationEnd(0, this.router.url, this.router.url)), filter$1((e) => e instanceof NavigationEnd), map(() => true), takeUntilDestroyed(this.destroyRef))
1420
+ .pipe(filter$1((e) => e instanceof NavigationEnd), takeUntilDestroyed(this.destroyRef))
1177
1421
  .subscribe(() => read());
1178
1422
  }
1179
1423
  /** Convenient selector for a data key with type-safe casting */
@@ -1204,5 +1448,5 @@ function joinUrl(segments) {
1204
1448
  * Generated bundle index. Do not edit.
1205
1449
  */
1206
1450
 
1207
- export { AdaptiveService, DEVICE_BREAKPOINTS, IfDeviceDirective, LANG_CONFIG, LANG_PIPE_CONFIG, LangDirective, LangPipe, LangService, RouteWatcher, SeoRouteListener, SeoService, THEME_CONFIG, ThemeService, darkThemePrefix, defaultBreakpoints, defaultThemeConfig, innerLangVal, provideReInit, themes };
1451
+ 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 };
1208
1452
  //# sourceMappingURL=reforgium-presentia.mjs.map