From 1bb4a2ee77a03211c6cbb6671a416106405c89f9 Mon Sep 17 00:00:00 2001 From: Anton Nesterov Date: Thu, 7 Apr 2016 18:10:53 +0300 Subject: [PATCH] Initial commit --- .gitignore | 39 +++++++++++ .travis.yml | 7 ++ LICENSE | 5 ++ README.md | 50 ++++++++++++++ index.js | 144 ++++++++++++++++++++++++++++++++++++++++ package.json | 59 ++++++++++++++++ tests/errors.tests.js | 48 ++++++++++++++ tests/main.tests.js | 90 +++++++++++++++++++++++++ tests/runTest.helper.js | 21 ++++++ tests/test.css | 9 +++ tests/test.html | 4 ++ 11 files changed, 476 insertions(+) create mode 100644 .gitignore create mode 100644 .travis.yml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 index.js create mode 100644 package.json create mode 100644 tests/errors.tests.js create mode 100644 tests/main.tests.js create mode 100644 tests/runTest.helper.js create mode 100644 tests/test.css create mode 100644 tests/test.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..599f454 --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ + +# Created by https://www.gitignore.io/api/node + +### Node ### +# Logs +logs +*.log +npm-debug.log* + +# Runtime data +pids +*.pid +*.seed + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules +jspm_packages + +# Optional npm cache directory +.npm + +# Optional REPL history +.node_repl_history + diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..0f996eb --- /dev/null +++ b/.travis.yml @@ -0,0 +1,7 @@ +language: node_js +node_js: + - "node" + - "iojs" + - "4.3" + - "0.12" + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1f9bf78 --- /dev/null +++ b/LICENSE @@ -0,0 +1,5 @@ +Copyright (c) 2016, Anton Nesterov + +Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..48dd611 --- /dev/null +++ b/README.md @@ -0,0 +1,50 @@ +# usedcss + +>PostCSS plugin which helps you extract only used styles. Unlike [uncss](https://github.com/giakki/uncss) and others does not render your pages to find used classes, but instead parse it statically, which can be beneficial in some cases. Also support simple Angular's ng-class parsing. And also, due to awesomeness of PostCSS, it works with LESS and SCSS via PostCSS syntaxes. + +## Installation + +``` +npm i usedcss --save +``` + +## Options + +### html + +Type: `array` of [globs](https://github.com/isaacs/node-glob) +*Required option* + +You must specify html files to check css selector usage against them. + +### ignore + +Type: `array` of `strings` + +Saves ignored selectors even if they are not presented in DOM. + +## ignoreRegexp + +Type: `array` of `regexps` + +Use it to save selectors based on regexp. + +## ngclass + +Type: `boolean` + +Default: `false` + +Parse or not to parse `ng-class` statements. + +## ignoreNesting + +Type: `boolean` + +Default: `false` + +Ignore nesting so `.class1 .class2` will be saved even if there is element with `class2`, but it's not nested with `class1`. Useful if you use templates. + +## Usage + +Check out [PostCSS documentation](https://github.com/postcss/postcss#usage) on how to use PostCSS plugins. diff --git a/index.js b/index.js new file mode 100644 index 0000000..a798877 --- /dev/null +++ b/index.js @@ -0,0 +1,144 @@ +'use strict'; +const Promise = require('bluebird'); +const fs = Promise.promisifyAll(require('fs')); +const glob = Promise.promisify(require('multi-glob').glob); +const postcss = require('postcss'); +const noop = require('node-noop').noop; +const cheerio = require('cheerio'); +const expressions = require('angular-expressions'); +const isRegex = require('is-regex'); + +module.exports = postcss.plugin('usedcss', (options) => { + var htmls = []; + return function(css) { + return new Promise((resolve, reject) => { + if (!options.html) { + reject('No html files specified.'); + return; + } + if (options.ignore && !Array.isArray(options.ignore)) { + reject('ignore option should be an array.'); + return; + } + if (options.ignoreRegexp && !Array.isArray(options.ignoreRegexp)) { + reject('ignoreRegexp option should be an array.'); + return; + } + if (options.ignoreRegexp && !options.ignoreRegexp.every(isRegex)) { + reject('ignoreRegexp option should contain regular expressions.'); + return; + } + if (options.ngclass && typeof options.ngclass != 'boolean') { + reject('ngclass option should be boolean.'); + return; + } + if (options.ignoreNesting && typeof options.ignoreNesting != 'boolean') { + reject('ignoreNesting option should be boolean.'); + return; + } + var promise; + if (options.ignoreNesting && options.ignore) { + promise = Promise.map(options.ignore, (item, i) => { + options.ignore[i] = item.replace(/^.*( |>|<)/g, ''); + }); + } else { + promise = Promise.resolve(); + } + promise.then(() => { + return glob(options.html) + .then((files) => { + return Promise.map(files, (file) => { + return fs.readFileAsync(file).then((content) => { + htmls.push(cheerio.load(content.toString())); + return Promise.resolve(); + }); + }); + }) + .then(() => { + if (options.ngclass) { + return Promise.map(htmls, (html) => { + html('[ng-class], [data-ng-class]').each((i, el) => { + var cls = []; + var ngcl = html(el).attr('ng-class'); + if (ngcl) { + cls = cls.concat( + Object.keys(expressions.compile(ngcl)()) + ); + } + var datang = html(el).attr('data-ng-class'); + if (datang) { + cls = cls.concat( + Object.keys(expressions.compile(datang)()) + ); + } + cls.forEach((cl) => { + html(el).addClass(cl); + }); + }); + return Promise.resolve(); + }); + } + return Promise.resolve(); + }) + .then(() => { + var promises = []; + css.walkRules((rule) => { + // ignore keyframes + if ( + rule.parent.type === 'atrule' && + /keyframes/.test(rule.parent.name) + ) { + return; + } + + // if we found an element, we reject the promise and do nothing + // promise is resolved if we found nothing after iteration + // in this case, we remove a rule + // sounds hacky, but it works + promises.push( + Promise.map(rule.selectors, (selector) => { + var promise; + if (options.ignoreRegexp) { + promise = Promise.map(options.ignoreRegexp, (item) => { + if (item.test(selector)) { + return Promise.reject(); + } + }); + } else { + promise = Promise.resolve(); + } + return promise.then(() => { + // remove pseudo-classes from selectors + selector = selector.replace(/::?[a-zA-Z-]*$/g, ''); + if (options.ignoreNesting) { + selector = selector.replace(/^.*( |>|<)/g, '') + } + return Promise.map(htmls, (html) => { + if ( + (html(selector).length > 0 || + ( + options.ignore && + options.ignore.indexOf(selector) > -1) + ) + ) { + return Promise.reject(); + } + return Promise.resolve(); + }); + }); + }) + .then(() => { + rule.remove(); + return Promise.resolve(); + }) + .catch(noop) + ); + }); + return Promise.all(promises); + }); + }) + .then(resolve) + .catch(reject); + }); + }; +}); diff --git a/package.json b/package.json new file mode 100644 index 0000000..b45516c --- /dev/null +++ b/package.json @@ -0,0 +1,59 @@ +{ + "name": "usedcss", + "version": "1.0.0", + "description": "Extract only styles presented in your html files.", + "main": "index.js", + "scripts": { + "test": "./node_modules/.bin/eslint *.js tests/*.js && ./node_modules/jscs/bin/jscs *.js tests/*.js && ./node_modules/mocha/bin/mocha tests/*.tests.js" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/komachi/usedcss.git" + }, + "keywords": [ + "css", + "postcss", + "less", + "scss", + "sass", + "postcss-plugin", + "optimization" + ], + "author": "Anton Nesterov", + "license": "ISC", + "bugs": { + "url": "https://github.com/komachi/usedcss/issues" + }, + "homepage": "https://nesterov.pw/usedcss", + "eslintConfig": { + "extends": "defaults", + "env": { + "node": true, + "es6": true, + "mocha": true + } + }, + "jscsConfig": { + "preset": "node-style-guide", + "esnext": true, + "verbose": true, + "requireTrailingComma": null, + "requireCapitalizedComments": null + }, + "dependencies": { + "angular-expressions": "^0.3.0", + "bluebird": "^3.3.4", + "cheerio": "^0.20.0", + "is-regex": "^1.0.3", + "multi-glob": "^1.0.1", + "node-noop": "^1.0.0", + "postcss": "^5.0.19" + }, + "devDependencies": { + "eslint": "^2.7.0", + "eslint-config-defaults": "^9.0.0", + "expect": "^1.16.0", + "jscs": "^2.11.0", + "mocha": "^2.4.5" + } +} diff --git a/tests/errors.tests.js b/tests/errors.tests.js new file mode 100644 index 0000000..85f0555 --- /dev/null +++ b/tests/errors.tests.js @@ -0,0 +1,48 @@ +const expect = require('expect'); +const runTest = require('./runTest.helper.js'); + +describe('errors', () => { + it('Should retun error if there is no html files specified.', (done) => { + runTest({html: null}).catch((err) => { + expect(err).toBe('No html files specified.'); + done(); + }); + }); + + it('Should retun error if ignore is not an array', (done) => { + runTest({ignore: 'test'}).catch((err) => { + expect(err).toBe('ignore option should be an array.'); + done(); + }); + }); + + it('Should retun error if ignoreRegexp is not an array', (done) => { + runTest({ignoreRegexp: 'test'}).catch((err) => { + expect(err).toBe('ignoreRegexp option should be an array.'); + done(); + }); + }); + + it('Should retun error if ignoreRegexp contains not a regexp', (done) => { + runTest({ignoreRegexp: ['test']}).catch((err) => { + expect(err).toBe( + 'ignoreRegexp option should contain regular expressions.' + ); + done(); + }); + }); + + it('Should retun error if ngclass is not boolean', (done) => { + runTest({ngclass: 'test'}).catch((err) => { + expect(err).toBe('ngclass option should be boolean.'); + done(); + }); + }); + + it('Should retun error if ignoreNesting is not boolean', (done) => { + runTest({ignoreNesting: 'test'}).catch((err) => { + expect(err).toBe('ignoreNesting option should be boolean.'); + done(); + }); + }); +}); diff --git a/tests/main.tests.js b/tests/main.tests.js new file mode 100644 index 0000000..e5c4370 --- /dev/null +++ b/tests/main.tests.js @@ -0,0 +1,90 @@ +const expect = require('expect'); +const runTest = require('./runTest.helper.js'); + +describe('main', () => { + it('Should remove unused css classes', (done) => { + runTest().then((result) => { + expect(result.css).toBe( + '.test1 .test2 { color: red }\n' + + '.test10,.test2 { color: pink; }\n.' + + 'test1:after { content: \'\'; }\n' + + '.test2::before { content: \'\'; }\n' + ); + done(); + }); + }); + + it('Should save ng-class classes', (done) => { + runTest({ngclass: true}).then((result) => { + expect(result.css).toBe( + '.test1 .test2 { color: red }\n' + + '.test10,.test2 { color: pink; }\n' + + '.test1:after { content: \'\'; }\n' + + '.test2::before { content: \'\'; }\n' + + '.test1 .test3 { color: white; }\n' + + '.test1 .test4 { color: orange; }\n' + ); + done(); + }); + }); + + it('Should ignore nesting', (done) => { + runTest({ignoreNesting: true}).then((result) => { + expect(result.css).toBe( + '.test1 .test2 { color: red }\n' + + '.test10,.test2 { color: pink; }\n' + + '.test1:after { content: \'\'; }\n' + + '.test2::before { content: \'\'; }\n' + + '.nested .test1 { color: blue; }\n' + + '.nested>.test2 { color: yellow;}\n' + ); + done(); + }); + }); + + it('Should work with ignore option', (done) => { + runTest({ignore: ['.remove']}).then((result) => { + expect(result.css).toBe( + '.test1 .test2 { color: red }\n' + + '.test10,.test2 { color: pink; }\n' + + '.test1:after { content: \'\'; }\n' + + '.test2::before { content: \'\'; }\n' + + '.remove { color: black; }\n' + ); + done(); + }); + }); + + it('Ignore should also ignore nesting if ignoreNesting is enabled', + (done) => { + runTest({ + ignore: ['.nesting .remove'], + ignoreNesting: true + }).then((result) => { + expect(result.css).toBe( + '.test1 .test2 { color: red }\n' + + '.test10,.test2 { color: pink; }\n' + + '.test1:after { content: \'\'; }\n' + + '.test2::before { content: \'\'; }\n' + + '.nested .test1 { color: blue; }\n' + + '.nested>.test2 { color: yellow;}\n' + + '.remove { color: black; }\n' + ); + done(); + }); + } + ); + + it('Should work with ignoreRegexp option', (done) => { + runTest({ignoreRegexp: [/.*remo.*/]}).then((result) => { + expect(result.css).toBe( + '.test1 .test2 { color: red }\n' + + '.test10,.test2 { color: pink; }\n' + + '.test1:after { content: \'\'; }\n' + + '.test2::before { content: \'\'; }\n' + + '.remove { color: black; }\n' + ); + done(); + }); + }); +}); diff --git a/tests/runTest.helper.js b/tests/runTest.helper.js new file mode 100644 index 0000000..a4c5f4f --- /dev/null +++ b/tests/runTest.helper.js @@ -0,0 +1,21 @@ +const postcss = require('postcss'); +const Promise = require('bluebird'); +const usedcss = require('../index.js'); +const readFile = Promise.promisify(require('fs').readFile); + +module.exports = (options, file) => { + if (!file) { + file = `${__dirname}/test.css`; + } + if (options && options.html === undefined) { + options.html = [`${__dirname}/test.html`]; + } + if (!options) { + options = { + html: [`${__dirname}/test.html`] + }; + } + return readFile(file).then((content) => { + return postcss([usedcss(options)]).process(content.toString()); + }); +}; diff --git a/tests/test.css b/tests/test.css new file mode 100644 index 0000000..d0d62cd --- /dev/null +++ b/tests/test.css @@ -0,0 +1,9 @@ +.test1 .test2 { color: red } +.test10,.test2 { color: pink; } +.test1:after { content: ''; } +.test2::before { content: ''; } +.test1 .test3 { color: white; } +.test1 .test4 { color: orange; } +.nested .test1 { color: blue; } +.nested>.test2 { color: yellow;} +.remove { color: black; } diff --git a/tests/test.html b/tests/test.html new file mode 100644 index 0000000..fa6d899 --- /dev/null +++ b/tests/test.html @@ -0,0 +1,4 @@ +
+
+
+