├── .eslintrc.json ├── .gitattributes ├── .github └── workflows │ ├── ci.yml │ └── codeql.yml ├── .gitignore ├── Gruntfile.js ├── LICENSE ├── README.md ├── _config.yml ├── assets ├── .eslintrc.json ├── master.css ├── master.js └── worker.js ├── backtest.js ├── benchmark.js ├── benchmarks ├── generated │ └── .gitkeep └── index.json ├── cli.js ├── dist ├── htmlminifier.js └── htmlminifier.min.js ├── index.html ├── package-lock.json ├── package.json ├── sample-cli-config-file.conf ├── src ├── .eslintrc.json ├── htmlminifier.js ├── htmlparser.js ├── tokenchain.js └── utils.js ├── test.js └── tests ├── .eslintrc.json ├── index.html └── minifier.js /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true 4 | }, 5 | "extends": "eslint:recommended", 6 | "rules": { 7 | "array-bracket-spacing": "error", 8 | "array-callback-return": "error", 9 | "block-scoped-var": "error", 10 | "block-spacing": "error", 11 | "brace-style": [ 12 | "error", 13 | "stroustrup", 14 | { 15 | "allowSingleLine": true 16 | } 17 | ], 18 | "comma-spacing": "error", 19 | "comma-style": [ 20 | "error", 21 | "last" 22 | ], 23 | "computed-property-spacing": "error", 24 | "curly": "error", 25 | "dot-location": [ 26 | "error", 27 | "property" 28 | ], 29 | "dot-notation": "error", 30 | "eol-last": "error", 31 | "eqeqeq": "error", 32 | "func-style": [ 33 | "error", 34 | "declaration" 35 | ], 36 | "indent": [ 37 | "error", 38 | 2, 39 | { 40 | "SwitchCase": 1, 41 | "VariableDeclarator": 2 42 | } 43 | ], 44 | "key-spacing": [ 45 | "error", 46 | { 47 | "beforeColon": false, 48 | "afterColon": true, 49 | "mode": "minimum" 50 | } 51 | ], 52 | "keyword-spacing": "error", 53 | "linebreak-style": "error", 54 | "new-parens": "error", 55 | "no-array-constructor": "error", 56 | "no-caller": "error", 57 | "no-console": "off", 58 | "no-else-return": "error", 59 | "no-eq-null": "error", 60 | "no-eval": "error", 61 | "no-extend-native": "error", 62 | "no-extra-bind": "error", 63 | "no-extra-label": "error", 64 | "no-extra-parens": "error", 65 | "no-floating-decimal": "error", 66 | "no-implied-eval": "error", 67 | "no-iterator": "error", 68 | "no-lone-blocks": "error", 69 | "no-lonely-if": "error", 70 | "no-multiple-empty-lines": "error", 71 | "no-multi-spaces": "error", 72 | "no-multi-str": "error", 73 | "no-native-reassign": "error", 74 | "no-negated-condition": "error", 75 | "no-new-wrappers": "error", 76 | "no-new-object": "error", 77 | "no-octal-escape": "error", 78 | "no-path-concat": "error", 79 | "no-process-env": "error", 80 | "no-proto": "error", 81 | "no-return-assign": "error", 82 | "no-script-url": "error", 83 | "no-self-compare": "error", 84 | "no-sequences": "error", 85 | "no-shadow-restricted-names": "error", 86 | "no-spaced-func": "error", 87 | "no-throw-literal": "error", 88 | "no-trailing-spaces": "error", 89 | "no-undef-init": "error", 90 | "no-undefined": "error", 91 | "no-unmodified-loop-condition": "error", 92 | "no-unneeded-ternary": "error", 93 | "no-unused-expressions": "error", 94 | "no-use-before-define": [ 95 | "error", 96 | "nofunc" 97 | ], 98 | "no-useless-call": "error", 99 | "no-useless-concat": "error", 100 | "no-useless-escape": "error", 101 | "no-void": "error", 102 | "no-whitespace-before-property": "error", 103 | "no-with": "error", 104 | "object-curly-spacing": [ 105 | "error", 106 | "always" 107 | ], 108 | "operator-assignment": [ 109 | "error", 110 | "always" 111 | ], 112 | "operator-linebreak": [ 113 | "error", 114 | "after" 115 | ], 116 | "quote-props": [ 117 | "error", 118 | "as-needed" 119 | ], 120 | "quotes": [ 121 | "error", 122 | "single" 123 | ], 124 | "semi": "error", 125 | "semi-spacing": "error", 126 | "space-before-blocks": "error", 127 | "space-before-function-paren": [ 128 | "error", 129 | "never" 130 | ], 131 | "space-in-parens": "error", 132 | "space-infix-ops": "error", 133 | "space-unary-ops": "error", 134 | "spaced-comment": [ 135 | "error", 136 | "always", 137 | { 138 | "markers": [ 139 | "!" 140 | ] 141 | } 142 | ], 143 | "strict": "error", 144 | "wrap-iife": [ 145 | "error", 146 | "inside" 147 | ], 148 | "yoda": "error" 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Enforce Unix newlines 2 | * text=auto eol=lf 3 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | env: 6 | FORCE_COLOR: 2 7 | 8 | jobs: 9 | test: 10 | name: Node ${{ matrix.node }} 11 | runs-on: ${{ matrix.os }} 12 | 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | os: [ubuntu-latest] 17 | node: [10, 12, 14, 16] 18 | 19 | steps: 20 | - name: Clone repository 21 | uses: actions/checkout@v2 22 | 23 | - name: Set up Node.js 24 | uses: actions/setup-node@v2 25 | with: 26 | node-version: ${{ matrix.node }} 27 | cache: npm 28 | 29 | - name: Install npm dependencies 30 | run: npm ci 31 | 32 | - name: Run tests 33 | run: npm test 34 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: 6 | - gh-pages 7 | - "!dependabot/**" 8 | pull_request: 9 | # The branches below must be a subset of the branches above 10 | branches: 11 | - gh-pages 12 | schedule: 13 | - cron: "0 0 * * 0" 14 | 15 | jobs: 16 | analyze: 17 | name: Analyze 18 | runs-on: ubuntu-latest 19 | permissions: 20 | actions: read 21 | contents: read 22 | security-events: write 23 | 24 | steps: 25 | - name: Checkout repository 26 | uses: actions/checkout@v2 27 | 28 | - name: Initialize CodeQL 29 | uses: github/codeql-action/init@v1 30 | with: 31 | languages: "javascript" 32 | 33 | - name: Perform CodeQL Analysis 34 | uses: github/codeql-action/analyze@v1 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.sublime-project 2 | *.sublime-workspace 3 | .DS_Store 4 | /.jekyll-metadata 5 | /_site/ 6 | /benchmarks/*.html 7 | /benchmarks/generated 8 | /node_modules/ 9 | /npm-debug.log 10 | -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | function qunitVersion() { 4 | var prepareStackTrace = Error.prepareStackTrace; 5 | Error.prepareStackTrace = function() { 6 | return ''; 7 | }; 8 | try { 9 | return require('qunit').version; 10 | } 11 | finally { 12 | Error.prepareStackTrace = prepareStackTrace; 13 | } 14 | } 15 | 16 | module.exports = function(grunt) { 17 | // Force use of Unix newlines 18 | grunt.util.linefeed = '\n'; 19 | 20 | grunt.initConfig({ 21 | pkg: grunt.file.readJSON('package.json'), 22 | qunit_ver: qunitVersion(), 23 | banner: '/*!\n' + 24 | ' * HTMLMinifier v<%= pkg.version %> (<%= pkg.homepage %>)\n' + 25 | ' * Copyright 2010-<%= grunt.template.today("yyyy") %> <%= pkg.author %>\n' + 26 | ' * Licensed under the <%= pkg.license %> license\n' + 27 | ' */\n', 28 | 29 | browserify: { 30 | src: { 31 | options: { 32 | banner: '<%= banner %>', 33 | preBundleCB: function() { 34 | var fs = require('fs'); 35 | var UglifyJS = require('uglify-js'); 36 | var files = {}; 37 | UglifyJS.FILES.forEach(function(file) { 38 | files[file] = fs.readFileSync(file, 'utf8'); 39 | }); 40 | fs.writeFileSync('./dist/uglify.js', UglifyJS.minify(files, { 41 | compress: false, 42 | mangle: false, 43 | wrap: 'exports' 44 | }).code); 45 | }, 46 | postBundleCB: function(err, src, next) { 47 | require('fs').unlinkSync('./dist/uglify.js'); 48 | next(err, src); 49 | }, 50 | require: [ 51 | './dist/uglify.js:uglify-js', 52 | './src/htmlminifier.js:html-minifier' 53 | ] 54 | }, 55 | src: 'src/htmlminifier.js', 56 | dest: 'dist/htmlminifier.js' 57 | } 58 | }, 59 | 60 | eslint: { 61 | grunt: { 62 | src: 'Gruntfile.js' 63 | }, 64 | src: { 65 | src: ['cli.js', 'src/**/*.js'] 66 | }, 67 | tests: { 68 | src: ['tests/*.js', 'test.js'] 69 | }, 70 | web: { 71 | src: ['assets/master.js', 'assets/worker.js'] 72 | }, 73 | other: { 74 | src: ['backtest.js', 'benchmark.js'] 75 | } 76 | }, 77 | 78 | qunit: { 79 | htmlminifier: ['./tests/minifier', 'tests/index.html'] 80 | }, 81 | 82 | replace: { 83 | './index.html': [ 84 | /(
foo`) 101 | 102 | ↓ 103 | 104 | Internal representation of markup in a form of tree (e.g. `{ tag: "p", attr: "id", children: ["foo"] }`) 105 | 106 | ↓ 107 | 108 | Transformation of internal representation (e.g. removal of `id` attribute) 109 | 110 | ↓ 111 | 112 | Output of resulting markup (e.g. `
foo
`) 113 | 114 | HTMLMinifier can't know that original markup was only half of the tree; it does its best to try to parse it as a full tree and it loses information about tree being malformed or partial in the beginning. As a result, it can't create a partial/malformed tree at the time of the output. 115 | 116 | ## Installation Instructions 117 | 118 | From NPM for use as a command line app: 119 | 120 | ```shell 121 | npm install html-minifier -g 122 | ``` 123 | 124 | From NPM for programmatic use: 125 | 126 | ```shell 127 | npm install html-minifier 128 | ``` 129 | 130 | From Git: 131 | 132 | ```shell 133 | git clone git://github.com/kangax/html-minifier.git 134 | cd html-minifier 135 | npm link . 136 | ``` 137 | 138 | ## Usage 139 | 140 | Note that almost all options are disabled by default. For command line usage please see `html-minifier --help` for a list of available options. Experiment and find what works best for you and your project. 141 | 142 | * **Sample command line:** ``html-minifier --collapse-whitespace --remove-comments --remove-optional-tags --remove-redundant-attributes --remove-script-type-attributes --remove-tag-whitespace --use-short-doctype --minify-css true --minify-js true`` 143 | 144 | ### Node.js 145 | 146 | ```js 147 | var minify = require('html-minifier').minify; 148 | var result = minify('foo
', { 149 | removeAttributeQuotes: true 150 | }); 151 | result; // 'foo
' 152 | ``` 153 | ### Gulp 154 | 155 | ```js 156 | const { src, dest, series } = require('gulp'); 157 | const htmlMinify = require('html-minifier'); 158 | 159 | const options = { 160 | includeAutoGeneratedTags: true, 161 | removeAttributeQuotes: true, 162 | removeComments: true, 163 | removeRedundantAttributes: true, 164 | removeScriptTypeAttributes: true, 165 | removeStyleLinkTypeAttributes: true, 166 | sortClassName: true, 167 | useShortDoctype: true, 168 | collapseWhitespace: true 169 | }; 170 | 171 | function html() { 172 | return src('app/**/*.html') 173 | .on('data', function(file) { 174 | const buferFile = Buffer.from(htmlMinify.minify(file.contents.toString(), options)) 175 | return file.contents = buferFile 176 | }) 177 | .pipe(dest('build')) 178 | } 179 | 180 | exports.html = series(html) 181 | ``` 182 | 183 | ## Running benchmarks 184 | 185 | Benchmarks for minified HTML: 186 | 187 | ```shell 188 | node benchmark.js 189 | ``` 190 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | exclude: 2 | - "*.conf" 3 | - "benchmarks" 4 | - "node_modules" 5 | - "src" 6 | - "backtest.js" 7 | - "benchmark.js" 8 | - "cli.js" 9 | - "Gruntfile.js" 10 | - "package.json" 11 | - "package-lock.json" 12 | - "README.md" 13 | - "LICENSE" 14 | - "test.js" 15 | -------------------------------------------------------------------------------- /assets/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "worker": true 5 | }, 6 | "rules": { 7 | "strict": [ 8 | "error", 9 | "function" 10 | ] 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /assets/master.css: -------------------------------------------------------------------------------- 1 | body { font-family: "Cambria", Georgia, Times, "Times New Roman", serif; margin-top: 0; padding-top: 0; } 2 | textarea { height: 30em; } 3 | h1 { margin-top: 0.5em; font-size: 1.25em; } 4 | h1 span { font-size: 0.6em; } 5 | button { font-weight: bold; width: 100px; } 6 | 7 | .minify-button { margin: 16px 0; } 8 | #outer-wrapper { overflow: hidden; } 9 | #wrapper { width: 65%; float: left; } 10 | #input { width: 99%; height: 18em; } 11 | #output { width: 99%; height: 18em; margin-bottom: 2em; } 12 | #options { float: right; width: 33%; padding-left: 1em; margin-top: 3em; } 13 | #options ul { list-style: none; padding: 0.5em; overflow: hidden; background: #ffe; margin-top: 0; } 14 | #options ul li { float: left; clear: both; padding-bottom: 0.5em; } 15 | #options ul li div { margin-left: 1.75em; } 16 | #options label, #options input { float: left; } 17 | #options label.sub-option{ margin-left: 22px; margin-right: 5px } 18 | #options label { margin-left: 0.25em; } 19 | #options label + input { margin-left: 0.5em; } 20 | #stats { margin-bottom: 2em; overflow: hidden; margin-top: 0; } 21 | #todo { font-family: monospace; margin-bottom: 2em; } 22 | 23 | .success { color: green; } 24 | .failure { color: red; } 25 | .quiet { font-size: 0.85em; color: #888; } 26 | .short { display: inline-block; width: 20em; margin-top: 0.25em; margin-left: 0.25em; } 27 | 28 | .controls span { margin-right: 0.5em; margin-left: 1em; } 29 | .controls a { margin-left: 0.1em; } 30 | .controls a:focus, .controls a:hover { text-decoration: none; } 31 | 32 | .unsafe { color: #f33; } 33 | 34 | iframe { position: absolute; top: 10px; right: 10px; } 35 | 36 | .footer p { font-style: italic; } 37 | -------------------------------------------------------------------------------- /assets/master.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | 'use strict'; 3 | 4 | var minify = (function() { 5 | var minify = require('html-minifier').minify; 6 | return function(value, options, callback, errorback) { 7 | options.log = function(message) { 8 | console.log(message); 9 | }; 10 | var minified; 11 | try { 12 | minified = minify(value, options); 13 | } 14 | catch (err) { 15 | return errorback(err); 16 | } 17 | callback(minified); 18 | }; 19 | })(); 20 | if (typeof Worker === 'function') { 21 | var worker = new Worker('assets/worker.js'); 22 | worker.onmessage = function() { 23 | minify = function(value, options, callback, errorback) { 24 | worker.onmessage = function(event) { 25 | var data = event.data; 26 | if (data.error) { 27 | errorback(data.error); 28 | } 29 | else { 30 | callback(data); 31 | } 32 | }; 33 | worker.postMessage({ 34 | value: value, 35 | options: options 36 | }); 37 | }; 38 | }; 39 | } 40 | 41 | function byId(id) { 42 | return document.getElementById(id); 43 | } 44 | 45 | function escapeHTML(str) { 46 | return (str + '').replace(/&/g, '&').replace(//g, '>'); 47 | } 48 | 49 | function forEachOption(fn) { 50 | [].forEach.call(byId('options').getElementsByTagName('input'), fn); 51 | } 52 | 53 | function getOptions() { 54 | var options = {}; 55 | forEachOption(function(element) { 56 | var key = element.id; 57 | var value; 58 | if (element.type === 'checkbox') { 59 | value = element.checked; 60 | } 61 | else { 62 | value = element.value.replace(/^\s+|\s+$/, ''); 63 | if (!value) { 64 | return; 65 | } 66 | } 67 | switch (key) { 68 | case 'maxLineLength': 69 | value = parseInt(value); 70 | break; 71 | case 'processScripts': 72 | value = value.split(/\s*,\s*/); 73 | } 74 | options[key] = value; 75 | }); 76 | return options; 77 | } 78 | 79 | function commify(str) { 80 | return String(str) 81 | .split('').reverse().join('') 82 | .replace(/(...)(?!$)/g, '$1,') 83 | .split('').reverse().join(''); 84 | } 85 | 86 | byId('minify-btn').onclick = function() { 87 | byId('minify-btn').disabled = true; 88 | var originalValue = byId('input').value; 89 | minify(originalValue, getOptions(), function(minifiedValue) { 90 | var diff = originalValue.length - minifiedValue.length; 91 | var savings = originalValue.length ? (100 * diff / originalValue.length).toFixed(2) : 0; 92 | 93 | byId('output').value = minifiedValue; 94 | 95 | byId('stats').innerHTML = 96 | '' + 97 | 'Original size: ' + commify(originalValue.length) + '' + 98 | '. Minified size: ' + commify(minifiedValue.length) + '' + 99 | '. Savings: ' + commify(diff) + ' (' + savings + '%).' + 100 | ''; 101 | byId('minify-btn').disabled = false; 102 | }, function(err) { 103 | byId('output').value = ''; 104 | byId('stats').innerHTML = '' + escapeHTML(err) + ''; 105 | byId('minify-btn').disabled = false; 106 | }); 107 | }; 108 | 109 | byId('select-all').onclick = function() { 110 | forEachOption(function(element) { 111 | if (element.type === 'checkbox') { 112 | element.checked = true; 113 | } 114 | }); 115 | return false; 116 | }; 117 | 118 | byId('select-none').onclick = function() { 119 | forEachOption(function(element) { 120 | if (element.type === 'checkbox') { 121 | element.checked = false; 122 | } 123 | else { 124 | element.value = ''; 125 | } 126 | }); 127 | return false; 128 | }; 129 | 130 | var defaultOptions = getOptions(); 131 | byId('select-defaults').onclick = function() { 132 | for (var key in defaultOptions) { 133 | var element = byId(key); 134 | element[element.type === 'checkbox' ? 'checked' : 'value'] = defaultOptions[key]; 135 | } 136 | return false; 137 | }; 138 | })(); 139 | 140 | /* eslint-disable */ 141 | (function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){ 142 | (i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o), 143 | m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m) 144 | })(window,document,'script','//www.google-analytics.com/analytics.js','ga'); 145 | 146 | ga('create', 'UA-1128111-22', 'auto'); 147 | ga('send', 'pageview'); 148 | 149 | (function(i){ 150 | var s = document.getElementById(i); 151 | var f = document.createElement('iframe'); 152 | f.src = (document.location.protocol === 'https:' ? 'https' : 'http') + '://api.flattr.com/button/view/?uid=kangax&button=compact&url=' + encodeURIComponent(document.URL); 153 | f.title = 'Flattr'; 154 | f.height = 20; 155 | f.width = 110; 156 | f.style.borderWidth = 0; 157 | s.parentNode.insertBefore(f, s); 158 | })('wrapper'); 159 | -------------------------------------------------------------------------------- /assets/worker.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | 'use strict'; 3 | 4 | importScripts('../dist/htmlminifier.min.js'); 5 | var minify = require('html-minifier').minify; 6 | addEventListener('message', function(event) { 7 | try { 8 | var options = event.data.options; 9 | options.log = function(message) { 10 | console.log(message); 11 | }; 12 | postMessage(minify(event.data.value, options)); 13 | } 14 | catch (err) { 15 | postMessage({ 16 | error: err + '' 17 | }); 18 | } 19 | }); 20 | postMessage(null); 21 | })(); 22 | -------------------------------------------------------------------------------- /backtest.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | 'use strict'; 4 | 5 | var child_process = require('child_process'), 6 | fs = require('fs'), 7 | os = require('os'), 8 | path = require('path'), 9 | Progress = require('progress'); 10 | 11 | var urls = require('./benchmarks'); 12 | var fileNames = Object.keys(urls); 13 | 14 | function git() { 15 | var args = [].concat.apply([], [].slice.call(arguments, 0, -1)); 16 | var callback = arguments[arguments.length - 1]; 17 | var task = child_process.spawn('git', args, { stdio: ['ignore', 'pipe', 'ignore'] }); 18 | var output = ''; 19 | task.stdout.setEncoding('utf8'); 20 | task.stdout.on('data', function(data) { 21 | output += data; 22 | }); 23 | task.on('exit', function(code) { 24 | callback(code, output); 25 | }); 26 | } 27 | 28 | function readText(filePath, callback) { 29 | fs.readFile(filePath, { encoding: 'utf8' }, callback); 30 | } 31 | 32 | function writeText(filePath, data) { 33 | fs.writeFile(filePath, data, { encoding: 'utf8' }, function(err) { 34 | if (err) { 35 | throw err; 36 | } 37 | }); 38 | } 39 | 40 | function loadModule() { 41 | require('./src/htmlparser'); 42 | return require('./src/htmlminifier').minify || global.minify; 43 | } 44 | 45 | function getOptions(fileName, options) { 46 | var result = { 47 | minifyURLs: { 48 | site: urls[fileName] 49 | } 50 | }; 51 | for (var key in options) { 52 | result[key] = options[key]; 53 | } 54 | return result; 55 | } 56 | 57 | function minify(hash, options) { 58 | var minify = loadModule(); 59 | process.send('ready'); 60 | var count = fileNames.length; 61 | fileNames.forEach(function(fileName) { 62 | readText(path.join('benchmarks/', fileName + '.html'), function(err, data) { 63 | if (err) { 64 | throw err; 65 | } 66 | else { 67 | try { 68 | var minified = minify(data, getOptions(fileName, options)); 69 | if (minified) { 70 | process.send({ name: fileName, size: minified.length }); 71 | } 72 | else { 73 | throw new Error('unexpected result: ' + minified); 74 | } 75 | } 76 | catch (e) { 77 | console.error('[' + fileName + ']', e.stack || e); 78 | } 79 | finally { 80 | if (!--count) { 81 | process.disconnect(); 82 | } 83 | } 84 | } 85 | }); 86 | }); 87 | } 88 | 89 | function print(table) { 90 | var output = []; 91 | var errors = []; 92 | var row = fileNames.slice(0); 93 | row.unshift('hash', 'date'); 94 | output.push(row.join(',')); 95 | for (var hash in table) { 96 | var data = table[hash]; 97 | row = [hash, '"' + data.date + '"']; 98 | fileNames.forEach(function(fileName) { 99 | row.push(data[fileName]); 100 | }); 101 | output.push(row.join(',')); 102 | if (data.error) { 103 | errors.push(hash + ' - ' + data.error); 104 | } 105 | } 106 | writeText('backtest.csv', output.join('\n')); 107 | writeText('backtest.log', errors.join('\n')); 108 | } 109 | 110 | if (process.argv.length > 2) { 111 | var count = +process.argv[2]; 112 | if (count) { 113 | git('log', '--date=iso', '--pretty=format:%h %cd', '-' + count, function(code, data) { 114 | var table = {}; 115 | var commits = data.split(/\s*?\n/).map(function(line) { 116 | var index = line.indexOf(' '); 117 | var hash = line.substr(0, index); 118 | table[hash] = { 119 | date: line.substr(index + 1).replace('+', '').replace(/ 0000$/, '') 120 | }; 121 | return hash; 122 | }); 123 | var nThreads = os.cpus().length; 124 | var running = 0; 125 | var progress = new Progress('[:bar] :etas', { 126 | width: 50, 127 | total: commits.length * 2 128 | }); 129 | 130 | function fork() { 131 | if (commits.length && running < nThreads) { 132 | var hash = commits.shift(); 133 | var task = child_process.fork('./backtest', { silent: true }); 134 | var error = ''; 135 | var id = setTimeout(function() { 136 | if (task.connected) { 137 | error += 'task timed out\n'; 138 | task.kill(); 139 | } 140 | }, 60000); 141 | task.on('message', function(data) { 142 | if (data === 'ready') { 143 | progress.tick(1); 144 | fork(); 145 | } 146 | else { 147 | table[hash][data.name] = data.size; 148 | } 149 | }).on('exit', function() { 150 | progress.tick(1); 151 | clearTimeout(id); 152 | if (error) { 153 | table[hash].error = error; 154 | } 155 | if (!--running && !commits.length) { 156 | print(table); 157 | } 158 | else { 159 | fork(); 160 | } 161 | }); 162 | task.stderr.setEncoding('utf8'); 163 | task.stderr.on('data', function(data) { 164 | error += data; 165 | }); 166 | task.stdout.resume(); 167 | task.send(hash); 168 | running++; 169 | } 170 | } 171 | 172 | fork(); 173 | }); 174 | } 175 | else { 176 | console.error('Invalid input:', process.argv[2]); 177 | } 178 | } 179 | else { 180 | process.on('message', function(hash) { 181 | var paths = ['src', 'benchmark.conf', 'sample-cli-config-file.conf']; 182 | git('reset', 'HEAD', '--', paths, function() { 183 | var conf = 'sample-cli-config-file.conf'; 184 | 185 | function checkout() { 186 | var path = paths.shift(); 187 | git('checkout', hash, '--', path, function(code) { 188 | if (code === 0 && path === 'benchmark.conf') { 189 | conf = path; 190 | } 191 | if (paths.length) { 192 | checkout(); 193 | } 194 | else { 195 | readText(conf, function(err, data) { 196 | if (err) { 197 | throw err; 198 | } 199 | else { 200 | minify(hash, JSON.parse(data)); 201 | } 202 | }); 203 | } 204 | }); 205 | } 206 | 207 | checkout(); 208 | }); 209 | }); 210 | } 211 | -------------------------------------------------------------------------------- /benchmark.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | 'use strict'; 4 | 5 | var packages = require('./package.json').benchmarkDependencies; 6 | packages = Object.keys(packages).map(function(name) { 7 | return name + '@' + packages[name]; 8 | }); 9 | packages.unshift('install', '--no-save', '--no-optional'); 10 | var installed = require('child_process').spawnSync('npm', packages, { 11 | encoding: 'utf-8', 12 | shell: true 13 | }); 14 | if (installed.error) { 15 | throw installed.error; 16 | } 17 | else if (installed.status) { 18 | console.log(installed.stdout); 19 | console.error(installed.stderr); 20 | process.exit(installed.status); 21 | } 22 | 23 | var brotli = require('brotli'), 24 | chalk = require('chalk'), 25 | fork = require('child_process').fork, 26 | fs = require('fs'), 27 | https = require('https'), 28 | lzma = require('lzma'), 29 | Minimize = require('minimize'), 30 | path = require('path'), 31 | Progress = require('progress'), 32 | querystring = require('querystring'), 33 | Table = require('cli-table'), 34 | url = require('url'), 35 | zlib = require('zlib'); 36 | 37 | var urls = require('./benchmarks'); 38 | var fileNames = Object.keys(urls); 39 | 40 | var minimize = new Minimize(); 41 | 42 | var progress = new Progress('[:bar] :etas :fileName', { 43 | width: 50, 44 | total: fileNames.length 45 | }); 46 | 47 | var table = new Table({ 48 | head: ['File', 'Before', 'After', 'Minimize', 'Will Peavy', 'htmlcompressor.com', 'Savings', 'Time'], 49 | colWidths: [fileNames.reduce(function(length, fileName) { 50 | return Math.max(length, fileName.length); 51 | }, 0) + 2, 25, 25, 25, 25, 25, 20, 10] 52 | }); 53 | 54 | function toKb(size, precision) { 55 | return (size / 1024).toFixed(precision || 0); 56 | } 57 | 58 | function redSize(size) { 59 | return chalk.red.bold(size) + chalk.white(' (' + toKb(size, 2) + ' KB)'); 60 | } 61 | 62 | function greenSize(size) { 63 | return chalk.green.bold(size) + chalk.white(' (' + toKb(size, 2) + ' KB)'); 64 | } 65 | 66 | function blueSavings(oldSize, newSize) { 67 | var savingsPercent = (1 - newSize / oldSize) * 100; 68 | var savings = oldSize - newSize; 69 | return chalk.cyan.bold(savingsPercent.toFixed(2)) + chalk.white('% (' + toKb(savings, 2) + ' KB)'); 70 | } 71 | 72 | function blueTime(time) { 73 | return chalk.cyan.bold(time) + chalk.white(' ms'); 74 | } 75 | 76 | function readBuffer(filePath, callback) { 77 | fs.readFile(filePath, function(err, data) { 78 | if (err) { 79 | throw new Error('There was an error reading ' + filePath); 80 | } 81 | callback(data); 82 | }); 83 | } 84 | 85 | function readText(filePath, callback) { 86 | fs.readFile(filePath, { encoding: 'utf8' }, function(err, data) { 87 | if (err) { 88 | throw new Error('There was an error reading ' + filePath); 89 | } 90 | callback(data); 91 | }); 92 | } 93 | 94 | function writeBuffer(filePath, data, callback) { 95 | fs.writeFile(filePath, data, function(err) { 96 | if (err) { 97 | throw new Error('There was an error writing ' + filePath); 98 | } 99 | callback(); 100 | }); 101 | } 102 | 103 | function writeText(filePath, data, callback) { 104 | fs.writeFile(filePath, data, { encoding: 'utf8' }, function(err) { 105 | if (err) { 106 | throw new Error('There was an error writing ' + filePath); 107 | } 108 | if (callback) { 109 | callback(); 110 | } 111 | }); 112 | } 113 | 114 | function readSize(filePath, callback) { 115 | fs.stat(filePath, function(err, stats) { 116 | if (err) { 117 | throw new Error('There was an error reading ' + filePath); 118 | } 119 | callback(stats.size); 120 | }); 121 | } 122 | 123 | function gzip(inPath, outPath, callback) { 124 | fs.createReadStream(inPath).pipe(zlib.createGzip({ 125 | level: zlib.Z_BEST_COMPRESSION 126 | })).pipe(fs.createWriteStream(outPath)).on('finish', callback); 127 | } 128 | 129 | function run(tasks, done) { 130 | var i = 0; 131 | 132 | function callback() { 133 | if (i < tasks.length) { 134 | tasks[i++](callback); 135 | } 136 | else { 137 | done(); 138 | } 139 | } 140 | 141 | callback(); 142 | } 143 | 144 | var rows = {}; 145 | 146 | function generateMarkdownTable() { 147 | var headers = [ 148 | 'Site', 149 | 'Original size *(KB)*', 150 | 'HTMLMinifier', 151 | 'minimize', 152 | 'Will Peavy', 153 | 'htmlcompressor.com' 154 | ]; 155 | fileNames.forEach(function(fileName) { 156 | var row = rows[fileName].report; 157 | row[2] = '**' + row[2] + '**'; 158 | }); 159 | var widths = headers.map(function(header, index) { 160 | var width = header.length; 161 | fileNames.forEach(function(fileName) { 162 | width = Math.max(width, rows[fileName].report[index].length); 163 | }); 164 | return width; 165 | }); 166 | var content = ''; 167 | 168 | function output(row) { 169 | widths.forEach(function(width, index) { 170 | var text = row[index]; 171 | content += '| ' + text + new Array(width - text.length + 2).join(' '); 172 | }); 173 | content += '|\n'; 174 | } 175 | 176 | output(headers); 177 | widths.forEach(function(width, index) { 178 | content += '|'; 179 | content += index === 1 ? ':' : ' '; 180 | content += new Array(width + 1).join('-'); 181 | content += index === 0 ? ' ' : ':'; 182 | }); 183 | content += '|\n'; 184 | fileNames.sort(function(a, b) { 185 | var r = +rows[a].report[1]; 186 | var s = +rows[b].report[1]; 187 | return r < s ? -1 : r > s ? 1 : a < b ? -1 : a > b ? 1 : 0; 188 | }).forEach(function(fileName) { 189 | output(rows[fileName].report); 190 | }); 191 | return content; 192 | } 193 | 194 | function displayTable() { 195 | fileNames.forEach(function(fileName) { 196 | table.push(rows[fileName].display); 197 | }); 198 | console.log(); 199 | console.log(table.toString()); 200 | } 201 | 202 | run(fileNames.map(function(fileName) { 203 | var filePath = path.join('benchmarks/', fileName + '.html'); 204 | 205 | function processFile(site, done) { 206 | var original = { 207 | filePath: filePath, 208 | gzFilePath: path.join('benchmarks/generated/', fileName + '.html.gz'), 209 | lzFilePath: path.join('benchmarks/generated/', fileName + '.html.lz'), 210 | brFilePath: path.join('benchmarks/generated/', fileName + '.html.br') 211 | }; 212 | var infos = {}; 213 | ['minifier', 'minimize', 'willpeavy', 'compressor'].forEach(function(name) { 214 | infos[name] = { 215 | filePath: path.join('benchmarks/generated/', fileName + '.' + name + '.html'), 216 | gzFilePath: path.join('benchmarks/generated/', fileName + '.' + name + '.html.gz'), 217 | lzFilePath: path.join('benchmarks/generated/', fileName + '.' + name + '.html.lz'), 218 | brFilePath: path.join('benchmarks/generated/', fileName + '.' + name + '.html.br') 219 | }; 220 | }); 221 | 222 | function readSizes(info, done) { 223 | info.endTime = Date.now(); 224 | run([ 225 | // Apply Gzip on minified output 226 | function(done) { 227 | gzip(info.filePath, info.gzFilePath, function() { 228 | info.gzTime = Date.now(); 229 | // Open and read the size of the minified+gzip output 230 | readSize(info.gzFilePath, function(size) { 231 | info.gzSize = size; 232 | done(); 233 | }); 234 | }); 235 | }, 236 | // Apply LZMA on minified output 237 | function(done) { 238 | readBuffer(info.filePath, function(data) { 239 | lzma.compress(data, 1, function(result, error) { 240 | if (error) { 241 | throw error; 242 | } 243 | writeBuffer(info.lzFilePath, new Buffer(result), function() { 244 | info.lzTime = Date.now(); 245 | // Open and read the size of the minified+lzma output 246 | readSize(info.lzFilePath, function(size) { 247 | info.lzSize = size; 248 | done(); 249 | }); 250 | }); 251 | }); 252 | }); 253 | }, 254 | // Apply Brotli on minified output 255 | function(done) { 256 | readBuffer(info.filePath, function(data) { 257 | var output = new Buffer(brotli.compress(data, true).buffer); 258 | writeBuffer(info.brFilePath, output, function() { 259 | info.brTime = Date.now(); 260 | // Open and read the size of the minified+brotli output 261 | readSize(info.brFilePath, function(size) { 262 | info.brSize = size; 263 | done(); 264 | }); 265 | }); 266 | }); 267 | }, 268 | // Open and read the size of the minified output 269 | function(done) { 270 | readSize(info.filePath, function(size) { 271 | info.size = size; 272 | done(); 273 | }); 274 | } 275 | ], done); 276 | } 277 | 278 | function testHTMLMinifier(done) { 279 | var info = infos.minifier; 280 | info.startTime = Date.now(); 281 | var args = [filePath, '-c', 'sample-cli-config-file.conf', '--minify-urls', site, '-o', info.filePath]; 282 | fork('./cli', args).on('exit', function() { 283 | readSizes(info, done); 284 | }); 285 | } 286 | 287 | function testMinimize(done) { 288 | readBuffer(filePath, function(data) { 289 | minimize.parse(data, function(error, data) { 290 | var info = infos.minimize; 291 | writeBuffer(info.filePath, data, function() { 292 | readSizes(info, done); 293 | }); 294 | }); 295 | }); 296 | } 297 | 298 | function testWillPeavy(done) { 299 | readText(filePath, function(data) { 300 | var options = url.parse('https://www.willpeavy.com/minifier/'); 301 | options.method = 'POST'; 302 | options.headers = { 303 | 'Content-Type': 'application/x-www-form-urlencoded' 304 | }; 305 | https.request(options, function(res) { 306 | res.setEncoding('utf8'); 307 | var response = ''; 308 | res.on('data', function(chunk) { 309 | response += chunk; 310 | }).on('end', function() { 311 | var info = infos.willpeavy; 312 | if (res.statusCode === 200) { 313 | // Extract result from 314 | var start = response.indexOf('>', response.indexOf('