├── .github └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE ├── README.md ├── package.json ├── src └── index.js ├── test ├── __snapshots__ │ └── template.test.js.snap ├── fixtures │ ├── base.js │ └── styles.css ├── helpers │ ├── compiler.js │ └── config.js └── template.test.js └── yarn.lock /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | types: [opened, synchronize] 8 | 9 | jobs: 10 | test: 11 | name: Test on Node ${{ matrix.node }} 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | node: [10, 12, 14] 16 | 17 | steps: 18 | - name: Checkout repository 19 | uses: actions/checkout@v2 20 | 21 | - name: Setup Node ${{ matrix.node }} 22 | uses: actions/setup-node@v1 23 | with: 24 | node-version: ${{ matrix.node }} 25 | 26 | - name: Install dependencies 27 | run: yarn install --frozen-lockfile --check-files 28 | 29 | - name: Run test 30 | run: yarn test 31 | 32 | release: 33 | name: Release package 34 | runs-on: ubuntu-latest 35 | needs: test 36 | if: github.event_name != 'pull_request' 37 | 38 | steps: 39 | - name: Checkout repository 40 | uses: actions/checkout@v2 41 | with: 42 | fetch-depth: 0 43 | 44 | - name: Run semantic-release 45 | run: npx semantic-release 46 | env: 47 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 48 | NPM_TOKEN: ${{ secrets.NPM_TOKEN}} 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .vscode/ 3 | .DS_Store 4 | Thumbs.db 5 | *.log 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) vxna 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 | # @vxna/mini-html-webpack-template 2 | 3 | [![Build Status](https://github.com/vxna/mini-html-webpack-template/workflows/CI/badge.svg)](https://github.com/vxna/mini-html-webpack-template/actions?query=workflow%3ACI+branch%3Amaster) [![npm](https://img.shields.io/npm/v/@vxna/mini-html-webpack-template.svg)](https://www.npmjs.com/package/@vxna/mini-html-webpack-template) 4 | 5 | Template for [mini-html-webpack-plugin](https://github.com/bebraw/mini-html-webpack-plugin) that extends default features with useful subset of options 6 | 7 | ## Warning 8 | 9 | It does not work with [html-webpack-plugin](https://github.com/jantimon/html-webpack-plugin) 10 | 11 | ## Common usage 12 | 13 | **webpack.config.js** 14 | 15 | ```js 16 | const MiniHtmlWebpackPlugin = require('mini-html-webpack-plugin') 17 | 18 | module.exports = { 19 | plugins: [ 20 | new MiniHtmlWebpackPlugin({ 21 | context: { 22 | title: 'common-usage', 23 | favicon: 'https://assets-cdn.github.com/favicon.ico', 24 | container: 'root', 25 | trimWhitespace: true, 26 | }, 27 | template: require('@vxna/mini-html-webpack-template'), 28 | }), 29 | ], 30 | } 31 | ``` 32 | 33 | ## Common options 34 | 35 | | Name | Type | Default | Description | 36 | | -------------------- | ----------- | -------------- | ---------------------------------- | 37 | | **`lang`** | `{String}` | `undefined` | Set document language | 38 | | **`title`** | `{String}` | `'sample-app'` | Set document title | 39 | | **`favicon`** | `{String}` | `undefined` | Set document favicon | 40 | | **`container`** | `{String}` | `undefined` | Set application mount point | 41 | | **`trimWhitespace`** | `{Boolean}` | `undefined` | Safe document whitespace reduction | 42 | 43 | ## Extended usage 44 | 45 | **webpack.config.js** 46 | 47 | ```js 48 | const MiniHtmlWebpackPlugin = require('mini-html-webpack-plugin') 49 | 50 | module.exports = { 51 | plugins: [ 52 | new MiniHtmlWebpackPlugin({ 53 | context: { 54 | title: 'extended-usage', 55 | head: { 56 | meta: [ 57 | { 58 | name: 'description', 59 | content: 'mini-html-webpack-template', 60 | }, 61 | ], 62 | }, 63 | body: { 64 | raw: '
', 65 | }, 66 | attrs: { 67 | js: { 68 | async: '', 69 | type: 'text/javascript', 70 | }, 71 | }, 72 | }, 73 | template: require('@vxna/mini-html-webpack-template'), 74 | }), 75 | ], 76 | } 77 | ``` 78 | 79 | ## Extended options 80 | 81 | | Name | Type | Default | Description | 82 | | ------------------ | ----------------- | ----------- | ----------------------------------------- | 83 | | **`head.meta`** | `{Array}` | `undefined` | Array of objects with key + value pairs | 84 | | **`head.links`** | `{Array}` | `undefined` | Array of objects with key + value pairs | 85 | | **`head.scripts`** | `{Array}` | `undefined` | Array of objects with key + value pairs | 86 | | **`head.raw`** | `{Array\|String}` | `undefined` | Generates raw document markup | 87 | | **`body.scripts`** | `{Array}` | `undefined` | Array of objects with key + value pairs | 88 | | **`body.raw`** | `{Array\|String}` | `undefined` | Generates raw document markup | 89 | | **`attrs.js`** | `{Object}` | `undefined` | Applies html attributes to webpack output | 90 | | **`attrs.css`** | `{Object}` | `undefined` | Applies html attributes to webpack output | 91 | 92 | ## Advanced minification 93 | 94 | For custom needs [html-minifier](https://github.com/kangax/html-minifier) middleware and all of it's [options](https://github.com/kangax/html-minifier#options-quick-reference) could be used 95 | 96 | **webpack.config.js** 97 | 98 | ```js 99 | const { minify } = require('html-minifier') 100 | const MiniHtmlWebpackPlugin = require('mini-html-webpack-plugin') 101 | 102 | module.exports = { 103 | plugins: [ 104 | new MiniHtmlWebpackPlugin({ 105 | context: { 106 | title: 'advanced-minification', 107 | }, 108 | template: (ctx) => 109 | minify(require('@vxna/mini-html-webpack-template')(ctx), { 110 | collapseWhitespace: true, 111 | minifyCSS: true, 112 | minifyJS: true, 113 | }), 114 | }), 115 | ], 116 | } 117 | ``` 118 | 119 | ## Complex security features 120 | 121 | [SRI](https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity) is out of scope of this project and it's recommended to use [html-webpack-plugin](https://github.com/jantimon/html-webpack-plugin) with it's ecosystem tools. 122 | 123 | ## Inspired by 124 | 125 | [html-webpack-template](https://github.com/jaketrent/html-webpack-template) 126 | 127 | ## License 128 | 129 | MIT (http://www.opensource.org/licenses/mit-license.php) 130 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@vxna/mini-html-webpack-template", 3 | "version": "0.0.0-development", 4 | "description": "Minimum viable template for mini-html-webpack-plugin", 5 | "author": "vxna", 6 | "license": "MIT", 7 | "main": "src/index.js", 8 | "files": [ 9 | "src/**/*.js" 10 | ], 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/vxna/mini-html-webpack-template.git" 14 | }, 15 | "keywords": [ 16 | "webpack", 17 | "mini-html-webpack-plugin" 18 | ], 19 | "engines": { 20 | "node": ">= 10" 21 | }, 22 | "scripts": { 23 | "pretest": "yarn lint", 24 | "lint": "eslint src/**/*.js --format codeframe --fix", 25 | "test": "jest", 26 | "format": "prettier src/**/*.{js,md} --write", 27 | "posttest": "yarn format" 28 | }, 29 | "dependencies": { 30 | "common-tags": "^1.8.0" 31 | }, 32 | "devDependencies": { 33 | "css-loader": "^4.2.2", 34 | "eslint": "^7.7.0", 35 | "eslint-config-prettier": "^6.11.0", 36 | "eslint-config-standard": "^14.1.1", 37 | "eslint-plugin-import": "^2.22.0", 38 | "eslint-plugin-node": "^11.1.0", 39 | "eslint-plugin-promise": "^4.2.1", 40 | "eslint-plugin-standard": "^4.0.1", 41 | "husky": "^4.2.5", 42 | "jest": "^26.4.2", 43 | "lint-staged": "^10.2.13", 44 | "memfs": "^3.2.0", 45 | "mini-css-extract-plugin": "^0.10.0", 46 | "mini-html-webpack-plugin": "^3.0.7", 47 | "prettier": "^2.1.1", 48 | "webpack": "^4.44.1" 49 | }, 50 | "eslintConfig": { 51 | "extends": [ 52 | "standard", 53 | "prettier" 54 | ], 55 | "env": { 56 | "jest": true 57 | } 58 | }, 59 | "jest": { 60 | "testEnvironment": "node" 61 | }, 62 | "prettier": { 63 | "singleQuote": true, 64 | "semi": false 65 | }, 66 | "husky": { 67 | "hooks": { 68 | "pre-commit": "lint-staged" 69 | } 70 | }, 71 | "lint-staged": { 72 | "*.js": [ 73 | "eslint --fix" 74 | ], 75 | "*.{js,md}": [ 76 | "prettier --write" 77 | ] 78 | }, 79 | "publishConfig": { 80 | "access": "public" 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const { 2 | html, 3 | oneLineTrim, 4 | TemplateTag, 5 | trimResultTransformer, 6 | replaceResultTransformer, 7 | } = require('common-tags') 8 | 9 | function template(ctx) { 10 | const { 11 | publicPath, 12 | css, 13 | js, 14 | lang, 15 | title, 16 | favicon, 17 | container, 18 | head = {}, 19 | body = {}, 20 | attrs = {}, 21 | trimWhitespace, 22 | } = ctx 23 | 24 | const doc = html` 25 | 26 | ${lang ? `` : ''} 27 | 28 | 29 | 30 | 31 | ${title || 'sample-app'} 32 | ${favicon && ``} 33 | ${generateTags(head.meta, 'meta')} 34 | ${generateTags(head.links, 'link')} 35 | ${generateTags(head.style, 'style')} 36 | ${generateTags(head.scripts, 'script')} 37 | ${generateRawTags(head.raw)} 38 | ${webpackArtifacts(css, publicPath, attrs.css, 'link')} 39 | 40 | 41 | ${container && `
`} 42 | ${generateRawTags(body.raw)} 43 | ${generateTags(body.scripts, 'script')} 44 | ${webpackArtifacts(js, publicPath, attrs.js, 'script')} 45 | 46 | ` 47 | 48 | return trimWhitespace ? oneLineTrim(doc) : emptyLineTrim(doc) 49 | } 50 | 51 | function mapAttrs(tag) { 52 | return Object.keys(tag) 53 | .map((attr) => `${attr}="${tag[attr]}"`) 54 | .join(' ') 55 | } 56 | 57 | function generateTags(tags = [], type) { 58 | const closing = type === ('script' || 'style') ? `>` : '>' 59 | return tags.map((tag) => `<${type} ${mapAttrs(tag)}${closing}`) 60 | } 61 | 62 | function generateRawTags(tags = []) { 63 | if (typeof tags === 'string' || tags instanceof String) { 64 | return tags 65 | } 66 | return tags.map((tag) => tag) 67 | } 68 | 69 | function webpackArtifacts(files = [], publicPath = '', attrs = {}, type) { 70 | const tag = (file) => 71 | type === 'script' 72 | ? Object.assign(attrs, { src: publicPath + file }) 73 | : Object.assign(attrs, { rel: 'stylesheet', href: publicPath + file }) 74 | 75 | return files.map((file) => generateTags([tag(file)], type)).join('\n') 76 | } 77 | 78 | const emptyLineTrim = new TemplateTag( 79 | replaceResultTransformer(/^\s*[\r\n]/gm, ''), 80 | trimResultTransformer 81 | ) 82 | 83 | module.exports = template 84 | -------------------------------------------------------------------------------- /test/__snapshots__/template.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`options: advanced 1`] = ` 4 | " 5 | 6 | 7 | 8 | 9 | 10 | advanced options 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | " 24 | `; 25 | 26 | exports[`options: common 1`] = ` 27 | " 28 | 29 | 30 | 31 | 32 | 33 | common options 34 | 35 | 36 | 37 | 38 |
39 | 40 | 41 | " 42 | `; 43 | 44 | exports[`options: output attrs 1`] = ` 45 | " 46 | 47 | 48 | 49 | 50 | 51 | output attrs 52 | 53 | 54 | 55 | 56 |
57 | 58 | 59 | " 60 | `; 61 | 62 | exports[`options: trim whitespace 1`] = `"trim whitespace
"`; 63 | -------------------------------------------------------------------------------- /test/fixtures/base.js: -------------------------------------------------------------------------------- 1 | import './styles.css' 2 | -------------------------------------------------------------------------------- /test/fixtures/styles.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | } 4 | -------------------------------------------------------------------------------- /test/helpers/compiler.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | const { createFsFromVolume, Volume } = require('memfs') 4 | const webpack = require('webpack') 5 | 6 | module.exports = (fixture, config) => { 7 | config = { 8 | mode: config.mode || 'development', 9 | optimization: config.optimization || {}, 10 | context: config.context || path.resolve(__dirname, '../fixtures'), 11 | entry: config.entry || path.resolve(__dirname, '../fixtures', fixture), 12 | output: { 13 | path: path.resolve(__dirname, '../output'), 14 | filename: '[name].js', 15 | chunkFilename: '[name].chunk.js', 16 | }, 17 | ...config, 18 | } 19 | 20 | const compiler = webpack(config) 21 | 22 | if (!config.outputFileSystem) { 23 | const outputFileSystem = createFsFromVolume(new Volume()) 24 | outputFileSystem.join = path.join.bind(path) 25 | 26 | compiler.outputFileSystem = outputFileSystem 27 | } 28 | 29 | return new Promise((resolve, reject) => 30 | compiler.run((err, stats) => { 31 | if (err) { 32 | reject(err) 33 | } 34 | 35 | resolve(stats) 36 | }) 37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /test/helpers/config.js: -------------------------------------------------------------------------------- 1 | const { MiniHtmlWebpackPlugin } = require('mini-html-webpack-plugin') 2 | const MiniCssExtractPlugin = require('mini-css-extract-plugin') 3 | 4 | const getConfig = ({ options }, config = {}) => { 5 | const defaults = { 6 | module: { 7 | rules: [ 8 | { 9 | test: /\.css$/, 10 | use: [MiniCssExtractPlugin.loader, 'css-loader'], 11 | }, 12 | ], 13 | }, 14 | plugins: [ 15 | new MiniHtmlWebpackPlugin({ 16 | context: options, 17 | template: require('../../src'), 18 | }), 19 | new MiniCssExtractPlugin(), 20 | ], 21 | } 22 | 23 | return { ...defaults, ...config } 24 | } 25 | 26 | module.exports = getConfig 27 | -------------------------------------------------------------------------------- /test/template.test.js: -------------------------------------------------------------------------------- 1 | const compiler = require('./helpers/compiler') 2 | const getConfig = require('./helpers/config') 3 | 4 | test('options: common', async () => { 5 | const config = getConfig({ 6 | options: { 7 | title: 'common options', 8 | favicon: '/favicon.ico', 9 | container: 'root', 10 | }, 11 | }) 12 | 13 | const stats = await compiler('base.js', config) 14 | const asset = stats.compilation.assets['index.html']._value 15 | 16 | expect(asset).toMatchSnapshot() 17 | }) 18 | 19 | test('options: advanced', async () => { 20 | const config = getConfig({ 21 | options: { 22 | lang: 'en', 23 | title: 'advanced options', 24 | head: { 25 | meta: [ 26 | { 27 | name: 'description', 28 | content: 'mini-html-webpack-template', 29 | }, 30 | { 31 | property: 'og:description', 32 | content: 'mini-html-webpack-template', 33 | }, 34 | ], 35 | links: [ 36 | { 37 | rel: 'icon', 38 | type: 'image/x-icon', 39 | href: '/favicon.ico', 40 | }, 41 | ], 42 | scripts: [ 43 | { 44 | defer: '', 45 | type: 'text/javascript', 46 | src: 'https://unpkg.com/random', 47 | }, 48 | ], 49 | raw: '', 50 | }, 51 | body: { 52 | raw: [ 53 | '', 54 | '', 55 | ], 56 | }, 57 | }, 58 | }) 59 | 60 | const stats = await compiler('base.js', config) 61 | const asset = stats.compilation.assets['index.html']._value 62 | 63 | expect(asset).toMatchSnapshot() 64 | }) 65 | 66 | test('options: output attrs', async () => { 67 | const config = getConfig({ 68 | options: { 69 | title: 'output attrs', 70 | favicon: '/favicon.ico', 71 | container: 'root', 72 | attrs: { 73 | js: { async: '', type: 'text/javascript' }, 74 | css: { type: 'text/css' }, 75 | }, 76 | }, 77 | }) 78 | 79 | const stats = await compiler('base.js', config) 80 | const asset = stats.compilation.assets['index.html']._value 81 | 82 | expect(asset).toMatchSnapshot() 83 | }) 84 | 85 | test('options: trim whitespace', async () => { 86 | const config = getConfig({ 87 | options: { 88 | title: 'trim whitespace', 89 | favicon: '/favicon.ico', 90 | container: 'root', 91 | trimWhitespace: true, 92 | }, 93 | }) 94 | 95 | const stats = await compiler('base.js', config) 96 | const asset = stats.compilation.assets['index.html']._value 97 | 98 | expect(asset).toMatchSnapshot() 99 | }) 100 | --------------------------------------------------------------------------------