├── .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 | /(

.*?).*?(<\/span><\/h1>)/, 85 | '$1(v<%= pkg.version %>)$2' 86 | ], 87 | './tests/index.html': [ 88 | /("[^"]+\/qunit-)[0-9.]+?(\.(?:css|js)")/g, 89 | '$1<%= qunit_ver %>$2' 90 | ] 91 | }, 92 | 93 | uglify: { 94 | options: { 95 | banner: '<%= banner %>', 96 | compress: true, 97 | mangle: true, 98 | preserveComments: false, 99 | report: 'min' 100 | }, 101 | minify: { 102 | files: { 103 | 'dist/htmlminifier.min.js': '<%= browserify.src.dest %>' 104 | } 105 | } 106 | } 107 | }); 108 | 109 | grunt.loadNpmTasks('grunt-browserify'); 110 | grunt.loadNpmTasks('grunt-contrib-uglify'); 111 | grunt.loadNpmTasks('grunt-eslint'); 112 | 113 | function report(type, details) { 114 | grunt.log.writeln(type + ' completed in ' + details.runtime + 'ms'); 115 | details.failures.forEach(function(details) { 116 | grunt.log.error(); 117 | grunt.log.error(details.name + (details.message ? ' [' + details.message + ']' : '')); 118 | grunt.log.error(details.source); 119 | grunt.log.error('Actual:'); 120 | grunt.log.error(details.actual); 121 | grunt.log.error('Expected:'); 122 | grunt.log.error(details.expected); 123 | }); 124 | grunt.log[details.failed ? 'error' : 'ok'](details.passed + ' of ' + details.total + ' passed, ' + details.failed + ' failed'); 125 | return details.failed; 126 | } 127 | 128 | var phantomjs = require('phantomjs-prebuilt').path; 129 | grunt.registerMultiTask('qunit', function() { 130 | var done = this.async(); 131 | var errors = []; 132 | 133 | function run(testType, binPath, testPath) { 134 | grunt.util.spawn({ 135 | cmd: binPath, 136 | args: ['test.js', testPath] 137 | }, function(error, result) { 138 | if (error) { 139 | grunt.log.error(result.stderr); 140 | grunt.log.error(testType + ' test failed to load'); 141 | errors.push(-1); 142 | } 143 | else { 144 | var output = result.stdout; 145 | var index = output.lastIndexOf('\n'); 146 | if (index !== -1) { 147 | // There's something before the report JSON 148 | // Log it to the console -- it's probably some debug output: 149 | console.log(output.slice(0, index)); 150 | output = output.slice(index); 151 | } 152 | errors.push(report(testType, JSON.parse(output))); 153 | } 154 | if (errors.length === 2) { 155 | done(!errors[0] && !errors[1]); 156 | } 157 | }); 158 | } 159 | 160 | run('node', process.argv[0], this.data[0]); 161 | run('web', phantomjs, this.data[1]); 162 | }); 163 | 164 | grunt.registerMultiTask('replace', function() { 165 | var pattern = this.data[0]; 166 | var path = this.target; 167 | var html = grunt.file.read(path); 168 | html = html.replace(pattern, this.data[1]); 169 | grunt.file.write(path, html); 170 | }); 171 | 172 | grunt.registerTask('dist', [ 173 | 'replace', 174 | 'browserify', 175 | 'uglify' 176 | ]); 177 | 178 | grunt.registerTask('test', [ 179 | 'eslint', 180 | 'dist', 181 | 'qunit' 182 | ]); 183 | 184 | grunt.registerTask('default', 'test'); 185 | }; 186 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2010-2018 Juriy "kangax" Zaytsev 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HTMLMinifier 2 | 3 | [![npm version](https://img.shields.io/npm/v/html-minifier.svg)](https://www.npmjs.com/package/html-minifier) 4 | [![Build Status](https://img.shields.io/github/workflow/status/kangax/html-minifier/CI/gh-pages)](https://github.com/kangax/html-minifier/actions?query=workflow%3ACI+branch%3Agh-pages) 5 | [![Dependency Status](https://img.shields.io/david/kangax/html-minifier.svg)](https://david-dm.org/kangax/html-minifier) 6 | 7 | [HTMLMinifier](https://kangax.github.io/html-minifier/) is a highly **configurable**, **well-tested**, JavaScript-based HTML minifier. 8 | 9 | See [corresponding blog post](http://perfectionkills.com/experimenting-with-html-minifier/) for all the gory details of [how it works](http://perfectionkills.com/experimenting-with-html-minifier/#how_it_works), [description of each option](http://perfectionkills.com/experimenting-with-html-minifier/#options), [testing results](http://perfectionkills.com/experimenting-with-html-minifier/#field_testing) and [conclusions](http://perfectionkills.com/experimenting-with-html-minifier/#cost_and_benefits). 10 | 11 | [Test suite is available online](https://kangax.github.io/html-minifier/tests/). 12 | 13 | Also see corresponding [Ruby wrapper](https://github.com/stereobooster/html_minifier), and for Node.js, [Grunt plugin](https://github.com/gruntjs/grunt-contrib-htmlmin), [Gulp module](https://github.com/jonschlinkert/gulp-htmlmin), [Koa middleware wrapper](https://github.com/koajs/html-minifier) and [Express middleware wrapper](https://github.com/melonmanchan/express-minify-html). 14 | 15 | For lint-like capabilities take a look at [HTMLLint](https://github.com/kangax/html-lint). 16 | 17 | ## Minification comparison 18 | 19 | How does HTMLMinifier compare to other solutions — [HTML Minifier from Will Peavy](http://www.willpeavy.com/minifier/) (1st result in [Google search for "html minifier"](https://www.google.com/#q=html+minifier)) as well as [htmlcompressor.com](http://htmlcompressor.com) and [minimize](https://github.com/Swaagie/minimize)? 20 | 21 | | Site | Original size *(KB)* | HTMLMinifier | minimize | Will Peavy | htmlcompressor.com | 22 | | ---------------------------------------------------------------------------- |:--------------------:| ------------:| --------:| ----------:| ------------------:| 23 | | [Google](https://www.google.com/) | 46 | **42** | 46 | 48 | 46 | 24 | | [HTMLMinifier](https://github.com/kangax/html-minifier) | 125 | **98** | 111 | 117 | 111 | 25 | | [Twitter](https://twitter.com/) | 207 | **165** | 200 | 224 | 200 | 26 | | [Stack Overflow](https://stackoverflow.com/) | 253 | **195** | 207 | 215 | 204 | 27 | | [Bootstrap CSS](https://getbootstrap.com/docs/3.3/css/) | 271 | **260** | 269 | 228 | 269 | 28 | | [BBC](https://www.bbc.co.uk/) | 298 | **239** | 290 | 291 | 280 | 29 | | [Amazon](https://www.amazon.co.uk/) | 422 | **316** | 412 | 425 | n/a | 30 | | [NBC](https://www.nbc.com/) | 553 | **530** | 552 | 553 | 534 | 31 | | [Wikipedia](https://en.wikipedia.org/wiki/President_of_the_United_States) | 565 | **461** | 548 | 569 | 548 | 32 | | [New York Times](https://www.nytimes.com/) | 678 | **606** | 675 | 670 | n/a | 33 | | [Eloquent Javascript](https://eloquentjavascript.net/1st_edition/print.html) | 870 | **815** | 840 | 864 | n/a | 34 | | [ES6 table](https://kangax.github.io/compat-table/es6/) | 5911 | **5051** | 5595 | n/a | n/a | 35 | | [ES draft](https://tc39.github.io/ecma262/) | 6126 | **5495** | 5664 | n/a | n/a | 36 | 37 | ## Options Quick Reference 38 | 39 | Most of the options are disabled by default. 40 | 41 | | Option | Description | Default | 42 | |--------------------------------|-----------------|---------| 43 | | `caseSensitive` | Treat attributes in case sensitive manner (useful for custom HTML tags) | `false` | 44 | | `collapseBooleanAttributes` | [Omit attribute values from boolean attributes](http://perfectionkills.com/experimenting-with-html-minifier/#collapse_boolean_attributes) | `false` | 45 | | `collapseInlineTagWhitespace` | Don't leave any spaces between `display:inline;` elements when collapsing. Must be used in conjunction with `collapseWhitespace=true` | `false` | 46 | | `collapseWhitespace` | [Collapse white space that contributes to text nodes in a document tree](http://perfectionkills.com/experimenting-with-html-minifier/#collapse_whitespace) | `false` | 47 | | `conservativeCollapse` | Always collapse to 1 space (never remove it entirely). Must be used in conjunction with `collapseWhitespace=true` | `false` | 48 | | `continueOnParseError` | [Handle parse errors](https://html.spec.whatwg.org/multipage/parsing.html#parse-errors) instead of aborting. | `false` | 49 | | `customAttrAssign` | Arrays of regex'es that allow to support custom attribute assign expressions (e.g. `'
'`) | `[ ]` | 50 | | `customAttrCollapse` | Regex that specifies custom attribute to strip newlines from (e.g. `/ng-class/`) | | 51 | | `customAttrSurround` | Arrays of regex'es that allow to support custom attribute surround expressions (e.g. ``) | `[ ]` | 52 | | `customEventAttributes` | Arrays of regex'es that allow to support custom event attributes for `minifyJS` (e.g. `ng-click`) | `[ /^on[a-z]{3,}$/ ]` | 53 | | `decodeEntities` | Use direct Unicode characters whenever possible | `false` | 54 | | `html5` | Parse input according to HTML5 specifications | `true` | 55 | | `ignoreCustomComments` | Array of regex'es that allow to ignore certain comments, when matched | `[ /^!/ ]` | 56 | | `ignoreCustomFragments` | Array of regex'es that allow to ignore certain fragments, when matched (e.g. ``, `{{ ... }}`, etc.) | `[ /<%[\s\S]*?%>/, /<\?[\s\S]*?\?>/ ]` | 57 | | `includeAutoGeneratedTags` | Insert tags generated by HTML parser | `true` | 58 | | `keepClosingSlash` | Keep the trailing slash on singleton elements | `false` | 59 | | `maxLineLength` | Specify a maximum line length. Compressed output will be split by newlines at valid HTML split-points | 60 | | `minifyCSS` | Minify CSS in style elements and style attributes (uses [clean-css](https://github.com/jakubpawlowicz/clean-css)) | `false` (could be `true`, `Object`, `Function(text, type)`) | 61 | | `minifyJS` | Minify JavaScript in script elements and event attributes (uses [UglifyJS](https://github.com/mishoo/UglifyJS2)) | `false` (could be `true`, `Object`, `Function(text, inline)`) | 62 | | `minifyURLs` | Minify URLs in various attributes (uses [relateurl](https://github.com/stevenvachon/relateurl)) | `false` (could be `String`, `Object`, `Function(text)`) | 63 | | `preserveLineBreaks` | Always collapse to 1 line break (never remove it entirely) when whitespace between tags include a line break. Must be used in conjunction with `collapseWhitespace=true` | `false` | 64 | | `preventAttributesEscaping` | Prevents the escaping of the values of attributes | `false` | 65 | | `processConditionalComments` | Process contents of conditional comments through minifier | `false` | 66 | | `processScripts` | Array of strings corresponding to types of script elements to process through minifier (e.g. `text/ng-template`, `text/x-handlebars-template`, etc.) | `[ ]` | 67 | | `quoteCharacter` | Type of quote to use for attribute values (' or ") | | 68 | | `removeAttributeQuotes` | [Remove quotes around attributes when possible](http://perfectionkills.com/experimenting-with-html-minifier/#remove_attribute_quotes) | `false` | 69 | | `removeComments` | [Strip HTML comments](http://perfectionkills.com/experimenting-with-html-minifier/#remove_comments) | `false` | 70 | | `removeEmptyAttributes` | [Remove all attributes with whitespace-only values](http://perfectionkills.com/experimenting-with-html-minifier/#remove_empty_or_blank_attributes) | `false` (could be `true`, `Function(attrName, tag)`) | 71 | | `removeEmptyElements` | [Remove all elements with empty contents](http://perfectionkills.com/experimenting-with-html-minifier/#remove_empty_elements) | `false` | 72 | | `removeOptionalTags` | [Remove optional tags](http://perfectionkills.com/experimenting-with-html-minifier/#remove_optional_tags) | `false` | 73 | | `removeRedundantAttributes` | [Remove attributes when value matches default.](http://perfectionkills.com/experimenting-with-html-minifier/#remove_redundant_attributes) | `false` | 74 | | `removeScriptTypeAttributes` | Remove `type="text/javascript"` from `script` tags. Other `type` attribute values are left intact | `false` | 75 | | `removeStyleLinkTypeAttributes`| Remove `type="text/css"` from `style` and `link` tags. Other `type` attribute values are left intact | `false` | 76 | | `removeTagWhitespace` | Remove space between attributes whenever possible. **Note that this will result in invalid HTML!** | `false` | 77 | | `sortAttributes` | [Sort attributes by frequency](#sorting-attributes--style-classes) | `false` | 78 | | `sortClassName` | [Sort style classes by frequency](#sorting-attributes--style-classes) | `false` | 79 | | `trimCustomFragments` | Trim white space around `ignoreCustomFragments`. | `false` | 80 | | `useShortDoctype` | [Replaces the `doctype` with the short (HTML5) doctype](http://perfectionkills.com/experimenting-with-html-minifier/#use_short_doctype) | `false` | 81 | 82 | ### Sorting attributes / style classes 83 | 84 | Minifier options like `sortAttributes` and `sortClassName` won't impact the plain-text size of the output. However, they form long repetitive chains of characters that should improve compression ratio of gzip used in HTTP compression. 85 | 86 | ## Special cases 87 | 88 | ### Ignoring chunks of markup 89 | 90 | If you have chunks of markup you would like preserved, you can wrap them ``. 91 | 92 | ### Preserving SVG tags 93 | 94 | SVG tags are automatically recognized, and when they are minified, both case-sensitivity and closing-slashes are preserved, regardless of the minification settings used for the rest of the file. 95 | 96 | ### Working with invalid markup 97 | 98 | HTMLMinifier **can't work with invalid or partial chunks of markup**. This is because it parses markup into a tree structure, then modifies it (removing anything that was specified for removal, ignoring anything that was specified to be ignored, etc.), then it creates a markup out of that tree and returns it. 99 | 100 | Input markup (e.g. `

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 '); 316 | var result = response.slice(start + 1, end).replace(/<\\\//g, '= 300 && status < 400 && res.headers.location) { 458 | get(url.resolve(site, res.headers.location), callback); 459 | } 460 | else { 461 | throw new Error('HTTP error ' + status + '\n' + site); 462 | } 463 | }); 464 | } 465 | 466 | return function(done) { 467 | progress.tick(0, { fileName: fileName }); 468 | get(urls[fileName], function(site) { 469 | processFile(site, done); 470 | }); 471 | }; 472 | }), function() { 473 | displayTable(); 474 | var content = generateMarkdownTable(); 475 | var readme = './README.md'; 476 | readText(readme, function(data) { 477 | var start = data.indexOf('## Minification comparison'); 478 | start = data.indexOf('|', start); 479 | var end = data.indexOf('##', start); 480 | end = data.lastIndexOf('|\n', end) + '|\n'.length; 481 | data = data.slice(0, start) + content + data.slice(end); 482 | writeText(readme, data); 483 | }); 484 | }); 485 | -------------------------------------------------------------------------------- /benchmarks/generated/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kangax/html-minifier/2f2db2eee9b972d4a4e275ae723a1f99a9e9da58/benchmarks/generated/.gitkeep -------------------------------------------------------------------------------- /benchmarks/index.json: -------------------------------------------------------------------------------- 1 | { 2 | "Amazon": "https://www.amazon.co.uk/", 3 | "BBC": "https://www.bbc.co.uk/", 4 | "Bootstrap CSS": "https://getbootstrap.com/docs/3.3/css/", 5 | "Eloquent Javascript": "https://eloquentjavascript.net/1st_edition/print.html", 6 | "ES draft": "https://tc39.github.io/ecma262/", 7 | "ES6 table": "https://kangax.github.io/compat-table/es6/", 8 | "Google": "https://www.google.com/", 9 | "HTMLMinifier": "https://github.com/kangax/html-minifier", 10 | "NBC": "https://www.nbc.com/", 11 | "New York Times": "https://www.nytimes.com/", 12 | "Stack Overflow": "https://stackoverflow.com/", 13 | "Twitter": "https://twitter.com/", 14 | "Wikipedia": "https://en.wikipedia.org/wiki/President_of_the_United_States" 15 | } 16 | -------------------------------------------------------------------------------- /cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /** 3 | * html-minifier CLI tool 4 | * 5 | * The MIT License (MIT) 6 | * 7 | * Copyright (c) 2014-2016 Zoltan Frombach 8 | * 9 | * Permission is hereby granted, free of charge, to any person obtaining a copy of 10 | * this software and associated documentation files (the "Software"), to deal in 11 | * the Software without restriction, including without limitation the rights to 12 | * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 13 | * the Software, and to permit persons to whom the Software is furnished to do so, 14 | * subject to the following conditions: 15 | * 16 | * The above copyright notice and this permission notice shall be included in all 17 | * copies or substantial portions of the Software. 18 | * 19 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 21 | * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 22 | * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 23 | * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 24 | * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 25 | * 26 | */ 27 | 28 | 'use strict'; 29 | 30 | var camelCase = require('camel-case'); 31 | var fs = require('fs'); 32 | var info = require('./package.json'); 33 | var minify = require('./' + info.main).minify; 34 | var paramCase = require('param-case'); 35 | var path = require('path'); 36 | var program = require('commander'); 37 | 38 | program._name = info.name; 39 | program.version(info.version); 40 | 41 | function fatal(message) { 42 | console.error(message); 43 | process.exit(1); 44 | } 45 | 46 | /** 47 | * JSON does not support regexes, so, e.g., JSON.parse() will not create 48 | * a RegExp from the JSON value `[ "/matchString/" ]`, which is 49 | * technically just an array containing a string that begins and end with 50 | * a forward slash. To get a RegExp from a JSON string, it must be 51 | * constructed explicitly in JavaScript. 52 | * 53 | * The likelihood of actually wanting to match text that is enclosed in 54 | * forward slashes is probably quite rare, so if forward slashes were 55 | * included in an argument that requires a regex, the user most likely 56 | * thought they were part of the syntax for specifying a regex. 57 | * 58 | * In the unlikely case that forward slashes are indeed desired in the 59 | * search string, the user would need to enclose the expression in a 60 | * second set of slashes: 61 | * 62 | * --customAttrSrround "[\"//matchString//\"]" 63 | */ 64 | function parseRegExp(value) { 65 | if (value) { 66 | return new RegExp(value.replace(/^\/(.*)\/$/, '$1')); 67 | } 68 | } 69 | 70 | function parseJSON(value) { 71 | if (value) { 72 | try { 73 | return JSON.parse(value); 74 | } 75 | catch (e) { 76 | if (/^{/.test(value)) { 77 | fatal('Could not parse JSON value \'' + value + '\''); 78 | } 79 | return value; 80 | } 81 | } 82 | } 83 | 84 | function parseJSONArray(value) { 85 | if (value) { 86 | value = parseJSON(value); 87 | return Array.isArray(value) ? value : [value]; 88 | } 89 | } 90 | 91 | function parseJSONRegExpArray(value) { 92 | value = parseJSONArray(value); 93 | return value && value.map(parseRegExp); 94 | } 95 | 96 | function parseString(value) { 97 | return value; 98 | } 99 | 100 | var mainOptions = { 101 | caseSensitive: 'Treat attributes in case sensitive manner (useful for SVG; e.g. viewBox)', 102 | collapseBooleanAttributes: 'Omit attribute values from boolean attributes', 103 | collapseInlineTagWhitespace: 'Collapse white space around inline tag', 104 | collapseWhitespace: 'Collapse white space that contributes to text nodes in a document tree.', 105 | conservativeCollapse: 'Always collapse to 1 space (never remove it entirely)', 106 | continueOnParseError: 'Handle parse errors instead of aborting', 107 | customAttrAssign: ['Arrays of regex\'es that allow to support custom attribute assign expressions (e.g. \'
\')', parseJSONRegExpArray], 108 | customAttrCollapse: ['Regex that specifies custom attribute to strip newlines from (e.g. /ng-class/)', parseRegExp], 109 | customAttrSurround: ['Arrays of regex\'es that allow to support custom attribute surround expressions (e.g. )', parseJSONRegExpArray], 110 | customEventAttributes: ['Arrays of regex\'es that allow to support custom event attributes for minifyJS (e.g. ng-click)', parseJSONRegExpArray], 111 | decodeEntities: 'Use direct Unicode characters whenever possible', 112 | html5: 'Parse input according to HTML5 specifications', 113 | ignoreCustomComments: ['Array of regex\'es that allow to ignore certain comments, when matched', parseJSONRegExpArray], 114 | ignoreCustomFragments: ['Array of regex\'es that allow to ignore certain fragments, when matched (e.g. , {{ ... }})', parseJSONRegExpArray], 115 | includeAutoGeneratedTags: 'Insert tags generated by HTML parser', 116 | keepClosingSlash: 'Keep the trailing slash on singleton elements', 117 | maxLineLength: ['Max line length', parseInt], 118 | minifyCSS: ['Minify CSS in style elements and style attributes (uses clean-css)', parseJSON], 119 | minifyJS: ['Minify Javascript in script elements and on* attributes (uses uglify-js)', parseJSON], 120 | minifyURLs: ['Minify URLs in various attributes (uses relateurl)', parseJSON], 121 | preserveLineBreaks: 'Always collapse to 1 line break (never remove it entirely) when whitespace between tags include a line break.', 122 | preventAttributesEscaping: 'Prevents the escaping of the values of attributes.', 123 | processConditionalComments: 'Process contents of conditional comments through minifier', 124 | processScripts: ['Array of strings corresponding to types of script elements to process through minifier (e.g. "text/ng-template", "text/x-handlebars-template", etc.)', parseJSONArray], 125 | quoteCharacter: ['Type of quote to use for attribute values (\' or ")', parseString], 126 | removeAttributeQuotes: 'Remove quotes around attributes when possible.', 127 | removeComments: 'Strip HTML comments', 128 | removeEmptyAttributes: 'Remove all attributes with whitespace-only values', 129 | removeEmptyElements: 'Remove all elements with empty contents', 130 | removeOptionalTags: 'Remove unrequired tags', 131 | removeRedundantAttributes: 'Remove attributes when value matches default.', 132 | removeScriptTypeAttributes: 'Remove type="text/javascript" from script tags. Other type attribute values are left intact.', 133 | removeStyleLinkTypeAttributes: 'Remove type="text/css" from style and link tags. Other type attribute values are left intact.', 134 | removeTagWhitespace: 'Remove space between attributes whenever possible', 135 | sortAttributes: 'Sort attributes by frequency', 136 | sortClassName: 'Sort style classes by frequency', 137 | trimCustomFragments: 'Trim white space around ignoreCustomFragments.', 138 | useShortDoctype: 'Replaces the doctype with the short (HTML5) doctype' 139 | }; 140 | var mainOptionKeys = Object.keys(mainOptions); 141 | mainOptionKeys.forEach(function(key) { 142 | var option = mainOptions[key]; 143 | if (Array.isArray(option)) { 144 | key = key === 'minifyURLs' ? '--minify-urls' : '--' + paramCase(key); 145 | key += option[1] === parseJSON ? ' [value]' : ' '; 146 | program.option(key, option[0], option[1]); 147 | } 148 | else if (~['html5', 'includeAutoGeneratedTags'].indexOf(key)) { 149 | program.option('--no-' + paramCase(key), option); 150 | } 151 | else { 152 | program.option('--' + paramCase(key), option); 153 | } 154 | }); 155 | program.option('-o --output ', 'Specify output file (if not specified STDOUT will be used for output)'); 156 | 157 | function readFile(file) { 158 | try { 159 | return fs.readFileSync(file, { encoding: 'utf8' }); 160 | } 161 | catch (e) { 162 | fatal('Cannot read ' + file + '\n' + e.message); 163 | } 164 | } 165 | 166 | var config = {}; 167 | program.option('-c --config-file ', 'Use config file', function(configPath) { 168 | var data = readFile(configPath); 169 | try { 170 | config = JSON.parse(data); 171 | } 172 | catch (je) { 173 | try { 174 | config = require(path.resolve(configPath)); 175 | } 176 | catch (ne) { 177 | fatal('Cannot read the specified config file.\nAs JSON: ' + je.message + '\nAs module: ' + ne.message); 178 | } 179 | } 180 | mainOptionKeys.forEach(function(key) { 181 | if (key in config) { 182 | var option = mainOptions[key]; 183 | if (Array.isArray(option)) { 184 | var value = config[key]; 185 | config[key] = option[1](typeof value === 'string' ? value : JSON.stringify(value)); 186 | } 187 | } 188 | }); 189 | }); 190 | program.option('--input-dir ', 'Specify an input directory'); 191 | program.option('--output-dir ', 'Specify an output directory'); 192 | program.option('--file-ext ', 'Specify an extension to be read, ex: html'); 193 | var content; 194 | program.arguments('[files...]').action(function(files) { 195 | content = files.map(readFile).join(''); 196 | }).parse(process.argv); 197 | 198 | function createOptions() { 199 | var options = {}; 200 | mainOptionKeys.forEach(function(key) { 201 | var param = program[key === 'minifyURLs' ? 'minifyUrls' : camelCase(key)]; 202 | if (typeof param !== 'undefined') { 203 | options[key] = param; 204 | } 205 | else if (key in config) { 206 | options[key] = config[key]; 207 | } 208 | }); 209 | return options; 210 | } 211 | 212 | function mkdir(outputDir, callback) { 213 | fs.mkdir(outputDir, function(err) { 214 | if (err) { 215 | switch (err.code) { 216 | case 'ENOENT': 217 | return mkdir(path.join(outputDir, '..'), function() { 218 | mkdir(outputDir, callback); 219 | }); 220 | case 'EEXIST': 221 | break; 222 | default: 223 | fatal('Cannot create directory ' + outputDir + '\n' + err.message); 224 | } 225 | } 226 | callback(); 227 | }); 228 | } 229 | 230 | function processFile(inputFile, outputFile) { 231 | fs.readFile(inputFile, { encoding: 'utf8' }, function(err, data) { 232 | if (err) { 233 | fatal('Cannot read ' + inputFile + '\n' + err.message); 234 | } 235 | var minified; 236 | try { 237 | minified = minify(data, createOptions()); 238 | } 239 | catch (e) { 240 | fatal('Minification error on ' + inputFile + '\n' + e.message); 241 | } 242 | fs.writeFile(outputFile, minified, { encoding: 'utf8' }, function(err) { 243 | if (err) { 244 | fatal('Cannot write ' + outputFile + '\n' + err.message); 245 | } 246 | }); 247 | }); 248 | } 249 | 250 | function processDirectory(inputDir, outputDir, fileExt) { 251 | fs.readdir(inputDir, function(err, files) { 252 | if (err) { 253 | fatal('Cannot read directory ' + inputDir + '\n' + err.message); 254 | } 255 | files.forEach(function(file) { 256 | var inputFile = path.join(inputDir, file); 257 | var outputFile = path.join(outputDir, file); 258 | fs.stat(inputFile, function(err, stat) { 259 | if (err) { 260 | fatal('Cannot read ' + inputFile + '\n' + err.message); 261 | } 262 | else if (stat.isDirectory()) { 263 | processDirectory(inputFile, outputFile, fileExt); 264 | } 265 | else if (!fileExt || path.extname(file) === '.' + fileExt) { 266 | mkdir(outputDir, function() { 267 | processFile(inputFile, outputFile); 268 | }); 269 | } 270 | }); 271 | }); 272 | }); 273 | } 274 | 275 | function writeMinify() { 276 | var minified; 277 | try { 278 | minified = minify(content, createOptions()); 279 | } 280 | catch (e) { 281 | fatal('Minification error:\n' + e.message); 282 | } 283 | (program.output ? fs.createWriteStream(program.output).on('error', function(e) { 284 | fatal('Cannot write ' + program.output + '\n' + e.message); 285 | }) : process.stdout).write(minified); 286 | } 287 | 288 | var inputDir = program.inputDir; 289 | var outputDir = program.outputDir; 290 | var fileExt = program.fileExt; 291 | if (inputDir || outputDir) { 292 | if (!inputDir) { 293 | fatal('The option output-dir needs to be used with the option input-dir. If you are working with a single file, use -o.'); 294 | } 295 | else if (!outputDir) { 296 | fatal('You need to specify where to write the output files with the option --output-dir'); 297 | } 298 | processDirectory(inputDir, outputDir, fileExt); 299 | } 300 | // Minifying one or more files specified on the CMD line 301 | else if (content) { 302 | writeMinify(); 303 | } 304 | // Minifying input coming from STDIN 305 | else { 306 | content = ''; 307 | process.stdin.setEncoding('utf8'); 308 | process.stdin.on('data', function(data) { 309 | content += data; 310 | }).on('end', writeMinify); 311 | } 312 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | HTML minifier 8 | 9 | 10 | 11 |
12 |
13 |

HTML Minifier (v4.0.0)

14 | 15 |
16 | 17 |
18 | 19 | 20 |

21 |
22 |
23 |
    24 |
  • 25 | 26 | 29 | 30 | Treat attributes in case sensitive manner (useful for custom HTML tags) 31 | 32 |
  • 33 |
  • 34 | 35 | 38 | 39 | Omit attribute values from boolean attributes 40 | 41 |
  • 42 |
  • 43 | 44 | 47 | 48 | Don't leave any spaces between display:inline; elements when collapsing. 49 | Must be used in conjunction with collapseWhitespace=true 50 | 51 |
  • 52 |
  • 53 | 54 | 57 | 58 | Collapse white space that contributes to text nodes in a document tree 59 | 60 |
  • 61 |
  • 62 | 63 | 66 | 67 | Always collapse to 1 space (never remove it entirely). 68 | Must be used in conjunction with collapseWhitespace=true 69 | 70 |
  • 71 |
  • 72 | 73 | 76 | 77 | Use direct Unicode characters whenever possible 78 | 79 |
  • 80 |
  • 81 | 82 | 85 | 86 | Parse input according to HTML5 specifications 87 | 88 |
  • 89 |
  • 90 | 91 | 94 | 95 | Insert tags generated by HTML parser 96 | 97 |
  • 98 |
  • 99 | 100 | 103 | 104 | Keep the trailing slash on singleton elements 105 | 106 |
  • 107 |
  • 108 | 111 | 112 | 113 | Specify a maximum line length. Compressed output will be split by newlines at valid HTML split-points 114 | 115 |
  • 116 |
  • 117 | 118 | 121 | 122 | Minify CSS in style elements and style attributes (uses clean-css) 123 | 124 |
  • 125 |
  • 126 | 127 | 130 | 131 | Minify JavaScript in script elements and event attributes (uses UglifyJS) 132 | 133 |
  • 134 |
  • 135 | 138 | 139 | 140 | Minify URLs in various attributes (uses relateurl) 141 | 142 |
  • 143 |
  • 144 | 145 | 148 | 149 | Always collapse to 1 line break (never remove it entirely) when whitespace between tags include a line break. 150 | Must be used in conjunction with collapseWhitespace=true 151 | 152 |
  • 153 |
  • 154 | 155 | 158 | 159 | Prevents the escaping of the values of attributes 160 | 161 |
  • 162 |
  • 163 | 164 | 167 | 168 | Process contents of conditional comments through minifier 169 | 170 |
  • 171 |
  • 172 | 175 | 176 | 177 | Comma-delimited string corresponding to types of script elements to process through minifier (e.g. text/ng-template, text/x-handlebars-template) 178 | 179 |
  • 180 |
  • 181 | 184 | 185 | 186 | Type of quote to use for attribute values (' or ") 187 | 188 |
  • 189 |
  • 190 | 191 | 194 | 195 | Remove quotes around attributes when possible 196 | 197 |
  • 198 |
  • 199 | 200 | 203 | 204 | Strip HTML comments 205 | 206 |
  • 207 |
  • 208 | 209 | 212 | 213 | Remove all attributes with whitespace-only values 214 | 215 |
  • 216 |
  • 217 | 218 | 221 | 222 | Remove all elements with empty contents 223 | 224 |
  • 225 |
  • 226 | 227 | 230 |
  • 231 |
  • 232 | 233 | 236 | 237 | Remove attributes when value matches default. 238 | 239 |
  • 240 |
  • 241 | 242 | 245 | 246 | Remove type="text/javascript" from script tags. 247 | Other type attribute values are left intact 248 | 249 |
  • 250 |
  • 251 | 252 | 255 | 256 | Remove type="text/css" from style and link tags. 257 | Other type attribute values are left intact 258 | 259 |
  • 260 |
  • 261 | 262 | 265 | 266 | Remove space between attributes whenever possible. 267 | Note that this will result in invalid HTML! 268 | 269 |
  • 270 |
  • 271 | 272 | 275 | 276 | Sort attributes by frequency 277 | 278 |
  • 279 |
  • 280 | 281 | 284 | 285 | Sort style classes by frequency 286 | 287 |
  • 288 |
  • 289 | 290 | 293 | 294 | Trim white space around ignoreCustomFragments. 295 | 296 |
  • 297 |
  • 298 | 299 | 302 | 303 | Replaces the doctype with the short (HTML5) doctype 304 | 305 |
  • 306 |
307 |
308 | Select: 309 | All, 310 | None, 311 | Reset 312 |
313 |
314 |
315 | 316 | 324 | 325 | 326 | 327 | 328 | 329 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "html-minifier", 3 | "description": "Highly configurable, well-tested, JavaScript-based HTML minifier.", 4 | "version": "4.0.0", 5 | "keywords": [ 6 | "cli", 7 | "compress", 8 | "compressor", 9 | "css", 10 | "html", 11 | "htmlmin", 12 | "javascript", 13 | "min", 14 | "minification", 15 | "minifier", 16 | "minify", 17 | "optimize", 18 | "optimizer", 19 | "pack", 20 | "packer", 21 | "parse", 22 | "parser", 23 | "uglifier", 24 | "uglify" 25 | ], 26 | "homepage": "https://kangax.github.io/html-minifier/", 27 | "author": "Juriy \"kangax\" Zaytsev", 28 | "maintainers": [ 29 | "Alex Lam ", 30 | "Juriy Zaytsev (http://perfectionkills.com/)" 31 | ], 32 | "contributors": [ 33 | "Gilmore Davidson (https://github.com/gilmoreorless)", 34 | "Hugo Wetterberg ", 35 | "Zoltan Frombach " 36 | ], 37 | "license": "MIT", 38 | "bin": { 39 | "html-minifier": "./cli.js" 40 | }, 41 | "main": "src/htmlminifier.js", 42 | "repository": { 43 | "type": "git", 44 | "url": "git+https://github.com/kangax/html-minifier.git" 45 | }, 46 | "bugs": { 47 | "url": "https://github.com/kangax/html-minifier/issues" 48 | }, 49 | "engines": { 50 | "node": ">=10" 51 | }, 52 | "scripts": { 53 | "dist": "grunt dist", 54 | "test": "grunt test" 55 | }, 56 | "dependencies": { 57 | "camel-case": "^3.0.0", 58 | "clean-css": "^5.2.1", 59 | "commander": "^2.20.3", 60 | "he": "^1.2.0", 61 | "param-case": "^2.1.1", 62 | "relateurl": "^0.2.7", 63 | "uglify-js": "^3.14.2" 64 | }, 65 | "devDependencies": { 66 | "grunt": "^1.4.1", 67 | "grunt-browserify": "^6.0.0", 68 | "grunt-contrib-uglify": "^5.0.1", 69 | "grunt-eslint": "^23.0.0", 70 | "phantomjs-prebuilt": "^2.1.16", 71 | "qunit": "^2.17.2" 72 | }, 73 | "benchmarkDependencies": { 74 | "brotli": "^1.3.2", 75 | "chalk": "^4.1.2", 76 | "cli-table": "^0.3.6", 77 | "lzma": "^2.3.2", 78 | "minimize": "^2.2.0", 79 | "progress": "^2.0.3" 80 | }, 81 | "files": [ 82 | "src/*.js", 83 | "cli.js", 84 | "sample-cli-config-file.conf" 85 | ] 86 | } 87 | -------------------------------------------------------------------------------- /sample-cli-config-file.conf: -------------------------------------------------------------------------------- 1 | { 2 | "caseSensitive": false, 3 | "collapseBooleanAttributes": true, 4 | "collapseInlineTagWhitespace": false, 5 | "collapseWhitespace": true, 6 | "conservativeCollapse": false, 7 | "continueOnParseError": true, 8 | "customAttrCollapse": ".*", 9 | "decodeEntities": true, 10 | "html5": true, 11 | "ignoreCustomFragments": [ 12 | "<#[\\s\\S]*?#>", 13 | "<%[\\s\\S]*?%>", 14 | "<\\?[\\s\\S]*?\\?>" 15 | ], 16 | "includeAutoGeneratedTags": false, 17 | "keepClosingSlash": false, 18 | "maxLineLength": 0, 19 | "minifyCSS": true, 20 | "minifyJS": true, 21 | "preserveLineBreaks": false, 22 | "preventAttributesEscaping": false, 23 | "processConditionalComments": true, 24 | "processScripts": [ 25 | "text/html" 26 | ], 27 | "removeAttributeQuotes": true, 28 | "removeComments": true, 29 | "removeEmptyAttributes": true, 30 | "removeEmptyElements": true, 31 | "removeOptionalTags": true, 32 | "removeRedundantAttributes": true, 33 | "removeScriptTypeAttributes": true, 34 | "removeStyleLinkTypeAttributes": true, 35 | "removeTagWhitespace": true, 36 | "sortAttributes": true, 37 | "sortClassName": true, 38 | "trimCustomFragments": true, 39 | "useShortDoctype": true 40 | } 41 | -------------------------------------------------------------------------------- /src/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/htmlminifier.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var CleanCSS = require('clean-css'); 4 | var decode = require('he').decode; 5 | var HTMLParser = require('./htmlparser').HTMLParser; 6 | var RelateUrl = require('relateurl'); 7 | var TokenChain = require('./tokenchain'); 8 | var UglifyJS = require('uglify-js'); 9 | var utils = require('./utils'); 10 | 11 | function trimWhitespace(str) { 12 | return str && str.replace(/^[ \n\r\t\f]+/, '').replace(/[ \n\r\t\f]+$/, ''); 13 | } 14 | 15 | function collapseWhitespaceAll(str) { 16 | // Non-breaking space is specifically handled inside the replacer function here: 17 | return str && str.replace(/[ \n\r\t\f\xA0]+/g, function(spaces) { 18 | return spaces === '\t' ? '\t' : spaces.replace(/(^|\xA0+)[^\xA0]+/g, '$1 '); 19 | }); 20 | } 21 | 22 | function collapseWhitespace(str, options, trimLeft, trimRight, collapseAll) { 23 | var lineBreakBefore = '', lineBreakAfter = ''; 24 | 25 | if (options.preserveLineBreaks) { 26 | str = str.replace(/^[ \n\r\t\f]*?[\n\r][ \n\r\t\f]*/, function() { 27 | lineBreakBefore = '\n'; 28 | return ''; 29 | }).replace(/[ \n\r\t\f]*?[\n\r][ \n\r\t\f]*$/, function() { 30 | lineBreakAfter = '\n'; 31 | return ''; 32 | }); 33 | } 34 | 35 | if (trimLeft) { 36 | // Non-breaking space is specifically handled inside the replacer function here: 37 | str = str.replace(/^[ \n\r\t\f\xA0]+/, function(spaces) { 38 | var conservative = !lineBreakBefore && options.conservativeCollapse; 39 | if (conservative && spaces === '\t') { 40 | return '\t'; 41 | } 42 | return spaces.replace(/^[^\xA0]+/, '').replace(/(\xA0+)[^\xA0]+/g, '$1 ') || (conservative ? ' ' : ''); 43 | }); 44 | } 45 | 46 | if (trimRight) { 47 | // Non-breaking space is specifically handled inside the replacer function here: 48 | str = str.replace(/[ \n\r\t\f\xA0]+$/, function(spaces) { 49 | var conservative = !lineBreakAfter && options.conservativeCollapse; 50 | if (conservative && spaces === '\t') { 51 | return '\t'; 52 | } 53 | return spaces.replace(/[^\xA0]+(\xA0+)/g, ' $1').replace(/[^\xA0]+$/, '') || (conservative ? ' ' : ''); 54 | }); 55 | } 56 | 57 | if (collapseAll) { 58 | // strip non space whitespace then compress spaces to one 59 | str = collapseWhitespaceAll(str); 60 | } 61 | 62 | return lineBreakBefore + str + lineBreakAfter; 63 | } 64 | 65 | var createMapFromString = utils.createMapFromString; 66 | // non-empty tags that will maintain whitespace around them 67 | var inlineTags = createMapFromString('a,abbr,acronym,b,bdi,bdo,big,button,cite,code,del,dfn,em,font,i,ins,kbd,label,mark,math,nobr,object,q,rp,rt,rtc,ruby,s,samp,select,small,span,strike,strong,sub,sup,svg,textarea,time,tt,u,var'); 68 | // non-empty tags that will maintain whitespace within them 69 | var inlineTextTags = createMapFromString('a,abbr,acronym,b,big,del,em,font,i,ins,kbd,mark,nobr,rp,s,samp,small,span,strike,strong,sub,sup,time,tt,u,var'); 70 | // self-closing tags that will maintain whitespace around them 71 | var selfClosingInlineTags = createMapFromString('comment,img,input,wbr'); 72 | 73 | function collapseWhitespaceSmart(str, prevTag, nextTag, options) { 74 | var trimLeft = prevTag && !selfClosingInlineTags(prevTag); 75 | if (trimLeft && !options.collapseInlineTagWhitespace) { 76 | trimLeft = prevTag.charAt(0) === '/' ? !inlineTags(prevTag.slice(1)) : !inlineTextTags(prevTag); 77 | } 78 | var trimRight = nextTag && !selfClosingInlineTags(nextTag); 79 | if (trimRight && !options.collapseInlineTagWhitespace) { 80 | trimRight = nextTag.charAt(0) === '/' ? !inlineTextTags(nextTag.slice(1)) : !inlineTags(nextTag); 81 | } 82 | return collapseWhitespace(str, options, trimLeft, trimRight, prevTag && nextTag); 83 | } 84 | 85 | function isConditionalComment(text) { 86 | return /^\[if\s[^\]]+]|\[endif]$/.test(text); 87 | } 88 | 89 | function isIgnoredComment(text, options) { 90 | for (var i = 0, len = options.ignoreCustomComments.length; i < len; i++) { 91 | if (options.ignoreCustomComments[i].test(text)) { 92 | return true; 93 | } 94 | } 95 | return false; 96 | } 97 | 98 | function isEventAttribute(attrName, options) { 99 | var patterns = options.customEventAttributes; 100 | if (patterns) { 101 | for (var i = patterns.length; i--;) { 102 | if (patterns[i].test(attrName)) { 103 | return true; 104 | } 105 | } 106 | return false; 107 | } 108 | return /^on[a-z]{3,}$/.test(attrName); 109 | } 110 | 111 | function canRemoveAttributeQuotes(value) { 112 | // https://mathiasbynens.be/notes/unquoted-attribute-values 113 | return /^[^ \t\n\f\r"'`=<>]+$/.test(value); 114 | } 115 | 116 | function attributesInclude(attributes, attribute) { 117 | for (var i = attributes.length; i--;) { 118 | if (attributes[i].name.toLowerCase() === attribute) { 119 | return true; 120 | } 121 | } 122 | return false; 123 | } 124 | 125 | function isAttributeRedundant(tag, attrName, attrValue, attrs) { 126 | attrValue = attrValue ? trimWhitespace(attrValue.toLowerCase()) : ''; 127 | 128 | return ( 129 | tag === 'script' && 130 | attrName === 'language' && 131 | attrValue === 'javascript' || 132 | 133 | tag === 'form' && 134 | attrName === 'method' && 135 | attrValue === 'get' || 136 | 137 | tag === 'input' && 138 | attrName === 'type' && 139 | attrValue === 'text' || 140 | 141 | tag === 'script' && 142 | attrName === 'charset' && 143 | !attributesInclude(attrs, 'src') || 144 | 145 | tag === 'a' && 146 | attrName === 'name' && 147 | attributesInclude(attrs, 'id') || 148 | 149 | tag === 'area' && 150 | attrName === 'shape' && 151 | attrValue === 'rect' 152 | ); 153 | } 154 | 155 | // https://mathiasbynens.be/demo/javascript-mime-type 156 | // https://developer.mozilla.org/en/docs/Web/HTML/Element/script#attr-type 157 | var executableScriptsMimetypes = utils.createMap([ 158 | 'text/javascript', 159 | 'text/ecmascript', 160 | 'text/jscript', 161 | 'application/javascript', 162 | 'application/x-javascript', 163 | 'application/ecmascript' 164 | ]); 165 | 166 | function isScriptTypeAttribute(attrValue) { 167 | attrValue = trimWhitespace(attrValue.split(/;/, 2)[0]).toLowerCase(); 168 | return attrValue === '' || executableScriptsMimetypes(attrValue); 169 | } 170 | 171 | function isExecutableScript(tag, attrs) { 172 | if (tag !== 'script') { 173 | return false; 174 | } 175 | for (var i = 0, len = attrs.length; i < len; i++) { 176 | var attrName = attrs[i].name.toLowerCase(); 177 | if (attrName === 'type') { 178 | return isScriptTypeAttribute(attrs[i].value); 179 | } 180 | } 181 | return true; 182 | } 183 | 184 | function isStyleLinkTypeAttribute(attrValue) { 185 | attrValue = trimWhitespace(attrValue).toLowerCase(); 186 | return attrValue === '' || attrValue === 'text/css'; 187 | } 188 | 189 | function isStyleSheet(tag, attrs) { 190 | if (tag !== 'style') { 191 | return false; 192 | } 193 | for (var i = 0, len = attrs.length; i < len; i++) { 194 | var attrName = attrs[i].name.toLowerCase(); 195 | if (attrName === 'type') { 196 | return isStyleLinkTypeAttribute(attrs[i].value); 197 | } 198 | } 199 | return true; 200 | } 201 | 202 | var isSimpleBoolean = createMapFromString('allowfullscreen,async,autofocus,autoplay,checked,compact,controls,declare,default,defaultchecked,defaultmuted,defaultselected,defer,disabled,enabled,formnovalidate,hidden,indeterminate,inert,ismap,itemscope,loop,multiple,muted,nohref,noresize,noshade,novalidate,nowrap,open,pauseonexit,readonly,required,reversed,scoped,seamless,selected,sortable,truespeed,typemustmatch,visible'); 203 | var isBooleanValue = createMapFromString('true,false'); 204 | 205 | function isBooleanAttribute(attrName, attrValue) { 206 | return isSimpleBoolean(attrName) || attrName === 'draggable' && !isBooleanValue(attrValue); 207 | } 208 | 209 | function isUriTypeAttribute(attrName, tag) { 210 | return ( 211 | /^(?:a|area|link|base)$/.test(tag) && attrName === 'href' || 212 | tag === 'img' && /^(?:src|longdesc|usemap)$/.test(attrName) || 213 | tag === 'object' && /^(?:classid|codebase|data|usemap)$/.test(attrName) || 214 | tag === 'q' && attrName === 'cite' || 215 | tag === 'blockquote' && attrName === 'cite' || 216 | (tag === 'ins' || tag === 'del') && attrName === 'cite' || 217 | tag === 'form' && attrName === 'action' || 218 | tag === 'input' && (attrName === 'src' || attrName === 'usemap') || 219 | tag === 'head' && attrName === 'profile' || 220 | tag === 'script' && (attrName === 'src' || attrName === 'for') 221 | ); 222 | } 223 | 224 | function isNumberTypeAttribute(attrName, tag) { 225 | return ( 226 | /^(?:a|area|object|button)$/.test(tag) && attrName === 'tabindex' || 227 | tag === 'input' && (attrName === 'maxlength' || attrName === 'tabindex') || 228 | tag === 'select' && (attrName === 'size' || attrName === 'tabindex') || 229 | tag === 'textarea' && /^(?:rows|cols|tabindex)$/.test(attrName) || 230 | tag === 'colgroup' && attrName === 'span' || 231 | tag === 'col' && attrName === 'span' || 232 | (tag === 'th' || tag === 'td') && (attrName === 'rowspan' || attrName === 'colspan') 233 | ); 234 | } 235 | 236 | function isLinkType(tag, attrs, value) { 237 | if (tag !== 'link') { 238 | return false; 239 | } 240 | for (var i = 0, len = attrs.length; i < len; i++) { 241 | if (attrs[i].name === 'rel' && attrs[i].value === value) { 242 | return true; 243 | } 244 | } 245 | } 246 | 247 | function isMediaQuery(tag, attrs, attrName) { 248 | return attrName === 'media' && (isLinkType(tag, attrs, 'stylesheet') || isStyleSheet(tag, attrs)); 249 | } 250 | 251 | var srcsetTags = createMapFromString('img,source'); 252 | 253 | function isSrcset(attrName, tag) { 254 | return attrName === 'srcset' && srcsetTags(tag); 255 | } 256 | 257 | function cleanAttributeValue(tag, attrName, attrValue, options, attrs) { 258 | if (isEventAttribute(attrName, options)) { 259 | attrValue = trimWhitespace(attrValue).replace(/^javascript:\s*/i, ''); 260 | return options.minifyJS(attrValue, true); 261 | } 262 | else if (attrName === 'class') { 263 | attrValue = trimWhitespace(attrValue); 264 | if (options.sortClassName) { 265 | attrValue = options.sortClassName(attrValue); 266 | } 267 | else { 268 | attrValue = collapseWhitespaceAll(attrValue); 269 | } 270 | return attrValue; 271 | } 272 | else if (isUriTypeAttribute(attrName, tag)) { 273 | attrValue = trimWhitespace(attrValue); 274 | return isLinkType(tag, attrs, 'canonical') ? attrValue : options.minifyURLs(attrValue); 275 | } 276 | else if (isNumberTypeAttribute(attrName, tag)) { 277 | return trimWhitespace(attrValue); 278 | } 279 | else if (attrName === 'style') { 280 | attrValue = trimWhitespace(attrValue); 281 | if (attrValue) { 282 | if (/;$/.test(attrValue) && !/&#?[0-9a-zA-Z]+;$/.test(attrValue)) { 283 | attrValue = attrValue.replace(/\s*;$/, ';'); 284 | } 285 | attrValue = options.minifyCSS(attrValue, 'inline'); 286 | } 287 | return attrValue; 288 | } 289 | else if (isSrcset(attrName, tag)) { 290 | // https://html.spec.whatwg.org/multipage/embedded-content.html#attr-img-srcset 291 | attrValue = trimWhitespace(attrValue).split(/\s+,\s*|\s*,\s+/).map(function(candidate) { 292 | var url = candidate; 293 | var descriptor = ''; 294 | var match = candidate.match(/\s+([1-9][0-9]*w|[0-9]+(?:\.[0-9]+)?x)$/); 295 | if (match) { 296 | url = url.slice(0, -match[0].length); 297 | var num = +match[1].slice(0, -1); 298 | var suffix = match[1].slice(-1); 299 | if (num !== 1 || suffix !== 'x') { 300 | descriptor = ' ' + num + suffix; 301 | } 302 | } 303 | return options.minifyURLs(url) + descriptor; 304 | }).join(', '); 305 | } 306 | else if (isMetaViewport(tag, attrs) && attrName === 'content') { 307 | attrValue = attrValue.replace(/\s+/g, '').replace(/[0-9]+\.[0-9]+/g, function(numString) { 308 | // "0.90000" -> "0.9" 309 | // "1.0" -> "1" 310 | // "1.0001" -> "1.0001" (unchanged) 311 | return (+numString).toString(); 312 | }); 313 | } 314 | else if (isContentSecurityPolicy(tag, attrs) && attrName.toLowerCase() === 'content') { 315 | return collapseWhitespaceAll(attrValue); 316 | } 317 | else if (options.customAttrCollapse && options.customAttrCollapse.test(attrName)) { 318 | attrValue = attrValue.replace(/\n+|\r+|\s{2,}/g, ''); 319 | } 320 | else if (tag === 'script' && attrName === 'type') { 321 | attrValue = trimWhitespace(attrValue.replace(/\s*;\s*/g, ';')); 322 | } 323 | else if (isMediaQuery(tag, attrs, attrName)) { 324 | attrValue = trimWhitespace(attrValue); 325 | return options.minifyCSS(attrValue, 'media'); 326 | } 327 | return attrValue; 328 | } 329 | 330 | function isMetaViewport(tag, attrs) { 331 | if (tag !== 'meta') { 332 | return false; 333 | } 334 | for (var i = 0, len = attrs.length; i < len; i++) { 335 | if (attrs[i].name === 'name' && attrs[i].value === 'viewport') { 336 | return true; 337 | } 338 | } 339 | } 340 | 341 | function isContentSecurityPolicy(tag, attrs) { 342 | if (tag !== 'meta') { 343 | return false; 344 | } 345 | for (var i = 0, len = attrs.length; i < len; i++) { 346 | if (attrs[i].name.toLowerCase() === 'http-equiv' && attrs[i].value.toLowerCase() === 'content-security-policy') { 347 | return true; 348 | } 349 | } 350 | } 351 | 352 | function ignoreCSS(id) { 353 | return '/* clean-css ignore:start */' + id + '/* clean-css ignore:end */'; 354 | } 355 | 356 | // Wrap CSS declarations for CleanCSS > 3.x 357 | // See https://github.com/jakubpawlowicz/clean-css/issues/418 358 | function wrapCSS(text, type) { 359 | switch (type) { 360 | case 'inline': 361 | return '*{' + text + '}'; 362 | case 'media': 363 | return '@media ' + text + '{a{top:0}}'; 364 | default: 365 | return text; 366 | } 367 | } 368 | 369 | function unwrapCSS(text, type) { 370 | var matches; 371 | switch (type) { 372 | case 'inline': 373 | matches = text.match(/^\*\{([\s\S]*)\}$/); 374 | break; 375 | case 'media': 376 | matches = text.match(/^@media ([\s\S]*?)\s*{[\s\S]*}$/); 377 | break; 378 | } 379 | return matches ? matches[1] : text; 380 | } 381 | 382 | function cleanConditionalComment(comment, options) { 383 | return options.processConditionalComments ? comment.replace(/^(\[if\s[^\]]+]>)([\s\S]*?)( -1) { 392 | return minify(text, options); 393 | } 394 | } 395 | return text; 396 | } 397 | 398 | // Tag omission rules from https://html.spec.whatwg.org/multipage/syntax.html#optional-tags 399 | // with the following deviations: 400 | // - retain if followed by