├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── .gitignore ├── .npmignore ├── CHANGELOG.md ├── LICENSE.txt ├── README.md ├── docs ├── README.md ├── babel.config.js ├── docs │ ├── 010-introduction.md │ ├── 020-usage.md │ ├── 030-config.md │ ├── 040-presets.md │ ├── 050-modules.md │ └── 060-contribute.md ├── docusaurus.config.js ├── netlify.toml ├── package-lock.json ├── package.json ├── sidebars.js ├── versioned_docs │ ├── version-1.1.1 │ │ ├── 010-introduction.md │ │ ├── 020-usage.md │ │ ├── 030-config.md │ │ ├── 040-presets.md │ │ ├── 050-modules.md │ │ └── 060-contribute.md │ └── version-2.0.0 │ │ ├── 010-introduction.md │ │ ├── 020-usage.md │ │ ├── 030-config.md │ │ ├── 040-presets.md │ │ ├── 050-modules.md │ │ └── 060-contribute.md ├── versioned_sidebars │ ├── version-1.1.1-sidebars.json │ └── version-2.0.0-sidebars.json └── versions.json ├── eslint.config.mjs ├── package.json ├── src ├── _modules │ ├── collapseAttributeWhitespace.ts │ ├── collapseBooleanAttributes.ts │ ├── collapseWhitespace.ts │ ├── custom.ts │ ├── deduplicateAttributeValues.ts │ ├── example.mjs │ ├── mergeScripts.mjs │ ├── mergeStyles.mjs │ ├── minifyConditionalComments.mjs │ ├── minifyCss.mjs │ ├── minifyJs.mjs │ ├── minifyJson.mjs │ ├── minifySvg.mjs │ ├── minifyUrls.mjs │ ├── normalizeAttributeValues.ts │ ├── removeAttributeQuotes.ts │ ├── removeComments.mjs │ ├── removeEmptyAttributes.ts │ ├── removeOptionalTags.mjs │ ├── removeRedundantAttributes.ts │ ├── removeUnusedCss.mjs │ ├── sortAttributes.mjs │ └── sortAttributesWithLists.mjs ├── helpers.ts ├── index.ts ├── presets │ ├── ampSafe.ts │ ├── max.ts │ └── safe.ts └── types.ts ├── test ├── helpers.mjs ├── htmlnano.mjs ├── htmlnano_compat.cjs ├── modules │ ├── collapseAttributeWhitespace.mjs │ ├── collapseBooleanAttributes.mjs │ ├── collapseWhitespace.mjs │ ├── custom.mjs │ ├── deduplicateAttributeValues.mjs │ ├── mergeScripts.mjs │ ├── mergeStyles.mjs │ ├── minifyConditionalComments.mjs │ ├── minifyCss.mjs │ ├── minifyJs.mjs │ ├── minifyJson.mjs │ ├── minifySvg.mjs │ ├── minifyUrls.mjs │ ├── normalizeAttributeValues.mjs │ ├── removeAttributeQuotes.mjs │ ├── removeComments.mjs │ ├── removeEmptyAttributes.mjs │ ├── removeOptionalTags.mjs │ ├── removeRedundantAttributes.mjs │ ├── removeUnusedCss.mjs │ ├── sortAttributes.mjs │ └── sortAttributesWithLists.mjs ├── testrc.json └── usage.mjs └── tsconfig.json /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | time: "01:00" 8 | - package-ecosystem: 'npm' 9 | directory: "/" 10 | schedule: 11 | interval: "daily" 12 | time: "02:00" 13 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | name: Run tests 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 12 | node-version: ['20', '22', '23', 'current'] 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - name: Use Node.js ${{ matrix.node-version }} 18 | uses: actions/setup-node@v4 19 | with: 20 | node-version: ${{ matrix.node-version }} 21 | 22 | - run: npm install 23 | - run: npm test 24 | 25 | test-docs: 26 | name: Run tests for docs 27 | runs-on: ubuntu-latest 28 | strategy: 29 | matrix: 30 | node-version: ['lts/*'] 31 | 32 | steps: 33 | - uses: actions/checkout@v4 34 | 35 | - name: Use Node.js ${{ matrix.node-version }} 36 | uses: actions/setup-node@v4 37 | with: 38 | node-version: ${{ matrix.node-version }} 39 | 40 | - name: Install docs dependencies 41 | run: npm install 42 | working-directory: docs 43 | 44 | - name: Test docs build 45 | run: npm run build 46 | working-directory: docs 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Source: https://github.com/github/gitignore/blob/master/Node.gitignore 2 | 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | 13 | # Directory for instrumented libs generated by jscoverage/JSCover 14 | lib-cov 15 | 16 | # Coverage directory used by tools like istanbul 17 | coverage 18 | 19 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 20 | .grunt 21 | 22 | # node-waf configuration 23 | .lock-wscript 24 | 25 | # Compiled binary addons (http://nodejs.org/api/addons.html) 26 | build/Release 27 | 28 | # Dependency directory 29 | # https://docs.npmjs.com/misc/faq#should-i-check-my-node-modules-folder-into-git 30 | node_modules 31 | 32 | # Optional npm cache directory 33 | .npm 34 | 35 | # Optional REPL history 36 | .node_repl_history 37 | 38 | /package-lock.json 39 | yarn.lock 40 | 41 | # Custom 42 | /lib/*.cjs 43 | /lib/*.js 44 | /lib/modules/*.cjs 45 | /lib/modules/*.js 46 | /lib/presets/*.cjs 47 | /lib/presets/*.js 48 | /test.cjs 49 | /test.js 50 | 51 | # Docs 52 | /docs/build/ 53 | /docs/.docusaurus/ 54 | 55 | # Dist 56 | dist 57 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .gitignore 2 | .eslintrc 3 | node_modules/ 4 | npm-debug.log 5 | /lib/modules/example.js 6 | /test/ 7 | /.github/ 8 | /docs/.docusaurus 9 | /docs/build 10 | /docs/node_modules 11 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Kirill Maltsev 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # htmlnano 2 | [![npm version](https://badge.fury.io/js/htmlnano.svg)](http://badge.fury.io/js/htmlnano) 3 | ![CI](https://github.com/posthtml/htmlnano/actions/workflows/ci.yml/badge.svg) 4 | 5 | Modular HTML minifier, built on top of the [PostHTML](https://github.com/posthtml/posthtml). Inspired by [cssnano](https://github.com/cssnano/cssnano). 6 | 7 | ## [Benchmark](https://github.com/maltsev/html-minifiers-benchmark/blob/master/README.md) 8 | [html-minifier-terser@6.0.2]: https://www.npmjs.com/package/html-minifier-terser 9 | [htmlnano@2.0.0]: https://www.npmjs.com/package/htmlnano 10 | 11 | | Website | Source (KB) | [html-minifier-terser@6.0.2] | [htmlnano@2.0.0] | 12 | |---------|------------:|----------------:|-----------:| 13 | | [stackoverflow.blog](https://stackoverflow.blog/) | 90 | 82 | 76 | 14 | | [github.com](https://github.com/) | 232 | 203 | 173 | 15 | | [en.wikipedia.org](https://en.wikipedia.org/wiki/Main_Page) | 81 | 76 | 75 | 16 | | [npmjs.com](https://www.npmjs.com/features) | 43 | 40 | 38 | 17 | | [tc39.es](https://tc39.es/ecma262/) | 6001 | 5465 | 5459 | 18 | | **Avg. minify rate** | 0% | **9%** | **13%** | 19 | 20 | 21 | ## Documentation 22 | https://htmlnano.netlify.app 23 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Website 2 | 3 | This website is built using [Docusaurus 2](https://docusaurus.io/), a modern static website generator. 4 | 5 | ## Installation 6 | 7 | ```console 8 | yarn install 9 | ``` 10 | 11 | ## Local Development 12 | 13 | ```console 14 | yarn start 15 | ``` 16 | 17 | This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server. 18 | 19 | ## Build 20 | 21 | ```console 22 | yarn build 23 | ``` 24 | 25 | This command generates static content into the `build` directory and can be served using any static contents hosting service. 26 | 27 | ## Deployment 28 | 29 | ```console 30 | GIT_USER= USE_SSH=true yarn deploy 31 | ``` 32 | 33 | If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch. 34 | -------------------------------------------------------------------------------- /docs/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [require.resolve('@docusaurus/core/lib/babel/preset')] 3 | }; 4 | -------------------------------------------------------------------------------- /docs/docs/010-introduction.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 1 3 | slug: / 4 | --- 5 | 6 | # Introduction 7 | 8 | Modular HTML minifier, built on top of the [PostHTML](https://github.com/posthtml/posthtml). 9 | Inspired by [cssnano](http://cssnano.co/). 10 | 11 | 12 | ## [Benchmark](https://github.com/maltsev/html-minifiers-benchmark/blob/master/README.md) 13 | [html-minifier-terser@5.1.1]: https://www.npmjs.com/package/html-minifier-terser 14 | [htmlnano@2.0.0]: https://www.npmjs.com/package/htmlnano 15 | 16 | | Website | Source (KB) | [html-minifier-terser@5.1.1] | [htmlnano@2.0.0] | 17 | |---------|------------:|----------------:|-----------:| 18 | | [stackoverflow.blog](https://stackoverflow.blog/) | 95 | 87 | 82 | 19 | | [github.com](https://github.com/) | 210 | 183 | 171 | 20 | | [en.wikipedia.org](https://en.wikipedia.org/wiki/Main_Page) | 78 | 72 | 72 | 21 | | [npmjs.com](https://www.npmjs.com/features) | 41 | 38 | 36 | 22 | | **Avg. minify rate** | 0% | **9%** | **13%** | 23 | -------------------------------------------------------------------------------- /docs/docs/020-usage.md: -------------------------------------------------------------------------------- 1 | # Usage 2 | ## Javascript 3 | ```js 4 | const htmlnano = require('htmlnano'); 5 | const options = { 6 | removeEmptyAttributes: false, // Disable the module "removeEmptyAttributes" 7 | collapseWhitespace: 'conservative' // Pass options to the module "collapseWhitespace" 8 | }; 9 | // posthtml, posthtml-render, and posthtml-parse options 10 | const postHtmlOptions = { 11 | sync: true, // https://github.com/posthtml/posthtml#usage 12 | lowerCaseTags: true, // https://github.com/posthtml/posthtml-parser#options 13 | quoteAllAttributes: false, // https://github.com/posthtml/posthtml-render#options 14 | }; 15 | 16 | htmlnano 17 | // "preset" arg might be skipped (see "Presets" section below for more info) 18 | // "postHtmlOptions" arg might be skipped 19 | .process(html, options, preset, postHtmlOptions) 20 | .then(function (result) { 21 | // result.html is minified 22 | }) 23 | .catch(function (err) { 24 | console.error(err); 25 | }); 26 | ``` 27 | 28 | 29 | ## PostHTML 30 | Just add `htmlnano` as a final plugin: 31 | ```js 32 | const posthtml = require('posthtml'); 33 | const options = { 34 | removeComments: false, // Disable the module "removeComments" 35 | collapseWhitespace: 'conservative' // Pass options to the module "collapseWhitespace" 36 | }; 37 | const posthtmlPlugins = [ 38 | /* other PostHTML plugins */ 39 | 40 | require('htmlnano')(options) 41 | ]; 42 | 43 | const posthtmlOptions = { 44 | // See PostHTML docs 45 | }; 46 | 47 | posthtml(posthtmlPlugins) 48 | .process(html, posthtmlOptions) 49 | .then(function (result) { 50 | // result.html is minified 51 | }) 52 | .catch(function (err) { 53 | console.error(err); 54 | }); 55 | ``` 56 | 57 | 58 | ## Webpack 59 | 60 | ```sh 61 | npm install html-minimizer-webpack-plugin --save-dev 62 | npm install htmlnano --save-dev 63 | ``` 64 | 65 | ```js 66 | // webpack.config.js 67 | const HtmlMinimizerWebpackPlugin = require('html-minimizer-webpack-plugin'); 68 | const htmlnano = require('htmlnano'); 69 | 70 | module.exports = { 71 | optimization: { 72 | minimize: true, 73 | minimizer: [ 74 | // For webpack@5 you can use the `...` syntax to extend existing minimizers (i.e. `terser-webpack-plugin`), uncomment the next line 75 | // `...`, 76 | new HtmlMinimizerWebpackPlugin({ 77 | // Add HtmlMinimizerWebpackPlugin's option here, see https://webpack.js.org/plugins/html-minimizer-webpack-plugin/#options 78 | // test: /\.html(\?.*)?$/i, 79 | 80 | // Use htmlnano as HtmlMinimizerWebpackPlugin's minimizer 81 | minify: htmlnano.htmlMinimizerWebpackPluginMinify, 82 | minimizerOptions: { 83 | // Add htmlnano's option here 84 | removeComments: false, // Disable the module "removeComments" 85 | collapseWhitespace: 'conservative' // Pass options to the module "collapseWhitespace" 86 | } 87 | }) 88 | ] 89 | } 90 | } 91 | ``` 92 | 93 | 94 | 95 | ## Gulp 96 | ```bash 97 | npm i -D gulp-posthtml htmlnano 98 | ``` 99 | 100 | ```js 101 | const gulp = require('gulp'); 102 | const posthtml = require('gulp-posthtml'); 103 | const htmlnano = require('htmlnano'); 104 | const options = { 105 | removeComments: false 106 | }; 107 | 108 | gulp.task('default', function() { 109 | return gulp 110 | .src('./index.html') 111 | .pipe(posthtml([ 112 | // Add `htmlnano` as a final plugin 113 | htmlnano(options) 114 | ])) 115 | .pipe(gulp.dest('./build')); 116 | }); 117 | ``` 118 | -------------------------------------------------------------------------------- /docs/docs/030-config.md: -------------------------------------------------------------------------------- 1 | # Config 2 | 3 | There are two main ways to configure htmlnano: 4 | 5 | ## Passing options to `htmlnano` directly 6 | This is the way described above in the examples. 7 | 8 | ## Using configuration file 9 | Alternatively, you might create a configuration file (e.g., `htmlnanorc.json` or `htmlnanorc.js`) or save options to `package.json` with `htmlnano` key. 10 | `htmlnano` uses `cosmiconfig`, so refer to [its documentation](https://github.com/davidtheclark/cosmiconfig/blob/main/README.md) for a more detailed description. 11 | 12 | If you want to specify a preset that way, use `preset` key: 13 | 14 | ```json 15 | { 16 | "preset": "max", 17 | } 18 | ``` 19 | 20 | Configuration files have lower precedence than passing options to `htmlnano` directly. 21 | So if you use both ways, then the configuration file would be ignored. -------------------------------------------------------------------------------- /docs/docs/040-presets.md: -------------------------------------------------------------------------------- 1 | # Presets 2 | 3 | A preset is just an object with modules config. 4 | 5 | Currently the following presets are available: 6 | - [safe](https://github.com/posthtml/htmlnano/blob/master/lib/presets/safe.mjs) — a default preset for minifying a regular HTML in a safe way (without breaking anything) 7 | - [ampSafe](https://github.com/posthtml/htmlnano/blob/master/lib/presets/ampSafe.mjs) - same as `safe` preset but for [AMP pages](https://www.ampproject.org/) 8 | - [max](https://github.com/posthtml/htmlnano/blob/master/lib/presets/max.mjs) - maximal minification (might break some pages) 9 | 10 | 11 | You can use them the following way: 12 | ```js 13 | const htmlnano = require('htmlnano'); 14 | const ampSafePreset = require('htmlnano').presets.ampSafe; 15 | const options = { 16 | // Your options 17 | }; 18 | 19 | htmlnano 20 | .process(html, options, ampSafePreset) 21 | .then(function (result) { 22 | // result.html is minified 23 | }) 24 | .catch(function (err) { 25 | console.error(err); 26 | }); 27 | ``` 28 | 29 | If you skip `preset` argument [`safe`](https://github.com/posthtml/htmlnano/blob/master/lib/presets/safe.mjs) preset would be used by default. 30 | 31 | 32 | If you'd like to define your very own config without any presets pass an empty object as a preset: 33 | ```js 34 | const htmlnano = require('htmlnano'); 35 | const options = { 36 | // Your options 37 | }; 38 | 39 | htmlnano 40 | .process(html, options, {}) 41 | .then(function (result) { 42 | // result.html is minified 43 | }) 44 | .catch(function (err) { 45 | console.error(err); 46 | }); 47 | ``` 48 | 49 | 50 | You might create also your own presets: 51 | ```js 52 | const htmlnano = require('htmlnano'); 53 | // Preset for minifying email templates 54 | const emailPreset = { 55 | mergeStyles: true, 56 | minifyCss: { 57 | safe: true 58 | }, 59 | }; 60 | 61 | const options = { 62 | // Some specific options 63 | }; 64 | 65 | htmlnano 66 | .process(html, options, emailPreset) 67 | .then(function (result) { 68 | // result.html is minified 69 | }) 70 | .catch(function (err) { 71 | console.error(err); 72 | }); 73 | ``` 74 | 75 | Feel free [to submit a PR](https://github.com/posthtml/htmlnano/issues/new) with your preset if it might be useful for other developers as well. 76 | -------------------------------------------------------------------------------- /docs/docs/060-contribute.md: -------------------------------------------------------------------------------- 1 | # Contribute 2 | 3 | Since the minifier is modular, it's very easy to add new modules: 4 | 5 | 1. Create a ES6-file inside `lib/modules/` with a function that does some minification. For example you can check [`lib/modules/example.mjs`](https://github.com/posthtml/htmlnano/blob/master/lib/modules/example.mjs). 6 | 7 | 2. Add the module's name into one of those [presets](https://github.com/posthtml/htmlnano/tree/master/lib/presets). You can choose either `ampSafe`, `max`, or `safe`. 8 | 9 | 3. Create a JS-file inside `test/modules/` with some unit-tests. 10 | 11 | 4. Describe your module in the section "[Modules](https://github.com/posthtml/htmlnano/blob/master/README.md#modules)". 12 | 13 | 5. Send me a pull request. 14 | 15 | Other types of contribution (bug fixes, documentation improves, etc) are also welcome! 16 | Would like to contribute, but don't have any ideas what to do? Check out [our issues](https://github.com/posthtml/htmlnano/labels/help%20wanted). 17 | -------------------------------------------------------------------------------- /docs/docusaurus.config.js: -------------------------------------------------------------------------------- 1 | const lightCodeTheme = require('prism-react-renderer/themes/github'); 2 | const darkCodeTheme = require('prism-react-renderer/themes/dracula'); 3 | 4 | /** @type {import('@docusaurus/types').DocusaurusConfig} */ 5 | module.exports = { 6 | title: 'htmlnano', 7 | tagline: 'Modular HTML minifier', 8 | url: 'https://htmlnano.netlify.app', 9 | favicon: 'favicon.ico', 10 | baseUrl: '/', 11 | onBrokenLinks: 'throw', 12 | onBrokenMarkdownLinks: 'warn', 13 | organizationName: 'posthtml', 14 | projectName: 'htmlnano', 15 | trailingSlash: false, 16 | plugins: ['docusaurus-plugin-goatcounter'], 17 | themeConfig: { 18 | navbar: { 19 | title: 'htmlnano', 20 | items: [ 21 | { 22 | type: 'docsVersionDropdown', 23 | position: 'right', 24 | dropdownActiveClassDisabled: true 25 | }, 26 | { 27 | href: 'https://github.com/posthtml/htmlnano', 28 | label: 'GitHub', 29 | position: 'right' 30 | } 31 | ] 32 | }, 33 | prism: { 34 | theme: lightCodeTheme, 35 | darkTheme: darkCodeTheme 36 | }, 37 | goatcounter: { 38 | code: 'htmlnano' 39 | } 40 | }, 41 | presets: [ 42 | [ 43 | '@docusaurus/preset-classic', 44 | { 45 | docs: { 46 | sidebarPath: require.resolve('./sidebars.js'), 47 | routeBasePath: '/', 48 | editUrl: 'https://github.com/posthtml/htmlnano/edit/master/docs/' 49 | } 50 | } 51 | ] 52 | ] 53 | }; 54 | 55 | const algoliaConfig = { 56 | appId: process.env.ALGOLIA_APP_ID, 57 | apiKey: process.env.ALGOLIA_API_KEY, 58 | indexName: 'htmlnano', 59 | contextualSearch: true 60 | }; 61 | 62 | if (algoliaConfig.apiKey) { 63 | module.exports.themeConfig.algolia = algoliaConfig; 64 | } 65 | -------------------------------------------------------------------------------- /docs/netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | base = "docs/" 3 | publish = "build/" 4 | command = "npm run build" -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "htmlnano-docs", 3 | "version": "1.0.0", 4 | "scripts": { 5 | "docusaurus": "docusaurus", 6 | "start": "docusaurus start", 7 | "build": "docusaurus build", 8 | "swizzle": "docusaurus swizzle", 9 | "deploy": "docusaurus deploy", 10 | "clear": "docusaurus clear", 11 | "serve": "docusaurus serve", 12 | "write-translations": "docusaurus write-translations", 13 | "write-heading-ids": "docusaurus write-heading-ids" 14 | }, 15 | "dependencies": { 16 | "@docusaurus/core": "2.2.0", 17 | "@docusaurus/preset-classic": "2.2.0", 18 | "@mdx-js/react": "^1.6.21", 19 | "@svgr/webpack": "^6.5.1", 20 | "clsx": "^1.1.1", 21 | "docusaurus-plugin-goatcounter": "^2.0.1", 22 | "file-loader": "^6.2.0", 23 | "prism-react-renderer": "^1.2.1", 24 | "react": "^17.0.1", 25 | "react-dom": "^17.0.1", 26 | "url-loader": "^4.1.1" 27 | }, 28 | "browserslist": { 29 | "production": [ 30 | ">0.5%", 31 | "not dead", 32 | "not op_mini all" 33 | ], 34 | "development": [ 35 | "last 1 chrome version", 36 | "last 1 firefox version", 37 | "last 1 safari version" 38 | ] 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /docs/sidebars.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Creating a sidebar enables you to: 3 | - create an ordered group of docs 4 | - render a sidebar for each doc of that group 5 | - provide next/previous navigation 6 | 7 | The sidebars can be generated from the filesystem, or explicitly defined here. 8 | 9 | Create as many sidebars as you want. 10 | */ 11 | 12 | module.exports = { 13 | // By default, Docusaurus generates a sidebar from the docs folder structure 14 | tutorialSidebar: [{ type: 'autogenerated', dirName: '.' }] 15 | 16 | // But you can create a sidebar manually 17 | /* 18 | tutorialSidebar: [ 19 | { 20 | type: 'category', 21 | label: 'Tutorial', 22 | items: ['hello'], 23 | }, 24 | ], 25 | */ 26 | }; 27 | -------------------------------------------------------------------------------- /docs/versioned_docs/version-1.1.1/010-introduction.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 1 3 | slug: / 4 | --- 5 | 6 | # Introduction 7 | 8 | Modular HTML minifier, built on top of the [PostHTML](https://github.com/posthtml/posthtml). 9 | Inspired by [cssnano](http://cssnano.co/). 10 | 11 | 12 | ## [Benchmark](https://github.com/maltsev/html-minifiers-benchmark/blob/master/README.md) 13 | [html-minifier-terser@5.1.1]: https://www.npmjs.com/package/html-minifier-terser 14 | [htmlnano@1.1.1]: https://www.npmjs.com/package/htmlnano 15 | 16 | | Website | Source (KB) | [html-minifier-terser@5.1.1] | [htmlnano@1.1.1] | 17 | |---------|------------:|----------------:|-----------:| 18 | | [stackoverflow.blog](https://stackoverflow.blog/) | 95 | 87 | 82 | 19 | | [github.com](https://github.com/) | 210 | 183 | 171 | 20 | | [en.wikipedia.org](https://en.wikipedia.org/wiki/Main_Page) | 78 | 72 | 72 | 21 | | [npmjs.com](https://www.npmjs.com/features) | 41 | 38 | 36 | 22 | | **Avg. minify rate** | 0% | **9%** | **13%** | 23 | -------------------------------------------------------------------------------- /docs/versioned_docs/version-1.1.1/020-usage.md: -------------------------------------------------------------------------------- 1 | # Usage 2 | 3 | ## Gulp 4 | ```bash 5 | npm install --save-dev gulp-htmlnano 6 | ``` 7 | 8 | ```js 9 | const gulp = require('gulp'); 10 | const htmlnano = require('gulp-htmlnano'); 11 | const options = { 12 | removeComments: false 13 | }; 14 | 15 | gulp.task('default', function() { 16 | return gulp 17 | .src('./index.html') 18 | .pipe(htmlnano(options)) 19 | .pipe(gulp.dest('./build')); 20 | }); 21 | ``` 22 | 23 | 24 | ## Javascript 25 | ```js 26 | const htmlnano = require('htmlnano'); 27 | const options = { 28 | removeEmptyAttributes: false, // Disable the module "removeEmptyAttributes" 29 | collapseWhitespace: 'conservative' // Pass options to the module "collapseWhitespace" 30 | }; 31 | // posthtml, posthtml-render, and posthtml-parse options 32 | const postHtmlOptions = { 33 | sync: true, // https://github.com/posthtml/posthtml#usage 34 | lowerCaseTags: true, // https://github.com/posthtml/posthtml-parser#options 35 | quoteAllAttributes: false, // https://github.com/posthtml/posthtml-render#options 36 | }; 37 | 38 | htmlnano 39 | // "preset" arg might be skipped (see "Presets" section below for more info) 40 | // "postHtmlOptions" arg might be skipped 41 | .process(html, options, preset, postHtmlOptions) 42 | .then(function (result) { 43 | // result.html is minified 44 | }) 45 | .catch(function (err) { 46 | console.error(err); 47 | }); 48 | ``` 49 | 50 | 51 | ## PostHTML 52 | Just add `htmlnano` as a final plugin: 53 | ```js 54 | const posthtml = require('posthtml'); 55 | const options = { 56 | removeComments: false, // Disable the module "removeComments" 57 | collapseWhitespace: 'conservative' // Pass options to the module "collapseWhitespace" 58 | }; 59 | const posthtmlPlugins = [ 60 | /* other PostHTML plugins */ 61 | 62 | require('htmlnano')(options) 63 | ]; 64 | 65 | const posthtmlOptions = { 66 | // See PostHTML docs 67 | }; 68 | 69 | posthtml(posthtmlPlugins) 70 | .process(html, posthtmlOptions) 71 | .then(function (result) { 72 | // result.html is minified 73 | }) 74 | .catch(function (err) { 75 | console.error(err); 76 | }); 77 | ``` -------------------------------------------------------------------------------- /docs/versioned_docs/version-1.1.1/030-config.md: -------------------------------------------------------------------------------- 1 | # Config 2 | 3 | There are two main ways to configure htmlnano: 4 | 5 | ## Passing options to `htmlnano` directly 6 | This is the way described above in the examples. 7 | 8 | ## Using configuration file 9 | Alternatively, you might create a configuration file (e.g., `htmlnanorc.json` or `htmlnanorc.js`) or save options to `package.json` with `htmlnano` key. 10 | `htmlnano` uses `cosmiconfig`, so refer to [its documentation](https://github.com/davidtheclark/cosmiconfig/blob/main/README.md) for a more detailed description. 11 | 12 | If you want to specify a preset that way, use `preset` key: 13 | 14 | ```json 15 | { 16 | "preset": "max", 17 | } 18 | ``` 19 | 20 | Configuration files have lower precedence than passing options to `htmlnano` directly. 21 | So if you use both ways, then the configuration file would be ignored. -------------------------------------------------------------------------------- /docs/versioned_docs/version-1.1.1/040-presets.md: -------------------------------------------------------------------------------- 1 | # Presets 2 | 3 | A preset is just an object with modules config. 4 | 5 | Currently the following presets are available: 6 | - [safe](https://github.com/posthtml/htmlnano/blob/master/lib/presets/safe.mjs) — a default preset for minifying a regular HTML in a safe way (without breaking anything) 7 | - [ampSafe](https://github.com/posthtml/htmlnano/blob/master/lib/presets/ampSafe.mjs) - same as `safe` preset but for [AMP pages](https://www.ampproject.org/) 8 | - [max](https://github.com/posthtml/htmlnano/blob/master/lib/presets/max.mjs) - maximal minification (might break some pages) 9 | 10 | 11 | You can use them the following way: 12 | ```js 13 | const htmlnano = require('htmlnano'); 14 | const ampSafePreset = require('htmlnano').presets.ampSafe; 15 | const options = { 16 | // Your options 17 | }; 18 | 19 | htmlnano 20 | .process(html, options, ampSafePreset) 21 | .then(function (result) { 22 | // result.html is minified 23 | }) 24 | .catch(function (err) { 25 | console.error(err); 26 | }); 27 | ``` 28 | 29 | If you skip `preset` argument [`safe`](https://github.com/posthtml/htmlnano/blob/master/lib/presets/safe.mjs) preset would be used by default. 30 | 31 | 32 | If you'd like to define your very own config without any presets pass an empty object as a preset: 33 | ```js 34 | const htmlnano = require('htmlnano'); 35 | const options = { 36 | // Your options 37 | }; 38 | 39 | htmlnano 40 | .process(html, options, {}) 41 | .then(function (result) { 42 | // result.html is minified 43 | }) 44 | .catch(function (err) { 45 | console.error(err); 46 | }); 47 | ``` 48 | 49 | 50 | You might create also your own presets: 51 | ```js 52 | const htmlnano = require('htmlnano'); 53 | // Preset for minifying email templates 54 | const emailPreset = { 55 | mergeStyles: true, 56 | minifyCss: { 57 | safe: true 58 | }, 59 | }; 60 | 61 | const options = { 62 | // Some specific options 63 | }; 64 | 65 | htmlnano 66 | .process(html, options, emailPreset) 67 | .then(function (result) { 68 | // result.html is minified 69 | }) 70 | .catch(function (err) { 71 | console.error(err); 72 | }); 73 | ``` 74 | 75 | Feel free [to submit a PR](https://github.com/posthtml/htmlnano/issues/new) with your preset if it might be useful for other developers as well. 76 | -------------------------------------------------------------------------------- /docs/versioned_docs/version-1.1.1/060-contribute.md: -------------------------------------------------------------------------------- 1 | # Contribute 2 | 3 | Since the minifier is modular, it's very easy to add new modules: 4 | 5 | 1. Create a ES6-file inside `lib/modules/` with a function that does some minification. For example you can check [`lib/modules/example.mjs`](https://github.com/posthtml/htmlnano/blob/master/lib/modules/example.mjs). 6 | 7 | 2. Add the module's name into one of those [presets](https://github.com/posthtml/htmlnano/tree/master/lib/presets). You can choose either `ampSafe`, `max`, or `safe`. 8 | 9 | 3. Create a JS-file inside `test/modules/` with some unit-tests. 10 | 11 | 4. Describe your module in the section "[Modules](https://github.com/posthtml/htmlnano/blob/master/README.md#modules)". 12 | 13 | 5. Send me a pull request. 14 | 15 | Other types of contribution (bug fixes, documentation improves, etc) are also welcome! 16 | Would like to contribute, but don't have any ideas what to do? Check out [our issues](https://github.com/posthtml/htmlnano/labels/help%20wanted). 17 | -------------------------------------------------------------------------------- /docs/versioned_docs/version-2.0.0/010-introduction.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 1 3 | slug: / 4 | --- 5 | 6 | # Introduction 7 | 8 | Modular HTML minifier, built on top of the [PostHTML](https://github.com/posthtml/posthtml). 9 | Inspired by [cssnano](http://cssnano.co/). 10 | 11 | 12 | ## [Benchmark](https://github.com/maltsev/html-minifiers-benchmark/blob/master/README.md) 13 | [html-minifier-terser@5.1.1]: https://www.npmjs.com/package/html-minifier-terser 14 | [htmlnano@2.0.0]: https://www.npmjs.com/package/htmlnano 15 | 16 | | Website | Source (KB) | [html-minifier-terser@5.1.1] | [htmlnano@2.0.0] | 17 | |---------|------------:|----------------:|-----------:| 18 | | [stackoverflow.blog](https://stackoverflow.blog/) | 95 | 87 | 82 | 19 | | [github.com](https://github.com/) | 210 | 183 | 171 | 20 | | [en.wikipedia.org](https://en.wikipedia.org/wiki/Main_Page) | 78 | 72 | 72 | 21 | | [npmjs.com](https://www.npmjs.com/features) | 41 | 38 | 36 | 22 | | **Avg. minify rate** | 0% | **9%** | **13%** | 23 | -------------------------------------------------------------------------------- /docs/versioned_docs/version-2.0.0/020-usage.md: -------------------------------------------------------------------------------- 1 | # Usage 2 | 3 | ## Gulp 4 | ```bash 5 | npm install --save-dev gulp-htmlnano 6 | ``` 7 | 8 | ```js 9 | const gulp = require('gulp'); 10 | const htmlnano = require('gulp-htmlnano'); 11 | const options = { 12 | removeComments: false 13 | }; 14 | 15 | gulp.task('default', function() { 16 | return gulp 17 | .src('./index.html') 18 | .pipe(htmlnano(options)) 19 | .pipe(gulp.dest('./build')); 20 | }); 21 | ``` 22 | 23 | 24 | ## Javascript 25 | ```js 26 | const htmlnano = require('htmlnano'); 27 | const options = { 28 | removeEmptyAttributes: false, // Disable the module "removeEmptyAttributes" 29 | collapseWhitespace: 'conservative' // Pass options to the module "collapseWhitespace" 30 | }; 31 | // posthtml, posthtml-render, and posthtml-parse options 32 | const postHtmlOptions = { 33 | sync: true, // https://github.com/posthtml/posthtml#usage 34 | lowerCaseTags: true, // https://github.com/posthtml/posthtml-parser#options 35 | quoteAllAttributes: false, // https://github.com/posthtml/posthtml-render#options 36 | }; 37 | 38 | htmlnano 39 | // "preset" arg might be skipped (see "Presets" section below for more info) 40 | // "postHtmlOptions" arg might be skipped 41 | .process(html, options, preset, postHtmlOptions) 42 | .then(function (result) { 43 | // result.html is minified 44 | }) 45 | .catch(function (err) { 46 | console.error(err); 47 | }); 48 | ``` 49 | 50 | 51 | ## PostHTML 52 | Just add `htmlnano` as a final plugin: 53 | ```js 54 | const posthtml = require('posthtml'); 55 | const options = { 56 | removeComments: false, // Disable the module "removeComments" 57 | collapseWhitespace: 'conservative' // Pass options to the module "collapseWhitespace" 58 | }; 59 | const posthtmlPlugins = [ 60 | /* other PostHTML plugins */ 61 | 62 | require('htmlnano')(options) 63 | ]; 64 | 65 | const posthtmlOptions = { 66 | // See PostHTML docs 67 | }; 68 | 69 | posthtml(posthtmlPlugins) 70 | .process(html, posthtmlOptions) 71 | .then(function (result) { 72 | // result.html is minified 73 | }) 74 | .catch(function (err) { 75 | console.error(err); 76 | }); 77 | ``` -------------------------------------------------------------------------------- /docs/versioned_docs/version-2.0.0/030-config.md: -------------------------------------------------------------------------------- 1 | # Config 2 | 3 | There are two main ways to configure htmlnano: 4 | 5 | ## Passing options to `htmlnano` directly 6 | This is the way described above in the examples. 7 | 8 | ## Using configuration file 9 | Alternatively, you might create a configuration file (e.g., `htmlnanorc.json` or `htmlnanorc.js`) or save options to `package.json` with `htmlnano` key. 10 | `htmlnano` uses `cosmiconfig`, so refer to [its documentation](https://github.com/davidtheclark/cosmiconfig/blob/main/README.md) for a more detailed description. 11 | 12 | If you want to specify a preset that way, use `preset` key: 13 | 14 | ```json 15 | { 16 | "preset": "max", 17 | } 18 | ``` 19 | 20 | Configuration files have lower precedence than passing options to `htmlnano` directly. 21 | So if you use both ways, then the configuration file would be ignored. 22 | -------------------------------------------------------------------------------- /docs/versioned_docs/version-2.0.0/040-presets.md: -------------------------------------------------------------------------------- 1 | # Presets 2 | 3 | A preset is just an object with modules config. 4 | 5 | Currently the following presets are available: 6 | - [safe](https://github.com/posthtml/htmlnano/blob/master/lib/presets/safe.mjs) — a default preset for minifying a regular HTML in a safe way (without breaking anything) 7 | - [ampSafe](https://github.com/posthtml/htmlnano/blob/master/lib/presets/ampSafe.mjs) - same as `safe` preset but for [AMP pages](https://www.ampproject.org/) 8 | - [max](https://github.com/posthtml/htmlnano/blob/master/lib/presets/max.mjs) - maximal minification (might break some pages) 9 | 10 | 11 | You can use them the following way: 12 | ```js 13 | const htmlnano = require('htmlnano'); 14 | const ampSafePreset = require('htmlnano').presets.ampSafe; 15 | const options = { 16 | // Your options 17 | }; 18 | 19 | htmlnano 20 | .process(html, options, ampSafePreset) 21 | .then(function (result) { 22 | // result.html is minified 23 | }) 24 | .catch(function (err) { 25 | console.error(err); 26 | }); 27 | ``` 28 | 29 | If you skip `preset` argument [`safe`](https://github.com/posthtml/htmlnano/blob/master/lib/presets/safe.mjs) preset would be used by default. 30 | 31 | 32 | If you'd like to define your very own config without any presets pass an empty object as a preset: 33 | ```js 34 | const htmlnano = require('htmlnano'); 35 | const options = { 36 | // Your options 37 | }; 38 | 39 | htmlnano 40 | .process(html, options, {}) 41 | .then(function (result) { 42 | // result.html is minified 43 | }) 44 | .catch(function (err) { 45 | console.error(err); 46 | }); 47 | ``` 48 | 49 | 50 | You might create also your own presets: 51 | ```js 52 | const htmlnano = require('htmlnano'); 53 | // Preset for minifying email templates 54 | const emailPreset = { 55 | mergeStyles: true, 56 | minifyCss: { 57 | safe: true 58 | }, 59 | }; 60 | 61 | const options = { 62 | // Some specific options 63 | }; 64 | 65 | htmlnano 66 | .process(html, options, emailPreset) 67 | .then(function (result) { 68 | // result.html is minified 69 | }) 70 | .catch(function (err) { 71 | console.error(err); 72 | }); 73 | ``` 74 | 75 | Feel free [to submit a PR](https://github.com/posthtml/htmlnano/issues/new) with your preset if it might be useful for other developers as well. 76 | -------------------------------------------------------------------------------- /docs/versioned_docs/version-2.0.0/060-contribute.md: -------------------------------------------------------------------------------- 1 | # Contribute 2 | 3 | Since the minifier is modular, it's very easy to add new modules: 4 | 5 | 1. Create a ES6-file inside `lib/modules/` with a function that does some minification. For example you can check [`lib/modules/example.mjs`](https://github.com/posthtml/htmlnano/blob/master/lib/modules/example.mjs). 6 | 7 | 2. Add the module's name into one of those [presets](https://github.com/posthtml/htmlnano/tree/master/lib/presets). You can choose either `ampSafe`, `max`, or `safe`. 8 | 9 | 3. Create a JS-file inside `test/modules/` with some unit-tests. 10 | 11 | 4. Describe your module in the section "[Modules](https://github.com/posthtml/htmlnano/blob/master/README.md#modules)". 12 | 13 | 5. Send me a pull request. 14 | 15 | Other types of contribution (bug fixes, documentation improves, etc) are also welcome! 16 | Would like to contribute, but don't have any ideas what to do? Check out [our issues](https://github.com/posthtml/htmlnano/labels/help%20wanted). 17 | -------------------------------------------------------------------------------- /docs/versioned_sidebars/version-1.1.1-sidebars.json: -------------------------------------------------------------------------------- 1 | { 2 | "version-1.1.1/tutorialSidebar": [ 3 | { 4 | "type": "autogenerated", 5 | "dirName": "." 6 | } 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /docs/versioned_sidebars/version-2.0.0-sidebars.json: -------------------------------------------------------------------------------- 1 | { 2 | "version-2.0.0/tutorialSidebar": [ 3 | { 4 | "type": "autogenerated", 5 | "dirName": "." 6 | } 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /docs/versions.json: -------------------------------------------------------------------------------- 1 | [ 2 | "2.0.0", 3 | "1.1.1" 4 | ] 5 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import globals from 'globals'; 2 | 3 | import gitignore from 'eslint-config-flat-gitignore'; 4 | import eslintPluginImportX, { createNodeResolver } from 'eslint-plugin-import-x'; 5 | import eslint from '@eslint/js'; 6 | import tseslint from 'typescript-eslint'; 7 | import eslintPluginUnusedImports from 'eslint-plugin-unused-imports'; 8 | import stylistic from '@stylistic/eslint-plugin'; 9 | 10 | export default tseslint.config( 11 | { 12 | name: '.eslintignore', // migrated from .eslintignore 13 | ignores: [ 14 | 'src/_modules/example.mjs', 15 | 'test.mjs', 16 | '**/*.cjs' 17 | ] 18 | }, 19 | gitignore({ 20 | root: true 21 | }), 22 | { 23 | name: 'eslint env config', 24 | languageOptions: { 25 | ecmaVersion: 'latest', 26 | sourceType: 'module', 27 | parserOptions: { 28 | ecmaVersion: 'latest', 29 | sourceType: 'module', 30 | ecmaFeatures: { 31 | jsx: false 32 | } 33 | }, 34 | globals: { 35 | ...globals.es2025, 36 | ...globals.node, 37 | ...globals.mocha 38 | } 39 | } 40 | }, 41 | eslint.configs.recommended, 42 | { 43 | ...eslintPluginImportX.flatConfigs.recommended, 44 | ...eslintPluginImportX.flatConfigs.typescript, 45 | settings: { 46 | ...eslintPluginImportX.flatConfigs.recommended.settings, 47 | ...eslintPluginImportX.flatConfigs.typescript.settings, 48 | 'import-x/resolver-next': { 49 | node: createNodeResolver() 50 | } 51 | }, 52 | name: 'eslint-import-x config' 53 | }, 54 | ...tseslint.configs.recommendedTypeChecked.map((config) => { 55 | return { 56 | ...config, 57 | files: ['**/*.mts', '**/*.cts', '**/*.ts'] 58 | }; 59 | }), 60 | { 61 | name: 'typescript-only config', 62 | files: ['**/*.mts', '**/*.cts', '**/*.ts'], 63 | languageOptions: { 64 | parserOptions: { 65 | projectService: true, 66 | tsconfigRootDir: import.meta.dirname 67 | } 68 | }, 69 | plugins: { 70 | 'unused-imports': eslintPluginUnusedImports 71 | }, 72 | rules: { 73 | '@typescript-eslint/no-explicit-any': 'warn', 74 | // the maintainers of @typescript-eslint DOESN'T KNOW ANYTHING about TypeScript AT ALL 75 | // and basically @typescript-eslint is a joke anyway 76 | '@typescript-eslint/no-empty-object-type': [ 77 | 'error', 78 | { 79 | allowInterfaces: 'with-single-extends', // interface Derived extends Base {} 80 | allowObjectTypes: 'never', 81 | allowWithName: 'Props$' 82 | } 83 | ], // {} is widely used with "& {}" approach 84 | 85 | '@typescript-eslint/no-require-imports': 'off', 86 | 87 | // replaced by unused-imports/no-unused-imports 88 | '@typescript-eslint/no-unused-vars': 'off', 89 | 'unused-imports/no-unused-vars': [ 90 | 'error', 91 | { vars: 'all', varsIgnorePattern: '^_', args: 'after-used', argsIgnorePattern: '^_', ignoreRestSiblings: true } 92 | ], 93 | 'unused-imports/no-unused-imports': 'error', 94 | 95 | 'import-x/consistent-type-specifier-style': ['error', 'prefer-top-level'], 96 | 97 | // https://github.com/un-ts/eslint-plugin-import-x/blob/3abe5e49683e0f973232bb631814b935e1ca7091/src/config/typescript.ts#L32C1-L33C1 98 | 'import-x/named': 'off', // TypeScript compilation already ensures that named imports exist in the referenced module 99 | 'import-x/namespace': 'off', 100 | 'import-x/default': 'off', 101 | 102 | 'import-x/no-duplicates': 'off', 103 | 104 | 'import-x/no-named-as-default-member': 'off', // import foo from 'foo'; 105 | // typescript-eslint already supports this 106 | 'import-x/no-deprecated': 'off', 107 | 108 | '@typescript-eslint/switch-exhaustiveness-check': ['error', { allowDefaultCaseForExhaustiveSwitch: true, considerDefaultExhaustiveForUnions: true }], 109 | '@typescript-eslint/parameter-properties': ['warn', { prefer: 'parameter-property' }], 110 | 111 | '@typescript-eslint/no-namespace': 'off' 112 | } 113 | }, 114 | { 115 | name: 'disable unused-vars on d.ts', 116 | files: ['**/*.d.ts'], 117 | rules: { 118 | 'import-x/no-duplicates': 'off', 119 | 'unused-imports/no-unused-vars': 'off' 120 | } 121 | }, 122 | stylistic.configs.customize({ 123 | indent: 4, 124 | quotes: 'single', 125 | commaDangle: 'never', 126 | semi: true, 127 | jsx: false, 128 | braceStyle: '1tbs', 129 | quoteProps: 'as-needed' 130 | }), 131 | { 132 | name: 'eslint stylistic config', 133 | rules: { 134 | 'linebreak-style': [2, 'unix'], 135 | camelcase: [2, { properties: 'always' }], 136 | '@stylistic/no-mixed-operators': 'off', // TODO: temporary disable during migration 137 | // enforce spacing before and after 138 | // https://eslint.style/rules/js/comma-spacing 139 | '@stylistic/comma-spacing': ['error', { before: false, after: true }], 140 | // enforce one true comma style 141 | // https://eslint.style/rules/js/comma-style 142 | '@stylistic/comma-style': ['error', 'last', { 143 | exceptions: { 144 | ArrayExpression: false, 145 | ArrayPattern: false, 146 | ArrowFunctionExpression: false, 147 | CallExpression: false, 148 | FunctionDeclaration: false, 149 | FunctionExpression: false, 150 | ImportDeclaration: false, 151 | ObjectExpression: false, 152 | ObjectPattern: false, 153 | VariableDeclaration: false, 154 | NewExpression: false 155 | } 156 | }] 157 | } 158 | }, 159 | { 160 | name: 'disable no-tabs in test', 161 | files: ['test/**/*'], 162 | rules: { 163 | '@stylistic/no-tabs': 'off' // test fixtures 164 | } 165 | } 166 | ); 167 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "htmlnano", 3 | "version": "2.1.2", 4 | "description": "Modular HTML minifier, built on top of the PostHTML", 5 | "author": "Kirill Maltsev ", 6 | "license": "MIT", 7 | "scripts": { 8 | "clean": "rimraf dist", 9 | "build": "npm run clean && bunchee", 10 | "compile": "npm run build", 11 | "lint": "eslint --fix .", 12 | "pretest": "npm run lint && npm run compile", 13 | "test": ":", 14 | "posttest": "mocha --timeout 5000 --require @swc-node/register --recursive --check-leaks --globals addresses", 15 | "prepare": "npm run compile" 16 | }, 17 | "files": [ 18 | "dist" 19 | ], 20 | "main": "./dist/index.js", 21 | "module": "./dist/index.mjs", 22 | "exports": { 23 | "./helpers": { 24 | "import": "./dist/helpers.mjs", 25 | "require": "./dist/helpers.js" 26 | }, 27 | ".": { 28 | "import": "./dist/index.mjs", 29 | "require": "./dist/index.js" 30 | }, 31 | "./presets/ampSafe": { 32 | "import": "./dist/presets/ampSafe.mjs", 33 | "require": "./dist/presets/ampSafe.js" 34 | }, 35 | "./presets/max": { 36 | "import": "./dist/presets/max.mjs", 37 | "require": "./dist/presets/max.js" 38 | }, 39 | "./presets/safe": { 40 | "import": "./dist/presets/safe.mjs", 41 | "require": "./dist/presets/safe.js" 42 | } 43 | }, 44 | "keywords": [ 45 | "posthtml", 46 | "posthtml-plugin", 47 | "html", 48 | "postproccessor", 49 | "minifier" 50 | ], 51 | "dependencies": { 52 | "cosmiconfig": "^9.0.0", 53 | "posthtml": "^0.16.5" 54 | }, 55 | "devDependencies": { 56 | "@stylistic/eslint-plugin": "^4.2.0", 57 | "@swc-node/register": "^1.10.10", 58 | "@types/node": "^22.15.3", 59 | "bunchee": "^6.5.1", 60 | "cssnano": "^7.0.0", 61 | "eslint": "^9.25.1", 62 | "eslint-config-flat-gitignore": "^2.1.0", 63 | "eslint-plugin-import": "^2.28.1", 64 | "eslint-plugin-import-x": "^4.11.0", 65 | "eslint-plugin-unused-imports": "^4.1.4", 66 | "expect": "^29.0.0", 67 | "globals": "^16.0.0", 68 | "mocha": "^11.0.1", 69 | "postcss": "^8.3.11", 70 | "purgecss": "^7.0.2", 71 | "relateurl": "^0.2.7", 72 | "rimraf": "^6.0.0", 73 | "srcset": "5.0.1", 74 | "svgo": "^3.0.2", 75 | "terser": "^5.21.0", 76 | "typescript": "^5.8.3", 77 | "typescript-eslint": "^8.31.1", 78 | "uncss": "^0.17.3" 79 | }, 80 | "peerDependencies": { 81 | "cssnano": "^7.0.0", 82 | "postcss": "^8.3.11", 83 | "purgecss": "^7.0.2", 84 | "relateurl": "^0.2.7", 85 | "srcset": "5.0.1", 86 | "svgo": "^3.0.2", 87 | "terser": "^5.10.0", 88 | "uncss": "^0.17.3" 89 | }, 90 | "peerDependenciesMeta": { 91 | "cssnano": { 92 | "optional": true 93 | }, 94 | "postcss": { 95 | "optional": true 96 | }, 97 | "purgecss": { 98 | "optional": true 99 | }, 100 | "relateurl": { 101 | "optional": true 102 | }, 103 | "srcset": { 104 | "optional": true 105 | }, 106 | "svgo": { 107 | "optional": true 108 | }, 109 | "terser": { 110 | "optional": true 111 | }, 112 | "uncss": { 113 | "optional": true 114 | } 115 | }, 116 | "repository": { 117 | "type": "git", 118 | "url": "git://github.com/posthtml/htmlnano.git" 119 | }, 120 | "bugs": { 121 | "url": "https://github.com/posthtml/htmlnano/issues" 122 | }, 123 | "homepage": "https://github.com/posthtml/htmlnano", 124 | "packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39" 125 | } 126 | -------------------------------------------------------------------------------- /src/_modules/collapseAttributeWhitespace.ts: -------------------------------------------------------------------------------- 1 | import { isEventHandler } from '../helpers'; 2 | import type { HtmlnanoModule } from '../types'; 3 | 4 | export const attributesWithLists = new Set([ 5 | 'class', 6 | 'dropzone', 7 | 'rel', // a, area, link 8 | 'ping', // a, area 9 | 'sandbox', // iframe 10 | /** 11 | * https://github.com/posthtml/htmlnano/issues/180 12 | * https://developer.mozilla.org/en-US/docs/Web/HTML/Element/link#attr-sizes 13 | * 14 | * "sizes" of should not be modified, while "sizes" of will only have one entry in most cases. 15 | */ 16 | // 'sizes', // link 17 | 'headers' // td, th 18 | ]); 19 | 20 | /** empty set means the attribute is alwasy trimmable */ 21 | const attributesWithSingleValue = new Map>([ 22 | ['accept', new Set(['input'])], 23 | ['action', new Set(['form'])], 24 | ['accesskey', new Set()], 25 | ['accept-charset', new Set(['form'])], 26 | ['cite', new Set(['blockquote', 'del', 'ins', 'q'])], 27 | ['cols', new Set(['textarea'])], 28 | ['colspan', new Set(['td', 'th'])], 29 | ['data', new Set(['object'])], 30 | ['dropzone', new Set()], 31 | ['formaction', new Set(['button', 'input'])], 32 | ['height', new Set(['canvas', 'embed', 'iframe', 'img', 'input', 'object', 'video'])], 33 | ['high', new Set(['meter'])], 34 | ['href', new Set(['a', 'area', 'base', 'link'])], 35 | ['itemid', new Set()], 36 | ['low', new Set(['meter'])], 37 | ['manifest', new Set(['html'])], 38 | ['max', new Set(['meter', 'progress'])], 39 | ['maxlength', new Set(['input', 'textarea'])], 40 | ['media', new Set(['source'])], 41 | ['min', new Set(['meter'])], 42 | ['minlength', new Set(['input', 'textarea'])], 43 | ['optimum', new Set(['meter'])], 44 | ['ping', new Set(['a', 'area'])], 45 | ['poster', new Set(['video'])], 46 | ['profile', new Set(['head'])], 47 | ['rows', new Set(['textarea'])], 48 | ['rowspan', new Set(['td', 'th'])], 49 | ['size', new Set(['input', 'select'])], 50 | ['span', new Set(['col', 'colgroup'])], 51 | ['src', new Set([ 52 | 'audio', 53 | 'embed', 54 | 'iframe', 55 | 'img', 56 | 'input', 57 | 'script', 58 | 'source', 59 | 'track', 60 | 'video' 61 | ])], 62 | ['start', new Set(['ol'])], 63 | ['step', new Set(['input'])], 64 | ['style', new Set()], 65 | ['tabindex', new Set()], 66 | ['usemap', new Set(['img', 'object'])], 67 | ['value', new Set(['li', 'meter', 'progress'])], 68 | ['width', new Set(['canvas', 'embed', 'iframe', 'img', 'input', 'object', 'video'])] 69 | ]); 70 | 71 | /** Collapse whitespaces inside list-like attributes (e.g. class, rel) */ 72 | const mod: HtmlnanoModule = { 73 | onAttrs() { 74 | return (attrs, node) => { 75 | const newAttrs = attrs; 76 | 77 | Object.entries(attrs).forEach(([attrName, attrValue]) => { 78 | if (typeof attrValue !== 'string') return; 79 | 80 | if (attributesWithLists.has(attrName)) { 81 | newAttrs[attrName] = attrValue.replace(/\s+/g, ' ').trim(); 82 | return; 83 | } 84 | 85 | if ( 86 | isEventHandler(attrName) 87 | ) { 88 | newAttrs[attrName] = attrValue.trim(); 89 | } else if (node.tag && attributesWithSingleValue.has(attrName)) { 90 | const tagSet = attributesWithSingleValue.get(attrName)!; 91 | if (tagSet.size === 0 || tagSet.has(node.tag)) { 92 | newAttrs[attrName] = attrValue.trim(); 93 | } 94 | } 95 | }); 96 | 97 | return newAttrs; 98 | }; 99 | } 100 | }; 101 | 102 | export default mod; 103 | -------------------------------------------------------------------------------- /src/_modules/collapseBooleanAttributes.ts: -------------------------------------------------------------------------------- 1 | // Source: https://github.com/kangax/html-minifier/issues/63 2 | // https://html.spec.whatwg.org/#boolean-attribute 3 | 4 | import type { HtmlnanoModule } from '../types'; 5 | 6 | // https://html.spec.whatwg.org/#attributes-1 7 | const htmlBooleanAttributes = new Set([ 8 | 'allowfullscreen', 9 | 'allowpaymentrequest', 10 | 'allowtransparency', 11 | 'async', 12 | 'autofocus', 13 | 'autoplay', 14 | 'checked', 15 | 'compact', 16 | 'controls', 17 | 'declare', 18 | 'default', 19 | 'defaultchecked', 20 | 'defaultmuted', 21 | 'defaultselected', 22 | 'defer', 23 | 'disabled', 24 | 'enabled', 25 | 'formnovalidate', 26 | 'hidden', 27 | 'indeterminate', 28 | 'inert', 29 | 'ismap', 30 | 'itemscope', 31 | 'loop', 32 | 'multiple', 33 | 'muted', 34 | 'nohref', 35 | 'nomodule', 36 | 'noresize', 37 | 'noshade', 38 | 'novalidate', 39 | 'nowrap', 40 | 'open', 41 | 'pauseonexit', 42 | 'playsinline', 43 | 'readonly', 44 | 'required', 45 | 'reversed', 46 | 'scoped', 47 | 'seamless', 48 | 'selected', 49 | 'sortable', 50 | 'truespeed', 51 | 'typemustmatch', 52 | 'visible' 53 | ]); 54 | 55 | const amphtmlBooleanAttributes = new Set([ 56 | '⚡', 57 | 'amp', 58 | '⚡4ads', 59 | 'amp4ads', 60 | '⚡4email', 61 | 'amp4email', 62 | 63 | 'amp-custom', 64 | 'amp-boilerplate', 65 | 'amp4ads-boilerplate', 66 | 'amp4email-boilerplate', 67 | 68 | 'allow-blocked-ranges', 69 | 'amp-access-hide', 70 | 'amp-access-template', 71 | 'amp-keyframes', 72 | 'animate', 73 | 'arrows', 74 | 'data-block-on-consent', 75 | 'data-enable-refresh', 76 | 'data-multi-size', 77 | 'date-template', 78 | 'disable-double-tap', 79 | 'disable-session-states', 80 | 'disableremoteplayback', 81 | 'dots', 82 | 'expand-single-section', 83 | 'expanded', 84 | 'fallback', 85 | 'first', 86 | 'fullscreen', 87 | 'inline', 88 | 'lightbox', 89 | 'noaudio', 90 | 'noautoplay', 91 | 'noloading', 92 | 'once', 93 | 'open-after-clear', 94 | 'open-after-select', 95 | 'open-button', 96 | 'placeholder', 97 | 'preload', 98 | 'reset-on-refresh', 99 | 'reset-on-resize', 100 | 'resizable', 101 | 'rotate-to-fullscreen', 102 | 'second', 103 | 'standalone', 104 | 'stereo', 105 | 'submit-error', 106 | 'submit-success', 107 | 'submitting', 108 | 'subscriptions-actions', 109 | 'subscriptions-dialog' 110 | ]); 111 | 112 | const missingValueDefaultEmptyStringAttributes: Record> = { 113 | // https://html.spec.whatwg.org/#attr-media-preload 114 | audio: { 115 | preload: 'auto' 116 | }, 117 | video: { 118 | preload: 'auto' 119 | } 120 | }; 121 | 122 | const tagsHasMissingValueDefaultEmptyStringAttributes = new Set(Object.keys(missingValueDefaultEmptyStringAttributes)); 123 | 124 | export interface CollapseBooleanAttributesOptions { 125 | amphtml: boolean; 126 | } 127 | 128 | const mod: HtmlnanoModule = { 129 | onAttrs(options, moduleOptions) { 130 | return (attrs, node) => { 131 | if (!node.tag) return attrs; 132 | 133 | const newAttrs = attrs; 134 | 135 | if (tagsHasMissingValueDefaultEmptyStringAttributes.has(node.tag)) { 136 | const tagAttributesCanBeReplacedWithEmptyString = missingValueDefaultEmptyStringAttributes[node.tag]; 137 | 138 | for (const attributesCanBeReplacedWithEmptyString of Object.keys(tagAttributesCanBeReplacedWithEmptyString)) { 139 | if ( 140 | attributesCanBeReplacedWithEmptyString in attrs 141 | && attributesCanBeReplacedWithEmptyString in tagAttributesCanBeReplacedWithEmptyString 142 | && attrs[attributesCanBeReplacedWithEmptyString] === tagAttributesCanBeReplacedWithEmptyString[attributesCanBeReplacedWithEmptyString] 143 | ) { 144 | attrs[attributesCanBeReplacedWithEmptyString] = true; 145 | } 146 | } 147 | } 148 | 149 | for (const attrName of Object.keys(attrs)) { 150 | if (attrName === 'visible' && node.tag.startsWith('a-')) { 151 | continue; 152 | } 153 | 154 | if (htmlBooleanAttributes.has(attrName)) { 155 | newAttrs[attrName] = true; 156 | } 157 | 158 | // Fast path optimization. 159 | // The rest of tranformations are only for string type attrValue. 160 | if (typeof newAttrs[attrName] !== 'string') continue; 161 | 162 | if (moduleOptions.amphtml && amphtmlBooleanAttributes.has(attrName) && attrs[attrName] === '') { 163 | newAttrs[attrName] = true; 164 | } 165 | // https://html.spec.whatwg.org/#a-quick-introduction-to-html 166 | // The value, along with the "=" character, can be omitted altogether if the value is the empty string. 167 | if (attrs[attrName] === '') { 168 | newAttrs[attrName] = true; 169 | } 170 | 171 | // collapse crossorigin attributes 172 | // Specification: https://html.spec.whatwg.org/multipage/urls-and-fetching.html#cors-settings-attributes 173 | if ( 174 | attrName.toLowerCase() === 'crossorigin' && ( 175 | attrs[attrName] === 'anonymous' 176 | ) 177 | ) { 178 | newAttrs[attrName] = true; 179 | } 180 | } 181 | 182 | return newAttrs; 183 | }; 184 | } 185 | }; 186 | 187 | export default mod; 188 | -------------------------------------------------------------------------------- /src/_modules/collapseWhitespace.ts: -------------------------------------------------------------------------------- 1 | import type PostHTML from 'posthtml'; 2 | import { isComment } from '../helpers'; 3 | import type { HtmlnanoModule, HtmlnanoOptions, PostHTMLTreeLike } from '../types'; 4 | 5 | const noWhitespaceCollapseElements = new Set([ 6 | 'script', 7 | 'style', 8 | 'pre', 9 | 'textarea' 10 | ]); 11 | 12 | const noTrimWhitespacesArroundElements = new Set([ 13 | // non-empty tags that will maintain whitespace around them 14 | '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', 15 | // self-closing tags that will maintain whitespace around them 16 | 'comment', 'img', 'input', 'wbr' 17 | ]); 18 | 19 | const noTrimWhitespacesInsideElements = new Set([ 20 | // non-empty tags that will maintain whitespace within them 21 | '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' 22 | ]); 23 | 24 | const startsWithWhitespacePattern = /^\s/; 25 | const endsWithWhitespacePattern = /\s$/; 26 | // See https://infra.spec.whatwg.org/#strip-and-collapse-ascii-whitespace and https://infra.spec.whatwg.org/#ascii-whitespace 27 | const multipleWhitespacePattern = /[\t\n\f\r ]+/g; 28 | const NONE = ''; 29 | const SINGLE_SPACE = ' '; 30 | const validOptions = ['all', 'aggressive', 'conservative']; 31 | 32 | type CollapseType = 'all' | 'aggressive' | 'conservative'; 33 | interface ParentInfo { 34 | node: PostHTML.Node; 35 | prevNode: PostHTML.Node | string | undefined; 36 | nextNode: PostHTML.Node | string | undefined; 37 | } 38 | 39 | /** Collapses redundant whitespaces */ 40 | function collapseWhitespace(tree: PostHTMLTreeLike, options: HtmlnanoOptions, collapseType: CollapseType, parent?: ParentInfo): PostHTMLTreeLike; 41 | function collapseWhitespace(tree: Array, options: HtmlnanoOptions, collapseType: CollapseType, parent?: ParentInfo): Array; 42 | function collapseWhitespace(tree: PostHTMLTreeLike | Array, options: HtmlnanoOptions, collapseType: CollapseType, parent?: ParentInfo) { 43 | collapseType = validOptions.includes(collapseType) ? collapseType : 'conservative'; 44 | tree.forEach((node, index) => { 45 | const prevNode = tree[index - 1]; 46 | const nextNode = tree[index + 1]; 47 | 48 | if (typeof node === 'string') { 49 | const parentNodeTag = parent?.node.tag; 50 | const isTopLevel = parentNodeTag == null || parentNodeTag === 'html' || parentNodeTag === 'head'; 51 | const shouldTrim = ( 52 | isTopLevel 53 | || collapseType === 'all' 54 | /* 55 | * When collapseType is set to 'aggressive', and the tag is not inside 'noTrimWhitespacesInsideElements'. 56 | * the first & last space inside the tag will be trimmed 57 | */ 58 | || collapseType === 'aggressive' 59 | ); 60 | 61 | node = collapseRedundantWhitespaces(node, collapseType, shouldTrim, parent, prevNode, nextNode); 62 | } else if (node.tag) { 63 | const isAllowCollapseWhitespace = !noWhitespaceCollapseElements.has(node.tag); 64 | if (isAllowCollapseWhitespace && node.content?.length) { 65 | node.content = collapseWhitespace(node.content, options, collapseType, { 66 | node, 67 | prevNode, 68 | nextNode 69 | }); 70 | } 71 | } 72 | tree[index] = node; 73 | }); 74 | 75 | return tree; 76 | } 77 | 78 | function collapseRedundantWhitespaces( 79 | text: string, collapseType: CollapseType, shouldTrim = false, parent: ParentInfo | undefined, 80 | prevNode: PostHTML.Node | string, nextNode: PostHTML.Node | string 81 | ) { 82 | if (!text || text.length === 0) { 83 | return NONE; 84 | } 85 | 86 | if (!isComment(text)) { 87 | text = text.replace(multipleWhitespacePattern, SINGLE_SPACE); 88 | } 89 | 90 | if (shouldTrim) { 91 | // either all or top level, trim all 92 | if (collapseType === 'all' || collapseType === 'conservative') { 93 | return text.trim(); 94 | } 95 | 96 | if ( 97 | typeof parent !== 'object' 98 | || !parent?.node.tag 99 | || !noTrimWhitespacesInsideElements.has(parent.node.tag) 100 | ) { 101 | if ( 102 | // It is the first child node of the parent 103 | !prevNode 104 | // It is not the first child node, and prevNode not a text node, and prevNode is safe to trim around 105 | || ( 106 | typeof prevNode === 'object' && prevNode.tag && !noTrimWhitespacesArroundElements.has(prevNode.tag)) 107 | ) { 108 | text = text.trimStart(); 109 | } else { 110 | // previous node is a "no trim whitespaces arround element" 111 | if ( 112 | // but previous node ends with a whitespace 113 | typeof prevNode === 'object' && prevNode.content 114 | ) { 115 | const prevNodeLastContent = prevNode.content[prevNode.content.length - 1]; 116 | if ( 117 | typeof prevNodeLastContent === 'string' 118 | && endsWithWhitespacePattern.test(prevNodeLastContent) 119 | && ( 120 | !nextNode // either the current node is the last child of the parent 121 | || ( 122 | // or the next node starts with a white space 123 | typeof nextNode === 'object' && nextNode.content && typeof nextNode.content[0] === 'string' 124 | && !startsWithWhitespacePattern.test(nextNode.content[0]) 125 | ) 126 | ) 127 | ) { 128 | text = text.trimStart(); 129 | } 130 | } 131 | } 132 | if ( 133 | !nextNode 134 | || typeof nextNode === 'object' && nextNode.tag && !noTrimWhitespacesArroundElements.has(nextNode.tag) 135 | ) { 136 | text = text.trimEnd(); 137 | } 138 | } else { 139 | // now it is a textNode inside a "no trim whitespaces inside elements" node 140 | if ( 141 | !prevNode // it the textnode is the first child of the node 142 | && startsWithWhitespacePattern.test(text[0]) // it starts with white space 143 | && typeof parent?.prevNode === 'string' // the prev of the node is a textNode as well 144 | && endsWithWhitespacePattern.test(parent.prevNode[parent.prevNode.length - 1]) // that prev is ends with a white 145 | ) { 146 | text = text.trimStart(); 147 | } 148 | } 149 | } 150 | 151 | return text; 152 | } 153 | 154 | const mod: HtmlnanoModule = { 155 | default: collapseWhitespace 156 | } as HtmlnanoModule; 157 | 158 | export default mod; 159 | -------------------------------------------------------------------------------- /src/_modules/custom.ts: -------------------------------------------------------------------------------- 1 | import type { HtmlnanoModule, HtmlnanoOptions, PostHTMLTreeLike } from '../types'; 2 | 3 | type CustomModule = (tree: PostHTMLTreeLike, options: Partial) => PostHTMLTreeLike; 4 | 5 | /** Meta-module that runs custom modules */ 6 | const mod: HtmlnanoModule = { 7 | default: function custom(tree, options, customModules) { 8 | if (!customModules) { 9 | return tree; 10 | } 11 | 12 | if (!Array.isArray(customModules)) { 13 | customModules = [customModules]; 14 | } 15 | 16 | customModules.forEach((customModule) => { 17 | if (customModule) { 18 | tree = customModule(tree, options); 19 | } 20 | }); 21 | 22 | return tree; 23 | } 24 | }; 25 | export default mod; 26 | -------------------------------------------------------------------------------- /src/_modules/deduplicateAttributeValues.ts: -------------------------------------------------------------------------------- 1 | import type { HtmlnanoModule } from '../types'; 2 | import { attributesWithLists } from './collapseAttributeWhitespace'; 3 | 4 | /** Deduplicate values inside list-like attributes (e.g. class, rel) */ 5 | const mod: HtmlnanoModule = { 6 | onAttrs() { 7 | return (attrs) => { 8 | const newAttrs = attrs; 9 | Object.keys(attrs).forEach((attrName) => { 10 | if (!attributesWithLists.has(attrName)) { 11 | return; 12 | } 13 | 14 | if (typeof attrs[attrName] !== 'string') { 15 | return; 16 | } 17 | 18 | const attrValues = attrs[attrName].split(/\s/); 19 | const uniqeAttrValues = new Set(); 20 | const deduplicatedAttrValues: string[] = []; 21 | 22 | attrValues.forEach((attrValue) => { 23 | if (!attrValue) { 24 | // Keep whitespaces 25 | deduplicatedAttrValues.push(''); 26 | return; 27 | } 28 | 29 | if (uniqeAttrValues.has(attrValue)) { 30 | return; 31 | } 32 | 33 | deduplicatedAttrValues.push(attrValue); 34 | uniqeAttrValues.add(attrValue); 35 | }); 36 | 37 | newAttrs[attrName] = deduplicatedAttrValues.join(' '); 38 | }); 39 | 40 | return newAttrs; 41 | }; 42 | } 43 | }; 44 | 45 | export default mod; 46 | -------------------------------------------------------------------------------- /src/_modules/example.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * It is an example htmlnano module. 3 | * 4 | * A htmlnano module can be modify the attributes of every node (through a "onAttrs" named export), 5 | * modify the content of every node (through an optional "onContent" named export), modify the node 6 | * itself (through an optional "onNode" named export), or modify the entire tree (through an optional 7 | * default export). 8 | */ 9 | 10 | /** 11 | * Modify attributes of node. Optional. 12 | * 13 | * @param {object} options - Options that were passed to htmlnano 14 | * @param moduleOptions — Module options. For most modules this is just "true" (indication that the module was enabled) 15 | * @return {Function} - Return a function that takes attribute object and the node (for the context), and returns the modified attribute object 16 | */ 17 | export function onAttrs(options, moduleOptions) { 18 | return (attrs, node) => { 19 | // You can modify "attrs" based on "node" 20 | const newAttrs = { ...attrs }; 21 | 22 | return newAttrs; // ... then return the modified attrs 23 | }; 24 | } 25 | 26 | /** 27 | * Modify content of node. Optional. 28 | * 29 | * @param {object} options - Options that were passed to htmlnano 30 | * @param moduleOptions — Module options. For most modules this is just "true" (indication that the module was enabled) 31 | * @return {Function} - Return a function that takes contents (an array of node and string) and the node (for the context), and returns the modified content array. 32 | */ 33 | export function onContent(options, moduleOptions) { 34 | return (content, node) => { 35 | // Same goes the "content" 36 | 37 | return content; // ... return modified content here 38 | }; 39 | } 40 | 41 | /** 42 | * It is possible to modify entire ndde as well. Optional. 43 | * @param {object} options - Options that were passed to htmlnano 44 | * @param moduleOptions — Module options. For most modules this is just "true" (indication that the module was enabled) 45 | * @return {Function} - Return a function that takes the node, and returns the new, modified node. 46 | */ 47 | export function onNode(options, moduleOptions) { 48 | return (node) => { 49 | return node; // ... return new node here 50 | }; 51 | } 52 | 53 | /** 54 | * Modify the entire tree. Optional. 55 | * 56 | * @param {object} tree - PostHTML tree (https://github.com/posthtml/posthtml/blob/master/README.md) 57 | * @param {object} options - Options that were passed to htmlnano 58 | * @param moduleOptions — Module options. For most modules this is just "true" (indication that the module was enabled) 59 | * @return {object | Proimse} - Return the modified tree. 60 | */ 61 | export default function example(tree, options, moduleOptions) { 62 | // Module filename (example.es6), exported default function name (example), 63 | // and test filename (example.js) must be the same. 64 | 65 | // You can traverse the tree... 66 | tree.walk(node => { 67 | // ...and make some minification 68 | }); 69 | 70 | // At the end you must return the tree 71 | return tree; 72 | 73 | // Or a promise with the tree 74 | return somePromise.then(() => tree); 75 | } 76 | -------------------------------------------------------------------------------- /src/_modules/mergeScripts.mjs: -------------------------------------------------------------------------------- 1 | /* Merge multiple ', 21 | '', 22 | options 23 | ); 24 | }); 25 | 26 | // https://html.spec.whatwg.org/#a-quick-introduction-to-html 27 | // The value, along with the "=" character, can be omitted altogether if the value is the empty string. 28 | // it('should not collapse non boolean attribute', () => { 29 | // return init( 30 | // 'link', 31 | // 'link', 32 | // options 33 | // ); 34 | // }); 35 | 36 | it('should collapse AMP boolean attributes with empty value', () => { 37 | const optionsWithAmp = { 38 | collapseBooleanAttributes: ampSafePreset.collapseBooleanAttributes 39 | }; 40 | 41 | return init( 42 | '' 43 | + '' 44 | + '', 45 | 46 | '' 47 | + '' 48 | + '', 49 | 50 | optionsWithAmp 51 | ); 52 | }); 53 | 54 | it('should not collapse A-Frame visible attribute', () => { 55 | return init( 56 | '', 57 | '', 58 | options 59 | ); 60 | }); 61 | 62 | it('should collapse crossorigin=anonymous attribute', () => { 63 | return init( 64 | '', 65 | '', 66 | options 67 | ); 68 | }); 69 | 70 | it('should collapse crossorigin="" attribute', () => { 71 | return init( 72 | '', 73 | '', 74 | options 75 | ); 76 | }); 77 | 78 | it('should not collapse crossorigin="use-credentials" attribute', () => { 79 | return init( 80 | '', 81 | '', 82 | options 83 | ); 84 | }); 85 | 86 | it('should remove preload="auto" from