Skip to content

Commit 9300545

Browse files
clydindgp1130
authored andcommitted
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. Closes #16341
1 parent 2f7d13a commit 9300545

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+
describe('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)