-
Notifications
You must be signed in to change notification settings - Fork 687
Add opt-in sourceMap option to emit .css.map files and sourceMappingURL comments alongside compiled CSS #5806
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| { | ||
| "changes": [ | ||
| { | ||
| "comment": "Add opt-in sourceMap option to emit .css.map files and sourceMappingURL comments alongside compiled CSS", | ||
| "type": "minor", | ||
| "packageName": "@rushstack/heft-sass-plugin" | ||
| } | ||
| ], | ||
| "packageName": "@rushstack/heft-sass-plugin", | ||
| "email": "cmalonzo@microsoft.com" | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -130,6 +130,12 @@ export interface ISassProcessorOptions { | |||||
| */ | ||||||
| preserveIcssExports?: boolean; | ||||||
|
|
||||||
| /** | ||||||
| * If true, a .css.map source map file will be written next to each emitted .css, and a | ||||||
| * sourceMappingURL comment will be appended to the .css. Defaults to false. | ||||||
| */ | ||||||
| sourceMap?: boolean; | ||||||
|
|
||||||
| /** | ||||||
| * A callback to further modify the raw CSS text after it has been generated. Only relevant if emitting CSS files. | ||||||
| */ | ||||||
|
|
@@ -261,7 +267,8 @@ export class SassProcessor { | |||||
| load: loadAsync | ||||||
| } | ||||||
| ], | ||||||
| silenceDeprecations: deprecationsToSilence | ||||||
| silenceDeprecations: deprecationsToSilence, | ||||||
| ...(options.sourceMap && { sourceMap: true, sourceMapIncludeSources: true }) | ||||||
| }; | ||||||
| } | ||||||
|
|
||||||
|
|
@@ -761,7 +768,8 @@ export class SassProcessor { | |||||
| exportAsDefault, | ||||||
| doNotTrimOriginalFileExtension, | ||||||
| postProcessCssAsync, | ||||||
| preserveIcssExports | ||||||
| preserveIcssExports, | ||||||
| sourceMap | ||||||
| } = this._options; | ||||||
|
|
||||||
| // Handle CSS modules | ||||||
|
|
@@ -833,11 +841,34 @@ export class SassProcessor { | |||||
| } | ||||||
|
|
||||||
| const cssPathFromJs: string = `./${cssFilename}`; | ||||||
|
|
||||||
| // When sourceMap is enabled, prepare the annotated CSS and map basename once — | ||||||
| // cssFilename is identical across all output folders. | ||||||
| const cssMapBasename: string | undefined = | ||||||
| sourceMap && result.sourceMap ? `${cssFilename}.map` : undefined; | ||||||
| const finalCss: string = cssMapBasename ? `${css}\n/*# sourceMappingURL=${cssMapBasename} */\n` : css; | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
|
|
||||||
| for (const cssOutputFolder of cssOutputFolders) { | ||||||
| const { folder, shimModuleFormat } = cssOutputFolder; | ||||||
|
|
||||||
| const cssFilePath: string = path.resolve(folder, relativeCssPath); | ||||||
| await FileSystem.writeFileAsync(cssFilePath, css, writeFileOptions); | ||||||
| await FileSystem.writeFileAsync(cssFilePath, finalCss, writeFileOptions); | ||||||
|
|
||||||
| if (cssMapBasename && result.sourceMap) { | ||||||
| const mapFilePath: string = `${cssFilePath}.map`; | ||||||
| const mapDir: string = path.dirname(cssFilePath); | ||||||
| // Rewrite heft: URL sources to paths relative to the map file's directory | ||||||
| // so that source-map-loader can resolve them back to the original .scss. | ||||||
| const rewrittenSources: string[] = result.sourceMap.sources.map((source) => { | ||||||
| const absoluteSourcePath: string = heftUrlToPath(source); | ||||||
| return Path.convertToSlashes(path.relative(mapDir, absoluteSourcePath)); | ||||||
| }); | ||||||
| await FileSystem.writeFileAsync( | ||||||
| mapFilePath, | ||||||
| JSON.stringify({ ...result.sourceMap, file: cssFilename, sources: rewrittenSources }), | ||||||
| writeFileOptions | ||||||
| ); | ||||||
| } | ||||||
|
|
||||||
| if (shimModuleFormat && !filename.endsWith('.css')) { | ||||||
| const jsFilePath: string = path.resolve(folder, `${relativeFilePath}.js`); | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -116,6 +116,11 @@ | |||||
| "preserveIcssExports": { | ||||||
| "type": "boolean", | ||||||
| "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." | ||||||
| }, | ||||||
|
|
||||||
| "sourceMap": { | ||||||
| "type": "boolean", | ||||||
| "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." | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| } | ||||||
| } | ||||||
| } | ||||||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -33,6 +33,7 @@ type ICreateProcessorOptions = Partial< | |||||
| | 'postProcessCssAsync' | ||||||
| | 'preserveIcssExports' | ||||||
| | 'silenceDeprecations' | ||||||
| | 'sourceMap' | ||||||
| | 'srcFolder' | ||||||
| > | ||||||
| >; | ||||||
|
|
@@ -65,6 +66,40 @@ async function compileFixtureAsync(processor: SassProcessor, fixtureFilename: st | |||||
| await processor.compileFilesAsync(new Set([`${fixturesFolder}/${fixtureFilename}`])); | ||||||
| } | ||||||
|
|
||||||
| /** | ||||||
| * Replaces OS-/checkout-dependent fields in a source map JSON string with stable placeholders so | ||||||
| * the test can snapshot the result on any machine. Specifically, rewrites `sources[]` entries that | ||||||
| * point at the fixtures folder to a `fixtures/<name>` form, and normalizes `sourcesContent[]` | ||||||
| * line endings to LF. | ||||||
| */ | ||||||
| function normalizeSourceMapForSnapshot(json: string): string { | ||||||
| const map: { | ||||||
| sources?: string[]; | ||||||
| sourcesContent?: string[]; | ||||||
| [key: string]: unknown; | ||||||
| } = JSON.parse(json); | ||||||
|
|
||||||
| if (map.sources) { | ||||||
| map.sources = map.sources.map((source) => { | ||||||
| const normalized: string = Path.convertToSlashes(source); | ||||||
| // sources[] are stored as paths relative to the .css.map output file, so they include a | ||||||
| // checkout-specific prefix like "../../../user/rushstack/heft-plugins/...". Strip everything | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Shouldn't they be relative paths? |
||||||
| // before the well-known "/fixtures/" segment to get a stable suffix. | ||||||
| const fixturesIndex: number = normalized.indexOf('/fixtures/'); | ||||||
| if (fixturesIndex >= 0) { | ||||||
| return normalized.slice(fixturesIndex + 1); | ||||||
| } | ||||||
| const lastSlash: number = normalized.lastIndexOf('/'); | ||||||
| return `fixtures/${lastSlash >= 0 ? normalized.slice(lastSlash + 1) : normalized}`; | ||||||
| }); | ||||||
| } | ||||||
| if (map.sourcesContent) { | ||||||
| map.sourcesContent = map.sourcesContent.map((entry) => entry.replace(/\r\n/g, '\n')); | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
( |
||||||
| } | ||||||
|
|
||||||
| return JSON.stringify(map); | ||||||
| } | ||||||
|
|
||||||
| describe(SassProcessor.name, () => { | ||||||
| let terminalProvider: StringBufferTerminalProvider; | ||||||
| /** Files captured by the mocked FileSystem.writeFileAsync, keyed by absolute path. */ | ||||||
|
|
@@ -112,7 +147,14 @@ describe(SassProcessor.name, () => { | |||||
| NORMALIZED_PLATFORM_FAKE_OUTPUT_BASE_FOLDER, | ||||||
| FAKE_OUTPUT_BASE_FOLDER | ||||||
| ); | ||||||
| writtenFiles.set(filePath, String(content)); | ||||||
| let serialized: string = String(content); | ||||||
| // Source map contents include the absolute-relative path back to the source file and the | ||||||
| // verbatim source file bytes. Both vary by checkout location and OS line endings, which makes | ||||||
| // raw snapshots non-portable. Normalize them to stable forms before storing. | ||||||
| if (filePath.endsWith('.css.map')) { | ||||||
| serialized = normalizeSourceMapForSnapshot(serialized); | ||||||
| } | ||||||
| writtenFiles.set(filePath, serialized); | ||||||
| }); | ||||||
| }); | ||||||
|
|
||||||
|
|
@@ -690,4 +732,52 @@ describe(SassProcessor.name, () => { | |||||
| expect(logger.errors.length).toBeGreaterThan(0); | ||||||
| }); | ||||||
| }); | ||||||
|
|
||||||
| describe('sourceMap option', () => { | ||||||
| it('emits .css.map and sourceMappingURL comment when sourceMap is true', async () => { | ||||||
| const { processor } = createProcessor(terminalProvider, { sourceMap: true }); | ||||||
| await compileFixtureAsync(processor, 'classes-and-exports.module.scss'); | ||||||
|
|
||||||
| const mapPaths: string[] = getAllWrittenPathsMatching('.css.map'); | ||||||
| expect(mapPaths).toHaveLength(1); | ||||||
|
|
||||||
| const css: string = getCssOutput('classes-and-exports.module.scss'); | ||||||
| expect(css).toMatch(/\/\*# sourceMappingURL=classes-and-exports\.module\.css\.map \*\//); | ||||||
|
|
||||||
| const mapJson: string = getWrittenFile('classes-and-exports.module.css.map'); | ||||||
| const parsedMap: { | ||||||
| version: number; | ||||||
| mappings: string; | ||||||
| sources: string[]; | ||||||
| } = JSON.parse(mapJson); | ||||||
| expect(parsedMap.version).toBe(3); | ||||||
| expect(parsedMap.mappings).toBeTruthy(); | ||||||
| expect(parsedMap.sources).toHaveLength(1); | ||||||
| expect(parsedMap.sources[0]).toMatch(/classes-and-exports\.module\.scss$/); | ||||||
| }); | ||||||
|
|
||||||
| it('does not emit .css.map or sourceMappingURL comment by default', async () => { | ||||||
| const { processor } = createProcessor(terminalProvider); | ||||||
| await compileFixtureAsync(processor, 'classes-and-exports.module.scss'); | ||||||
|
|
||||||
| expect(getAllWrittenPathsMatching('.css.map')).toHaveLength(0); | ||||||
| expect(getCssOutput('classes-and-exports.module.scss')).not.toContain('sourceMappingURL'); | ||||||
| }); | ||||||
|
|
||||||
| it('uses the correct map filename when doNotTrimOriginalFileExtension is true', async () => { | ||||||
| const { processor } = createProcessor(terminalProvider, { | ||||||
| sourceMap: true, | ||||||
| doNotTrimOriginalFileExtension: true | ||||||
| }); | ||||||
| await compileFixtureAsync(processor, 'classes-and-exports.module.scss'); | ||||||
|
|
||||||
| // With doNotTrimOriginalFileExtension the CSS file is foo.scss.css, so the map is foo.scss.css.map | ||||||
| const mapPaths: string[] = getAllWrittenPathsMatching('.css.map'); | ||||||
| expect(mapPaths).toHaveLength(1); | ||||||
| expect(mapPaths[0]).toMatch(/classes-and-exports\.module\.scss\.css\.map$/); | ||||||
|
|
||||||
| const css: string = getWrittenFile('classes-and-exports.module.scss.css'); | ||||||
| expect(css).toMatch(/\/\*# sourceMappingURL=classes-and-exports\.module\.scss\.css\.map \*\//); | ||||||
| }); | ||||||
| }); | ||||||
| }); | ||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.