From c1e68acb68b7627f3cf452a06f7f72c973d6b02e Mon Sep 17 00:00:00 2001 From: Tim Sebastian Date: Sat, 17 Sep 2016 12:24:04 +0200 Subject: [PATCH] initial commit --- .babelrc | 3 ++ .editorconfig | 30 ++++++++++++++ .eslintrc | 45 +++++++++++++++++++++ .gitignore | 4 ++ .npmignore | 6 +++ README.md | 71 ++++++++++++++++++++++++++++++++++ jsconfig.json | 8 ++++ package.json | 54 ++++++++++++++++++++++++++ src/cssModuleHelper.js | 39 +++++++++++++++++++ src/cssModuleToInterface.js | 32 +++++++++++++++ src/index.js | 27 +++++++++++++ src/persist.js | 17 ++++++++ test/entry.ts | 4 ++ test/example.css | 7 ++++ test/expected-example.css.d.ts | 7 ++++ test/tsconfig.json | 6 +++ test/webpack.config.babel.js | 13 +++++++ 17 files changed, 373 insertions(+) create mode 100644 .babelrc create mode 100644 .editorconfig create mode 100644 .eslintrc create mode 100644 .gitignore create mode 100644 .npmignore create mode 100644 README.md create mode 100644 jsconfig.json create mode 100644 package.json create mode 100644 src/cssModuleHelper.js create mode 100644 src/cssModuleToInterface.js create mode 100644 src/index.js create mode 100644 src/persist.js create mode 100644 test/entry.ts create mode 100644 test/example.css create mode 100644 test/expected-example.css.d.ts create mode 100644 test/tsconfig.json create mode 100644 test/webpack.config.babel.js diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..11f1df8 --- /dev/null +++ b/.babelrc @@ -0,0 +1,3 @@ +{ + "presets": ["es2015", "stage-3"] +} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..90913b4 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,30 @@ +# http://editorconfig.org + +# A special property that should be specified at the top of the file outside of +# any sections. Set to true to stop .editor config file search on current file +root = true + +[*] +# Indentation style +# Possible values - tab, space +indent_style = space + +# Indentation size in single-spaced characters +# Possible values - an integer, tab +indent_size = 2 + +# Line ending file format +# Possible values - lf, crlf, cr +end_of_line = lf + +# File character encoding +# Possible values - latin1, utf-8, utf-16be, utf-16le +charset = utf-8 + +# Denotes whether to trim whitespace at the end of lines +# Possible values - true, false +trim_trailing_whitespace = true + +# Denotes whether file should end with a newline +# Possible values - true, false +insert_final_newline = true diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..5a1c164 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,45 @@ +{ + "env": { + "es6": true, + "node": true + }, + "extends": "eslint:recommended", + "parser": "babel-eslint", + "plugins": [ + "babel" + ], + "parserOptions": { + "sourceType": "module" + }, + "rules": { + "indent": [ + "error", + 2 + ], + "linebreak-style": [ + "error", + "unix" + ], + "quotes": [ + "error", + "single" + ], + "semi": [ + "error", + "always" + ], + "no-console": [ + 0 + ], + "no-var": [ + "error" + ], + "comma-dangle": [ + "error", + "always" + ], + "no-unused-vars": [ + 2, {"vars": "all", "args": "after-used"} + ] + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0f66358 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules +lib +bundle.js +example.css.d.ts diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..6a5a33a --- /dev/null +++ b/.npmignore @@ -0,0 +1,6 @@ +.babelrc +.eslintrc +.editorconfig +jsconfig.json +src +test diff --git a/README.md b/README.md new file mode 100644 index 0000000..f919669 --- /dev/null +++ b/README.md @@ -0,0 +1,71 @@ +# typings-for-css-modules-loaderg + +Webpack loader that works as a css-loader drop-in replacement to generate TypeScript typings for CSS modules on the fly + +## Installation + +Install via npm `npm install --save-dev typings-for-css-modules-loader` + +## Usage + +Keep your `webpack.config` as is just instead of using `css-loader` use `typings-for-css-modules-loader` +*its important you keep all the params that you used for the css-loader before, as they will be passed along in the process* + +before: +```js +webpackConfig.module.loaders: [ + { test: /\.css$/, loader: 'css?modules' } + { test: /\.scss$/, loader: 'css?modules&sass' } +]; +``` + +after: +```js +webpackConfig.module.loaders: [ + { test: /\.css$/, loader: 'typings-for-css-modules?modules' } + { test: /\.scss$/, loader: 'typings-for-css-modules?modules&sass' } +]; +``` + +## Example + +Imagine you have a file `~/my-project/src/component/MyComponent/component.scss` in your project with the following content: +``` +.some-class { + // some styles + &.someOtherClass { + // some other styles + } + &-sayWhat { + // more styles + } +} +``` + +Adding the `typings-for-css-modules-loader` will generate a file `~/my-project/src/component/MyComponent/mycomponent.scss.d.ts` that has the following content: +``` +export interface IMyComponentScss { + 'some-class': string; + 'someOtherClass': string; + 'some-class-sayWhat': string; +} +declare const styles: IMyComponentScss; + +export default styles; +``` + +### Example in Visual Studio Code +![typed-css-modules](https://cloud.githubusercontent.com/assets/749171/16340497/c1cb6888-3a28-11e6-919b-f2f51a282bba.gif) + +## Support + +As the loader just acts as an intermediary it can handle all kind of css preprocessors (`sass`, `scss`, `stylus`, `less`, ...). +The only requirement is that those preprocessors have proper webpack loaders defined - meaning they can already be loaded by webpack anyways. + +## Requirements + +The loader uses `css-loader`(https://github.com/webpack/css-loader) under the hood. Thus it is a peer-dependency and the expected loader to create CSS Modules. + +## Known issues + + - There may be a lag or a reload necessary when adding a new style-file to your project as the typescript loader may take a while to "find" the new typings file. diff --git a/jsconfig.json b/jsconfig.json new file mode 100644 index 0000000..69ca405 --- /dev/null +++ b/jsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "target": "ES6" + }, + "exclude": [ + "node_modules" + ] +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..e2841df --- /dev/null +++ b/package.json @@ -0,0 +1,54 @@ +{ + "name": "typings-for-css-modules-loader", + "version": "0.0.1", + "description": "Drop-in replacement for css-loader to generate typings for your CSS-Modules on the fly in webpack", + "main": "lib/index.js", + "scripts": { + "build": "babel src -d lib", + "prepublish": "npm run build", + "pretest": "rm -f ./test/example.css.d.ts && touch ./test/example.css.d.ts", + "test:run": "babel-node ./node_modules/webpack/bin/webpack --config ./test/webpack.config.babel.js && diff ./test/example.css.d.ts ./test/expected-example.css.d.ts", + "test": "npm run test:run > /dev/null 2>&1 && npm run test:run" + }, + "author": "Tim Sebastian ", + "license": "MIT", + "keywords": [ + "Typescript", + "TypeScript", + "CSS Modules", + "CSSModules", + "CSS Modules typings", + "Webpack", + "Webpack loader", + "Webpack css module typings loader", + "typescript webpack typings", + "css modules webpack typings" + ], + "dependencies": { + "graceful-fs": "4.1.4" + }, + "devDependencies": { + "babel-cli": "6.10.1", + "babel-eslint": "6.1.0", + "babel-loader": "^6.2.5", + "babel-polyfill": "^6.13.0", + "babel-preset-es2015": "6.9.0", + "babel-preset-stage-0": "6.5.0", + "eslint": "2.13.1", + "eslint-plugin-babel": "3.3.0", + "ts-loader": "^0.8.2", + "typescript": "^1.8.10", + "webpack": "^1.13.2" + }, + "peerDependencies": { + "css-loader": "^0.23.1" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/Jimdo/typings-for-css-modules-loader.git" + }, + "bugs": { + "url": "https://github.com/Jimdo/typings-for-css-modules-loader/issues" + }, + "homepage": "https://github.com/Jimdo/typings-for-css-modules-loader#readme" +} diff --git a/src/cssModuleHelper.js b/src/cssModuleHelper.js new file mode 100644 index 0000000..ba81b5a --- /dev/null +++ b/src/cssModuleHelper.js @@ -0,0 +1,39 @@ +import path from 'path'; +import vm from 'vm'; + +const isCssModule = (module) => { + if (!module || typeof module.request !== 'string') { + return false; + } + + const extname = path.extname(module.request); + return /\/css-loader\//.test(module.request) && extname !== '.js'; +}; + +export const filterCssModules = (modules) => { + return modules.filter(isCssModule); +}; + +export const removeLoadersBeforeCssLoader = (loaders) => { + let sawCssLoader = false; + // remove all loaders before the css-loader + return loaders.filter((loader)=> { + if (loader.indexOf('/css-loader/') > -1) { + sawCssLoader = true; + } + + return sawCssLoader; + }); +}; + +export const extractCssModuleFromSource = (source) => { + const sandbox = { + exports: null, + module: {}, + require: () => () => [], + }; + const script = new vm.Script(source); + const context = new vm.createContext(sandbox); + script.runInContext(context); + return sandbox.exports.locals; +}; diff --git a/src/cssModuleToInterface.js b/src/cssModuleToInterface.js new file mode 100644 index 0000000..c3f2f59 --- /dev/null +++ b/src/cssModuleToInterface.js @@ -0,0 +1,32 @@ +import path from 'path'; + +const filenameToInterfaceName = (filename) => { + return path.basename(filename) + .replace(/^(\w)/, (_, c) => 'I' + c.toUpperCase()) + .replace(/\W+(\w)/g, (_, c) => c.toUpperCase()); +}; + +const cssModuleToTypescriptInterfaceProperties = (cssModuleObject, indent = ' ') => { + return Object.keys(cssModuleObject) + .map((key) => `${indent}'${key}': string;`) + .join('\n'); +}; + +export const filenameToTypingsFilename = (filename) => { + const dirName = path.dirname(filename); + const baseName = path.basename(filename); + return path.join(dirName, `${baseName}.d.ts`); +}; + +export const generateInterface = (cssModuleObject, filename, indent) => { + const interfaceName = filenameToInterfaceName(filename); + const interfaceProperties = cssModuleToTypescriptInterfaceProperties(cssModuleObject, indent); + return ( +`export interface ${interfaceName} { +${interfaceProperties} +} +declare const styles: ${interfaceName}; + +export default styles; +`); +}; diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..2f20871 --- /dev/null +++ b/src/index.js @@ -0,0 +1,27 @@ +import cssLoader from 'css-loader'; +import cssLocalsLoader from 'css-loader/locals'; +import { + generateInterface, + filenameToTypingsFilename, +} from './cssModuleToInterface'; +import * as persist from './persist'; + +module.exports = function(input) { + if(this.cacheable) this.cacheable(); + + // mock async step 1 - css loader is async, we need to intercept this so we get async ourselves + const callback = this.async(); + // mock async step 2 - offer css loader a "fake" callback + this.async = () => (err, content) => { + const cssmodules = this.exec(content); + const requestedResource = this.resourcePath; + + const cssModuleInterfaceFilename = filenameToTypingsFilename(requestedResource); + const cssModuleInterface = generateInterface(cssmodules, requestedResource); + persist.writeToFileIfChanged(cssModuleInterfaceFilename, cssModuleInterface); + // mock async step 3 - make `async` return the actual callback again before calling the 'real' css-loader + this.async = () => callback; + cssLoader.call(this, input); + }; + cssLocalsLoader.call(this, input); +} diff --git a/src/persist.js b/src/persist.js new file mode 100644 index 0000000..5fe2d5b --- /dev/null +++ b/src/persist.js @@ -0,0 +1,17 @@ +import fs from 'graceful-fs'; +import crypto from 'crypto'; + +export const writeToFileIfChanged = (filename, content) => { + try { + const currentInput = fs.readFileSync(filename, 'utf-8'); + const oldHash = crypto.createHash('md5').update(currentInput).digest("hex"); + const newHash = crypto.createHash('md5').update(content).digest("hex"); + // the definitions haven't changed - ignore this + if (oldHash === newHash) { + return false; + } + } catch(e) { + } finally { + fs.writeFileSync(filename, content); + } +}; diff --git a/test/entry.ts b/test/entry.ts new file mode 100644 index 0000000..e944711 --- /dev/null +++ b/test/entry.ts @@ -0,0 +1,4 @@ +import styles from './example.css'; + +const foo = styles.foo; +const barBaz = styles['bar-baz']; diff --git a/test/example.css b/test/example.css new file mode 100644 index 0000000..8942895 --- /dev/null +++ b/test/example.css @@ -0,0 +1,7 @@ +.foo { + color: white; +} + +.bar-baz { + color: green; +} diff --git a/test/expected-example.css.d.ts b/test/expected-example.css.d.ts new file mode 100644 index 0000000..ae90010 --- /dev/null +++ b/test/expected-example.css.d.ts @@ -0,0 +1,7 @@ +export interface IExampleCss { + 'foo': string; + 'bar-baz': string; +} +declare const styles: IExampleCss; + +export default styles; diff --git a/test/tsconfig.json b/test/tsconfig.json new file mode 100644 index 0000000..b666373 --- /dev/null +++ b/test/tsconfig.json @@ -0,0 +1,6 @@ +{ + "compilerOptions": { + "target": "es6", + "noImplicitAny": true + } +} diff --git a/test/webpack.config.babel.js b/test/webpack.config.babel.js new file mode 100644 index 0000000..1c1b578 --- /dev/null +++ b/test/webpack.config.babel.js @@ -0,0 +1,13 @@ +module.exports = { + entry: './test/entry.ts', + output: { + path: __dirname, + filename: 'bundle.js' + }, + module: { + loaders: [ + { test: /\.ts$/, loaders: ['babel', 'ts'] }, + { test: /\.css$/, loader: '../src/index.js?modules' } + ] + } +};