Skip to content

Commit cd97295

Browse files
committed
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 ffad81a commit cd97295

File tree

6 files changed

+352
-8
lines changed

6 files changed

+352
-8
lines changed

packages/angular/build/src/utils/server-rendering/manifest.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export function generateAngularServerAppEngineManifest(
5656
baseHref: string | undefined,
5757
): string {
5858
const entryPoints: Record<string, string> = {};
59+
const supportedLocales: Record<string, string> = {};
5960

6061
if (i18nOptions.shouldInline) {
6162
for (const locale of i18nOptions.inlineLocales) {
@@ -70,14 +71,23 @@ export function generateAngularServerAppEngineManifest(
7071
localeWithBaseHref = localeWithBaseHref.slice(start, end);
7172

7273
entryPoints[localeWithBaseHref] = `() => import('${importPath}')`;
74+
supportedLocales[locale] = localeWithBaseHref;
7375
}
7476
} else {
7577
entryPoints[''] = `() => import('./${MAIN_SERVER_OUTPUT_FILENAME}')`;
78+
supportedLocales[i18nOptions.sourceLocale] = '';
79+
}
80+
81+
// Remove trailing slash but retain leading slash.
82+
let basePath = baseHref || '/';
83+
if (basePath.length > 1 && basePath[basePath.length - 1] === '/') {
84+
basePath = basePath.slice(0, -1);
7685
}
7786

7887
const manifestContent = `
7988
export default {
80-
basePath: '${baseHref ?? '/'}',
89+
basePath: '${basePath}',
90+
supportedLocales: ${JSON.stringify(supportedLocales, undefined, 2)},
8191
entryPoints: {
8292
${Object.entries(entryPoints)
8393
.map(([key, value]) => `'${key}': ${value}`)

packages/angular/ssr/src/app-engine.ts

Lines changed: 55 additions & 3 deletions
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.
@@ -51,6 +52,11 @@ export class AngularAppEngine {
5152
*/
5253
private readonly entryPointsCount = Object.keys(this.manifest.entryPoints).length;
5354

55+
/**
56+
* A map of supported locales from the server application's manifest.
57+
*/
58+
private readonly supportedLocales = new Set(Object.keys(this.manifest.supportedLocales));
59+
5460
/**
5561
* A cache that holds entry points, keyed by their potential locale string.
5662
*/
@@ -70,7 +76,53 @@ export class AngularAppEngine {
7076
async handle(request: Request, requestContext?: unknown): Promise<Response | null> {
7177
const serverApp = await this.getAngularServerAppForRequest(request);
7278

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

76128
/**
@@ -148,6 +200,6 @@ export class AngularAppEngine {
148200

149201
const potentialLocale = getPotentialLocaleIdFromUrl(url, basePath);
150202

151-
return this.getEntryPointExports(potentialLocale);
203+
return this.getEntryPointExports(potentialLocale) ?? this.getEntryPointExports('');
152204
}
153205
}

packages/angular/ssr/src/i18n.ts

Lines changed: 157 additions & 0 deletions
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
80+
.trim()
81+
.split(';', 2)
82+
.map((v) => v.trim());
83+
84+
const quality = qualityValue?.startsWith('q=') ? parseFloat(qualityValue.slice(2)) : 1;
85+
86+
return [locale, quality] as const;
87+
})
88+
.sort((a, b) => b[1] - a[1]);
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. If no exact match is found, it attempts to find the closest match
96+
* based on language prefixes (e.g., `en` matching `en-US` or `en-GB`).
97+
*
98+
* The function considers the quality values (`q=<value>`) in the `Accept-Language` header. If no quality
99+
* value is provided, it defaults to `q=1`. The function returns the locale from `supportedLocales`
100+
* with the highest quality value. If no suitable match is found, it returns `null`.
101+
*
102+
* @param header - The `Accept-Language` header string to parse and evaluate. It may contain multiple
103+
* locales with optional quality values, for example: `'en-US;q=0.8,fr-FR;q=0.9'`.
104+
* @param supportedLocales - A readonly set of supported locales (e.g., `new Set(['en-US', 'fr-FR'])`),
105+
* representing the locales available in the application.
106+
* @returns The best matching locale from the supported languages, or `null` if no match is found.
107+
*
108+
* @example
109+
* ```js
110+
* getPreferredLocale('en-US;q=0.8,fr-FR;q=0.9', new Set(['en-US', 'fr-FR', 'de-DE']))
111+
* // returns 'fr-FR'
112+
*
113+
* getPreferredLocale('en;q=0.9,fr-FR;q=0.8', new Set(['en-US', 'fr-FR', 'de-DE']))
114+
* // returns 'en-US'
115+
*
116+
* getPreferredLocale('es-ES;q=0.7', new Set(['en-US', 'fr-FR', 'de-DE']))
117+
* // returns null
118+
* ```
119+
*/
120+
export function getPreferredLocale(
121+
header: string,
122+
supportedLocales: ReadonlySet<string>,
123+
): string | null {
124+
const parsedLocales = parseLanguageHeader(header);
125+
if (
126+
parsedLocales.size === 0 ||
127+
supportedLocales.size === 1 ||
128+
(parsedLocales.size === 1 && parsedLocales.has('*'))
129+
) {
130+
return supportedLocales.values().next().value as string;
131+
}
132+
133+
// First, try to find the best exact match
134+
// If no exact match, try to find the best loose match
135+
const match =
136+
getBestExactMatch(parsedLocales, supportedLocales) ??
137+
getBestLooseMatch(parsedLocales, supportedLocales);
138+
if (match !== undefined) {
139+
return match;
140+
}
141+
142+
// Return the first locale that is not quality zero.
143+
for (const locale of supportedLocales) {
144+
if (parsedLocales.get(locale) !== 0) {
145+
return locale;
146+
}
147+
}
148+
149+
return null;
150+
}
151+
152+
/**
153+
* Finds the best exact match for the parsed locales from the supported languages.
154+
* @param parsedLocales - A read-only map of parsed locales with their associated quality values.
155+
* @param supportedLocales - A set of supported languages.
156+
* @returns The best matching locale from the supported languages or undefined if no match is found.
157+
*/
158+
function getBestExactMatch(
159+
parsedLocales: ReadonlyMap<string, number>,
160+
supportedLocales: ReadonlySet<string>,
161+
): string | undefined {
162+
// Find the best exact match based on quality
163+
for (const [locale, quality] of parsedLocales) {
164+
if (quality === 0) {
165+
continue;
166+
}
167+
168+
if (supportedLocales.has(locale)) {
169+
return locale;
170+
}
171+
}
172+
173+
return undefined;
174+
}
175+
176+
/**
177+
* Finds the best loose match for the parsed locales from the supported languages.
178+
* A loose match is a match where the locale's prefix matches a supported language.
179+
* @param parsedLocales - A read-only map of parsed locales with their associated quality values.
180+
* @param supportedLocales - A set of supported languages.
181+
* @returns The best loose matching locale from the supported languages or undefined if no match is found.
182+
*/
183+
function getBestLooseMatch(
184+
parsedLocales: ReadonlyMap<string, number>,
185+
supportedLocales: ReadonlySet<string>,
186+
): string | undefined {
187+
// If no exact match, fallback to closest matches
188+
for (const [locale, quality] of parsedLocales) {
189+
if (quality === 0) {
190+
continue;
191+
}
192+
193+
const [languagePrefix] = locale.split('-', 1);
194+
for (const availableLocale of supportedLocales) {
195+
if (availableLocale.startsWith(languagePrefix)) {
196+
return availableLocale;
197+
}
198+
}
199+
}
200+
201+
return undefined;
202+
}

packages/angular/ssr/src/manifest.ts

Lines changed: 9 additions & 1 deletion
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
/**

packages/angular/ssr/test/app-engine_spec.ts

Lines changed: 35 additions & 2 deletions
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 },
@@ -81,7 +103,8 @@ describe('AngularAppEngine', () => {
81103
it: createEntryPoint('it'),
82104
en: createEntryPoint('en'),
83105
},
84-
basePath: '',
106+
supportedLocales: { 'it': 'it', 'en': 'en' },
107+
basePath: '/',
85108
});
86109

87110
appEngine = new AngularAppEngine();
@@ -130,6 +153,15 @@ describe('AngularAppEngine', () => {
130153
expect(response).toBeNull();
131154
});
132155

156+
it('should redirect to the highest priority locale when the URL is "/"', async () => {
157+
const request = new Request('https://example.com/', {
158+
headers: { 'Accept-Language': 'fr-CH, fr;q=0.9, it;q=0.8, en;q=0.7, *;q=0.5' },
159+
});
160+
const response = await appEngine.handle(request);
161+
expect(response?.status).toBe(302);
162+
expect(response?.headers.get('Location')).toBe('https://example.com/it');
163+
});
164+
133165
it('should return null for requests to file-like resources in a locale', async () => {
134166
const request = new Request('https://example.com/it/logo.png');
135167
const response = await appEngine.handle(request);
@@ -161,7 +193,8 @@ describe('AngularAppEngine', () => {
161193
};
162194
},
163195
},
164-
basePath: '',
196+
basePath: '/',
197+
supportedLocales: { 'en-US': '' },
165198
});
166199

167200
appEngine = new AngularAppEngine();

0 commit comments

Comments
 (0)