Skip to content

Commit cc6d3a3

Browse files
committed
feat(@angular/build): migrate i18n inliner to oxc-parser + magic-string
This refactors the i18n translation and locale inlining in the ESBuild pipeline to use `oxc-parser` and `magic-string` instead of `@babel/core`. By using the lightweight AST and precise token spans provided by OXC, we are able to perform `$localize` inlining and locale name replacements via targeted `magic-string` overwrites in-place. This completely removes the dependency on `@babel/core` and the custom/upstream Babel plugins in the worker, yielding faster startup times and significant compile-time speedups for localized builds.
1 parent a2346bf commit cc6d3a3

4 files changed

Lines changed: 335 additions & 82 deletions

File tree

packages/angular/build/BUILD.bazel

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ ts_project(
8585
":node_modules/@babel/helper-annotate-as-pure",
8686
":node_modules/@babel/helper-split-export-declaration",
8787
":node_modules/@inquirer/confirm",
88+
":node_modules/@oxc-project/types",
8889
":node_modules/@vitejs/plugin-basic-ssl",
8990
":node_modules/beasties",
9091
":node_modules/browserslist",
@@ -97,6 +98,7 @@ ts_project(
9798
":node_modules/magic-string",
9899
":node_modules/mrmime",
99100
":node_modules/ng-packagr",
101+
":node_modules/oxc-parser",
100102
":node_modules/parse5-html-rewriting-stream",
101103
":node_modules/picomatch",
102104
":node_modules/piscina",

packages/angular/build/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
"listr2": "10.2.2",
3434
"magic-string": "0.30.21",
3535
"mrmime": "2.0.1",
36+
"oxc-parser": "0.140.0",
3637
"parse5-html-rewriting-stream": "8.0.1",
3738
"picomatch": "4.0.5",
3839
"piscina": "5.2.0",
@@ -50,6 +51,7 @@
5051
"devDependencies": {
5152
"@angular-devkit/core": "workspace:*",
5253
"@angular/ssr": "workspace:*",
54+
"@oxc-project/types": "0.140.0",
5355
"istanbul-lib-instrument": "6.0.3",
5456
"jsdom": "29.1.1",
5557
"less": "4.6.7",

packages/angular/build/src/tools/esbuild/i18n-inliner-worker.ts

Lines changed: 76 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,11 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import remapping, { SourceMapInput } from '@ampproject/remapping';
10-
import { NodePath, PluginItem, parseSync, transformFromAstAsync, types } from '@babel/core';
9+
import remapping, { type EncodedSourceMap, type SourceMapInput } from '@ampproject/remapping';
10+
import MagicString from 'magic-string';
1111
import assert from 'node:assert';
1212
import { workerData } from 'node:worker_threads';
13-
import { assertIsError } from '../../utils/error';
13+
import { Visitor, parseSync } from 'oxc-parser';
1414

1515
/**
1616
* The options passed to the inliner for each file request
@@ -80,11 +80,7 @@ export default async function inlineFile(request: InlineFileRequest) {
8080

8181
const code = await data.text();
8282
const map = await files.get(request.filename + '.map')?.text();
83-
const result = await transformWithBabel(
84-
code,
85-
map && (JSON.parse(map) as SourceMapInput),
86-
request,
87-
);
83+
const result = await transformWithOxc(code, map && (JSON.parse(map) as SourceMapInput), request);
8884

8985
return {
9086
file: request.filename,
@@ -102,7 +98,7 @@ export default async function inlineFile(request: InlineFileRequest) {
10298
* @returns An object containing the inlined code.
10399
*/
104100
export async function inlineCode(request: InlineCodeRequest) {
105-
const result = await transformWithBabel(request.code, undefined, request);
101+
const result = await transformWithOxc(request.code, undefined, request);
106102

107103
return {
108104
output: result.code,
@@ -136,95 +132,93 @@ async function loadLocalizeTools(): Promise<LocalizeUtilityModule> {
136132
}
137133

138134
/**
139-
* Creates the needed Babel plugins to inline a given locale and translation for a JavaScript file.
140-
* @param locale A string containing the locale specifier to use.
141-
* @param translation A object record containing locale specific messages to use.
142-
* @returns An array of Babel plugins.
143-
*/
144-
async function createI18nPlugins(locale: string, translation: Record<string, unknown> | undefined) {
145-
const { Diagnostics, makeEs2015TranslatePlugin } = await loadLocalizeTools();
146-
147-
const plugins: PluginItem[] = [];
148-
const diagnostics = new Diagnostics();
149-
150-
plugins.push(
151-
makeEs2015TranslatePlugin(diagnostics, translation || {}, {
152-
missingTranslation: translation === undefined ? 'ignore' : missingTranslation,
153-
}) as unknown as PluginItem,
154-
);
155-
156-
// Create a plugin to replace the locale specifier constant inject by the build system with the actual specifier
157-
plugins.push(() => ({
158-
visitor: {
159-
StringLiteral(path: NodePath<types.StringLiteral>) {
160-
if (path.node.value === '___NG_LOCALE_INSERT___') {
161-
path.replaceWith(types.stringLiteral(locale));
162-
}
163-
},
164-
},
165-
}));
166-
167-
return { diagnostics, plugins };
168-
}
169-
170-
/**
171-
* Transforms a JavaScript file using Babel to inline the request locale and translation.
135+
* Transforms a JavaScript file using OXC and Magic-String to inline the request locale and translation.
172136
* @param code A string containing the JavaScript code to transform.
173137
* @param map A sourcemap object for the provided JavaScript code.
174138
* @param options The inline request options to use.
175139
* @returns An object containing the code, map, and diagnostics from the transformation.
176140
*/
177-
async function transformWithBabel(
141+
async function transformWithOxc(
178142
code: string,
179143
map: SourceMapInput | undefined,
180144
options: InlineFileRequest,
181145
) {
182-
let ast;
183-
try {
184-
ast = parseSync(code, {
185-
babelrc: false,
186-
configFile: false,
187-
sourceType: 'unambiguous',
188-
filename: options.filename,
189-
});
190-
} catch (error) {
191-
assertIsError(error);
192-
193-
// Make the error more readable.
194-
// Same errors will contain the full content of the file as the error message
195-
// Which makes it hard to find the actual error message.
196-
const index = error.message.indexOf(')\n');
197-
const msg = index !== -1 ? error.message.slice(0, index + 1) : error.message;
198-
throw new Error(`${msg}\nAn error occurred inlining file "${options.filename}"`, {
199-
cause: error,
200-
});
201-
}
146+
const { program } = parseSync(options.filename, code, {
147+
sourceType: 'unambiguous',
148+
});
202149

203-
if (!ast) {
204-
throw new Error(`Unknown error occurred inlining file "${options.filename}"`);
150+
if (!program) {
151+
throw new Error(`Unknown error occurred parsing file "${options.filename}" with OXC.`);
205152
}
206153

207-
const { diagnostics, plugins } = await createI18nPlugins(options.locale, options.translation);
208-
const transformResult = await transformFromAstAsync(ast, code, {
209-
filename: options.filename,
210-
// false is a valid value but not included in the type definition
211-
inputSourceMap: false as unknown as undefined,
212-
sourceMaps: !!map,
213-
compact: shouldOptimize,
214-
configFile: false,
215-
babelrc: false,
216-
browserslistConfigFile: false,
217-
plugins,
154+
const magicString = new MagicString(code);
155+
const { Diagnostics, translate } = await loadLocalizeTools();
156+
const diagnostics = new Diagnostics();
157+
158+
const visitor = new Visitor({
159+
Literal(node) {
160+
if (typeof node.value === 'string' && node.value === '___NG_LOCALE_INSERT___') {
161+
magicString.overwrite(node.start, node.end, JSON.stringify(options.locale));
162+
}
163+
},
164+
'TaggedTemplateExpression:exit'(node) {
165+
if (node.tag.type === 'Identifier' && node.tag.name === '$localize') {
166+
const cooked = node.quasi.quasis.map((q) => q.value.cooked);
167+
const raw = node.quasi.quasis.map((q) => q.value.raw);
168+
const messageParts = Object.assign(cooked, { raw }) as unknown as TemplateStringsArray;
169+
170+
const [translatedParts, translatedSubstitutions] = translate(
171+
diagnostics,
172+
options.translation || {},
173+
messageParts,
174+
node.quasi.expressions.map((_, index) => index),
175+
options.translation === undefined ? 'ignore' : missingTranslation,
176+
);
177+
178+
// Reconstruct the new template/string literal replacement
179+
let replacement: string;
180+
if (translatedSubstitutions.length === 0) {
181+
replacement = JSON.stringify(translatedParts[0]);
182+
} else {
183+
replacement = '`';
184+
for (let i = 0; i < translatedParts.length; i++) {
185+
const escapedPart = translatedParts[i]
186+
.replace(/\\/g, '\\\\')
187+
.replace(/`/g, '\\`')
188+
.replace(/\$\{/g, '\\${');
189+
replacement += escapedPart;
190+
191+
if (i < translatedSubstitutions.length) {
192+
const originalIndex = translatedSubstitutions[i];
193+
const exprNode = node.quasi.expressions[originalIndex];
194+
const exprCode = magicString.slice(exprNode.start, exprNode.end);
195+
replacement += '${' + exprCode + '}';
196+
}
197+
}
198+
replacement += '`';
199+
}
200+
201+
magicString.overwrite(node.start, node.end, replacement);
202+
}
203+
},
218204
});
219205

220-
if (!transformResult || !transformResult.code) {
221-
throw new Error(`Unknown error occurred processing bundle for "${options.filename}".`);
222-
}
206+
visitor.visit(program);
223207

208+
const outputCode = magicString.toString();
224209
let outputMap;
225-
if (map && transformResult.map) {
226-
outputMap = remapping([transformResult.map as SourceMapInput, map], () => null);
210+
if (map && magicString.hasChanged()) {
211+
const rawMap = magicString.generateMap({
212+
source: options.filename,
213+
includeContent: true,
214+
hires: 'boundary',
215+
});
216+
outputMap = remapping([rawMap as EncodedSourceMap, map], () => null);
227217
}
228218

229-
return { code: transformResult.code, map: outputMap && JSON.stringify(outputMap), diagnostics };
219+
return {
220+
code: outputCode,
221+
map: outputMap && JSON.stringify(outputMap),
222+
diagnostics,
223+
};
230224
}

0 commit comments

Comments
 (0)