@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 CHANGED
@@ -6,13 +6,7 @@
6
6
  [![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/mihajm/mmstack/blob/master/LICENSE)
7
7
  [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](CONTRIBUTING.md)
8
8
 
9
- `@mmstack/translate` is an opinionated internationalization (i18n) library for Angular applications built with three core priorities:
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
- **Optional Configuration:**
105
-
106
- ```typescript
107
- provideIntlConfig({
108
- defaultLocale: 'en-US',
109
- supportedLocales: ['en-US', 'sl-SI', 'de-DE'],
94
+ **Additional options:**
110
95
 
111
- // Automatically detect and respond to locale route parameter changes, should correspond with actual param name example bellow
112
- localeParamName: 'locale',
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's compiled translation (functions as fallback)
182
- () => import('./quote.namespace').then((m) => m.default),
162
+ // Default locale (also acts as the fallback).
163
+ () => import('./quote.namespace'),
183
164
  {
184
- // Map other locales to promise factories (dynamic imports)
185
- 'sl-SI': () => import('./quote-sl.translation').then((m) => m.default),
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., `/en-US/quotes`, `/sl-SI/quotes`), the library can automatically detect and switch locales.
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
- **Step 1: Configure locale parameter name**
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
- ```typescript
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
- export const appConfig: ApplicationConfig = {
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'])], // Validates second segment
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 applications that need runtime language switching without page refreshes (e.g., language selector in header), use `injectDynamicLocale()`:
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
- import { Component } from '@angular/core';
361
- import { injectDynamicLocale } from '@mmstack/translate';
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
- **Features:**
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
- - Validates against `supportedLocales` (if configured)
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
- **Important Note for Pure Pipes:**
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
- // Option 1: Pass locale as parameter (recommended)
319
+ // Recommended: pass locale as a pipe argument so the input identity changes.
400
320
  {{ 'common.yes' | translate : locale() }}
401
321
 
402
- // Option 2: Make pipe impure (not recommended for performance)
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').then((m) => m.default),
360
+ () => import('./common.namespace'),
461
361
  {
462
- 'sl-SI': () => import('./common-sl.translation').then((m) => m.default),
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
- - Cancellation support via `AbortSignal`
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 automatically adapt to the current locale. They are standalone functions that do not require dependency injection, making them easy to use anywhere.
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
- **Example:**
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 { formatCurrency, formatDate } from '@mmstack/translate';
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 OR locale changes
626
- readonly displayPrice = computed(() => formatCurrency(this.price(), 'EUR'));
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` can work exactly like `@angular/localize` by default - no migration needed for the build process! Simply:
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
- 1. Define your translations using `createNamespace`
706
- 2. Register namespaces with resolvers
707
- 3. Use the translation functions/pipes/directives
773
+ ### From @jsverse/transloco
708
774
 
709
- The main difference is the namespace organization and type safety.
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
- ### From transloco/ngx-translate
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
- If you're migrating from a runtime-only solution:
790
+ Migration sketch:
714
791
 
715
- 1. Configure `provideIntlConfig()` with your supported locales
716
- 2. Use `localeParamName` if you have route-based locales
717
- 3. Use `injectDynamicLocale()` for programmatic locale switching
718
- 4. Convert your translation JSON files to TypeScript using `createNamespace`
719
- 5. Update component/template usage to use the type-safe APIs
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 imperitive escape hatches for those edge cases. These are the ones mmstack/translate currently provides:
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.