@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.
- package/README.md +427 -132
- package/bin/presentia-gen-lang-keys.mjs +111 -0
- package/fesm2022/reforgium-presentia.mjs +362 -118
- package/fesm2022/reforgium-presentia.mjs.map +1 -1
- package/package.json +5 -2
- package/types/reforgium-presentia.d.ts +133 -55
- package/types/reforgium-presentia.d.ts.map +1 -1
|
@@ -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,
|
|
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 {
|
|
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
|
|
123
|
-
|
|
124
|
-
this
|
|
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
|
-
|
|
130
|
-
|
|
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 '
|
|
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 === '
|
|
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
|
|
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) =>
|
|
314
|
+
const namespaces = Array.from(this.#loadedNamespaces.values()).map((key) => this.namespaceFromKey(key));
|
|
283
315
|
this.#loadedNamespaces.clear();
|
|
284
|
-
|
|
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(
|
|
324
|
+
return this.applyParams(baseValue, params);
|
|
299
325
|
}
|
|
300
|
-
return
|
|
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
|
|
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
|
|
327
|
-
|
|
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 =
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
-
|
|
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
|
|
495
|
+
return defaultLang;
|
|
392
496
|
}
|
|
393
|
-
return localStorage.getItem('lang')
|
|
497
|
+
return normalize(localStorage.getItem('lang')) ?? defaultLang;
|
|
394
498
|
}
|
|
395
|
-
makeUrl(ns) {
|
|
396
|
-
|
|
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
|
-
|
|
400
|
-
|
|
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 ===
|
|
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 =
|
|
579
|
-
|
|
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
|
-
|
|
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(
|
|
677
|
-
|
|
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(
|
|
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
|
|
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: [
|
|
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: () => [
|
|
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
|
-
|
|
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,
|
|
1267
|
+
this.meta.updateTag(tag, selector);
|
|
1029
1268
|
}
|
|
1030
1269
|
else if (tag.property) {
|
|
1031
|
-
this.meta.updateTag(tag,
|
|
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
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|