From d74b0fa1799b6313c296ad73da43b7867e6090d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 21 Feb 2026 22:49:24 +0000 Subject: [PATCH 1/6] feat(composables/i18n): add signal-based translation library MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new `@homj/composables/i18n` secondary entry point that provides a signal-based i18n system backed by Angular's resource API. Key features: - `provideTranslation(loader?)` — registers the global translation store and optional global loader at environment level - `provideTranslationScope(scope, loader)` — registers a local scope loader at component level; the resource is created lazily in the store - `useTranslation()` — returns a reactive `TranslateFn` that reads from signal-backed resources; falls back to the key while loading - Translation keys follow the format `'key'` (global) or `'scope:key'` (scoped, e.g. `'my-component:foo'`) - `{{ paramName }}` placeholder interpolation via optional params argument - Each scope is backed by a single `resource()` instance; subsequent calls to `ensureScope` with the same scope are no-ops https://claude.ai/code/session_017w3dyuKpzdFHjg7ng5SeJD --- libs/composables/i18n/ng-package.json | 5 + .../use-translation.composable.spec.ts | 193 ++++++++++++++++++ .../composables/use-translation.composable.ts | 65 ++++++ libs/composables/i18n/src/index.ts | 1 + .../i18n/src/models/translation.types.ts | 32 +++ .../src/providers/translation.providers.ts | 78 +++++++ libs/composables/i18n/src/public-api.ts | 13 ++ .../src/service/translation.store.spec.ts | 193 ++++++++++++++++++ .../i18n/src/service/translation.store.ts | 99 +++++++++ .../i18n/src/tokens/translation.tokens.ts | 29 +++ tsconfig.base.json | 3 +- 11 files changed, 710 insertions(+), 1 deletion(-) create mode 100644 libs/composables/i18n/ng-package.json create mode 100644 libs/composables/i18n/src/composables/use-translation.composable.spec.ts create mode 100644 libs/composables/i18n/src/composables/use-translation.composable.ts create mode 100644 libs/composables/i18n/src/index.ts create mode 100644 libs/composables/i18n/src/models/translation.types.ts create mode 100644 libs/composables/i18n/src/providers/translation.providers.ts create mode 100644 libs/composables/i18n/src/public-api.ts create mode 100644 libs/composables/i18n/src/service/translation.store.spec.ts create mode 100644 libs/composables/i18n/src/service/translation.store.ts create mode 100644 libs/composables/i18n/src/tokens/translation.tokens.ts diff --git a/libs/composables/i18n/ng-package.json b/libs/composables/i18n/ng-package.json new file mode 100644 index 0000000..78e382c --- /dev/null +++ b/libs/composables/i18n/ng-package.json @@ -0,0 +1,5 @@ +{ + "lib": { + "entryFile": "src/index.ts" + } +} diff --git a/libs/composables/i18n/src/composables/use-translation.composable.spec.ts b/libs/composables/i18n/src/composables/use-translation.composable.spec.ts new file mode 100644 index 0000000..7649844 --- /dev/null +++ b/libs/composables/i18n/src/composables/use-translation.composable.spec.ts @@ -0,0 +1,193 @@ +import { Component } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { useTranslation } from './use-translation.composable'; +import { TranslationStore } from '../service/translation.store'; +import { provideTranslationScope } from '../providers/translation.providers'; +import { TRANSLATION_SCOPE } from '../tokens/translation.tokens'; +import { TranslationData } from '../models/translation.types'; + +const GLOBAL_DATA: TranslationData = { title: 'Hello World' }; +const SCOPE_DATA: TranslationData = { description: 'Component description' }; + +const mockStore = (overrides: Partial = {}): TranslationStore => + ({ + ensureScope: jest.fn(), + translate: jest.fn((key: string) => key), + isLoading: jest.fn(), + ...overrides + }) as unknown as TranslationStore; + +describe('useTranslation', () => { + it('should return a function', () => { + TestBed.configureTestingModule({ + providers: [{ provide: TranslationStore, useValue: mockStore() }] + }); + + TestBed.runInInjectionContext(() => { + const t = useTranslation(); + + expect(typeof t).toEqual('function'); + }); + }); + + it('should delegate to store.translate', () => { + const translateSpy = jest.fn().mockReturnValue('translated value'); + TestBed.configureTestingModule({ + providers: [{ provide: TranslationStore, useValue: mockStore({ translate: translateSpy }) }] + }); + + TestBed.runInInjectionContext(() => { + const t = useTranslation(); + const result = t('title'); + + expect(translateSpy).toHaveBeenCalledWith('title', undefined); + expect(result).toEqual('translated value'); + }); + }); + + it('should forward params to store.translate', () => { + const translateSpy = jest.fn().mockReturnValue('Hello, Jane!'); + TestBed.configureTestingModule({ + providers: [{ provide: TranslationStore, useValue: mockStore({ translate: translateSpy }) }] + }); + + TestBed.runInInjectionContext(() => { + const t = useTranslation(); + + t('greeting', { name: 'Jane' }); + + expect(translateSpy).toHaveBeenCalledWith('greeting', { name: 'Jane' }); + }); + }); + + it('should register scope loaders from TRANSLATION_SCOPE token', () => { + const ensureScopeSpy = jest.fn(); + const loader = jest.fn().mockResolvedValue(SCOPE_DATA); + + TestBed.configureTestingModule({ + providers: [ + { provide: TranslationStore, useValue: mockStore({ ensureScope: ensureScopeSpy }) }, + { provide: TRANSLATION_SCOPE, useValue: { scope: 'my-comp', loader }, multi: true } + ] + }); + + TestBed.runInInjectionContext(() => { + useTranslation(); + + expect(ensureScopeSpy).toHaveBeenCalledWith('my-comp', loader); + }); + }); + + it('should register multiple scope loaders', () => { + const ensureScopeSpy = jest.fn(); + const loaderA = jest.fn().mockResolvedValue({}); + const loaderB = jest.fn().mockResolvedValue({}); + + TestBed.configureTestingModule({ + providers: [ + { provide: TranslationStore, useValue: mockStore({ ensureScope: ensureScopeSpy }) }, + { provide: TRANSLATION_SCOPE, useValue: { scope: 'scope-a', loader: loaderA }, multi: true }, + { provide: TRANSLATION_SCOPE, useValue: { scope: 'scope-b', loader: loaderB }, multi: true } + ] + }); + + TestBed.runInInjectionContext(() => { + useTranslation(); + + expect(ensureScopeSpy).toHaveBeenCalledWith('scope-a', loaderA); + expect(ensureScopeSpy).toHaveBeenCalledWith('scope-b', loaderB); + }); + }); + + it('should work without any TRANSLATION_SCOPE providers', () => { + const ensureScopeSpy = jest.fn(); + TestBed.configureTestingModule({ + providers: [{ provide: TranslationStore, useValue: mockStore({ ensureScope: ensureScopeSpy }) }] + }); + + TestBed.runInInjectionContext(() => { + useTranslation(); + + expect(ensureScopeSpy).not.toHaveBeenCalled(); + }); + }); + + describe('integration — global translations', () => { + @Component({ + template: `

{{ t('title') }}

`, + standalone: true + }) + class TestComponent { + t = useTranslation(); + } + + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TestComponent], + providers: [ + { + provide: TranslationStore, + useValue: mockStore({ translate: (key: string) => GLOBAL_DATA[key] ?? key }) + } + ] + }).compileComponents(); + + fixture = TestBed.createComponent(TestComponent); + fixture.detectChanges(); + }); + + it('should render the translated value in the template', () => { + const p = fixture.nativeElement.querySelector('#title'); + + expect(p.textContent).toEqual('Hello World'); + }); + + it('should render the key as fallback for missing translations', () => { + const t = fixture.componentInstance.t; + + expect(t('missing')).toEqual('missing'); + }); + }); + + describe('integration — scoped translations via provideTranslationScope', () => { + @Component({ + template: `

{{ t('my-scope:description') }}

`, + standalone: true, + providers: [provideTranslationScope('my-scope', () => Promise.resolve(SCOPE_DATA))] + }) + class ScopedComponent { + t = useTranslation(); + } + + let fixture: ComponentFixture; + + beforeEach(async () => { + const ensureScopeSpy = jest.fn(); + const translateSpy = jest.fn((key: string) => { + if (key === 'my-scope:description') return 'Component description'; + return key; + }); + + await TestBed.configureTestingModule({ + imports: [ScopedComponent], + providers: [ + { + provide: TranslationStore, + useValue: mockStore({ ensureScope: ensureScopeSpy, translate: translateSpy }) + } + ] + }).compileComponents(); + + fixture = TestBed.createComponent(ScopedComponent); + fixture.detectChanges(); + }); + + it('should render the scoped translation in the template', () => { + const p = fixture.nativeElement.querySelector('#desc'); + + expect(p.textContent).toEqual('Component description'); + }); + }); +}); diff --git a/libs/composables/i18n/src/composables/use-translation.composable.ts b/libs/composables/i18n/src/composables/use-translation.composable.ts new file mode 100644 index 0000000..b9c0a1d --- /dev/null +++ b/libs/composables/i18n/src/composables/use-translation.composable.ts @@ -0,0 +1,65 @@ +import { inject } from '@angular/core'; +import { TranslateFn } from '../models/translation.types'; +import { TRANSLATION_SCOPE, TranslationScopeConfig } from '../tokens/translation.tokens'; +import { TranslationStore } from '../service/translation.store'; + +/** + * Returns a reactive translate function `t` that resolves translation keys to strings. + * + * **Key format:** + * - Global: `t('title')` — looks up `title` in the global translation namespace + * - Scoped: `t('my-component:foo')` — looks up `foo` in the `my-component` scope + * + * **Reactivity:** + * The returned function reads from Angular signal resources internally. + * When called inside a reactive context — a component template, `computed()`, or `effect()` — + * Angular tracks the read and will re-evaluate the expression once translations finish loading. + * While loading, the key itself is returned as a fallback. + * + * **Interpolation:** + * Pass a params object as the second argument to replace `{{ paramName }}` placeholders: + * ```ts + * t('greeting', { name: 'Jane' }) // "Hello, Jane!" (if translation is "Hello, {{ name }}!") + * ``` + * + * **Setup:** + * Requires {@link provideTranslation} to be called in the environment providers. + * Scope-level loaders are registered via {@link provideTranslationScope} in the + * component's `providers` array. + * + * @example + * ```ts + * @Component({ + * selector: 'app-root', + * template: `

{{ t('title') }}

` + * }) + * class AppComponent { + * t = useTranslation(); + * } + * ``` + * + * @example + * With a local scope: + * ```ts + * @Component({ + * selector: 'my-component', + * providers: [ + * provideTranslationScope('my-component', () => import('./i18n/en.json')) + * ], + * template: `

{{ t('my-component:description') }}

` + * }) + * class MyComponent { + * t = useTranslation(); + * } + * ``` + * + * @returns A reactive {@link TranslateFn} + */ +export function useTranslation(): TranslateFn { + const store = inject(TranslationStore); + const scopes = inject(TRANSLATION_SCOPE, { optional: true }) as TranslationScopeConfig[] | null; + + scopes?.forEach(({ scope, loader }) => store.ensureScope(scope, loader)); + + return (key: string, params?) => store.translate(key, params); +} diff --git a/libs/composables/i18n/src/index.ts b/libs/composables/i18n/src/index.ts new file mode 100644 index 0000000..7e1a213 --- /dev/null +++ b/libs/composables/i18n/src/index.ts @@ -0,0 +1 @@ +export * from './public-api'; diff --git a/libs/composables/i18n/src/models/translation.types.ts b/libs/composables/i18n/src/models/translation.types.ts new file mode 100644 index 0000000..cfe432d --- /dev/null +++ b/libs/composables/i18n/src/models/translation.types.ts @@ -0,0 +1,32 @@ +/** + * A flat map of translation keys to their translated string values. + */ +export type TranslationData = Record; + +/** + * A function that asynchronously loads translation data for a scope or the global namespace. + * Intended to be used with dynamic imports, e.g. `() => import('./i18n/en.json')`. + */ +export type TranslationLoader = () => Promise; + +/** + * Parameters used for interpolating values into a translated string. + * Keys correspond to the placeholder names used in translation strings (e.g. `{{ name }}`). + */ +export type TranslationParams = Record; + +/** + * The translate function returned by {@link useTranslation}. + * + * - Global key: `t('title')` → looks up `title` in the global translation namespace + * - Scoped key: `t('my-component:foo')` → looks up `foo` in the `my-component` scope + * + * The function is reactive: when called inside a signal reactive context + * (template, `computed`, `effect`), it will cause a re-evaluation whenever + * the underlying translations finish loading or change. + * + * @param key - A global key (`'title'`) or scoped key (`'scope:key'`) + * @param params - Optional interpolation parameters (replaces `{{ paramName }}` placeholders) + * @returns The translated string, or the key itself as a fallback while loading + */ +export type TranslateFn = (key: string, params?: TranslationParams) => string; diff --git a/libs/composables/i18n/src/providers/translation.providers.ts b/libs/composables/i18n/src/providers/translation.providers.ts new file mode 100644 index 0000000..5fa6337 --- /dev/null +++ b/libs/composables/i18n/src/providers/translation.providers.ts @@ -0,0 +1,78 @@ +import { EnvironmentProviders, makeEnvironmentProviders, Provider } from '@angular/core'; +import { TranslationLoader } from '../models/translation.types'; +import { TRANSLATION_LOADER, TRANSLATION_SCOPE } from '../tokens/translation.tokens'; +import { TranslationStore } from '../service/translation.store'; + +/** + * Registers the {@link TranslationStore} and optionally a global translation loader + * at the environment (application or route) level. + * + * Call this once in your `ApplicationConfig` providers (or in a lazy-loaded route's + * `providers` array). Scoped translations can then be added per-component with + * {@link provideTranslationScope}. + * + * @example + * ```ts + * // app.config.ts + * export const appConfig: ApplicationConfig = { + * providers: [ + * provideTranslation(() => import('./i18n/en.json')) + * ] + * }; + * ``` + * + * @example + * Without a global loader (only scoped translations): + * ```ts + * provideTranslation() + * ``` + * + * @param loader - Optional loader for the global (unscoped) translations + * @returns Environment providers for the translation system + */ +export function provideTranslation(loader?: TranslationLoader): EnvironmentProviders { + return makeEnvironmentProviders([ + TranslationStore, + ...(loader ? [{ provide: TRANSLATION_LOADER, useValue: loader }] : []) + ]); +} + +/** + * Registers a local scope loader for a specific translation scope. + * + * Add this to a component's (or directive's) `providers` array alongside + * {@link useTranslation} inside that component. + * + * The loader is lazy — it only runs when `useTranslation` is first called in + * the component's injection context and a key for this scope is looked up. + * + * @example + * ```ts + * @Component({ + * selector: 'my-component', + * providers: [ + * provideTranslationScope('my-component', () => import('./i18n/en.json')) + * ], + * template: ` + *

{{ t('title') }}

+ *

{{ t('my-component:description') }}

+ * ` + * }) + * class MyComponent { + * t = useTranslation(); + * } + * ``` + * + * @param scope - The scope identifier used as the prefix in translation keys, e.g. `'my-component'` + * @param loader - Loader function for this scope's translations + * @returns Component-level providers for the translation scope + */ +export function provideTranslationScope(scope: string, loader: TranslationLoader): Provider[] { + return [ + { + provide: TRANSLATION_SCOPE, + useValue: { scope, loader }, + multi: true + } + ]; +} diff --git a/libs/composables/i18n/src/public-api.ts b/libs/composables/i18n/src/public-api.ts new file mode 100644 index 0000000..7d64d04 --- /dev/null +++ b/libs/composables/i18n/src/public-api.ts @@ -0,0 +1,13 @@ +/** + * @packageDocumentation + * Signal-based translation library with support for global and scoped namespaces, + * backed by Angular's resource API for lazy async loading. + * + * @module @homj/composables/i18n + */ + +export { TranslationData, TranslationLoader, TranslationParams, TranslateFn } from './models/translation.types'; +export { TRANSLATION_LOADER, TRANSLATION_SCOPE, TranslationScopeConfig } from './tokens/translation.tokens'; +export { TranslationStore } from './service/translation.store'; +export { provideTranslation, provideTranslationScope } from './providers/translation.providers'; +export { useTranslation } from './composables/use-translation.composable'; diff --git a/libs/composables/i18n/src/service/translation.store.spec.ts b/libs/composables/i18n/src/service/translation.store.spec.ts new file mode 100644 index 0000000..2ed3af6 --- /dev/null +++ b/libs/composables/i18n/src/service/translation.store.spec.ts @@ -0,0 +1,193 @@ +import { TestBed } from '@angular/core/testing'; +import { TranslationStore } from './translation.store'; +import { TRANSLATION_LOADER } from '../tokens/translation.tokens'; +import { TranslationData } from '../models/translation.types'; + +const GLOBAL_DATA: TranslationData = { title: 'Hello World', greeting: 'Hello, {{ name }}!' }; +const SCOPE_DATA: TranslationData = { description: 'A description', label: 'Label' }; + +const resolvedLoader = (data: TranslationData) => () => Promise.resolve(data); + +describe('TranslationStore', () => { + describe('without a global loader', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [TranslationStore] + }); + }); + + it('should be created', () => { + const store = TestBed.inject(TranslationStore); + + expect(store).toBeTruthy(); + }); + + it('should return the key as fallback for an unknown global key', () => { + const store = TestBed.inject(TranslationStore); + + expect(store.translate('title')).toEqual('title'); + }); + + it('should return the full scoped key as fallback for an unknown scoped key', () => { + const store = TestBed.inject(TranslationStore); + + expect(store.translate('my-scope:description')).toEqual('my-scope:description'); + }); + }); + + describe('with a global loader', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + TranslationStore, + { provide: TRANSLATION_LOADER, useValue: resolvedLoader(GLOBAL_DATA) } + ] + }); + }); + + it('should return the key as fallback before translations are loaded', () => { + const store = TestBed.inject(TranslationStore); + + expect(store.translate('title')).toEqual('title'); + }); + + it('should return translated value after translations are loaded', async () => { + const store = TestBed.inject(TranslationStore); + + await TestBed.inject(TestBed as any, { optional: true }); + + // Flush microtasks so the resource promise resolves + await new Promise(resolve => setTimeout(resolve, 0)); + TestBed.flushEffects(); + + expect(store.translate('title')).toEqual('Hello World'); + }); + + it('should return key fallback for a missing translation key', async () => { + const store = TestBed.inject(TranslationStore); + + await new Promise(resolve => setTimeout(resolve, 0)); + TestBed.flushEffects(); + + expect(store.translate('missing')).toEqual('missing'); + }); + + it('should interpolate params after translations are loaded', async () => { + const store = TestBed.inject(TranslationStore); + + await new Promise(resolve => setTimeout(resolve, 0)); + TestBed.flushEffects(); + + expect(store.translate('greeting', { name: 'Jane' })).toEqual('Hello, Jane!'); + }); + }); + + describe('ensureScope', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [TranslationStore] + }); + }); + + it('should load translations for a registered scope', async () => { + const store = TestBed.inject(TranslationStore); + + store.ensureScope('my-scope', resolvedLoader(SCOPE_DATA)); + + await new Promise(resolve => setTimeout(resolve, 0)); + TestBed.flushEffects(); + + expect(store.translate('my-scope:description')).toEqual('A description'); + }); + + it('should not create a second resource when called again with the same scope', () => { + const store = TestBed.inject(TranslationStore); + const loader = jest.fn().mockResolvedValue(SCOPE_DATA); + + store.ensureScope('my-scope', loader); + store.ensureScope('my-scope', loader); + + expect(loader).toHaveBeenCalledTimes(1); + }); + + it('should return full scoped key as fallback before scope is loaded', () => { + const store = TestBed.inject(TranslationStore); + + store.ensureScope('my-scope', resolvedLoader(SCOPE_DATA)); + + expect(store.translate('my-scope:label')).toEqual('my-scope:label'); + }); + }); + + describe('translate — key parsing', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [TranslationStore] + }); + }); + + it('should treat a key without ":" as global', async () => { + const store = TestBed.inject(TranslationStore); + + store.ensureScope('', resolvedLoader(GLOBAL_DATA)); + + await new Promise(resolve => setTimeout(resolve, 0)); + TestBed.flushEffects(); + + expect(store.translate('title')).toEqual('Hello World'); + }); + + it('should split on the first ":" only', async () => { + const data: TranslationData = { 'foo:bar': 'value' }; + const store = TestBed.inject(TranslationStore); + + store.ensureScope('scope', resolvedLoader(data)); + + await new Promise(resolve => setTimeout(resolve, 0)); + TestBed.flushEffects(); + + expect(store.translate('scope:foo:bar')).toEqual('scope:foo:bar'); + }); + }); + + describe('interpolation', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + TranslationStore, + { provide: TRANSLATION_LOADER, useValue: resolvedLoader(GLOBAL_DATA) } + ] + }); + }); + + it('should replace {{ param }} placeholders', async () => { + const store = TestBed.inject(TranslationStore); + + await new Promise(resolve => setTimeout(resolve, 0)); + TestBed.flushEffects(); + + expect(store.translate('greeting', { name: 'Alice' })).toEqual('Hello, Alice!'); + }); + + it('should keep placeholder text for missing params', async () => { + const store = TestBed.inject(TranslationStore); + + await new Promise(resolve => setTimeout(resolve, 0)); + TestBed.flushEffects(); + + expect(store.translate('greeting', {})).toEqual('Hello, name!'); + }); + + it('should support numeric param values', async () => { + const data: TranslationData = { count: 'Total: {{ n }}' }; + const store = TestBed.inject(TranslationStore); + + store.ensureScope('', resolvedLoader(data)); + + await new Promise(resolve => setTimeout(resolve, 0)); + TestBed.flushEffects(); + + expect(store.translate('count', { n: 42 })).toEqual('Total: 42'); + }); + }); +}); diff --git a/libs/composables/i18n/src/service/translation.store.ts b/libs/composables/i18n/src/service/translation.store.ts new file mode 100644 index 0000000..bf6ba9c --- /dev/null +++ b/libs/composables/i18n/src/service/translation.store.ts @@ -0,0 +1,99 @@ +import { inject, Injectable, Injector, resource, ResourceRef, runInInjectionContext, Signal } from '@angular/core'; +import { TranslationData, TranslationLoader, TranslationParams } from '../models/translation.types'; +import { TRANSLATION_LOADER } from '../tokens/translation.tokens'; + +/** Sentinel key used internally for the global (unscoped) translation namespace. */ +const GLOBAL_SCOPE = ''; + +/** + * @internal + * Replaces `{{ paramName }}` placeholders in a string with values from the params map. + */ +const interpolate = (value: string, params: TranslationParams): string => + value.replace(/\{\{\s*(\w+)\s*\}\}/g, (_, key: string) => String(params[key] ?? key)); + +/** + * Central store for all translation resources. + * + * Each scope (including the global scope) is backed by an Angular `resource()` + * that loads translation data asynchronously. Resources are created lazily on + * first access and cached for the lifetime of the store. + * + * You do not need to inject this service directly — use {@link useTranslation} instead. + * It is exported to allow advanced testing scenarios. + */ +@Injectable() +export class TranslationStore { + private readonly injector = inject(Injector); + private readonly resources = new Map>(); + + constructor() { + const globalLoader = inject(TRANSLATION_LOADER, { optional: true }); + + if (globalLoader) { + this.ensureScope(GLOBAL_SCOPE, globalLoader); + } + } + + /** + * Ensures a resource exists for the given scope. + * If a resource for this scope already exists, this is a no-op. + * + * @param scope - The scope identifier, or `''` for the global namespace + * @param loader - The loader function for this scope + */ + ensureScope(scope: string, loader: TranslationLoader): void { + if (this.resources.has(scope)) { + return; + } + + const ref = runInInjectionContext(this.injector, () => + resource({ + loader: () => loader() + }) + ); + + this.resources.set(scope, ref); + } + + /** + * Returns the loading signal for a specific scope. + * Useful for showing loading indicators. + * + * @param scope - The scope identifier, or `''` for the global namespace + */ + isLoading(scope = GLOBAL_SCOPE): Signal | undefined { + return this.resources.get(scope)?.isLoading; + } + + /** + * Translates a key, optionally interpolating parameters. + * + * This method reads from signal values internally — calling it inside a + * reactive context (template, `computed`, `effect`) will cause a re-evaluation + * when the underlying resource finishes loading. + * + * @param key - A global key (`'title'`) or scoped key (`'scope:key'`) + * @param params - Optional interpolation parameters + * @returns The translated string, or the key itself as a fallback while loading + */ + translate(key: string, params?: TranslationParams): string { + const colonIdx = key.indexOf(':'); + + let scope: string; + let actualKey: string; + + if (colonIdx === -1) { + scope = GLOBAL_SCOPE; + actualKey = key; + } else { + scope = key.slice(0, colonIdx); + actualKey = key.slice(colonIdx + 1); + } + + const data = this.resources.get(scope)?.value(); + const value = data?.[actualKey] ?? key; + + return params ? interpolate(value, params) : value; + } +} diff --git a/libs/composables/i18n/src/tokens/translation.tokens.ts b/libs/composables/i18n/src/tokens/translation.tokens.ts new file mode 100644 index 0000000..80ba79a --- /dev/null +++ b/libs/composables/i18n/src/tokens/translation.tokens.ts @@ -0,0 +1,29 @@ +import { InjectionToken } from '@angular/core'; +import { TranslationLoader } from '../models/translation.types'; + +/** + * Injection token for the global (unscoped) translation loader. + * Provided by {@link provideTranslation}. + */ +export const TRANSLATION_LOADER = new InjectionToken( + '@homj/composables/i18n: global loader' +); + +/** + * Configuration object for a single translation scope. + */ +export interface TranslationScopeConfig { + /** The scope identifier, matching the prefix before `:` in scoped translation keys. */ + scope: string; + /** Loader function for this scope's translation data. */ + loader: TranslationLoader; +} + +/** + * Multi-injection token for scope-level translation loaders. + * Each entry is a {@link TranslationScopeConfig} object. + * Provided by {@link provideTranslationScope}. + */ +export const TRANSLATION_SCOPE = new InjectionToken( + '@homj/composables/i18n: scope config' +); diff --git a/tsconfig.base.json b/tsconfig.base.json index 62d7faf..5c2fd23 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -20,7 +20,8 @@ "@homj/composables/ngxs": ["libs/composables/ngxs/src/index.ts"], "@homj/composables/observer": ["libs/composables/observer/src/index.ts"], "@homj/composables/storage": ["libs/composables/storage/src/index.ts"], - "@homj/composables/title": ["libs/composables/title/src/index.ts"] + "@homj/composables/title": ["libs/composables/title/src/index.ts"], + "@homj/composables/i18n": ["libs/composables/i18n/src/index.ts"] } }, "exclude": ["node_modules", "tmp"] From 3e3c69244830482dc3d3d360fc5a95d164050bdf Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 22 Feb 2026 07:51:43 +0000 Subject: [PATCH 2/6] feat(demo): add i18n demo page with language switching - Extend @homj/composables/i18n with CURRENT_LANGUAGE signal token so any component can switch the active language reactively; all resource scopes reload automatically via the resource request signal - Change TranslationLoader signature to (lang: string) => Promise so loaders can serve the correct language file - Add defaultLang parameter to provideTranslation (defaults to 'en') - Export CURRENT_LANGUAGE from public API - Update specs to provide CURRENT_LANGUAGE in TestBed and use the new loader signature; add a language-switching integration test - Refactor AppComponent to a shell (header nav + router-outlet); extract ButtonsDemoComponent for the existing button demo - Add /i18n route with I18nDemoComponent that demonstrates: * Global scope translations (EN/DE, with interpolation) * Custom scope via ScopedDemoComponent (user-card scope) * Lazy-loaded scope via LazyDemoComponent inside @defer (on interaction) * Error / fallback cases for missing key and unknown scope * Language switcher button (EN / DE) wired to CURRENT_LANGUAGE https://claude.ai/code/session_017w3dyuKpzdFHjg7ng5SeJD --- apps/demo/src/app/app.component.html | 160 +---------------- apps/demo/src/app/app.component.scss | 93 ++++------ apps/demo/src/app/app.component.spec.ts | 22 --- apps/demo/src/app/app.component.ts | 24 +-- apps/demo/src/app/app.config.ts | 9 +- apps/demo/src/app/app.routes.ts | 14 +- apps/demo/src/app/i18n/de.json | 5 + apps/demo/src/app/i18n/en.json | 5 + .../pages/buttons/buttons-demo.component.html | 155 ++++++++++++++++ .../pages/buttons/buttons-demo.component.scss | 69 +++++++ .../pages/buttons/buttons-demo.component.ts | 23 +++ .../i18n-demo/components/lazy/i18n/de.json | 5 + .../i18n-demo/components/lazy/i18n/en.json | 5 + .../components/lazy/lazy-demo.component.ts | 22 +++ .../i18n-demo/components/scoped/i18n/de.json | 5 + .../i18n-demo/components/scoped/i18n/en.json | 5 + .../scoped/scoped-demo.component.ts | 26 +++ .../pages/i18n-demo/i18n-demo.component.html | 82 +++++++++ .../pages/i18n-demo/i18n-demo.component.scss | 170 ++++++++++++++++++ .../pages/i18n-demo/i18n-demo.component.ts | 28 +++ .../demo/src/app/pages/i18n-demo/i18n/de.json | 14 ++ .../demo/src/app/pages/i18n-demo/i18n/en.json | 14 ++ .../use-translation.composable.spec.ts | 8 +- .../composables/use-translation.composable.ts | 2 +- .../i18n/src/models/translation.types.ts | 7 +- .../src/providers/translation.providers.ts | 15 +- libs/composables/i18n/src/public-api.ts | 2 +- .../src/service/translation.store.spec.ts | 49 ++++- .../i18n/src/service/translation.store.ts | 10 +- .../i18n/src/tokens/translation.tokens.ts | 11 +- tsconfig.base.json | 1 + 31 files changed, 777 insertions(+), 283 deletions(-) create mode 100644 apps/demo/src/app/i18n/de.json create mode 100644 apps/demo/src/app/i18n/en.json create mode 100644 apps/demo/src/app/pages/buttons/buttons-demo.component.html create mode 100644 apps/demo/src/app/pages/buttons/buttons-demo.component.scss create mode 100644 apps/demo/src/app/pages/buttons/buttons-demo.component.ts create mode 100644 apps/demo/src/app/pages/i18n-demo/components/lazy/i18n/de.json create mode 100644 apps/demo/src/app/pages/i18n-demo/components/lazy/i18n/en.json create mode 100644 apps/demo/src/app/pages/i18n-demo/components/lazy/lazy-demo.component.ts create mode 100644 apps/demo/src/app/pages/i18n-demo/components/scoped/i18n/de.json create mode 100644 apps/demo/src/app/pages/i18n-demo/components/scoped/i18n/en.json create mode 100644 apps/demo/src/app/pages/i18n-demo/components/scoped/scoped-demo.component.ts create mode 100644 apps/demo/src/app/pages/i18n-demo/i18n-demo.component.html create mode 100644 apps/demo/src/app/pages/i18n-demo/i18n-demo.component.scss create mode 100644 apps/demo/src/app/pages/i18n-demo/i18n-demo.component.ts create mode 100644 apps/demo/src/app/pages/i18n-demo/i18n/de.json create mode 100644 apps/demo/src/app/pages/i18n-demo/i18n/en.json diff --git a/apps/demo/src/app/app.component.html b/apps/demo/src/app/app.component.html index 8c35740..5c2b817 100644 --- a/apps/demo/src/app/app.component.html +++ b/apps/demo/src/app/app.component.html @@ -1,162 +1,12 @@

{{ title() }}

+
- - -
-
-

Old

-

- Using plain Angular APIs (code) -

-
- - Button - -
- -
-
-

New

-

- Using @homj/composables (code) -

-
- - Button - -
+
diff --git a/apps/demo/src/app/app.component.scss b/apps/demo/src/app/app.component.scss index 223e8b9..b264bb9 100644 --- a/apps/demo/src/app/app.component.scss +++ b/apps/demo/src/app/app.component.scss @@ -1,7 +1,7 @@ :host { display: flex; flex-direction: column; - height: 100dvh; + min-height: 100dvh; background-color: var(--surface-1); @media (prefers-reduced-motion: no-preference) { @@ -11,85 +11,56 @@ header { align-items: center; display: flex; + gap: 1.5rem; justify-content: space-between; - margin: 2rem; + padding: 1rem 2rem; + border-bottom: var(--border-size-1) solid var(--surface-3); + + @media (prefers-reduced-motion: no-preference) { + transition: border-color 0.3s var(--ease-1); + } h1 { max-inline-size: unset; + font-size: var(--font-size-fluid-1); @media (prefers-reduced-motion: no-preference) { transition: color 0.3s var(--ease-1); } } - } - - main { - align-items: stretch; - display: flex; - flex-direction: row; - flex-wrap: wrap; - gap: 2rem; - justify-content: center; - margin-block: auto; - padding: 4rem; - aside { - background-color: white; - border-radius: 1rem; - padding: 0.5rem 1rem 1rem; - - form { - align-items: stretch; - display: flex; - flex-direction: column; - gap: 1rem; - justify-content: center; - } + nav { + display: flex; + gap: 0.25rem; + flex: 1; - fieldset { - display: grid; - gap: 1rem; - min-width: 10rem; + a { + border-radius: var(--radius-2); + color: var(--text-2); + font-weight: var(--font-weight-5); + padding: 0.375rem 0.75rem; + text-decoration: none; - @media (prefers-reduced-motion: no-preference) { - transition: color 0.3s var(--ease-1), border-color 0.3s var(--ease-1); + &:hover { + color: var(--text-1); + background-color: var(--surface-3); } - } - } - .output { - align-items: center; - display: flex; - justify-content: center; - min-height: 15rem; - width: 25rem; - max-width: 100%; - position: relative; - - header { - display: flex; - flex-direction: column; - inset: 1rem 0.5rem auto 1rem; - position: absolute; - - h2 { - font-size: var(--font-size-fluid-1); + &.active { + color: var(--brand); + background-color: var(--surface-2); } - p { - color: var(--text-2); + @media (prefers-reduced-motion: no-preference) { + transition: color 0.2s var(--ease-1), background-color 0.2s var(--ease-1); } } } + } - .card { - background-color: var(--surface-2); - border: var(--border-size-1) solid var(--surface-3); - border-radius: var(--radius-conditional-3); - - @media (prefers-reduced-motion: no-preference) { - transition: background-color 0.3s var(--ease-1), border-color 0.3s var(--ease-1); - } - } + main { + display: flex; + flex: 1; + flex-direction: column; } } diff --git a/apps/demo/src/app/app.component.spec.ts b/apps/demo/src/app/app.component.spec.ts index 0cf2e75..7ffb85e 100644 --- a/apps/demo/src/app/app.component.spec.ts +++ b/apps/demo/src/app/app.component.spec.ts @@ -17,22 +17,6 @@ describe('AppComponent', () => { fixture.detectChanges(); }); - describe('counter', () => { - it('should initially be 0', () => { - expect(fixture.componentInstance.counter()).toBe(0); - }); - - it('should be incremented by 1 after calling incrementCounter', () => { - fixture.componentInstance.incrementCounter(); - - expect(fixture.componentInstance.counter()).toBe(1); - - fixture.componentInstance.incrementCounter(); - - expect(fixture.componentInstance.counter()).toBe(2); - }); - }); - describe('title', () => { it(`should have the correct title`, () => { expect(fixture.componentInstance.title()).toEqual('@homj/composables'); @@ -47,12 +31,6 @@ describe('AppComponent', () => { it('should bind the title to the document', () => { expect(document.title).toContain('@homj/composables'); }); - - it('should include the click counter after the first click', () => { - fixture.componentInstance.incrementCounter(); - - expect(fixture.componentInstance.title()).toEqual('@homj/composables - Clicks: 1'); - }); }); it('should render the color-scheme-switch component', () => { diff --git a/apps/demo/src/app/app.component.ts b/apps/demo/src/app/app.component.ts index 8708dba..f54ecad 100644 --- a/apps/demo/src/app/app.component.ts +++ b/apps/demo/src/app/app.component.ts @@ -1,29 +1,17 @@ -import { Component, computed, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; import { RouterModule } from '@angular/router'; import { bindTitle } from '@homj/composables/title'; +import { signal } from '@angular/core'; -import { ButtonAppearance, ButtonColor, ButtonComponent } from './components/button/button.component'; import { ColorSchemeSwitchComponent } from './components/color-scheme-switch/color-scheme-switch.component'; -import { OldButtonComponent } from './components/old-button/old-button.component'; @Component({ - imports: [RouterModule, ButtonComponent, ColorSchemeSwitchComponent, OldButtonComponent], + imports: [RouterModule, ColorSchemeSwitchComponent], selector: 'demo-root', templateUrl: './app.component.html', - styleUrls: ['./app.component.scss'] + styleUrls: ['./app.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush }) export class AppComponent { - readonly counter = signal(0); - readonly title = bindTitle( - computed(() => (this.counter() ? `@homj/composables - Clicks: ${this.counter()}` : '@homj/composables')) - ); - - readonly disabled = signal(false); - readonly loading = signal(false); - readonly appearance = signal('solid'); - readonly color = signal(undefined); - - incrementCounter() { - this.counter.update((value) => value + 1); - } + readonly title = bindTitle(signal('@homj/composables')); } diff --git a/apps/demo/src/app/app.config.ts b/apps/demo/src/app/app.config.ts index 00f53d2..f220657 100644 --- a/apps/demo/src/app/app.config.ts +++ b/apps/demo/src/app/app.config.ts @@ -1,8 +1,15 @@ import { ApplicationConfig } from '@angular/core'; import { provideRouter, withEnabledBlockingInitialNavigation } from '@angular/router'; +import { provideTranslation } from '@homj/composables/i18n'; import { appRoutes } from './app.routes'; export const appConfig: ApplicationConfig = { - providers: [ provideRouter(appRoutes, withEnabledBlockingInitialNavigation()) ] + providers: [ + provideRouter(appRoutes, withEnabledBlockingInitialNavigation()), + provideTranslation( + (lang) => import(`./i18n/${lang}.json`).then((m) => m.default), + 'en' + ) + ] }; diff --git a/apps/demo/src/app/app.routes.ts b/apps/demo/src/app/app.routes.ts index 8762dfe..159d5ee 100644 --- a/apps/demo/src/app/app.routes.ts +++ b/apps/demo/src/app/app.routes.ts @@ -1,3 +1,15 @@ import { Route } from '@angular/router'; -export const appRoutes: Route[] = []; +export const appRoutes: Route[] = [ + { path: '', redirectTo: 'buttons', pathMatch: 'full' }, + { + path: 'buttons', + loadComponent: () => + import('./pages/buttons/buttons-demo.component').then((m) => m.ButtonsDemoComponent) + }, + { + path: 'i18n', + loadComponent: () => + import('./pages/i18n-demo/i18n-demo.component').then((m) => m.I18nDemoComponent) + } +]; diff --git a/apps/demo/src/app/i18n/de.json b/apps/demo/src/app/i18n/de.json new file mode 100644 index 0000000..6fd8fe3 --- /dev/null +++ b/apps/demo/src/app/i18n/de.json @@ -0,0 +1,5 @@ +{ + "hello": "Hallo, {{ name }}!", + "welcome": "Willkommen zur Übersetzungs-Demo.", + "language": "Sprache" +} diff --git a/apps/demo/src/app/i18n/en.json b/apps/demo/src/app/i18n/en.json new file mode 100644 index 0000000..b8b3a35 --- /dev/null +++ b/apps/demo/src/app/i18n/en.json @@ -0,0 +1,5 @@ +{ + "hello": "Hello, {{ name }}!", + "welcome": "Welcome to the translations demo.", + "language": "Language" +} diff --git a/apps/demo/src/app/pages/buttons/buttons-demo.component.html b/apps/demo/src/app/pages/buttons/buttons-demo.component.html new file mode 100644 index 0000000..35a82af --- /dev/null +++ b/apps/demo/src/app/pages/buttons/buttons-demo.component.html @@ -0,0 +1,155 @@ + + +
+
+

Old

+

+ Using plain Angular APIs (code) +

+
+ + Button + +
+ +
+
+

New

+

+ Using @homj/composables (code) +

+
+ + Button + +
diff --git a/apps/demo/src/app/pages/buttons/buttons-demo.component.scss b/apps/demo/src/app/pages/buttons/buttons-demo.component.scss new file mode 100644 index 0000000..ce39401 --- /dev/null +++ b/apps/demo/src/app/pages/buttons/buttons-demo.component.scss @@ -0,0 +1,69 @@ +:host { + align-items: stretch; + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 2rem; + justify-content: center; + margin-block: auto; + padding: 4rem; + + aside { + background-color: white; + border-radius: 1rem; + padding: 0.5rem 1rem 1rem; + + form { + align-items: stretch; + display: flex; + flex-direction: column; + gap: 1rem; + justify-content: center; + } + + fieldset { + display: grid; + gap: 1rem; + min-width: 10rem; + + @media (prefers-reduced-motion: no-preference) { + transition: color 0.3s var(--ease-1), border-color 0.3s var(--ease-1); + } + } + } + + .output { + align-items: center; + display: flex; + justify-content: center; + min-height: 15rem; + width: 25rem; + max-width: 100%; + position: relative; + + header { + display: flex; + flex-direction: column; + inset: 1rem 0.5rem auto 1rem; + position: absolute; + + h2 { + font-size: var(--font-size-fluid-1); + } + + p { + color: var(--text-2); + } + } + } + + .card { + background-color: var(--surface-2); + border: var(--border-size-1) solid var(--surface-3); + border-radius: var(--radius-conditional-3); + + @media (prefers-reduced-motion: no-preference) { + transition: background-color 0.3s var(--ease-1), border-color 0.3s var(--ease-1); + } + } +} diff --git a/apps/demo/src/app/pages/buttons/buttons-demo.component.ts b/apps/demo/src/app/pages/buttons/buttons-demo.component.ts new file mode 100644 index 0000000..6eaa1dd --- /dev/null +++ b/apps/demo/src/app/pages/buttons/buttons-demo.component.ts @@ -0,0 +1,23 @@ +import { ChangeDetectionStrategy, Component, signal } from '@angular/core'; + +import { ButtonAppearance, ButtonColor, ButtonComponent } from '../../components/button/button.component'; +import { OldButtonComponent } from '../../components/old-button/old-button.component'; + +@Component({ + selector: 'demo-buttons-demo', + imports: [ButtonComponent, OldButtonComponent], + templateUrl: './buttons-demo.component.html', + styleUrls: ['./buttons-demo.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class ButtonsDemoComponent { + readonly counter = signal(0); + readonly disabled = signal(false); + readonly loading = signal(false); + readonly appearance = signal('solid'); + readonly color = signal(undefined); + + incrementCounter() { + this.counter.update((value) => value + 1); + } +} diff --git a/apps/demo/src/app/pages/i18n-demo/components/lazy/i18n/de.json b/apps/demo/src/app/pages/i18n-demo/components/lazy/i18n/de.json new file mode 100644 index 0000000..47b2036 --- /dev/null +++ b/apps/demo/src/app/pages/i18n-demo/components/lazy/i18n/de.json @@ -0,0 +1,5 @@ +{ + "title": "Lazy-Komponente geladen!", + "message": "Diese Komponente und ihr Übersetzungsbereich wurden bei Bedarf abgerufen — erst als der Nutzer es ausgelöst hat.", + "current-lang": "Aktuelle Sprache: {{ lang }}" +} diff --git a/apps/demo/src/app/pages/i18n-demo/components/lazy/i18n/en.json b/apps/demo/src/app/pages/i18n-demo/components/lazy/i18n/en.json new file mode 100644 index 0000000..7da07fd --- /dev/null +++ b/apps/demo/src/app/pages/i18n-demo/components/lazy/i18n/en.json @@ -0,0 +1,5 @@ +{ + "title": "Lazy Component Loaded!", + "message": "This component and its translation scope were fetched on demand — only when the user triggered it.", + "current-lang": "Active language: {{ lang }}" +} diff --git a/apps/demo/src/app/pages/i18n-demo/components/lazy/lazy-demo.component.ts b/apps/demo/src/app/pages/i18n-demo/components/lazy/lazy-demo.component.ts new file mode 100644 index 0000000..8b59ace --- /dev/null +++ b/apps/demo/src/app/pages/i18n-demo/components/lazy/lazy-demo.component.ts @@ -0,0 +1,22 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { CURRENT_LANGUAGE, provideTranslationScope, useTranslation } from '@homj/composables/i18n'; + +@Component({ + selector: 'demo-lazy-demo', + providers: [ + provideTranslationScope( + 'lazy-scope', + (lang) => import(`./i18n/${lang}.json`).then((m) => m.default) + ) + ], + template: ` +

{{ t('lazy-scope:title') }}

+

{{ t('lazy-scope:message') }}

+

{{ t('lazy-scope:current-lang', { lang: lang() }) }}

+ `, + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class LazyDemoComponent { + protected readonly t = useTranslation(); + protected readonly lang = inject(CURRENT_LANGUAGE); +} diff --git a/apps/demo/src/app/pages/i18n-demo/components/scoped/i18n/de.json b/apps/demo/src/app/pages/i18n-demo/components/scoped/i18n/de.json new file mode 100644 index 0000000..dfe43fb --- /dev/null +++ b/apps/demo/src/app/pages/i18n-demo/components/scoped/i18n/de.json @@ -0,0 +1,5 @@ +{ + "name": "Jana Mustermann", + "role": "Senior-Ingenieurin", + "bio": "Leidenschaftlich für sauberen Code und signalbasierte Reaktivität." +} diff --git a/apps/demo/src/app/pages/i18n-demo/components/scoped/i18n/en.json b/apps/demo/src/app/pages/i18n-demo/components/scoped/i18n/en.json new file mode 100644 index 0000000..45ad96d --- /dev/null +++ b/apps/demo/src/app/pages/i18n-demo/components/scoped/i18n/en.json @@ -0,0 +1,5 @@ +{ + "name": "Jane Doe", + "role": "Senior Engineer", + "bio": "Passionate about clean code and signal-based reactivity." +} diff --git a/apps/demo/src/app/pages/i18n-demo/components/scoped/scoped-demo.component.ts b/apps/demo/src/app/pages/i18n-demo/components/scoped/scoped-demo.component.ts new file mode 100644 index 0000000..e0b9e4a --- /dev/null +++ b/apps/demo/src/app/pages/i18n-demo/components/scoped/scoped-demo.component.ts @@ -0,0 +1,26 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { provideTranslationScope, useTranslation } from '@homj/composables/i18n'; + +@Component({ + selector: 'demo-scoped-demo', + providers: [ + provideTranslationScope( + 'user-card', + (lang) => import(`./i18n/${lang}.json`).then((m) => m.default) + ) + ], + template: ` +
+
Name
+
{{ t('user-card:name') }}
+
Role
+
{{ t('user-card:role') }}
+
Bio
+
{{ t('user-card:bio') }}
+
+ `, + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class ScopedDemoComponent { + protected readonly t = useTranslation(); +} diff --git a/apps/demo/src/app/pages/i18n-demo/i18n-demo.component.html b/apps/demo/src/app/pages/i18n-demo/i18n-demo.component.html new file mode 100644 index 0000000..9ff74fd --- /dev/null +++ b/apps/demo/src/app/pages/i18n-demo/i18n-demo.component.html @@ -0,0 +1,82 @@ + + +
+ +
+
+

{{ t('i18n-demo:global-section') }}

+

{{ t('i18n-demo:global-hint') }}

+
+ +
+
t('hello', { name: 'World' })
+
{{ t('hello', { name: 'World' }) }}
+ +
t('welcome')
+
{{ t('welcome') }}
+
+
+ + +
+
+

{{ t('i18n-demo:custom-section') }}

+

{{ t('i18n-demo:custom-hint') }}

+
+ + +
+ + +
+
+

{{ t('i18n-demo:lazy-section') }}

+

{{ t('i18n-demo:lazy-hint') }}

+
+ + @defer (on interaction) { + + } @placeholder { + + } +
+ + +
+
+

{{ t('i18n-demo:error-section') }}

+
+ +
+
+ {{ t('i18n-demo:error-missing-key-label') }} + t('nonexistent-key') +
+
{{ t('nonexistent-key') }}
+ +
+ {{ t('i18n-demo:error-missing-scope-label') }} + t('unknown-scope:foo') +
+
{{ t('unknown-scope:foo') }}
+
+
+
diff --git a/apps/demo/src/app/pages/i18n-demo/i18n-demo.component.scss b/apps/demo/src/app/pages/i18n-demo/i18n-demo.component.scss new file mode 100644 index 0000000..a59b5c5 --- /dev/null +++ b/apps/demo/src/app/pages/i18n-demo/i18n-demo.component.scss @@ -0,0 +1,170 @@ +:host { + display: flex; + flex-direction: column; + gap: 2rem; + padding: 2rem; + max-width: 56rem; + margin-inline: auto; + width: 100%; + box-sizing: border-box; +} + +// ── Page header ──────────────────────────────────────────── +.page-header { + align-items: flex-start; + display: flex; + flex-wrap: wrap; + gap: 1rem; + justify-content: space-between; + + &__titles { + h2 { + font-size: var(--font-size-fluid-2); + max-inline-size: unset; + } + + p { + color: var(--text-2); + margin-block-start: 0.25rem; + } + } +} + +// ── Language switcher ────────────────────────────────────── +.lang-switcher { + align-items: center; + display: flex; + gap: 0.5rem; + + &__label { + color: var(--text-2); + font-size: var(--font-size-0); + } +} + +.lang-btn { + background: var(--surface-3); + border: var(--border-size-1) solid transparent; + border-radius: var(--radius-2); + color: var(--text-2); + cursor: pointer; + font-size: var(--font-size-0); + font-weight: var(--font-weight-6); + padding: 0.25rem 0.75rem; + + &:hover { + background: var(--surface-4); + color: var(--text-1); + } + + &--active { + background: transparent; + border-color: var(--brand); + color: var(--brand); + } + + @media (prefers-reduced-motion: no-preference) { + transition: background-color 0.15s var(--ease-1), color 0.15s var(--ease-1); + } +} + +// ── Section grid ─────────────────────────────────────────── +.sections { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +// ── Card ─────────────────────────────────────────────────── +.card { + background-color: var(--surface-2); + border: var(--border-size-1) solid var(--surface-3); + border-radius: var(--radius-conditional-3); + display: flex; + flex-direction: column; + gap: 1rem; + padding: 1.5rem; + + @media (prefers-reduced-motion: no-preference) { + transition: background-color 0.3s var(--ease-1), border-color 0.3s var(--ease-1); + } + + header { + h3 { + font-size: var(--font-size-2); + max-inline-size: unset; + } + + .hint { + color: var(--text-2); + font-size: var(--font-size-0); + margin-block-start: 0.25rem; + } + } + + &--error { + border-color: var(--red-4); + } +} + +// ── Definition list ──────────────────────────────────────── +dl { + display: grid; + gap: 0.5rem 1rem; + grid-template-columns: auto 1fr; + align-items: baseline; + + dt { + color: var(--text-2); + display: flex; + flex-direction: column; + gap: 0.125rem; + font-size: var(--font-size-0); + + .label { + font-style: italic; + } + + code { + font-size: 0.8em; + } + } + + dd { + font-weight: var(--font-weight-5); + margin: 0; + } + + .fallback { + color: var(--red-6); + font-family: var(--font-mono); + font-size: var(--font-size-0); + font-weight: var(--font-weight-4); + } +} + +// ── Lazy trigger button ──────────────────────────────────── +.load-btn { + background: var(--brand); + border: none; + border-radius: var(--radius-2); + color: var(--text-inverted-1); + cursor: pointer; + font-weight: var(--font-weight-6); + padding: 0.5rem 1.25rem; + + &:hover { + opacity: 0.85; + } + + @media (prefers-reduced-motion: no-preference) { + transition: opacity 0.15s var(--ease-1); + } +} + +// ── Lazy demo styles (shared via host) ───────────────────── +demo-lazy-demo { + display: flex; + flex-direction: column; + gap: 0.5rem; +} diff --git a/apps/demo/src/app/pages/i18n-demo/i18n-demo.component.ts b/apps/demo/src/app/pages/i18n-demo/i18n-demo.component.ts new file mode 100644 index 0000000..6a95b3d --- /dev/null +++ b/apps/demo/src/app/pages/i18n-demo/i18n-demo.component.ts @@ -0,0 +1,28 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { CURRENT_LANGUAGE, provideTranslationScope, useTranslation } from '@homj/composables/i18n'; + +import { ScopedDemoComponent } from './components/scoped/scoped-demo.component'; +import { LazyDemoComponent } from './components/lazy/lazy-demo.component'; + +@Component({ + selector: 'demo-i18n-demo', + imports: [ScopedDemoComponent, LazyDemoComponent], + providers: [ + provideTranslationScope( + 'i18n-demo', + (lang) => import(`./i18n/${lang}.json`).then((m) => m.default) + ) + ], + templateUrl: './i18n-demo.component.html', + styleUrls: ['./i18n-demo.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class I18nDemoComponent { + protected readonly lang = inject(CURRENT_LANGUAGE); + protected readonly t = useTranslation(); + + protected readonly languages = [ + { code: 'en', label: 'EN' }, + { code: 'de', label: 'DE' } + ] as const; +} diff --git a/apps/demo/src/app/pages/i18n-demo/i18n/de.json b/apps/demo/src/app/pages/i18n-demo/i18n/de.json new file mode 100644 index 0000000..69f5af4 --- /dev/null +++ b/apps/demo/src/app/pages/i18n-demo/i18n/de.json @@ -0,0 +1,14 @@ +{ + "title": "Übersetzungs-Demo", + "subtitle": "Signalbasierte Übersetzungen mit Angulars Resource API", + "global-section": "Globaler Bereich", + "global-hint": "Schlüssel im globalen Namensraum, registriert via provideTranslation()", + "custom-section": "Benutzerdefinierter Bereich", + "custom-hint": "Die Komponente unten registriert ihren eigenen Bereich via provideTranslationScope()", + "lazy-section": "Lazy geladener Bereich", + "lazy-hint": "Klicke den Button, um die Komponente und ihren Übersetzungsbereich per Defer zu laden", + "lazy-trigger": "Komponente laden", + "error-section": "Fallback / Fehlerfälle", + "error-missing-key-label": "Fehlender Schlüssel im globalen Bereich", + "error-missing-scope-label": "Unbekannter Bereich" +} diff --git a/apps/demo/src/app/pages/i18n-demo/i18n/en.json b/apps/demo/src/app/pages/i18n-demo/i18n/en.json new file mode 100644 index 0000000..aa42186 --- /dev/null +++ b/apps/demo/src/app/pages/i18n-demo/i18n/en.json @@ -0,0 +1,14 @@ +{ + "title": "Translation Demo", + "subtitle": "Signal-based translations powered by Angular's resource API", + "global-section": "Global scope", + "global-hint": "Keys looked up in the global namespace registered via provideTranslation()", + "custom-section": "Custom scope", + "custom-hint": "The component below registers its own scope via provideTranslationScope()", + "lazy-section": "Lazy-loaded scope", + "lazy-hint": "Click the button to defer-load the component and its translation scope", + "lazy-trigger": "Load component", + "error-section": "Fallback / error cases", + "error-missing-key-label": "Missing key in global scope", + "error-missing-scope-label": "Unknown scope" +} diff --git a/libs/composables/i18n/src/composables/use-translation.composable.spec.ts b/libs/composables/i18n/src/composables/use-translation.composable.spec.ts index 7649844..700e5cb 100644 --- a/libs/composables/i18n/src/composables/use-translation.composable.spec.ts +++ b/libs/composables/i18n/src/composables/use-translation.composable.spec.ts @@ -62,7 +62,7 @@ describe('useTranslation', () => { it('should register scope loaders from TRANSLATION_SCOPE token', () => { const ensureScopeSpy = jest.fn(); - const loader = jest.fn().mockResolvedValue(SCOPE_DATA); + const loader = jest.fn((_lang: string) => Promise.resolve(SCOPE_DATA)); TestBed.configureTestingModule({ providers: [ @@ -80,8 +80,8 @@ describe('useTranslation', () => { it('should register multiple scope loaders', () => { const ensureScopeSpy = jest.fn(); - const loaderA = jest.fn().mockResolvedValue({}); - const loaderB = jest.fn().mockResolvedValue({}); + const loaderA = jest.fn((_lang: string) => Promise.resolve({})); + const loaderB = jest.fn((_lang: string) => Promise.resolve({})); TestBed.configureTestingModule({ providers: [ @@ -155,7 +155,7 @@ describe('useTranslation', () => { @Component({ template: `

{{ t('my-scope:description') }}

`, standalone: true, - providers: [provideTranslationScope('my-scope', () => Promise.resolve(SCOPE_DATA))] + providers: [provideTranslationScope('my-scope', (_lang: string) => Promise.resolve(SCOPE_DATA))] }) class ScopedComponent { t = useTranslation(); diff --git a/libs/composables/i18n/src/composables/use-translation.composable.ts b/libs/composables/i18n/src/composables/use-translation.composable.ts index b9c0a1d..32a1360 100644 --- a/libs/composables/i18n/src/composables/use-translation.composable.ts +++ b/libs/composables/i18n/src/composables/use-translation.composable.ts @@ -44,7 +44,7 @@ import { TranslationStore } from '../service/translation.store'; * @Component({ * selector: 'my-component', * providers: [ - * provideTranslationScope('my-component', () => import('./i18n/en.json')) + * provideTranslationScope('my-component', (lang) => import(`./i18n/${lang}.json`).then(m => m.default)) * ], * template: `

{{ t('my-component:description') }}

` * }) diff --git a/libs/composables/i18n/src/models/translation.types.ts b/libs/composables/i18n/src/models/translation.types.ts index cfe432d..a909bd5 100644 --- a/libs/composables/i18n/src/models/translation.types.ts +++ b/libs/composables/i18n/src/models/translation.types.ts @@ -4,10 +4,11 @@ export type TranslationData = Record; /** - * A function that asynchronously loads translation data for a scope or the global namespace. - * Intended to be used with dynamic imports, e.g. `() => import('./i18n/en.json')`. + * A function that asynchronously loads translation data for a given language. + * Receives the active language tag (e.g. `'en'`, `'de'`) and returns translation data. + * Intended to be used with dynamic imports, e.g. `(lang) => import('./i18n/${lang}.json').then(m => m.default)`. */ -export type TranslationLoader = () => Promise; +export type TranslationLoader = (lang: string) => Promise; /** * Parameters used for interpolating values into a translated string. diff --git a/libs/composables/i18n/src/providers/translation.providers.ts b/libs/composables/i18n/src/providers/translation.providers.ts index 5fa6337..a1ca3ff 100644 --- a/libs/composables/i18n/src/providers/translation.providers.ts +++ b/libs/composables/i18n/src/providers/translation.providers.ts @@ -1,6 +1,6 @@ -import { EnvironmentProviders, makeEnvironmentProviders, Provider } from '@angular/core'; +import { EnvironmentProviders, makeEnvironmentProviders, Provider, signal } from '@angular/core'; import { TranslationLoader } from '../models/translation.types'; -import { TRANSLATION_LOADER, TRANSLATION_SCOPE } from '../tokens/translation.tokens'; +import { CURRENT_LANGUAGE, TRANSLATION_LOADER, TRANSLATION_SCOPE } from '../tokens/translation.tokens'; import { TranslationStore } from '../service/translation.store'; /** @@ -16,7 +16,10 @@ import { TranslationStore } from '../service/translation.store'; * // app.config.ts * export const appConfig: ApplicationConfig = { * providers: [ - * provideTranslation(() => import('./i18n/en.json')) + * provideTranslation( + * (lang) => import(`./i18n/${lang}.json`).then(m => m.default), + * 'en' + * ) * ] * }; * ``` @@ -28,11 +31,13 @@ import { TranslationStore } from '../service/translation.store'; * ``` * * @param loader - Optional loader for the global (unscoped) translations + * @param defaultLang - The initial active language tag (defaults to `'en'`) * @returns Environment providers for the translation system */ -export function provideTranslation(loader?: TranslationLoader): EnvironmentProviders { +export function provideTranslation(loader?: TranslationLoader, defaultLang = 'en'): EnvironmentProviders { return makeEnvironmentProviders([ TranslationStore, + { provide: CURRENT_LANGUAGE, useValue: signal(defaultLang) }, ...(loader ? [{ provide: TRANSLATION_LOADER, useValue: loader }] : []) ]); } @@ -51,7 +56,7 @@ export function provideTranslation(loader?: TranslationLoader): EnvironmentProvi * @Component({ * selector: 'my-component', * providers: [ - * provideTranslationScope('my-component', () => import('./i18n/en.json')) + * provideTranslationScope('my-component', (lang) => import(`./i18n/${lang}.json`).then(m => m.default)) * ], * template: ` *

{{ t('title') }}

diff --git a/libs/composables/i18n/src/public-api.ts b/libs/composables/i18n/src/public-api.ts index 7d64d04..6f35653 100644 --- a/libs/composables/i18n/src/public-api.ts +++ b/libs/composables/i18n/src/public-api.ts @@ -7,7 +7,7 @@ */ export { TranslationData, TranslationLoader, TranslationParams, TranslateFn } from './models/translation.types'; -export { TRANSLATION_LOADER, TRANSLATION_SCOPE, TranslationScopeConfig } from './tokens/translation.tokens'; +export { CURRENT_LANGUAGE, TRANSLATION_LOADER, TRANSLATION_SCOPE, TranslationScopeConfig } from './tokens/translation.tokens'; export { TranslationStore } from './service/translation.store'; export { provideTranslation, provideTranslationScope } from './providers/translation.providers'; export { useTranslation } from './composables/use-translation.composable'; diff --git a/libs/composables/i18n/src/service/translation.store.spec.ts b/libs/composables/i18n/src/service/translation.store.spec.ts index 2ed3af6..d77a68c 100644 --- a/libs/composables/i18n/src/service/translation.store.spec.ts +++ b/libs/composables/i18n/src/service/translation.store.spec.ts @@ -1,18 +1,21 @@ +import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { TranslationStore } from './translation.store'; -import { TRANSLATION_LOADER } from '../tokens/translation.tokens'; +import { CURRENT_LANGUAGE, TRANSLATION_LOADER } from '../tokens/translation.tokens'; import { TranslationData } from '../models/translation.types'; const GLOBAL_DATA: TranslationData = { title: 'Hello World', greeting: 'Hello, {{ name }}!' }; const SCOPE_DATA: TranslationData = { description: 'A description', label: 'Label' }; -const resolvedLoader = (data: TranslationData) => () => Promise.resolve(data); +const resolvedLoader = (data: TranslationData) => (_lang: string) => Promise.resolve(data); + +const withLang = (lang = 'en') => ({ provide: CURRENT_LANGUAGE, useValue: signal(lang) }); describe('TranslationStore', () => { describe('without a global loader', () => { beforeEach(() => { TestBed.configureTestingModule({ - providers: [TranslationStore] + providers: [TranslationStore, withLang()] }); }); @@ -40,6 +43,7 @@ describe('TranslationStore', () => { TestBed.configureTestingModule({ providers: [ TranslationStore, + withLang(), { provide: TRANSLATION_LOADER, useValue: resolvedLoader(GLOBAL_DATA) } ] }); @@ -85,7 +89,7 @@ describe('TranslationStore', () => { describe('ensureScope', () => { beforeEach(() => { TestBed.configureTestingModule({ - providers: [TranslationStore] + providers: [TranslationStore, withLang()] }); }); @@ -100,13 +104,16 @@ describe('TranslationStore', () => { expect(store.translate('my-scope:description')).toEqual('A description'); }); - it('should not create a second resource when called again with the same scope', () => { + it('should not create a second resource when called again with the same scope', async () => { const store = TestBed.inject(TranslationStore); const loader = jest.fn().mockResolvedValue(SCOPE_DATA); store.ensureScope('my-scope', loader); store.ensureScope('my-scope', loader); + await new Promise(resolve => setTimeout(resolve, 0)); + TestBed.flushEffects(); + expect(loader).toHaveBeenCalledTimes(1); }); @@ -122,7 +129,7 @@ describe('TranslationStore', () => { describe('translate — key parsing', () => { beforeEach(() => { TestBed.configureTestingModule({ - providers: [TranslationStore] + providers: [TranslationStore, withLang()] }); }); @@ -155,6 +162,7 @@ describe('TranslationStore', () => { TestBed.configureTestingModule({ providers: [ TranslationStore, + withLang(), { provide: TRANSLATION_LOADER, useValue: resolvedLoader(GLOBAL_DATA) } ] }); @@ -190,4 +198,33 @@ describe('TranslationStore', () => { expect(store.translate('count', { n: 42 })).toEqual('Total: 42'); }); }); + + describe('language switching', () => { + it('should reload translations when the language changes', async () => { + const lang = signal('en'); + const enData: TranslationData = { hello: 'Hello' }; + const deData: TranslationData = { hello: 'Hallo' }; + const loader = jest.fn((l: string) => Promise.resolve(l === 'de' ? deData : enData)); + + TestBed.configureTestingModule({ + providers: [ + TranslationStore, + { provide: CURRENT_LANGUAGE, useValue: lang }, + { provide: TRANSLATION_LOADER, useValue: loader } + ] + }); + + const store = TestBed.inject(TranslationStore); + + await new Promise(resolve => setTimeout(resolve, 0)); + TestBed.flushEffects(); + expect(store.translate('hello')).toEqual('Hello'); + + lang.set('de'); + + await new Promise(resolve => setTimeout(resolve, 0)); + TestBed.flushEffects(); + expect(store.translate('hello')).toEqual('Hallo'); + }); + }); }); diff --git a/libs/composables/i18n/src/service/translation.store.ts b/libs/composables/i18n/src/service/translation.store.ts index bf6ba9c..fc9c895 100644 --- a/libs/composables/i18n/src/service/translation.store.ts +++ b/libs/composables/i18n/src/service/translation.store.ts @@ -1,6 +1,6 @@ -import { inject, Injectable, Injector, resource, ResourceRef, runInInjectionContext, Signal } from '@angular/core'; +import { inject, Injectable, Injector, resource, ResourceRef, runInInjectionContext, Signal, WritableSignal } from '@angular/core'; import { TranslationData, TranslationLoader, TranslationParams } from '../models/translation.types'; -import { TRANSLATION_LOADER } from '../tokens/translation.tokens'; +import { CURRENT_LANGUAGE, TRANSLATION_LOADER } from '../tokens/translation.tokens'; /** Sentinel key used internally for the global (unscoped) translation namespace. */ const GLOBAL_SCOPE = ''; @@ -25,6 +25,7 @@ const interpolate = (value: string, params: TranslationParams): string => @Injectable() export class TranslationStore { private readonly injector = inject(Injector); + private readonly lang: WritableSignal = inject(CURRENT_LANGUAGE); private readonly resources = new Map>(); constructor() { @@ -48,8 +49,9 @@ export class TranslationStore { } const ref = runInInjectionContext(this.injector, () => - resource({ - loader: () => loader() + resource({ + request: () => this.lang(), + loader: ({ request: lang }) => loader(lang) }) ); diff --git a/libs/composables/i18n/src/tokens/translation.tokens.ts b/libs/composables/i18n/src/tokens/translation.tokens.ts index 80ba79a..b2c86ba 100644 --- a/libs/composables/i18n/src/tokens/translation.tokens.ts +++ b/libs/composables/i18n/src/tokens/translation.tokens.ts @@ -1,4 +1,4 @@ -import { InjectionToken } from '@angular/core'; +import { InjectionToken, WritableSignal } from '@angular/core'; import { TranslationLoader } from '../models/translation.types'; /** @@ -9,6 +9,15 @@ export const TRANSLATION_LOADER = new InjectionToken( '@homj/composables/i18n: global loader' ); +/** + * Injection token for the active language signal. + * Holds a {@link WritableSignal} so any component can switch the language reactively. + * Provided by {@link provideTranslation}. + */ +export const CURRENT_LANGUAGE = new InjectionToken>( + '@homj/composables/i18n: current language' +); + /** * Configuration object for a single translation scope. */ diff --git a/tsconfig.base.json b/tsconfig.base.json index 5c2fd23..0fd4ab2 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -13,6 +13,7 @@ "lib": ["es2020", "dom"], "skipLibCheck": true, "skipDefaultLibCheck": true, + "resolveJsonModule": true, "baseUrl": ".", "paths": { "@homj/composables/attribute": ["libs/composables/attribute/src/index.ts"], From 51032babcc0b02d69c85b8bca2ef38c7ac1fe29d Mon Sep 17 00:00:00 2001 From: Johannes Homeier Date: Sun, 22 Feb 2026 08:34:19 +0100 Subject: [PATCH 3/6] chore(composables): rebased --- .../composables/use-translation.composable.ts | 25 ++++- libs/composables/i18n/src/models/language.ts | 4 + .../i18n/src/models/maybe-signal.ts | 3 + .../i18n/src/models/translation.types.ts | 11 +- .../i18n/src/service/translation.store.ts | 101 +++++++++++++----- .../src/tokens/defualt-language.tokens.ts | 4 + .../i18n/src/utils/resolve-signal-value.ts | 5 + 7 files changed, 120 insertions(+), 33 deletions(-) create mode 100644 libs/composables/i18n/src/models/language.ts create mode 100644 libs/composables/i18n/src/models/maybe-signal.ts create mode 100644 libs/composables/i18n/src/tokens/defualt-language.tokens.ts create mode 100644 libs/composables/i18n/src/utils/resolve-signal-value.ts diff --git a/libs/composables/i18n/src/composables/use-translation.composable.ts b/libs/composables/i18n/src/composables/use-translation.composable.ts index 32a1360..e258028 100644 --- a/libs/composables/i18n/src/composables/use-translation.composable.ts +++ b/libs/composables/i18n/src/composables/use-translation.composable.ts @@ -1,7 +1,9 @@ -import { inject } from '@angular/core'; -import { TranslateFn } from '../models/translation.types'; +import { computed, inject, isSignal } from '@angular/core'; +import { MaybeSignal } from '../models/maybe-signal'; +import { ScopedTranslateFn, TranslateFn, TranslationParams } from '../models/translation.types'; import { TRANSLATION_SCOPE, TranslationScopeConfig } from '../tokens/translation.tokens'; import { TranslationStore } from '../service/translation.store'; +import { resolveSignalValue } from '../utils/resolve-signal-value'; /** * Returns a reactive translate function `t` that resolves translation keys to strings. @@ -55,11 +57,26 @@ import { TranslationStore } from '../service/translation.store'; * * @returns A reactive {@link TranslateFn} */ -export function useTranslation(): TranslateFn { +export function useTranslation(): TranslateFn; +export function useTranslation(scope: MaybeSignal): ScopedTranslateFn; +export function useTranslation(scope?: MaybeSignal): TranslateFn | ScopedTranslateFn { const store = inject(TranslationStore); const scopes = inject(TRANSLATION_SCOPE, { optional: true }) as TranslationScopeConfig[] | null; scopes?.forEach(({ scope, loader }) => store.ensureScope(scope, loader)); - return (key: string, params?) => store.translate(key, params); + const globalTranslateFn: TranslateFn = (key, params) => store.translate(key, params); + + if (scope) { + const scopedTranslateFn: ScopedTranslateFn = (key, params) => + store.translate( + computed(() => `${resolveSignalValue(scope)}:${resolveSignalValue(key)}`), + params + ); + + scopedTranslateFn.global = globalTranslateFn; + return scopedTranslateFn; + } + + return globalTranslateFn; } diff --git a/libs/composables/i18n/src/models/language.ts b/libs/composables/i18n/src/models/language.ts new file mode 100644 index 0000000..cab6789 --- /dev/null +++ b/libs/composables/i18n/src/models/language.ts @@ -0,0 +1,4 @@ +import { z } from 'zod'; + +export const LanguageSchema = z.string().brand('Language'); +export type Language = z.infer; diff --git a/libs/composables/i18n/src/models/maybe-signal.ts b/libs/composables/i18n/src/models/maybe-signal.ts new file mode 100644 index 0000000..f07b51c --- /dev/null +++ b/libs/composables/i18n/src/models/maybe-signal.ts @@ -0,0 +1,3 @@ +import { Signal } from '@angular/core'; + +export type MaybeSignal = T | Signal; diff --git a/libs/composables/i18n/src/models/translation.types.ts b/libs/composables/i18n/src/models/translation.types.ts index a909bd5..9a16373 100644 --- a/libs/composables/i18n/src/models/translation.types.ts +++ b/libs/composables/i18n/src/models/translation.types.ts @@ -1,3 +1,7 @@ +import { Resource, Signal } from '@angular/core'; +import { Language } from './language'; +import { MaybeSignal } from './maybe-signal'; + /** * A flat map of translation keys to their translated string values. */ @@ -8,7 +12,9 @@ export type TranslationData = Record; * Receives the active language tag (e.g. `'en'`, `'de'`) and returns translation data. * Intended to be used with dynamic imports, e.g. `(lang) => import('./i18n/${lang}.json').then(m => m.default)`. */ -export type TranslationLoader = (lang: string) => Promise; +export type TranslationLoader = (language: Language) => Promise; + +export type TranslationResource = Resource; /** * Parameters used for interpolating values into a translated string. @@ -30,4 +36,5 @@ export type TranslationParams = Record; * @param params - Optional interpolation parameters (replaces `{{ paramName }}` placeholders) * @returns The translated string, or the key itself as a fallback while loading */ -export type TranslateFn = (key: string, params?: TranslationParams) => string; +export type TranslateFn = (key: MaybeSignal, params?: MaybeSignal) => Signal; +export type ScopedTranslateFn = TranslateFn & { global: TranslateFn }; diff --git a/libs/composables/i18n/src/service/translation.store.ts b/libs/composables/i18n/src/service/translation.store.ts index fc9c895..86156eb 100644 --- a/libs/composables/i18n/src/service/translation.store.ts +++ b/libs/composables/i18n/src/service/translation.store.ts @@ -1,9 +1,44 @@ +import { + computed, + inject, + Injectable, + Injector, + isSignal, + resource, + runInInjectionContext, + signal, + Signal +} from '@angular/core'; +import { Language } from '../models/language'; +import { TranslationLoader, TranslationParams, TranslationResource } from '../models/translation.types'; +import { DEFAULT_LANGUAGE } from '../tokens/defualt-language.tokens'; +import { TRANSLATION_LOADER } from '../tokens/translation.tokens'; import { inject, Injectable, Injector, resource, ResourceRef, runInInjectionContext, Signal, WritableSignal } from '@angular/core'; import { TranslationData, TranslationLoader, TranslationParams } from '../models/translation.types'; import { CURRENT_LANGUAGE, TRANSLATION_LOADER } from '../tokens/translation.tokens'; /** Sentinel key used internally for the global (unscoped) translation namespace. */ -const GLOBAL_SCOPE = ''; +const GLOBAL_SCOPE = Symbol('global'); +type Scope = string | symbol; + +const isResource = (value: unknown): value is TranslationResource => value != null && 'value' in (value as object); + +const getScopeAndPath = (key: string) => { + const colonIdx = key.indexOf(':'); + + let scope: Scope; + let path: string; + + if (colonIdx === -1) { + scope = GLOBAL_SCOPE; + path = key; + } else { + scope = key.slice(0, colonIdx); + path = key.slice(colonIdx + 1); + } + + return { scope, path }; +}; /** * @internal @@ -25,8 +60,8 @@ const interpolate = (value: string, params: TranslationParams): string => @Injectable() export class TranslationStore { private readonly injector = inject(Injector); - private readonly lang: WritableSignal = inject(CURRENT_LANGUAGE); - private readonly resources = new Map>(); + private readonly resources = new Map(); + readonly language = signal(inject(DEFAULT_LANGUAGE)); constructor() { const globalLoader = inject(TRANSLATION_LOADER, { optional: true }); @@ -43,17 +78,21 @@ export class TranslationStore { * @param scope - The scope identifier, or `''` for the global namespace * @param loader - The loader function for this scope */ - ensureScope(scope: string, loader: TranslationLoader): void { + ensureScope(scope: Scope, loader: TranslationLoader | TranslationResource): void { if (this.resources.has(scope)) { return; } - const ref = runInInjectionContext(this.injector, () => - resource({ - request: () => this.lang(), - loader: ({ request: lang }) => loader(lang) - }) - ); + const ref = isResource(loader) + ? loader + : runInInjectionContext( + this.injector, + () => + resource({ + params: () => ({ language: this.language() }), + loader: ({ params }) => loader(params.language) + }) as TranslationResource + ); this.resources.set(scope, ref); } @@ -64,7 +103,7 @@ export class TranslationStore { * * @param scope - The scope identifier, or `''` for the global namespace */ - isLoading(scope = GLOBAL_SCOPE): Signal | undefined { + isLoading(scope: Scope = GLOBAL_SCOPE): Signal | undefined { return this.resources.get(scope)?.isLoading; } @@ -79,23 +118,31 @@ export class TranslationStore { * @param params - Optional interpolation parameters * @returns The translated string, or the key itself as a fallback while loading */ - translate(key: string, params?: TranslationParams): string { - const colonIdx = key.indexOf(':'); - - let scope: string; - let actualKey: string; - - if (colonIdx === -1) { - scope = GLOBAL_SCOPE; - actualKey = key; - } else { - scope = key.slice(0, colonIdx); - actualKey = key.slice(colonIdx + 1); - } + translate( + key: string | Signal, + params?: TranslationParams | Signal + ): Signal { + return computed(() => { + const resolvedKey = isSignal(key) ? key() : key; + const resolvedParams = isSignal(params) ? params() : params; + const { scope, path } = getScopeAndPath(resolvedKey); + + const resource = this.resources.get(scope); + + if (!resource) { + throw new Error( + `Resource not defined for ${scope === GLOBAL_SCOPE ? 'global scope' : `scope '${scope as string}'`}` + ); + } + + if (!resource.hasValue()) { + return resolvedKey; // TODO: Loading / Error handler + } - const data = this.resources.get(scope)?.value(); - const value = data?.[actualKey] ?? key; + const data = resource.value(); + const value = data?.[path] ?? resolvedKey; // TODO: Missing translation handler - return params ? interpolate(value, params) : value; + return resolvedParams ? interpolate(value, resolvedParams) : value; + }); } } diff --git a/libs/composables/i18n/src/tokens/defualt-language.tokens.ts b/libs/composables/i18n/src/tokens/defualt-language.tokens.ts new file mode 100644 index 0000000..8285138 --- /dev/null +++ b/libs/composables/i18n/src/tokens/defualt-language.tokens.ts @@ -0,0 +1,4 @@ +import { InjectionToken } from '@angular/core'; +import { Language } from '../models/language'; + +export const DEFAULT_LANGUAGE = new InjectionToken('@homj/composables/i18n: default language'); diff --git a/libs/composables/i18n/src/utils/resolve-signal-value.ts b/libs/composables/i18n/src/utils/resolve-signal-value.ts new file mode 100644 index 0000000..62472f9 --- /dev/null +++ b/libs/composables/i18n/src/utils/resolve-signal-value.ts @@ -0,0 +1,5 @@ +import { isSignal } from '@angular/core'; +import { MaybeSignal } from '../models/maybe-signal'; + +export const resolveSignalValue = (maybeSignal: MaybeSignal): T => + isSignal(maybeSignal) ? maybeSignal() : maybeSignal; From 44b3597e935ee4407990753731498324c156fbc5 Mon Sep 17 00:00:00 2001 From: Johannes Homeier Date: Sun, 22 Feb 2026 17:33:53 +0100 Subject: [PATCH 4/6] chore: cleanup rebase --- apps/demo/src/app/app.config.ts | 7 +-- .../components/lazy/lazy-demo.component.ts | 23 ++++---- .../pages/i18n-demo/i18n-demo.component.html | 20 +++---- .../pages/i18n-demo/i18n-demo.component.ts | 19 +++---- .../i18n/src/composables/public-api.ts | 2 + .../composables/use-language.composable.ts | 7 +++ .../composables/use-translation.composable.ts | 4 +- .../composables/i18n/src/models/public-api.ts | 3 + .../i18n/src/models/translation.types.ts | 2 +- .../i18n/src/providers/public-api.ts | 1 + .../src/providers/translation.providers.ts | 10 ++-- libs/composables/i18n/src/public-api.ts | 9 ++- .../i18n/src/service/public-api.ts | 1 + .../src/service/translation.store.spec.ts | 56 ++++++++----------- .../i18n/src/service/translation.store.ts | 3 - .../i18n/src/tokens/translation.tokens.ts | 19 +------ 16 files changed, 79 insertions(+), 107 deletions(-) create mode 100644 libs/composables/i18n/src/composables/public-api.ts create mode 100644 libs/composables/i18n/src/composables/use-language.composable.ts create mode 100644 libs/composables/i18n/src/models/public-api.ts create mode 100644 libs/composables/i18n/src/providers/public-api.ts create mode 100644 libs/composables/i18n/src/service/public-api.ts diff --git a/apps/demo/src/app/app.config.ts b/apps/demo/src/app/app.config.ts index f220657..48a63ae 100644 --- a/apps/demo/src/app/app.config.ts +++ b/apps/demo/src/app/app.config.ts @@ -1,15 +1,12 @@ import { ApplicationConfig } from '@angular/core'; import { provideRouter, withEnabledBlockingInitialNavigation } from '@angular/router'; -import { provideTranslation } from '@homj/composables/i18n'; +import { Language, provideTranslation } from '@homj/composables/i18n'; import { appRoutes } from './app.routes'; export const appConfig: ApplicationConfig = { providers: [ provideRouter(appRoutes, withEnabledBlockingInitialNavigation()), - provideTranslation( - (lang) => import(`./i18n/${lang}.json`).then((m) => m.default), - 'en' - ) + provideTranslation((lang) => import(`./i18n/${lang}.json`).then((m) => m.default), 'en' as Language) ] }; diff --git a/apps/demo/src/app/pages/i18n-demo/components/lazy/lazy-demo.component.ts b/apps/demo/src/app/pages/i18n-demo/components/lazy/lazy-demo.component.ts index 8b59ace..547057d 100644 --- a/apps/demo/src/app/pages/i18n-demo/components/lazy/lazy-demo.component.ts +++ b/apps/demo/src/app/pages/i18n-demo/components/lazy/lazy-demo.component.ts @@ -1,22 +1,19 @@ -import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; -import { CURRENT_LANGUAGE, provideTranslationScope, useTranslation } from '@homj/composables/i18n'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { provideTranslationScope, useLanguage, useTranslation } from '@homj/composables/i18n'; @Component({ selector: 'demo-lazy-demo', - providers: [ - provideTranslationScope( - 'lazy-scope', - (lang) => import(`./i18n/${lang}.json`).then((m) => m.default) - ) - ], + providers: [provideTranslationScope('lazy-scope', (lang) => import(`./i18n/${lang}.json`).then((m) => m.default))], template: ` -

{{ t('lazy-scope:title') }}

-

{{ t('lazy-scope:message') }}

-

{{ t('lazy-scope:current-lang', { lang: lang() }) }}

+

{{ t('title') }}

+

{{ t('message') }}

+

{{ t('current-lang', { lang: lang() }) }}

+ Global key: +

{{ t.global('welcome') }}

`, changeDetection: ChangeDetectionStrategy.OnPush }) export class LazyDemoComponent { - protected readonly t = useTranslation(); - protected readonly lang = inject(CURRENT_LANGUAGE); + protected readonly lang = useLanguage(); + protected readonly t = useTranslation('lazy-scope'); } diff --git a/apps/demo/src/app/pages/i18n-demo/i18n-demo.component.html b/apps/demo/src/app/pages/i18n-demo/i18n-demo.component.html index 9ff74fd..7d6cf78 100644 --- a/apps/demo/src/app/pages/i18n-demo/i18n-demo.component.html +++ b/apps/demo/src/app/pages/i18n-demo/i18n-demo.component.html @@ -7,13 +7,9 @@

{{ t('i18n-demo:title') }}

{{ t('language') }} @for (l of languages; track l.code) { - + }
@@ -53,14 +49,14 @@

{{ t('i18n-demo:lazy-section') }}

@defer (on interaction) { - + } @placeholder { - + } - -
+ ── Error / fallback cases ────────────────────────── + diff --git a/apps/demo/src/app/pages/i18n-demo/i18n-demo.component.ts b/apps/demo/src/app/pages/i18n-demo/i18n-demo.component.ts index 6a95b3d..3fe33de 100644 --- a/apps/demo/src/app/pages/i18n-demo/i18n-demo.component.ts +++ b/apps/demo/src/app/pages/i18n-demo/i18n-demo.component.ts @@ -1,28 +1,23 @@ -import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; -import { CURRENT_LANGUAGE, provideTranslationScope, useTranslation } from '@homj/composables/i18n'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { Language, provideTranslationScope, useLanguage, useTranslation } from '@homj/composables/i18n'; +import { LazyDemoComponent } from './components/lazy/lazy-demo.component'; import { ScopedDemoComponent } from './components/scoped/scoped-demo.component'; -import { LazyDemoComponent } from './components/lazy/lazy-demo.component'; @Component({ selector: 'demo-i18n-demo', imports: [ScopedDemoComponent, LazyDemoComponent], - providers: [ - provideTranslationScope( - 'i18n-demo', - (lang) => import(`./i18n/${lang}.json`).then((m) => m.default) - ) - ], + providers: [provideTranslationScope('i18n-demo', (lang) => import(`./i18n/${lang}.json`).then((m) => m.default))], templateUrl: './i18n-demo.component.html', styleUrls: ['./i18n-demo.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush }) export class I18nDemoComponent { - protected readonly lang = inject(CURRENT_LANGUAGE); + protected readonly lang = useLanguage(); protected readonly t = useTranslation(); protected readonly languages = [ - { code: 'en', label: 'EN' }, - { code: 'de', label: 'DE' } + { code: 'en' as Language, label: 'EN' }, + { code: 'de' as Language, label: 'DE' } ] as const; } diff --git a/libs/composables/i18n/src/composables/public-api.ts b/libs/composables/i18n/src/composables/public-api.ts new file mode 100644 index 0000000..8aa01de --- /dev/null +++ b/libs/composables/i18n/src/composables/public-api.ts @@ -0,0 +1,2 @@ +export * from './use-translation.composable'; +export * from './use-language.composable'; diff --git a/libs/composables/i18n/src/composables/use-language.composable.ts b/libs/composables/i18n/src/composables/use-language.composable.ts new file mode 100644 index 0000000..3ac48bb --- /dev/null +++ b/libs/composables/i18n/src/composables/use-language.composable.ts @@ -0,0 +1,7 @@ +import { inject } from '@angular/core'; +import { TranslationStore } from '../service/translation.store'; + +export const useLanguage = () => { + const store = inject(TranslationStore); + return store.language; +}; diff --git a/libs/composables/i18n/src/composables/use-translation.composable.ts b/libs/composables/i18n/src/composables/use-translation.composable.ts index e258028..c51b91c 100644 --- a/libs/composables/i18n/src/composables/use-translation.composable.ts +++ b/libs/composables/i18n/src/composables/use-translation.composable.ts @@ -65,14 +65,14 @@ export function useTranslation(scope?: MaybeSignal): TranslateFn | Scope scopes?.forEach(({ scope, loader }) => store.ensureScope(scope, loader)); - const globalTranslateFn: TranslateFn = (key, params) => store.translate(key, params); + const globalTranslateFn: TranslateFn = (key, params) => store.translate(key, params)(); if (scope) { const scopedTranslateFn: ScopedTranslateFn = (key, params) => store.translate( computed(() => `${resolveSignalValue(scope)}:${resolveSignalValue(key)}`), params - ); + )(); scopedTranslateFn.global = globalTranslateFn; return scopedTranslateFn; diff --git a/libs/composables/i18n/src/models/public-api.ts b/libs/composables/i18n/src/models/public-api.ts new file mode 100644 index 0000000..b0395fb --- /dev/null +++ b/libs/composables/i18n/src/models/public-api.ts @@ -0,0 +1,3 @@ +export * from './language'; +export * from './maybe-signal'; +export * from './translation.types'; diff --git a/libs/composables/i18n/src/models/translation.types.ts b/libs/composables/i18n/src/models/translation.types.ts index 9a16373..e5658ea 100644 --- a/libs/composables/i18n/src/models/translation.types.ts +++ b/libs/composables/i18n/src/models/translation.types.ts @@ -36,5 +36,5 @@ export type TranslationParams = Record; * @param params - Optional interpolation parameters (replaces `{{ paramName }}` placeholders) * @returns The translated string, or the key itself as a fallback while loading */ -export type TranslateFn = (key: MaybeSignal, params?: MaybeSignal) => Signal; +export type TranslateFn = (key: MaybeSignal, params?: MaybeSignal) => string; export type ScopedTranslateFn = TranslateFn & { global: TranslateFn }; diff --git a/libs/composables/i18n/src/providers/public-api.ts b/libs/composables/i18n/src/providers/public-api.ts new file mode 100644 index 0000000..0707b4b --- /dev/null +++ b/libs/composables/i18n/src/providers/public-api.ts @@ -0,0 +1 @@ +export * from './translation.providers'; diff --git a/libs/composables/i18n/src/providers/translation.providers.ts b/libs/composables/i18n/src/providers/translation.providers.ts index a1ca3ff..36d23d4 100644 --- a/libs/composables/i18n/src/providers/translation.providers.ts +++ b/libs/composables/i18n/src/providers/translation.providers.ts @@ -1,6 +1,8 @@ import { EnvironmentProviders, makeEnvironmentProviders, Provider, signal } from '@angular/core'; +import { Language } from '../models/language'; import { TranslationLoader } from '../models/translation.types'; -import { CURRENT_LANGUAGE, TRANSLATION_LOADER, TRANSLATION_SCOPE } from '../tokens/translation.tokens'; +import { DEFAULT_LANGUAGE } from '../tokens/defualt-language.tokens'; +import { TRANSLATION_LOADER, TRANSLATION_SCOPE } from '../tokens/translation.tokens'; import { TranslationStore } from '../service/translation.store'; /** @@ -31,13 +33,13 @@ import { TranslationStore } from '../service/translation.store'; * ``` * * @param loader - Optional loader for the global (unscoped) translations - * @param defaultLang - The initial active language tag (defaults to `'en'`) + * @param defaultLang - The initial active language tag * @returns Environment providers for the translation system */ -export function provideTranslation(loader?: TranslationLoader, defaultLang = 'en'): EnvironmentProviders { +export function provideTranslation(loader?: TranslationLoader, defaultLang?: Language): EnvironmentProviders { return makeEnvironmentProviders([ TranslationStore, - { provide: CURRENT_LANGUAGE, useValue: signal(defaultLang) }, + { provide: DEFAULT_LANGUAGE, useValue: defaultLang }, ...(loader ? [{ provide: TRANSLATION_LOADER, useValue: loader }] : []) ]); } diff --git a/libs/composables/i18n/src/public-api.ts b/libs/composables/i18n/src/public-api.ts index 6f35653..f4d6de2 100644 --- a/libs/composables/i18n/src/public-api.ts +++ b/libs/composables/i18n/src/public-api.ts @@ -6,8 +6,7 @@ * @module @homj/composables/i18n */ -export { TranslationData, TranslationLoader, TranslationParams, TranslateFn } from './models/translation.types'; -export { CURRENT_LANGUAGE, TRANSLATION_LOADER, TRANSLATION_SCOPE, TranslationScopeConfig } from './tokens/translation.tokens'; -export { TranslationStore } from './service/translation.store'; -export { provideTranslation, provideTranslationScope } from './providers/translation.providers'; -export { useTranslation } from './composables/use-translation.composable'; +export * from './composables/public-api'; +export * from './models/public-api'; +export * from './providers/public-api'; +export * from './service/public-api'; diff --git a/libs/composables/i18n/src/service/public-api.ts b/libs/composables/i18n/src/service/public-api.ts new file mode 100644 index 0000000..3af0784 --- /dev/null +++ b/libs/composables/i18n/src/service/public-api.ts @@ -0,0 +1 @@ +export * from './translation.store'; diff --git a/libs/composables/i18n/src/service/translation.store.spec.ts b/libs/composables/i18n/src/service/translation.store.spec.ts index d77a68c..d5e5381 100644 --- a/libs/composables/i18n/src/service/translation.store.spec.ts +++ b/libs/composables/i18n/src/service/translation.store.spec.ts @@ -1,21 +1,19 @@ -import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { TranslationStore } from './translation.store'; -import { CURRENT_LANGUAGE, TRANSLATION_LOADER } from '../tokens/translation.tokens'; +import { provideTranslation } from '../index'; import { TranslationData } from '../models/translation.types'; +import { TRANSLATION_LOADER } from '../tokens/translation.tokens'; +import { TranslationStore } from './translation.store'; const GLOBAL_DATA: TranslationData = { title: 'Hello World', greeting: 'Hello, {{ name }}!' }; const SCOPE_DATA: TranslationData = { description: 'A description', label: 'Label' }; const resolvedLoader = (data: TranslationData) => (_lang: string) => Promise.resolve(data); -const withLang = (lang = 'en') => ({ provide: CURRENT_LANGUAGE, useValue: signal(lang) }); - describe('TranslationStore', () => { describe('without a global loader', () => { beforeEach(() => { TestBed.configureTestingModule({ - providers: [TranslationStore, withLang()] + providers: [provideTranslation()] }); }); @@ -41,11 +39,7 @@ describe('TranslationStore', () => { describe('with a global loader', () => { beforeEach(() => { TestBed.configureTestingModule({ - providers: [ - TranslationStore, - withLang(), - { provide: TRANSLATION_LOADER, useValue: resolvedLoader(GLOBAL_DATA) } - ] + providers: [TranslationStore, { provide: TRANSLATION_LOADER, useValue: resolvedLoader(GLOBAL_DATA) }] }); }); @@ -61,7 +55,7 @@ describe('TranslationStore', () => { await TestBed.inject(TestBed as any, { optional: true }); // Flush microtasks so the resource promise resolves - await new Promise(resolve => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); TestBed.flushEffects(); expect(store.translate('title')).toEqual('Hello World'); @@ -70,7 +64,7 @@ describe('TranslationStore', () => { it('should return key fallback for a missing translation key', async () => { const store = TestBed.inject(TranslationStore); - await new Promise(resolve => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); TestBed.flushEffects(); expect(store.translate('missing')).toEqual('missing'); @@ -79,7 +73,7 @@ describe('TranslationStore', () => { it('should interpolate params after translations are loaded', async () => { const store = TestBed.inject(TranslationStore); - await new Promise(resolve => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); TestBed.flushEffects(); expect(store.translate('greeting', { name: 'Jane' })).toEqual('Hello, Jane!'); @@ -89,7 +83,7 @@ describe('TranslationStore', () => { describe('ensureScope', () => { beforeEach(() => { TestBed.configureTestingModule({ - providers: [TranslationStore, withLang()] + providers: [provideTranslation()] }); }); @@ -98,7 +92,7 @@ describe('TranslationStore', () => { store.ensureScope('my-scope', resolvedLoader(SCOPE_DATA)); - await new Promise(resolve => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); TestBed.flushEffects(); expect(store.translate('my-scope:description')).toEqual('A description'); @@ -111,7 +105,7 @@ describe('TranslationStore', () => { store.ensureScope('my-scope', loader); store.ensureScope('my-scope', loader); - await new Promise(resolve => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); TestBed.flushEffects(); expect(loader).toHaveBeenCalledTimes(1); @@ -129,7 +123,7 @@ describe('TranslationStore', () => { describe('translate — key parsing', () => { beforeEach(() => { TestBed.configureTestingModule({ - providers: [TranslationStore, withLang()] + providers: [provideTranslation()] }); }); @@ -138,7 +132,7 @@ describe('TranslationStore', () => { store.ensureScope('', resolvedLoader(GLOBAL_DATA)); - await new Promise(resolve => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); TestBed.flushEffects(); expect(store.translate('title')).toEqual('Hello World'); @@ -150,7 +144,7 @@ describe('TranslationStore', () => { store.ensureScope('scope', resolvedLoader(data)); - await new Promise(resolve => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); TestBed.flushEffects(); expect(store.translate('scope:foo:bar')).toEqual('scope:foo:bar'); @@ -161,8 +155,7 @@ describe('TranslationStore', () => { beforeEach(() => { TestBed.configureTestingModule({ providers: [ - TranslationStore, - withLang(), + provideTranslation(), { provide: TRANSLATION_LOADER, useValue: resolvedLoader(GLOBAL_DATA) } ] }); @@ -171,7 +164,7 @@ describe('TranslationStore', () => { it('should replace {{ param }} placeholders', async () => { const store = TestBed.inject(TranslationStore); - await new Promise(resolve => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); TestBed.flushEffects(); expect(store.translate('greeting', { name: 'Alice' })).toEqual('Hello, Alice!'); @@ -180,7 +173,7 @@ describe('TranslationStore', () => { it('should keep placeholder text for missing params', async () => { const store = TestBed.inject(TranslationStore); - await new Promise(resolve => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); TestBed.flushEffects(); expect(store.translate('greeting', {})).toEqual('Hello, name!'); @@ -192,7 +185,7 @@ describe('TranslationStore', () => { store.ensureScope('', resolvedLoader(data)); - await new Promise(resolve => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); TestBed.flushEffects(); expect(store.translate('count', { n: 42 })).toEqual('Total: 42'); @@ -201,28 +194,23 @@ describe('TranslationStore', () => { describe('language switching', () => { it('should reload translations when the language changes', async () => { - const lang = signal('en'); const enData: TranslationData = { hello: 'Hello' }; const deData: TranslationData = { hello: 'Hallo' }; const loader = jest.fn((l: string) => Promise.resolve(l === 'de' ? deData : enData)); TestBed.configureTestingModule({ - providers: [ - TranslationStore, - { provide: CURRENT_LANGUAGE, useValue: lang }, - { provide: TRANSLATION_LOADER, useValue: loader } - ] + providers: [provideTranslation(), { provide: TRANSLATION_LOADER, useValue: loader }] }); const store = TestBed.inject(TranslationStore); - await new Promise(resolve => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); TestBed.flushEffects(); expect(store.translate('hello')).toEqual('Hello'); - lang.set('de'); + store.language.set('de'); - await new Promise(resolve => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); TestBed.flushEffects(); expect(store.translate('hello')).toEqual('Hallo'); }); diff --git a/libs/composables/i18n/src/service/translation.store.ts b/libs/composables/i18n/src/service/translation.store.ts index 86156eb..e171c13 100644 --- a/libs/composables/i18n/src/service/translation.store.ts +++ b/libs/composables/i18n/src/service/translation.store.ts @@ -13,9 +13,6 @@ import { Language } from '../models/language'; import { TranslationLoader, TranslationParams, TranslationResource } from '../models/translation.types'; import { DEFAULT_LANGUAGE } from '../tokens/defualt-language.tokens'; import { TRANSLATION_LOADER } from '../tokens/translation.tokens'; -import { inject, Injectable, Injector, resource, ResourceRef, runInInjectionContext, Signal, WritableSignal } from '@angular/core'; -import { TranslationData, TranslationLoader, TranslationParams } from '../models/translation.types'; -import { CURRENT_LANGUAGE, TRANSLATION_LOADER } from '../tokens/translation.tokens'; /** Sentinel key used internally for the global (unscoped) translation namespace. */ const GLOBAL_SCOPE = Symbol('global'); diff --git a/libs/composables/i18n/src/tokens/translation.tokens.ts b/libs/composables/i18n/src/tokens/translation.tokens.ts index b2c86ba..e55bad7 100644 --- a/libs/composables/i18n/src/tokens/translation.tokens.ts +++ b/libs/composables/i18n/src/tokens/translation.tokens.ts @@ -1,22 +1,11 @@ -import { InjectionToken, WritableSignal } from '@angular/core'; +import { InjectionToken } from '@angular/core'; import { TranslationLoader } from '../models/translation.types'; /** * Injection token for the global (unscoped) translation loader. * Provided by {@link provideTranslation}. */ -export const TRANSLATION_LOADER = new InjectionToken( - '@homj/composables/i18n: global loader' -); - -/** - * Injection token for the active language signal. - * Holds a {@link WritableSignal} so any component can switch the language reactively. - * Provided by {@link provideTranslation}. - */ -export const CURRENT_LANGUAGE = new InjectionToken>( - '@homj/composables/i18n: current language' -); +export const TRANSLATION_LOADER = new InjectionToken('@homj/composables/i18n: global loader'); /** * Configuration object for a single translation scope. @@ -33,6 +22,4 @@ export interface TranslationScopeConfig { * Each entry is a {@link TranslationScopeConfig} object. * Provided by {@link provideTranslationScope}. */ -export const TRANSLATION_SCOPE = new InjectionToken( - '@homj/composables/i18n: scope config' -); +export const TRANSLATION_SCOPE = new InjectionToken('@homj/composables/i18n: scope config'); From 716df049bd8f422546506b4ae54d37f4ef9caaee Mon Sep 17 00:00:00 2001 From: Johannes Homeier Date: Sun, 22 Feb 2026 17:34:16 +0100 Subject: [PATCH 5/6] style: apply auto format --- apps/demo/src/app/app.routes.ts | 6 ++---- .../i18n-demo/components/scoped/scoped-demo.component.ts | 7 +------ .../src/composables/use-translation.composable.spec.ts | 2 +- 3 files changed, 4 insertions(+), 11 deletions(-) diff --git a/apps/demo/src/app/app.routes.ts b/apps/demo/src/app/app.routes.ts index 159d5ee..08c415e 100644 --- a/apps/demo/src/app/app.routes.ts +++ b/apps/demo/src/app/app.routes.ts @@ -4,12 +4,10 @@ export const appRoutes: Route[] = [ { path: '', redirectTo: 'buttons', pathMatch: 'full' }, { path: 'buttons', - loadComponent: () => - import('./pages/buttons/buttons-demo.component').then((m) => m.ButtonsDemoComponent) + loadComponent: () => import('./pages/buttons/buttons-demo.component').then((m) => m.ButtonsDemoComponent) }, { path: 'i18n', - loadComponent: () => - import('./pages/i18n-demo/i18n-demo.component').then((m) => m.I18nDemoComponent) + loadComponent: () => import('./pages/i18n-demo/i18n-demo.component').then((m) => m.I18nDemoComponent) } ]; diff --git a/apps/demo/src/app/pages/i18n-demo/components/scoped/scoped-demo.component.ts b/apps/demo/src/app/pages/i18n-demo/components/scoped/scoped-demo.component.ts index e0b9e4a..7f85ef6 100644 --- a/apps/demo/src/app/pages/i18n-demo/components/scoped/scoped-demo.component.ts +++ b/apps/demo/src/app/pages/i18n-demo/components/scoped/scoped-demo.component.ts @@ -3,12 +3,7 @@ import { provideTranslationScope, useTranslation } from '@homj/composables/i18n' @Component({ selector: 'demo-scoped-demo', - providers: [ - provideTranslationScope( - 'user-card', - (lang) => import(`./i18n/${lang}.json`).then((m) => m.default) - ) - ], + providers: [provideTranslationScope('user-card', (lang) => import(`./i18n/${lang}.json`).then((m) => m.default))], template: `
Name
diff --git a/libs/composables/i18n/src/composables/use-translation.composable.spec.ts b/libs/composables/i18n/src/composables/use-translation.composable.spec.ts index 700e5cb..8830005 100644 --- a/libs/composables/i18n/src/composables/use-translation.composable.spec.ts +++ b/libs/composables/i18n/src/composables/use-translation.composable.spec.ts @@ -15,7 +15,7 @@ const mockStore = (overrides: Partial = {}): TranslationStore translate: jest.fn((key: string) => key), isLoading: jest.fn(), ...overrides - }) as unknown as TranslationStore; + } as unknown as TranslationStore); describe('useTranslation', () => { it('should return a function', () => { From f97df3ddb36ecd8703a32fa850813da7f06945a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 24 Feb 2026 21:12:08 +0000 Subject: [PATCH 6/6] feat(composables/i18n): add pluggable translation parser API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a TranslationParser type and TRANSLATION_PARSER injection token so the string-formatting step can be swapped out per application. Built-in parsers - interpolationParser (default, no extra deps) — replaces {{ name }} placeholders; extracted from the store so it is independently testable - withMessageFormat(MessageFormatCtor) — factory that wraps any MessageFormat-compatible library (e.g. @messageformat/core) with per-language compiler instances and a lang+pattern cache API changes - provideTranslation() gains an optional third parameter `parser` (defaults to interpolationParser, fully backward-compatible) - TRANSLATION_PARSER token is exported so advanced setups can provide a parser directly via DI - TranslationParser and MessageFormatLike types are exported from the public API TranslationStore now injects TRANSLATION_PARSER (optional, falls back to interpolationParser) and passes (pattern, lang, params) to the parser on every translate() call when params are present. https://claude.ai/code/session_017w3dyuKpzdFHjg7ng5SeJD --- .../i18n/src/models/translation.types.ts | 20 ++- .../src/parsers/interpolation.parser.spec.ts | 47 ++++++ .../i18n/src/parsers/interpolation.parser.ts | 30 ++++ .../src/parsers/message-format.parser.spec.ts | 104 ++++++++++++++ .../i18n/src/parsers/message-format.parser.ts | 98 +++++++++++++ .../i18n/src/parsers/public-api.ts | 2 + .../src/providers/translation.providers.ts | 21 ++- libs/composables/i18n/src/public-api.ts | 1 + .../src/service/translation.store.spec.ts | 136 ++++++++++++++---- .../i18n/src/service/translation.store.ts | 38 +++-- .../i18n/src/tokens/translation.tokens.ts | 10 +- 11 files changed, 457 insertions(+), 50 deletions(-) create mode 100644 libs/composables/i18n/src/parsers/interpolation.parser.spec.ts create mode 100644 libs/composables/i18n/src/parsers/interpolation.parser.ts create mode 100644 libs/composables/i18n/src/parsers/message-format.parser.spec.ts create mode 100644 libs/composables/i18n/src/parsers/message-format.parser.ts create mode 100644 libs/composables/i18n/src/parsers/public-api.ts diff --git a/libs/composables/i18n/src/models/translation.types.ts b/libs/composables/i18n/src/models/translation.types.ts index e5658ea..b213b70 100644 --- a/libs/composables/i18n/src/models/translation.types.ts +++ b/libs/composables/i18n/src/models/translation.types.ts @@ -22,6 +22,24 @@ export type TranslationResource = Resource; */ export type TranslationParams = Record; +/** + * A function that formats a raw translation pattern into a final string. + * + * The active language tag is passed so locale-sensitive parsers (e.g. MessageFormat + * plural rules, number formatting) can react to the current language even though the + * pattern is already in the right language. + * + * Built-in implementations: + * - {@link interpolationParser} — replaces `{{ name }}` placeholders (default) + * - {@link withMessageFormat} — ICU MessageFormat via any compatible library + * + * @param pattern - The raw translation string from the loaded data + * @param lang - The active language tag (e.g. `'en'`, `'de'`) + * @param params - Optional interpolation / formatting parameters + * @returns The fully formatted string + */ +export type TranslationParser = (pattern: string, lang: string, params?: TranslationParams) => string; + /** * The translate function returned by {@link useTranslation}. * @@ -33,7 +51,7 @@ export type TranslationParams = Record; * the underlying translations finish loading or change. * * @param key - A global key (`'title'`) or scoped key (`'scope:key'`) - * @param params - Optional interpolation parameters (replaces `{{ paramName }}` placeholders) + * @param params - Optional parameters forwarded to the active {@link TranslationParser} * @returns The translated string, or the key itself as a fallback while loading */ export type TranslateFn = (key: MaybeSignal, params?: MaybeSignal) => string; diff --git a/libs/composables/i18n/src/parsers/interpolation.parser.spec.ts b/libs/composables/i18n/src/parsers/interpolation.parser.spec.ts new file mode 100644 index 0000000..5876e81 --- /dev/null +++ b/libs/composables/i18n/src/parsers/interpolation.parser.spec.ts @@ -0,0 +1,47 @@ +import { interpolationParser } from './interpolation.parser'; + +describe('interpolationParser', () => { + describe('without params', () => { + it('should return the pattern unchanged', () => { + expect(interpolationParser('Hello, {{ name }}!', 'en')).toEqual('Hello, {{ name }}!'); + }); + + it('should return a plain string unchanged', () => { + expect(interpolationParser('Hello World', 'en')).toEqual('Hello World'); + }); + }); + + describe('with params', () => { + it('should replace a single {{ placeholder }}', () => { + expect(interpolationParser('Hello, {{ name }}!', 'en', { name: 'Jane' })).toEqual('Hello, Jane!'); + }); + + it('should replace multiple placeholders', () => { + expect(interpolationParser('{{ greeting }}, {{ name }}!', 'en', { greeting: 'Hi', name: 'Jane' })).toEqual( + 'Hi, Jane!' + ); + }); + + it('should support numeric values', () => { + expect(interpolationParser('Count: {{ n }}', 'en', { n: 42 })).toEqual('Count: 42'); + }); + + it('should ignore whitespace inside the braces', () => { + expect(interpolationParser('{{name}} and {{ name }}', 'en', { name: 'Jane' })).toEqual('Jane and Jane'); + }); + + it('should fall back to the placeholder name for a missing param', () => { + expect(interpolationParser('Hello, {{ name }}!', 'en', {})).toEqual('Hello, name!'); + }); + + it('should not modify the string when params is an empty object and there are no placeholders', () => { + expect(interpolationParser('Hello World', 'en', {})).toEqual('Hello World'); + }); + + it('should not be affected by the lang argument', () => { + const result = interpolationParser('{{ x }}', 'de', { x: 'Welt' }); + + expect(result).toEqual('Welt'); + }); + }); +}); diff --git a/libs/composables/i18n/src/parsers/interpolation.parser.ts b/libs/composables/i18n/src/parsers/interpolation.parser.ts new file mode 100644 index 0000000..65e68aa --- /dev/null +++ b/libs/composables/i18n/src/parsers/interpolation.parser.ts @@ -0,0 +1,30 @@ +import { TranslationParser } from '../models/translation.types'; + +/** + * The default {@link TranslationParser}. + * + * Replaces `{{ paramName }}` placeholders in a pattern string with values from + * the params map. If a param key is missing, the placeholder name itself is used + * as a fallback so the output is always a readable string. + * + * Leading and trailing whitespace inside the braces is ignored, so both + * `{{ name }}` and `{{name}}` are valid. + * + * @example + * ```ts + * interpolationParser('Hello, {{ name }}!', 'en', { name: 'Jane' }) + * // → 'Hello, Jane!' + * + * interpolationParser('{{ greeting }}, {{ name }}!', 'en', { greeting: 'Hi' }) + * // → 'Hi, name!' ← missing param falls back to placeholder name + * ``` + */ +export const interpolationParser: TranslationParser = ( + pattern: string, + _lang: string, + params +): string => { + if (!params) return pattern; + + return pattern.replace(/\{\{\s*(\w+)\s*\}\}/g, (_, key: string) => String(params[key] ?? key)); +}; diff --git a/libs/composables/i18n/src/parsers/message-format.parser.spec.ts b/libs/composables/i18n/src/parsers/message-format.parser.spec.ts new file mode 100644 index 0000000..328b22b --- /dev/null +++ b/libs/composables/i18n/src/parsers/message-format.parser.spec.ts @@ -0,0 +1,104 @@ +import { MessageFormatLike, withMessageFormat } from './message-format.parser'; + +// --------------------------------------------------------------------------- +// Minimal mock of a MessageFormat-compatible constructor. +// Each compile() call returns a function that replaces {key} with params[key]. +// --------------------------------------------------------------------------- +class MockMessageFormat { + constructor(public readonly locale: string) {} + + compile(pattern: string) { + return (params: Record = {}) => + pattern.replace(/\{(\w+)\}/g, (_, key: string) => String(params[key] ?? key)); + } +} + +const MF = MockMessageFormat as unknown as MessageFormatLike; + +describe('withMessageFormat', () => { + it('should return a TranslationParser function', () => { + expect(typeof withMessageFormat(MF)).toEqual('function'); + }); + + describe('formatting', () => { + it('should format a simple {name} pattern', () => { + const parser = withMessageFormat(MF); + + expect(parser('Hello, {name}!', 'en', { name: 'Jane' })).toEqual('Hello, Jane!'); + }); + + it('should support numeric params', () => { + const parser = withMessageFormat(MF); + + expect(parser('Count: {n}', 'en', { n: 42 })).toEqual('Count: 42'); + }); + + it('should fall back to the placeholder name for a missing param', () => { + const parser = withMessageFormat(MF); + + expect(parser('Hello, {name}!', 'en', {})).toEqual('Hello, name!'); + }); + + it('should return the pattern unchanged when params is undefined', () => { + const parser = withMessageFormat(MF); + + // compile({}) returns the pattern because there are no params + expect(parser('Hello World', 'en', undefined)).toEqual('Hello World'); + }); + }); + + describe('caching', () => { + it('should compile each pattern only once per language', () => { + const compileSpy = jest.fn((pattern: string) => (_: object) => pattern); + const SpyMF = jest.fn().mockImplementation(() => ({ compile: compileSpy })) as unknown as MessageFormatLike; + const parser = withMessageFormat(SpyMF); + + parser('Hello, {name}!', 'en', { name: 'A' }); + parser('Hello, {name}!', 'en', { name: 'B' }); + + expect(compileSpy).toHaveBeenCalledTimes(1); + }); + + it('should compile the same pattern separately for each language', () => { + const compileSpy = jest.fn((pattern: string) => (_: object) => pattern); + const SpyMF = jest.fn().mockImplementation(() => ({ compile: compileSpy })) as unknown as MessageFormatLike; + const parser = withMessageFormat(SpyMF); + + parser('Hello', 'en', {}); + parser('Hello', 'de', {}); + + expect(compileSpy).toHaveBeenCalledTimes(2); + }); + + it('should create one MessageFormat instance per language', () => { + const SpyMF = jest + .fn() + .mockImplementation(() => ({ compile: () => () => '' })) as unknown as MessageFormatLike; + const parser = withMessageFormat(SpyMF); + + parser('a', 'en', {}); + parser('b', 'en', {}); + parser('a', 'de', {}); + + expect(SpyMF).toHaveBeenCalledTimes(2); + expect(SpyMF).toHaveBeenCalledWith('en'); + expect(SpyMF).toHaveBeenCalledWith('de'); + }); + }); + + describe('isolation', () => { + it('should keep separate caches between different withMessageFormat() calls', () => { + const compileSpy = jest.fn((pattern: string) => (_: object) => pattern); + const SpyMF = jest.fn().mockImplementation(() => ({ compile: compileSpy })) as unknown as MessageFormatLike; + + const parserA = withMessageFormat(SpyMF); + const parserB = withMessageFormat(SpyMF); + + parserA('Hello', 'en', {}); + parserB('Hello', 'en', {}); + + // Each parser has its own cache, so compile is called once per parser + expect(compileSpy).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/libs/composables/i18n/src/parsers/message-format.parser.ts b/libs/composables/i18n/src/parsers/message-format.parser.ts new file mode 100644 index 0000000..1f091a6 --- /dev/null +++ b/libs/composables/i18n/src/parsers/message-format.parser.ts @@ -0,0 +1,98 @@ +import { TranslationParams, TranslationParser } from '../models/translation.types'; + +/** A compiled ICU MessageFormat function ready to format params into a string. */ +type CompiledMessage = (params?: Record) => string; + +/** A MessageFormat compiler instance (one per locale). */ +interface MessageFormatInstance { + compile(pattern: string): CompiledMessage; +} + +/** + * Minimal structural type for a MessageFormat constructor. + * + * Compatible with `@messageformat/core` (v3 / v4) and any library that exposes + * a class taking a locale and returning an object with a `compile` method. + * + * @example + * ```ts + * import MessageFormat from '@messageformat/core'; + * // typeof MessageFormat satisfies MessageFormatLike ✓ + * ``` + */ +export interface MessageFormatLike { + new (locale: string | string[]): MessageFormatInstance; +} + +/** + * Creates a {@link TranslationParser} that formats ICU MessageFormat patterns using + * any MessageFormat-compatible library (e.g. `@messageformat/core`). + * + * **Features** + * - One compiler instance is created per locale and reused for all patterns. + * - Compiled pattern functions are cached by `lang + pattern`, so each pattern is + * compiled at most once per active language. + * - When the active language changes, patterns for the new language are compiled on demand. + * + * **Peer dependency** + * + * This factory does not bundle a MessageFormat implementation. Install the library of + * your choice and pass its constructor: + * + * ```sh + * npm install @messageformat/core + * ``` + * + * @example + * ```ts + * // app.config.ts + * import MessageFormat from '@messageformat/core'; + * import { provideTranslation, withMessageFormat } from '@homj/composables/i18n'; + * + * export const appConfig: ApplicationConfig = { + * providers: [ + * provideTranslation( + * (lang) => import(`./i18n/${lang}.json`).then(m => m.default), + * 'en', + * withMessageFormat(MessageFormat) + * ) + * ] + * }; + * ``` + * + * Translation file (ICU syntax): + * ```json + * { + * "greeting": "Hello, {name}!", + * "inbox": "You have {count, plural, one {# message} other {# messages}}." + * } + * ``` + * + * Component usage — identical to the default parser: + * ```ts + * t('greeting', { name: 'Jane' }) // → 'Hello, Jane!' + * t('inbox', { count: 1 }) // → 'You have 1 message.' + * t('inbox', { count: 5 }) // → 'You have 5 messages.' + * ``` + * + * @param MessageFormat - A MessageFormat constructor compatible with {@link MessageFormatLike} + * @returns A {@link TranslationParser} backed by the supplied MessageFormat implementation + */ +export function withMessageFormat(MessageFormat: MessageFormatLike): TranslationParser { + const instances = new Map(); + const cache = new Map(); + + return (pattern: string, lang: string, params?: TranslationParams): string => { + const cacheKey = `${lang}::${pattern}`; + + if (!cache.has(cacheKey)) { + if (!instances.has(lang)) { + instances.set(lang, new MessageFormat(lang)); + } + + cache.set(cacheKey, instances.get(lang)!.compile(pattern)); + } + + return cache.get(cacheKey)!(params as Record); + }; +} diff --git a/libs/composables/i18n/src/parsers/public-api.ts b/libs/composables/i18n/src/parsers/public-api.ts new file mode 100644 index 0000000..f1c92d9 --- /dev/null +++ b/libs/composables/i18n/src/parsers/public-api.ts @@ -0,0 +1,2 @@ +export * from './interpolation.parser'; +export * from './message-format.parser'; diff --git a/libs/composables/i18n/src/providers/translation.providers.ts b/libs/composables/i18n/src/providers/translation.providers.ts index 36d23d4..d9d459e 100644 --- a/libs/composables/i18n/src/providers/translation.providers.ts +++ b/libs/composables/i18n/src/providers/translation.providers.ts @@ -1,9 +1,10 @@ import { EnvironmentProviders, makeEnvironmentProviders, Provider, signal } from '@angular/core'; import { Language } from '../models/language'; -import { TranslationLoader } from '../models/translation.types'; +import { TranslationLoader, TranslationParser } from '../models/translation.types'; import { DEFAULT_LANGUAGE } from '../tokens/defualt-language.tokens'; -import { TRANSLATION_LOADER, TRANSLATION_SCOPE } from '../tokens/translation.tokens'; +import { TRANSLATION_LOADER, TRANSLATION_PARSER, TRANSLATION_SCOPE } from '../tokens/translation.tokens'; import { TranslationStore } from '../service/translation.store'; +import { interpolationParser } from '../parsers/interpolation.parser'; /** * Registers the {@link TranslationStore} and optionally a global translation loader @@ -27,6 +28,14 @@ import { TranslationStore } from '../service/translation.store'; * ``` * * @example + * With MessageFormat: + * ```ts + * import MessageFormat from '@messageformat/core'; + * + * provideTranslation(loader, 'en', withMessageFormat(MessageFormat)) + * ``` + * + * @example * Without a global loader (only scoped translations): * ```ts * provideTranslation() @@ -34,12 +43,18 @@ import { TranslationStore } from '../service/translation.store'; * * @param loader - Optional loader for the global (unscoped) translations * @param defaultLang - The initial active language tag + * @param parser - The {@link TranslationParser} to use for all scopes (defaults to {@link interpolationParser}) * @returns Environment providers for the translation system */ -export function provideTranslation(loader?: TranslationLoader, defaultLang?: Language): EnvironmentProviders { +export function provideTranslation( + loader?: TranslationLoader, + defaultLang?: Language, + parser: TranslationParser = interpolationParser +): EnvironmentProviders { return makeEnvironmentProviders([ TranslationStore, { provide: DEFAULT_LANGUAGE, useValue: defaultLang }, + { provide: TRANSLATION_PARSER, useValue: parser }, ...(loader ? [{ provide: TRANSLATION_LOADER, useValue: loader }] : []) ]); } diff --git a/libs/composables/i18n/src/public-api.ts b/libs/composables/i18n/src/public-api.ts index f4d6de2..d661a51 100644 --- a/libs/composables/i18n/src/public-api.ts +++ b/libs/composables/i18n/src/public-api.ts @@ -8,5 +8,6 @@ export * from './composables/public-api'; export * from './models/public-api'; +export * from './parsers/public-api'; export * from './providers/public-api'; export * from './service/public-api'; diff --git a/libs/composables/i18n/src/service/translation.store.spec.ts b/libs/composables/i18n/src/service/translation.store.spec.ts index d5e5381..4da027f 100644 --- a/libs/composables/i18n/src/service/translation.store.spec.ts +++ b/libs/composables/i18n/src/service/translation.store.spec.ts @@ -1,7 +1,10 @@ +import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { provideTranslation } from '../index'; +import { provideTranslation } from '../providers/translation.providers'; import { TranslationData } from '../models/translation.types'; -import { TRANSLATION_LOADER } from '../tokens/translation.tokens'; +import { TRANSLATION_LOADER, TRANSLATION_PARSER } from '../tokens/translation.tokens'; +import { DEFAULT_LANGUAGE } from '../tokens/defualt-language.tokens'; +import { Language } from '../models/language'; import { TranslationStore } from './translation.store'; const GLOBAL_DATA: TranslationData = { title: 'Hello World', greeting: 'Hello, {{ name }}!' }; @@ -9,6 +12,9 @@ const SCOPE_DATA: TranslationData = { description: 'A description', label: 'Labe const resolvedLoader = (data: TranslationData) => (_lang: string) => Promise.resolve(data); +/** Provides DEFAULT_LANGUAGE when bypassing provideTranslation() in tests. */ +const withLang = (lang: Language = 'en' as Language) => ({ provide: DEFAULT_LANGUAGE, useValue: lang }); + describe('TranslationStore', () => { describe('without a global loader', () => { beforeEach(() => { @@ -26,39 +32,37 @@ describe('TranslationStore', () => { it('should return the key as fallback for an unknown global key', () => { const store = TestBed.inject(TranslationStore); - expect(store.translate('title')).toEqual('title'); + expect(store.translate('title')()).toEqual('title'); }); it('should return the full scoped key as fallback for an unknown scoped key', () => { const store = TestBed.inject(TranslationStore); - expect(store.translate('my-scope:description')).toEqual('my-scope:description'); + expect(store.translate('my-scope:description')()).toEqual('my-scope:description'); }); }); describe('with a global loader', () => { beforeEach(() => { TestBed.configureTestingModule({ - providers: [TranslationStore, { provide: TRANSLATION_LOADER, useValue: resolvedLoader(GLOBAL_DATA) }] + providers: [TranslationStore, withLang(), { provide: TRANSLATION_LOADER, useValue: resolvedLoader(GLOBAL_DATA) }] }); }); it('should return the key as fallback before translations are loaded', () => { const store = TestBed.inject(TranslationStore); - expect(store.translate('title')).toEqual('title'); + expect(store.translate('title')()).toEqual('title'); }); it('should return translated value after translations are loaded', async () => { const store = TestBed.inject(TranslationStore); - await TestBed.inject(TestBed as any, { optional: true }); - // Flush microtasks so the resource promise resolves await new Promise((resolve) => setTimeout(resolve, 0)); TestBed.flushEffects(); - expect(store.translate('title')).toEqual('Hello World'); + expect(store.translate('title')()).toEqual('Hello World'); }); it('should return key fallback for a missing translation key', async () => { @@ -67,7 +71,7 @@ describe('TranslationStore', () => { await new Promise((resolve) => setTimeout(resolve, 0)); TestBed.flushEffects(); - expect(store.translate('missing')).toEqual('missing'); + expect(store.translate('missing')()).toEqual('missing'); }); it('should interpolate params after translations are loaded', async () => { @@ -76,7 +80,7 @@ describe('TranslationStore', () => { await new Promise((resolve) => setTimeout(resolve, 0)); TestBed.flushEffects(); - expect(store.translate('greeting', { name: 'Jane' })).toEqual('Hello, Jane!'); + expect(store.translate('greeting', { name: 'Jane' })()).toEqual('Hello, Jane!'); }); }); @@ -95,7 +99,7 @@ describe('TranslationStore', () => { await new Promise((resolve) => setTimeout(resolve, 0)); TestBed.flushEffects(); - expect(store.translate('my-scope:description')).toEqual('A description'); + expect(store.translate('my-scope:description')()).toEqual('A description'); }); it('should not create a second resource when called again with the same scope', async () => { @@ -116,26 +120,24 @@ describe('TranslationStore', () => { store.ensureScope('my-scope', resolvedLoader(SCOPE_DATA)); - expect(store.translate('my-scope:label')).toEqual('my-scope:label'); + expect(store.translate('my-scope:label')()).toEqual('my-scope:label'); }); }); describe('translate — key parsing', () => { beforeEach(() => { TestBed.configureTestingModule({ - providers: [provideTranslation()] + providers: [provideTranslation(resolvedLoader(GLOBAL_DATA))] }); }); it('should treat a key without ":" as global', async () => { const store = TestBed.inject(TranslationStore); - store.ensureScope('', resolvedLoader(GLOBAL_DATA)); - await new Promise((resolve) => setTimeout(resolve, 0)); TestBed.flushEffects(); - expect(store.translate('title')).toEqual('Hello World'); + expect(store.translate('title')()).toEqual('Hello World'); }); it('should split on the first ":" only', async () => { @@ -147,7 +149,7 @@ describe('TranslationStore', () => { await new Promise((resolve) => setTimeout(resolve, 0)); TestBed.flushEffects(); - expect(store.translate('scope:foo:bar')).toEqual('scope:foo:bar'); + expect(store.translate('scope:foo:bar')()).toEqual('scope:foo:bar'); }); }); @@ -167,7 +169,7 @@ describe('TranslationStore', () => { await new Promise((resolve) => setTimeout(resolve, 0)); TestBed.flushEffects(); - expect(store.translate('greeting', { name: 'Alice' })).toEqual('Hello, Alice!'); + expect(store.translate('greeting', { name: 'Alice' })()).toEqual('Hello, Alice!'); }); it('should keep placeholder text for missing params', async () => { @@ -176,7 +178,7 @@ describe('TranslationStore', () => { await new Promise((resolve) => setTimeout(resolve, 0)); TestBed.flushEffects(); - expect(store.translate('greeting', {})).toEqual('Hello, name!'); + expect(store.translate('greeting', {})()).toEqual('Hello, name!'); }); it('should support numeric param values', async () => { @@ -188,7 +190,93 @@ describe('TranslationStore', () => { await new Promise((resolve) => setTimeout(resolve, 0)); TestBed.flushEffects(); - expect(store.translate('count', { n: 42 })).toEqual('Total: 42'); + expect(store.translate('count', { n: 42 })()).toEqual('Total: 42'); + }); + }); + + describe('TRANSLATION_PARSER', () => { + const DATA: TranslationData = { greeting: 'Hello, {name}!' }; + + it('should use the default interpolationParser when no TRANSLATION_PARSER is provided', async () => { + TestBed.configureTestingModule({ + providers: [ + TranslationStore, + withLang(), + { provide: TRANSLATION_LOADER, useValue: resolvedLoader({ greeting: 'Hello, {{ name }}!' }) } + ] + }); + + const store = TestBed.inject(TranslationStore); + + await new Promise(resolve => setTimeout(resolve, 0)); + TestBed.flushEffects(); + + expect(store.translate('greeting', { name: 'Jane' })()).toEqual('Hello, Jane!'); + }); + + it('should use a custom parser when TRANSLATION_PARSER is provided', async () => { + const customParser = jest.fn((_pattern: string, _lang: string) => 'custom result'); + + TestBed.configureTestingModule({ + providers: [ + TranslationStore, + withLang(), + { provide: TRANSLATION_LOADER, useValue: resolvedLoader(DATA) }, + { provide: TRANSLATION_PARSER, useValue: customParser } + ] + }); + + const store = TestBed.inject(TranslationStore); + + await new Promise(resolve => setTimeout(resolve, 0)); + TestBed.flushEffects(); + + expect(store.translate('greeting', { name: 'Jane' })()).toEqual('custom result'); + expect(customParser).toHaveBeenCalledWith('Hello, {name}!', 'en', { name: 'Jane' }); + }); + + it('should pass the active language to the parser', async () => { + const parserSpy = jest.fn((_pattern: string, _lang: string) => 'ok'); + + TestBed.configureTestingModule({ + providers: [ + TranslationStore, + withLang('de' as Language), + { provide: TRANSLATION_LOADER, useValue: resolvedLoader(DATA) }, + { provide: TRANSLATION_PARSER, useValue: parserSpy } + ] + }); + + const store = TestBed.inject(TranslationStore); + + await new Promise(resolve => setTimeout(resolve, 0)); + TestBed.flushEffects(); + + store.translate('greeting', { name: 'Welt' })(); + + expect(parserSpy).toHaveBeenCalledWith(expect.any(String), 'de', { name: 'Welt' }); + }); + + it('should not call the parser when no params are given', async () => { + const parserSpy = jest.fn(); + + TestBed.configureTestingModule({ + providers: [ + TranslationStore, + withLang(), + { provide: TRANSLATION_LOADER, useValue: resolvedLoader(DATA) }, + { provide: TRANSLATION_PARSER, useValue: parserSpy } + ] + }); + + const store = TestBed.inject(TranslationStore); + + await new Promise(resolve => setTimeout(resolve, 0)); + TestBed.flushEffects(); + + store.translate('greeting')(); + + expect(parserSpy).not.toHaveBeenCalled(); }); }); @@ -206,13 +294,13 @@ describe('TranslationStore', () => { await new Promise((resolve) => setTimeout(resolve, 0)); TestBed.flushEffects(); - expect(store.translate('hello')).toEqual('Hello'); + expect(store.translate('hello')()).toEqual('Hello'); - store.language.set('de'); + store.language.set('de' as Language); await new Promise((resolve) => setTimeout(resolve, 0)); TestBed.flushEffects(); - expect(store.translate('hello')).toEqual('Hallo'); + expect(store.translate('hello')()).toEqual('Hallo'); }); }); }); diff --git a/libs/composables/i18n/src/service/translation.store.ts b/libs/composables/i18n/src/service/translation.store.ts index e171c13..aeee91f 100644 --- a/libs/composables/i18n/src/service/translation.store.ts +++ b/libs/composables/i18n/src/service/translation.store.ts @@ -10,9 +10,12 @@ import { Signal } from '@angular/core'; import { Language } from '../models/language'; -import { TranslationLoader, TranslationParams, TranslationResource } from '../models/translation.types'; +import { TranslationLoader, TranslationParams, TranslationParser, TranslationResource } from '../models/translation.types'; import { DEFAULT_LANGUAGE } from '../tokens/defualt-language.tokens'; -import { TRANSLATION_LOADER } from '../tokens/translation.tokens'; +import { TRANSLATION_LOADER, TRANSLATION_PARSER } from '../tokens/translation.tokens'; +import { interpolationParser } from '../parsers/interpolation.parser'; +import { MaybeSignal } from '../models/maybe-signal'; +import { resolveSignalValue } from '../utils/resolve-signal-value'; /** Sentinel key used internally for the global (unscoped) translation namespace. */ const GLOBAL_SCOPE = Symbol('global'); @@ -37,13 +40,6 @@ const getScopeAndPath = (key: string) => { return { scope, path }; }; -/** - * @internal - * Replaces `{{ paramName }}` placeholders in a string with values from the params map. - */ -const interpolate = (value: string, params: TranslationParams): string => - value.replace(/\{\{\s*(\w+)\s*\}\}/g, (_, key: string) => String(params[key] ?? key)); - /** * Central store for all translation resources. * @@ -57,6 +53,8 @@ const interpolate = (value: string, params: TranslationParams): string => @Injectable() export class TranslationStore { private readonly injector = inject(Injector); + private readonly parser: TranslationParser = + inject(TRANSLATION_PARSER, { optional: true }) ?? interpolationParser; private readonly resources = new Map(); readonly language = signal(inject(DEFAULT_LANGUAGE)); @@ -105,31 +103,29 @@ export class TranslationStore { } /** - * Translates a key, optionally interpolating parameters. + * Translates a key, optionally formatting it with the active {@link TranslationParser}. * * This method reads from signal values internally — calling it inside a * reactive context (template, `computed`, `effect`) will cause a re-evaluation * when the underlying resource finishes loading. * * @param key - A global key (`'title'`) or scoped key (`'scope:key'`) - * @param params - Optional interpolation parameters - * @returns The translated string, or the key itself as a fallback while loading + * @param params - Optional parameters forwarded to the active parser + * @returns A signal that resolves to the formatted string, or the key as a fallback while loading */ translate( - key: string | Signal, - params?: TranslationParams | Signal + key: MaybeSignal, + params?: MaybeSignal ): Signal { return computed(() => { - const resolvedKey = isSignal(key) ? key() : key; - const resolvedParams = isSignal(params) ? params() : params; + const resolvedKey = resolveSignalValue(key); + const resolvedParams = resolveSignalValue(params); const { scope, path } = getScopeAndPath(resolvedKey); const resource = this.resources.get(scope); if (!resource) { - throw new Error( - `Resource not defined for ${scope === GLOBAL_SCOPE ? 'global scope' : `scope '${scope as string}'`}` - ); + return resolvedKey; } if (!resource.hasValue()) { @@ -137,9 +133,9 @@ export class TranslationStore { } const data = resource.value(); - const value = data?.[path] ?? resolvedKey; // TODO: Missing translation handler + const pattern = data?.[path] ?? resolvedKey; // TODO: Missing translation handler - return resolvedParams ? interpolate(value, resolvedParams) : value; + return resolvedParams ? this.parser(pattern, this.language(), resolvedParams) : pattern; }); } } diff --git a/libs/composables/i18n/src/tokens/translation.tokens.ts b/libs/composables/i18n/src/tokens/translation.tokens.ts index e55bad7..5e0d06f 100644 --- a/libs/composables/i18n/src/tokens/translation.tokens.ts +++ b/libs/composables/i18n/src/tokens/translation.tokens.ts @@ -1,5 +1,5 @@ import { InjectionToken } from '@angular/core'; -import { TranslationLoader } from '../models/translation.types'; +import { TranslationLoader, TranslationParser } from '../models/translation.types'; /** * Injection token for the global (unscoped) translation loader. @@ -7,6 +7,14 @@ import { TranslationLoader } from '../models/translation.types'; */ export const TRANSLATION_LOADER = new InjectionToken('@homj/composables/i18n: global loader'); +/** + * Injection token for the active {@link TranslationParser}. + * Provided by {@link provideTranslation}; defaults to {@link interpolationParser} when absent. + */ +export const TRANSLATION_PARSER = new InjectionToken( + '@homj/composables/i18n: parser' +); + /** * Configuration object for a single translation scope. */