From 18118aeaef63e51a3e3beff54fa5e023fc7ee917 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Kvasni=C4=8D=C3=A1k?= Date: Mon, 28 Mar 2016 16:21:45 +0200 Subject: [PATCH] accumulate generated css styles for imported/required css files Fixes #5 Replaces #6 It is helpful to release a library that uses css-modules. To combine all css files in a single file, give its name: ``` { "plugins": [ [ "css-modules-transform", { "extractCss": "./dist/stylesheets/combined.css" } ] ] } ``` To extract all files in a single directory, give an object: ``` { "plugins": [ [ "css-modules-transform", { "extractCss": { dir: "./dist/stylesheets/", relativeRoot: "./src/", filename: "[path]/[name].css" } } ] ] } ``` --- README.md | 53 ++++- package.json | 6 +- src/index.js | 125 ++++++++-- test/fixtures/exctractcss.include.js | 1 + test/fixtures/exctractcss.main.expected.js | 3 + test/fixtures/exctractcss.main.js | 2 + .../fixtures/extractcss.combined.expected.css | 6 + test/fixtures/extractcss.parent.expected.css | 3 + test/fixtures/extractcss.styles.expected.css | 3 + test/fixtures/import.expected.js | 2 +- test/index.spec.js | 222 ++++++++++++++++-- 11 files changed, 383 insertions(+), 43 deletions(-) create mode 100644 test/fixtures/exctractcss.include.js create mode 100644 test/fixtures/exctractcss.main.expected.js create mode 100644 test/fixtures/exctractcss.main.js create mode 100644 test/fixtures/extractcss.combined.expected.css create mode 100644 test/fixtures/extractcss.parent.expected.css create mode 100644 test/fixtures/extractcss.styles.expected.css diff --git a/README.md b/README.md index f622674..5e062aa 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,7 @@ npm install --save-dev babel-plugin-css-modules-transform "npm-module-name", "./path/to/module-exporting-a-function.js" ], + "extractCss": "./dist/stylesheets/combined.css" } ] ] @@ -116,6 +117,54 @@ and then add any relevant extensions to your plugin config: ``` +## Extract CSS Files + +When you publish a library, you might want to ship compiled css files as well to +help integration in other projects. + +An more complete alternative is to use +[babel-plugin-webpack-loaders](https://github.com/istarkov/babel-plugin-webpack-loaders) +but be aware that a new webpack instance is run for each css file, this has a +huge overhead. If you do not use fancy stuff, you might consider using +[babel-plugin-css-modules-transform](https://github.com/michalkvasnicak/babel-plugin-css-modules-transform) +instead. + + +To combine all css files in a single file, give its name: + +``` +{ + "plugins": [ + [ + "css-modules-transform", { + "extractCss": "./dist/stylesheets/combined.css" + } + ] + ] +} +``` + +To extract all files in a single directory, give an object: + +``` +{ + "plugins": [ + [ + "css-modules-transform", { + "extractCss": { + dir: "./dist/stylesheets/", + relativeRoot: "./src/", + filename: "[path]/[name].css" + } + } + ] + ] +} +``` + +Note that `relativeRoot` is used to resolve relative directory names, available +as `[path]` in `filename` pattern. + ## Using a `babel-register` Make sure you set `ignore` option of `babel-register` to ignore all files used by css-modules-require-hook to process your css files. @@ -124,7 +173,7 @@ Make sure you set `ignore` option of `babel-register` to ignore all files used b ```js require('babel-register')({ - ignore: /processCss\.js$/ // regex matching all files used by css-modules-require-hook to process your css files + ignore: /processCss\.js$/ // regex matching all files used by css-modules-require-hook to process your css files }) ``` @@ -134,7 +183,7 @@ Create a js file with content ```js require('babel-register')({ - ignore: /processCss\.js$/ // regex matching all files used by css-modules-require-hook to process your css files + ignore: /processCss\.js$/ // regex matching all files used by css-modules-require-hook to process your css files }) ``` diff --git a/package.json b/package.json index a075f17..4438718 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,8 @@ }, "homepage": "https://github.com/michalkvasnicak/babel-plugin-css-modules-transform#readme", "dependencies": { - "css-modules-require-hook": "^3.0.0" + "css-modules-require-hook": "^3.0.0", + "mkdirp": "^0.5.1" }, "devDependencies": { "babel-cli": "^6.1.18", @@ -49,7 +50,8 @@ "postcss-modules-extract-imports": "^1.x", "postcss-modules-local-by-default": "^1.x", "postcss-modules-scope": "^1.x", - "postcss-modules-values": "^1.x" + "postcss-modules-values": "^1.x", + "rimraf": "^2.5.4" }, "engines": { "node": ">=4.0.0" diff --git a/src/index.js b/src/index.js index ce0ccd6..3544f08 100644 --- a/src/index.js +++ b/src/index.js @@ -1,4 +1,22 @@ -import { resolve, dirname, isAbsolute } from 'path'; +import { resolve, dirname, basename, extname, isAbsolute, join, relative } from 'path'; + +import mkdirp from 'mkdirp'; +// * +import { writeFileSync, appendFileSync } from 'fs'; +/* / +const writeFileSync = (file, content) => { + console.log(`Will save ${file}\n${content.replace(/^/gm, ' ')}`); +}; +// */ + +const writeCssFile = (filename, content) => { + mkdirp.sync(dirname(filename)); + writeFileSync(filename, content); +}; +const appendCssFile = (filename, content) => { + mkdirp.sync(dirname(filename)); + appendFileSync(filename, content); +}; const simpleRequires = [ 'createImportedName', @@ -52,17 +70,78 @@ export default function transformCssModules({ types: t }) { return { visitor: { - Program(path, { opts }) { + Program(path, state) { if (initialized) { return; } - const currentConfig = { ...defaultOptions, ...opts }; + const currentConfig = { ...defaultOptions, ...state.opts }; + // this is not a css-require-ook config + delete currentConfig.extractCss; // match file extensions, speeds up transform by creating one // RegExp ahead of execution time matchExtensions = matcher(currentConfig.extensions); + // Add a space in current state for css filenames + state.$$css = { + styles: new Map() + }; + + const extractCssFile = (filepath, css) => { + const { extractCss = null } = state.opts; + if (!extractCss) return null; + + // this is the case where a single extractCss is requested + if (typeof(extractCss) === 'string') { + // If this is the first file, then we should replace + // old content + if (state.$$css.styles.size === 1) { + return writeCssFile(extractCss, css); + } + // this should output in a single file. + // Let's append the new file content. + return appendCssFile(extractCss, css); + } + + // This is the case where each css file is written in + // its own file. + const { + dir = 'dist', + filename = '[name].css', + relativeRoot = '' + } = extractCss; + + // Make css file narmpe relative to relativeRoot + const relativePath = relative( + resolve(process.cwd(), relativeRoot), + filepath + ); + const destination = join( + resolve(process.cwd(), dir), + filename + ) + .replace(/\[name]/, basename(filepath, extname(filepath))) + .replace(/\[path]/, relativePath); + + writeCssFile(destination, css); + }; + + const pushStylesCreator = (toWrap) => (css, filepath) => { + let processed; + if (typeof toWrap === 'function') { + processed = toWrap(css, filepath); + } + if (typeof processed !== 'string') processed = css; + + if (!state.$$css.styles.has(filepath)) { + state.$$css.styles.set(filepath, processed); + extractCssFile(filepath, processed); + } + + return processed; + }; + // check if there are simple requires and if they are functions simpleRequires.forEach(key => { if (typeof currentConfig[key] !== 'string') { @@ -108,6 +187,9 @@ export default function transformCssModules({ types: t }) { } }); + // wrap or define processCss function that collect generated css + currentConfig.processCss = pushStylesCreator(currentConfig.processCss); + complexRequires.forEach(key => { if (!currentConfig.hasOwnProperty(key)) { return; @@ -142,6 +224,18 @@ export default function transformCssModules({ types: t }) { initialized = true; }, + ImportDeclaration(path, { file }) { + // this method is called between enter and exit, so we can map css to our state + // it is then replaced with require call which will be handled in seconds pass by CallExpression + // CallExpression will then replace it or remove depending on parent node (if is Program or not) + const { value } = path.node.source; + + if (matchExtensions.test(value)) { + const requiringFile = file.opts.filename; + requireCssFile(requiringFile, value); + } + }, + CallExpression(path, { file }) { const { callee: { name: calleeName }, arguments: args } = path.node; @@ -152,25 +246,24 @@ export default function transformCssModules({ types: t }) { const [{ value: stylesheetPath }] = args; if (matchExtensions.test(stylesheetPath)) { - // if parent expression is variable declarator, replace right side with tokens - if (!t.isVariableDeclarator(path.parent)) { - throw new Error( - `You can't import css file ${stylesheetPath} to a module scope.` - ); - } - const requiringFile = file.opts.filename; const tokens = requireCssFile(requiringFile, stylesheetPath); - /* eslint-disable new-cap */ - path.replaceWith(t.ObjectExpression( + // if parent expression is not a Program, replace expression with tokens + // Otherwise remove require from file, we just want to get generated css for our output + if (!t.isExpressionStatement(path.parent)) { + /* eslint-disable new-cap */ + path.replaceWith(t.ObjectExpression( Object.keys(tokens).map( token => t.ObjectProperty( - t.StringLiteral(token), - t.StringLiteral(tokens[token]) + t.StringLiteral(token), + t.StringLiteral(tokens[token]) + ) ) - ) - )); + )); + } else { + path.remove(); + } } } } diff --git a/test/fixtures/exctractcss.include.js b/test/fixtures/exctractcss.include.js new file mode 100644 index 0000000..7944933 --- /dev/null +++ b/test/fixtures/exctractcss.include.js @@ -0,0 +1 @@ +require('../styles.css'); diff --git a/test/fixtures/exctractcss.main.expected.js b/test/fixtures/exctractcss.main.expected.js new file mode 100644 index 0000000..1e36ade --- /dev/null +++ b/test/fixtures/exctractcss.main.expected.js @@ -0,0 +1,3 @@ +'use strict'; + +require('./exctractcss.include.js'); diff --git a/test/fixtures/exctractcss.main.js b/test/fixtures/exctractcss.main.js new file mode 100644 index 0000000..7e78b02 --- /dev/null +++ b/test/fixtures/exctractcss.main.js @@ -0,0 +1,2 @@ +require('./exctractcss.include.js'); +require('../parent.css'); diff --git a/test/fixtures/extractcss.combined.expected.css b/test/fixtures/extractcss.combined.expected.css new file mode 100644 index 0000000..dc3c29d --- /dev/null +++ b/test/fixtures/extractcss.combined.expected.css @@ -0,0 +1,6 @@ +.parent__block___33Sxl { + display: block; +} +.styles__className___385m0 { + color: red; +} diff --git a/test/fixtures/extractcss.parent.expected.css b/test/fixtures/extractcss.parent.expected.css new file mode 100644 index 0000000..d5d6cc7 --- /dev/null +++ b/test/fixtures/extractcss.parent.expected.css @@ -0,0 +1,3 @@ +.parent__block___33Sxl { + display: block; +} diff --git a/test/fixtures/extractcss.styles.expected.css b/test/fixtures/extractcss.styles.expected.css new file mode 100644 index 0000000..ef8049c --- /dev/null +++ b/test/fixtures/extractcss.styles.expected.css @@ -0,0 +1,3 @@ +.styles__className___385m0 { + color: red; +} diff --git a/test/fixtures/import.expected.js b/test/fixtures/import.expected.js index c235411..c2d60df 100644 --- a/test/fixtures/import.expected.js +++ b/test/fixtures/import.expected.js @@ -6,4 +6,4 @@ var _styles = { var _styles2 = _interopRequireDefault(_styles); -function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } \ No newline at end of file +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } diff --git a/test/index.spec.js b/test/index.spec.js index b3b8e49..0ead100 100644 --- a/test/index.spec.js +++ b/test/index.spec.js @@ -1,14 +1,15 @@ import { expect } from 'chai'; -import { resolve, join } from 'path'; +import { resolve, join, relative } from 'path'; import { readFileSync } from 'fs'; import gulpUtil from 'gulp-util'; -import gulpBabel from 'gulp-babel'; +import rimraf from 'rimraf'; describe('babel-plugin-css-modules-transform', () => { function transform(path, configuration = {}) { // remove css modules transform plugin (simulates clean processes) delete require.cache[resolve(__dirname, '../src/index.js')]; const babel = require('babel-core'); + if (configuration && !('devMode' in configuration)) configuration.devMode = true; return babel.transformFileSync(resolve(__dirname, path), { plugins: [ @@ -24,6 +25,27 @@ describe('babel-plugin-css-modules-transform', () => { }); } + function createBabelStream(configuration = {}) { + // remove css modules transform plugin (simulates clean processes) + delete require.cache[resolve(__dirname, '../src/index.js')]; + const gulpBabel = require('gulp-babel'); + // set css-modules-require-hook in dev to clear cache + if (configuration && !('devMode' in configuration)) configuration.devMode = true; + + return gulpBabel({ + plugins: [ + 'transform-strict-mode', + 'transform-es2015-parameters', + 'transform-es2015-destructuring', + 'transform-es2015-modules-commonjs', + 'transform-object-rest-spread', + 'transform-es2015-spread', + 'transform-export-extensions', + ['../../src/index.js', configuration] + ] + }); + } + function readExpected(path) { // We trim the contents of the file so that we don't have // to deal with newline issues, since some text editors @@ -33,14 +55,18 @@ describe('babel-plugin-css-modules-transform', () => { return readFileSync(resolve(__dirname, path), 'utf8').trim(); } - it('should throw if we are requiring css module to module scope', () => { - expect(() => transform('fixtures/global.require.js')).to.throw( - /^.+: You can't import css file .+ to a module scope\.$/ - ); + beforeEach((done) => { + rimraf(`${__dirname}/output/`, done); + }); - expect(() => transform('fixtures/global.import.js')).to.throw( - /^.+: You can't import css file .+ to a module scope\.$/ - ); + after((done) => { + rimraf(`${__dirname}/output/`, done); + }); + + it('should not throw if we are requiring css module to module scope', () => { + expect(() => transform('fixtures/global.require.js')).to.not.throw(); + + expect(() => transform('fixtures/global.import.js')).to.not.throw(); }); it('should throw if generateScopeName is not exporting a function', () => { @@ -113,18 +139,7 @@ describe('babel-plugin-css-modules-transform', () => { }); it('should replace require call with hash of class name => css class name via gulp', (cb) => { - const stream = gulpBabel({ - plugins: [ - 'transform-strict-mode', - 'transform-es2015-parameters', - 'transform-es2015-destructuring', - 'transform-es2015-modules-commonjs', - 'transform-object-rest-spread', - 'transform-es2015-spread', - 'transform-export-extensions', - ['../../src/index.js', {}] - ] - }); + const stream = createBabelStream({}); stream.on('data', (file) => { expect(file.contents.toString()).to.be.equal(readExpected('fixtures/import.expected.js')); @@ -135,7 +150,7 @@ describe('babel-plugin-css-modules-transform', () => { stream.write(new gulpUtil.File({ cwd: __dirname, base: join(__dirname, 'fixtures'), - path: join(__dirname, 'fixtures/require.js'), + path: join(__dirname, 'fixtures/import.js'), contents: readFileSync(join(__dirname, 'fixtures/import.js')) })); @@ -145,4 +160,167 @@ describe('babel-plugin-css-modules-transform', () => { it('should accept file extensions as an array', () => { expect(transform('fixtures/extensions.js', {extensions: ['.scss', '.css']}).code).to.be.equal(readExpected('fixtures/extensions.expected.js')); }); + + it('should write a multiple css files using import', () => { + expect(transform('fixtures/import.js', { + extractCss: { + dir: `${__dirname}/output/`, + filename: '[name].css', + relativeRoot: `${__dirname}` + } + }).code).to.be.equal(readExpected('fixtures/import.expected.js')); + + expect(readExpected(`${__dirname}/output/parent.css`)) + .to.be.equal(readExpected('fixtures/extractcss.parent.expected.css')); + expect(readExpected(`${__dirname}/output/styles.css`)) + .to.be.equal(readExpected('fixtures/extractcss.styles.expected.css')); + }); + + it('should write a multiple css files using require', () => { + expect(transform('fixtures/require.js', { + extractCss: { + dir: `${__dirname}/output/`, + filename: '[name].css', + relativeRoot: `${__dirname}` + } + }).code).to.be.equal(readExpected('fixtures/require.expected.js')); + + expect(readExpected(`${__dirname}/output/parent.css`)) + .to.be.equal(readExpected('fixtures/extractcss.parent.expected.css')); + expect(readExpected(`${__dirname}/output/styles.css`)) + .to.be.equal(readExpected('fixtures/extractcss.styles.expected.css')); + }); + + it('should write a single css file using import', () => { + expect(transform('fixtures/import.js', { + extractCss: `${__dirname}/output/combined.css` + }).code).to.be.equal(readExpected('fixtures/import.expected.js')); + + expect(readExpected(`${__dirname}/output/combined.css`)) + .to.be.equal(readExpected('fixtures/extractcss.combined.expected.css')); + }); + + it('should write a single css file using require', () => { + expect(transform('fixtures/require.js', { + extractCss: `${__dirname}/output/combined.css` + }).code).to.be.equal(readExpected('fixtures/require.expected.js')); + + expect(readExpected(`${__dirname}/output/combined.css`)) + .to.be.equal(readExpected('fixtures/extractcss.combined.expected.css')); + }); + + it('should extract styles with a single input file via gulp', (cb) => { + const stream = createBabelStream({ + extractCss: `${__dirname}/output/combined.css` + }); + + stream.on('data', (file) => { + expect(file.contents.toString()).to.be.equal(readExpected('fixtures/exctractcss.main.expected.js')); + }); + + stream.on('end', (err) => { + if (err) return cb(err); + expect(readExpected(`${__dirname}/output/combined.css`)) + .to.be.equal(readExpected('fixtures/extractcss.parent.expected.css')); + + return cb(); + }); + + stream.write(new gulpUtil.File({ + cwd: __dirname, + base: join(__dirname, 'fixtures'), + path: join(__dirname, 'fixtures/exctractcss.main.js'), + contents: readFileSync(join(__dirname, 'fixtures/exctractcss.main.js')) + })); + + stream.end(); + }); + + it('should extract multiple files via gulp', (cb) => { + const stream = createBabelStream({ + extractCss: { + dir: `${__dirname}/output/`, + filename: '[name].css', + relativeRoot: `${__dirname}` + } + }); + + // it seems that a data function is required + stream.on('data', () => {}); + + stream.on('end', (err) => { + if (err) return cb(err); + + expect(readExpected(`${__dirname}/output/parent.css`)) + .to.be.equal(readExpected('fixtures/extractcss.parent.expected.css')); + expect(readExpected(`${__dirname}/output/styles.css`)) + .to.be.equal(readExpected('fixtures/extractcss.styles.expected.css')); + + return cb(); + }); + + stream.write(new gulpUtil.File({ + cwd: __dirname, + base: join(__dirname, 'fixtures'), + path: join(__dirname, 'fixtures/exctractcss.main.js'), + contents: readFileSync(join(__dirname, 'fixtures/exctractcss.main.js')) + })); + + stream.write(new gulpUtil.File({ + cwd: __dirname, + base: join(__dirname, 'fixtures'), + path: join(__dirname, 'fixtures/exctractcss.include.js'), + contents: readFileSync(join(__dirname, 'fixtures/exctractcss.include.js')) + })); + + stream.end(); + }); + + it('should extract combined files via gulp', (cb) => { + const stream = createBabelStream({ + extractCss: `${__dirname}/output/combined.css` + }); + + // it seems that a data function is required + stream.on('data', () => {}); + + stream.on('end', (err) => { + if (err) return cb(err); + + expect(readExpected(`${__dirname}/output/combined.css`)) + .to.be.equal(readExpected('fixtures/extractcss.combined.expected.css')); + return cb(); + }); + + stream.write(new gulpUtil.File({ + cwd: __dirname, + base: join(__dirname, 'fixtures'), + path: join(__dirname, 'fixtures/exctractcss.main.js'), + contents: readFileSync(join(__dirname, 'fixtures/exctractcss.main.js')) + })); + + stream.write(new gulpUtil.File({ + cwd: __dirname, + base: join(__dirname, 'fixtures'), + path: join(__dirname, 'fixtures/exctractcss.include.js'), + contents: readFileSync(join(__dirname, 'fixtures/exctractcss.include.js')) + })); + + stream.end(); + }); + + it('should call custom preprocess', () => { + const called = []; + expect(transform('fixtures/require.js', { + extractCss: `${__dirname}/output/combined.css`, + processCss(css, filename) { + called.push(relative(__dirname, filename)); + return css; + } + }).code).to.be.equal(readExpected('fixtures/require.expected.js')); + expect(called).to.be.deep.equal([ + 'parent.css', + 'styles.css' + ]); + }); });