Skip to content

Commit 0a570c0

Browse files
committed
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

Diff for: 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

Diff for: 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
}

Diff for: 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;

Diff for: 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));

Diff for: 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}')`;

Diff for: 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))

0 commit comments

Comments
 (0)