Skip to content

Commit 35403a3

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 `urlSegment` option, which simplifies the i18n process, particularly in static site generation (SSG) and server-side rendering (SSR). When the `urlSegment` option is used, the `baseHref` is ignored. Instead, the `urlSegment` serves as both the base href and the name of the directory containing the localized version of the app. ### Configuration Example Below is an example configuration showcasing the use of `urlSegment`: ```json "i18n": { "sourceLocale": { "code": "en-US", "urlSegment": "" }, "locales": { "fr-BE": { "urlSegment": "fr", "translation": "src/i18n/messages.fr-BE.xlf" }, "de-BE": { "urlSegment": "de", "translation": "src/i18n/messages.de-BE.xlf" } } } ``` ### Example Directory Structure The following tree structure demonstrates how the `urlSegment` 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` ``` Closes #16997 and closes #28967
1 parent ca757c9 commit 35403a3

File tree

7 files changed

+274
-32
lines changed

7 files changed

+274
-32
lines changed

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

Lines changed: 13 additions & 13 deletions
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 urlSegment
97+
const urlSegment = i18nOptions.locales[locale]?.urlSegment ?? locale;
98+
if (i18nOptions.flatOutput !== true) {
9999
localeOutputFiles.forEach((file) => {
100-
file.path = join(locale, file.path);
100+
file.path = join(urlSegment, 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(urlSegment, 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

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -643,17 +643,25 @@ function normalizeGlobalEntries(
643643
}
644644

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

654-
if (i18n.locales[locale] && i18n.locales[locale].baseHref !== '') {
655-
return urlJoin(baseHref || '', i18n.locales[locale].baseHref ?? `/${locale}/`);
654+
const localeData = i18n.locales[locale];
655+
if (!localeData) {
656+
return undefined;
656657
}
657658

658-
return undefined;
659+
let urlSegment = localeData.urlSegment;
660+
if (urlSegment !== undefined) {
661+
urlSegment += '/';
662+
}
663+
664+
const baseHrefSuffix = urlSegment ?? localeData.baseHref;
665+
666+
return baseHrefSuffix !== '' ? urlJoin(baseHref, baseHrefSuffix ?? locale + '/') : undefined;
659667
}

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

Lines changed: 39 additions & 4 deletions
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+
urlSegment?: string;
2122
}
2223

2324
export interface I18nOptions {
@@ -64,6 +65,16 @@ function ensureString(value: unknown, name: string): asserts value is string {
6465
}
6566
}
6667

68+
function ensureValidateUrlSegment(value: unknown, name: string): asserts value is string {
69+
ensureString(value, name);
70+
71+
if (!/^[\w-]*$/.test(value)) {
72+
throw new Error(
73+
`Project ${name} field is malformed. Expected to match pattern: '/^[\\w-]*$/'.`,
74+
);
75+
}
76+
}
77+
6778
export function createI18nOptions(
6879
projectMetadata: { i18n?: unknown },
6980
inline?: boolean | string[],
@@ -82,8 +93,9 @@ export function createI18nOptions(
8293
},
8394
};
8495

85-
let rawSourceLocale;
86-
let rawSourceLocaleBaseHref;
96+
let rawSourceLocale: string | undefined;
97+
let rawSourceLocaleBaseHref: string | undefined;
98+
let rawUrlSegment: string | undefined;
8799
if (typeof metadata.sourceLocale === 'string') {
88100
rawSourceLocale = metadata.sourceLocale;
89101
} else if (metadata.sourceLocale !== undefined) {
@@ -98,6 +110,15 @@ export function createI18nOptions(
98110
ensureString(metadata.sourceLocale.baseHref, 'i18n sourceLocale baseHref');
99111
rawSourceLocaleBaseHref = metadata.sourceLocale.baseHref;
100112
}
113+
114+
if (metadata.sourceLocale.urlSegment !== undefined) {
115+
ensureValidateUrlSegment(metadata.sourceLocale.urlSegment, 'i18n sourceLocale urlSegment');
116+
rawUrlSegment = metadata.sourceLocale.urlSegment;
117+
}
118+
119+
if (rawUrlSegment !== undefined && rawSourceLocaleBaseHref !== undefined) {
120+
throw new Error(`i18n sourceLocale urlSegment and baseHref cannot be used togather.`);
121+
}
101122
}
102123

103124
if (rawSourceLocale !== undefined) {
@@ -108,21 +129,35 @@ export function createI18nOptions(
108129
i18n.locales[i18n.sourceLocale] = {
109130
files: [],
110131
baseHref: rawSourceLocaleBaseHref,
132+
urlSegment: rawUrlSegment,
111133
};
112134

113135
if (metadata.locales !== undefined) {
114136
ensureObject(metadata.locales, 'i18n locales');
115137

116138
for (const [locale, options] of Object.entries(metadata.locales)) {
117-
let translationFiles;
118-
let baseHref;
139+
let translationFiles: string[] | undefined;
140+
let baseHref: string | undefined;
141+
let urlSegment: string | undefined;
142+
119143
if (options && typeof options === 'object' && 'translation' in options) {
120144
translationFiles = normalizeTranslationFileOption(options.translation, locale, false);
121145

122146
if ('baseHref' in options) {
123147
ensureString(options.baseHref, `i18n locales ${locale} baseHref`);
124148
baseHref = options.baseHref;
125149
}
150+
151+
if ('urlSegment' in options) {
152+
ensureString(options.urlSegment, `i18n locales ${locale} urlSegment`);
153+
urlSegment = options.urlSegment;
154+
}
155+
156+
if (urlSegment !== undefined && baseHref !== undefined) {
157+
throw new Error(
158+
`i18n locales ${locale} urlSegment and baseHref cannot be used togather.`,
159+
);
160+
}
126161
} else {
127162
translationFiles = normalizeTranslationFileOption(options, locale, true);
128163
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ export function generateAngularServerAppEngineManifest(
6262
const importPath =
6363
'./' + (i18nOptions.flatOutput ? '' : locale + '/') + MAIN_SERVER_OUTPUT_FILENAME;
6464

65-
let localeWithBaseHref = getLocaleBaseHref('', i18nOptions, locale) || '/';
65+
let localeWithBaseHref = getLocaleBaseHref('', i18nOptions, locale) || '';
6666

6767
// Remove leading and trailing slashes.
6868
const start = localeWithBaseHref[0] === '/' ? 1 : 0;

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

Lines changed: 57 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -275,18 +275,42 @@
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+
"description": "Specifies the HTML base HREF for the locale. Defaults to the locale code if not provided."
288+
},
289+
"urlSegment": {
290+
"type": "string",
291+
"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.",
292+
"pattern": "^[\\w-]*$"
288293
}
289294
},
295+
"anyOf": [
296+
{
297+
"required": ["urlSegment"],
298+
"not": {
299+
"required": ["baseHref"]
300+
}
301+
},
302+
{
303+
"required": ["baseHref"],
304+
"not": {
305+
"required": ["urlSegment"]
306+
}
307+
},
308+
{
309+
"not": {
310+
"required": ["baseHref", "urlSegment"]
311+
}
312+
}
313+
],
290314
"additionalProperties": false
291315
}
292316
]
@@ -299,29 +323,29 @@
299323
"oneOf": [
300324
{
301325
"type": "string",
302-
"description": "Localization file to use for i18n"
326+
"description": "Localization file to use for i18n."
303327
},
304328
{
305329
"type": "array",
306-
"description": "Localization files to use for i18n",
330+
"description": "Localization files to use for i18n.",
307331
"items": {
308332
"type": "string",
309333
"uniqueItems": true
310334
}
311335
},
312336
{
313337
"type": "object",
314-
"description": "Localization options to use for the locale",
338+
"description": "Localization options to use for the locale.",
315339
"properties": {
316340
"translation": {
317341
"oneOf": [
318342
{
319343
"type": "string",
320-
"description": "Localization file to use for i18n"
344+
"description": "Localization file to use for i18n."
321345
},
322346
{
323347
"type": "array",
324-
"description": "Localization files to use for i18n",
348+
"description": "Localization files to use for i18n.",
325349
"items": {
326350
"type": "string",
327351
"uniqueItems": true
@@ -331,9 +355,33 @@
331355
},
332356
"baseHref": {
333357
"type": "string",
334-
"description": "HTML base HREF to use for the locale (defaults to the locale code)"
358+
"description": "Specifies the HTML base HREF for the locale. Defaults to the locale code if not provided."
359+
},
360+
"urlSegment": {
361+
"type": "string",
362+
"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.",
363+
"pattern": "^[\\w-]*$"
335364
}
336365
},
366+
"anyOf": [
367+
{
368+
"required": ["urlSegment"],
369+
"not": {
370+
"required": ["baseHref"]
371+
}
372+
},
373+
{
374+
"required": ["baseHref"],
375+
"not": {
376+
"required": ["urlSegment"]
377+
}
378+
},
379+
{
380+
"not": {
381+
"required": ["baseHref", "urlSegment"]
382+
}
383+
}
384+
],
337385
"additionalProperties": false
338386
}
339387
]

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,6 @@ export class AngularAppEngine {
143143

144144
const potentialLocale = getPotentialLocaleIdFromUrl(url, basePath);
145145

146-
return this.getEntryPointExports(potentialLocale);
146+
return this.getEntryPointExports(potentialLocale) ?? this.getEntryPointExports('');
147147
}
148148
}

0 commit comments

Comments
 (0)