66import * as esbuild from 'esbuild' ;
77import * as path from 'path' ;
88import * as fs from 'fs' ;
9+ import { SourceMapGenerator } from 'source-map' ;
910import {
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+
163171function 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
214301function replaceInOutput (
@@ -300,15 +387,23 @@ export function nlsPlugin(options: NLSPluginOptions): esbuild.Plugin {
300387 . replace ( / \. t s $ / , '' ) ;
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