Skip to content
Merged
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,11 @@
{
"changes": [
{
"comment": "Fix generated JS shims and `.d.ts` for `.module.scss` files that contain only `:global` styles and have no local CSS class exports",
"type": "patch",
"packageName": "@rushstack/heft-sass-plugin"
}
],
"packageName": "@rushstack/heft-sass-plugin",
"email": "iclanton@users.noreply.github.com"
}
72 changes: 43 additions & 29 deletions heft-plugins/heft-sass-plugin/src/SassProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -795,7 +795,11 @@ export class SassProcessor {

const relativeFilePath: string = path.relative(srcFolder, sourceFilePath);

const dtsContent: string = this._createDTS(moduleMap);
// A module file with no local class exports (e.g. only :global styles) has no
// default export at runtime, so treat it as a side-effect-only import just like
// a non-module file.
const hasModuleExports: boolean | undefined = moduleMap && Object.keys(moduleMap).length > 0;
const dtsContent: string = createDTS(moduleMap, exportAsDefault, hasModuleExports);

const writeFileOptions: IFileSystemWriteFileOptions = {
ensureFolderExists: true
Expand Down Expand Up @@ -840,48 +844,58 @@ export class SassProcessor {
const jsShimContent: string = generateJsShimContent(
shimModuleFormat,
cssPathFromJs,
record.isModule
hasModuleExports
);
await FileSystem.writeFileAsync(jsFilePath, jsShimContent, writeFileOptions);
}
}
}
}
}

private _createDTS(moduleMap: JsonObject | undefined): string {
function createDTS(
moduleMap: JsonObject | undefined,
exportAsDefault: boolean,
hasModuleExports: boolean | undefined
): string;
function createDTS(moduleMap: JsonObject, exportAsDefault: boolean, hasModuleExports: true): string;
function createDTS(
moduleMap: JsonObject | undefined,
exportAsDefault: boolean,
hasModuleExports: boolean | undefined
): string {
if (hasModuleExports) {
// Create a source file.
const source: string[] = [];

if (moduleMap) {
if (this._options.exportAsDefault) {
source.push(`declare interface IStyles {`);
for (const className of Object.keys(moduleMap)) {
const safeClassName: string = SIMPLE_IDENTIFIER_REGEX.test(className)
? className
: JSON.stringify(className);
// Quote and escape class names as needed.
source.push(` ${safeClassName}: string;`);
}
source.push(`}`);
source.push(`declare const styles: IStyles;`);
source.push(`export default styles;`);
} else {
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"`
);
}
source.push(`export const ${className}: string;`);
}
if (exportAsDefault) {
source.push(`declare interface IStyles {`);
for (const className of Object.keys(moduleMap)) {
const safeClassName: string = SIMPLE_IDENTIFIER_REGEX.test(className)
? className
: JSON.stringify(className);
// Quote and escape class names as needed.
source.push(` ${safeClassName}: string;`);
}
}

if (source.length === 0 || !moduleMap) {
return `export {};`;
source.push(`}`);
source.push(`declare const styles: IStyles;`);
source.push(`export default styles;`);
} else {
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"`
);
}

source.push(`export const ${className}: string;`);
}
}

return source.join('\n');
} else {
return `export {};`;
}
}

Expand Down Expand Up @@ -1021,7 +1035,7 @@ function determineSyntaxFromFilePath(filePath: string): Syntax {
function generateJsShimContent(
format: 'commonjs' | 'esnext',
relativePathToCss: string,
isModule: boolean
isModule: boolean | undefined
): string {
const pathString: string = JSON.stringify(relativePathToCss);
switch (format) {
Expand Down
35 changes: 35 additions & 0 deletions heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,41 @@ describe(SassProcessor.name, () => {
});
});

describe('global-only.module.scss (module file with only :global styles)', () => {
it('emits export {}; in the .d.ts when all styles are :global', async () => {
const { processor } = createProcessor(terminalProvider);
await compileFixtureAsync(processor, 'global-only.module.scss');
const dts: string = getDtsOutput('global-only.module.scss');
expect(dts).toBe('export {};');
});

it('emits a side-effect ESM shim (no default re-export) when all styles are :global', async () => {
const { processor } = createProcessor(terminalProvider, {
cssOutputFolders: [{ folder: CSS_OUTPUT_FOLDER, shimModuleFormat: 'esnext' }]
});
await compileFixtureAsync(processor, 'global-only.module.scss');
const shim: string = getJsShimOutput('global-only.module.scss');
expect(shim).toBe(`import "./global-only.module.css";export {};`);
});

it('emits a side-effect CJS shim (no module.exports assignment) when all styles are :global', async () => {
const { processor } = createProcessor(terminalProvider, {
cssOutputFolders: [{ folder: CSS_OUTPUT_FOLDER, shimModuleFormat: 'commonjs' }]
});
await compileFixtureAsync(processor, 'global-only.module.scss');
const shim: string = getJsShimOutput('global-only.module.scss');
expect(shim).toBe(`require("./global-only.module.css");`);
});

it('emits compiled CSS with the :global styles applied', async () => {
const { processor } = createProcessor(terminalProvider);
await compileFixtureAsync(processor, 'global-only.module.scss');
const css: string = getCssOutput('global-only.module.scss');
expect(css).toContain('.ms-Nav-group');
expect(css).toContain('.ms-Nav-link');
});
});

describe('non-module (global) files', () => {
it('emits plain compiled CSS for a .global.scss file', async () => {
const { processor } = createProcessor(terminalProvider, {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,84 @@ export default styles;",
}
`;

exports[`SassProcessor global-only.module.scss (module file with only :global styles) emits a side-effect CJS shim (no module.exports assignment) when all styles are :global: terminal-output 1`] = `
Array [
"[verbose] Checking for changes to 1 files...[n]",
"[ log] Compiling 1 files...[n]",
]
`;

exports[`SassProcessor global-only.module.scss (module file with only :global styles) emits a side-effect CJS shim (no module.exports assignment) when all styles are :global: 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 global-only.module.scss (module file with only :global styles) emits a side-effect ESM shim (no default re-export) when all styles are :global: terminal-output 1`] = `
Array [
"[verbose] Checking for changes to 1 files...[n]",
"[ log] Compiling 1 files...[n]",
]
`;

exports[`SassProcessor global-only.module.scss (module file with only :global styles) emits a side-effect ESM shim (no default re-export) when all styles are :global: 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 global-only.module.scss (module file with only :global styles) emits compiled CSS with the :global styles applied: terminal-output 1`] = `
Array [
"[verbose] Checking for changes to 1 files...[n]",
"[ log] Compiling 1 files...[n]",
]
`;

exports[`SassProcessor global-only.module.scss (module file with only :global styles) emits compiled CSS with the :global styles applied: 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;
}",
}
`;

exports[`SassProcessor global-only.module.scss (module file with only :global styles) emits export {}; in the .d.ts when all styles are :global: terminal-output 1`] = `
Array [
"[verbose] Checking for changes to 1 files...[n]",
"[ log] Compiling 1 files...[n]",
]
`;

exports[`SassProcessor global-only.module.scss (module file with only :global styles) emits export {}; in the .d.ts when all styles are :global: 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;
}",
}
`;

exports[`SassProcessor mixin-with-exports.module.scss (Sass @mixin) expands @mixin calls in CSS output: terminal-output 1`] = `
Array [
"[verbose] Checking for changes to 1 files...[n]",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// A module SCSS file that contains only :global styles and no local CSS module class exports.
// This pattern is used for applying global overrides from a file named .module.scss.
:global {
.ms-Nav-group {
overflow: hidden;
}

.ms-Nav-link {
height: 30px;
}
}