diff --git a/packages/angular/build/src/utils/server-rendering/manifest.ts b/packages/angular/build/src/utils/server-rendering/manifest.ts index bdd7fdd28df9..a8be8d833efa 100644 --- a/packages/angular/build/src/utils/server-rendering/manifest.ts +++ b/packages/angular/build/src/utils/server-rendering/manifest.ts @@ -60,19 +60,30 @@ export function generateAngularServerAppEngineManifest( baseHref: string | undefined, ): string { const entryPoints: Record = {}; + const supportedLocales: Record = {}; + if (i18nOptions.shouldInline && !i18nOptions.flatOutput) { for (const locale of i18nOptions.inlineLocales) { const { subPath } = i18nOptions.locales[locale]; const importPath = `${subPath ? `${subPath}/` : ''}${MAIN_SERVER_OUTPUT_FILENAME}`; entryPoints[subPath] = `() => import('./${importPath}')`; + supportedLocales[locale] = subPath; } } 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}`) diff --git a/packages/angular/ssr/src/app-engine.ts b/packages/angular/ssr/src/app-engine.ts index 68117744d8c5..d8df64fa4bbd 100644 --- a/packages/angular/ssr/src/app-engine.ts +++ b/packages/angular/ssr/src/app-engine.ts @@ -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. @@ -47,9 +48,11 @@ 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: ReadonlyArray = Object.keys( + this.manifest.supportedLocales, + ); /** * A cache that holds entry points, keyed by their potential locale string. @@ -70,7 +73,58 @@ export class AngularAppEngine { async handle(request: Request, requestContext?: unknown): Promise { const serverApp = await this.getAngularServerAppForRequest(request); - return serverApp ? serverApp.handle(request, requestContext) : null; + if (serverApp) { + return serverApp.handle(request, requestContext); + } + + if (this.supportedLocales.length > 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) { + 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; } /** @@ -142,7 +196,7 @@ export class AngularAppEngine { */ private getEntryPointExportsForUrl(url: URL): Promise | undefined { const { basePath } = this.manifest; - if (this.entryPointsCount === 1) { + if (this.supportedLocales.length === 1) { return this.getEntryPointExports(''); } diff --git a/packages/angular/ssr/src/i18n.ts b/packages/angular/ssr/src/i18n.ts index d87c69666794..06129bcb22ed 100644 --- a/packages/angular/ssr/src/i18n.ts +++ b/packages/angular/ssr/src/i18n.ts @@ -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=`. 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 { + if (header === '*') { + return new Map([['*', 1]]); + } + + const parsedValues = header + .split(',') + .map((item) => { + const [locale, qualityValue] = item.split(';', 2).map((v) => v.trim()); + + let quality = qualityValue?.startsWith('q=') ? parseFloat(qualityValue.slice(2)) : undefined; + if (typeof quality !== 'number' || isNaN(quality) || quality < 0 || quality > 1) { + quality = 1; // Invalid quality value defaults to 1 + } + + return [locale, quality] as const; + }) + .sort(([_localeA, qualityA], [_localeB, qualityB]) => qualityB - qualityA); + + 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. + * + * This function adheres to the HTTP `Accept-Language` header specification as defined in + * [RFC 7231](https://datatracker.ietf.org/doc/html/rfc7231#section-5.3.5), including: + * - Case-insensitive matching of language tags. + * - Quality value handling (e.g., `q=1`, `q=0.8`). If no quality value is provided, it defaults to `q=1`. + * - Prefix matching (e.g., `en` matching `en-US` or `en-GB`). + * + * @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 - An array of supported locales (e.g., `['en-US', 'fr-FR']`), + * representing the locales available in the application. + * @returns The best matching locale from the supported languages, or `undefined` if no match is found. + * + * @example + * ```js + * getPreferredLocale('en-US;q=0.8,fr-FR;q=0.9', ['en-US', 'fr-FR', 'de-DE']) + * // returns 'fr-FR' + * + * getPreferredLocale('en;q=0.9,fr-FR;q=0.8', ['en-US', 'fr-FR', 'de-DE']) + * // returns 'en-US' + * + * getPreferredLocale('es-ES;q=0.7', ['en-US', 'fr-FR', 'de-DE']) + * // returns undefined + * ``` + */ +export function getPreferredLocale( + header: string, + supportedLocales: ReadonlyArray, +): string | undefined { + if (supportedLocales.length < 2) { + return supportedLocales[0]; + } + + const parsedLocales = parseLanguageHeader(header); + + // Handle edge cases: + // - No preferred locales provided. + // - Only one supported locale. + // - Wildcard preference. + if (parsedLocales.size === 0 || (parsedLocales.size === 1 && parsedLocales.has('*'))) { + return supportedLocales[0]; + } + + // Create a map for case-insensitive lookup of supported locales. + // Keys are normalized (lowercase) locale values, values are original casing. + const normalizedSupportedLocales = new Map(); + for (const locale of supportedLocales) { + normalizedSupportedLocales.set(normalizeLocale(locale), locale); + } + + // Iterate through parsed locales in descending order of quality. + let bestMatch: string | undefined; + const qualityZeroNormalizedLocales = new Set(); + for (const [locale, quality] of parsedLocales) { + const normalizedLocale = normalizeLocale(locale); + if (quality === 0) { + qualityZeroNormalizedLocales.add(normalizedLocale); + continue; // Skip locales with quality value of 0. + } + + // Exact match found. + if (normalizedSupportedLocales.has(normalizedLocale)) { + return normalizedSupportedLocales.get(normalizedLocale); + } + + // If an exact match is not found, try prefix matching (e.g., "en" matches "en-US"). + // Store the first prefix match encountered, as it has the highest quality value. + if (bestMatch !== undefined) { + continue; + } + + const [languagePrefix] = normalizedLocale.split('-', 1); + for (const supportedLocale of normalizedSupportedLocales.keys()) { + if (supportedLocale.startsWith(languagePrefix)) { + bestMatch = normalizedSupportedLocales.get(supportedLocale); + break; // No need to continue searching for this locale. + } + } + } + + if (bestMatch !== undefined) { + return bestMatch; + } + + // Return the first locale that is not quality zero. + for (const [normalizedLocale, locale] of normalizedSupportedLocales) { + if (!qualityZeroNormalizedLocales.has(normalizedLocale)) { + return locale; + } + } +} + +/** + * Normalizes a locale string by converting it to lowercase. + * + * @param locale - The locale string to normalize. + * @returns The normalized locale string in lowercase. + * + * @example + * ```ts + * const normalized = normalizeLocale('EN-US'); + * console.log(normalized); // Output: "en-us" + * ``` + */ +function normalizeLocale(locale: string): string { + return locale.toLowerCase(); +} diff --git a/packages/angular/ssr/src/manifest.ts b/packages/angular/ssr/src/manifest.ts index f37ca613314b..ae33dc979577 100644 --- a/packages/angular/ssr/src/manifest.ts +++ b/packages/angular/ssr/src/manifest.ts @@ -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 Promise) | undefined>>; @@ -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>; } /** diff --git a/packages/angular/ssr/test/app-engine_spec.ts b/packages/angular/ssr/test/app-engine_spec.ts index 2cad65fe03cb..712c39539751 100644 --- a/packages/angular/ssr/test/app-engine_spec.ts +++ b/packages/angular/ssr/test/app-engine_spec.ts @@ -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 }, @@ -82,7 +104,8 @@ describe('AngularAppEngine', () => { it: createEntryPoint('it'), en: createEntryPoint('en'), }, - basePath: '', + supportedLocales: { 'it': 'it', 'en': 'en' }, + basePath: '/', }); appEngine = new AngularAppEngine(); @@ -133,6 +156,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); @@ -164,7 +197,8 @@ describe('AngularAppEngine', () => { }; }, }, - basePath: '', + basePath: '/', + supportedLocales: { 'en-US': '' }, }); appEngine = new AngularAppEngine(); diff --git a/packages/angular/ssr/test/i18n_spec.ts b/packages/angular/ssr/test/i18n_spec.ts index 3996e37187c6..e5dcfd397c4c 100644 --- a/packages/angular/ssr/test/i18n_spec.ts +++ b/packages/angular/ssr/test/i18n_spec.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ -import { getPotentialLocaleIdFromUrl } from '../src/i18n'; +import { getPotentialLocaleIdFromUrl, getPreferredLocale } from '../src/i18n'; describe('getPotentialLocaleIdFromUrl', () => { it('should extract locale ID correctly when basePath is present', () => { @@ -65,3 +65,106 @@ describe('getPotentialLocaleIdFromUrl', () => { expect(localeId).toBe('en'); }); }); + +describe('getPreferredLocale', () => { + it('should return the exact match with the highest quality value', () => { + const header = 'en-GB;q=0.8,fr-FR;q=0.9'; + const supportedLocales = ['en-GB', 'fr-FR', 'fr-BE']; + const result = getPreferredLocale(header, supportedLocales); + // Exact match for 'fr-FR' with the highest quality (0.9) + expect(result).toBe('fr-FR'); + }); + + it('should return the best match when no exact match is found, using language prefixes', () => { + const header = 'en-GB;q=0.9,fr;q=0.8'; + const supportedLocales = ['fr-FR', 'de-DE', 'en-US']; + const result = getPreferredLocale(header, supportedLocales); + // 'en-US' is the exact match with the highest quality (0.9) + expect(result).toBe('en-US'); + }); + + it('should match based on language prefix when no exact match is found', () => { + const header = 'en-US;q=0.8,fr;q=0.9,en-GB;q=0.7'; + const supportedLocales = ['en-GB', 'fr-FR', 'de-DE']; + const result = getPreferredLocale(header, supportedLocales); + // Best match is 'en-GB' based on exact match (0.8 for 'en-US') + expect(result).toBe('en-GB'); + }); + + it('should return the first available locale when no exact match or prefix is found', () => { + const header = 'it-IT;q=0.8'; + const supportedLocales = ['en-GB', 'fr-FR', 'de-DE']; + const result = getPreferredLocale(header, supportedLocales); + // The first locale in the supportedLocales set + expect(result).toBe('en-GB'); + }); + + it('should return the first available locale when the header is empty', () => { + const header = ''; + const supportedLocales = ['en-GB', 'fr-FR', 'de-DE']; + const result = getPreferredLocale(header, supportedLocales); + expect(result).toBe('en-GB'); // The first locale in the supportedLocales set + }); + + it('should return the first available locale when the header is just "*"', () => { + const header = '*'; + const supportedLocales = ['en-GB', 'fr-FR', 'de-DE']; + const result = getPreferredLocale(header, supportedLocales); + // The first locale in the supportedLocales set + expect(result).toBe('en-GB'); + }); + + it('should return the first available locale when no valid languages are in header', () => { + const header = 'xyz;q=0.5'; + const supportedLocales = ['en-GB', 'fr-FR', 'de-DE']; + const result = getPreferredLocale(header, supportedLocales); + // No valid language, fallback to the first available locale + expect(result).toBe('en-GB'); + }); + + it('should return the closest match when no valid languages in header', () => { + const header = 'en-XYZ;q=0.7,fr-XYZ;q=0.8'; + const supportedLocales = ['en-GB', 'fr-FR', 'de-DE']; + const result = getPreferredLocale(header, supportedLocales); + + // Since there is no exact match for 'en-XYZ' or 'fr-XYZ', + // the function should return 'fr-FR' as the closest match, + // as it shares the language prefix 'fr' with the 'fr-XYZ' in the header. + expect(result).toBe('fr-FR'); + }); + + it('should ignore locales with quality 0 and choose the highest quality supported locale', () => { + const header = 'en-GB;q=0,fr;q=0.9'; + const supportedLocales = ['en-GB', 'fr-FR', 'fr-BE']; + const result = getPreferredLocale(header, supportedLocales); + // 'en-GB' is ignored because quality is 0, so 'fr-FR' is chosen + expect(result).toBe('fr-FR'); + }); + + it('should select the highest quality supported locale as fallback, ignoring those with quality 0', () => { + const header = 'en-GB;q=0'; + const supportedLocales = ['en-GB', 'fr-FR', 'fr-BE']; + const result = getPreferredLocale(header, supportedLocales); + // 'en-GB' is ignored because quality is 0, so 'fr-FR' is chosen as the highest quality supported locale + expect(result).toBe('fr-FR'); + }); + + it('should select the closest match based on quality before considering wildcard "*"', () => { + const header = 'fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7, *;q=0.5'; + const supportedLocales = ['it-IT', 'fr-FR', 'de-DE']; + const result = getPreferredLocale(header, supportedLocales); + + // 'fr-FR' matches the 'fr' prefix with quality 0.9 + expect(result).toBe('fr-FR'); + }); + + it('should select the first available locale when only the wildcard "*" matches', () => { + const header = 'fr-CH, fr;q=0.9, *;q=0.5'; + const supportedLocales = ['it-IT', 'de-DE']; + const result = getPreferredLocale(header, supportedLocales); + + // Since 'fr-CH' and 'fr' do not match any supported locales, + // and '*' is present with quality 0.5, the first supported locale is chosen as a fallback. + expect(result).toBe('it-IT'); + }); +});