diff --git a/frontend/src/app/app.config.ts b/frontend/src/app/app.config.ts index 836d25d..a72432c 100644 --- a/frontend/src/app/app.config.ts +++ b/frontend/src/app/app.config.ts @@ -29,6 +29,8 @@ import { serverOriginInterceptor } from './core/interceptors/server-origin.inter import { catchError, firstValueFrom, of } from 'rxjs'; import { StaticTranslateLoader } from './core/i18n/static-translate.loader'; import { + getNavigatorLanguagePreferences, + parseAcceptLanguage, resolveInitialLanguage, SUPPORTED_LANGS, } from './core/i18n/language-resolution'; @@ -70,6 +72,11 @@ export const appConfig: ApplicationConfig = { (typeof request?.url === 'string' && request.url) || router.url || '/'; const lang = resolveInitialLanguage({ url: requestedUrl, + preferredLanguages: request + ? parseAcceptLanguage(readRequestHeader(request, 'accept-language')) + : getNavigatorLanguagePreferences( + typeof navigator === 'undefined' ? null : navigator, + ), }); return firstValueFrom( @@ -88,3 +95,21 @@ export const appConfig: ApplicationConfig = { provideClientHydration(withEventReplay()), ], }; + +function readRequestHeader( + request: { + headers?: Record; + } | null, + headerName: string, +): string | null { + if (!request?.headers) { + return null; + } + + const headerValue = request.headers[headerName.toLowerCase()]; + if (Array.isArray(headerValue)) { + return headerValue[0] ?? null; + } + + return typeof headerValue === 'string' ? headerValue : null; +} diff --git a/frontend/src/app/core/services/language.service.spec.ts b/frontend/src/app/core/services/language.service.spec.ts index 025338e..d0996b2 100644 --- a/frontend/src/app/core/services/language.service.spec.ts +++ b/frontend/src/app/core/services/language.service.spec.ts @@ -7,6 +7,7 @@ import { } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; import { LanguageService } from './language.service'; +import { RequestLike } from '../../../core/request-origin'; describe('LanguageService', () => { function createTranslateMock() { @@ -82,9 +83,14 @@ describe('LanguageService', () => { const translate = createTranslateMock(); const router = createRouterMock('/calculator?session=abc'); const navigateSpy = router.navigateByUrl as unknown as jasmine.Spy; + const request: RequestLike = { + headers: { + 'accept-language': 'it-CH,it;q=0.9,en;q=0.8', + }, + }; // eslint-disable-next-line @typescript-eslint/no-unused-vars - const service = new LanguageService(translate, router); + const service = new LanguageService(translate, router, request); expect(translate.use).toHaveBeenCalledWith('it'); expect((translate as any).setFallbackLang).toHaveBeenCalledWith('it'); @@ -97,31 +103,41 @@ describe('LanguageService', () => { expect(navOptions.replaceUrl).toBeTrue(); }); - it('uses the default language on the root URL', () => { + it('uses the preferred browser language on the root URL', () => { const translate = createTranslateMock(); const router = createRouterMock('/'); const navigateSpy = router.navigateByUrl as unknown as jasmine.Spy; + const request: RequestLike = { + headers: { + 'accept-language': 'de-CH,de;q=0.9,en;q=0.8,it;q=0.7', + }, + }; // eslint-disable-next-line @typescript-eslint/no-unused-vars - const service = new LanguageService(translate, router); + const service = new LanguageService(translate, router, request); - expect(translate.use).toHaveBeenCalledWith('it'); + expect(translate.use).toHaveBeenCalledWith('de'); expect(navigateSpy).toHaveBeenCalledTimes(1); const firstCall = navigateSpy.calls.mostRecent(); const tree = firstCall.args[0] as UrlTree; - expect(router.serializeUrl(tree)).toBe('/it'); + expect(router.serializeUrl(tree)).toBe('/de'); }); it('uses the default language for non-root URLs without a language prefix', () => { const translate = createTranslateMock(); const router = createRouterMock('/calculator?session=abc'); const navigateSpy = router.navigateByUrl as unknown as jasmine.Spy; + const request: RequestLike = { + headers: { + 'accept-language': 'de-CH,de;q=0.9,en;q=0.8,it;q=0.7', + }, + }; // eslint-disable-next-line @typescript-eslint/no-unused-vars - const service = new LanguageService(translate, router); + const service = new LanguageService(translate, router, request); - expect(translate.use).toHaveBeenCalledWith('it'); + expect(translate.use).toHaveBeenCalledWith('de'); expect(navigateSpy).toHaveBeenCalledTimes(1); const firstCall = navigateSpy.calls.mostRecent(); diff --git a/frontend/src/app/core/services/language.service.ts b/frontend/src/app/core/services/language.service.ts index 264f65d..2d6ab50 100644 --- a/frontend/src/app/core/services/language.service.ts +++ b/frontend/src/app/core/services/language.service.ts @@ -1,4 +1,4 @@ -import { Injectable, signal } from '@angular/core'; +import { Inject, Injectable, Optional, REQUEST, signal } from '@angular/core'; import { TranslateService } from '@ngx-translate/core'; import { NavigationEnd, @@ -6,7 +6,12 @@ import { Router, UrlTree, } from '@angular/router'; -import { resolveInitialLanguage } from '../i18n/language-resolution'; +import { + getNavigatorLanguagePreferences, + parseAcceptLanguage, + resolveInitialLanguage, +} from '../i18n/language-resolution'; +import { RequestLike } from '../../../core/request-origin'; type SupportedLang = 'it' | 'en' | 'de' | 'fr'; type LocalizedRouteOverrides = Partial>; @@ -23,6 +28,7 @@ export class LanguageService { constructor( private translate: TranslateService, private router: Router, + @Optional() @Inject(REQUEST) private request: RequestLike | null = null, ) { this.translate.addLangs(this.supportedLangs); this.translate.setFallbackLang('it'); @@ -37,6 +43,11 @@ export class LanguageService { const initialTree = this.router.parseUrl(this.router.url); const initialLang = resolveInitialLanguage({ url: this.router.url, + preferredLanguages: this.request + ? parseAcceptLanguage(this.readRequestHeader('accept-language')) + : getNavigatorLanguagePreferences( + typeof navigator === 'undefined' ? null : navigator, + ), }); this.applyLanguage(initialLang); this.ensureLanguageInPath(initialTree); @@ -137,7 +148,7 @@ export class LanguageService { const queryLang = this.getQueryLang(urlTree); const rootLang = this.isSupportedLang(queryLang) ? queryLang - : this.defaultLang; + : this.currentLang(); if (rootLang !== this.currentLang()) { this.applyLanguage(rootLang); } @@ -169,6 +180,17 @@ export class LanguageService { return typeof lang === 'string' ? lang.toLowerCase() : null; } + private readRequestHeader(headerName: string): string | null { + const headerValue = + this.request?.headers?.[headerName.toLowerCase()] ?? + this.request?.get?.(headerName.toLowerCase()); + if (Array.isArray(headerValue)) { + return headerValue[0] ?? null; + } + + return typeof headerValue === 'string' ? headerValue : null; + } + private isSupportedLang( lang: string | null | undefined, ): lang is SupportedLang { diff --git a/frontend/src/server-routing.spec.ts b/frontend/src/server-routing.spec.ts index 1cccfa4..2cf9d58 100644 --- a/frontend/src/server-routing.spec.ts +++ b/frontend/src/server-routing.spec.ts @@ -1,8 +1,8 @@ import { resolvePublicRedirectTarget } from './server-routing'; describe('server routing redirects', () => { - it('redirects the root path to the default language', () => { - expect(resolvePublicRedirectTarget('/')).toBe('/it'); + it('does not handle the root path because it is resolved separately', () => { + expect(resolvePublicRedirectTarget('/')).toBeNull(); }); it('redirects unprefixed public pages to the default language', () => { diff --git a/frontend/src/server-routing.ts b/frontend/src/server-routing.ts index b3745e9..c66c3e7 100644 --- a/frontend/src/server-routing.ts +++ b/frontend/src/server-routing.ts @@ -13,7 +13,7 @@ export function resolvePublicRedirectTarget(pathname: string): string | null { normalizedPath === '/' ? '/' : normalizedPath.replace(/\/+$/, ''); const segments = splitSegments(trimmedPath); if (segments.length === 0) { - return `/${DEFAULT_LANG}`; + return null; } const firstSegment = segments[0].toLowerCase(); diff --git a/frontend/src/server.ts b/frontend/src/server.ts index 459986e..13580e1 100644 --- a/frontend/src/server.ts +++ b/frontend/src/server.ts @@ -5,6 +5,10 @@ import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import bootstrap from './main.server'; import { resolveRequestOrigin } from './core/request-origin'; +import { + parseAcceptLanguage, + resolveInitialLanguage, +} from './app/core/i18n/language-resolution'; import { resolvePublicRedirectTarget } from './server-routing'; const serverDistFolder = dirname(fileURLToPath(import.meta.url)); @@ -37,6 +41,25 @@ app.get( }), ); +app.get('/', (req, res) => { + const userAgent = req.get('user-agent'); + const preferredLanguages = parseAcceptLanguage(req.get('accept-language')); + const lang = resolveInitialLanguage({ + preferredLanguages, + }); + const stableRedirect = shouldUseStableRootRedirect( + userAgent, + preferredLanguages, + ); + + res.setHeader('Vary', 'Accept-Language, User-Agent'); + res.setHeader('Cache-Control', 'private, no-store'); + res.redirect( + stableRedirect ? 308 : 302, + `/${stableRedirect ? 'it' : lang}${querySuffix(req.originalUrl)}`, + ); +}); + app.get('**', (req, res, next) => { const targetPath = resolvePublicRedirectTarget(req.path); if (!targetPath) { @@ -83,3 +106,21 @@ function querySuffix(url: string): string { const queryIndex = String(url ?? '').indexOf('?'); return queryIndex >= 0 ? String(url).slice(queryIndex) : ''; } + +function shouldUseStableRootRedirect( + userAgent: string | undefined, + preferredLanguages: readonly string[], +): boolean { + return preferredLanguages.length === 0 || isLikelyCrawler(userAgent); +} + +function isLikelyCrawler(userAgent: string | undefined): boolean { + const normalized = String(userAgent ?? '').toLowerCase(); + if (!normalized) { + return false; + } + + return /(bot|crawler|spider|slurp|bingpreview|google-read-aloud)/.test( + normalized, + ); +}