@seliseblocks/blocks-angular-localization 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,1430 @@
|
|
|
1
|
+
import * as i0 from '@angular/core';
|
|
2
|
+
import { InjectionToken, Injectable, inject, DestroyRef, signal, computed, makeEnvironmentProviders, provideEnvironmentInitializer, effect, TemplateRef, ViewContainerRef, input, Directive, Pipe, ChangeDetectionStrategy, Component } from '@angular/core';
|
|
3
|
+
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
|
4
|
+
import { Subject, of, shareReplay, from, forkJoin } from 'rxjs';
|
|
5
|
+
import { HttpClient, HttpHeaders } from '@angular/common/http';
|
|
6
|
+
import { map, catchError, switchMap, tap } from 'rxjs/operators';
|
|
7
|
+
import { NgTemplateOutlet } from '@angular/common';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Injection token for the blocks-localization configuration.
|
|
11
|
+
* Provided by `provideBlocksLocalization()` at the application root.
|
|
12
|
+
*/
|
|
13
|
+
const BLOCKS_LOCALIZATION_CONFIG = new InjectionToken('BlocksLocalizationConfig');
|
|
14
|
+
|
|
15
|
+
const DB_NAME = 'uilm-translations';
|
|
16
|
+
const DB_VERSION = 1;
|
|
17
|
+
const STORE_NAME = 'translations';
|
|
18
|
+
/**
|
|
19
|
+
* IndexedDB persistence layer for UILM translation cache.
|
|
20
|
+
*
|
|
21
|
+
* Stores translation maps keyed by `{prefix}::{lang}` with timestamps
|
|
22
|
+
* for TTL-based invalidation. Falls back gracefully to `null` when
|
|
23
|
+
* IndexedDB is unavailable (SSR, restrictive incognito, etc.).
|
|
24
|
+
*
|
|
25
|
+
* All public methods are **fire-and-forget safe** — they never throw
|
|
26
|
+
* and resolve to `null` / `void` on any failure.
|
|
27
|
+
*
|
|
28
|
+
* @publicApi
|
|
29
|
+
*/
|
|
30
|
+
class UilmIndexedDbCache {
|
|
31
|
+
dbReady;
|
|
32
|
+
constructor() {
|
|
33
|
+
this.dbReady = this.openDb();
|
|
34
|
+
}
|
|
35
|
+
/** Re-open the database connection (used after unexpected close). */
|
|
36
|
+
reconnect() {
|
|
37
|
+
this.dbReady = this.openDb();
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Retrieve a cached entry if it exists and hasn't exceeded the TTL.
|
|
41
|
+
*
|
|
42
|
+
* @param key Cache key (`{prefix}::{lang}`)
|
|
43
|
+
* @param ttl Time-to-live in ms. `0` disables expiry (entry is always valid).
|
|
44
|
+
* @returns The translation map, or `null` on miss / expiry / error.
|
|
45
|
+
*/
|
|
46
|
+
async get(key, ttl) {
|
|
47
|
+
const db = await this.dbReady;
|
|
48
|
+
if (!db)
|
|
49
|
+
return null;
|
|
50
|
+
try {
|
|
51
|
+
const entry = await this.txGet(db, key);
|
|
52
|
+
if (!entry)
|
|
53
|
+
return null;
|
|
54
|
+
if (ttl > 0 && Date.now() - entry.timestamp >= ttl) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
return entry.data;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
this.reconnect();
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Retrieve a stale entry (ignoring TTL) for fallback purposes.
|
|
66
|
+
*
|
|
67
|
+
* @param key Cache key
|
|
68
|
+
* @returns The translation map regardless of age, or `null` if absent.
|
|
69
|
+
*/
|
|
70
|
+
async getStale(key) {
|
|
71
|
+
const db = await this.dbReady;
|
|
72
|
+
if (!db)
|
|
73
|
+
return null;
|
|
74
|
+
try {
|
|
75
|
+
const entry = await this.txGet(db, key);
|
|
76
|
+
return entry?.data ?? null;
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
this.reconnect();
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Persist a translation map.
|
|
85
|
+
*
|
|
86
|
+
* @param key Cache key
|
|
87
|
+
* @param data Flat key→value translation map
|
|
88
|
+
*/
|
|
89
|
+
async set(key, data) {
|
|
90
|
+
const db = await this.dbReady;
|
|
91
|
+
if (!db)
|
|
92
|
+
return;
|
|
93
|
+
try {
|
|
94
|
+
await this.txPut(db, { key, data, timestamp: Date.now() });
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
this.reconnect();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/** Remove all cached translations from IndexedDB. */
|
|
101
|
+
async clear() {
|
|
102
|
+
const db = await this.dbReady;
|
|
103
|
+
if (!db)
|
|
104
|
+
return;
|
|
105
|
+
try {
|
|
106
|
+
await this.txClear(db);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
this.reconnect();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// Private — IndexedDB primitives
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
openDb() {
|
|
116
|
+
return new Promise((resolve) => {
|
|
117
|
+
try {
|
|
118
|
+
if (typeof indexedDB === 'undefined') {
|
|
119
|
+
resolve(null);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
|
123
|
+
request.onupgradeneeded = () => {
|
|
124
|
+
const db = request.result;
|
|
125
|
+
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
|
126
|
+
db.createObjectStore(STORE_NAME, { keyPath: 'key' });
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
request.onsuccess = () => {
|
|
130
|
+
const db = request.result;
|
|
131
|
+
// Allow other tabs to upgrade the DB by closing our connection on demand
|
|
132
|
+
db.onversionchange = () => {
|
|
133
|
+
db.close();
|
|
134
|
+
this.reconnect();
|
|
135
|
+
};
|
|
136
|
+
// Reconnect if the browser unexpectedly closes the connection
|
|
137
|
+
db.onclose = () => {
|
|
138
|
+
this.reconnect();
|
|
139
|
+
};
|
|
140
|
+
resolve(db);
|
|
141
|
+
};
|
|
142
|
+
request.onerror = () => resolve(null);
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
resolve(null);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
txGet(db, key) {
|
|
150
|
+
return new Promise((resolve, reject) => {
|
|
151
|
+
const tx = db.transaction(STORE_NAME, 'readonly');
|
|
152
|
+
const request = tx.objectStore(STORE_NAME).get(key);
|
|
153
|
+
request.onsuccess = () => resolve(request.result);
|
|
154
|
+
request.onerror = () => reject(request.error);
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
txPut(db, entry) {
|
|
158
|
+
return new Promise((resolve, reject) => {
|
|
159
|
+
const tx = db.transaction(STORE_NAME, 'readwrite');
|
|
160
|
+
const request = tx.objectStore(STORE_NAME).put(entry);
|
|
161
|
+
request.onsuccess = () => resolve();
|
|
162
|
+
request.onerror = () => reject(request.error);
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
txClear(db) {
|
|
166
|
+
return new Promise((resolve, reject) => {
|
|
167
|
+
const tx = db.transaction(STORE_NAME, 'readwrite');
|
|
168
|
+
const request = tx.objectStore(STORE_NAME).clear();
|
|
169
|
+
request.onsuccess = () => resolve();
|
|
170
|
+
request.onerror = () => reject(request.error);
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: UilmIndexedDbCache, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
174
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: UilmIndexedDbCache, providedIn: 'root' });
|
|
175
|
+
}
|
|
176
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: UilmIndexedDbCache, decorators: [{
|
|
177
|
+
type: Injectable,
|
|
178
|
+
args: [{ providedIn: 'root' }]
|
|
179
|
+
}], ctorParameters: () => [] });
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Flattens a nested object into dot-notation keys.
|
|
183
|
+
*
|
|
184
|
+
* @example
|
|
185
|
+
* ```typescript
|
|
186
|
+
* flattenJson({ LABEL: { HELLO: 'Hello', WORLD: 'World' }, BTN: { SAVE: 'Save' } })
|
|
187
|
+
* // → { 'LABEL.HELLO': 'Hello', 'LABEL.WORLD': 'World', 'BTN.SAVE': 'Save' }
|
|
188
|
+
* ```
|
|
189
|
+
*/
|
|
190
|
+
function flattenJson(obj, parentKey = '', separator = '.', _visited = new Set()) {
|
|
191
|
+
_visited.add(obj);
|
|
192
|
+
const result = {};
|
|
193
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
194
|
+
const newKey = parentKey ? `${parentKey}${separator}${key}` : key;
|
|
195
|
+
if (value != null && typeof value === 'object' && !Array.isArray(value)) {
|
|
196
|
+
if (_visited.has(value))
|
|
197
|
+
continue; // skip circular refs
|
|
198
|
+
_visited.add(value);
|
|
199
|
+
Object.assign(result, flattenJson(value, newKey, separator, _visited));
|
|
200
|
+
}
|
|
201
|
+
else if (Array.isArray(value)) {
|
|
202
|
+
if (_visited.has(value))
|
|
203
|
+
continue; // skip circular refs
|
|
204
|
+
_visited.add(value);
|
|
205
|
+
value.forEach((item, i) => {
|
|
206
|
+
const arrKey = `${newKey}${separator}${i}`;
|
|
207
|
+
if (item != null && typeof item === 'object' && !Array.isArray(item)) {
|
|
208
|
+
if (_visited.has(item))
|
|
209
|
+
return;
|
|
210
|
+
_visited.add(item);
|
|
211
|
+
Object.assign(result, flattenJson(item, arrKey, separator, _visited));
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
result[arrKey] = String(item ?? '');
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
result[newKey] = String(value ?? '');
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return result;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Convert a short language code to a full locale code using the provided mapping.
|
|
227
|
+
* Returns the original code if no mapping is found or if it's already a full code.
|
|
228
|
+
*/
|
|
229
|
+
function toFullLangCode(lang, mapping) {
|
|
230
|
+
if (lang.includes('-')) {
|
|
231
|
+
return lang;
|
|
232
|
+
}
|
|
233
|
+
return mapping[lang] || lang;
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Convert a full locale code (e.g. 'en-US') to a short code (e.g. 'en').
|
|
237
|
+
*/
|
|
238
|
+
function toShortLangCode(fullCode) {
|
|
239
|
+
return fullCode.split('-')[0];
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Build a reverse mapping from full locale codes to short codes.
|
|
243
|
+
*/
|
|
244
|
+
function buildReverseMapping(localeMapping) {
|
|
245
|
+
return Object.fromEntries(Object.entries(localeMapping).map(([short, full]) => [full, short]));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Low-level HTTP client for fetching translations from the UILM API.
|
|
250
|
+
*
|
|
251
|
+
* ## Caching architecture (two-tier)
|
|
252
|
+
*
|
|
253
|
+
* | Layer | Storage | Lifetime | When enabled |
|
|
254
|
+
* |-------|-----------------|-----------------|---------------------------|
|
|
255
|
+
* | L1 | In-memory `Map` | Current session | Always |
|
|
256
|
+
* | L2 | IndexedDB | Cross-session | `cacheStorage: 'indexeddb'`|
|
|
257
|
+
*
|
|
258
|
+
* ### Lookup order
|
|
259
|
+
* 1. **L1 hit** (valid TTL) → return immediately
|
|
260
|
+
* 2. **In-flight dedup** → share existing Observable
|
|
261
|
+
* 3. **L2 hit** (valid TTL, if enabled) → populate L1, return
|
|
262
|
+
* 4. **HTTP fetch** → populate L1 + L2, return
|
|
263
|
+
* 5. **Error fallback chain**: stale L1 → stale L2 → local JSON → empty `{}`
|
|
264
|
+
*
|
|
265
|
+
* @publicApi
|
|
266
|
+
*/
|
|
267
|
+
class UilmLoader {
|
|
268
|
+
http = inject(HttpClient);
|
|
269
|
+
config = inject(BLOCKS_LOCALIZATION_CONFIG);
|
|
270
|
+
idbCache = inject(UilmIndexedDbCache);
|
|
271
|
+
destroyRef = inject(DestroyRef);
|
|
272
|
+
/** L1 in-memory cache. */
|
|
273
|
+
memCache = new Map();
|
|
274
|
+
/** Tracks in-flight HTTP observables for request deduplication. */
|
|
275
|
+
inflight = new Map();
|
|
276
|
+
/** Emits when a background revalidation produces updated translations. */
|
|
277
|
+
revalidated$ = new Subject();
|
|
278
|
+
availableModules = [];
|
|
279
|
+
availableLanguages = [];
|
|
280
|
+
shortToFullMapping = {};
|
|
281
|
+
modulesLoaded = false;
|
|
282
|
+
languagesLoaded = false;
|
|
283
|
+
metadataInflight$ = null;
|
|
284
|
+
constructor() {
|
|
285
|
+
if (this.config.localeMapping) {
|
|
286
|
+
this.shortToFullMapping = { ...this.config.localeMapping };
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
// ---------------------------------------------------------------------------
|
|
290
|
+
// Derived config helpers
|
|
291
|
+
// ---------------------------------------------------------------------------
|
|
292
|
+
get cacheTimeout() {
|
|
293
|
+
return this.config.cacheTimeout ?? 0;
|
|
294
|
+
}
|
|
295
|
+
get shouldPrefixKeys() {
|
|
296
|
+
return !!this.config.prefixKeysWithModule;
|
|
297
|
+
}
|
|
298
|
+
get useIndexedDb() {
|
|
299
|
+
return this.config.cacheStorage === 'indexeddb';
|
|
300
|
+
}
|
|
301
|
+
get shouldRevalidate() {
|
|
302
|
+
return this.useIndexedDb && !!this.config.revalidateInBackground;
|
|
303
|
+
}
|
|
304
|
+
get shouldFallbackToLocal() {
|
|
305
|
+
return this.config.fallbackToLocal !== false;
|
|
306
|
+
}
|
|
307
|
+
get localAssetsPath() {
|
|
308
|
+
return this.config.localAssetsPath ?? 'assets/i18n';
|
|
309
|
+
}
|
|
310
|
+
// ---------------------------------------------------------------------------
|
|
311
|
+
// Public API
|
|
312
|
+
// ---------------------------------------------------------------------------
|
|
313
|
+
/**
|
|
314
|
+
* Fetch a single UILM module's translations.
|
|
315
|
+
*
|
|
316
|
+
* @param lang Short language code (e.g. `'en'`)
|
|
317
|
+
* @param moduleName UILM module name as registered in the API
|
|
318
|
+
* @param alias Optional alias used as the key prefix instead of the module name
|
|
319
|
+
* @returns Observable emitting the (optionally prefixed) key→value map
|
|
320
|
+
*/
|
|
321
|
+
fetchModuleTranslations(lang, moduleName, alias) {
|
|
322
|
+
const prefix = alias ?? moduleName;
|
|
323
|
+
const cacheKey = this.buildCacheKey(prefix, lang);
|
|
324
|
+
// 1. L1 hit
|
|
325
|
+
const l1Entry = this.memCache.get(cacheKey);
|
|
326
|
+
if (l1Entry && this.isL1Valid(cacheKey)) {
|
|
327
|
+
// Still fire background revalidation so stale cached data gets updated
|
|
328
|
+
if (this.shouldRevalidate) {
|
|
329
|
+
this.revalidateFromApi(lang, moduleName, prefix, cacheKey, l1Entry.data);
|
|
330
|
+
}
|
|
331
|
+
return of(l1Entry.data);
|
|
332
|
+
}
|
|
333
|
+
// 2. In-flight dedup
|
|
334
|
+
const existing = this.inflight.get(cacheKey);
|
|
335
|
+
if (existing)
|
|
336
|
+
return existing;
|
|
337
|
+
// 3. Build the fetch pipeline: L2 → HTTP → fallback
|
|
338
|
+
const request$ = this.resolveTranslation(lang, moduleName, prefix, cacheKey).pipe(shareReplay(1));
|
|
339
|
+
this.inflight.set(cacheKey, request$);
|
|
340
|
+
return request$;
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Attempt to load translations from IndexedDB cache only (no API, no metadata).
|
|
344
|
+
* Returns `null` if IndexedDB is disabled or the entry is missing.
|
|
345
|
+
* Used for instant store hydration before metadata is available.
|
|
346
|
+
*/
|
|
347
|
+
loadFromCacheOnly(lang, moduleName, alias) {
|
|
348
|
+
if (!this.useIndexedDb)
|
|
349
|
+
return of(null);
|
|
350
|
+
const prefix = alias ?? moduleName;
|
|
351
|
+
const cacheKey = this.buildCacheKey(prefix, lang);
|
|
352
|
+
// L1 hit
|
|
353
|
+
const l1Entry = this.memCache.get(cacheKey);
|
|
354
|
+
if (l1Entry && this.isL1Valid(cacheKey)) {
|
|
355
|
+
return of(l1Entry.data);
|
|
356
|
+
}
|
|
357
|
+
return from(this.idbCache.get(cacheKey, this.cacheTimeout)).pipe(map((data) => {
|
|
358
|
+
if (data) {
|
|
359
|
+
this.memCache.set(cacheKey, { data, timestamp: Date.now() });
|
|
360
|
+
}
|
|
361
|
+
return data;
|
|
362
|
+
}), catchError(() => of(null)));
|
|
363
|
+
}
|
|
364
|
+
/** Ensure modules and languages metadata are loaded (fetches once, then no-ops). */
|
|
365
|
+
ensureMetadataLoaded() {
|
|
366
|
+
if (this.modulesLoaded && this.languagesLoaded) {
|
|
367
|
+
return of(undefined);
|
|
368
|
+
}
|
|
369
|
+
if (this.metadataInflight$)
|
|
370
|
+
return this.metadataInflight$;
|
|
371
|
+
this.metadataInflight$ = this.getAvailableModules().pipe(switchMap(() => this.getAvailableLanguages()), map(() => undefined), tap(() => {
|
|
372
|
+
this.metadataInflight$ = null;
|
|
373
|
+
}), catchError((err) => {
|
|
374
|
+
this.metadataInflight$ = null;
|
|
375
|
+
throw err;
|
|
376
|
+
}), shareReplay(1));
|
|
377
|
+
return this.metadataInflight$;
|
|
378
|
+
}
|
|
379
|
+
/** Clear all translation caches (L1 in-memory, in-flight, and L2 IndexedDB if enabled). */
|
|
380
|
+
clearCache() {
|
|
381
|
+
this.memCache.clear();
|
|
382
|
+
this.inflight.clear();
|
|
383
|
+
if (this.useIndexedDb) {
|
|
384
|
+
this.idbCache.clear();
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
/** Fetch available languages from the UILM API. */
|
|
388
|
+
getAvailableLanguages() {
|
|
389
|
+
if (this.languagesLoaded && this.availableLanguages.length > 0) {
|
|
390
|
+
return of(this.availableLanguages);
|
|
391
|
+
}
|
|
392
|
+
const url = `${this.config.uilmApiBaseUrl}/Language/Gets?ProjectKey=${encodeURIComponent(this.config.projectKey)}`;
|
|
393
|
+
return this.http.get(url, { headers: this.buildHeaders() }).pipe(tap((languages) => {
|
|
394
|
+
this.availableLanguages = languages ?? [];
|
|
395
|
+
for (const lang of this.availableLanguages) {
|
|
396
|
+
const shortCode = lang.languageCode.split('-')[0];
|
|
397
|
+
// Only populate mapping if user config didn't already define this short code
|
|
398
|
+
if (!this.shortToFullMapping[shortCode]) {
|
|
399
|
+
this.shortToFullMapping[shortCode] = lang.languageCode;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
this.languagesLoaded = true;
|
|
403
|
+
}), catchError(() => {
|
|
404
|
+
this.languagesLoaded = true;
|
|
405
|
+
return of([]);
|
|
406
|
+
}));
|
|
407
|
+
}
|
|
408
|
+
/** Fetch available modules from the UILM API. */
|
|
409
|
+
getAvailableModules() {
|
|
410
|
+
if (this.modulesLoaded && this.availableModules.length > 0) {
|
|
411
|
+
return of(this.availableModules);
|
|
412
|
+
}
|
|
413
|
+
const url = `${this.config.uilmApiBaseUrl}/Module/Gets?ProjectKey=${encodeURIComponent(this.config.projectKey)}`;
|
|
414
|
+
return this.http.get(url, { headers: this.buildHeaders() }).pipe(tap((modules) => {
|
|
415
|
+
this.availableModules = modules ?? [];
|
|
416
|
+
this.modulesLoaded = true;
|
|
417
|
+
}), catchError(() => {
|
|
418
|
+
this.modulesLoaded = true;
|
|
419
|
+
return of([]);
|
|
420
|
+
}));
|
|
421
|
+
}
|
|
422
|
+
/** Get cached languages (empty until `ensureMetadataLoaded()` resolves). */
|
|
423
|
+
getLanguages() {
|
|
424
|
+
return this.availableLanguages;
|
|
425
|
+
}
|
|
426
|
+
/** Get cached modules (empty until `ensureMetadataLoaded()` resolves). */
|
|
427
|
+
getModules() {
|
|
428
|
+
return this.availableModules;
|
|
429
|
+
}
|
|
430
|
+
// ---------------------------------------------------------------------------
|
|
431
|
+
// Private — cache helpers
|
|
432
|
+
// ---------------------------------------------------------------------------
|
|
433
|
+
buildCacheKey(prefix, lang) {
|
|
434
|
+
return `${prefix}::${lang}`;
|
|
435
|
+
}
|
|
436
|
+
/** Check whether the L1 entry is present and within TTL. `cacheTimeout=0` means no expiry. */
|
|
437
|
+
isL1Valid(key) {
|
|
438
|
+
const entry = this.memCache.get(key);
|
|
439
|
+
if (!entry)
|
|
440
|
+
return false;
|
|
441
|
+
if (this.cacheTimeout <= 0)
|
|
442
|
+
return true; // 0 = no expiry, entry is always valid
|
|
443
|
+
return Date.now() - entry.timestamp < this.cacheTimeout;
|
|
444
|
+
}
|
|
445
|
+
/** Write data to L1 (always) and L2 (when enabled). */
|
|
446
|
+
populateCache(key, data) {
|
|
447
|
+
this.memCache.set(key, { data, timestamp: Date.now() });
|
|
448
|
+
if (this.useIndexedDb) {
|
|
449
|
+
// Fire-and-forget — IndexedDB write is best-effort
|
|
450
|
+
this.idbCache.set(key, data);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
// ---------------------------------------------------------------------------
|
|
454
|
+
// Private — fetch pipeline
|
|
455
|
+
// ---------------------------------------------------------------------------
|
|
456
|
+
/**
|
|
457
|
+
* Resolve translations through the full L2 → HTTP → fallback chain.
|
|
458
|
+
* Called only when L1 misses and no in-flight request exists.
|
|
459
|
+
*/
|
|
460
|
+
resolveTranslation(lang, moduleName, prefix, cacheKey) {
|
|
461
|
+
if (this.useIndexedDb) {
|
|
462
|
+
return from(this.idbCache.get(cacheKey, this.cacheTimeout)).pipe(switchMap((idbData) => {
|
|
463
|
+
if (idbData) {
|
|
464
|
+
this.memCache.set(cacheKey, { data: idbData, timestamp: Date.now() });
|
|
465
|
+
this.inflight.delete(cacheKey);
|
|
466
|
+
if (this.shouldRevalidate) {
|
|
467
|
+
this.revalidateFromApi(lang, moduleName, prefix, cacheKey, idbData);
|
|
468
|
+
}
|
|
469
|
+
return of(idbData);
|
|
470
|
+
}
|
|
471
|
+
return this.fetchFromApi(lang, moduleName, prefix, cacheKey);
|
|
472
|
+
}));
|
|
473
|
+
}
|
|
474
|
+
return this.fetchFromApi(lang, moduleName, prefix, cacheKey);
|
|
475
|
+
}
|
|
476
|
+
/** Issue the HTTP request and handle success / error with full fallback chain. */
|
|
477
|
+
fetchFromApi(lang, moduleName, prefix, cacheKey) {
|
|
478
|
+
const fullLangCode = toFullLangCode(lang, this.shortToFullMapping);
|
|
479
|
+
const url = `${this.config.uilmApiBaseUrl}/Key/GetUilmFile` +
|
|
480
|
+
`?ProjectKey=${this.config.projectKey}` +
|
|
481
|
+
`&ModuleName=${encodeURIComponent(moduleName)}` +
|
|
482
|
+
`&Language=${encodeURIComponent(fullLangCode)}`;
|
|
483
|
+
return this.http.get(url, { headers: this.buildHeaders() }).pipe(map((data) => this.sanitizeApiResponse(data)), map((data) => (this.shouldPrefixKeys ? this.prefixKeys(data, prefix) : data)), tap((data) => {
|
|
484
|
+
this.populateCache(cacheKey, data);
|
|
485
|
+
this.inflight.delete(cacheKey);
|
|
486
|
+
}), catchError(() => {
|
|
487
|
+
this.inflight.delete(cacheKey);
|
|
488
|
+
return this.resolveFromFallbacks(lang, moduleName, prefix, cacheKey);
|
|
489
|
+
}));
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* Fallback chain on HTTP failure:
|
|
493
|
+
* 1. Stale L1 (in-memory)
|
|
494
|
+
* 2. Stale L2 (IndexedDB, if enabled)
|
|
495
|
+
* 3. Local JSON file (if enabled)
|
|
496
|
+
* 4. Empty map
|
|
497
|
+
*/
|
|
498
|
+
resolveFromFallbacks(lang, moduleName, prefix, cacheKey) {
|
|
499
|
+
// 1. Stale L1
|
|
500
|
+
const staleL1 = this.memCache.get(cacheKey);
|
|
501
|
+
if (staleL1)
|
|
502
|
+
return of(staleL1.data);
|
|
503
|
+
// 2. Stale L2
|
|
504
|
+
if (this.useIndexedDb) {
|
|
505
|
+
return from(this.idbCache.getStale(cacheKey)).pipe(switchMap((staleIdb) => {
|
|
506
|
+
if (staleIdb)
|
|
507
|
+
return of(staleIdb);
|
|
508
|
+
return this.localFallbackOrEmpty(lang, moduleName, prefix);
|
|
509
|
+
}));
|
|
510
|
+
}
|
|
511
|
+
// 3 + 4. Local JSON or empty
|
|
512
|
+
return this.localFallbackOrEmpty(lang, moduleName, prefix);
|
|
513
|
+
}
|
|
514
|
+
/** Attempt local JSON fallback, or return empty map. */
|
|
515
|
+
localFallbackOrEmpty(lang, moduleName, prefix) {
|
|
516
|
+
if (this.shouldFallbackToLocal) {
|
|
517
|
+
return this.fetchLocalFallback(lang, moduleName, prefix);
|
|
518
|
+
}
|
|
519
|
+
return of({});
|
|
520
|
+
}
|
|
521
|
+
// ---------------------------------------------------------------------------
|
|
522
|
+
// Private — local fallback
|
|
523
|
+
// ---------------------------------------------------------------------------
|
|
524
|
+
/**
|
|
525
|
+
* Fetch translations from a local JSON file.
|
|
526
|
+
* Nested JSON is automatically flattened to dot-notation keys.
|
|
527
|
+
*
|
|
528
|
+
* Path convention:
|
|
529
|
+
* - Module with content: `{localAssetsPath}/{moduleName}/{lang}.json`
|
|
530
|
+
* - Root/common module (empty alias): `{localAssetsPath}/{lang}.json`
|
|
531
|
+
*/
|
|
532
|
+
fetchLocalFallback(lang, moduleName, prefix) {
|
|
533
|
+
const path = prefix === ''
|
|
534
|
+
? `${this.localAssetsPath}/${lang}.json`
|
|
535
|
+
: `${this.localAssetsPath}/${moduleName}/${lang}.json`;
|
|
536
|
+
return this.http.get(path).pipe(map((nested) => {
|
|
537
|
+
const flat = flattenJson(nested);
|
|
538
|
+
return this.shouldPrefixKeys ? this.prefixKeys(flat, prefix) : flat;
|
|
539
|
+
}), tap((data) => this.populateCache(this.buildCacheKey(prefix, lang), data)), catchError(() => of({})));
|
|
540
|
+
}
|
|
541
|
+
// ---------------------------------------------------------------------------
|
|
542
|
+
// Private — background revalidation
|
|
543
|
+
// ---------------------------------------------------------------------------
|
|
544
|
+
/**
|
|
545
|
+
* Fire-and-forget API fetch that silently updates caches and emits on
|
|
546
|
+
* `revalidated$` when the response differs from the currently cached data.
|
|
547
|
+
*/
|
|
548
|
+
revalidateFromApi(lang, moduleName, prefix, cacheKey, cachedData) {
|
|
549
|
+
const fullLangCode = toFullLangCode(lang, this.shortToFullMapping);
|
|
550
|
+
const url = `${this.config.uilmApiBaseUrl}/Key/GetUilmFile` +
|
|
551
|
+
`?ProjectKey=${this.config.projectKey}` +
|
|
552
|
+
`&ModuleName=${encodeURIComponent(moduleName)}` +
|
|
553
|
+
`&Language=${encodeURIComponent(fullLangCode)}`;
|
|
554
|
+
this.http
|
|
555
|
+
.get(url, { headers: this.buildHeaders() })
|
|
556
|
+
.pipe(map((data) => this.sanitizeApiResponse(data)), map((data) => (this.shouldPrefixKeys ? this.prefixKeys(data, prefix) : data)), takeUntilDestroyed(this.destroyRef))
|
|
557
|
+
.subscribe({
|
|
558
|
+
next: (freshData) => {
|
|
559
|
+
if (!this.shallowEqual(freshData, cachedData)) {
|
|
560
|
+
this.populateCache(cacheKey, freshData);
|
|
561
|
+
this.revalidated$.next({ lang, data: freshData });
|
|
562
|
+
}
|
|
563
|
+
},
|
|
564
|
+
error: () => {
|
|
565
|
+
/* silent — cached data is already served */
|
|
566
|
+
},
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
// ---------------------------------------------------------------------------
|
|
570
|
+
// Private — utilities
|
|
571
|
+
// ---------------------------------------------------------------------------
|
|
572
|
+
buildHeaders() {
|
|
573
|
+
const headers = {
|
|
574
|
+
'x-blocks-key': this.config.projectKey,
|
|
575
|
+
'Content-Type': 'application/json',
|
|
576
|
+
Accept: 'application/json',
|
|
577
|
+
};
|
|
578
|
+
if (this.config.accessToken) {
|
|
579
|
+
headers['Authorization'] = `Bearer ${this.config.accessToken}`;
|
|
580
|
+
}
|
|
581
|
+
return new HttpHeaders(headers);
|
|
582
|
+
}
|
|
583
|
+
/** Ensure API response is a valid flat object. Returns empty map for malformed responses. */
|
|
584
|
+
sanitizeApiResponse(data) {
|
|
585
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
586
|
+
return {};
|
|
587
|
+
}
|
|
588
|
+
return data;
|
|
589
|
+
}
|
|
590
|
+
/** Shallow key-by-key equality check for flat TranslationMaps. */
|
|
591
|
+
shallowEqual(a, b) {
|
|
592
|
+
const aKeys = Object.keys(a);
|
|
593
|
+
const bKeys = Object.keys(b);
|
|
594
|
+
if (aKeys.length !== bKeys.length)
|
|
595
|
+
return false;
|
|
596
|
+
return aKeys.every((key) => a[key] === b[key]);
|
|
597
|
+
}
|
|
598
|
+
prefixKeys(data, prefix) {
|
|
599
|
+
if (!prefix)
|
|
600
|
+
return data;
|
|
601
|
+
const prefixed = {};
|
|
602
|
+
const lowerPrefix = prefix.toLowerCase();
|
|
603
|
+
for (const [key, value] of Object.entries(data)) {
|
|
604
|
+
prefixed[`${lowerPrefix}.${key}`] = value;
|
|
605
|
+
}
|
|
606
|
+
return prefixed;
|
|
607
|
+
}
|
|
608
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: UilmLoader, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
609
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: UilmLoader, providedIn: 'root' });
|
|
610
|
+
}
|
|
611
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: UilmLoader, decorators: [{
|
|
612
|
+
type: Injectable,
|
|
613
|
+
args: [{ providedIn: 'root' }]
|
|
614
|
+
}], ctorParameters: () => [] });
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Core reactive translation store.
|
|
618
|
+
*
|
|
619
|
+
* Holds translations per language, tracks the active language via signals,
|
|
620
|
+
* and persists language preference to `localStorage`.
|
|
621
|
+
*
|
|
622
|
+
* @publicApi
|
|
623
|
+
*/
|
|
624
|
+
class UilmStore {
|
|
625
|
+
config = inject(BLOCKS_LOCALIZATION_CONFIG);
|
|
626
|
+
destroyRef = inject(DestroyRef);
|
|
627
|
+
storageKey = this.config.langStorageKey ?? 'uilmLang';
|
|
628
|
+
/** Internal translation maps: `lang → flat key-value pairs` */
|
|
629
|
+
store = new Map();
|
|
630
|
+
/** Toggled on every `setTranslation` call to trigger signal reactivity. */
|
|
631
|
+
_version = signal(0, ...(ngDevMode ? [{ debugName: "_version" }] : []));
|
|
632
|
+
versionCounter = 0;
|
|
633
|
+
/** Active language short code. */
|
|
634
|
+
activeLang = signal(this.loadPersistedLang(), ...(ngDevMode ? [{ debugName: "activeLang" }] : []));
|
|
635
|
+
/**
|
|
636
|
+
* When `true`, `translate()` returns the raw key instead of the translated value.
|
|
637
|
+
* Toggled via `window.postMessage({ action: 'keymode', keymode: true/false })`.
|
|
638
|
+
* Useful for testing with a browser extension.
|
|
639
|
+
*/
|
|
640
|
+
keyMode = signal(false, ...(ngDevMode ? [{ debugName: "keyMode" }] : []));
|
|
641
|
+
/** Read-only version signal — depend on this to react to translation changes. */
|
|
642
|
+
version = this._version.asReadonly();
|
|
643
|
+
/** `true` when translations have been loaded for the active language. */
|
|
644
|
+
ready = computed(() => {
|
|
645
|
+
this._version();
|
|
646
|
+
return this.store.has(this.activeLang());
|
|
647
|
+
}, ...(ngDevMode ? [{ debugName: "ready" }] : []));
|
|
648
|
+
constructor() {
|
|
649
|
+
this.listenForKeyModeToggle();
|
|
650
|
+
}
|
|
651
|
+
/**
|
|
652
|
+
* Translate a key for the active language.
|
|
653
|
+
* Supports interpolation: `{{ name }}` in values is replaced by `params.name`.
|
|
654
|
+
*
|
|
655
|
+
* Returns the raw key if no translation is found.
|
|
656
|
+
*/
|
|
657
|
+
translate(key, params) {
|
|
658
|
+
this._version(); // touch so computed signals re-evaluate
|
|
659
|
+
// In key mode, always return the raw key for debugging/testing
|
|
660
|
+
if (this.keyMode())
|
|
661
|
+
return key;
|
|
662
|
+
const translations = this.store.get(this.activeLang());
|
|
663
|
+
let value = translations?.[key];
|
|
664
|
+
if (value == null)
|
|
665
|
+
return key;
|
|
666
|
+
if (params) {
|
|
667
|
+
value = value.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (match, paramKey) => {
|
|
668
|
+
const val = paramKey.split('.').reduce((obj, k) => {
|
|
669
|
+
if (obj != null && typeof obj === 'object' && !Array.isArray(obj)) {
|
|
670
|
+
return obj[k];
|
|
671
|
+
}
|
|
672
|
+
return undefined;
|
|
673
|
+
}, params);
|
|
674
|
+
return val != null ? String(val) : match;
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
return value;
|
|
678
|
+
}
|
|
679
|
+
/** Check if a translation key exists for the active language. */
|
|
680
|
+
has(key) {
|
|
681
|
+
this._version();
|
|
682
|
+
return this.store.get(this.activeLang())?.[key] != null;
|
|
683
|
+
}
|
|
684
|
+
/** Merge translations into the store for a given language. */
|
|
685
|
+
setTranslation(data, lang) {
|
|
686
|
+
const existing = this.store.get(lang) ?? {};
|
|
687
|
+
this.store.set(lang, { ...existing, ...data });
|
|
688
|
+
this.versionCounter = (this.versionCounter + 1) % 0x7fffffff;
|
|
689
|
+
this._version.set(this.versionCounter);
|
|
690
|
+
}
|
|
691
|
+
/** Set active language and persist to `localStorage`. Ignores invalid language codes. */
|
|
692
|
+
setActiveLang(lang) {
|
|
693
|
+
if (!this.config.availableLangs.includes(lang)) {
|
|
694
|
+
console.warn(`[blocks-localization] "${lang}" is not in availableLangs [${this.config.availableLangs.join(', ')}]. Ignoring.`);
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
this.activeLang.set(lang);
|
|
698
|
+
this.persistLang(lang);
|
|
699
|
+
}
|
|
700
|
+
/** Get configured available languages. */
|
|
701
|
+
getAvailableLangs() {
|
|
702
|
+
return [...this.config.availableLangs];
|
|
703
|
+
}
|
|
704
|
+
// ---------------------------------------------------------------------------
|
|
705
|
+
// Private
|
|
706
|
+
// ---------------------------------------------------------------------------
|
|
707
|
+
getStorage() {
|
|
708
|
+
try {
|
|
709
|
+
const type = this.config.langStorage ?? 'localStorage';
|
|
710
|
+
if (type === 'none')
|
|
711
|
+
return null;
|
|
712
|
+
return type === 'sessionStorage' ? sessionStorage : localStorage;
|
|
713
|
+
}
|
|
714
|
+
catch {
|
|
715
|
+
return null;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
loadPersistedLang() {
|
|
719
|
+
try {
|
|
720
|
+
const storage = this.getStorage();
|
|
721
|
+
const stored = storage?.getItem(this.storageKey) ?? null;
|
|
722
|
+
if (stored) {
|
|
723
|
+
const reverse = this.config.localeMapping
|
|
724
|
+
? buildReverseMapping(this.config.localeMapping)
|
|
725
|
+
: {};
|
|
726
|
+
const shortCode = reverse[stored] ?? stored;
|
|
727
|
+
if (this.config.availableLangs.includes(shortCode)) {
|
|
728
|
+
return shortCode;
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
catch {
|
|
733
|
+
// Storage unavailable (SSR, incognito restrictions, etc.)
|
|
734
|
+
}
|
|
735
|
+
return this.config.defaultLang;
|
|
736
|
+
}
|
|
737
|
+
persistLang(lang) {
|
|
738
|
+
try {
|
|
739
|
+
const storage = this.getStorage();
|
|
740
|
+
if (!storage)
|
|
741
|
+
return;
|
|
742
|
+
const fullCode = this.config.localeMapping?.[lang] ?? lang;
|
|
743
|
+
storage.setItem(this.storageKey, fullCode);
|
|
744
|
+
}
|
|
745
|
+
catch {
|
|
746
|
+
// Storage unavailable
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
/**
|
|
750
|
+
* Listen for `window.postMessage` events to toggle key mode.
|
|
751
|
+
*
|
|
752
|
+
* Expected payload: `{ action: 'keymode', keymode: boolean }`
|
|
753
|
+
*
|
|
754
|
+
* Only messages from the same window and origin are accepted.
|
|
755
|
+
* Toggling key mode bumps the version signal so all translated
|
|
756
|
+
* values reactively update across the UI.
|
|
757
|
+
*/
|
|
758
|
+
listenForKeyModeToggle() {
|
|
759
|
+
if (typeof window === 'undefined')
|
|
760
|
+
return;
|
|
761
|
+
const handler = (event) => {
|
|
762
|
+
if (event.source !== window)
|
|
763
|
+
return;
|
|
764
|
+
if (event.origin !== window.location.origin)
|
|
765
|
+
return;
|
|
766
|
+
const { data } = event;
|
|
767
|
+
if (!data || typeof data !== 'object')
|
|
768
|
+
return;
|
|
769
|
+
const { action, keymode } = data;
|
|
770
|
+
if (action === 'keymode' && typeof keymode === 'boolean') {
|
|
771
|
+
const previous = this.keyMode();
|
|
772
|
+
this.keyMode.set(keymode);
|
|
773
|
+
if (previous !== keymode) {
|
|
774
|
+
this.versionCounter = (this.versionCounter + 1) % 0x7fffffff;
|
|
775
|
+
this._version.set(this.versionCounter);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
};
|
|
779
|
+
window.addEventListener('message', handler);
|
|
780
|
+
this.destroyRef.onDestroy(() => window.removeEventListener('message', handler));
|
|
781
|
+
}
|
|
782
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: UilmStore, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
783
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: UilmStore, providedIn: 'root' });
|
|
784
|
+
}
|
|
785
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: UilmStore, decorators: [{
|
|
786
|
+
type: Injectable,
|
|
787
|
+
args: [{ providedIn: 'root' }]
|
|
788
|
+
}], ctorParameters: () => [] });
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* Build an array of `fetchModuleTranslations` observables from module entries.
|
|
792
|
+
* @internal
|
|
793
|
+
*/
|
|
794
|
+
function buildRequests(loader, modules, lang) {
|
|
795
|
+
return modules.map((m) => typeof m === 'string'
|
|
796
|
+
? loader.fetchModuleTranslations(lang, m)
|
|
797
|
+
: loader.fetchModuleTranslations(lang, m.module, m.alias));
|
|
798
|
+
}
|
|
799
|
+
/**
|
|
800
|
+
* Build an array of cache-only read observables from module entries.
|
|
801
|
+
* @internal
|
|
802
|
+
*/
|
|
803
|
+
function buildCacheRequests(loader, modules, lang) {
|
|
804
|
+
return modules.map((m) => typeof m === 'string'
|
|
805
|
+
? loader.loadFromCacheOnly(lang, m)
|
|
806
|
+
: loader.loadFromCacheOnly(lang, m.module, m.alias));
|
|
807
|
+
}
|
|
808
|
+
/**
|
|
809
|
+
* Provides all UILM translation infrastructure for an Angular application.
|
|
810
|
+
*
|
|
811
|
+
* ## Strategies
|
|
812
|
+
*
|
|
813
|
+
* ### `'modular'` (default)
|
|
814
|
+
* Preloads only the modules listed in `preloadModules` at startup.
|
|
815
|
+
* Additional modules are lazily loaded per route via `provideUilmScope()`.
|
|
816
|
+
* Shows empty placeholders while translations are loading.
|
|
817
|
+
*
|
|
818
|
+
* ### `'eager'`
|
|
819
|
+
* All modules in `preloadModules` are fetched at startup (non-blocking).
|
|
820
|
+
* Use `UilmLoadingScreenComponent` with `UilmStore.ready` to gate the UI
|
|
821
|
+
* until translations are available. Route-level `provideUilmScope()` calls
|
|
822
|
+
* become cache hits.
|
|
823
|
+
*
|
|
824
|
+
* @example
|
|
825
|
+
* ```typescript
|
|
826
|
+
* // Eager — all modules upfront with loading screen
|
|
827
|
+
* provideBlocksLocalization({
|
|
828
|
+
* strategy: 'eager',
|
|
829
|
+
* preloadModules: [
|
|
830
|
+
* { module: 'common', alias: '' },
|
|
831
|
+
* 'dashboard',
|
|
832
|
+
* { module: 'opportunity', alias: 'op' },
|
|
833
|
+
* ],
|
|
834
|
+
* // ...
|
|
835
|
+
* })
|
|
836
|
+
*
|
|
837
|
+
* // app.component.html
|
|
838
|
+
* // @if (!store.ready()) {
|
|
839
|
+
* // <uilm-loading-screen />
|
|
840
|
+
* // } @else {
|
|
841
|
+
* // <router-outlet />
|
|
842
|
+
* // }
|
|
843
|
+
* ```
|
|
844
|
+
*
|
|
845
|
+
* @publicApi
|
|
846
|
+
*/
|
|
847
|
+
function provideBlocksLocalization(config) {
|
|
848
|
+
const modules = config.preloadModules ?? [];
|
|
849
|
+
return makeEnvironmentProviders([
|
|
850
|
+
{ provide: BLOCKS_LOCALIZATION_CONFIG, useValue: config },
|
|
851
|
+
// -------------------------------------------------------------------------
|
|
852
|
+
// Preload modules + language-change watcher
|
|
853
|
+
// -------------------------------------------------------------------------
|
|
854
|
+
...(modules.length
|
|
855
|
+
? [
|
|
856
|
+
provideEnvironmentInitializer(() => {
|
|
857
|
+
const loader = inject(UilmLoader);
|
|
858
|
+
const store = inject(UilmStore);
|
|
859
|
+
const destroyRef = inject(DestroyRef);
|
|
860
|
+
const loadForLang = (lang) => {
|
|
861
|
+
forkJoin(buildRequests(loader, modules, lang))
|
|
862
|
+
.pipe(takeUntilDestroyed(destroyRef))
|
|
863
|
+
.subscribe((results) => {
|
|
864
|
+
const merged = Object.assign({}, ...results);
|
|
865
|
+
store.setTranslation(merged, lang);
|
|
866
|
+
});
|
|
867
|
+
};
|
|
868
|
+
// Merge background revalidation updates into the store
|
|
869
|
+
loader.revalidated$
|
|
870
|
+
.pipe(takeUntilDestroyed(destroyRef))
|
|
871
|
+
.subscribe(({ lang, data }) => store.setTranslation(data, lang));
|
|
872
|
+
// Hydrate from IndexedDB cache immediately (no metadata needed),
|
|
873
|
+
// then let the normal metadata + API flow handle revalidation.
|
|
874
|
+
if (config.revalidateInBackground && config.cacheStorage === 'indexeddb') {
|
|
875
|
+
const lang = store.activeLang();
|
|
876
|
+
forkJoin(buildCacheRequests(loader, modules, lang))
|
|
877
|
+
.pipe(takeUntilDestroyed(destroyRef))
|
|
878
|
+
.subscribe((results) => {
|
|
879
|
+
// Only hydrate if the store hasn't been populated yet
|
|
880
|
+
// (avoids overwriting fresher API data that arrived first)
|
|
881
|
+
if (store.ready())
|
|
882
|
+
return;
|
|
883
|
+
const cached = results.filter((r) => r != null);
|
|
884
|
+
if (cached.length > 0) {
|
|
885
|
+
store.setTranslation(Object.assign({}, ...cached), lang);
|
|
886
|
+
}
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
// Load for current language (metadata + API)
|
|
890
|
+
loader
|
|
891
|
+
.ensureMetadataLoaded()
|
|
892
|
+
.pipe(takeUntilDestroyed(destroyRef))
|
|
893
|
+
.subscribe(() => loadForLang(store.activeLang()));
|
|
894
|
+
// Re-load when language changes
|
|
895
|
+
let previousLang = store.activeLang();
|
|
896
|
+
effect(() => {
|
|
897
|
+
const newLang = store.activeLang();
|
|
898
|
+
if (newLang !== previousLang) {
|
|
899
|
+
previousLang = newLang;
|
|
900
|
+
loader
|
|
901
|
+
.ensureMetadataLoaded()
|
|
902
|
+
.pipe(takeUntilDestroyed(destroyRef))
|
|
903
|
+
.subscribe(() => loadForLang(newLang));
|
|
904
|
+
}
|
|
905
|
+
});
|
|
906
|
+
}),
|
|
907
|
+
]
|
|
908
|
+
: []),
|
|
909
|
+
]);
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
function normalizeEntries(modules) {
|
|
913
|
+
return modules.map((m) => (typeof m === 'string' ? { module: m } : m));
|
|
914
|
+
}
|
|
915
|
+
/**
|
|
916
|
+
* Route-level provider that lazily loads specific UILM modules on navigation.
|
|
917
|
+
*
|
|
918
|
+
* In `'eager'` strategy mode, translations are typically already cached
|
|
919
|
+
* from the initial `APP_INITIALIZER` load. The loader's cache deduplication
|
|
920
|
+
* ensures no redundant HTTP calls are made.
|
|
921
|
+
*
|
|
922
|
+
* Re-fetches automatically when the active language changes.
|
|
923
|
+
* Subscriptions are auto-cleaned via `DestroyRef`.
|
|
924
|
+
*
|
|
925
|
+
* @publicApi
|
|
926
|
+
*/
|
|
927
|
+
function provideUilmScope(config) {
|
|
928
|
+
const entries = normalizeEntries(config.modules);
|
|
929
|
+
return makeEnvironmentProviders([
|
|
930
|
+
provideEnvironmentInitializer(() => {
|
|
931
|
+
const loader = inject(UilmLoader);
|
|
932
|
+
const store = inject(UilmStore);
|
|
933
|
+
const destroyRef = inject(DestroyRef);
|
|
934
|
+
const globalConfig = inject(BLOCKS_LOCALIZATION_CONFIG);
|
|
935
|
+
const loadForLang = (lang) => {
|
|
936
|
+
if (entries.length === 0)
|
|
937
|
+
return;
|
|
938
|
+
const requests = entries.map((e) => loader.fetchModuleTranslations(lang, e.module, e.alias));
|
|
939
|
+
forkJoin(requests)
|
|
940
|
+
.pipe(takeUntilDestroyed(destroyRef))
|
|
941
|
+
.subscribe((results) => {
|
|
942
|
+
const merged = Object.assign({}, ...results);
|
|
943
|
+
store.setTranslation(merged, lang);
|
|
944
|
+
});
|
|
945
|
+
};
|
|
946
|
+
// Merge background revalidation updates into the store
|
|
947
|
+
loader.revalidated$
|
|
948
|
+
.pipe(takeUntilDestroyed(destroyRef))
|
|
949
|
+
.subscribe(({ lang, data }) => store.setTranslation(data, lang));
|
|
950
|
+
// Hydrate from IndexedDB cache immediately for instant rendering
|
|
951
|
+
if (globalConfig.revalidateInBackground &&
|
|
952
|
+
globalConfig.cacheStorage === 'indexeddb' &&
|
|
953
|
+
entries.length > 0) {
|
|
954
|
+
const lang = store.activeLang();
|
|
955
|
+
const cacheRequests = entries.map((e) => loader.loadFromCacheOnly(lang, e.module, e.alias));
|
|
956
|
+
forkJoin(cacheRequests)
|
|
957
|
+
.pipe(takeUntilDestroyed(destroyRef))
|
|
958
|
+
.subscribe((results) => {
|
|
959
|
+
// Only hydrate if the store hasn't been populated yet
|
|
960
|
+
// (avoids overwriting fresher API data that arrived first)
|
|
961
|
+
if (store.ready())
|
|
962
|
+
return;
|
|
963
|
+
const cached = results.filter((r) => r != null);
|
|
964
|
+
if (cached.length > 0) {
|
|
965
|
+
store.setTranslation(Object.assign({}, ...cached), lang);
|
|
966
|
+
}
|
|
967
|
+
});
|
|
968
|
+
}
|
|
969
|
+
// In eager mode, skip the initial fetch — APP_INITIALIZER already loaded everything.
|
|
970
|
+
// The loader cache will short-circuit anyway, but this avoids unnecessary forkJoin overhead.
|
|
971
|
+
if (globalConfig.strategy !== 'eager') {
|
|
972
|
+
loader
|
|
973
|
+
.ensureMetadataLoaded()
|
|
974
|
+
.pipe(takeUntilDestroyed(destroyRef))
|
|
975
|
+
.subscribe(() => loadForLang(store.activeLang()));
|
|
976
|
+
}
|
|
977
|
+
// Re-load when language changes (always needed — even in eager mode)
|
|
978
|
+
let previousLang = store.activeLang();
|
|
979
|
+
effect(() => {
|
|
980
|
+
const newLang = store.activeLang();
|
|
981
|
+
if (newLang !== previousLang) {
|
|
982
|
+
previousLang = newLang;
|
|
983
|
+
loader
|
|
984
|
+
.ensureMetadataLoaded()
|
|
985
|
+
.pipe(takeUntilDestroyed(destroyRef))
|
|
986
|
+
.subscribe(() => loadForLang(newLang));
|
|
987
|
+
}
|
|
988
|
+
});
|
|
989
|
+
}),
|
|
990
|
+
]);
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
/**
|
|
994
|
+
* Headless language switching service.
|
|
995
|
+
* Provides programmatic control over the active language.
|
|
996
|
+
*/
|
|
997
|
+
class BlocksLangSwitcher {
|
|
998
|
+
store = inject(UilmStore);
|
|
999
|
+
loader = inject(UilmLoader);
|
|
1000
|
+
/** Active language as a signal. */
|
|
1001
|
+
activeLang = this.store.activeLang;
|
|
1002
|
+
/** Get the currently active short language code. */
|
|
1003
|
+
getActiveLang() {
|
|
1004
|
+
return this.store.activeLang();
|
|
1005
|
+
}
|
|
1006
|
+
/** Get the list of configured available language codes. */
|
|
1007
|
+
getAvailableLangs() {
|
|
1008
|
+
return this.store.getAvailableLangs();
|
|
1009
|
+
}
|
|
1010
|
+
/**
|
|
1011
|
+
* Switch the active language.
|
|
1012
|
+
* @param lang Short language code (e.g. 'en', 'de')
|
|
1013
|
+
* @param reload Whether to reload the page after switching. Default: false
|
|
1014
|
+
*/
|
|
1015
|
+
setActiveLang(lang, reload = false) {
|
|
1016
|
+
this.store.setActiveLang(lang);
|
|
1017
|
+
if (reload && typeof window !== 'undefined') {
|
|
1018
|
+
window.location.reload();
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
/** Fetch available languages from the UILM API. */
|
|
1022
|
+
getAvailableLanguagesFromApi() {
|
|
1023
|
+
return this.loader.getAvailableLanguages();
|
|
1024
|
+
}
|
|
1025
|
+
/** Clear the translation cache. */
|
|
1026
|
+
clearCache() {
|
|
1027
|
+
this.loader.clearCache();
|
|
1028
|
+
}
|
|
1029
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: BlocksLangSwitcher, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
1030
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: BlocksLangSwitcher, providedIn: 'root' });
|
|
1031
|
+
}
|
|
1032
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: BlocksLangSwitcher, decorators: [{
|
|
1033
|
+
type: Injectable,
|
|
1034
|
+
args: [{ providedIn: 'root' }]
|
|
1035
|
+
}] });
|
|
1036
|
+
|
|
1037
|
+
/**
|
|
1038
|
+
* Structural directive that provides a translation function to the template.
|
|
1039
|
+
* Re-renders when the active language or translations change.
|
|
1040
|
+
*
|
|
1041
|
+
* @example
|
|
1042
|
+
* ```html
|
|
1043
|
+
* <section *uilmTranslate="let t">
|
|
1044
|
+
* <p>{{ t('dashboard.LABEL.TITLE') }}</p>
|
|
1045
|
+
* </section>
|
|
1046
|
+
* ```
|
|
1047
|
+
*
|
|
1048
|
+
* With scope (auto-prefixes keys):
|
|
1049
|
+
* ```html
|
|
1050
|
+
* <section *uilmTranslate="let t; scope: 'dashboard'">
|
|
1051
|
+
* <p>{{ t('LABEL.TITLE') }}</p> <!-- resolves to dashboard.LABEL.TITLE -->
|
|
1052
|
+
* </section>
|
|
1053
|
+
* ```
|
|
1054
|
+
*/
|
|
1055
|
+
class UilmTranslateDirective {
|
|
1056
|
+
store = inject(UilmStore);
|
|
1057
|
+
templateRef = inject((TemplateRef));
|
|
1058
|
+
vcr = inject(ViewContainerRef);
|
|
1059
|
+
viewRef = null;
|
|
1060
|
+
/** Optional scope prefix */
|
|
1061
|
+
uilmTranslateScope = input('', ...(ngDevMode ? [{ debugName: "uilmTranslateScope" }] : []));
|
|
1062
|
+
constructor() {
|
|
1063
|
+
effect(() => {
|
|
1064
|
+
this.store.activeLang();
|
|
1065
|
+
this.store.version();
|
|
1066
|
+
const isReady = this.store.ready();
|
|
1067
|
+
if (isReady) {
|
|
1068
|
+
this.ensureView();
|
|
1069
|
+
}
|
|
1070
|
+
else {
|
|
1071
|
+
this.vcr.clear();
|
|
1072
|
+
this.viewRef = null;
|
|
1073
|
+
}
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
ensureView() {
|
|
1077
|
+
const scope = this.uilmTranslateScope();
|
|
1078
|
+
const translateFn = (key, params) => {
|
|
1079
|
+
const resolve = (k) => this.store.has(k) ? this.store.translate(k, params) : null;
|
|
1080
|
+
const result = scope ? (resolve(`${scope}.${key}`) ?? resolve(key)) : resolve(key);
|
|
1081
|
+
return result ?? key;
|
|
1082
|
+
};
|
|
1083
|
+
if (this.viewRef) {
|
|
1084
|
+
// Update context in-place — preserves DOM, focus, scroll, animation state
|
|
1085
|
+
this.viewRef.context.$implicit = translateFn;
|
|
1086
|
+
this.viewRef.context.uilmTranslate = translateFn;
|
|
1087
|
+
this.viewRef.markForCheck();
|
|
1088
|
+
}
|
|
1089
|
+
else {
|
|
1090
|
+
this.viewRef = this.vcr.createEmbeddedView(this.templateRef, {
|
|
1091
|
+
$implicit: translateFn,
|
|
1092
|
+
uilmTranslate: translateFn,
|
|
1093
|
+
});
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
static ngTemplateContextGuard(_dir, _ctx) {
|
|
1097
|
+
return true;
|
|
1098
|
+
}
|
|
1099
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: UilmTranslateDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
1100
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "20.3.19", type: UilmTranslateDirective, isStandalone: true, selector: "[uilmTranslate]", inputs: { uilmTranslateScope: { classPropertyName: "uilmTranslateScope", publicName: "uilmTranslateScope", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0 });
|
|
1101
|
+
}
|
|
1102
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: UilmTranslateDirective, decorators: [{
|
|
1103
|
+
type: Directive,
|
|
1104
|
+
args: [{
|
|
1105
|
+
selector: '[uilmTranslate]',
|
|
1106
|
+
standalone: true,
|
|
1107
|
+
}]
|
|
1108
|
+
}], ctorParameters: () => [], propDecorators: { uilmTranslateScope: [{ type: i0.Input, args: [{ isSignal: true, alias: "uilmTranslateScope", required: false }] }] } });
|
|
1109
|
+
|
|
1110
|
+
/**
|
|
1111
|
+
* Resolves a multilingual object to the value matching the current active language.
|
|
1112
|
+
*
|
|
1113
|
+
* @example
|
|
1114
|
+
* ```html
|
|
1115
|
+
* <!-- Given: { en: 'Hello', de: 'Hallo', fr: 'Bonjour' } -->
|
|
1116
|
+
* {{ item.name | multiLang }} <!-- outputs 'Hello' when lang is 'en' -->
|
|
1117
|
+
* ```
|
|
1118
|
+
*/
|
|
1119
|
+
class MultiLangPipe {
|
|
1120
|
+
store = inject(UilmStore);
|
|
1121
|
+
transform(value) {
|
|
1122
|
+
if (!value || typeof value === 'string') {
|
|
1123
|
+
return value ?? '';
|
|
1124
|
+
}
|
|
1125
|
+
const lang = this.store.activeLang();
|
|
1126
|
+
const defaultLang = this.store.getAvailableLangs()[0];
|
|
1127
|
+
return value[lang] ?? (defaultLang ? value[defaultLang] : '') ?? '';
|
|
1128
|
+
}
|
|
1129
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: MultiLangPipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe });
|
|
1130
|
+
static ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "20.3.19", ngImport: i0, type: MultiLangPipe, isStandalone: true, name: "multiLang", pure: false });
|
|
1131
|
+
}
|
|
1132
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: MultiLangPipe, decorators: [{
|
|
1133
|
+
type: Pipe,
|
|
1134
|
+
args: [{
|
|
1135
|
+
name: 'multiLang',
|
|
1136
|
+
standalone: true,
|
|
1137
|
+
pure: false,
|
|
1138
|
+
}]
|
|
1139
|
+
}] });
|
|
1140
|
+
|
|
1141
|
+
/**
|
|
1142
|
+
* Impure pipe that translates a key using the UILM store.
|
|
1143
|
+
* Re-evaluates when the active language or translations change.
|
|
1144
|
+
*
|
|
1145
|
+
* @example
|
|
1146
|
+
* ```html
|
|
1147
|
+
* <p>{{ 'dashboard.LABEL.TITLE' | uilmTranslate }}</p>
|
|
1148
|
+
* <p>{{ 'LABEL.HELLO' | uilmTranslate: { name: userName } }}</p>
|
|
1149
|
+
* ```
|
|
1150
|
+
*/
|
|
1151
|
+
class UilmTranslatePipe {
|
|
1152
|
+
store = inject(UilmStore);
|
|
1153
|
+
lastKey = '';
|
|
1154
|
+
lastLang = '';
|
|
1155
|
+
lastVersion = -1;
|
|
1156
|
+
lastParamsJson = '';
|
|
1157
|
+
lastValue = '';
|
|
1158
|
+
transform(key, params) {
|
|
1159
|
+
const lang = this.store.activeLang();
|
|
1160
|
+
const version = this.store.version();
|
|
1161
|
+
if (!this.store.ready()) {
|
|
1162
|
+
return '';
|
|
1163
|
+
}
|
|
1164
|
+
const paramsJson = params ? JSON.stringify(params) : '';
|
|
1165
|
+
if (key === this.lastKey &&
|
|
1166
|
+
lang === this.lastLang &&
|
|
1167
|
+
version === this.lastVersion &&
|
|
1168
|
+
paramsJson === this.lastParamsJson) {
|
|
1169
|
+
return this.lastValue;
|
|
1170
|
+
}
|
|
1171
|
+
this.lastKey = key;
|
|
1172
|
+
this.lastLang = lang;
|
|
1173
|
+
this.lastVersion = version;
|
|
1174
|
+
this.lastParamsJson = paramsJson;
|
|
1175
|
+
this.lastValue = this.store.has(key)
|
|
1176
|
+
? this.store.translate(key, params)
|
|
1177
|
+
: key;
|
|
1178
|
+
return this.lastValue;
|
|
1179
|
+
}
|
|
1180
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: UilmTranslatePipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe });
|
|
1181
|
+
static ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "20.3.19", ngImport: i0, type: UilmTranslatePipe, isStandalone: true, name: "uilmTranslate", pure: false });
|
|
1182
|
+
}
|
|
1183
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: UilmTranslatePipe, decorators: [{
|
|
1184
|
+
type: Pipe,
|
|
1185
|
+
args: [{
|
|
1186
|
+
name: 'uilmTranslate',
|
|
1187
|
+
standalone: true,
|
|
1188
|
+
pure: false,
|
|
1189
|
+
}]
|
|
1190
|
+
}] });
|
|
1191
|
+
|
|
1192
|
+
/**
|
|
1193
|
+
* Translation service for component classes.
|
|
1194
|
+
* Fully signal-based with sync helpers.
|
|
1195
|
+
*
|
|
1196
|
+
* @example
|
|
1197
|
+
* ```typescript
|
|
1198
|
+
* private readonly uilm = inject(UilmTranslateService);
|
|
1199
|
+
*
|
|
1200
|
+
* // Signal-based (reactive, updates on lang change + translation load)
|
|
1201
|
+
* title = this.uilm.t('dashboard.LABEL.TITLE');
|
|
1202
|
+
* // In template: {{ title() }}
|
|
1203
|
+
*
|
|
1204
|
+
* // Sync snapshot (does NOT react)
|
|
1205
|
+
* label = this.uilm.translate('dashboard.LABEL.TITLE');
|
|
1206
|
+
* ```
|
|
1207
|
+
*/
|
|
1208
|
+
class UilmTranslateService {
|
|
1209
|
+
store = inject(UilmStore);
|
|
1210
|
+
/** Active language as a signal. */
|
|
1211
|
+
activeLang = this.store.activeLang;
|
|
1212
|
+
/**
|
|
1213
|
+
* Signal-based translation. Auto-updates on language change
|
|
1214
|
+
* and when new translations are loaded.
|
|
1215
|
+
*/
|
|
1216
|
+
t(key, params) {
|
|
1217
|
+
return computed(() => this.store.translate(key, params));
|
|
1218
|
+
}
|
|
1219
|
+
/** Synchronous translation snapshot. Does NOT react to changes. */
|
|
1220
|
+
translate(key, params) {
|
|
1221
|
+
return this.store.translate(key, params);
|
|
1222
|
+
}
|
|
1223
|
+
/** Current active language code. */
|
|
1224
|
+
getActiveLang() {
|
|
1225
|
+
return this.store.activeLang();
|
|
1226
|
+
}
|
|
1227
|
+
/** Set active language. */
|
|
1228
|
+
setActiveLang(lang) {
|
|
1229
|
+
this.store.setActiveLang(lang);
|
|
1230
|
+
}
|
|
1231
|
+
/**
|
|
1232
|
+
* Signal-based batch translation. Reactive to lang and translation changes.
|
|
1233
|
+
*/
|
|
1234
|
+
tMany(keys, params) {
|
|
1235
|
+
return computed(() => {
|
|
1236
|
+
const result = {};
|
|
1237
|
+
for (const key of keys) {
|
|
1238
|
+
result[key] = this.store.translate(key, params);
|
|
1239
|
+
}
|
|
1240
|
+
return result;
|
|
1241
|
+
});
|
|
1242
|
+
}
|
|
1243
|
+
/** Synchronous batch translation snapshot. */
|
|
1244
|
+
translateMany(keys, params) {
|
|
1245
|
+
const result = {};
|
|
1246
|
+
for (const key of keys) {
|
|
1247
|
+
result[key] = this.store.translate(key, params);
|
|
1248
|
+
}
|
|
1249
|
+
return result;
|
|
1250
|
+
}
|
|
1251
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: UilmTranslateService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
1252
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: UilmTranslateService, providedIn: 'root' });
|
|
1253
|
+
}
|
|
1254
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: UilmTranslateService, decorators: [{
|
|
1255
|
+
type: Injectable,
|
|
1256
|
+
args: [{ providedIn: 'root' }]
|
|
1257
|
+
}] });
|
|
1258
|
+
|
|
1259
|
+
class UilmLoadingScreenComponent {
|
|
1260
|
+
title = input('Loading', ...(ngDevMode ? [{ debugName: "title" }] : []));
|
|
1261
|
+
description = input('Loading translations...', ...(ngDevMode ? [{ debugName: "description" }] : []));
|
|
1262
|
+
/**
|
|
1263
|
+
* Optional custom template to replace the entire default loading UI.
|
|
1264
|
+
*
|
|
1265
|
+
* @example
|
|
1266
|
+
* ```html
|
|
1267
|
+
* <ng-template #customLoading>
|
|
1268
|
+
* <div class="my-loading">
|
|
1269
|
+
* <img src="assets/logo.svg" />
|
|
1270
|
+
* <p>Please wait...</p>
|
|
1271
|
+
* </div>
|
|
1272
|
+
* </ng-template>
|
|
1273
|
+
*
|
|
1274
|
+
* <uilm-loading-screen [customTemplate]="customLoading" />
|
|
1275
|
+
* ```
|
|
1276
|
+
*/
|
|
1277
|
+
customTemplate = input(...(ngDevMode ? [undefined, { debugName: "customTemplate" }] : []));
|
|
1278
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: UilmLoadingScreenComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1279
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.19", type: UilmLoadingScreenComponent, isStandalone: true, selector: "uilm-loading-screen", inputs: { title: { classPropertyName: "title", publicName: "title", isSignal: true, isRequired: false, transformFunction: null }, description: { classPropertyName: "description", publicName: "description", isSignal: true, isRequired: false, transformFunction: null }, customTemplate: { classPropertyName: "customTemplate", publicName: "customTemplate", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: `
|
|
1280
|
+
<div class="uilm-loading-screen">
|
|
1281
|
+
@if (customTemplate()) {
|
|
1282
|
+
<ng-container *ngTemplateOutlet="customTemplate()!" />
|
|
1283
|
+
} @else {
|
|
1284
|
+
<div class="uilm-loading-content">
|
|
1285
|
+
<div class="uilm-loading-logo">
|
|
1286
|
+
<svg
|
|
1287
|
+
width="44"
|
|
1288
|
+
height="44"
|
|
1289
|
+
viewBox="0 0 24 24"
|
|
1290
|
+
fill="none"
|
|
1291
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
1292
|
+
>
|
|
1293
|
+
<path d="M12 2L2 7l10 5 10-5-10-5z" fill="#6366f1" />
|
|
1294
|
+
<path
|
|
1295
|
+
d="M2 17l10 5 10-5"
|
|
1296
|
+
stroke="#6366f1"
|
|
1297
|
+
stroke-opacity="0.35"
|
|
1298
|
+
stroke-width="1.5"
|
|
1299
|
+
stroke-linecap="round"
|
|
1300
|
+
stroke-linejoin="round"
|
|
1301
|
+
/>
|
|
1302
|
+
<path
|
|
1303
|
+
d="M2 12l10 5 10-5"
|
|
1304
|
+
stroke="#6366f1"
|
|
1305
|
+
stroke-opacity="0.6"
|
|
1306
|
+
stroke-width="1.5"
|
|
1307
|
+
stroke-linecap="round"
|
|
1308
|
+
stroke-linejoin="round"
|
|
1309
|
+
/>
|
|
1310
|
+
</svg>
|
|
1311
|
+
</div>
|
|
1312
|
+
<h2 class="uilm-loading-title">{{ title() }}</h2>
|
|
1313
|
+
<p class="uilm-loading-description">{{ description() }}</p>
|
|
1314
|
+
<div class="uilm-loading-bar">
|
|
1315
|
+
<div class="uilm-loading-bar-fill"></div>
|
|
1316
|
+
</div>
|
|
1317
|
+
</div>
|
|
1318
|
+
}
|
|
1319
|
+
</div>
|
|
1320
|
+
`, isInline: true, styles: [".uilm-loading-screen{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;background:#fff;z-index:9999;animation:uilm-fade-in .3s ease-out}.uilm-loading-content{display:flex;flex-direction:column;align-items:center;gap:16px;text-align:center;padding:32px}.uilm-loading-logo{display:flex;align-items:center;justify-content:center;animation:uilm-pulse 2s ease-in-out infinite}.uilm-loading-title{margin:0;font-size:1.5rem;font-weight:600;color:#374151;letter-spacing:-.01em;font-family:system-ui,-apple-system,sans-serif}.uilm-loading-description{margin:0;font-size:1rem;color:#9ca3af;font-family:system-ui,-apple-system,sans-serif}.uilm-loading-bar{width:180px;height:3px;background:#0000000f;border-radius:3px;overflow:hidden;margin-top:4px}.uilm-loading-bar-fill{height:100%;width:40%;background:#6366f1;border-radius:3px;animation:uilm-slide 1.4s ease-in-out infinite}@keyframes uilm-fade-in{0%{opacity:0}to{opacity:1}}@keyframes uilm-pulse{0%,to{transform:scale(1);opacity:1}50%{transform:scale(1.04);opacity:.85}}@keyframes uilm-slide{0%{transform:translate(-100%)}50%{transform:translate(350%)}to{transform:translate(-100%)}}\n"], dependencies: [{ kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
1321
|
+
}
|
|
1322
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: UilmLoadingScreenComponent, decorators: [{
|
|
1323
|
+
type: Component,
|
|
1324
|
+
args: [{ selector: 'uilm-loading-screen', standalone: true, imports: [NgTemplateOutlet], changeDetection: ChangeDetectionStrategy.OnPush, template: `
|
|
1325
|
+
<div class="uilm-loading-screen">
|
|
1326
|
+
@if (customTemplate()) {
|
|
1327
|
+
<ng-container *ngTemplateOutlet="customTemplate()!" />
|
|
1328
|
+
} @else {
|
|
1329
|
+
<div class="uilm-loading-content">
|
|
1330
|
+
<div class="uilm-loading-logo">
|
|
1331
|
+
<svg
|
|
1332
|
+
width="44"
|
|
1333
|
+
height="44"
|
|
1334
|
+
viewBox="0 0 24 24"
|
|
1335
|
+
fill="none"
|
|
1336
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
1337
|
+
>
|
|
1338
|
+
<path d="M12 2L2 7l10 5 10-5-10-5z" fill="#6366f1" />
|
|
1339
|
+
<path
|
|
1340
|
+
d="M2 17l10 5 10-5"
|
|
1341
|
+
stroke="#6366f1"
|
|
1342
|
+
stroke-opacity="0.35"
|
|
1343
|
+
stroke-width="1.5"
|
|
1344
|
+
stroke-linecap="round"
|
|
1345
|
+
stroke-linejoin="round"
|
|
1346
|
+
/>
|
|
1347
|
+
<path
|
|
1348
|
+
d="M2 12l10 5 10-5"
|
|
1349
|
+
stroke="#6366f1"
|
|
1350
|
+
stroke-opacity="0.6"
|
|
1351
|
+
stroke-width="1.5"
|
|
1352
|
+
stroke-linecap="round"
|
|
1353
|
+
stroke-linejoin="round"
|
|
1354
|
+
/>
|
|
1355
|
+
</svg>
|
|
1356
|
+
</div>
|
|
1357
|
+
<h2 class="uilm-loading-title">{{ title() }}</h2>
|
|
1358
|
+
<p class="uilm-loading-description">{{ description() }}</p>
|
|
1359
|
+
<div class="uilm-loading-bar">
|
|
1360
|
+
<div class="uilm-loading-bar-fill"></div>
|
|
1361
|
+
</div>
|
|
1362
|
+
</div>
|
|
1363
|
+
}
|
|
1364
|
+
</div>
|
|
1365
|
+
`, styles: [".uilm-loading-screen{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;background:#fff;z-index:9999;animation:uilm-fade-in .3s ease-out}.uilm-loading-content{display:flex;flex-direction:column;align-items:center;gap:16px;text-align:center;padding:32px}.uilm-loading-logo{display:flex;align-items:center;justify-content:center;animation:uilm-pulse 2s ease-in-out infinite}.uilm-loading-title{margin:0;font-size:1.5rem;font-weight:600;color:#374151;letter-spacing:-.01em;font-family:system-ui,-apple-system,sans-serif}.uilm-loading-description{margin:0;font-size:1rem;color:#9ca3af;font-family:system-ui,-apple-system,sans-serif}.uilm-loading-bar{width:180px;height:3px;background:#0000000f;border-radius:3px;overflow:hidden;margin-top:4px}.uilm-loading-bar-fill{height:100%;width:40%;background:#6366f1;border-radius:3px;animation:uilm-slide 1.4s ease-in-out infinite}@keyframes uilm-fade-in{0%{opacity:0}to{opacity:1}}@keyframes uilm-pulse{0%,to{transform:scale(1);opacity:1}50%{transform:scale(1.04);opacity:.85}}@keyframes uilm-slide{0%{transform:translate(-100%)}50%{transform:translate(350%)}to{transform:translate(-100%)}}\n"] }]
|
|
1366
|
+
}], propDecorators: { title: [{ type: i0.Input, args: [{ isSignal: true, alias: "title", required: false }] }], description: [{ type: i0.Input, args: [{ isSignal: true, alias: "description", required: false }] }], customTemplate: [{ type: i0.Input, args: [{ isSignal: true, alias: "customTemplate", required: false }] }] } });
|
|
1367
|
+
|
|
1368
|
+
/**
|
|
1369
|
+
* Creates an empty record with keys for each provided language.
|
|
1370
|
+
* Useful for initializing multilingual form models.
|
|
1371
|
+
*
|
|
1372
|
+
* @example
|
|
1373
|
+
* createI18nRecord(['en', 'de', 'fr']) // { en: '', de: '', fr: '' }
|
|
1374
|
+
*/
|
|
1375
|
+
function createI18nRecord(langs) {
|
|
1376
|
+
return langs.reduce((acc, lang) => {
|
|
1377
|
+
acc[lang] = '';
|
|
1378
|
+
return acc;
|
|
1379
|
+
}, {});
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
/**
|
|
1383
|
+
* Provides a test-friendly translation setup with in-memory translations.
|
|
1384
|
+
* No HTTP calls are made.
|
|
1385
|
+
*
|
|
1386
|
+
* @example
|
|
1387
|
+
* ```typescript
|
|
1388
|
+
* TestBed.configureTestingModule({
|
|
1389
|
+
* providers: [
|
|
1390
|
+
* provideBlocksLocalizationTesting({
|
|
1391
|
+
* en: { 'dashboard.LABEL.HELLO': 'Hello', 'dashboard.LABEL.WORLD': 'World' },
|
|
1392
|
+
* de: { 'dashboard.LABEL.HELLO': 'Hallo', 'dashboard.LABEL.WORLD': 'Welt' },
|
|
1393
|
+
* }),
|
|
1394
|
+
* ],
|
|
1395
|
+
* });
|
|
1396
|
+
* ```
|
|
1397
|
+
*/
|
|
1398
|
+
function provideBlocksLocalizationTesting(translations = { en: {} }, config) {
|
|
1399
|
+
const langs = Object.keys(translations);
|
|
1400
|
+
const defaultLang = config?.defaultLang ?? langs[0] ?? 'en';
|
|
1401
|
+
const fullConfig = {
|
|
1402
|
+
uilmApiBaseUrl: '',
|
|
1403
|
+
projectKey: '',
|
|
1404
|
+
availableLangs: langs,
|
|
1405
|
+
defaultLang,
|
|
1406
|
+
...config,
|
|
1407
|
+
};
|
|
1408
|
+
return makeEnvironmentProviders([
|
|
1409
|
+
{ provide: BLOCKS_LOCALIZATION_CONFIG, useValue: fullConfig },
|
|
1410
|
+
provideEnvironmentInitializer(() => {
|
|
1411
|
+
const store = inject(UilmStore);
|
|
1412
|
+
for (const [lang, data] of Object.entries(translations)) {
|
|
1413
|
+
store.setTranslation(data, lang);
|
|
1414
|
+
}
|
|
1415
|
+
// Ensure deterministic active language regardless of leftover localStorage
|
|
1416
|
+
store.setActiveLang(defaultLang);
|
|
1417
|
+
}),
|
|
1418
|
+
]);
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
// ---------------------------------------------------------------------------
|
|
1422
|
+
// Core configuration
|
|
1423
|
+
// ---------------------------------------------------------------------------
|
|
1424
|
+
|
|
1425
|
+
/**
|
|
1426
|
+
* Generated bundle index. Do not edit.
|
|
1427
|
+
*/
|
|
1428
|
+
|
|
1429
|
+
export { BLOCKS_LOCALIZATION_CONFIG, BlocksLangSwitcher, MultiLangPipe, UilmIndexedDbCache, UilmLoader, UilmLoadingScreenComponent, UilmStore, UilmTranslateDirective, UilmTranslatePipe, UilmTranslateService, buildReverseMapping, createI18nRecord, flattenJson, provideBlocksLocalization, provideBlocksLocalizationTesting, provideUilmScope, toFullLangCode, toShortLangCode };
|
|
1430
|
+
//# sourceMappingURL=seliseblocks-blocks-angular-localization.mjs.map
|