Skip to content

Commit

Permalink
feat(@angular/ssr): redirect to preferred locale when accessing root …
Browse files Browse the repository at this point in the history
…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.
  • Loading branch information
alan-agius4 committed Dec 6, 2024
1 parent ffad81a commit 9089ec9
Show file tree
Hide file tree
Showing 6 changed files with 356 additions and 11 deletions.
12 changes: 11 additions & 1 deletion packages/angular/build/src/utils/server-rendering/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export function generateAngularServerAppEngineManifest(
baseHref: string | undefined,
): string {
const entryPoints: Record<string, string> = {};
const supportedLocales: Record<string, string> = {};

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

entryPoints[localeWithBaseHref] = `() => import('${importPath}')`;
supportedLocales[locale] = localeWithBaseHref;
}
} else {
entryPoints[''] = `() => import('./${MAIN_SERVER_OUTPUT_FILENAME}')`;
supportedLocales[i18nOptions.sourceLocale] = '';
}

// Remove trailing slash but retain leading slash.
let basePath = baseHref || '/';
if (basePath.length > 1 && basePath[basePath.length - 1] === '/') {
basePath = basePath.slice(0, -1);
}

const manifestContent = `
export default {
basePath: '${baseHref ?? '/'}',
basePath: '${basePath}',
supportedLocales: ${JSON.stringify(supportedLocales, undefined, 2)},
entryPoints: {
${Object.entries(entryPoints)
.map(([key, value]) => `'${key}': ${value}`)
Expand Down
64 changes: 58 additions & 6 deletions packages/angular/ssr/src/app-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@

import type { AngularServerApp, getOrCreateAngularServerApp } from './app';
import { Hooks } from './hooks';
import { getPotentialLocaleIdFromUrl } from './i18n';
import { getPotentialLocaleIdFromUrl, getPreferredLocale } from './i18n';
import { EntryPointExports, getAngularAppEngineManifest } from './manifest';
import { joinUrlParts } from './utils/url';

/**
* Angular server application engine.
Expand Down Expand Up @@ -47,9 +48,9 @@ export class AngularAppEngine {
private readonly manifest = getAngularAppEngineManifest();

/**
* The number of entry points available in the server application's manifest.
* A map of supported locales from the server application's manifest.
*/
private readonly entryPointsCount = Object.keys(this.manifest.entryPoints).length;
private readonly supportedLocales = new Set(Object.keys(this.manifest.supportedLocales));

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

return serverApp ? serverApp.handle(request, requestContext) : null;
if (serverApp) {
return serverApp.handle(request, requestContext);
}

if (this.supportedLocales.size > 1) {
// Redirect to the preferred language if i18n is enabled.
return this.redirectBasedOnAcceptLanguage(request);
}

return null;
}

/**
* Handles requests for the base path when i18n is enabled.
* Redirects the user to a locale-specific path based on the `Accept-Language` header.
*
* @param request The incoming request.
* @returns A `Response` object with a 302 redirect, or `null` if i18n is not enabled
* or the request is not for the base path.
*/
private redirectBasedOnAcceptLanguage(request: Request): Response | null {
const { basePath, supportedLocales } = this.manifest;

// If the request is not for the base path, it's not our responsibility to handle it.
const url = new URL(request.url);
if (url.pathname !== basePath) {
return null;
}

// For requests to the base path (typically '/'), attempt to extract the preferred locale
// from the 'Accept-Language' header.
const preferredLocale = getPreferredLocale(
request.headers.get('Accept-Language') || '*',
this.supportedLocales,
);

if (preferredLocale !== null) {
const subPath = supportedLocales[preferredLocale];
if (subPath !== undefined) {
url.pathname = joinUrlParts(url.pathname, subPath);

return new Response(null, {
status: 302, // Use a 302 redirect as language preference may change.
headers: {
'Location': url.toString(),
'Vary': 'Accept-Language',
},
});
}
}

return null;
}

/**
Expand Down Expand Up @@ -142,12 +194,12 @@ export class AngularAppEngine {
*/
private getEntryPointExportsForUrl(url: URL): Promise<EntryPointExports> | undefined {
const { basePath } = this.manifest;
if (this.entryPointsCount === 1) {
if (this.supportedLocales.size === 1) {
return this.getEntryPointExports('');
}

const potentialLocale = getPotentialLocaleIdFromUrl(url, basePath);

return this.getEntryPointExports(potentialLocale);
return this.getEntryPointExports(potentialLocale) ?? this.getEntryPointExports('');
}
}
157 changes: 157 additions & 0 deletions packages/angular/ssr/src/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,160 @@ export function getPotentialLocaleIdFromUrl(url: URL, basePath: string): string
// Extract the potential locale id.
return pathname.slice(start, end);
}

/**
* Parses the `Accept-Language` header and returns a list of locale preferences with their respective quality values.
*
* The `Accept-Language` header is typically a comma-separated list of locales, with optional quality values
* in the form of `q=<value>`. If no quality value is specified, a default quality of `1` is assumed.
* Special case: if the header is `*`, it returns the default locale with a quality of `1`.
*
* @param header - The value of the `Accept-Language` header, typically a comma-separated list of locales
* with optional quality values (e.g., `en-US;q=0.8,fr-FR;q=0.9`). If the header is `*`,
* it represents a wildcard for any language, returning the default locale.
*
* @returns A `ReadonlyMap` where the key is the locale (e.g., `en-US`, `fr-FR`), and the value is
* the associated quality value (a number between 0 and 1). If no quality value is provided,
* a default of `1` is used.
*
* @example
* ```js
* parseLanguageHeader('en-US;q=0.8,fr-FR;q=0.9')
* // returns new Map([['en-US', 0.8], ['fr-FR', 0.9]])
* parseLanguageHeader('*')
* // returns new Map([['*', 1]])
* ```
*/
function parseLanguageHeader(header: string): ReadonlyMap<string, number> {
if (header === '*') {
return new Map([['*', 1]]);
}

const parsedValues = header
.split(',')
.map((item) => {
const [locale, qualityValue] = item
.trim()
.split(';', 2)
.map((v) => v.trim());

const quality = qualityValue?.startsWith('q=') ? parseFloat(qualityValue.slice(2)) : 1;

return [locale, quality] as const;
})
.sort((a, b) => b[1] - a[1]);

return new Map(parsedValues);
}

/**
* Gets the preferred locale based on the highest quality value from the provided `Accept-Language` header
* and the set of available locales. If no exact match is found, it attempts to find the closest match
* based on language prefixes (e.g., `en` matching `en-US` or `en-GB`).
*
* The function considers the quality values (`q=<value>`) in the `Accept-Language` header. If no quality
* value is provided, it defaults to `q=1`. The function returns the locale from `supportedLocales`
* with the highest quality value. If no suitable match is found, it returns `null`.
*
* @param header - The `Accept-Language` header string to parse and evaluate. It may contain multiple
* locales with optional quality values, for example: `'en-US;q=0.8,fr-FR;q=0.9'`.
* @param supportedLocales - A readonly set of supported locales (e.g., `new Set(['en-US', 'fr-FR'])`),
* representing the locales available in the application.
* @returns The best matching locale from the supported languages, or `null` if no match is found.
*
* @example
* ```js
* getPreferredLocale('en-US;q=0.8,fr-FR;q=0.9', new Set(['en-US', 'fr-FR', 'de-DE']))
* // returns 'fr-FR'
*
* getPreferredLocale('en;q=0.9,fr-FR;q=0.8', new Set(['en-US', 'fr-FR', 'de-DE']))
* // returns 'en-US'
*
* getPreferredLocale('es-ES;q=0.7', new Set(['en-US', 'fr-FR', 'de-DE']))
* // returns null
* ```
*/
export function getPreferredLocale(
header: string,
supportedLocales: ReadonlySet<string>,
): string | null {
const parsedLocales = parseLanguageHeader(header);
if (
parsedLocales.size === 0 ||
supportedLocales.size === 1 ||
(parsedLocales.size === 1 && parsedLocales.has('*'))
) {
return supportedLocales.values().next().value as string;
}

// First, try to find the best exact match
// If no exact match, try to find the best loose match
const match =
getBestExactMatch(parsedLocales, supportedLocales) ??
getBestLooseMatch(parsedLocales, supportedLocales);
if (match !== undefined) {
return match;
}

// Return the first locale that is not quality zero.
for (const locale of supportedLocales) {
if (parsedLocales.get(locale) !== 0) {
return locale;
}
}

return null;
}

/**
* Finds the best exact match for the parsed locales from the supported languages.
* @param parsedLocales - A read-only map of parsed locales with their associated quality values.
* @param supportedLocales - A set of supported languages.
* @returns The best matching locale from the supported languages or undefined if no match is found.
*/
function getBestExactMatch(
parsedLocales: ReadonlyMap<string, number>,
supportedLocales: ReadonlySet<string>,
): string | undefined {
// Find the best exact match based on quality
for (const [locale, quality] of parsedLocales) {
if (quality === 0) {
continue;
}

if (supportedLocales.has(locale)) {
return locale;
}
}

return undefined;
}

/**
* Finds the best loose match for the parsed locales from the supported languages.
* A loose match is a match where the locale's prefix matches a supported language.
* @param parsedLocales - A read-only map of parsed locales with their associated quality values.
* @param supportedLocales - A set of supported languages.
* @returns The best loose matching locale from the supported languages or undefined if no match is found.
*/
function getBestLooseMatch(
parsedLocales: ReadonlyMap<string, number>,
supportedLocales: ReadonlySet<string>,
): string | undefined {
// If no exact match, fallback to closest matches
for (const [locale, quality] of parsedLocales) {
if (quality === 0) {
continue;
}

const [languagePrefix] = locale.split('-', 1);
for (const availableLocale of supportedLocales) {
if (availableLocale.startsWith(languagePrefix)) {
return availableLocale;
}
}
}

return undefined;
}
10 changes: 9 additions & 1 deletion packages/angular/ssr/src/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export interface AngularAppEngineManifest {
/**
* A readonly record of entry points for the server application.
* Each entry consists of:
* - `key`: The base href for the entry point.
* - `key`: The url segment for the entry point.
* - `value`: A function that returns a promise resolving to an object of type `EntryPointExports`.
*/
readonly entryPoints: Readonly<Record<string, (() => Promise<EntryPointExports>) | undefined>>;
Expand All @@ -65,6 +65,14 @@ export interface AngularAppEngineManifest {
* This is used to determine the root path of the application.
*/
readonly basePath: string;

/**
* A readonly record mapping supported locales to their respective entry-point paths.
* Each entry consists of:
* - `key`: The locale identifier (e.g., 'en', 'fr').
* - `value`: The url segment associated with that locale.
*/
readonly supportedLocales: Readonly<Record<string, string | undefined>>;
}

/**
Expand Down
38 changes: 36 additions & 2 deletions packages/angular/ssr/test/app-engine_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,32 @@ function createEntryPoint(locale: string) {
class SSGComponent {}

return async () => {
@Component({
standalone: true,
selector: `app-home-${locale}`,
template: `Home works ${locale.toUpperCase()}`,
})
class HomeComponent {}

@Component({
standalone: true,
selector: `app-ssr-${locale}`,
template: `SSR works ${locale.toUpperCase()}`,
})
class SSRComponent {}

@Component({
standalone: true,
selector: `app-ssg-${locale}`,
template: `SSG works ${locale.toUpperCase()}`,
})
class SSGComponent {}

setAngularAppTestingManifest(
[
{ path: 'ssg', component: SSGComponent },
{ path: 'ssr', component: SSRComponent },
{ path: '', component: HomeComponent },
],
[
{ path: 'ssg', renderMode: RenderMode.Prerender },
Expand Down Expand Up @@ -81,7 +103,8 @@ describe('AngularAppEngine', () => {
it: createEntryPoint('it'),
en: createEntryPoint('en'),
},
basePath: '',
supportedLocales: { 'it': 'it', 'en': 'en' },
basePath: '/',
});

appEngine = new AngularAppEngine();
Expand Down Expand Up @@ -130,6 +153,16 @@ describe('AngularAppEngine', () => {
expect(response).toBeNull();
});

it('should redirect to the highest priority locale when the URL is "/"', async () => {
const request = new Request('https://example.com/', {
headers: { 'Accept-Language': 'fr-CH, fr;q=0.9, it;q=0.8, en;q=0.7, *;q=0.5' },
});
const response = await appEngine.handle(request);
expect(response?.status).toBe(302);
expect(response?.headers.get('Location')).toBe('https://example.com/it');
expect(response?.headers.get('Vary')).toBe('Accept-Language');
});

it('should return null for requests to file-like resources in a locale', async () => {
const request = new Request('https://example.com/it/logo.png');
const response = await appEngine.handle(request);
Expand Down Expand Up @@ -161,7 +194,8 @@ describe('AngularAppEngine', () => {
};
},
},
basePath: '',
basePath: '/',
supportedLocales: { 'en-US': '' },
});

appEngine = new AngularAppEngine();
Expand Down
Loading

0 comments on commit 9089ec9

Please sign in to comment.