@mmstack/translate 20.5.11 β 20.5.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +298 -181
- package/fesm2022/mmstack-translate.mjs +484 -96
- package/fesm2022/mmstack-translate.mjs.map +1 -1
- package/index.d.ts +316 -71
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,13 +6,7 @@
|
|
|
6
6
|
[](https://github.com/mihajm/mmstack/blob/master/LICENSE)
|
|
7
7
|
[](CONTRIBUTING.md)
|
|
8
8
|
|
|
9
|
-
`@mmstack/translate` is an opinionated internationalization (i18n) library for Angular applications
|
|
10
|
-
|
|
11
|
-
1. **Maximum Type Safety:** Catch errors related to missing keys or incorrect/missing parameters at compile time.
|
|
12
|
-
2. **Flexible Build Process:** Works as a traditional multi-build solution (like `@angular/localize`) **OR** as a single-build runtime solution.
|
|
13
|
-
3. **Scalable Modularity:** Organize translations into **namespaces** (typically aligned with feature libraries) and load them on demand.
|
|
14
|
-
|
|
15
|
-
It uses the robust **FormatJS** Intl runtime (`@formatjs/intl`) for ICU message formatting, and integrates with Angular's dependency injection and routing.
|
|
9
|
+
`@mmstack/translate` is an opinionated internationalization (i18n) library for Angular applications. It uses the **FormatJS** Intl runtime (`@formatjs/intl`) for ICU message formatting and integrates with Angular's dependency injection, routing, and signals.
|
|
16
10
|
|
|
17
11
|
## Features
|
|
18
12
|
|
|
@@ -32,24 +26,6 @@ It uses the robust **FormatJS** Intl runtime (`@formatjs/intl`) for ICU message
|
|
|
32
26
|
- π οΈ **Template Helpers:** Includes abstract `Translator` pipe and `Translate` directive for easy, type-safe templating.
|
|
33
27
|
- π’ **Reactive Formatters:** First-class, Intl-based, locale-aware formatters for dates, numbers, currencies, percentages, lists, and relative time β all automatically reactive to locale changes via signals, no zone or common/locale dependency required.
|
|
34
28
|
|
|
35
|
-
### Comparison
|
|
36
|
-
|
|
37
|
-
While Angular offers excellent i18n solutions like `@angular/localize` and `@jsverse/transloco`, `@mmstack/translate` aims to fill a specific niche by supporting **both** traditional multi-build and modern single-build approaches with a typesafe & modular approach, perfect for nx-based environments.
|
|
38
|
-
|
|
39
|
-
| Feature | `@mmstack/translate` | `@angular/localize` | `@jsverse/transloco` | `ngx-translate` |
|
|
40
|
-
| :----------------------- | :----------------------------------: | :---------------------------: | :--------------------------------------------------------------------------------------------------: | :-------------------------: |
|
|
41
|
-
| **Build Process** | β
Single or Multi-Build | β Multi-Build (Typical) | β
Single Build | β
Single Build |
|
|
42
|
-
| **Translation Timing** | Runtime or Build Time | Compile Time | Runtime | Runtime |
|
|
43
|
-
| **Type Safety (Keys)** | β
Strong (Inferred from structure) | π‘ via extraction | π‘ Tooling/TS Files | π‘ OK Manual/Tooling |
|
|
44
|
-
| **Type Safety (Params)** | β
Strong (Inferred from ICU) | β None | π‘ Manual | π‘ Manual |
|
|
45
|
-
| **Locale Switching** | β
Dynamic (Runtime) or Page refresh | π Page Refresh Required | β
Dynamic (Runtime) | β
Dynamic (Runtime) |
|
|
46
|
-
| **Lazy Loading** | β
Built-in (Namespaces/Resolvers) | N/A (Compile Time) | β
Built-in (Scopes) | β
Yes (Custom Loaders) |
|
|
47
|
-
| **Namespacing/Scopes** | β
Built-in | β None | β
Built-in (Scopes) | π‘ Manual (File Structure) |
|
|
48
|
-
| **ICU Support** | β
Subset (via FormatJS Runtime) | β
Yes (Compile Time) | β
Yes (Runtime Intl/Plugins) | π‘ Via Extensions |
|
|
49
|
-
| **Signal Integration** | β
Great (fully reactive) | N/A | β
Good (`translateSignal()`, `activeLang` signal) | β Minimal/NoneΒΉ |
|
|
50
|
-
| **Reactive Formatters** | β
Built-in Intl integration | π‘ Angular pipes (zone-based) | β
@jsverse/transloco-locale(`transloco-locale`Β²: date/number/currency/percent, not signal-reactive) | β None (use Angular pipes) |
|
|
51
|
-
| **Maturity / Community** | π‘ Less mature, but battle tested | Core Angular | β
Mature / Active | β
Mature |
|
|
52
|
-
|
|
53
29
|
## Installation
|
|
54
30
|
|
|
55
31
|
Install the library & its peer dependency, `@formatjs/intl`.
|
|
@@ -58,6 +34,20 @@ Install the library & its peer dependency, `@formatjs/intl`.
|
|
|
58
34
|
npm install @mmstack/translate @formatjs/intl
|
|
59
35
|
```
|
|
60
36
|
|
|
37
|
+
## Table of contents
|
|
38
|
+
|
|
39
|
+
- [Configuration](#configuration) β multi-build (default) or single-build with `provideIntlConfig`
|
|
40
|
+
- [Usage](#usage) β defining namespaces, registering them, reading translations
|
|
41
|
+
- [Example configurations](#example-configurations) β full configs for the two most common scenarios
|
|
42
|
+
- [Helper functions](#helper-functions) β `injectDefaultLocale`, `injectIntl`, `injectDynamicLocale`, `canMatchLocale`
|
|
43
|
+
- [Formatters](#formatters) β reactive `Intl.*` wrappers for date, number, currency, percent, list, relative time, display name
|
|
44
|
+
- [Remote / unsafe namespaces](#remote--unsafe-namespaces) β for translations whose keys aren't known at build time
|
|
45
|
+
- [Escape hatches](#escape-hatches) β `withParams`, `injectAddTranslations`, `injectUnsafeT`
|
|
46
|
+
- [Testing](#testing) β `provideMockTranslations`
|
|
47
|
+
- [Architecture & performance](#advanced-architecture--performance)
|
|
48
|
+
- [Migration from other libraries](#migration-from-other-libraries)
|
|
49
|
+
- [Alternatives & comparison](#alternatives--comparison)
|
|
50
|
+
|
|
61
51
|
## Configuration
|
|
62
52
|
|
|
63
53
|
### Default: Multi-Build Scenario (like @angular/localize)
|
|
@@ -101,20 +91,11 @@ export const appConfig: ApplicationConfig = {
|
|
|
101
91
|
};
|
|
102
92
|
```
|
|
103
93
|
|
|
104
|
-
**
|
|
105
|
-
|
|
106
|
-
```typescript
|
|
107
|
-
provideIntlConfig({
|
|
108
|
-
defaultLocale: 'en-US',
|
|
109
|
-
supportedLocales: ['en-US', 'sl-SI', 'de-DE'],
|
|
94
|
+
**Additional options:**
|
|
110
95
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
// Preload default locale for synchronous fallback (rarely needed)
|
|
115
|
-
preloadDefaultLocale: true,
|
|
116
|
-
});
|
|
117
|
-
```
|
|
96
|
+
- **`localeParamName`** β drive the active locale from a route parameter. See [Route-based locale detection](#4-optional-route-based-locale-detection) and [Scenario A](#scenario-a-route-based-locale).
|
|
97
|
+
- **`localeStorage`** β persist the user-selected locale across reloads via a `read` / `write` adapter. See [Dynamic language switching](#5-optional-dynamic-language-switching) and [Scenario B](#scenario-b-dynamic-locale-with-localstorage-persistence). Mutually exclusive with `localeParamName`.
|
|
98
|
+
- **`preloadDefaultLocale: true`** β eagerly load the default-locale bundle so it's available as a synchronous fallback. Rarely needed.
|
|
118
99
|
|
|
119
100
|
## Usage
|
|
120
101
|
|
|
@@ -178,11 +159,11 @@ Use `registerNamespace` to prepare your namespace definition and obtain the `inj
|
|
|
178
159
|
import { registerNamespace } from '@mmstack/translate';
|
|
179
160
|
|
|
180
161
|
const r = registerNamespace(
|
|
181
|
-
// Default locale
|
|
182
|
-
() => import('./quote.namespace')
|
|
162
|
+
// Default locale (also acts as the fallback).
|
|
163
|
+
() => import('./quote.namespace'),
|
|
183
164
|
{
|
|
184
|
-
//
|
|
185
|
-
'sl-SI': () => import('./quote-sl.translation')
|
|
165
|
+
// Other locales β each value is a `() => Promise<...>` factory.
|
|
166
|
+
'sl-SI': () => import('./quote-sl.translation'),
|
|
186
167
|
// Add more locales as needed...
|
|
187
168
|
},
|
|
188
169
|
);
|
|
@@ -191,6 +172,16 @@ export const injectQuoteT = r.injectNamespaceT;
|
|
|
191
172
|
export const resolveQuoteTranslations = r.resolveNamespaceTranslation;
|
|
192
173
|
```
|
|
193
174
|
|
|
175
|
+
Each loader can return either a `CompiledTranslation` directly, or an ES module exposing one as `default` or as a named `translation` export. So all three of these are equivalent:
|
|
176
|
+
|
|
177
|
+
```typescript
|
|
178
|
+
() => import('./quote.namespace'), // ES module with `export default`
|
|
179
|
+
() => import('./quote.namespace').then((m) => m.default), // explicit unwrap (still supported)
|
|
180
|
+
() => import('./quote.namespace').then((m) => m.translation), // for files using `export const translation`
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Bare dynamic imports are the most ergonomic; the explicit forms continue to work and can be useful when a single module re-exports several namespaces.
|
|
184
|
+
|
|
194
185
|
**Add the resolver to your routes:**
|
|
195
186
|
|
|
196
187
|
```typescript
|
|
@@ -291,139 +282,48 @@ export class QuoteComponent {
|
|
|
291
282
|
|
|
292
283
|
### 4. [OPTIONAL] Route-Based Locale Detection
|
|
293
284
|
|
|
294
|
-
For applications with locale-based routing (e.g
|
|
285
|
+
For applications with locale-based routing (e.g. `/en-US/quotes`, `/sl-SI/quotes`), the library can detect and switch locales automatically:
|
|
295
286
|
|
|
296
|
-
|
|
287
|
+
- Set `localeParamName` in `provideIntlConfig({ ... })` β the store will react to that route param.
|
|
288
|
+
- Wire `canMatchLocale()` as a `canMatch` guard on the route that owns the locale segment β it validates against `supportedLocales` and redirects invalid locales to the default.
|
|
297
289
|
|
|
298
|
-
|
|
299
|
-
// app.config.ts
|
|
300
|
-
import { provideIntlConfig } from '@mmstack/translate';
|
|
290
|
+
See [Scenario A](#scenario-a-route-based-locale) for a complete end-to-end config.
|
|
301
291
|
|
|
302
|
-
|
|
303
|
-
providers: [
|
|
304
|
-
provideIntlConfig({
|
|
305
|
-
defaultLocale: 'en-US',
|
|
306
|
-
supportedLocales: ['en-US', 'sl-SI', 'de-DE'],
|
|
307
|
-
localeParamName: 'locale', // Track this route parameter automatically
|
|
308
|
-
}),
|
|
309
|
-
],
|
|
310
|
-
};
|
|
311
|
-
```
|
|
312
|
-
|
|
313
|
-
**Step 2: Add route guard for validation**
|
|
314
|
-
|
|
315
|
-
```typescript
|
|
316
|
-
// app.routes.ts
|
|
317
|
-
import { Routes } from '@angular/router';
|
|
318
|
-
import { canMatchLocale } from '@mmstack/translate';
|
|
319
|
-
|
|
320
|
-
export const routes: Routes = [
|
|
321
|
-
{
|
|
322
|
-
path: ':locale',
|
|
323
|
-
canMatch: [canMatchLocale()], // Validates & redirects invalid locales
|
|
324
|
-
children: [
|
|
325
|
-
{
|
|
326
|
-
path: 'quotes',
|
|
327
|
-
loadChildren: () =>
|
|
328
|
-
import('./quote/quote.routes').then((m) => m.QUOTE_ROUTES),
|
|
329
|
-
},
|
|
330
|
-
// ... other routes
|
|
331
|
-
],
|
|
332
|
-
},
|
|
333
|
-
];
|
|
334
|
-
```
|
|
335
|
-
|
|
336
|
-
**That's it!** The library will:
|
|
337
|
-
|
|
338
|
-
- Detect locale changes from route parameters
|
|
339
|
-
- Load translations on demand for the new locale
|
|
340
|
-
- Update all translation outputs reactively
|
|
341
|
-
- Redirect invalid locales to the default
|
|
342
|
-
|
|
343
|
-
**With prefix segments:**
|
|
344
|
-
|
|
345
|
-
If your locale parameter isn't the first segment (e.g., `/app/:locale/...`):
|
|
292
|
+
**Locale parameter not in the first position?** Pass the leading static segments to `canMatchLocale`:
|
|
346
293
|
|
|
347
294
|
```typescript
|
|
348
295
|
{
|
|
349
296
|
path: 'app/:locale',
|
|
350
|
-
canMatch: [canMatchLocale(['app'])], //
|
|
297
|
+
canMatch: [canMatchLocale(['app'])], // matches the segment after 'app'
|
|
351
298
|
children: [...]
|
|
352
299
|
}
|
|
353
300
|
```
|
|
354
301
|
|
|
355
302
|
### 5. [OPTIONAL] Dynamic Language Switching
|
|
356
303
|
|
|
357
|
-
For
|
|
304
|
+
For runtime language switching without page refreshes (e.g. a language picker in the header), `injectDynamicLocale()` returns a `WritableSignal<string>` with an attached `isLoading: Signal<boolean>`. Setting it triggers automatic loading of any missing namespace translations for the new locale; setting it to a value not in `supportedLocales` is a no-op (with a dev-mode warning).
|
|
358
305
|
|
|
359
306
|
```typescript
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
@Component({
|
|
364
|
-
selector: 'app-language-switcher',
|
|
365
|
-
template: `
|
|
366
|
-
<select [value]="locale()" (change)="changeLanguage($event)">
|
|
367
|
-
<option value="en-US">English</option>
|
|
368
|
-
<option value="sl-SI">SlovenΕ‘Δina</option>
|
|
369
|
-
<option value="de-DE">Deutsch</option>
|
|
370
|
-
</select>
|
|
371
|
-
|
|
372
|
-
@if (locale.isLoading()) {
|
|
373
|
-
<div class="spinner">Loading translations...</div>
|
|
374
|
-
}
|
|
375
|
-
`,
|
|
376
|
-
})
|
|
377
|
-
export class LanguageSwitcherComponent {
|
|
378
|
-
protected readonly locale = injectDynamicLocale();
|
|
379
|
-
|
|
380
|
-
changeLanguage(event: Event) {
|
|
381
|
-
const target = event.target as HTMLSelectElement;
|
|
382
|
-
this.locale.set(target.value); // Automatically loads missing translations
|
|
383
|
-
}
|
|
384
|
-
}
|
|
307
|
+
const locale = injectDynamicLocale();
|
|
308
|
+
locale.set('sl-SI'); // missing translations load automatically
|
|
309
|
+
if (locale.isLoading()) { /* show a spinner */ }
|
|
385
310
|
```
|
|
386
311
|
|
|
387
|
-
|
|
312
|
+
Pair with `localeStorage` in `provideIntlConfig({ ... })` to persist the user's choice across reloads β `read()` runs once on init, `write()` fires on every successful change. Stored values are validated against `supportedLocales`; errors from `read()`/`write()` are swallowed (and dev-mode logged) so a misbehaving backend can't break the app. `localeStorage` is mutually exclusive with `localeParamName` at the type level.
|
|
388
313
|
|
|
389
|
-
-
|
|
390
|
-
- Automatically loads missing namespace translations
|
|
391
|
-
- Provides `isLoading()` signal for UI feedback
|
|
392
|
-
- Works with route-based locales
|
|
314
|
+
See [Scenario B](#scenario-b-dynamic-locale-with-localstorage-persistence) for a complete app config plus a language-switcher component.
|
|
393
315
|
|
|
394
|
-
**
|
|
395
|
-
|
|
396
|
-
Due to Angular's memoization, pure pipes don't automatically react to locale changes. Solutions:
|
|
316
|
+
**Note for pure pipes:** Angular memoizes pure pipes by input identity, so they don't naturally re-evaluate when only the store's locale signal changes. Two ways out:
|
|
397
317
|
|
|
398
318
|
```typescript
|
|
399
|
-
//
|
|
319
|
+
// Recommended: pass locale as a pipe argument so the input identity changes.
|
|
400
320
|
{{ 'common.yes' | translate : locale() }}
|
|
401
321
|
|
|
402
|
-
//
|
|
403
|
-
@Pipe({
|
|
404
|
-
name: 'translate',
|
|
405
|
-
pure: false,
|
|
406
|
-
})
|
|
322
|
+
// Alternative: opt the pipe out of memoization (slower; re-runs every CD cycle).
|
|
323
|
+
@Pipe({ name: 'translate', pure: false })
|
|
407
324
|
export class QuoteTranslator extends Translator<QuoteLocale> {}
|
|
408
325
|
```
|
|
409
326
|
|
|
410
|
-
**Persisting the selected locale:**
|
|
411
|
-
|
|
412
|
-
If you want the user's last selected locale to survive page reloads, pass a `localeStorage` adapter to `provideIntlConfig()`. The library calls `read()` once on init to restore the previous selection, and `write()` whenever the active locale changes β you decide where it lives (localStorage, cookies, IndexedDB-with-sync-wrapper, etc.).
|
|
413
|
-
|
|
414
|
-
```typescript
|
|
415
|
-
provideIntlConfig({
|
|
416
|
-
defaultLocale: 'en-US',
|
|
417
|
-
supportedLocales: ['en-US', 'sl-SI', 'de-DE'],
|
|
418
|
-
localeStorage: {
|
|
419
|
-
read: () => localStorage.getItem('locale'),
|
|
420
|
-
write: (locale) => localStorage.setItem('locale', locale),
|
|
421
|
-
},
|
|
422
|
-
});
|
|
423
|
-
```
|
|
424
|
-
|
|
425
|
-
Stored values are validated against `supportedLocales` before being applied β anything unrecognized is ignored and the default applies. Errors thrown from `read()` / `write()` are swallowed (and logged in dev mode) so a misbehaving storage backend can't break the app. `localeStorage` is mutually exclusive with `localeParamName` at the type level β when the URL is the source of truth, persisting separately would just fight it.
|
|
426
|
-
|
|
427
327
|
### 6. [OPTIONAL] Creating a Shared/Common Namespace
|
|
428
328
|
|
|
429
329
|
A shared namespace allows you to define common translations (e.g., 'Save', 'Cancel', 'Yes', 'No') once and use them type-safely across all other namespaces.
|
|
@@ -457,9 +357,9 @@ export const createAppNamespace = ns.createMergedNamespace;
|
|
|
457
357
|
import { registerNamespace } from '@mmstack/translate';
|
|
458
358
|
|
|
459
359
|
const r = registerNamespace(
|
|
460
|
-
() => import('./common.namespace')
|
|
360
|
+
() => import('./common.namespace'),
|
|
461
361
|
{
|
|
462
|
-
'sl-SI': () => import('./common-sl.translation')
|
|
362
|
+
'sl-SI': () => import('./common-sl.translation'),
|
|
463
363
|
},
|
|
464
364
|
);
|
|
465
365
|
|
|
@@ -513,6 +413,111 @@ export class QuoteComponent {
|
|
|
513
413
|
}
|
|
514
414
|
```
|
|
515
415
|
|
|
416
|
+
## Example configurations
|
|
417
|
+
|
|
418
|
+
Two end-to-end app configs covering the most common single-build scenarios. Copy either as a starting point.
|
|
419
|
+
|
|
420
|
+
### Scenario A: Route-based locale
|
|
421
|
+
|
|
422
|
+
The locale lives in the URL (`/en-US/quotes`, `/sl-SI/quotes`), the router guard validates it, and the resolver picks it up automatically. Best when you want shareable, SEO-friendly locale URLs.
|
|
423
|
+
|
|
424
|
+
```typescript
|
|
425
|
+
// app.config.ts
|
|
426
|
+
import { ApplicationConfig, LOCALE_ID } from '@angular/core';
|
|
427
|
+
import { provideRouter } from '@angular/router';
|
|
428
|
+
import { provideIntlConfig } from '@mmstack/translate';
|
|
429
|
+
import { routes } from './app.routes';
|
|
430
|
+
|
|
431
|
+
export const appConfig: ApplicationConfig = {
|
|
432
|
+
providers: [
|
|
433
|
+
{ provide: LOCALE_ID, useValue: 'en-US' }, // initial / fallback locale
|
|
434
|
+
provideIntlConfig({
|
|
435
|
+
defaultLocale: 'en-US',
|
|
436
|
+
supportedLocales: ['en-US', 'sl-SI', 'de-DE'],
|
|
437
|
+
localeParamName: 'locale', // store reacts to this route param
|
|
438
|
+
}),
|
|
439
|
+
provideRouter(routes),
|
|
440
|
+
],
|
|
441
|
+
};
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
```typescript
|
|
445
|
+
// app.routes.ts
|
|
446
|
+
import { Routes } from '@angular/router';
|
|
447
|
+
import { canMatchLocale } from '@mmstack/translate';
|
|
448
|
+
|
|
449
|
+
export const routes: Routes = [
|
|
450
|
+
{
|
|
451
|
+
path: ':locale',
|
|
452
|
+
canMatch: [canMatchLocale()], // redirects invalid locales to default
|
|
453
|
+
children: [
|
|
454
|
+
{
|
|
455
|
+
path: 'quotes',
|
|
456
|
+
loadChildren: () =>
|
|
457
|
+
import('./quote/quote.routes').then((m) => m.QUOTE_ROUTES),
|
|
458
|
+
},
|
|
459
|
+
// ... other locale-scoped routes
|
|
460
|
+
],
|
|
461
|
+
},
|
|
462
|
+
];
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
Visiting `/sl-SI/quotes` triggers the resolver, which loads the `sl-SI` translation and switches the store's locale. Visiting `/zz-ZZ/quotes` is redirected to `/en-US/quotes` by the guard.
|
|
466
|
+
|
|
467
|
+
### Scenario B: Dynamic locale with localStorage persistence
|
|
468
|
+
|
|
469
|
+
The locale lives in a writable signal driven by the user (typically a language picker), and the choice survives reloads. Best when the locale isn't part of the URL.
|
|
470
|
+
|
|
471
|
+
```typescript
|
|
472
|
+
// app.config.ts
|
|
473
|
+
import { ApplicationConfig, LOCALE_ID } from '@angular/core';
|
|
474
|
+
import { provideRouter } from '@angular/router';
|
|
475
|
+
import { provideIntlConfig } from '@mmstack/translate';
|
|
476
|
+
import { routes } from './app.routes';
|
|
477
|
+
|
|
478
|
+
export const appConfig: ApplicationConfig = {
|
|
479
|
+
providers: [
|
|
480
|
+
{ provide: LOCALE_ID, useValue: 'en-US' }, // initial / fallback locale
|
|
481
|
+
provideIntlConfig({
|
|
482
|
+
defaultLocale: 'en-US',
|
|
483
|
+
supportedLocales: ['en-US', 'sl-SI', 'de-DE'],
|
|
484
|
+
localeStorage: {
|
|
485
|
+
read: () => localStorage.getItem('locale'),
|
|
486
|
+
write: (locale) => localStorage.setItem('locale', locale),
|
|
487
|
+
},
|
|
488
|
+
}),
|
|
489
|
+
provideRouter(routes),
|
|
490
|
+
],
|
|
491
|
+
};
|
|
492
|
+
```
|
|
493
|
+
|
|
494
|
+
```typescript
|
|
495
|
+
// language-switcher.component.ts
|
|
496
|
+
import { Component } from '@angular/core';
|
|
497
|
+
import { injectDynamicLocale } from '@mmstack/translate';
|
|
498
|
+
|
|
499
|
+
@Component({
|
|
500
|
+
selector: 'app-language-switcher',
|
|
501
|
+
template: `
|
|
502
|
+
<select [value]="locale()" (change)="onChange($event)">
|
|
503
|
+
<option value="en-US">English</option>
|
|
504
|
+
<option value="sl-SI">SlovenΕ‘Δina</option>
|
|
505
|
+
<option value="de-DE">Deutsch</option>
|
|
506
|
+
</select>
|
|
507
|
+
@if (locale.isLoading()) { <span>Loadingβ¦</span> }
|
|
508
|
+
`,
|
|
509
|
+
})
|
|
510
|
+
export class LanguageSwitcherComponent {
|
|
511
|
+
protected readonly locale = injectDynamicLocale();
|
|
512
|
+
|
|
513
|
+
protected onChange(event: Event) {
|
|
514
|
+
this.locale.set((event.target as HTMLSelectElement).value);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
```
|
|
518
|
+
|
|
519
|
+
`localeStorage.read()` runs once on init to restore the previous choice (silently ignored if not in `supportedLocales`); `write()` fires on every successful locale change. `localeStorage` and `localeParamName` are mutually exclusive at the type level β when the URL is the source of truth, persisting separately would just fight it.
|
|
520
|
+
|
|
516
521
|
## Helper Functions
|
|
517
522
|
|
|
518
523
|
### Core Injection Functions
|
|
@@ -552,7 +557,7 @@ The library uses Angular's `resource()` API for efficient, reactive translation
|
|
|
552
557
|
|
|
553
558
|
- Automatic request deduplication
|
|
554
559
|
- Built-in loading states
|
|
555
|
-
-
|
|
560
|
+
- Stale-result discarding via `AbortSignal` (the abort signal is checked after each load resolves; in-flight `fetch` cancellation isn't propagated to user-supplied loaders)
|
|
556
561
|
- Better error handling
|
|
557
562
|
|
|
558
563
|
### On-Demand Translation Loading
|
|
@@ -592,15 +597,7 @@ const valueThatNeedsProps = t('remote.myOtherKey', {
|
|
|
592
597
|
|
|
593
598
|
## Formatters
|
|
594
599
|
|
|
595
|
-
The library includes a set of reactive formatters that
|
|
596
|
-
|
|
597
|
-
**Note:** For reactivity, wrap them in a `computed()` if the input signals change or if you want them to react to dynamic locale changes.
|
|
598
|
-
|
|
599
|
-
**SSR note:** Formatters read the active locale from a process-level signal. This is safe for all client-side usage and for SSR on serverless platforms (Lambda, Vercel, Netlify Edge) or worker-thread pools, where each request runs in an isolated V8 context. If you run a traditional single-process Node.js SSR server and concurrently render pages for **different** locales, pass the locale explicitly via the `locale` option on each formatter to avoid a potential cross-request read:
|
|
600
|
-
|
|
601
|
-
```typescript
|
|
602
|
-
readonly displayDate = computed(() => formatDate(this.date, { locale: this.currentLocale() }));
|
|
603
|
-
```
|
|
600
|
+
The library includes a set of reactive formatters that wrap the standard `Intl.*` APIs and integrate with the dynamic locale signal.
|
|
604
601
|
|
|
605
602
|
Available formatters:
|
|
606
603
|
|
|
@@ -612,24 +609,82 @@ Available formatters:
|
|
|
612
609
|
- **`formatRelativeTime`**: Wraps `Intl.RelativeTimeFormat`
|
|
613
610
|
- **`formatDisplayName`**: Wraps `Intl.DisplayNames`
|
|
614
611
|
|
|
615
|
-
|
|
612
|
+
Each formatter ships in two flavors:
|
|
613
|
+
|
|
614
|
+
1. **Standalone function** (`formatDate`, `formatList`, β¦) β pass `locale` explicitly, either as a string or via the options object. Three overloads per formatter: `(value, locale)`, `(value, opt)` with a required `locale` field, and a deprecated unsafe form that omits the locale (kept for backwards compatibility, see SSR note below).
|
|
615
|
+
2. **`injectFormat*()` companion** (`injectFormatDate`, `injectFormatList`, β¦) β call inside an injection context to get a function that auto-resolves locale from `injectDynamicLocale()` and respects any defaults registered via `provideFormat*Defaults`. **This is the recommended path for components and services.**
|
|
616
|
+
|
|
617
|
+
You can grab them all at once via the `injectFormatters()` facade:
|
|
616
618
|
|
|
617
619
|
```typescript
|
|
618
620
|
import { computed, signal } from '@angular/core';
|
|
619
|
-
import {
|
|
621
|
+
import { injectFormatters } from '@mmstack/translate';
|
|
620
622
|
|
|
621
623
|
export class MyComponent {
|
|
624
|
+
private readonly fmt = injectFormatters();
|
|
625
|
+
|
|
622
626
|
readonly price = signal(1234.56);
|
|
623
627
|
readonly date = new Date();
|
|
624
628
|
|
|
625
|
-
// Reacts to price changes
|
|
626
|
-
readonly displayPrice = computed(() =>
|
|
627
|
-
|
|
628
|
-
// Reacts to locale changes
|
|
629
|
-
readonly displayDate = computed(() => formatDate(this.date));
|
|
629
|
+
// Reacts to price changes AND locale changes β no explicit locale needed
|
|
630
|
+
readonly displayPrice = computed(() => this.fmt.currency(this.price(), 'EUR'));
|
|
631
|
+
readonly displayDate = computed(() => this.fmt.date(this.date));
|
|
630
632
|
}
|
|
631
633
|
```
|
|
632
634
|
|
|
635
|
+
If you'd rather call the standalone forms (e.g. outside an injection context), pass the locale explicitly:
|
|
636
|
+
|
|
637
|
+
```typescript
|
|
638
|
+
import { computed } from '@angular/core';
|
|
639
|
+
import { formatCurrency, formatDate, injectDynamicLocale } from '@mmstack/translate';
|
|
640
|
+
|
|
641
|
+
const locale = injectDynamicLocale();
|
|
642
|
+
|
|
643
|
+
readonly displayPrice = computed(() => formatCurrency(this.price(), 'EUR', { locale: locale() }));
|
|
644
|
+
readonly displayDate = computed(() => formatDate(this.date, locale()));
|
|
645
|
+
```
|
|
646
|
+
|
|
647
|
+
### Provider defaults
|
|
648
|
+
|
|
649
|
+
Each formatter has a paired `provideFormat*Defaults` provider, and they can be registered together via `provideFormatDefaults`:
|
|
650
|
+
|
|
651
|
+
```typescript
|
|
652
|
+
import { provideFormatDefaults } from '@mmstack/translate';
|
|
653
|
+
|
|
654
|
+
bootstrapApplication(AppComponent, {
|
|
655
|
+
providers: [
|
|
656
|
+
provideFormatDefaults({
|
|
657
|
+
date: { format: 'mediumDate' },
|
|
658
|
+
number: { useGrouping: true, maxFractionDigits: 2 },
|
|
659
|
+
currency: { display: 'code' },
|
|
660
|
+
relativeTime: { numeric: 'auto' },
|
|
661
|
+
list: { type: 'disjunction' },
|
|
662
|
+
percent: { maxFractionDigits: 1 },
|
|
663
|
+
displayName: { style: 'short' },
|
|
664
|
+
}),
|
|
665
|
+
],
|
|
666
|
+
});
|
|
667
|
+
```
|
|
668
|
+
|
|
669
|
+
The injected formatter functions (`injectFormat*()`) automatically merge these defaults with the dynamic locale.
|
|
670
|
+
|
|
671
|
+
### SSR note
|
|
672
|
+
|
|
673
|
+
The deprecated unsafe overload (omitting `locale`) reads from a **process-level global signal**, which can cross-contaminate concurrent requests on a single-process Node.js SSR server rendering pages for different locales. The new overloads with required `locale` and the `injectFormat*()` companions are SSR-safe.
|
|
674
|
+
|
|
675
|
+
For SSR-bound code, prefer either:
|
|
676
|
+
|
|
677
|
+
```typescript
|
|
678
|
+
// 1. Recommended β let the injected formatter handle locale
|
|
679
|
+
private readonly formatDate = injectFormatDate();
|
|
680
|
+
readonly displayDate = computed(() => this.formatDate(this.date));
|
|
681
|
+
|
|
682
|
+
// 2. Or pass locale explicitly to the standalone function
|
|
683
|
+
readonly displayDate = computed(() => formatDate(this.date, this.currentLocale()));
|
|
684
|
+
```
|
|
685
|
+
|
|
686
|
+
The unsafe overload is kept for backwards compatibility and will be removed in a future release.
|
|
687
|
+
|
|
633
688
|
## Testing
|
|
634
689
|
|
|
635
690
|
When testing components that use `@mmstack/translate` (via `injectNamespaceT`, `Translate` directive, or `Translator` pipe), you don't need to configure actual translated namespaces or deal with Intl loading. Instead, use the provided `provideMockTranslations()` utility.
|
|
@@ -700,27 +755,71 @@ describe('MyComponent', () => {
|
|
|
700
755
|
|
|
701
756
|
### From @angular/localize
|
|
702
757
|
|
|
703
|
-
`@mmstack/translate`
|
|
758
|
+
You don't have to change your build pipeline β `@mmstack/translate` runs alongside (or in place of) `@angular/localize` in multi-build mode without rebuilds. The migration happens at the source level: replace `$localize` template tags and `i18n` attributes with namespace-based access.
|
|
759
|
+
|
|
760
|
+
| `@angular/localize` | `@mmstack/translate` |
|
|
761
|
+
| ---------------------------------------------- | --------------------------------------------------------------------------------- |
|
|
762
|
+
| ``$localize`Hello ${name}:name:` `` | `t('quote.greeting', { name })` |
|
|
763
|
+
| `<h1 i18n>Title</h1>` | `<h1 [translate]="'quote.title'">` / `{{ t('quote.title') }}` / pipe |
|
|
764
|
+
| `messages.xlf` extraction | TypeScript: `createNamespace('quote', { ... })` |
|
|
765
|
+
| `messages.<locale>.xlf` translation file | TS file: `createQuoteTranslation('sl-SI', { ... })` |
|
|
766
|
+
| `angular.json` `localize` config (multi-build) | Same multi-build still works; or switch to a single build with `provideIntlConfig` |
|
|
767
|
+
| `<my-cmp i18n-title title="Hi">` | Bind the title from a translation: `[title]="t('ns.greeting')"` |
|
|
768
|
+
|
|
769
|
+
**ICU plurals and selects use the same syntax** β no conversion. They're now type-checked end to end, which the `@angular/localize` extractor doesn't provide.
|
|
770
|
+
|
|
771
|
+
**No auto-extraction.** Translation files are authored as TypeScript, so the compiler enforces shape consistency and parameter coverage across locales β at the cost of losing the `ng extract-i18n` workflow. For greenfield namespaces this is usually a net win; for huge existing xlf catalogs, plan a one-time script to convert them.
|
|
704
772
|
|
|
705
|
-
|
|
706
|
-
2. Register namespaces with resolvers
|
|
707
|
-
3. Use the translation functions/pipes/directives
|
|
773
|
+
### From @jsverse/transloco
|
|
708
774
|
|
|
709
|
-
The main
|
|
775
|
+
Conceptually close β both are runtime, signal-aware, and namespace/scope-based. The main shift is from JSON files + a service API to TypeScript namespaces + an injected `t` function:
|
|
710
776
|
|
|
711
|
-
|
|
777
|
+
| transloco | `@mmstack/translate` |
|
|
778
|
+
| -------------------------------------------------------- | --------------------------------------------------------------------- |
|
|
779
|
+
| `provideTransloco({ ... })` | `provideIntlConfig({ ... })` |
|
|
780
|
+
| `scope` (e.g. `'lazy-page'`) | `namespace` (first arg to `createNamespace` / `registerNamespace`) |
|
|
781
|
+
| JSON translation files | TS namespace files (default + one per other locale) |
|
|
782
|
+
| `inject(TranslocoService).translate(key, params)` | `injectQuoteT()(...)` (typed `t` from `registerNamespace`) |
|
|
783
|
+
| `translateSignal(key, params)` / `*transloco` | `t.asSignal(key, () => params)` / `Translate` directive |
|
|
784
|
+
| `transloco` pipe | `Translator` pipe (typed subclass) |
|
|
785
|
+
| `TranslocoService.activeLang` signal | `injectDynamicLocale()` (writable signal) |
|
|
786
|
+
| `TranslocoService.langChanges$` | `effect(() => locale())` |
|
|
787
|
+
| HTTP-loader-based lazy scope | Dynamic `import()` factory passed to `registerNamespace` |
|
|
788
|
+
| `*translocoLoading` | `locale.isLoading()` signal on `injectDynamicLocale` |
|
|
712
789
|
|
|
713
|
-
|
|
790
|
+
Migration sketch:
|
|
714
791
|
|
|
715
|
-
1.
|
|
716
|
-
2.
|
|
717
|
-
3.
|
|
718
|
-
4.
|
|
719
|
-
|
|
792
|
+
1. Pick a locale strategy and configure `provideIntlConfig` accordingly (see [Example configurations](#example-configurations)).
|
|
793
|
+
2. For each transloco scope: convert the JSON into a `createNamespace('<name>', defaultTranslations)` file plus one `createXTranslation('<locale>', ...)` file per non-default locale.
|
|
794
|
+
3. In each scope's loader module, call `registerNamespace(() => import('./<ns>.namespace'), { ... })` and export the resulting `injectNamespaceT` / `resolveNamespaceTranslation`. Wire the resolver into the matching route.
|
|
795
|
+
4. Replace `TranslocoService.translate` / `translateSignal` calls with the injected typed `t`. Replace the `transloco` pipe and `*transloco` directive with typed subclasses of `Translator` and `Translate`.
|
|
796
|
+
|
|
797
|
+
### From ngx-translate
|
|
798
|
+
|
|
799
|
+
Same source-level shape change as transloco but with more legacy API surface to replace:
|
|
800
|
+
|
|
801
|
+
| ngx-translate | `@mmstack/translate` |
|
|
802
|
+
| --------------------------------------------------- | --------------------------------------------------------------------------------- |
|
|
803
|
+
| `TranslateModule.forRoot({ loader: ... })` | `provideIntlConfig` + per-namespace `registerNamespace` |
|
|
804
|
+
| `TranslateService.get(key, params)` (Observable) | `t('ns.key', params)` (plain string) or `t.asSignal(...)` (Signal) |
|
|
805
|
+
| `TranslateService.instant(key, params)` | `t('ns.key', params)` |
|
|
806
|
+
| `TranslateService.use('locale')` | `injectDynamicLocale().set('locale')` |
|
|
807
|
+
| `TranslateService.onLangChange.subscribe(...)` | `effect(() => locale())` |
|
|
808
|
+
| `TranslateHttpLoader` | Dynamic `import()` factory: `() => import('./ns.<locale>').then((m) => m.default)`|
|
|
809
|
+
| `translate` pipe | Typed `Translator` pipe subclass |
|
|
810
|
+
| `MissingTranslationHandler` | Falls back to the default-locale message; dev-mode `console.warn` |
|
|
811
|
+
|
|
812
|
+
Migration sketch:
|
|
813
|
+
|
|
814
|
+
1. Convert each JSON translation file to TypeScript with `createNamespace` (default locale) + `createXTranslation` (other locales). The compiler enforces parameter and shape consistency.
|
|
815
|
+
2. Replace `TranslateService` usage with the typed `t` from `registerNamespace`. RxJS observables become plain strings (eager) or signals (`t.asSignal(...)`).
|
|
816
|
+
3. Replace `TranslateHttpLoader` with dynamic `import()` factories β your bundler code-splits each locale automatically; no HTTP fetch needed.
|
|
817
|
+
4. Pick a locale strategy: [route-based](#scenario-a-route-based-locale) (`canMatchLocale` + `localeParamName`) or [dynamic with persistence](#scenario-b-dynamic-locale-with-localstorage-persistence) (`injectDynamicLocale` + `localeStorage`).
|
|
818
|
+
5. Swap the `translate` pipe usage for a typed `Translator` pipe subclass (per namespace). Pure-pipe locale memoization needs the `locale()` argument trick β see [Step 5](#5-optional-dynamic-language-switching).
|
|
720
819
|
|
|
721
820
|
## Escape Hatches
|
|
722
821
|
|
|
723
|
-
Sometimes we all hit the limit of an api & need
|
|
822
|
+
Sometimes we all hit the limit of an api & need imperative escape hatches for those edge cases. These are the ones mmstack/translate currently provides:
|
|
724
823
|
|
|
725
824
|
**`withParams()`**
|
|
726
825
|
|
|
@@ -776,6 +875,24 @@ const signalGreeting = t.asSignal('dynamicNs.greeting', () => ({
|
|
|
776
875
|
}));
|
|
777
876
|
```
|
|
778
877
|
|
|
878
|
+
## Alternatives & comparison
|
|
879
|
+
|
|
880
|
+
`@mmstack/translate` fills a specific niche: supporting **both** traditional multi-build and modern single-build approaches with a typesafe & modular API, well-suited to nx-based environments. The table below positions it relative to the main alternatives.
|
|
881
|
+
|
|
882
|
+
| Feature | `@mmstack/translate` | `@angular/localize` | `@jsverse/transloco` | `ngx-translate` |
|
|
883
|
+
| :----------------------- | :----------------------------------: | :---------------------------: | :--------------------------------------------------------------------------------------------------: | :-------------------------: |
|
|
884
|
+
| **Build Process** | β
Single or Multi-Build | β Multi-Build (Typical) | β
Single Build | β
Single Build |
|
|
885
|
+
| **Translation Timing** | Runtime or Build Time | Compile Time | Runtime | Runtime |
|
|
886
|
+
| **Type Safety (Keys)** | β
Strong (Inferred from structure) | π‘ via extraction | π‘ Tooling/TS Files | π‘ OK Manual/Tooling |
|
|
887
|
+
| **Type Safety (Params)** | β
Strong (Inferred from ICU) | β None | π‘ Manual | π‘ Manual |
|
|
888
|
+
| **Locale Switching** | β
Dynamic (Runtime) or Page refresh | π Page Refresh Required | β
Dynamic (Runtime) | β
Dynamic (Runtime) |
|
|
889
|
+
| **Lazy Loading** | β
Built-in (Namespaces/Resolvers) | N/A (Compile Time) | β
Built-in (Scopes) | β
Yes (Custom Loaders) |
|
|
890
|
+
| **Namespacing/Scopes** | β
Built-in | β None | β
Built-in (Scopes) | π‘ Manual (File Structure) |
|
|
891
|
+
| **ICU Support** | β
Subset (via FormatJS Runtime) | β
Yes (Compile Time) | β
Yes (Runtime Intl/Plugins) | π‘ Via Extensions |
|
|
892
|
+
| **Signal Integration** | β
Great (fully reactive) | N/A | β
Good (`translateSignal()`, `activeLang` signal) | β Minimal/NoneΒΉ |
|
|
893
|
+
| **Reactive Formatters** | β
Built-in Intl integration | π‘ Angular pipes (zone-based) | β
@jsverse/transloco-locale(`transloco-locale`Β²: date/number/currency/percent, not signal-reactive) | β None (use Angular pipes) |
|
|
894
|
+
| **Maturity / Community** | π‘ Less mature, but battle tested | Core Angular | β
Mature / Active | β
Mature |
|
|
895
|
+
|
|
779
896
|
## Contributing
|
|
780
897
|
|
|
781
898
|
Contributions, issues, and feature requests are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for details.
|