From 1bb8b05fd0252eae7e394c88584c619285cf7f62 Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Wed, 28 Oct 2026 15:11:56 -0700 Subject: [PATCH 1/6] [heft-sass-plugin] Support named exports in CSS shims Allow CSS output folders with named typings so libraries can publish compiled CSS and shims without copying Sass sources. Re-export named bindings from ESM shims and preserve CommonJS loader namespaces without synthesizing or overwriting a default export. Keep default-export and side-effect-only behavior unchanged, and cover CSS/source-map parity, ICSS composition and empty modules. Update the schema, template and documentation with downstream loader requirements. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../named-css-shims_2026-10-28-13-00.json | 10 + heft-plugins/heft-sass-plugin/README.md | 28 +- .../heft-sass-plugin/src/SassProcessor.ts | 26 +- .../src/schemas/heft-sass-plugin.schema.json | 2 +- .../heft-sass-plugin/src/templates/sass.json | 2 +- .../src/test/SassProcessor.test.ts | 88 +++- .../__snapshots__/SassProcessor.test.ts.snap | 418 ++++++++++++++++++ .../src/test/fixtures/composed.module.scss | 15 + .../src/test/fixtures/empty.module.scss | 2 + 9 files changed, 573 insertions(+), 18 deletions(-) create mode 100644 common/changes/@rushstack/heft-sass-plugin/named-css-shims_2026-10-28-13-00.json create mode 100644 heft-plugins/heft-sass-plugin/src/test/fixtures/composed.module.scss create mode 100644 heft-plugins/heft-sass-plugin/src/test/fixtures/empty.module.scss diff --git a/common/changes/@rushstack/heft-sass-plugin/named-css-shims_2026-10-28-13-00.json b/common/changes/@rushstack/heft-sass-plugin/named-css-shims_2026-10-28-13-00.json new file mode 100644 index 00000000000..2687364f7b7 --- /dev/null +++ b/common/changes/@rushstack/heft-sass-plugin/named-css-shims_2026-10-28-13-00.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/heft-sass-plugin", + "comment": "Support named exports when emitting CSS and CommonJS or ESM shims with exportAsDefault set to false.", + "type": "minor" + } + ], + "packageName": "@rushstack/heft-sass-plugin" +} diff --git a/heft-plugins/heft-sass-plugin/README.md b/heft-plugins/heft-sass-plugin/README.md index fe276a33d04..fc8d7ad650a 100644 --- a/heft-plugins/heft-sass-plugin/README.md +++ b/heft-plugins/heft-sass-plugin/README.md @@ -157,7 +157,7 @@ All options are set in `config/sass.json`. Every option is optional. | `srcFolder` | `"src/"` | Root directory that is scanned for SCSS files | | `generatedTsFolder` | `"temp/sass-ts/"` | Output directory for generated `.d.ts` files | | `secondaryGeneratedTsFolders` | `[]` | Additional directories to also write `.d.ts` files to (e.g. `"lib-esm"` when publishing typings alongside compiled output) | -| `exportAsDefault` | `true` | When `true`, wraps exports in a typed default interface. When `false`, generates individual named exports (`export const className: string`). Note: `false` is incompatible with `cssOutputFolders`. | +| `exportAsDefault` | `true` | When `true`, wraps exports in a typed default interface. When `false`, generates individual named exports (`export const className: string`). JavaScript shims use the same export style; configure the downstream CSS loader to match. | | `cssOutputFolders` | _(none)_ | Folders where compiled `.css` files are written. Each entry is either a plain folder path string, or an object with `folder` and optional `shimModuleFormat` (see below). | | `fileExtensions` | `[".sass", ".scss", ".css"]` | File extensions to treat as CSS modules | | `nonModuleFileExtensions` | `[".global.sass", ".global.scss", ".global.css"]` | File extensions to treat as global (non-module) stylesheets | @@ -181,7 +181,7 @@ Each entry in `cssOutputFolders` can be a plain string (folder path only) or an } ``` -When `shimModuleFormat` is set, the plugin writes a `.js` shim alongside each `.css` file. For a CSS module, the shim re-exports the CSS: +When `shimModuleFormat` is set, the plugin writes a `.js` shim alongside each `.css` file. For a CSS module with `exportAsDefault: true` (the default), the shim re-exports the CSS: ```js // ESM shim (shimModuleFormat: "esnext") @@ -192,7 +192,29 @@ module.exports = require("./Button.module.css"); module.exports.default = module.exports; ``` -For a global stylesheet, the shim is a side-effect-only import: +With `exportAsDefault: false`, the shim forwards named exports instead: + +```js +// ESM shim +export * from "./Button.module.css"; + +// CommonJS shim +module.exports = require("./Button.module.css"); +``` + +The ESM shim keeps named re-exports statically analyzable and does not forward the CSS loader's default +export. The CommonJS shim forwards the loader's exports unchanged, including any `__esModule` marker; +it does not synthesize a default styles object or overwrite a loader-provided default export. + +The consuming bundler must configure its CSS loader for named exports (for example, css-loader with +`esModule: true` and `modules.namedExport: true`). Export names must match the generated typings; use +`modules.exportLocalsConvention: "as-is"` with css-loader versions that support it, or restrict names +to ones unchanged by the loader's convention. Names must also be valid identifiers for named typings. +Set `preserveIcssExports: true` when the downstream loader must process ICSS `:export` values or +`composes` declarations. + +For a global stylesheet, or a CSS module with no local exports, the shim is a side-effect-only import +regardless of `exportAsDefault`: ```js // ESM shim diff --git a/heft-plugins/heft-sass-plugin/src/SassProcessor.ts b/heft-plugins/heft-sass-plugin/src/SassProcessor.ts index e2e8d9057ed..89455a665e3 100644 --- a/heft-plugins/heft-sass-plugin/src/SassProcessor.ts +++ b/heft-plugins/heft-sass-plugin/src/SassProcessor.ts @@ -88,7 +88,8 @@ export interface ISassProcessorOptions { cssOutputFolders?: ICssOutputFolder[]; /** - * Determines whether export values are wrapped in a default property, or not. + * Determines whether typings and JavaScript shims use a default export or named exports. + * The downstream CSS loader must use the corresponding export style. */ exportAsDefault: boolean; @@ -1004,10 +1005,6 @@ export class SassProcessor { } 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; @@ -1058,7 +1055,8 @@ export class SassProcessor { const jsShimContent: string = generateJsShimContent( shimModuleFormat, cssPathFromJs, - hasModuleExports + hasModuleExports, + exportAsDefault ); await FileSystem.writeFileAsync(jsFilePath, jsShimContent, writeFileOptions); } @@ -1275,15 +1273,23 @@ function determineSyntaxFromFilePath(filePath: string): Syntax { function generateJsShimContent( format: 'commonjs' | 'esnext', relativePathToCss: string, - isModule: boolean | undefined + hasModuleExports: boolean | undefined, + exportAsDefault: boolean ): string { const pathString: string = JSON.stringify(relativePathToCss); switch (format) { case 'commonjs': - return isModule + if (!hasModuleExports) { + return `require(${pathString});`; + } + + // Named exports preserve the loader's namespace and __esModule marker without synthesizing a default. + return exportAsDefault ? `module.exports = require(${pathString});\nmodule.exports.default = module.exports;` - : `require(${pathString});`; + : `module.exports = require(${pathString});`; case 'esnext': - return isModule ? `export { default } from ${pathString};` : `import ${pathString};export {};`; + return hasModuleExports + ? `export ${exportAsDefault ? '{ default }' : '*'} from ${pathString};` + : `import ${pathString};export {};`; } } 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 f4b34b60856..13518760204 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 @@ -37,7 +37,7 @@ "exportAsDefault": { "type": "boolean", - "description": "Determines whether export values are wrapped in a default property, or not." + "description": "Determines whether typings and JavaScript shims use a default export (true) or named exports (false). Configure the downstream CSS loader to use the same export style." }, "cssOutputFolders": { diff --git a/heft-plugins/heft-sass-plugin/src/templates/sass.json b/heft-plugins/heft-sass-plugin/src/templates/sass.json index 73f4eff6584..cb0ccb2fd57 100644 --- a/heft-plugins/heft-sass-plugin/src/templates/sass.json +++ b/heft-plugins/heft-sass-plugin/src/templates/sass.json @@ -36,7 +36,7 @@ * Determines whether CSS module exports are wrapped in a typed default interface (true) or emitted as * individual named exports (false). * - * Note: setting this to false is incompatible with cssOutputFolders. + * JavaScript shims use the same export style. Configure the downstream CSS loader to match. * * Default value: true */ diff --git a/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts b/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts index b5eefedfad4..56a448474f7 100644 --- a/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts +++ b/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts @@ -3,6 +3,7 @@ import path from 'node:path'; import nodeJsPath from 'node:path'; +import { runInNewContext } from 'node:vm'; import { FileSystem, Path, Text } from '@rushstack/node-core-library'; import { MockScopedLogger } from '@rushstack/heft/lib/pluginFramework/logging/MockScopedLogger'; @@ -271,10 +272,8 @@ describe(SassProcessor.name, () => { }); 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: [] + exportAsDefault: false }); await compileFixtureAsync(processor, 'classes-and-exports.module.scss'); const dts: string = getDtsOutput('classes-and-exports.module.scss'); @@ -387,6 +386,89 @@ describe(SassProcessor.name, () => { }); describe('JS shim files', () => { + describe.each(['commonjs', 'esnext'] as const)('named exports (%s)', (format: 'commonjs' | 'esnext') => { + it.each([ + 'classes-and-exports.module.scss', + 'export-only.module.scss', + 'composed.module.scss', + 'simple.module.sass' + ])( + 'forwards named exports for %s without changing CSS or source maps', + async (fixtureFilename: string) => { + const options: ICreateProcessorOptions = { + cssOutputFolders: [{ folder: CSS_OUTPUT_FOLDER, shimModuleFormat: format }], + doNotTrimOriginalFileExtension: true, + preserveIcssExports: true, + sourceMap: true + }; + const { processor: defaultProcessor } = createProcessor(terminalProvider, options); + await compileFixtureAsync(defaultProcessor, fixtureFilename); + const defaultCss: string = getWrittenFile(`${fixtureFilename}.css`); + const defaultMap: string = getWrittenFile(`${fixtureFilename}.css.map`); + writtenFiles.clear(); + + const { processor } = createProcessor(terminalProvider, { ...options, exportAsDefault: false }); + await compileFixtureAsync(processor, fixtureFilename); + const shim: string = getJsShimOutput(fixtureFilename); + const cssSpecifier: string = JSON.stringify(`./${fixtureFilename}.css`); + expect(shim).toBe( + format === 'esnext' + ? `export * from ${cssSpecifier};` + : `module.exports = require(${cssSpecifier});` + ); + expect(getDtsOutput(fixtureFilename)).toContain('export const '); + expect(getDtsOutput(fixtureFilename)).not.toContain('export default'); + expect(getWrittenFile(`${fixtureFilename}.css`)).toBe(defaultCss); + expect(getWrittenFile(`${fixtureFilename}.css.map`)).toBe(defaultMap); + } + ); + + it.each(['global-styles.global.scss', 'global-only.module.scss', 'empty.module.scss'])( + 'keeps %s side-effect-only', + async (fixtureFilename: string) => { + const { processor } = createProcessor(terminalProvider, { + exportAsDefault: false, + cssOutputFolders: [{ folder: CSS_OUTPUT_FOLDER, shimModuleFormat: format }] + }); + await compileFixtureAsync(processor, fixtureFilename); + const cssSpecifier: string = JSON.stringify(`./${fixtureFilename.replace(/\.scss$/, '.css')}`); + expect(getJsShimOutput(fixtureFilename)).toBe( + format === 'esnext' ? `import ${cssSpecifier};export {};` : `require(${cssSpecifier});` + ); + expect(getDtsOutput(fixtureFilename)).toBe('export {};'); + } + ); + }); + + it.each([false, true])( + 'forwards CommonJS named exports unchanged with __esModule=%s', + async (esModule: boolean) => { + const { processor } = createProcessor(terminalProvider, { + exportAsDefault: false, + cssOutputFolders: [{ folder: CSS_OUTPUT_FOLDER, shimModuleFormat: 'commonjs' }] + }); + await compileFixtureAsync(processor, 'classes-and-exports.module.scss'); + const cssExports: { root: string; default?: unknown } = { root: 'hashed-root' }; + if (esModule) { + Object.defineProperty(cssExports, '__esModule', { value: true }); + // css-loader can export its CSS API as default alongside named class exports. + cssExports.default = ['CSS API']; + } + + const originalDescriptors: PropertyDescriptorMap = Object.getOwnPropertyDescriptors(cssExports); + const shimModule: { exports: unknown } = { exports: {} }; + const requireCss: jest.Mock = jest.fn(() => cssExports); + runInNewContext(getJsShimOutput('classes-and-exports.module.scss'), { + module: shimModule, + require: requireCss + }); + expect(requireCss).toHaveBeenCalledTimes(1); + expect(requireCss).toHaveBeenCalledWith('./classes-and-exports.module.css'); + expect(shimModule.exports).toBe(cssExports); + expect(Object.getOwnPropertyDescriptors(cssExports)).toEqual(originalDescriptors); + } + ); + it('emits a CommonJS shim for a module file', async () => { const { processor } = createProcessor(terminalProvider, { cssOutputFolders: [{ folder: CSS_OUTPUT_FOLDER, shimModuleFormat: 'commonjs' }] 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 index 586fa188657..5d0c6f770fa 100644 --- 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 @@ -135,6 +135,416 @@ h1 { } `; +exports[`SassProcessor JS shim files forwards CommonJS named exports unchanged with __esModule=false: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor JS shim files forwards CommonJS named exports unchanged with __esModule=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;", + "/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\\");", +} +`; + +exports[`SassProcessor JS shim files forwards CommonJS named exports unchanged with __esModule=true: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor JS shim files forwards CommonJS named exports unchanged with __esModule=true: 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;", + "/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\\");", +} +`; + +exports[`SassProcessor JS shim files named exports (commonjs) forwards named exports for classes-and-exports.module.scss without changing CSS or source maps: 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 JS shim files named exports (commonjs) forwards named exports for classes-and-exports.module.scss without changing CSS or source maps: 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;", + "/fake/output/css/classes-and-exports.module.scss.css" => ".root { + color: red; + font-size: 14px; +} + +.highlighted { + background-color: yellow; +} + +:export { + themeColor: blue; + spacing: 8px; +} +/*# sourceMappingURL=classes-and-exports.module.scss.css.map */ +", + "/fake/output/css/classes-and-exports.module.scss.css.map" => "{\\"version\\":3,\\"sourceRoot\\":\\"\\",\\"sources\\":[\\"fixtures/classes-and-exports.module.scss\\"],\\"names\\":[],\\"mappings\\":\\"AACA;EACE;EACA;;;AAGF;EACE;;;AAGF;EACE;EACA\\",\\"sourcesContent\\":[\\"// A CSS module that exports both class names and ICSS :export values.\\\\n.root {\\\\n color: red;\\\\n font-size: 14px;\\\\n}\\\\n\\\\n.highlighted {\\\\n background-color: yellow;\\\\n}\\\\n\\\\n:export {\\\\n themeColor: blue;\\\\n spacing: 8px;\\\\n}\\\\n\\"],\\"file\\":\\"classes-and-exports.module.scss.css\\"}", + "/fake/output/css/classes-and-exports.module.scss.js" => "module.exports = require(\\"./classes-and-exports.module.scss.css\\");", +} +`; + +exports[`SassProcessor JS shim files named exports (commonjs) forwards named exports for composed.module.scss without changing CSS or source maps: 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 JS shim files named exports (commonjs) forwards named exports for composed.module.scss without changing CSS or source maps: written-files 1`] = ` +Map { + "/fake/output/dts/composed.module.scss.d.ts" => "export const themeColor: string; +export const base_style: string; +export const composedStyle: string;", + "/fake/output/css/composed.module.scss.css" => ".base_style { + color: red; +} + +.composedStyle { + composes: base_style; + color: blue; +} + +:export { + themeColor: purple; +} +/*# sourceMappingURL=composed.module.scss.css.map */ +", + "/fake/output/css/composed.module.scss.css.map" => "{\\"version\\":3,\\"sourceRoot\\":\\"\\",\\"sources\\":[\\"fixtures/composed.module.scss\\"],\\"names\\":[],\\"mappings\\":\\"AAGA;EACE;;;AAGF;EACE;EACA;;;AAGF;EACE\\",\\"sourcesContent\\":[\\"// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.\\\\n// See LICENSE in the project root for license information.\\\\n\\\\n.base_style {\\\\n color: red;\\\\n}\\\\n\\\\n.composedStyle {\\\\n composes: base_style;\\\\n color: blue;\\\\n}\\\\n\\\\n:export {\\\\n themeColor: purple;\\\\n}\\\\n\\"],\\"file\\":\\"composed.module.scss.css\\"}", + "/fake/output/css/composed.module.scss.js" => "module.exports = require(\\"./composed.module.scss.css\\");", +} +`; + +exports[`SassProcessor JS shim files named exports (commonjs) forwards named exports for export-only.module.scss without changing CSS or source maps: 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 JS shim files named exports (commonjs) forwards named exports for export-only.module.scss without changing CSS or source maps: written-files 1`] = ` +Map { + "/fake/output/dts/export-only.module.scss.d.ts" => "export const primaryColor: string; +export const fontFamily: string;", + "/fake/output/css/export-only.module.scss.css" => ":export { + primaryColor: #0078d4; + fontFamily: \\"Segoe UI\\"; +} +/*# sourceMappingURL=export-only.module.scss.css.map */ +", + "/fake/output/css/export-only.module.scss.css.map" => "{\\"version\\":3,\\"sourceRoot\\":\\"\\",\\"sources\\":[\\"fixtures/export-only.module.scss\\"],\\"names\\":[],\\"mappings\\":\\"AAEA;EACE;EACA\\",\\"sourcesContent\\":[\\"// A CSS module that only exports ICSS values - no class names.\\\\n// Used to verify that the :export block is preserved or stripped based on preserveIcssExports.\\\\n:export {\\\\n primaryColor: #0078d4;\\\\n fontFamily: 'Segoe UI';\\\\n}\\\\n\\"],\\"file\\":\\"export-only.module.scss.css\\"}", + "/fake/output/css/export-only.module.scss.js" => "module.exports = require(\\"./export-only.module.scss.css\\");", +} +`; + +exports[`SassProcessor JS shim files named exports (commonjs) forwards named exports for simple.module.sass without changing CSS or source maps: 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 JS shim files named exports (commonjs) forwards named exports for simple.module.sass without changing CSS or source maps: written-files 1`] = ` +Map { + "/fake/output/dts/simple.module.sass.d.ts" => "export const exampleClass: string; +export const exampleHeading: string;", + "/fake/output/css/simple.module.sass.css" => ".exampleClass { + color: red; + font-size: 14px; +} + +.exampleHeading { + font-weight: bold; + color: navy; +} +/*# sourceMappingURL=simple.module.sass.css.map */ +", + "/fake/output/css/simple.module.sass.css.map" => "{\\"version\\":3,\\"sourceRoot\\":\\"\\",\\"sources\\":[\\"fixtures/simple.module.sass\\"],\\"names\\":[],\\"mappings\\":\\"AAEA;EACE;EACA;;;AAEF;EACE;EACA\\",\\"sourcesContent\\":[\\"// Indented Sass syntax (whitespace-significant, no braces).\\\\n// Used to verify that .sass files are parsed with the indented syntax.\\\\n.exampleClass\\\\n color: red\\\\n font-size: 14px\\\\n\\\\n.exampleHeading\\\\n font-weight: bold\\\\n color: navy\\\\n\\"],\\"file\\":\\"simple.module.sass.css\\"}", + "/fake/output/css/simple.module.sass.js" => "module.exports = require(\\"./simple.module.sass.css\\");", +} +`; + +exports[`SassProcessor JS shim files named exports (commonjs) keeps empty.module.scss side-effect-only: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor JS shim files named exports (commonjs) keeps empty.module.scss side-effect-only: written-files 1`] = ` +Map { + "/fake/output/dts/empty.module.scss.d.ts" => "export {};", + "/fake/output/css/empty.module.css" => "", + "/fake/output/css/empty.module.scss.js" => "require(\\"./empty.module.css\\");", +} +`; + +exports[`SassProcessor JS shim files named exports (commonjs) keeps global-only.module.scss side-effect-only: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor JS shim files named exports (commonjs) keeps global-only.module.scss side-effect-only: written-files 1`] = ` +Map { + "/fake/output/dts/global-only.module.scss.d.ts" => "export {};", + "/fake/output/css/global-only.module.css" => ".ms-Nav-group { + overflow: hidden; +} +.ms-Nav-link { + height: 30px; +}", + "/fake/output/css/global-only.module.scss.js" => "require(\\"./global-only.module.css\\");", +} +`; + +exports[`SassProcessor JS shim files named exports (commonjs) keeps global-styles.global.scss side-effect-only: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor JS shim files named exports (commonjs) keeps global-styles.global.scss side-effect-only: 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 named exports (esnext) forwards named exports for classes-and-exports.module.scss without changing CSS or source maps: 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 JS shim files named exports (esnext) forwards named exports for classes-and-exports.module.scss without changing CSS or source maps: 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;", + "/fake/output/css/classes-and-exports.module.scss.css" => ".root { + color: red; + font-size: 14px; +} + +.highlighted { + background-color: yellow; +} + +:export { + themeColor: blue; + spacing: 8px; +} +/*# sourceMappingURL=classes-and-exports.module.scss.css.map */ +", + "/fake/output/css/classes-and-exports.module.scss.css.map" => "{\\"version\\":3,\\"sourceRoot\\":\\"\\",\\"sources\\":[\\"fixtures/classes-and-exports.module.scss\\"],\\"names\\":[],\\"mappings\\":\\"AACA;EACE;EACA;;;AAGF;EACE;;;AAGF;EACE;EACA\\",\\"sourcesContent\\":[\\"// A CSS module that exports both class names and ICSS :export values.\\\\n.root {\\\\n color: red;\\\\n font-size: 14px;\\\\n}\\\\n\\\\n.highlighted {\\\\n background-color: yellow;\\\\n}\\\\n\\\\n:export {\\\\n themeColor: blue;\\\\n spacing: 8px;\\\\n}\\\\n\\"],\\"file\\":\\"classes-and-exports.module.scss.css\\"}", + "/fake/output/css/classes-and-exports.module.scss.js" => "export * from \\"./classes-and-exports.module.scss.css\\";", +} +`; + +exports[`SassProcessor JS shim files named exports (esnext) forwards named exports for composed.module.scss without changing CSS or source maps: 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 JS shim files named exports (esnext) forwards named exports for composed.module.scss without changing CSS or source maps: written-files 1`] = ` +Map { + "/fake/output/dts/composed.module.scss.d.ts" => "export const themeColor: string; +export const base_style: string; +export const composedStyle: string;", + "/fake/output/css/composed.module.scss.css" => ".base_style { + color: red; +} + +.composedStyle { + composes: base_style; + color: blue; +} + +:export { + themeColor: purple; +} +/*# sourceMappingURL=composed.module.scss.css.map */ +", + "/fake/output/css/composed.module.scss.css.map" => "{\\"version\\":3,\\"sourceRoot\\":\\"\\",\\"sources\\":[\\"fixtures/composed.module.scss\\"],\\"names\\":[],\\"mappings\\":\\"AAGA;EACE;;;AAGF;EACE;EACA;;;AAGF;EACE\\",\\"sourcesContent\\":[\\"// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.\\\\n// See LICENSE in the project root for license information.\\\\n\\\\n.base_style {\\\\n color: red;\\\\n}\\\\n\\\\n.composedStyle {\\\\n composes: base_style;\\\\n color: blue;\\\\n}\\\\n\\\\n:export {\\\\n themeColor: purple;\\\\n}\\\\n\\"],\\"file\\":\\"composed.module.scss.css\\"}", + "/fake/output/css/composed.module.scss.js" => "export * from \\"./composed.module.scss.css\\";", +} +`; + +exports[`SassProcessor JS shim files named exports (esnext) forwards named exports for export-only.module.scss without changing CSS or source maps: 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 JS shim files named exports (esnext) forwards named exports for export-only.module.scss without changing CSS or source maps: written-files 1`] = ` +Map { + "/fake/output/dts/export-only.module.scss.d.ts" => "export const primaryColor: string; +export const fontFamily: string;", + "/fake/output/css/export-only.module.scss.css" => ":export { + primaryColor: #0078d4; + fontFamily: \\"Segoe UI\\"; +} +/*# sourceMappingURL=export-only.module.scss.css.map */ +", + "/fake/output/css/export-only.module.scss.css.map" => "{\\"version\\":3,\\"sourceRoot\\":\\"\\",\\"sources\\":[\\"fixtures/export-only.module.scss\\"],\\"names\\":[],\\"mappings\\":\\"AAEA;EACE;EACA\\",\\"sourcesContent\\":[\\"// A CSS module that only exports ICSS values - no class names.\\\\n// Used to verify that the :export block is preserved or stripped based on preserveIcssExports.\\\\n:export {\\\\n primaryColor: #0078d4;\\\\n fontFamily: 'Segoe UI';\\\\n}\\\\n\\"],\\"file\\":\\"export-only.module.scss.css\\"}", + "/fake/output/css/export-only.module.scss.js" => "export * from \\"./export-only.module.scss.css\\";", +} +`; + +exports[`SassProcessor JS shim files named exports (esnext) forwards named exports for simple.module.sass without changing CSS or source maps: 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 JS shim files named exports (esnext) forwards named exports for simple.module.sass without changing CSS or source maps: written-files 1`] = ` +Map { + "/fake/output/dts/simple.module.sass.d.ts" => "export const exampleClass: string; +export const exampleHeading: string;", + "/fake/output/css/simple.module.sass.css" => ".exampleClass { + color: red; + font-size: 14px; +} + +.exampleHeading { + font-weight: bold; + color: navy; +} +/*# sourceMappingURL=simple.module.sass.css.map */ +", + "/fake/output/css/simple.module.sass.css.map" => "{\\"version\\":3,\\"sourceRoot\\":\\"\\",\\"sources\\":[\\"fixtures/simple.module.sass\\"],\\"names\\":[],\\"mappings\\":\\"AAEA;EACE;EACA;;;AAEF;EACE;EACA\\",\\"sourcesContent\\":[\\"// Indented Sass syntax (whitespace-significant, no braces).\\\\n// Used to verify that .sass files are parsed with the indented syntax.\\\\n.exampleClass\\\\n color: red\\\\n font-size: 14px\\\\n\\\\n.exampleHeading\\\\n font-weight: bold\\\\n color: navy\\\\n\\"],\\"file\\":\\"simple.module.sass.css\\"}", + "/fake/output/css/simple.module.sass.js" => "export * from \\"./simple.module.sass.css\\";", +} +`; + +exports[`SassProcessor JS shim files named exports (esnext) keeps empty.module.scss side-effect-only: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor JS shim files named exports (esnext) keeps empty.module.scss side-effect-only: written-files 1`] = ` +Map { + "/fake/output/dts/empty.module.scss.d.ts" => "export {};", + "/fake/output/css/empty.module.css" => "", + "/fake/output/css/empty.module.scss.js" => "import \\"./empty.module.css\\";export {};", +} +`; + +exports[`SassProcessor JS shim files named exports (esnext) keeps global-only.module.scss side-effect-only: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor JS shim files named exports (esnext) keeps global-only.module.scss side-effect-only: written-files 1`] = ` +Map { + "/fake/output/dts/global-only.module.scss.d.ts" => "export {};", + "/fake/output/css/global-only.module.css" => ".ms-Nav-group { + overflow: hidden; +} +.ms-Nav-link { + height: 30px; +}", + "/fake/output/css/global-only.module.scss.js" => "import \\"./global-only.module.css\\";export {};", +} +`; + +exports[`SassProcessor JS shim files named exports (esnext) keeps global-styles.global.scss side-effect-only: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor JS shim files named exports (esnext) keeps global-styles.global.scss side-effect-only: 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]", @@ -313,6 +723,14 @@ Map { export const spacing: string; export const root: string; export const highlighted: string;", + "/fake/output/css/classes-and-exports.module.css" => ".root { + color: red; + font-size: 14px; +} + +.highlighted { + background-color: yellow; +}", } `; diff --git a/heft-plugins/heft-sass-plugin/src/test/fixtures/composed.module.scss b/heft-plugins/heft-sass-plugin/src/test/fixtures/composed.module.scss new file mode 100644 index 00000000000..4ed68b583f7 --- /dev/null +++ b/heft-plugins/heft-sass-plugin/src/test/fixtures/composed.module.scss @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +.base_style { + color: red; +} + +.composedStyle { + composes: base_style; + color: blue; +} + +:export { + themeColor: purple; +} diff --git a/heft-plugins/heft-sass-plugin/src/test/fixtures/empty.module.scss b/heft-plugins/heft-sass-plugin/src/test/fixtures/empty.module.scss new file mode 100644 index 00000000000..b2d2e4d1b84 --- /dev/null +++ b/heft-plugins/heft-sass-plugin/src/test/fixtures/empty.module.scss @@ -0,0 +1,2 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. From b5911f13f9eb91f819804073f3e4699146fc13ca Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Wed, 28 Oct 2026 17:49:56 -0700 Subject: [PATCH 2/6] Update common/changes/@rushstack/heft-sass-plugin/named-css-shims_2026-10-28-13-00.json --- .../heft-sass-plugin/named-css-shims_2026-10-28-13-00.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/changes/@rushstack/heft-sass-plugin/named-css-shims_2026-10-28-13-00.json b/common/changes/@rushstack/heft-sass-plugin/named-css-shims_2026-10-28-13-00.json index 2687364f7b7..dc74a62ebd9 100644 --- a/common/changes/@rushstack/heft-sass-plugin/named-css-shims_2026-10-28-13-00.json +++ b/common/changes/@rushstack/heft-sass-plugin/named-css-shims_2026-10-28-13-00.json @@ -2,7 +2,7 @@ "changes": [ { "packageName": "@rushstack/heft-sass-plugin", - "comment": "Support named exports when emitting CSS and CommonJS or ESM shims with exportAsDefault set to false.", + "comment": "Support named exports when emitting CSS and CommonJS or ESM shims with `exportAsDefault` set to false.", "type": "minor" } ], From 3f292413d14c71662f549777a6096a9475ce67ca Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Thu, 29 Oct 2026 16:27:46 -0700 Subject: [PATCH 3/6] [heft-sass-plugin] Support non-identifier named exports Generate quoted export aliases for hyphenated class names and ICSS exports instead of rejecting them. Avoid alias collisions with existing stylesheet exports such as id0. Cover composition, both shim formats and alias collisions, and document the TypeScript 5.6 requirement for quoted export names. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- heft-plugins/heft-sass-plugin/README.md | 11 ++- .../heft-sass-plugin/src/SassProcessor.ts | 17 +++-- .../src/test/SassProcessor.test.ts | 12 +++ .../__snapshots__/SassProcessor.test.ts.snap | 74 +++++++++++++++++-- .../src/test/fixtures/composed.module.scss | 11 ++- 5 files changed, 112 insertions(+), 13 deletions(-) diff --git a/heft-plugins/heft-sass-plugin/README.md b/heft-plugins/heft-sass-plugin/README.md index fc8d7ad650a..4e14b6c2279 100644 --- a/heft-plugins/heft-sass-plugin/README.md +++ b/heft-plugins/heft-sass-plugin/README.md @@ -209,7 +209,16 @@ it does not synthesize a default styles object or overwrite a loader-provided de The consuming bundler must configure its CSS loader for named exports (for example, css-loader with `esModule: true` and `modules.namedExport: true`). Export names must match the generated typings; use `modules.exportLocalsConvention: "as-is"` with css-loader versions that support it, or restrict names -to ones unchanged by the loader's convention. Names must also be valid identifiers for named typings. +to ones unchanged by the loader's convention. Non-identifier names such as `base-style` use quoted +export aliases in the typings (requiring TypeScript 5.6 or newer): + +```typescript +declare const id0: string; +export { id0 as "base-style" }; +``` + +Generated alias names avoid collisions with the stylesheet's own exports. The CSS loader must also +preserve these export names; consumers can access them with `styles["base-style"]`. Set `preserveIcssExports: true` when the downstream loader must process ICSS `:export` values or `composes` declarations. diff --git a/heft-plugins/heft-sass-plugin/src/SassProcessor.ts b/heft-plugins/heft-sass-plugin/src/SassProcessor.ts index 89455a665e3..06cdf057cf6 100644 --- a/heft-plugins/heft-sass-plugin/src/SassProcessor.ts +++ b/heft-plugins/heft-sass-plugin/src/SassProcessor.ts @@ -1094,14 +1094,21 @@ function createDTS( source.push(`declare const styles: IStyles;`); source.push(`export default styles;`); } else { + let identifierIndex: number = 0; for (const className of Object.keys(moduleMap)) { - if (!SIMPLE_IDENTIFIER_REGEX.test(className)) { - throw new Error( - `Class name "${className}" is not a valid identifier and may only be exported using "exportAsDefault: true"` + if (SIMPLE_IDENTIFIER_REGEX.test(className)) { + source.push(`export const ${className}: string;`); + } else { + let identifier: string; + do { + identifier = `id${identifierIndex++}`; + } while (Object.prototype.hasOwnProperty.call(moduleMap, identifier)); + + source.push( + `declare const ${identifier}: string;`, + `export { ${identifier} as ${JSON.stringify(className)} };` ); } - - source.push(`export const ${className}: string;`); } } diff --git a/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts b/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts index 56a448474f7..7ae471888ae 100644 --- a/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts +++ b/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts @@ -386,6 +386,18 @@ describe(SassProcessor.name, () => { }); describe('JS shim files', () => { + it('uses collision-free quoted export aliases for non-identifier class names and ICSS exports', async () => { + const { processor } = createProcessor(terminalProvider, { exportAsDefault: false }); + await compileFixtureAsync(processor, 'composed.module.scss'); + const dts: string = getDtsOutput('composed.module.scss'); + expect(dts).toContain('export const base_style: string;'); + expect(dts).toContain('export const id0: string;'); + expect(dts).toContain('declare const id1: string;\nexport { id1 as "theme-color" };'); + expect(dts).toContain('declare const id2: string;\nexport { id2 as "base-style" };'); + expect(dts).not.toContain('export default'); + expect(terminalProvider.getOutput({ normalizeSpecialCharacters: true })).not.toContain('ERROR'); + }); + describe.each(['commonjs', 'esnext'] as const)('named exports (%s)', (format: 'commonjs' | 'esnext') => { it.each([ 'classes-and-exports.module.scss', 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 index 5d0c6f770fa..9348a03a3bb 100644 --- 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 @@ -232,23 +232,37 @@ Array [ exports[`SassProcessor JS shim files named exports (commonjs) forwards named exports for composed.module.scss without changing CSS or source maps: written-files 1`] = ` Map { "/fake/output/dts/composed.module.scss.d.ts" => "export const themeColor: string; +declare const id1: string; +export { id1 as \\"theme-color\\" }; export const base_style: string; -export const composedStyle: string;", +declare const id2: string; +export { id2 as \\"base-style\\" }; +export const composedStyle: string; +export const id0: string;", "/fake/output/css/composed.module.scss.css" => ".base_style { color: red; } -.composedStyle { +.base-style { composes: base_style; +} + +.composedStyle { + composes: base-style; color: blue; } +.id0 { + color: green; +} + :export { themeColor: purple; + theme-color: purple; } /*# sourceMappingURL=composed.module.scss.css.map */ ", - "/fake/output/css/composed.module.scss.css.map" => "{\\"version\\":3,\\"sourceRoot\\":\\"\\",\\"sources\\":[\\"fixtures/composed.module.scss\\"],\\"names\\":[],\\"mappings\\":\\"AAGA;EACE;;;AAGF;EACE;EACA;;;AAGF;EACE\\",\\"sourcesContent\\":[\\"// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.\\\\n// See LICENSE in the project root for license information.\\\\n\\\\n.base_style {\\\\n color: red;\\\\n}\\\\n\\\\n.composedStyle {\\\\n composes: base_style;\\\\n color: blue;\\\\n}\\\\n\\\\n:export {\\\\n themeColor: purple;\\\\n}\\\\n\\"],\\"file\\":\\"composed.module.scss.css\\"}", + "/fake/output/css/composed.module.scss.css.map" => "{\\"version\\":3,\\"sourceRoot\\":\\"\\",\\"sources\\":[\\"fixtures/composed.module.scss\\"],\\"names\\":[],\\"mappings\\":\\"AAGA;EACE;;;AAGF;EACE;;;AAGF;EACE;EACA;;;AAGF;EACE;;;AAGF;EACE;EACA\\",\\"sourcesContent\\":[\\"// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.\\\\n// See LICENSE in the project root for license information.\\\\n\\\\n.base_style {\\\\n color: red;\\\\n}\\\\n\\\\n.base-style {\\\\n composes: base_style;\\\\n}\\\\n\\\\n.composedStyle {\\\\n composes: base-style;\\\\n color: blue;\\\\n}\\\\n\\\\n.id0 {\\\\n color: green;\\\\n}\\\\n\\\\n:export {\\\\n themeColor: purple;\\\\n theme-color: purple;\\\\n}\\\\n\\"],\\"file\\":\\"composed.module.scss.css\\"}", "/fake/output/css/composed.module.scss.js" => "module.exports = require(\\"./composed.module.scss.css\\");", } `; @@ -412,23 +426,37 @@ Array [ exports[`SassProcessor JS shim files named exports (esnext) forwards named exports for composed.module.scss without changing CSS or source maps: written-files 1`] = ` Map { "/fake/output/dts/composed.module.scss.d.ts" => "export const themeColor: string; +declare const id1: string; +export { id1 as \\"theme-color\\" }; export const base_style: string; -export const composedStyle: string;", +declare const id2: string; +export { id2 as \\"base-style\\" }; +export const composedStyle: string; +export const id0: string;", "/fake/output/css/composed.module.scss.css" => ".base_style { color: red; } -.composedStyle { +.base-style { composes: base_style; +} + +.composedStyle { + composes: base-style; color: blue; } +.id0 { + color: green; +} + :export { themeColor: purple; + theme-color: purple; } /*# sourceMappingURL=composed.module.scss.css.map */ ", - "/fake/output/css/composed.module.scss.css.map" => "{\\"version\\":3,\\"sourceRoot\\":\\"\\",\\"sources\\":[\\"fixtures/composed.module.scss\\"],\\"names\\":[],\\"mappings\\":\\"AAGA;EACE;;;AAGF;EACE;EACA;;;AAGF;EACE\\",\\"sourcesContent\\":[\\"// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.\\\\n// See LICENSE in the project root for license information.\\\\n\\\\n.base_style {\\\\n color: red;\\\\n}\\\\n\\\\n.composedStyle {\\\\n composes: base_style;\\\\n color: blue;\\\\n}\\\\n\\\\n:export {\\\\n themeColor: purple;\\\\n}\\\\n\\"],\\"file\\":\\"composed.module.scss.css\\"}", + "/fake/output/css/composed.module.scss.css.map" => "{\\"version\\":3,\\"sourceRoot\\":\\"\\",\\"sources\\":[\\"fixtures/composed.module.scss\\"],\\"names\\":[],\\"mappings\\":\\"AAGA;EACE;;;AAGF;EACE;;;AAGF;EACE;EACA;;;AAGF;EACE;;;AAGF;EACE;EACA\\",\\"sourcesContent\\":[\\"// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.\\\\n// See LICENSE in the project root for license information.\\\\n\\\\n.base_style {\\\\n color: red;\\\\n}\\\\n\\\\n.base-style {\\\\n composes: base_style;\\\\n}\\\\n\\\\n.composedStyle {\\\\n composes: base-style;\\\\n color: blue;\\\\n}\\\\n\\\\n.id0 {\\\\n color: green;\\\\n}\\\\n\\\\n:export {\\\\n themeColor: purple;\\\\n theme-color: purple;\\\\n}\\\\n\\"],\\"file\\":\\"composed.module.scss.css\\"}", "/fake/output/css/composed.module.scss.js" => "export * from \\"./composed.module.scss.css\\";", } `; @@ -545,6 +573,40 @@ h1 { } `; +exports[`SassProcessor JS shim files uses collision-free quoted export aliases for non-identifier class names and ICSS exports: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor JS shim files uses collision-free quoted export aliases for non-identifier class names and ICSS exports: written-files 1`] = ` +Map { + "/fake/output/dts/composed.module.scss.d.ts" => "export const themeColor: string; +declare const id1: string; +export { id1 as \\"theme-color\\" }; +export const base_style: string; +declare const id2: string; +export { id2 as \\"base-style\\" }; +export const composedStyle: string; +export const id0: string;", + "/fake/output/css/composed.module.css" => ".base_style { + color: red; +} + +.base-style { +} + +.composedStyle { + color: blue; +} + +.id0 { + color: green; +}", +} +`; + exports[`SassProcessor JS shim files writes shims to each configured cssOutputFolder independently: terminal-output 1`] = ` Array [ "[verbose] Checking for changes to 1 files...[n]", diff --git a/heft-plugins/heft-sass-plugin/src/test/fixtures/composed.module.scss b/heft-plugins/heft-sass-plugin/src/test/fixtures/composed.module.scss index 4ed68b583f7..ad2dc76bf73 100644 --- a/heft-plugins/heft-sass-plugin/src/test/fixtures/composed.module.scss +++ b/heft-plugins/heft-sass-plugin/src/test/fixtures/composed.module.scss @@ -5,11 +5,20 @@ color: red; } -.composedStyle { +.base-style { composes: base_style; +} + +.composedStyle { + composes: base-style; color: blue; } +.id0 { + color: green; +} + :export { themeColor: purple; + theme-color: purple; } From d867503d6353885d9fbb20b01ec350325f0ff971 Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Thu, 29 Oct 2026 17:07:33 -0700 Subject: [PATCH 4/6] [heft-sass-plugin] Use readable private CSS export aliases Camel-case kebab-case private declaration aliases while retaining the original quoted public export names. Report named-mode collisions explicitly rather than hiding mixed conventions behind numeric aliases. Reserve readable identifiers before allocating fallbacks for other characters, preserve default-mode compatibility, and verify that private aliases are absent from namespace imports. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- heft-plugins/heft-sass-plugin/README.md | 14 +- .../heft-sass-plugin/src/SassProcessor.ts | 34 ++- .../src/test/SassProcessor.test.ts | 40 +++- .../__snapshots__/SassProcessor.test.ts.snap | 196 +++++++++++++++--- .../alias-collision-reversed.module.scss | 10 + .../test/fixtures/alias-collision.module.scss | 10 + .../fixtures/alias-icss-collision.module.scss | 7 + .../fixtures/alias-pair-collision.module.scss | 10 + .../src/test/fixtures/composed.module.scss | 6 +- 9 files changed, 290 insertions(+), 37 deletions(-) create mode 100644 heft-plugins/heft-sass-plugin/src/test/fixtures/alias-collision-reversed.module.scss create mode 100644 heft-plugins/heft-sass-plugin/src/test/fixtures/alias-collision.module.scss create mode 100644 heft-plugins/heft-sass-plugin/src/test/fixtures/alias-icss-collision.module.scss create mode 100644 heft-plugins/heft-sass-plugin/src/test/fixtures/alias-pair-collision.module.scss diff --git a/heft-plugins/heft-sass-plugin/README.md b/heft-plugins/heft-sass-plugin/README.md index 4e14b6c2279..f7580e46350 100644 --- a/heft-plugins/heft-sass-plugin/README.md +++ b/heft-plugins/heft-sass-plugin/README.md @@ -213,12 +213,18 @@ to ones unchanged by the loader's convention. Non-identifier names such as `base export aliases in the typings (requiring TypeScript 5.6 or newer): ```typescript -declare const id0: string; -export { id0 as "base-style" }; +declare const baseStyle: string; +export { baseStyle as "base-style" }; ``` -Generated alias names avoid collisions with the stylesheet's own exports. The CSS loader must also -preserve these export names; consumers can access them with `styles["base-style"]`. +Kebab-case names use camelCase private aliases. Named declaration generation reports an error if +two exports require the same identifier, such as `base-style` and `baseStyle`. Default-export mode +still supports both names. Names that cannot be converted to identifiers use collision-free numeric +private aliases. + +The private alias does not change the public export name. With +`import * as styles from "./Button.module.scss"`, consumers access `styles["base-style"]`, not +`styles.baseStyle`. The CSS loader must also preserve the original export names. Set `preserveIcssExports: true` when the downstream loader must process ICSS `:export` values or `composes` declarations. diff --git a/heft-plugins/heft-sass-plugin/src/SassProcessor.ts b/heft-plugins/heft-sass-plugin/src/SassProcessor.ts index 06cdf057cf6..6a40517a993 100644 --- a/heft-plugins/heft-sass-plugin/src/SassProcessor.ts +++ b/heft-plugins/heft-sass-plugin/src/SassProcessor.ts @@ -1094,16 +1094,40 @@ function createDTS( source.push(`declare const styles: IStyles;`); source.push(`export default styles;`); } else { + const identifierByClassName: Map = new Map(); + const classNameByIdentifier: Map = new Map(); + for (const className of Object.keys(moduleMap)) { + const identifier: string = className.replace( + /-+([a-zA-Z0-9_$])/g, + (match: string, character: string): string => character.toUpperCase() + ); + if (SIMPLE_IDENTIFIER_REGEX.test(identifier)) { + const existingClassName: string | undefined = classNameByIdentifier.get(identifier); + if (existingClassName !== undefined) { + throw new Error( + `CSS exports ${JSON.stringify(existingClassName)} and ${JSON.stringify(className)} both map to declaration identifier "${identifier}". Rename one of them or use "exportAsDefault: true".` + ); + } + + identifierByClassName.set(className, identifier); + classNameByIdentifier.set(identifier, className); + } + } + let identifierIndex: number = 0; for (const className of Object.keys(moduleMap)) { - if (SIMPLE_IDENTIFIER_REGEX.test(className)) { - source.push(`export const ${className}: string;`); - } else { - let identifier: string; + let identifier: string | undefined = identifierByClassName.get(className); + if (!identifier) { do { identifier = `id${identifierIndex++}`; - } while (Object.prototype.hasOwnProperty.call(moduleMap, identifier)); + } while (classNameByIdentifier.has(identifier)); + + classNameByIdentifier.set(identifier, className); + } + if (identifier === className) { + source.push(`export const ${className}: string;`); + } else { source.push( `declare const ${identifier}: string;`, `export { ${identifier} as ${JSON.stringify(className)} };` diff --git a/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts b/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts index 7ae471888ae..6d6b2e40bfe 100644 --- a/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts +++ b/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts @@ -386,18 +386,52 @@ describe(SassProcessor.name, () => { }); describe('JS shim files', () => { - it('uses collision-free quoted export aliases for non-identifier class names and ICSS exports', async () => { + it('uses camelCase private aliases for kebab-case exports and numeric fallbacks for other names', async () => { const { processor } = createProcessor(terminalProvider, { exportAsDefault: false }); await compileFixtureAsync(processor, 'composed.module.scss'); const dts: string = getDtsOutput('composed.module.scss'); expect(dts).toContain('export const base_style: string;'); expect(dts).toContain('export const id0: string;'); - expect(dts).toContain('declare const id1: string;\nexport { id1 as "theme-color" };'); - expect(dts).toContain('declare const id2: string;\nexport { id2 as "base-style" };'); + expect(dts).toContain('declare const themeColor: string;\nexport { themeColor as "theme-color" };'); + expect(dts).toContain('declare const baseStyle: string;\nexport { baseStyle as "base-style" };'); + expect(dts).toContain('declare const id1: string;\nexport { id1 as "unusual:name" };'); + expect(dts).not.toContain('export const baseStyle'); + expect(dts).not.toContain('export const themeColor'); expect(dts).not.toContain('export default'); expect(terminalProvider.getOutput({ normalizeSpecialCharacters: true })).not.toContain('ERROR'); }); + describe.each([ + ['alias-collision.module.scss', 'base-style', 'baseStyle', 'baseStyle'], + ['alias-collision-reversed.module.scss', 'baseStyle', 'base-style', 'baseStyle'], + ['alias-pair-collision.module.scss', 'base-style', 'base--style', 'baseStyle'], + ['alias-icss-collision.module.scss', 'theme-color', 'themeColor', 'themeColor'] + ])( + 'alias collision in %s', + (fixtureFilename: string, firstName: string, secondName: string, identifier: string) => { + it('reports both exports and the conflicting declaration identifier in named mode', async () => { + const { processor, logger } = createProcessor(terminalProvider, { exportAsDefault: false }); + await compileFixtureAsync(processor, fixtureFilename); + expect(logger.errors).toHaveLength(1); + const [error] = logger.errors; + expect(error.message).toContain(JSON.stringify(firstName)); + expect(error.message).toContain(JSON.stringify(secondName)); + expect(error.message).toContain(`declaration identifier "${identifier}"`); + expect(writtenFiles.size).toBe(0); + }); + + it('preserves both exports in default mode', async () => { + const { processor, logger } = createProcessor(terminalProvider); + await compileFixtureAsync(processor, fixtureFilename); + expect(logger.errors).toHaveLength(0); + const dts: string = getDtsOutput(fixtureFilename); + expect(dts).toContain(firstName); + expect(dts).toContain(secondName); + expect(dts).toContain('export default styles;'); + }); + } + ); + describe.each(['commonjs', 'esnext'] as const)('named exports (%s)', (format: 'commonjs' | 'esnext') => { it.each([ 'classes-and-exports.module.scss', 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 index 9348a03a3bb..47c8a64c22c 100644 --- 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 @@ -1,5 +1,135 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing +exports[`SassProcessor JS shim files alias collision in alias-collision.module.scss preserves both exports in default mode: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor JS shim files alias collision in alias-collision.module.scss preserves both exports in default mode: written-files 1`] = ` +Map { + "/fake/output/dts/alias-collision.module.scss.d.ts" => "declare interface IStyles { + \\"base-style\\": string; + baseStyle: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/alias-collision.module.css" => ".base-style { + color: red; +} + +.baseStyle { + color: blue; +}", +} +`; + +exports[`SassProcessor JS shim files alias collision in alias-collision.module.scss reports both exports and the conflicting declaration identifier in named mode: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor JS shim files alias collision in alias-collision.module.scss reports both exports and the conflicting declaration identifier in named mode: written-files 1`] = `Map {}`; + +exports[`SassProcessor JS shim files alias collision in alias-collision-reversed.module.scss preserves both exports in default mode: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor JS shim files alias collision in alias-collision-reversed.module.scss preserves both exports in default mode: written-files 1`] = ` +Map { + "/fake/output/dts/alias-collision-reversed.module.scss.d.ts" => "declare interface IStyles { + baseStyle: string; + \\"base-style\\": string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/alias-collision-reversed.module.css" => ".baseStyle { + color: blue; +} + +.base-style { + color: red; +}", +} +`; + +exports[`SassProcessor JS shim files alias collision in alias-collision-reversed.module.scss reports both exports and the conflicting declaration identifier in named mode: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor JS shim files alias collision in alias-collision-reversed.module.scss reports both exports and the conflicting declaration identifier in named mode: written-files 1`] = `Map {}`; + +exports[`SassProcessor JS shim files alias collision in alias-icss-collision.module.scss preserves both exports in default mode: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor JS shim files alias collision in alias-icss-collision.module.scss preserves both exports in default mode: written-files 1`] = ` +Map { + "/fake/output/dts/alias-icss-collision.module.scss.d.ts" => "declare interface IStyles { + \\"theme-color\\": string; + themeColor: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/alias-icss-collision.module.css" => "", +} +`; + +exports[`SassProcessor JS shim files alias collision in alias-icss-collision.module.scss reports both exports and the conflicting declaration identifier in named mode: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor JS shim files alias collision in alias-icss-collision.module.scss reports both exports and the conflicting declaration identifier in named mode: written-files 1`] = `Map {}`; + +exports[`SassProcessor JS shim files alias collision in alias-pair-collision.module.scss preserves both exports in default mode: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor JS shim files alias collision in alias-pair-collision.module.scss preserves both exports in default mode: written-files 1`] = ` +Map { + "/fake/output/dts/alias-pair-collision.module.scss.d.ts" => "declare interface IStyles { + \\"base-style\\": string; + \\"base--style\\": string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/alias-pair-collision.module.css" => ".base-style { + color: red; +} + +.base--style { + color: blue; +}", +} +`; + +exports[`SassProcessor JS shim files alias collision in alias-pair-collision.module.scss reports both exports and the conflicting declaration identifier in named mode: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor JS shim files alias collision in alias-pair-collision.module.scss reports both exports and the conflicting declaration identifier in named mode: written-files 1`] = `Map {}`; + 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]", @@ -231,14 +361,16 @@ Array [ exports[`SassProcessor JS shim files named exports (commonjs) forwards named exports for composed.module.scss without changing CSS or source maps: written-files 1`] = ` Map { - "/fake/output/dts/composed.module.scss.d.ts" => "export const themeColor: string; -declare const id1: string; -export { id1 as \\"theme-color\\" }; + "/fake/output/dts/composed.module.scss.d.ts" => "export const theme: string; +declare const themeColor: string; +export { themeColor as \\"theme-color\\" }; export const base_style: string; -declare const id2: string; -export { id2 as \\"base-style\\" }; +declare const baseStyle: string; +export { baseStyle as \\"base-style\\" }; export const composedStyle: string; -export const id0: string;", +export const id0: string; +declare const id1: string; +export { id1 as \\"unusual:name\\" };", "/fake/output/css/composed.module.scss.css" => ".base_style { color: red; } @@ -256,13 +388,17 @@ export const id0: string;", color: green; } +.unusual\\\\:name { + color: purple; +} + :export { - themeColor: purple; + theme: purple; theme-color: purple; } /*# sourceMappingURL=composed.module.scss.css.map */ ", - "/fake/output/css/composed.module.scss.css.map" => "{\\"version\\":3,\\"sourceRoot\\":\\"\\",\\"sources\\":[\\"fixtures/composed.module.scss\\"],\\"names\\":[],\\"mappings\\":\\"AAGA;EACE;;;AAGF;EACE;;;AAGF;EACE;EACA;;;AAGF;EACE;;;AAGF;EACE;EACA\\",\\"sourcesContent\\":[\\"// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.\\\\n// See LICENSE in the project root for license information.\\\\n\\\\n.base_style {\\\\n color: red;\\\\n}\\\\n\\\\n.base-style {\\\\n composes: base_style;\\\\n}\\\\n\\\\n.composedStyle {\\\\n composes: base-style;\\\\n color: blue;\\\\n}\\\\n\\\\n.id0 {\\\\n color: green;\\\\n}\\\\n\\\\n:export {\\\\n themeColor: purple;\\\\n theme-color: purple;\\\\n}\\\\n\\"],\\"file\\":\\"composed.module.scss.css\\"}", + "/fake/output/css/composed.module.scss.css.map" => "{\\"version\\":3,\\"sourceRoot\\":\\"\\",\\"sources\\":[\\"fixtures/composed.module.scss\\"],\\"names\\":[],\\"mappings\\":\\"AAGA;EACE;;;AAGF;EACE;;;AAGF;EACE;EACA;;;AAGF;EACE;;;AAGF;EACE;;;AAGF;EACE;EACA\\",\\"sourcesContent\\":[\\"// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.\\\\n// See LICENSE in the project root for license information.\\\\n\\\\n.base_style {\\\\n color: red;\\\\n}\\\\n\\\\n.base-style {\\\\n composes: base_style;\\\\n}\\\\n\\\\n.composedStyle {\\\\n composes: base-style;\\\\n color: blue;\\\\n}\\\\n\\\\n.id0 {\\\\n color: green;\\\\n}\\\\n\\\\n.unusual\\\\\\\\:name {\\\\n color: purple;\\\\n}\\\\n\\\\n:export {\\\\n theme: purple;\\\\n theme-color: purple;\\\\n}\\\\n\\"],\\"file\\":\\"composed.module.scss.css\\"}", "/fake/output/css/composed.module.scss.js" => "module.exports = require(\\"./composed.module.scss.css\\");", } `; @@ -425,14 +561,16 @@ Array [ exports[`SassProcessor JS shim files named exports (esnext) forwards named exports for composed.module.scss without changing CSS or source maps: written-files 1`] = ` Map { - "/fake/output/dts/composed.module.scss.d.ts" => "export const themeColor: string; -declare const id1: string; -export { id1 as \\"theme-color\\" }; + "/fake/output/dts/composed.module.scss.d.ts" => "export const theme: string; +declare const themeColor: string; +export { themeColor as \\"theme-color\\" }; export const base_style: string; -declare const id2: string; -export { id2 as \\"base-style\\" }; +declare const baseStyle: string; +export { baseStyle as \\"base-style\\" }; export const composedStyle: string; -export const id0: string;", +export const id0: string; +declare const id1: string; +export { id1 as \\"unusual:name\\" };", "/fake/output/css/composed.module.scss.css" => ".base_style { color: red; } @@ -450,13 +588,17 @@ export const id0: string;", color: green; } +.unusual\\\\:name { + color: purple; +} + :export { - themeColor: purple; + theme: purple; theme-color: purple; } /*# sourceMappingURL=composed.module.scss.css.map */ ", - "/fake/output/css/composed.module.scss.css.map" => "{\\"version\\":3,\\"sourceRoot\\":\\"\\",\\"sources\\":[\\"fixtures/composed.module.scss\\"],\\"names\\":[],\\"mappings\\":\\"AAGA;EACE;;;AAGF;EACE;;;AAGF;EACE;EACA;;;AAGF;EACE;;;AAGF;EACE;EACA\\",\\"sourcesContent\\":[\\"// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.\\\\n// See LICENSE in the project root for license information.\\\\n\\\\n.base_style {\\\\n color: red;\\\\n}\\\\n\\\\n.base-style {\\\\n composes: base_style;\\\\n}\\\\n\\\\n.composedStyle {\\\\n composes: base-style;\\\\n color: blue;\\\\n}\\\\n\\\\n.id0 {\\\\n color: green;\\\\n}\\\\n\\\\n:export {\\\\n themeColor: purple;\\\\n theme-color: purple;\\\\n}\\\\n\\"],\\"file\\":\\"composed.module.scss.css\\"}", + "/fake/output/css/composed.module.scss.css.map" => "{\\"version\\":3,\\"sourceRoot\\":\\"\\",\\"sources\\":[\\"fixtures/composed.module.scss\\"],\\"names\\":[],\\"mappings\\":\\"AAGA;EACE;;;AAGF;EACE;;;AAGF;EACE;EACA;;;AAGF;EACE;;;AAGF;EACE;;;AAGF;EACE;EACA\\",\\"sourcesContent\\":[\\"// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.\\\\n// See LICENSE in the project root for license information.\\\\n\\\\n.base_style {\\\\n color: red;\\\\n}\\\\n\\\\n.base-style {\\\\n composes: base_style;\\\\n}\\\\n\\\\n.composedStyle {\\\\n composes: base-style;\\\\n color: blue;\\\\n}\\\\n\\\\n.id0 {\\\\n color: green;\\\\n}\\\\n\\\\n.unusual\\\\\\\\:name {\\\\n color: purple;\\\\n}\\\\n\\\\n:export {\\\\n theme: purple;\\\\n theme-color: purple;\\\\n}\\\\n\\"],\\"file\\":\\"composed.module.scss.css\\"}", "/fake/output/css/composed.module.scss.js" => "export * from \\"./composed.module.scss.css\\";", } `; @@ -573,23 +715,25 @@ h1 { } `; -exports[`SassProcessor JS shim files uses collision-free quoted export aliases for non-identifier class names and ICSS exports: terminal-output 1`] = ` +exports[`SassProcessor JS shim files uses camelCase private aliases for kebab-case exports and numeric fallbacks for other names: terminal-output 1`] = ` Array [ "[verbose] Checking for changes to 1 files...[n]", "[ log] Compiling 1 files...[n]", ] `; -exports[`SassProcessor JS shim files uses collision-free quoted export aliases for non-identifier class names and ICSS exports: written-files 1`] = ` +exports[`SassProcessor JS shim files uses camelCase private aliases for kebab-case exports and numeric fallbacks for other names: written-files 1`] = ` Map { - "/fake/output/dts/composed.module.scss.d.ts" => "export const themeColor: string; -declare const id1: string; -export { id1 as \\"theme-color\\" }; + "/fake/output/dts/composed.module.scss.d.ts" => "export const theme: string; +declare const themeColor: string; +export { themeColor as \\"theme-color\\" }; export const base_style: string; -declare const id2: string; -export { id2 as \\"base-style\\" }; +declare const baseStyle: string; +export { baseStyle as \\"base-style\\" }; export const composedStyle: string; -export const id0: string;", +export const id0: string; +declare const id1: string; +export { id1 as \\"unusual:name\\" };", "/fake/output/css/composed.module.css" => ".base_style { color: red; } @@ -603,6 +747,10 @@ export const id0: string;", .id0 { color: green; +} + +.unusual\\\\:name { + color: purple; }", } `; diff --git a/heft-plugins/heft-sass-plugin/src/test/fixtures/alias-collision-reversed.module.scss b/heft-plugins/heft-sass-plugin/src/test/fixtures/alias-collision-reversed.module.scss new file mode 100644 index 00000000000..a2dc5435d35 --- /dev/null +++ b/heft-plugins/heft-sass-plugin/src/test/fixtures/alias-collision-reversed.module.scss @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +.baseStyle { + color: blue; +} + +.base-style { + color: red; +} diff --git a/heft-plugins/heft-sass-plugin/src/test/fixtures/alias-collision.module.scss b/heft-plugins/heft-sass-plugin/src/test/fixtures/alias-collision.module.scss new file mode 100644 index 00000000000..8c47979a678 --- /dev/null +++ b/heft-plugins/heft-sass-plugin/src/test/fixtures/alias-collision.module.scss @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +.base-style { + color: red; +} + +.baseStyle { + color: blue; +} diff --git a/heft-plugins/heft-sass-plugin/src/test/fixtures/alias-icss-collision.module.scss b/heft-plugins/heft-sass-plugin/src/test/fixtures/alias-icss-collision.module.scss new file mode 100644 index 00000000000..abdbb51a0d3 --- /dev/null +++ b/heft-plugins/heft-sass-plugin/src/test/fixtures/alias-icss-collision.module.scss @@ -0,0 +1,7 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +:export { + theme-color: red; + themeColor: blue; +} diff --git a/heft-plugins/heft-sass-plugin/src/test/fixtures/alias-pair-collision.module.scss b/heft-plugins/heft-sass-plugin/src/test/fixtures/alias-pair-collision.module.scss new file mode 100644 index 00000000000..0ccdef0a24a --- /dev/null +++ b/heft-plugins/heft-sass-plugin/src/test/fixtures/alias-pair-collision.module.scss @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +.base-style { + color: red; +} + +.base--style { + color: blue; +} diff --git a/heft-plugins/heft-sass-plugin/src/test/fixtures/composed.module.scss b/heft-plugins/heft-sass-plugin/src/test/fixtures/composed.module.scss index ad2dc76bf73..8bde3aab6d9 100644 --- a/heft-plugins/heft-sass-plugin/src/test/fixtures/composed.module.scss +++ b/heft-plugins/heft-sass-plugin/src/test/fixtures/composed.module.scss @@ -18,7 +18,11 @@ color: green; } +.unusual\:name { + color: purple; +} + :export { - themeColor: purple; + theme: purple; theme-color: purple; } From 1e3641576c7e57ce5bf6df9c12abb8521e22c406 Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Thu, 29 Oct 2026 17:11:27 -0700 Subject: [PATCH 5/6] [heft-sass-plugin] Retain private numeric export aliases Private declaration binding names are not part of the consumer API. Restore collision-free numeric aliases instead of imposing unnecessary camelCase collision errors on otherwise valid stylesheets. Document that consumers access the original quoted export names, and verify that private aliases are absent from both namespace typings and runtime exports. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- heft-plugins/heft-sass-plugin/README.md | 16 +- .../heft-sass-plugin/src/SassProcessor.ts | 34 +-- .../src/test/SassProcessor.test.ts | 40 +--- .../__snapshots__/SassProcessor.test.ts.snap | 196 +++--------------- .../alias-collision-reversed.module.scss | 10 - .../test/fixtures/alias-collision.module.scss | 10 - .../fixtures/alias-icss-collision.module.scss | 7 - .../fixtures/alias-pair-collision.module.scss | 10 - .../src/test/fixtures/composed.module.scss | 6 +- 9 files changed, 39 insertions(+), 290 deletions(-) delete mode 100644 heft-plugins/heft-sass-plugin/src/test/fixtures/alias-collision-reversed.module.scss delete mode 100644 heft-plugins/heft-sass-plugin/src/test/fixtures/alias-collision.module.scss delete mode 100644 heft-plugins/heft-sass-plugin/src/test/fixtures/alias-icss-collision.module.scss delete mode 100644 heft-plugins/heft-sass-plugin/src/test/fixtures/alias-pair-collision.module.scss diff --git a/heft-plugins/heft-sass-plugin/README.md b/heft-plugins/heft-sass-plugin/README.md index f7580e46350..2ea4a6ee5b5 100644 --- a/heft-plugins/heft-sass-plugin/README.md +++ b/heft-plugins/heft-sass-plugin/README.md @@ -213,18 +213,14 @@ to ones unchanged by the loader's convention. Non-identifier names such as `base export aliases in the typings (requiring TypeScript 5.6 or newer): ```typescript -declare const baseStyle: string; -export { baseStyle as "base-style" }; +declare const id0: string; +export { id0 as "base-style" }; ``` -Kebab-case names use camelCase private aliases. Named declaration generation reports an error if -two exports require the same identifier, such as `base-style` and `baseStyle`. Default-export mode -still supports both names. Names that cannot be converted to identifiers use collision-free numeric -private aliases. - -The private alias does not change the public export name. With -`import * as styles from "./Button.module.scss"`, consumers access `styles["base-style"]`, not -`styles.baseStyle`. The CSS loader must also preserve the original export names. +Generated alias names avoid collisions with the stylesheet's own exports. The CSS loader must also +preserve these export names; consumers can access them with `styles["base-style"]`. +Aliases such as `id0` are private bindings inside the declaration file, not additional public exports +or runtime variables. `styles.id0` is available only if the stylesheet itself exports a name `id0`. Set `preserveIcssExports: true` when the downstream loader must process ICSS `:export` values or `composes` declarations. diff --git a/heft-plugins/heft-sass-plugin/src/SassProcessor.ts b/heft-plugins/heft-sass-plugin/src/SassProcessor.ts index 6a40517a993..06cdf057cf6 100644 --- a/heft-plugins/heft-sass-plugin/src/SassProcessor.ts +++ b/heft-plugins/heft-sass-plugin/src/SassProcessor.ts @@ -1094,40 +1094,16 @@ function createDTS( source.push(`declare const styles: IStyles;`); source.push(`export default styles;`); } else { - const identifierByClassName: Map = new Map(); - const classNameByIdentifier: Map = new Map(); - for (const className of Object.keys(moduleMap)) { - const identifier: string = className.replace( - /-+([a-zA-Z0-9_$])/g, - (match: string, character: string): string => character.toUpperCase() - ); - if (SIMPLE_IDENTIFIER_REGEX.test(identifier)) { - const existingClassName: string | undefined = classNameByIdentifier.get(identifier); - if (existingClassName !== undefined) { - throw new Error( - `CSS exports ${JSON.stringify(existingClassName)} and ${JSON.stringify(className)} both map to declaration identifier "${identifier}". Rename one of them or use "exportAsDefault: true".` - ); - } - - identifierByClassName.set(className, identifier); - classNameByIdentifier.set(identifier, className); - } - } - let identifierIndex: number = 0; for (const className of Object.keys(moduleMap)) { - let identifier: string | undefined = identifierByClassName.get(className); - if (!identifier) { + if (SIMPLE_IDENTIFIER_REGEX.test(className)) { + source.push(`export const ${className}: string;`); + } else { + let identifier: string; do { identifier = `id${identifierIndex++}`; - } while (classNameByIdentifier.has(identifier)); - - classNameByIdentifier.set(identifier, className); - } + } while (Object.prototype.hasOwnProperty.call(moduleMap, identifier)); - if (identifier === className) { - source.push(`export const ${className}: string;`); - } else { source.push( `declare const ${identifier}: string;`, `export { ${identifier} as ${JSON.stringify(className)} };` diff --git a/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts b/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts index 6d6b2e40bfe..7ae471888ae 100644 --- a/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts +++ b/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts @@ -386,52 +386,18 @@ describe(SassProcessor.name, () => { }); describe('JS shim files', () => { - it('uses camelCase private aliases for kebab-case exports and numeric fallbacks for other names', async () => { + it('uses collision-free quoted export aliases for non-identifier class names and ICSS exports', async () => { const { processor } = createProcessor(terminalProvider, { exportAsDefault: false }); await compileFixtureAsync(processor, 'composed.module.scss'); const dts: string = getDtsOutput('composed.module.scss'); expect(dts).toContain('export const base_style: string;'); expect(dts).toContain('export const id0: string;'); - expect(dts).toContain('declare const themeColor: string;\nexport { themeColor as "theme-color" };'); - expect(dts).toContain('declare const baseStyle: string;\nexport { baseStyle as "base-style" };'); - expect(dts).toContain('declare const id1: string;\nexport { id1 as "unusual:name" };'); - expect(dts).not.toContain('export const baseStyle'); - expect(dts).not.toContain('export const themeColor'); + expect(dts).toContain('declare const id1: string;\nexport { id1 as "theme-color" };'); + expect(dts).toContain('declare const id2: string;\nexport { id2 as "base-style" };'); expect(dts).not.toContain('export default'); expect(terminalProvider.getOutput({ normalizeSpecialCharacters: true })).not.toContain('ERROR'); }); - describe.each([ - ['alias-collision.module.scss', 'base-style', 'baseStyle', 'baseStyle'], - ['alias-collision-reversed.module.scss', 'baseStyle', 'base-style', 'baseStyle'], - ['alias-pair-collision.module.scss', 'base-style', 'base--style', 'baseStyle'], - ['alias-icss-collision.module.scss', 'theme-color', 'themeColor', 'themeColor'] - ])( - 'alias collision in %s', - (fixtureFilename: string, firstName: string, secondName: string, identifier: string) => { - it('reports both exports and the conflicting declaration identifier in named mode', async () => { - const { processor, logger } = createProcessor(terminalProvider, { exportAsDefault: false }); - await compileFixtureAsync(processor, fixtureFilename); - expect(logger.errors).toHaveLength(1); - const [error] = logger.errors; - expect(error.message).toContain(JSON.stringify(firstName)); - expect(error.message).toContain(JSON.stringify(secondName)); - expect(error.message).toContain(`declaration identifier "${identifier}"`); - expect(writtenFiles.size).toBe(0); - }); - - it('preserves both exports in default mode', async () => { - const { processor, logger } = createProcessor(terminalProvider); - await compileFixtureAsync(processor, fixtureFilename); - expect(logger.errors).toHaveLength(0); - const dts: string = getDtsOutput(fixtureFilename); - expect(dts).toContain(firstName); - expect(dts).toContain(secondName); - expect(dts).toContain('export default styles;'); - }); - } - ); - describe.each(['commonjs', 'esnext'] as const)('named exports (%s)', (format: 'commonjs' | 'esnext') => { it.each([ 'classes-and-exports.module.scss', 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 index 47c8a64c22c..9348a03a3bb 100644 --- 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 @@ -1,135 +1,5 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing -exports[`SassProcessor JS shim files alias collision in alias-collision.module.scss preserves both exports in default mode: terminal-output 1`] = ` -Array [ - "[verbose] Checking for changes to 1 files...[n]", - "[ log] Compiling 1 files...[n]", -] -`; - -exports[`SassProcessor JS shim files alias collision in alias-collision.module.scss preserves both exports in default mode: written-files 1`] = ` -Map { - "/fake/output/dts/alias-collision.module.scss.d.ts" => "declare interface IStyles { - \\"base-style\\": string; - baseStyle: string; -} -declare const styles: IStyles; -export default styles;", - "/fake/output/css/alias-collision.module.css" => ".base-style { - color: red; -} - -.baseStyle { - color: blue; -}", -} -`; - -exports[`SassProcessor JS shim files alias collision in alias-collision.module.scss reports both exports and the conflicting declaration identifier in named mode: terminal-output 1`] = ` -Array [ - "[verbose] Checking for changes to 1 files...[n]", - "[ log] Compiling 1 files...[n]", -] -`; - -exports[`SassProcessor JS shim files alias collision in alias-collision.module.scss reports both exports and the conflicting declaration identifier in named mode: written-files 1`] = `Map {}`; - -exports[`SassProcessor JS shim files alias collision in alias-collision-reversed.module.scss preserves both exports in default mode: terminal-output 1`] = ` -Array [ - "[verbose] Checking for changes to 1 files...[n]", - "[ log] Compiling 1 files...[n]", -] -`; - -exports[`SassProcessor JS shim files alias collision in alias-collision-reversed.module.scss preserves both exports in default mode: written-files 1`] = ` -Map { - "/fake/output/dts/alias-collision-reversed.module.scss.d.ts" => "declare interface IStyles { - baseStyle: string; - \\"base-style\\": string; -} -declare const styles: IStyles; -export default styles;", - "/fake/output/css/alias-collision-reversed.module.css" => ".baseStyle { - color: blue; -} - -.base-style { - color: red; -}", -} -`; - -exports[`SassProcessor JS shim files alias collision in alias-collision-reversed.module.scss reports both exports and the conflicting declaration identifier in named mode: terminal-output 1`] = ` -Array [ - "[verbose] Checking for changes to 1 files...[n]", - "[ log] Compiling 1 files...[n]", -] -`; - -exports[`SassProcessor JS shim files alias collision in alias-collision-reversed.module.scss reports both exports and the conflicting declaration identifier in named mode: written-files 1`] = `Map {}`; - -exports[`SassProcessor JS shim files alias collision in alias-icss-collision.module.scss preserves both exports in default mode: terminal-output 1`] = ` -Array [ - "[verbose] Checking for changes to 1 files...[n]", - "[ log] Compiling 1 files...[n]", -] -`; - -exports[`SassProcessor JS shim files alias collision in alias-icss-collision.module.scss preserves both exports in default mode: written-files 1`] = ` -Map { - "/fake/output/dts/alias-icss-collision.module.scss.d.ts" => "declare interface IStyles { - \\"theme-color\\": string; - themeColor: string; -} -declare const styles: IStyles; -export default styles;", - "/fake/output/css/alias-icss-collision.module.css" => "", -} -`; - -exports[`SassProcessor JS shim files alias collision in alias-icss-collision.module.scss reports both exports and the conflicting declaration identifier in named mode: terminal-output 1`] = ` -Array [ - "[verbose] Checking for changes to 1 files...[n]", - "[ log] Compiling 1 files...[n]", -] -`; - -exports[`SassProcessor JS shim files alias collision in alias-icss-collision.module.scss reports both exports and the conflicting declaration identifier in named mode: written-files 1`] = `Map {}`; - -exports[`SassProcessor JS shim files alias collision in alias-pair-collision.module.scss preserves both exports in default mode: terminal-output 1`] = ` -Array [ - "[verbose] Checking for changes to 1 files...[n]", - "[ log] Compiling 1 files...[n]", -] -`; - -exports[`SassProcessor JS shim files alias collision in alias-pair-collision.module.scss preserves both exports in default mode: written-files 1`] = ` -Map { - "/fake/output/dts/alias-pair-collision.module.scss.d.ts" => "declare interface IStyles { - \\"base-style\\": string; - \\"base--style\\": string; -} -declare const styles: IStyles; -export default styles;", - "/fake/output/css/alias-pair-collision.module.css" => ".base-style { - color: red; -} - -.base--style { - color: blue; -}", -} -`; - -exports[`SassProcessor JS shim files alias collision in alias-pair-collision.module.scss reports both exports and the conflicting declaration identifier in named mode: terminal-output 1`] = ` -Array [ - "[verbose] Checking for changes to 1 files...[n]", - "[ log] Compiling 1 files...[n]", -] -`; - -exports[`SassProcessor JS shim files alias collision in alias-pair-collision.module.scss reports both exports and the conflicting declaration identifier in named mode: written-files 1`] = `Map {}`; - 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]", @@ -361,16 +231,14 @@ Array [ exports[`SassProcessor JS shim files named exports (commonjs) forwards named exports for composed.module.scss without changing CSS or source maps: written-files 1`] = ` Map { - "/fake/output/dts/composed.module.scss.d.ts" => "export const theme: string; -declare const themeColor: string; -export { themeColor as \\"theme-color\\" }; + "/fake/output/dts/composed.module.scss.d.ts" => "export const themeColor: string; +declare const id1: string; +export { id1 as \\"theme-color\\" }; export const base_style: string; -declare const baseStyle: string; -export { baseStyle as \\"base-style\\" }; +declare const id2: string; +export { id2 as \\"base-style\\" }; export const composedStyle: string; -export const id0: string; -declare const id1: string; -export { id1 as \\"unusual:name\\" };", +export const id0: string;", "/fake/output/css/composed.module.scss.css" => ".base_style { color: red; } @@ -388,17 +256,13 @@ export { id1 as \\"unusual:name\\" };", color: green; } -.unusual\\\\:name { - color: purple; -} - :export { - theme: purple; + themeColor: purple; theme-color: purple; } /*# sourceMappingURL=composed.module.scss.css.map */ ", - "/fake/output/css/composed.module.scss.css.map" => "{\\"version\\":3,\\"sourceRoot\\":\\"\\",\\"sources\\":[\\"fixtures/composed.module.scss\\"],\\"names\\":[],\\"mappings\\":\\"AAGA;EACE;;;AAGF;EACE;;;AAGF;EACE;EACA;;;AAGF;EACE;;;AAGF;EACE;;;AAGF;EACE;EACA\\",\\"sourcesContent\\":[\\"// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.\\\\n// See LICENSE in the project root for license information.\\\\n\\\\n.base_style {\\\\n color: red;\\\\n}\\\\n\\\\n.base-style {\\\\n composes: base_style;\\\\n}\\\\n\\\\n.composedStyle {\\\\n composes: base-style;\\\\n color: blue;\\\\n}\\\\n\\\\n.id0 {\\\\n color: green;\\\\n}\\\\n\\\\n.unusual\\\\\\\\:name {\\\\n color: purple;\\\\n}\\\\n\\\\n:export {\\\\n theme: purple;\\\\n theme-color: purple;\\\\n}\\\\n\\"],\\"file\\":\\"composed.module.scss.css\\"}", + "/fake/output/css/composed.module.scss.css.map" => "{\\"version\\":3,\\"sourceRoot\\":\\"\\",\\"sources\\":[\\"fixtures/composed.module.scss\\"],\\"names\\":[],\\"mappings\\":\\"AAGA;EACE;;;AAGF;EACE;;;AAGF;EACE;EACA;;;AAGF;EACE;;;AAGF;EACE;EACA\\",\\"sourcesContent\\":[\\"// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.\\\\n// See LICENSE in the project root for license information.\\\\n\\\\n.base_style {\\\\n color: red;\\\\n}\\\\n\\\\n.base-style {\\\\n composes: base_style;\\\\n}\\\\n\\\\n.composedStyle {\\\\n composes: base-style;\\\\n color: blue;\\\\n}\\\\n\\\\n.id0 {\\\\n color: green;\\\\n}\\\\n\\\\n:export {\\\\n themeColor: purple;\\\\n theme-color: purple;\\\\n}\\\\n\\"],\\"file\\":\\"composed.module.scss.css\\"}", "/fake/output/css/composed.module.scss.js" => "module.exports = require(\\"./composed.module.scss.css\\");", } `; @@ -561,16 +425,14 @@ Array [ exports[`SassProcessor JS shim files named exports (esnext) forwards named exports for composed.module.scss without changing CSS or source maps: written-files 1`] = ` Map { - "/fake/output/dts/composed.module.scss.d.ts" => "export const theme: string; -declare const themeColor: string; -export { themeColor as \\"theme-color\\" }; + "/fake/output/dts/composed.module.scss.d.ts" => "export const themeColor: string; +declare const id1: string; +export { id1 as \\"theme-color\\" }; export const base_style: string; -declare const baseStyle: string; -export { baseStyle as \\"base-style\\" }; +declare const id2: string; +export { id2 as \\"base-style\\" }; export const composedStyle: string; -export const id0: string; -declare const id1: string; -export { id1 as \\"unusual:name\\" };", +export const id0: string;", "/fake/output/css/composed.module.scss.css" => ".base_style { color: red; } @@ -588,17 +450,13 @@ export { id1 as \\"unusual:name\\" };", color: green; } -.unusual\\\\:name { - color: purple; -} - :export { - theme: purple; + themeColor: purple; theme-color: purple; } /*# sourceMappingURL=composed.module.scss.css.map */ ", - "/fake/output/css/composed.module.scss.css.map" => "{\\"version\\":3,\\"sourceRoot\\":\\"\\",\\"sources\\":[\\"fixtures/composed.module.scss\\"],\\"names\\":[],\\"mappings\\":\\"AAGA;EACE;;;AAGF;EACE;;;AAGF;EACE;EACA;;;AAGF;EACE;;;AAGF;EACE;;;AAGF;EACE;EACA\\",\\"sourcesContent\\":[\\"// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.\\\\n// See LICENSE in the project root for license information.\\\\n\\\\n.base_style {\\\\n color: red;\\\\n}\\\\n\\\\n.base-style {\\\\n composes: base_style;\\\\n}\\\\n\\\\n.composedStyle {\\\\n composes: base-style;\\\\n color: blue;\\\\n}\\\\n\\\\n.id0 {\\\\n color: green;\\\\n}\\\\n\\\\n.unusual\\\\\\\\:name {\\\\n color: purple;\\\\n}\\\\n\\\\n:export {\\\\n theme: purple;\\\\n theme-color: purple;\\\\n}\\\\n\\"],\\"file\\":\\"composed.module.scss.css\\"}", + "/fake/output/css/composed.module.scss.css.map" => "{\\"version\\":3,\\"sourceRoot\\":\\"\\",\\"sources\\":[\\"fixtures/composed.module.scss\\"],\\"names\\":[],\\"mappings\\":\\"AAGA;EACE;;;AAGF;EACE;;;AAGF;EACE;EACA;;;AAGF;EACE;;;AAGF;EACE;EACA\\",\\"sourcesContent\\":[\\"// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.\\\\n// See LICENSE in the project root for license information.\\\\n\\\\n.base_style {\\\\n color: red;\\\\n}\\\\n\\\\n.base-style {\\\\n composes: base_style;\\\\n}\\\\n\\\\n.composedStyle {\\\\n composes: base-style;\\\\n color: blue;\\\\n}\\\\n\\\\n.id0 {\\\\n color: green;\\\\n}\\\\n\\\\n:export {\\\\n themeColor: purple;\\\\n theme-color: purple;\\\\n}\\\\n\\"],\\"file\\":\\"composed.module.scss.css\\"}", "/fake/output/css/composed.module.scss.js" => "export * from \\"./composed.module.scss.css\\";", } `; @@ -715,25 +573,23 @@ h1 { } `; -exports[`SassProcessor JS shim files uses camelCase private aliases for kebab-case exports and numeric fallbacks for other names: terminal-output 1`] = ` +exports[`SassProcessor JS shim files uses collision-free quoted export aliases for non-identifier class names and ICSS exports: terminal-output 1`] = ` Array [ "[verbose] Checking for changes to 1 files...[n]", "[ log] Compiling 1 files...[n]", ] `; -exports[`SassProcessor JS shim files uses camelCase private aliases for kebab-case exports and numeric fallbacks for other names: written-files 1`] = ` +exports[`SassProcessor JS shim files uses collision-free quoted export aliases for non-identifier class names and ICSS exports: written-files 1`] = ` Map { - "/fake/output/dts/composed.module.scss.d.ts" => "export const theme: string; -declare const themeColor: string; -export { themeColor as \\"theme-color\\" }; + "/fake/output/dts/composed.module.scss.d.ts" => "export const themeColor: string; +declare const id1: string; +export { id1 as \\"theme-color\\" }; export const base_style: string; -declare const baseStyle: string; -export { baseStyle as \\"base-style\\" }; +declare const id2: string; +export { id2 as \\"base-style\\" }; export const composedStyle: string; -export const id0: string; -declare const id1: string; -export { id1 as \\"unusual:name\\" };", +export const id0: string;", "/fake/output/css/composed.module.css" => ".base_style { color: red; } @@ -747,10 +603,6 @@ export { id1 as \\"unusual:name\\" };", .id0 { color: green; -} - -.unusual\\\\:name { - color: purple; }", } `; diff --git a/heft-plugins/heft-sass-plugin/src/test/fixtures/alias-collision-reversed.module.scss b/heft-plugins/heft-sass-plugin/src/test/fixtures/alias-collision-reversed.module.scss deleted file mode 100644 index a2dc5435d35..00000000000 --- a/heft-plugins/heft-sass-plugin/src/test/fixtures/alias-collision-reversed.module.scss +++ /dev/null @@ -1,10 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. -// See LICENSE in the project root for license information. - -.baseStyle { - color: blue; -} - -.base-style { - color: red; -} diff --git a/heft-plugins/heft-sass-plugin/src/test/fixtures/alias-collision.module.scss b/heft-plugins/heft-sass-plugin/src/test/fixtures/alias-collision.module.scss deleted file mode 100644 index 8c47979a678..00000000000 --- a/heft-plugins/heft-sass-plugin/src/test/fixtures/alias-collision.module.scss +++ /dev/null @@ -1,10 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. -// See LICENSE in the project root for license information. - -.base-style { - color: red; -} - -.baseStyle { - color: blue; -} diff --git a/heft-plugins/heft-sass-plugin/src/test/fixtures/alias-icss-collision.module.scss b/heft-plugins/heft-sass-plugin/src/test/fixtures/alias-icss-collision.module.scss deleted file mode 100644 index abdbb51a0d3..00000000000 --- a/heft-plugins/heft-sass-plugin/src/test/fixtures/alias-icss-collision.module.scss +++ /dev/null @@ -1,7 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. -// See LICENSE in the project root for license information. - -:export { - theme-color: red; - themeColor: blue; -} diff --git a/heft-plugins/heft-sass-plugin/src/test/fixtures/alias-pair-collision.module.scss b/heft-plugins/heft-sass-plugin/src/test/fixtures/alias-pair-collision.module.scss deleted file mode 100644 index 0ccdef0a24a..00000000000 --- a/heft-plugins/heft-sass-plugin/src/test/fixtures/alias-pair-collision.module.scss +++ /dev/null @@ -1,10 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. -// See LICENSE in the project root for license information. - -.base-style { - color: red; -} - -.base--style { - color: blue; -} diff --git a/heft-plugins/heft-sass-plugin/src/test/fixtures/composed.module.scss b/heft-plugins/heft-sass-plugin/src/test/fixtures/composed.module.scss index 8bde3aab6d9..ad2dc76bf73 100644 --- a/heft-plugins/heft-sass-plugin/src/test/fixtures/composed.module.scss +++ b/heft-plugins/heft-sass-plugin/src/test/fixtures/composed.module.scss @@ -18,11 +18,7 @@ color: green; } -.unusual\:name { - color: purple; -} - :export { - theme: purple; + themeColor: purple; theme-color: purple; } From 087fb1f64c78396b09d0d42d48d854ff43e540b6 Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Thu, 29 Oct 2026 17:41:14 -0700 Subject: [PATCH 6/6] [heft-sass-plugin] Handle reserved CSS export names Declare private bindings and re-export their public names so JavaScript keywords never become invalid variable declarations. Match the modern css-loader default-class convention and reject its ambiguous default/_default pair before emitting typings. Add keyword and loader-interop regressions, preserve legacy default-mode behavior, and document real-loader compatibility boundaries verified in production bundles and browser-applied CSS. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- heft-plugins/heft-sass-plugin/README.md | 25 +- .../heft-sass-plugin/src/SassProcessor.ts | 36 +- .../src/test/SassProcessor.test.ts | 71 +++- .../__snapshots__/SassProcessor.test.ts.snap | 325 +++++++++++++++--- .../fixtures/default-collision.module.scss | 10 + .../test/fixtures/loader-reserved.module.scss | 11 + .../src/test/fixtures/reserved.module.scss | 26 ++ 7 files changed, 432 insertions(+), 72 deletions(-) create mode 100644 heft-plugins/heft-sass-plugin/src/test/fixtures/default-collision.module.scss create mode 100644 heft-plugins/heft-sass-plugin/src/test/fixtures/loader-reserved.module.scss create mode 100644 heft-plugins/heft-sass-plugin/src/test/fixtures/reserved.module.scss diff --git a/heft-plugins/heft-sass-plugin/README.md b/heft-plugins/heft-sass-plugin/README.md index 2ea4a6ee5b5..5202ffe6d3c 100644 --- a/heft-plugins/heft-sass-plugin/README.md +++ b/heft-plugins/heft-sass-plugin/README.md @@ -157,7 +157,7 @@ All options are set in `config/sass.json`. Every option is optional. | `srcFolder` | `"src/"` | Root directory that is scanned for SCSS files | | `generatedTsFolder` | `"temp/sass-ts/"` | Output directory for generated `.d.ts` files | | `secondaryGeneratedTsFolders` | `[]` | Additional directories to also write `.d.ts` files to (e.g. `"lib-esm"` when publishing typings alongside compiled output) | -| `exportAsDefault` | `true` | When `true`, wraps exports in a typed default interface. When `false`, generates individual named exports (`export const className: string`). JavaScript shims use the same export style; configure the downstream CSS loader to match. | +| `exportAsDefault` | `true` | When `true`, wraps exports in a typed default interface. When `false`, generates individual named string exports. JavaScript shims use the same export style; configure the downstream CSS loader to match. | | `cssOutputFolders` | _(none)_ | Folders where compiled `.css` files are written. Each entry is either a plain folder path string, or an object with `folder` and optional `shimModuleFormat` (see below). | | `fileExtensions` | `[".sass", ".scss", ".css"]` | File extensions to treat as CSS modules | | `nonModuleFileExtensions` | `[".global.sass", ".global.scss", ".global.css"]` | File extensions to treat as global (non-module) stylesheets | @@ -221,9 +221,32 @@ Generated alias names avoid collisions with the stylesheet's own exports. The CS preserve these export names; consumers can access them with `styles["base-style"]`. Aliases such as `id0` are private bindings inside the declaration file, not additional public exports or runtime variables. `styles.id0` is available only if the stylesheet itself exports a name `id0`. +Named declarations use private bindings for identifier-shaped names as well, so JavaScript keywords +such as `class` and `function` never become invalid variable declarations. Set `preserveIcssExports: true` when the downstream loader must process ICSS `:export` values or `composes` declarations. +#### Loader compatibility + +CSS Modules do not inherently require a JavaScript default export; the loader determines that +interface. [css-loader 5](https://github.com/webpack/css-loader/tree/v5.2.7#namedexport) already supported +opt-in named exports, but required camelCase names and disallowed JavaScript keywords. +[css-loader 7](https://github.com/webpack/css-loader/tree/v7.1.5#namedexport) enables named exports by +default with `esModule: true` and supports `exportLocalsConvention: "as-is"`. + +The class name `default` is a special case: css-loader exposes it as `_default` in named mode, and +the generated typings follow that convention. A stylesheet exporting both `default` and `_default` +is rejected in named mode because the loader would produce duplicate exports; default-export mode +still supports both names. +For default-export mode, use ESM shims when a stylesheet has a `default` class: the legacy CommonJS +shim synthesizes a `.default` interop property, which can overwrite that key in a loader's class map. + +Valid declarations alone cannot fix loader-generated JavaScript. css-loader 7.1.4 and 7.1.5 have a +reserved-word limitation for names such as `let`, `yield`, `await`, `eval`, and `arguments`. Avoid those +names in named mode, or use matching default-export settings until the loader supports them. +The [css-loader repository](https://github.com/webpack/css-loader) is archived; these are compatibility +limits of the tested releases, not a promise of a future loader fix. + For a global stylesheet, or a CSS module with no local exports, the shim is a side-effect-only import regardless of `exportAsDefault`: diff --git a/heft-plugins/heft-sass-plugin/src/SassProcessor.ts b/heft-plugins/heft-sass-plugin/src/SassProcessor.ts index 06cdf057cf6..f71f32b5e3f 100644 --- a/heft-plugins/heft-sass-plugin/src/SassProcessor.ts +++ b/heft-plugins/heft-sass-plugin/src/SassProcessor.ts @@ -1094,21 +1094,31 @@ function createDTS( source.push(`declare const styles: IStyles;`); source.push(`export default styles;`); } else { + // css-loader exposes a class named "default" as "_default", reserving default for its CSS API. + if ( + Object.prototype.hasOwnProperty.call(moduleMap, 'default') && + Object.prototype.hasOwnProperty.call(moduleMap, '_default') + ) { + throw new Error( + 'CSS exports "default" and "_default" both map to "_default" in named mode. Rename one of them or use "exportAsDefault: true".' + ); + } + let identifierIndex: number = 0; for (const className of Object.keys(moduleMap)) { - if (SIMPLE_IDENTIFIER_REGEX.test(className)) { - source.push(`export const ${className}: string;`); - } else { - let identifier: string; - do { - identifier = `id${identifierIndex++}`; - } while (Object.prototype.hasOwnProperty.call(moduleMap, identifier)); - - source.push( - `declare const ${identifier}: string;`, - `export { ${identifier} as ${JSON.stringify(className)} };` - ); - } + let identifier: string; + do { + identifier = `id${identifierIndex++}`; + } while (Object.prototype.hasOwnProperty.call(moduleMap, identifier)); + + const exportName: string = className === 'default' ? '_default' : className; + const exportSpecifier: string = SIMPLE_IDENTIFIER_REGEX.test(exportName) + ? exportName + : JSON.stringify(exportName); + source.push( + `declare const ${identifier}: string;`, + `export { ${identifier} as ${exportSpecifier} };` + ); } } diff --git a/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts b/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts index 7ae471888ae..eb4565aa79a 100644 --- a/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts +++ b/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts @@ -277,10 +277,9 @@ describe(SassProcessor.name, () => { }); 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).toContain(' as root };'); + expect(dts).toContain(' as highlighted };'); + expect(dts).toContain(' as themeColor };'); expect(dts).not.toContain('export default'); }); }); @@ -390,19 +389,73 @@ describe(SassProcessor.name, () => { const { processor } = createProcessor(terminalProvider, { exportAsDefault: false }); await compileFixtureAsync(processor, 'composed.module.scss'); const dts: string = getDtsOutput('composed.module.scss'); - expect(dts).toContain('export const base_style: string;'); - expect(dts).toContain('export const id0: string;'); - expect(dts).toContain('declare const id1: string;\nexport { id1 as "theme-color" };'); - expect(dts).toContain('declare const id2: string;\nexport { id2 as "base-style" };'); + expect(dts).toContain(' as base_style };'); + expect(dts).toContain(' as id0 };'); + expect(dts).toContain('declare const id2: string;\nexport { id2 as "theme-color" };'); + expect(dts).toContain('declare const id4: string;\nexport { id4 as "base-style" };'); expect(dts).not.toContain('export default'); expect(terminalProvider.getOutput({ normalizeSpecialCharacters: true })).not.toContain('ERROR'); }); + it('uses private bindings for reserved names and matches the loader default-class convention', async () => { + const { processor, logger } = createProcessor(terminalProvider, { exportAsDefault: false }); + await compileFixtureAsync(processor, 'reserved.module.scss'); + expect(logger.errors).toHaveLength(0); + const dts: string = getDtsOutput('reserved.module.scss'); + for (const name of [ + 'class', + 'function', + 'for', + 'return', + 'enum', + 'implements', + 'interface', + 'private', + 'protected', + 'public', + 'static', + 'let', + 'yield', + 'await', + 'eval', + 'arguments', + 'true', + 'false', + 'null', + '_default', + 'id0' + ]) { + expect(dts).toContain(` as ${name} };`); + expect(dts).not.toContain(`export const ${name}:`); + } + + expect(dts).not.toContain(' as default };'); + }); + + it('reports the loader default-name collision in named mode', async () => { + const { processor, logger } = createProcessor(terminalProvider, { exportAsDefault: false }); + await compileFixtureAsync(processor, 'default-collision.module.scss'); + expect(logger.errors).toHaveLength(1); + expect(logger.errors[0].message).toContain('"default" and "_default"'); + expect(writtenFiles.size).toBe(0); + }); + + it('preserves both default and _default class names in default mode', async () => { + const { processor, logger } = createProcessor(terminalProvider); + await compileFixtureAsync(processor, 'default-collision.module.scss'); + expect(logger.errors).toHaveLength(0); + const dts: string = getDtsOutput('default-collision.module.scss'); + expect(dts).toContain('default: string;'); + expect(dts).toContain('_default: string;'); + expect(dts).toContain('export default styles;'); + }); + describe.each(['commonjs', 'esnext'] as const)('named exports (%s)', (format: 'commonjs' | 'esnext') => { it.each([ 'classes-and-exports.module.scss', 'export-only.module.scss', 'composed.module.scss', + 'loader-reserved.module.scss', 'simple.module.sass' ])( 'forwards named exports for %s without changing CSS or source maps', @@ -428,7 +481,7 @@ describe(SassProcessor.name, () => { ? `export * from ${cssSpecifier};` : `module.exports = require(${cssSpecifier});` ); - expect(getDtsOutput(fixtureFilename)).toContain('export const '); + expect(getDtsOutput(fixtureFilename)).toContain('declare const '); expect(getDtsOutput(fixtureFilename)).not.toContain('export default'); expect(getWrittenFile(`${fixtureFilename}.css`)).toBe(defaultCss); expect(getWrittenFile(`${fixtureFilename}.css.map`)).toBe(defaultMap); 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 index 9348a03a3bb..808f48fa252 100644 --- 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 @@ -144,10 +144,14 @@ Array [ exports[`SassProcessor JS shim files forwards CommonJS named exports unchanged with __esModule=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;", + "/fake/output/dts/classes-and-exports.module.scss.d.ts" => "declare const id0: string; +export { id0 as themeColor }; +declare const id1: string; +export { id1 as spacing }; +declare const id2: string; +export { id2 as root }; +declare const id3: string; +export { id3 as highlighted };", "/fake/output/css/classes-and-exports.module.css" => ".root { color: red; font-size: 14px; @@ -169,10 +173,14 @@ Array [ exports[`SassProcessor JS shim files forwards CommonJS named exports unchanged with __esModule=true: 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;", + "/fake/output/dts/classes-and-exports.module.scss.d.ts" => "declare const id0: string; +export { id0 as themeColor }; +declare const id1: string; +export { id1 as spacing }; +declare const id2: string; +export { id2 as root }; +declare const id3: string; +export { id3 as highlighted };", "/fake/output/css/classes-and-exports.module.css" => ".root { color: red; font-size: 14px; @@ -196,10 +204,14 @@ Array [ exports[`SassProcessor JS shim files named exports (commonjs) forwards named exports for classes-and-exports.module.scss without changing CSS or source maps: 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;", + "/fake/output/dts/classes-and-exports.module.scss.d.ts" => "declare const id0: string; +export { id0 as themeColor }; +declare const id1: string; +export { id1 as spacing }; +declare const id2: string; +export { id2 as root }; +declare const id3: string; +export { id3 as highlighted };", "/fake/output/css/classes-and-exports.module.scss.css" => ".root { color: red; font-size: 14px; @@ -231,14 +243,18 @@ Array [ exports[`SassProcessor JS shim files named exports (commonjs) forwards named exports for composed.module.scss without changing CSS or source maps: written-files 1`] = ` Map { - "/fake/output/dts/composed.module.scss.d.ts" => "export const themeColor: string; -declare const id1: string; -export { id1 as \\"theme-color\\" }; -export const base_style: string; + "/fake/output/dts/composed.module.scss.d.ts" => "declare const id1: string; +export { id1 as themeColor }; declare const id2: string; -export { id2 as \\"base-style\\" }; -export const composedStyle: string; -export const id0: string;", +export { id2 as \\"theme-color\\" }; +declare const id3: string; +export { id3 as base_style }; +declare const id4: string; +export { id4 as \\"base-style\\" }; +declare const id5: string; +export { id5 as composedStyle }; +declare const id6: string; +export { id6 as id0 };", "/fake/output/css/composed.module.scss.css" => ".base_style { color: red; } @@ -278,8 +294,10 @@ Array [ exports[`SassProcessor JS shim files named exports (commonjs) forwards named exports for export-only.module.scss without changing CSS or source maps: written-files 1`] = ` Map { - "/fake/output/dts/export-only.module.scss.d.ts" => "export const primaryColor: string; -export const fontFamily: string;", + "/fake/output/dts/export-only.module.scss.d.ts" => "declare const id0: string; +export { id0 as primaryColor }; +declare const id1: string; +export { id1 as fontFamily };", "/fake/output/css/export-only.module.scss.css" => ":export { primaryColor: #0078d4; fontFamily: \\"Segoe UI\\"; @@ -291,6 +309,44 @@ export const fontFamily: string;", } `; +exports[`SassProcessor JS shim files named exports (commonjs) forwards named exports for loader-reserved.module.scss without changing CSS or source maps: 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 JS shim files named exports (commonjs) forwards named exports for loader-reserved.module.scss without changing CSS or source maps: written-files 1`] = ` +Map { + "/fake/output/dts/loader-reserved.module.scss.d.ts" => "declare const id0: string; +export { id0 as class }; +declare const id1: string; +export { id1 as function }; +declare const id2: string; +export { id2 as for }; +declare const id3: string; +export { id3 as enum }; +declare const id4: string; +export { id4 as true }; +declare const id5: string; +export { id5 as _default };", + "/fake/output/css/loader-reserved.module.scss.css" => ".class, +.function, +.for, +.enum, +.true, +.default { + color: red; +} +/*# sourceMappingURL=loader-reserved.module.scss.css.map */ +", + "/fake/output/css/loader-reserved.module.scss.css.map" => "{\\"version\\":3,\\"sourceRoot\\":\\"\\",\\"sources\\":[\\"fixtures/loader-reserved.module.scss\\"],\\"names\\":[],\\"mappings\\":\\"AAGA;AAAA;AAAA;AAAA;AAAA;AAAA;EAME\\",\\"sourcesContent\\":[\\"// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.\\\\n// See LICENSE in the project root for license information.\\\\n\\\\n.class,\\\\n.function,\\\\n.for,\\\\n.enum,\\\\n.true,\\\\n.default {\\\\n color: red;\\\\n}\\\\n\\"],\\"file\\":\\"loader-reserved.module.scss.css\\"}", + "/fake/output/css/loader-reserved.module.scss.js" => "module.exports = require(\\"./loader-reserved.module.scss.css\\");", +} +`; + exports[`SassProcessor JS shim files named exports (commonjs) forwards named exports for simple.module.sass without changing CSS or source maps: terminal-output 1`] = ` Array [ "[verbose] Checking for changes to 1 files...[n]", @@ -302,8 +358,10 @@ Array [ exports[`SassProcessor JS shim files named exports (commonjs) forwards named exports for simple.module.sass without changing CSS or source maps: written-files 1`] = ` Map { - "/fake/output/dts/simple.module.sass.d.ts" => "export const exampleClass: string; -export const exampleHeading: string;", + "/fake/output/dts/simple.module.sass.d.ts" => "declare const id0: string; +export { id0 as exampleClass }; +declare const id1: string; +export { id1 as exampleHeading };", "/fake/output/css/simple.module.sass.css" => ".exampleClass { color: red; font-size: 14px; @@ -390,10 +448,14 @@ Array [ exports[`SassProcessor JS shim files named exports (esnext) forwards named exports for classes-and-exports.module.scss without changing CSS or source maps: 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;", + "/fake/output/dts/classes-and-exports.module.scss.d.ts" => "declare const id0: string; +export { id0 as themeColor }; +declare const id1: string; +export { id1 as spacing }; +declare const id2: string; +export { id2 as root }; +declare const id3: string; +export { id3 as highlighted };", "/fake/output/css/classes-and-exports.module.scss.css" => ".root { color: red; font-size: 14px; @@ -425,14 +487,18 @@ Array [ exports[`SassProcessor JS shim files named exports (esnext) forwards named exports for composed.module.scss without changing CSS or source maps: written-files 1`] = ` Map { - "/fake/output/dts/composed.module.scss.d.ts" => "export const themeColor: string; -declare const id1: string; -export { id1 as \\"theme-color\\" }; -export const base_style: string; + "/fake/output/dts/composed.module.scss.d.ts" => "declare const id1: string; +export { id1 as themeColor }; declare const id2: string; -export { id2 as \\"base-style\\" }; -export const composedStyle: string; -export const id0: string;", +export { id2 as \\"theme-color\\" }; +declare const id3: string; +export { id3 as base_style }; +declare const id4: string; +export { id4 as \\"base-style\\" }; +declare const id5: string; +export { id5 as composedStyle }; +declare const id6: string; +export { id6 as id0 };", "/fake/output/css/composed.module.scss.css" => ".base_style { color: red; } @@ -472,8 +538,10 @@ Array [ exports[`SassProcessor JS shim files named exports (esnext) forwards named exports for export-only.module.scss without changing CSS or source maps: written-files 1`] = ` Map { - "/fake/output/dts/export-only.module.scss.d.ts" => "export const primaryColor: string; -export const fontFamily: string;", + "/fake/output/dts/export-only.module.scss.d.ts" => "declare const id0: string; +export { id0 as primaryColor }; +declare const id1: string; +export { id1 as fontFamily };", "/fake/output/css/export-only.module.scss.css" => ":export { primaryColor: #0078d4; fontFamily: \\"Segoe UI\\"; @@ -485,6 +553,44 @@ export const fontFamily: string;", } `; +exports[`SassProcessor JS shim files named exports (esnext) forwards named exports for loader-reserved.module.scss without changing CSS or source maps: 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 JS shim files named exports (esnext) forwards named exports for loader-reserved.module.scss without changing CSS or source maps: written-files 1`] = ` +Map { + "/fake/output/dts/loader-reserved.module.scss.d.ts" => "declare const id0: string; +export { id0 as class }; +declare const id1: string; +export { id1 as function }; +declare const id2: string; +export { id2 as for }; +declare const id3: string; +export { id3 as enum }; +declare const id4: string; +export { id4 as true }; +declare const id5: string; +export { id5 as _default };", + "/fake/output/css/loader-reserved.module.scss.css" => ".class, +.function, +.for, +.enum, +.true, +.default { + color: red; +} +/*# sourceMappingURL=loader-reserved.module.scss.css.map */ +", + "/fake/output/css/loader-reserved.module.scss.css.map" => "{\\"version\\":3,\\"sourceRoot\\":\\"\\",\\"sources\\":[\\"fixtures/loader-reserved.module.scss\\"],\\"names\\":[],\\"mappings\\":\\"AAGA;AAAA;AAAA;AAAA;AAAA;AAAA;EAME\\",\\"sourcesContent\\":[\\"// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.\\\\n// See LICENSE in the project root for license information.\\\\n\\\\n.class,\\\\n.function,\\\\n.for,\\\\n.enum,\\\\n.true,\\\\n.default {\\\\n color: red;\\\\n}\\\\n\\"],\\"file\\":\\"loader-reserved.module.scss.css\\"}", + "/fake/output/css/loader-reserved.module.scss.js" => "export * from \\"./loader-reserved.module.scss.css\\";", +} +`; + exports[`SassProcessor JS shim files named exports (esnext) forwards named exports for simple.module.sass without changing CSS or source maps: terminal-output 1`] = ` Array [ "[verbose] Checking for changes to 1 files...[n]", @@ -496,8 +602,10 @@ Array [ exports[`SassProcessor JS shim files named exports (esnext) forwards named exports for simple.module.sass without changing CSS or source maps: written-files 1`] = ` Map { - "/fake/output/dts/simple.module.sass.d.ts" => "export const exampleClass: string; -export const exampleHeading: string;", + "/fake/output/dts/simple.module.sass.d.ts" => "declare const id0: string; +export { id0 as exampleClass }; +declare const id1: string; +export { id1 as exampleHeading };", "/fake/output/css/simple.module.sass.css" => ".exampleClass { color: red; font-size: 14px; @@ -573,6 +681,40 @@ h1 { } `; +exports[`SassProcessor JS shim files preserves both default and _default class names in default mode: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor JS shim files preserves both default and _default class names in default mode: written-files 1`] = ` +Map { + "/fake/output/dts/default-collision.module.scss.d.ts" => "declare interface IStyles { + default: string; + _default: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/default-collision.module.css" => ".default { + color: red; +} + +._default { + color: blue; +}", +} +`; + +exports[`SassProcessor JS shim files reports the loader default-name collision in named mode: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor JS shim files reports the loader default-name collision in named mode: written-files 1`] = `Map {}`; + exports[`SassProcessor JS shim files uses collision-free quoted export aliases for non-identifier class names and ICSS exports: terminal-output 1`] = ` Array [ "[verbose] Checking for changes to 1 files...[n]", @@ -582,14 +724,18 @@ Array [ exports[`SassProcessor JS shim files uses collision-free quoted export aliases for non-identifier class names and ICSS exports: written-files 1`] = ` Map { - "/fake/output/dts/composed.module.scss.d.ts" => "export const themeColor: string; -declare const id1: string; -export { id1 as \\"theme-color\\" }; -export const base_style: string; + "/fake/output/dts/composed.module.scss.d.ts" => "declare const id1: string; +export { id1 as themeColor }; declare const id2: string; -export { id2 as \\"base-style\\" }; -export const composedStyle: string; -export const id0: string;", +export { id2 as \\"theme-color\\" }; +declare const id3: string; +export { id3 as base_style }; +declare const id4: string; +export { id4 as \\"base-style\\" }; +declare const id5: string; +export { id5 as composedStyle }; +declare const id6: string; +export { id6 as id0 };", "/fake/output/css/composed.module.css" => ".base_style { color: red; } @@ -607,6 +753,83 @@ export const id0: string;", } `; +exports[`SassProcessor JS shim files uses private bindings for reserved names and matches the loader default-class convention: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor JS shim files uses private bindings for reserved names and matches the loader default-class convention: written-files 1`] = ` +Map { + "/fake/output/dts/reserved.module.scss.d.ts" => "declare const id1: string; +export { id1 as class }; +declare const id2: string; +export { id2 as function }; +declare const id3: string; +export { id3 as for }; +declare const id4: string; +export { id4 as return }; +declare const id5: string; +export { id5 as enum }; +declare const id6: string; +export { id6 as implements }; +declare const id7: string; +export { id7 as interface }; +declare const id8: string; +export { id8 as private }; +declare const id9: string; +export { id9 as protected }; +declare const id10: string; +export { id10 as public }; +declare const id11: string; +export { id11 as static }; +declare const id12: string; +export { id12 as let }; +declare const id13: string; +export { id13 as yield }; +declare const id14: string; +export { id14 as await }; +declare const id15: string; +export { id15 as eval }; +declare const id16: string; +export { id16 as arguments }; +declare const id17: string; +export { id17 as true }; +declare const id18: string; +export { id18 as false }; +declare const id19: string; +export { id19 as null }; +declare const id20: string; +export { id20 as _default }; +declare const id21: string; +export { id21 as id0 };", + "/fake/output/css/reserved.module.css" => ".class, +.function, +.for, +.return, +.enum, +.implements, +.interface, +.private, +.protected, +.public, +.static, +.let, +.yield, +.await, +.eval, +.arguments, +.true, +.false, +.null, +.default, +.id0 { + color: red; +}", +} +`; + exports[`SassProcessor JS shim files writes shims to each configured cssOutputFolder independently: terminal-output 1`] = ` Array [ "[verbose] Checking for changes to 1 files...[n]", @@ -781,10 +1004,14 @@ Array [ 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;", + "/fake/output/dts/classes-and-exports.module.scss.d.ts" => "declare const id0: string; +export { id0 as themeColor }; +declare const id1: string; +export { id1 as spacing }; +declare const id2: string; +export { id2 as root }; +declare const id3: string; +export { id3 as highlighted };", "/fake/output/css/classes-and-exports.module.css" => ".root { color: red; font-size: 14px; diff --git a/heft-plugins/heft-sass-plugin/src/test/fixtures/default-collision.module.scss b/heft-plugins/heft-sass-plugin/src/test/fixtures/default-collision.module.scss new file mode 100644 index 00000000000..dc4bbccebe6 --- /dev/null +++ b/heft-plugins/heft-sass-plugin/src/test/fixtures/default-collision.module.scss @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +.default { + color: red; +} + +._default { + color: blue; +} diff --git a/heft-plugins/heft-sass-plugin/src/test/fixtures/loader-reserved.module.scss b/heft-plugins/heft-sass-plugin/src/test/fixtures/loader-reserved.module.scss new file mode 100644 index 00000000000..7f9928c4d46 --- /dev/null +++ b/heft-plugins/heft-sass-plugin/src/test/fixtures/loader-reserved.module.scss @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +.class, +.function, +.for, +.enum, +.true, +.default { + color: red; +} diff --git a/heft-plugins/heft-sass-plugin/src/test/fixtures/reserved.module.scss b/heft-plugins/heft-sass-plugin/src/test/fixtures/reserved.module.scss new file mode 100644 index 00000000000..bf41a73144a --- /dev/null +++ b/heft-plugins/heft-sass-plugin/src/test/fixtures/reserved.module.scss @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +.class, +.function, +.for, +.return, +.enum, +.implements, +.interface, +.private, +.protected, +.public, +.static, +.let, +.yield, +.await, +.eval, +.arguments, +.true, +.false, +.null, +.default, +.id0 { + color: red; +}