8000 [heft-sass-plugin] Support non-module SCSS · syengineering/rushstack@828909e · GitHub
[go: up one dir, main page]

Skip to content

Commit 828909e

Browse files
committed
[heft-sass-plugin] Support non-module SCSS
1 parent 50378eb commit 828909e

File tree

5 files changed

+126
-13
lines changed

5 files changed

+126
-13
lines changed

build-tests/heft-sass-test/src/ExampleApp.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import * as React from 'react';
66
import styles from './styles.sass';
77
import oldStyles from './stylesCSS.css';
88
import altSyntaxStyles from './stylesAltSyntax.scss';
9+
import './stylesAltSyntax.global.scss';
910

1011
/**
1112
* This React component renders the application page.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
/**
2+
* This file gets transpiled by the heft-sass-plugin and output to the lib/ folder.
3+
* Then Webpack uses css-loader to embed, and finally style-loader to apply it to the DOM.
4+
*/
5+
6+
// Testing SCSS syntax
7+
$marginValue: 20px;
8+
9+
.ms-label {
10+
margin-bottom: $marginValue;
11+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"changes": [
3+
{
4+
"packageName": "@rushstack/heft-sass-plugin",
5+
"comment": "Add `nonModuleFileExtensions` property to support generating typings for non-module CSS files.",
6+
"type": "minor"
7+
}
8+
],
9+
"packageName": "@rushstack/heft-sass-plugin"
10+
}

heft-plugins/heft-sass-plugin/src/SassProcessor.ts

Lines changed: 94 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import * as path from 'path';
55
import { render, Result, SassError } from 'node-sass';
66
import * as postcss from 'postcss';
77
import cssModules from 'postcss-modules';
8-
import { FileSystem, LegacyAdapters } from '@rushstack/node-core-library';
8+
import { FileSystem, LegacyAdapters, Sort } from '@rushstack/node-core-library';
99
import { IStringValueTypings, StringValuesTypingsGenerator } from '@rushstack/typings-generator';
1010

1111
/**
@@ -42,10 +42,18 @@ export interface ISassConfiguration {
4242

4343
/**
4444
* Files with these extensions will pass through the Sass transpiler for typings generation.
45+
* They will be treated as SCSS modules.
4546
* Defaults to [".sass", ".scss", ".css"]
4647
*/
4748
fileExtensions?: string[];
4849

50+
/**
51+
* Files with these extensions will pass through the Sass transpiler for typings generation.
52+
* They will be treated as non-module SCSS.
53+
* Defaults to [".global.sass", ".global.scss", ".global.css"]
54+
*/
55+
nonModuleFileExtensions?: string[];
56+
4957
/**
5058
* A list of paths used when resolving Sass imports.
5159
* The paths should be relative to the project root.
@@ -88,7 +96,9 @@ export class SassProcessor extends StringValuesTypingsGenerator {
8896
const exportAsDefault: boolean =
8997
sassConfiguration.exportAsDefault === undefined ? true : sassConfiguration.exportAsDefault;
9098
const exportAsDefaultInterfaceName: string = 'IExportStyles';
91-
const fileExtensions: string[] = sassConfiguration.fileExtensions || ['.sass', '.scss', '.css'];
99+
100+
const { allFileExtensions, isFileModule } = buildExtensionClassifier(sassConfiguration);
101+
92102
const { cssOutputFolders } = sassConfiguration;
93103

94104
const getCssPaths: ((relativePath: string) => string[]) | undefined = cssOutputFolders
@@ -105,7 +115,7 @@ export class SassProcessor extends StringValuesTypingsGenerator {
105115
generatedTsFolder,
106116
exportAsDefault,
107117
exportAsDefaultInterfaceName,
108-
fileExtensions,
118+
fileExtensions: allFileExtensions,
109119
filesToIgnore: sassConfiguration.excludeFiles,
110120
secondaryGeneratedTsFolders: sassConfiguration.secondaryGeneratedTsFolders,
111121

@@ -118,6 +128,8 @@ export class SassProcessor extends StringValuesTypingsGenerator {
118128
return;
119129
}
120130

131+
const isModule: boolean = isFileModule(relativePath);
132+
121133
const css: string = await this._transpileSassAsync(
122134
fileContents,
123135
filePath,
@@ -126,16 +138,20 @@ export class SassProcessor extends StringValuesTypingsGenerator {
126138
);
127139

128140
let classMap: IClassMap = {};
129-
const cssModulesClassMapPlugin: postcss.Plugin = cssModules({
130-
getJSON: (cssFileName: string, json: IClassMap) => {
131-
// This callback will be invoked during the promise evaluation of the postcss process() function.
132-
classMap = json;
133-
},
134-
// Avoid unnecessary name hashing.
135-
generateScopedName: (name: string) => name
136-
});
137141

138-
await postcss.default([cssModulesClassMapPlugin]).process(css, { from: filePath });
142+
if (isModule) {
143+
// Not all input files are SCSS modules
144+
const cssModulesClassMapPlugin: postcss.Plugin = cssModules({
145+
getJSON: (cssFileName: string, json: IClassMap) => {
146+
// This callback will be invoked during the promise evaluation of the postcss process() function.
147+
classMap = json;
148+
},
149+
// Avoid unnecessary name hashing.
150+
generateScopedName: (name: string) => name
151+
});
152+
153+
await postcss.default([cssModulesClassMapPlugin]).process(css, { from: filePath });
154+
}
139155

140156
if (getCssPaths) {
141157
await Promise.all(
@@ -213,3 +229,69 @@ export class SassProcessor extends StringValuesTypingsGenerator {
213229
return url;
214230
}
215231
}
232+
233+
interface IExtensionClassifierResult {
234+
allFileExtensions: string[];
235+
isFileModule: (relativePath: string) => boolean;
236+
}
237+
238+
function buildExtensionClassifier(sassConfiguration: ISassConfiguration): IExtensionClassifierResult {
239+
const {
240+
fileExtensions: moduleFileExtensions = ['.sass', '.scss', '.css'],
241+
nonModuleFileExtensions = ['.global.sass', '.global.scss', '.global.css']
242+
} = sassConfiguration;
243+
244+
const hasModules: boolean = moduleFileExtensions.length > 0;
245+
const hasNonModules: boolean = nonModuleFileExtensions.length > 0;
246+
247+
if (!hasModules) {
248+
return {
249+
allFileExtensions: nonModuleFileExtensions,
250+
isFileModule: (relativePath: string) => false
251+
};
252+
}
253+
if (!hasNonModules) {
254+
return {
255+
allFileExtensions: moduleFileExtensions,
256+
isFileModule: (relativePath: string) => true
257+
};
258+
}
259+
260+
const extensionClassifier: Map<string, boolean> = new Map();
261+
for (const extension of moduleFileExtensions) {
262+
const normalizedExtension: string = extension.startsWith('.') ? extension : `.${extension}`;
263+
extensionClassifier.set(normalizedExtension, true);
264+
}
265+
266+
for (const extension of nonModuleFileExtensions) {
267+
const normalizedExtension: string = extension.startsWith('.') ? extension : `.${extension}`;
268+
const existingClassification: boolean | undefined = extensionClassifier.get(normalizedExtension);
269+
if (existingClassification === true) {
270+
throw new Error(
271+
`File extension "${normalizedExtension}" is declared as both a SCSS module and not an SCSS module.`
272+
);
273+
}
274+
extensionClassifier.set(normalizedExtension, false);
275+
}
276+
277+
Sort.sortMapKeys(extensionClassifier, (key1, key2) => {
278+
// Order by length, descending, so the longest gets tested first.
279+
return key2.length - key1.length;
280+
});
281+
282+
const isFileModule: (relativePath: string) => boolean = (relativePath: string) => {
283+
// Naive comparison algorithm. O(E), where E is the number of extensions
284+
// If performance becomes an issue, switch to using LookupByPath with a reverse iteration order using `.` as the delimiter
285+
for (const [extension, isExtensionModule] of extensionClassifier) {
286+
if (relativePath.endsWith(extension)) {
287+
return isExtensionModule;
288+
}
289+
}
290+
throw new Error(`Could not classify ${relativePath} as a SCSS module / not an SCSS module`);
291+
};
292+
293+
return {
294+
allFileExtensions: [...extensionClassifier.keys()],
295+
isFileModule
296+
};
297+
}

heft-plugins/heft-sass-plugin/src/schemas/heft-sass-plugin.schema.json

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,16 @@
5151

5252
"fileExtensions": {
5353
"type": "array",
54-
"description": "Files with these extensions will pass through the Sass transpiler for typings generation.",
54+
"description": "Files with these extensions will be treated as SCSS modules and pass through the Sass transpiler for typings generation.",
55+
"items": {
56+
"type": "string",
57+
"pattern": "^\\.[A-z0-9-_.]*[A-z0-9-_]+$"
58+
}
59+
},
60+
61+
"nonModuleFileExtensions": {
62+
"type": "array",
63+
"description": "Files with these extensions will be treated as non-module SCSS and pass through the Sass transpiler for typings generation.",
5564
"items": {
5665
"type": "string",
5766
"pattern": "^\\.[A-z0-9-_.]*[A-z0-9-_]+$"

0 commit comments

Comments
 (0)
0