diff --git a/README.md b/README.md index 8a54e1dea4e..8c9266c3f1d 100644 --- a/README.md +++ b/README.md @@ -201,6 +201,7 @@ These GitHub repositories provide supplementary resources for Rush Stack: | [/build-tests/heft-parameter-plugin](./build-tests/heft-parameter-plugin/) | This project contains a Heft plugin that adds a custom parameter to built-in actions | | [/build-tests/heft-parameter-plugin-test](./build-tests/heft-parameter-plugin-test/) | This project exercises a built-in Heft action with a custom parameter | | [/build-tests/heft-rspack-everything-test](./build-tests/heft-rspack-everything-test/) | Building this project tests every task and config file for Heft when targeting the web browser runtime using Rspack | +| [/build-tests/heft-sass-doNotTrimOriginalFileExtension-test](./build-tests/heft-sass-doNotTrimOriginalFileExtension-test/) | Tests the doNotTrimOriginalFileExtension option for heft-sass-plugin | | [/build-tests/heft-sass-test](./build-tests/heft-sass-test/) | This project illustrates a minimal tutorial Heft project targeting the web browser runtime | | [/build-tests/heft-swc-test](./build-tests/heft-swc-test/) | Building this project tests building with SWC | | [/build-tests/heft-typescript-composite-test](./build-tests/heft-typescript-composite-test/) | Building this project tests behavior of Heft when the tsconfig.json file uses project references. | diff --git a/build-tests/heft-sass-doNotTrimOriginalFileExtension-test/.gitignore b/build-tests/heft-sass-doNotTrimOriginalFileExtension-test/.gitignore new file mode 100644 index 00000000000..a214a6e4904 --- /dev/null +++ b/build-tests/heft-sass-doNotTrimOriginalFileExtension-test/.gitignore @@ -0,0 +1 @@ +lib-css diff --git a/build-tests/heft-sass-doNotTrimOriginalFileExtension-test/config/heft.json b/build-tests/heft-sass-doNotTrimOriginalFileExtension-test/config/heft.json new file mode 100644 index 00000000000..922235cb167 --- /dev/null +++ b/build-tests/heft-sass-doNotTrimOriginalFileExtension-test/config/heft.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/heft/v0/heft.schema.json", + + "phasesByName": { + "build": { + "cleanFiles": [{ "includeGlobs": ["lib-commonjs", "lib-css", "temp"] }], + + "tasksByName": { + "sass": { + "taskPlugin": { + "pluginPackage": "@rushstack/heft-sass-plugin" + } + }, + "typescript": { + "taskDependencies": ["sass"], + "taskPlugin": { + "pluginPackage": "@rushstack/heft-typescript-plugin" + } + } + } + }, + + "test": { + "phaseDependencies": ["build"], + "tasksByName": { + "jest": { + "taskPlugin": { + "pluginPackage": "@rushstack/heft-jest-plugin" + } + } + } + } + } +} diff --git a/build-tests/heft-sass-doNotTrimOriginalFileExtension-test/config/jest.config.json b/build-tests/heft-sass-doNotTrimOriginalFileExtension-test/config/jest.config.json new file mode 100644 index 00000000000..0ba0c340faa --- /dev/null +++ b/build-tests/heft-sass-doNotTrimOriginalFileExtension-test/config/jest.config.json @@ -0,0 +1,6 @@ +{ + "extends": "@rushstack/heft-jest-plugin/includes/jest-shared.config.json", + + "roots": ["/lib-commonjs"], + "testMatch": ["/lib-commonjs/**/*.test.{cjs,js}"] +} diff --git a/build-tests/heft-sass-doNotTrimOriginalFileExtension-test/config/rush-project.json b/build-tests/heft-sass-doNotTrimOriginalFileExtension-test/config/rush-project.json new file mode 100644 index 00000000000..3136f4e81a8 --- /dev/null +++ b/build-tests/heft-sass-doNotTrimOriginalFileExtension-test/config/rush-project.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/rush-project.schema.json", + + "operationSettings": [ + { + "operationName": "_phase:build", + "outputFolderNames": ["lib-commonjs", "lib-css", "temp/sass-ts"] + }, + { + "operationName": "_phase:test", + "outputFolderNames": ["coverage"] + } + ] +} diff --git a/build-tests/heft-sass-doNotTrimOriginalFileExtension-test/config/sass.json b/build-tests/heft-sass-doNotTrimOriginalFileExtension-test/config/sass.json new file mode 100644 index 00000000000..ce8eb11a29b --- /dev/null +++ b/build-tests/heft-sass-doNotTrimOriginalFileExtension-test/config/sass.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/heft/v0/heft-sass-plugin.schema.json", + + "cssOutputFolders": ["lib-css"], + "fileExtensions": [".module.scss"], + "nonModuleFileExtensions": [".global.scss"], + "doNotTrimOriginalFileExtension": true, + "silenceDeprecations": ["mixed-decls", "import", "global-builtin", "color-functions"] +} diff --git a/build-tests/heft-sass-doNotTrimOriginalFileExtension-test/package.json b/build-tests/heft-sass-doNotTrimOriginalFileExtension-test/package.json new file mode 100644 index 00000000000..0953af9d6dc --- /dev/null +++ b/build-tests/heft-sass-doNotTrimOriginalFileExtension-test/package.json @@ -0,0 +1,20 @@ +{ + "name": "heft-sass-doNotTrimOriginalFileExtension-test", + "description": "Tests the doNotTrimOriginalFileExtension option for heft-sass-plugin", + "version": "1.0.0", + "private": true, + "scripts": { + "build": "heft build --clean", + "_phase:build": "heft run --only build -- --clean", + "_phase:test": "heft run --only test -- --clean" + }, + "devDependencies": { + "@rushstack/heft": "workspace:*", + "@rushstack/heft-jest-plugin": "workspace:*", + "@rushstack/heft-sass-plugin": "workspace:*", + "@rushstack/heft-typescript-plugin": "workspace:*", + "@types/heft-jest": "1.0.1", + "@types/node": "20.17.19", + "typescript": "~5.8.2" + } +} diff --git a/build-tests/heft-sass-doNotTrimOriginalFileExtension-test/src/styles.module.scss b/build-tests/heft-sass-doNotTrimOriginalFileExtension-test/src/styles.module.scss new file mode 100644 index 00000000000..300ef97e35a --- /dev/null +++ b/build-tests/heft-sass-doNotTrimOriginalFileExtension-test/src/styles.module.scss @@ -0,0 +1,12 @@ +/** + * Test SCSS module for verifying doNotTrimOriginalFileExtension output. + * Expected output: styles.module.scss.css (not styles.module.css) + */ + +.label { + color: royalblue; +} + +.container { + padding: 16px; +} diff --git a/build-tests/heft-sass-doNotTrimOriginalFileExtension-test/src/stylesGlobal.global.scss b/build-tests/heft-sass-doNotTrimOriginalFileExtension-test/src/stylesGlobal.global.scss new file mode 100644 index 00000000000..11cbf01cdd2 --- /dev/null +++ b/build-tests/heft-sass-doNotTrimOriginalFileExtension-test/src/stylesGlobal.global.scss @@ -0,0 +1,8 @@ +/** + * Test non-module SCSS for verifying doNotTrimOriginalFileExtension output. + * Expected output: stylesGlobal.global.scss.css (not stylesGlobal.global.css) + */ + +.globalWrapper { + font-size: 16px; +} diff --git a/build-tests/heft-sass-doNotTrimOriginalFileExtension-test/src/test/__snapshots__/lib-css.test.ts.snap b/build-tests/heft-sass-doNotTrimOriginalFileExtension-test/src/test/__snapshots__/lib-css.test.ts.snap new file mode 100644 index 00000000000..30fd7e2089d --- /dev/null +++ b/build-tests/heft-sass-doNotTrimOriginalFileExtension-test/src/test/__snapshots__/lib-css.test.ts.snap @@ -0,0 +1,37 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SASS No Shims (doNotTrimOriginalFileExtension) styles.module.scss: files 1`] = ` +Array [ + "styles.module.scss.css", +] +`; + +exports[`SASS No Shims (doNotTrimOriginalFileExtension) styles.module.scss: styles.module.scss.css 1`] = ` +"/** + * Test SCSS module for verifying doNotTrimOriginalFileExtension output. + * Expected output: styles.module.scss.css (not styles.module.css) + */ +.label { + color: royalblue; +} + +.container { + padding: 16px; +}" +`; + +exports[`SASS No Shims (doNotTrimOriginalFileExtension) stylesGlobal.global.scss: files 1`] = ` +Array [ + "stylesGlobal.global.scss.css", +] +`; + +exports[`SASS No Shims (doNotTrimOriginalFileExtension) stylesGlobal.global.scss: stylesGlobal.global.scss.css 1`] = ` +"/** + * Test non-module SCSS for verifying doNotTrimOriginalFileExtension output. + * Expected output: stylesGlobal.global.scss.css (not stylesGlobal.global.css) + */ +.globalWrapper { + font-size: 16px; +}" +`; diff --git a/build-tests/heft-sass-doNotTrimOriginalFileExtension-test/src/test/lib-css.test.ts b/build-tests/heft-sass-doNotTrimOriginalFileExtension-test/src/test/lib-css.test.ts new file mode 100644 index 00000000000..dc065a1196f --- /dev/null +++ b/build-tests/heft-sass-doNotTrimOriginalFileExtension-test/src/test/lib-css.test.ts @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as path from 'node:path'; +import { validateSnapshots, getScssFiles } from './validateSnapshots'; + +describe('SASS No Shims (doNotTrimOriginalFileExtension)', () => { + const libFolder: string = path.join(__dirname, '../../lib-css'); + getScssFiles().forEach((fileName: string) => { + it(fileName, () => { + validateSnapshots(libFolder, fileName); + }); + }); +}); diff --git a/build-tests/heft-sass-doNotTrimOriginalFileExtension-test/src/test/validateSnapshots.ts b/build-tests/heft-sass-doNotTrimOriginalFileExtension-test/src/test/validateSnapshots.ts new file mode 100644 index 00000000000..f7b5542c881 --- /dev/null +++ b/build-tests/heft-sass-doNotTrimOriginalFileExtension-test/src/test/validateSnapshots.ts @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/// +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +export function getScssFiles(): string[] { + const srcFolder: string = path.join(__dirname, '../../src'); + const sourceFiles: string[] = fs + .readdirSync(srcFolder, { withFileTypes: true }) + .filter((file: fs.Dirent) => { + const { name } = file; + return file.isFile() && !name.startsWith('_') && (name.endsWith('.sass') || name.endsWith('.scss')); + }) + .map((dirent) => dirent.name); + return sourceFiles; +} + +export function validateSnapshots(dir: string, fileName: string): void { + const originalExt: string = path.extname(fileName); + const basename: string = path.basename(fileName, originalExt) + '.'; + const files: fs.Dirent[] = fs.readdirSync(dir, { withFileTypes: true }); + const filteredFiles: fs.Dirent[] = files.filter((file: fs.Dirent) => { + return file.isFile() && file.name.startsWith(basename); + }); + expect(filteredFiles.map((x) => x.name)).toMatchSnapshot(`files`); + filteredFiles.forEach((file: fs.Dirent) => { + if (!file.isFile() || !file.name.startsWith(basename)) { + return; + } + const filePath: string = path.join(dir, file.name); + const fileContents: string = fs.readFileSync(filePath, 'utf8'); + const normalizedFileContents: string = fileContents.replace(/\r/gm, ''); + expect(normalizedFileContents).toMatchSnapshot(`${file.name}`); + }); +} diff --git a/build-tests/heft-sass-doNotTrimOriginalFileExtension-test/tsconfig.json b/build-tests/heft-sass-doNotTrimOriginalFileExtension-test/tsconfig.json new file mode 100644 index 00000000000..9f9b216bd5a --- /dev/null +++ b/build-tests/heft-sass-doNotTrimOriginalFileExtension-test/tsconfig.json @@ -0,0 +1,23 @@ +{ + "$schema": "http://json.schemastore.org/tsconfig", + + "compilerOptions": { + "outDir": "lib-commonjs", + "rootDir": "src", + "rootDirs": ["src", "temp/sass-ts"], + + "forceConsistentCasingInFileNames": true, + "declaration": true, + "sourceMap": true, + "declarationMap": true, + "inlineSources": true, + "strictNullChecks": true, + "noUnusedLocals": true, + "types": ["heft-jest", "node"], + + "module": "commonjs", + "target": "es2017", + "lib": ["es2017"] + }, + "include": ["src/**/*.ts"] +} diff --git a/common/changes/@rushstack/heft-sass-plugin/sass-doNotTrimOriginalFileExtension_2026-04-08-00-49.json b/common/changes/@rushstack/heft-sass-plugin/sass-doNotTrimOriginalFileExtension_2026-04-08-00-49.json new file mode 100644 index 00000000000..e66f43798b0 --- /dev/null +++ b/common/changes/@rushstack/heft-sass-plugin/sass-doNotTrimOriginalFileExtension_2026-04-08-00-49.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "Add a `doNotTrimOriginalFileExtension` option. When enabled, the original file extension is preserved in the CSS output filename (e.g. `styles.scss` emits `styles.scss.css` instead of `styles.css`)", + "type": "minor", + "packageName": "@rushstack/heft-sass-plugin" + } + ], + "packageName": "@rushstack/heft-sass-plugin", + "email": "iclanton@users.noreply.github.com" +} \ No newline at end of file diff --git a/common/config/subspaces/default/pnpm-lock.yaml b/common/config/subspaces/default/pnpm-lock.yaml index af1789b7858..28275dea0df 100644 --- a/common/config/subspaces/default/pnpm-lock.yaml +++ b/common/config/subspaces/default/pnpm-lock.yaml @@ -2136,6 +2136,30 @@ importers: specifier: ~5.8.2 version: 5.8.2 + ../../../build-tests/heft-sass-doNotTrimOriginalFileExtension-test: + devDependencies: + '@rushstack/heft': + specifier: workspace:* + version: link:../../apps/heft + '@rushstack/heft-jest-plugin': + specifier: workspace:* + version: link:../../heft-plugins/heft-jest-plugin + '@rushstack/heft-sass-plugin': + specifier: workspace:* + version: link:../../heft-plugins/heft-sass-plugin + '@rushstack/heft-typescript-plugin': + specifier: workspace:* + version: link:../../heft-plugins/heft-typescript-plugin + '@types/heft-jest': + specifier: 1.0.1 + version: 1.0.1 + '@types/node': + specifier: 20.17.19 + version: 20.17.19 + typescript: + specifier: ~5.8.2 + version: 5.8.2 + ../../../build-tests/heft-sass-test: dependencies: buttono: diff --git a/heft-plugins/heft-sass-plugin/src/SassPlugin.ts b/heft-plugins/heft-sass-plugin/src/SassPlugin.ts index 3525cbfa817..0ab3e899db7 100644 --- a/heft-plugins/heft-sass-plugin/src/SassPlugin.ts +++ b/heft-plugins/heft-sass-plugin/src/SassPlugin.ts @@ -29,6 +29,7 @@ export interface ISassConfigurationJson { nonModuleFileExtensions?: string[]; silenceDeprecations?: string[]; excludeFiles?: string[]; + doNotTrimOriginalFileExtension?: boolean; } const SASS_CONFIGURATION_LOCATION: string = 'config/sass.json'; @@ -96,7 +97,8 @@ export default class SassPlugin implements IHeftPlugin { fileExtensions, nonModuleFileExtensions, silenceDeprecations, - excludeFiles + excludeFiles, + doNotTrimOriginalFileExtension } = sassConfigurationJson || {}; function resolveFolder(folder: string): string { @@ -123,6 +125,7 @@ export default class SassPlugin implements IHeftPlugin { }; }), silenceDeprecations, + doNotTrimOriginalFileExtension, postProcessCssAsync: hooks.postProcessCss.isUsed() ? async (cssText: string) => hooks.postProcessCss.promise(cssText) : undefined diff --git a/heft-plugins/heft-sass-plugin/src/SassProcessor.ts b/heft-plugins/heft-sass-plugin/src/SassProcessor.ts index d3a7da77acc..df223f08ca7 100644 --- a/heft-plugins/heft-sass-plugin/src/SassProcessor.ts +++ b/heft-plugins/heft-sass-plugin/src/SassProcessor.ts @@ -41,7 +41,7 @@ const SIMPLE_IDENTIFIER_REGEX: RegExp = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/; */ export interface ICssOutputFolder { folder: string; - shimModuleFormat: 'commonjs' | 'esnext' | undefined; + shimModuleFormat?: 'commonjs' | 'esnext'; } /** @@ -114,6 +114,13 @@ export interface ISassProcessorOptions { */ silenceDeprecations?: readonly string[]; + /** + * If true, the original file extension will not be trimmed when generating the output CSS file. The generated CSS + * file will retain its original extension. For example, "styles.scss" will generate "styles.scss.css" + * instead of "styles.css". + */ + doNotTrimOriginalFileExtension?: boolean; + /** * A callback to further modify the raw CSS text after it has been generated. Only relevant if emitting CSS files. */ @@ -738,8 +745,14 @@ export class SassProcessor { } record.cssVersion = contentHash; - const { cssOutputFolders, dtsOutputFolders, srcFolder, exportAsDefault, postProcessCssAsync } = - this._options; + const { + cssOutputFolders, + dtsOutputFolders, + srcFolder, + exportAsDefault, + doNotTrimOriginalFileExtension, + postProcessCssAsync + } = this._options; // Handle CSS modules let moduleMap: JsonObject | undefined; @@ -779,16 +792,26 @@ export class SassProcessor { ); } - const filename: string = path.basename(relativeFilePath); - const extensionStart: number = filename.lastIndexOf('.'); - const cssPathFromJs: string = `./${filename.slice(0, extensionStart)}.css`; - const relativeCssPath: string = `${relativeFilePath.slice(0, relativeFilePath.lastIndexOf('.'))}.css`; - if (cssOutputFolders && cssOutputFolders.length > 0) { if (!exportAsDefault) { throw new Error(`The "cssOutputFolders" option is not supported when "exportAsDefault" is false.`); } + const filename: string = path.basename(relativeFilePath); + let cssFilename: string; + let relativeCssPath: string; + if (doNotTrimOriginalFileExtension) { + cssFilename = `${filename}.css`; + relativeCssPath = `${relativeFilePath}.css`; + } else { + const extensionStart: number = filename.lastIndexOf('.'); + cssFilename = `${filename.slice(0, extensionStart)}.css`; + + const relativeFilePathStart: number = relativeFilePath.lastIndexOf('.'); + relativeCssPath = `${relativeFilePath.slice(0, relativeFilePathStart)}.css`; + } + + const cssPathFromJs: string = `./${cssFilename}`; for (const cssOutputFolder of cssOutputFolders) { const { folder, shimModuleFormat } = cssOutputFolder; diff --git a/heft-plugins/heft-sass-plugin/src/schemas/heft-sass-plugin.schema.json b/heft-plugins/heft-sass-plugin/src/schemas/heft-sass-plugin.schema.json index 0ff5b533c91..e31d0716507 100644 --- a/heft-plugins/heft-sass-plugin/src/schemas/heft-sass-plugin.schema.json +++ b/heft-plugins/heft-sass-plugin/src/schemas/heft-sass-plugin.schema.json @@ -106,6 +106,11 @@ "items": { "type": "string" } + }, + + "doNotTrimOriginalFileExtension": { + "type": "boolean", + "description": "If true, the original file extension will not be trimmed when generating the output CSS file. The generated CSS file will retain its original extension. For example, \"styles.scss\" will generate \"styles.scss.css\" instead of \"styles.css\"." } } } diff --git a/heft-plugins/heft-sass-plugin/src/templates/sass.json b/heft-plugins/heft-sass-plugin/src/templates/sass.json index 88b2b1a7120..a7218c0eb10 100644 --- a/heft-plugins/heft-sass-plugin/src/templates/sass.json +++ b/heft-plugins/heft-sass-plugin/src/templates/sass.json @@ -87,5 +87,14 @@ * * Default value: [] */ - // "silenceDeprecations": ["mixed-decls"] + // "silenceDeprecations": ["mixed-decls"], + + /** + * If true, the original file extension will not be trimmed when generating the output CSS file. + * The generated CSS file will retain its original extension. For example, "styles.scss" will generate + * "styles.scss.css" instead of "styles.css". + * + * Default value: false + */ + // "doNotTrimOriginalFileExtension": true } diff --git a/rush.json b/rush.json index 45d0205e8a4..9bfd1632f89 100644 --- a/rush.json +++ b/rush.json @@ -935,6 +935,12 @@ "reviewCategory": "tests", "shouldPublish": false }, + { + "packageName": "heft-sass-doNotTrimOriginalFileExtension-test", + "projectFolder": "build-tests/heft-sass-doNotTrimOriginalFileExtension-test", + "reviewCategory": "tests", + "shouldPublish": false + }, { "packageName": "heft-sass-test", "projectFolder": "build-tests/heft-sass-test",