@@ -12,6 +12,7 @@ import {
1212 analyzeLocalizeCalls ,
1313 parseLocalizeKeyOrValue
1414} from '../lib/nls-analysis.ts' ;
15+ import type { TextEdit } from './private-to-property.ts' ;
1516
1617// ============================================================================
1718// Types
@@ -148,12 +149,13 @@ export async function finalizeNLS(
148149
149150/**
150151 * Post-processes a JavaScript file to replace NLS placeholders with indices.
152+ * Returns the transformed code and the edits applied (for source map adjustment).
151153 */
152154export function postProcessNLS (
153155 content : string ,
154156 indexMap : Map < string , number > ,
155157 preserveEnglish : boolean
156- ) : string {
158+ ) : { code : string ; edits : readonly TextEdit [ ] } {
157159 return replaceInOutput ( content , indexMap , preserveEnglish ) ;
158160}
159161
@@ -244,7 +246,7 @@ function generateNLSSourceMap(
244246 const generator = new SourceMapGenerator ( ) ;
245247 generator . setSourceContent ( filePath , originalSource ) ;
246248
247- const lineCount = originalSource . split ( '\n' ) . length ;
249+ const lines = originalSource . split ( '\n' ) ;
248250
249251 // Group edits by line
250252 const editsByLine = new Map < number , NLSEdit [ ] > ( ) ;
@@ -257,7 +259,7 @@ function generateNLSSourceMap(
257259 arr . push ( edit ) ;
258260 }
259261
260- for ( let line = 0 ; line < lineCount ; line ++ ) {
262+ for ( let line = 0 ; line < lines . length ; line ++ ) {
261263 const smLine = line + 1 ; // source maps use 1-based lines
262264
263265 // Always map start of line
@@ -273,7 +275,8 @@ function generateNLSSourceMap(
273275
274276 let cumulativeShift = 0 ;
275277
276- for ( const edit of lineEdits ) {
278+ for ( let i = 0 ; i < lineEdits . length ; i ++ ) {
279+ const edit = lineEdits [ i ] ;
277280 const origLen = edit . endCol - edit . startCol ;
278281
279282 // Map start of edit: the replacement begins at the same original position
@@ -285,12 +288,20 @@ function generateNLSSourceMap(
285288
286289 cumulativeShift += edit . newLength - origLen ;
287290
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- } ) ;
291+ // Source maps don't interpolate columns — each query resolves to the
292+ // last segment with generatedColumn <= queryColumn. A single mapping
293+ // at edit-end would cause every subsequent column on this line to
294+ // collapse to that one original position. Add per-column identity
295+ // mappings from edit-end to the next edit (or end of line) so that
296+ // esbuild's source-map composition preserves fine-grained accuracy.
297+ const nextBound = i + 1 < lineEdits . length ? lineEdits [ i + 1 ] . startCol : lines [ line ] . length ;
298+ for ( let origCol = edit . endCol ; origCol < nextBound ; origCol ++ ) {
299+ generator . addMapping ( {
300+ generated : { line : smLine , column : origCol + cumulativeShift } ,
301+ original : { line : smLine , column : origCol } ,
302+ source : filePath ,
303+ } ) ;
304+ }
294305 }
295306 }
296307 }
@@ -302,63 +313,80 @@ function replaceInOutput(
302313 content : string ,
303314 indexMap : Map < string , number > ,
304315 preserveEnglish : boolean
305- ) : string {
306- // Replace all placeholders in a single pass using regex
307- // Two types of placeholders:
308- // - %%NLS:moduleId#key%% for localize() - message replaced with null
309- // - %%NLS2:moduleId#key%% for localize2() - message preserved
310- // Note: esbuild may use single or double quotes, so we handle both
316+ ) : { code : string ; edits : readonly TextEdit [ ] } {
317+ // Collect all matches first, then apply from back to front so that byte
318+ // offsets remain valid. Each match becomes a TextEdit in terms of the
319+ // ORIGINAL content offsets, which is what adjustSourceMap expects.
320+
321+ interface PendingEdit { start : number ; end : number ; replacement : string }
322+ const pending : PendingEdit [ ] = [ ] ;
311323
312324 if ( preserveEnglish ) {
313- // Just replace the placeholder with the index (both NLS and NLS2)
314- return content . replace ( / [ " ' ] % % N L S 2 ? : ( [ ^ % ] + ) % % [ " ' ] / g, ( match , inner ) => {
315- // Try NLS first, then NLS2
325+ const re = / [ " ' ] % % N L S 2 ? : ( [ ^ % ] + ) % % [ " ' ] / g;
326+ let m : RegExpExecArray | null ;
327+ while ( ( m = re . exec ( content ) ) !== null ) {
328+ const inner = m [ 1 ] ;
316329 let placeholder = `%%NLS:${ inner } %%` ;
317330 let index = indexMap . get ( placeholder ) ;
318331 if ( index === undefined ) {
319332 placeholder = `%%NLS2:${ inner } %%` ;
320333 index = indexMap . get ( placeholder ) ;
321334 }
322335 if ( index !== undefined ) {
323- return String ( index ) ;
336+ pending . push ( { start : m . index , end : m . index + m [ 0 ] . length , replacement : String ( index ) } ) ;
324337 }
325- // Placeholder not found in map, leave as-is (shouldn't happen)
326- return match ;
327- } ) ;
338+ }
328339 } else {
329- // For NLS (localize): replace placeholder with index AND replace message with null
330- // For NLS2 (localize2): replace placeholder with index, keep message
331- // Note: Use (?:[^"\\]|\\.)* to properly handle escaped quotes like \" or \\
332- // Note: esbuild may use single or double quotes, so we handle both
333-
334- // First handle NLS (localize) - replace both key and message
335- content = content . replace (
336- / [ " ' ] % % N L S : ( [ ^ % ] + ) % % [ " ' ] ( \s * , \s * ) (?: " (?: [ ^ " \\ ] | \\ .) * " | ' (?: [ ^ ' \\ ] | \\ .) * ' | ` (?: [ ^ ` \\ ] | \\ .) * ` ) / g,
337- ( match , inner , comma ) => {
338- const placeholder = `%%NLS:${ inner } %%` ;
339- const index = indexMap . get ( placeholder ) ;
340- if ( index !== undefined ) {
341- return `${ index } ${ comma } null` ;
342- }
343- return match ;
340+ // NLS (localize): replace placeholder with index AND replace message with null
341+ const reNLS = / [ " ' ] % % N L S : ( [ ^ % ] + ) % % [ " ' ] ( \s * , \s * ) (?: " (?: [ ^ " \\ ] | \\ .) * " | ' (?: [ ^ ' \\ ] | \\ .) * ' | ` (?: [ ^ ` \\ ] | \\ .) * ` ) / g;
342+ let m : RegExpExecArray | null ;
343+ while ( ( m = reNLS . exec ( content ) ) !== null ) {
344+ const inner = m [ 1 ] ;
345+ const comma = m [ 2 ] ;
346+ const placeholder = `%%NLS:${ inner } %%` ;
347+ const index = indexMap . get ( placeholder ) ;
348+ if ( index !== undefined ) {
349+ pending . push ( { start : m . index , end : m . index + m [ 0 ] . length , replacement : `${ index } ${ comma } null` } ) ;
344350 }
345- ) ;
346-
347- // Then handle NLS2 (localize2) - replace only key, keep message
348- content = content . replace (
349- / [ " ' ] % % N L S 2 : ( [ ^ % ] + ) % % [ " ' ] / g,
350- ( match , inner ) => {
351- const placeholder = `%%NLS2:${ inner } %%` ;
352- const index = indexMap . get ( placeholder ) ;
353- if ( index !== undefined ) {
354- return String ( index ) ;
355- }
356- return match ;
351+ }
352+
353+ // NLS2 (localize2): replace only key, keep message
354+ const reNLS2 = / [ " ' ] % % N L S 2 : ( [ ^ % ] + ) % % [ " ' ] / g;
355+ while ( ( m = reNLS2 . exec ( content ) ) !== null ) {
356+ const inner = m [ 1 ] ;
357+ const placeholder = `%%NLS2:${ inner } %%` ;
358+ const index = indexMap . get ( placeholder ) ;
359+ if ( index !== undefined ) {
360+ pending . push ( { start : m . index , end : m . index + m [ 0 ] . length , replacement : String ( index ) } ) ;
357361 }
358- ) ;
362+ }
363+ }
359364
360- return content ;
365+ if ( pending . length === 0 ) {
366+ return { code : content , edits : [ ] } ;
361367 }
368+
369+ // Sort by offset ascending, then apply back-to-front to keep offsets valid
370+ pending . sort ( ( a , b ) => a . start - b . start ) ;
371+
372+ // Build TextEdit[] (in original-content coordinates) and apply edits
373+ const edits : TextEdit [ ] = [ ] ;
374+ for ( const p of pending ) {
375+ edits . push ( { start : p . start , end : p . end , newText : p . replacement } ) ;
376+ }
377+
378+ // Apply edits using forward-scanning parts array — O(N+K) instead of
379+ // O(N*K) from repeated substring concatenation on large strings.
380+ const parts : string [ ] = [ ] ;
381+ let lastEnd = 0 ;
382+ for ( const p of pending ) {
383+ parts . push ( content . substring ( lastEnd , p . start ) ) ;
384+ parts . push ( p . replacement ) ;
385+ lastEnd = p . end ;
386+ }
387+ parts . push ( content . substring ( lastEnd ) ) ;
388+
389+ return { code : parts . join ( '' ) , edits } ;
362390}
363391
364392// ============================================================================
0 commit comments