Skip to content

Commit 9c2922a

Browse files
authored
feat: enhance source map handling in NLS and private field conversion (#296957)
* feat: enhance source map handling in NLS and private field conversion - Implemented inline source maps in the NLS plugin to ensure accurate mapping from transformed source back to original. - Modified `transformToPlaceholders` to return edits for source map adjustments. - Added `adjustSourceMap` function to update source maps based on text edits in `convertPrivateFields`. - Introduced tests for source map accuracy in both NLS and private field conversion scenarios. - Updated documentation to reflect changes in source map handling and the rationale behind accepting column imprecision. * address PR review: fix source name collision, guard unmapped segments, reorder mangle before NLS
1 parent e50a372 commit 9c2922a

File tree

6 files changed

+830
-35
lines changed

6 files changed

+830
-35
lines changed

build/next/index.ts

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { promisify } from 'util';
1010
import glob from 'glob';
1111
import gulpWatch from '../lib/watch/index.ts';
1212
import { nlsPlugin, createNLSCollector, finalizeNLS, postProcessNLS } from './nls-plugin.ts';
13-
import { convertPrivateFields, type ConvertPrivateFieldsResult } from './private-to-property.ts';
13+
import { convertPrivateFields, adjustSourceMap, type ConvertPrivateFieldsResult } from './private-to-property.ts';
1414
import { getVersion } from '../lib/getVersion.ts';
1515
import product from '../../product.json' with { type: 'json' };
1616
import packageJson from '../../package.json' with { type: 'json' };
@@ -883,6 +883,8 @@ ${tslib}`,
883883
// Post-process and write all output files
884884
let bundled = 0;
885885
const mangleStats: { file: string; result: ConvertPrivateFieldsResult }[] = [];
886+
// Map from JS file path to pre-mangle content + edits, for source map adjustment
887+
const mangleEdits = new Map<string, { preMangleCode: string; edits: readonly import('./private-to-property.ts').TextEdit[] }>();
886888
for (const { result } of buildResults) {
887889
if (!result.outputFiles) {
888890
continue;
@@ -894,22 +896,26 @@ ${tslib}`,
894896
if (file.path.endsWith('.js') || file.path.endsWith('.css')) {
895897
let content = file.text;
896898

897-
// Apply NLS post-processing if enabled (JS only)
898-
if (file.path.endsWith('.js') && doNls && indexMap.size > 0) {
899-
content = postProcessNLS(content, indexMap, preserveEnglish);
900-
}
901-
902-
// Convert native #private fields to regular properties.
899+
// Convert native #private fields to regular properties BEFORE NLS
900+
// post-processing, so that the edit offsets align with esbuild's
901+
// source map coordinate system (both reference the raw esbuild output).
903902
// Skip extension host bundles - they expose API surface to extensions
904903
// where true encapsulation matters more than the perf gain.
905904
if (file.path.endsWith('.js') && doManglePrivates && !isExtensionHostBundle(file.path)) {
905+
const preMangleCode = content;
906906
const mangleResult = convertPrivateFields(content, file.path);
907907
content = mangleResult.code;
908908
if (mangleResult.editCount > 0) {
909909
mangleStats.push({ file: path.relative(path.join(REPO_ROOT, outDir), file.path), result: mangleResult });
910+
mangleEdits.set(file.path, { preMangleCode, edits: mangleResult.edits });
910911
}
911912
}
912913

914+
// Apply NLS post-processing if enabled (JS only)
915+
if (file.path.endsWith('.js') && doNls && indexMap.size > 0) {
916+
content = postProcessNLS(content, indexMap, preserveEnglish);
917+
}
918+
913919
// Rewrite sourceMappingURL to CDN URL if configured
914920
if (sourceMapBaseUrl) {
915921
const relativePath = path.relative(path.join(REPO_ROOT, outDir), file.path);
@@ -924,8 +930,19 @@ ${tslib}`,
924930
}
925931

926932
await fs.promises.writeFile(file.path, content);
933+
} else if (file.path.endsWith('.map')) {
934+
// Source maps may need adjustment if private fields were mangled
935+
const jsPath = file.path.replace(/\.map$/, '');
936+
const editInfo = mangleEdits.get(jsPath);
937+
if (editInfo) {
938+
const mapJson = JSON.parse(file.text);
939+
const adjusted = adjustSourceMap(mapJson, editInfo.preMangleCode, editInfo.edits);
940+
await fs.promises.writeFile(file.path, JSON.stringify(adjusted));
941+
} else {
942+
await fs.promises.writeFile(file.path, file.contents);
943+
}
927944
} else {
928-
// Write other files (source maps, assets) as-is
945+
// Write other files (assets, etc.) as-is
929946
await fs.promises.writeFile(file.path, file.contents);
930947
}
931948
}

build/next/nls-plugin.ts

Lines changed: 102 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import * as esbuild from 'esbuild';
77
import * as path from 'path';
88
import * as fs from 'fs';
9+
import { SourceMapGenerator } from 'source-map';
910
import {
1011
TextModel,
1112
analyzeLocalizeCalls,
@@ -160,10 +161,17 @@ export function postProcessNLS(
160161
// Transformation
161162
// ============================================================================
162163

164+
interface NLSEdit {
165+
line: number; // 0-based line in original source
166+
startCol: number; // 0-based start column in original
167+
endCol: number; // 0-based end column in original
168+
newLength: number; // length of replacement text
169+
}
170+
163171
function transformToPlaceholders(
164172
source: string,
165173
moduleId: string
166-
): { code: string; entries: NLSEntry[] } {
174+
): { code: string; entries: NLSEntry[]; edits: NLSEdit[] } {
167175
const localizeCalls = analyzeLocalizeCalls(source, 'localize');
168176
const localize2Calls = analyzeLocalizeCalls(source, 'localize2');
169177

@@ -176,10 +184,11 @@ function transformToPlaceholders(
176184
);
177185

178186
if (allCalls.length === 0) {
179-
return { code: source, entries: [] };
187+
return { code: source, entries: [], edits: [] };
180188
}
181189

182190
const entries: NLSEntry[] = [];
191+
const edits: NLSEdit[] = [];
183192
const model = new TextModel(source);
184193

185194
// Process in reverse order to preserve positions
@@ -201,14 +210,92 @@ function transformToPlaceholders(
201210
placeholder
202211
});
203212

213+
const replacementText = `"${placeholder}"`;
214+
215+
// Track the edit for source map generation (positions are in original source coords)
216+
edits.push({
217+
line: call.keySpan.start.line,
218+
startCol: call.keySpan.start.character,
219+
endCol: call.keySpan.end.character,
220+
newLength: replacementText.length,
221+
});
222+
204223
// Replace the key with the placeholder string
205-
model.apply(call.keySpan, `"${placeholder}"`);
224+
model.apply(call.keySpan, replacementText);
206225
}
207226

208-
// Reverse entries to match source order
227+
// Reverse entries and edits to match source order
209228
entries.reverse();
229+
edits.reverse();
230+
231+
return { code: model.toString(), entries, edits };
232+
}
233+
234+
/**
235+
* Generates a source map that maps from the NLS-transformed source back to the
236+
* original source. esbuild composes this with its own bundle source map so that
237+
* the final source map points all the way back to the untransformed TypeScript.
238+
*/
239+
function generateNLSSourceMap(
240+
originalSource: string,
241+
filePath: string,
242+
edits: NLSEdit[]
243+
): string {
244+
const generator = new SourceMapGenerator();
245+
generator.setSourceContent(filePath, originalSource);
246+
247+
const lineCount = originalSource.split('\n').length;
248+
249+
// Group edits by line
250+
const editsByLine = new Map<number, NLSEdit[]>();
251+
for (const edit of edits) {
252+
let arr = editsByLine.get(edit.line);
253+
if (!arr) {
254+
arr = [];
255+
editsByLine.set(edit.line, arr);
256+
}
257+
arr.push(edit);
258+
}
259+
260+
for (let line = 0; line < lineCount; line++) {
261+
const smLine = line + 1; // source maps use 1-based lines
262+
263+
// Always map start of line
264+
generator.addMapping({
265+
generated: { line: smLine, column: 0 },
266+
original: { line: smLine, column: 0 },
267+
source: filePath,
268+
});
269+
270+
const lineEdits = editsByLine.get(line);
271+
if (lineEdits) {
272+
lineEdits.sort((a, b) => a.startCol - b.startCol);
273+
274+
let cumulativeShift = 0;
275+
276+
for (const edit of lineEdits) {
277+
const origLen = edit.endCol - edit.startCol;
278+
279+
// Map start of edit: the replacement begins at the same original position
280+
generator.addMapping({
281+
generated: { line: smLine, column: edit.startCol + cumulativeShift },
282+
original: { line: smLine, column: edit.startCol },
283+
source: filePath,
284+
});
285+
286+
cumulativeShift += edit.newLength - origLen;
287+
288+
// Map content after edit: columns resume with the shift applied
289+
generator.addMapping({
290+
generated: { line: smLine, column: edit.endCol + cumulativeShift },
291+
original: { line: smLine, column: edit.endCol },
292+
source: filePath,
293+
});
294+
}
295+
}
296+
}
210297

211-
return { code: model.toString(), entries };
298+
return generator.toString();
212299
}
213300

214301
function replaceInOutput(
@@ -300,15 +387,23 @@ export function nlsPlugin(options: NLSPluginOptions): esbuild.Plugin {
300387
.replace(/\.ts$/, '');
301388

302389
// Transform localize() calls to placeholders
303-
const { code, entries: fileEntries } = transformToPlaceholders(source, moduleId);
390+
const { code, entries: fileEntries, edits } = transformToPlaceholders(source, moduleId);
304391

305392
// Collect entries
306393
for (const entry of fileEntries) {
307394
collector.add(entry);
308395
}
309396

310397
if (fileEntries.length > 0) {
311-
return { contents: code, loader: 'ts' };
398+
// Generate a source map that maps from the NLS-transformed source
399+
// back to the original. Embed it inline so esbuild composes it
400+
// with its own bundle source map, making the final map point to
401+
// the original TS source.
402+
const sourceName = relativePath.replace(/\\/g, '/');
403+
const sourcemap = generateNLSSourceMap(source, sourceName, edits);
404+
const encodedMap = Buffer.from(sourcemap).toString('base64');
405+
const contentsWithMap = code + `\n//# sourceMappingURL=data:application/json;base64,${encodedMap}\n`;
406+
return { contents: contentsWithMap, loader: 'ts' };
312407
}
313408

314409
// No NLS calls, return undefined to let esbuild handle normally

0 commit comments

Comments
 (0)