├── 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 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # fontmin-webpack
2 |
3 | [](https://www.npmjs.com/package/fontmin-webpack)
4 | [](https://travis-ci.org/patrickhulce/fontmin-webpack)
5 | [](https://coveralls.io/github/patrickhulce/fontmin-webpack?branch=master)
6 | [](http://commitizen.github.io/cz-cli/)
7 | [](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 |
--------------------------------------------------------------------------------