Skip to content

Commit 9cd796b

Browse files
clydinalan-agius4
authored andcommitted
feat(@angular-devkit/build-angular): support multiple translation files per locale
This change implements the capability to specify multiple translation files per locale. The specified translation files for each locale will be merged prior to localization. The Angular configuration file has been updated to allow for both a single path string or an array of path strings when specifying the translations for each locale. If the same message identifier is present in multiple translation files, a warning will currently be issued and the last file with the duplicate message identifier will take precedence. Closes #18276
1 parent 57b80ee commit 9cd796b

File tree

7 files changed

+172
-54
lines changed

7 files changed

+172
-54
lines changed

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

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -418,13 +418,33 @@
418418
"type": "string",
419419
"description": "Localization file to use for i18n"
420420
},
421+
{
422+
"type": "array",
423+
"description": "Localization files to use for i18n",
424+
"items": {
425+
"type": "string",
426+
"uniqueItems": true
427+
}
428+
},
421429
{
422430
"type": "object",
423431
"description": "Localization options to use for the locale",
424432
"properties": {
425433
"translation": {
426-
"type": "string",
427-
"description": "Localization file to use for i18n"
434+
"oneOf": [
435+
{
436+
"type": "string",
437+
"description": "Localization file to use for i18n"
438+
},
439+
{
440+
"type": "array",
441+
"description": "Localization files to use for i18n",
442+
"items": {
443+
"type": "string",
444+
"uniqueItems": true
445+
}
446+
}
447+
]
428448
},
429449
"baseHref": {
430450
"type": "string",

packages/angular_devkit/build_angular/src/dev-server/index.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -287,14 +287,13 @@ async function setupLocalize(
287287
const localeDescription = i18n.locales[locale];
288288
const { plugins, diagnostics } = await createI18nPlugins(
289289
locale,
290-
localeDescription && localeDescription.translation,
290+
localeDescription?.translation,
291291
browserOptions.i18nMissingTranslation || 'ignore',
292292
);
293293

294294
// Modify main entrypoint to include locale data
295295
if (
296-
localeDescription &&
297-
localeDescription.dataPath &&
296+
localeDescription?.dataPath &&
298297
typeof webpackConfig.entry === 'object' &&
299298
!Array.isArray(webpackConfig.entry) &&
300299
webpackConfig.entry['main']
@@ -321,7 +320,7 @@ async function setupLocalize(
321320
cacheIdentifier: JSON.stringify({
322321
buildAngular: require('../../package.json').version,
323322
locale,
324-
translationIntegrity: localeDescription && localeDescription.integrity,
323+
translationIntegrity: localeDescription?.files.map((file) => file.integrity),
325324
}),
326325
plugins,
327326
},

packages/angular_devkit/build_angular/src/utils/i18n-options.ts

Lines changed: 79 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,9 @@ export interface I18nOptions {
2222
locales: Record<
2323
string,
2424
{
25-
file: string;
26-
format?: string;
27-
translation?: unknown;
25+
files: { path: string; integrity?: string; format?: string }[];
26+
translation?: Record<string, unknown>;
2827
dataPath?: string;
29-
integrity?: string;
3028
baseHref?: string;
3129
}
3230
>;
@@ -35,6 +33,29 @@ export interface I18nOptions {
3533
veCompatLocale?: string;
3634
}
3735

36+
function normalizeTranslationFileOption(
37+
option: json.JsonValue,
38+
locale: string,
39+
expectObjectInError: boolean,
40+
): string[] {
41+
if (typeof option === 'string') {
42+
return [option];
43+
}
44+
45+
if (Array.isArray(option) && option.every((element) => typeof element === 'string')) {
46+
return option as string[];
47+
}
48+
49+
let errorMessage = `Project i18n locales translation field value for '${locale}' is malformed. `;
50+
if (expectObjectInError) {
51+
errorMessage += 'Expected a string, array of strings, or object.';
52+
} else {
53+
errorMessage += 'Expected a string or array of strings.';
54+
}
55+
56+
throw new Error(errorMessage);
57+
}
58+
3859
export function createI18nOptions(
3960
metadata: json.JsonObject,
4061
inline?: boolean | string[],
@@ -75,32 +96,24 @@ export function createI18nOptions(
7596
}
7697

7798
i18n.locales[i18n.sourceLocale] = {
78-
file: '',
99+
files: [],
79100
baseHref: rawSourceLocaleBaseHref,
80101
};
81102

82103
if (metadata.locales !== undefined && !json.isJsonObject(metadata.locales)) {
83104
throw new Error('Project i18n locales field is malformed. Expected an object.');
84105
} else if (metadata.locales) {
85106
for (const [locale, options] of Object.entries(metadata.locales)) {
86-
let translationFile;
107+
let translationFiles;
87108
let baseHref;
88109
if (json.isJsonObject(options)) {
89-
if (typeof options.translation !== 'string') {
90-
throw new Error(
91-
`Project i18n locales translation field value for '${locale}' is malformed. Expected a string.`,
92-
);
93-
}
94-
translationFile = options.translation;
110+
translationFiles = normalizeTranslationFileOption(options.translation, locale, false);
111+
95112
if (typeof options.baseHref === 'string') {
96113
baseHref = options.baseHref;
97114
}
98-
} else if (typeof options !== 'string') {
99-
throw new Error(
100-
`Project i18n locales field value for '${locale}' is malformed. Expected a string or object.`,
101-
);
102115
} else {
103-
translationFile = options;
116+
translationFiles = normalizeTranslationFileOption(options, locale, true);
104117
}
105118

106119
if (locale === i18n.sourceLocale) {
@@ -110,7 +123,7 @@ export function createI18nOptions(
110123
}
111124

112125
i18n.locales[locale] = {
113-
file: translationFile,
126+
files: translationFiles.map((file) => ({ path: file })),
114127
baseHref,
115128
};
116129
}
@@ -226,33 +239,55 @@ export async function configureI18nBuild<T extends BrowserBuilderSchema | Server
226239
desc.dataPath = localeDataPath;
227240
}
228241

229-
if (!desc.file) {
242+
if (!desc.files.length) {
230243
continue;
231244
}
232245

233-
const result = loader(path.join(context.workspaceRoot, desc.file));
246+
for (const file of desc.files) {
247+
const loadResult = loader(path.join(context.workspaceRoot, file.path));
234248

235-
for (const diagnostics of result.diagnostics.messages) {
236-
if (diagnostics.type === 'error') {
249+
for (const diagnostics of loadResult.diagnostics.messages) {
250+
if (diagnostics.type === 'error') {
251+
throw new Error(
252+
`Error parsing translation file '${file.path}': ${diagnostics.message}`,
253+
);
254+
} else {
255+
context.logger.warn(`WARNING [${file.path}]: ${diagnostics.message}`);
256+
}
257+
}
258+
259+
if (loadResult.locale !== undefined && loadResult.locale !== locale) {
260+
context.logger.warn(
261+
`WARNING [${file.path}]: File target locale ('${loadResult.locale}') does not match configured locale ('${locale}')`,
262+
);
263+
}
264+
265+
usedFormats.add(loadResult.format);
266+
if (usedFormats.size > 1 && tsConfig.options.enableI18nLegacyMessageIdFormat !== false) {
267+
// This limitation is only for legacy message id support (defaults to true as of 9.0)
237268
throw new Error(
238-
`Error parsing translation file '${desc.file}': ${diagnostics.message}`,
269+
'Localization currently only supports using one type of translation file format for the entire application.',
239270
);
240-
} else {
241-
context.logger.warn(`WARNING [${desc.file}]: ${diagnostics.message}`);
242271
}
243-
}
244272

245-
usedFormats.add(result.format);
246-
if (usedFormats.size > 1 && tsConfig.options.enableI18nLegacyMessageIdFormat !== false) {
247-
// This limitation is only for legacy message id support (defaults to true as of 9.0)
248-
throw new Error(
249-
'Localization currently only supports using one type of translation file format for the entire application.',
250-
);
273+
file.format = loadResult.format;
274+
file.integrity = loadResult.integrity;
275+
276+
if (desc.translation) {
277+
// Merge translations
278+
for (const [id, message] of Object.entries(loadResult.translations)) {
279+
if (desc.translation[id] !== undefined) {
280+
context.logger.warn(
281+
`WARNING [${file.path}]: Duplicate translations for message '${id}' when merging`,
282+
);
283+
}
284+
desc.translation[id] = message;
285+
}
286+
} else {
287+
// First or only translation file
288+
desc.translation = loadResult.translations;
289+
}
251290
}
252-
253-
desc.format = result.format;
254-
desc.translation = result.translation;
255-
desc.integrity = result.integrity;
256291
}
257292

258293
// Legacy message id's require the format of the translations
@@ -265,7 +300,12 @@ export async function configureI18nBuild<T extends BrowserBuilderSchema | Server
265300
i18n.veCompatLocale = buildOptions.i18nLocale = [...i18n.inlineLocales][0];
266301

267302
if (buildOptions.i18nLocale !== i18n.sourceLocale) {
268-
buildOptions.i18nFile = i18n.locales[buildOptions.i18nLocale].file;
303+
if (i18n.locales[buildOptions.i18nLocale].files.length > 1) {
304+
throw new Error(
305+
'Localization with View Engine only supports using a single translation file per locale.',
306+
);
307+
}
308+
buildOptions.i18nFile = i18n.locales[buildOptions.i18nLocale].files[0].path;
269309
}
270310

271311
// Clear inline locales to prevent any new i18n related processing
@@ -306,12 +346,12 @@ function mergeDeprecatedI18nOptions(
306346
i18n.inlineLocales.add(i18nLocale);
307347

308348
if (i18nFile !== undefined) {
309-
i18n.locales[i18nLocale] = { file: i18nFile, baseHref: '' };
349+
i18n.locales[i18nLocale] = { files: [{ path: i18nFile }], baseHref: '' };
310350
} else {
311351
// If no file, treat the locale as the source locale
312352
// This mimics deprecated behavior
313353
i18n.sourceLocale = i18nLocale;
314-
i18n.locales[i18nLocale] = { file: '', baseHref: '' };
354+
i18n.locales[i18nLocale] = { files: [], baseHref: '' };
315355
}
316356

317357
i18n.flatOutput = true;

packages/angular_devkit/build_angular/src/utils/load-translations.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@ import * as fs from 'fs';
1111
export type TranslationLoader = (
1212
path: string,
1313
) => {
14-
translation: unknown;
14+
// tslint:disable-next-line: no-implicit-dependencies
15+
translations: Record<string, import('@angular/localize').ɵParsedTranslation>;
1516
format: string;
17+
locale?: string;
1618
// tslint:disable-next-line: no-implicit-dependencies
1719
diagnostics: import('@angular/localize/src/tools/src/diagnostics').Diagnostics;
1820
integrity: string;
@@ -23,15 +25,14 @@ export async function createTranslationLoader(): Promise<TranslationLoader> {
2325

2426
return (path: string) => {
2527
const content = fs.readFileSync(path, 'utf8');
26-
2728
const unusedParsers = new Map();
2829
for (const [format, parser] of Object.entries(parsers)) {
2930
const analysis = analyze(parser, path, content);
3031
if (analysis.canParse) {
31-
const translationBundle = parser.parse(path, content, analysis.hint);
32+
const { locale, translations } = parser.parse(path, content, analysis.hint);
3233
const integrity = 'sha256-' + createHash('sha256').update(content).digest('base64');
3334

34-
return { format, translation: translationBundle.translations, diagnostics, integrity };
35+
return { format, locale, translations, diagnostics, integrity };
3536
} else {
3637
unusedParsers.set(parser, analysis);
3738
}
@@ -41,7 +42,10 @@ export async function createTranslationLoader(): Promise<TranslationLoader> {
4142
for (const [parser, analysis] of unusedParsers.entries()) {
4243
messages.push(analysis.diagnostics.formatDiagnostics(`*** ${parser.constructor.name} ***`));
4344
}
44-
throw new Error(`Unsupported translation file format in ${path}. The following parsers were tried:\n` + messages.join('\n'));
45+
throw new Error(
46+
`Unsupported translation file format in ${path}. The following parsers were tried:\n` +
47+
messages.join('\n'),
48+
);
4549
};
4650

4751
// TODO: `parser.canParse()` is deprecated; remove this polyfill once we are sure all parsers provide the `parser.analyze()` method.
@@ -52,7 +56,7 @@ export async function createTranslationLoader(): Promise<TranslationLoader> {
5256
} else {
5357
const hint = parser.canParse(path, content);
5458

55-
return {canParse: hint !== false, hint, diagnostics};
59+
return { canParse: hint !== false, hint, diagnostics };
5660
}
5761
}
5862
}

packages/angular_devkit/build_angular/src/utils/webpack-browser-config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ export async function generateI18nBrowserWebpackConfigFromContext(
172172

173173
// Update file hashes to include translation file content
174174
const i18nHash = Object.values(i18n.locales).reduce(
175-
(data, locale) => data + (locale.integrity || ''),
175+
(data, locale) => data + locale.files.map((file) => file.integrity || '').join('|'),
176176
'',
177177
);
178178
if (!config.plugins) {

packages/angular_devkit/core/src/experimental/workspace/workspace-schema.json

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -166,13 +166,33 @@
166166
"type": "string",
167167
"description": "Localization file to use for i18n"
168168
},
169+
{
170+
"type": "array",
171+
"description": "Localization files to use for i18n",
172+
"items": {
173+
"type": "string",
174+
"uniqueItems": true
175+
}
176+
},
169177
{
170178
"type": "object",
171179
"description": "Localization options to use for the locale",
172180
"properties": {
173181
"translation": {
174-
"type": "string",
175-
"description": "Localization file to use for i18n"
182+
"oneOf": [
183+
{
184+
"type": "string",
185+
"description": "Localization file to use for i18n"
186+
},
187+
{
188+
"type": "array",
189+
"description": "Localization files to use for i18n",
190+
"items": {
191+
"type": "string",
192+
"uniqueItems": true
193+
}
194+
}
195+
]
176196
},
177197
"baseHref": {
178198
"type": "string",
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/**
2+
* @license
3+
* Copyright Google Inc. All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
import { ng } from '../../utils/process';
9+
import { updateJsonFile } from '../../utils/project';
10+
import { setupI18nConfig } from './legacy';
11+
12+
export default async function() {
13+
// Setup i18n tests and config.
14+
await setupI18nConfig(true);
15+
16+
// Update angular.json
17+
await updateJsonFile('angular.json', workspaceJson => {
18+
const appProject = workspaceJson.projects['test-project'];
19+
// tslint:disable-next-line: no-any
20+
const i18n: Record<string, any> = appProject.i18n;
21+
22+
i18n.locales['fr'] = [
23+
i18n.locales['fr'],
24+
i18n.locales['fr'],
25+
]
26+
appProject.architect['build'].options.localize = ['fr'];
27+
});
28+
29+
const { stderr: err1 } = await ng('build');
30+
if (!err1.includes('Duplicate translations for message')) {
31+
throw new Error('duplicate translations warning not shown');
32+
}
33+
34+
35+
}

0 commit comments

Comments
 (0)