diff --git a/common/changes/@rushstack/heft-sass-plugin/heft-sass-plugin-preserve-icss-exports_2026-04-09-00-00.json b/common/changes/@rushstack/heft-sass-plugin/heft-sass-plugin-preserve-icss-exports_2026-04-09-00-00.json new file mode 100644 index 00000000000..21099abeab8 --- /dev/null +++ b/common/changes/@rushstack/heft-sass-plugin/heft-sass-plugin-preserve-icss-exports_2026-04-09-00-00.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/heft-sass-plugin", + "comment": "Add `preserveIcssExports` option to keep the ICSS `:export` block in emitted CSS output, required when downstream webpack loaders (e.g. `css-loader` icssParser) need to extract `:export` values at bundle time.", + "type": "minor" + } + ], + "packageName": "@rushstack/heft-sass-plugin" +} diff --git a/common/config/subspaces/default/pnpm-lock.yaml b/common/config/subspaces/default/pnpm-lock.yaml index 28275dea0df..c5f417c83ef 100644 --- a/common/config/subspaces/default/pnpm-lock.yaml +++ b/common/config/subspaces/default/pnpm-lock.yaml @@ -3481,6 +3481,9 @@ importers: '@rushstack/heft': specifier: workspace:* version: link:../../apps/heft + '@rushstack/terminal': + specifier: workspace:* + version: link:../../libraries/terminal eslint: specifier: ~9.37.0 version: 9.37.0 diff --git a/heft-plugins/heft-sass-plugin/config/jest.config.json b/heft-plugins/heft-sass-plugin/config/jest.config.json new file mode 100644 index 00000000000..d1749681d90 --- /dev/null +++ b/heft-plugins/heft-sass-plugin/config/jest.config.json @@ -0,0 +1,3 @@ +{ + "extends": "local-node-rig/profiles/default/config/jest.config.json" +} diff --git a/heft-plugins/heft-sass-plugin/package.json b/heft-plugins/heft-sass-plugin/package.json index a69e61e9044..95bba09f470 100644 --- a/heft-plugins/heft-sass-plugin/package.json +++ b/heft-plugins/heft-sass-plugin/package.json @@ -56,6 +56,7 @@ "devDependencies": { "@microsoft/api-extractor": "workspace:*", "@rushstack/heft": "workspace:*", + "@rushstack/terminal": "workspace:*", "eslint": "~9.37.0", "local-node-rig": "workspace:*" }, diff --git a/heft-plugins/heft-sass-plugin/src/SassPlugin.ts b/heft-plugins/heft-sass-plugin/src/SassPlugin.ts index 0ab3e899db7..cc1f2f9e1eb 100644 --- a/heft-plugins/heft-sass-plugin/src/SassPlugin.ts +++ b/heft-plugins/heft-sass-plugin/src/SassPlugin.ts @@ -30,6 +30,7 @@ export interface ISassConfigurationJson { silenceDeprecations?: string[]; excludeFiles?: string[]; doNotTrimOriginalFileExtension?: boolean; + preserveIcssExports?: boolean; } const SASS_CONFIGURATION_LOCATION: string = 'config/sass.json'; @@ -98,7 +99,8 @@ export default class SassPlugin implements IHeftPlugin { nonModuleFileExtensions, silenceDeprecations, excludeFiles, - doNotTrimOriginalFileExtension + doNotTrimOriginalFileExtension, + preserveIcssExports } = sassConfigurationJson || {}; function resolveFolder(folder: string): string { @@ -126,6 +128,7 @@ export default class SassPlugin implements IHeftPlugin { }), silenceDeprecations, doNotTrimOriginalFileExtension, + preserveIcssExports, 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 df223f08ca7..9bddcd03f22 100644 --- a/heft-plugins/heft-sass-plugin/src/SassProcessor.ts +++ b/heft-plugins/heft-sass-plugin/src/SassProcessor.ts @@ -121,6 +121,15 @@ export interface ISassProcessorOptions { */ doNotTrimOriginalFileExtension?: boolean; + /** + * 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. + */ + preserveIcssExports?: boolean; + /** * A callback to further modify the raw CSS text after it has been generated. Only relevant if emitting CSS files. */ @@ -751,7 +760,8 @@ export class SassProcessor { srcFolder, exportAsDefault, doNotTrimOriginalFileExtension, - postProcessCssAsync + postProcessCssAsync, + preserveIcssExports } = this._options; // Handle CSS modules @@ -769,7 +779,14 @@ export class SassProcessor { const postCssResult: postcss.Result = await postcss .default([postCssModules]) .process(css, { from: sourceFilePath }); - css = postCssResult.css; + + if (!preserveIcssExports) { + // Default behavior: use the transformed CSS output, which has the :export block stripped. + css = postCssResult.css; + } + // If preserveIcssExports is true, we discard the transformed CSS and keep the original so + // that the :export block remains in the output for downstream webpack loaders (e.g. + // css-loader's icssParser) that extract :export values at bundle time. } if (postProcessCssAsync) { 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 e31d0716507..0aa2b15934b 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 @@ -111,6 +111,11 @@ "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\"." + }, + + "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." } } } diff --git a/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts b/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts new file mode 100644 index 00000000000..6f9ca801125 --- /dev/null +++ b/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts @@ -0,0 +1,537 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import path from 'node:path'; +import nodeJsPath from 'node:path'; + +import { FileSystem, Path } from '@rushstack/node-core-library'; +import { MockScopedLogger } from '@rushstack/heft/lib/pluginFramework/logging/MockScopedLogger'; +import { StringBufferTerminalProvider, Terminal } from '@rushstack/terminal'; + +import { type ICssOutputFolder, type ISassProcessorOptions, SassProcessor } from '../SassProcessor'; + +const projectFolder: string = path.resolve(__dirname, '../..'); +const fixturesFolder: string = path.resolve(__dirname, '../../src/test/fixtures'); + +// Fake output folder paths — never actually written to disk because FileSystem.writeFileAsync is mocked. +const FAKE_OUTPUT_BASE_FOLDER: string = '/fake/output'; +const NORMALIZED_PLATFORM_FAKE_OUTPUT_BASE_FOLDER: string = Path.convertToSlashes( + nodeJsPath.resolve(FAKE_OUTPUT_BASE_FOLDER) +); +const CSS_OUTPUT_FOLDER: string = `${FAKE_OUTPUT_BASE_FOLDER}/css`; +const DTS_OUTPUT_FOLDER: string = `${FAKE_OUTPUT_BASE_FOLDER}/dts`; + +type ICreateProcessorOptions = Partial< + Pick< + ISassProcessorOptions, + | 'cssOutputFolders' + | 'doNotTrimOriginalFileExtension' + | 'dtsOutputFolders' + | 'exportAsDefault' + | 'fileExtensions' + | 'nonModuleFileExtensions' + | 'postProcessCssAsync' + | 'preserveIcssExports' + | 'srcFolder' + > +>; + +function createProcessor( + terminalProvider: StringBufferTerminalProvider, + options: ICreateProcessorOptions = {} +): { + processor: SassProcessor; + logger: MockScopedLogger; +} { + const terminal: Terminal = new Terminal(terminalProvider); + const logger: MockScopedLogger = new MockScopedLogger(terminal); + + const processor: SassProcessor = new SassProcessor({ + logger, + buildFolder: projectFolder, + concurrency: 1, + srcFolder: fixturesFolder, + dtsOutputFolders: [DTS_OUTPUT_FOLDER], + cssOutputFolders: [{ folder: CSS_OUTPUT_FOLDER, shimModuleFormat: undefined }], + exportAsDefault: true, + ...options + }); + + return { processor, logger }; +} + +async function compileFixtureAsync(processor: SassProcessor, fixtureFilename: string): Promise { + await processor.compileFilesAsync(new Set([`${fixturesFolder}/${fixtureFilename}`])); +} + +describe(SassProcessor.name, () => { + let terminalProvider: StringBufferTerminalProvider; + /** Files captured by the mocked FileSystem.writeFileAsync, keyed by absolute path. */ + let writtenFiles: Map; + + /** Returns the content written to a path whose last segment matches the given filename. */ + function getWrittenFile(filename: string): string { + for (const [filePath, content] of writtenFiles) { + if (filePath.endsWith(`/${filename}`)) { + return content; + } + } + + throw new Error( + `No file written matching ".../${filename}". Written paths:\n${[...writtenFiles.keys()].join('\n')}` + ); + } + + /** Returns all paths written that end with the given suffix. */ + function getAllWrittenPathsMatching(suffix: string): string[] { + return [...writtenFiles.keys()].filter((p) => p.endsWith(suffix)); + } + + function getCssOutput(fixtureFilename: string): string { + // SassProcessor strips the last extension then appends .css + // export-only.module.scss → export-only.module.css + const withoutExt: string = fixtureFilename.slice(0, fixtureFilename.lastIndexOf('.')); + return getWrittenFile(`${withoutExt}.css`); + } + + function getDtsOutput(fixtureFilename: string): string { + return getWrittenFile(`${fixtureFilename}.d.ts`); + } + + function getJsShimOutput(fixtureFilename: string): string { + return getWrittenFile(`${fixtureFilename}.js`); + } + + beforeEach(() => { + terminalProvider = new StringBufferTerminalProvider(); + + writtenFiles = new Map(); + jest.spyOn(FileSystem, 'writeFileAsync').mockImplementation(async (filePath, content) => { + filePath = Path.convertToSlashes(filePath).replace( + NORMALIZED_PLATFORM_FAKE_OUTPUT_BASE_FOLDER, + FAKE_OUTPUT_BASE_FOLDER + ); + writtenFiles.set(filePath, String(content)); + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + + expect(writtenFiles).toMatchSnapshot('written-files'); + expect(terminalProvider.getAllOutputAsChunks({ asLines: true })).toMatchSnapshot('terminal-output'); + }); + + describe('export-only.module.scss', () => { + it('strips the :export block from CSS when preserveIcssExports is false', async () => { + const { processor } = createProcessor(terminalProvider); + await compileFixtureAsync(processor, 'export-only.module.scss'); + const css: string = getCssOutput('export-only.module.scss'); + expect(css).not.toContain(':export'); + }); + + it('preserves the :export block in CSS when preserveIcssExports is true', async () => { + const { processor } = createProcessor(terminalProvider, { preserveIcssExports: true }); + await compileFixtureAsync(processor, 'export-only.module.scss'); + const css: string = getCssOutput('export-only.module.scss'); + expect(css).toContain(':export'); + }); + + it('generates the same .d.ts regardless of preserveIcssExports', async () => { + const { processor: processorFalse } = createProcessor(terminalProvider); + await compileFixtureAsync(processorFalse, 'export-only.module.scss'); + const dtsFalse: string = getDtsOutput('export-only.module.scss'); + + writtenFiles.clear(); + + const { processor: processorTrue } = createProcessor(terminalProvider, { + preserveIcssExports: true + }); + await compileFixtureAsync(processorTrue, 'export-only.module.scss'); + const dtsTrue: string = getDtsOutput('export-only.module.scss'); + + expect(dtsFalse).toEqual(dtsTrue); + }); + }); + + describe('classes-and-exports.module.scss', () => { + it('strips the :export block from CSS when preserveIcssExports is false', async () => { + const { processor } = createProcessor(terminalProvider); + await compileFixtureAsync(processor, 'classes-and-exports.module.scss'); + const css: string = getCssOutput('classes-and-exports.module.scss'); + expect(css).not.toContain(':export'); + expect(css).toContain('.root'); + }); + + it('preserves the :export block in CSS when preserveIcssExports is true', async () => { + const { processor } = createProcessor(terminalProvider, { preserveIcssExports: true }); + await compileFixtureAsync(processor, 'classes-and-exports.module.scss'); + const css: string = getCssOutput('classes-and-exports.module.scss'); + expect(css).toContain(':export'); + expect(css).toContain('.root'); + }); + + it('generates correct .d.ts with both class names and :export values', async () => { + const { processor } = createProcessor(terminalProvider); + await compileFixtureAsync(processor, 'classes-and-exports.module.scss'); + const dts: string = getDtsOutput('classes-and-exports.module.scss'); + expect(dts).toContain('root'); + expect(dts).toContain('highlighted'); + expect(dts).toContain('themeColor'); + expect(dts).toContain('spacing'); + }); + + it('generates named exports in .d.ts when exportAsDefault is false', async () => { + // cssOutputFolders requires exportAsDefault: true, so omit it here + const { processor } = createProcessor(terminalProvider, { + exportAsDefault: false, + cssOutputFolders: [] + }); + await compileFixtureAsync(processor, 'classes-and-exports.module.scss'); + const dts: string = getDtsOutput('classes-and-exports.module.scss'); + // Named exports: "export const root: string;" instead of a default interface + expect(dts).toContain('export const root'); + expect(dts).toContain('export const highlighted'); + expect(dts).toContain('export const themeColor'); + expect(dts).not.toContain('export default'); + }); + }); + + describe('sass-variables-and-exports.module.scss (Sass variables, nesting, BEM)', () => { + it('resolves Sass variables and expands nested rules in CSS output', async () => { + const { processor } = createProcessor(terminalProvider); + await compileFixtureAsync(processor, 'sass-variables-and-exports.module.scss'); + const css: string = getCssOutput('sass-variables-and-exports.module.scss'); + // Sass variables should be resolved to literal values + expect(css).toContain('#0078d4'); + expect(css).toContain('#106ebe'); + // Nested rules should be expanded + expect(css).toContain('.container:hover'); + expect(css).toContain('.container__title'); + // :export block should be stripped (preserveIcssExports: false) + expect(css).not.toContain(':export'); + }); + + it('resolves Sass variables inside the :export block when preserveIcssExports is true', async () => { + const { processor } = createProcessor(terminalProvider, { preserveIcssExports: true }); + await compileFixtureAsync(processor, 'sass-variables-and-exports.module.scss'); + const css: string = getCssOutput('sass-variables-and-exports.module.scss'); + // The :export block should contain resolved values, not Sass variable names + expect(css).toContain(':export'); + expect(css).toContain('#0078d4'); + expect(css).not.toContain('$primary-color'); + }); + + it('generates .d.ts with resolved :export keys as typed properties', async () => { + const { processor } = createProcessor(terminalProvider); + await compileFixtureAsync(processor, 'sass-variables-and-exports.module.scss'); + const dts: string = getDtsOutput('sass-variables-and-exports.module.scss'); + expect(dts).toContain('container'); + expect(dts).toContain('primaryColor'); + expect(dts).toContain('secondaryColor'); + expect(dts).toContain('baseSpacing'); + }); + }); + + describe('mixin-with-exports.module.scss (Sass @mixin)', () => { + it('expands @mixin calls in CSS output', async () => { + const { processor } = createProcessor(terminalProvider); + await compileFixtureAsync(processor, 'mixin-with-exports.module.scss'); + const css: string = getCssOutput('mixin-with-exports.module.scss'); + // Mixin output should be inlined — no @mixin or @include in the output + expect(css).not.toContain('@mixin'); + expect(css).not.toContain('@include'); + expect(css).toContain('display: flex'); + expect(css).toContain('.card'); + expect(css).toContain('.card--vertical'); + }); + + it('preserves :export alongside expanded @mixin output when preserveIcssExports is true', async () => { + const { processor } = createProcessor(terminalProvider, { preserveIcssExports: true }); + await compileFixtureAsync(processor, 'mixin-with-exports.module.scss'); + const css: string = getCssOutput('mixin-with-exports.module.scss'); + expect(css).toContain(':export'); + expect(css).toContain('display: flex'); + expect(css).not.toContain('@mixin'); + }); + + it('generates .d.ts with :export values and class names from @mixin-using file', async () => { + const { processor } = createProcessor(terminalProvider); + await compileFixtureAsync(processor, 'mixin-with-exports.module.scss'); + const dts: string = getDtsOutput('mixin-with-exports.module.scss'); + expect(dts).toContain('card'); + expect(dts).toContain('cardRadius'); + expect(dts).toContain('animationDuration'); + }); + }); + + describe('extend-with-exports.module.scss (Sass @extend / placeholder selectors)', () => { + it('merges @extend selectors and strips :export when preserveIcssExports is false', async () => { + const { processor } = createProcessor(terminalProvider); + await compileFixtureAsync(processor, 'extend-with-exports.module.scss'); + const css: string = getCssOutput('extend-with-exports.module.scss'); + // Placeholder %button-base should not appear literally; its rules should be merged + expect(css).not.toContain('%button-base'); + expect(css).toContain('.primaryButton'); + expect(css).toContain('.dangerButton'); + expect(css).not.toContain(':export'); + }); + + it('preserves :export alongside @extend-merged output when preserveIcssExports is true', async () => { + const { processor } = createProcessor(terminalProvider, { preserveIcssExports: true }); + await compileFixtureAsync(processor, 'extend-with-exports.module.scss'); + const css: string = getCssOutput('extend-with-exports.module.scss'); + expect(css).toContain(':export'); + expect(css).toContain('.primaryButton'); + expect(css).not.toContain('%button-base'); + }); + + it('generates .d.ts with class names and :export values for @extend file', async () => { + const { processor } = createProcessor(terminalProvider); + await compileFixtureAsync(processor, 'extend-with-exports.module.scss'); + const dts: string = getDtsOutput('extend-with-exports.module.scss'); + expect(dts).toContain('primaryButton'); + expect(dts).toContain('dangerButton'); + expect(dts).toContain('colorPrimary'); + expect(dts).toContain('colorDanger'); + }); + }); + + describe('JS shim files', () => { + it('emits a CommonJS shim for a module file', async () => { + const { processor } = createProcessor(terminalProvider, { + cssOutputFolders: [{ folder: CSS_OUTPUT_FOLDER, shimModuleFormat: 'commonjs' }] + }); + await compileFixtureAsync(processor, 'classes-and-exports.module.scss'); + const shim: string = getJsShimOutput('classes-and-exports.module.scss'); + // CJS module shim re-exports from the CSS file and mirrors it as .default + expect(shim).toContain(`require("./classes-and-exports.module.css")`); + expect(shim).toContain('module.exports.default = module.exports'); + }); + + it('emits an ESM shim for a module file', async () => { + const { processor } = createProcessor(terminalProvider, { + cssOutputFolders: [{ folder: CSS_OUTPUT_FOLDER, shimModuleFormat: 'esnext' }] + }); + await compileFixtureAsync(processor, 'classes-and-exports.module.scss'); + const shim: string = getJsShimOutput('classes-and-exports.module.scss'); + // ESM module shim re-exports the default from the CSS file + expect(shim).toBe(`export { default } from "./classes-and-exports.module.css";`); + }); + + it('emits a CommonJS shim for a non-module (global) file', async () => { + const { processor } = createProcessor(terminalProvider, { + cssOutputFolders: [{ folder: CSS_OUTPUT_FOLDER, shimModuleFormat: 'commonjs' }], + // Register the .global.scss extension so the processor classifies it correctly + nonModuleFileExtensions: ['.global.scss'] + }); + await compileFixtureAsync(processor, 'global-styles.global.scss'); + const shim: string = getJsShimOutput('global-styles.global.scss'); + // CJS non-module shim: side-effect require only + expect(shim).toBe(`require("./global-styles.global.css");`); + }); + + it('emits an ESM shim for a non-module (global) file', async () => { + const { processor } = createProcessor(terminalProvider, { + cssOutputFolders: [{ folder: CSS_OUTPUT_FOLDER, shimModuleFormat: 'esnext' }], + nonModuleFileExtensions: ['.global.scss'] + }); + await compileFixtureAsync(processor, 'global-styles.global.scss'); + const shim: string = getJsShimOutput('global-styles.global.scss'); + // ESM non-module shim: side-effect import only + expect(shim).toBe(`import "./global-styles.global.css";export {};`); + }); + + it('does not emit a shim when shimModuleFormat is undefined', async () => { + const { processor } = createProcessor(terminalProvider, { + cssOutputFolders: [{ folder: CSS_OUTPUT_FOLDER, shimModuleFormat: undefined }] + }); + await compileFixtureAsync(processor, 'classes-and-exports.module.scss'); + // Only the CSS and DTS files should be written — no .js shim + const shimPaths: string[] = getAllWrittenPathsMatching('.module.scss.js'); + expect(shimPaths).toHaveLength(0); + }); + + it('writes shims to each configured cssOutputFolder independently', async () => { + const CSS_FOLDER_ESM: string = '/fake/output/css-esm'; + const CSS_FOLDER_CJS: string = '/fake/output/css-cjs'; + const cssOutputFolders: ICssOutputFolder[] = [ + { folder: CSS_FOLDER_ESM, shimModuleFormat: 'esnext' }, + { folder: CSS_FOLDER_CJS, shimModuleFormat: 'commonjs' } + ]; + const { processor } = createProcessor(terminalProvider, { cssOutputFolders }); + await compileFixtureAsync(processor, 'classes-and-exports.module.scss'); + + const esmShim: string = writtenFiles.get(`${CSS_FOLDER_ESM}/classes-and-exports.module.scss.js`)!; + const cjsShim: string = writtenFiles.get(`${CSS_FOLDER_CJS}/classes-and-exports.module.scss.js`)!; + + expect(esmShim).toBe(`export { default } from "./classes-and-exports.module.css";`); + expect(cjsShim).toContain('module.exports.default = module.exports'); + }); + }); + + describe('non-module (global) files', () => { + it('emits plain compiled CSS for a .global.scss file', async () => { + const { processor } = createProcessor(terminalProvider, { + nonModuleFileExtensions: ['.global.scss'] + }); + await compileFixtureAsync(processor, 'global-styles.global.scss'); + const css: string = getCssOutput('global-styles.global.scss'); + // Variables should be resolved; selectors should be present + expect(css).toContain('body'); + expect(css).toContain('h1'); + expect(css).toContain('font-family'); + expect(css).not.toContain('$body-font'); + }); + + it('emits export {}; in the .d.ts for a non-module file', async () => { + const { processor } = createProcessor(terminalProvider, { + nonModuleFileExtensions: ['.global.scss'] + }); + await compileFixtureAsync(processor, 'global-styles.global.scss'); + const dts: string = getDtsOutput('global-styles.global.scss'); + expect(dts).toBe('export {};'); + }); + }); + + describe('multiple output folders', () => { + it('writes .d.ts to every configured dtsOutputFolder', async () => { + const DTS_FOLDER_A: string = '/fake/output/dts-a'; + const DTS_FOLDER_B: string = '/fake/output/dts-b'; + const { processor } = createProcessor(terminalProvider, { + dtsOutputFolders: [DTS_FOLDER_A, DTS_FOLDER_B] + }); + await compileFixtureAsync(processor, 'export-only.module.scss'); + + const dtsA: string = writtenFiles.get(`${DTS_FOLDER_A}/export-only.module.scss.d.ts`)!; + const dtsB: string = writtenFiles.get(`${DTS_FOLDER_B}/export-only.module.scss.d.ts`)!; + + expect(dtsA).toBeDefined(); + expect(dtsA).toEqual(dtsB); + }); + + it('writes CSS to every configured cssOutputFolder', async () => { + const CSS_FOLDER_A: string = '/fake/output/css-a'; + const CSS_FOLDER_B: string = '/fake/output/css-b'; + const { processor } = createProcessor(terminalProvider, { + cssOutputFolders: [ + { folder: CSS_FOLDER_A, shimModuleFormat: undefined }, + { folder: CSS_FOLDER_B, shimModuleFormat: undefined } + ] + }); + await compileFixtureAsync(processor, 'export-only.module.scss'); + + const cssA: string = writtenFiles.get(`${CSS_FOLDER_A}/export-only.module.css`)!; + const cssB: string = writtenFiles.get(`${CSS_FOLDER_B}/export-only.module.css`)!; + + expect(cssA).toBeDefined(); + expect(cssA).toEqual(cssB); + }); + }); + + describe('postProcessCssAsync', () => { + it('passes compiled CSS through the post-processor callback', async () => { + const postProcessed: string[] = []; + const { processor } = createProcessor(terminalProvider, { + postProcessCssAsync: async (css: string) => { + postProcessed.push(css); + return css.replace(/color:/g, 'color: /* post-processed */'); + } + }); + await compileFixtureAsync(processor, 'classes-and-exports.module.scss'); + + // The callback should have been called with the raw CSS + expect(postProcessed.length).toBe(1); + expect(postProcessed[0]).toContain('color:'); + + // The emitted CSS should reflect the transformation + const css: string = getCssOutput('classes-and-exports.module.scss'); + expect(css).toContain('color: /* post-processed */'); + }); + + it('post-processor runs after postcss-modules strips :export', async () => { + const seenCss: string[] = []; + const { processor } = createProcessor(terminalProvider, { + postProcessCssAsync: async (css: string) => { + seenCss.push(css); + return css; + } + }); + await compileFixtureAsync(processor, 'export-only.module.scss'); + + // With preserveIcssExports: false (default), the CSS seen by the callback + // should already have the :export block stripped + expect(seenCss[0]).not.toContain(':export'); + }); + + it('post-processor receives the original CSS including :export when preserveIcssExports is true', async () => { + const seenCss: string[] = []; + const { processor } = createProcessor(terminalProvider, { + preserveIcssExports: true, + postProcessCssAsync: async (css: string) => { + seenCss.push(css); + return css; + } + }); + await compileFixtureAsync(processor, 'export-only.module.scss'); + + expect(seenCss[0]).toContain(':export'); + }); + }); + + describe('doNotTrimOriginalFileExtension', () => { + it('strips the source extension by default (doNotTrimOriginalFileExtension: false)', async () => { + const { processor } = createProcessor(terminalProvider); + await compileFixtureAsync(processor, 'classes-and-exports.module.scss'); + // Default: "classes-and-exports.module.scss" → "classes-and-exports.module.css" + const css: string = writtenFiles.get(`${CSS_OUTPUT_FOLDER}/classes-and-exports.module.css`)!; + expect(css).toBeDefined(); + expect(writtenFiles.has(`${CSS_OUTPUT_FOLDER}/classes-and-exports.module.scss.css`)).toBe(false); + }); + + it('preserves the source extension when doNotTrimOriginalFileExtension is true', async () => { + const { processor } = createProcessor(terminalProvider, { doNotTrimOriginalFileExtension: true }); + await compileFixtureAsync(processor, 'classes-and-exports.module.scss'); + // "classes-and-exports.module.scss" → "classes-and-exports.module.scss.css" + const css: string = writtenFiles.get(`${CSS_OUTPUT_FOLDER}/classes-and-exports.module.scss.css`)!; + expect(css).toBeDefined(); + expect(writtenFiles.has(`${CSS_OUTPUT_FOLDER}/classes-and-exports.module.css`)).toBe(false); + }); + + it('uses the .scss.css filename in JS shims when doNotTrimOriginalFileExtension is true', async () => { + const { processor } = createProcessor(terminalProvider, { + doNotTrimOriginalFileExtension: true, + cssOutputFolders: [{ folder: CSS_OUTPUT_FOLDER, shimModuleFormat: 'commonjs' }] + }); + await compileFixtureAsync(processor, 'classes-and-exports.module.scss'); + const shim: string = writtenFiles.get(`${CSS_OUTPUT_FOLDER}/classes-and-exports.module.scss.js`)!; + expect(shim).toContain(`require("./classes-and-exports.module.scss.css")`); + }); + + it('the CSS content is the same regardless of doNotTrimOriginalFileExtension', async () => { + const { processor: processorDefault } = createProcessor(terminalProvider); + await compileFixtureAsync(processorDefault, 'classes-and-exports.module.scss'); + const cssDefault: string = writtenFiles.get(`${CSS_OUTPUT_FOLDER}/classes-and-exports.module.css`)!; + + writtenFiles.clear(); + + const { processor: processorPreserve } = createProcessor(terminalProvider, { + doNotTrimOriginalFileExtension: true + }); + await compileFixtureAsync(processorPreserve, 'classes-and-exports.module.scss'); + const cssPreserve: string = writtenFiles.get( + `${CSS_OUTPUT_FOLDER}/classes-and-exports.module.scss.css` + )!; + + expect(cssDefault).toEqual(cssPreserve); + }); + }); + + describe('error reporting', () => { + it('emits an error for invalid SCSS syntax', async () => { + const { processor, logger } = createProcessor(terminalProvider); + await compileFixtureAsync(processor, 'invalid.module.scss'); + expect(logger.errors.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/heft-plugins/heft-sass-plugin/src/test/__snapshots__/SassProcessor.test.ts.snap b/heft-plugins/heft-sass-plugin/src/test/__snapshots__/SassProcessor.test.ts.snap new file mode 100644 index 00000000000..8ef7e174a5f --- /dev/null +++ b/heft-plugins/heft-sass-plugin/src/test/__snapshots__/SassProcessor.test.ts.snap @@ -0,0 +1,955 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SassProcessor JS shim files does not emit a shim when shimModuleFormat is undefined: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor JS shim files does not emit a shim when shimModuleFormat is undefined: written-files 1`] = ` +Map { + "/fake/output/dts/classes-and-exports.module.scss.d.ts" => "declare interface IStyles { + themeColor: string; + spacing: string; + root: string; + highlighted: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/classes-and-exports.module.css" => ".root { + color: red; + font-size: 14px; +} + +.highlighted { + background-color: yellow; +}", +} +`; + +exports[`SassProcessor JS shim files emits a CommonJS shim for a module file: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor JS shim files emits a CommonJS shim for a module file: written-files 1`] = ` +Map { + "/fake/output/dts/classes-and-exports.module.scss.d.ts" => "declare interface IStyles { + themeColor: string; + spacing: string; + root: string; + highlighted: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/classes-and-exports.module.css" => ".root { + color: red; + font-size: 14px; +} + +.highlighted { + background-color: yellow; +}", + "/fake/output/css/classes-and-exports.module.scss.js" => "module.exports = require(\\"./classes-and-exports.module.css\\"); +module.exports.default = module.exports;", +} +`; + +exports[`SassProcessor JS shim files emits a CommonJS shim for a non-module (global) file: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor JS shim files emits a CommonJS shim for a non-module (global) file: written-files 1`] = ` +Map { + "/fake/output/dts/global-styles.global.scss.d.ts" => "export {};", + "/fake/output/css/global-styles.global.css" => "body { + margin: 0; + padding: 0; + font-family: \\"Segoe UI\\", sans-serif; +} + +h1 { + font-size: 24px; + color: #333; +}", + "/fake/output/css/global-styles.global.scss.js" => "require(\\"./global-styles.global.css\\");", +} +`; + +exports[`SassProcessor JS shim files emits an ESM shim for a module file: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor JS shim files emits an ESM shim for a module file: written-files 1`] = ` +Map { + "/fake/output/dts/classes-and-exports.module.scss.d.ts" => "declare interface IStyles { + themeColor: string; + spacing: string; + root: string; + highlighted: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/classes-and-exports.module.css" => ".root { + color: red; + font-size: 14px; +} + +.highlighted { + background-color: yellow; +}", + "/fake/output/css/classes-and-exports.module.scss.js" => "export { default } from \\"./classes-and-exports.module.css\\";", +} +`; + +exports[`SassProcessor JS shim files emits an ESM shim for a non-module (global) file: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor JS shim files emits an ESM shim for a non-module (global) file: written-files 1`] = ` +Map { + "/fake/output/dts/global-styles.global.scss.d.ts" => "export {};", + "/fake/output/css/global-styles.global.css" => "body { + margin: 0; + padding: 0; + font-family: \\"Segoe UI\\", sans-serif; +} + +h1 { + font-size: 24px; + color: #333; +}", + "/fake/output/css/global-styles.global.scss.js" => "import \\"./global-styles.global.css\\";export {};", +} +`; + +exports[`SassProcessor JS shim files writes shims to each configured cssOutputFolder independently: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor JS shim files writes shims to each configured cssOutputFolder independently: written-files 1`] = ` +Map { + "/fake/output/dts/classes-and-exports.module.scss.d.ts" => "declare interface IStyles { + themeColor: string; + spacing: string; + root: string; + highlighted: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css-esm/classes-and-exports.module.css" => ".root { + color: red; + font-size: 14px; +} + +.highlighted { + background-color: yellow; +}", + "/fake/output/css-esm/classes-and-exports.module.scss.js" => "export { default } from \\"./classes-and-exports.module.css\\";", + "/fake/output/css-cjs/classes-and-exports.module.css" => ".root { + color: red; + font-size: 14px; +} + +.highlighted { + background-color: yellow; +}", + "/fake/output/css-cjs/classes-and-exports.module.scss.js" => "module.exports = require(\\"./classes-and-exports.module.css\\"); +module.exports.default = module.exports;", +} +`; + +exports[`SassProcessor classes-and-exports.module.scss generates correct .d.ts with both class names and :export values: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor classes-and-exports.module.scss generates correct .d.ts with both class names and :export values: written-files 1`] = ` +Map { + "/fake/output/dts/classes-and-exports.module.scss.d.ts" => "declare interface IStyles { + themeColor: string; + spacing: string; + root: string; + highlighted: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/classes-and-exports.module.css" => ".root { + color: red; + font-size: 14px; +} + +.highlighted { + background-color: yellow; +}", +} +`; + +exports[`SassProcessor classes-and-exports.module.scss generates named exports in .d.ts when exportAsDefault is false: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor classes-and-exports.module.scss generates named exports in .d.ts when exportAsDefault is false: written-files 1`] = ` +Map { + "/fake/output/dts/classes-and-exports.module.scss.d.ts" => "export const themeColor: string; +export const spacing: string; +export const root: string; +export const highlighted: string;", +} +`; + +exports[`SassProcessor classes-and-exports.module.scss preserves the :export block in CSS when preserveIcssExports is true: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor classes-and-exports.module.scss preserves the :export block in CSS when preserveIcssExports is true: written-files 1`] = ` +Map { + "/fake/output/dts/classes-and-exports.module.scss.d.ts" => "declare interface IStyles { + themeColor: string; + spacing: string; + root: string; + highlighted: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/classes-and-exports.module.css" => ".root { + color: red; + font-size: 14px; +} + +.highlighted { + background-color: yellow; +} + +:export { + themeColor: blue; + spacing: 8px; +}", +} +`; + +exports[`SassProcessor classes-and-exports.module.scss strips the :export block from CSS when preserveIcssExports is false: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor classes-and-exports.module.scss strips the :export block from CSS when preserveIcssExports is false: written-files 1`] = ` +Map { + "/fake/output/dts/classes-and-exports.module.scss.d.ts" => "declare interface IStyles { + themeColor: string; + spacing: string; + root: string; + highlighted: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/classes-and-exports.module.css" => ".root { + color: red; + font-size: 14px; +} + +.highlighted { + background-color: yellow; +}", +} +`; + +exports[`SassProcessor doNotTrimOriginalFileExtension preserves the source extension when doNotTrimOriginalFileExtension is true: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor doNotTrimOriginalFileExtension preserves the source extension when doNotTrimOriginalFileExtension is true: written-files 1`] = ` +Map { + "/fake/output/dts/classes-and-exports.module.scss.d.ts" => "declare interface IStyles { + themeColor: string; + spacing: string; + root: string; + highlighted: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/classes-and-exports.module.scss.css" => ".root { + color: red; + font-size: 14px; +} + +.highlighted { + background-color: yellow; +}", +} +`; + +exports[`SassProcessor doNotTrimOriginalFileExtension strips the source extension by default (doNotTrimOriginalFileExtension: false): terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor doNotTrimOriginalFileExtension strips the source extension by default (doNotTrimOriginalFileExtension: false): written-files 1`] = ` +Map { + "/fake/output/dts/classes-and-exports.module.scss.d.ts" => "declare interface IStyles { + themeColor: string; + spacing: string; + root: string; + highlighted: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/classes-and-exports.module.css" => ".root { + color: red; + font-size: 14px; +} + +.highlighted { + background-color: yellow; +}", +} +`; + +exports[`SassProcessor doNotTrimOriginalFileExtension the CSS content is the same regardless of doNotTrimOriginalFileExtension: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor doNotTrimOriginalFileExtension the CSS content is the same regardless of doNotTrimOriginalFileExtension: written-files 1`] = ` +Map { + "/fake/output/dts/classes-and-exports.module.scss.d.ts" => "declare interface IStyles { + themeColor: string; + spacing: string; + root: string; + highlighted: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/classes-and-exports.module.scss.css" => ".root { + color: red; + font-size: 14px; +} + +.highlighted { + background-color: yellow; +}", +} +`; + +exports[`SassProcessor doNotTrimOriginalFileExtension uses the .scss.css filename in JS shims when doNotTrimOriginalFileExtension is true: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor doNotTrimOriginalFileExtension uses the .scss.css filename in JS shims when doNotTrimOriginalFileExtension is true: written-files 1`] = ` +Map { + "/fake/output/dts/classes-and-exports.module.scss.d.ts" => "declare interface IStyles { + themeColor: string; + spacing: string; + root: string; + highlighted: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/classes-and-exports.module.scss.css" => ".root { + color: red; + font-size: 14px; +} + +.highlighted { + background-color: yellow; +}", + "/fake/output/css/classes-and-exports.module.scss.js" => "module.exports = require(\\"./classes-and-exports.module.scss.css\\"); +module.exports.default = module.exports;", +} +`; + +exports[`SassProcessor error reporting emits an error for invalid SCSS syntax: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor error reporting emits an error for invalid SCSS syntax: written-files 1`] = `Map {}`; + +exports[`SassProcessor export-only.module.scss generates the same .d.ts regardless of preserveIcssExports: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor export-only.module.scss generates the same .d.ts regardless of preserveIcssExports: written-files 1`] = ` +Map { + "/fake/output/dts/export-only.module.scss.d.ts" => "declare interface IStyles { + primaryColor: string; + fontFamily: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/export-only.module.css" => ":export { + primaryColor: #0078d4; + fontFamily: \\"Segoe UI\\"; +}", +} +`; + +exports[`SassProcessor export-only.module.scss preserves the :export block in CSS when preserveIcssExports is true: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor export-only.module.scss preserves the :export block in CSS when preserveIcssExports is true: written-files 1`] = ` +Map { + "/fake/output/dts/export-only.module.scss.d.ts" => "declare interface IStyles { + primaryColor: string; + fontFamily: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/export-only.module.css" => ":export { + primaryColor: #0078d4; + fontFamily: \\"Segoe UI\\"; +}", +} +`; + +exports[`SassProcessor export-only.module.scss strips the :export block from CSS when preserveIcssExports is false: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor export-only.module.scss strips the :export block from CSS when preserveIcssExports is false: written-files 1`] = ` +Map { + "/fake/output/dts/export-only.module.scss.d.ts" => "declare interface IStyles { + primaryColor: string; + fontFamily: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/export-only.module.css" => "", +} +`; + +exports[`SassProcessor extend-with-exports.module.scss (Sass @extend / placeholder selectors) generates .d.ts with class names and :export values for @extend file: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor extend-with-exports.module.scss (Sass @extend / placeholder selectors) generates .d.ts with class names and :export values for @extend file: written-files 1`] = ` +Map { + "/fake/output/dts/extend-with-exports.module.scss.d.ts" => "declare interface IStyles { + colorPrimary: string; + colorDanger: string; + dangerButton: string; + primaryButton: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/extend-with-exports.module.css" => ".dangerButton, .primaryButton { + display: inline-flex; + align-items: center; + cursor: pointer; + border: none; + border-radius: 2px; +} + +.primaryButton { + background-color: #0078d4; + color: white; +} + +.dangerButton { + background-color: #d13438; + color: white; +}", +} +`; + +exports[`SassProcessor extend-with-exports.module.scss (Sass @extend / placeholder selectors) merges @extend selectors and strips :export when preserveIcssExports is false: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor extend-with-exports.module.scss (Sass @extend / placeholder selectors) merges @extend selectors and strips :export when preserveIcssExports is false: written-files 1`] = ` +Map { + "/fake/output/dts/extend-with-exports.module.scss.d.ts" => "declare interface IStyles { + colorPrimary: string; + colorDanger: string; + dangerButton: string; + primaryButton: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/extend-with-exports.module.css" => ".dangerButton, .primaryButton { + display: inline-flex; + align-items: center; + cursor: pointer; + border: none; + border-radius: 2px; +} + +.primaryButton { + background-color: #0078d4; + color: white; +} + +.dangerButton { + background-color: #d13438; + color: white; +}", +} +`; + +exports[`SassProcessor extend-with-exports.module.scss (Sass @extend / placeholder selectors) preserves :export alongside @extend-merged output when preserveIcssExports is true: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor extend-with-exports.module.scss (Sass @extend / placeholder selectors) preserves :export alongside @extend-merged output when preserveIcssExports is true: written-files 1`] = ` +Map { + "/fake/output/dts/extend-with-exports.module.scss.d.ts" => "declare interface IStyles { + colorPrimary: string; + colorDanger: string; + dangerButton: string; + primaryButton: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/extend-with-exports.module.css" => ".dangerButton, .primaryButton { + display: inline-flex; + align-items: center; + cursor: pointer; + border: none; + border-radius: 2px; +} + +.primaryButton { + background-color: #0078d4; + color: white; +} + +.dangerButton { + background-color: #d13438; + color: white; +} + +:export { + colorPrimary: #0078d4; + colorDanger: #d13438; +}", +} +`; + +exports[`SassProcessor mixin-with-exports.module.scss (Sass @mixin) expands @mixin calls in CSS output: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor mixin-with-exports.module.scss (Sass @mixin) expands @mixin calls in CSS output: written-files 1`] = ` +Map { + "/fake/output/dts/mixin-with-exports.module.scss.d.ts" => "declare interface IStyles { + cardRadius: string; + animationDuration: string; + card: string; + \\"card--vertical\\": string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/mixin-with-exports.module.css" => ".card { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + border-radius: 4px; +} + +.card--vertical { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +}", +} +`; + +exports[`SassProcessor mixin-with-exports.module.scss (Sass @mixin) generates .d.ts with :export values and class names from @mixin-using file: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor mixin-with-exports.module.scss (Sass @mixin) generates .d.ts with :export values and class names from @mixin-using file: written-files 1`] = ` +Map { + "/fake/output/dts/mixin-with-exports.module.scss.d.ts" => "declare interface IStyles { + cardRadius: string; + animationDuration: string; + card: string; + \\"card--vertical\\": string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/mixin-with-exports.module.css" => ".card { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + border-radius: 4px; +} + +.card--vertical { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +}", +} +`; + +exports[`SassProcessor mixin-with-exports.module.scss (Sass @mixin) preserves :export alongside expanded @mixin output when preserveIcssExports is true: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor mixin-with-exports.module.scss (Sass @mixin) preserves :export alongside expanded @mixin output when preserveIcssExports is true: written-files 1`] = ` +Map { + "/fake/output/dts/mixin-with-exports.module.scss.d.ts" => "declare interface IStyles { + cardRadius: string; + animationDuration: string; + card: string; + \\"card--vertical\\": string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/mixin-with-exports.module.css" => ".card { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + border-radius: 4px; +} + +.card--vertical { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +:export { + cardRadius: 4px; + animationDuration: 200ms; +}", +} +`; + +exports[`SassProcessor multiple output folders writes .d.ts to every configured dtsOutputFolder: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor multiple output folders writes .d.ts to every configured dtsOutputFolder: written-files 1`] = ` +Map { + "/fake/output/dts-a/export-only.module.scss.d.ts" => "declare interface IStyles { + primaryColor: string; + fontFamily: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/dts-b/export-only.module.scss.d.ts" => "declare interface IStyles { + primaryColor: string; + fontFamily: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/export-only.module.css" => "", +} +`; + +exports[`SassProcessor multiple output folders writes CSS to every configured cssOutputFolder: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor multiple output folders writes CSS to every configured cssOutputFolder: written-files 1`] = ` +Map { + "/fake/output/dts/export-only.module.scss.d.ts" => "declare interface IStyles { + primaryColor: string; + fontFamily: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css-a/export-only.module.css" => "", + "/fake/output/css-b/export-only.module.css" => "", +} +`; + +exports[`SassProcessor non-module (global) files emits export {}; in the .d.ts for a non-module file: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor non-module (global) files emits export {}; in the .d.ts for a non-module file: written-files 1`] = ` +Map { + "/fake/output/dts/global-styles.global.scss.d.ts" => "export {};", + "/fake/output/css/global-styles.global.css" => "body { + margin: 0; + padding: 0; + font-family: \\"Segoe UI\\", sans-serif; +} + +h1 { + font-size: 24px; + color: #333; +}", +} +`; + +exports[`SassProcessor non-module (global) files emits plain compiled CSS for a .global.scss file: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor non-module (global) files emits plain compiled CSS for a .global.scss file: written-files 1`] = ` +Map { + "/fake/output/dts/global-styles.global.scss.d.ts" => "export {};", + "/fake/output/css/global-styles.global.css" => "body { + margin: 0; + padding: 0; + font-family: \\"Segoe UI\\", sans-serif; +} + +h1 { + font-size: 24px; + color: #333; +}", +} +`; + +exports[`SassProcessor postProcessCssAsync passes compiled CSS through the post-processor callback: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor postProcessCssAsync passes compiled CSS through the post-processor callback: written-files 1`] = ` +Map { + "/fake/output/dts/classes-and-exports.module.scss.d.ts" => "declare interface IStyles { + themeColor: string; + spacing: string; + root: string; + highlighted: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/classes-and-exports.module.css" => ".root { + color: /* post-processed */ red; + font-size: 14px; +} + +.highlighted { + background-color: /* post-processed */ yellow; +}", +} +`; + +exports[`SassProcessor postProcessCssAsync post-processor receives the original CSS including :export when preserveIcssExports is true: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor postProcessCssAsync post-processor receives the original CSS including :export when preserveIcssExports is true: written-files 1`] = ` +Map { + "/fake/output/dts/export-only.module.scss.d.ts" => "declare interface IStyles { + primaryColor: string; + fontFamily: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/export-only.module.css" => ":export { + primaryColor: #0078d4; + fontFamily: \\"Segoe UI\\"; +}", +} +`; + +exports[`SassProcessor postProcessCssAsync post-processor runs after postcss-modules strips :export: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor postProcessCssAsync post-processor runs after postcss-modules strips :export: written-files 1`] = ` +Map { + "/fake/output/dts/export-only.module.scss.d.ts" => "declare interface IStyles { + primaryColor: string; + fontFamily: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/export-only.module.css" => "", +} +`; + +exports[`SassProcessor sass-variables-and-exports.module.scss (Sass variables, nesting, BEM) generates .d.ts with resolved :export keys as typed properties: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor sass-variables-and-exports.module.scss (Sass variables, nesting, BEM) generates .d.ts with resolved :export keys as typed properties: written-files 1`] = ` +Map { + "/fake/output/dts/sass-variables-and-exports.module.scss.d.ts" => "declare interface IStyles { + primaryColor: string; + secondaryColor: string; + baseSpacing: string; + container: string; + container__title: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/sass-variables-and-exports.module.css" => ".container { + color: #0078d4; + padding: 8px; +} +.container:hover { + color: #106ebe; +} +.container__title { + font-size: 16px; + margin-bottom: 16px; +}", +} +`; + +exports[`SassProcessor sass-variables-and-exports.module.scss (Sass variables, nesting, BEM) resolves Sass variables and expands nested rules in CSS output: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor sass-variables-and-exports.module.scss (Sass variables, nesting, BEM) resolves Sass variables and expands nested rules in CSS output: written-files 1`] = ` +Map { + "/fake/output/dts/sass-variables-and-exports.module.scss.d.ts" => "declare interface IStyles { + primaryColor: string; + secondaryColor: string; + baseSpacing: string; + container: string; + container__title: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/sass-variables-and-exports.module.css" => ".container { + color: #0078d4; + padding: 8px; +} +.container:hover { + color: #106ebe; +} +.container__title { + font-size: 16px; + margin-bottom: 16px; +}", +} +`; + +exports[`SassProcessor sass-variables-and-exports.module.scss (Sass variables, nesting, BEM) resolves Sass variables inside the :export block when preserveIcssExports is true: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor sass-variables-and-exports.module.scss (Sass variables, nesting, BEM) resolves Sass variables inside the :export block when preserveIcssExports is true: written-files 1`] = ` +Map { + "/fake/output/dts/sass-variables-and-exports.module.scss.d.ts" => "declare interface IStyles { + primaryColor: string; + secondaryColor: string; + baseSpacing: string; + container: string; + container__title: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/sass-variables-and-exports.module.css" => ".container { + color: #0078d4; + padding: 8px; +} +.container:hover { + color: #106ebe; +} +.container__title { + font-size: 16px; + margin-bottom: 16px; +} + +:export { + primaryColor: #0078d4; + secondaryColor: #106ebe; + baseSpacing: 8px; +}", +} +`; diff --git a/heft-plugins/heft-sass-plugin/src/test/fixtures/classes-and-exports.module.scss b/heft-plugins/heft-sass-plugin/src/test/fixtures/classes-and-exports.module.scss new file mode 100644 index 00000000000..8bd91e97f7e --- /dev/null +++ b/heft-plugins/heft-sass-plugin/src/test/fixtures/classes-and-exports.module.scss @@ -0,0 +1,14 @@ +// A CSS module that exports both class names and ICSS :export values. +.root { + color: red; + font-size: 14px; +} + +.highlighted { + background-color: yellow; +} + +:export { + themeColor: blue; + spacing: 8px; +} diff --git a/heft-plugins/heft-sass-plugin/src/test/fixtures/export-only.module.scss b/heft-plugins/heft-sass-plugin/src/test/fixtures/export-only.module.scss new file mode 100644 index 00000000000..abe60ef5051 --- /dev/null +++ b/heft-plugins/heft-sass-plugin/src/test/fixtures/export-only.module.scss @@ -0,0 +1,6 @@ +// A CSS module that only exports ICSS values — no class names. +// Used to verify that the :export block is preserved or stripped based on preserveIcssExports. +:export { + primaryColor: #0078d4; + fontFamily: 'Segoe UI'; +} diff --git a/heft-plugins/heft-sass-plugin/src/test/fixtures/extend-with-exports.module.scss b/heft-plugins/heft-sass-plugin/src/test/fixtures/extend-with-exports.module.scss new file mode 100644 index 00000000000..f36921fbba9 --- /dev/null +++ b/heft-plugins/heft-sass-plugin/src/test/fixtures/extend-with-exports.module.scss @@ -0,0 +1,28 @@ +// Tests that @extend / placeholder selectors work alongside :export. +%button-base { + display: inline-flex; + align-items: center; + cursor: pointer; + border: none; + border-radius: 2px; +} + +$color-primary: #0078d4; +$color-danger: #d13438; + +.primaryButton { + @extend %button-base; + background-color: $color-primary; + color: white; +} + +.dangerButton { + @extend %button-base; + background-color: $color-danger; + color: white; +} + +:export { + colorPrimary: #{$color-primary}; + colorDanger: #{$color-danger}; +} diff --git a/heft-plugins/heft-sass-plugin/src/test/fixtures/global-styles.global.scss b/heft-plugins/heft-sass-plugin/src/test/fixtures/global-styles.global.scss new file mode 100644 index 00000000000..bd77ea5d649 --- /dev/null +++ b/heft-plugins/heft-sass-plugin/src/test/fixtures/global-styles.global.scss @@ -0,0 +1,15 @@ +// Non-module global stylesheet — processed as plain CSS, not a CSS module. +// Used to verify that global files produce no class exports and generate correct shims. +$body-font: 'Segoe UI', sans-serif; +$heading-color: #333; + +body { + margin: 0; + padding: 0; + font-family: $body-font; +} + +h1 { + font-size: 24px; + color: $heading-color; +} diff --git a/heft-plugins/heft-sass-plugin/src/test/fixtures/invalid.module.scss b/heft-plugins/heft-sass-plugin/src/test/fixtures/invalid.module.scss new file mode 100644 index 00000000000..7c9df51305b --- /dev/null +++ b/heft-plugins/heft-sass-plugin/src/test/fixtures/invalid.module.scss @@ -0,0 +1,4 @@ +// Intentionally invalid SCSS — used to verify that SassProcessor emits errors correctly. +.broken { + color:; +} diff --git a/heft-plugins/heft-sass-plugin/src/test/fixtures/mixin-with-exports.module.scss b/heft-plugins/heft-sass-plugin/src/test/fixtures/mixin-with-exports.module.scss new file mode 100644 index 00000000000..28a8f98b9bb --- /dev/null +++ b/heft-plugins/heft-sass-plugin/src/test/fixtures/mixin-with-exports.module.scss @@ -0,0 +1,24 @@ +// Tests that @mixin expansion works correctly alongside :export. +@mixin flex-center($direction: row) { + display: flex; + flex-direction: $direction; + align-items: center; + justify-content: center; +} + +$card-radius: 4px; +$animation-duration: 200ms; + +.card { + @include flex-center; + border-radius: $card-radius; +} + +.card--vertical { + @include flex-center(column); +} + +:export { + cardRadius: #{$card-radius}; + animationDuration: #{$animation-duration}; +} diff --git a/heft-plugins/heft-sass-plugin/src/test/fixtures/sass-variables-and-exports.module.scss b/heft-plugins/heft-sass-plugin/src/test/fixtures/sass-variables-and-exports.module.scss new file mode 100644 index 00000000000..eda77f65c1e --- /dev/null +++ b/heft-plugins/heft-sass-plugin/src/test/fixtures/sass-variables-and-exports.module.scss @@ -0,0 +1,24 @@ +// Tests that Sass variables are resolved in both class rules and the :export block. +$primary-color: #0078d4; +$secondary-color: #106ebe; +$base-spacing: 8px; + +.container { + color: $primary-color; + padding: $base-spacing; + + &:hover { + color: $secondary-color; + } + + &__title { + font-size: 16px; + margin-bottom: $base-spacing * 2; + } +} + +:export { + primaryColor: #{$primary-color}; + secondaryColor: #{$secondary-color}; + baseSpacing: #{$base-spacing}; +}