@mmstack/translate 21.1.11 → 21.1.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
@@ -164,7 +164,8 @@ export default createQuoteTranslation('sl-SI', {
164
164
  errors: {
165
165
  minLength: 'Citat mora imeti vsaj {min} znakov.', // Variables must match original
166
166
  },
167
- stats: '{count, plural, =1 {# citat} =2 {# citata} few {# citati} other {# citatov}} na voljo',
167
+ stats:
168
+ '{count, plural, =1 {# citat} =2 {# citata} few {# citati} other {# citatov}} na voljo',
168
169
  });
169
170
  ```
170
171
 
@@ -223,7 +224,10 @@ export class QuoteTranslator extends Translator<QuoteLocale> {}
223
224
  @Directive({
224
225
  selector: '[translate]', // Input in Translate is named 'translate'
225
226
  })
226
- export class QuoteTranslate<TInput extends string> extends Translate<TInput, QuoteLocale> {}
227
+ export class QuoteTranslate<TInput extends string> extends Translate<
228
+ TInput,
229
+ QuoteLocale
230
+ > {}
227
231
  ```
228
232
 
229
233
  ### 3. Use Translations in Components
@@ -320,7 +324,8 @@ export const routes: Routes = [
320
324
  children: [
321
325
  {
322
326
  path: 'quotes',
323
- loadChildren: () => import('./quote/quote.routes').then((m) => m.QUOTE_ROUTES),
327
+ loadChildren: () =>
328
+ import('./quote/quote.routes').then((m) => m.QUOTE_ROUTES),
324
329
  },
325
330
  // ... other routes
326
331
  ],
@@ -451,9 +456,12 @@ export const createAppNamespace = ns.createMergedNamespace;
451
456
  // common.t.ts
452
457
  import { registerNamespace } from '@mmstack/translate';
453
458
 
454
- const r = registerNamespace(() => import('./common.namespace').then((m) => m.default), {
455
- 'sl-SI': () => import('./common-sl.translation').then((m) => m.default),
456
- });
459
+ const r = registerNamespace(
460
+ () => import('./common.namespace').then((m) => m.default),
461
+ {
462
+ 'sl-SI': () => import('./common-sl.translation').then((m) => m.default),
463
+ },
464
+ );
457
465
 
458
466
  export const injectCommonT = r.injectNamespaceT;
459
467
  export const resolveCommonTranslations = r.resolveNamespaceTranslation;
@@ -564,9 +572,13 @@ For cases where you need to load translations from a remote API (where keys aren
564
572
  import { registerRemoteNamespace } from '@mmstack/translate';
565
573
 
566
574
  // Returns an untyped t function: t('any.key')
567
- const { injectNamespaceT: injectRemoteT } = registerRemoteNamespace('remote', () => fetch('/api/en').then((r) => r.json()), {
568
- 'sl-SI': () => fetch('/api/sl').then((r) => r.json()),
569
- });
575
+ const { injectNamespaceT: injectRemoteT } = registerRemoteNamespace(
576
+ 'remote',
577
+ () => fetch('/api/en').then((r) => r.json()),
578
+ {
579
+ 'sl-SI': () => fetch('/api/sl').then((r) => r.json()),
580
+ },
581
+ );
570
582
 
571
583
  // usage
572
584
  const t = injectRemoteT();
@@ -580,15 +592,7 @@ const valueThatNeedsProps = t('remote.myOtherKey', {
580
592
 
581
593
  ## Formatters
582
594
 
583
- 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.
584
-
585
- **Note:** For reactivity, wrap them in a `computed()` if the input signals change or if you want them to react to dynamic locale changes.
586
-
587
- **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:
588
-
589
- ```typescript
590
- readonly displayDate = computed(() => formatDate(this.date, { locale: this.currentLocale() }));
591
- ```
595
+ The library includes a set of reactive formatters that wrap the standard `Intl.*` APIs and integrate with the dynamic locale signal.
592
596
 
593
597
  Available formatters:
594
598
 
@@ -600,24 +604,82 @@ Available formatters:
600
604
  - **`formatRelativeTime`**: Wraps `Intl.RelativeTimeFormat`
601
605
  - **`formatDisplayName`**: Wraps `Intl.DisplayNames`
602
606
 
603
- **Example:**
607
+ Each formatter ships in two flavors:
608
+
609
+ 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).
610
+ 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.**
611
+
612
+ You can grab them all at once via the `injectFormatters()` facade:
604
613
 
605
614
  ```typescript
606
615
  import { computed, signal } from '@angular/core';
607
- import { formatCurrency, formatDate } from '@mmstack/translate';
616
+ import { injectFormatters } from '@mmstack/translate';
608
617
 
609
618
  export class MyComponent {
619
+ private readonly fmt = injectFormatters();
620
+
610
621
  readonly price = signal(1234.56);
611
622
  readonly date = new Date();
612
623
 
613
- // Reacts to price changes OR locale changes
614
- readonly displayPrice = computed(() => formatCurrency(this.price(), 'EUR'));
615
-
616
- // Reacts to locale changes
617
- readonly displayDate = computed(() => formatDate(this.date));
624
+ // Reacts to price changes AND locale changes — no explicit locale needed
625
+ readonly displayPrice = computed(() => this.fmt.currency(this.price(), 'EUR'));
626
+ readonly displayDate = computed(() => this.fmt.date(this.date));
618
627
  }
619
628
  ```
620
629
 
630
+ If you'd rather call the standalone forms (e.g. outside an injection context), pass the locale explicitly:
631
+
632
+ ```typescript
633
+ import { computed } from '@angular/core';
634
+ import { formatCurrency, formatDate, injectDynamicLocale } from '@mmstack/translate';
635
+
636
+ const locale = injectDynamicLocale();
637
+
638
+ readonly displayPrice = computed(() => formatCurrency(this.price(), 'EUR', { locale: locale() }));
639
+ readonly displayDate = computed(() => formatDate(this.date, locale()));
640
+ ```
641
+
642
+ ### Provider defaults
643
+
644
+ Each formatter has a paired `provideFormat*Defaults` provider, and they can be registered together via `provideFormatDefaults`:
645
+
646
+ ```typescript
647
+ import { provideFormatDefaults } from '@mmstack/translate';
648
+
649
+ bootstrapApplication(AppComponent, {
650
+ providers: [
651
+ provideFormatDefaults({
652
+ date: { format: 'mediumDate' },
653
+ number: { useGrouping: true, maxFractionDigits: 2 },
654
+ currency: { display: 'code' },
655
+ relativeTime: { numeric: 'auto' },
656
+ list: { type: 'disjunction' },
657
+ percent: { maxFractionDigits: 1 },
658
+ displayName: { style: 'short' },
659
+ }),
660
+ ],
661
+ });
662
+ ```
663
+
664
+ The injected formatter functions (`injectFormat*()`) automatically merge these defaults with the dynamic locale.
665
+
666
+ ### SSR note
667
+
668
+ 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.
669
+
670
+ For SSR-bound code, prefer either:
671
+
672
+ ```typescript
673
+ // 1. Recommended — let the injected formatter handle locale
674
+ private readonly formatDate = injectFormatDate();
675
+ readonly displayDate = computed(() => this.formatDate(this.date));
676
+
677
+ // 2. Or pass locale explicitly to the standalone function
678
+ readonly displayDate = computed(() => formatDate(this.date, this.currentLocale()));
679
+ ```
680
+
681
+ The unsafe overload is kept for backwards compatibility and will be removed in a future release.
682
+
621
683
  ## Testing
622
684
 
623
685
  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.
@@ -643,7 +705,9 @@ describe('MyComponent', () => {
643
705
  fixture.detectChanges();
644
706
 
645
707
  // By default, it echoes back the flattened object key using dot notation
646
- expect(fixture.nativeElement.textContent).toContain('myNamespace.greeting.title');
708
+ expect(fixture.nativeElement.textContent).toContain(
709
+ 'myNamespace.greeting.title',
710
+ );
647
711
  });
648
712
 
649
713
  it('allows providing explicit mock overrides', () => {
@@ -704,6 +768,64 @@ If you're migrating from a runtime-only solution:
704
768
  4. Convert your translation JSON files to TypeScript using `createNamespace`
705
769
  5. Update component/template usage to use the type-safe APIs
706
770
 
771
+ ## Escape Hatches
772
+
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:
774
+
775
+ **`withParams()`**
776
+
777
+ Type-level parameter inference is one level deep — variables inside `plural` / `select` / `selectordinal` arms aren't picked up (e.g. the `{name}` inside `{count, plural, one {Hi {name}} ...}`). For those cases, wrap the message with `withParams<P>(...)` to declare the missing params explicitly:
778
+
779
+ ```typescript
780
+ import { createNamespace, withParams } from '@mmstack/translate';
781
+
782
+ const ns = createNamespace('quote', {
783
+ // auto-extracts `count`; `name` is declared because it lives inside the arms
784
+ stats: withParams<{ name: string }>(
785
+ '{count, plural, one {1 quote from {name}} other {# quotes from {name}}}',
786
+ ),
787
+ });
788
+
789
+ // t inferred as: ('quote.stats', { count: number; name: string }) => string
790
+ t('quote.stats', { count: 3, name: 'Alice' });
791
+ ```
792
+
793
+ Declared params are merged with auto-extracted ones; on key conflict, declared wins. Non-default locales for a wrapped key don't need to repeat the helper — they accept any string:
794
+
795
+ ```typescript
796
+ createQuoteTranslation('sl-SI', {
797
+ stats: '{count, plural, =1 {1 citat od {name}} other {# citatov od {name}}}',
798
+ });
799
+ ```
800
+
801
+ Trade-off: wrapping a key opts out of template-literal shape strictness for that key in non-default locales (the auto-validation that requires placeholders to appear in the right positions). The library still enforces top-level placeholders for non-wrapped keys.
802
+
803
+ **`injectAddTranslations()`**
804
+ Allows adding flat, per-locale translations to any namespace at runtime.
805
+
806
+ ```typescript
807
+ import { injectAddTranslations } from '@mmstack/translate';
808
+
809
+ const addTranslations = injectAddTranslations();
810
+ addTranslations('dynamicNs', {
811
+ 'en-US': { greeting: 'Hello {name}!' },
812
+ 'sl-SI': { greeting: 'Zdravo {name}!' },
813
+ });
814
+ ```
815
+
816
+ **`injectUnsafeT()`**
817
+ Returns a fully untyped translation function `t('anyNamespace.key')`. Ideal for reading dynamically added keys or cross-namespace lookups where the typed API would be impractical.
818
+
819
+ ```typescript
820
+ import { injectUnsafeT } from '@mmstack/translate';
821
+
822
+ const t = injectUnsafeT();
823
+ const greeting = t('dynamicNs.greeting', { name: 'Alice' });
824
+ const signalGreeting = t.asSignal('dynamicNs.greeting', () => ({
825
+ name: 'Alice',
826
+ }));
827
+ ```
828
+
707
829
  ## Contributing
708
830
 
709
831
  Contributions, issues, and feature requests are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for details.