├── test ├── fixtures │ ├── custom-icon.js │ ├── entry-utf32.js │ ├── later.css │ ├── index.html │ ├── custom-icon │ │ ├── custom-icon.eot │ │ ├── custom-icon.ttf │ │ ├── custom-icon.woff │ │ └── custom-icon.svg │ ├── entry.js │ ├── webpack.unicode.config.js │ ├── webpack.custom-icon.config.js │ ├── webpack.config.js │ ├── webpack.extract-text.config.js │ ├── entry.css │ └── custom-icon.css └── index.test.js ├── .gitignore ├── .github └── workflows │ └── ci.yml ├── LICENSE ├── package.json ├── README.md └── lib └── index.js /test/fixtures/custom-icon.js: -------------------------------------------------------------------------------- 1 | require('./custom-icon.css') 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | coverage/ 3 | dist/ 4 | npm-debug.log 5 | -------------------------------------------------------------------------------- /test/fixtures/entry-utf32.js: -------------------------------------------------------------------------------- 1 | require('@mdi/font/css/materialdesignicons.min.css') 2 | -------------------------------------------------------------------------------- /test/fixtures/later.css: -------------------------------------------------------------------------------- 1 | /* fa-film glyph */ 2 | .my-later-file { 3 | content: '\f008'; 4 | } 5 | -------------------------------------------------------------------------------- /test/fixtures/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /test/fixtures/custom-icon/custom-icon.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/patrickhulce/fontmin-webpack/HEAD/test/fixtures/custom-icon/custom-icon.eot -------------------------------------------------------------------------------- /test/fixtures/custom-icon/custom-icon.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/patrickhulce/fontmin-webpack/HEAD/test/fixtures/custom-icon/custom-icon.ttf -------------------------------------------------------------------------------- /test/fixtures/custom-icon/custom-icon.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/patrickhulce/fontmin-webpack/HEAD/test/fixtures/custom-icon/custom-icon.woff -------------------------------------------------------------------------------- /test/fixtures/entry.js: -------------------------------------------------------------------------------- 1 | require('./entry.css') 2 | 3 | function later() { 4 | require.ensure(['./later.css'], () => console.log('loaded!')) 5 | } 6 | 7 | console.log(classes) 8 | setTimeout(later, 1000) 9 | -------------------------------------------------------------------------------- /test/fixtures/webpack.unicode.config.js: -------------------------------------------------------------------------------- 1 | const FontminPlugin = require('../../lib') 2 | 3 | module.exports = { 4 | entry: `${__dirname}/entry-utf32.js`, 5 | output: {filename: 'out.js', path: `${__dirname}/dist`, publicPath: '/test/fixtures/dist/'}, 6 | module: { 7 | rules: [ 8 | {test: /\.(woff|woff2)(\?v=.+)?$/, use: ['file-loader']}, 9 | {test: /\.(svg|ttf|eot|png)(\?v=.+)?$/, use: ['file-loader']}, 10 | {test: /\.css$/, use: ['style-loader', 'css-loader']}, 11 | ], 12 | }, 13 | plugins: [new FontminPlugin()], 14 | } 15 | -------------------------------------------------------------------------------- /test/fixtures/webpack.custom-icon.config.js: -------------------------------------------------------------------------------- 1 | const FontminPlugin = require('../../lib') 2 | 3 | module.exports = { 4 | entry: `${__dirname}/custom-icon.js`, 5 | output: {filename: 'out.js', path: `${__dirname}/dist`, publicPath: '/test/fixtures/dist/'}, 6 | module: { 7 | rules: [ 8 | {test: /\.(woff|woff2)(\?v=.+)?$/, use: ['file-loader']}, 9 | {test: /\.(svg|ttf|eot|png)(\?v=.+)?$/, use: ['file-loader']}, 10 | {test: /\.css$/, use: ['style-loader', 'css-loader'], include: __dirname}, 11 | ], 12 | }, 13 | plugins: [new FontminPlugin()], 14 | } 15 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: [master] 5 | pull_request: # run on all PRs, not just PRs to a particular branch 6 | jobs: 7 | ci: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: git clone 11 | uses: actions/checkout@v2 12 | with: 13 | fetch-depth: 100 14 | - name: Use Node.js 12.x 15 | uses: actions/setup-node@v1 16 | with: 17 | node-version: 12.x 18 | - run: yarn install --frozen-lockfile --network-timeout 1000000 19 | - run: npm run start 20 | - run: npm run test:lint 21 | - run: npm run test:unit 22 | -------------------------------------------------------------------------------- /test/fixtures/webpack.config.js: -------------------------------------------------------------------------------- 1 | const FontminPlugin = require('../../lib') 2 | 3 | module.exports = { 4 | entry: `${__dirname}/entry.js`, 5 | output: {filename: 'out.js', path: `${__dirname}/dist`, publicPath: '/test/fixtures/dist/'}, 6 | module: { 7 | rules: [ 8 | {test: /\.(woff|woff2)(\?v=.+)?$/, use: { 9 | loader: 'file-loader', 10 | options: { 11 | name: '[name].[ext]?[contenthash]', 12 | }, 13 | }}, 14 | {test: /\.(svg|ttf|eot|png)(\?v=.+)?$/, use: ['file-loader']}, 15 | {test: /\.css$/, use: ['style-loader', 'css-loader'], include: __dirname}, 16 | ], 17 | }, 18 | plugins: [new FontminPlugin()], 19 | } 20 | -------------------------------------------------------------------------------- /test/fixtures/webpack.extract-text.config.js: -------------------------------------------------------------------------------- 1 | const FontminPlugin = require('../../lib') 2 | const HtmlWebpackPlugin = require('html-webpack-plugin') 3 | const MiniCssExtractPlugin = require('mini-css-extract-plugin') 4 | 5 | module.exports = { 6 | entry: `${__dirname}/entry.js`, 7 | output: {filename: 'out.js', path: `${__dirname}/dist`, publicPath: '/test/fixtures/dist/'}, 8 | module: { 9 | rules: [ 10 | {test: /\.(woff|woff2)(\?v=.+)?$/, use: ['file-loader']}, 11 | {test: /\.(svg|ttf|eot|png)(\?v=.+)?$/, use: ['file-loader']}, 12 | {test: /\.css$/, use: [MiniCssExtractPlugin.loader, 'css-loader']}, 13 | ], 14 | }, 15 | plugins: [ 16 | new MiniCssExtractPlugin({ 17 | filename: 'out.css' 18 | }), 19 | new HtmlWebpackPlugin(), 20 | new FontminPlugin(), 21 | ], 22 | } 23 | -------------------------------------------------------------------------------- /test/fixtures/entry.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'FontAwesome'; 3 | src: url('~font-awesome/fonts/fontawesome-webfont.eot?v=4.7.0'); 4 | src: url('~font-awesome/fonts/fontawesome-webfont.eot?#iefix&v=4.7.0') format('embedded-opentype'), 5 | url('~font-awesome/fonts/fontawesome-webfont.woff2?v=4.7.0') format('woff2'), 6 | url('~font-awesome/fonts/fontawesome-webfont.woff?v=4.7.0') format('woff'), 7 | url('~font-awesome/fonts/fontawesome-webfont.ttf?v=4.7.0') format('truetype'), 8 | url('~font-awesome/fonts/fontawesome-webfont.svg?v=4.7.0#fontawesomeregular') format('svg'); 9 | font-weight: normal; 10 | font-style: normal; 11 | } 12 | 13 | .fa-th-large { 14 | content: '\f009'; 15 | } 16 | 17 | .fa-ok { 18 | content: '\f00c'; 19 | } 20 | 21 | .fa-remove { 22 | content: '\f00d'; 23 | } 24 | 25 | .fa-table { 26 | content: '\f0ce'; 27 | } 28 | 29 | .fa-address-book { 30 | content: '\f0ce'; 31 | } 32 | 33 | .custom-content { 34 | content: '\f0'; /* ð */ 35 | } 36 | 37 | .entry { 38 | background: white; 39 | } 40 | -------------------------------------------------------------------------------- /test/fixtures/custom-icon.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'custom-icon'; 3 | src: url('./custom-icon/custom-icon.eot'); 4 | src: url('./custom-icon/custom-icon.eot#iefix') format('embedded-opentype'), 5 | url('./custom-icon/custom-icon.ttf') format('truetype'), 6 | url('./custom-icon/custom-icon.woff') format('woff'), 7 | url('./custom-icon/custom-icon.svg#custom-icon') format('svg'); 8 | font-weight: normal; 9 | font-style: normal; 10 | font-display: block; 11 | } 12 | 13 | [class^="custom-icon-"], [class*=" custom-icon-"] { 14 | /* use !important to prevent issues with browser extensions that change fonts */ 15 | font-family: 'custom-icon' !important; 16 | speak: never; 17 | font-style: normal; 18 | font-weight: normal; 19 | font-variant: normal; 20 | text-transform: none; 21 | line-height: 1; 22 | 23 | /* Better Font Rendering =========== */ 24 | -webkit-font-smoothing: antialiased; 25 | -moz-osx-font-smoothing: grayscale; 26 | } 27 | 28 | .custom-icon-coffee:before { 29 | content: "\e900"; 30 | } 31 | 32 | .custom-icon-plus:before { 33 | content: "\2b"; 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Patrick Hulce (https://patrickhulce.com/) 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fontmin-webpack", 3 | "version": "0.0.0", 4 | "description": "Minifies icon fonts to just what is used.", 5 | "main": "./lib/index.js", 6 | "scripts": { 7 | "start": "webpack --config test/fixtures/webpack.config.js", 8 | "start:extract-text": "webpack --config test/fixtures/webpack.extract-text.config.js", 9 | "start:debug": "node --inspect --debug-brk ./node_modules/.bin/webpack --config test/fixtures/webpack.config.js", 10 | "test": "npm run test:lint && npm run test:unit", 11 | "test:lint": "lint node", 12 | "test:unit": "mocha --reporter spec test/*.test.js test/**/*.test.js", 13 | "test:watch": "mocha --watch --reporter dot test/*.test.js test/**/*.test.js" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "https://github.com/patrickhulce/fontmin-webpack.git" 18 | }, 19 | "author": "Patrick Hulce ", 20 | "license": "MIT", 21 | "bugs": { 22 | "url": "https://github.com/patrickhulce/fontmin-webpack/issues" 23 | }, 24 | "homepage": "https://github.com/patrickhulce/fontmin-webpack#readme", 25 | "config": { 26 | "commitizen": { 27 | "path": "./node_modules/cz-conventional-changelog" 28 | } 29 | }, 30 | "dependencies": { 31 | "debug": "^4.3.1", 32 | "fontmin": "^0.9.9", 33 | "lodash": "^4.17.21", 34 | "webpack-sources": "^2.2.0" 35 | }, 36 | "peerDependencies": { 37 | "webpack": "5.x" 38 | }, 39 | "devDependencies": { 40 | "@mdi/font": "^5.9.55", 41 | "@patrickhulce/lint": "^2.1.3", 42 | "chai": "^4.3.0", 43 | "css-loader": "^5.0.2", 44 | "cz-conventional-changelog": "^3.3.0", 45 | "file-loader": "^6.2.0", 46 | "font-awesome": "^4.7.0", 47 | "html-webpack-plugin": "^5.2.0", 48 | "mini-css-extract-plugin": "^1.3.8", 49 | "mocha": "^8.3.0", 50 | "rimraf": "^3.0.2", 51 | "style-loader": "^2.0.0", 52 | "webpack": "^5.24.1", 53 | "webpack-cli": "^4.5.0" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /test/fixtures/custom-icon/custom-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Generated by IcoMoon 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fontmin-webpack 2 | 3 | [![NPM Package](https://badge.fury.io/js/fontmin-webpack.svg)](https://www.npmjs.com/package/fontmin-webpack) 4 | [![Build Status](https://travis-ci.org/patrickhulce/fontmin-webpack.svg?branch=master)](https://travis-ci.org/patrickhulce/fontmin-webpack) 5 | [![Coverage Status](https://coveralls.io/repos/github/patrickhulce/fontmin-webpack/badge.svg?branch=master)](https://coveralls.io/github/patrickhulce/fontmin-webpack?branch=master) 6 | [![Commitizen friendly](https://img.shields.io/badge/commitizen-friendly-brightgreen.svg)](http://commitizen.github.io/cz-cli/) 7 | [![Dependencies](https://david-dm.org/patrickhulce/fontmin-webpack.svg)](https://david-dm.org/patrickhulce/fontmin-webpack) 8 | 9 | Minifies icon fonts to just what is used. 10 | 11 | ```bash 12 | # for webpack 5 13 | npm install --save-dev fontmin-webpack 14 | # for webpack 4 15 | npm install --save-dev fontmin-webpack@^2.0.1 16 | # for webpack <=3 17 | npm install --save-dev fontmin-webpack@^1.0.2 18 | ``` 19 | 20 | ## How It Works 21 | 22 | - Examines your webpack output assets to identify font formats that have the same name 23 | - Identifies CSS rules that specify a content property and extracts unicode characters 24 | - Uses `fontmin` to minify the TrueType font to only the used glyphs 25 | - Converts the ttf back to all other formats (supports `ttf`, `eot`, `svg`, `woff`, and `woff2` although you should really only need to care about [woff](http://caniuse.com/#search=woff)) 26 | - Replaces the webpack output asset with the minified version 27 | 28 | ## Usage 29 | 30 | #### Install fontmin-webpack 31 | 32 | `npm install --save-dev fontmin-webpack` 33 | 34 | #### Include Your Icon Font 35 | 36 | The example below uses glyphs `\uf0c7` and `\uf0ce` 37 | 38 | ```css 39 | @font-face { 40 | font-family: 'FontAwesome'; 41 | src: url('fontawesome-webfont.eot') format('embedded-opentype'), url('fontawesome-webfont.woff2') 42 | format('woff2'), url('fontawesome-webfont.woff') format('woff'), url('fontawesome-webfont.ttf') 43 | format('ttf'); 44 | } 45 | 46 | /** 47 | * Remove other unused icons from the file. 48 | */ 49 | .fa-save:before, 50 | .fa-floppy-o:before { 51 | content: '\f0c7'; 52 | } 53 | .fa-table:before { 54 | content: '\f0ce'; 55 | } 56 | ``` 57 | 58 | #### Setup Your Webpack Configuration 59 | 60 | ```js 61 | const FontminPlugin = require('fontmin-webpack') 62 | 63 | module.exports = { 64 | entry: 'my-entry.js', 65 | output: { 66 | // ... 67 | }, 68 | plugins: [ 69 | // ... 70 | new FontminPlugin({ 71 | autodetect: true, // automatically pull unicode characters from CSS 72 | glyphs: ['\uf0c8' /* extra glyphs to include */], 73 | // note: these settings are mutually exclusive and allowedFilesRegex has priority over skippedFilesRegex 74 | allowedFilesRegex: null, // RegExp to only target specific fonts by their names 75 | skippedFilesRegex: null, // RegExp to skip specific fonts by their names 76 | textRegex: /\.(js|css|html)$/, // RegExp for searching text reference 77 | webpackCompilationHook: 'thisCompilation', // Webpack compilation hook (for example PurgeCss webpack plugin use 'compilation' ) 78 | }), 79 | ], 80 | } 81 | ``` 82 | 83 | #### Use in Vue3 84 | 85 | In a Vue3 project PurgeCss needs to be executed as a Webpack plugin. 86 | The easiest way to add Webpack plugins is to declare them in vue.config.js 87 | 88 | This is a sample for a project with Vue3 / Tailwindcss 3 / Fontawesome 6 89 | #### **`vue.config.js`** 90 | ```js 91 | const webpackPlugins = []; 92 | const __DEBUG__='0'; //turn to 1 for avoiding purgecss and fontmin 93 | 94 | // ********************************** 95 | // Purgecss unused classes 96 | // 97 | if (__DEBUG__ !== '1') { 98 | const PurgecssPlugin = require('purgecss-webpack-plugin'); 99 | const glob = require('glob-all') 100 | const purgeCssPlugin = new PurgecssPlugin({ 101 | paths: glob.sync( 102 | [ 103 | path.join(__dirname, './public/*.html'), 104 | path.join(__dirname, './src/**/*.vue'), 105 | path.join(__dirname, './src/**/*.js') 106 | ]), 107 | safelist: [/^sm:/, /^md:/, /^lg:/, /^xl:/, /^2xl:/, /^focus:/, /^hover:/, /^group-hover:/, /\[.*\]/, /^basicLightbox/, /\/[0-9]/, /^tns/], 108 | fontFace: true 109 | }) 110 | webpackPlugins.push(purgeCssPlugin); 111 | } 112 | 113 | // ********************************** 114 | // fontminifying Fontawesome 115 | // 116 | if (__DEBUG__ !== '1') { 117 | const FontMinPlugin = require('fontmin-webpack'); 118 | const fontMinPlugin = new FontMinPlugin({ 119 | autodetect: true, 120 | glyphs: [], 121 | allowedFilesRegex: /^fa[srltdb]*-/, // RegExp to only target specific fonts by their names 122 | skippedFilesRegex: null, // RegExp to skip specific fonts by their names 123 | textRegex: /\.(js|css|html|vue)$/, // RegExp for searching text reference 124 | webpackCompilationHook: 'compilation', // Webpack compilation hook (for example PurgeCss webpack plugin use 'compilation' ) 125 | }); 126 | webpackPlugins.push(fontMinPlugin); 127 | } 128 | 129 | module.exports = { 130 | runtimeCompiler: true, 131 | configureWebpack: { 132 | plugins: webpackPlugins, 133 | devtool: false, 134 | mode: 'production', 135 | }, 136 | }; 137 | ``` 138 | 139 | Obviously the required dependencies must be added in package.json 140 | #### **`package.json`** 141 | ```json 142 | "devDependencies": { 143 | … 144 | "fontmin-webpack": "^4.0.0", 145 | "glob-all": "^3.3.0", 146 | "purgecss-webpack-plugin": "^4.1.3", 147 | "webpack": "^5.71.0", 148 | … 149 | } 150 | ``` 151 | 152 | #### Save Bytes 153 | 154 | **Before** 155 | 156 | ``` 157 | 674f50d287a8c48dc19ba404d20fe713.eot 166 kB [emitted] 158 | 912ec66d7572ff821749319396470bde.svg 444 kB [emitted] [big] 159 | b06871f281fee6b241d60582ae9369b9.ttf 166 kB [emitted] 160 | af7ae505a9eed503f8b8e6982036873e.woff2 77.2 kB [emitted] 161 | fee66e712a8a08eef5805a46892932ad.woff 98 kB [emitted] 162 | ``` 163 | 164 | **After** 165 | 166 | ``` 167 | 674f50d287a8c48dc19ba404d20fe713.eot 2.82 kB [emitted] 168 | 912ec66d7572ff821749319396470bde.svg 2.88 kB [emitted] 169 | b06871f281fee6b241d60582ae9369b9.ttf 2.64 kB [emitted] 170 | af7ae505a9eed503f8b8e6982036873e.woff2 1.01 kB [emitted] 171 | fee66e712a8a08eef5805a46892932ad.woff 2.72 kB [emitted] 172 | ``` 173 | 174 | ## Limitations 175 | 176 | - Fonts must be loaded with `file-loader` 177 | - Fonts must have the same name as the TrueType version of the font. 178 | - Font file names are not changed by different used glyph sets ([See #8](https://github.com/patrickhulce/fontmin-webpack/issues/8)) 179 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | const crypto = require('crypto') 4 | const log = require('debug')('fontmin-webpack') 5 | const _ = require('lodash') 6 | const Fontmin = require('fontmin') 7 | const RawSource = require('webpack-sources').RawSource 8 | 9 | const FONT_REGEX = /\.(eot|ttf|svg|woff|woff2)(\?.+)?$/ 10 | const TEXT_REGEX = /\.(js|css|html)$/ 11 | const GLYPH_REGEX = /content\s*:[^};]*?('|")(.*?)\s*('|"|;)/g 12 | const UNICODE_REGEX = /\\([0-9a-f]{2,6})/i 13 | const FONTMIN_EXTENSIONS = ['eot', 'woff', 'woff2', 'svg'] 14 | const WEBPACK_COMPILATION = 'thisCompilation' 15 | 16 | function getSurrogatePair(astralCodePoint) { 17 | const highSurrogate = Math.floor((astralCodePoint - 0x10000) / 0x400) + 0xD800 18 | const lowSurrogate = ((astralCodePoint - 0x10000) % 0x400) + 0xDC00 19 | return [highSurrogate, lowSurrogate] 20 | } 21 | 22 | function getFilenameWithoutQueryString(filename) { 23 | return filename.split('?', 2)[0] 24 | } 25 | 26 | class FontminPlugin { 27 | constructor(options) { 28 | this._options = _.assign( 29 | { 30 | glyphs: [], 31 | autodetect: true, 32 | allowedFilesRegex: null, 33 | skippedFilesRegex: null, 34 | appendHash: false, 35 | textRegex: TEXT_REGEX, 36 | webpackCompilationHook: WEBPACK_COMPILATION, 37 | }, 38 | options, 39 | ) 40 | } 41 | 42 | computeFinalGlyphs(foundGlyphs) { 43 | let glyphs = _.clone(this._options.glyphs) 44 | if (this._options.autodetect) { 45 | glyphs = glyphs.concat(foundGlyphs) 46 | } 47 | 48 | return glyphs 49 | } 50 | 51 | hasFontAsset(assets) { 52 | return _.find(assets, (val, key) => FONT_REGEX.test(key)) 53 | } 54 | 55 | findFontFiles(compilation) { 56 | const regular = this.findRegularFontFiles(compilation) 57 | const extract = this.findExtractTextFontFiles(compilation) 58 | 59 | // Prefer the ExtractTextPlugin version of any assets we find 60 | const regularWithExtractRemoved = _.differenceBy(regular, extract, 'asset') 61 | return _.uniqBy(regularWithExtractRemoved.concat(extract), 'asset') 62 | } 63 | 64 | findRegularFontFiles(compilation) { 65 | return _(Array.from(compilation.modules)) 66 | .filter(module => this.hasFontAsset(module.buildInfo.assets)) 67 | .map(module => { 68 | const filename = Array.from(module.buildInfo.assetsInfo.values())[0].sourceFilename 69 | const font = path.basename(filename, path.extname(filename)) 70 | return _.keys(module.buildInfo.assets).map(asset => { 71 | const buffer = module.buildInfo.assets[asset].source() 72 | const extension = path.extname(getFilenameWithoutQueryString(asset)) 73 | return {asset, extension, font, buffer} 74 | }) 75 | }) 76 | .flatten() 77 | .value() 78 | } 79 | 80 | findExtractTextFontFiles(compilation) { 81 | const fileDependencies = _(Array.from(compilation.modules)) 82 | .map(module => 83 | Array.from((module.buildInfo.assetsInfo && module.buildInfo.assetsInfo.values()) || []).map( 84 | file => file.sourceFilename, 85 | ), 86 | ) 87 | .flatten() 88 | .filter(filename => FONT_REGEX.test(filename)) 89 | .map(getFilenameWithoutQueryString) 90 | .map(filename => { 91 | return { 92 | filename, 93 | stats: fs.statSync(filename), 94 | } 95 | }) 96 | .value() 97 | 98 | return _(compilation.assets) 99 | .keys() 100 | .filter(name => FONT_REGEX.test(name)) 101 | .map(asset => { 102 | const assetFilename = getFilenameWithoutQueryString(asset) 103 | const buffer = compilation.assets[asset].source() 104 | const extension = path.extname(assetFilename) 105 | const dependency = fileDependencies.find(dependency => { 106 | return ( 107 | path.extname(dependency.filename) === extension && 108 | buffer.length === dependency.stats.size 109 | ) 110 | }) 111 | const filename = (dependency && dependency.filename) || assetFilename 112 | const font = path.basename(filename, extension) 113 | return {asset, extension, font, buffer} 114 | }) 115 | .value() 116 | } 117 | 118 | findUnicodeGlyphs(compilation) { 119 | return _(compilation.assets) 120 | .map((asset, name) => ({asset, name})) 121 | .filter(item => this._options.textRegex.test(item.name)) 122 | .map(item => { 123 | const content = item.asset.source() 124 | const matches = content.match(GLYPH_REGEX) || [] 125 | return matches 126 | .map(match => { 127 | const unicodeMatch = match.match(UNICODE_REGEX) 128 | if (!unicodeMatch) { 129 | return false 130 | } 131 | const unicodeHex = unicodeMatch[1] 132 | const numericValue = parseInt(unicodeHex, 16) 133 | const character = String.fromCharCode(numericValue) 134 | if (unicodeHex.length === 2 || unicodeHex.length === 4) { 135 | return character 136 | } 137 | const multiCharacter = getSurrogatePair(numericValue) 138 | .map(v => String.fromCharCode(v)) 139 | .join('') 140 | return multiCharacter 141 | }) 142 | .filter(Boolean) 143 | }) 144 | .flatten() 145 | .value() 146 | } 147 | 148 | setupFontmin(extensions, usedGlyphs = []) { 149 | usedGlyphs = _.isArray(usedGlyphs) ? usedGlyphs : [usedGlyphs] 150 | let fontmin = new Fontmin().use(Fontmin.glyph({text: usedGlyphs.join(' ')})) 151 | FONTMIN_EXTENSIONS.forEach(ext => { 152 | if (extensions.includes('.' + ext)) { 153 | fontmin = fontmin.use(Fontmin[`ttf2${ext}`]()) 154 | } 155 | }) 156 | 157 | return fontmin 158 | } 159 | 160 | mergeAssetsAndFiles(group, files) { 161 | const byExtension = _.keyBy(files, 'extname') 162 | return group 163 | .map(item => { 164 | const extension = item.extension 165 | const buffer = byExtension[extension] && byExtension[extension].contents 166 | if (!buffer) { 167 | return undefined 168 | } 169 | 170 | const minified = buffer 171 | return _.assign(item, {minified}) 172 | }) 173 | .filter(Boolean) 174 | } 175 | 176 | minifyFontGroup(group, usedGlyphs = []) { 177 | log('analyzing font group:', _.get(group, '0.font')) 178 | const ttfInfo = _.find(group, {extension: '.ttf'}) 179 | if (!ttfInfo) { 180 | log('font group has no TTF file, skipping...') 181 | return Promise.resolve([]) 182 | } 183 | 184 | const extensions = _.map(group, 'extension') 185 | const fontmin = this.setupFontmin(extensions, usedGlyphs) 186 | return new Promise((resolve, reject) => { 187 | fontmin.src(ttfInfo.buffer).run((err, files) => { 188 | if (err) { 189 | reject(err) 190 | } else { 191 | resolve(this.mergeAssetsAndFiles(group, files)) 192 | } 193 | }) 194 | }) 195 | } 196 | 197 | onAdditionalAssets(compilation) { 198 | const allowedFiles = this._options.allowedFilesRegex 199 | const skippedFiles = this._options.skippedFilesRegex 200 | const appendHash = this._options.appendHash 201 | const fontFiles = this.findFontFiles(compilation) 202 | const glyphsInCss = this.findUnicodeGlyphs(compilation) 203 | log(`found ${glyphsInCss.length} glyphs in CSS`) 204 | const minifiableFonts = _(fontFiles) 205 | .groupBy('font') 206 | .filter(font => { 207 | const fontName = font[0].font 208 | if (allowedFiles instanceof RegExp) { 209 | if (!fontName.match(allowedFiles)) { 210 | log(`Font "${fontName}" not allowed by pattern: ${allowedFiles}.`) 211 | return false 212 | } 213 | } else if (skippedFiles instanceof RegExp) { 214 | if (fontName.match(skippedFiles)) { 215 | log(`Font "${fontName}" skipped by pattern ${skippedFiles}.`) 216 | return false 217 | } 218 | } 219 | 220 | return true 221 | }) 222 | .values() 223 | 224 | const glyphs = this.computeFinalGlyphs(glyphsInCss) 225 | return minifiableFonts.reduce((prev, group) => { 226 | return prev 227 | .then(() => this.minifyFontGroup(group, glyphs)) 228 | .then(files => { 229 | return files.forEach(file => { 230 | if (file.buffer.length > file.minified.length) { 231 | if (appendHash) { 232 | const newAssetName = this.appendMinifiedFileHash(file) 233 | compilation.assets[newAssetName] = new RawSource(file.minified) 234 | this.hashifyFontReferences(file.asset, newAssetName, compilation.assets) 235 | delete compilation.assets[file.asset] 236 | } else { 237 | compilation.assets[file.asset] = new RawSource(file.minified) 238 | } 239 | } 240 | }) 241 | }) 242 | }, Promise.resolve()) 243 | } 244 | 245 | appendMinifiedFileHash(file) { 246 | const fileHash = crypto.createHash('md5').update(file.minified).digest('hex') 247 | return file.asset.split('.').join(`-${fileHash}.`) 248 | } 249 | 250 | hashifyFontReferences(oldAssetName, newAssetName, assets) { 251 | Object.keys(assets).forEach( 252 | asset => { 253 | const oldAssetNameRegex = new RegExp( 254 | oldAssetName.replace('.', '\\.').replace('?', '\\?'), 255 | 'g' 256 | ) 257 | const assetSource = assets[asset].source().toString() 258 | if (assetSource.match(oldAssetNameRegex)) { 259 | assets[asset] = new RawSource(assetSource.replace(oldAssetNameRegex, newAssetName)) 260 | } 261 | } 262 | ) 263 | } 264 | 265 | apply(compiler) { 266 | compiler.hooks[this._options.webpackCompilationHook].tap('FontminPlugin', compilation => { 267 | compilation.hooks.additionalAssets.tapPromise('FontminPlugin', () => { 268 | if (!compilation.modules || !compilation.assets) { 269 | // eslint-disable-next-line no-console 270 | console.warn(`[fontmin-webpack] Failed to detect modules. Check your webpack version!`) 271 | return Promise.resolve() 272 | } 273 | 274 | return this.onAdditionalAssets(compilation) 275 | }) 276 | }) 277 | } 278 | } 279 | 280 | module.exports = FontminPlugin 281 | -------------------------------------------------------------------------------- /test/index.test.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | 4 | const _ = require('lodash') 5 | const rimraf = require('rimraf') 6 | const expect = require('chai').expect 7 | const webpack = require('webpack') 8 | const Plugin = require('../lib') 9 | 10 | const DIST_FOLDER = path.join(__dirname, 'fixtures/dist/') 11 | const FONT_AWESOME_FOLDER = path.join(__dirname, '../node_modules/font-awesome') 12 | const CUSTOM_ICON_FOLDER = path.join(__dirname, 'fixtures/custom-icon') 13 | 14 | function getFilenameWithoutQueryString(filename) { 15 | return filename.split('?', 2)[0] 16 | } 17 | 18 | describe('FontminPlugin', () => { 19 | let fontStats 20 | const baseConfig = require('./fixtures/webpack.config') 21 | const baseExtractConfig = require('./fixtures/webpack.extract-text.config') 22 | const baseUnicodeConfig = require('./fixtures/webpack.unicode.config') 23 | const customIconConfig = require('./fixtures/webpack.custom-icon.config') 24 | const originalStats = collectFontStats(FONT_AWESOME_FOLDER + '/fonts', { 25 | 'fontawesome-webfont.eot': true, 26 | 'fontawesome-webfont.ttf': true, 27 | 'fontawesome-webfont.svg': true, 28 | 'fontawesome-webfont.woff': true, 29 | 'fontawesome-webfont.woff2': true, 30 | }) 31 | const customIconOriginalStats = collectFontStats(CUSTOM_ICON_FOLDER, { 32 | 'custom-icon.eot': true, 33 | 'custom-icon.ttf': true, 34 | 'custom-icon.svg': true, 35 | 'custom-icon.woff': true, 36 | }) 37 | 38 | function collectFontStats(directory, files) { 39 | return _.keys(files) 40 | .map(name => { 41 | const filename = getFilenameWithoutQueryString(name) 42 | const filePath = `${directory}/${filename}` 43 | return { 44 | filename, 45 | filePath, 46 | extension: path.extname(filename), 47 | stats: fs.statSync(filePath), 48 | } 49 | }) 50 | .filter(item => item.extension !== '.js') 51 | .filter(item => item.filename !== 'OpenSans-Bold.ttf') 52 | } 53 | 54 | function getGlyphs() { 55 | const svg = _.find(fontStats, {extension: '.svg'}) 56 | const contents = fs.readFileSync(svg.filePath, 'utf8') 57 | const matchedContents = contents.match(/glyph-name="(.*?)"/g) 58 | const getGlyphName = s => s.slice('glyph-name="'.length, s.length - 1) 59 | return matchedContents.map(getGlyphName) 60 | } 61 | 62 | function testWithConfig(config, done) { 63 | webpack(config, (err, stats) => { 64 | try { 65 | if (err) { 66 | done(err) 67 | } else { 68 | fontStats = collectFontStats(DIST_FOLDER, stats.compilation.assets) 69 | done() 70 | } 71 | } catch (err) { 72 | done(err) 73 | } 74 | }) 75 | } 76 | 77 | describe('FontAwesome micro', () => { 78 | it('should run successfully', function (done) { 79 | this.timeout(10000) 80 | const plugin = new Plugin({autodetect: false, glyphs: '\uF0C7'}) 81 | const config = _.cloneDeep(baseConfig) 82 | testWithConfig(_.assign(config, {plugins: [plugin]}), done) 83 | }) 84 | 85 | before(done => rimraf(DIST_FOLDER, done)) 86 | after(done => rimraf(DIST_FOLDER, done)) 87 | 88 | it('should minify eot', () => { 89 | const eot = _.find(fontStats, {extension: '.eot'}) 90 | expect(eot.stats.size) 91 | .to.be.greaterThan(500) 92 | .lessThan(2400) 93 | }) 94 | 95 | it('should minify svg', () => { 96 | const glyphs = getGlyphs() 97 | expect(glyphs).to.eql(['save']) 98 | const svg = _.find(fontStats, {extension: '.svg'}) 99 | expect(svg.stats.size) 100 | .to.be.greaterThan(500) 101 | .lessThan(2000) 102 | }) 103 | 104 | it('should minify tff', () => { 105 | const ttf = _.find(fontStats, {extension: '.ttf'}) 106 | expect(ttf.stats.size) 107 | .to.be.greaterThan(500) 108 | .lessThan(2200) 109 | }) 110 | 111 | it('should minify woff', () => { 112 | const woff = _.find(fontStats, {extension: '.woff'}) 113 | expect(woff.stats.size) 114 | .to.be.greaterThan(500) 115 | .lessThan(2300) 116 | }) 117 | 118 | it('should minify woff2', () => { 119 | const woff2 = _.find(fontStats, {extension: '.woff2'}) 120 | expect(woff2.stats.size) 121 | .to.be.greaterThan(500) 122 | .lessThan(1000) 123 | }) 124 | }) 125 | 126 | describe('FontAwesome inferred', () => { 127 | it('should run successfully', function (done) { 128 | this.timeout(60000) 129 | testWithConfig(baseConfig, done) 130 | }) 131 | 132 | after(done => rimraf(DIST_FOLDER, done)) 133 | 134 | it('should contain the right glyphs', () => { 135 | const glyphs = getGlyphs() 136 | expect(glyphs).to.not.contain('heart') 137 | expect(glyphs).to.contain('table') 138 | expect(glyphs).to.contain('film') 139 | expect(glyphs).to.contain('ok') 140 | expect(glyphs).to.contain('remove') 141 | }) 142 | }) 143 | 144 | describe('FontAwesome full', () => { 145 | it('should run successfully', function (done) { 146 | this.timeout(60000) 147 | const plugin = new Plugin({autodetect: false}) 148 | const config = _.cloneDeep(baseConfig) 149 | testWithConfig(_.assign(config, {plugins: [plugin]}), done) 150 | }) 151 | 152 | after(done => rimraf(DIST_FOLDER, done)) 153 | 154 | it('should not replace with a larger version', () => { 155 | const svg = _.find(fontStats, {extension: '.svg'}) 156 | const svgOriginal = _.find(originalStats, {extension: '.svg'}) 157 | expect(svg.stats.size).to.equal(svgOriginal.stats.size) 158 | }) 159 | }) 160 | 161 | describe('FontAwesome with ExtractTextPlugin', () => { 162 | it('should run successfully', function (done) { 163 | this.timeout(60000) 164 | testWithConfig(baseExtractConfig, done) 165 | }) 166 | 167 | after(done => rimraf(DIST_FOLDER, done)) 168 | 169 | it('should minify eot', () => { 170 | const eot = _.find(fontStats, {extension: '.eot'}) 171 | expect(eot.stats.size) 172 | .to.be.greaterThan(500) 173 | .lessThan(7000) 174 | }) 175 | 176 | it('should minify svg', () => { 177 | const svg = _.find(fontStats, {extension: '.svg'}) 178 | expect(svg.stats.size) 179 | .to.be.greaterThan(500) 180 | .lessThan(7000) 181 | }) 182 | 183 | it('should minify tff', () => { 184 | const ttf = _.find(fontStats, {extension: '.ttf'}) 185 | expect(ttf.stats.size) 186 | .to.be.greaterThan(500) 187 | .lessThan(7000) 188 | }) 189 | 190 | it('should minify woff', () => { 191 | const woff = _.find(fontStats, {extension: '.woff'}) 192 | expect(woff.stats.size) 193 | .to.be.greaterThan(500) 194 | .lessThan(7000) 195 | }) 196 | 197 | it('should minify woff2', () => { 198 | const woff2 = _.find(fontStats, {extension: '.woff2'}) 199 | expect(woff2.stats.size) 200 | .to.be.greaterThan(500) 201 | .lessThan(7000) 202 | }) 203 | 204 | it('should contain the right glyphs', () => { 205 | const glyphs = getGlyphs() 206 | expect(glyphs).to.not.contain('heart') 207 | expect(glyphs).to.contain('table') 208 | expect(glyphs).to.contain('film') 209 | expect(glyphs).to.contain('ok') 210 | expect(glyphs).to.contain('remove') 211 | }) 212 | }) 213 | 214 | describe('FontAwesome with multi-byte unicode characters', () => { 215 | it('should run successfully', function (done) { 216 | this.timeout(60000) 217 | testWithConfig(baseUnicodeConfig, done) 218 | }) 219 | 220 | after(done => rimraf(DIST_FOLDER, done)) 221 | }) 222 | 223 | describe('FontAwesome in allowed fonts list', () => { 224 | it('should run successfully', function (done) { 225 | this.timeout(60000) 226 | const plugin = new Plugin({allowedFilesRegex: /^fontawesome/}) 227 | const config = _.cloneDeep(baseConfig) 228 | testWithConfig(_.assign(config, {plugins: [plugin]}), done) 229 | }) 230 | 231 | after(done => rimraf(DIST_FOLDER, done)) 232 | 233 | it('should minify font', () => { 234 | const svg = _.find(fontStats, {extension: '.svg'}) 235 | const svgOriginal = _.find(originalStats, {extension: '.svg'}) 236 | expect(svg.stats.size).to.be.lessThan(svgOriginal.stats.size) 237 | }) 238 | }) 239 | 240 | describe('FontAwesome in skipped fonts list', () => { 241 | it('should run successfully', function (done) { 242 | this.timeout(60000) 243 | const plugin = new Plugin({skippedFilesRegex: /^fontawesome/}) 244 | const config = _.cloneDeep(baseConfig) 245 | testWithConfig(_.assign(config, {plugins: [plugin]}), done) 246 | }) 247 | 248 | after(done => rimraf(DIST_FOLDER, done)) 249 | 250 | it('should not minify font', () => { 251 | const svg = _.find(fontStats, {extension: '.svg'}) 252 | const svgOriginal = _.find(originalStats, {extension: '.svg'}) 253 | expect(svg.stats.size).to.be.equal(svgOriginal.stats.size) 254 | }) 255 | }) 256 | 257 | describe('FontAwesome with appendHash option', () => { 258 | it('should run successfully', function (done) { 259 | this.timeout(60000) 260 | const plugin = new Plugin({appendHash: true}) 261 | const config = _.cloneDeep(baseConfig) 262 | testWithConfig(_.assign(config, {plugins: [plugin]}), done) 263 | }) 264 | 265 | it('should append the hash to the ends of all refrences in all assets', () => { 266 | const out = fs.readFileSync(DIST_FOLDER + '/out.js').toString() 267 | fontStats.forEach(file => expect(out.match(file.filename)).to.be.ok) 268 | fontStats.forEach(file => expect(out.match(file.filename.replace(/-([a-z]|[0-9])+\./, '.'))).to.not.be.ok) 269 | }) 270 | }) 271 | 272 | describe('Custom Icon pack', () => { 273 | it('should run successfully', function (done) { 274 | this.timeout(10000) 275 | const plugin = new Plugin({autodetect: true}) 276 | const config = _.cloneDeep(customIconConfig) 277 | testWithConfig(_.assign(config, {plugins: [plugin]}), done) 278 | }) 279 | 280 | before(done => rimraf(DIST_FOLDER, done)) 281 | after(done => rimraf(DIST_FOLDER, done)) 282 | 283 | it('should contain the right glyphs', () => { 284 | const glyphs = getGlyphs() 285 | expect(glyphs).to.not.contain('cloud-lightning') 286 | expect(glyphs).to.contain('coffee') 287 | expect(glyphs).to.contain('plus') 288 | }) 289 | 290 | it('should minify eot', () => { 291 | const original = _.find(customIconOriginalStats, {extension: '.eot'}) 292 | const eot = _.find(fontStats, {extension: '.eot'}) 293 | expect(eot.stats.size) 294 | .to.be.greaterThan(500) 295 | .lessThan(original.stats.size) 296 | }) 297 | 298 | it('should minify svg', () => { 299 | const glyphs = getGlyphs() 300 | expect(glyphs).to.eql(['plus', 'coffee']) 301 | const original = _.find(customIconOriginalStats, {extension: '.svg'}) 302 | const svg = _.find(fontStats, {extension: '.svg'}) 303 | expect(svg.stats.size) 304 | .to.be.greaterThan(500) 305 | .lessThan(original.stats.size) 306 | }) 307 | 308 | it('should minify tff', () => { 309 | const original = _.find(customIconOriginalStats, {extension: '.ttf'}) 310 | const ttf = _.find(fontStats, {extension: '.ttf'}) 311 | expect(ttf.stats.size) 312 | .to.be.greaterThan(500) 313 | .lessThan(original.stats.size) 314 | }) 315 | 316 | it('should minify woff', () => { 317 | const original = _.find(customIconOriginalStats, {extension: '.woff'}) 318 | const woff = _.find(fontStats, {extension: '.woff'}) 319 | expect(woff.stats.size) 320 | .to.be.greaterThan(500) 321 | .lessThan(original.stats.size) 322 | }) 323 | }) 324 | }) 325 | --------------------------------------------------------------------------------