dev #51
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user