Skip to content

Commit 0a570c0

Browse files
committedDec 7, 2024·
feat(@angular/build): add support for customizing URL segments with i18n
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
1 parent d7214e9 commit 0a570c0

File tree

15 files changed

+335
-68
lines changed

15 files changed

+335
-68
lines changed
 

‎packages/angular/build/src/builders/application/i18n.ts

+13-13
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,14 @@ export async function inlineI18n(
3636
warnings: string[];
3737
prerenderedRoutes: PrerenderedRoutesRecord;
3838
}> {
39+
const { i18nOptions, optimizationOptions, baseHref } = options;
40+
3941
// Create the multi-threaded inliner with common options and the files generated from the build.
4042
const inliner = new I18nInliner(
4143
{
42-
missingTranslation: options.i18nOptions.missingTranslationBehavior ?? 'warning',
44+
missingTranslation: i18nOptions.missingTranslationBehavior ?? 'warning',
4345
outputFiles: executionResult.outputFiles,
44-
shouldOptimize: options.optimizationOptions.scripts,
46+
shouldOptimize: optimizationOptions.scripts,
4547
},
4648
maxWorkers,
4749
);
@@ -60,19 +62,16 @@ export async function inlineI18n(
6062
const updatedOutputFiles = [];
6163
const updatedAssetFiles = [];
6264
try {
63-
for (const locale of options.i18nOptions.inlineLocales) {
65+
for (const locale of i18nOptions.inlineLocales) {
6466
// A locale specific set of files is returned from the inliner.
6567
const localeInlineResult = await inliner.inlineForLocale(
6668
locale,
67-
options.i18nOptions.locales[locale].translation,
69+
i18nOptions.locales[locale].translation,
6870
);
6971
const localeOutputFiles = localeInlineResult.outputFiles;
7072
inlineResult.errors.push(...localeInlineResult.errors);
7173
inlineResult.warnings.push(...localeInlineResult.warnings);
7274

73-
const baseHref =
74-
getLocaleBaseHref(options.baseHref, options.i18nOptions, locale) ?? options.baseHref;
75-
7675
const {
7776
errors,
7877
warnings,
@@ -82,7 +81,7 @@ export async function inlineI18n(
8281
} = await executePostBundleSteps(
8382
{
8483
...options,
85-
baseHref,
84+
baseHref: getLocaleBaseHref(baseHref, i18nOptions, locale) ?? baseHref,
8685
},
8786
localeOutputFiles,
8887
executionResult.assetFiles,
@@ -94,16 +93,17 @@ export async function inlineI18n(
9493
inlineResult.errors.push(...errors);
9594
inlineResult.warnings.push(...warnings);
9695

97-
// Update directory with locale base
98-
if (options.i18nOptions.flatOutput !== true) {
96+
// Update directory with locale base or subPath
97+
const subPath = i18nOptions.locales[locale].subPath;
98+
if (i18nOptions.flatOutput !== true) {
9999
localeOutputFiles.forEach((file) => {
100-
file.path = join(locale, file.path);
100+
file.path = join(subPath, file.path);
101101
});
102102

103103
for (const assetFile of [...executionResult.assetFiles, ...additionalAssets]) {
104104
updatedAssetFiles.push({
105105
source: assetFile.source,
106-
destination: join(locale, assetFile.destination),
106+
destination: join(subPath, assetFile.destination),
107107
});
108108
}
109109
} else {
@@ -128,7 +128,7 @@ export async function inlineI18n(
128128
];
129129

130130
// Assets are only changed if not using the flat output option
131-
if (options.i18nOptions.flatOutput !== true) {
131+
if (!i18nOptions.flatOutput) {
132132
executionResult.assetFiles = updatedAssetFiles;
133133
}
134134

‎packages/angular/build/src/builders/application/options.ts

+8-5
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ export async function normalizeOptions(
168168
const i18nOptions: I18nOptions & {
169169
duplicateTranslationBehavior?: I18NTranslation;
170170
missingTranslationBehavior?: I18NTranslation;
171-
} = createI18nOptions(projectMetadata, options.localize);
171+
} = createI18nOptions(projectMetadata, options.localize, context.logger);
172172
i18nOptions.duplicateTranslationBehavior = options.i18nDuplicateTranslation;
173173
i18nOptions.missingTranslationBehavior = options.i18nMissingTranslation;
174174
if (options.forceI18nFlatOutput) {
@@ -645,17 +645,20 @@ function normalizeGlobalEntries(
645645
}
646646

647647
export function getLocaleBaseHref(
648-
baseHref: string | undefined,
648+
baseHref: string | undefined = '',
649649
i18n: NormalizedApplicationBuildOptions['i18nOptions'],
650650
locale: string,
651651
): string | undefined {
652652
if (i18n.flatOutput) {
653653
return undefined;
654654
}
655655

656-
if (i18n.locales[locale] && i18n.locales[locale].baseHref !== '') {
657-
return urlJoin(baseHref || '', i18n.locales[locale].baseHref ?? `/${locale}/`);
656+
const localeData = i18n.locales[locale];
657+
if (!localeData) {
658+
return undefined;
658659
}
659660

660-
return undefined;
661+
const baseHrefSuffix = localeData.baseHref ?? localeData.subPath + '/';
662+
663+
return baseHrefSuffix !== '' ? urlJoin(baseHref, baseHrefSuffix) : undefined;
661664
}

‎packages/angular/build/src/builders/extract-i18n/options.ts

+1-2
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,7 @@ export async function normalizeOptions(
3636
// Target specifier defaults to the current project's build target with no specified configuration
3737
const buildTargetSpecifier = options.buildTarget ?? ':';
3838
const buildTarget = targetFromTargetString(buildTargetSpecifier, projectName, 'build');
39-
40-
const i18nOptions = createI18nOptions(projectMetadata);
39+
const i18nOptions = createI18nOptions(projectMetadata, /** inline */ false, context.logger);
4140

4241
// Normalize xliff format extensions
4342
let format = options.format;

‎packages/angular/build/src/utils/i18n-options.ts

+79-10
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export interface LocaleDescription {
1818
translation?: Record<string, unknown>;
1919
dataPath?: string;
2020
baseHref?: string;
21+
subPath: string;
2122
}
2223

2324
export interface I18nOptions {
@@ -54,19 +55,31 @@ function normalizeTranslationFileOption(
5455

5556
function ensureObject(value: unknown, name: string): asserts value is Record<string, unknown> {
5657
if (!value || typeof value !== 'object' || Array.isArray(value)) {
57-
throw new Error(`Project ${name} field is malformed. Expected an object.`);
58+
throw new Error(`Project field '${name}' is malformed. Expected an object.`);
5859
}
5960
}
6061

6162
function ensureString(value: unknown, name: string): asserts value is string {
6263
if (typeof value !== 'string') {
63-
throw new Error(`Project ${name} field is malformed. Expected a string.`);
64+
throw new Error(`Project field '${name}' is malformed. Expected a string.`);
6465
}
6566
}
6667

68+
function ensureValidsubPath(value: unknown, name: string): asserts value is string {
69+
ensureString(value, name);
70+
71+
if (!/^[\w-]*$/.test(value)) {
72+
throw new Error(
73+
`Project field '${name}' is invalid. It can only contain letters, numbers, hyphens, and underscores.`,
74+
);
75+
}
76+
}
6777
export function createI18nOptions(
6878
projectMetadata: { i18n?: unknown },
6979
inline?: boolean | string[],
80+
logger?: {
81+
warn(message: string): void;
82+
},
7083
): I18nOptions {
7184
const { i18n: metadata = {} } = projectMetadata;
7285

@@ -82,22 +95,41 @@ export function createI18nOptions(
8295
},
8396
};
8497

85-
let rawSourceLocale;
86-
let rawSourceLocaleBaseHref;
98+
let rawSourceLocale: string | undefined;
99+
let rawSourceLocaleBaseHref: string | undefined;
100+
let rawsubPath: string | undefined;
87101
if (typeof metadata.sourceLocale === 'string') {
88102
rawSourceLocale = metadata.sourceLocale;
89103
} else if (metadata.sourceLocale !== undefined) {
90-
ensureObject(metadata.sourceLocale, 'i18n sourceLocale');
104+
ensureObject(metadata.sourceLocale, 'i18n.sourceLocale');
91105

92106
if (metadata.sourceLocale.code !== undefined) {
93-
ensureString(metadata.sourceLocale.code, 'i18n sourceLocale code');
107+
ensureString(metadata.sourceLocale.code, 'i18n.sourceLocale.code');
94108
rawSourceLocale = metadata.sourceLocale.code;
95109
}
96110

97111
if (metadata.sourceLocale.baseHref !== undefined) {
98-
ensureString(metadata.sourceLocale.baseHref, 'i18n sourceLocale baseHref');
112+
ensureString(metadata.sourceLocale.baseHref, 'i18n.sourceLocale.baseHref');
113+
logger?.warn(
114+
`The 'baseHref' field under 'i18n.sourceLocale' is deprecated and will be removed in future versions. ` +
115+
`Please use 'subPath' instead.\nNote: 'subPath' defines the URL segment for the locale, acting ` +
116+
`as both the HTML base HREF and the directory name for output.\nBy default, ` +
117+
`if not specified, 'subPath' uses the locale code.`,
118+
);
119+
99120
rawSourceLocaleBaseHref = metadata.sourceLocale.baseHref;
100121
}
122+
123+
if (metadata.sourceLocale.subPath !== undefined) {
124+
ensureValidsubPath(metadata.sourceLocale.subPath, 'i18n.sourceLocale.subPath');
125+
rawsubPath = metadata.sourceLocale.subPath;
126+
}
127+
128+
if (rawsubPath !== undefined && rawSourceLocaleBaseHref !== undefined) {
129+
throw new Error(
130+
`'i18n.sourceLocale.subPath' and 'i18n.sourceLocale.baseHref' cannot be used together.`,
131+
);
132+
}
101133
}
102134

103135
if (rawSourceLocale !== undefined) {
@@ -108,21 +140,41 @@ export function createI18nOptions(
108140
i18n.locales[i18n.sourceLocale] = {
109141
files: [],
110142
baseHref: rawSourceLocaleBaseHref,
143+
subPath: rawsubPath ?? i18n.sourceLocale,
111144
};
112145

113146
if (metadata.locales !== undefined) {
114147
ensureObject(metadata.locales, 'i18n locales');
115148

116149
for (const [locale, options] of Object.entries(metadata.locales)) {
117-
let translationFiles;
118-
let baseHref;
150+
let translationFiles: string[] | undefined;
151+
let baseHref: string | undefined;
152+
let subPath: string | undefined;
153+
119154
if (options && typeof options === 'object' && 'translation' in options) {
120155
translationFiles = normalizeTranslationFileOption(options.translation, locale, false);
121156

122157
if ('baseHref' in options) {
123-
ensureString(options.baseHref, `i18n locales ${locale} baseHref`);
158+
ensureString(options.baseHref, `i18n.locales.${locale}.baseHref`);
159+
logger?.warn(
160+
`The 'baseHref' field under 'i18n.locales.${locale}' is deprecated and will be removed in future versions. ` +
161+
`Please use 'subPath' instead.\nNote: 'subPath' defines the URL segment for the locale, acting ` +
162+
`as both the HTML base HREF and the directory name for output.\nBy default, ` +
163+
`if not specified, 'subPath' uses the locale code.`,
164+
);
124165
baseHref = options.baseHref;
125166
}
167+
168+
if ('subPath' in options) {
169+
ensureString(options.subPath, `i18n.locales.${locale}.subPath`);
170+
subPath = options.subPath;
171+
}
172+
173+
if (subPath !== undefined && baseHref !== undefined) {
174+
throw new Error(
175+
`'i18n.locales.${locale}.subPath' and 'i18n.locales.${locale}.baseHref' cannot be used together.`,
176+
);
177+
}
126178
} else {
127179
translationFiles = normalizeTranslationFileOption(options, locale, true);
128180
}
@@ -136,10 +188,27 @@ export function createI18nOptions(
136188
i18n.locales[locale] = {
137189
files: translationFiles.map((file) => ({ path: file })),
138190
baseHref,
191+
subPath: subPath ?? locale,
139192
};
140193
}
141194
}
142195

196+
// Check that subPaths are unique.
197+
const localesData = Object.entries(i18n.locales);
198+
for (let i = 0; i < localesData.length; i++) {
199+
const [localeA, { subPath: subPathA }] = localesData[i];
200+
201+
for (let j = i + 1; j < localesData.length; j++) {
202+
const [localeB, { subPath: subPathB }] = localesData[j];
203+
204+
if (subPathA === subPathB) {
205+
throw new Error(
206+
`Invalid i18n configuration: Locales '${localeA}' and '${localeB}' cannot have the same subPath: '${subPathB}'.`,
207+
);
208+
}
209+
}
210+
}
211+
143212
if (inline === true) {
144213
i18n.inlineLocales.add(i18n.sourceLocale);
145214
Object.keys(i18n.locales).forEach((locale) => i18n.inlineLocales.add(locale));

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

+5-17
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,7 @@
77
*/
88

99
import { extname } from 'node:path';
10-
import {
11-
NormalizedApplicationBuildOptions,
12-
getLocaleBaseHref,
13-
} from '../../builders/application/options';
10+
import { NormalizedApplicationBuildOptions } from '../../builders/application/options';
1411
import { type BuildOutputFile, BuildOutputFileType } from '../../tools/esbuild/bundler-context';
1512
import { createOutputFile } from '../../tools/esbuild/utils';
1613

@@ -56,20 +53,11 @@ export function generateAngularServerAppEngineManifest(
5653
baseHref: string | undefined,
5754
): string {
5855
const entryPoints: Record<string, string> = {};
59-
60-
if (i18nOptions.shouldInline) {
56+
if (i18nOptions.shouldInline && !i18nOptions.flatOutput) {
6157
for (const locale of i18nOptions.inlineLocales) {
62-
const importPath =
63-
'./' + (i18nOptions.flatOutput ? '' : locale + '/') + MAIN_SERVER_OUTPUT_FILENAME;
64-
65-
let localeWithBaseHref = getLocaleBaseHref('', i18nOptions, locale) || '/';
66-
67-
// Remove leading and trailing slashes.
68-
const start = localeWithBaseHref[0] === '/' ? 1 : 0;
69-
const end = localeWithBaseHref[localeWithBaseHref.length - 1] === '/' ? -1 : undefined;
70-
localeWithBaseHref = localeWithBaseHref.slice(start, end);
71-
72-
entryPoints[localeWithBaseHref] = `() => import('${importPath}')`;
58+
const { subPath } = i18nOptions.locales[locale];
59+
const importPath = `${subPath ? `${subPath}/` : ''}${MAIN_SERVER_OUTPUT_FILENAME}`;
60+
entryPoints[subPath] = `() => import('./${importPath}')`;
7361
}
7462
} else {
7563
entryPoints[''] = `() => import('./${MAIN_SERVER_OUTPUT_FILENAME}')`;

‎packages/angular/build/src/utils/server-rendering/prerender.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,7 @@ async function renderPages(
219219
const appShellRouteWithLeadingSlash = appShellRoute && addLeadingSlash(appShellRoute);
220220
const baseHrefWithLeadingSlash = addLeadingSlash(baseHref);
221221

222-
for (const { route, redirectTo, renderMode } of serializableRouteTreeNode) {
222+
for (const { route, redirectTo } of serializableRouteTreeNode) {
223223
// Remove the base href from the file output path.
224224
const routeWithoutBaseHref = addTrailingSlash(route).startsWith(baseHrefWithLeadingSlash)
225225
? addLeadingSlash(route.slice(baseHrefWithLeadingSlash.length))

‎packages/angular/cli/lib/config/workspace-schema.json

+59-9
Original file line numberDiff line numberDiff line change
@@ -275,18 +275,43 @@
275275
},
276276
{
277277
"type": "object",
278-
"description": "Localization options to use for the source locale",
278+
"description": "Localization options to use for the source locale.",
279279
"properties": {
280280
"code": {
281281
"type": "string",
282-
"description": "Specifies the locale code of the source locale",
282+
"description": "Specifies the locale code of the source locale.",
283283
"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})+)?$"
284284
},
285285
"baseHref": {
286286
"type": "string",
287-
"description": "HTML base HREF to use for the locale (defaults to the locale code)"
287+
"deprecated": true,
288+
"description": "Specifies the HTML base HREF for the locale. Defaults to the locale code if not provided."
289+
},
290+
"subPath": {
291+
"type": "string",
292+
"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.",
293+
"pattern": "^[\\w-]*$"
288294
}
289295
},
296+
"anyOf": [
297+
{
298+
"required": ["subPath"],
299+
"not": {
300+
"required": ["baseHref"]
301+
}
302+
},
303+
{
304+
"required": ["baseHref"],
305+
"not": {
306+
"required": ["subPath"]
307+
}
308+
},
309+
{
310+
"not": {
311+
"required": ["baseHref", "subPath"]
312+
}
313+
}
314+
],
290315
"additionalProperties": false
291316
}
292317
]
@@ -299,29 +324,29 @@
299324
"oneOf": [
300325
{
301326
"type": "string",
302-
"description": "Localization file to use for i18n"
327+
"description": "Localization file to use for i18n."
303328
},
304329
{
305330
"type": "array",
306-
"description": "Localization files to use for i18n",
331+
"description": "Localization files to use for i18n.",
307332
"items": {
308333
"type": "string",
309334
"uniqueItems": true
310335
}
311336
},
312337
{
313338
"type": "object",
314-
"description": "Localization options to use for the locale",
339+
"description": "Localization options to use for the locale.",
315340
"properties": {
316341
"translation": {
317342
"oneOf": [
318343
{
319344
"type": "string",
320-
"description": "Localization file to use for i18n"
345+
"description": "Localization file to use for i18n."
321346
},
322347
{
323348
"type": "array",
324-
"description": "Localization files to use for i18n",
349+
"description": "Localization files to use for i18n.",
325350
"items": {
326351
"type": "string",
327352
"uniqueItems": true
@@ -331,9 +356,34 @@
331356
},
332357
"baseHref": {
333358
"type": "string",
334-
"description": "HTML base HREF to use for the locale (defaults to the locale code)"
359+
"deprecated": true,
360+
"description": "Specifies the HTML base HREF for the locale. Defaults to the locale code if not provided."
361+
},
362+
"subPath": {
363+
"type": "string",
364+
"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.",
365+
"pattern": "^[\\w-]*$"
335366
}
336367
},
368+
"anyOf": [
369+
{
370+
"required": ["subPath"],
371+
"not": {
372+
"required": ["baseHref"]
373+
}
374+
},
375+
{
376+
"required": ["baseHref"],
377+
"not": {
378+
"required": ["subPath"]
379+
}
380+
},
381+
{
382+
"not": {
383+
"required": ["baseHref", "subPath"]
384+
}
385+
}
386+
],
337387
"additionalProperties": false
338388
}
339389
]

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

+1-1
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,6 @@ export class AngularAppEngine {
148148

149149
const potentialLocale = getPotentialLocaleIdFromUrl(url, basePath);
150150

151-
return this.getEntryPointExports(potentialLocale);
151+
return this.getEntryPointExports(potentialLocale) ?? this.getEntryPointExports('');
152152
}
153153
}

‎packages/angular_devkit/build_angular/src/builders/browser/index.ts

+10-3
Original file line numberDiff line numberDiff line change
@@ -425,11 +425,18 @@ export function buildWebpackBrowser(
425425
);
426426

427427
function getLocaleBaseHref(i18n: I18nOptions, locale: string): string | undefined {
428-
if (i18n.locales[locale] && i18n.locales[locale]?.baseHref !== '') {
429-
return urlJoin(options.baseHref || '', i18n.locales[locale].baseHref ?? `/${locale}/`);
428+
if (i18n.flatOutput) {
429+
return undefined;
430430
}
431431

432-
return undefined;
432+
const localeData = i18n.locales[locale];
433+
if (!localeData) {
434+
return undefined;
435+
}
436+
437+
const baseHrefSuffix = localeData.baseHref ?? localeData.subPath + '/';
438+
439+
return baseHrefSuffix !== '' ? urlJoin(options.baseHref || '', baseHrefSuffix) : undefined;
433440
}
434441
}
435442

‎packages/angular_devkit/build_angular/src/builders/extract-i18n/options.ts

+1-2
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,7 @@ export async function normalizeOptions(
3636
// Target specifier defaults to the current project's build target with no specified configuration
3737
const buildTargetSpecifier = options.buildTarget ?? ':';
3838
const buildTarget = targetFromTargetString(buildTargetSpecifier, projectName, 'build');
39-
40-
const i18nOptions = createI18nOptions(projectMetadata);
39+
const i18nOptions = createI18nOptions(projectMetadata, /** inline */ false, context.logger);
4140

4241
// Normalize xliff format extensions
4342
let format = options.format;

‎packages/angular_devkit/build_angular/src/utils/i18n-webpack.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ export async function configureI18nBuild<T extends BrowserBuilderSchema | Server
4343
const buildOptions = { ...options };
4444
const tsConfig = await readTsconfig(buildOptions.tsConfig, context.workspaceRoot);
4545
const metadata = await context.getProjectMetadata(context.target);
46-
const i18n = createI18nOptions(metadata, buildOptions.localize);
46+
const i18n = createI18nOptions(metadata, buildOptions.localize, context.logger);
4747

4848
// No additional processing needed if no inlining requested and no source locale defined.
4949
if (!i18n.shouldInline && !i18n.hasDefinedSourceLocale) {

‎packages/angular_devkit/build_angular/src/utils/output-paths.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export function ensureOutputPaths(baseOutputPath: string, i18n: I18nOptions): Ma
1414
const outputPaths: [string, string][] = i18n.shouldInline
1515
? [...i18n.inlineLocales].map((l) => [
1616
l,
17-
i18n.flatOutput ? baseOutputPath : join(baseOutputPath, l),
17+
i18n.flatOutput ? baseOutputPath : join(baseOutputPath, i18n.locales[l].subPath),
1818
])
1919
: [['', baseOutputPath]];
2020

‎packages/schematics/angular/guard/index.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ export default function (options: GuardOptions): Rule {
3434
const routerNamedImports: string[] = [...options.implements, 'MaybeAsync', 'GuardResult'];
3535

3636
if (options.implements.includes(GuardInterface.CanMatch)) {
37-
routerNamedImports.push('Route', 'UrlSegment');
37+
routerNamedImports.push('Route', 'subPath');
3838

3939
if (options.implements.length > 1) {
4040
routerNamedImports.push(...commonRouterNameImports);

‎packages/schematics/angular/guard/index_spec.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ describe('Guard Schematic', () => {
143143
const options = { ...defaultOptions, implements: implementationOptions, functional: false };
144144
const tree = await schematicRunner.runSchematic('guard', options, appTree);
145145
const fileString = tree.readContent('/projects/bar/src/app/foo.guard.ts');
146-
const expectedImports = `import { CanMatch, GuardResult, MaybeAsync, Route, UrlSegment } from '@angular/router';`;
146+
const expectedImports = `import { CanMatch, GuardResult, MaybeAsync, Route, subPath } from '@angular/router';`;
147147

148148
expect(fileString).toContain(expectedImports);
149149
});
@@ -176,7 +176,7 @@ describe('Guard Schematic', () => {
176176
const fileString = tree.readContent('/projects/bar/src/app/foo.guard.ts');
177177
const expectedImports =
178178
`import { ActivatedRouteSnapshot, CanActivate, CanActivateChild, CanMatch, GuardResult, ` +
179-
`MaybeAsync, Route, RouterStateSnapshot, UrlSegment } from '@angular/router';`;
179+
`MaybeAsync, Route, RouterStateSnapshot, subPath } from '@angular/router';`;
180180

181181
expect(fileString).toContain(expectedImports);
182182
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { join } from 'node:path';
2+
import assert from 'node:assert';
3+
import { expectFileToMatch, writeFile } from '../../../utils/fs';
4+
import { execAndWaitForOutputToMatch, ng, noSilentNg, silentNg } from '../../../utils/process';
5+
import { langTranslations, setupI18nConfig } from '../../i18n/setup';
6+
import { findFreePort } from '../../../utils/network';
7+
import { getGlobalVariable } from '../../../utils/env';
8+
import { installWorkspacePackages, uninstallPackage } from '../../../utils/packages';
9+
import { updateJsonFile, useSha } from '../../../utils/project';
10+
11+
export default async function () {
12+
assert(
13+
getGlobalVariable('argv')['esbuild'],
14+
'This test should not be called in the Webpack suite.',
15+
);
16+
17+
// Setup project
18+
await setupI18nConfig();
19+
20+
// Update angular.json
21+
const URL_SUB_PATH: Record<string, string> = {
22+
'en-US': '',
23+
'fr': 'fr',
24+
'de': 'deutsche',
25+
};
26+
27+
await updateJsonFile('angular.json', (workspaceJson) => {
28+
const appProject = workspaceJson.projects['test-project'];
29+
const i18n: Record<string, any> = appProject.i18n;
30+
i18n.sourceLocale = {
31+
subPath: URL_SUB_PATH['en-US'],
32+
};
33+
34+
i18n.locales['fr'] = {
35+
translation: i18n.locales['fr'],
36+
subPath: URL_SUB_PATH['fr'],
37+
};
38+
39+
i18n.locales['de'] = {
40+
translation: i18n.locales['de'],
41+
subPath: URL_SUB_PATH['de'],
42+
};
43+
});
44+
45+
// Forcibly remove in case another test doesn't clean itself up.
46+
await uninstallPackage('@angular/ssr');
47+
await ng('add', '@angular/ssr', '--server-routing', '--skip-confirmation', '--skip-install');
48+
await useSha();
49+
await installWorkspacePackages();
50+
51+
// Add routes
52+
await writeFile(
53+
'src/app/app.routes.ts',
54+
`
55+
import { Routes } from '@angular/router';
56+
import { HomeComponent } from './home/home.component';
57+
import { SsrComponent } from './ssr/ssr.component';
58+
import { SsgComponent } from './ssg/ssg.component';
59+
60+
export const routes: Routes = [
61+
{
62+
path: '',
63+
component: HomeComponent,
64+
},
65+
{
66+
path: 'ssg',
67+
component: SsgComponent,
68+
},
69+
{
70+
path: 'ssr',
71+
component: SsrComponent,
72+
},
73+
];
74+
`,
75+
);
76+
77+
// Add server routing
78+
await writeFile(
79+
'src/app/app.routes.server.ts',
80+
`
81+
import { RenderMode, ServerRoute } from '@angular/ssr';
82+
83+
export const serverRoutes: ServerRoute[] = [
84+
{
85+
path: '',
86+
renderMode: RenderMode.Prerender,
87+
},
88+
{
89+
path: 'ssg',
90+
renderMode: RenderMode.Prerender,
91+
},
92+
{
93+
path: '**',
94+
renderMode: RenderMode.Server,
95+
},
96+
];
97+
`,
98+
);
99+
100+
// Generate components for the above routes
101+
const componentNames: string[] = ['home', 'ssg', 'ssr'];
102+
for (const componentName of componentNames) {
103+
await silentNg('generate', 'component', componentName);
104+
}
105+
106+
await noSilentNg('build', '--output-mode=server', '--base-href=/base/');
107+
108+
const pathToVerify = ['/index.html', '/ssg/index.html'];
109+
for (const { lang } of langTranslations) {
110+
const subPath = URL_SUB_PATH[lang];
111+
const outputPath = join('dist/test-project/browser', subPath);
112+
113+
for (const path of pathToVerify) {
114+
await expectFileToMatch(join(outputPath, path), `<p id="locale">${lang}</p>`);
115+
const baseHref = `/base/${subPath ? `${subPath}/` : ''}`;
116+
await expectFileToMatch(join(outputPath, path), `<base href="${baseHref}">`);
117+
}
118+
}
119+
120+
// Tests responses
121+
const port = await spawnServer();
122+
const pathnamesToVerify = ['/ssr', '/ssg'];
123+
124+
for (const { lang } of langTranslations) {
125+
for (const pathname of pathnamesToVerify) {
126+
const subPath = URL_SUB_PATH[lang];
127+
const urlPathname = `/base${subPath ? `/${subPath}` : ''}${pathname}`;
128+
const res = await fetch(`http://localhost:${port}${urlPathname}`);
129+
const text = await res.text();
130+
131+
assert.match(
132+
text,
133+
new RegExp(`<p id="locale">${lang}</p>`),
134+
`Response for '${urlPathname}': '<p id="locale">${lang}</p>' was not matched in content.`,
135+
);
136+
}
137+
}
138+
}
139+
140+
async function spawnServer(): Promise<number> {
141+
const port = await findFreePort();
142+
await execAndWaitForOutputToMatch(
143+
'npm',
144+
['run', 'serve:ssr:test-project'],
145+
/Node Express server listening on/,
146+
{
147+
'PORT': String(port),
148+
},
149+
);
150+
151+
return port;
152+
}

0 commit comments

Comments
 (0)
Please sign in to comment.