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' } + ] + } +};