diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/.eslintignore @@ -0,0 +1 @@ +node_modules/ diff --git a/.eslintrc b/.eslintrc new file mode 100755 index 0000000..ef37a41 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,236 @@ +{ + "ecmaFeatures": { + }, + "env": { + "node": true + }, + "rules": { + "comma-dangle": [2, "never"], + "no-cond-assign": 2, + "no-constant-condition": 2, + "no-control-regex": 2, + "no-debugger": 2, + "no-dupe-keys": 2, + "no-empty": 2, + "no-empty-character-class": 2, + "no-ex-assign": 2, + "no-extra-boolean-cast": 2, + "no-extra-parens": 0, + "no-extra-semi": 2, + "no-func-assign": 2, + "no-inner-declarations": 2, + "no-invalid-regexp": 2, + "no-irregular-whitespace": 2, + "no-negated-in-lhs": 2, + "no-obj-calls": 2, + "no-regex-spaces": 2, + "no-sparse-arrays": 2, + "no-unreachable": 2, + "use-isnan": 2, + "valid-typeof": 2, + "block-scoped-var": 0, + "consistent-return": 2, + "curly": [ + 1, + "multi-line" + ], + "default-case": 2, + "dot-notation": 2, + "eqeqeq": 2, + "guard-for-in": 2, + "no-alert": 1, + "no-caller": 2, + "no-div-regex": 2, + "no-eq-null": 2, + "no-eval": 2, + "no-extend-native": 2, + "no-extra-bind": 2, + "no-fallthrough": 2, + "no-floating-decimal": 2, + "no-implied-eval": 2, + "no-iterator": 2, + "no-labels": 2, + "no-lone-blocks": 2, + "no-loop-func": 2, + "no-multi-spaces": 1, + "no-multi-str": 2, + "no-native-reassign": 2, + "no-new": 2, + "no-new-func": 2, + "no-new-wrappers": 2, + "no-octal": 2, + "no-octal-escape": 2, + "no-proto": 2, + "no-redeclare": 2, + "no-return-assign": [2, "except-parens"], + "no-script-url": 2, + "no-self-compare": 2, + "no-sequences": 2, + "no-throw-literal": 2, + "no-unused-expressions": 0, + "no-with": 2, + "radix": 2, + "vars-on-top": 0, + "wrap-iife": 2, + "yoda": [ + 1, + "never" + ], + "strict": [ + 0, + "never" + ], + "no-delete-var": 2, + "no-label-var": 2, + "no-shadow": 2, + "no-shadow-restricted-names": 2, + "no-undef": 2, + "no-undef-init": 2, + "no-undefined": 2, + "no-unused-vars": [ + 2, + "all" + ], + "no-use-before-define": 0, + "handle-callback-err": 2, + "no-mixed-requires": 0, + "no-new-require": 2, + "no-path-concat": 2, + "indent": [ + 1, + 2, + {"SwitchCase": 1} + ], + "brace-style": [ + 1, + "stroustrup", + { + "allowSingleLine": true + } + ], + "comma-spacing": [ + 1, + { + "before": false, + "after": true + } + ], + "comma-style": [ + 2, + "first" + ], + "consistent-this": [ + 1, + "self" + ], + "eol-last": 2, + "func-names": 0, + "key-spacing": [ + 1, + { + "beforeColon": false, + "afterColon": true + } + ], + "max-nested-callbacks": [ + 2, + 3 + ], + "new-cap": 2, + "new-parens": 2, + "no-array-constructor": 0, + "no-inline-comments": 1, + "no-lonely-if": 0, + "no-mixed-spaces-and-tabs": 2, + "no-multiple-empty-lines": 2, + "no-nested-ternary": 0, + "no-new-object": 2, + "semi-spacing": [2, {"before": false, "after": true}], + "no-spaced-func": 1, + "no-ternary": 0, + "no-trailing-spaces": 2, + "no-underscore-dangle": 0, + "one-var": [ + 1, + { + "var": "never", + "let": "never", + "const": "never" + } + ], + "operator-assignment": [ + 2, + "always" + ], + "padded-blocks": [ + 1, + "never" + ], + "quote-props": [ + 1, + "as-needed" + ], + "quotes": [ + 2, + "single" + ], + "semi": [ + 2, + "always" + ], + "sort-vars": 0, + "space-after-keywords": [ + 1, + "always" + ], + "space-before-blocks": 0, + "space-before-function-paren": [ + 1, + "always" + ], + "object-curly-spacing": [ + 1, + "never" + ], + "array-bracket-spacing": [ + 1, + "never" + ], + "space-in-parens": [ + 1, + "never" + ], + "space-infix-ops": 2, + "space-return-throw-case": 2, + "space-unary-ops": [ + 1, + { + "words": true, + "nonwords": false + } + ], + "spaced-comment": [ + 1, + "always", + { + "exceptions": [ + "-" + ] + } + ], + "wrap-regex": 0, + "constructor-super": 2, + "no-this-before-super": 2, + "require-yield": 2, + "prefer-spread": 1, + "no-useless-call": 1, + "no-invalid-this": 0, + "no-implicit-coercion": 0, + "no-const-assign": 2, + "no-class-assign": 2, + "init-declarations": 0, + "callback-return": [0, ["callback", "cb", "done", "next"]], + "arrow-spacing": [1, {"before": true, "after": true}], + "arrow-parens": 1 + } +} diff --git a/.gitignore b/.gitignore index 8f5c351..ce25fd0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ node_modules/* !node_modules/cool-styles +npm-debug.log \ No newline at end of file diff --git a/.travis.yml b/.travis.yml index 9ccb608..1d0ad98 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,5 @@ sudo: false language: node_js node_js: - - "0.10" - - "0.12" - - "iojs" + - "4" + - "6" diff --git a/README.md b/README.md index de4617d..f806a7a 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![Build Status](https://travis-ci.org/css-modules/css-modulesify.svg?branch=master)](https://travis-ci.org/css-modules/css-modulesify) -A browserify transform to load [CSS Modules]. +A browserify plugin to load [CSS Modules]. [CSS Modules]: https://github.com/css-modules/css-modules @@ -45,11 +45,46 @@ b.plugin(require('css-modulesify'), { b.bundle(); ``` +```js +// or, get the output as a stream +var b = require('browserify')(); +var fs = require('fs'); + +b.add('./main.js'); +b.plugin(require('css-modulesify'), { + rootDir: __dirname +}); + +var bundle = b.bundle() +b.on('css stream', function (css) { + css.pipe(fs.createWriteStream('mycss.css')); +}); +``` + ### Options: -- `rootDir`: absolute path to your project's root directory. This is optional but providing it will result in better generated classnames. -- `output`: path to write the generated css -- `use`: optional array of postcss plugins (by default we use the css-modules core plugins) +- `rootDir`: absolute path to your project's root directory. This is optional but providing it will result in better generated classnames. css-modulesify will try to use the browserify `basedir` if `rootDir` is not specified, if both are not specified it will use the location from which the command was executed. +- `output`: path to write the generated css. If not provided, you'll need to listen to the `'css stream'` event on the bundle to get the output. +- `jsonOutput`: optional path to write a json manifest of classnames. +- `use`: optional array of postcss plugins (by default we use the css-modules core plugins). NOTE: it's safer to use `after` +- `before`: optional array of postcss plugins to run before the required css-modules core plugins are run. +- `after`: optional array of postcss plugins to run after the required css-modules core plugins are run. +- `generateScopedName`: (API only) a function to override the default behaviour of creating locally scoped classnames. +- `global`: optional boolean. Set to `true` if you want `css-modulesify` to apply to `node_modules` as well as local files. You can read more about it in the [browserify docs](https://github.com/substack/node-browserify/#btransformtr-opts). +- `filePattern`: optional regular expression string to specify css file names. (default: `\.css$`) +- `cache`: optional object to persist cache between runs. + +### Events +- `b.on('css stream', callback)` The callback is called with a readable stream containing the compiled CSS. You can write this to a file. + +## Using CSS Modules on the backend + +If you want to use CSS Modules in server-generated templates there are a couple of options: + +- Option A (nodejs only): register the [require-hook](https://github.com/css-modules/css-modules-require-hook) so that `var styles = require('./foo.css')` operates the same way as on the frontend. Make sure that the `rootDir` option matches to guarantee that the classnames are the same. + +- Option B: configure the `jsonOutput` option with a file path and `css-modulesify` will generate a JSON manifest of classnames. + ## PostCSS Plugins @@ -58,27 +93,42 @@ The following PostCSS plugins are enabled by default: * [postcss-modules-local-by-default] * [postcss-modules-extract-imports] * [postcss-modules-scope] + * [postcss-modules-values] (i.e. the [CSS Modules] specification). -You can supply your own additional PostCSS Plugins by passing `--use|-u` to `css-modulesify`. +You can override the default PostCSS Plugins (and add your own) by passing `--use|-u` to `css-modulesify`. + +Or if you just want to add some extra plugins to run after the default, add them to the `postcssAfter` array option (API only at this time). In the same way, add extra plugins to `postcssBefore` to run the before the defaults. -In addion you may also wish to configure defined PostCSS plugins by passing `--plugin.option true`. +In addition you may also wish to configure defined PostCSS plugins by passing `--plugin.option true`. An example of this would be: ``` -browserify -p [css-modulesify -u postcss-modules-local-by-default \ - -u postcss-modules-extract-imports \ - -u postcss-modules-scope \ - -u postcss-color-rebeccapurple \ - -u autoprefixer --autoprefixer.browsers '> 5%' \ +browserify -p [css-modulesify \ + --after autoprefixer --autoprefixer.browsers '> 5%' \ -o dist/main.css] -o dist/index.js src/index.js ``` [postcss-modules-local-by-default]: https://github.com/css-modules/postcss-modules-local-by-default [postcss-modules-extract-imports]: https://github.com/css-modules/postcss-modules-extract-imports [postcss-modules-scope]: https://github.com/css-modules/postcss-modules-scope +[postcss-modules-values]: https://github.com/css-modules/postcss-modules-values + +## Building for production + +If you set `NODE_ENV=production` then `css-modulesify` will generate shorter (though less useful) classnames. + +You can also manually switch to short names by setting the `generateScopedName` option. Eg: + +``` +browserify.plugin(cssModulesify, { + rootDir: __dirname, + output: './dist/main.css', + generateScopedName: cssModulesify.generateShortName +}) +``` ## Example diff --git a/cmify.js b/cmify.js new file mode 100644 index 0000000..a8dec5a --- /dev/null +++ b/cmify.js @@ -0,0 +1,87 @@ +var stream = require('stream'); +var util = require('util'); +var assign = require('object-assign'); +var path = require('path'); + +util.inherits(Cmify, stream.Transform); +function Cmify (filename, opts) { + if (!(this instanceof Cmify)) { + return new Cmify(filename, opts); + } + + stream.Transform.call(this); + + this.cssFilePattern = new RegExp(opts.cssFilePattern || '\.css$'); + this._data = ''; + this._filename = filename; + this._cssOutFilename = opts.cssOutFilename; + this._loader = opts.loader; + this._tokensByFile = opts.tokensByFile; + this._rootDir = opts.rootDir; + opts.cssFiles.push(filename); +} + +Cmify.prototype.isCssFile = function (filename) { + return this.cssFilePattern.test(filename); +}; + +Cmify.prototype._transform = function (buf, enc, callback) { + // only handle .css files + if (!this.isCssFile(this._filename)) { + this.push(buf); + return callback(); + } + + this._data += buf; + callback(); +}; + +Cmify.prototype._flush = function (callback) { + var self = this; + var filename = this._filename; + + // only handle .css files + if (!this.isCssFile(filename)) { return callback(); } + + // grab the correct loader + var loader = this._loader; + var tokensByFile = this._tokensByFile; + + // convert css to js before pushing + // reset the `tokensByFile` state + var relFilename = path.relative(this._rootDir, filename); + tokensByFile[filename] = loader.tokensByFile[filename] = null; + + loader.fetch(relFilename, '/').then(function (tokens) { + var deps = loader.deps.dependenciesOf(filename); + var output = deps.map(function (f) { + return 'require("' + f + '")'; + }); + output.push('module.exports = ' + JSON.stringify(tokens)); + + var isValid = true; + var isUndefined = /\bundefined\b/; + Object.keys(tokens).forEach(function (k) { + if (isUndefined.test(tokens[k])) { + isValid = false; + } + }); + + if (!isValid) { + var err = 'Composition in ' + filename + ' contains an undefined reference'; + console.error(err); + output.push('console.error("' + err + '");'); + } + + assign(tokensByFile, loader.tokensByFile); + + self.push(output.join('\n')); + return callback(); + }).catch(function (err) { + self.push('console.error("' + err + '");'); + self.emit('error', err); + return callback(); + }); +}; + +module.exports = Cmify; diff --git a/file-system-loader.js b/file-system-loader.js new file mode 100644 index 0000000..4e033fc --- /dev/null +++ b/file-system-loader.js @@ -0,0 +1,143 @@ +'use strict'; + +var DepGraph = require('dependency-graph').DepGraph; +var nodeResolve = require('resolve'); + +Object.defineProperty(exports, '__esModule', { + value: true +}); + +var _createClass = (function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ('value' in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; })(); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { 'default': obj }; } + +function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError('Cannot call a class as a function'); } } + +var _indexJs = require('css-modules-loader-core/lib/index.js'); + +var _indexJs2 = _interopRequireDefault(_indexJs); + +var _fs = require('fs'); + +var _fs2 = _interopRequireDefault(_fs); + +var _path = require('path'); + +var _path2 = _interopRequireDefault(_path); + +// Sorts dependencies in the following way: +// AAA comes before AA and A +// AB comes after AA and before A +// All Bs come after all As +// This ensures that the files are always returned in the following order: +// - In the order they were required, except +// - After all their dependencies +var traceKeySorter = function traceKeySorter(a, b) { + if (a.length < b.length) { + return a < b.substring(0, a.length) ? -1 : 1; + } else if (a.length > b.length) { + return a.substring(0, b.length) <= b ? -1 : 1; + } else { + return a < b ? -1 : 1; + } +}; + +var FileSystemLoader = (function () { + function FileSystemLoader(root, plugins) { + _classCallCheck(this, FileSystemLoader); + + this.root = root; + this.sources = {}; + this.importNr = 0; + this.core = new _indexJs2['default'](plugins); + this.tokensByFile = {}; + this.deps = new DepGraph(); + } + + _createClass(FileSystemLoader, [{ + key: 'fetch', + value: function fetch(_newPath, relativeTo, _trace) { + var _this = this; + + var newPath = _newPath.replace(/^["']|["']$/g, ''), + trace = _trace || String.fromCharCode(this.importNr++); + return new Promise(function (resolve, reject) { + var relativeDir = _path2['default'].dirname(relativeTo), + rootRelativePath = _path2['default'].resolve(relativeDir, newPath), + rootRelativeDir = _path2['default'].join(_this.root, relativeDir), + fileRelativePath = _path2['default'].resolve(rootRelativeDir, newPath); + + // if the path is not relative or absolute, try to resolve it in node_modules + if (newPath[0] !== '.' && newPath[0] !== '/') { + var paths; + if (process.env.NODE_PATH) { + paths = process.env.NODE_PATH.split(_path2['default'].delimiter); + } + try { + fileRelativePath = nodeResolve.sync(newPath, { + basedir: rootRelativeDir, + paths: paths + }); + // in this case we need to actualize rootRelativePath too + rootRelativePath = _path2['default'].relative(_this.root, fileRelativePath); + } catch (e) {} + } + + // first time? add a node + if (_trace === undefined) { + if (!_this.deps.hasNode(fileRelativePath)) { + _this.deps.addNode(fileRelativePath); + } + } + // otherwise add a dependency + else { + var parentFilePath = _path2['default'].join(_this.root, relativeTo); + if (!_this.deps.hasNode(parentFilePath)) { + console.error('NO NODE', parentFilePath, fileRelativePath) + } + if (!_this.deps.hasNode(fileRelativePath)) { + _this.deps.addNode(fileRelativePath); + } + _this.deps.addDependency(parentFilePath, fileRelativePath); + } + + var tokens = _this.tokensByFile[fileRelativePath]; + if (tokens) { + return resolve(tokens); + } + + _fs2['default'].readFile(fileRelativePath, 'utf-8', function (err, source) { + if (err) reject(err); + _this.core.load(source, rootRelativePath, trace, _this.fetch.bind(_this)).then(function (_ref) { + var injectableSource = _ref.injectableSource; + var exportTokens = _ref.exportTokens; + + _this.sources[fileRelativePath] = injectableSource; + _this.tokensByFile[fileRelativePath] = exportTokens; + resolve(exportTokens); + }, reject); + }); + }); + } + }, { + key: 'finalSource', + get: function () { + var sources = this.sources; + var written = {}; + + return this.deps.overallOrder().map(function (filename) { + if (written[filename] === true) { + return null; + } + written[filename] = true; + + return sources[filename]; + }).join(''); + } + }]); + + return FileSystemLoader; +})(); + +exports['default'] = FileSystemLoader; +module.exports = exports['default']; diff --git a/index.js b/index.js index 5132d43..d71525e 100644 --- a/index.js +++ b/index.js @@ -1,137 +1,224 @@ +// Some css-modules-loader-code dependencies use Promise so we'll provide it for older node versions +if (!global.Promise) { global.Promise = require('promise-polyfill'); } + var fs = require('fs'); var path = require('path'); -var through = require('through'); +var Cmify = require('./cmify'); var Core = require('css-modules-loader-core'); -var FileSystemLoader = require('css-modules-loader-core/lib/file-system-loader'); -var assign = require('object-assign'); +var FileSystemLoader = require('./file-system-loader'); var stringHash = require('string-hash'); +var ReadableStream = require('stream').Readable; +var through = require('through2'); + +/* + Custom `generateScopedName` function for `postcss-modules-scope`. + Short names consisting of source hash and line number. +*/ +function generateShortName (name, filename, css) { + // first occurrence of the name + // TODO: better match with regex + var i = css.indexOf('.' + name); + var numLines = css.substr(0, i).split(/[\r\n]/).length; + + var hash = stringHash(css).toString(36).substr(0, 5); + return '_' + name + '_' + hash + '_' + numLines; +} /* Custom `generateScopedName` function for `postcss-modules-scope`. Appends a hash of the css source. */ -function createScopedNameFunc (plugin) { - var orig = plugin.generateScopedName; - return function (name, path, css) { - var hash = stringHash(css).toString(36).substr(0, 5); - return orig.apply(plugin, arguments) + '___' + hash; - } -}; +function generateLongName (name, filename) { + var sanitisedPath = filename.replace(/\.[^\.\/\\]+$/, '') + .replace(/[\W_]+/g, '_') + .replace(/^_|_$/g, ''); -var cssExt = /\.css$/; -module.exports = function (browserify, options) { - options = options || {}; + return '_' + sanitisedPath + '__' + name; +} - var rootDir = options.rootDir || options.d || '/'; +/* + Get the default plugins and apply options. +*/ +function getDefaultPlugins (options) { + var scope = Core.scope; + var customNameFunc = options.generateScopedName; + var defaultNameFunc = process.env.NODE_ENV === 'production' ? + generateShortName : + generateLongName; + + scope.generateScopedName = customNameFunc || defaultNameFunc; + + return [ + Core.values + , Core.localByDefault + , Core.extractImports + , scope + ]; +} - var cssOutFilename = options.output || options.o; - if (!cssOutFilename) { - throw new Error('css-modulesify needs the --output / -o option (path to output css file)'); - } +/* - // PostCSS plugins passed to FileSystemLoader + Normalize the manifest paths so that they are always relative + to the project root directory. + +*/ +function normalizeManifestPaths (tokensByFile, rootDir) { + var output = {}; + var rootDirLength = rootDir.length + 1; + + Object.keys(tokensByFile).forEach(function (filename) { + var normalizedFilename = filename.substr(rootDirLength); + output[normalizedFilename] = tokensByFile[filename]; + }); + + return output; +} + +// PostCSS plugins passed to FileSystemLoader +function getPlugins (options) { var plugins = options.use || options.u; if (!plugins) { - plugins = Core.defaultPlugins; - } else { + plugins = getDefaultPlugins(options); + } + else { if (typeof plugins === 'string') { - plugins = [ plugins ]; + plugins = [plugins]; } + } - plugins = plugins.map(function requirePlugin (name) { - // assume functions are already required plugins - if (typeof name === 'function') { - return name; - } + var postcssBefore = options.postcssBefore || options.before || []; + var postcssAfter = options.postcssAfter || options.after || []; + plugins = (Array.isArray(postcssBefore) ? postcssBefore : [postcssBefore]).concat(plugins).concat(postcssAfter); - var plugin = require(require.resolve(name)); + // load plugins by name (if a string is used) + return plugins.map(function requirePlugin (name) { + // assume not strings are already required plugins + if (typeof name !== 'string') { + return name; + } - // custom scoped name generation - if (name === 'postcss-modules-scope') { - options[name] = options[name] || {}; - if (!options[name].generateScopedName) { - options[name].generateScopedName = createScopedNameFunc(plugin); - } - } + var plugin = module.parent.require(name); - if (name in options) { - plugin = plugin(options[name]); - } else { - plugin = plugin.postcss || plugin(); + // custom scoped name generation + if (name === 'postcss-modules-scope') { + options[name] = options[name] || {}; + if (!options[name].generateScopedName) { + options[name].generateScopedName = generateLongName; } + } - return plugin; - }); - } + if (name in options) { + plugin = plugin(options[name]); + } + else { + plugin = plugin.postcss || plugin(); + } - // keep track of css files visited - var filenames = []; + return plugin; + }); +} - // keep track of all tokens so we can avoid duplicates - var tokensByFile = {}; +module.exports = function (browserify, options) { + options = options || {}; - // keep track of all source files for later builds: when - // using watchify, not all files will be caught on subsequent - // bundles - var sourceByFile = {}; + // if no root directory is specified, assume the cwd + var rootDir = options.rootDir || options.d || browserify._options.basedir; + if (rootDir) { + rootDir = path.resolve(rootDir); + } + if (!rootDir) { + rootDir = process.cwd(); + } - function transform (filename) { - // only handle .css files - if (!cssExt.test(filename)) { - return through(); + var cssOutFilename = options.output || options.o; + var jsonOutFilename = options.json || options.jsonOutput; + var loader; + // keep track of all tokens so we can avoid duplicates + var tokensByFile; + if (options.cache) { + if (options.cache.loaders) { + loader = options.cache.loaders[cssOutFilename]; + } else { + options.cache.loaders = {}; + } + if (options.cache.tokens) { + tokensByFile = options.cache.tokens; + } else { + options.cache.tokens = {}; } + } - // collect visited filenames - filenames.push(filename); + loader = loader || new FileSystemLoader(rootDir, getPlugins(options)); + tokensByFile = tokensByFile || {}; - return through(function noop () {}, function end () { - var self = this; - var loader = new FileSystemLoader(rootDir, plugins); + if (options.cache) { + options.cache.loaders[cssOutFilename] = loader; + options.cache.tokens = tokensByFile; + } - // pre-populate the loader's tokensByFile - loader.tokensByFile = tokensByFile; + var transformOpts = { + cssFilePattern: options.filePattern + , cssFiles: [] + , cssOutFilename: cssOutFilename + , global: options.global || options.g + , loader: loader + , rootDir: rootDir + , tokensByFile: tokensByFile + }; - loader.fetch(path.relative(rootDir, filename), '/').then(function (tokens) { - var output = "module.exports = " + JSON.stringify(tokens); + browserify.transform(Cmify, transformOpts); - assign(tokensByFile, loader.tokensByFile); + // ---- - // store this file's source to be written out to disk later - sourceByFile[filename] = loader.finalSource; + function addHooks () { + browserify.pipeline.get('pack').push(through(function write (row, enc, next) { + next(null, row); + }, function end (cb) { + // on each bundle, create a new stream b/c the old one might have ended + var compiledCssStream = new ReadableStream(); + compiledCssStream._read = function () {}; - self.queue(output); - self.queue(null); - }, function (err) { - console.error('loader err', err); - }); - }); - } + browserify.emit('css stream', compiledCssStream); - browserify.transform(transform, { - global: true - }); + // Combine the collected sources for a single bundle into a single CSS file + var self = this; + var css = loader.finalSource; - // wrap the `bundle` function - var bundle = browserify.bundle; - browserify.bundle = function (cb) { - // reset the `tokensByFile` cache - tokensByFile = {}; - - // call the original - var stream = bundle.call(browserify, function () { - // Combine the collected sources into a single CSS file - var css = Object.keys(sourceByFile).map(function(file) { - return sourceByFile[file]; - }).join('\n'); - var args = arguments; - - fs.writeFile(cssOutFilename, css, function () { - if (typeof cb === 'function') cb.apply(null, args); - }); - }); + // end the output stream + compiledCssStream.push(css); + compiledCssStream.push(null); - return stream; - }; + var writes = []; + + // write the css file + if (cssOutFilename) { + writes.push(writeFile(cssOutFilename, css)); + } + + // write the classname manifest + if (jsonOutFilename) { + writes.push(writeFile(jsonOutFilename, JSON.stringify(normalizeManifestPaths(tokensByFile, rootDir)))); + } + Promise.all(writes) + .then(function () { cb(); }) + .catch(function (err) { self.emit('error', err); cb(); }); + })); + } + + browserify.on('reset', addHooks); + addHooks(); return browserify; }; + +function writeFile (filename, content) { + return new Promise(function (resolve, reject) { + fs.writeFile(filename, content, function (err) { + if (err) reject(err); + else resolve(); + }); + }); +} + +module.exports.generateShortName = generateShortName; +module.exports.generateLongName = generateLongName; diff --git a/package.json b/package.json index bd27301..bba0ee2 100644 --- a/package.json +++ b/package.json @@ -1,21 +1,27 @@ { "name": "css-modulesify", - "version": "0.5.0", + "version": "0.28.0", "description": "A browserify transform to load CSS Modules", "main": "index.js", "dependencies": { - "css-modules-loader-core": "0.0.12", + "css-modules-loader-core": "^1.1.0", + "dependency-graph": "^0.4.1", "object-assign": "^3.0.0", + "promise-polyfill": "^2.1.0", + "resolve": "^1.1.7", "string-hash": "^1.1.0", - "through": "^2.3.7" + "through2": "^2.0.1" }, "devDependencies": { "browserify": "^11.0.1", + "eslint": "^1.4.0", "proxyquire": "^1.6.0", + "rebundler": "^0.2.0", "tape": "^4.0.1" }, "scripts": { - "test": "tape tests/*.js" + "test": "tape tests/*.js", + "lint": "eslint index.js tests/" }, "author": "joshwnj", "license": "MIT", diff --git a/tests/cache.js b/tests/cache.js new file mode 100644 index 0000000..3f59240 --- /dev/null +++ b/tests/cache.js @@ -0,0 +1,51 @@ +var tape = require('tape'); + +var browserify = require('browserify'); +var proxyquire = require('proxyquire'); +var fs = require('fs'); +var path = require('path'); +var rebundler = require('rebundler'); + +var casesDir = path.join(__dirname, 'cases'); +var simpleCaseDir = path.join(casesDir, 'simple'); +var cssOutFilename = 'out.css'; + +tape('multiple builds', function (t) { + var fakeFs = { + writeFile: function (filename, content, cb) { + var expected = fs.readFileSync(path.join(simpleCaseDir, 'expected.css'), 'utf8'); + + t.equal(filename, cssOutFilename, 'correct output filename'); + t.equal(content, expected, 'output matches expected'); + cb(); + } + }; + + var cssModulesify = proxyquire('../', { + fs: fakeFs + }); + + var cssModulesifyCache = {}; + var getBundler = rebundler(function (cache, packageCache) { + return browserify(path.join(simpleCaseDir, 'main.js'), { + cache: cache + , packageCache: packageCache + , fullPaths: true + }) + .plugin(cssModulesify, { + rootDir: path.join(simpleCaseDir) + , output: cssOutFilename + , cache: cssModulesifyCache + }); + }); + + getBundler().bundle(function (err) { + t.error(err, 'initial bundle without a cache does not error'); + + getBundler().bundle(function (err2) { + t.error(err2, 'second pass bundle with a cache does not error'); + + t.end(); + }); + }); +}); diff --git a/tests/cases/compose-from-shared/expected.css b/tests/cases/compose-from-shared/expected.css new file mode 100644 index 0000000..9c004b1 --- /dev/null +++ b/tests/cases/compose-from-shared/expected.css @@ -0,0 +1,9 @@ +._shared__shared { + background: #000; +} +._styles_1__foo { + color: #F00; +} +._styles_2__bar { + background: #BAA; +} diff --git a/tests/cases/compose-from-shared/main.js b/tests/cases/compose-from-shared/main.js new file mode 100644 index 0000000..14cc992 --- /dev/null +++ b/tests/cases/compose-from-shared/main.js @@ -0,0 +1,2 @@ +require('./styles-1.css'); +require('./styles-2.css'); diff --git a/tests/cases/compose-from-shared/shared.css b/tests/cases/compose-from-shared/shared.css new file mode 100644 index 0000000..d6fc168 --- /dev/null +++ b/tests/cases/compose-from-shared/shared.css @@ -0,0 +1,3 @@ +.shared { + background: #000; +} diff --git a/tests/cases/compose-from-shared/styles-1.css b/tests/cases/compose-from-shared/styles-1.css new file mode 100644 index 0000000..2ffd802 --- /dev/null +++ b/tests/cases/compose-from-shared/styles-1.css @@ -0,0 +1,4 @@ +.foo { + composes: shared from "./shared.css"; + color: #F00; +} diff --git a/tests/cases/compose-from-shared/styles-2.css b/tests/cases/compose-from-shared/styles-2.css new file mode 100644 index 0000000..f9d32d8 --- /dev/null +++ b/tests/cases/compose-from-shared/styles-2.css @@ -0,0 +1,4 @@ +.bar { + composes: shared from "./shared.css"; + background: #BAA; +} diff --git a/tests/cases/compose-local-node-module/custom.js b/tests/cases/compose-local-node-module/custom.js new file mode 100644 index 0000000..7e10afd --- /dev/null +++ b/tests/cases/compose-local-node-module/custom.js @@ -0,0 +1,3 @@ +module.exports = { + global: true +} diff --git a/tests/cases/compose-local-node-module/expected.css b/tests/cases/compose-local-node-module/expected.css new file mode 100644 index 0000000..fa249e5 --- /dev/null +++ b/tests/cases/compose-local-node-module/expected.css @@ -0,0 +1,6 @@ +._node_modules_cool_local_styles_styles__foo { + color: #F00; +} +._styles__foo { + background: black; +} diff --git a/tests/cases/compose-local-node-module/main.js b/tests/cases/compose-local-node-module/main.js new file mode 100644 index 0000000..922a6aa --- /dev/null +++ b/tests/cases/compose-local-node-module/main.js @@ -0,0 +1,2 @@ +var styles = require('./styles.css'); +module.exports = styles; diff --git a/tests/cases/compose-local-node-module/node_modules/cool-local-styles/styles.css b/tests/cases/compose-local-node-module/node_modules/cool-local-styles/styles.css new file mode 100644 index 0000000..092b8ad --- /dev/null +++ b/tests/cases/compose-local-node-module/node_modules/cool-local-styles/styles.css @@ -0,0 +1,3 @@ +.foo { + color: #F00; +} diff --git a/tests/cases/compose-local-node-module/styles.css b/tests/cases/compose-local-node-module/styles.css new file mode 100644 index 0000000..2f17694 --- /dev/null +++ b/tests/cases/compose-local-node-module/styles.css @@ -0,0 +1,4 @@ +.foo { + composes: foo from "cool-local-styles/styles.css"; + background: black; +} diff --git a/tests/cases/compose-node-module/custom.js b/tests/cases/compose-node-module/custom.js new file mode 100644 index 0000000..7e10afd --- /dev/null +++ b/tests/cases/compose-node-module/custom.js @@ -0,0 +1,3 @@ +module.exports = { + global: true +} diff --git a/tests/cases/compose-node-module/expected.css b/tests/cases/compose-node-module/expected.css index ddd63b1..492da06 100644 --- a/tests/cases/compose-node-module/expected.css +++ b/tests/cases/compose-node-module/expected.css @@ -1,4 +1,4 @@ -._cool_styles_styles__foo { +._node_modules_cool_styles_styles__foo { color: #F00; } ._styles__foo { diff --git a/tests/cases/compose-node-module/main.js b/tests/cases/compose-node-module/main.js index 791235e..922a6aa 100644 --- a/tests/cases/compose-node-module/main.js +++ b/tests/cases/compose-node-module/main.js @@ -1 +1,2 @@ var styles = require('./styles.css'); +module.exports = styles; diff --git a/tests/cases/custom-ext/custom.js b/tests/cases/custom-ext/custom.js new file mode 100644 index 0000000..548c51d --- /dev/null +++ b/tests/cases/custom-ext/custom.js @@ -0,0 +1,3 @@ +module.exports = { + filePattern: /\.cssmodule$/ +} diff --git a/tests/cases/custom-ext/expected.css b/tests/cases/custom-ext/expected.css new file mode 100644 index 0000000..f0a88f9 --- /dev/null +++ b/tests/cases/custom-ext/expected.css @@ -0,0 +1,3 @@ +._styles__foo { + color: #F00; +} diff --git a/tests/cases/custom-ext/main.js b/tests/cases/custom-ext/main.js new file mode 100644 index 0000000..96280a2 --- /dev/null +++ b/tests/cases/custom-ext/main.js @@ -0,0 +1,3 @@ +// test using a custom `filePattern` option +var styles = require('./styles.cssmodule'); +module.exports = styles; diff --git a/tests/cases/custom-ext/styles.cssmodule b/tests/cases/custom-ext/styles.cssmodule new file mode 100644 index 0000000..092b8ad --- /dev/null +++ b/tests/cases/custom-ext/styles.cssmodule @@ -0,0 +1,3 @@ +.foo { + color: #F00; +} diff --git a/tests/cases/import-node-module/custom.js b/tests/cases/import-node-module/custom.js new file mode 100644 index 0000000..7e10afd --- /dev/null +++ b/tests/cases/import-node-module/custom.js @@ -0,0 +1,3 @@ +module.exports = { + global: true +} diff --git a/tests/cases/import-node-module/main.js b/tests/cases/import-node-module/main.js index 59f8d0d..729af44 100644 --- a/tests/cases/import-node-module/main.js +++ b/tests/cases/import-node-module/main.js @@ -1 +1,2 @@ var styles = require('cool-styles/styles.css'); +module.exports = styles; diff --git a/tests/cases/multiple-js-files/expected.css b/tests/cases/multiple-js-files/expected.css new file mode 100644 index 0000000..00c95c8 --- /dev/null +++ b/tests/cases/multiple-js-files/expected.css @@ -0,0 +1,3 @@ +._simple_styles__foo { + color: #F00; +} diff --git a/tests/cases/multiple-js-files/main.js b/tests/cases/multiple-js-files/main.js new file mode 100644 index 0000000..a095317 --- /dev/null +++ b/tests/cases/multiple-js-files/main.js @@ -0,0 +1 @@ +module.exports = require('../simple/main.js'); diff --git a/tests/index.js b/tests/index.js index 92e028d..0270550 100644 --- a/tests/index.js +++ b/tests/index.js @@ -8,13 +8,18 @@ var path = require('path'); var casesDir = path.join(__dirname, 'cases'); var cssOutFilename = 'out.css'; -// test cases are expected to have: -// - main.js (entry point) -// - expected.css (what to expect from css-modulesify output) -fs.readdirSync(path.join(__dirname, 'cases')).forEach(runTestCase); - function runTestCase (dir) { tape('case: ' + dir, function (t) { + // load (optional) custom setup for this testcase + var customPath = path.join(casesDir, dir, 'custom.js'); + var customOpts; + try { + fs.accessSync(customPath); + customOpts = require(customPath); + } catch (e) { + customOpts = {}; + } + var fakeFs = { writeFile: function (filename, content, cb) { var expected = fs.readFileSync(path.join(casesDir, dir, 'expected.css'), 'utf8'); @@ -30,19 +35,28 @@ function runTestCase (dir) { }); var b = browserify(); + b.add(path.join(casesDir, dir, 'main.js')); - b.plugin(cssModulesify, { - rootDir: path.join(casesDir, dir), - output: cssOutFilename - }); + b.plugin(cssModulesify, Object.assign({}, { + rootDir: path.join(casesDir, dir) + , output: cssOutFilename + , generateScopedName: cssModulesify.generateLongName + }, customOpts)); - b.bundle(function (err, buf) { + b.bundle(function (err) { if (err) { - console.error(err); - return t.fail('Unexpected error'); + t.error(err, 'should not error'); } t.end(); }); }); } + +// test cases are expected to have: +// - main.js (entry point) +// - expected.css (what to expect from css-modulesify output) +// optional: +// - custom.js (module that exports an object of custom settings) + +fs.readdirSync(path.join(__dirname, 'cases')).forEach(runTestCase); diff --git a/tests/stream-output.js b/tests/stream-output.js new file mode 100644 index 0000000..48fc861 --- /dev/null +++ b/tests/stream-output.js @@ -0,0 +1,69 @@ +var tape = require('tape'); + +var browserify = require('browserify'); +var proxyquire = require('proxyquire'); +var fs = require('fs'); +var path = require('path'); + +var casesDir = path.join(__dirname, 'cases'); +var simpleCaseDir = path.join(casesDir, 'simple'); +var cssFilesTotal = 1; +var cssOutFilename = 'out.css'; + +tape('stream output', function (t) { + var fakeFs = { + writeFile: function (filename, content, cb) { + var expected = fs.readFileSync(path.join(simpleCaseDir, 'expected.css'), 'utf8'); + + t.equal(filename, cssOutFilename, 'correct output filename'); + t.equal(content, expected, 'output matches expected'); + cb(); + } + }; + + var cssModulesify = proxyquire('../', { + fs: fakeFs + }); + + t.plan(cssFilesTotal * 2 + 2); + + var cssFilesCount = 0; + var b = browserify(path.join(simpleCaseDir, 'main.js')); + + b + .plugin(cssModulesify, { + rootDir: path.join(simpleCaseDir) + }) + .on('error', t.error) + .bundle(function noop () {}); + + b + .once('css stream', function (stream) { + stream + .on('data', function onData (css) { + var cssString = css.toString(); + // just get the first class name, use that as an id + var cssId = cssString.split('\n')[0].split(' ')[0]; + + t.ok( + ++cssFilesCount <= cssFilesTotal + , 'emits data for ' + cssId + ); + + t.ok( + cssString.indexOf('._styles') === 0 + , 'emits compiled css for ' + cssId + ); + }) + .on('end', function onEnd () { + t.pass('ends the stream'); + + b.bundle(function noop () {}); + + b.once('css stream', function (stream2) { + t.ok(stream2, 'registers a second event for a CSS stream'); + }); + }) + .on('error', t.error); + }); +});