@mmstack/translate 19.2.8 → 19.3.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.
- package/LICENSE +21 -21
- package/README.md +649 -308
- package/fesm2022/mmstack-translate.mjs +677 -38
- package/fesm2022/mmstack-translate.mjs.map +1 -1
- package/index.d.ts +8 -4
- package/lib/format/date.d.ts +35 -0
- package/lib/format/display-name.d.ts +26 -0
- package/lib/format/index.d.ts +5 -0
- package/lib/format/list.d.ts +31 -0
- package/lib/format/numeric.d.ts +91 -0
- package/lib/format/relative-time.d.ts +33 -0
- package/lib/format/unwrap.d.ts +2 -0
- package/lib/path-param.d.ts +3 -0
- package/lib/register-namespace.d.ts +19 -6
- package/lib/resovler-locale.d.ts +2 -0
- package/lib/route-helpers.d.ts +18 -0
- package/lib/testing/provide-mock-translations.d.ts +35 -0
- package/lib/translate.d.ts +10 -0
- package/lib/translation-store.d.ts +69 -0
- package/lib/translator.d.ts +8 -0
- package/package.json +4 -3
- package/lib/translate.directive.d.ts +0 -10
- package/lib/translate.pipe.d.ts +0 -6
- package/lib/translation.store.d.ts +0 -21
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import * as i0 from '@angular/core';
|
|
2
|
-
import {
|
|
2
|
+
import { computed, inject, InjectionToken, LOCALE_ID, signal, resource, untracked, isDevMode, effect, Injectable, isSignal, input, Renderer2, ElementRef, afterRenderEffect, Directive, ChangeDetectorRef } from '@angular/core';
|
|
3
3
|
import { createIntlCache, createIntl } from '@formatjs/intl';
|
|
4
|
+
import { toSignal } from '@angular/core/rxjs-interop';
|
|
5
|
+
import { Router, ActivatedRoute } from '@angular/router';
|
|
4
6
|
|
|
5
7
|
const KEY_DELIM = '::MMT_DELIM::';
|
|
6
8
|
function prependDelim(prefix, key) {
|
|
@@ -53,27 +55,97 @@ function createNamespace(ns, translation) {
|
|
|
53
55
|
return namespace;
|
|
54
56
|
}
|
|
55
57
|
|
|
58
|
+
function pathParam(key, route = inject(ActivatedRoute)) {
|
|
59
|
+
const keySignal = typeof key === 'string' ? computed(() => key) : computed(key);
|
|
60
|
+
const routerOptions = inject(Router)['options'];
|
|
61
|
+
if (routerOptions &&
|
|
62
|
+
typeof routerOptions === 'object' &&
|
|
63
|
+
routerOptions.paramsInheritanceStrategy === 'always') {
|
|
64
|
+
const params = toSignal(route.paramMap, {
|
|
65
|
+
initialValue: route.snapshot.paramMap,
|
|
66
|
+
});
|
|
67
|
+
return computed(() => params().get(keySignal()));
|
|
68
|
+
}
|
|
69
|
+
const paramMapSignals = [];
|
|
70
|
+
let currentRoute = route;
|
|
71
|
+
const isStatic = typeof key === 'string';
|
|
72
|
+
while (currentRoute) {
|
|
73
|
+
const initial = currentRoute.snapshot.paramMap;
|
|
74
|
+
paramMapSignals.push(toSignal(currentRoute.paramMap, {
|
|
75
|
+
initialValue: initial,
|
|
76
|
+
}));
|
|
77
|
+
// For static keys, stop once we find the param, will find first in computed for loop already so basically noop for for loop
|
|
78
|
+
if (isStatic && initial.has(key))
|
|
79
|
+
break;
|
|
80
|
+
currentRoute = currentRoute.parent;
|
|
81
|
+
}
|
|
82
|
+
return computed(() => {
|
|
83
|
+
const paramKey = keySignal();
|
|
84
|
+
for (const map of paramMapSignals) {
|
|
85
|
+
const v = map().get(paramKey);
|
|
86
|
+
if (v)
|
|
87
|
+
return v;
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
56
93
|
const CONFIG_TOKEN = new InjectionToken('mmstack-intl-config');
|
|
57
94
|
function provideIntlConfig(config) {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
95
|
+
const providers = [
|
|
96
|
+
{
|
|
97
|
+
useFactory: (localeId) => {
|
|
98
|
+
const next = {
|
|
99
|
+
...config,
|
|
100
|
+
};
|
|
101
|
+
const defaultLocale = config.defaultLocale ?? config.supportedLocales?.at(0) ?? localeId;
|
|
102
|
+
if (next.supportedLocales &&
|
|
103
|
+
!next.supportedLocales.includes(defaultLocale)) {
|
|
104
|
+
next.supportedLocales = [...next.supportedLocales, defaultLocale];
|
|
105
|
+
}
|
|
106
|
+
return next;
|
|
107
|
+
},
|
|
108
|
+
deps: [LOCALE_ID],
|
|
109
|
+
provide: CONFIG_TOKEN,
|
|
110
|
+
},
|
|
111
|
+
];
|
|
112
|
+
const defaultLocale = config.defaultLocale ?? config.supportedLocales?.at(0);
|
|
113
|
+
if (!defaultLocale)
|
|
114
|
+
return providers;
|
|
115
|
+
providers.push({
|
|
116
|
+
provide: LOCALE_ID,
|
|
117
|
+
useValue: defaultLocale,
|
|
118
|
+
});
|
|
119
|
+
return providers;
|
|
62
120
|
}
|
|
63
121
|
function injectIntlConfig() {
|
|
64
122
|
return inject(CONFIG_TOKEN, { optional: true }) ?? undefined;
|
|
65
123
|
}
|
|
66
124
|
function injectDefaultLocale() {
|
|
67
|
-
return injectIntlConfig()?.defaultLocale ?? 'en-US';
|
|
125
|
+
return injectIntlConfig()?.defaultLocale ?? inject(LOCALE_ID) ?? 'en-US';
|
|
126
|
+
}
|
|
127
|
+
function injectSupportedLocales() {
|
|
128
|
+
return injectIntlConfig()?.supportedLocales ?? [injectDefaultLocale()];
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* @internal
|
|
132
|
+
* the actual locale signal used to store the current locale string
|
|
133
|
+
*/
|
|
134
|
+
const STORE_LOCALE = signal('en-US');
|
|
135
|
+
function injectLocaleInternal() {
|
|
136
|
+
return STORE_LOCALE;
|
|
68
137
|
}
|
|
69
138
|
class TranslationStore {
|
|
70
139
|
cache = createIntlCache();
|
|
71
140
|
config = injectIntlConfig();
|
|
72
|
-
|
|
141
|
+
loadQueue = signal([]);
|
|
142
|
+
locale;
|
|
73
143
|
defaultLocale = injectDefaultLocale();
|
|
74
144
|
translations = signal({
|
|
75
145
|
[this.defaultLocale]: {},
|
|
76
146
|
});
|
|
147
|
+
attemptedFallbackLoad = false;
|
|
148
|
+
onDemandLoaders = new Map();
|
|
77
149
|
nonMessageConfig = computed(() => ({
|
|
78
150
|
...this.config,
|
|
79
151
|
locale: this.locale(),
|
|
@@ -81,18 +153,107 @@ class TranslationStore {
|
|
|
81
153
|
messages = computed(() => this.translations()[this.locale()] ??
|
|
82
154
|
this.translations()[this.defaultLocale] ??
|
|
83
155
|
{});
|
|
156
|
+
dynamicLocaleLoader = resource({
|
|
157
|
+
request: computed(() => this.loadQueue().at(0) ?? null),
|
|
158
|
+
loader: async ({ request: newLocale, abortSignal }) => {
|
|
159
|
+
if (!newLocale)
|
|
160
|
+
return;
|
|
161
|
+
const currentTranslations = untracked(this.translations);
|
|
162
|
+
const loadPromises = [];
|
|
163
|
+
for (const [namespace, loaders] of this.onDemandLoaders.entries()) {
|
|
164
|
+
const loader = loaders[newLocale];
|
|
165
|
+
if (loader) {
|
|
166
|
+
const hasNamespaceForLocale = currentTranslations[newLocale] &&
|
|
167
|
+
Object.keys(currentTranslations[newLocale]).some((key) => key.startsWith(`${prependDelim(namespace, '').slice(0, -1)}`));
|
|
168
|
+
if (!hasNamespaceForLocale) {
|
|
169
|
+
loadPromises.push(loader()
|
|
170
|
+
.then((translation) => {
|
|
171
|
+
if (abortSignal.aborted)
|
|
172
|
+
return null;
|
|
173
|
+
return {
|
|
174
|
+
namespace: translation.namespace,
|
|
175
|
+
flat: translation.flat,
|
|
176
|
+
};
|
|
177
|
+
})
|
|
178
|
+
.catch((err) => {
|
|
179
|
+
if (isDevMode()) {
|
|
180
|
+
console.error('[Translate] Failed to load', namespace, newLocale, err);
|
|
181
|
+
}
|
|
182
|
+
return null;
|
|
183
|
+
}));
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return Promise.all(loadPromises)
|
|
188
|
+
.then((res) => res.filter((r) => r !== null))
|
|
189
|
+
.then((res) => ({
|
|
190
|
+
locales: res,
|
|
191
|
+
locale: newLocale,
|
|
192
|
+
}));
|
|
193
|
+
},
|
|
194
|
+
});
|
|
84
195
|
intl = computed(() => createIntl({
|
|
85
196
|
...this.nonMessageConfig(),
|
|
86
197
|
messages: this.messages(),
|
|
87
198
|
}, this.cache));
|
|
199
|
+
constructor() {
|
|
200
|
+
this.locale = STORE_LOCALE;
|
|
201
|
+
this.locale.set(injectDefaultLocale());
|
|
202
|
+
const paramName = this.config?.localeParamName;
|
|
203
|
+
if (paramName) {
|
|
204
|
+
const param = pathParam(paramName);
|
|
205
|
+
effect(() => {
|
|
206
|
+
const loc = param();
|
|
207
|
+
if (!loc ||
|
|
208
|
+
loc === untracked(this.locale) ||
|
|
209
|
+
untracked(this.loadQueue).includes(loc))
|
|
210
|
+
return;
|
|
211
|
+
if (this.hasLocaleLoaders(loc))
|
|
212
|
+
this.locale.set(loc);
|
|
213
|
+
else
|
|
214
|
+
this.loadQueue.update((q) => [...q, loc]);
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
effect(() => {
|
|
218
|
+
if (
|
|
219
|
+
// should never be in error state, but best to check in case something throws
|
|
220
|
+
this.dynamicLocaleLoader.error() ||
|
|
221
|
+
this.dynamicLocaleLoader.isLoading())
|
|
222
|
+
return;
|
|
223
|
+
const dynamicLocales = this.dynamicLocaleLoader.value();
|
|
224
|
+
if (!dynamicLocales)
|
|
225
|
+
return;
|
|
226
|
+
// Register loaded translations
|
|
227
|
+
for (const locale of dynamicLocales.locales) {
|
|
228
|
+
this.register(locale.namespace, {
|
|
229
|
+
[dynamicLocales.locale]: locale.flat,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
const hasTranslations = dynamicLocales.locales.length > 0 ||
|
|
233
|
+
this.translations()[dynamicLocales.locale];
|
|
234
|
+
if (hasTranslations) {
|
|
235
|
+
this.loadQueue.update((q) => q.filter((l) => l !== dynamicLocales.locale));
|
|
236
|
+
this.locale.set(dynamicLocales.locale);
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
}
|
|
88
240
|
formatMessage(key, values) {
|
|
89
|
-
const message = this.translations()[this.locale()]?.[key] ??
|
|
90
|
-
|
|
241
|
+
const message = this.translations()[this.locale()]?.[key] ??
|
|
242
|
+
this.translations()[this.defaultLocale]?.[key] ??
|
|
243
|
+
'';
|
|
244
|
+
if (!message) {
|
|
245
|
+
if (this.attemptedFallbackLoad)
|
|
246
|
+
return '';
|
|
247
|
+
this.attemptedFallbackLoad = true;
|
|
248
|
+
untracked(() => {
|
|
249
|
+
if (!this.loadQueue().includes(this.defaultLocale))
|
|
250
|
+
this.loadQueue.update((q) => [...q, this.defaultLocale]);
|
|
251
|
+
});
|
|
91
252
|
return '';
|
|
253
|
+
}
|
|
92
254
|
return this.intl().formatMessage({ id: key, defaultMessage: message }, values);
|
|
93
255
|
}
|
|
94
|
-
register(namespace, flat
|
|
95
|
-
this.locale.set(locale);
|
|
256
|
+
register(namespace, flat) {
|
|
96
257
|
this.translations.update((cur) => {
|
|
97
258
|
return Object.entries(flat).reduce((acc, [locale, translation]) => {
|
|
98
259
|
const localeTranslation = acc[locale] ?? {};
|
|
@@ -108,18 +269,311 @@ class TranslationStore {
|
|
|
108
269
|
}, { ...cur });
|
|
109
270
|
});
|
|
110
271
|
}
|
|
111
|
-
|
|
112
|
-
|
|
272
|
+
registerOnDemandLoaders(namespace, loaders) {
|
|
273
|
+
this.onDemandLoaders.set(namespace, loaders);
|
|
274
|
+
}
|
|
275
|
+
hasLocaleLoaders(locale) {
|
|
276
|
+
return Array.from(this.onDemandLoaders.values()).some((loaders) => loaders[locale]);
|
|
277
|
+
}
|
|
278
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.19", ngImport: i0, type: TranslationStore, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
279
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.19", ngImport: i0, type: TranslationStore, providedIn: 'root' });
|
|
113
280
|
}
|
|
114
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.
|
|
281
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.19", ngImport: i0, type: TranslationStore, decorators: [{
|
|
115
282
|
type: Injectable,
|
|
116
283
|
args: [{
|
|
117
284
|
providedIn: 'root',
|
|
118
285
|
}]
|
|
119
|
-
}] });
|
|
286
|
+
}], ctorParameters: () => [] });
|
|
120
287
|
function injectIntl() {
|
|
121
288
|
return inject(TranslationStore).intl;
|
|
122
289
|
}
|
|
290
|
+
/**
|
|
291
|
+
* Inject a dynamic locale signal that supports runtime language switching.
|
|
292
|
+
*
|
|
293
|
+
* @returns A writable signal with the current locale and loading state.
|
|
294
|
+
* Only allows switching to locales that have registered loaders.
|
|
295
|
+
*
|
|
296
|
+
* @example
|
|
297
|
+
* ```typescript
|
|
298
|
+
* const locale = injectDynamicLocale();
|
|
299
|
+
*
|
|
300
|
+
* // Switch language (triggers automatic translation loading)
|
|
301
|
+
* locale.set('sl-SI');
|
|
302
|
+
*
|
|
303
|
+
* // Check loading state
|
|
304
|
+
* if (locale.isLoading()) {
|
|
305
|
+
* // Show spinner
|
|
306
|
+
* }
|
|
307
|
+
* ```
|
|
308
|
+
*/
|
|
309
|
+
function injectDynamicLocale() {
|
|
310
|
+
const store = inject(TranslationStore);
|
|
311
|
+
const supportedLocales = injectIntlConfig()?.supportedLocales;
|
|
312
|
+
const source = computed(() => store.locale());
|
|
313
|
+
const inSupportedLocales = supportedLocales === undefined
|
|
314
|
+
? () => true
|
|
315
|
+
: (locale) => supportedLocales.includes(locale);
|
|
316
|
+
const set = (value) => {
|
|
317
|
+
if (value === untracked(source) ||
|
|
318
|
+
untracked(store.loadQueue).includes(value))
|
|
319
|
+
return;
|
|
320
|
+
if (!inSupportedLocales(value)) {
|
|
321
|
+
if (isDevMode())
|
|
322
|
+
console.warn(`[Translate] Locale "${value}" is not in supportedLocales, switch prevented. Available options are:`, supportedLocales);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if (isDevMode() && !store.hasLocaleLoaders(value))
|
|
326
|
+
console.warn(`[Translate] No loaders registered for locale "${value}". Switching to this locale will have no effect.`);
|
|
327
|
+
store.loadQueue.update((q) => [...q, value]);
|
|
328
|
+
};
|
|
329
|
+
source.set = set;
|
|
330
|
+
source.update = (updater) => {
|
|
331
|
+
const next = updater(untracked(source));
|
|
332
|
+
source.set(next);
|
|
333
|
+
};
|
|
334
|
+
source.asReadonly = () => source;
|
|
335
|
+
source.isLoading = store.dynamicLocaleLoader.isLoading;
|
|
336
|
+
return source;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function unwrap(value) {
|
|
340
|
+
return isSignal(value) ? value() : value;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const FORMAT_PRESETS = {
|
|
344
|
+
short: { dateStyle: 'short', timeStyle: 'short' },
|
|
345
|
+
medium: { dateStyle: 'medium', timeStyle: 'medium' },
|
|
346
|
+
long: { dateStyle: 'long', timeStyle: 'long' },
|
|
347
|
+
full: { dateStyle: 'full', timeStyle: 'full' },
|
|
348
|
+
shortDate: { dateStyle: 'short' },
|
|
349
|
+
mediumDate: { dateStyle: 'medium' },
|
|
350
|
+
longDate: { dateStyle: 'long' },
|
|
351
|
+
fullDate: { dateStyle: 'full' },
|
|
352
|
+
shortTime: { timeStyle: 'short' },
|
|
353
|
+
mediumTime: { timeStyle: 'medium' },
|
|
354
|
+
longTime: { timeStyle: 'long' },
|
|
355
|
+
fullTime: { timeStyle: 'full' },
|
|
356
|
+
};
|
|
357
|
+
function validDateOrNull(date) {
|
|
358
|
+
if (date == null)
|
|
359
|
+
return null;
|
|
360
|
+
const d = date instanceof Date ? date : new Date(date);
|
|
361
|
+
return isNaN(d.getTime()) ? null : d;
|
|
362
|
+
}
|
|
363
|
+
const cache$4 = new Map();
|
|
364
|
+
function getFormatter$4(locale, format, timeZone) {
|
|
365
|
+
const cacheKey = `${locale}|${format}|${timeZone ?? ''}`;
|
|
366
|
+
let formatter = cache$4.get(cacheKey);
|
|
367
|
+
if (!formatter) {
|
|
368
|
+
formatter = new Intl.DateTimeFormat(locale, {
|
|
369
|
+
...FORMAT_PRESETS[format],
|
|
370
|
+
timeZone,
|
|
371
|
+
});
|
|
372
|
+
cache$4.set(cacheKey, formatter);
|
|
373
|
+
}
|
|
374
|
+
return formatter;
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Format a date using the current or provided locale & timezone
|
|
378
|
+
* By default it is reactive to the global dynamic locale, works best when wrapped in a computed() if you need to react to locale changes
|
|
379
|
+
*
|
|
380
|
+
* @param date - Date to format
|
|
381
|
+
* @param opt - Options for formatting
|
|
382
|
+
* @returns Formatted date string
|
|
383
|
+
*/
|
|
384
|
+
function formatDate(date, opt) {
|
|
385
|
+
const validDate = validDateOrNull(unwrap(date));
|
|
386
|
+
if (validDate === null)
|
|
387
|
+
return '';
|
|
388
|
+
const unwrappedOpt = unwrap(opt);
|
|
389
|
+
const loc = unwrappedOpt?.locale ?? injectLocaleInternal()();
|
|
390
|
+
return getFormatter$4(loc, unwrappedOpt?.format ?? 'medium', unwrappedOpt?.tz).format(validDate);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const cache$3 = new Map();
|
|
394
|
+
function getFormatter$3(locale, type, style) {
|
|
395
|
+
const cacheKey = `${locale}|${type}|${style}`;
|
|
396
|
+
let formatter = cache$3.get(cacheKey);
|
|
397
|
+
if (!formatter) {
|
|
398
|
+
formatter = new Intl.DisplayNames(locale, {
|
|
399
|
+
type,
|
|
400
|
+
style,
|
|
401
|
+
});
|
|
402
|
+
cache$3.set(cacheKey, formatter);
|
|
403
|
+
}
|
|
404
|
+
return formatter;
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Format a display name using the current or provided locale
|
|
408
|
+
* By default it is reactive to the global dynamic locale, works best when wrapped in a computed() if you need to react to locale changes
|
|
409
|
+
*
|
|
410
|
+
* @param value - The code to format
|
|
411
|
+
* @param type - The type of display name to format
|
|
412
|
+
* @param opt - Options for formatting
|
|
413
|
+
* @returns Formatted display name string
|
|
414
|
+
*/
|
|
415
|
+
function formatDisplayName(value, type, opt) {
|
|
416
|
+
const unwrapped = unwrap(value);
|
|
417
|
+
if (!unwrapped?.trim())
|
|
418
|
+
return '';
|
|
419
|
+
const unwrappedType = unwrap(type);
|
|
420
|
+
const unwrappedOpt = unwrap(opt);
|
|
421
|
+
const locale = unwrappedOpt?.locale ?? injectLocaleInternal()();
|
|
422
|
+
return (getFormatter$3(locale, unwrappedType, unwrappedOpt?.style ?? 'long').of(unwrapped) ?? '');
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const cache$2 = new Map();
|
|
426
|
+
const EMPTY_ARRAY = [];
|
|
427
|
+
function unwrapList(value) {
|
|
428
|
+
const unwrapped = unwrap(value);
|
|
429
|
+
return Array.isArray(unwrapped) ? unwrapped : EMPTY_ARRAY;
|
|
430
|
+
}
|
|
431
|
+
function getFormatter$2(locale, type, style) {
|
|
432
|
+
const cacheKey = `${locale}|${type}|${style}`;
|
|
433
|
+
let formatter = cache$2.get(cacheKey);
|
|
434
|
+
if (!formatter) {
|
|
435
|
+
formatter = new Intl.ListFormat(locale, { type, style });
|
|
436
|
+
cache$2.set(cacheKey, formatter);
|
|
437
|
+
}
|
|
438
|
+
return formatter;
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* Format a list using the current or provided locale
|
|
442
|
+
* By default it is reactive to the global dynamic locale, works best when wrapped in a computed() if you need to react to locale changes
|
|
443
|
+
*
|
|
444
|
+
* @param value - The list to format
|
|
445
|
+
* @param opt - Options for formatting
|
|
446
|
+
* @returns Formatted list string
|
|
447
|
+
*/
|
|
448
|
+
function formatList(value, opt) {
|
|
449
|
+
const unwrapped = unwrapList(value);
|
|
450
|
+
if (unwrapped.length === 0)
|
|
451
|
+
return '';
|
|
452
|
+
const unwrappedOpt = unwrap(opt);
|
|
453
|
+
const loc = unwrappedOpt?.locale ?? injectLocaleInternal()();
|
|
454
|
+
return getFormatter$2(loc, unwrappedOpt?.type ?? 'conjunction', unwrappedOpt?.style ?? 'long').format(unwrapped);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const cache$1 = new Map();
|
|
458
|
+
function unwrapValue(value, fallbackToZero = false) {
|
|
459
|
+
const unwrapped = unwrap(value);
|
|
460
|
+
if (unwrapped === null || unwrapped === undefined || isNaN(unwrapped))
|
|
461
|
+
return fallbackToZero ? 0 : null;
|
|
462
|
+
return unwrapped;
|
|
463
|
+
}
|
|
464
|
+
function getFormatter$1(locale, minFractionDigits, maxFractionDigits, useGrouping, notation, currency, display, style) {
|
|
465
|
+
const cacheKey = `${locale}|${notation ?? 'standard'}|${minFractionDigits}|${maxFractionDigits}|${useGrouping ?? true}|${currency ?? 'none'}|${display ?? 'none'}|${style ?? 'decimal'}`;
|
|
466
|
+
let formatter = cache$1.get(cacheKey);
|
|
467
|
+
if (!formatter) {
|
|
468
|
+
formatter = new Intl.NumberFormat(locale, {
|
|
469
|
+
style,
|
|
470
|
+
notation,
|
|
471
|
+
minimumFractionDigits: minFractionDigits,
|
|
472
|
+
maximumFractionDigits: maxFractionDigits,
|
|
473
|
+
useGrouping,
|
|
474
|
+
currency,
|
|
475
|
+
currencyDisplay: display,
|
|
476
|
+
});
|
|
477
|
+
cache$1.set(cacheKey, formatter);
|
|
478
|
+
}
|
|
479
|
+
return formatter;
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* Format a number using the current or provided locale
|
|
483
|
+
* By default it is reactive to the global dynamic locale, works best when wrapped in a computed() if you need to react to locale changes
|
|
484
|
+
*
|
|
485
|
+
* @param number - Number to format
|
|
486
|
+
* @param opt - Options for formatting
|
|
487
|
+
* @returns Formatted number string
|
|
488
|
+
*/
|
|
489
|
+
function formatNumber(value, opt) {
|
|
490
|
+
const unwrappedOpt = unwrap(opt);
|
|
491
|
+
const unwrappedNumber = unwrapValue(value, unwrappedOpt?.fallbackToZero);
|
|
492
|
+
if (unwrappedNumber === null)
|
|
493
|
+
return '';
|
|
494
|
+
const loc = unwrappedOpt?.locale ?? injectLocaleInternal()();
|
|
495
|
+
return getFormatter$1(loc, unwrappedOpt?.minFractionDigits, unwrappedOpt?.maxFractionDigits, unwrappedOpt?.useGrouping ?? true, unwrappedOpt?.notation ?? 'standard').format(unwrappedNumber);
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* Format a percentage using the current or provided locale
|
|
499
|
+
* By default it is reactive to the global dynamic locale, works best when wrapped in a computed() if you need to react to locale changes
|
|
500
|
+
*
|
|
501
|
+
* @param number - Number to format
|
|
502
|
+
* @param opt - Options for formatting
|
|
503
|
+
* @returns Formatted percentage string
|
|
504
|
+
*/
|
|
505
|
+
function formatPercent(value, opt) {
|
|
506
|
+
const unwrappedOpt = unwrap(opt);
|
|
507
|
+
const unwrappedNumber = unwrapValue(value, unwrappedOpt?.fallbackToZero);
|
|
508
|
+
if (unwrappedNumber === null)
|
|
509
|
+
return '';
|
|
510
|
+
const loc = unwrappedOpt?.locale ?? injectLocaleInternal()();
|
|
511
|
+
return getFormatter$1(loc, unwrappedOpt?.minFractionDigits, unwrappedOpt?.maxFractionDigits, undefined, undefined, undefined, undefined, 'percent').format(unwrappedNumber);
|
|
512
|
+
}
|
|
513
|
+
function formatCurrency(value, currency, opt) {
|
|
514
|
+
const unwrappedOpt = unwrap(opt);
|
|
515
|
+
const unwrappedValue = unwrapValue(value, unwrappedOpt?.fallbackToZero);
|
|
516
|
+
if (unwrappedValue === null)
|
|
517
|
+
return '';
|
|
518
|
+
const loc = unwrappedOpt?.locale ?? injectLocaleInternal()();
|
|
519
|
+
return getFormatter$1(loc, undefined, undefined, undefined, undefined, unwrap(currency), unwrappedOpt?.display ?? 'symbol', 'currency').format(unwrappedValue);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const cache = new Map();
|
|
523
|
+
function getFormatter(locale, style, numeric) {
|
|
524
|
+
const cacheKey = `${locale}|${style}|${numeric}`;
|
|
525
|
+
let formatter = cache.get(cacheKey);
|
|
526
|
+
if (!formatter) {
|
|
527
|
+
formatter = new Intl.RelativeTimeFormat(locale, { style, numeric });
|
|
528
|
+
cache.set(cacheKey, formatter);
|
|
529
|
+
}
|
|
530
|
+
return formatter;
|
|
531
|
+
}
|
|
532
|
+
/**
|
|
533
|
+
* Format a relative time using the current or provided locale
|
|
534
|
+
* By default it is reactive to the global dynamic locale, works best when wrapped in a computed() if you need to react to locale changes
|
|
535
|
+
*
|
|
536
|
+
* @param value - The numeric value to use in the relative time internationalization message
|
|
537
|
+
* @param unit - The unit to use in the relative time internationalization message
|
|
538
|
+
* @param opt - Options for formatting
|
|
539
|
+
* @returns Formatted relative time string
|
|
540
|
+
*/
|
|
541
|
+
function formatRelativeTime(value, unit, opt) {
|
|
542
|
+
const unwrappedValue = unwrap(value);
|
|
543
|
+
if (unwrappedValue === null ||
|
|
544
|
+
unwrappedValue === undefined ||
|
|
545
|
+
isNaN(unwrappedValue))
|
|
546
|
+
return '';
|
|
547
|
+
const unwrappedUnit = unwrap(unit);
|
|
548
|
+
if (!unwrappedUnit)
|
|
549
|
+
return '';
|
|
550
|
+
const unwrappedOpt = unwrap(opt);
|
|
551
|
+
const loc = unwrappedOpt?.locale ?? injectLocaleInternal()();
|
|
552
|
+
return getFormatter(loc, unwrappedOpt?.style ?? 'long', unwrappedOpt?.numeric ?? 'always').format(unwrappedValue, unwrappedUnit);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function injectResolveParamLocale(snapshot) {
|
|
556
|
+
let locale = null;
|
|
557
|
+
const paramName = injectIntlConfig()?.localeParamName;
|
|
558
|
+
const routerConfig = inject(Router)['options'];
|
|
559
|
+
const alwaysInheritParams = typeof routerConfig === 'object' &&
|
|
560
|
+
!!routerConfig &&
|
|
561
|
+
routerConfig.paramsInheritanceStrategy === 'always';
|
|
562
|
+
if (paramName) {
|
|
563
|
+
locale = snapshot.paramMap.get(paramName);
|
|
564
|
+
if (!locale && !alwaysInheritParams) {
|
|
565
|
+
let currentRoute = snapshot;
|
|
566
|
+
while (currentRoute && !locale) {
|
|
567
|
+
locale = currentRoute.paramMap.get('locale');
|
|
568
|
+
currentRoute = currentRoute.parent;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
if (!locale) {
|
|
573
|
+
locale = untracked(inject(TranslationStore).locale);
|
|
574
|
+
}
|
|
575
|
+
return locale;
|
|
576
|
+
}
|
|
123
577
|
|
|
124
578
|
function createEqualsRecord(keys = []) {
|
|
125
579
|
let keyMatcher;
|
|
@@ -149,7 +603,7 @@ function addSignalFn(fn, store) {
|
|
|
149
603
|
const variables = args[0];
|
|
150
604
|
const stringKey = key;
|
|
151
605
|
const flatPath = replaceWithDelim(stringKey);
|
|
152
|
-
const varsFn = variables
|
|
606
|
+
const varsFn = variables ?? (() => undefined);
|
|
153
607
|
const varsSignal = isSignal(varsFn)
|
|
154
608
|
? varsFn
|
|
155
609
|
: computed(varsFn, {
|
|
@@ -173,9 +627,11 @@ function registerNamespace(defaultTranslation, other) {
|
|
|
173
627
|
return addSignalFn(createT(store), store);
|
|
174
628
|
};
|
|
175
629
|
let defaultTranslationLoaded = false;
|
|
176
|
-
const resolver = async () => {
|
|
177
|
-
const locale = inject(LOCALE_ID);
|
|
630
|
+
const resolver = async (snapshot) => {
|
|
178
631
|
const store = inject(TranslationStore);
|
|
632
|
+
const locale = injectResolveParamLocale(snapshot);
|
|
633
|
+
const defaultLocale = injectDefaultLocale();
|
|
634
|
+
const shouldPreloadDefault = injectIntlConfig()?.preloadDefaultLocale ?? false;
|
|
179
635
|
const tPromise = other[locale];
|
|
180
636
|
const promise = tPromise ?? defaultTranslation;
|
|
181
637
|
if (!promise && isDevMode()) {
|
|
@@ -184,25 +640,110 @@ function registerNamespace(defaultTranslation, other) {
|
|
|
184
640
|
if (promise === defaultTranslation && defaultTranslationLoaded)
|
|
185
641
|
return;
|
|
186
642
|
try {
|
|
187
|
-
const
|
|
188
|
-
if (
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
643
|
+
const promises = [promise()];
|
|
644
|
+
if (shouldPreloadDefault &&
|
|
645
|
+
!defaultTranslationLoaded &&
|
|
646
|
+
promise !== defaultTranslation)
|
|
647
|
+
promises.push(defaultTranslation());
|
|
648
|
+
const translations = await Promise.allSettled(promises);
|
|
649
|
+
const fullfilled = translations.map((t) => t.status === 'fulfilled' ? t.value : null);
|
|
650
|
+
if (fullfilled.at(0) === null && fullfilled.at(1) === null)
|
|
651
|
+
throw new Error('Failed to load translations');
|
|
652
|
+
const [t, defaultT] = fullfilled;
|
|
653
|
+
const ns = t?.namespace ?? defaultT?.namespace;
|
|
654
|
+
if (!ns)
|
|
655
|
+
throw new Error('No namespace found in translation');
|
|
656
|
+
if (isDevMode() && t && t.locale !== locale && t.locale)
|
|
657
|
+
console.warn(`Expected locale to be ${locale} but got ${t.locale}`);
|
|
658
|
+
store.registerOnDemandLoaders(ns, {
|
|
659
|
+
...other,
|
|
660
|
+
[defaultLocale]: defaultTranslation,
|
|
661
|
+
});
|
|
662
|
+
const toRegister = {};
|
|
663
|
+
if (t)
|
|
664
|
+
toRegister[locale] = t.flat;
|
|
665
|
+
if (defaultT)
|
|
666
|
+
toRegister[defaultLocale] = defaultT.flat;
|
|
667
|
+
store.register(ns, toRegister);
|
|
668
|
+
if (promise === defaultTranslation || defaultT)
|
|
198
669
|
defaultTranslationLoaded = true;
|
|
670
|
+
}
|
|
671
|
+
catch {
|
|
672
|
+
if (isDevMode()) {
|
|
673
|
+
console.warn(`Failed to load translation for locale: ${locale}`);
|
|
199
674
|
}
|
|
200
675
|
}
|
|
676
|
+
finally {
|
|
677
|
+
if (locale !== untracked(store.locale))
|
|
678
|
+
store.locale.set(locale);
|
|
679
|
+
}
|
|
680
|
+
};
|
|
681
|
+
return {
|
|
682
|
+
injectNamespaceT: injectT,
|
|
683
|
+
resolveNamespaceTranslation: resolver,
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
/**
|
|
687
|
+
* Registers a type-unsafe namespace, meant for remote loading of unknown key-value pairs using mmstack/translate infrastructure
|
|
688
|
+
* The resolver & t function work the same as they would with typed namespaces, but without type safety
|
|
689
|
+
*/
|
|
690
|
+
function registerRemoteNamespace(ns, defaultTranslation, other) {
|
|
691
|
+
const injectT = () => {
|
|
692
|
+
const store = inject(TranslationStore);
|
|
693
|
+
return addSignalFn(createT(store), store);
|
|
694
|
+
};
|
|
695
|
+
let defaultTranslationLoaded = false;
|
|
696
|
+
const resolver = async (snapshot) => {
|
|
697
|
+
const store = inject(TranslationStore);
|
|
698
|
+
const locale = injectResolveParamLocale(snapshot);
|
|
699
|
+
const defaultLocale = injectDefaultLocale();
|
|
700
|
+
const shouldPreloadDefault = injectIntlConfig()?.preloadDefaultLocale ?? false;
|
|
701
|
+
const tPromise = other[locale];
|
|
702
|
+
const promise = tPromise ?? defaultTranslation;
|
|
703
|
+
if (!promise && isDevMode()) {
|
|
704
|
+
return console.warn(`No translation found for locale: ${locale}`);
|
|
705
|
+
}
|
|
706
|
+
if (promise === defaultTranslation && defaultTranslationLoaded)
|
|
707
|
+
return;
|
|
708
|
+
try {
|
|
709
|
+
const promises = [promise()];
|
|
710
|
+
if (shouldPreloadDefault &&
|
|
711
|
+
!defaultTranslationLoaded &&
|
|
712
|
+
promise !== defaultTranslation)
|
|
713
|
+
promises.push(defaultTranslation());
|
|
714
|
+
const translations = await Promise.allSettled(promises);
|
|
715
|
+
const fullfilled = translations.map((t) => t.status === 'fulfilled' ? t.value : null);
|
|
716
|
+
if (fullfilled.at(0) === null && fullfilled.at(1) === null)
|
|
717
|
+
throw new Error('Failed to load translations');
|
|
718
|
+
const [baseT, baseDefaultT] = fullfilled;
|
|
719
|
+
const t = baseT ? compileTranslation(baseT, ns, locale) : null;
|
|
720
|
+
const defaultT = baseDefaultT
|
|
721
|
+
? compileTranslation(baseDefaultT, ns, defaultLocale)
|
|
722
|
+
: null;
|
|
723
|
+
if (isDevMode() && t && t.locale !== locale && t.locale)
|
|
724
|
+
console.warn(`Expected locale to be ${locale} but got ${t.locale}`);
|
|
725
|
+
store.registerOnDemandLoaders(ns, {
|
|
726
|
+
...other,
|
|
727
|
+
[defaultLocale]: defaultTranslation,
|
|
728
|
+
});
|
|
729
|
+
const toRegister = {};
|
|
730
|
+
if (t)
|
|
731
|
+
toRegister[locale] = t.flat;
|
|
732
|
+
if (defaultT)
|
|
733
|
+
toRegister[defaultLocale] = defaultT.flat;
|
|
734
|
+
store.register(ns, toRegister);
|
|
735
|
+
if (promise === defaultTranslation || defaultT)
|
|
736
|
+
defaultTranslationLoaded = true;
|
|
737
|
+
}
|
|
201
738
|
catch {
|
|
202
739
|
if (isDevMode()) {
|
|
203
740
|
console.warn(`Failed to load translation for locale: ${locale}`);
|
|
204
741
|
}
|
|
205
742
|
}
|
|
743
|
+
finally {
|
|
744
|
+
if (locale !== untracked(store.locale))
|
|
745
|
+
store.locale.set(locale);
|
|
746
|
+
}
|
|
206
747
|
};
|
|
207
748
|
return {
|
|
208
749
|
injectNamespaceT: injectT,
|
|
@@ -210,7 +751,92 @@ function registerNamespace(defaultTranslation, other) {
|
|
|
210
751
|
};
|
|
211
752
|
}
|
|
212
753
|
|
|
213
|
-
|
|
754
|
+
/**
|
|
755
|
+
* Guard that validates the locale parameter against supported locales.
|
|
756
|
+
* Redirects to default locale if the locale is invalid.
|
|
757
|
+
*
|
|
758
|
+
* @param prefixSegments Optional array of path segments preceding the locale segment.
|
|
759
|
+
* if (you wanted to match /app/:locale/... you would pass ['app'] here) & the function would match the second parameter + redirect accordingly
|
|
760
|
+
*
|
|
761
|
+
* @example
|
|
762
|
+
* ```typescript
|
|
763
|
+
* {
|
|
764
|
+
* path: ':locale',
|
|
765
|
+
* canMatch: [canMatchLocale()],
|
|
766
|
+
* children: [...]
|
|
767
|
+
* }
|
|
768
|
+
* ```
|
|
769
|
+
*/
|
|
770
|
+
function canMatchLocale(prefixSegments = []) {
|
|
771
|
+
return (_route, segments) => {
|
|
772
|
+
const supportedLocales = injectSupportedLocales();
|
|
773
|
+
const locale = segments.at(prefixSegments.length)?.path;
|
|
774
|
+
if (!locale || !supportedLocales.includes(locale))
|
|
775
|
+
return inject(Router).createUrlTree([
|
|
776
|
+
...prefixSegments,
|
|
777
|
+
injectDefaultLocale(),
|
|
778
|
+
]);
|
|
779
|
+
return true;
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* Provides an isolated mock `TranslationStore` usable across testing modules that use components
|
|
785
|
+
* depending on `@mmstack/translate` APIs (like `Translate` directive, `Translator` pipe, or `injectNamespaceT`).
|
|
786
|
+
*
|
|
787
|
+
* This provider intercepts all translation logic, bypassing chunk loaders and Intl.
|
|
788
|
+
* When a custom configuration isn't provided, formatMessage simply echoes the translation key, using dots `.`.
|
|
789
|
+
*
|
|
790
|
+
* ### Usage
|
|
791
|
+
* ```typescript
|
|
792
|
+
* TestBed.configureTestingModule({
|
|
793
|
+
* providers: [provideMockTranslations()]
|
|
794
|
+
* });
|
|
795
|
+
* ```
|
|
796
|
+
*/
|
|
797
|
+
function provideMockTranslations(options) {
|
|
798
|
+
// We compile the mock strings to flat delimiters just like the internal compile module.
|
|
799
|
+
const mappedMocks = {};
|
|
800
|
+
if (options?.translations) {
|
|
801
|
+
for (const [namespace, translationObj] of Object.entries(options.translations)) {
|
|
802
|
+
const compiled = compileTranslation(translationObj, namespace);
|
|
803
|
+
for (const [key, val] of Object.entries(compiled.flat)) {
|
|
804
|
+
// e.g. from 'home::MMT_DELIM::title'
|
|
805
|
+
const fullKey = `${namespace}::MMT_DELIM::${key}`;
|
|
806
|
+
mappedMocks[fullKey] = val;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
return [
|
|
811
|
+
{
|
|
812
|
+
provide: TranslationStore,
|
|
813
|
+
useValue: {
|
|
814
|
+
locale: signal('en-US'),
|
|
815
|
+
formatMessage: (key) => {
|
|
816
|
+
if (mappedMocks[key])
|
|
817
|
+
return mappedMocks[key];
|
|
818
|
+
// Fallback to echoing the key back in dot notation (more readable for unit assertions).
|
|
819
|
+
return key.replaceAll('::MMT_DELIM::', '.');
|
|
820
|
+
},
|
|
821
|
+
hasLocaleLoaders: () => false,
|
|
822
|
+
register: () => {
|
|
823
|
+
// noop
|
|
824
|
+
},
|
|
825
|
+
registerOnDemandLoaders: () => {
|
|
826
|
+
// noop
|
|
827
|
+
},
|
|
828
|
+
dynamicLocaleLoader: {
|
|
829
|
+
isLoading: signal(false),
|
|
830
|
+
value: signal(null),
|
|
831
|
+
error: signal(null),
|
|
832
|
+
},
|
|
833
|
+
loadQueue: signal([]),
|
|
834
|
+
},
|
|
835
|
+
},
|
|
836
|
+
];
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
class Translate {
|
|
214
840
|
t = createT(inject(TranslationStore));
|
|
215
841
|
translate = input.required();
|
|
216
842
|
constructor() {
|
|
@@ -238,19 +864,32 @@ class BaseTranslateDirective {
|
|
|
238
864
|
const translation = computed(() => this.t(key(), args()));
|
|
239
865
|
const renderer = inject(Renderer2);
|
|
240
866
|
const el = inject(ElementRef);
|
|
241
|
-
|
|
867
|
+
afterRenderEffect({
|
|
868
|
+
write: () => {
|
|
869
|
+
renderer.setProperty(el.nativeElement, 'textContent', translation());
|
|
870
|
+
},
|
|
871
|
+
});
|
|
242
872
|
}
|
|
243
|
-
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.
|
|
244
|
-
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "19.2.
|
|
873
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.19", ngImport: i0, type: Translate, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
874
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "19.2.19", type: Translate, isStandalone: true, inputs: { translate: { classPropertyName: "translate", publicName: "translate", isSignal: true, isRequired: true, transformFunction: null } }, ngImport: i0 });
|
|
245
875
|
}
|
|
246
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.
|
|
876
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.19", ngImport: i0, type: Translate, decorators: [{
|
|
247
877
|
type: Directive
|
|
248
878
|
}], ctorParameters: () => [] });
|
|
249
879
|
|
|
250
|
-
class
|
|
251
|
-
|
|
880
|
+
class Translator {
|
|
881
|
+
store = inject(TranslationStore);
|
|
882
|
+
t = createT(this.store);
|
|
883
|
+
constructor() {
|
|
884
|
+
const cdr = inject(ChangeDetectorRef);
|
|
885
|
+
effect(() => {
|
|
886
|
+
this.store.locale();
|
|
887
|
+
cdr.markForCheck();
|
|
888
|
+
});
|
|
889
|
+
}
|
|
252
890
|
transform(key, ...args) {
|
|
253
|
-
|
|
891
|
+
const actualArgs = args.filter((a) => typeof a === 'object');
|
|
892
|
+
return this.t(key, ...actualArgs);
|
|
254
893
|
}
|
|
255
894
|
}
|
|
256
895
|
|
|
@@ -258,5 +897,5 @@ class BaseTranslatePipe {
|
|
|
258
897
|
* Generated bundle index. Do not edit.
|
|
259
898
|
*/
|
|
260
899
|
|
|
261
|
-
export {
|
|
900
|
+
export { Translate, Translator, canMatchLocale, compileTranslation, createNamespace, formatCurrency, formatDate, formatDisplayName, formatList, formatNumber, formatPercent, formatRelativeTime, injectDynamicLocale, injectIntl, injectResolveParamLocale, injectSupportedLocales, provideIntlConfig, provideMockTranslations, registerNamespace, registerRemoteNamespace };
|
|
262
901
|
//# sourceMappingURL=mmstack-translate.mjs.map
|