Skip to content

Commit f5f1f7b

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 598190e commit f5f1f7b

File tree

2 files changed

+83
-60
lines changed

2 files changed

+83
-60
lines changed

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

Lines changed: 52 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@
77
*/
88
import { custom } from 'babel-loader';
99
import { ScriptTarget } from 'typescript';
10+
import { ApplicationPresetOptions } from './presets/application';
1011

1112
interface AngularCustomOptions {
1213
forceAsyncTransformation: boolean;
1314
forceES5: boolean;
1415
shouldLink: boolean;
16+
i18n: ApplicationPresetOptions['i18n'];
1517
}
1618

1719
/**
@@ -65,56 +67,82 @@ export default custom<AngularCustomOptions>(() => {
6567
});
6668

6769
return {
68-
async customOptions({ scriptTarget, ...loaderOptions }, { source }) {
70+
async customOptions({ i18n, scriptTarget, ...rawOptions }, { source }) {
6971
// Must process file if plugins are added
70-
let shouldProcess = Array.isArray(loaderOptions.plugins) && loaderOptions.plugins.length > 0;
72+
let shouldProcess = Array.isArray(rawOptions.plugins) && rawOptions.plugins.length > 0;
73+
74+
const customOptions: AngularCustomOptions = {
75+
forceAsyncTransformation: false,
76+
forceES5: false,
77+
shouldLink: false,
78+
i18n: undefined,
79+
};
7180

7281
// Analyze file for linking
73-
let shouldLink = false;
7482
const { hasLinkerSupport, requiresLinking } = await checkLinking(this.resourcePath, source);
7583
if (requiresLinking && !hasLinkerSupport) {
7684
// Cannot link if there is no linker support
7785
this.emitError(
7886
'File requires the Angular linker. "@angular/compiler-cli" version 11.1.0 or greater is needed.',
7987
);
8088
} else {
81-
shouldLink = requiresLinking;
89+
customOptions.shouldLink = requiresLinking;
8290
}
83-
shouldProcess ||= shouldLink;
91+
shouldProcess ||= customOptions.shouldLink;
8492

8593
// Analyze for ES target processing
86-
let forceES5 = false;
87-
let forceAsyncTransformation = false;
88-
const esTarget = scriptTarget as ScriptTarget;
89-
if (esTarget < ScriptTarget.ES2015) {
90-
// TypeScript files will have already been downlevelled
91-
forceES5 = !/\.tsx?$/.test(this.resourcePath);
92-
} else if (esTarget >= ScriptTarget.ES2017) {
93-
forceAsyncTransformation = source.includes('async');
94+
const esTarget = scriptTarget as ScriptTarget | undefined;
95+
if (esTarget !== undefined) {
96+
if (esTarget < ScriptTarget.ES2015) {
97+
// TypeScript files will have already been downlevelled
98+
customOptions.forceES5 = !/\.tsx?$/.test(this.resourcePath);
99+
} else if (esTarget >= ScriptTarget.ES2017) {
100+
customOptions.forceAsyncTransformation = source.includes('async');
101+
}
102+
shouldProcess ||= customOptions.forceAsyncTransformation || customOptions.forceES5;
103+
}
104+
105+
// Analyze for i18n inlining
106+
if (
107+
i18n &&
108+
!/[\\\/]@angular[\\\/](?:compiler|localize)/.test(this.resourcePath) &&
109+
source.includes('$localize')
110+
) {
111+
const { translationFiles, ...i18nOptions } = i18n as ApplicationPresetOptions['i18n'] & {
112+
translationFiles?: string[];
113+
};
114+
customOptions.i18n = i18nOptions;
115+
116+
// Add translation files as dependencies of the file to support rebuilds
117+
// Except for `@angular/core` which needs locale injection but has no translations
118+
if (translationFiles && !/[\\\/]@angular[\\\/]core/.test(this.resourcePath)) {
119+
for (const file of translationFiles) {
120+
this.addDependency(file);
121+
}
122+
}
123+
124+
shouldProcess = true;
94125
}
95-
shouldProcess ||= forceAsyncTransformation || forceES5;
96126

97127
// Add provided loader options to default base options
98-
const options: Record<string, unknown> = {
128+
const loaderOptions: Record<string, unknown> = {
99129
...baseOptions,
100-
...loaderOptions,
130+
...rawOptions,
101131
cacheIdentifier: JSON.stringify({
102132
buildAngular: require('../../package.json').version,
103-
forceAsyncTransformation,
104-
forceES5,
105-
shouldLink,
133+
customOptions,
106134
baseOptions,
107-
loaderOptions,
135+
rawOptions,
108136
}),
109137
};
110138

111139
// Skip babel processing if no actions are needed
112140
if (!shouldProcess) {
113141
// Force the current file to be ignored
114-
options.ignore = [() => true];
142+
loaderOptions.ignore = [() => true];
115143
}
116144

117-
return { custom: { forceAsyncTransformation, forceES5, shouldLink }, loader: options };
145+
return { custom: customOptions, loader: loaderOptions };
118146
},
119147
config(configuration, { customOptions }) {
120148
return {
@@ -127,6 +155,7 @@ export default custom<AngularCustomOptions>(() => {
127155
angularLinker: customOptions.shouldLink,
128156
forceES5: customOptions.forceES5,
129157
forceAsyncTransformation: customOptions.forceAsyncTransformation,
158+
i18n: customOptions.i18n,
130159
diagnosticReporter: (type, message) => {
131160
switch (type) {
132161
case 'error':
@@ -139,7 +168,7 @@ export default custom<AngularCustomOptions>(() => {
139168
break;
140169
}
141170
},
142-
} as import('./presets/application').ApplicationPresetOptions,
171+
} as ApplicationPresetOptions,
143172
],
144173
],
145174
};

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

Lines changed: 31 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,7 @@ export function serveWebpackBrowser(
242242
);
243243
}
244244

245-
await setupLocalize(locale, i18n, browserOptions, webpackConfig);
245+
await setupLocalize(locale, i18n, browserOptions, webpackConfig, context);
246246
}
247247
}
248248

@@ -352,9 +352,9 @@ async function setupLocalize(
352352
i18n: I18nOptions,
353353
browserOptions: BrowserBuilderSchema,
354354
webpackConfig: webpack.Configuration,
355+
context: BuilderContext,
355356
) {
356357
const localeDescription = i18n.locales[locale];
357-
const i18nDiagnostics: { type: string, message: string }[] = [];
358358

359359
// Modify main entrypoint to include locale data
360360
if (
@@ -372,43 +372,37 @@ async function setupLocalize(
372372

373373
let missingTranslationBehavior = browserOptions.i18nMissingTranslation || 'ignore';
374374
let translation = localeDescription?.translation || {};
375+
const sourceLocale = i18n.sourceLocale;
376+
const shouldInline = i18n.shouldInline;
375377

376-
if (locale === i18n.sourceLocale) {
378+
if (locale === sourceLocale) {
377379
missingTranslationBehavior = 'ignore';
378380
translation = {};
379381
}
380382

383+
const i18nLoaderOptions = {
384+
locale,
385+
missingTranslationBehavior,
386+
translation: shouldInline ? translation : undefined,
387+
translationFiles: localeDescription?.files.map((file) =>
388+
path.resolve(context.workspaceRoot, file.path),
389+
),
390+
};
391+
381392
const i18nRule: webpack.RuleSetRule = {
382393
test: /\.(?:m?js|ts)$/,
383394
enforce: 'post',
384395
use: [
385396
{
386-
loader: require.resolve('babel-loader'),
397+
loader: require.resolve('../babel/webpack-loader'),
387398
options: {
388-
babelrc: false,
389-
configFile: false,
390-
compact: false,
391-
cacheCompression: false,
392-
cacheDirectory: findCachePath('babel-loader'),
393-
cacheIdentifier: JSON.stringify({
394-
buildAngular: require('../../package.json').version,
395-
locale,
396-
translationIntegrity: localeDescription?.files.map((file) => file.integrity),
397-
}),
398-
sourceType: 'unambiguous',
399-
presets: [
400-
[
401-
require.resolve('../babel/presets/application'),
402-
{
403-
i18n: {
404-
locale,
405-
translation: i18n.shouldInline ? translation : undefined,
406-
missingTranslationBehavior,
407-
},
408-
diagnosticReporter: (type, message) => i18nDiagnostics.push({ type, message }),
409-
} as import('../babel/presets/application').ApplicationPresetOptions,
410-
],
411-
],
399+
// cacheDirectory: findCachePath('babel-dev-server-i18n'),
400+
// cacheIdentifier: JSON.stringify({
401+
// buildAngular: require('../../package.json').version,
402+
// locale,
403+
// translationIntegrity: localeDescription?.files.map((file) => file.integrity),
404+
// }),
405+
i18n: i18nLoaderOptions,
412406
},
413407
},
414408
],
@@ -424,20 +418,20 @@ async function setupLocalize(
424418

425419
rules.push(i18nRule);
426420

427-
// Add a plugin to inject the i18n diagnostics
421+
// Add a plugin to reload translation files on rebuilds
428422
// tslint:disable-next-line: no-non-null-assertion
429423
webpackConfig.plugins!.push({
430424
apply: (compiler: webpack.Compiler) => {
431425
compiler.hooks.thisCompilation.tap('build-angular', compilation => {
426+
if (shouldInline && i18nLoaderOptions.translation === undefined) {
427+
// Reload translation
428+
// NOTE: This could be further optimized by checking if any translation file actually changed
429+
430+
}
431+
432432
compilation.hooks.finishModules.tap('build-angular', () => {
433-
for (const diagnostic of i18nDiagnostics) {
434-
if (diagnostic.type === 'error') {
435-
addError(compilation, diagnostic.message);
436-
} else {
437-
addWarning(compilation, diagnostic.message);
438-
}
439-
}
440-
i18nDiagnostics.length = 0;
433+
// After loaders are finished, clear out the now unneeded translations
434+
i18nLoaderOptions.translation = undefined;
441435
});
442436
});
443437
},

0 commit comments

Comments
 (0)