Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 53 additions & 8 deletions packages/angular/build/src/tools/esbuild/i18n-inliner-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@
*/

import remapping, { type DecodedSourceMap, type SourceMapInput } from '@ampproject/remapping';
import type { Node } from '@oxc-project/types';
import { MagicString } from 'magic-string';
import assert from 'node:assert';
import { deserialize } from 'node:v8';
import { workerData } from 'node:worker_threads';
import { Visitor, parseSync } from 'oxc-parser';
import { parseSync, visitorKeys } from 'oxc-parser';

/**
* The options passed to the inliner for each file request
Expand Down Expand Up @@ -180,6 +181,53 @@ async function loadLocalizeTools(): Promise<LocalizeUtilityModule> {
return localizeToolsModule;
}

/**
* Traverses ESTree AST nodes in post-order (bottom-up) without recursion.
* Bottom-up traversal ensures that nested `$localize` expressions are transformed and
* written to MagicString before outer containing templates are evaluated.
*
* @param root The root AST node to traverse.
* @param onExit Callback invoked on each AST node in post-order.
*/
function walkAstPostOrder(root: Node, onExit: (node: Node) => void): void {
const traverseStack: Node[] = [root];
const postOrderNodes: Node[] = [];

while (traverseStack.length > 0) {
const current = traverseStack.pop();
if (!current) {
continue;
}

postOrderNodes.push(current);

const keys = visitorKeys[current.type];
if (!keys) {
continue;
}

for (let i = 0; i < keys.length; i++) {
const child = (current as unknown as Record<string, Node | Node[]>)[keys[i]];
if (child) {
if (Array.isArray(child)) {
for (let j = 0; j < child.length; j++) {
const item = child[j];
if (item) {
traverseStack.push(item);
}
}
} else {
traverseStack.push(child);
}
}
}
}

for (let i = postOrderNodes.length - 1; i >= 0; i--) {
onExit(postOrderNodes[i]);
}
}
Comment thread
clydin marked this conversation as resolved.

/**
* Transforms a JavaScript file using OXC and Magic-String to inline the request locale and translation.
* @param code A string containing the JavaScript code to transform.
Expand All @@ -206,13 +254,12 @@ async function transformWithOxc(
const { Diagnostics, translate } = await loadLocalizeTools();
const diagnostics = new Diagnostics();

const visitor = new Visitor({
Literal(node) {
walkAstPostOrder(program, (node) => {
if (node.type === 'Literal') {
if (typeof node.value === 'string' && node.value === '___NG_LOCALE_INSERT___') {
magicString.overwrite(node.start, node.end, JSON.stringify(options.locale));
}
},
'TaggedTemplateExpression:exit'(node) {
} else if (node.type === 'TaggedTemplateExpression') {
if (node.tag.type === 'Identifier' && node.tag.name === '$localize') {
const cooked = node.quasi.quasis.map((q) => q.value.cooked);
const raw = node.quasi.quasis.map((q) => q.value.raw);
Expand Down Expand Up @@ -252,11 +299,9 @@ async function transformWithOxc(

magicString.overwrite(node.start, node.end, replacement);
}
},
}
});

visitor.visit(program);

const outputCode = magicString.toString();
let outputMap;
if (map && magicString.hasChanged()) {
Expand Down
21 changes: 21 additions & 0 deletions packages/angular/build/src/tools/esbuild/i18n-inliner_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,4 +274,25 @@ describe('I18nInliner', () => {

expect(findFile(outputFiles, 'other.js').text).toBe('export const answer = 42;\n');
});

it('inlines nested $localize calls in post-order', async () => {
const source =
'export const msg = $localize`:@@outer:You selected ${$localize`:@@inner:Apple`} for delivery.`;\n';
const { outputFiles, errors, warnings } = await createInliner([
browserFile('main.js', source),
]).inlineForLocale('fr', {
inner: translationFor('Pomme'),
outer: {
messageParts: ['Vous avez sélectionné ', ' pour la livraison.'],
placeholderNames: ['PH'],
text: 'Vous avez sélectionné {$PH} pour la livraison.',
},
});

expect(errors).toEqual([]);
expect(warnings).toEqual([]);
expect(findFile(outputFiles, 'main.js').text).toBe(
'export const msg = `Vous avez sélectionné ${"Pomme"} pour la livraison.`;\n',
);
});
});