Old
-- Using plain Angular APIs (code) -
-New
-- Using @homj/composables (code) -
-- Using plain Angular APIs (code) -
-- Using @homj/composables (code) -
-+ Using plain Angular APIs (code) +
++ Using @homj/composables (code) +
+{{ t('title') }}
+{{ t('message') }}
+ + Global key: + + `, + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class LazyDemoComponent { + protected readonly lang = useLanguage(); + protected readonly t = useTranslation('lazy-scope'); +} 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..7f85ef6 --- /dev/null +++ b/apps/demo/src/app/pages/i18n-demo/components/scoped/scoped-demo.component.ts @@ -0,0 +1,21 @@ +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: ` +{{ t('i18n-demo:subtitle') }}
+{{ t('i18n-demo:global-hint') }}
+t('hello', { name: 'World' })t('welcome'){{ t('i18n-demo:custom-hint') }}
+{{ t('i18n-demo:lazy-hint') }}
+{{ t('title') }}
`, + standalone: true + }) + class TestComponent { + t = useTranslation(); + } + + let fixture: ComponentFixture{{ t('my-scope:description') }}
`, + standalone: true, + providers: [provideTranslationScope('my-scope', (_lang: string) => Promise.resolve(SCOPE_DATA))] + }) + class ScopedComponent { + t = useTranslation(); + } + + let fixture: ComponentFixture{{ t('my-component:description') }}
` + * }) + * class MyComponent { + * t = useTranslation(); + * } + * ``` + * + * @returns A reactive {@link TranslateFn} + */ +export function useTranslation(): TranslateFn; +export function useTranslation(scope: MaybeSignal{{ 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..d661a51 --- /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 * 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/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 new file mode 100644 index 0000000..4da027f --- /dev/null +++ b/libs/composables/i18n/src/service/translation.store.spec.ts @@ -0,0 +1,306 @@ +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { provideTranslation } from '../providers/translation.providers'; +import { TranslationData } from '../models/translation.types'; +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 }}!' }; +const SCOPE_DATA: TranslationData = { description: 'A description', label: 'Label' }; + +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(() => { + TestBed.configureTestingModule({ + providers: [provideTranslation()] + }); + }); + + 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, 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'); + }); + + it('should return translated value after translations are loaded', async () => { + const store = TestBed.inject(TranslationStore); + + // Flush microtasks so the resource promise resolves + await new Promise