Skip to content

Commit 3d36469

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 3d1f8d4 commit 3d36469

File tree

5 files changed

+242
-10
lines changed

5 files changed

+242
-10
lines changed

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,15 +57,21 @@ export function generateAngularServerAppEngineManifest(
5757
for (const locale of i18nOptions.inlineLocales) {
5858
const { urlSegment = locale } = i18nOptions.locales[locale];
5959
const importPath = `${urlSegment ? `${urlSegment}/` : ''}${MAIN_SERVER_OUTPUT_FILENAME}`;
60-
entryPoints[urlSegment] = `() => import('./${importPath}')]`;
60+
entryPoints[urlSegment] = `() => import('./${importPath}')`;
6161
}
6262
} else {
6363
entryPoints[''] = `() => import('./${MAIN_SERVER_OUTPUT_FILENAME}')`;
6464
}
6565

66+
// Remove trailing slash
67+
let basePath = baseHref || '/';
68+
if (basePath.length > 1 && basePath[basePath.length - 1] === '/') {
69+
basePath = basePath.slice(0, -1);
70+
}
71+
6672
const manifestContent = `
6773
export default {
68-
basePath: '${baseHref ?? '/'}',
74+
basePath: '${basePath}',
6975
entryPoints: {
7076
${Object.entries(entryPoints)
7177
.map(([key, value]) => `'${key}': ${value}`)

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

Lines changed: 42 additions & 5 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.
@@ -47,9 +48,9 @@ export class AngularAppEngine {
4748
private readonly manifest = getAngularAppEngineManifest();
4849

4950
/**
50-
* The number of entry points available in the server application's manifest.
51+
* The keys of entry points available in the server application's manifest.
5152
*/
52-
private readonly entryPointsCount = Object.keys(this.manifest.entryPoints).length;
53+
private readonly entryPointsLocales = new Set(Object.keys(this.manifest.entryPoints));
5354

5455
/**
5556
* A cache that holds entry points, keyed by their potential locale string.
@@ -70,7 +71,43 @@ export class AngularAppEngine {
7071
async handle(request: Request, requestContext?: unknown): Promise<Response | null> {
7172
const serverApp = await this.getAngularServerAppForRequest(request);
7273

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

76113
/**
@@ -142,7 +179,7 @@ export class AngularAppEngine {
142179
*/
143180
private getEntryPointExportsForUrl(url: URL): Promise<EntryPointExports> | undefined {
144181
const { basePath } = this.manifest;
145-
if (this.entryPointsCount === 1) {
182+
if (this.entryPointsLocales.size === 1) {
146183
return this.getEntryPointExports('');
147184
}
148185

packages/angular/ssr/src/i18n.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,107 @@ export function getPotentialLocaleIdFromUrl(url: URL, basePath: string): string
4343
// Extract the potential locale id.
4444
return pathname.slice(start, end);
4545
}
46+
47+
/**
48+
* Represents a locale preference with an optional quality value.
49+
*/
50+
interface LocalePreference {
51+
/** The locale (e.g., 'en-US', 'fr') */
52+
locale: string;
53+
54+
/** The quality value indicating the preference */
55+
quality?: number;
56+
}
57+
58+
/**
59+
* Parses the `Accept-Language` header and returns a list of locale preferences with their respective quality values.
60+
*
61+
* @param header - The value of the `Accept-Language` header, typically a comma-separated list of locales with optional quality values.
62+
* @returns A list of `LocalePreference` objects representing the parsed locales and their associated quality values.
63+
*
64+
* @example
65+
* ```js
66+
* parseLanguageHeader('en-US;q=0.8,fr-FR;q=0.9')
67+
* // returns [{ locale: 'en-US', quality: 0.8 }, { locale: 'fr-FR', quality: 0.9 }]
68+
* ```
69+
*/
70+
function parseLanguageHeader(header: string): LocalePreference[] {
71+
if (header === '*') {
72+
return [{ locale: '*', quality: undefined }];
73+
}
74+
75+
const localesList: LocalePreference[] = [];
76+
const items = header.split(',');
77+
78+
for (const item of items) {
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)) : undefined;
85+
86+
localesList.push({
87+
locale,
88+
quality: quality !== undefined && quality <= 1 ? quality : undefined,
89+
});
90+
}
91+
92+
return localesList;
93+
}
94+
95+
/**
96+
* Gets the preferred locale based on the highest quality value from the provided header and the set of available locales.
97+
* If no exact match is found, it tries to find the closest match based on language prefixes.
98+
*
99+
* @param header - The `Accept-Language` header string to parse and evaluate.
100+
* @param availableLocales - A readonly set of available locales that the user can choose from.
101+
* @returns The best matching locale.
102+
*
103+
* @example
104+
* ```js
105+
* getPreferredLocale('en-US;q=0.8,fr-FR;q=0.9', new Set(['en-US', 'fr-FR', 'de-DE']))
106+
* // returns 'fr-FR'
107+
* ```
108+
*/
109+
export function getPreferredLocale(header: string, availableLocales: ReadonlySet<string>): string {
110+
const parsedLocales = parseLanguageHeader(header);
111+
if (
112+
parsedLocales.length === 0 ||
113+
(parsedLocales.length === 1 && parsedLocales[0].locale === '*')
114+
) {
115+
return availableLocales.values().next().value as string;
116+
}
117+
118+
let bestMatch: string | undefined;
119+
let bestQuality = 0;
120+
121+
// Find the best exact match based on quality
122+
for (const { locale, quality } of parsedLocales) {
123+
const currentQuality = quality ?? 1;
124+
if (availableLocales.has(locale) && currentQuality > bestQuality) {
125+
bestMatch = locale;
126+
bestQuality = currentQuality;
127+
}
128+
}
129+
130+
if (bestMatch) {
131+
return bestMatch;
132+
}
133+
134+
// If no exact match, fallback to closest matches
135+
bestQuality = 0;
136+
for (const { locale, quality } of parsedLocales) {
137+
const currentQuality = quality ?? 1;
138+
const languagePrefix = locale.split('-', 1)[0];
139+
for (const availableLocale of availableLocales) {
140+
if (availableLocale.startsWith(languagePrefix) && currentQuality > bestQuality) {
141+
bestMatch = availableLocale;
142+
bestQuality = currentQuality;
143+
break;
144+
}
145+
}
146+
}
147+
148+
return bestMatch || (availableLocales.values().next().value as string);
149+
}

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

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,13 @@ import { setAngularAppTestingManifest } from './testing-utils';
2020

2121
function createEntryPoint(locale: string) {
2222
return async () => {
23+
@Component({
24+
standalone: true,
25+
selector: `app-home-${locale}`,
26+
template: `Home works ${locale.toUpperCase()}`,
27+
})
28+
class HomeComponent {}
29+
2330
@Component({
2431
standalone: true,
2532
selector: `app-ssr-${locale}`,
@@ -38,6 +45,7 @@ function createEntryPoint(locale: string) {
3845
[
3946
{ path: 'ssg', component: SSGComponent },
4047
{ path: 'ssr', component: SSRComponent },
48+
{ path: '', component: HomeComponent },
4149
],
4250
[
4351
{ path: 'ssg', renderMode: RenderMode.Prerender },
@@ -83,7 +91,7 @@ describe('AngularAppEngine', () => {
8391
it: createEntryPoint('it'),
8492
en: createEntryPoint('en'),
8593
},
86-
basePath: '',
94+
basePath: '/',
8795
});
8896

8997
appEngine = new AngularAppEngine();
@@ -132,6 +140,15 @@ describe('AngularAppEngine', () => {
132140
expect(response).toBeNull();
133141
});
134142

143+
it('should redirect to the highest priority locale when the URL is "/"', async () => {
144+
const request = new Request('https://example.com/', {
145+
headers: { 'Accept-Language': 'fr-CH, fr;q=0.9, it;q=0.8, en;q=0.7, *;q=0.5' },
146+
});
147+
const response = await appEngine.handle(request);
148+
expect(response?.status).toBe(302);
149+
expect(response?.headers.get('Location')).toBe('https://example.com/it');
150+
});
151+
135152
it('should return null for requests to file-like resources in a locale', async () => {
136153
const request = new Request('https://example.com/it/logo.png');
137154
const response = await appEngine.handle(request);
@@ -165,7 +182,7 @@ describe('AngularAppEngine', () => {
165182
};
166183
},
167184
},
168-
basePath: '',
185+
basePath: '/',
169186
});
170187

171188
appEngine = new AngularAppEngine();

packages/angular/ssr/test/i18n_spec.ts

Lines changed: 69 additions & 1 deletion
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,71 @@ 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 availableLocales = new Set(['en-GB', 'fr-FR', 'fr-BE']);
73+
const result = getPreferredLocale(header, availableLocales);
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 availableLocales = new Set(['fr-FR', 'de-DE', 'en-US']);
81+
const result = getPreferredLocale(header, availableLocales);
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 availableLocales = new Set(['en-GB', 'fr-FR', 'de-DE']);
89+
const result = getPreferredLocale(header, availableLocales);
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 availableLocales = new Set(['en-GB', 'fr-FR', 'de-DE']);
97+
const result = getPreferredLocale(header, availableLocales);
98+
// The first locale in the availableLocales 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 availableLocales = new Set(['en-GB', 'fr-FR', 'de-DE']);
105+
const result = getPreferredLocale(header, availableLocales);
106+
expect(result).toBe('en-GB'); // The first locale in the availableLocales set
107+
});
108+
109+
it('should return the first available locale when the header is just "*"', () => {
110+
const header = '*';
111+
const availableLocales = new Set(['en-GB', 'fr-FR', 'de-DE']);
112+
const result = getPreferredLocale(header, availableLocales);
113+
// The first locale in the availableLocales 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 availableLocales = new Set(['en-GB', 'fr-FR', 'de-DE']);
120+
const result = getPreferredLocale(header, availableLocales);
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 availableLocales = new Set(['en-GB', 'fr-FR', 'de-DE']);
128+
const result = getPreferredLocale(header, availableLocales);
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+
});

0 commit comments

Comments
 (0)