@mmstack/translate 20.5.12 β†’ 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'],
110
-
111
- // Automatically detect and respond to locale route parameter changes, should correspond with actual param name example bellow
112
- localeParamName: 'locale',
94
+ **Additional options:**
113
95
 
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.
295
-
296
- **Step 1: Configure locale parameter name**
297
-
298
- ```typescript
299
- // app.config.ts
300
- import { provideIntlConfig } from '@mmstack/translate';
301
-
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
- ```
285
+ For applications with locale-based routing (e.g. `/en-US/quotes`, `/sl-SI/quotes`), the library can detect and switch locales automatically:
335
286
 
336
- **That's it!** The library will:
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.
337
289
 
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
290
+ See [Scenario A](#scenario-a-route-based-locale) for a complete end-to-end config.
342
291
 
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:**
388
-
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
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.
393
313
 
394
- **Important Note for Pure Pipes:**
314
+ See [Scenario B](#scenario-b-dynamic-locale-with-localstorage-persistence) for a complete app config plus a language-switcher component.
395
315
 
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
@@ -750,27 +755,71 @@ describe('MyComponent', () => {
750
755
 
751
756
  ### From @angular/localize
752
757
 
753
- `@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.
772
+
773
+ ### From @jsverse/transloco
774
+
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:
776
+
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` |
754
789
 
755
- 1. Define your translations using `createNamespace`
756
- 2. Register namespaces with resolvers
757
- 3. Use the translation functions/pipes/directives
790
+ Migration sketch:
758
791
 
759
- The main difference is the namespace organization and type safety.
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`.
760
796
 
761
- ### From transloco/ngx-translate
797
+ ### From ngx-translate
762
798
 
763
- If you're migrating from a runtime-only solution:
799
+ Same source-level shape change as transloco but with more legacy API surface to replace:
764
800
 
765
- 1. Configure `provideIntlConfig()` with your supported locales
766
- 2. Use `localeParamName` if you have route-based locales
767
- 3. Use `injectDynamicLocale()` for programmatic locale switching
768
- 4. Convert your translation JSON files to TypeScript using `createNamespace`
769
- 5. Update component/template usage to use the type-safe APIs
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).
770
819
 
771
820
  ## Escape Hatches
772
821
 
773
- 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:
774
823
 
775
824
  **`withParams()`**
776
825
 
@@ -826,6 +875,24 @@ const signalGreeting = t.asSignal('dynamicNs.greeting', () => ({
826
875
  }));
827
876
  ```
828
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
+
829
896
  ## Contributing
830
897
 
831
898
  Contributions, issues, and feature requests are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for details.