From 28b1b88ace146dd1febed1e9d526a2513c6c3cea Mon Sep 17 00:00:00 2001 From: Julian Lechner Date: Thu, 15 Oct 2026 16:22:30 +0200 Subject: [PATCH] feat(i18n): pick language from ?lang, saved choice or browser language Co-Authored-By: Claude Opus 5.5 --- .../language-preference.service.spec.ts | 52 +++++++++++++++++++ .../services/language-preference.service.ts | 22 ++++---- src/app/services/language.service.ts | 23 +++----- src/app/services/shell-language.service.ts | 12 +---- 4 files changed, 72 insertions(+), 37 deletions(-) create mode 100644 src/app/services/language-preference.service.spec.ts diff --git a/src/app/services/language-preference.service.spec.ts b/src/app/services/language-preference.service.spec.ts new file mode 100644 index 0000000..9f3bb3e --- /dev/null +++ b/src/app/services/language-preference.service.spec.ts @@ -0,0 +1,52 @@ +import { TestBed } from '@angular/core/testing'; + +import { LanguagePreferenceService } from './language-preference.service'; + +describe('LanguagePreferenceService', (): void => { + let service: LanguagePreferenceService; + + beforeEach((): void => { + localStorage.clear(); + window.history.replaceState(null, '', '/'); + service = TestBed.inject(LanguagePreferenceService); + }); + + afterEach((): void => { + vi.restoreAllMocks(); + window.history.replaceState(null, '', '/'); + }); + + it('should fall back to English for unsupported browser languages', (): void => { + vi.spyOn(navigator, 'language', 'get').mockReturnValue('fr-FR'); + expect(service.initialize()).toBe('en'); + }); + + it('should use the browser language on the first visit', (): void => { + vi.spyOn(navigator, 'language', 'get').mockReturnValue('de-AT'); + expect(service.initialize()).toBe('de'); + }); + + it('should prefer a stored choice over the browser language', (): void => { + vi.spyOn(navigator, 'language', 'get').mockReturnValue('de-AT'); + localStorage.setItem('portfolio-language', 'en'); + expect(service.initialize()).toBe('en'); + }); + + it('should prefer the lang query parameter over everything else', (): void => { + localStorage.setItem('portfolio-language', 'en'); + window.history.replaceState(null, '', '/?lang=de'); + expect(service.initialize()).toBe('de'); + }); + + it('should ignore an unsupported lang query parameter', (): void => { + vi.spyOn(navigator, 'language', 'get').mockReturnValue('en-US'); + window.history.replaceState(null, '', '/?lang=fr'); + expect(service.initialize()).toBe('en'); + }); + + it('should persist a confirmed language', (): void => { + service.confirmLanguage('de'); + expect(service.languageCode()).toBe('de'); + expect(localStorage.getItem('portfolio-language')).toBe('de'); + }); +}); diff --git a/src/app/services/language-preference.service.ts b/src/app/services/language-preference.service.ts index 6cc6afc..165d164 100644 --- a/src/app/services/language-preference.service.ts +++ b/src/app/services/language-preference.service.ts @@ -2,31 +2,35 @@ import { Injectable, signal } from '@angular/core'; import type { LanguageCode } from '../../languages/language.types'; +const isLanguageCode = (value: string | null): value is LanguageCode => + value === 'en' || value === 'de'; + @Injectable({ providedIn: 'root' }) export class LanguagePreferenceService { private static readonly storageKey = 'portfolio-language'; private readonly currentLanguageCode = signal('en'); - private readonly languageConfirmed = signal(true); readonly languageCode = this.currentLanguageCode.asReadonly(); - readonly isLanguageConfirmed = this.languageConfirmed.asReadonly(); - initializeFromStorage(): LanguageCode { + /** Picks the language from `?lang=`, then the stored choice, then the browser language. */ + initialize(): LanguageCode { if (typeof window === 'undefined') { return this.currentLanguageCode(); } - const storedLanguage = window.localStorage.getItem(LanguagePreferenceService.storageKey); - if (storedLanguage === 'de' || storedLanguage === 'en') { - this.currentLanguageCode.set(storedLanguage); - } + const candidates = [ + new URLSearchParams(window.location.search).get('lang'), + window.localStorage.getItem(LanguagePreferenceService.storageKey), + window.navigator.language.slice(0, 2).toLowerCase(), + ]; + const language = candidates.find(isLanguageCode) ?? 'en'; + this.currentLanguageCode.set(language); - return this.currentLanguageCode(); + return language; } confirmLanguage(code: LanguageCode): void { this.currentLanguageCode.set(code); - this.languageConfirmed.set(true); if (typeof window !== 'undefined') { window.localStorage.setItem(LanguagePreferenceService.storageKey, code); diff --git a/src/app/services/language.service.ts b/src/app/services/language.service.ts index 6abbcd3..88f66c8 100644 --- a/src/app/services/language.service.ts +++ b/src/app/services/language.service.ts @@ -2,26 +2,15 @@ import { Injectable, computed, inject } from '@angular/core'; import { deLanguage } from '../../languages/de'; import { enLanguage } from '../../languages/en'; -import type { LanguageCode, LanguagePack } from '../../languages/language.types'; +import type { LanguagePack } from '../../languages/language.types'; import { LanguagePreferenceService } from './language-preference.service'; +/** Page content for the lazy-loaded routes; kept apart from the shell texts to stay out of the initial bundle. */ @Injectable({ providedIn: 'root' }) export class LanguageService { - private readonly languagePreferenceService = inject(LanguagePreferenceService); + private readonly languageCode = inject(LanguagePreferenceService).languageCode; - readonly languageCode = this.languagePreferenceService.languageCode; - readonly isLanguageConfirmed = this.languagePreferenceService.isLanguageConfirmed; - readonly content = computed(() => this.getPack(this.languageCode())); - - initializeFromStorage(): LanguageCode { - return this.languagePreferenceService.initializeFromStorage(); - } - - confirmLanguage(code: LanguageCode): void { - this.languagePreferenceService.confirmLanguage(code); - } - - getPack(code: LanguageCode): LanguagePack { - return code === 'de' ? deLanguage : enLanguage; - } + readonly content = computed(() => + this.languageCode() === 'de' ? deLanguage : enLanguage, + ); } diff --git a/src/app/services/shell-language.service.ts b/src/app/services/shell-language.service.ts index c930ebe..00711af 100644 --- a/src/app/services/shell-language.service.ts +++ b/src/app/services/shell-language.service.ts @@ -6,20 +6,10 @@ import { LanguagePreferenceService } from './language-preference.service'; @Injectable({ providedIn: 'root' }) export class ShellLanguageService { - private readonly languagePreferenceService = inject(LanguagePreferenceService); + private readonly languageCode = inject(LanguagePreferenceService).languageCode; - readonly languageCode = this.languagePreferenceService.languageCode; - readonly isLanguageConfirmed = this.languagePreferenceService.isLanguageConfirmed; readonly content = computed(() => this.getPack(this.languageCode())); - initializeFromStorage(): LanguageCode { - return this.languagePreferenceService.initializeFromStorage(); - } - - confirmLanguage(code: LanguageCode): void { - this.languagePreferenceService.confirmLanguage(code); - } - getPack(code: LanguageCode): LanguageShellPack { return code === 'de' ? deShellLanguage : enShellLanguage; }