diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..f7fd45b --- /dev/null +++ b/.babelrc @@ -0,0 +1,9 @@ +{ + "presets": [ + "es2015" + ], + "plugins": [ + "syntax-object-rest-spread", + "transform-object-rest-spread" + ] +} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..52494a4 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +# editorconfig.org + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..2c370f0 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,16 @@ +module.exports = { + "extends": "airbnb", + "env": { + "browser": true, + "node": true, + "mocha": true + }, + "rules": { + "prefer-arrow-callback": 0, // mocha tests (recommendation) + "func-names": 0, // mocha tests (recommendation) + "comma-dangle": ["error", "never"], // personal preference + "no-param-reassign": 0, // the plugin needs this (webpack design :( ) + "no-use-before-define": 0, // personal preference + "no-console": 0 // allow logging + } +}; diff --git a/.gitignore b/.gitignore index c2658d7..9d256f2 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,7 @@ node_modules/ +build/ +dist/ +coverage/ +*.log +.eslintcache + diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..552f221 --- /dev/null +++ b/.npmignore @@ -0,0 +1,2 @@ +node_modules/ +*.log diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..33fce7f --- /dev/null +++ b/.travis.yml @@ -0,0 +1,11 @@ +language: node_js +node_js: + - "4" + - "5" + - "6" +script: + - (cd examples && npm i) + - npm run build + - npm run test:all +after_success: + - bash <(curl -s https://codecov.io/bash) diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..05933b9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,90 @@ +## purifycss-webpack + +0.7.0 / 2017-05-16 +================== + + * Breaking - Push **purify-css** as a peer dependency. #108 + +0.6.2 / 2017-05-08 +================== + + * Docs - Add **glob-all** example. #105 + +0.6.1 / 2017-04-14 +================== + + * Docs - Fix CSS Modules example (prefix has to be lowercase to work). + +0.6.0 / 2017-04-07 +================== + + * Feature - Allow asset names to contain `?`. Example: `style.css?218aa9358a709a5a0a12`. #94 + +0.5.0 / 2017-03-02 +================== + + * Feature - Add strict validation against `paths`. #88 + +0.4.3 / 2017-03-01 +================== + + * Bug fix - Fix chunk file match dependency on [name]. #86 + +0.4.2 / 2017-01-28 +================== + + * Feature - Show nicer errors if there are extra fields. + +0.4.1 / 2017-01-28 +================== + + * Bug fix - If an entry name does not match while processing, skip it. #72 + +0.4.0 / 2017-01-27 +================== + + * Bug fix - Support `: ` style entries. #71 + * Bug fix - Fail at validation if entry keys don't match with path keys. #67 + * Feature - Expose `minimize` flag for CSS minification. + +0.3.1 / 2017-01-26 +================== + + * Bug fix - Make `search.files` more robust against potentially missing data. #67 + +0.3.0 / 2017-01-26 +================== + + * Breaking - Rename `fileExtensions` as `styleExtensions`. This communicates better how it works underneath. + +0.2.2 / 2017-01-24 +================== + + * Chore - Push option defaults to schema. + +0.2.1 / 2017-01-24 +================== + + * Bug fix - Include webpack 2 properly to the peer dependency. + +0.2.0 / 2017-01-24 +================== + + * Feature - Add stricter plugin option validation. + +0.1.1 / 2017-01-23 +================== + + * Bug fix - Include `lib` to distribution to avoid installation failure. + +0.1.0 / 2017-01-23 +================== + + * Complete rewrite with more functionality and a new API. Different name due to npm issues. + +## purifycss-webpack-plugin + +2.0.3 / 2016-02-12 +================== + + * Last release without changelogs. diff --git a/ISSUE_TEMPLATE.md b/ISSUE_TEMPLATE.md new file mode 100644 index 0000000..0434854 --- /dev/null +++ b/ISSUE_TEMPLATE.md @@ -0,0 +1,5 @@ +1. Check the version of package you are using. If it's not the newest version, update and try again (see changelog while updating!). +2. If the issue is still there, write a minimal project showing the problem and expected output. +3. Link to the project and mention Node version and OS in your report. + +**IMPORTANT! You should use [Stack Overflow](https://stackoverflow.com/) for support related questions.** diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8c11fc7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,20 @@ +Copyright JS Foundation and other contributors + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md index 2abedef..078fd07 100644 --- a/README.md +++ b/README.md @@ -1,77 +1,190 @@ -# PurifyCSS WebPack Plugin +# UNMAINTAINED -This is a plugin for WebPack that utilizes PurifyCSS to clean your CSS. Its dead simple, but it requires you to be prepared. +Please use: https://github.com/FullHuman/purgecss-webpack-plugin -So, let's go and clean some style! +--- +[![npm][npm]][npm-url] +[![deps][deps]][deps-url] +[![test][test]][test-url] +[![coverage][cover]][cover-url] +[![quality][quality]][quality-url] +[![chat][chat]][chat-url] -## Dependencies -- WebPack must be already installed, so that this plugin can use it's library parts. -- You **should** use the `extract-text-webpack-plugin` - although, you are not enforced to. Without any CSS file being emitted as an asset, this plugin will not do a thing except idle about inside the compiler. You can also use the `file` plugin to drop a special CSS file into your output folder, but I highly recommend the extract plugin. +
+ + + + +

PurifyCSS Plugin

+

PurifyCSS for Webpack.

+

-## Use -First, get it: +This plugin uses [PurifyCSS](https://github.com/purifycss/purifycss) to remove unused selectors from your CSS. You **should** use it with the [extract-text-webpack-plugin](https://www.npmjs.com/package/extract-text-webpack-plugin). - npm install --save bird3-purifycss-webpack-plugin +Without any CSS file being emitted as an asset, this plugin will do nothing. You can also use the `file` plugin to drop a CSS file into your output folder, but it is highly recommended to use the PurifyCSS plugin with the Extract Text plugin. -Let's assume you have a basic webpack configuration like so: +> This plugin replaces earlier [purifycss-webpack-plugin](https://www.npmjs.com/package/purifycss-webpack-plugin) and it has a different API! -```javascript -var extractor = require("extract-text-webpack-plugin"); -module.exports = { - entry: "app.js", - output: { - // ... - }, - module: { - loaders: [ - { test: /\.css$/, loader: extractor.loader("style","css") } - ] - }, - plugins: [ - new extractor("[name].css") - ] -} +

Install

+ +```bash +npm i -D purifycss-webpack purify-css ``` -Now, all we add, is another plugin definition. Please note: Plugins seem to be executed in order, so make sure that this plugin comes _after_ the extract plugin, if you use it. +

Usage

+ +Configure as follows: ```javascript -var extractor = require("extract-text-webpack-plugin"); -var purify = require("bird3-purifycss-webpack-plugin"); +const path = require('path'); +const glob = require('glob'); +const ExtractTextPlugin = require('extract-text-webpack-plugin'); +const PurifyCSSPlugin = require('purifycss-webpack'); + module.exports = { - entry: "app.js", - output: { - // ... - }, - module: { - loaders: [ - { test: /\.css$/, loader: extractor.loader("style","css") } - ] - }, - plugins: [ - new extractor("[name].css"), - new purify({ - basePath: __dirname, - paths: [ - "app/views/*.html", - "app/layout/*.html" - ] + entry: {...}, + output: {...}, + module: { + rules: [ + { + test: /\.css$/, + loader: ExtractTextPlugin.extract({ + fallbackLoader: 'style-loader', + loader: 'css-loader' }) + } ] -} + }, + plugins: [ + new ExtractTextPlugin('[name].[contenthash].css'), + // Make sure this is after ExtractTextPlugin! + new PurifyCSSPlugin({ + // Give paths to parse for rules. These should be absolute! + paths: glob.sync(path.join(__dirname, 'app/*.html')), + }) + ] +}; ``` And, that's it! Your scripts and view files will be scanned for classes, and those that are unused will be stripped off your CSS - aka. "purified". -## Options -This plugin, unlike the original PurifyCSS plugin, provides special features, such as scanning the dependency files and all kinds of files. To configure such behaviours, I will show you the options. +In order to use this plugin to look into multiple paths you will need to: + +1. npm install --save glob-all +2. Add `const glob = require('glob-all');` at the top of your webpack config +3. Then you can pass your paths to an array, like so: + +```javascript +paths: glob.sync([ + path.join(__dirname, '.php'), + path.join(__dirname, 'partials/.php') +]), +``` + +> You can pass an object (` -> []`) to `paths` if you want to control the behavior per entry. + +

Options

+ +This plugin, unlike the original PurifyCSS plugin, provides special features, such as scanning the dependency files. You can configure using the following fields: | Property | Description |---------------------|------------ -| `basePath` | The path from which all the other paths will start. Required. -| `resolveExtensions` | An array of extensions that should be given to PurifyCSS when determining classes. (defaults to webpack `resolve.extensions` config) -| `paths` | An array of globs that reveal all your files. See [glob](http://npmjs.org/glob)'s documentation to see what kind of paths you can pass in this array. Use this array to pass files that won't be known to WebPack. -| `purifyOptions` | Pass these options to PurifyCSS. See [here](https://github.com/purifycss/purifycss#options-optional). Note: `output` is always `false`. +| `styleExtensions` | An array of file extensions for determining used classes within style files. Defaults to `['.css']`. +| `moduleExtensions` | An array of file extensions for determining used classes within `node_modules`. Defaults to `[]`, but `['.html']` can be useful here. +| `minimize` | Enable CSS minification. Alias to `purifyOptions.minify`. Disabled by default. +| `paths` | An array of absolute paths or a path to traverse. This also accepts an object (` -> `). It can be a good idea [glob](http://npmjs.org/glob) these. +| `purifyOptions` | Pass [custom options to PurifyCSS](https://github.com/purifycss/purifycss#the-optional-options-argument). +| `verbose` | Set this flag to get verbose output from the plugin. This sets `purifyOptions.info`, but you can override `info` separately if you want less logging. + +> The plugin does **not** emit sourcemaps even if you enable `sourceMap` option on loaders! + +

Usage with CSS Modules

+ +PurifyCSS doesn't support classes that have been namespaced with CSS Modules. However, by adding a static string to `css-loader`'s `localIdentName`, you can effectively whitelist these namespaced classes. + +In this example, `purify` will be our whitelisted string. **Note:** Make sure this string doesn't occur in any of your other CSS class names. Keep in mind that whatever you choose will end up in your application at runtime - try to keep it short! + +```javascript +module.exports = { + module: { + rules: [ + { + test: /\.css$/, + loader: ExtractTextPlugin.extract({ + fallback: 'style-loader', + use: [ + { + loader: 'css-loader', + options: { + localIdentName: 'purify_[hash:base64:5]', + modules: true + } + } + ] + }) + } + ] + }, + plugins: [ + ..., + new PurifyCSSPlugin({ + purifyOptions: { + whitelist: ['*purify*'] + } + }) + ] +}; +``` + +

Maintainers

+ + + + + + + + + + +
+ +
+ Juho Vepsäläinen +
+ +
+ Joshua Wiens +
+ +
+ Kees Kluskens +
+ +
+ Sean Larkin +
+ + +[npm]: https://img.shields.io/npm/v/purifycss-webpack.svg +[npm-url]: https://npmjs.com/package/purifycss-webpack + +[deps]: https://david-dm.org/webpack-contrib/purifycss-webpack.svg +[deps-url]: https://david-dm.org/webpack-contrib/purifycss-webpack + +[chat]: https://img.shields.io/badge/gitter-webpack%2Fwebpack-brightgreen.svg +[chat-url]: https://gitter.im/webpack/webpack + +[test]: https://secure.travis-ci.org/webpack-contrib/purifycss-webpack.svg +[test-url]: http://travis-ci.org/webpack-contrib/purifycss-webpack + +[cover]: https://codecov.io/gh/webpack-contrib/purifycss-webpack/branch/master/graph/badge.svg +[cover-url]: https://codecov.io/gh/webpack-contrib/purifycss-webpack -## Notes -This plugin is NOT a fork of [the offical plugin](https://github.com/purifycss/purifycss-webpack-plugin)! Instead, this is it's own. I prefixed it with `bird3`, since it was created within my [BIRD3](https://github.com/DragonsInn/BIRD3) project and to make an obvious separation to the offical version. +[quality]: https://www.bithound.io/github/webpack-contrib/purifycss-webpack/badges/score.svg +[quality-url]: https://www.bithound.io/github/webpack-contrib/purifycss-webpack diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..daa6189 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,6 @@ +1. `npm install` at project root +2. `npm run build` +3. `cd examples` +4. `npm install` +5. `npm run build` +6. Examine `./build` diff --git a/examples/another/component.js b/examples/another/component.js new file mode 100644 index 0000000..859f06b --- /dev/null +++ b/examples/another/component.js @@ -0,0 +1,8 @@ +export default function () { + const element = document.createElement('h1'); + + element.className = 'pure-table'; + element.innerHTML = 'Hello world Again'; + + return element; +} diff --git a/examples/another/index.js b/examples/another/index.js new file mode 100644 index 0000000..960ad6b --- /dev/null +++ b/examples/another/index.js @@ -0,0 +1,5 @@ +import 'purecss'; +import './main.css'; +import component from './component'; + +document.body.appendChild(component()); diff --git a/examples/another/main.css b/examples/another/main.css new file mode 100644 index 0000000..b1645d6 --- /dev/null +++ b/examples/another/main.css @@ -0,0 +1,7 @@ +body { + background: cornsilk; +} + +body div { + background-color: deepskyblue; +} diff --git a/examples/app/component.js b/examples/app/component.js new file mode 100644 index 0000000..54d7e41 --- /dev/null +++ b/examples/app/component.js @@ -0,0 +1,8 @@ +export default function () { + const element = document.createElement('h1'); + + element.className = 'pure-button'; + element.innerHTML = 'Hello world'; + + return element; +} diff --git a/examples/app/index.js b/examples/app/index.js new file mode 100644 index 0000000..960ad6b --- /dev/null +++ b/examples/app/index.js @@ -0,0 +1,5 @@ +import 'purecss'; +import './main.css'; +import component from './component'; + +document.body.appendChild(component()); diff --git a/examples/app/main.css b/examples/app/main.css new file mode 100644 index 0000000..b1645d6 --- /dev/null +++ b/examples/app/main.css @@ -0,0 +1,7 @@ +body { + background: cornsilk; +} + +body div { + background-color: deepskyblue; +} diff --git a/examples/package.json b/examples/package.json new file mode 100644 index 0000000..5469eec --- /dev/null +++ b/examples/package.json @@ -0,0 +1,15 @@ +{ + "name": "examples", + "scripts": { + "build": "webpack" + }, + "dependencies": { + "css-loader": "^0.26.1", + "extract-text-webpack-plugin": "^2.0.0-beta.5", + "glob": "^7.1.1", + "purecss": "^0.6.2", + "style-loader": "^0.13.1", + "webpack": "^2.2.0-rc.3", + "webpack-merge": "^2.4.0" + } +} diff --git a/examples/webpack.config.js b/examples/webpack.config.js new file mode 100644 index 0000000..aacb91d --- /dev/null +++ b/examples/webpack.config.js @@ -0,0 +1,52 @@ +const path = require('path'); +const merge = require('webpack-merge'); +const glob = require('glob'); + +const parts = require('./webpack.parts'); + +const PATHS = { + app: path.join(__dirname, 'app'), + another: path.join(__dirname, 'another'), + build: path.join(__dirname, 'build') +}; + +module.exports = [ + merge( + { + entry: { + app: PATHS.app + }, + output: { + path: path.join(PATHS.build, 'first'), + filename: '[name].js' + } + }, + parts.extractCSS(), + parts.purifyCSS({ + verbose: true, + minimize: true, + paths: glob.sync(`${PATHS.app}/*`), + styleExtensions: ['.css'] + }) + ), + merge( + { + entry: { + first: PATHS.app, + second: PATHS.another + }, + output: { + path: path.join(PATHS.build, 'second'), + filename: '[name].js' + } + }, + parts.extractCSS(), + parts.purifyCSS({ + paths: { + first: glob.sync(`${PATHS.app}/*`), + second: glob.sync(`${PATHS.another}/*`) + }, + styleExtensions: ['.css'] + }) + ) +]; diff --git a/examples/webpack.parts.js b/examples/webpack.parts.js new file mode 100644 index 0000000..4933b1f --- /dev/null +++ b/examples/webpack.parts.js @@ -0,0 +1,30 @@ +const ExtractTextPlugin = require('extract-text-webpack-plugin'); +const PurifyCSSPlugin = require('../'); + +exports.extractCSS = function extractCSS(paths) { + return { + module: { + rules: [ + { + test: /\.css$/, + include: paths, + loader: ExtractTextPlugin.extract({ + fallbackLoader: 'style-loader', + loader: 'css-loader?sourceMap' + }) + } + ] + }, + plugins: [ + new ExtractTextPlugin('[name].css?[hash]') + ] + }; +}; + +exports.purifyCSS = function purifyCSS(options) { + return { + plugins: [ + new PurifyCSSPlugin(options) + ] + }; +}; diff --git a/index.js b/index.js deleted file mode 100644 index 3265401..0000000 --- a/index.js +++ /dev/null @@ -1,59 +0,0 @@ -var purify = require("purify-css"); -var glob = require("glob").sync; -var path = require("path"); -var ConcatSource = require("webpack/lib/ConcatSource"); - -module.exports = function PurifyPlugin(options) { - // Store the user's options - this.userOptions = options; -} - -module.exports.prototype.apply = function(compiler) { - // Keep a reference to self - var self = this; - - // Bind the plugin into this compilation. - compiler.plugin("this-compilation", function(compilation) { - // WebPack options - var wpOptions = compilation.compiler.options; - // Base path - self.basePath = self.userOptions.basePath || wpOptions.context || process.cwd(); - // Purify options - self.purifyOptions = self.userOptions.purifyOptions || { - minify: false, - info: wpOptions.debug || false - }; - self.purifyOptions.output = false; - // Path/files to check. If none supplied, an empty array will do. - self.paths = self.userOptions.paths || []; - // Additional extensions to scan for. This is kept minimal, for obvious reasons. - // We are not opinionated... - self.resolveExtensions = self.userOptions.resolveExtensions || compiler.options.resolve.extensions; - - var files = self.paths.reduce(function(results, p) { - return results.concat(glob(path.join(self.basePath, p))); - }, []); - - compilation.plugin("additional-assets", function(cb){ - // Look for additional JS/HTML stuff. - for(var key in compilation.fileDependencies) { - var file = compilation.fileDependencies[key]; - var ext = path.extname(file); - if (self.resolveExtensions.indexOf(ext) > -1) files.push(file); - } - - // Look for purifyable CSs... - for(var key in compilation.assets) { - if(/\.css$/i.test(key)) { - // We found a CSS. So purify it. - var asset = compilation.assets[key]; - var css = asset.source(); - var newCss = new ConcatSource(); - newCss.add(purify(files, css, self.purifyOptions)); - compilation.assets[key] = newCss; - } - } - cb(); - }); - }); -} diff --git a/lib/post_install.js b/lib/post_install.js new file mode 100644 index 0000000..8d6218c --- /dev/null +++ b/lib/post_install.js @@ -0,0 +1,23 @@ +/* eslint-disable */ +// adapted based on rackt/history (MIT) +// Node 4+ +var execSync = require('child_process').execSync; +var stat = require('fs').stat; + +function exec(command) { + execSync(command, { + stdio: [0, 1, 2] + }); +} + +stat('dist', function(error, stat) { + // Skip building on Travis + if (process.env.TRAVIS) { + return; + } + + if (error || !stat.isDirectory()) { + exec('npm i babel-cli babel-preset-es2015 babel-plugin-syntax-object-rest-spread babel-plugin-transform-object-rest-spread'); + exec('npm run build'); + } +}); diff --git a/package.json b/package.json index 585bbfd..cee2a3e 100644 --- a/package.json +++ b/package.json @@ -1,31 +1,86 @@ { - "name": "bird3-purifycss-webpack-plugin", - "version": "1.0.9", - "description": "BIRD3's PurifyCSS WebPack plugin. Time to clean your styles!", - "main": "index.js", + "name": "purifycss-webpack", + "version": "0.7.0", + "description": "PurifyCSS for webpack", + "main": "./dist", "scripts": { - "test": "echo 'No test command available, yet.' && exit 1" + "build": "rimraf dist && babel src -d dist", + "build:watch": "npm-watch", + "test:all": "npm run test:coverage && npm run test:lint", + "test": "jest", + "test:coverage": "jest --coverage", + "test:watch": "jest --watch", + "test:lint": "eslint . --ext .js --ignore-path .gitignore --cache", + "preversion": "npm run test:all && npm run build && git commit --allow-empty -am \"Update dist\"", + "postinstall": "node lib/post_install.js" }, "repository": { "type": "git", - "url": "git+https://github.com/DragonsInn/bird3-purifycss-webpack-plugin.git" + "url": "https://github.com/webpack-contrib/purifycss-webpack.git" }, "keywords": [ - "purify-css", - "purifycss", "webpack", + "uncss", "plugin", - "purify", - "css" + "purify" ], - "author": "Ingwie Phoenix ", + "files": [ + "dist", + "lib" + ], + "jest": { + "collectCoverage": true, + "collectCoverageFrom": "src/**/*.js", + "moduleFileExtensions": [ + "js" + ], + "moduleDirectories": [ + "node_modules" + ] + }, + "author": "Kenny Tran, Matthew Rourke, Phoebe Li, Kevin \"Ingwie Phoenix\" Ingwersen", "license": "MIT", "bugs": { - "url": "https://github.com/DragonsInn/bird3-purifycss-webpack-plugin/issues" + "url": "https://github.com/webpack-contrib/purifycss-webpack/issues" + }, + "homepage": "https://github.com/webpack-contrib/purifycss-webpack", + "peerDependencies": { + "purify-css": ">= 1.0.0 < 2.0.0" }, - "homepage": "https://github.com/DragonsInn/bird3-purifycss-webpack-plugin#readme", "dependencies": { - "glob": "^5.0.15", - "purify-css": "^1.0.17" + "ajv": "^4.11.2", + "webpack-sources": "^0.1.4" + }, + "devDependencies": { + "babel-cli": "^6.18.0", + "babel-core": "^6.21.0", + "babel-eslint": "^7.1.1", + "babel-jest": "^18.0.0", + "babel-plugin-syntax-object-rest-spread": "^6.13.0", + "babel-plugin-transform-object-rest-spread": "^6.20.2", + "babel-preset-es2015": "^6.18.0", + "eslint": "^3.13.1", + "eslint-config-airbnb": "^14.0.0", + "eslint-plugin-import": "^2.2.0", + "eslint-plugin-jsx-a11y": "^3.0.2", + "eslint-plugin-react": "^6.9.0", + "git-prepush-hook": "^1.0.1", + "jest": "^18.1.0", + "npm-watch": "^0.1.8", + "purify-css": "^1.2.2", + "rimraf": "^2.6.1" + }, + "pre-push": [ + "build", + "test:all" + ], + "watch": { + "build": { + "patterns": [ + "src" + ], + "extensions": "js", + "quiet": false + } } } diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..3acdaaf --- /dev/null +++ b/src/index.js @@ -0,0 +1,85 @@ +import fs from 'fs'; +import purify from 'purify-css'; +import { ConcatSource } from 'webpack-sources'; +import * as parse from './parse'; +import * as search from './search'; +import validateOptions from './validate-options'; +import schema from './schema'; + +module.exports = function PurifyPlugin(options) { + return { + apply(compiler) { + const validation = validateOptions( + schema({ + entry: compiler.options.entry + }), + options + ); + + if (!validation.isValid) { + throw new Error(validation.error); + } + + compiler.plugin('this-compilation', (compilation) => { + const entryPaths = parse.entryPaths(options.paths); + + parse.flatten(entryPaths).forEach((p) => { + if (!fs.existsSync(p)) throw new Error(`Path ${p} does not exist.`); + }); + + // Output debug information through a callback pattern + // to avoid unnecessary processing + const output = options.verbose ? + messageCb => console.info(...messageCb()) : + () => {}; + + compilation.plugin('additional-assets', (cb) => { + // Go through chunks and purify as configured + compilation.chunks.forEach( + ({ name: chunkName, files, modules }) => { + const assetsToPurify = search.assets( + compilation.assets, options.styleExtensions + ).filter( + asset => files.indexOf(asset.name) >= 0 + ); + + output(() => [ + 'Assets to purify:', + assetsToPurify.map(({ name }) => name).join(', ') + ]); + + assetsToPurify.forEach(({ name, asset }) => { + const filesToSearch = parse.entries(entryPaths, chunkName).concat( + search.files( + modules, options.moduleExtensions || [], file => file.resource + ) + ); + + output(() => [ + 'Files to search for used rules:', + filesToSearch.join(', ') + ]); + + // Compile through Purify and attach to output. + // This loses sourcemaps should there be any! + compilation.assets[name] = new ConcatSource( + purify( + filesToSearch, + asset.source(), + { + info: options.verbose, + minify: options.minimize, + ...options.purifyOptions + } + ) + ); + }); + } + ); + + cb(); + }); + }); + } + }; +}; diff --git a/src/parse.js b/src/parse.js new file mode 100644 index 0000000..0797085 --- /dev/null +++ b/src/parse.js @@ -0,0 +1,36 @@ +function parseEntryPaths(paths) { + const ret = paths || []; + + // Convert possible string to an array + if (typeof ret === 'string') { + return [ret]; + } + + return ret; +} + +function flattenEntryPaths(paths) { + return Array.isArray(paths) ? + paths : + Object.keys(paths).reduce((acc, val) => [...acc, ...paths[val]], []); +} + +function parseEntries(paths, chunkName) { + if (Array.isArray(paths)) { + return paths; + } + + if (!(chunkName in paths)) { + return []; + } + + const ret = paths[chunkName]; + + return Array.isArray(ret) ? ret : [ret]; +} + +module.exports = { + entryPaths: parseEntryPaths, + flatten: flattenEntryPaths, + entries: parseEntries +}; diff --git a/src/parse.test.js b/src/parse.test.js new file mode 100644 index 0000000..1b75d7e --- /dev/null +++ b/src/parse.test.js @@ -0,0 +1,83 @@ +const assert = require('assert'); +const parse = require('./parse'); + +describe('Parse entry paths', function () { + it('returns an empty array by default', function () { + assert.deepEqual(parse.entryPaths(), []); + }); + + it('returns an object as itself', function () { + const o = { a: ['a', 'b', 'c'] }; + + assert.deepEqual(parse.entryPaths(o), o); + }); + + it('puts a string inside an array', function () { + const str = 'foobar'; + + assert.deepEqual(parse.entryPaths(str), [str]); + }); +}); + +describe('Flatten entry paths', function () { + it('returns an array as itself', function () { + const a = ['a', 'b', 'c']; + + assert.deepEqual(parse.flatten(a), a); + }); + + it('returns an object of arrays as one flat array', function () { + const o = { a: ['a', 'b'], b: ['c', 'd'] }; + + assert.deepEqual(parse.flatten(o), ['a', 'b', 'c', 'd']); + }); +}); + +describe('Parse entries', function () { + it('returns paths if there is no chunk name', function () { + const paths = ['a', 'b', 'c']; + + assert.deepEqual(parse.entries(paths), paths); + }); + + it('returns paths if paths are an array already', function () { + const paths = ['a', 'b', 'c']; + + assert.deepEqual(parse.entries(paths, 'foobar'), paths); + }); + + it('returns chunk paths', function () { + const entryPaths = ['a', 'b', 'c']; + const paths = { + foobar: entryPaths + }; + + assert.deepEqual(parse.entries(paths, 'foobar'), entryPaths); + }); + + it('returns chunk path wrapped in an array', function () { + const entryPaths = 'a'; + const paths = { + foobar: entryPaths + }; + + assert.deepEqual(parse.entries(paths, 'foobar'), [entryPaths]); + }); + + it('returns an empty array if failed to find entry', function () { + const paths = { + foobar: 'a' + }; + + assert.deepEqual(parse.entries(paths, 'barbar'), []); + }); + + it('returns an empty array if failed to find entry with multiple paths', function () { + const paths = { + foobar: 'a', + barbar: 'b' + }; + + assert.deepEqual(parse.entries(paths, 'foofoo'), []); + }); +}); diff --git a/src/schema.js b/src/schema.js new file mode 100644 index 0000000..cce104d --- /dev/null +++ b/src/schema.js @@ -0,0 +1,68 @@ +const schema = ({ entry } = {}) => ({ + $schema: 'http://json-schema.org/draft-04/schema#', + additionalProperties: false, + type: 'object', + properties: { + styleExtensions: { + type: 'array', + items: { + type: 'string' + }, + default: ['.css'] + }, + minimize: { + type: 'boolean' + }, + moduleExtensions: { + type: 'array', + items: { + type: 'string' + } + }, + paths: parsePaths(entry), + purifyOptions: { + type: 'object', + properties: {} + }, + verbose: { + type: 'boolean' + } + }, + required: [ + 'paths' + ] +}); + +function parsePaths(entry) { + const ret = { + type: ['array', 'object'] + }; + + if (entry instanceof Object) { + ret.additionalProperties = false; + ret.properties = generateProperties(entry); + } else { + ret.items = { + type: 'string' + }; + } + + return ret; +} + +function generateProperties(entry) { + const ret = {}; + + Object.keys(entry).forEach((e) => { + ret[e] = { + type: ['array', 'string'], + items: { + type: 'string' + } + }; + }); + + return ret; +} + +export default schema; diff --git a/src/schema.test.js b/src/schema.test.js new file mode 100644 index 0000000..8d01808 --- /dev/null +++ b/src/schema.test.js @@ -0,0 +1,21 @@ +const assert = require('assert'); +const schema = require('./schema').default; + +describe('Schema', function () { + it('converts an object entry to validation', function () { + const entry = { + a: 'foo' + }; + const result = schema({ entry }); + const expected = { + a: { + type: ['array', 'string'], + items: { + type: 'string' + } + } + }; + + assert.deepEqual(result.properties.paths.properties, expected); + }); +}); diff --git a/src/search.js b/src/search.js new file mode 100644 index 0000000..80d33e8 --- /dev/null +++ b/src/search.js @@ -0,0 +1,37 @@ +const path = require('path'); + +function searchAssets( + assets = [], + extensions = [] +) { + return Object.keys(assets).map( + name => ( + extensions.indexOf( + path.extname( + name.indexOf('?') >= 0 ? name.split('?').slice(0, -1).join('') : name + ) + ) >= 0 && { name, asset: assets[name] } + ) + ).filter(a => a); +} + +function searchFiles( + modules = {}, + extensions = [], + getter = a => a +) { + return Object.keys(modules).map((name) => { + const file = getter(modules[name]); + + if (!file) { + return null; + } + + return extensions.indexOf(path.extname(file)) >= 0 && file; + }).filter(a => a); +} + +module.exports = { + assets: searchAssets, + files: searchFiles +}; diff --git a/src/search.test.js b/src/search.test.js new file mode 100644 index 0000000..a542091 --- /dev/null +++ b/src/search.test.js @@ -0,0 +1,89 @@ +const assert = require('assert'); +const search = require('./search'); + +describe('Search assets', function () { + it('returns nothing if nothing is passed', function () { + assert.deepEqual(search.assets(), []); + }); + + it('returns matches based on a pattern', function () { + const modules = { + 'foobar.txt': {}, + 'barbar.css': {} + }; + const extensions = ['.txt']; + const matches = [{ name: 'foobar.txt', asset: {} }]; + + assert.deepEqual(search.assets(modules, extensions), matches); + }); + + it('returns matches if they have query', function () { + const modules = { + 'foobar.txt?123': {}, + 'barbar.css': {} + }; + const extensions = ['.txt']; + const matches = [{ name: 'foobar.txt?123', asset: {} }]; + + assert.deepEqual(search.assets(modules, extensions), matches); + }); +}); + +describe('Search files', function () { + it('returns nothing if nothing is passed', function () { + assert.deepEqual(search.files(), []); + }); + + it('returns matches based on extension', function () { + const modules = ['foobar.txt', 'barbar.css']; + const extensions = ['.txt']; + const matches = ['foobar.txt']; + + assert.deepEqual(search.files(modules, extensions), matches); + }); + + it('does not fail with missing modules', function () { + const modules = ['foobar.txt', '', 'barbar.css']; + const extensions = ['.txt']; + const matches = ['foobar.txt']; + + assert.deepEqual(search.files(modules, extensions), matches); + }); + + it('returns matches based on extension with a customized getter', function () { + const modules = { + foobar: { + resource: 'foobar.txt' + }, + barbar: { + resource: 'barbar.css' + } + }; + const extensions = ['.txt']; + const matches = ['foobar.txt']; + + assert.deepEqual( + search.files(modules, extensions, file => file.resource), + matches + ); + }); + + it('does not fail with missing modules when a getter fails', function () { + const modules = { + foobar: { + resource: 'foobar.txt' + }, + demo: {}, + barbar: { + resource: 'barbar.css' + } + }; + const extensions = ['.txt']; + const matches = ['foobar.txt']; + + assert.deepEqual( + search.files(modules, extensions, file => file.resource), + matches + ); + }); +}); diff --git a/src/validate-options.js b/src/validate-options.js new file mode 100644 index 0000000..c06ec45 --- /dev/null +++ b/src/validate-options.js @@ -0,0 +1,16 @@ +import Ajv from 'ajv'; + +function validateOptions(schema, data) { + const ajv = new Ajv({ + useDefaults: true, // This mutates the original data with defaults! + errorDataPath: 'property' + }); + const isValid = ajv.validate(schema, data); + + return { + isValid, + error: ajv.errors && ajv.errorsText() + }; +} + +export default validateOptions; diff --git a/src/validate-options.test.js b/src/validate-options.test.js new file mode 100644 index 0000000..fbae580 --- /dev/null +++ b/src/validate-options.test.js @@ -0,0 +1,63 @@ +const assert = require('assert'); +const validateOptions = require('./validate-options').default; +const schema = require('./schema').default; + +describe('Validate options', function () { + it('fails without a schema and data', function () { + assert.throws( + () => { + validateOptions(); + }, + Error + ); + }); + + it('fails with empty data', function () { + const result = validateOptions(schema()); + + assert.ok(!result.isValid); + assert.ok(result.error); + }); + + it('does not fail if paths are provided', function () { + const result = validateOptions(schema(), { paths: ['./foo'] }); + + assert.ok(result.isValid); + assert.ok(!result.error); + }); + + it('does not allow arbitrary properties', function () { + const result = validateOptions(schema(), { paths: ['./foo'], foobar: ['./foo'] }); + + assert.ok(!result.isValid); + assert.ok(result.error); + }); + + it('styleExtensions have defaults', function () { + const paths = ['./foo']; + const data = { paths }; + + // Currently this mutates data with defaults due to ajv design. It + // might be a good idea to change that behavior, though. + const result = validateOptions(schema(), data); + + assert.deepEqual(data, { paths, styleExtensions: ['.css'] }); + assert.ok(!result.error); + }); + + it('fails without matching path keys', function () { + const data = { + paths: { + a: './foo' + } + }; + + const result = validateOptions(schema({ + entry: { + b: './bar' + } + }), data); + + assert.ok(result.error); + }); +});