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..48a63ae 100644 --- a/apps/demo/src/app/app.config.ts +++ b/apps/demo/src/app/app.config.ts @@ -1,8 +1,12 @@ import { ApplicationConfig } from '@angular/core'; import { provideRouter, withEnabledBlockingInitialNavigation } from '@angular/router'; +import { Language, 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' as Language) + ] }; diff --git a/apps/demo/src/app/app.routes.ts b/apps/demo/src/app/app.routes.ts index 8762dfe..08c415e 100644 --- a/apps/demo/src/app/app.routes.ts +++ b/apps/demo/src/app/app.routes.ts @@ -1,3 +1,13 @@ 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..547057d --- /dev/null +++ b/apps/demo/src/app/pages/i18n-demo/components/lazy/lazy-demo.component.ts @@ -0,0 +1,19 @@ +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))], + template: ` +

{{ t('title') }}

+

{{ t('message') }}

+

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

+ Global key: +

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

+ `, + 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: ` +
+
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..7d6cf78 --- /dev/null +++ b/apps/demo/src/app/pages/i18n-demo/i18n-demo.component.html @@ -0,0 +1,78 @@ + + +
+ +
+
+

{{ 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 { + + } +
+ + ── Error / fallback cases ────────────────────────── + +
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..3fe33de --- /dev/null +++ b/apps/demo/src/app/pages/i18n-demo/i18n-demo.component.ts @@ -0,0 +1,23 @@ +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'; + +@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 = useLanguage(); + protected readonly t = useTranslation(); + + protected readonly languages = [ + { code: 'en' as Language, label: 'EN' }, + { code: 'de' as Language, 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/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/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.spec.ts b/libs/composables/i18n/src/composables/use-translation.composable.spec.ts new file mode 100644 index 0000000..8830005 --- /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((_lang: string) => Promise.resolve(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((_lang: string) => Promise.resolve({})); + const loaderB = jest.fn((_lang: string) => Promise.resolve({})); + + 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', (_lang: string) => 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..c51b91c --- /dev/null +++ b/libs/composables/i18n/src/composables/use-translation.composable.ts @@ -0,0 +1,82 @@ +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. + * + * **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', (lang) => import(`./i18n/${lang}.json`).then(m => m.default)) + * ], + * template: `

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

` + * }) + * class MyComponent { + * t = useTranslation(); + * } + * ``` + * + * @returns A reactive {@link 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)); + + 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/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/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/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 new file mode 100644 index 0000000..b213b70 --- /dev/null +++ b/libs/composables/i18n/src/models/translation.types.ts @@ -0,0 +1,58 @@ +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. + */ +export type TranslationData = Record; + +/** + * 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 = (language: Language) => Promise; + +export type TranslationResource = Resource; + +/** + * 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; + +/** + * 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}. + * + * - 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 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; +export type ScopedTranslateFn = TranslateFn & { global: TranslateFn }; 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/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 new file mode 100644 index 0000000..d9d459e --- /dev/null +++ b/libs/composables/i18n/src/providers/translation.providers.ts @@ -0,0 +1,100 @@ +import { EnvironmentProviders, makeEnvironmentProviders, Provider, signal } from '@angular/core'; +import { Language } from '../models/language'; +import { TranslationLoader, TranslationParser } from '../models/translation.types'; +import { DEFAULT_LANGUAGE } from '../tokens/defualt-language.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 + * 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( + * (lang) => import(`./i18n/${lang}.json`).then(m => m.default), + * 'en' + * ) + * ] + * }; + * ``` + * + * @example + * With MessageFormat: + * ```ts + * import MessageFormat from '@messageformat/core'; + * + * provideTranslation(loader, 'en', withMessageFormat(MessageFormat)) + * ``` + * + * @example + * Without a global loader (only scoped translations): + * ```ts + * provideTranslation() + * ``` + * + * @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, + parser: TranslationParser = interpolationParser +): EnvironmentProviders { + return makeEnvironmentProviders([ + TranslationStore, + { provide: DEFAULT_LANGUAGE, useValue: defaultLang }, + { provide: TRANSLATION_PARSER, useValue: parser }, + ...(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', (lang) => import(`./i18n/${lang}.json`).then(m => m.default)) + * ], + * 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..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((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: [provideTranslation()] + }); + }); + + 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', 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); + }); + + 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: [provideTranslation(resolvedLoader(GLOBAL_DATA))] + }); + }); + + it('should treat a key without ":" as global', async () => { + const store = TestBed.inject(TranslationStore); + + 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: [ + provideTranslation(), + { 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'); + }); + }); + + 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(); + }); + }); + + describe('language switching', () => { + it('should reload translations when the language changes', async () => { + 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: [provideTranslation(), { 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'); + + store.language.set('de' as Language); + + 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 new file mode 100644 index 0000000..aeee91f --- /dev/null +++ b/libs/composables/i18n/src/service/translation.store.ts @@ -0,0 +1,141 @@ +import { + computed, + inject, + Injectable, + Injector, + isSignal, + resource, + runInInjectionContext, + signal, + Signal +} from '@angular/core'; +import { Language } from '../models/language'; +import { TranslationLoader, TranslationParams, TranslationParser, TranslationResource } from '../models/translation.types'; +import { DEFAULT_LANGUAGE } from '../tokens/defualt-language.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'); +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 }; +}; + +/** + * 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 parser: TranslationParser = + inject(TRANSLATION_PARSER, { optional: true }) ?? interpolationParser; + private readonly resources = new Map(); + readonly language = signal(inject(DEFAULT_LANGUAGE)); + + 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: Scope, loader: TranslationLoader | TranslationResource): void { + if (this.resources.has(scope)) { + return; + } + + 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); + } + + /** + * 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: Scope = GLOBAL_SCOPE): Signal | undefined { + return this.resources.get(scope)?.isLoading; + } + + /** + * 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 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: MaybeSignal, + params?: MaybeSignal + ): Signal { + return computed(() => { + const resolvedKey = resolveSignalValue(key); + const resolvedParams = resolveSignalValue(params); + const { scope, path } = getScopeAndPath(resolvedKey); + + const resource = this.resources.get(scope); + + if (!resource) { + return resolvedKey; + } + + if (!resource.hasValue()) { + return resolvedKey; // TODO: Loading / Error handler + } + + const data = resource.value(); + const pattern = data?.[path] ?? resolvedKey; // TODO: Missing translation handler + + return resolvedParams ? this.parser(pattern, this.language(), resolvedParams) : pattern; + }); + } +} 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/tokens/translation.tokens.ts b/libs/composables/i18n/src/tokens/translation.tokens.ts new file mode 100644 index 0000000..5e0d06f --- /dev/null +++ b/libs/composables/i18n/src/tokens/translation.tokens.ts @@ -0,0 +1,33 @@ +import { InjectionToken } from '@angular/core'; +import { TranslationLoader, TranslationParser } 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 {@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. + */ +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/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; diff --git a/tsconfig.base.json b/tsconfig.base.json index 62d7faf..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"], @@ -20,7 +21,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"]