Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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"
}
28 changes: 25 additions & 3 deletions heft-plugins/heft-sass-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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")
Expand All @@ -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
Expand Down
26 changes: 16 additions & 10 deletions heft-plugins/heft-sass-plugin/src/SassProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1058,7 +1055,8 @@ export class SassProcessor {
const jsShimContent: string = generateJsShimContent(
shimModuleFormat,
cssPathFromJs,
hasModuleExports
hasModuleExports,
exportAsDefault
);
await FileSystem.writeFileAsync(jsFilePath, jsShimContent, writeFileOptions);
}
Expand Down Expand Up @@ -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 {};`;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion heft-plugins/heft-sass-plugin/src/templates/sass.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
88 changes: 85 additions & 3 deletions heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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' }]
Expand Down
Loading
Loading