Skip to content

Commit fb210e5

Browse files
committed
refactor(@angular-devkit/build-angular): support ESM @angular/localize 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.
1 parent a02f48d commit fb210e5

File tree

6 files changed

+282
-61
lines changed

6 files changed

+282
-61
lines changed

packages/angular_devkit/build_angular/src/babel/presets/application.ts

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,28 @@ import * as fs from 'fs';
1010
import * as path from 'path';
1111

1212
export type DiagnosticReporter = (type: 'error' | 'warning' | 'info', message: string) => void;
13+
14+
/**
15+
* An interface representing the factory functions for the `@angular/localize` translation Babel plugins.
16+
* This must be provided for the ESM imports since dynamic imports are required to be asynchronous and
17+
* Babel presets currently can only be synchronous.
18+
*
19+
* TODO_ESM: Remove all deep imports once `@angular/localize` is published with the `tools` entry point
20+
*/
21+
export interface I18nPluginCreators {
22+
/* eslint-disable max-len */
23+
makeEs2015TranslatePlugin: typeof import('@angular/localize/src/tools/src/translate/source_files/es2015_translate_plugin').makeEs2015TranslatePlugin;
24+
makeEs5TranslatePlugin: typeof import('@angular/localize/src/tools/src/translate/source_files/es5_translate_plugin').makeEs5TranslatePlugin;
25+
makeLocalePlugin: typeof import('@angular/localize/src/tools/src/translate/source_files/locale_plugin').makeLocalePlugin;
26+
/* eslint-enable max-len */
27+
}
28+
1329
export interface ApplicationPresetOptions {
1430
i18n?: {
1531
locale: string;
1632
missingTranslationBehavior?: 'error' | 'warning' | 'ignore';
1733
translation?: unknown;
34+
pluginCreators?: I18nPluginCreators;
1835
};
1936

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

87106
if (translation) {
88107
const {
89108
makeEs2015TranslatePlugin,
90-
} = require('@angular/localize/src/tools/src/translate/source_files/es2015_translate_plugin');
109+
// TODO_ESM: Remove all deep imports once `@angular/localize` is published with the `tools` entry point
110+
} =
111+
pluginCreators ??
112+
require('@angular/localize/src/tools/src/translate/source_files/es2015_translate_plugin');
91113
plugins.push(
92114
makeEs2015TranslatePlugin(diagnostics, translation, {
93115
missingTranslation: missingTranslationBehavior,
@@ -96,7 +118,10 @@ function createI18nPlugins(
96118

97119
const {
98120
makeEs5TranslatePlugin,
99-
} = require('@angular/localize/src/tools/src/translate/source_files/es5_translate_plugin');
121+
// TODO_ESM: Remove all deep imports once `@angular/localize` is published with the `tools` entry point
122+
} =
123+
pluginCreators ??
124+
require('@angular/localize/src/tools/src/translate/source_files/es5_translate_plugin');
100125
plugins.push(
101126
makeEs5TranslatePlugin(diagnostics, translation, {
102127
missingTranslation: missingTranslationBehavior,
@@ -106,7 +131,10 @@ function createI18nPlugins(
106131

107132
const {
108133
makeLocalePlugin,
109-
} = require('@angular/localize/src/tools/src/translate/source_files/locale_plugin');
134+
// TODO_ESM: Remove all deep imports once `@angular/localize` is published with the `tools` entry point
135+
} =
136+
pluginCreators ??
137+
require('@angular/localize/src/tools/src/translate/source_files/locale_plugin');
110138
plugins.push(makeLocalePlugin(locale));
111139

112140
return plugins;
@@ -168,12 +196,13 @@ export default function (api: unknown, options: ApplicationPresetOptions) {
168196
}
169197

170198
if (options.i18n) {
171-
const { locale, missingTranslationBehavior, translation } = options.i18n;
199+
const { locale, missingTranslationBehavior, pluginCreators, translation } = options.i18n;
172200
const i18nPlugins = createI18nPlugins(
173201
locale,
174202
translation,
175203
missingTranslationBehavior || 'ignore',
176204
options.diagnosticReporter,
205+
pluginCreators,
177206
);
178207

179208
plugins.push(...i18nPlugins);

packages/angular_devkit/build_angular/src/babel/webpack-loader.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import { custom } from 'babel-loader';
1010
import { ScriptTarget } from 'typescript';
1111
import { loadEsmModule } from '../utils/load-esm';
12-
import { ApplicationPresetOptions } from './presets/application';
12+
import { ApplicationPresetOptions, I18nPluginCreators } from './presets/application';
1313

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

36+
/**
37+
* Cached instance of the localize Babel plugins factory functions.
38+
*/
39+
let i18nPluginCreators: I18nPluginCreators | undefined;
40+
3641
async function requiresLinking(path: string, source: string): Promise<boolean> {
3742
// @angular/core and @angular/compiler will cause false positives
3843
// Also, TypeScript files do not require linking
@@ -117,7 +122,25 @@ export default custom<AngularCustomOptions>(() => {
117122
!/[\\/]@angular[\\/](?:compiler|localize)/.test(this.resourcePath) &&
118123
source.includes('$localize')
119124
) {
120-
customOptions.i18n = i18n as ApplicationPresetOptions['i18n'];
125+
// Load the i18n plugin creators from the new `@angular/localize/tools` entry point.
126+
// This may fail during the transition to ESM due to the entry point not yet existing.
127+
// During the transition, this will always attempt to load the entry point for each file.
128+
// This will only occur during prerelease and will be automatically corrected once the new
129+
// entry point exists.
130+
// TODO_ESM: Make import failure an error once the `tools` entry point exists.
131+
if (i18nPluginCreators === undefined) {
132+
// Load ESM `@angular/localize/tools` using the TypeScript dynamic import workaround.
133+
// Once TypeScript provides support for keeping the dynamic import this workaround can be
134+
// changed to a direct dynamic import.
135+
try {
136+
i18nPluginCreators = await loadEsmModule<I18nPluginCreators>('@angular/localize/tools');
137+
} catch {}
138+
}
139+
140+
customOptions.i18n = {
141+
...(i18n as ApplicationPresetOptions['i18n']),
142+
i18nPluginCreators,
143+
} as ApplicationPresetOptions['i18n'];
121144
shouldProcess = true;
122145
}
123146

packages/angular_devkit/build_angular/src/builders/extract-i18n/index.ts

Lines changed: 64 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import * as path from 'path';
1616
import webpack from 'webpack';
1717
import { ExecutionTransformer } from '../../transforms';
1818
import { createI18nOptions } from '../../utils/i18n-options';
19+
import { loadEsmModule } from '../../utils/load-esm';
1920
import { assertCompatibleAngularVersion } from '../../utils/version';
2021
import { generateBrowserWebpackConfigFromContext } from '../../utils/webpack-browser-config';
2122
import {
@@ -31,6 +32,24 @@ import { Format, Schema } from './schema';
3132

3233
export type ExtractI18nBuilderOptions = Schema & JsonObject;
3334

35+
/**
36+
* The manually constructed type for the `@angular/localize/tools` module.
37+
* This type only contains the exports that are need for this file.
38+
*
39+
* TODO_ESM: Remove once the `tools` entry point exists in a published package version
40+
*/
41+
interface LocalizeToolsModule {
42+
/* eslint-disable max-len */
43+
checkDuplicateMessages: typeof import('@angular/localize/src/tools/src/extract/duplicates').checkDuplicateMessages;
44+
XmbTranslationSerializer: typeof import('@angular/localize/src/tools/src/extract/translation_files/xmb_translation_serializer').XmbTranslationSerializer;
45+
SimpleJsonTranslationSerializer: typeof import('@angular/localize/src/tools/src/extract/translation_files/json_translation_serializer').SimpleJsonTranslationSerializer;
46+
Xliff1TranslationSerializer: typeof import('@angular/localize/src/tools/src/extract/translation_files/xliff1_translation_serializer').Xliff1TranslationSerializer;
47+
Xliff2TranslationSerializer: typeof import('@angular/localize/src/tools/src/extract/translation_files/xliff2_translation_serializer').Xliff2TranslationSerializer;
48+
ArbTranslationSerializer: typeof import('@angular/localize/src/tools/src/extract/translation_files/arb_translation_serializer').ArbTranslationSerializer;
49+
LegacyMessageIdMigrationSerializer: typeof import('@angular/localize/src/tools/src/extract/translation_files/legacy_message_id_migration_serializer').LegacyMessageIdMigrationSerializer;
50+
/* eslint-enable max-len */
51+
}
52+
3453
function getI18nOutfile(format: string | undefined) {
3554
switch (format) {
3655
case 'xmb':
@@ -52,6 +71,7 @@ function getI18nOutfile(format: string | undefined) {
5271
}
5372

5473
async function getSerializer(
74+
localizeToolsModule: LocalizeToolsModule | undefined,
5575
format: Format,
5676
sourceLocale: string,
5777
basePath: string,
@@ -60,45 +80,57 @@ async function getSerializer(
6080
) {
6181
switch (format) {
6282
case Format.Xmb:
63-
const { XmbTranslationSerializer } = await import(
64-
'@angular/localize/src/tools/src/extract/translation_files/xmb_translation_serializer'
65-
);
83+
const { XmbTranslationSerializer } =
84+
localizeToolsModule ??
85+
(await import(
86+
'@angular/localize/src/tools/src/extract/translation_files/xmb_translation_serializer'
87+
));
6688

6789
// eslint-disable-next-line @typescript-eslint/no-explicit-any
6890
return new XmbTranslationSerializer(basePath as any, useLegacyIds);
6991
case Format.Xlf:
7092
case Format.Xlif:
7193
case Format.Xliff:
72-
const { Xliff1TranslationSerializer } = await import(
73-
'@angular/localize/src/tools/src/extract/translation_files/xliff1_translation_serializer'
74-
);
94+
const { Xliff1TranslationSerializer } =
95+
localizeToolsModule ??
96+
(await import(
97+
'@angular/localize/src/tools/src/extract/translation_files/xliff1_translation_serializer'
98+
));
7599

76100
// eslint-disable-next-line @typescript-eslint/no-explicit-any
77101
return new Xliff1TranslationSerializer(sourceLocale, basePath as any, useLegacyIds, {});
78102
case Format.Xlf2:
79103
case Format.Xliff2:
80-
const { Xliff2TranslationSerializer } = await import(
81-
'@angular/localize/src/tools/src/extract/translation_files/xliff2_translation_serializer'
82-
);
104+
const { Xliff2TranslationSerializer } =
105+
localizeToolsModule ??
106+
(await import(
107+
'@angular/localize/src/tools/src/extract/translation_files/xliff2_translation_serializer'
108+
));
83109

84110
// eslint-disable-next-line @typescript-eslint/no-explicit-any
85111
return new Xliff2TranslationSerializer(sourceLocale, basePath as any, useLegacyIds, {});
86112
case Format.Json:
87-
const { SimpleJsonTranslationSerializer } = await import(
88-
'@angular/localize/src/tools/src/extract/translation_files/json_translation_serializer'
89-
);
113+
const { SimpleJsonTranslationSerializer } =
114+
localizeToolsModule ??
115+
(await import(
116+
'@angular/localize/src/tools/src/extract/translation_files/json_translation_serializer'
117+
));
90118

91119
return new SimpleJsonTranslationSerializer(sourceLocale);
92120
case Format.LegacyMigrate:
93-
const { LegacyMessageIdMigrationSerializer } = await import(
94-
'@angular/localize/src/tools/src/extract/translation_files/legacy_message_id_migration_serializer'
95-
);
121+
const { LegacyMessageIdMigrationSerializer } =
122+
localizeToolsModule ??
123+
(await import(
124+
'@angular/localize/src/tools/src/extract/translation_files/legacy_message_id_migration_serializer'
125+
));
96126

97127
return new LegacyMessageIdMigrationSerializer(diagnostics);
98128
case Format.Arb:
99-
const { ArbTranslationSerializer } = await import(
100-
'@angular/localize/src/tools/src/extract/translation_files/arb_translation_serializer'
101-
);
129+
const { ArbTranslationSerializer } =
130+
localizeToolsModule ??
131+
(await import(
132+
'@angular/localize/src/tools/src/extract/translation_files/arb_translation_serializer'
133+
));
102134

103135
const fileSystem = {
104136
relative(from: string, to: string): string {
@@ -253,6 +285,17 @@ export async function execute(
253285
};
254286
}
255287

288+
// All the localize usages are setup to first try the ESM entry point then fallback to the deep imports.
289+
// This provides interim compatibility while the framework is transitioned to bundled ESM packages.
290+
// TODO_ESM: Remove all deep imports once `@angular/localize` is published with the `tools` entry point
291+
let localizeToolsModule;
292+
try {
293+
// Load ESM `@angular/localize/tools` using the TypeScript dynamic import workaround.
294+
// Once TypeScript provides support for keeping the dynamic import this workaround can be
295+
// changed to a direct dynamic import.
296+
localizeToolsModule = await loadEsmModule<LocalizeToolsModule>('@angular/localize/tools');
297+
} catch {}
298+
256299
const webpackResult = await runWebpack(
257300
(await transforms?.webpackConfiguration?.(config)) || config,
258301
context,
@@ -272,9 +315,8 @@ export async function execute(
272315

273316
const basePath = config.context || projectRoot;
274317

275-
const { checkDuplicateMessages } = await import(
276-
'@angular/localize/src/tools/src/extract/duplicates'
277-
);
318+
const { checkDuplicateMessages } =
319+
localizeToolsModule ?? (await import('@angular/localize/src/tools/src/extract/duplicates'));
278320

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

298340
// Serialize all extracted messages
299341
const serializer = await getSerializer(
342+
localizeToolsModule,
300343
format,
301344
i18n.sourceLocale,
302345
basePath,

packages/angular_devkit/build_angular/src/builders/extract-i18n/ivy-extract-loader.ts

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

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

1212
// Extract loader source map parameter type since it is not exported directly
1313
type LoaderSourceMap = Parameters<import('webpack').LoaderDefinitionFunction>[1];
@@ -21,9 +21,49 @@ export default function localizeExtractLoader(
2121
content: string,
2222
map: LoaderSourceMap,
2323
) {
24-
// eslint-disable-next-line @typescript-eslint/no-this-alias
25-
const loaderContext = this;
2624
const options = this.getOptions();
25+
const callback = this.async();
26+
27+
extract(this, content, map, options).then(
28+
() => {
29+
// Pass through the original content now that messages have been extracted
30+
callback(undefined, content, map);
31+
},
32+
(error) => {
33+
callback(error);
34+
},
35+
);
36+
}
37+
38+
async function extract(
39+
loaderContext: import('webpack').LoaderContext<LocalizeExtractLoaderOptions>,
40+
content: string,
41+
map: string | LoaderSourceMap | undefined,
42+
options: LocalizeExtractLoaderOptions,
43+
) {
44+
// Try to load the `@angular/localize` message extractor.
45+
// All the localize usages are setup to first try the ESM entry point then fallback to the deep imports.
46+
// This provides interim compatibility while the framework is transitioned to bundled ESM packages.
47+
// TODO_ESM: Remove all deep imports once `@angular/localize` is published with the `tools` entry point
48+
let MessageExtractor;
49+
try {
50+
try {
51+
// Load ESM `@angular/localize/tools` using the TypeScript dynamic import workaround.
52+
// Once TypeScript provides support for keeping the dynamic import this workaround can be
53+
// changed to a direct dynamic import.
54+
const localizeToolsModule = await loadEsmModule<
55+
typeof import('@angular/localize/src/tools/src/extract/extraction')
56+
>('@angular/localize/tools');
57+
MessageExtractor = localizeToolsModule.MessageExtractor;
58+
} catch {
59+
MessageExtractor = (await import('@angular/localize/src/tools/src/extract/extraction'))
60+
.MessageExtractor;
61+
}
62+
} catch {
63+
throw new Error(
64+
`Unable to load message extractor. Please ensure '@angular/localize' is installed.`,
65+
);
66+
}
2767

2868
// Setup a Webpack-based logger instance
2969
const logger = {
@@ -82,15 +122,12 @@ export default function localizeExtractLoader(
82122
// eslint-disable-next-line @typescript-eslint/no-explicit-any
83123
const extractor = new MessageExtractor(filesystem as any, logger, {
84124
// eslint-disable-next-line @typescript-eslint/no-explicit-any
85-
basePath: this.rootContext as any,
125+
basePath: loaderContext.rootContext as any,
86126
useSourceMaps: !!map,
87127
});
88128

89129
const messages = extractor.extractMessages(filename);
90130
if (messages.length > 0) {
91131
options?.messageHandler(messages);
92132
}
93-
94-
// Pass through the original content now that messages have been extracted
95-
this.callback(undefined, content, map);
96133
}

0 commit comments

Comments
 (0)