Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor(@angular-devkit/build-angular): support ESM @angular/localize usage #21818

Merged
merged 1 commit into from
Sep 27, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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);
}