Skip to content

Commit

Permalink
feat(@angular-devkit/build-angular): support i18n localization for no…
Browse files Browse the repository at this point in the history
…n-differential builds
  • Loading branch information
clydin authored and vikerman committed Oct 21, 2019
1 parent e9279bb commit 358bc12
Show file tree
Hide file tree
Showing 6 changed files with 477 additions and 13 deletions.
106 changes: 102 additions & 4 deletions packages/angular_devkit/build_angular/src/browser/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import {
normalizeSourceMaps,
} from '../utils';
import { copyAssets } from '../utils/copy-assets';
import { emittedFilesToInlineOptions } from '../utils/i18n-inlining';
import { I18nOptions, createI18nOptions, mergeDeprecatedI18nOptions } from '../utils/i18n-options';
import { createTranslationLoader } from '../utils/load-translations';
import {
Expand Down Expand Up @@ -282,6 +283,9 @@ export function buildWebpackBrowser(
// tslint:disable-next-line: no-big-function
concatMap(async buildEvent => {
const { webpackStats, success, emittedFiles = [] } = buildEvent;
if (!webpackStats) {
throw new Error('Webpack stats build result is required.');
}

if (!success && useBundleDownleveling) {
// If using bundle downleveling then there is only one build
Expand Down Expand Up @@ -319,14 +323,27 @@ export function buildWebpackBrowser(
files = moduleFiles.filter(
x => x.extension === '.css' || (x.name && scriptsEntryPointName.includes(x.name)),
);
if (i18n.shouldInline) {
const success = await i18nInlineEmittedFiles(
context,
emittedFiles,
i18n,
baseOutputPath,
outputPaths,
scriptsEntryPointName,
// tslint:disable-next-line: no-non-null-assertion
webpackStats.outputPath!,
target <= ScriptTarget.ES5,
options.i18nMissingTranslation,
);
if (!success) {
return { success: false };
}
}
} else if (isDifferentialLoadingNeeded) {
moduleFiles = [];
noModuleFiles = [];

if (!webpackStats) {
throw new Error('Webpack stats build result is required.');
}

// Common options for all bundle process actions
const sourceMapOptions = normalizeSourceMaps(options.sourceMap || false);
const actionOptions: Partial<ProcessBundleOptions> = {
Expand Down Expand Up @@ -648,6 +665,23 @@ export function buildWebpackBrowser(
} else {
files = emittedFiles.filter(x => x.name !== 'polyfills-es5');
noModuleFiles = emittedFiles.filter(x => x.name === 'polyfills-es5');
if (i18n.shouldInline) {
const success = await i18nInlineEmittedFiles(
context,
emittedFiles,
i18n,
baseOutputPath,
outputPaths,
scriptsEntryPointName,
// tslint:disable-next-line: no-non-null-assertion
webpackStats.outputPath!,
target <= ScriptTarget.ES5,
options.i18nMissingTranslation,
);
if (!success) {
return { success: false };
}
}
}

if (options.index) {
Expand Down Expand Up @@ -732,6 +766,70 @@ function generateIndex(
}).toPromise();
}

async function i18nInlineEmittedFiles(
context: BuilderContext,
emittedFiles: EmittedFiles[],
i18n: I18nOptions,
baseOutputPath: string,
outputPaths: string[],
scriptsEntryPointName: string[],
emittedPath: string,
es5: boolean,
missingTranslation: 'error' | 'warning' | 'ignore' | undefined,
) {
const executor = new BundleActionExecutor({ i18n });
let hasErrors = false;
try {
const { options, originalFiles: processedFiles } = emittedFilesToInlineOptions(
emittedFiles,
scriptsEntryPointName,
emittedPath,
baseOutputPath,
es5,
missingTranslation,
);

for await (const result of executor.inlineAll(options)) {
for (const diagnostic of result.diagnostics) {
if (diagnostic.type === 'error') {
hasErrors = true;
context.logger.error(diagnostic.message);
} else {
context.logger.warn(diagnostic.message);
}
}
}

// Copy any non-processed files into the output locations
await copyAssets(
[
{
glob: '**/*',
input: emittedPath,
output: '',
ignore: [...processedFiles].map(f => path.relative(emittedPath, f)),
},
],
outputPaths,
'',
);
} catch (err) {
context.logger.error('Localized bundle generation failed: ' + err.message);

return false;
} finally {
executor.stop();
}

context.logger.info(`Localized bundle generation ${hasErrors ? 'failed' : 'complete'}.`);

if (hasErrors) {
return false;
}

return true;
}

function mapErrorToMessage(error: unknown): string | undefined {
if (error instanceof Error) {
return error.message;
Expand Down
56 changes: 56 additions & 0 deletions packages/angular_devkit/build_angular/src/utils/i18n-inlining.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* @license
* Copyright Google Inc. 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
*/
import { EmittedFiles } from '@angular-devkit/build-webpack';
import * as fs from 'fs';
import * as path from 'path';
import { InlineOptions } from './process-bundle';

export function emittedFilesToInlineOptions(
emittedFiles: EmittedFiles[],
scriptsEntryPointName: string[],
emittedPath: string,
outputPath: string,
es5: boolean,
missingTranslation: 'error' | 'warning' | 'ignore' | undefined,
): { options: InlineOptions[]; originalFiles: string[] } {
const options: InlineOptions[] = [];
const originalFiles: string[] = [];
for (const emittedFile of emittedFiles) {
if (
emittedFile.asset ||
emittedFile.extension !== '.js' ||
(emittedFile.name && scriptsEntryPointName.includes(emittedFile.name))
) {
continue;
}

const originalPath = path.join(emittedPath, emittedFile.file);
const action: InlineOptions = {
filename: emittedFile.file,
code: fs.readFileSync(originalPath, 'utf8'),
es5,
outputPath,
missingTranslation,
};
originalFiles.push(originalPath);

try {
const originalMapPath = originalPath + '.map';
action.map = fs.readFileSync(originalMapPath, 'utf8');
originalFiles.push(originalMapPath);
} catch (err) {
if (err.code !== 'ENOENT') {
throw err;
}
}

options.push(action);
}

return { options, originalFiles };
}
16 changes: 7 additions & 9 deletions tests/legacy-cli/e2e/tests/i18n/build-locale.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
import { ng } from '../../utils/process';
import { expectFileToMatch, rimraf } from '../../utils/fs';
import { getGlobalVariable } from '../../utils/env';
import { expectFileToMatch, rimraf } from '../../utils/fs';
import { ng } from '../../utils/process';


export default function () {
// TODO(architect): Delete this test. It is now in devkit/build-angular.

// Skip this test in Angular 2/4.
if (getGlobalVariable('argv').ng2 || getGlobalVariable('argv').ng4) {
return Promise.resolve();
export default async function () {
const argv = getGlobalVariable('argv');
const veEnabled = argv['ve'];
if (!veEnabled) {
return;
}

// These tests should be moved to the default when we use ng5 in new projects.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ export default async function() {
}
await npm('install', `${localizeVersion}`);

await updateJsonFile('tsconfig.json', config => {
config.compilerOptions.target = 'es2015';
config.angularCompilerOptions.disableTypeScriptVersionCheck = true;
});

const baseDir = 'dist/test-project';

// Set configurations for each locale.
Expand Down
154 changes: 154 additions & 0 deletions tests/legacy-cli/e2e/tests/i18n/ivy-localize-es2015.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import * as express from 'express';
import { resolve } from 'path';
import { getGlobalVariable } from '../../utils/env';
import {
appendToFile,
copyFile,
expectFileNotToExist,
expectFileToExist,
expectFileToMatch,
replaceInFile,
writeFile,
} from '../../utils/fs';
import { ng, npm } from '../../utils/process';
import { updateJsonFile } from '../../utils/project';
import { expectToFail } from '../../utils/utils';
import { readNgVersion } from '../../utils/version';

export default async function() {
if (getGlobalVariable('argv').ve) {
return;
}

let localizeVersion = '@angular/localize@' + readNgVersion();
if (getGlobalVariable('argv')['ng-snapshots']) {
localizeVersion = require('../../ng-snapshot/package.json').dependencies['@angular/localize'];
}
await npm('install', `${localizeVersion}`);

await writeFile('browserslist', 'Chrome 65');
await updateJsonFile('tsconfig.json', config => {
config.compilerOptions.target = 'es2015';
config.angularCompilerOptions.disableTypeScriptVersionCheck = true;
});

const baseDir = 'dist/test-project';

// Set configurations for each locale.
const langTranslations = [
{ lang: 'en-US', translation: 'Hello i18n!' },
{ lang: 'fr', translation: 'Bonjour i18n!' },
{ lang: 'de', translation: 'Hallo i18n!' },
];

await updateJsonFile('angular.json', workspaceJson => {
const appProject = workspaceJson.projects['test-project'];
const appArchitect = appProject.architect || appProject.targets;
const serveConfigs = appArchitect['serve'].configurations;
const e2eConfigs = appArchitect['e2e'].configurations;

// Make default builds prod.
appArchitect['build'].options.optimization = true;
appArchitect['build'].options.buildOptimizer = true;
appArchitect['build'].options.aot = true;
appArchitect['build'].options.fileReplacements = [
{
replace: 'src/environments/environment.ts',
with: 'src/environments/environment.prod.ts',
},
];

// Enable localization for all locales
appArchitect['build'].options.localize = true;

// Add locale definitions to the project
// tslint:disable-next-line: no-any
const i18n: Record<string, any> = (appProject.i18n = { locales: {} });
for (const { lang } of langTranslations) {
if (lang == 'en-US') {
i18n.sourceLocale = lang;
} else {
i18n.locales[lang] = `src/locale/messages.${lang}.xlf`;
}
serveConfigs[lang] = { browserTarget: `test-project:build:${lang}` };
e2eConfigs[lang] = {
specs: [`./src/app.${lang}.e2e-spec.ts`],
devServerTarget: `test-project:serve:${lang}`,
};
}
});

// Add a translatable element.
await writeFile(
'src/app/app.component.html',
'<h1 i18n="An introduction header for this sample">Hello i18n!</h1>',
);

// Extract the translation messages and copy them for each language.
await ng('xi18n', '--output-path=src/locale');
await expectFileToExist('src/locale/messages.xlf');
await expectFileToMatch('src/locale/messages.xlf', `source-language="en-US"`);
await expectFileToMatch('src/locale/messages.xlf', `An introduction header for this sample`);

for (const { lang, translation } of langTranslations) {
if (lang != 'en-US') {
await copyFile('src/locale/messages.xlf', `src/locale/messages.${lang}.xlf`);
await replaceInFile(
`src/locale/messages.${lang}.xlf`,
'source-language="en-US"',
`source-language="en-US" target-language="${lang}"`,
);
await replaceInFile(
`src/locale/messages.${lang}.xlf`,
'<source>Hello i18n!</source>',
`<source>Hello i18n!</source>\n<target>${translation}</target>`,
);
}
}

// Build each locale and verify the output.
await ng('build', '--i18n-missing-translation', 'error');
for (const { lang, translation } of langTranslations) {
await expectFileToMatch(`${baseDir}/${lang}/main.js`, translation);
await expectToFail(() => expectFileToMatch(`${baseDir}/${lang}/main.js`, '$localize'));
await expectFileNotToExist(`${baseDir}/${lang}/main-es5.js`);

// Ivy i18n doesn't yet work with `ng serve` so we must use a separate server.
const app = express();
app.use(express.static(resolve(baseDir, lang)));
const server = app.listen(4200, 'localhost');
try {
// Add E2E test for locale
await writeFile(
'e2e/src/app.e2e-spec.ts',
`
import { browser, logging, element, by } from 'protractor';
describe('workspace-project App', () => {
it('should display welcome message', () => {
browser.get(browser.baseUrl);
expect(element(by.css('h1')).getText()).toEqual('${translation}');
});
afterEach(async () => {
// Assert that there are no errors emitted from the browser
const logs = await browser.manage().logs().get(logging.Type.BROWSER);
expect(logs).not.toContain(jasmine.objectContaining({
level: logging.Level.SEVERE,
} as logging.Entry));
});
});
`,
);

// Execute without a devserver.
await ng('e2e', '--devServerTarget=');
} finally {
server.close();
}
}

// Verify missing translation behaviour.
await appendToFile('src/app/app.component.html', '<p i18n>Other content</p>');
await ng('build', '--i18n-missing-translation', 'ignore');
await expectFileToMatch(`${baseDir}/fr/main.js`, /Other content/);
await expectToFail(() => ng('build', '--i18n-missing-translation', 'error'));
}
Loading

0 comments on commit 358bc12

Please sign in to comment.