Skip to content

Commit 862b28f

Browse files
clydinmgechev
authored andcommitted
fix(@angular-devkit/build-angular): allow localization with development server (#16053)
* fix(@angular-devkit/build-angular): allow localization with development server * test: ensure i18n application E2E tests are executed
1 parent b8257e2 commit 862b28f

File tree

11 files changed

+176
-46
lines changed

11 files changed

+176
-46
lines changed

packages/angular_devkit/build_angular/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"@ngtools/webpack": "0.0.0",
1818
"ajv": "6.10.2",
1919
"autoprefixer": "9.7.1",
20+
"babel-loader": "8.0.6",
2021
"browserslist": "4.7.2",
2122
"cacache": "13.0.1",
2223
"caniuse-lite": "1.0.30001008",

packages/angular_devkit/build_angular/src/browser/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -355,7 +355,7 @@ export function buildWebpackBrowser(
355355
}
356356
seen.add(file.file);
357357

358-
if (file.name === 'main') {
358+
if (file.name === 'vendor' || (!mainChunkId && file.name === 'main')) {
359359
// tslint:disable-next-line: no-non-null-assertion
360360
mainChunkId = file.id!.toString();
361361
}

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

Lines changed: 115 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,7 @@ import {
1212
WebpackLoggingCallback,
1313
runWebpackDevServer,
1414
} from '@angular-devkit/build-webpack';
15-
import {
16-
json,
17-
logging,
18-
tags,
19-
} from '@angular-devkit/core';
15+
import { json, logging, tags } from '@angular-devkit/core';
2016
import { NodeJsSyncHost } from '@angular-devkit/core/node';
2117
import { existsSync, readFileSync } from 'fs';
2218
import * as path from 'path';
@@ -58,6 +54,46 @@ const devServerBuildOverriddenKeys: (keyof DevServerBuilderOptions)[] = [
5854
'deployUrl',
5955
];
6056

57+
async function createI18nPlugins(
58+
locale: string,
59+
translation: unknown | undefined,
60+
missingTranslation?: 'error' | 'warning' | 'ignore',
61+
) {
62+
const plugins = [];
63+
// tslint:disable-next-line: no-implicit-dependencies
64+
const localizeDiag = await import('@angular/localize/src/tools/src/diagnostics');
65+
66+
const diagnostics = new localizeDiag.Diagnostics();
67+
68+
if (translation) {
69+
const es2015 = await import(
70+
// tslint:disable-next-line: trailing-comma no-implicit-dependencies
71+
'@angular/localize/src/tools/src/translate/source_files/es2015_translate_plugin'
72+
);
73+
plugins.push(
74+
// tslint:disable-next-line: no-any
75+
es2015.makeEs2015TranslatePlugin(diagnostics, translation as any, { missingTranslation }),
76+
);
77+
78+
const es5 = await import(
79+
// tslint:disable-next-line: trailing-comma no-implicit-dependencies
80+
'@angular/localize/src/tools/src/translate/source_files/es5_translate_plugin'
81+
);
82+
plugins.push(
83+
// tslint:disable-next-line: no-any
84+
es5.makeEs5TranslatePlugin(diagnostics, translation as any, { missingTranslation }),
85+
);
86+
}
87+
88+
const inlineLocale = await import(
89+
// tslint:disable-next-line: trailing-comma no-implicit-dependencies
90+
'@angular/localize/src/tools/src/translate/source_files/locale_plugin'
91+
);
92+
plugins.push(inlineLocale.makeLocalePlugin(locale));
93+
94+
return { diagnostics, plugins };
95+
}
96+
6197
export type DevServerBuilderOutput = DevServerBuildOutput & {
6298
baseUrl: string;
6399
};
@@ -69,6 +105,7 @@ export type DevServerBuilderOutput = DevServerBuildOutput & {
69105
* @param transforms A map of transforms that can be used to hook into some logic (such as
70106
* transforming webpack configuration before passing it to webpack).
71107
*/
108+
// tslint:disable-next-line: no-big-function
72109
export function serveWebpackBrowser(
73110
options: DevServerBuilderOptions,
74111
context: BuilderContext,
@@ -119,14 +156,83 @@ export function serveWebpackBrowser(
119156
browserName,
120157
);
121158

122-
const webpackConfigResult = await buildBrowserWebpackConfigFromContext(
159+
const { config, projectRoot, i18n } = await buildBrowserWebpackConfigFromContext(
123160
browserOptions,
124161
context,
125162
host,
163+
true,
126164
);
165+
let webpackConfig = config;
166+
167+
const tsConfig = readTsconfig(browserOptions.tsConfig, context.workspaceRoot);
168+
if (i18n.shouldInline && tsConfig.options.enableIvy !== false) {
169+
if (i18n.inlineLocales.size > 1) {
170+
throw new Error(
171+
'The development server only supports localizing a single locale per build',
172+
);
173+
}
174+
175+
const locale = [...i18n.inlineLocales][0];
176+
const translation = i18n.locales[locale] && i18n.locales[locale].translation;
177+
178+
const { plugins, diagnostics } = await createI18nPlugins(
179+
locale,
180+
translation,
181+
browserOptions.i18nMissingTranslation,
182+
);
127183

128-
// No differential loading for dev-server, hence there is just one config
129-
let webpackConfig = webpackConfigResult.config;
184+
// Get the insertion point for the i18n babel loader rule
185+
// This is currently dependent on the rule order/construction in common.ts
186+
// A future refactor of the webpack configuration definition will improve this situation
187+
// tslint:disable-next-line: no-non-null-assertion
188+
const rules = webpackConfig.module!.rules;
189+
const index = rules.findIndex(r => r.enforce === 'pre');
190+
if (index === -1) {
191+
throw new Error('Invalid internal webpack configuration');
192+
}
193+
194+
const i18nRule: webpack.Rule = {
195+
test: /\.(?:m?js|ts)$/,
196+
enforce: 'post',
197+
use: [
198+
{
199+
loader: 'babel-loader',
200+
options: {
201+
babelrc: false,
202+
compact: false,
203+
cacheCompression: false,
204+
plugins,
205+
},
206+
},
207+
],
208+
};
209+
210+
rules.splice(index, 0, i18nRule);
211+
212+
// Add a plugin to inject the i18n diagnostics
213+
// tslint:disable-next-line: no-non-null-assertion
214+
webpackConfig.plugins!.push({
215+
// tslint:disable-next-line:no-any
216+
apply: (compiler: webpack.Compiler) => {
217+
compiler.hooks.thisCompilation.tap('build-angular', compilation => {
218+
compilation.hooks.finishModules.tap('build-angular', () => {
219+
if (!diagnostics) {
220+
return;
221+
}
222+
for (const diagnostic of diagnostics.messages) {
223+
if (diagnostic.type === 'error') {
224+
compilation.errors.push(diagnostic.message);
225+
} else {
226+
compilation.warnings.push(diagnostic.message);
227+
}
228+
}
229+
230+
diagnostics.messages.length = 0;
231+
});
232+
});
233+
},
234+
});
235+
}
130236

131237
const port = await checkPort(options.port || 0, options.host || 'localhost', 4200);
132238
const webpackDevServerConfig = (webpackConfig.devServer = buildServerConfig(
@@ -145,7 +251,7 @@ export function serveWebpackBrowser(
145251
webpackConfig,
146252
webpackDevServerConfig,
147253
port,
148-
projectRoot: webpackConfigResult.projectRoot,
254+
projectRoot,
149255
};
150256
}
151257

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ function emittedFilesToInlineOptions(
4040
es5,
4141
outputPath,
4242
missingTranslation,
43-
setLocale: emittedFile.name === 'main',
43+
setLocale: emittedFile.name === 'main' || emittedFile.name === 'vendor',
4444
};
4545
originalFiles.push(originalPath);
4646

tests/legacy-cli/e2e/tests/i18n/ivy-localize-dl.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
import { appendToFile, expectFileToMatch } from '../../utils/fs';
2-
import { ng } from '../../utils/process';
2+
import { execAndWaitForOutputToMatch, killAllProcesses, ng } from '../../utils/process';
33
import { updateJsonFile } from '../../utils/project';
44
import { expectToFail } from '../../utils/utils';
55
import { baseDir, externalServer, langTranslations, setupI18nConfig } from './legacy';
66

7-
8-
export default async function () {
7+
export default async function() {
98
// Setup i18n tests and config.
109
await setupI18nConfig();
1110

@@ -37,9 +36,12 @@ export default async function () {
3736
// await expectFileToMatch(`${outputPath}/main-es5.js`, '.ng.common.locales');
3837
// await expectFileToMatch(`${outputPath}/main-es2015.js`, '.ng.common.locales');
3938

39+
// Execute Application E2E tests with dev server
40+
await ng('e2e', `--configuration=${lang}`, '--port=0');
41+
42+
// Execute Application E2E tests for a production build without dev server
4043
const server = externalServer(outputPath);
4144
try {
42-
// Execute without a devserver.
4345
await ng('e2e', `--configuration=${lang}`, '--devServerTarget=');
4446
} finally {
4547
server.close();
@@ -57,4 +59,9 @@ export default async function () {
5759
await expectFileToMatch(`${baseDir}/fr/main-es5.js`, /Other content/);
5860
await expectFileToMatch(`${baseDir}/fr/main-es2015.js`, /Other content/);
5961
await expectToFail(() => ng('build'));
62+
try {
63+
await execAndWaitForOutputToMatch('ng', ['serve', '--port=0'], /No translation found for/);
64+
} finally {
65+
killAllProcesses();
66+
}
6067
}

tests/legacy-cli/e2e/tests/i18n/ivy-localize-es2015.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,12 @@ export default async function() {
2626
await expectFileNotToExist(`${outputPath}/main-es5.js`);
2727
await expectFileToMatch(`${outputPath}/main.js`, lang);
2828

29+
// Execute Application E2E tests with dev server
30+
await ng('e2e', `--configuration=${lang}`, '--port=0');
31+
32+
// Execute Application E2E tests for a production build without dev server
2933
const server = externalServer(outputPath);
3034
try {
31-
// Execute without a devserver.
3235
await ng('e2e', `--configuration=${lang}`, '--devServerTarget=');
3336
} finally {
3437
server.close();

tests/legacy-cli/e2e/tests/i18n/ivy-localize-es5.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,12 @@ export default async function() {
2525
await expectFileNotToExist(`${outputPath}/main-es2015.js`);
2626
await expectFileToMatch(`${outputPath}/main.js`, lang);
2727

28+
// Execute Application E2E tests with dev server
29+
await ng('e2e', `--configuration=${lang}`, '--port=0');
30+
31+
// Execute Application E2E tests for a production build without dev server
2832
const server = externalServer(outputPath);
2933
try {
30-
// Execute without a devserver.
3134
await ng('e2e', `--configuration=${lang}`, '--devServerTarget=');
3235
} finally {
3336
server.close();

tests/legacy-cli/e2e/tests/i18n/ivy-localize-server.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ import { langTranslations, setupI18nConfig } from './legacy';
1010
const snapshots = require('../../ng-snapshot/package.json');
1111

1212
export default async function () {
13+
// TODO: Re-enable pending further Ivy/Universal/i18n work
14+
return;
15+
1316
// Setup i18n tests and config.
1417
await setupI18nConfig();
1518

tests/legacy-cli/e2e/tests/i18n/legacy.ts

Lines changed: 25 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export const langTranslations = [
2525
translation: {
2626
helloPartial: 'Bonjour',
2727
hello: 'Bonjour i18n!',
28-
plural: 'Mis à jour Il y a 3 minutes',
28+
plural: 'Mis à jour il y a 3 minutes',
2929
date: 'janvier',
3030
},
3131
translationReplacements: [
@@ -34,7 +34,7 @@ export const langTranslations = [
3434
['Updated', 'Mis à jour'],
3535
['just now', 'juste maintenant'],
3636
['one minute ago', 'il y a une minute'],
37-
['other {', 'other {Il y a'],
37+
[/other {/g, 'other {il y a '],
3838
['minutes ago', 'minutes'],
3939
],
4040
},
@@ -52,7 +52,7 @@ export const langTranslations = [
5252
['Updated', 'Aktualisiert'],
5353
['just now', 'gerade jetzt'],
5454
['one minute ago', 'vor einer Minute'],
55-
['other {', 'other {vor'],
55+
[/other {/g, 'other {vor '],
5656
['minutes ago', 'Minuten'],
5757
],
5858
},
@@ -91,12 +91,12 @@ export async function setupI18nConfig(useLocalize = true) {
9191

9292
// Add e2e specs for each lang.
9393
for (const { lang, translation } of langTranslations) {
94-
await writeFile(`./src/app.${lang}.e2e-spec.ts`, `
94+
await writeFile(`./e2e/src/app.${lang}.e2e-spec.ts`, `
9595
import { browser, logging, element, by } from 'protractor';
9696
9797
describe('workspace-project App', () => {
9898
const getParagraph = (name: string) => element(by.css('app-root p#' + name)).getText();
99-
beforeEach(() => browser.get(browser.baseUrl););
99+
beforeEach(() => browser.get(browser.baseUrl));
100100
afterEach(async () => {
101101
// Assert that there are no errors emitted from the browser
102102
const logs = await browser.manage().logs().get(logging.Type.BROWSER);
@@ -112,7 +112,7 @@ export async function setupI18nConfig(useLocalize = true) {
112112
expect(getParagraph('locale')).toEqual('${lang}'));
113113
114114
it('should display localized date', () =>
115-
expect(getParagraph('date')).toEqual('${translation.plural}'));
115+
expect(getParagraph('date')).toEqual('${translation.date}'));
116116
117117
it('should display pluralized message', () =>
118118
expect(getParagraph('plural')).toEqual('${translation.plural}'));
@@ -190,13 +190,13 @@ export async function setupI18nConfig(useLocalize = true) {
190190
if (lang != sourceLocale) {
191191
await copyFile('src/locale/messages.xlf', `src/locale/messages.${lang}.xlf`);
192192
for (const replacements of translationReplacements) {
193-
await replaceInFile(`src/locale/messages.${lang}.xlf`, replacements[0], replacements[1]);
193+
await replaceInFile(`src/locale/messages.${lang}.xlf`, replacements[0], replacements[1] as string);
194194
}
195195
}
196196
}
197197

198-
if (useLocalize) {
199-
// Install the localize package.
198+
// Install the localize package if using ivy
199+
if (!getGlobalVariable('argv')['ve']) {
200200
let localizeVersion = '@angular/localize@' + readNgVersion();
201201
if (getGlobalVariable('argv')['ng-snapshots']) {
202202
localizeVersion = require('../../ng-snapshot/package.json').dependencies['@angular/localize'];
@@ -209,23 +209,28 @@ export default async function () {
209209
// Setup i18n tests and config.
210210
await setupI18nConfig(false);
211211

212+
// Legacy option usage with the en-US locale needs $localize when using ivy
213+
// Legacy usage did not need to process en-US and typically no i18nLocale options were present
214+
// This will currently be the overwhelmingly common scenario for users updating existing projects
215+
if (!getGlobalVariable('argv')['ve']) {
216+
await appendToFile('src/polyfills.ts', `import '@angular/localize/init';`);
217+
}
218+
212219
// Build each locale and verify the output.
213220
for (const { lang, translation, outputPath } of langTranslations) {
214221
await ng('build', `--configuration=${lang}`);
215222
await expectFileToMatch(`${outputPath}/main-es5.js`, translation.helloPartial);
216223
await expectFileToMatch(`${outputPath}/main-es2015.js`, translation.helloPartial);
217224

218-
// E2E to verify the output runs and is correct.
219-
if (getGlobalVariable('argv')['ve']) {
220-
await ng('e2e', `--configuration=${lang}`);
221-
} else {
222-
const server = externalServer(outputPath);
223-
try {
224-
// Execute without a devserver.
225-
await ng('e2e', `--configuration=${lang}`, '--devServerTarget=');
226-
} finally {
227-
server.close();
228-
}
225+
// Execute Application E2E tests with dev server
226+
await ng('e2e', `--configuration=${lang}`, '--port=0');
227+
228+
// Execute Application E2E tests for a production build without dev server
229+
const server = externalServer(outputPath);
230+
try {
231+
await ng('e2e', `--configuration=${lang}`, '--devServerTarget=');
232+
} finally {
233+
server.close();
229234
}
230235
}
231236

tests/legacy-cli/e2e_runner.ts

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -97,14 +97,6 @@ if (argv.ve) {
9797
// Remove Ivy specific tests
9898
allTests = allTests
9999
.filter(name => !name.includes('tests/i18n/ivy-localize-'));
100-
} else {
101-
// These tests are disabled on the Ivy CI jobs because:
102-
// - Ivy doesn't support the functionality yet
103-
// - The test itself is not applicable to Ivy
104-
// As we transition into using Ivy as the default this list should be reassessed.
105-
allTests = allTests
106-
// Ivy doesn't support i18n externally at the moment.
107-
.filter(name => !name.endsWith('tests/build/aot/aot-i18n.ts'));
108100
}
109101

110102
const shardId = 'shard' in argv ? argv['shard'] : null;

0 commit comments

Comments
 (0)