From 9300545e6148b4548cc02bb6a311a2f0e2bb79c5 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Wed, 27 Jan 2021 13:03:27 -0500 Subject: [PATCH] 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 --- .../src/babel/presets/application.ts | 1 + .../build_angular/src/babel/webpack-loader.ts | 13 ++ .../src/builders/dev-server/index.ts | 37 ++++- .../behavior/build_translation_watch_spec.ts | 109 +++++++++++++++ .../build_angular/src/utils/i18n-options.ts | 132 +++++++++++------- 5 files changed, 238 insertions(+), 54 deletions(-) create mode 100644 packages/angular_devkit/build_angular/src/builders/dev-server/tests/behavior/build_translation_watch_spec.ts diff --git a/packages/angular_devkit/build_angular/src/babel/presets/application.ts b/packages/angular_devkit/build_angular/src/babel/presets/application.ts index 8b849f07d6c4..c5ed477949f2 100644 --- a/packages/angular_devkit/build_angular/src/babel/presets/application.ts +++ b/packages/angular_devkit/build_angular/src/babel/presets/application.ts @@ -36,6 +36,7 @@ export interface ApplicationPresetOptions { locale: string; missingTranslationBehavior?: 'error' | 'warning' | 'ignore'; translation?: unknown; + translationFiles?: string[]; pluginCreators?: I18nPluginCreators; }; diff --git a/packages/angular_devkit/build_angular/src/babel/webpack-loader.ts b/packages/angular_devkit/build_angular/src/babel/webpack-loader.ts index d74a9e753643..2e44db09c02d 100644 --- a/packages/angular_devkit/build_angular/src/babel/webpack-loader.ts +++ b/packages/angular_devkit/build_angular/src/babel/webpack-loader.ts @@ -62,6 +62,7 @@ async function requiresLinking(path: string, source: string): Promise { return needsLinking(path, source); } +// eslint-disable-next-line max-lines-per-function export default custom(() => { const baseOptions = Object.freeze({ babelrc: false, @@ -149,6 +150,18 @@ export default custom(() => { ...(i18n as NonNullable), pluginCreators: i18nPluginCreators, }; + + // Add translation files as dependencies of the file to support rebuilds + // Except for `@angular/core` which needs locale injection but has no translations + if ( + customOptions.i18n.translationFiles && + !/[\\/]@angular[\\/]core/.test(this.resourcePath) + ) { + for (const file of customOptions.i18n.translationFiles) { + this.addDependency(file); + } + } + shouldProcess = true; } diff --git a/packages/angular_devkit/build_angular/src/builders/dev-server/index.ts b/packages/angular_devkit/build_angular/src/builders/dev-server/index.ts index af470f77a406..208c095bac90 100644 --- a/packages/angular_devkit/build_angular/src/builders/dev-server/index.ts +++ b/packages/angular_devkit/build_angular/src/builders/dev-server/index.ts @@ -23,8 +23,9 @@ import { ExecutionTransformer } from '../../transforms'; import { normalizeOptimization } from '../../utils'; import { checkPort } from '../../utils/check-port'; import { colors } from '../../utils/color'; -import { I18nOptions } from '../../utils/i18n-options'; +import { I18nOptions, loadTranslations } from '../../utils/i18n-options'; import { IndexHtmlTransform } from '../../utils/index-file/index-html-generator'; +import { createTranslationLoader } from '../../utils/load-translations'; import { NormalizedCachedOptions, normalizeCacheOptions } from '../../utils/normalize-cache'; import { generateEntryPoints } from '../../utils/package-chunk-sort'; import { assertCompatibleAngularVersion } from '../../utils/version'; @@ -33,6 +34,7 @@ import { getIndexInputFile, getIndexOutputFile, } from '../../utils/webpack-browser-config'; +import { addError, addWarning } from '../../utils/webpack-diagnostics'; import { getAnalyticsConfig, getCommonConfig, @@ -192,7 +194,7 @@ export function serveWebpackBrowser( ); } - await setupLocalize(locale, i18n, browserOptions, webpackConfig, cacheOptions); + await setupLocalize(locale, i18n, browserOptions, webpackConfig, cacheOptions, context); } if (transforms.webpackConfiguration) { @@ -288,6 +290,7 @@ async function setupLocalize( browserOptions: BrowserBuilderSchema, webpackConfig: webpack.Configuration, cacheOptions: NormalizedCachedOptions, + context: BuilderContext, ) { const localeDescription = i18n.locales[locale]; @@ -320,6 +323,9 @@ async function setupLocalize( locale, missingTranslationBehavior, translation: i18n.shouldInline ? translation : undefined, + translationFiles: localeDescription?.files.map((file) => + path.resolve(context.workspaceRoot, file.path), + ), }; const i18nRule: webpack.RuleSetRule = { @@ -351,6 +357,33 @@ async function setupLocalize( } rules.push(i18nRule); + + // Add a plugin to reload translation files on rebuilds + const loader = await createTranslationLoader(); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + webpackConfig.plugins!.push({ + apply: (compiler: webpack.Compiler) => { + compiler.hooks.thisCompilation.tap('build-angular', (compilation) => { + if (i18n.shouldInline && i18nLoaderOptions.translation === undefined) { + // Reload translations + loadTranslations(locale, localeDescription, context.workspaceRoot, loader, { + warn(message) { + addWarning(compilation, message); + }, + error(message) { + addError(compilation, message); + }, + }); + i18nLoaderOptions.translation = localeDescription.translation; + } + + compilation.hooks.finishModules.tap('build-angular', () => { + // After loaders are finished, clear out the now unneeded translations + i18nLoaderOptions.translation = undefined; + }); + }); + }, + }); } export default createBuilder(serveWebpackBrowser); diff --git a/packages/angular_devkit/build_angular/src/builders/dev-server/tests/behavior/build_translation_watch_spec.ts b/packages/angular_devkit/build_angular/src/builders/dev-server/tests/behavior/build_translation_watch_spec.ts new file mode 100644 index 000000000000..aea5f2ff5889 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/dev-server/tests/behavior/build_translation_watch_spec.ts @@ -0,0 +1,109 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +/* eslint-disable max-len */ +import fetch from 'node-fetch'; // eslint-disable-line import/no-extraneous-dependencies +import { concatMap, count, take, timeout } from 'rxjs/operators'; +import { URL } from 'url'; +import { serveWebpackBrowser } from '../../index'; +import { + BASE_OPTIONS, + BUILD_TIMEOUT, + DEV_SERVER_BUILDER_INFO, + describeBuilder, + setupBrowserTarget, +} from '../setup'; + +describeBuilder(serveWebpackBrowser, DEV_SERVER_BUILDER_INFO, (harness) => { + describe('Behavior: "i18n translation file watching"', () => { + beforeEach(() => { + harness.useProject('test', { + root: '.', + sourceRoot: 'src', + cli: { + cache: { + enabled: false, + }, + }, + i18n: { + locales: { + 'fr': 'src/locales/messages.fr.xlf', + }, + }, + }); + + setupBrowserTarget(harness, { localize: ['fr'] }); + }); + + it('watches i18n translation files by default', async () => { + harness.useTarget('serve', { + ...BASE_OPTIONS, + }); + + await harness.writeFile( + 'src/app/app.component.html', + ` +

Hello {{ title }}!

+ `, + ); + + await harness.writeFile('src/locales/messages.fr.xlf', TRANSLATION_FILE_CONTENT); + + const buildCount = await harness + .execute() + .pipe( + timeout(BUILD_TIMEOUT * 2), + concatMap(async ({ result }, index) => { + expect(result?.success).toBe(true); + + const mainUrl = new URL('main.js', `${result?.baseUrl}`); + + switch (index) { + case 0: { + const response = await fetch(mainUrl); + expect(await response?.text()).toContain('Bonjour'); + + await harness.modifyFile('src/locales/messages.fr.xlf', (content) => + content.replace('Bonjour', 'Salut'), + ); + break; + } + case 1: { + const response = await fetch(mainUrl); + expect(await response?.text()).toContain('Salut'); + break; + } + } + }), + take(2), + count(), + ) + .toPromise(); + + expect(buildCount).toBe(2); + }); + }); +}); + +const TRANSLATION_FILE_CONTENT = ` + + + + + + Bonjour ! + + src/app/app.component.html + 2,3 + + An introduction header for this sample + + + + +`; diff --git a/packages/angular_devkit/build_angular/src/utils/i18n-options.ts b/packages/angular_devkit/build_angular/src/utils/i18n-options.ts index 0106b7064260..9d57aceb44b8 100644 --- a/packages/angular_devkit/build_angular/src/utils/i18n-options.ts +++ b/packages/angular_devkit/build_angular/src/utils/i18n-options.ts @@ -15,25 +15,28 @@ import path from 'path'; import { Schema as BrowserBuilderSchema } from '../builders/browser/schema'; import { Schema as ServerBuilderSchema } from '../builders/server/schema'; import { readTsconfig } from '../utils/read-tsconfig'; -import { createTranslationLoader } from './load-translations'; +import { TranslationLoader, createTranslationLoader } from './load-translations'; /** * The base module location used to search for locale specific data. */ const LOCALE_DATA_BASE_MODULE = '@angular/common/locales/global'; +export interface LocaleDescription { + files: { + path: string; + integrity?: string; + format?: string; + }[]; + translation?: Record; + dataPath?: string; + baseHref?: string; +} + export interface I18nOptions { inlineLocales: Set; sourceLocale: string; - locales: Record< - string, - { - files: { path: string; integrity?: string; format?: string }[]; - translation?: Record; - dataPath?: string; - baseHref?: string; - } - >; + locales: Record; flatOutput?: boolean; readonly shouldInline: boolean; hasDefinedSourceLocale?: boolean; @@ -218,48 +221,27 @@ export async function configureI18nBuild 1 && tsConfig.options.enableI18nLegacyMessageIdFormat !== false) { - // This limitation is only for legacy message id support (defaults to true as of 9.0) - throw new Error( - 'Localization currently only supports using one type of translation file format for the entire application.', - ); - } - - file.format = loadResult.format; - file.integrity = loadResult.integrity; - - if (desc.translation) { - // Merge translations - for (const [id, message] of Object.entries(loadResult.translations)) { - if (desc.translation[id] !== undefined) { - context.logger.warn( - `WARNING [${file.path}]: Duplicate translations for message '${id}' when merging`, - ); - } - desc.translation[id] = message; - } - } else { - // First or only translation file - desc.translation = loadResult.translations; - } + loadTranslations( + locale, + desc, + context.workspaceRoot, + loader, + { + warn(message) { + context.logger.warn(message); + }, + error(message) { + throw new Error(message); + }, + }, + usedFormats, + ); + + if (usedFormats.size > 1 && tsConfig.options.enableI18nLegacyMessageIdFormat !== false) { + // This limitation is only for legacy message id support (defaults to true as of 9.0) + throw new Error( + 'Localization currently only supports using one type of translation file format for the entire application.', + ); } } @@ -294,3 +276,49 @@ function findLocaleDataPath(locale: string, resolver: (locale: string) => string return null; } } + +export function loadTranslations( + locale: string, + desc: LocaleDescription, + workspaceRoot: string, + loader: TranslationLoader, + logger: { warn: (message: string) => void; error: (message: string) => void }, + usedFormats?: Set, +) { + for (const file of desc.files) { + const loadResult = loader(path.join(workspaceRoot, file.path)); + + for (const diagnostics of loadResult.diagnostics.messages) { + if (diagnostics.type === 'error') { + logger.error(`Error parsing translation file '${file.path}': ${diagnostics.message}`); + } else { + logger.warn(`WARNING [${file.path}]: ${diagnostics.message}`); + } + } + + if (loadResult.locale !== undefined && loadResult.locale !== locale) { + logger.warn( + `WARNING [${file.path}]: File target locale ('${loadResult.locale}') does not match configured locale ('${locale}')`, + ); + } + + usedFormats?.add(loadResult.format); + file.format = loadResult.format; + file.integrity = loadResult.integrity; + + if (desc.translation) { + // Merge translations + for (const [id, message] of Object.entries(loadResult.translations)) { + if (desc.translation[id] !== undefined) { + logger.warn( + `WARNING [${file.path}]: Duplicate translations for message '${id}' when merging`, + ); + } + desc.translation[id] = message; + } + } else { + // First or only translation file + desc.translation = loadResult.translations; + } + } +}