From f936df1d1b29342710803076278f370d0ee188ef Mon Sep 17 00:00:00 2001 From: Alan Agius Date: Fri, 6 Dec 2024 09:57:39 +0000 Subject: [PATCH] feat(@angular/build): add support for customizing URL segments with i18n MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, the `baseHref` option under each locale allowed for generating a unique base href for specific locales. However, users were still required to handle file organization manually, and `baseHref` appeared to be primarily designed for this purpose. This commit introduces a new `subPath` option, which simplifies the i18n process, particularly in static site generation (SSG) and server-side rendering (SSR). When the `subPath` option is used, the `baseHref` is ignored. Instead, the `subPath` serves as both the base href and the name of the directory containing the localized version of the app. Below is an example configuration showcasing the use of `subPath`: ```json "i18n": { "sourceLocale": { "code": "en-US", "subPath": "" }, "locales": { "fr-BE": { "subPath": "fr", "translation": "src/i18n/messages.fr-BE.xlf" }, "de-BE": { "subPath": "de", "translation": "src/i18n/messages.de-BE.xlf" } } } ``` The following tree structure demonstrates how the `subPath` organizes localized build output: ``` dist/ ├── app/ │ └── browser/ # Default locale, accessible at `/` │ ├── fr/ # Locale for `fr-BE`, accessible at `/fr` │ └── de/ # Locale for `de-BE`, accessible at `/de` ``` DEPRECATED: The `baseHref` option under `i18n.locales` and `i18n.sourceLocale` in `angular.json` is deprecated in favor of `subPath`. The `subPath` defines the URL segment for the locale, serving as both the HTML base HREF and the directory name for output. By default, if not specified, `subPath` will use the locale code. Closes #16997 and closes #28967 --- .../build/src/builders/application/i18n.ts | 26 +-- .../build/src/builders/application/options.ts | 18 ++- .../src/builders/extract-i18n/options.ts | 3 +- .../angular/build/src/utils/i18n-options.ts | 73 +++++++-- .../src/utils/server-rendering/manifest.ts | 22 +-- .../src/utils/server-rendering/prerender.ts | 2 +- .../cli/lib/config/workspace-schema.json | 68 ++++++-- packages/angular/ssr/src/app-engine.ts | 2 +- .../src/builders/browser/index.ts | 20 ++- .../src/builders/extract-i18n/options.ts | 3 +- .../build_angular/src/utils/i18n-webpack.ts | 2 +- .../build_angular/src/utils/output-paths.ts | 2 +- packages/schematics/angular/guard/index.ts | 2 +- .../schematics/angular/guard/index_spec.ts | 4 +- ...routes-output-mode-server-i18n-sub-path.ts | 152 ++++++++++++++++++ 15 files changed, 331 insertions(+), 68 deletions(-) create mode 100644 tests/legacy-cli/e2e/tests/build/server-rendering/server-routes-output-mode-server-i18n-sub-path.ts diff --git a/packages/angular/build/src/builders/application/i18n.ts b/packages/angular/build/src/builders/application/i18n.ts index cfb044f0e34f..6b45092cee21 100644 --- a/packages/angular/build/src/builders/application/i18n.ts +++ b/packages/angular/build/src/builders/application/i18n.ts @@ -36,12 +36,14 @@ export async function inlineI18n( warnings: string[]; prerenderedRoutes: PrerenderedRoutesRecord; }> { + const { i18nOptions, optimizationOptions, baseHref } = options; + // Create the multi-threaded inliner with common options and the files generated from the build. const inliner = new I18nInliner( { - missingTranslation: options.i18nOptions.missingTranslationBehavior ?? 'warning', + missingTranslation: i18nOptions.missingTranslationBehavior ?? 'warning', outputFiles: executionResult.outputFiles, - shouldOptimize: options.optimizationOptions.scripts, + shouldOptimize: optimizationOptions.scripts, }, maxWorkers, ); @@ -60,19 +62,16 @@ export async function inlineI18n( const updatedOutputFiles = []; const updatedAssetFiles = []; try { - for (const locale of options.i18nOptions.inlineLocales) { + for (const locale of i18nOptions.inlineLocales) { // A locale specific set of files is returned from the inliner. const localeInlineResult = await inliner.inlineForLocale( locale, - options.i18nOptions.locales[locale].translation, + i18nOptions.locales[locale].translation, ); const localeOutputFiles = localeInlineResult.outputFiles; inlineResult.errors.push(...localeInlineResult.errors); inlineResult.warnings.push(...localeInlineResult.warnings); - const baseHref = - getLocaleBaseHref(options.baseHref, options.i18nOptions, locale) ?? options.baseHref; - const { errors, warnings, @@ -82,7 +81,7 @@ export async function inlineI18n( } = await executePostBundleSteps( { ...options, - baseHref, + baseHref: getLocaleBaseHref(baseHref, i18nOptions, locale) ?? baseHref, }, localeOutputFiles, executionResult.assetFiles, @@ -94,16 +93,17 @@ export async function inlineI18n( inlineResult.errors.push(...errors); inlineResult.warnings.push(...warnings); - // Update directory with locale base - if (options.i18nOptions.flatOutput !== true) { + // Update directory with locale base or subPath + const subPath = i18nOptions.locales[locale]?.subPath ?? locale; + if (i18nOptions.flatOutput !== true) { localeOutputFiles.forEach((file) => { - file.path = join(locale, file.path); + file.path = join(subPath, file.path); }); for (const assetFile of [...executionResult.assetFiles, ...additionalAssets]) { updatedAssetFiles.push({ source: assetFile.source, - destination: join(locale, assetFile.destination), + destination: join(subPath, assetFile.destination), }); } } else { @@ -128,7 +128,7 @@ export async function inlineI18n( ]; // Assets are only changed if not using the flat output option - if (options.i18nOptions.flatOutput !== true) { + if (!i18nOptions.flatOutput) { executionResult.assetFiles = updatedAssetFiles; } diff --git a/packages/angular/build/src/builders/application/options.ts b/packages/angular/build/src/builders/application/options.ts index 4a1d781f6680..46d2e9205c9f 100644 --- a/packages/angular/build/src/builders/application/options.ts +++ b/packages/angular/build/src/builders/application/options.ts @@ -168,7 +168,7 @@ export async function normalizeOptions( const i18nOptions: I18nOptions & { duplicateTranslationBehavior?: I18NTranslation; missingTranslationBehavior?: I18NTranslation; - } = createI18nOptions(projectMetadata, options.localize); + } = createI18nOptions(projectMetadata, options.localize, context.logger); i18nOptions.duplicateTranslationBehavior = options.i18nDuplicateTranslation; i18nOptions.missingTranslationBehavior = options.i18nMissingTranslation; if (options.forceI18nFlatOutput) { @@ -645,7 +645,7 @@ function normalizeGlobalEntries( } export function getLocaleBaseHref( - baseHref: string | undefined, + baseHref: string | undefined = '', i18n: NormalizedApplicationBuildOptions['i18nOptions'], locale: string, ): string | undefined { @@ -653,9 +653,17 @@ export function getLocaleBaseHref( return undefined; } - if (i18n.locales[locale] && i18n.locales[locale].baseHref !== '') { - return urlJoin(baseHref || '', i18n.locales[locale].baseHref ?? `/${locale}/`); + const localeData = i18n.locales[locale]; + if (!localeData) { + return undefined; } - return undefined; + let subPath = localeData.subPath; + if (subPath !== undefined) { + subPath += '/'; + } + + const baseHrefSuffix = subPath ?? localeData.baseHref; + + return baseHrefSuffix !== '' ? urlJoin(baseHref, baseHrefSuffix ?? locale + '/') : undefined; } diff --git a/packages/angular/build/src/builders/extract-i18n/options.ts b/packages/angular/build/src/builders/extract-i18n/options.ts index 8e36f3db28f1..24be86ee7c8f 100644 --- a/packages/angular/build/src/builders/extract-i18n/options.ts +++ b/packages/angular/build/src/builders/extract-i18n/options.ts @@ -36,8 +36,7 @@ export async function normalizeOptions( // Target specifier defaults to the current project's build target with no specified configuration const buildTargetSpecifier = options.buildTarget ?? ':'; const buildTarget = targetFromTargetString(buildTargetSpecifier, projectName, 'build'); - - const i18nOptions = createI18nOptions(projectMetadata); + const i18nOptions = createI18nOptions(projectMetadata, /** inline */ false, context.logger); // Normalize xliff format extensions let format = options.format; diff --git a/packages/angular/build/src/utils/i18n-options.ts b/packages/angular/build/src/utils/i18n-options.ts index 3f63e9a68099..1ce69e6eb4c3 100644 --- a/packages/angular/build/src/utils/i18n-options.ts +++ b/packages/angular/build/src/utils/i18n-options.ts @@ -18,6 +18,7 @@ export interface LocaleDescription { translation?: Record; dataPath?: string; baseHref?: string; + subPath?: string; } export interface I18nOptions { @@ -54,19 +55,31 @@ function normalizeTranslationFileOption( function ensureObject(value: unknown, name: string): asserts value is Record { if (!value || typeof value !== 'object' || Array.isArray(value)) { - throw new Error(`Project ${name} field is malformed. Expected an object.`); + throw new Error(`Project field '${name}' is malformed. Expected an object.`); } } function ensureString(value: unknown, name: string): asserts value is string { if (typeof value !== 'string') { - throw new Error(`Project ${name} field is malformed. Expected a string.`); + throw new Error(`Project field '${name}' is malformed. Expected a string.`); } } +function ensureValidsubPath(value: unknown, name: string): asserts value is string { + ensureString(value, name); + + if (/^[\w-]*$/.test(value)) { + throw new Error( + `Project field '${name}' is invalid. It can only contain letters, numbers, hyphens, and underscores.`, + ); + } +} export function createI18nOptions( projectMetadata: { i18n?: unknown }, inline?: boolean | string[], + logger?: { + warn(message: string): void; + }, ): I18nOptions { const { i18n: metadata = {} } = projectMetadata; @@ -82,22 +95,41 @@ export function createI18nOptions( }, }; - let rawSourceLocale; - let rawSourceLocaleBaseHref; + let rawSourceLocale: string | undefined; + let rawSourceLocaleBaseHref: string | undefined; + let rawsubPath: string | undefined; if (typeof metadata.sourceLocale === 'string') { rawSourceLocale = metadata.sourceLocale; } else if (metadata.sourceLocale !== undefined) { - ensureObject(metadata.sourceLocale, 'i18n sourceLocale'); + ensureObject(metadata.sourceLocale, 'i18n.sourceLocale'); if (metadata.sourceLocale.code !== undefined) { - ensureString(metadata.sourceLocale.code, 'i18n sourceLocale code'); + ensureString(metadata.sourceLocale.code, 'i18n.sourceLocale.code'); rawSourceLocale = metadata.sourceLocale.code; } if (metadata.sourceLocale.baseHref !== undefined) { - ensureString(metadata.sourceLocale.baseHref, 'i18n sourceLocale baseHref'); + ensureString(metadata.sourceLocale.baseHref, 'i18n.sourceLocale.baseHref'); + logger?.warn( + `The 'baseHref' field under 'i18n.sourceLocale' is deprecated and will be removed in future versions. ` + + `Please use 'subPath' instead.\nNote: 'subPath' defines the URL segment for the locale, acting ` + + `as both the HTML base HREF and the directory name for output.\nBy default, ` + + `if not specified, 'subPath' uses the locale code.`, + ); + rawSourceLocaleBaseHref = metadata.sourceLocale.baseHref; } + + if (metadata.sourceLocale.subPath !== undefined) { + ensureValidsubPath(metadata.sourceLocale.subPath, 'i18n.sourceLocale.subPath'); + rawsubPath = metadata.sourceLocale.subPath; + } + + if (rawsubPath !== undefined && rawSourceLocaleBaseHref !== undefined) { + throw new Error( + `'i18n.sourceLocale.subPath' and 'i18n.sourceLocale.baseHref' cannot be used together.`, + ); + } } if (rawSourceLocale !== undefined) { @@ -108,21 +140,41 @@ export function createI18nOptions( i18n.locales[i18n.sourceLocale] = { files: [], baseHref: rawSourceLocaleBaseHref, + subPath: rawsubPath, }; if (metadata.locales !== undefined) { ensureObject(metadata.locales, 'i18n locales'); for (const [locale, options] of Object.entries(metadata.locales)) { - let translationFiles; - let baseHref; + let translationFiles: string[] | undefined; + let baseHref: string | undefined; + let subPath: string | undefined; + if (options && typeof options === 'object' && 'translation' in options) { translationFiles = normalizeTranslationFileOption(options.translation, locale, false); if ('baseHref' in options) { - ensureString(options.baseHref, `i18n locales ${locale} baseHref`); + ensureString(options.baseHref, `i18n.locales.${locale}.baseHref`); + logger?.warn( + `The 'baseHref' field under 'i18n.locales.${locale}' is deprecated and will be removed in future versions. ` + + `Please use 'subPath' instead.\nNote: 'subPath' defines the URL segment for the locale, acting ` + + `as both the HTML base HREF and the directory name for output.\nBy default, ` + + `if not specified, 'subPath' uses the locale code.`, + ); baseHref = options.baseHref; } + + if ('subPath' in options) { + ensureString(options.subPath, `i18n.locales.${locale}.subPath`); + subPath = options.subPath; + } + + if (subPath !== undefined && baseHref !== undefined) { + throw new Error( + `'i18n.locales.${locale}.subPath' and 'i18n.locales.${locale}.baseHref' cannot be used together.`, + ); + } } else { translationFiles = normalizeTranslationFileOption(options, locale, true); } @@ -136,6 +188,7 @@ export function createI18nOptions( i18n.locales[locale] = { files: translationFiles.map((file) => ({ path: file })), baseHref, + subPath, }; } } diff --git a/packages/angular/build/src/utils/server-rendering/manifest.ts b/packages/angular/build/src/utils/server-rendering/manifest.ts index ba05db89aa65..ac05c9b0f7a0 100644 --- a/packages/angular/build/src/utils/server-rendering/manifest.ts +++ b/packages/angular/build/src/utils/server-rendering/manifest.ts @@ -7,10 +7,7 @@ */ import { extname } from 'node:path'; -import { - NormalizedApplicationBuildOptions, - getLocaleBaseHref, -} from '../../builders/application/options'; +import { NormalizedApplicationBuildOptions } from '../../builders/application/options'; import { type BuildOutputFile, BuildOutputFileType } from '../../tools/esbuild/bundler-context'; import { createOutputFile } from '../../tools/esbuild/utils'; @@ -56,20 +53,11 @@ export function generateAngularServerAppEngineManifest( baseHref: string | undefined, ): string { const entryPoints: Record = {}; - - if (i18nOptions.shouldInline) { + if (i18nOptions.shouldInline && !i18nOptions.flatOutput) { for (const locale of i18nOptions.inlineLocales) { - const importPath = - './' + (i18nOptions.flatOutput ? '' : locale + '/') + MAIN_SERVER_OUTPUT_FILENAME; - - let localeWithBaseHref = getLocaleBaseHref('', i18nOptions, locale) || '/'; - - // Remove leading and trailing slashes. - const start = localeWithBaseHref[0] === '/' ? 1 : 0; - const end = localeWithBaseHref[localeWithBaseHref.length - 1] === '/' ? -1 : undefined; - localeWithBaseHref = localeWithBaseHref.slice(start, end); - - entryPoints[localeWithBaseHref] = `() => import('${importPath}')`; + const { subPath = locale } = i18nOptions.locales[locale]; + const importPath = `${subPath ? `${subPath}/` : ''}${MAIN_SERVER_OUTPUT_FILENAME}`; + entryPoints[subPath] = `() => import('./${importPath}')`; } } else { entryPoints[''] = `() => import('./${MAIN_SERVER_OUTPUT_FILENAME}')`; diff --git a/packages/angular/build/src/utils/server-rendering/prerender.ts b/packages/angular/build/src/utils/server-rendering/prerender.ts index 2c539502382c..93b76bf17dcf 100644 --- a/packages/angular/build/src/utils/server-rendering/prerender.ts +++ b/packages/angular/build/src/utils/server-rendering/prerender.ts @@ -219,7 +219,7 @@ async function renderPages( const appShellRouteWithLeadingSlash = appShellRoute && addLeadingSlash(appShellRoute); const baseHrefWithLeadingSlash = addLeadingSlash(baseHref); - for (const { route, redirectTo, renderMode } of serializableRouteTreeNode) { + for (const { route, redirectTo } of serializableRouteTreeNode) { // Remove the base href from the file output path. const routeWithoutBaseHref = addTrailingSlash(route).startsWith(baseHrefWithLeadingSlash) ? addLeadingSlash(route.slice(baseHrefWithLeadingSlash.length)) diff --git a/packages/angular/cli/lib/config/workspace-schema.json b/packages/angular/cli/lib/config/workspace-schema.json index dce8ecfec6fa..402ad662cf09 100644 --- a/packages/angular/cli/lib/config/workspace-schema.json +++ b/packages/angular/cli/lib/config/workspace-schema.json @@ -275,18 +275,43 @@ }, { "type": "object", - "description": "Localization options to use for the source locale", + "description": "Localization options to use for the source locale.", "properties": { "code": { "type": "string", - "description": "Specifies the locale code of the source locale", + "description": "Specifies the locale code of the source locale.", "pattern": "^[a-zA-Z]{2,3}(-[a-zA-Z]{4})?(-([a-zA-Z]{2}|[0-9]{3}))?(-[a-zA-Z]{5,8})?(-x(-[a-zA-Z0-9]{1,8})+)?$" }, "baseHref": { "type": "string", - "description": "HTML base HREF to use for the locale (defaults to the locale code)" + "deprecated": true, + "description": "Specifies the HTML base HREF for the locale. Defaults to the locale code if not provided." + }, + "subPath": { + "type": "string", + "description": "Defines the subpath for accessing this locale. It serves as the HTML base HREF and the directory name for the output. Defaults to the locale code if not specified.", + "pattern": "^[\\w-]*$" } }, + "anyOf": [ + { + "required": ["subPath"], + "not": { + "required": ["baseHref"] + } + }, + { + "required": ["baseHref"], + "not": { + "required": ["subPath"] + } + }, + { + "not": { + "required": ["baseHref", "subPath"] + } + } + ], "additionalProperties": false } ] @@ -299,11 +324,11 @@ "oneOf": [ { "type": "string", - "description": "Localization file to use for i18n" + "description": "Localization file to use for i18n." }, { "type": "array", - "description": "Localization files to use for i18n", + "description": "Localization files to use for i18n.", "items": { "type": "string", "uniqueItems": true @@ -311,17 +336,17 @@ }, { "type": "object", - "description": "Localization options to use for the locale", + "description": "Localization options to use for the locale.", "properties": { "translation": { "oneOf": [ { "type": "string", - "description": "Localization file to use for i18n" + "description": "Localization file to use for i18n." }, { "type": "array", - "description": "Localization files to use for i18n", + "description": "Localization files to use for i18n.", "items": { "type": "string", "uniqueItems": true @@ -331,9 +356,34 @@ }, "baseHref": { "type": "string", - "description": "HTML base HREF to use for the locale (defaults to the locale code)" + "deprecated": true, + "description": "Specifies the HTML base HREF for the locale. Defaults to the locale code if not provided." + }, + "subPath": { + "type": "string", + "description": "Defines the URL segment for accessing this locale. It serves as the HTML base HREF and the directory name for the output. Defaults to the locale code if not specified.", + "pattern": "^[\\w-]*$" } }, + "anyOf": [ + { + "required": ["subPath"], + "not": { + "required": ["baseHref"] + } + }, + { + "required": ["baseHref"], + "not": { + "required": ["subPath"] + } + }, + { + "not": { + "required": ["baseHref", "subPath"] + } + } + ], "additionalProperties": false } ] diff --git a/packages/angular/ssr/src/app-engine.ts b/packages/angular/ssr/src/app-engine.ts index 2b3b15ef7ea4..68117744d8c5 100644 --- a/packages/angular/ssr/src/app-engine.ts +++ b/packages/angular/ssr/src/app-engine.ts @@ -148,6 +148,6 @@ export class AngularAppEngine { const potentialLocale = getPotentialLocaleIdFromUrl(url, basePath); - return this.getEntryPointExports(potentialLocale); + return this.getEntryPointExports(potentialLocale) ?? this.getEntryPointExports(''); } } diff --git a/packages/angular_devkit/build_angular/src/builders/browser/index.ts b/packages/angular_devkit/build_angular/src/builders/browser/index.ts index 963e404041fb..087546414c57 100644 --- a/packages/angular_devkit/build_angular/src/builders/browser/index.ts +++ b/packages/angular_devkit/build_angular/src/builders/browser/index.ts @@ -425,11 +425,25 @@ export function buildWebpackBrowser( ); function getLocaleBaseHref(i18n: I18nOptions, locale: string): string | undefined { - if (i18n.locales[locale] && i18n.locales[locale]?.baseHref !== '') { - return urlJoin(options.baseHref || '', i18n.locales[locale].baseHref ?? `/${locale}/`); + if (i18n.flatOutput) { + return undefined; } - return undefined; + const localeData = i18n.locales[locale]; + if (!localeData) { + return undefined; + } + + let subPath = localeData.subPath; + if (subPath !== undefined) { + subPath += '/'; + } + + const baseHrefSuffix = subPath ?? localeData.baseHref; + + return baseHrefSuffix !== '' + ? urlJoin(options.baseHref || '', baseHrefSuffix ?? locale + '/') + : undefined; } } diff --git a/packages/angular_devkit/build_angular/src/builders/extract-i18n/options.ts b/packages/angular_devkit/build_angular/src/builders/extract-i18n/options.ts index 46a0ac56e99d..492909da14f0 100644 --- a/packages/angular_devkit/build_angular/src/builders/extract-i18n/options.ts +++ b/packages/angular_devkit/build_angular/src/builders/extract-i18n/options.ts @@ -36,8 +36,7 @@ export async function normalizeOptions( // Target specifier defaults to the current project's build target with no specified configuration const buildTargetSpecifier = options.buildTarget ?? ':'; const buildTarget = targetFromTargetString(buildTargetSpecifier, projectName, 'build'); - - const i18nOptions = createI18nOptions(projectMetadata); + const i18nOptions = createI18nOptions(projectMetadata, /** inline */ false, context.logger); // Normalize xliff format extensions let format = options.format; diff --git a/packages/angular_devkit/build_angular/src/utils/i18n-webpack.ts b/packages/angular_devkit/build_angular/src/utils/i18n-webpack.ts index 242d21c51d73..ef0fd1dc2207 100644 --- a/packages/angular_devkit/build_angular/src/utils/i18n-webpack.ts +++ b/packages/angular_devkit/build_angular/src/utils/i18n-webpack.ts @@ -43,7 +43,7 @@ export async function configureI18nBuild [ l, - i18n.flatOutput ? baseOutputPath : join(baseOutputPath, l), + i18n.flatOutput ? baseOutputPath : join(baseOutputPath, i18n.locales[l].subPath ?? l), ]) : [['', baseOutputPath]]; diff --git a/packages/schematics/angular/guard/index.ts b/packages/schematics/angular/guard/index.ts index 8e8d5c75610c..467fe6198935 100644 --- a/packages/schematics/angular/guard/index.ts +++ b/packages/schematics/angular/guard/index.ts @@ -34,7 +34,7 @@ export default function (options: GuardOptions): Rule { const routerNamedImports: string[] = [...options.implements, 'MaybeAsync', 'GuardResult']; if (options.implements.includes(GuardInterface.CanMatch)) { - routerNamedImports.push('Route', 'UrlSegment'); + routerNamedImports.push('Route', 'subPath'); if (options.implements.length > 1) { routerNamedImports.push(...commonRouterNameImports); diff --git a/packages/schematics/angular/guard/index_spec.ts b/packages/schematics/angular/guard/index_spec.ts index bfa09f524ede..05abf2a525ad 100644 --- a/packages/schematics/angular/guard/index_spec.ts +++ b/packages/schematics/angular/guard/index_spec.ts @@ -143,7 +143,7 @@ describe('Guard Schematic', () => { const options = { ...defaultOptions, implements: implementationOptions, functional: false }; const tree = await schematicRunner.runSchematic('guard', options, appTree); const fileString = tree.readContent('/projects/bar/src/app/foo.guard.ts'); - const expectedImports = `import { CanMatch, GuardResult, MaybeAsync, Route, UrlSegment } from '@angular/router';`; + const expectedImports = `import { CanMatch, GuardResult, MaybeAsync, Route, subPath } from '@angular/router';`; expect(fileString).toContain(expectedImports); }); @@ -176,7 +176,7 @@ describe('Guard Schematic', () => { const fileString = tree.readContent('/projects/bar/src/app/foo.guard.ts'); const expectedImports = `import { ActivatedRouteSnapshot, CanActivate, CanActivateChild, CanMatch, GuardResult, ` + - `MaybeAsync, Route, RouterStateSnapshot, UrlSegment } from '@angular/router';`; + `MaybeAsync, Route, RouterStateSnapshot, subPath } from '@angular/router';`; expect(fileString).toContain(expectedImports); }); diff --git a/tests/legacy-cli/e2e/tests/build/server-rendering/server-routes-output-mode-server-i18n-sub-path.ts b/tests/legacy-cli/e2e/tests/build/server-rendering/server-routes-output-mode-server-i18n-sub-path.ts new file mode 100644 index 000000000000..6e473880b32c --- /dev/null +++ b/tests/legacy-cli/e2e/tests/build/server-rendering/server-routes-output-mode-server-i18n-sub-path.ts @@ -0,0 +1,152 @@ +import { join } from 'node:path'; +import assert from 'node:assert'; +import { expectFileToMatch, writeFile } from '../../../utils/fs'; +import { execAndWaitForOutputToMatch, ng, noSilentNg, silentNg } from '../../../utils/process'; +import { langTranslations, setupI18nConfig } from '../../i18n/setup'; +import { findFreePort } from '../../../utils/network'; +import { getGlobalVariable } from '../../../utils/env'; +import { installWorkspacePackages, uninstallPackage } from '../../../utils/packages'; +import { updateJsonFile, useSha } from '../../../utils/project'; + +export default async function () { + assert( + getGlobalVariable('argv')['esbuild'], + 'This test should not be called in the Webpack suite.', + ); + + // Setup project + await setupI18nConfig(); + + // Update angular.json + const URL_SUB_PATH: Record = { + 'en-US': '', + 'fr': 'fr', + 'de': 'deutsche', + }; + + await updateJsonFile('angular.json', (workspaceJson) => { + const appProject = workspaceJson.projects['test-project']; + const i18n: Record = appProject.i18n; + i18n.sourceLocale = { + subPath: URL_SUB_PATH['en-US'], + }; + + i18n.locales['fr'] = { + translation: i18n.locales['fr'], + subPath: URL_SUB_PATH['fr'], + }; + + i18n.locales['de'] = { + translation: i18n.locales['de'], + subPath: URL_SUB_PATH['de'], + }; + }); + + // Forcibly remove in case another test doesn't clean itself up. + await uninstallPackage('@angular/ssr'); + await ng('add', '@angular/ssr', '--server-routing', '--skip-confirmation', '--skip-install'); + await useSha(); + await installWorkspacePackages(); + + // Add routes + await writeFile( + 'src/app/app.routes.ts', + ` + import { Routes } from '@angular/router'; + import { HomeComponent } from './home/home.component'; + import { SsrComponent } from './ssr/ssr.component'; + import { SsgComponent } from './ssg/ssg.component'; + + export const routes: Routes = [ + { + path: '', + component: HomeComponent, + }, + { + path: 'ssg', + component: SsgComponent, + }, + { + path: 'ssr', + component: SsrComponent, + }, + ]; + `, + ); + + // Add server routing + await writeFile( + 'src/app/app.routes.server.ts', + ` + import { RenderMode, ServerRoute } from '@angular/ssr'; + + export const serverRoutes: ServerRoute[] = [ + { + path: '', + renderMode: RenderMode.Prerender, + }, + { + path: 'ssg', + renderMode: RenderMode.Prerender, + }, + { + path: '**', + renderMode: RenderMode.Server, + }, + ]; + `, + ); + + // Generate components for the above routes + const componentNames: string[] = ['home', 'ssg', 'ssr']; + for (const componentName of componentNames) { + await silentNg('generate', 'component', componentName); + } + + await noSilentNg('build', '--output-mode=server', '--base-href=/base/'); + + const pathToVerify = ['/index.html', '/ssg/index.html']; + for (const { lang } of langTranslations) { + const subPath = URL_SUB_PATH[lang]; + const outputPath = join('dist/test-project/browser', subPath); + + for (const path of pathToVerify) { + await expectFileToMatch(join(outputPath, path), `

${lang}

`); + const baseHref = `/base/${subPath ? `${subPath}/` : ''}`; + await expectFileToMatch(join(outputPath, path), ``); + } + } + + // Tests responses + const port = await spawnServer(); + const pathnamesToVerify = ['/ssr', '/ssg']; + + for (const { lang } of langTranslations) { + for (const pathname of pathnamesToVerify) { + const subPath = URL_SUB_PATH[lang]; + const urlPathname = `/base${subPath ? `/${subPath}` : ''}${pathname}`; + const res = await fetch(`http://localhost:${port}${urlPathname}`); + const text = await res.text(); + + assert.match( + text, + new RegExp(`

${lang}

`), + `Response for '${urlPathname}': '

${lang}

' was not matched in content.`, + ); + } + } +} + +async function spawnServer(): Promise { + const port = await findFreePort(); + await execAndWaitForOutputToMatch( + 'npm', + ['run', 'serve:ssr:test-project'], + /Node Express server listening on/, + { + 'PORT': String(port), + }, + ); + + return port; +}