Skip to content

Commit 41ece63

Browse files
committedDec 10, 2024·
feat(@angular/ssr): redirect to preferred locale when accessing root route without a specified locale
When users access the root route `/` without providing a locale, the application now redirects them to their preferred locale based on the `Accept-Language` header. This enhancement leverages the user's browser preferences to determine the most appropriate locale, providing a seamless and personalized experience without requiring manual locale selection.
1 parent 8d7a51d commit 41ece63

File tree

6 files changed

+377
-10
lines changed

6 files changed

+377
-10
lines changed
 

Diff for: ‎packages/angular/build/src/utils/server-rendering/manifest.ts

+12-1
Original file line numberDiff line numberDiff line change
@@ -60,19 +60,30 @@ export function generateAngularServerAppEngineManifest(
6060
baseHref: string | undefined,
6161
): string {
6262
const entryPoints: Record<string, string> = {};
63+
const supportedLocales: Record<string, string> = {};
64+
6365
if (i18nOptions.shouldInline && !i18nOptions.flatOutput) {
6466
for (const locale of i18nOptions.inlineLocales) {
6567
const { subPath } = i18nOptions.locales[locale];
6668
const importPath = `${subPath ? `${subPath}/` : ''}${MAIN_SERVER_OUTPUT_FILENAME}`;
6769
entryPoints[subPath] = `() => import('./${importPath}')`;
70+
supportedLocales[locale] = subPath;
6871
}
6972
} else {
7073
entryPoints[''] = `() => import('./${MAIN_SERVER_OUTPUT_FILENAME}')`;
74+
supportedLocales[i18nOptions.sourceLocale] = '';
75+
}
76+
77+
// Remove trailing slash but retain leading slash.
78+
let basePath = baseHref || '/';
79+
if (basePath.length > 1 && basePath[basePath.length - 1] === '/') {
80+
basePath = basePath.slice(0, -1);
7181
}
7282

7383
const manifestContent = `
7484
export default {
75-
basePath: '${baseHref ?? '/'}',
85+
basePath: '${basePath}',
86+
supportedLocales: ${JSON.stringify(supportedLocales, undefined, 2)},
7687
entryPoints: {
7788
${Object.entries(entryPoints)
7889
.map(([key, value]) => `'${key}': ${value}`)

Diff for: ‎packages/angular/ssr/src/app-engine.ts

+59-5
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@
88

99
import type { AngularServerApp, getOrCreateAngularServerApp } from './app';
1010
import { Hooks } from './hooks';
11-
import { getPotentialLocaleIdFromUrl } from './i18n';
11+
import { getPotentialLocaleIdFromUrl, getPreferredLocale } from './i18n';
1212
import { EntryPointExports, getAngularAppEngineManifest } from './manifest';
13+
import { joinUrlParts } from './utils/url';
1314

1415
/**
1516
* Angular server application engine.
@@ -47,9 +48,11 @@ export class AngularAppEngine {
4748
private readonly manifest = getAngularAppEngineManifest();
4849

4950
/**
50-
* The number of entry points available in the server application's manifest.
51+
* A map of supported locales from the server application's manifest.
5152
*/
52-
private readonly entryPointsCount = Object.keys(this.manifest.entryPoints).length;
53+
private readonly supportedLocales: ReadonlyArray<string> = Object.keys(
54+
this.manifest.supportedLocales,
55+
);
5356

5457
/**
5558
* A cache that holds entry points, keyed by their potential locale string.
@@ -70,7 +73,58 @@ export class AngularAppEngine {
7073
async handle(request: Request, requestContext?: unknown): Promise<Response | null> {
7174
const serverApp = await this.getAngularServerAppForRequest(request);
7275

73-
return serverApp ? serverApp.handle(request, requestContext) : null;
76+
if (serverApp) {
77+
return serverApp.handle(request, requestContext);
78+
}
79+
80+
if (this.supportedLocales.length > 1) {
81+
// Redirect to the preferred language if i18n is enabled.
82+
return this.redirectBasedOnAcceptLanguage(request);
83+
}
84+
85+
return null;
86+
}
87+
88+
/**
89+
* Handles requests for the base path when i18n is enabled.
90+
* Redirects the user to a locale-specific path based on the `Accept-Language` header.
91+
*
92+
* @param request The incoming request.
93+
* @returns A `Response` object with a 302 redirect, or `null` if i18n is not enabled
94+
* or the request is not for the base path.
95+
*/
96+
private redirectBasedOnAcceptLanguage(request: Request): Response | null {
97+
const { basePath, supportedLocales } = this.manifest;
98+
99+
// If the request is not for the base path, it's not our responsibility to handle it.
100+
const url = new URL(request.url);
101+
if (url.pathname !== basePath) {
102+
return null;
103+
}
104+
105+
// For requests to the base path (typically '/'), attempt to extract the preferred locale
106+
// from the 'Accept-Language' header.
107+
const preferredLocale = getPreferredLocale(
108+
request.headers.get('Accept-Language') || '*',
109+
this.supportedLocales,
110+
);
111+
112+
if (preferredLocale) {
113+
const subPath = supportedLocales[preferredLocale];
114+
if (subPath !== undefined) {
115+
url.pathname = joinUrlParts(url.pathname, subPath);
116+
117+
return new Response(null, {
118+
status: 302, // Use a 302 redirect as language preference may change.
119+
headers: {
120+
'Location': url.toString(),
121+
'Vary': 'Accept-Language',
122+
},
123+
});
124+
}
125+
}
126+
127+
return null;
74128
}
75129

76130
/**
@@ -142,7 +196,7 @@ export class AngularAppEngine {
142196
*/
143197
private getEntryPointExportsForUrl(url: URL): Promise<EntryPointExports> | undefined {
144198
const { basePath } = this.manifest;
145-
if (this.entryPointsCount === 1) {
199+
if (this.supportedLocales.length === 1) {
146200
return this.getEntryPointExports('');
147201
}
148202

Diff for: ‎packages/angular/ssr/src/i18n.ts

+157
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,160 @@ export function getPotentialLocaleIdFromUrl(url: URL, basePath: string): string
4343
// Extract the potential locale id.
4444
return pathname.slice(start, end);
4545
}
46+
47+
/**
48+
* Parses the `Accept-Language` header and returns a list of locale preferences with their respective quality values.
49+
*
50+
* The `Accept-Language` header is typically a comma-separated list of locales, with optional quality values
51+
* in the form of `q=<value>`. If no quality value is specified, a default quality of `1` is assumed.
52+
* Special case: if the header is `*`, it returns the default locale with a quality of `1`.
53+
*
54+
* @param header - The value of the `Accept-Language` header, typically a comma-separated list of locales
55+
* with optional quality values (e.g., `en-US;q=0.8,fr-FR;q=0.9`). If the header is `*`,
56+
* it represents a wildcard for any language, returning the default locale.
57+
*
58+
* @returns A `ReadonlyMap` where the key is the locale (e.g., `en-US`, `fr-FR`), and the value is
59+
* the associated quality value (a number between 0 and 1). If no quality value is provided,
60+
* a default of `1` is used.
61+
*
62+
* @example
63+
* ```js
64+
* parseLanguageHeader('en-US;q=0.8,fr-FR;q=0.9')
65+
* // returns new Map([['en-US', 0.8], ['fr-FR', 0.9]])
66+
67+
* parseLanguageHeader('*')
68+
* // returns new Map([['*', 1]])
69+
* ```
70+
*/
71+
function parseLanguageHeader(header: string): ReadonlyMap<string, number> {
72+
if (header === '*') {
73+
return new Map([['*', 1]]);
74+
}
75+
76+
const parsedValues = header
77+
.split(',')
78+
.map((item) => {
79+
const [locale, qualityValue] = item.split(';', 2).map((v) => v.trim());
80+
81+
let quality = qualityValue?.startsWith('q=') ? parseFloat(qualityValue.slice(2)) : undefined;
82+
if (typeof quality !== 'number' || isNaN(quality) || quality < 0 || quality > 1) {
83+
quality = 1; // Invalid quality value defaults to 1
84+
}
85+
86+
return [locale, quality] as const;
87+
})
88+
.sort(([_localeA, qualityA], [_localeB, qualityB]) => qualityB - qualityA);
89+
90+
return new Map(parsedValues);
91+
}
92+
93+
/**
94+
* Gets the preferred locale based on the highest quality value from the provided `Accept-Language` header
95+
* and the set of available locales.
96+
*
97+
* This function adheres to the HTTP `Accept-Language` header specification as defined in
98+
* [RFC 7231](https://datatracker.ietf.org/doc/html/rfc7231#section-5.3.5), including:
99+
* - Case-insensitive matching of language tags.
100+
* - Quality value handling (e.g., `q=1`, `q=0.8`). If no quality value is provided, it defaults to `q=1`.
101+
* - Prefix matching (e.g., `en` matching `en-US` or `en-GB`).
102+
*
103+
* @param header - The `Accept-Language` header string to parse and evaluate. It may contain multiple
104+
* locales with optional quality values, for example: `'en-US;q=0.8,fr-FR;q=0.9'`.
105+
* @param supportedLocales - An array of supported locales (e.g., `['en-US', 'fr-FR']`),
106+
* representing the locales available in the application.
107+
* @returns The best matching locale from the supported languages, or `undefined` if no match is found.
108+
*
109+
* @example
110+
* ```js
111+
* getPreferredLocale('en-US;q=0.8,fr-FR;q=0.9', ['en-US', 'fr-FR', 'de-DE'])
112+
* // returns 'fr-FR'
113+
*
114+
* getPreferredLocale('en;q=0.9,fr-FR;q=0.8', ['en-US', 'fr-FR', 'de-DE'])
115+
* // returns 'en-US'
116+
*
117+
* getPreferredLocale('es-ES;q=0.7', ['en-US', 'fr-FR', 'de-DE'])
118+
* // returns undefined
119+
* ```
120+
*/
121+
export function getPreferredLocale(
122+
header: string,
123+
supportedLocales: ReadonlyArray<string>,
124+
): string | undefined {
125+
if (supportedLocales.length < 2) {
126+
return supportedLocales[0];
127+
}
128+
129+
const parsedLocales = parseLanguageHeader(header);
130+
131+
// Handle edge cases:
132+
// - No preferred locales provided.
133+
// - Only one supported locale.
134+
// - Wildcard preference.
135+
if (parsedLocales.size === 0 || (parsedLocales.size === 1 && parsedLocales.has('*'))) {
136+
return supportedLocales[0];
137+
}
138+
139+
// Create a map for case-insensitive lookup of supported locales.
140+
// Keys are normalized (lowercase) locale values, values are original casing.
141+
const normalizedSupportedLocales = new Map<string, string>();
142+
for (const locale of supportedLocales) {
143+
normalizedSupportedLocales.set(normalizeLocale(locale), locale);
144+
}
145+
146+
// Iterate through parsed locales in descending order of quality.
147+
let bestMatch: string | undefined;
148+
const qualityZeroNormalizedLocales = new Set<string>();
149+
for (const [locale, quality] of parsedLocales) {
150+
const normalizedLocale = normalizeLocale(locale);
151+
if (quality === 0) {
152+
qualityZeroNormalizedLocales.add(normalizedLocale);
153+
continue; // Skip locales with quality value of 0.
154+
}
155+
156+
// Exact match found.
157+
if (normalizedSupportedLocales.has(normalizedLocale)) {
158+
return normalizedSupportedLocales.get(normalizedLocale);
159+
}
160+
161+
// If an exact match is not found, try prefix matching (e.g., "en" matches "en-US").
162+
// Store the first prefix match encountered, as it has the highest quality value.
163+
if (bestMatch !== undefined) {
164+
continue;
165+
}
166+
167+
const [languagePrefix] = normalizedLocale.split('-', 1);
168+
for (const supportedLocale of normalizedSupportedLocales.keys()) {
169+
if (supportedLocale.startsWith(languagePrefix)) {
170+
bestMatch = normalizedSupportedLocales.get(supportedLocale);
171+
break; // No need to continue searching for this locale.
172+
}
173+
}
174+
}
175+
176+
if (bestMatch !== undefined) {
177+
return bestMatch;
178+
}
179+
180+
// Return the first locale that is not quality zero.
181+
for (const [normalizedLocale, locale] of normalizedSupportedLocales) {
182+
if (!qualityZeroNormalizedLocales.has(normalizedLocale)) {
183+
return locale;
184+
}
185+
}
186+
}
187+
188+
/**
189+
* Normalizes a locale string by converting it to lowercase.
190+
*
191+
* @param locale - The locale string to normalize.
192+
* @returns The normalized locale string in lowercase.
193+
*
194+
* @example
195+
* ```ts
196+
* const normalized = normalizeLocale('EN-US');
197+
* console.log(normalized); // Output: "en-us"
198+
* ```
199+
*/
200+
function normalizeLocale(locale: string): string {
201+
return locale.toLowerCase();
202+
}

Diff for: ‎packages/angular/ssr/src/manifest.ts

+9-1
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ export interface AngularAppEngineManifest {
5555
/**
5656
* A readonly record of entry points for the server application.
5757
* Each entry consists of:
58-
* - `key`: The base href for the entry point.
58+
* - `key`: The url segment for the entry point.
5959
* - `value`: A function that returns a promise resolving to an object of type `EntryPointExports`.
6060
*/
6161
readonly entryPoints: Readonly<Record<string, (() => Promise<EntryPointExports>) | undefined>>;
@@ -65,6 +65,14 @@ export interface AngularAppEngineManifest {
6565
* This is used to determine the root path of the application.
6666
*/
6767
readonly basePath: string;
68+
69+
/**
70+
* A readonly record mapping supported locales to their respective entry-point paths.
71+
* Each entry consists of:
72+
* - `key`: The locale identifier (e.g., 'en', 'fr').
73+
* - `value`: The url segment associated with that locale.
74+
*/
75+
readonly supportedLocales: Readonly<Record<string, string | undefined>>;
6876
}
6977

7078
/**

Diff for: ‎packages/angular/ssr/test/app-engine_spec.ts

+36-2
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,32 @@ function createEntryPoint(locale: string) {
3434
class SSGComponent {}
3535

3636
return async () => {
37+
@Component({
38+
standalone: true,
39+
selector: `app-home-${locale}`,
40+
template: `Home works ${locale.toUpperCase()}`,
41+
})
42+
class HomeComponent {}
43+
44+
@Component({
45+
standalone: true,
46+
selector: `app-ssr-${locale}`,
47+
template: `SSR works ${locale.toUpperCase()}`,
48+
})
49+
class SSRComponent {}
50+
51+
@Component({
52+
standalone: true,
53+
selector: `app-ssg-${locale}`,
54+
template: `SSG works ${locale.toUpperCase()}`,
55+
})
56+
class SSGComponent {}
57+
3758
setAngularAppTestingManifest(
3859
[
3960
{ path: 'ssg', component: SSGComponent },
4061
{ path: 'ssr', component: SSRComponent },
62+
{ path: '', component: HomeComponent },
4163
],
4264
[
4365
{ path: 'ssg', renderMode: RenderMode.Prerender },
@@ -82,7 +104,8 @@ describe('AngularAppEngine', () => {
82104
it: createEntryPoint('it'),
83105
en: createEntryPoint('en'),
84106
},
85-
basePath: '',
107+
supportedLocales: { 'it': 'it', 'en': 'en' },
108+
basePath: '/',
86109
});
87110

88111
appEngine = new AngularAppEngine();
@@ -133,6 +156,16 @@ describe('AngularAppEngine', () => {
133156
expect(response).toBeNull();
134157
});
135158

159+
it('should redirect to the highest priority locale when the URL is "/"', async () => {
160+
const request = new Request('https://example.com/', {
161+
headers: { 'Accept-Language': 'fr-CH, fr;q=0.9, it;q=0.8, en;q=0.7, *;q=0.5' },
162+
});
163+
const response = await appEngine.handle(request);
164+
expect(response?.status).toBe(302);
165+
expect(response?.headers.get('Location')).toBe('https://example.com/it');
166+
expect(response?.headers.get('Vary')).toBe('Accept-Language');
167+
});
168+
136169
it('should return null for requests to file-like resources in a locale', async () => {
137170
const request = new Request('https://example.com/it/logo.png');
138171
const response = await appEngine.handle(request);
@@ -164,7 +197,8 @@ describe('AngularAppEngine', () => {
164197
};
165198
},
166199
},
167-
basePath: '',
200+
basePath: '/',
201+
supportedLocales: { 'en-US': '' },
168202
});
169203

170204
appEngine = new AngularAppEngine();

Diff for: ‎packages/angular/ssr/test/i18n_spec.ts

+104-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import { getPotentialLocaleIdFromUrl } from '../src/i18n';
9+
import { getPotentialLocaleIdFromUrl, getPreferredLocale } from '../src/i18n';
1010

1111
describe('getPotentialLocaleIdFromUrl', () => {
1212
it('should extract locale ID correctly when basePath is present', () => {
@@ -65,3 +65,106 @@ describe('getPotentialLocaleIdFromUrl', () => {
6565
expect(localeId).toBe('en');
6666
});
6767
});
68+
69+
describe('getPreferredLocale', () => {
70+
it('should return the exact match with the highest quality value', () => {
71+
const header = 'en-GB;q=0.8,fr-FR;q=0.9';
72+
const supportedLocales = ['en-GB', 'fr-FR', 'fr-BE'];
73+
const result = getPreferredLocale(header, supportedLocales);
74+
// Exact match for 'fr-FR' with the highest quality (0.9)
75+
expect(result).toBe('fr-FR');
76+
});
77+
78+
it('should return the best match when no exact match is found, using language prefixes', () => {
79+
const header = 'en-GB;q=0.9,fr;q=0.8';
80+
const supportedLocales = ['fr-FR', 'de-DE', 'en-US'];
81+
const result = getPreferredLocale(header, supportedLocales);
82+
// 'en-US' is the exact match with the highest quality (0.9)
83+
expect(result).toBe('en-US');
84+
});
85+
86+
it('should match based on language prefix when no exact match is found', () => {
87+
const header = 'en-US;q=0.8,fr;q=0.9,en-GB;q=0.7';
88+
const supportedLocales = ['en-GB', 'fr-FR', 'de-DE'];
89+
const result = getPreferredLocale(header, supportedLocales);
90+
// Best match is 'en-GB' based on exact match (0.8 for 'en-US')
91+
expect(result).toBe('en-GB');
92+
});
93+
94+
it('should return the first available locale when no exact match or prefix is found', () => {
95+
const header = 'it-IT;q=0.8';
96+
const supportedLocales = ['en-GB', 'fr-FR', 'de-DE'];
97+
const result = getPreferredLocale(header, supportedLocales);
98+
// The first locale in the supportedLocales set
99+
expect(result).toBe('en-GB');
100+
});
101+
102+
it('should return the first available locale when the header is empty', () => {
103+
const header = '';
104+
const supportedLocales = ['en-GB', 'fr-FR', 'de-DE'];
105+
const result = getPreferredLocale(header, supportedLocales);
106+
expect(result).toBe('en-GB'); // The first locale in the supportedLocales set
107+
});
108+
109+
it('should return the first available locale when the header is just "*"', () => {
110+
const header = '*';
111+
const supportedLocales = ['en-GB', 'fr-FR', 'de-DE'];
112+
const result = getPreferredLocale(header, supportedLocales);
113+
// The first locale in the supportedLocales set
114+
expect(result).toBe('en-GB');
115+
});
116+
117+
it('should return the first available locale when no valid languages are in header', () => {
118+
const header = 'xyz;q=0.5';
119+
const supportedLocales = ['en-GB', 'fr-FR', 'de-DE'];
120+
const result = getPreferredLocale(header, supportedLocales);
121+
// No valid language, fallback to the first available locale
122+
expect(result).toBe('en-GB');
123+
});
124+
125+
it('should return the closest match when no valid languages in header', () => {
126+
const header = 'en-XYZ;q=0.7,fr-XYZ;q=0.8';
127+
const supportedLocales = ['en-GB', 'fr-FR', 'de-DE'];
128+
const result = getPreferredLocale(header, supportedLocales);
129+
130+
// Since there is no exact match for 'en-XYZ' or 'fr-XYZ',
131+
// the function should return 'fr-FR' as the closest match,
132+
// as it shares the language prefix 'fr' with the 'fr-XYZ' in the header.
133+
expect(result).toBe('fr-FR');
134+
});
135+
136+
it('should ignore locales with quality 0 and choose the highest quality supported locale', () => {
137+
const header = 'en-GB;q=0,fr;q=0.9';
138+
const supportedLocales = ['en-GB', 'fr-FR', 'fr-BE'];
139+
const result = getPreferredLocale(header, supportedLocales);
140+
// 'en-GB' is ignored because quality is 0, so 'fr-FR' is chosen
141+
expect(result).toBe('fr-FR');
142+
});
143+
144+
it('should select the highest quality supported locale as fallback, ignoring those with quality 0', () => {
145+
const header = 'en-GB;q=0';
146+
const supportedLocales = ['en-GB', 'fr-FR', 'fr-BE'];
147+
const result = getPreferredLocale(header, supportedLocales);
148+
// 'en-GB' is ignored because quality is 0, so 'fr-FR' is chosen as the highest quality supported locale
149+
expect(result).toBe('fr-FR');
150+
});
151+
152+
it('should select the closest match based on quality before considering wildcard "*"', () => {
153+
const header = 'fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7, *;q=0.5';
154+
const supportedLocales = ['it-IT', 'fr-FR', 'de-DE'];
155+
const result = getPreferredLocale(header, supportedLocales);
156+
157+
// 'fr-FR' matches the 'fr' prefix with quality 0.9
158+
expect(result).toBe('fr-FR');
159+
});
160+
161+
it('should select the first available locale when only the wildcard "*" matches', () => {
162+
const header = 'fr-CH, fr;q=0.9, *;q=0.5';
163+
const supportedLocales = ['it-IT', 'de-DE'];
164+
const result = getPreferredLocale(header, supportedLocales);
165+
166+
// Since 'fr-CH' and 'fr' do not match any supported locales,
167+
// and '*' is present with quality 0.5, the first supported locale is chosen as a fallback.
168+
expect(result).toBe('it-IT');
169+
});
170+
});

0 commit comments

Comments
 (0)
Please sign in to comment.