Skip to content

Commit

Permalink
refactor(@angular-devkit/build-angular): support ESM `@angular/locali…
Browse files Browse the repository at this point in the history
…ze` usage

With the Angular CLI currently being a CommonJS package, this change uses a dynamic import to load `@angular/localize` which may be ESM. CommonJS code can load ESM code via a dynamic import. Unfortunately, TypeScript will currently, unconditionally downlevel dynamic import into a require call. require calls cannot load ESM code and will result in a runtime error. To workaround this, a Function constructor is used to prevent TypeScript from changing the dynamic import. Once TypeScript provides support for keeping the dynamic import this workaround can be dropped and replaced with a standard dynamic import.
  • Loading branch information
clydin committed Sep 27, 2021
1 parent a02f48d commit fb210e5
Show file tree
Hide file tree
Showing 6 changed files with 282 additions and 61 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,28 @@ import * as fs from 'fs';
import * as path from 'path';

export type DiagnosticReporter = (type: 'error' | 'warning' | 'info', message: string) => void;

/**
* An interface representing the factory functions for the `@angular/localize` translation Babel plugins.
* This must be provided for the ESM imports since dynamic imports are required to be asynchronous and
* Babel presets currently can only be synchronous.
*
* TODO_ESM: Remove all deep imports once `@angular/localize` is published with the `tools` entry point
*/
export interface I18nPluginCreators {
/* eslint-disable max-len */
makeEs2015TranslatePlugin: typeof import('@angular/localize/src/tools/src/translate/source_files/es2015_translate_plugin').makeEs2015TranslatePlugin;
makeEs5TranslatePlugin: typeof import('@angular/localize/src/tools/src/translate/source_files/es5_translate_plugin').makeEs5TranslatePlugin;
makeLocalePlugin: typeof import('@angular/localize/src/tools/src/translate/source_files/locale_plugin').makeLocalePlugin;
/* eslint-enable max-len */
}

export interface ApplicationPresetOptions {
i18n?: {
locale: string;
missingTranslationBehavior?: 'error' | 'warning' | 'ignore';
translation?: unknown;
pluginCreators?: I18nPluginCreators;
};

angularLinker?: {
Expand Down Expand Up @@ -80,14 +97,19 @@ function createI18nPlugins(
translation: unknown | undefined,
missingTranslationBehavior: 'error' | 'warning' | 'ignore',
diagnosticReporter: DiagnosticReporter | undefined,
// TODO_ESM: Make `pluginCreators` required once `@angular/localize` is published with the `tools` entry point
pluginCreators: I18nPluginCreators | undefined,
) {
const diagnostics = createI18nDiagnostics(diagnosticReporter);
const plugins = [];

if (translation) {
const {
makeEs2015TranslatePlugin,
} = require('@angular/localize/src/tools/src/translate/source_files/es2015_translate_plugin');
// TODO_ESM: Remove all deep imports once `@angular/localize` is published with the `tools` entry point
} =
pluginCreators ??
require('@angular/localize/src/tools/src/translate/source_files/es2015_translate_plugin');
plugins.push(
makeEs2015TranslatePlugin(diagnostics, translation, {
missingTranslation: missingTranslationBehavior,
Expand All @@ -96,7 +118,10 @@ function createI18nPlugins(

const {
makeEs5TranslatePlugin,
} = require('@angular/localize/src/tools/src/translate/source_files/es5_translate_plugin');
// TODO_ESM: Remove all deep imports once `@angular/localize` is published with the `tools` entry point
} =
pluginCreators ??
require('@angular/localize/src/tools/src/translate/source_files/es5_translate_plugin');
plugins.push(
makeEs5TranslatePlugin(diagnostics, translation, {
missingTranslation: missingTranslationBehavior,
Expand All @@ -106,7 +131,10 @@ function createI18nPlugins(

const {
makeLocalePlugin,
} = require('@angular/localize/src/tools/src/translate/source_files/locale_plugin');
// TODO_ESM: Remove all deep imports once `@angular/localize` is published with the `tools` entry point
} =
pluginCreators ??
require('@angular/localize/src/tools/src/translate/source_files/locale_plugin');
plugins.push(makeLocalePlugin(locale));

return plugins;
Expand Down Expand Up @@ -168,12 +196,13 @@ export default function (api: unknown, options: ApplicationPresetOptions) {
}

if (options.i18n) {
const { locale, missingTranslationBehavior, translation } = options.i18n;
const { locale, missingTranslationBehavior, pluginCreators, translation } = options.i18n;
const i18nPlugins = createI18nPlugins(
locale,
translation,
missingTranslationBehavior || 'ignore',
options.diagnosticReporter,
pluginCreators,
);

plugins.push(...i18nPlugins);
Expand Down
27 changes: 25 additions & 2 deletions packages/angular_devkit/build_angular/src/babel/webpack-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import { custom } from 'babel-loader';
import { ScriptTarget } from 'typescript';
import { loadEsmModule } from '../utils/load-esm';
import { ApplicationPresetOptions } from './presets/application';
import { ApplicationPresetOptions, I18nPluginCreators } from './presets/application';

interface AngularCustomOptions extends Pick<ApplicationPresetOptions, 'angularLinker' | 'i18n'> {
forceAsyncTransformation: boolean;
Expand All @@ -33,6 +33,11 @@ let linkerPluginCreator:
| typeof import('@angular/compiler-cli/linker/babel').createEs2015LinkerPlugin
| undefined;

/**
* Cached instance of the localize Babel plugins factory functions.
*/
let i18nPluginCreators: I18nPluginCreators | undefined;

async function requiresLinking(path: string, source: string): Promise<boolean> {
// @angular/core and @angular/compiler will cause false positives
// Also, TypeScript files do not require linking
Expand Down Expand Up @@ -117,7 +122,25 @@ export default custom<AngularCustomOptions>(() => {
!/[\\/]@angular[\\/](?:compiler|localize)/.test(this.resourcePath) &&
source.includes('$localize')
) {
customOptions.i18n = i18n as ApplicationPresetOptions['i18n'];
// Load the i18n plugin creators from the new `@angular/localize/tools` entry point.
// This may fail during the transition to ESM due to the entry point not yet existing.
// During the transition, this will always attempt to load the entry point for each file.
// This will only occur during prerelease and will be automatically corrected once the new
// entry point exists.
// TODO_ESM: Make import failure an error once the `tools` entry point exists.
if (i18nPluginCreators === undefined) {
// Load ESM `@angular/localize/tools` using the TypeScript dynamic import workaround.
// Once TypeScript provides support for keeping the dynamic import this workaround can be
// changed to a direct dynamic import.
try {
i18nPluginCreators = await loadEsmModule<I18nPluginCreators>('@angular/localize/tools');
} catch {}
}

customOptions.i18n = {
...(i18n as ApplicationPresetOptions['i18n']),
i18nPluginCreators,
} as ApplicationPresetOptions['i18n'];
shouldProcess = true;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import * as path from 'path';
import webpack from 'webpack';
import { ExecutionTransformer } from '../../transforms';
import { createI18nOptions } from '../../utils/i18n-options';
import { loadEsmModule } from '../../utils/load-esm';
import { assertCompatibleAngularVersion } from '../../utils/version';
import { generateBrowserWebpackConfigFromContext } from '../../utils/webpack-browser-config';
import {
Expand All @@ -31,6 +32,24 @@ import { Format, Schema } from './schema';

export type ExtractI18nBuilderOptions = Schema & JsonObject;

/**
* The manually constructed type for the `@angular/localize/tools` module.
* This type only contains the exports that are need for this file.
*
* TODO_ESM: Remove once the `tools` entry point exists in a published package version
*/
interface LocalizeToolsModule {
/* eslint-disable max-len */
checkDuplicateMessages: typeof import('@angular/localize/src/tools/src/extract/duplicates').checkDuplicateMessages;
XmbTranslationSerializer: typeof import('@angular/localize/src/tools/src/extract/translation_files/xmb_translation_serializer').XmbTranslationSerializer;
SimpleJsonTranslationSerializer: typeof import('@angular/localize/src/tools/src/extract/translation_files/json_translation_serializer').SimpleJsonTranslationSerializer;
Xliff1TranslationSerializer: typeof import('@angular/localize/src/tools/src/extract/translation_files/xliff1_translation_serializer').Xliff1TranslationSerializer;
Xliff2TranslationSerializer: typeof import('@angular/localize/src/tools/src/extract/translation_files/xliff2_translation_serializer').Xliff2TranslationSerializer;
ArbTranslationSerializer: typeof import('@angular/localize/src/tools/src/extract/translation_files/arb_translation_serializer').ArbTranslationSerializer;
LegacyMessageIdMigrationSerializer: typeof import('@angular/localize/src/tools/src/extract/translation_files/legacy_message_id_migration_serializer').LegacyMessageIdMigrationSerializer;
/* eslint-enable max-len */
}

function getI18nOutfile(format: string | undefined) {
switch (format) {
case 'xmb':
Expand All @@ -52,6 +71,7 @@ function getI18nOutfile(format: string | undefined) {
}

async function getSerializer(
localizeToolsModule: LocalizeToolsModule | undefined,
format: Format,
sourceLocale: string,
basePath: string,
Expand All @@ -60,45 +80,57 @@ async function getSerializer(
) {
switch (format) {
case Format.Xmb:
const { XmbTranslationSerializer } = await import(
'@angular/localize/src/tools/src/extract/translation_files/xmb_translation_serializer'
);
const { XmbTranslationSerializer } =
localizeToolsModule ??
(await import(
'@angular/localize/src/tools/src/extract/translation_files/xmb_translation_serializer'
));

// eslint-disable-next-line @typescript-eslint/no-explicit-any
return new XmbTranslationSerializer(basePath as any, useLegacyIds);
case Format.Xlf:
case Format.Xlif:
case Format.Xliff:
const { Xliff1TranslationSerializer } = await import(
'@angular/localize/src/tools/src/extract/translation_files/xliff1_translation_serializer'
);
const { Xliff1TranslationSerializer } =
localizeToolsModule ??
(await import(
'@angular/localize/src/tools/src/extract/translation_files/xliff1_translation_serializer'
));

// eslint-disable-next-line @typescript-eslint/no-explicit-any
return new Xliff1TranslationSerializer(sourceLocale, basePath as any, useLegacyIds, {});
case Format.Xlf2:
case Format.Xliff2:
const { Xliff2TranslationSerializer } = await import(
'@angular/localize/src/tools/src/extract/translation_files/xliff2_translation_serializer'
);
const { Xliff2TranslationSerializer } =
localizeToolsModule ??
(await import(
'@angular/localize/src/tools/src/extract/translation_files/xliff2_translation_serializer'
));

// eslint-disable-next-line @typescript-eslint/no-explicit-any
return new Xliff2TranslationSerializer(sourceLocale, basePath as any, useLegacyIds, {});
case Format.Json:
const { SimpleJsonTranslationSerializer } = await import(
'@angular/localize/src/tools/src/extract/translation_files/json_translation_serializer'
);
const { SimpleJsonTranslationSerializer } =
localizeToolsModule ??
(await import(
'@angular/localize/src/tools/src/extract/translation_files/json_translation_serializer'
));

return new SimpleJsonTranslationSerializer(sourceLocale);
case Format.LegacyMigrate:
const { LegacyMessageIdMigrationSerializer } = await import(
'@angular/localize/src/tools/src/extract/translation_files/legacy_message_id_migration_serializer'
);
const { LegacyMessageIdMigrationSerializer } =
localizeToolsModule ??
(await import(
'@angular/localize/src/tools/src/extract/translation_files/legacy_message_id_migration_serializer'
));

return new LegacyMessageIdMigrationSerializer(diagnostics);
case Format.Arb:
const { ArbTranslationSerializer } = await import(
'@angular/localize/src/tools/src/extract/translation_files/arb_translation_serializer'
);
const { ArbTranslationSerializer } =
localizeToolsModule ??
(await import(
'@angular/localize/src/tools/src/extract/translation_files/arb_translation_serializer'
));

const fileSystem = {
relative(from: string, to: string): string {
Expand Down Expand Up @@ -253,6 +285,17 @@ export async function execute(
};
}

// All the localize usages are setup to first try the ESM entry point then fallback to the deep imports.
// This provides interim compatibility while the framework is transitioned to bundled ESM packages.
// TODO_ESM: Remove all deep imports once `@angular/localize` is published with the `tools` entry point
let localizeToolsModule;
try {
// Load ESM `@angular/localize/tools` using the TypeScript dynamic import workaround.
// Once TypeScript provides support for keeping the dynamic import this workaround can be
// changed to a direct dynamic import.
localizeToolsModule = await loadEsmModule<LocalizeToolsModule>('@angular/localize/tools');
} catch {}

const webpackResult = await runWebpack(
(await transforms?.webpackConfiguration?.(config)) || config,
context,
Expand All @@ -272,9 +315,8 @@ export async function execute(

const basePath = config.context || projectRoot;

const { checkDuplicateMessages } = await import(
'@angular/localize/src/tools/src/extract/duplicates'
);
const { checkDuplicateMessages } =
localizeToolsModule ?? (await import('@angular/localize/src/tools/src/extract/duplicates'));

// The filesystem is used to create a relative path for each file
// from the basePath. This relative path is then used in the error message.
Expand All @@ -297,6 +339,7 @@ export async function execute(

// Serialize all extracted messages
const serializer = await getSerializer(
localizeToolsModule,
format,
i18n.sourceLocale,
basePath,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
* found in the LICENSE file at https://angular.io/license
*/

import { MessageExtractor } from '@angular/localize/src/tools/src/extract/extraction';
import * as nodePath from 'path';
import { loadEsmModule } from '../../utils/load-esm';

// Extract loader source map parameter type since it is not exported directly
type LoaderSourceMap = Parameters<import('webpack').LoaderDefinitionFunction>[1];
Expand All @@ -21,9 +21,49 @@ export default function localizeExtractLoader(
content: string,
map: LoaderSourceMap,
) {
// eslint-disable-next-line @typescript-eslint/no-this-alias
const loaderContext = this;
const options = this.getOptions();
const callback = this.async();

extract(this, content, map, options).then(
() => {
// Pass through the original content now that messages have been extracted
callback(undefined, content, map);
},
(error) => {
callback(error);
},
);
}

async function extract(
loaderContext: import('webpack').LoaderContext<LocalizeExtractLoaderOptions>,
content: string,
map: string | LoaderSourceMap | undefined,
options: LocalizeExtractLoaderOptions,
) {
// Try to load the `@angular/localize` message extractor.
// All the localize usages are setup to first try the ESM entry point then fallback to the deep imports.
// This provides interim compatibility while the framework is transitioned to bundled ESM packages.
// TODO_ESM: Remove all deep imports once `@angular/localize` is published with the `tools` entry point
let MessageExtractor;
try {
try {
// Load ESM `@angular/localize/tools` using the TypeScript dynamic import workaround.
// Once TypeScript provides support for keeping the dynamic import this workaround can be
// changed to a direct dynamic import.
const localizeToolsModule = await loadEsmModule<
typeof import('@angular/localize/src/tools/src/extract/extraction')
>('@angular/localize/tools');
MessageExtractor = localizeToolsModule.MessageExtractor;
} catch {
MessageExtractor = (await import('@angular/localize/src/tools/src/extract/extraction'))
.MessageExtractor;
}
} catch {
throw new Error(
`Unable to load message extractor. Please ensure '@angular/localize' is installed.`,
);
}

// Setup a Webpack-based logger instance
const logger = {
Expand Down Expand Up @@ -82,15 +122,12 @@ export default function localizeExtractLoader(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const extractor = new MessageExtractor(filesystem as any, logger, {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
basePath: this.rootContext as any,
basePath: loaderContext.rootContext as any,
useSourceMaps: !!map,
});

const messages = extractor.extractMessages(filename);
if (messages.length > 0) {
options?.messageHandler(messages);
}

// Pass through the original content now that messages have been extracted
this.callback(undefined, content, map);
}
Loading

0 comments on commit fb210e5

Please sign in to comment.