Skip to content

Commit 55a0a8e

Browse files
committed
feat(@angular-devkit/build-angular): watch i18n translation files with dev server
When using i18n with the dev server, the translation files will now be linked as a dependency to any file containing translated text. This allows translation files to be watched and the application to be rebuilt using the changed translation files. This change should also provide some performance improvements as well since now only files containing `$localize` will be parsed and processed by the babel-based localization inliner. Closes #16341
1 parent 5805c78 commit 55a0a8e

File tree

5 files changed

+238
-54
lines changed

5 files changed

+238
-54
lines changed

packages/angular_devkit/build_angular/src/babel/presets/application.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export interface ApplicationPresetOptions {
3636
locale: string;
3737
missingTranslationBehavior?: 'error' | 'warning' | 'ignore';
3838
translation?: unknown;
39+
translationFiles?: string[];
3940
pluginCreators?: I18nPluginCreators;
4041
};
4142

packages/angular_devkit/build_angular/src/babel/webpack-loader.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ async function requiresLinking(path: string, source: string): Promise<boolean> {
6262
return needsLinking(path, source);
6363
}
6464

65+
// eslint-disable-next-line max-lines-per-function
6566
export default custom<ApplicationPresetOptions>(() => {
6667
const baseOptions = Object.freeze({
6768
babelrc: false,
@@ -149,6 +150,18 @@ export default custom<ApplicationPresetOptions>(() => {
149150
...(i18n as NonNullable<ApplicationPresetOptions['i18n']>),
150151
pluginCreators: i18nPluginCreators,
151152
};
153+
154+
// Add translation files as dependencies of the file to support rebuilds
155+
// Except for `@angular/core` which needs locale injection but has no translations
156+
if (
157+
customOptions.i18n.translationFiles &&
158+
!/[\\/]@angular[\\/]core/.test(this.resourcePath)
159+
) {
160+
for (const file of customOptions.i18n.translationFiles) {
161+
this.addDependency(file);
162+
}
163+
}
164+
152165
shouldProcess = true;
153166
}
154167

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

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,9 @@ import { ExecutionTransformer } from '../../transforms';
2323
import { normalizeOptimization } from '../../utils';
2424
import { checkPort } from '../../utils/check-port';
2525
import { colors } from '../../utils/color';
26-
import { I18nOptions } from '../../utils/i18n-options';
26+
import { I18nOptions, loadTranslations } from '../../utils/i18n-options';
2727
import { IndexHtmlTransform } from '../../utils/index-file/index-html-generator';
28+
import { createTranslationLoader } from '../../utils/load-translations';
2829
import { NormalizedCachedOptions, normalizeCacheOptions } from '../../utils/normalize-cache';
2930
import { generateEntryPoints } from '../../utils/package-chunk-sort';
3031
import { assertCompatibleAngularVersion } from '../../utils/version';
@@ -33,6 +34,7 @@ import {
3334
getIndexInputFile,
3435
getIndexOutputFile,
3536
} from '../../utils/webpack-browser-config';
37+
import { addError, addWarning } from '../../utils/webpack-diagnostics';
3638
import {
3739
getAnalyticsConfig,
3840
getCommonConfig,
@@ -192,7 +194,7 @@ export function serveWebpackBrowser(
192194
);
193195
}
194196

195-
await setupLocalize(locale, i18n, browserOptions, webpackConfig, cacheOptions);
197+
await setupLocalize(locale, i18n, browserOptions, webpackConfig, cacheOptions, context);
196198
}
197199

198200
if (transforms.webpackConfiguration) {
@@ -288,6 +290,7 @@ async function setupLocalize(
288290
browserOptions: BrowserBuilderSchema,
289291
webpackConfig: webpack.Configuration,
290292
cacheOptions: NormalizedCachedOptions,
293+
context: BuilderContext,
291294
) {
292295
const localeDescription = i18n.locales[locale];
293296

@@ -320,6 +323,9 @@ async function setupLocalize(
320323
locale,
321324
missingTranslationBehavior,
322325
translation: i18n.shouldInline ? translation : undefined,
326+
translationFiles: localeDescription?.files.map((file) =>
327+
path.resolve(context.workspaceRoot, file.path),
328+
),
323329
};
324330

325331
const i18nRule: webpack.RuleSetRule = {
@@ -351,6 +357,33 @@ async function setupLocalize(
351357
}
352358

353359
rules.push(i18nRule);
360+
361+
// Add a plugin to reload translation files on rebuilds
362+
const loader = await createTranslationLoader();
363+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
364+
webpackConfig.plugins!.push({
365+
apply: (compiler: webpack.Compiler) => {
366+
compiler.hooks.thisCompilation.tap('build-angular', (compilation) => {
367+
if (i18n.shouldInline && i18nLoaderOptions.translation === undefined) {
368+
// Reload translations
369+
loadTranslations(locale, localeDescription, context.workspaceRoot, loader, {
370+
warn(message) {
371+
addWarning(compilation, message);
372+
},
373+
error(message) {
374+
addError(compilation, message);
375+
},
376+
});
377+
i18nLoaderOptions.translation = localeDescription.translation;
378+
}
379+
380+
compilation.hooks.finishModules.tap('build-angular', () => {
381+
// After loaders are finished, clear out the now unneeded translations
382+
i18nLoaderOptions.translation = undefined;
383+
});
384+
});
385+
},
386+
});
354387
}
355388

356389
export default createBuilder<DevServerBuilderOptions, DevServerBuilderOutput>(serveWebpackBrowser);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC 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+
9+
/* eslint-disable max-len */
10+
import fetch from 'node-fetch'; // eslint-disable-line import/no-extraneous-dependencies
11+
import { concatMap, count, take, timeout } from 'rxjs/operators';
12+
import { URL } from 'url';
13+
import { serveWebpackBrowser } from '../../index';
14+
import {
15+
BASE_OPTIONS,
16+
BUILD_TIMEOUT,
17+
DEV_SERVER_BUILDER_INFO,
18+
describeBuilder,
19+
setupBrowserTarget,
20+
} from '../setup';
21+
22+
describeBuilder(serveWebpackBrowser, DEV_SERVER_BUILDER_INFO, (harness) => {
23+
fdescribe('Behavior: "i18n translation file watching"', () => {
24+
beforeEach(() => {
25+
harness.useProject('test', {
26+
root: '.',
27+
sourceRoot: 'src',
28+
cli: {
29+
cache: {
30+
enabled: false,
31+
},
32+
},
33+
i18n: {
34+
locales: {
35+
'fr': 'src/locales/messages.fr.xlf',
36+
},
37+
},
38+
});
39+
40+
setupBrowserTarget(harness, { localize: ['fr'] });
41+
});
42+
43+
it('watches i18n translation files by default', async () => {
44+
harness.useTarget('serve', {
45+
...BASE_OPTIONS,
46+
});
47+
48+
await harness.writeFile(
49+
'src/app/app.component.html',
50+
`
51+
<p id="hello" i18n="An introduction header for this sample">Hello {{ title }}! </p>
52+
`,
53+
);
54+
55+
await harness.writeFile('src/locales/messages.fr.xlf', TRANSLATION_FILE_CONTENT);
56+
57+
const buildCount = await harness
58+
.execute()
59+
.pipe(
60+
timeout(BUILD_TIMEOUT * 2),
61+
concatMap(async ({ result }, index) => {
62+
expect(result?.success).toBe(true);
63+
64+
const mainUrl = new URL('main.js', `${result?.baseUrl}`);
65+
66+
switch (index) {
67+
case 0: {
68+
const response = await fetch(mainUrl);
69+
expect(await response?.text()).toContain('Bonjour');
70+
71+
await harness.modifyFile('src/locales/messages.fr.xlf', (content) =>
72+
content.replace('Bonjour', 'Salut'),
73+
);
74+
break;
75+
}
76+
case 1: {
77+
const response = await fetch(mainUrl);
78+
expect(await response?.text()).toContain('Salut');
79+
break;
80+
}
81+
}
82+
}),
83+
take(2),
84+
count(),
85+
)
86+
.toPromise();
87+
88+
expect(buildCount).toBe(2);
89+
});
90+
});
91+
});
92+
93+
const TRANSLATION_FILE_CONTENT = `
94+
<?xml version="1.0" encoding="UTF-8" ?>
95+
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
96+
<file target-language="en-US" datatype="plaintext" original="ng2.template">
97+
<body>
98+
<trans-unit id="4286451273117902052" datatype="html">
99+
<target>Bonjour <x id="INTERPOLATION" equiv-text="{{ title }}"/>! </target>
100+
<context-group purpose="location">
101+
<context context-type="targetfile">src/app/app.component.html</context>
102+
<context context-type="linenumber">2,3</context>
103+
</context-group>
104+
<note priority="1" from="description">An introduction header for this sample</note>
105+
</trans-unit>
106+
</body>
107+
</file>
108+
</xliff>
109+
`;

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

Lines changed: 80 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -15,25 +15,28 @@ import path from 'path';
1515
import { Schema as BrowserBuilderSchema } from '../builders/browser/schema';
1616
import { Schema as ServerBuilderSchema } from '../builders/server/schema';
1717
import { readTsconfig } from '../utils/read-tsconfig';
18-
import { createTranslationLoader } from './load-translations';
18+
import { TranslationLoader, createTranslationLoader } from './load-translations';
1919

2020
/**
2121
* The base module location used to search for locale specific data.
2222
*/
2323
const LOCALE_DATA_BASE_MODULE = '@angular/common/locales/global';
2424

25+
export interface LocaleDescription {
26+
files: {
27+
path: string;
28+
integrity?: string;
29+
format?: string;
30+
}[];
31+
translation?: Record<string, unknown>;
32+
dataPath?: string;
33+
baseHref?: string;
34+
}
35+
2536
export interface I18nOptions {
2637
inlineLocales: Set<string>;
2738
sourceLocale: string;
28-
locales: Record<
29-
string,
30-
{
31-
files: { path: string; integrity?: string; format?: string }[];
32-
translation?: Record<string, unknown>;
33-
dataPath?: string;
34-
baseHref?: string;
35-
}
36-
>;
39+
locales: Record<string, LocaleDescription>;
3740
flatOutput?: boolean;
3841
readonly shouldInline: boolean;
3942
hasDefinedSourceLocale?: boolean;
@@ -218,48 +221,27 @@ export async function configureI18nBuild<T extends BrowserBuilderSchema | Server
218221
loader = await createTranslationLoader();
219222
}
220223

221-
for (const file of desc.files) {
222-
const loadResult = loader(path.join(context.workspaceRoot, file.path));
223-
224-
for (const diagnostics of loadResult.diagnostics.messages) {
225-
if (diagnostics.type === 'error') {
226-
throw new Error(`Error parsing translation file '${file.path}': ${diagnostics.message}`);
227-
} else {
228-
context.logger.warn(`WARNING [${file.path}]: ${diagnostics.message}`);
229-
}
230-
}
231-
232-
if (loadResult.locale !== undefined && loadResult.locale !== locale) {
233-
context.logger.warn(
234-
`WARNING [${file.path}]: File target locale ('${loadResult.locale}') does not match configured locale ('${locale}')`,
235-
);
236-
}
237-
238-
usedFormats.add(loadResult.format);
239-
if (usedFormats.size > 1 && tsConfig.options.enableI18nLegacyMessageIdFormat !== false) {
240-
// This limitation is only for legacy message id support (defaults to true as of 9.0)
241-
throw new Error(
242-
'Localization currently only supports using one type of translation file format for the entire application.',
243-
);
244-
}
245-
246-
file.format = loadResult.format;
247-
file.integrity = loadResult.integrity;
248-
249-
if (desc.translation) {
250-
// Merge translations
251-
for (const [id, message] of Object.entries(loadResult.translations)) {
252-
if (desc.translation[id] !== undefined) {
253-
context.logger.warn(
254-
`WARNING [${file.path}]: Duplicate translations for message '${id}' when merging`,
255-
);
256-
}
257-
desc.translation[id] = message;
258-
}
259-
} else {
260-
// First or only translation file
261-
desc.translation = loadResult.translations;
262-
}
224+
loadTranslations(
225+
locale,
226+
desc,
227+
context.workspaceRoot,
228+
loader,
229+
{
230+
warn(message) {
231+
context.logger.warn(message);
232+
},
233+
error(message) {
234+
throw new Error(message);
235+
},
236+
},
237+
usedFormats,
238+
);
239+
240+
if (usedFormats.size > 1 && tsConfig.options.enableI18nLegacyMessageIdFormat !== false) {
241+
// This limitation is only for legacy message id support (defaults to true as of 9.0)
242+
throw new Error(
243+
'Localization currently only supports using one type of translation file format for the entire application.',
244+
);
263245
}
264246
}
265247

@@ -294,3 +276,49 @@ function findLocaleDataPath(locale: string, resolver: (locale: string) => string
294276
return null;
295277
}
296278
}
279+
280+
export function loadTranslations(
281+
locale: string,
282+
desc: LocaleDescription,
283+
workspaceRoot: string,
284+
loader: TranslationLoader,
285+
logger: { warn: (message: string) => void; error: (message: string) => void },
286+
usedFormats?: Set<string>,
287+
) {
288+
for (const file of desc.files) {
289+
const loadResult = loader(path.join(workspaceRoot, file.path));
290+
291+
for (const diagnostics of loadResult.diagnostics.messages) {
292+
if (diagnostics.type === 'error') {
293+
logger.error(`Error parsing translation file '${file.path}': ${diagnostics.message}`);
294+
} else {
295+
logger.warn(`WARNING [${file.path}]: ${diagnostics.message}`);
296+
}
297+
}
298+
299+
if (loadResult.locale !== undefined && loadResult.locale !== locale) {
300+
logger.warn(
301+
`WARNING [${file.path}]: File target locale ('${loadResult.locale}') does not match configured locale ('${locale}')`,
302+
);
303+
}
304+
305+
usedFormats?.add(loadResult.format);
306+
file.format = loadResult.format;
307+
file.integrity = loadResult.integrity;
308+
309+
if (desc.translation) {
310+
// Merge translations
311+
for (const [id, message] of Object.entries(loadResult.translations)) {
312+
if (desc.translation[id] !== undefined) {
313+
logger.warn(
314+
`WARNING [${file.path}]: Duplicate translations for message '${id}' when merging`,
315+
);
316+
}
317+
desc.translation[id] = message;
318+
}
319+
} else {
320+
// First or only translation file
321+
desc.translation = loadResult.translations;
322+
}
323+
}
324+
}

0 commit comments

Comments
 (0)