diff --git a/packages/angular_devkit/build_angular/package.json b/packages/angular_devkit/build_angular/package.json index 0e2c5b56db54..631c4faf1e3a 100644 --- a/packages/angular_devkit/build_angular/package.json +++ b/packages/angular_devkit/build_angular/package.json @@ -12,6 +12,7 @@ "@angular-devkit/build-webpack": "0.0.0", "@angular-devkit/core": "0.0.0", "@babel/core": "7.6.4", + "@babel/generator": "7.6.4", "@babel/preset-env": "7.6.3", "@ngtools/webpack": "0.0.0", "ajv": "6.10.2", @@ -33,6 +34,7 @@ "less-loader": "5.0.0", "license-webpack-plugin": "2.1.3", "loader-utils": "1.2.3", + "magic-string": "0.25.4", "mini-css-extract-plugin": "0.8.0", "minimatch": "3.0.4", "parse5": "4.0.0", diff --git a/packages/angular_devkit/build_angular/src/browser/action-executor.ts b/packages/angular_devkit/build_angular/src/browser/action-executor.ts index 8e24c3d5d27e..c9576e70784c 100644 --- a/packages/angular_devkit/build_angular/src/browser/action-executor.ts +++ b/packages/angular_devkit/build_angular/src/browser/action-executor.ts @@ -8,9 +8,25 @@ import JestWorker from 'jest-worker'; import * as os from 'os'; import * as path from 'path'; -import { ProcessBundleOptions, ProcessBundleResult } from '../utils/process-bundle'; +import * as v8 from 'v8'; +import { InlineOptions, ProcessBundleOptions, ProcessBundleResult } from '../utils/process-bundle'; import { BundleActionCache } from './action-cache'; +const hasThreadSupport = (() => { + try { + require('worker_threads'); + + return true; + } catch { + return false; + } +})(); + +// This is used to normalize serialization messaging across threads and processes +// Threads use the structured clone algorithm which handles more types +// Processes use JSON which is much more limited +const serialize = ((v8 as unknown) as { serialize(value: unknown): Buffer }).serialize; + let workerFile = require.resolve('../utils/process-bundle'); workerFile = path.extname(workerFile) === '.ts' @@ -41,8 +57,8 @@ export class BundleActionExecutor { // larger files are processed in a separate process to limit memory usage in the main process return (this.largeWorker = new JestWorker(workerFile, { - exposedMethods: ['process'], - setupArgs: [this.workerOptions], + exposedMethods: ['process', 'inlineLocales'], + setupArgs: [[...serialize(this.workerOptions)]], })); } @@ -54,11 +70,10 @@ export class BundleActionExecutor { // small files are processed in a limited number of threads to improve speed // The limited number also prevents a large increase in memory usage for an otherwise short operation return (this.smallWorker = new JestWorker(workerFile, { - exposedMethods: ['process'], - setupArgs: [this.workerOptions], + exposedMethods: ['process', 'inlineLocales'], + setupArgs: hasThreadSupport ? [this.workerOptions] : [[...serialize(this.workerOptions)]], numWorkers: os.cpus().length < 2 ? 1 : 2, - // Will automatically fallback to processes if not supported - enableWorkerThreads: true, + enableWorkerThreads: hasThreadSupport, })); } @@ -71,7 +86,7 @@ export class BundleActionExecutor { } } - async process(action: ProcessBundleOptions) { + async process(action: ProcessBundleOptions): Promise { const cacheKeys = this.cache.generateCacheKeys(action); action.cacheKeys = cacheKeys; @@ -86,10 +101,27 @@ export class BundleActionExecutor { return this.executeAction('process', action); } - async *processAll(actions: Iterable) { - const executions = new Map, Promise>(); + processAll(actions: Iterable): AsyncIterable { + return BundleActionExecutor.executeAll(actions, action => this.process(action)); + } + + async inline( + action: InlineOptions, + ): Promise<{ file: string; diagnostics: { type: string; message: string }[]; count: number; }> { + return this.executeAction('inlineLocales', action); + } + + inlineAll(actions: Iterable) { + return BundleActionExecutor.executeAll(actions, action => this.inline(action)); + } + + private static async *executeAll( + actions: Iterable, + executor: (action: I) => Promise, + ): AsyncIterable { + const executions = new Map, Promise>(); for (const action of actions) { - const execution = this.process(action); + const execution = executor(action); executions.set( execution, execution.then(result => { @@ -105,7 +137,7 @@ export class BundleActionExecutor { } } - stop() { + stop(): void { if (this.largeWorker) { this.largeWorker.end(); } diff --git a/packages/angular_devkit/build_angular/src/browser/index.ts b/packages/angular_devkit/build_angular/src/browser/index.ts index 640136b3cd96..9840d61483ea 100644 --- a/packages/angular_devkit/build_angular/src/browser/index.ts +++ b/packages/angular_devkit/build_angular/src/browser/index.ts @@ -6,11 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ import { BuilderContext, BuilderOutput, createBuilder } from '@angular-devkit/architect'; -import { - EmittedFiles, - WebpackLoggingCallback, - runWebpack, -} from '@angular-devkit/build-webpack'; +import { EmittedFiles, WebpackLoggingCallback, runWebpack } from '@angular-devkit/build-webpack'; import { join, json, logging, normalize, tags, virtualFs } from '@angular-devkit/core'; import { NodeJsSyncHost } from '@angular-devkit/core/node'; import * as findCacheDirectory from 'find-cache-dir'; @@ -55,9 +51,11 @@ import { normalizeSourceMaps, } from '../utils'; import { copyAssets } from '../utils/copy-assets'; -import { I18nOptions, createI18nOptions } from '../utils/i18n-options'; +import { emittedFilesToInlineOptions } from '../utils/i18n-inlining'; +import { I18nOptions, createI18nOptions, mergeDeprecatedI18nOptions } from '../utils/i18n-options'; import { createTranslationLoader } from '../utils/load-translations'; import { + InlineOptions, ProcessBundleFile, ProcessBundleOptions, ProcessBundleResult, @@ -166,9 +164,21 @@ async function initialize( throw new Error('The builder requires a target.'); } + const tsConfig = readTsconfig(options.tsConfig, context.workspaceRoot); + const usingIvy = tsConfig.options.enableIvy !== false; const metadata = await context.getProjectMetadata(context.target); const i18n = createI18nOptions(metadata, options.localize); + // Until 11.0, support deprecated i18n options when not using new localize option + // i18nFormat is automatically calculated + if (options.localize === undefined && usingIvy) { + mergeDeprecatedI18nOptions(i18n, options.i18nLocale, options.i18nFile); + } else if (options.localize !== undefined && !usingIvy) { + options.localize = undefined; + + context.logger.warn(`Option 'localize' is not supported with View Engine.`); + } + if (i18n.inlineLocales.size > 0) { // LoadĀ locales const loader = await createTranslationLoader(); @@ -273,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 @@ -286,8 +299,14 @@ export function buildWebpackBrowser( return { success }; } else if (success) { - if (!fs.existsSync(baseOutputPath)) { - fs.mkdirSync(baseOutputPath, { recursive: true }); + const outputPaths = + i18n.shouldInline && !i18n.flatOutput + ? [...i18n.inlineLocales].map(l => path.join(baseOutputPath, l)) + : [baseOutputPath]; + for (const outputPath of outputPaths) { + if (!fs.existsSync(outputPath)) { + fs.mkdirSync(outputPath, { recursive: true }); + } } let noModuleFiles: EmittedFiles[] | undefined; @@ -304,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 = { @@ -447,84 +479,104 @@ export function buildWebpackBrowser( } context.logger.info('ES5 bundle generation complete.'); - } finally { - executor.stop(); - } - if (i18n.shouldInline) { - context.logger.info('Generating localized bundles...'); - - const localize = await import('@angular/localize/src/tools/src/translate/main'); - const localizeDiag = await import('@angular/localize/src/tools/src/diagnostics'); + if (i18n.shouldInline) { + context.logger.info('Generating localized bundles...'); - const diagnostics = new localizeDiag.Diagnostics(); - const translationFilePaths = []; - let handleSourceLocale = false; - for (const locale of i18n.inlineLocales) { - if (locale === i18n.sourceLocale) { - handleSourceLocale = true; - continue; - } - translationFilePaths.push(i18n.locales[locale].file); - } - - if (translationFilePaths.length > 0) { - const sourceFilePaths = []; + const inlineActions: InlineOptions[] = []; + const processedFiles = new Set(); for (const result of processResults) { if (result.original) { - sourceFilePaths.push(result.original.filename); + inlineActions.push({ + filename: path.basename(result.original.filename), + code: fs.readFileSync(result.original.filename, 'utf8'), + map: + result.original.map && + fs.readFileSync(result.original.map.filename, 'utf8'), + outputPath: baseOutputPath, + es5: false, + missingTranslation: options.i18nMissingTranslation, + }); + processedFiles.add(result.original.filename); } if (result.downlevel) { - sourceFilePaths.push(result.downlevel.filename); + inlineActions.push({ + filename: path.basename(result.downlevel.filename), + code: fs.readFileSync(result.downlevel.filename, 'utf8'), + map: + result.downlevel.map && + fs.readFileSync(result.downlevel.map.filename, 'utf8'), + outputPath: baseOutputPath, + es5: true, + missingTranslation: options.i18nMissingTranslation, + }); + processedFiles.add(result.downlevel.filename); } } + + let hasErrors = false; try { - localize.translateFiles({ - // tslint:disable-next-line: no-non-null-assertion - sourceRootPath: webpackStats.outputPath!, - sourceFilePaths, - translationFilePaths, - outputPathFn: (locale, relativePath) => - path.join(baseOutputPath, locale, relativePath), - diagnostics, - missingTranslation: options.i18nMissingTranslation || 'warning', - sourceLocale: handleSourceLocale ? i18n.sourceLocale : undefined, - }); + for await (const result of executor.inlineAll(inlineActions)) { + if (options.verbose) { + context.logger.info( + `Localized "${result.file}" [${result.count} translation(s)].`, + ); + } + 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: '**/*', + // tslint:disable-next-line: no-non-null-assertion + input: webpackStats.outputPath!, + output: '', + ignore: [...processedFiles].map(f => + // tslint:disable-next-line: no-non-null-assertion + path.relative(webpackStats.outputPath!, f), + ), + }, + ], + outputPaths, + '', + ); } catch (err) { context.logger.error('Localized bundle generation failed: ' + err.message); return { success: false }; - } finally { - try { - // Remove temporary directory used for i18n processing - // tslint:disable-next-line: no-non-null-assertion - await host.delete(normalize(webpackStats.outputPath!)).toPromise(); - } catch {} } - } - context.logger.info( - `Localized bundle generation ${diagnostics.hasErrors ? 'failed' : 'complete'}.`, - ); + context.logger.info( + `Localized bundle generation ${hasErrors ? 'failed' : 'complete'}.`, + ); - for (const message of diagnostics.messages) { - if (message.type === 'error') { - context.logger.error(message.message); - } else { - context.logger.warn(message.message); + if (hasErrors) { + return { success: false }; } } + } finally { + executor.stop(); - if (diagnostics.hasErrors) { - return { success: false }; + if (i18n.shouldInline) { + try { + // Remove temporary directory used for i18n processing + // tslint:disable-next-line: no-non-null-assertion + await host.delete(normalize(webpackStats.outputPath!)).toPromise(); + } catch {} } } // Copy assets if (options.assets) { - const outputPaths = i18n.shouldInline - ? [...i18n.inlineLocales].map(l => path.join(baseOutputPath, l)) - : [baseOutputPath]; try { await copyAssets( normalizeAssetPatterns( @@ -613,13 +665,26 @@ 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) { - const outputPaths = i18n.shouldInline - ? [...i18n.inlineLocales].map(l => path.join(baseOutputPath, l)) - : [baseOutputPath]; - for (const outputPath of outputPaths) { try { await generateIndex( @@ -701,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; diff --git a/packages/angular_devkit/build_angular/src/utils/i18n-inlining.ts b/packages/angular_devkit/build_angular/src/utils/i18n-inlining.ts new file mode 100644 index 000000000000..5609e1d35a00 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/utils/i18n-inlining.ts @@ -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 }; +} 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 89b03da22a51..4db1ef7a606d 100644 --- a/packages/angular_devkit/build_angular/src/utils/i18n-options.ts +++ b/packages/angular_devkit/build_angular/src/utils/i18n-options.ts @@ -11,6 +11,7 @@ export interface I18nOptions { inlineLocales: Set; sourceLocale: string; locales: Record; + flatOutput?: boolean; readonly shouldInline: boolean; } @@ -80,3 +81,21 @@ export function createI18nOptions( return i18n; } + +export function mergeDeprecatedI18nOptions(i18n: I18nOptions, i18nLocale: string | undefined, i18nFile: string | undefined): I18nOptions { + if (i18nFile !== undefined && i18nLocale === undefined) { + throw new Error(`Option 'i18nFile' cannot be used without the 'i18nLocale' option.`); + } + + if (i18nLocale !== undefined) { + i18n.inlineLocales.clear(); + i18n.inlineLocales.add(i18nLocale); + + if (i18nFile !== undefined) { + i18n.locales[i18nLocale] = { file: i18nFile }; + i18n.flatOutput = true; + } + } + + return i18n; +} diff --git a/packages/angular_devkit/build_angular/src/utils/load-translations.ts b/packages/angular_devkit/build_angular/src/utils/load-translations.ts index 3e5a89e22c62..a34b16102219 100644 --- a/packages/angular_devkit/build_angular/src/utils/load-translations.ts +++ b/packages/angular_devkit/build_angular/src/utils/load-translations.ts @@ -30,7 +30,7 @@ export async function createTranslationLoader(): Promise { for (const [format, parser] of Object.entries(parsers)) { if (parser.canParse(path, content)) { - return { format, translation: parser.parse(path, content) }; + return { format, translation: parser.parse(path, content).translations }; } } diff --git a/packages/angular_devkit/build_angular/src/utils/process-bundle.ts b/packages/angular_devkit/build_angular/src/utils/process-bundle.ts index 6e45bf448826..469f6c653294 100644 --- a/packages/angular_devkit/build_angular/src/utils/process-bundle.ts +++ b/packages/angular_devkit/build_angular/src/utils/process-bundle.ts @@ -5,15 +5,19 @@ * 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 { transformAsync } from '@babel/core'; +import { NodePath, parseSync, transformAsync, traverse, types } from '@babel/core'; import { createHash } from 'crypto'; import * as fs from 'fs'; import * as path from 'path'; import { RawSourceMap, SourceMapConsumer, SourceMapGenerator } from 'source-map'; import { minify } from 'terser'; +import * as v8 from 'v8'; +import { SourceMapSource } from 'webpack-sources'; +import { I18nOptions } from './i18n-options'; import { manglingDisabled } from './mangle-options'; const cacache = require('cacache'); +const deserialize = ((v8 as unknown) as { deserialize(buffer: Buffer): unknown }).deserialize; export interface ProcessBundleOptions { filename: string; @@ -57,9 +61,14 @@ export const enum CacheKey { } let cachePath: string | undefined; +let i18n: I18nOptions | undefined; -export function setup(options: { cachePath: string }): void { +export function setup(data: number[] | { cachePath: string; i18n: I18nOptions }): void { + const options = Array.isArray(data) + ? (deserialize(Buffer.from(data)) as { cachePath: string; i18n: I18nOptions }) + : data; cachePath = options.cachePath; + i18n = options.i18n; } async function cachePut(content: string, key: string | null, integrity?: string): Promise { @@ -123,7 +132,7 @@ export async function process(options: ProcessBundleOptions): Promise 1) { + throw new Error('Flat output is only supported when inlining one locale.'); + } + + if (!options.code.includes(localizeName)) { + return inlineCopyOnly(options); + } + + const { default: MagicString } = await import('magic-string'); + const { default: generate } = await import('@babel/generator'); + const utils = await import( + // tslint:disable-next-line: trailing-comma + '@angular/localize/src/tools/src/translate/source_files/source_file_utils' + ); + const localizeDiag = await import('@angular/localize/src/tools/src/diagnostics'); + + const diagnostics = new localizeDiag.Diagnostics(); + + const positions = findLocalizePositions(options, utils); + if (positions.length === 0) { + return inlineCopyOnly(options); + } + + const content = new MagicString(options.code); + const inputMap = options.map && JSON.parse(options.map) as RawSourceMap; + + for (const locale of i18n.inlineLocales) { + const isSourceLocale = locale === i18n.sourceLocale; + // tslint:disable-next-line: no-any + const translations: any = isSourceLocale ? {} : i18n.locales[locale].translation || {}; + for (const position of positions) { + const translated = utils.translate( + diagnostics, + translations, + position.messageParts, + position.expressions, + isSourceLocale ? 'ignore' : options.missingTranslation || 'warning', + ); + + const expression = utils.buildLocalizeReplacement(translated[0], translated[1]); + const { code } = generate(expression); + + content.overwrite(position.start, position.end, code); + } + + const output = content.toString(); + const outputPath = path.join( + options.outputPath, + i18n.flatOutput ? '' : locale, + options.filename, + ); + fs.writeFileSync(outputPath, output); + + if (inputMap) { + const contentMap = content.generateMap(); + const outputMap = mergeSourceMaps( + options.code, + inputMap, + output, + contentMap, + options.filename, + ); + + fs.writeFileSync(outputPath + '.map', JSON.stringify(outputMap)); + } + } + + return { file: options.filename, diagnostics: diagnostics.messages, count: positions.length }; +} + +function inlineCopyOnly(options: InlineOptions) { + if (!i18n) { + throw new Error('i18n options are missing'); + } + + for (const locale of i18n.inlineLocales) { + const outputPath = path.join(options.outputPath, i18n.flatOutput ? '' : locale, options.filename); + fs.writeFileSync(outputPath, options.code); + if (options.map) { + fs.writeFileSync(outputPath + '.map', options.map); + } + } + + return { file: options.filename, diagnostics: [], count: 0 }; +} + +function findLocalizePositions( + options: InlineOptions, + utils: typeof import('@angular/localize/src/tools/src/translate/source_files/source_file_utils'), +): LocalizePosition[] { + const ast = parseSync(options.code, { babelrc: false }); + if (!ast) { + throw new Error(`Unknown error occurred inlining file "${options.filename}"`); + } + + const positions: LocalizePosition[] = []; + + if (options.es5) { + traverse(ast, { + CallExpression(path: NodePath) { + const callee = path.get('callee'); + if (callee.isIdentifier() && callee.node.name === localizeName) { + const messageParts = utils.unwrapMessagePartsFromLocalizeCall(path); + const expressions = utils.unwrapSubstitutionsFromLocalizeCall(path.node); + positions.push({ + // tslint:disable-next-line: no-non-null-assertion + start: path.node.start!, + // tslint:disable-next-line: no-non-null-assertion + end: path.node.end!, + messageParts, + expressions, + }); + } + }, + }); + } else { + const traverseFast = ((types as unknown) as { + traverseFast: (node: types.Node, enter: (node: types.Node) => void) => void; + }).traverseFast; + + traverseFast(ast, node => { + if ( + node.type === 'TaggedTemplateExpression' && + types.isIdentifier(node.tag) && + node.tag.name === localizeName + ) { + const messageParts = utils.unwrapMessagePartsFromTemplateLiteral(node.quasi.quasis); + positions.push({ + // tslint:disable-next-line: no-non-null-assertion + start: node.start!, + // tslint:disable-next-line: no-non-null-assertion + end: node.end!, + messageParts, + expressions: node.quasi.expressions, + }); + } + }); + } + + return positions; +} diff --git a/tests/legacy-cli/e2e/tests/i18n/build-locale.ts b/tests/legacy-cli/e2e/tests/i18n/build-locale.ts index 6fd4f6166f42..cfc3ef2120cc 100644 --- a/tests/legacy-cli/e2e/tests/i18n/build-locale.ts +++ b/tests/legacy-cli/e2e/tests/i18n/build-locale.ts @@ -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. diff --git a/tests/legacy-cli/e2e/tests/i18n/ivy-localize.ts b/tests/legacy-cli/e2e/tests/i18n/ivy-localize-dl.ts similarity index 96% rename from tests/legacy-cli/e2e/tests/i18n/ivy-localize.ts rename to tests/legacy-cli/e2e/tests/i18n/ivy-localize-dl.ts index a35ca635f7b7..b71a56516b60 100644 --- a/tests/legacy-cli/e2e/tests/i18n/ivy-localize.ts +++ b/tests/legacy-cli/e2e/tests/i18n/ivy-localize-dl.ts @@ -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. diff --git a/tests/legacy-cli/e2e/tests/i18n/ivy-localize-es2015.ts b/tests/legacy-cli/e2e/tests/i18n/ivy-localize-es2015.ts new file mode 100644 index 000000000000..90c254be6117 --- /dev/null +++ b/tests/legacy-cli/e2e/tests/i18n/ivy-localize-es2015.ts @@ -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 = (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', + '

Hello i18n!

', + ); + + // 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`, + 'Hello i18n!', + `Hello i18n!\n${translation}`, + ); + } + } + + // 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', '

Other content

'); + await ng('build', '--i18n-missing-translation', 'ignore'); + await expectFileToMatch(`${baseDir}/fr/main.js`, /Other content/); + await expectToFail(() => ng('build', '--i18n-missing-translation', 'error')); +} diff --git a/tests/legacy-cli/e2e/tests/i18n/ivy-localize-es5.ts b/tests/legacy-cli/e2e/tests/i18n/ivy-localize-es5.ts new file mode 100644 index 000000000000..d85a047f346d --- /dev/null +++ b/tests/legacy-cli/e2e/tests/i18n/ivy-localize-es5.ts @@ -0,0 +1,153 @@ +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 updateJsonFile('tsconfig.json', config => { + config.compilerOptions.target = 'es5'; + 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 = (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', + '

Hello i18n!

', + ); + + // 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`, + 'Hello i18n!', + `Hello i18n!\n${translation}`, + ); + } + } + + // 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-es2015.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', '

Other content

'); + await ng('build', '--i18n-missing-translation', 'ignore'); + await expectFileToMatch(`${baseDir}/fr/main.js`, /Other content/); + await expectToFail(() => ng('build', '--i18n-missing-translation', 'error')); +} diff --git a/yarn.lock b/yarn.lock index 3183be4add3c..42ffd7a2fab1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -123,7 +123,7 @@ semver "^5.4.1" source-map "^0.5.0" -"@babel/generator@^7.4.0", "@babel/generator@^7.6.3", "@babel/generator@^7.6.4": +"@babel/generator@7.6.4", "@babel/generator@^7.4.0", "@babel/generator@^7.6.3", "@babel/generator@^7.6.4": version "7.6.4" resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.6.4.tgz#a4f8437287bf9671b07f483b76e3bb731bc97671" integrity sha512-jsBuXkFoZxk0yWLyGI9llT9oiQ2FeTASmRFE32U+aaDTfoE92t78eroO7PTpU/OrYq38hlcDM6vbfLDaOLy+7w== @@ -9122,7 +9122,6 @@ sauce-connect-launcher@^1.2.4: "sauce-connect-proxy@https://saucelabs.com/downloads/sc-4.5.4-linux.tar.gz": version "0.0.0" - uid dc5efcd2be24ddb099a85b923d6e754754651fa8 resolved "https://saucelabs.com/downloads/sc-4.5.4-linux.tar.gz#dc5efcd2be24ddb099a85b923d6e754754651fa8" saucelabs@^1.5.0: