Skip to content

Commit 358bc12

Browse files
clydinvikerman
authored andcommitted
feat(@angular-devkit/build-angular): support i18n localization for non-differential builds
1 parent e9279bb commit 358bc12

File tree

6 files changed

+477
-13
lines changed

6 files changed

+477
-13
lines changed

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

Lines changed: 102 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import {
5151
normalizeSourceMaps,
5252
} from '../utils';
5353
import { copyAssets } from '../utils/copy-assets';
54+
import { emittedFilesToInlineOptions } from '../utils/i18n-inlining';
5455
import { I18nOptions, createI18nOptions, mergeDeprecatedI18nOptions } from '../utils/i18n-options';
5556
import { createTranslationLoader } from '../utils/load-translations';
5657
import {
@@ -282,6 +283,9 @@ export function buildWebpackBrowser(
282283
// tslint:disable-next-line: no-big-function
283284
concatMap(async buildEvent => {
284285
const { webpackStats, success, emittedFiles = [] } = buildEvent;
286+
if (!webpackStats) {
287+
throw new Error('Webpack stats build result is required.');
288+
}
285289

286290
if (!success && useBundleDownleveling) {
287291
// If using bundle downleveling then there is only one build
@@ -319,14 +323,27 @@ export function buildWebpackBrowser(
319323
files = moduleFiles.filter(
320324
x => x.extension === '.css' || (x.name && scriptsEntryPointName.includes(x.name)),
321325
);
326+
if (i18n.shouldInline) {
327+
const success = await i18nInlineEmittedFiles(
328+
context,
329+
emittedFiles,
330+
i18n,
331+
baseOutputPath,
332+
outputPaths,
333+
scriptsEntryPointName,
334+
// tslint:disable-next-line: no-non-null-assertion
335+
webpackStats.outputPath!,
336+
target <= ScriptTarget.ES5,
337+
options.i18nMissingTranslation,
338+
);
339+
if (!success) {
340+
return { success: false };
341+
}
342+
}
322343
} else if (isDifferentialLoadingNeeded) {
323344
moduleFiles = [];
324345
noModuleFiles = [];
325346

326-
if (!webpackStats) {
327-
throw new Error('Webpack stats build result is required.');
328-
}
329-
330347
// Common options for all bundle process actions
331348
const sourceMapOptions = normalizeSourceMaps(options.sourceMap || false);
332349
const actionOptions: Partial<ProcessBundleOptions> = {
@@ -648,6 +665,23 @@ export function buildWebpackBrowser(
648665
} else {
649666
files = emittedFiles.filter(x => x.name !== 'polyfills-es5');
650667
noModuleFiles = emittedFiles.filter(x => x.name === 'polyfills-es5');
668+
if (i18n.shouldInline) {
669+
const success = await i18nInlineEmittedFiles(
670+
context,
671+
emittedFiles,
672+
i18n,
673+
baseOutputPath,
674+
outputPaths,
675+
scriptsEntryPointName,
676+
// tslint:disable-next-line: no-non-null-assertion
677+
webpackStats.outputPath!,
678+
target <= ScriptTarget.ES5,
679+
options.i18nMissingTranslation,
680+
);
681+
if (!success) {
682+
return { success: false };
683+
}
684+
}
651685
}
652686

653687
if (options.index) {
@@ -732,6 +766,70 @@ function generateIndex(
732766
}).toPromise();
733767
}
734768

769+
async function i18nInlineEmittedFiles(
770+
context: BuilderContext,
771+
emittedFiles: EmittedFiles[],
772+
i18n: I18nOptions,
773+
baseOutputPath: string,
774+
outputPaths: string[],
775+
scriptsEntryPointName: string[],
776+
emittedPath: string,
777+
es5: boolean,
778+
missingTranslation: 'error' | 'warning' | 'ignore' | undefined,
779+
) {
780+
const executor = new BundleActionExecutor({ i18n });
781+
let hasErrors = false;
782+
try {
783+
const { options, originalFiles: processedFiles } = emittedFilesToInlineOptions(
784+
emittedFiles,
785+
scriptsEntryPointName,
786+
emittedPath,
787+
baseOutputPath,
788+
es5,
789+
missingTranslation,
790+
);
791+
792+
for await (const result of executor.inlineAll(options)) {
793+
for (const diagnostic of result.diagnostics) {
794+
if (diagnostic.type === 'error') {
795+
hasErrors = true;
796+
context.logger.error(diagnostic.message);
797+
} else {
798+
context.logger.warn(diagnostic.message);
799+
}
800+
}
801+
}
802+
803+
// Copy any non-processed files into the output locations
804+
await copyAssets(
805+
[
806+
{
807+
glob: '**/*',
808+
input: emittedPath,
809+
output: '',
810+
ignore: [...processedFiles].map(f => path.relative(emittedPath, f)),
811+
},
812+
],
813+
outputPaths,
814+
'',
815+
);
816+
} catch (err) {
817+
context.logger.error('Localized bundle generation failed: ' + err.message);
818+
819+
return false;
820+
} finally {
821+
executor.stop();
822+
}
823+
824+
context.logger.info(`Localized bundle generation ${hasErrors ? 'failed' : 'complete'}.`);
825+
826+
if (hasErrors) {
827+
return false;
828+
}
829+
830+
return true;
831+
}
832+
735833
function mapErrorToMessage(error: unknown): string | undefined {
736834
if (error instanceof Error) {
737835
return error.message;
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/**
2+
* @license
3+
* Copyright Google Inc. 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+
import { EmittedFiles } from '@angular-devkit/build-webpack';
9+
import * as fs from 'fs';
10+
import * as path from 'path';
11+
import { InlineOptions } from './process-bundle';
12+
13+
export function emittedFilesToInlineOptions(
14+
emittedFiles: EmittedFiles[],
15+
scriptsEntryPointName: string[],
16+
emittedPath: string,
17+
outputPath: string,
18+
es5: boolean,
19+
missingTranslation: 'error' | 'warning' | 'ignore' | undefined,
20+
): { options: InlineOptions[]; originalFiles: string[] } {
21+
const options: InlineOptions[] = [];
22+
const originalFiles: string[] = [];
23+
for (const emittedFile of emittedFiles) {
24+
if (
25+
emittedFile.asset ||
26+
emittedFile.extension !== '.js' ||
27+
(emittedFile.name && scriptsEntryPointName.includes(emittedFile.name))
28+
) {
29+
continue;
30+
}
31+
32+
const originalPath = path.join(emittedPath, emittedFile.file);
33+
const action: InlineOptions = {
34+
filename: emittedFile.file,
35+
code: fs.readFileSync(originalPath, 'utf8'),
36+
es5,
37+
outputPath,
38+
missingTranslation,
39+
};
40+
originalFiles.push(originalPath);
41+
42+
try {
43+
const originalMapPath = originalPath + '.map';
44+
action.map = fs.readFileSync(originalMapPath, 'utf8');
45+
originalFiles.push(originalMapPath);
46+
} catch (err) {
47+
if (err.code !== 'ENOENT') {
48+
throw err;
49+
}
50+
}
51+
52+
options.push(action);
53+
}
54+
55+
return { options, originalFiles };
56+
}

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

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,12 @@
1-
import { ng } from '../../utils/process';
2-
import { expectFileToMatch, rimraf } from '../../utils/fs';
31
import { getGlobalVariable } from '../../utils/env';
2+
import { expectFileToMatch, rimraf } from '../../utils/fs';
3+
import { ng } from '../../utils/process';
44

5-
6-
export default function () {
7-
// TODO(architect): Delete this test. It is now in devkit/build-angular.
8-
9-
// Skip this test in Angular 2/4.
10-
if (getGlobalVariable('argv').ng2 || getGlobalVariable('argv').ng4) {
11-
return Promise.resolve();
5+
export default async function () {
6+
const argv = getGlobalVariable('argv');
7+
const veEnabled = argv['ve'];
8+
if (!veEnabled) {
9+
return;
1210
}
1311

1412
// These tests should be moved to the default when we use ng5 in new projects.

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ export default async function() {
2525
}
2626
await npm('install', `${localizeVersion}`);
2727

28+
await updateJsonFile('tsconfig.json', config => {
29+
config.compilerOptions.target = 'es2015';
30+
config.angularCompilerOptions.disableTypeScriptVersionCheck = true;
31+
});
32+
2833
const baseDir = 'dist/test-project';
2934

3035
// Set configurations for each locale.
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import * as express from 'express';
2+
import { resolve } from 'path';
3+
import { getGlobalVariable } from '../../utils/env';
4+
import {
5+
appendToFile,
6+
copyFile,
7+
expectFileNotToExist,
8+
expectFileToExist,
9+
expectFileToMatch,
10+
replaceInFile,
11+
writeFile,
12+
} from '../../utils/fs';
13+
import { ng, npm } from '../../utils/process';
14+
import { updateJsonFile } from '../../utils/project';
15+
import { expectToFail } from '../../utils/utils';
16+
import { readNgVersion } from '../../utils/version';
17+
18+
export default async function() {
19+
if (getGlobalVariable('argv').ve) {
20+
return;
21+
}
22+
23+
let localizeVersion = '@angular/localize@' + readNgVersion();
24+
if (getGlobalVariable('argv')['ng-snapshots']) {
25+
localizeVersion = require('../../ng-snapshot/package.json').dependencies['@angular/localize'];
26+
}
27+
await npm('install', `${localizeVersion}`);
28+
29+
await writeFile('browserslist', 'Chrome 65');
30+
await updateJsonFile('tsconfig.json', config => {
31+
config.compilerOptions.target = 'es2015';
32+
config.angularCompilerOptions.disableTypeScriptVersionCheck = true;
33+
});
34+
35+
const baseDir = 'dist/test-project';
36+
37+
// Set configurations for each locale.
38+
const langTranslations = [
39+
{ lang: 'en-US', translation: 'Hello i18n!' },
40+
{ lang: 'fr', translation: 'Bonjour i18n!' },
41+
{ lang: 'de', translation: 'Hallo i18n!' },
42+
];
43+
44+
await updateJsonFile('angular.json', workspaceJson => {
45+
const appProject = workspaceJson.projects['test-project'];
46+
const appArchitect = appProject.architect || appProject.targets;
47+
const serveConfigs = appArchitect['serve'].configurations;
48+
const e2eConfigs = appArchitect['e2e'].configurations;
49+
50+
// Make default builds prod.
51+
appArchitect['build'].options.optimization = true;
52+
appArchitect['build'].options.buildOptimizer = true;
53+
appArchitect['build'].options.aot = true;
54+
appArchitect['build'].options.fileReplacements = [
55+
{
56+
replace: 'src/environments/environment.ts',
57+
with: 'src/environments/environment.prod.ts',
58+
},
59+
];
60+
61+
// Enable localization for all locales
62+
appArchitect['build'].options.localize = true;
63+
64+
// Add locale definitions to the project
65+
// tslint:disable-next-line: no-any
66+
const i18n: Record<string, any> = (appProject.i18n = { locales: {} });
67+
for (const { lang } of langTranslations) {
68+
if (lang == 'en-US') {
69+
i18n.sourceLocale = lang;
70+
} else {
71+
i18n.locales[lang] = `src/locale/messages.${lang}.xlf`;
72+
}
73+
serveConfigs[lang] = { browserTarget: `test-project:build:${lang}` };
74+
e2eConfigs[lang] = {
75+
specs: [`./src/app.${lang}.e2e-spec.ts`],
76+
devServerTarget: `test-project:serve:${lang}`,
77+
};
78+
}
79+
});
80+
81+
// Add a translatable element.
82+
await writeFile(
83+
'src/app/app.component.html',
84+
'<h1 i18n="An introduction header for this sample">Hello i18n!</h1>',
85+
);
86+
87+
// Extract the translation messages and copy them for each language.
88+
await ng('xi18n', '--output-path=src/locale');
89+
await expectFileToExist('src/locale/messages.xlf');
90+
await expectFileToMatch('src/locale/messages.xlf', `source-language="en-US"`);
91+
await expectFileToMatch('src/locale/messages.xlf', `An introduction header for this sample`);
92+
93+
for (const { lang, translation } of langTranslations) {
94+
if (lang != 'en-US') {
95+
await copyFile('src/locale/messages.xlf', `src/locale/messages.${lang}.xlf`);
96+
await replaceInFile(
97+
`src/locale/messages.${lang}.xlf`,
98+
'source-language="en-US"',
99+
`source-language="en-US" target-language="${lang}"`,
100+
);
101+
await replaceInFile(
102+
`src/locale/messages.${lang}.xlf`,
103+
'<source>Hello i18n!</source>',
104+
`<source>Hello i18n!</source>\n<target>${translation}</target>`,
105+
);
106+
}
107+
}
108+
109+
// Build each locale and verify the output.
110+
await ng('build', '--i18n-missing-translation', 'error');
111+
for (const { lang, translation } of langTranslations) {
112+
await expectFileToMatch(`${baseDir}/${lang}/main.js`, translation);
113+
await expectToFail(() => expectFileToMatch(`${baseDir}/${lang}/main.js`, '$localize'));
114+
await expectFileNotToExist(`${baseDir}/${lang}/main-es5.js`);
115+
116+
// Ivy i18n doesn't yet work with `ng serve` so we must use a separate server.
117+
const app = express();
118+
app.use(express.static(resolve(baseDir, lang)));
119+
const server = app.listen(4200, 'localhost');
120+
try {
121+
// Add E2E test for locale
122+
await writeFile(
123+
'e2e/src/app.e2e-spec.ts',
124+
`
125+
import { browser, logging, element, by } from 'protractor';
126+
describe('workspace-project App', () => {
127+
it('should display welcome message', () => {
128+
browser.get(browser.baseUrl);
129+
expect(element(by.css('h1')).getText()).toEqual('${translation}');
130+
});
131+
afterEach(async () => {
132+
// Assert that there are no errors emitted from the browser
133+
const logs = await browser.manage().logs().get(logging.Type.BROWSER);
134+
expect(logs).not.toContain(jasmine.objectContaining({
135+
level: logging.Level.SEVERE,
136+
} as logging.Entry));
137+
});
138+
});
139+
`,
140+
);
141+
142+
// Execute without a devserver.
143+
await ng('e2e', '--devServerTarget=');
144+
} finally {
145+
server.close();
146+
}
147+
}
148+
149+
// Verify missing translation behaviour.
150+
await appendToFile('src/app/app.component.html', '<p i18n>Other content</p>');
151+
await ng('build', '--i18n-missing-translation', 'ignore');
152+
await expectFileToMatch(`${baseDir}/fr/main.js`, /Other content/);
153+
await expectToFail(() => ng('build', '--i18n-missing-translation', 'error'));
154+
}

0 commit comments

Comments
 (0)