Skip to content

Commit 89e14ce

Browse files
authored
Add opt-in sourceMap option to emit .css.map files and sourceMappingURL comments alongside compiled CSS (#5806)
* Add opt-in sourceMap option to emit .css.map files and sourceMappingURL comments alongside compiled CSS * Fix os dependent paths + tests * Address comments --------- Co-authored-by: Camille Malonzo <cmalonzo@users.noreply.github.com>
1 parent 531e14b commit 89e14ce

6 files changed

Lines changed: 239 additions & 6 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"changes": [
3+
{
4+
"comment": "Add opt-in sourceMap option to emit `.css.map` files and `sourceMappingURL` comments alongside compiled CSS.",
5+
"type": "minor",
6+
"packageName": "@rushstack/heft-sass-plugin"
7+
}
8+
],
9+
"packageName": "@rushstack/heft-sass-plugin",
10+
"email": "cmalonzo@microsoft.com"
11+
}

heft-plugins/heft-sass-plugin/src/SassPlugin.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export interface ISassConfigurationJson {
3131
excludeFiles?: string[];
3232
doNotTrimOriginalFileExtension?: boolean;
3333
preserveIcssExports?: boolean;
34+
sourceMap?: boolean;
3435
}
3536

3637
const SASS_CONFIGURATION_LOCATION: string = 'config/sass.json';
@@ -100,7 +101,8 @@ export default class SassPlugin implements IHeftPlugin {
100101
silenceDeprecations,
101102
excludeFiles,
102103
doNotTrimOriginalFileExtension,
103-
preserveIcssExports
104+
preserveIcssExports,
105+
sourceMap
104106
} = sassConfigurationJson || {};
105107

106108
function resolveFolder(folder: string): string {
@@ -129,6 +131,7 @@ export default class SassPlugin implements IHeftPlugin {
129131
silenceDeprecations,
130132
doNotTrimOriginalFileExtension,
131133
preserveIcssExports,
134+
sourceMap,
132135
postProcessCssAsync: hooks.postProcessCss.isUsed()
133136
? async (cssText: string) => hooks.postProcessCss.promise(cssText)
134137
: undefined

heft-plugins/heft-sass-plugin/src/SassProcessor.ts

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,12 @@ export interface ISassProcessorOptions {
130130
*/
131131
preserveIcssExports?: boolean;
132132

133+
/**
134+
* If true, a .css.map source map file will be written next to each emitted .css, and a
135+
* sourceMappingURL comment will be appended to the .css. Defaults to false.
136+
*/
137+
sourceMap?: boolean;
138+
133139
/**
134140
* A callback to further modify the raw CSS text after it has been generated. Only relevant if emitting CSS files.
135141
*/
@@ -261,7 +267,8 @@ export class SassProcessor {
261267
load: loadAsync
262268
}
263269
],
264-
silenceDeprecations: deprecationsToSilence
270+
silenceDeprecations: deprecationsToSilence,
271+
...(options.sourceMap && { sourceMap: true, sourceMapIncludeSources: true })
265272
};
266273
}
267274

@@ -761,7 +768,8 @@ export class SassProcessor {
761768
exportAsDefault,
762769
doNotTrimOriginalFileExtension,
763770
postProcessCssAsync,
764-
preserveIcssExports
771+
preserveIcssExports,
772+
sourceMap
765773
} = this._options;
766774

767775
// Handle CSS modules
@@ -833,11 +841,34 @@ export class SassProcessor {
833841
}
834842

835843
const cssPathFromJs: string = `./${cssFilename}`;
844+
845+
// When sourceMap is enabled, prepare the annotated CSS and map basename once —
846+
// cssFilename is identical across all output folders.
847+
const cssMapBasename: string | undefined =
848+
sourceMap && result.sourceMap ? `${cssFilename}.map` : undefined;
849+
const finalCss: string = cssMapBasename ? `${css}\n/*# sourceMappingURL=${cssMapBasename} */\n` : css;
850+
836851
for (const cssOutputFolder of cssOutputFolders) {
837852
const { folder, shimModuleFormat } = cssOutputFolder;
838853

839854
const cssFilePath: string = path.resolve(folder, relativeCssPath);
840-
await FileSystem.writeFileAsync(cssFilePath, css, writeFileOptions);
855+
await FileSystem.writeFileAsync(cssFilePath, finalCss, writeFileOptions);
856+
857+
if (cssMapBasename && result.sourceMap) {
858+
const mapFilePath: string = `${cssFilePath}.map`;
859+
const mapDir: string = path.dirname(cssFilePath);
860+
// Rewrite heft: URL sources to paths relative to the map file's directory
861+
// so that source-map-loader can resolve them back to the original .scss.
862+
const rewrittenSources: string[] = result.sourceMap.sources.map((source) => {
863+
const absoluteSourcePath: string = heftUrlToPath(source);
864+
return Path.convertToSlashes(path.relative(mapDir, absoluteSourcePath));
865+
});
866+
await FileSystem.writeFileAsync(
867+
mapFilePath,
868+
JSON.stringify({ ...result.sourceMap, file: cssFilename, sources: rewrittenSources }),
869+
writeFileOptions
870+
);
871+
}
841872

842873
if (shimModuleFormat && !filename.endsWith('.css')) {
843874
const jsFilePath: string = path.resolve(folder, `${relativeFilePath}.js`);

heft-plugins/heft-sass-plugin/src/schemas/heft-sass-plugin.schema.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,11 @@
116116
"preserveIcssExports": {
117117
"type": "boolean",
118118
"description": "If true, the ICSS `:export` block will be preserved in the emitted CSS output. This is necessary when the CSS is consumed by a webpack loader (e.g. css-loader's icssParser) that extracts `:export` values at bundle time to generate JavaScript exports. Defaults to false."
119+
},
120+
121+
"sourceMap": {
122+
"type": "boolean",
123+
"description": "If true, a `.css.map` source map file will be written next to each emitted `.css` file, and a `sourceMappingURL` comment will be appended to the `.css`. Defaults to `false`."
119124
}
120125
}
121126
}

heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts

Lines changed: 95 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import path from 'node:path';
55
import nodeJsPath from 'node:path';
66

7-
import { FileSystem, Path } from '@rushstack/node-core-library';
7+
import { FileSystem, Path, Text } from '@rushstack/node-core-library';
88
import { MockScopedLogger } from '@rushstack/heft/lib/pluginFramework/logging/MockScopedLogger';
99
import { StringBufferTerminalProvider, Terminal } from '@rushstack/terminal';
1010

@@ -33,6 +33,7 @@ type ICreateProcessorOptions = Partial<
3333
| 'postProcessCssAsync'
3434
| 'preserveIcssExports'
3535
| 'silenceDeprecations'
36+
| 'sourceMap'
3637
| 'srcFolder'
3738
>
3839
>;
@@ -65,6 +66,43 @@ async function compileFixtureAsync(processor: SassProcessor, fixtureFilename: st
6566
await processor.compileFilesAsync(new Set([`${fixturesFolder}/${fixtureFilename}`]));
6667
}
6768

69+
/**
70+
* Replaces OS-/checkout-dependent fields in a source map JSON string with stable placeholders so
71+
* the test can snapshot the result on any machine. Specifically, rewrites `sources[]` entries that
72+
* point at the fixtures folder to a `fixtures/<name>` form, and normalizes `sourcesContent[]`
73+
* line endings to LF.
74+
*/
75+
function normalizeSourceMapForSnapshot(json: string): string {
76+
const map: {
77+
sources?: string[];
78+
sourcesContent?: string[];
79+
[key: string]: unknown;
80+
} = JSON.parse(json);
81+
82+
if (map.sources) {
83+
// sources[] are relative paths from the .css.map output file back to the source .scss.
84+
// In real use both ends live on disk in the same project so this is stable; in this test
85+
// the output folder is the mocked /fake/output/css/ while the source is the real fixture
86+
// on disk, so the relative path traverses the entire checkout-specific path from /fake up
87+
// to the real fixtures folder. Strip everything before the "/fixtures/" segment so the
88+
// snapshot is checkout-independent.
89+
map.sources = map.sources.map((source) => {
90+
const normalized: string = Path.convertToSlashes(source);
91+
const fixturesIndex: number = normalized.indexOf('/fixtures/');
92+
if (fixturesIndex >= 0) {
93+
return normalized.slice(fixturesIndex + 1);
94+
}
95+
const lastSlash: number = normalized.lastIndexOf('/');
96+
return `fixtures/${lastSlash >= 0 ? normalized.slice(lastSlash + 1) : normalized}`;
97+
});
98+
}
99+
if (map.sourcesContent) {
100+
map.sourcesContent = map.sourcesContent.map(Text.convertToLf);
101+
}
102+
103+
return JSON.stringify(map);
104+
}
105+
68106
describe(SassProcessor.name, () => {
69107
let terminalProvider: StringBufferTerminalProvider;
70108
/** Files captured by the mocked FileSystem.writeFileAsync, keyed by absolute path. */
@@ -112,7 +150,14 @@ describe(SassProcessor.name, () => {
112150
NORMALIZED_PLATFORM_FAKE_OUTPUT_BASE_FOLDER,
113151
FAKE_OUTPUT_BASE_FOLDER
114152
);
115-
writtenFiles.set(filePath, String(content));
153+
let serialized: string = String(content);
154+
// Source map contents include the absolute-relative path back to the source file and the
155+
// verbatim source file bytes. Both vary by checkout location and OS line endings, which makes
156+
// raw snapshots non-portable. Normalize them to stable forms before storing.
157+
if (filePath.endsWith('.css.map')) {
158+
serialized = normalizeSourceMapForSnapshot(serialized);
159+
}
160+
writtenFiles.set(filePath, serialized);
116161
});
117162
});
118163

@@ -690,4 +735,52 @@ describe(SassProcessor.name, () => {
690735
expect(logger.errors.length).toBeGreaterThan(0);
691736
});
692737
});
738+
739+
describe('sourceMap option', () => {
740+
it('emits .css.map and sourceMappingURL comment when sourceMap is true', async () => {
741+
const { processor } = createProcessor(terminalProvider, { sourceMap: true });
742+
await compileFixtureAsync(processor, 'classes-and-exports.module.scss');
743+
744+
const mapPaths: string[] = getAllWrittenPathsMatching('.css.map');
745+
expect(mapPaths).toHaveLength(1);
746+
747+
const css: string = getCssOutput('classes-and-exports.module.scss');
748+
expect(css).toMatch(/\/\*# sourceMappingURL=classes-and-exports\.module\.css\.map \*\//);
749+
750+
const mapJson: string = getWrittenFile('classes-and-exports.module.css.map');
751+
const parsedMap: {
752+
version: number;
753+
mappings: string;
754+
sources: string[];
755+
} = JSON.parse(mapJson);
756+
expect(parsedMap.version).toBe(3);
757+
expect(parsedMap.mappings).toBeTruthy();
758+
expect(parsedMap.sources).toHaveLength(1);
759+
expect(parsedMap.sources[0]).toMatch(/classes-and-exports\.module\.scss$/);
760+
});
761+
762+
it('does not emit .css.map or sourceMappingURL comment by default', async () => {
763+
const { processor } = createProcessor(terminalProvider);
764+
await compileFixtureAsync(processor, 'classes-and-exports.module.scss');
765+
766+
expect(getAllWrittenPathsMatching('.css.map')).toHaveLength(0);
767+
expect(getCssOutput('classes-and-exports.module.scss')).not.toContain('sourceMappingURL');
768+
});
769+
770+
it('uses the correct map filename when doNotTrimOriginalFileExtension is true', async () => {
771+
const { processor } = createProcessor(terminalProvider, {
772+
sourceMap: true,
773+
doNotTrimOriginalFileExtension: true
774+
});
775+
await compileFixtureAsync(processor, 'classes-and-exports.module.scss');
776+
777+
// With doNotTrimOriginalFileExtension the CSS file is foo.scss.css, so the map is foo.scss.css.map
778+
const mapPaths: string[] = getAllWrittenPathsMatching('.css.map');
779+
expect(mapPaths).toHaveLength(1);
780+
expect(mapPaths[0]).toMatch(/classes-and-exports\.module\.scss\.css\.map$/);
781+
782+
const css: string = getWrittenFile('classes-and-exports.module.scss.css');
783+
expect(css).toMatch(/\/\*# sourceMappingURL=classes-and-exports\.module\.scss\.css\.map \*\//);
784+
});
785+
});
693786
});

heft-plugins/heft-sass-plugin/src/test/__snapshots__/SassProcessor.test.ts.snap

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1272,6 +1272,96 @@ export default styles;",
12721272
}
12731273
`;
12741274

1275+
exports[`SassProcessor sourceMap option does not emit .css.map or sourceMappingURL comment by default: terminal-output 1`] = `
1276+
Array [
1277+
"[verbose] Checking for changes to 1 files...[n]",
1278+
"[ log] Compiling 1 files...[n]",
1279+
]
1280+
`;
1281+
1282+
exports[`SassProcessor sourceMap option does not emit .css.map or sourceMappingURL comment by default: written-files 1`] = `
1283+
Map {
1284+
"/fake/output/dts/classes-and-exports.module.scss.d.ts" => "declare interface IStyles {
1285+
themeColor: string;
1286+
spacing: string;
1287+
root: string;
1288+
highlighted: string;
1289+
}
1290+
declare const styles: IStyles;
1291+
export default styles;",
1292+
"/fake/output/css/classes-and-exports.module.css" => ".root {
1293+
color: red;
1294+
font-size: 14px;
1295+
}
1296+
1297+
.highlighted {
1298+
background-color: yellow;
1299+
}",
1300+
}
1301+
`;
1302+
1303+
exports[`SassProcessor sourceMap option emits .css.map and sourceMappingURL comment when sourceMap is true: terminal-output 1`] = `
1304+
Array [
1305+
"[verbose] Checking for changes to 1 files...[n]",
1306+
"[ log] Compiling 1 files...[n]",
1307+
]
1308+
`;
1309+
1310+
exports[`SassProcessor sourceMap option emits .css.map and sourceMappingURL comment when sourceMap is true: written-files 1`] = `
1311+
Map {
1312+
"/fake/output/dts/classes-and-exports.module.scss.d.ts" => "declare interface IStyles {
1313+
themeColor: string;
1314+
spacing: string;
1315+
root: string;
1316+
highlighted: string;
1317+
}
1318+
declare const styles: IStyles;
1319+
export default styles;",
1320+
"/fake/output/css/classes-and-exports.module.css" => ".root {
1321+
color: red;
1322+
font-size: 14px;
1323+
}
1324+
1325+
.highlighted {
1326+
background-color: yellow;
1327+
}
1328+
/*# sourceMappingURL=classes-and-exports.module.css.map */
1329+
",
1330+
"/fake/output/css/classes-and-exports.module.css.map" => "{\\"version\\":3,\\"sourceRoot\\":\\"\\",\\"sources\\":[\\"fixtures/classes-and-exports.module.scss\\"],\\"names\\":[],\\"mappings\\":\\"AACA;EACE;EACA;;;AAGF;EACE;;;AAGF;EACE;EACA\\",\\"sourcesContent\\":[\\"// A CSS module that exports both class names and ICSS :export values.\\\\n.root {\\\\n color: red;\\\\n font-size: 14px;\\\\n}\\\\n\\\\n.highlighted {\\\\n background-color: yellow;\\\\n}\\\\n\\\\n:export {\\\\n themeColor: blue;\\\\n spacing: 8px;\\\\n}\\\\n\\"],\\"file\\":\\"classes-and-exports.module.css\\"}",
1331+
}
1332+
`;
1333+
1334+
exports[`SassProcessor sourceMap option uses the correct map filename when doNotTrimOriginalFileExtension is true: terminal-output 1`] = `
1335+
Array [
1336+
"[verbose] Checking for changes to 1 files...[n]",
1337+
"[ log] Compiling 1 files...[n]",
1338+
]
1339+
`;
1340+
1341+
exports[`SassProcessor sourceMap option uses the correct map filename when doNotTrimOriginalFileExtension is true: written-files 1`] = `
1342+
Map {
1343+
"/fake/output/dts/classes-and-exports.module.scss.d.ts" => "declare interface IStyles {
1344+
themeColor: string;
1345+
spacing: string;
1346+
root: string;
1347+
highlighted: string;
1348+
}
1349+
declare const styles: IStyles;
1350+
export default styles;",
1351+
"/fake/output/css/classes-and-exports.module.scss.css" => ".root {
1352+
color: red;
1353+
font-size: 14px;
1354+
}
1355+
1356+
.highlighted {
1357+
background-color: yellow;
1358+
}
1359+
/*# sourceMappingURL=classes-and-exports.module.scss.css.map */
1360+
",
1361+
"/fake/output/css/classes-and-exports.module.scss.css.map" => "{\\"version\\":3,\\"sourceRoot\\":\\"\\",\\"sources\\":[\\"fixtures/classes-and-exports.module.scss\\"],\\"names\\":[],\\"mappings\\":\\"AACA;EACE;EACA;;;AAGF;EACE;;;AAGF;EACE;EACA\\",\\"sourcesContent\\":[\\"// A CSS module that exports both class names and ICSS :export values.\\\\n.root {\\\\n color: red;\\\\n font-size: 14px;\\\\n}\\\\n\\\\n.highlighted {\\\\n background-color: yellow;\\\\n}\\\\n\\\\n:export {\\\\n themeColor: blue;\\\\n spacing: 8px;\\\\n}\\\\n\\"],\\"file\\":\\"classes-and-exports.module.scss.css\\"}",
1362+
}
1363+
`;
1364+
12751365
exports[`SassProcessor use-with-partial.module.scss (@use with local partial) generates .d.ts with class names from a file using @use: terminal-output 1`] = `
12761366
Array [
12771367
"[verbose] Checking for changes to 1 files...[n]",

0 commit comments

Comments
 (0)