Skip to content

Commit f11cd1d

Browse files
committed
feat: chain source maps for all minimizers via the dispatcher
Move source-map composition out of each minimizer and into `minify.js`. After every step in the `minify` array, the dispatcher composes the step's `name → step-output` map with the accumulated `original → name` map using a tiny in-house composer built on `@jridgewell/trace-mapping` (already a direct dep) plus an inline VLQ encoder. No new packages. This makes source-map chaining work uniformly across every built-in minimizer — including the ones whose underlying tools don't accept an input source map natively (`@swc/core`, `esbuild`, `csso`, `@swc/css`). Previously, mixing such minimizers in a `minify: [...]` array silently dropped the chain back to the original sources. Adds three more multi-minimizer test cases (terser+swc, terser+esbuild, and a CSS chain that runs `cssnano` → `csso` → `cleanCss` → `lightningCss` → `swcMinifyCss` → `esbuildMinifyCss`); existing snapshots that previously captured tool-internal chained maps are updated to the dispatcher-chained equivalents.
1 parent fa047c8 commit f11cd1d

7 files changed

Lines changed: 454 additions & 73 deletions

File tree

src/minify.js

Lines changed: 284 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,286 @@
66
* @typedef {import("./index.js").MinimizerOptions<T>} MinimizerOptions
77
*/
88

9+
const VLQ_BASE64 =
10+
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
11+
12+
/**
13+
* Encode a single integer as Base64 VLQ as used by the source-map spec.
14+
* @param {number} value integer to encode
15+
* @returns {string} encoded VLQ characters
16+
*/
17+
/* eslint-disable prefer-destructuring, no-eq-null, eqeqeq */
18+
/**
19+
* @param {number} value integer to encode
20+
* @returns {string} encoded VLQ characters
21+
*/
22+
function encodeVlq(value) {
23+
let vlq = value < 0 ? (-value << 1) | 1 : value << 1;
24+
let out = "";
25+
26+
do {
27+
let digit = vlq & 0b11111;
28+
29+
vlq >>>= 5;
30+
31+
if (vlq > 0) {
32+
digit |= 0b100000;
33+
}
34+
35+
out += VLQ_BASE64[digit];
36+
} while (vlq > 0);
37+
38+
return out;
39+
}
40+
41+
/**
42+
* Encode decoded source-map mappings (per-line arrays of segments) back into
43+
* the spec's `mappings` string.
44+
* @param {number[][][]} decoded mappings as nested arrays of segments
45+
* @returns {string} encoded `mappings` field
46+
*/
47+
function encodeMappings(decoded) {
48+
let result = "";
49+
let prevSourceIdx = 0;
50+
let prevOriginalLine = 0;
51+
let prevOriginalColumn = 0;
52+
let prevNameIdx = 0;
53+
54+
for (let line = 0; line < decoded.length; line++) {
55+
if (line > 0) {
56+
result += ";";
57+
}
58+
59+
let prevGeneratedColumn = 0;
60+
const segments = decoded[line];
61+
62+
for (let i = 0; i < segments.length; i++) {
63+
if (i > 0) {
64+
result += ",";
65+
}
66+
67+
const seg = segments[i];
68+
69+
result += encodeVlq(seg[0] - prevGeneratedColumn);
70+
prevGeneratedColumn = seg[0];
71+
72+
if (seg.length >= 4) {
73+
result += encodeVlq(seg[1] - prevSourceIdx);
74+
prevSourceIdx = seg[1];
75+
result += encodeVlq(seg[2] - prevOriginalLine);
76+
prevOriginalLine = seg[2];
77+
result += encodeVlq(seg[3] - prevOriginalColumn);
78+
prevOriginalColumn = seg[3];
79+
80+
if (seg.length >= 5) {
81+
result += encodeVlq(seg[4] - prevNameIdx);
82+
prevNameIdx = seg[4];
83+
}
84+
}
85+
}
86+
}
87+
88+
return result;
89+
}
90+
91+
/**
92+
* Compose a freshly-produced source map with the input source map fed to
93+
* the minimizer. `currentMap` represents `name → step-output` and
94+
* `prevMap` represents `original → name`; the result represents
95+
* `original → step-output`.
96+
* @param {RawSourceMap | undefined} currentMap map produced by the minimizer
97+
* @param {RawSourceMap | undefined} prevMap input source map fed to the minimizer
98+
* @param {string} name name of the asset that the current map points to
99+
* @returns {RawSourceMap | undefined} composed map
100+
*/
101+
function composeSourceMaps(currentMap, prevMap, name) {
102+
if (!currentMap || !prevMap) {
103+
return currentMap;
104+
}
105+
106+
const {
107+
TraceMap,
108+
decodedMappings,
109+
originalPositionFor,
110+
sourceContentFor,
111+
} = require("@jridgewell/trace-mapping");
112+
113+
const current = new TraceMap(
114+
/** @type {import("@jridgewell/trace-mapping").SourceMapInput} */ (
115+
/** @type {unknown} */ (currentMap)
116+
),
117+
);
118+
const previous = new TraceMap(
119+
/** @type {import("@jridgewell/trace-mapping").SourceMapInput} */ (
120+
/** @type {unknown} */ (prevMap)
121+
),
122+
);
123+
124+
/** @type {string[]} */
125+
const sources = [];
126+
/** @type {(string | null)[]} */
127+
const sourcesContent = [];
128+
/** @type {string[]} */
129+
const names = [];
130+
/** @type {Map<string, number>} */
131+
const sourceIdx = new Map();
132+
/** @type {Map<string, number>} */
133+
const nameIdx = new Map();
134+
135+
/**
136+
* @param {string | null | undefined} source source identifier
137+
* @param {string | undefined} content source content (when available)
138+
* @returns {number} index assigned in the composed map
139+
*/
140+
const getSourceIdx = (source, content) => {
141+
const key = source || "";
142+
let idx = sourceIdx.get(key);
143+
144+
if (typeof idx === "undefined") {
145+
idx = sources.length;
146+
sources.push(key);
147+
sourcesContent.push(typeof content === "string" ? content : null);
148+
sourceIdx.set(key, idx);
149+
} else if (typeof content === "string" && sourcesContent[idx] === null) {
150+
sourcesContent[idx] = content;
151+
}
152+
153+
return idx;
154+
};
155+
156+
/**
157+
* @param {string | null | undefined} value name
158+
* @returns {number} index assigned in the composed map
159+
*/
160+
const getNameIdx = (value) => {
161+
if (typeof value !== "string") {
162+
return -1;
163+
}
164+
165+
let idx = nameIdx.get(value);
166+
167+
if (typeof idx === "undefined") {
168+
idx = names.length;
169+
names.push(value);
170+
nameIdx.set(value, idx);
171+
}
172+
173+
return idx;
174+
};
175+
176+
const decoded = decodedMappings(current);
177+
const currentSources = current.sources.map(
178+
/**
179+
* @param {string | null} source source from current map
180+
* @returns {string} normalized source string
181+
*/
182+
(source) => source || "",
183+
);
184+
const currentNames = current.names;
185+
186+
/** @type {number[][][]} */
187+
const composed = [];
188+
189+
for (let line = 0; line < decoded.length; line++) {
190+
/** @type {number[][]} */
191+
const newSegments = [];
192+
193+
for (const rawSeg of decoded[line]) {
194+
const seg = /** @type {number[]} */ (rawSeg);
195+
196+
// Single-element segment is just a generated column with no source info
197+
if (seg.length < 4) {
198+
newSegments.push([seg[0]]);
199+
continue;
200+
}
201+
202+
const sourceName = currentSources[seg[1]];
203+
const origLine = /** @type {number} */ (seg[2]);
204+
const origCol = /** @type {number} */ (seg[3]);
205+
const segName =
206+
seg.length >= 5
207+
? currentNames[seg[4]]
208+
: /** @type {string | null} */ (null);
209+
210+
// When the segment points back at our intermediate `name`, look up
211+
// the original position in the previous map and emit a mapping that
212+
// points all the way back. Otherwise keep the segment as-is.
213+
if (sourceName === name) {
214+
const orig = originalPositionFor(previous, {
215+
line: origLine + 1,
216+
column: origCol,
217+
});
218+
219+
if (
220+
typeof orig.source !== "string" ||
221+
orig.line == null ||
222+
orig.column == null
223+
) {
224+
continue;
225+
}
226+
227+
const content = sourceContentFor(previous, orig.source) || undefined;
228+
const newSrcIdx = getSourceIdx(orig.source, content);
229+
const finalName =
230+
typeof orig.name === "string" && orig.name ? orig.name : segName;
231+
232+
if (typeof finalName === "string") {
233+
newSegments.push([
234+
seg[0],
235+
newSrcIdx,
236+
orig.line - 1,
237+
orig.column,
238+
getNameIdx(finalName),
239+
]);
240+
} else {
241+
newSegments.push([seg[0], newSrcIdx, orig.line - 1, orig.column]);
242+
}
243+
} else {
244+
const content = sourceContentFor(current, sourceName) || undefined;
245+
const newSrcIdx = getSourceIdx(sourceName, content);
246+
247+
if (typeof segName === "string") {
248+
newSegments.push([
249+
seg[0],
250+
newSrcIdx,
251+
origLine,
252+
origCol,
253+
getNameIdx(segName),
254+
]);
255+
} else {
256+
newSegments.push([seg[0], newSrcIdx, origLine, origCol]);
257+
}
258+
}
259+
}
260+
261+
composed.push(newSegments);
262+
}
263+
264+
const result =
265+
/** @type {RawSourceMap} */
266+
(
267+
/** @type {unknown} */ ({
268+
version: 3,
269+
sources,
270+
names,
271+
mappings: encodeMappings(composed),
272+
})
273+
);
274+
275+
if (currentMap.file) {
276+
result.file = currentMap.file;
277+
}
278+
279+
if (sourcesContent.some((value) => typeof value === "string")) {
280+
result.sourcesContent =
281+
/** @type {string[]} */
282+
(/** @type {unknown} */ (sourcesContent));
283+
}
284+
285+
return result;
286+
}
287+
/* eslint-enable no-bitwise, prefer-destructuring, no-eq-null, eqeqeq */
288+
9289
/**
10290
* @template T
11291
* @param {import("./index.js").InternalOptions<T>} options options
@@ -74,7 +354,10 @@ async function minify(options) {
74354

75355
if (typeof result.code === "string") {
76356
lastCode = result.code;
77-
lastMap = result.map;
357+
// The minimizer's output map is `name → step-output`. Chain it with
358+
// the previous accumulated map so that across an array of minimizers
359+
// the final map points back to the original sources.
360+
lastMap = composeSourceMaps(result.map, currentMap, name);
78361
}
79362
}
80363

src/utils.js

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -289,14 +289,10 @@ async function terserMinify(
289289
// Copy `terser` options
290290
const terserOptions = buildTerserOptions(minimizerOptions);
291291

292-
// Let terser generate a SourceMap. Pass the input source map so that
293-
// chained minimizers produce a map back to the original sources.
292+
// Let terser generate a SourceMap. The dispatcher in `minify.js`
293+
// chains the previous step's map onto this one.
294294
if (sourceMap) {
295-
terserOptions.sourceMap =
296-
/** @type {import("terser").SourceMapOptions} */ ({
297-
asObject: true,
298-
content: sourceMap,
299-
});
295+
terserOptions.sourceMap = { asObject: true };
300296
}
301297

302298
/** @type {ExtractedComments} */
@@ -543,13 +539,10 @@ async function uglifyJsMinify(
543539
// Copy `uglify-js` options
544540
const uglifyJsOptions = buildUglifyJsOptions(minimizerOptions);
545541

546-
// Let `uglify-js` generate a SourceMap, chaining through the input
547-
// map so that combined minimizers map back to original sources.
542+
// Let `uglify-js` generate a SourceMap. The dispatcher in `minify.js`
543+
// chains the previous step's map onto this one.
548544
if (sourceMap) {
549-
uglifyJsOptions.sourceMap =
550-
/** @type {import("uglify-js").SourceMapOptions} */ (
551-
/** @type {unknown} */ ({ content: sourceMap })
552-
);
545+
uglifyJsOptions.sourceMap = true;
553546
}
554547

555548
/** @type {ExtractedComments} */
@@ -1310,7 +1303,7 @@ async function cssnanoMinify(
13101303
}
13111304

13121305
if (sourceMap) {
1313-
postcssOptions.map = { annotation: false, prev: sourceMap };
1306+
postcssOptions.map = { annotation: false };
13141307
}
13151308

13161309
const result = await postcss
@@ -1624,7 +1617,8 @@ async function lightningCssMinify(input, sourceMap, minimizerOptions) {
16241617
// Copy `lightningCss` options
16251618
const lightningCssOptions = buildLightningCssOptions(minimizerOptions);
16261619

1627-
// Let `lightningcss` generate a SourceMap
1620+
// Let `lightningcss` generate a SourceMap. The dispatcher in
1621+
// `minify.js` chains the previous step's map onto this one.
16281622
if (sourceMap) {
16291623
lightningCssOptions.sourceMap = true;
16301624
}

0 commit comments

Comments
 (0)