pkgprn 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.d.ts.map CHANGED
@@ -6,11 +6,11 @@
6
6
  "Logger"
7
7
  ],
8
8
  "sources": [
9
- "prune.js"
9
+ "src/prune.js"
10
10
  ],
11
11
  "sourcesContent": [
12
12
  null
13
13
  ],
14
- "mappings": ";;;;iBAsEsBA,QAAQA;aAhCUC,MAAMA",
14
+ "mappings": ";;;;iBAuEsBA,QAAQA;aAhCUC,MAAMA",
15
15
  "ignoreList": []
16
16
  }
package/package.json CHANGED
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "name": "pkgprn",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "license": "MIT",
5
5
  "author": "Konstantin Shutkin",
6
- "bin": "./index.js",
6
+ "bin": "./src/index.js",
7
7
  "type": "module",
8
8
  "types": "./index.d.ts",
9
9
  "exports": {
10
10
  ".": {
11
11
  "types": "./index.d.ts",
12
- "default": "./prune.js"
12
+ "default": "./src/prune.js"
13
13
  },
14
14
  "./package.json": "./package.json"
15
15
  },
@@ -29,11 +29,10 @@
29
29
  "type": "git",
30
30
  "url": "git+https://github.com/kshutkin/package-prune.git"
31
31
  },
32
- "main": "prune.js",
32
+ "main": "src/prune.js",
33
33
  "dependencies": {
34
34
  "@jridgewell/sourcemap-codec": "^1.5.5",
35
35
  "@niceties/logger": "^1.1.13",
36
- "cleye": "2.2.1",
37
- "jsonata": "^2.1.0"
36
+ "cleye": "2.2.1"
38
37
  }
39
38
  }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Extracts all file path references from a package.json object.
3
+ *
4
+ * Replaces the jsonata expression:
5
+ * [bin, bin.*, main, module, unpkg, umd, types, typings, exports[].*.*, typesVersions.*.*, directories.bin]
6
+ *
7
+ * @param {Record<string, unknown>} pkg
8
+ * @returns {unknown[]}
9
+ */
10
+ export function extractReferences(pkg) {
11
+ /** @type {unknown[]} */
12
+ const result = [];
13
+
14
+ // bin — included as-is (string or object; non-strings are skipped by consuming code)
15
+ if (pkg.bin !== undefined && pkg.bin !== null) {
16
+ result.push(pkg.bin);
17
+ }
18
+
19
+ // bin.* — all values when bin is an object
20
+ if (typeof pkg.bin === 'object' && pkg.bin !== null && !Array.isArray(pkg.bin)) {
21
+ for (const value of Object.values(pkg.bin)) {
22
+ result.push(value);
23
+ }
24
+ }
25
+
26
+ // simple top-level fields: main, module, unpkg, umd, types, typings
27
+ const topLevelFields = ['main', 'module', 'unpkg', 'umd', 'types', 'typings'];
28
+ for (const field of topLevelFields) {
29
+ if (pkg[field] !== undefined && pkg[field] !== null) {
30
+ result.push(pkg[field]);
31
+ }
32
+ }
33
+
34
+ // exports[].*.* — navigate exactly 2 levels deep into the exports object.
35
+ // Level 1: values of the exports object (e.g. exports["."], exports["./second"])
36
+ // Level 2: values of each level-1 value (if it's an object)
37
+ // String values at level 1 are skipped (jsonata wildcard on string yields nothing).
38
+ // Array values at level 2 are flattened.
39
+ if (typeof pkg.exports === 'object' && pkg.exports !== null && !Array.isArray(pkg.exports)) {
40
+ for (const level1 of Object.values(pkg.exports)) {
41
+ if (typeof level1 === 'object' && level1 !== null && !Array.isArray(level1)) {
42
+ for (const level2 of Object.values(/** @type {Record<string, unknown>} */ (level1))) {
43
+ if (Array.isArray(level2)) {
44
+ for (const item of level2) {
45
+ result.push(item);
46
+ }
47
+ } else {
48
+ result.push(level2);
49
+ }
50
+ }
51
+ }
52
+ }
53
+ }
54
+
55
+ // typesVersions.*.* — 2 levels of wildcard, arrays are flattened
56
+ if (typeof pkg.typesVersions === 'object' && pkg.typesVersions !== null && !Array.isArray(pkg.typesVersions)) {
57
+ for (const level1 of Object.values(pkg.typesVersions)) {
58
+ if (typeof level1 === 'object' && level1 !== null && !Array.isArray(level1)) {
59
+ for (const level2 of Object.values(/** @type {Record<string, unknown>} */ (level1))) {
60
+ if (Array.isArray(level2)) {
61
+ for (const item of level2) {
62
+ result.push(item);
63
+ }
64
+ } else {
65
+ result.push(level2);
66
+ }
67
+ }
68
+ }
69
+ }
70
+ }
71
+
72
+ // directories.bin
73
+ if (typeof pkg.directories === 'object' && pkg.directories !== null) {
74
+ const dirs = /** @type {Record<string, unknown>} */ (pkg.directories);
75
+ if (dirs.bin !== undefined && dirs.bin !== null) {
76
+ result.push(dirs.bin);
77
+ }
78
+ }
79
+
80
+ return result;
81
+ }
@@ -1,6 +1,7 @@
1
1
  import { access, mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
 
4
+ import { extractReferences } from './extract-references.js';
4
5
  import { adjustSourcemapLineMappings, isStrippableFile, parseCommentTypes, stripCommentsWithLineMap } from './strip-comments.js';
5
6
 
6
7
  /**
@@ -270,12 +271,9 @@ export async function prunePkg(pkg, options, logger) {
270
271
  * @param {Logger} logger
271
272
  */
272
273
  async function flatten(pkg, flatten, logger) {
273
- const { default: jsonata } = await import('jsonata');
274
-
275
274
  // find out where is the dist folder
276
275
 
277
- const expression = jsonata('[bin, bin.*, main, module, unpkg, umd, types, typings, exports[].*.*, typesVersions.*.*, directories.bin]');
278
- const allReferences = await expression.evaluate(pkg);
276
+ const allReferences = extractReferences(pkg);
279
277
 
280
278
  /** @type {string[]} */
281
279
  let distDirs;
@@ -592,7 +590,8 @@ async function adjustSourcemapPaths(newMapPath, oldMapPath, oldToNew) {
592
590
  * @returns {boolean}
593
591
  */
594
592
  function isSubDirectory(parent, child) {
595
- return path.relative(child, parent).startsWith('..');
593
+ const rel = path.relative(parent, child);
594
+ return rel !== '' && !rel.startsWith('..');
596
595
  }
597
596
 
598
597
  /**
@@ -0,0 +1,731 @@
1
+ import { decode, encode } from '@jridgewell/sourcemap-codec';
2
+
3
+ /**
4
+ * @typedef {'jsdoc' | 'license' | 'regular'} CommentType
5
+ */
6
+
7
+ /**
8
+ * @typedef {Object} CommentRange
9
+ * @property {number} start - Start index in source (inclusive)
10
+ * @property {number} end - End index in source (exclusive)
11
+ * @property {CommentType} type - Classification of the comment
12
+ */
13
+
14
+ /**
15
+ * @typedef {Object} StripResult
16
+ * @property {string} result - The stripped source text
17
+ * @property {Int32Array | null} lineMap - Maps 0-based original line → 0-based new line (-1 if removed). null when nothing changed.
18
+ */
19
+
20
+ const jsExtensions = ['.js', '.mjs', '.cjs'];
21
+
22
+ /**
23
+ * Check if a file path has a JS extension that may contain comments.
24
+ * @param {string} file
25
+ * @returns {boolean}
26
+ */
27
+ export function isStrippableFile(file) {
28
+ return jsExtensions.some(ext => file.endsWith(ext));
29
+ }
30
+
31
+ /**
32
+ * Keywords after which a `/` token begins a regex literal rather than division.
33
+ */
34
+ const regexPrecedingKeywords = new Set([
35
+ 'return',
36
+ 'throw',
37
+ 'typeof',
38
+ 'void',
39
+ 'delete',
40
+ 'new',
41
+ 'in',
42
+ 'instanceof',
43
+ 'case',
44
+ 'yield',
45
+ 'await',
46
+ 'of',
47
+ 'export',
48
+ 'import',
49
+ 'default',
50
+ 'extends',
51
+ 'else',
52
+ ]);
53
+
54
+ /**
55
+ * Classify a block comment based on its content.
56
+ * Priority: license > jsdoc > regular.
57
+ *
58
+ * @param {string} source - Full source text
59
+ * @param {number} start - Start index of the comment (at `/`)
60
+ * @param {number} end - End index of the comment (after `*​/`)
61
+ * @returns {CommentType}
62
+ */
63
+ function classifyBlockComment(source, start, end) {
64
+ // License: starts with /*! or contains @license or @preserve
65
+ if (source[start + 2] === '!') {
66
+ return 'license';
67
+ }
68
+
69
+ // Check for @license or @preserve inside the comment body
70
+ const body = source.slice(start + 2, end - 2);
71
+ if (body.includes('@license') || body.includes('@preserve')) {
72
+ return 'license';
73
+ }
74
+
75
+ // JSDoc: starts with /** (and is not the degenerate /**/ which is length 4)
76
+ if (source[start + 2] === '*' && end - start > 4) {
77
+ return 'jsdoc';
78
+ }
79
+
80
+ return 'regular';
81
+ }
82
+
83
+ /**
84
+ * Scan source code and return an array of comment ranges with their types.
85
+ * Correctly handles:
86
+ * - Single and double quoted strings (with escapes)
87
+ * - Template literals (with nested `${…}` expressions, arbitrarily deep)
88
+ * - Regular expression literals (with character classes `[…]`)
89
+ * - Hashbang lines (`#!/…`)
90
+ * - Single-line comments (`// …`)
91
+ * - Block comments (`/* … *​/`)
92
+ *
93
+ * @param {string} source
94
+ * @returns {CommentRange[]}
95
+ */
96
+ export function scanComments(source) {
97
+ /** @type {CommentRange[]} */
98
+ const comments = [];
99
+ const len = source.length;
100
+ let i = 0;
101
+
102
+ // Stack for template literal nesting.
103
+ // Each entry holds the brace depth inside a `${…}` expression.
104
+ // When the stack is non-empty the main loop is inside a template expression.
105
+ /** @type {number[]} */
106
+ const templateStack = [];
107
+
108
+ // For regex-vs-division disambiguation we track whether the last
109
+ // *significant* (non-whitespace, non-comment) token could be the end
110
+ // of an expression. If it could, `/` is the division operator;
111
+ // otherwise `/` starts a regex literal.
112
+ let exprEnd = false;
113
+
114
+ // --- Hashbang ----------------------------------------------------------
115
+ if (len >= 2 && source[0] === '#' && source[1] === '!') {
116
+ // Skip the entire hashbang line — it is never a comment.
117
+ while (i < len && source[i] !== '\n') i++;
118
+ // exprEnd stays false (hashbang is like the start of the file)
119
+ }
120
+
121
+ while (i < len) {
122
+ const ch = source.charCodeAt(i);
123
+
124
+ // ---- whitespace (skip, preserve exprEnd) --------------------------
125
+ // space, tab, newline, carriage return, vertical tab, form feed,
126
+ // BOM / NBSP (0xFEFF, 0x00A0) – we keep it simple: anything ≤ 0x20
127
+ // plus the two common Unicode whitespace chars.
128
+ if (ch <= 0x20 || ch === 0xfeff || ch === 0xa0) {
129
+ i++;
130
+ continue;
131
+ }
132
+
133
+ // ---- single-line comment ------------------------------------------
134
+ if (ch === 0x2f /* / */ && i + 1 < len && source.charCodeAt(i + 1) === 0x2f /* / */) {
135
+ const start = i;
136
+ i += 2;
137
+ while (i < len && source.charCodeAt(i) !== 0x0a /* \n */) i++;
138
+ comments.push({ start, end: i, type: 'regular' });
139
+ // exprEnd unchanged (comments are transparent)
140
+ continue;
141
+ }
142
+
143
+ // ---- block comment ------------------------------------------------
144
+ if (ch === 0x2f /* / */ && i + 1 < len && source.charCodeAt(i + 1) === 0x2a /* * */) {
145
+ const start = i;
146
+ i += 2;
147
+ while (i < len && !((source.charCodeAt(i) === 0x2a /* * */ && i + 1 < len && source.charCodeAt(i + 1) === 0x2f) /* / */)) {
148
+ i++;
149
+ }
150
+ if (i < len) i += 2; // skip closing */
151
+ comments.push({ start, end: i, type: classifyBlockComment(source, start, i) });
152
+ // exprEnd unchanged
153
+ continue;
154
+ }
155
+
156
+ // ---- regex literal ------------------------------------------------
157
+ if (ch === 0x2f /* / */ && !exprEnd) {
158
+ i = skipRegex(source, i, len);
159
+ exprEnd = true; // a regex is a value
160
+ continue;
161
+ }
162
+
163
+ // ---- single-quoted string ----------------------------------------
164
+ if (ch === 0x27 /* ' */) {
165
+ i = skipSingleString(source, i, len);
166
+ exprEnd = true;
167
+ continue;
168
+ }
169
+
170
+ // ---- double-quoted string ----------------------------------------
171
+ if (ch === 0x22 /* " */) {
172
+ i = skipDoubleString(source, i, len);
173
+ exprEnd = true;
174
+ continue;
175
+ }
176
+
177
+ // ---- template literal --------------------------------------------
178
+ if (ch === 0x60 /* ` */) {
179
+ i = scanTemplateTail(source, i + 1, len, templateStack, comments);
180
+ exprEnd = true;
181
+ continue;
182
+ }
183
+
184
+ // ---- closing brace: may end a template expression ----------------
185
+ if (ch === 0x7d /* } */) {
186
+ if (templateStack.length > 0) {
187
+ const depth = templateStack[templateStack.length - 1];
188
+ if (depth === 0) {
189
+ // Returning from a template expression back to the template body.
190
+ templateStack.pop();
191
+ i = scanTemplateTail(source, i + 1, len, templateStack, comments);
192
+ exprEnd = true;
193
+ continue;
194
+ }
195
+ templateStack[templateStack.length - 1] = depth - 1;
196
+ }
197
+ i++;
198
+ // After `}` we conservatively assume regex can follow.
199
+ // This is correct for block statements, if/for/while bodies,
200
+ // class bodies, etc. For the rare `({}) / x` pattern it would
201
+ // misidentify division as regex, but that is harmless for
202
+ // comment detection (we just skip over the "regex" body).
203
+ exprEnd = false;
204
+ continue;
205
+ }
206
+
207
+ // ---- opening brace -----------------------------------------------
208
+ if (ch === 0x7b /* { */) {
209
+ if (templateStack.length > 0) {
210
+ templateStack[templateStack.length - 1]++;
211
+ }
212
+ i++;
213
+ exprEnd = false;
214
+ continue;
215
+ }
216
+
217
+ // ---- identifier / keyword / number --------------------------------
218
+ if (isIdentStart(ch) || isDigit(ch)) {
219
+ const wordStart = i;
220
+ i++;
221
+ while (i < len && isIdentPart(source.charCodeAt(i))) i++;
222
+ const word = source.slice(wordStart, i);
223
+ exprEnd = !regexPrecedingKeywords.has(word);
224
+ continue;
225
+ }
226
+
227
+ // ---- ++ and -- ----------------------------------------------------
228
+ if ((ch === 0x2b /* + */ || ch === 0x2d) /* - */ && i + 1 < len && source.charCodeAt(i + 1) === ch) {
229
+ i += 2;
230
+ exprEnd = true; // `x++` / `x--` end an expression
231
+ continue;
232
+ }
233
+
234
+ // ---- closing brackets ) ] ----------------------------------------
235
+ if (ch === 0x29 /* ) */ || ch === 0x5d /* ] */) {
236
+ i++;
237
+ exprEnd = true;
238
+ continue;
239
+ }
240
+
241
+ // ---- everything else: operators, punctuation ----------------------
242
+ i++;
243
+ exprEnd = false;
244
+ }
245
+
246
+ return comments;
247
+ }
248
+
249
+ // ---------------------------------------------------------------------------
250
+ // Character classification helpers
251
+ // ---------------------------------------------------------------------------
252
+
253
+ /**
254
+ * @param {number} ch - char code
255
+ * @returns {boolean}
256
+ */
257
+ function isDigit(ch) {
258
+ return ch >= 0x30 && ch <= 0x39; // 0-9
259
+ }
260
+
261
+ /**
262
+ * @param {number} ch - char code
263
+ * @returns {boolean}
264
+ */
265
+ function isIdentStart(ch) {
266
+ return (
267
+ (ch >= 0x41 && ch <= 0x5a) || // A-Z
268
+ (ch >= 0x61 && ch <= 0x7a) || // a-z
269
+ ch === 0x5f || // _
270
+ ch === 0x24 || // $
271
+ ch === 0x5c || // \ (unicode escape in identifier)
272
+ ch > 0x7f // non-ASCII (simplified – covers all Unicode ID_Start)
273
+ );
274
+ }
275
+
276
+ /**
277
+ * @param {number} ch - char code
278
+ * @returns {boolean}
279
+ */
280
+ function isIdentPart(ch) {
281
+ return isIdentStart(ch) || isDigit(ch);
282
+ }
283
+
284
+ // ---------------------------------------------------------------------------
285
+ // Skip helpers — each returns the new index *after* the construct.
286
+ // ---------------------------------------------------------------------------
287
+
288
+ /**
289
+ * Skip a single-quoted string starting at index `i` (which points at the
290
+ * opening `'`). Returns the index after the closing `'`.
291
+ * @param {string} s
292
+ * @param {number} i
293
+ * @param {number} len
294
+ * @returns {number}
295
+ */
296
+ function skipSingleString(s, i, len) {
297
+ i++; // skip opening '
298
+ while (i < len) {
299
+ const ch = s.charCodeAt(i);
300
+ if (ch === 0x27 /* ' */) {
301
+ i++;
302
+ break;
303
+ }
304
+ if (ch === 0x5c /* \ */) {
305
+ i += 2;
306
+ continue;
307
+ } // escape
308
+ if (ch === 0x0a /* \n */ || ch === 0x0d /* \r */) break; // unterminated
309
+ i++;
310
+ }
311
+ return i;
312
+ }
313
+
314
+ /**
315
+ * Skip a double-quoted string starting at index `i` (which points at the
316
+ * opening `"`). Returns the index after the closing `"`.
317
+ * @param {string} s
318
+ * @param {number} i
319
+ * @param {number} len
320
+ * @returns {number}
321
+ */
322
+ function skipDoubleString(s, i, len) {
323
+ i++; // skip opening "
324
+ while (i < len) {
325
+ const ch = s.charCodeAt(i);
326
+ if (ch === 0x22 /* " */) {
327
+ i++;
328
+ break;
329
+ }
330
+ if (ch === 0x5c /* \ */) {
331
+ i += 2;
332
+ continue;
333
+ }
334
+ if (ch === 0x0a || ch === 0x0d) break; // unterminated
335
+ i++;
336
+ }
337
+ return i;
338
+ }
339
+
340
+ /**
341
+ * Skip a regex literal starting at index `i` (which points at the opening `/`).
342
+ * Handles character classes `[…]` and escape sequences.
343
+ * Returns the index after the closing `/` and any flags.
344
+ * @param {string} s
345
+ * @param {number} i
346
+ * @param {number} len
347
+ * @returns {number}
348
+ */
349
+ function skipRegex(s, i, len) {
350
+ i++; // skip opening /
351
+ while (i < len) {
352
+ const ch = s.charCodeAt(i);
353
+ if (ch === 0x5c /* \ */) {
354
+ i += 2; // skip escaped char
355
+ continue;
356
+ }
357
+ if (ch === 0x5b /* [ */) {
358
+ // character class — `]` inside does not end the regex
359
+ i++;
360
+ while (i < len) {
361
+ const cc = s.charCodeAt(i);
362
+ if (cc === 0x5c /* \ */) {
363
+ i += 2;
364
+ continue;
365
+ }
366
+ if (cc === 0x5d /* ] */) {
367
+ i++;
368
+ break;
369
+ }
370
+ if (cc === 0x0a || cc === 0x0d) break; // safety: unterminated
371
+ i++;
372
+ }
373
+ continue;
374
+ }
375
+ if (ch === 0x2f /* / */) {
376
+ i++; // skip closing /
377
+ // consume flags: [a-z] (dgimsvy…)
378
+ while (i < len && isRegexFlag(s.charCodeAt(i))) i++;
379
+ break;
380
+ }
381
+ if (ch === 0x0a || ch === 0x0d) break; // unterminated on this line
382
+ i++;
383
+ }
384
+ return i;
385
+ }
386
+
387
+ /**
388
+ * @param {number} ch
389
+ * @returns {boolean}
390
+ */
391
+ function isRegexFlag(ch) {
392
+ return ch >= 0x61 && ch <= 0x7a; // a-z
393
+ }
394
+
395
+ /**
396
+ * Scan the body of a template literal starting *after* the opening `` ` ``
397
+ * (or after the `}` that closes a template expression).
398
+ *
399
+ * If we hit `${`, we push onto `templateStack` and return to the main loop
400
+ * so that the expression is parsed as normal code (which may contain
401
+ * comments, nested templates, etc.).
402
+ *
403
+ * If we hit the closing `` ` ``, we return and the template is done.
404
+ *
405
+ * @param {string} s
406
+ * @param {number} i - index right after the `` ` `` or `}`
407
+ * @param {number} len
408
+ * @param {number[]} templateStack
409
+ * @param {CommentRange[]} comments - passed through so inner comments are recorded
410
+ * @returns {number} new index
411
+ */
412
+ function scanTemplateTail(s, i, len, templateStack, comments) {
413
+ void comments; // comments only found inside ${} which returns to main loop
414
+ while (i < len) {
415
+ const ch = s.charCodeAt(i);
416
+ if (ch === 0x5c /* \ */) {
417
+ i += 2; // skip escape sequence
418
+ continue;
419
+ }
420
+ if (ch === 0x60 /* ` */) {
421
+ i++; // closing backtick
422
+ return i;
423
+ }
424
+ if (ch === 0x24 /* $ */ && i + 1 < len && s.charCodeAt(i + 1) === 0x7b /* { */) {
425
+ i += 2; // skip ${
426
+ templateStack.push(0); // push new brace depth for this expression
427
+ return i; // return to main loop for expression parsing
428
+ }
429
+ i++;
430
+ }
431
+ return i;
432
+ }
433
+
434
+ // ---------------------------------------------------------------------------
435
+ // Public API
436
+ // ---------------------------------------------------------------------------
437
+
438
+ /**
439
+ * Parse the `--strip-comments` flag value into a `Set` of comment types.
440
+ *
441
+ * - `'all'` or `true` → `{'jsdoc', 'license', 'regular'}`
442
+ * - `'jsdoc,regular'` → `{'jsdoc', 'regular'}`
443
+ *
444
+ * @param {string | true} value
445
+ * @returns {Set<CommentType>}
446
+ */
447
+ export function parseCommentTypes(value) {
448
+ if (value === true || value === 'all') {
449
+ return new Set(/** @type {CommentType[]} */ (['jsdoc', 'license', 'regular']));
450
+ }
451
+
452
+ const valid = /** @type {CommentType[]} */ (['jsdoc', 'license', 'regular']);
453
+ const parts = String(value)
454
+ .split(',')
455
+ .map(s => s.trim())
456
+ .filter(Boolean);
457
+
458
+ /** @type {Set<CommentType>} */
459
+ const result = new Set();
460
+
461
+ for (const part of parts) {
462
+ if (part === 'all') {
463
+ return new Set(valid);
464
+ }
465
+ if (!valid.includes(/** @type {CommentType} */ (part))) {
466
+ throw new Error(`unknown comment type "${part}" (expected: ${valid.join(', ')}, all)`);
467
+ }
468
+ result.add(/** @type {CommentType} */ (part));
469
+ }
470
+
471
+ if (result.size === 0) {
472
+ return new Set(valid); // fallback to all
473
+ }
474
+
475
+ return result;
476
+ }
477
+
478
+ /**
479
+ * Strip comments from `source` whose type is in `typesToStrip`.
480
+ *
481
+ * @param {string} source
482
+ * @param {Set<CommentType>} typesToStrip
483
+ * @returns {string}
484
+ */
485
+ export function stripComments(source, typesToStrip) {
486
+ return stripCommentsWithLineMap(source, typesToStrip).result;
487
+ }
488
+
489
+ /**
490
+ * Strip comments and return both the stripped source and a line map that
491
+ * tracks where each original line ended up in the output.
492
+ *
493
+ * @param {string} source
494
+ * @param {Set<CommentType>} typesToStrip
495
+ * @returns {StripResult}
496
+ */
497
+ export function stripCommentsWithLineMap(source, typesToStrip) {
498
+ const comments = scanComments(source);
499
+
500
+ if (comments.length === 0) return { result: source, lineMap: null };
501
+
502
+ // Filter to only the comments we want to remove.
503
+ const toRemove = comments.filter(c => typesToStrip.has(c.type));
504
+
505
+ if (toRemove.length === 0) return { result: source, lineMap: null };
506
+
507
+ // Build output by copying non-removed ranges.
508
+ /** @type {string[]} */
509
+ const parts = [];
510
+ let pos = 0;
511
+
512
+ for (const { start, end } of toRemove) {
513
+ if (start > pos) {
514
+ parts.push(source.slice(pos, start));
515
+ }
516
+ pos = end;
517
+ }
518
+
519
+ if (pos < source.length) {
520
+ parts.push(source.slice(pos));
521
+ }
522
+
523
+ let intermediate = parts.join('');
524
+
525
+ // --- Build original-line → intermediate-line mapping -------------------
526
+ // For every original offset, compute how many bytes were removed before it.
527
+ // Then convert original line-start offsets to intermediate offsets and
528
+ // derive intermediate line numbers.
529
+
530
+ const origLines = source.split('\n');
531
+ const origLineCount = origLines.length;
532
+
533
+ // Build sorted prefix-sum of removed byte counts for fast lookup.
534
+ // removedBefore(offset) = total chars removed in ranges fully before offset.
535
+ // We also detect if an offset falls inside a removed range.
536
+
537
+ /**
538
+ * Translate an original offset to an intermediate offset.
539
+ * Returns -1 if the offset is inside a removed range.
540
+ * @param {number} offset
541
+ * @returns {number}
542
+ */
543
+ function translateOffset(offset) {
544
+ let removed = 0;
545
+ for (const { start, end } of toRemove) {
546
+ if (offset < start) break;
547
+ if (offset < end) return -1; // inside removed range
548
+ removed += end - start;
549
+ }
550
+ return offset - removed;
551
+ }
552
+
553
+ // For each original line, figure out which intermediate line it maps to.
554
+ // An original line maps to -1 if its entire non-whitespace content was
555
+ // inside removed ranges (i.e. the line becomes blank/whitespace-only).
556
+ const intermediateText = intermediate;
557
+ const intermediateLineStarts = buildLineStarts(intermediateText);
558
+
559
+ /** @type {Int32Array} */
560
+ const origToIntermediate = new Int32Array(origLineCount).fill(-1);
561
+ let origOffset = 0;
562
+ for (let oi = 0; oi < origLineCount; oi++) {
563
+ const lineLen = origLines[oi].length;
564
+ // Check if any content on this line survives.
565
+ // We try the line-start offset; if it's inside a removed range
566
+ // the whole beginning is gone, but content may survive later.
567
+ // The most reliable way: translate the offset of each non-WS char.
568
+ let survived = false;
569
+ for (let ci = 0; ci < lineLen; ci++) {
570
+ const ch = source.charCodeAt(origOffset + ci);
571
+ // skip whitespace chars — they don't count as surviving content
572
+ if (ch === 0x20 || ch === 0x09 || ch === 0x0d) continue;
573
+ const mapped = translateOffset(origOffset + ci);
574
+ if (mapped !== -1) {
575
+ survived = true;
576
+ // Convert intermediate offset to intermediate line number.
577
+ origToIntermediate[oi] = offsetToLine(intermediateLineStarts, mapped);
578
+ break;
579
+ }
580
+ }
581
+ if (!survived) {
582
+ origToIntermediate[oi] = -1;
583
+ }
584
+ origOffset += lineLen + 1; // +1 for the '\n' (split removed it)
585
+ }
586
+
587
+ // --- Apply cleanup (same logic as before) ------------------------------
588
+
589
+ // Trim trailing whitespace from every line.
590
+ intermediate = intermediate.replace(/[ \t]+$/gm, '');
591
+
592
+ // Collapse 3+ consecutive newlines into 2 newlines.
593
+ intermediate = intermediate.replace(/\n{3,}/g, '\n\n');
594
+
595
+ // Remove leading blank lines (preserve hashbang).
596
+ if (intermediate.startsWith('#!')) {
597
+ const hashbangEnd = intermediate.indexOf('\n');
598
+ if (hashbangEnd !== -1) {
599
+ const before = intermediate.slice(0, hashbangEnd + 1);
600
+ const after = intermediate.slice(hashbangEnd + 1).replace(/^\n+/, '');
601
+ intermediate = before + after;
602
+ }
603
+ } else {
604
+ intermediate = intermediate.replace(/^\n+/, '');
605
+ }
606
+
607
+ // Ensure the file ends with exactly one newline (if it originally did).
608
+ if (source.endsWith('\n') && intermediate.length > 0) {
609
+ intermediate = intermediate.replace(/\n*$/, '\n');
610
+ }
611
+
612
+ const result = intermediate;
613
+
614
+ // --- Build intermediate-line → final-line mapping ----------------------
615
+ // The cleanup may have removed/collapsed lines from the intermediateText.
616
+ // We line up intermediateText lines with final lines by content matching.
617
+ const finalLines = result.split('\n');
618
+ const intLines = intermediateText.split('\n');
619
+
620
+ // Trim trailing WS from intermediate lines to match what cleanup did.
621
+ const intLinesTrimmed = intLines.map(l => l.replace(/[ \t]+$/, ''));
622
+
623
+ /** @type {Int32Array} */
624
+ const intermediateToFinal = new Int32Array(intLines.length).fill(-1);
625
+ let fi = 0;
626
+ for (let ii = 0; ii < intLinesTrimmed.length && fi < finalLines.length; ii++) {
627
+ if (intLinesTrimmed[ii] === finalLines[fi]) {
628
+ intermediateToFinal[ii] = fi;
629
+ fi++;
630
+ }
631
+ // else: this intermediate line was removed by cleanup → stays -1
632
+ }
633
+
634
+ // --- Compose: original → intermediate → final --------------------------
635
+ /** @type {Int32Array} */
636
+ const lineMap = new Int32Array(origLineCount).fill(-1);
637
+ for (let oi = 0; oi < origLineCount; oi++) {
638
+ const il = origToIntermediate[oi];
639
+ if (il >= 0 && il < intermediateToFinal.length) {
640
+ lineMap[oi] = intermediateToFinal[il];
641
+ }
642
+ }
643
+
644
+ return { result, lineMap };
645
+ }
646
+
647
+ /**
648
+ * Build an array of line-start offsets for the given text.
649
+ * `result[i]` is the char offset where line `i` begins (0-based lines).
650
+ * @param {string} text
651
+ * @returns {number[]}
652
+ */
653
+ function buildLineStarts(text) {
654
+ /** @type {number[]} */
655
+ const starts = [0];
656
+ for (let i = 0; i < text.length; i++) {
657
+ if (text.charCodeAt(i) === 0x0a) {
658
+ starts.push(i + 1);
659
+ }
660
+ }
661
+ return starts;
662
+ }
663
+
664
+ /**
665
+ * Given sorted line-start offsets, find which line a char offset falls on.
666
+ * @param {number[]} lineStarts
667
+ * @param {number} offset
668
+ * @returns {number} 0-based line number
669
+ */
670
+ function offsetToLine(lineStarts, offset) {
671
+ // Binary search for the last lineStart <= offset.
672
+ let lo = 0;
673
+ let hi = lineStarts.length - 1;
674
+ while (lo < hi) {
675
+ const mid = (lo + hi + 1) >>> 1;
676
+ if (lineStarts[mid] <= offset) {
677
+ lo = mid;
678
+ } else {
679
+ hi = mid - 1;
680
+ }
681
+ }
682
+ return lo;
683
+ }
684
+
685
+ /**
686
+ * Adjust a parsed sourcemap (v3) whose `sources` reference a file that had
687
+ * comments stripped. Updates the original-line numbers in `mappings` for
688
+ * segments that point at the given source index.
689
+ *
690
+ * Segments whose original line maps to -1 (i.e. the line was removed) are
691
+ * dropped from the output.
692
+ *
693
+ * @param {{ version: number, mappings: string, sources?: string[], names?: string[], [k: string]: unknown }} map - Parsed sourcemap object (mutated in place).
694
+ * @param {number} sourceIndex - Index in `map.sources` of the stripped file.
695
+ * @param {Int32Array} lineMap - 0-based original line → 0-based new line (-1 if removed).
696
+ */
697
+ export function adjustSourcemapLineMappings(map, sourceIndex, lineMap) {
698
+ if (map.version !== 3 || typeof map.mappings !== 'string') return;
699
+
700
+ const decoded = decode(map.mappings);
701
+
702
+ for (const line of decoded) {
703
+ // Walk backwards so we can splice without index issues.
704
+ for (let si = line.length - 1; si >= 0; si--) {
705
+ const seg = line[si];
706
+ // Segments with < 4 fields have no source mapping.
707
+ if (seg.length < 4) continue;
708
+ const seg4 = /** @type {[number, number, number, number, ...number[]]} */ (seg);
709
+ // Only adjust segments pointing at the stripped source file.
710
+ if (seg4[1] !== sourceIndex) continue;
711
+
712
+ const origLine = seg4[2]; // 0-based
713
+ if (origLine < 0 || origLine >= lineMap.length) {
714
+ // Out of range — drop it.
715
+ line.splice(si, 1);
716
+ continue;
717
+ }
718
+
719
+ const newLine = lineMap[origLine];
720
+ if (newLine === -1) {
721
+ // The line was removed — drop this segment.
722
+ line.splice(si, 1);
723
+ continue;
724
+ }
725
+
726
+ seg4[2] = newLine;
727
+ }
728
+ }
729
+
730
+ map.mappings = encode(decoded);
731
+ }
File without changes