Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/build/next/nls-plugin.ts
13379 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import * as esbuild from 'esbuild';
7
import * as path from 'path';
8
import * as fs from 'fs';
9
import { SourceMapGenerator } from 'source-map';
10
import {
11
TextModel,
12
analyzeLocalizeCalls,
13
parseLocalizeKeyOrValue
14
} from '../lib/nls-analysis.ts';
15
import type { TextEdit } from './private-to-property.ts';
16
17
// ============================================================================
18
// Types
19
// ============================================================================
20
21
interface NLSEntry {
22
moduleId: string;
23
key: string | { key: string; comment: string[] };
24
message: string;
25
placeholder: string;
26
}
27
28
export interface NLSPluginOptions {
29
/**
30
* Base path for computing module IDs (e.g., 'src')
31
*/
32
baseDir: string;
33
34
/**
35
* Shared collector for NLS entries across multiple builds.
36
* Create with createNLSCollector() and pass to multiple plugin instances.
37
*/
38
collector: NLSCollector;
39
}
40
41
/**
42
* Collector for NLS entries across multiple esbuild builds.
43
*/
44
export interface NLSCollector {
45
entries: Map<string, NLSEntry>;
46
add(entry: NLSEntry): void;
47
}
48
49
/**
50
* Creates a shared NLS collector that can be passed to multiple plugin instances.
51
*/
52
export function createNLSCollector(): NLSCollector {
53
const entries = new Map<string, NLSEntry>();
54
return {
55
entries,
56
add(entry: NLSEntry) {
57
entries.set(entry.placeholder, entry);
58
}
59
};
60
}
61
62
/**
63
* Finalizes NLS collection and writes output files.
64
* Call this after all esbuild builds have completed.
65
*/
66
export async function finalizeNLS(
67
collector: NLSCollector,
68
outDir: string,
69
alsoWriteTo?: string[]
70
): Promise<{ indexMap: Map<string, number>; messageCount: number }> {
71
if (collector.entries.size === 0) {
72
return { indexMap: new Map(), messageCount: 0 };
73
}
74
75
// Sort entries by moduleId, then by key for stable indices
76
const sortedEntries = [...collector.entries.values()].sort((a, b) => {
77
const aKey = typeof a.key === 'string' ? a.key : a.key.key;
78
const bKey = typeof b.key === 'string' ? b.key : b.key.key;
79
const moduleCompare = a.moduleId.localeCompare(b.moduleId);
80
if (moduleCompare !== 0) {
81
return moduleCompare;
82
}
83
return aKey.localeCompare(bKey);
84
});
85
86
// Create index map
87
const indexMap = new Map<string, number>();
88
sortedEntries.forEach((entry, idx) => {
89
indexMap.set(entry.placeholder, idx);
90
});
91
92
// Build NLS metadata
93
const allMessages: string[] = [];
94
const moduleToKeys: Map<string, (string | { key: string; comment: string[] })[]> = new Map();
95
const moduleToMessages: Map<string, string[]> = new Map();
96
97
for (const entry of sortedEntries) {
98
allMessages.push(entry.message);
99
100
if (!moduleToKeys.has(entry.moduleId)) {
101
moduleToKeys.set(entry.moduleId, []);
102
moduleToMessages.set(entry.moduleId, []);
103
}
104
moduleToKeys.get(entry.moduleId)!.push(entry.key);
105
moduleToMessages.get(entry.moduleId)!.push(entry.message);
106
}
107
108
// nls.keys.json: [["moduleId", ["key1", "key2"]], ...]
109
const nlsKeysJson: [string, string[]][] = [];
110
for (const [moduleId, keys] of moduleToKeys) {
111
nlsKeysJson.push([moduleId, keys.map(k => typeof k === 'string' ? k : k.key)]);
112
}
113
114
// nls.metadata.json: { keys: {...}, messages: {...} }
115
const nlsMetadataJson = {
116
keys: Object.fromEntries(moduleToKeys),
117
messages: Object.fromEntries(moduleToMessages)
118
};
119
120
// Write NLS files
121
const allOutDirs = [outDir, ...(alsoWriteTo ?? [])];
122
for (const dir of allOutDirs) {
123
await fs.promises.mkdir(dir, { recursive: true });
124
}
125
126
await Promise.all(allOutDirs.flatMap(dir => [
127
fs.promises.writeFile(
128
path.join(dir, 'nls.messages.json'),
129
JSON.stringify(allMessages)
130
),
131
fs.promises.writeFile(
132
path.join(dir, 'nls.keys.json'),
133
JSON.stringify(nlsKeysJson)
134
),
135
fs.promises.writeFile(
136
path.join(dir, 'nls.metadata.json'),
137
JSON.stringify(nlsMetadataJson, null, '\t')
138
),
139
fs.promises.writeFile(
140
path.join(dir, 'nls.messages.js'),
141
`/*---------------------------------------------------------\n * Copyright (C) Microsoft Corporation. All rights reserved.\n *--------------------------------------------------------*/\nglobalThis._VSCODE_NLS_MESSAGES=${JSON.stringify(allMessages)};`
142
),
143
]));
144
145
console.log(`[nls] Extracted ${allMessages.length} messages from ${moduleToKeys.size} modules`);
146
147
return { indexMap, messageCount: allMessages.length };
148
}
149
150
/**
151
* Post-processes a JavaScript file to replace NLS placeholders with indices.
152
* Returns the transformed code and the edits applied (for source map adjustment).
153
*/
154
export function postProcessNLS(
155
content: string,
156
indexMap: Map<string, number>,
157
preserveEnglish: boolean
158
): { code: string; edits: readonly TextEdit[] } {
159
return replaceInOutput(content, indexMap, preserveEnglish);
160
}
161
162
// ============================================================================
163
// Transformation
164
// ============================================================================
165
166
interface NLSEdit {
167
line: number; // 0-based line in original source
168
startCol: number; // 0-based start column in original
169
endCol: number; // 0-based end column in original
170
newLength: number; // length of replacement text
171
}
172
173
function transformToPlaceholders(
174
source: string,
175
moduleId: string
176
): { code: string; entries: NLSEntry[]; edits: NLSEdit[] } {
177
const localizeCalls = analyzeLocalizeCalls(source, 'localize');
178
const localize2Calls = analyzeLocalizeCalls(source, 'localize2');
179
180
// Tag calls with their type so we can handle them differently later
181
const taggedLocalize = localizeCalls.map(call => ({ call, isLocalize2: false }));
182
const taggedLocalize2 = localize2Calls.map(call => ({ call, isLocalize2: true }));
183
const allCalls = [...taggedLocalize, ...taggedLocalize2].sort(
184
(a, b) => a.call.keySpan.start.line - b.call.keySpan.start.line ||
185
a.call.keySpan.start.character - b.call.keySpan.start.character
186
);
187
188
if (allCalls.length === 0) {
189
return { code: source, entries: [], edits: [] };
190
}
191
192
const entries: NLSEntry[] = [];
193
const edits: NLSEdit[] = [];
194
const model = new TextModel(source);
195
196
// Process in reverse order to preserve positions
197
for (const { call, isLocalize2 } of allCalls.reverse()) {
198
const keyParsed = parseLocalizeKeyOrValue(call.key) as string | { key: string; comment: string[] };
199
const messageParsed = parseLocalizeKeyOrValue(call.value);
200
const keyString = typeof keyParsed === 'string' ? keyParsed : keyParsed.key;
201
202
// Use different placeholder prefix for localize vs localize2
203
// localize: message will be replaced with null
204
// localize2: message will be preserved (only key replaced)
205
const prefix = isLocalize2 ? 'NLS2' : 'NLS';
206
const placeholder = `%%${prefix}:${moduleId}#${keyString}%%`;
207
208
entries.push({
209
moduleId,
210
key: keyParsed,
211
message: String(messageParsed),
212
placeholder
213
});
214
215
const replacementText = `"${placeholder}"`;
216
217
// Track the edit for source map generation (positions are in original source coords)
218
edits.push({
219
line: call.keySpan.start.line,
220
startCol: call.keySpan.start.character,
221
endCol: call.keySpan.end.character,
222
newLength: replacementText.length,
223
});
224
225
// Replace the key with the placeholder string
226
model.apply(call.keySpan, replacementText);
227
}
228
229
// Reverse entries and edits to match source order
230
entries.reverse();
231
edits.reverse();
232
233
return { code: model.toString(), entries, edits };
234
}
235
236
/**
237
* Generates a source map that maps from the NLS-transformed source back to the
238
* original source. esbuild composes this with its own bundle source map so that
239
* the final source map points all the way back to the untransformed TypeScript.
240
*/
241
function generateNLSSourceMap(
242
originalSource: string,
243
filePath: string,
244
edits: NLSEdit[]
245
): string {
246
const generator = new SourceMapGenerator();
247
generator.setSourceContent(filePath, originalSource);
248
249
const lines = originalSource.split('\n');
250
251
// Group edits by line
252
const editsByLine = new Map<number, NLSEdit[]>();
253
for (const edit of edits) {
254
let arr = editsByLine.get(edit.line);
255
if (!arr) {
256
arr = [];
257
editsByLine.set(edit.line, arr);
258
}
259
arr.push(edit);
260
}
261
262
for (let line = 0; line < lines.length; line++) {
263
const smLine = line + 1; // source maps use 1-based lines
264
265
// Always map start of line
266
generator.addMapping({
267
generated: { line: smLine, column: 0 },
268
original: { line: smLine, column: 0 },
269
source: filePath,
270
});
271
272
const lineEdits = editsByLine.get(line);
273
if (lineEdits) {
274
lineEdits.sort((a, b) => a.startCol - b.startCol);
275
276
let cumulativeShift = 0;
277
278
for (let i = 0; i < lineEdits.length; i++) {
279
const edit = lineEdits[i];
280
const origLen = edit.endCol - edit.startCol;
281
282
// Map start of edit: the replacement begins at the same original position
283
generator.addMapping({
284
generated: { line: smLine, column: edit.startCol + cumulativeShift },
285
original: { line: smLine, column: edit.startCol },
286
source: filePath,
287
});
288
289
cumulativeShift += edit.newLength - origLen;
290
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
}
305
}
306
}
307
}
308
309
return generator.toString();
310
}
311
312
function replaceInOutput(
313
content: string,
314
indexMap: Map<string, number>,
315
preserveEnglish: boolean
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[] = [];
323
324
if (preserveEnglish) {
325
const re = /["']%%NLS2?:([^%]+)%%["']/g;
326
let m: RegExpExecArray | null;
327
while ((m = re.exec(content)) !== null) {
328
const inner = m[1];
329
let placeholder = `%%NLS:${inner}%%`;
330
let index = indexMap.get(placeholder);
331
if (index === undefined) {
332
placeholder = `%%NLS2:${inner}%%`;
333
index = indexMap.get(placeholder);
334
}
335
if (index !== undefined) {
336
pending.push({ start: m.index, end: m.index + m[0].length, replacement: String(index) });
337
}
338
}
339
} else {
340
// NLS (localize): replace placeholder with index AND replace message with null
341
const reNLS = /["']%%NLS:([^%]+)%%["'](\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` });
350
}
351
}
352
353
// NLS2 (localize2): replace only key, keep message
354
const reNLS2 = /["']%%NLS2:([^%]+)%%["']/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) });
361
}
362
}
363
}
364
365
if (pending.length === 0) {
366
return { code: content, edits: [] };
367
}
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 };
390
}
391
392
// ============================================================================
393
// Plugin
394
// ============================================================================
395
396
export function nlsPlugin(options: NLSPluginOptions): esbuild.Plugin {
397
const { collector } = options;
398
399
return {
400
name: 'nls',
401
setup(build) {
402
// Transform TypeScript files to replace localize() calls with placeholders
403
build.onLoad({ filter: /\.ts$/ }, async (args) => {
404
// Skip .d.ts files
405
if (args.path.endsWith('.d.ts')) {
406
return undefined;
407
}
408
409
const source = await fs.promises.readFile(args.path, 'utf-8');
410
411
// Compute module ID (e.g., "vs/editor/editor" from "src/vs/editor/editor.ts")
412
const relativePath = path.relative(options.baseDir, args.path);
413
const moduleId = relativePath
414
.replace(/\\/g, '/')
415
.replace(/\.ts$/, '');
416
417
// Transform localize() calls to placeholders
418
const { code, entries: fileEntries, edits } = transformToPlaceholders(source, moduleId);
419
420
// Collect entries
421
for (const entry of fileEntries) {
422
collector.add(entry);
423
}
424
425
if (fileEntries.length > 0) {
426
// Generate a source map that maps from the NLS-transformed source
427
// back to the original. Embed it inline so esbuild composes it
428
// with its own bundle source map, making the final map point to
429
// the original TS source.
430
// This inline source map is resolved relative to esbuild's sourcefile
431
// for args.path. Using the full repo-relative path here makes esbuild
432
// resolve it against the file's own directory, which duplicates the
433
// directory segments in the final bundled source map.
434
const sourceName = path.basename(args.path);
435
const sourcemap = generateNLSSourceMap(source, sourceName, edits);
436
const encodedMap = Buffer.from(sourcemap).toString('base64');
437
const contentsWithMap = code + `\n//# sourceMappingURL=data:application/json;base64,${encodedMap}\n`;
438
return { contents: contentsWithMap, loader: 'ts' };
439
}
440
441
// No NLS calls, return undefined to let esbuild handle normally
442
return undefined;
443
});
444
}
445
};
446
}
447
448