dev #51

Merged
JoeKung merged 5 commits from dev into main 2026-03-22 23:06:02 +01:00
6 changed files with 117 additions and 13 deletions
Showing only changes of commit b317196217 - Show all commits

View File

@@ -29,6 +29,8 @@ import { serverOriginInterceptor } from './core/interceptors/server-origin.inter
import { catchError, firstValueFrom, of } from 'rxjs'; import { catchError, firstValueFrom, of } from 'rxjs';
import { StaticTranslateLoader } from './core/i18n/static-translate.loader'; import { StaticTranslateLoader } from './core/i18n/static-translate.loader';
import { import {
getNavigatorLanguagePreferences,
parseAcceptLanguage,
resolveInitialLanguage, resolveInitialLanguage,
SUPPORTED_LANGS, SUPPORTED_LANGS,
} from './core/i18n/language-resolution'; } from './core/i18n/language-resolution';
@@ -70,6 +72,11 @@ export const appConfig: ApplicationConfig = {
(typeof request?.url === 'string' && request.url) || router.url || '/'; (typeof request?.url === 'string' && request.url) || router.url || '/';
const lang = resolveInitialLanguage({ const lang = resolveInitialLanguage({
url: requestedUrl, url: requestedUrl,
preferredLanguages: request
? parseAcceptLanguage(readRequestHeader(request, 'accept-language'))
: getNavigatorLanguagePreferences(
typeof navigator === 'undefined' ? null : navigator,
),
}); });
return firstValueFrom( return firstValueFrom(
@@ -88,3 +95,21 @@ export const appConfig: ApplicationConfig = {
provideClientHydration(withEventReplay()), provideClientHydration(withEventReplay()),
], ],
}; };
function readRequestHeader(
request: {
headers?: Record<string, string | string[] | undefined>;
} | 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;
}

View File

@@ -7,6 +7,7 @@ import {
} from '@angular/router'; } from '@angular/router';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { LanguageService } from './language.service'; import { LanguageService } from './language.service';
import { RequestLike } from '../../../core/request-origin';
describe('LanguageService', () => { describe('LanguageService', () => {
function createTranslateMock() { function createTranslateMock() {
@@ -82,9 +83,14 @@ describe('LanguageService', () => {
const translate = createTranslateMock(); const translate = createTranslateMock();
const router = createRouterMock('/calculator?session=abc'); const router = createRouterMock('/calculator?session=abc');
const navigateSpy = router.navigateByUrl as unknown as jasmine.Spy; 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 // 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('it');
expect((translate as any).setFallbackLang).toHaveBeenCalledWith('it'); expect((translate as any).setFallbackLang).toHaveBeenCalledWith('it');
@@ -97,31 +103,41 @@ describe('LanguageService', () => {
expect(navOptions.replaceUrl).toBeTrue(); 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 translate = createTranslateMock();
const router = createRouterMock('/'); const router = createRouterMock('/');
const navigateSpy = router.navigateByUrl as unknown as jasmine.Spy; 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 // 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); expect(navigateSpy).toHaveBeenCalledTimes(1);
const firstCall = navigateSpy.calls.mostRecent(); const firstCall = navigateSpy.calls.mostRecent();
const tree = firstCall.args[0] as UrlTree; 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', () => { it('uses the default language for non-root URLs without a language prefix', () => {
const translate = createTranslateMock(); const translate = createTranslateMock();
const router = createRouterMock('/calculator?session=abc'); const router = createRouterMock('/calculator?session=abc');
const navigateSpy = router.navigateByUrl as unknown as jasmine.Spy; 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 // 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); expect(navigateSpy).toHaveBeenCalledTimes(1);
const firstCall = navigateSpy.calls.mostRecent(); const firstCall = navigateSpy.calls.mostRecent();

View File

@@ -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 { TranslateService } from '@ngx-translate/core';
import { import {
NavigationEnd, NavigationEnd,
@@ -6,7 +6,12 @@ import {
Router, Router,
UrlTree, UrlTree,
} from '@angular/router'; } 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 SupportedLang = 'it' | 'en' | 'de' | 'fr';
type LocalizedRouteOverrides = Partial<Record<SupportedLang, string>>; type LocalizedRouteOverrides = Partial<Record<SupportedLang, string>>;
@@ -23,6 +28,7 @@ export class LanguageService {
constructor( constructor(
private translate: TranslateService, private translate: TranslateService,
private router: Router, private router: Router,
@Optional() @Inject(REQUEST) private request: RequestLike | null = null,
) { ) {
this.translate.addLangs(this.supportedLangs); this.translate.addLangs(this.supportedLangs);
this.translate.setFallbackLang('it'); this.translate.setFallbackLang('it');
@@ -37,6 +43,11 @@ export class LanguageService {
const initialTree = this.router.parseUrl(this.router.url); const initialTree = this.router.parseUrl(this.router.url);
const initialLang = resolveInitialLanguage({ const initialLang = resolveInitialLanguage({
url: this.router.url, url: this.router.url,
preferredLanguages: this.request
? parseAcceptLanguage(this.readRequestHeader('accept-language'))
: getNavigatorLanguagePreferences(
typeof navigator === 'undefined' ? null : navigator,
),
}); });
this.applyLanguage(initialLang); this.applyLanguage(initialLang);
this.ensureLanguageInPath(initialTree); this.ensureLanguageInPath(initialTree);
@@ -137,7 +148,7 @@ export class LanguageService {
const queryLang = this.getQueryLang(urlTree); const queryLang = this.getQueryLang(urlTree);
const rootLang = this.isSupportedLang(queryLang) const rootLang = this.isSupportedLang(queryLang)
? queryLang ? queryLang
: this.defaultLang; : this.currentLang();
if (rootLang !== this.currentLang()) { if (rootLang !== this.currentLang()) {
this.applyLanguage(rootLang); this.applyLanguage(rootLang);
} }
@@ -169,6 +180,17 @@ export class LanguageService {
return typeof lang === 'string' ? lang.toLowerCase() : null; 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( private isSupportedLang(
lang: string | null | undefined, lang: string | null | undefined,
): lang is SupportedLang { ): lang is SupportedLang {

View File

@@ -1,8 +1,8 @@
import { resolvePublicRedirectTarget } from './server-routing'; import { resolvePublicRedirectTarget } from './server-routing';
describe('server routing redirects', () => { describe('server routing redirects', () => {
it('redirects the root path to the default language', () => { it('does not handle the root path because it is resolved separately', () => {
expect(resolvePublicRedirectTarget('/')).toBe('/it'); expect(resolvePublicRedirectTarget('/')).toBeNull();
}); });
it('redirects unprefixed public pages to the default language', () => { it('redirects unprefixed public pages to the default language', () => {

View File

@@ -13,7 +13,7 @@ export function resolvePublicRedirectTarget(pathname: string): string | null {
normalizedPath === '/' ? '/' : normalizedPath.replace(/\/+$/, ''); normalizedPath === '/' ? '/' : normalizedPath.replace(/\/+$/, '');
const segments = splitSegments(trimmedPath); const segments = splitSegments(trimmedPath);
if (segments.length === 0) { if (segments.length === 0) {
return `/${DEFAULT_LANG}`; return null;
} }
const firstSegment = segments[0].toLowerCase(); const firstSegment = segments[0].toLowerCase();

View File

@@ -5,6 +5,10 @@ import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import bootstrap from './main.server'; import bootstrap from './main.server';
import { resolveRequestOrigin } from './core/request-origin'; import { resolveRequestOrigin } from './core/request-origin';
import {
parseAcceptLanguage,
resolveInitialLanguage,
} from './app/core/i18n/language-resolution';
import { resolvePublicRedirectTarget } from './server-routing'; import { resolvePublicRedirectTarget } from './server-routing';
const serverDistFolder = dirname(fileURLToPath(import.meta.url)); 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) => { app.get('**', (req, res, next) => {
const targetPath = resolvePublicRedirectTarget(req.path); const targetPath = resolvePublicRedirectTarget(req.path);
if (!targetPath) { if (!targetPath) {
@@ -83,3 +106,21 @@ function querySuffix(url: string): string {
const queryIndex = String(url ?? '').indexOf('?'); const queryIndex = String(url ?? '').indexOf('?');
return queryIndex >= 0 ? String(url).slice(queryIndex) : ''; 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,
);
}