diff --git a/.gitignore b/.gitignore index 0542088f0ac..48bfc992e9c 100644 --- a/.gitignore +++ b/.gitignore @@ -67,3 +67,6 @@ temp # Rush files common/temp/** package-deps.json + +# OS X +.DS_Store diff --git a/common/changes/@microsoft/gulp-core-build-sass/jowedeki-hash-fix_2019-05-29-04-35.json b/common/changes/@microsoft/gulp-core-build-sass/jowedeki-hash-fix_2019-05-29-04-35.json new file mode 100644 index 00000000000..945d187c07b --- /dev/null +++ b/common/changes/@microsoft/gulp-core-build-sass/jowedeki-hash-fix_2019-05-29-04-35.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/gulp-core-build-sass", + "comment": "Make css modules class hash names consistent relative to root path.", + "type": "minor" + } + ], + "packageName": "@microsoft/gulp-core-build-sass", + "email": "halfnibble@users.noreply.github.com" +} \ No newline at end of file diff --git a/core-build/gulp-core-build-sass/config/jest.json b/core-build/gulp-core-build-sass/config/jest.json new file mode 100644 index 00000000000..b4a7ec97a56 --- /dev/null +++ b/core-build/gulp-core-build-sass/config/jest.json @@ -0,0 +1,3 @@ +{ + "isEnabled": true +} \ No newline at end of file diff --git a/core-build/gulp-core-build-sass/gulpfile.js b/core-build/gulp-core-build-sass/gulpfile.js index 37aa39ec67b..296eccbf8a6 100644 --- a/core-build/gulp-core-build-sass/gulpfile.js +++ b/core-build/gulp-core-build-sass/gulpfile.js @@ -1,5 +1,5 @@ 'use strict'; let build = require('@microsoft/node-library-build'); -build.mocha.enabled = false; + build.initialize(require('gulp')); diff --git a/core-build/gulp-core-build-sass/package.json b/core-build/gulp-core-build-sass/package.json index 92de9db2436..8af21fb258b 100644 --- a/core-build/gulp-core-build-sass/package.json +++ b/core-build/gulp-core-build-sass/package.json @@ -13,7 +13,7 @@ "url": "https://github.com/Microsoft/web-build-tools/tree/master/core-build/gulp-core-build-sass" }, "scripts": { - "build": "gulp --clean" + "build": "gulp test --clean" }, "dependencies": { "@microsoft/gulp-core-build": "3.9.26", @@ -34,6 +34,8 @@ "@types/glob": "5.0.30", "@types/node-sass": "3.10.32", "@types/clean-css": "4.2.1", - "gulp": "~3.9.1" + "gulp": "~3.9.1", + "@types/jest": "23.3.11", + "jest": "~23.6.0" } } diff --git a/core-build/gulp-core-build-sass/src/CSSModules.ts b/core-build/gulp-core-build-sass/src/CSSModules.ts new file mode 100644 index 00000000000..66d0cc1bbdc --- /dev/null +++ b/core-build/gulp-core-build-sass/src/CSSModules.ts @@ -0,0 +1,70 @@ +import * as path from 'path'; + +import * as postcss from 'postcss'; +import * as cssModules from 'postcss-modules'; +import * as crypto from 'crypto'; + +export interface IClassMap { + [className: string]: string; +} + +export interface ICSSModules { + /** + * Return a configured postcss plugin that will map class names to a + * consistently generated scoped name. + */ + getPlugin(): postcss.AcceptedPlugin; + + /** + * Return the CSS class map that is stored after postcss-modules runs. + */ + getClassMap(): IClassMap; +} + +export default class CSSModules implements ICSSModules { + private _classMap: IClassMap; + private _rootPath: string; + + /** + * CSSModules includes the source file's path relative to the project root + * as part of the class name hashing algorithm. + * This should be configured with the setting: + * {@link @microsoft/gulp-core-build#IBuildConfig.rootPath} + * That is used in {@link ./SassTask#SassTask} + * But will default the process' current working dir. + */ + constructor(rootPath?: string) { + this._classMap = {}; + if (rootPath) { + this._rootPath = rootPath; + } else { + this._rootPath = process.cwd(); + } + } + + public getPlugin(): postcss.AcceptedPlugin { + return cssModules({ + getJSON: this.saveJson.bind(this), + generateScopedName: this.generateScopedName.bind(this) + }); + } + + public getClassMap(): IClassMap { + return this._classMap; + } + + protected saveJson(cssFileName: string, json: IClassMap): void { + this._classMap = json; + } + + protected generateScopedName(name: string, fileName: string, css: string) + : string { + const fileBaseName: string = path.relative(this._rootPath, fileName); + const safeFileBaseName: string = fileBaseName.replace(/\\/g, '/'); + const hash: string = crypto.createHmac('sha1', safeFileBaseName) + .update(css) + .digest('hex') + .substring(0, 8); + return `${name}_${hash}`; + } +} diff --git a/core-build/gulp-core-build-sass/src/SassTask.ts b/core-build/gulp-core-build-sass/src/SassTask.ts index 5d32e3e6a23..17d43f7feb3 100644 --- a/core-build/gulp-core-build-sass/src/SassTask.ts +++ b/core-build/gulp-core-build-sass/src/SassTask.ts @@ -17,8 +17,7 @@ import * as nodeSass from 'node-sass'; import * as postcss from 'postcss'; import * as CleanCss from 'clean-css'; import * as autoprefixer from 'autoprefixer'; -import * as cssModules from 'postcss-modules'; -import * as crypto from 'crypto'; +import CSSModules, { ICSSModules, IClassMap } from './CSSModules'; export interface ISassTaskConfig { /** @@ -27,7 +26,8 @@ export interface ISassTaskConfig { preamble?: string; /** - * An optional parameter for text to include at the end of the generated TypeScript file. + * An optional parameter for text to include at the end of the generated + * TypeScript file. */ postamble?: string; @@ -37,47 +37,48 @@ export interface ISassTaskConfig { sassMatch?: string[]; /** - * If this option is specified, ALL files will be treated as module.sass or module.scss and will - * automatically generate a corresponding TypeScript file. All classes will be - * appended with a hash to help ensure uniqueness on a page. This file can be - * imported directly, and will contain an object describing the mangled class names. + * If this option is specified, ALL files will be treated as module.sass or + * module.scss and will automatically generate a corresponding TypeScript + * file. All classes will be appended with a hash to help ensure uniqueness + * on a page. This file can be imported directly, and will contain an object + * describing the mangled class names. */ useCSSModules?: boolean; /** - * If false, we will set the CSS property naming warning to verbose message while the module generates - * to prevent task exit with exitcode: 1. - * Default value is true + * If false, we will set the CSS property naming warning to verbose message + * while the module generates to prevent task exit with exitcode: 1. + * Default value is true. */ warnOnCssInvalidPropertyName?: boolean; /** - * If true, we will generate a CSS in the lib folder. If false, the CSS is directly embedded - * into the TypeScript file + * If true, we will generate CSS in the lib folder. If false, the CSS is + * directly embedded into the TypeScript file. */ dropCssFiles?: boolean; /** - * If files are matched by sassMatch which do not end in .module.sass or .module.scss, log a warning. + * If files are matched by sassMatch which do not end in .module.sass or + * .module.scss, log a warning. */ warnOnNonCSSModules?: boolean; /** - * If this option is specified, module CSS will be exported using the name provided. If an - * empty value is specified, the styles will be exported using 'export =', rather than a - * named export. By default we use the 'default' export name. + * If this option is specified, module CSS will be exported using the name + * provided. If an empty value is specified, the styles will be exported + * using 'export =', rather than a named export. By default, we use the + * 'default' export name. */ moduleExportName?: string; /** - * Allows the override of the options passed to clean-css. Options such a returnPromise and - * sourceMap will be ignored. + * Allows the override of the options passed to clean-css. Options such a + * returnPromise and sourceMap will be ignored. */ cleanCssOptions?: CleanCss.Options; } -const _classMaps: { [file: string]: Object } = {}; - export class SassTask extends GulpTask { public cleanMatch: string[] = [ 'src/**/*.sass.ts', @@ -88,13 +89,6 @@ export class SassTask extends GulpTask { autoprefixer({ browsers: ['> 1%', 'last 2 versions', 'ie >= 10'] }) ]; - private _modulePostCssAdditionalPlugins: postcss.AcceptedPlugin[] = [ - cssModules({ - getJSON: this._generateModuleStub.bind(this), - generateScopedName: this._generateScopedName.bind(this) - }) - ]; - constructor() { super( 'sass', @@ -127,14 +121,6 @@ export class SassTask extends GulpTask { }).then(() => { /* collapse void[] to void */ }); } - private _generateModuleStub(cssFileName: string, json: Object): void { - _classMaps[cssFileName] = json; - } - - private _generateScopedName(name: string, fileName: string, css: string): string { - return name + '_' + crypto.createHmac('sha1', fileName).update(css).digest('hex').substring(0, 8); - } - private _processFile(filePath: string): Promise { // Ignore files that start with underscores if (path.basename(filePath).match(/^\_/)) { @@ -143,11 +129,15 @@ export class SassTask extends GulpTask { const isFileModuleCss: boolean = !!filePath.match(/\.module\.s(a|c)ss/); const processAsModuleCss: boolean = isFileModuleCss || !!this.taskConfig.useCSSModules; + const cssModules: ICSSModules = new CSSModules(this.buildConfig.rootPath); - if (!isFileModuleCss && !this.taskConfig.useCSSModules && this.taskConfig.warnOnNonCSSModules) { - // If the file doesn't end with .module.scss and we don't treat all files as module-scss, warn - const relativeFilePath: string = path.relative(this.buildConfig.rootPath, filePath); - this.logWarning(`${relativeFilePath}: filename should end with module.sass or module.scss`); + if (!processAsModuleCss && this.taskConfig.warnOnNonCSSModules) { + const relativeFilePath: string = path.relative( + this.buildConfig.rootPath, filePath + ); + this.logWarning( + `${relativeFilePath}: filename should end with module.sass or module.scss` + ); } let cssOutputPath: string | undefined = undefined; @@ -185,10 +175,10 @@ export class SassTask extends GulpTask { }; } - const plugins: postcss.AcceptedPlugin[] = [ - ...this._postCSSPlugins, - ...(processAsModuleCss ? this._modulePostCssAdditionalPlugins : []) - ]; + const plugins: postcss.AcceptedPlugin[] = [...this._postCSSPlugins]; + if (processAsModuleCss) { + plugins.push(cssModules.getPlugin()); + } return postcss(plugins).process(result.css.toString(), options) as PromiseLike; }).then((result: postcss.Result) => { let cleanCssOptions: CleanCss.Options = { level: 1, returnPromise: true }; @@ -206,76 +196,36 @@ export class SassTask extends GulpTask { ]; if (result.sourceMap && !this.buildConfig.production) { const encodedSourceMap: string = Buffer.from(result.sourceMap.toString()).toString('base64'); - generatedFileLines.push(...[ + generatedFileLines.push( `/*# sourceMappingURL=data:application/json;base64,${encodedSourceMap} */` - ]); + ); } - FileSystem.writeFile(cssOutputPathAbsolute, generatedFileLines.join(EOL), { ensureFolderExists: true }); + FileSystem.writeFile( + cssOutputPathAbsolute, + generatedFileLines.join(EOL), + { ensureFolderExists: true } + ); } const scssTsOutputPath: string = `${filePath}.ts`; - const classNames: Object = _classMaps[filePath]; - let exportClassNames: string = ''; + const classMap: IClassMap = cssModules.getClassMap(); + const stylesExportString: string = this._getStylesExportString(classMap); const content: string | undefined = result.styles; - if (classNames) { - const classNamesLines: string[] = [ - 'const styles = {' - ]; - - const classKeys: string[] = Object.keys(classNames); - classKeys.forEach((key: string, index: number) => { - const value: string = classNames[key]; - let line: string = ''; - if (key.indexOf('-') !== -1) { - const message: string = `The local CSS class '${key}' is not camelCase and will not be type-safe.`; - this.taskConfig.warnOnCssInvalidPropertyName ? - this.logWarning(message) : - this.logVerbose(message); - line = ` '${key}': '${value}'`; - } else { - line = ` ${key}: '${value}'`; - } - - if ((index + 1) <= classKeys.length) { - line += ','; - } - - classNamesLines.push(line); - }); - - let exportString: string = 'export default styles;'; - - if (this.taskConfig.moduleExportName === '') { - exportString = 'export = styles;'; - } else if (!!this.taskConfig.moduleExportName) { - // exportString = `export const ${this.taskConfig.moduleExportName} = styles;`; - } - - classNamesLines.push( - '};', - '', - exportString - ); - - exportClassNames = classNamesLines.join(EOL); - } - let lines: string[] = []; - lines.push(this.taskConfig.preamble || ''); if (cssOutputPathAbsolute) { lines = lines.concat([ `require(${JSON.stringify(`./${path.basename(cssOutputPathAbsolute)}`)});`, - exportClassNames + stylesExportString ]); } else if (!!content) { lines = lines.concat([ 'import { loadStyles } from \'@microsoft/load-themed-styles\';', '', - exportClassNames, + stylesExportString, '', `loadStyles(${JSON.stringify(splitStyles(content))});` ]); @@ -322,4 +272,39 @@ export class SassTask extends GulpTask { return url; } + + private _getStylesExportString(classMap: IClassMap): string { + const classKeys: string[] = Object.keys(classMap); + const styleLines: string[] = []; + classKeys.forEach((key: string) => { + const value: string = classMap[key]; + if (key.indexOf('-') !== -1) { + const message: string = `The local CSS class '${key}' is not ` + + `camelCase and will not be type-safe.`; + if (this.taskConfig.warnOnCssInvalidPropertyName) { + this.logWarning(message); + } else { + this.logVerbose(message); + } + key = `'${key}'`; + } + styleLines.push(` ${key}: '${value}'`); + }); + + let exportString: string = 'export default styles;'; + + if (this.taskConfig.moduleExportName === '') { + exportString = 'export = styles;'; + } else if (!!this.taskConfig.moduleExportName) { + // exportString = `export const ${this.taskConfig.moduleExportName} = styles;`; + } + + return [ + 'const styles = {', + styleLines.join(`,${EOL}`), + '};', + '', + exportString + ].join(EOL); + } } diff --git a/core-build/gulp-core-build-sass/src/test/CSSModules.test.ts b/core-build/gulp-core-build-sass/src/test/CSSModules.test.ts new file mode 100644 index 00000000000..8b16897a927 --- /dev/null +++ b/core-build/gulp-core-build-sass/src/test/CSSModules.test.ts @@ -0,0 +1,90 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. +import * as path from 'path'; + +import CSSModules from '../CSSModules'; + +interface IScopedNameArgs { + name: string; + fileName: string; + css: string; +} + +interface ITestCSSModules { + testGenerateScopedName(name: string, fileName: string, css: string): string; +} + +class TestCSSModules extends CSSModules { + public testGenerateScopedName(name: string, fileName: string, css: string) + : string { + return this.generateScopedName(name, fileName, css); + } +} + +test('will generate different hashes for different content', () => { + const version1: IScopedNameArgs = { + name: 'Button', + fileName: path.join(__dirname, 'Sally', 'src', 'main.sass'), + css: 'color: blue;' + }; + const version2: IScopedNameArgs = { + name: 'Button', + fileName: path.join(__dirname, 'Sally', 'src', 'main.sass'), + css: 'color: pink;' + }; + const cssModules: ITestCSSModules = new TestCSSModules(); + const output1: string = cssModules.testGenerateScopedName( + version1.name, version1.fileName, version1.css + ); + const output2: string = cssModules.testGenerateScopedName( + version2.name, version2.fileName, version2.css + ); + expect(output1).not.toBe(output2); +}); + +test('will generate the same hash in a different root path', () => { + const version1: IScopedNameArgs = { + name: 'Button', + fileName: path.join(__dirname, 'Sally', 'src', 'main.sass'), + css: 'color: blue;' + }; + const version2: IScopedNameArgs = { + name: 'Button', + fileName: path.join(__dirname, 'Suzan', 'workspace', 'src', 'main.sass'), + css: 'color: blue;' + }; + const cssModules: ITestCSSModules = new TestCSSModules( + path.join(__dirname, 'Sally') + ); + const output1: string = cssModules.testGenerateScopedName( + version1.name, version1.fileName, version1.css + ); + const cssModules2: ITestCSSModules = new TestCSSModules( + path.join(__dirname, 'Suzan', 'workspace') + ); + const output2: string = cssModules2.testGenerateScopedName( + version2.name, version2.fileName, version2.css + ); + expect(output1).toBe(output2); +}); + +test('will generate a different hash in a different src path', () => { + const version1: IScopedNameArgs = { + name: 'Button', + fileName: path.join(__dirname, 'Sally', 'src', 'main.sass'), + css: 'color: blue;' + }; + const version2: IScopedNameArgs = { + name: 'Button', + fileName: path.join(__dirname, 'Sally', 'src', 'lib', 'main.sass'), + css: 'color: blue;' + }; + const cssModules: ITestCSSModules = new TestCSSModules(); + const output1: string = cssModules.testGenerateScopedName( + version1.name, version1.fileName, version1.css + ); + const output2: string = cssModules.testGenerateScopedName( + version2.name, version2.fileName, version2.css + ); + expect(output1).not.toBe(output2); +}); diff --git a/core-build/gulp-core-build-sass/tsconfig.json b/core-build/gulp-core-build-sass/tsconfig.json index c690935203f..7ce1b56fed8 100644 --- a/core-build/gulp-core-build-sass/tsconfig.json +++ b/core-build/gulp-core-build-sass/tsconfig.json @@ -1,3 +1,8 @@ { - "extends": "./node_modules/@microsoft/rush-stack-compiler-3.2/includes/tsconfig-node.json" + "extends": "./node_modules/@microsoft/rush-stack-compiler-3.2/includes/tsconfig-node.json", + "compilerOptions": { + "types": [ + "jest" + ] + } }