refactor(@angular-devkit/build-angular): move around i18n methods to make them re-usable

This commit is contained in:
Alan Agius 2019-10-24 19:30:38 +02:00 committed by vikerman
parent 3163a4391e
commit 936a9512ae
11 changed files with 277 additions and 193 deletions

View File

@ -82,10 +82,14 @@ export function installTempPackage(
logger: logging.Logger,
packageManager: PackageManager = PackageManager.Npm,
): string {
const tempPath = mkdtempSync(join(realpathSync(tmpdir()), '.ng-temp-packages-'));
const tempPath = mkdtempSync(join(realpathSync(tmpdir()), 'angular-cli-packages-'));
// clean up temp directory on process exit
process.on('exit', () => rimraf.sync(tempPath));
process.on('exit', () => {
try {
rimraf.sync(tempPath);
} catch { }
});
// setup prefix/global modules path
const packageManagerArgs = getPackageManagerArguments(packageManager);

View File

@ -44,6 +44,7 @@
"postcss-loader": "3.0.0",
"raw-loader": "3.1.0",
"regenerator-runtime": "0.13.3",
"rimraf": "3.0.0",
"rollup": "1.25.2",
"rxjs": "6.5.3",
"sass": "1.23.1",

View File

@ -10,7 +10,6 @@ import { EmittedFiles, WebpackLoggingCallback, runWebpack } from '@angular-devki
import { join, json, logging, normalize, tags, virtualFs } from '@angular-devkit/core';
import { NodeJsSyncHost } from '@angular-devkit/core/node';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { Observable, from, of } from 'rxjs';
import { concatMap, map, switchMap } from 'rxjs/operators';
@ -49,12 +48,13 @@ import {
normalizeOptimization,
normalizeSourceMaps,
} from '../utils';
import { BundleActionExecutor } from '../utils/action-executor';
import { findCachePath } from '../utils/cache-path';
import { copyAssets } from '../utils/copy-assets';
import { cachingDisabled } from '../utils/environment-options';
import { emittedFilesToInlineOptions } from '../utils/i18n-inlining';
import { I18nOptions, createI18nOptions, mergeDeprecatedI18nOptions } from '../utils/i18n-options';
import { createTranslationLoader } from '../utils/load-translations';
import { i18nInlineEmittedFiles } from '../utils/i18n-inlining';
import { I18nOptions } from '../utils/i18n-options';
import { ensureOutputPaths } from '../utils/output-paths';
import {
InlineOptions,
ProcessBundleFile,
@ -63,11 +63,12 @@ import {
} from '../utils/process-bundle';
import { assertCompatibleAngularVersion } from '../utils/version';
import {
BrowserWebpackConfigOptions,
generateBrowserWebpackConfigFromContext,
generateI18nBrowserWebpackConfigFromContext,
getIndexInputFile,
getIndexOutputFile,
} from '../utils/webpack-browser-config';
import { BundleActionExecutor } from './action-executor';
import { Schema as BrowserBuilderSchema } from './schema';
const cacheDownlevelPath = cachingDisabled ? undefined : findCachePath('angular-build-dl');
@ -99,23 +100,53 @@ export function createBrowserLoggingCallback(
};
}
// todo: the below should be cleaned once dev-server support the new i18n
interface ConfigFromContextReturn {
config: webpack.Configuration;
projectRoot: string;
projectSourceRoot?: string;
}
export async function buildBrowserWebpackConfigFromContext(
options: BrowserBuilderSchema,
context: BuilderContext,
host: virtualFs.Host<fs.Stats>,
i18n: boolean,
): Promise<ConfigFromContextReturn & { i18n: I18nOptions }>;
export async function buildBrowserWebpackConfigFromContext(
options: BrowserBuilderSchema,
context: BuilderContext,
host?: virtualFs.Host<fs.Stats>,
): Promise<ConfigFromContextReturn>;
export async function buildBrowserWebpackConfigFromContext(
options: BrowserBuilderSchema,
context: BuilderContext,
host: virtualFs.Host<fs.Stats> = new NodeJsSyncHost(),
): Promise<{ config: webpack.Configuration; projectRoot: string; projectSourceRoot?: string }> {
i18n = false,
): Promise<ConfigFromContextReturn & { i18n?: I18nOptions }> {
const webpackPartialGenerator = (wco: BrowserWebpackConfigOptions) => [
getCommonConfig(wco),
getBrowserConfig(wco),
getStylesConfig(wco),
getStatsConfig(wco),
getAnalyticsConfig(wco, context),
getCompilerConfig(wco),
wco.buildOptions.webWorkerTsConfig ? getWorkerConfig(wco) : {},
];
if (i18n) {
return generateI18nBrowserWebpackConfigFromContext(
options,
context,
webpackPartialGenerator,
host,
);
}
return generateBrowserWebpackConfigFromContext(
options,
context,
wco => [
getCommonConfig(wco),
getBrowserConfig(wco),
getStylesConfig(wco),
getStatsConfig(wco),
getAnalyticsConfig(wco, context),
getCompilerConfig(wco),
wco.buildOptions.webWorkerTsConfig ? getWorkerConfig(wco) : {},
],
webpackPartialGenerator,
host,
);
}
@ -161,89 +192,24 @@ async function initialize(
projectSourceRoot?: string;
i18n: I18nOptions;
}> {
if (!context.target) {
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 projectRoot = path.join(context.workspaceRoot, (metadata.root as string) || '');
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.shouldInline) {
// Load locales
const loader = await createTranslationLoader();
const usedFormats = new Set<string>();
for (const [locale, desc] of Object.entries(i18n.locales)) {
if (i18n.inlineLocales.has(locale)) {
const result = loader(path.join(projectRoot, desc.file));
usedFormats.add(result.format);
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.',
);
}
desc.format = result.format;
desc.translation = result.translation;
}
}
// Legacy message id's require the format of the translations
if (usedFormats.size > 0) {
options.i18nFormat = [...usedFormats][0];
}
}
const originalOutputPath = options.outputPath;
// If inlining store the output in a temporary location to facilitate post-processing
if (i18n.shouldInline) {
options.outputPath = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'angular-cli-'));
}
const { config, projectSourceRoot } = await buildBrowserWebpackConfigFromContext(
const { config, projectRoot, projectSourceRoot, i18n } = await buildBrowserWebpackConfigFromContext(
options,
context,
host,
true,
);
if (i18n.shouldInline) {
// Remove localize "polyfill"
if (!config.resolve) {
config.resolve = {};
}
if (!config.resolve.alias) {
config.resolve.alias = {};
}
config.resolve.alias['@angular/localize/init'] = require.resolve('./empty.js');
}
let transformedConfig;
if (webpackConfigurationTransform) {
transformedConfig = await webpackConfigurationTransform(config);
}
if (options.deleteOutputPath) {
await deleteOutputDir(
normalize(context.workspaceRoot),
normalize(originalOutputPath),
host,
).toPromise();
deleteOutputDir(
context.workspaceRoot,
originalOutputPath,
);
}
return { config: transformedConfig || config, projectRoot, projectSourceRoot, i18n };
@ -312,15 +278,7 @@ export function buildWebpackBrowser(
return { success };
} else if (success) {
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 });
}
}
const outputPaths = ensureOutputPaths(baseOutputPath, i18n);
let noModuleFiles: EmittedFiles[] | undefined;
let moduleFiles: EmittedFiles[] | undefined;
@ -586,14 +544,6 @@ export function buildWebpackBrowser(
}
} finally {
executor.stop();
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
@ -787,70 +737,6 @@ 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;

View File

@ -7,9 +7,9 @@
*/
import { createHash } from 'crypto';
import * as fs from 'fs';
import { copyFile } from '../utils/copy-file';
import { manglingDisabled } from '../utils/environment-options';
import { CacheKey, ProcessBundleOptions, ProcessBundleResult } from '../utils/process-bundle';
import { copyFile } from './copy-file';
import { manglingDisabled } from './environment-options';
import { CacheKey, ProcessBundleOptions, ProcessBundleResult } from './process-bundle';
const cacache = require('cacache');
const packageVersion = require('../../package.json').version;

View File

@ -9,9 +9,9 @@ import JestWorker from 'jest-worker';
import * as os from 'os';
import * as path from 'path';
import * as v8 from 'v8';
import { I18nOptions } from '../utils/i18n-options';
import { InlineOptions, ProcessBundleOptions, ProcessBundleResult } from '../utils/process-bundle';
import { BundleActionCache } from './action-cache';
import { I18nOptions } from './i18n-options';
import { InlineOptions, ProcessBundleOptions, ProcessBundleResult } from './process-bundle';
const hasThreadSupport = (() => {
try {
@ -28,10 +28,10 @@ const hasThreadSupport = (() => {
// 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');
let workerFile = require.resolve('./process-bundle');
workerFile =
path.extname(workerFile) === '.ts'
? require.resolve('../utils/process-bundle-bootstrap')
? require.resolve('./process-bundle-bootstrap')
: workerFile;
export class BundleActionExecutor {

View File

@ -5,22 +5,17 @@
* 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 { Path, resolve, virtualFs } from '@angular-devkit/core';
import { EMPTY, Observable } from 'rxjs';
import { concatMap, last, map } from 'rxjs/operators';
import { resolve } from 'path';
import * as rimraf from 'rimraf';
/**
* Delete an output directory, but error out if it's the root of the project.
*/
export function deleteOutputDir(root: Path, outputPath: Path, host: virtualFs.Host): Observable<void> {
export function deleteOutputDir(root: string, outputPath: string) {
const resolvedOutputPath = resolve(root, outputPath);
if (resolvedOutputPath === root) {
throw new Error('Output path MUST not be project root directory!');
}
return host.exists(resolvedOutputPath).pipe(
concatMap(exists => exists ? host.delete(resolvedOutputPath) : EMPTY),
last(null, null),
map(() => undefined),
);
rimraf.sync(resolvedOutputPath);
}

View File

@ -5,12 +5,16 @@
* 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 { BuilderContext } from '@angular-devkit/architect';
import { EmittedFiles } from '@angular-devkit/build-webpack';
import * as fs from 'fs';
import * as path from 'path';
import { BundleActionExecutor } from './action-executor';
import { copyAssets } from './copy-assets';
import { I18nOptions } from './i18n-options';
import { InlineOptions } from './process-bundle';
export function emittedFilesToInlineOptions(
function emittedFilesToInlineOptions(
emittedFiles: EmittedFiles[],
scriptsEntryPointName: string[],
emittedPath: string,
@ -55,3 +59,68 @@ export function emittedFilesToInlineOptions(
return { options, originalFiles };
}
export async function i18nInlineEmittedFiles(
context: BuilderContext,
emittedFiles: EmittedFiles[],
i18n: I18nOptions,
baseOutputPath: string,
outputPaths: string[],
scriptsEntryPointName: string[],
emittedPath: string,
es5: boolean,
missingTranslation: 'error' | 'warning' | 'ignore' | undefined,
): Promise<boolean> {
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();
}
if (hasErrors) {
context.logger.error('Localized bundle generation failed.');
} else {
context.logger.info('Localized bundle generation complete.');
}
return !hasErrors;
}

View File

@ -5,7 +5,16 @@
* 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 { json } from '@angular-devkit/core';
import { BuilderContext } from '@angular-devkit/architect';
import { json, virtualFs } from '@angular-devkit/core';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import * as rimraf from 'rimraf';
import { readTsconfig } from '../angular-cli-files/utilities/read-tsconfig';
import { Schema as BrowserBuilderSchema } from '../browser/schema';
import { Schema as ServerBuilderSchema } from '../server/schema';
import { createTranslationLoader } from './load-translations';
export interface I18nOptions {
inlineLocales: Set<string>;
@ -82,7 +91,80 @@ export function createI18nOptions(
return i18n;
}
export function mergeDeprecatedI18nOptions(i18n: I18nOptions, i18nLocale: string | undefined, i18nFile: string | undefined): I18nOptions {
export async function configureI18nBuild<T extends BrowserBuilderSchema | ServerBuilderSchema>(
context: BuilderContext,
host: virtualFs.Host<fs.Stats>,
options: T,
): Promise<{
buildOptions: T,
i18n: I18nOptions,
}> {
if (!context.target) {
throw new Error('The builder requires a target.');
}
const buildOptions = { ... options };
const tsConfig = readTsconfig(buildOptions.tsConfig, context.workspaceRoot);
const usingIvy = tsConfig.options.enableIvy !== false;
const metadata = await context.getProjectMetadata(context.target);
const i18n = createI18nOptions(metadata, buildOptions.localize);
// Until 11.0, support deprecated i18n options when not using new localize option
// i18nFormat is automatically calculated
if (buildOptions.localize === undefined && usingIvy) {
mergeDeprecatedI18nOptions(i18n, buildOptions.i18nLocale, buildOptions.i18nFile);
} else if (buildOptions.localize !== undefined && !usingIvy) {
buildOptions.localize = undefined;
context.logger.warn(`Option 'localize' is not supported with View Engine.`);
}
if (i18n.inlineLocales.size > 0) {
// Load locales
const loader = await createTranslationLoader();
const projectRoot = path.join(context.workspaceRoot, (metadata.root as string) || '');
const usedFormats = new Set<string>();
for (const [locale, desc] of Object.entries(i18n.locales)) {
if (i18n.inlineLocales.has(locale)) {
const result = loader(path.join(projectRoot, desc.file));
usedFormats.add(result.format);
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.',
);
}
desc.format = result.format;
desc.translation = result.translation;
}
}
// Legacy message id's require the format of the translations
if (usedFormats.size > 0) {
buildOptions.i18nFormat = [...usedFormats][0];
}
}
// If inlining store the output in a temporary location to facilitate post-processing
if (i18n.shouldInline) {
const tempPath = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'angular-cli-i18n-'));
buildOptions.outputPath = tempPath;
// Remove temporary directory used for i18n processing
process.on('exit', () => {
try {
rimraf.sync(tempPath);
} catch { }
});
}
return { buildOptions, i18n };
}
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.`);
}

View File

@ -0,0 +1,25 @@
/**
* @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 { existsSync, mkdirSync } from 'fs';
import { join } from 'path';
import { I18nOptions } from './i18n-options';
export function ensureOutputPaths(baseOutputPath: string, i18n: I18nOptions): string[] {
const outputPaths = i18n.shouldInline && !i18n.flatOutput
? [...i18n.inlineLocales].map(l => join(baseOutputPath, l))
: [baseOutputPath];
for (const outputPath of outputPaths) {
if (!existsSync(outputPath)) {
mkdirSync(outputPath, { recursive: true });
}
}
return outputPaths;
}

View File

@ -5,7 +5,7 @@
* 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 { NodePath, parseSync, transformAsync, traverse, types } from '@babel/core';
import { NodePath, ParseResult, parseSync, transformAsync, traverse, types } from '@babel/core';
import { createHash } from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
@ -454,7 +454,9 @@ async function processRuntime(
(options.cacheKeys && options.cacheKeys[CacheKey.DownlevelMap]) || null,
);
fs.writeFileSync(downlevelFilePath + '.map', downlevelMap);
downlevelCode += `\n//# sourceMappingURL=${path.basename(downlevelFilePath)}.map`;
if (!options.hiddenSourceMaps) {
downlevelCode += `\n//# sourceMappingURL=${path.basename(downlevelFilePath)}.map`;
}
}
await cachePut(
downlevelCode,
@ -594,13 +596,29 @@ 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 });
let ast: ParseResult | undefined | null;
try {
ast = parseSync(options.code, {
babelrc: false,
sourceType: 'script',
});
} catch (error) {
if (error.message) {
// Make the error more readable.
// Same errors will contain the full content of the file as the error message
// Which makes it hard to find the actual error message.
const index = error.message.indexOf(')\n');
const msg = index !== -1 ? error.message.substr(0, index + 1) : error.message;
throw new Error(`${msg}\nAn error occurred inlining file "${options.filename}"`);
}
}
if (!ast) {
throw new Error(`Unknown error occurred inlining file "${options.filename}"`);
}
const positions: LocalizePosition[] = [];
if (options.es5) {
traverse(ast, {
CallExpression(path: NodePath<types.CallExpression>) {

View File

@ -93,7 +93,11 @@ allTests = allTests
// Disabled on rc.0 due to needed sync with devkit for changes.
.filter(name => !name.endsWith('/service-worker.ts'));
if (!argv.ve) {
if (argv.ve) {
// Remove Ivy specific tests
allTests = allTests
.filter(name => !name.includes('tests/i18n/ivy-localize-'));
} else {
// These tests are disabled on the Ivy CI jobs because:
// - Ivy doesn't support the functionality yet
// - The test itself is not applicable to Ivy