diff --git a/.github/workflows/tester.yml b/.github/workflows/tester.yml index 72243f9..7ae0ec1 100644 --- a/.github/workflows/tester.yml +++ b/.github/workflows/tester.yml @@ -24,20 +24,8 @@ jobs: restore-keys: ${{ runner.os }}-npm-cache - name: Install Dependencies run: npm install - - name: Determine zstd binary version - shell: bash - run: | - case "$RUNNER_OS" in - "Linux") - echo "PLATFORM=linux-x64-gnu" >> "$GITHUB_ENV" ;; - "Windows") - echo "PLATFORM=win32-x64-msvc" >> "$GITHUB_ENV" ;; - "macOS") - echo "PLATFORM=darwin-arm64" >> "$GITHUB_ENV" ;; - esac - name: Install zstd binary - shell: bash - run: npm install "@mongodb-js/zstd-$PLATFORM" + run: npm install "@mongodb-js/zstd" - name: Test run: npm run test env: diff --git a/.gitignore b/.gitignore index 22bded3..33581c3 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ tmp/ *.log coverage/ bun.lockb +*.bak diff --git a/README.md b/README.md index 732469c..7a44dc1 100644 --- a/README.md +++ b/README.md @@ -39,10 +39,11 @@ minify: css: js: svg: - gzip: - brotli: xml: json: + gzip: + brotli: + zstd: ``` - **enable** - Enable the plugin. Defaults to `true`. @@ -51,10 +52,11 @@ minify: - **css** - See [CSS](#css) section - **js** - See [JS](#js) section - **svg** - See [SVG](#svg) section -- **gzip** - See [Gzip](#gzip) section -- **brotli** - See [Brotli](#brotli) section - **xml** - See [XML](#xml) section - **json** - See [JSON](#json) section +- **gzip** - See [Gzip](#gzip) section +- **brotli** - See [Brotli](#brotli) section +- **zstd** - See [Zstd](#zstd) section ## HTML @@ -88,6 +90,8 @@ minify: - **verbose** - Verbose output. Defaults to `false`. - **exclude** - Exclude files. Support [wildcard](http://www.globtester.com/) pattern(s) in a string or array. - **level** - Optimization level. Defaults to `2`. +- **sourceMap** - Source map options. Defaults to `false`. +- **mapIncludeSources** - Include sources in map, Defaults to `false`. - **globOptions** - See [globbing](#globbing) section. For more options, see [clean-css](https://github.com/jakubpawlowicz/clean-css). @@ -110,6 +114,8 @@ minify: - **mangle** - Mangle variable names. Defaults to `true`. Pass an object to specify [mangle options](https://github.com/terser-js/terser#mangle-options). - **output** - Output options. - To retain comments, `output: {comments: true}`. +- **sourceMap** - Source map options. Defaults to `false`. + - To include sources in map, `sourceMap: { includeSources: true }` - **globOptions** - See [globbing](#globbing) section. For more options, see [Terser](https://github.com/terser-js/terser). @@ -175,7 +181,9 @@ minify: enable: false include: - "*.json" + - "*.webmanifest" - "!*.min.json" + - "!*.min.webmanifest" ``` - **enable** - Enable the plugin. Defaults to `false`. @@ -195,6 +203,8 @@ minify: - "*.html" - "*.css" - "*.js" + - "*.map" + - "*.wasm" - "*.txt" - "*.ttf" - "*.atom" @@ -203,6 +213,7 @@ minify: - "*.svg" - "*.eot" - "*.json" + - "*.webmanifest" ``` - **enable** - Enable the plugin. Defaults to `true`. @@ -211,6 +222,7 @@ minify: - **include** - Include files. Support [wildcard](http://www.globtester.com/) pattern(s) in a string or array. - Support one-liner, `include: ['*.html','*.css','*.js']`. - Must include asterisk and single quotes. `.html` is invalid. `'*.html'` is valid. +- **ensureCompressed** - Ensure the compressed file is smaller than the original, otherwise do not output. Defaults to `true`. - **globOptions** - See [globbing](#globbing) section. - **level** - Compression level; lower value may results in faster compression but slightly larger (compressed) file. Range `1-9`. Defaults to `9`, or the value of [`zlib.constants.Z_BEST_COMPRESSION`](https://nodejs.org/docs/latest-v12.x/api/zlib.html#zlib_zlib_constants) @@ -224,6 +236,8 @@ minify: - "*.html" - "*.css" - "*.js" + - "*.map" + - "*.wasm" - "*.txt" - "*.ttf" - "*.atom" @@ -232,12 +246,14 @@ minify: - "*.svg" - "*.eot" - "*.json" + - "*.webmanifest" ``` - **enable** - Enable the plugin. Defaults to `true`. - **priority** - Plugin's priority. Defaults to `10`. - **verbose** - Verbose output. Defaults to `false`. - **include** - Include files. Support [wildcard](http://www.globtester.com/) pattern(s) in a string or array. +- **ensureCompressed** - Ensure the compressed file is smaller than the original, otherwise do not output. Defaults to `true`. - **globOptions** - See [globbing](#globbing) section. - **level** - Compression level. Range `1-11`. Defaults to `11`, or the value of [`zlib.constants.BROTLI_MAX_QUALITY`](https://nodejs.org/docs/latest-v12.x/api/zlib.html#zlib_brotli_constants) @@ -251,6 +267,8 @@ minify: - "*.html" - "*.css" - "*.js" + - "*.map" + - "*.wasm" - "*.txt" - "*.ttf" - "*.atom" @@ -259,12 +277,14 @@ minify: - "*.svg" - "*.eot" - "*.json" + - "*.webmanifest" ``` - **enable** - Enable the plugin. Defaults to `false`. - **priority** - Plugin's priority. Defaults to `10`. - **verbose** - Verbose output. Defaults to `false`. - **include** - Include files. Support [wildcard](http://www.globtester.com/) pattern(s) in a string or array. +- **ensureCompressed** - Ensure the compressed file is smaller than the original, otherwise do not output. Defaults to `true`. - **globOptions** - See [globbing](#globbing) section. - **level** - Compression level. Range `1-22`. Defaults to `3`, or the value of [`DEFAULT_LEVEL`](https://github.com/mongodb-js/zstd/blob/a3a08c61c9045411c8275e248498dbc583457fb5/src/lib.rs#L9) diff --git a/index.js b/index.js index 5294e56..15ab3e6 100644 --- a/index.js +++ b/index.js @@ -1,12 +1,15 @@ /* global hexo */ 'use strict' -hexo.config.minify = Object.assign({ - enable: true, - previewServer: true -}, hexo.config.minify) +/** @typedef {import("@types/hexo")} */ -hexo.config.minify.html = Object.assign({ +hexo.config.minify = { + enable: true, + previewServer: true, + ...hexo.config.minify +} + +hexo.config.minify.html = { enable: true, priority: 10, verbose: false, @@ -21,19 +24,23 @@ hexo.config.minify.html = Object.assign({ removeStyleLinkTypeAttributes: true, minifyJS: true, minifyCSS: true, - globOptions: { basename: true } -}, hexo.config.minify.html) + globOptions: { basename: true }, + ...hexo.config.minify.html +} -hexo.config.minify.css = Object.assign({ +hexo.config.minify.css = { enable: true, priority: 10, verbose: false, exclude: ['*.min.css'], level: 2, - globOptions: { basename: true } -}, hexo.config.minify.css) + sourceMap: false, + mapIncludeSources: false, + globOptions: { basename: true }, + ...hexo.config.minify.css +} -hexo.config.minify.js = Object.assign({ +hexo.config.minify.js = { enable: true, priority: 10, verbose: false, @@ -41,68 +48,114 @@ hexo.config.minify.js = Object.assign({ compress: {}, mangle: true, output: {}, - globOptions: { basename: true } -}, hexo.config.minify.js) + sourceMap: false, + globOptions: { basename: true }, + ...hexo.config.minify.js +} -hexo.config.minify.svg = Object.assign({ +hexo.config.minify.svg = { enable: true, priority: 10, verbose: false, include: ['*.svg', '!*.min.svg'], plugins: {}, - globOptions: { basename: true } -}, hexo.config.minify.svg) + globOptions: { basename: true }, + ...hexo.config.minify.svg +} -hexo.config.minify.gzip = Object.assign({ - enable: true, - priority: 10, - verbose: false, - include: ['*.html', '*.css', '*.js', '*.txt', '*.ttf', '*.atom', '*.stl', '*.xml', '*.svg', '*.eot', '*.json'], - globOptions: { basename: true } -}, hexo.config.minify.gzip) - -hexo.config.minify.brotli = Object.assign({ - enable: true, - priority: 10, - verbose: false, - include: ['*.html', '*.css', '*.js', '*.txt', '*.ttf', '*.atom', '*.stl', '*.xml', '*.svg', '*.eot', '*.json'], - globOptions: { basename: true } -}, hexo.config.minify.brotli) - -hexo.config.minify.zstd = Object.assign({ - enable: false, - priority: 10, - verbose: false, - include: ['*.html', '*.css', '*.js', '*.txt', '*.ttf', '*.atom', '*.stl', '*.xml', '*.svg', '*.eot', '*.json'], - globOptions: { basename: true } -}, hexo.config.minify.zstd) - -hexo.config.minify.xml = Object.assign({ +hexo.config.minify.xml = { enable: false, priority: 10, verbose: false, include: ['*.xml', '!*.min.xml'], removeComments: true, - globOptions: { basename: true } -}, hexo.config.minify.xml) + globOptions: { basename: true }, + ...hexo.config.minify.xml +} -hexo.config.minify.json = Object.assign({ +hexo.config.minify.json = { enable: false, priority: 10, verbose: false, - include: ['*.json', '!*.min.json'], - globOptions: { basename: true } -}, hexo.config.minify.json) + include: ['*.json', '*.webmanifest', '!*.min.json', '!*.min.webmanifest'], + globOptions: { basename: true }, + ...hexo.config.minify.json +} + +hexo.config.minify.gzip = { + enable: true, + priority: 10, + verbose: false, + include: ['*.html', '*.css', '*.js', '*.map', '*.wasm', '*.txt', '*.ttf', '*.atom', '*.stl', '*.xml', '*.svg', '*.eot', '*.json', '*.webmanifest'], + ensureCompressed: true, + globOptions: { basename: true }, + ...hexo.config.minify.gzip +} + +hexo.config.minify.brotli = { + enable: true, + priority: 10, + verbose: false, + include: ['*.html', '*.css', '*.js', '*.map', '*.wasm', '*.txt', '*.ttf', '*.atom', '*.stl', '*.xml', '*.svg', '*.eot', '*.json', '*.webmanifest'], + ensureCompressed: true, + globOptions: { basename: true }, + ...hexo.config.minify.brotli +} + +hexo.config.minify.zstd = { + enable: false, + priority: 10, + verbose: false, + include: ['*.html', '*.css', '*.js', '*.map', '*.wasm', '*.txt', '*.ttf', '*.atom', '*.stl', '*.xml', '*.svg', '*.eot', '*.json', '*.webmanifest'], + ensureCompressed: true, + globOptions: { basename: true }, + ...hexo.config.minify.zstd +} if (hexo.config.minify.enable === true && !(hexo.config.minify.previewServer === true && ['s', 'server'].includes(hexo.env.cmd))) { - const filter = require('./lib/filter') - hexo.extend.filter.register('after_render:html', filter.minifyHtml, hexo.config.minify.html.priority) - hexo.extend.filter.register('after_render:css', filter.minifyCss, hexo.config.minify.css.priority) - hexo.extend.filter.register('after_render:js', filter.minifyJs, hexo.config.minify.js.priority) - hexo.extend.filter.register('after_generate', filter.minifySvg, hexo.config.minify.svg.priority) - hexo.extend.filter.register('after_generate', filter.gzipFn, hexo.config.minify.gzip.priority) - hexo.extend.filter.register('after_generate', filter.brotliFn, hexo.config.minify.brotli.priority) - hexo.extend.filter.register('after_generate', filter.zstdFn, hexo.config.minify.zstd.priority) - hexo.extend.filter.register('after_generate', filter.minifyXml, hexo.config.minify.xml.priority) - hexo.extend.filter.register('after_generate', filter.minifyJson, hexo.config.minify.json.priority) -} + if (hexo.config.minify.html.enable === true) { + hexo.extend.filter.register('after_render:html', require('./lib/html').minifyHtml, hexo.config.minify.html.priority) + } + if (hexo.config.minify.css.enable === true) { + if (hexo.config.minify.css.sourceMap) { + hexo.extend.filter.register('after_generate', require('./lib/css').minifyCssWithMap, hexo.config.minify.js.priority) + } + else { + hexo.extend.filter.register('after_render:css', require('./lib/css').minifyCss, hexo.config.minify.css.priority) + } + } + if (hexo.config.minify.js.enable === true) { + if (hexo.config.minify.js.sourceMap) { + hexo.extend.filter.register('after_generate', require('./lib/js').minifyJsWithMap, hexo.config.minify.js.priority) + } + else { + hexo.extend.filter.register('after_render:js', require('./lib/js').minifyJs, hexo.config.minify.js.priority) + } + } + if (hexo.config.minify.svg.enable === true) { + hexo.extend.filter.register('after_generate', require('./lib/svg').minifySvg, hexo.config.minify.svg.priority) + } + if (hexo.config.minify.xml.enable === true) { + hexo.extend.filter.register('after_generate', require('./lib/xml').minifyXml, hexo.config.minify.xml.priority) + } + if (hexo.config.minify.json.enable === true) { + hexo.extend.filter.register('after_generate', require('./lib/json').minifyJson, hexo.config.minify.json.priority) + } + if (hexo.config.minify.gzip.enable || hexo.config.minify.brotli.enable) { + const zlib = require('./lib/zlib') + if (hexo.config.minify.gzip.enable === true) { + hexo.extend.filter.register('after_generate', zlib.gzipFn, hexo.config.minify.gzip.priority) + } + if (hexo.config.minify.brotli.enable === true) { + hexo.extend.filter.register('after_generate', zlib.brotliFn, hexo.config.minify.brotli.priority) + } + } + if (hexo.config.minify.zstd.enable === true) { + try { + hexo.extend.filter.register('after_generate', require('./lib/zstd').zstdFn, hexo.config.minify.zstd.priority) + } catch (ex) { + const log = hexo.log || console + log.warn(`ZSTD load failed. ${ex}`) + } + } +} \ No newline at end of file diff --git a/lib/css.js b/lib/css.js new file mode 100644 index 0000000..e7a8e58 --- /dev/null +++ b/lib/css.js @@ -0,0 +1,75 @@ +'use strict' +const CleanCSS = require('clean-css') +const { isMatch, match, logFn } = require('./tools') + +/** + * @param {string} str + * @param {{ path: string }} data + * @this {import('@types/hexo')} + */ +async function minifyCss(str, data) { + const hexo = this + const options = hexo.config.minify.css + if (!str) return str + + const path = data.path + const { exclude, globOptions, verbose } = options + + if (isMatch(path, exclude, globOptions)) return str + + try { + const styles = await new CleanCSS(options).minify(str).styles + if (verbose) logFn.call(this, str, styles, path, 'css') + return styles + } catch (err) { + throw new Error(`Path: ${path}\n${err}`) + } +} + +/** + * @this {import('@types/hexo')} + */ +function minifyCssWithMap() { + const hexo = this + const options = hexo.config.minify.css + const { parse } = require('path') + + const route = hexo.route + const routeList = route.list() + /** @type {{ exclude: string[] }} */ + const { exclude, globOptions, verbose } = options + const include = ['*.css', ...exclude.map(x => `!${x}`)] + const cleanCSS = new CleanCSS(options) + + return Promise.all((match(routeList, include, globOptions)).map(path => { + return new Promise((/** @type {(value: void) => void} */ resolve, reject) => { + const assetPath = route.get(path) + let assetTxt = '' + assetPath.on('data', chunk => (assetTxt += chunk)) + assetPath.on('end', async () => { + if (assetTxt.length) { + try { + const { base, ext, name } = parse(path) + const { styles, sourceMap } = await cleanCSS.minify(assetTxt) + if (verbose) logFn.call(this, assetTxt, result, path, 'css') + route.set(path, `${styles}\n/*# sourceMappingURL=${base}.map */`) + const map = sourceMap.toJSON() + map.sources = [`${name}.source${ext}`] + if (options.mapIncludeSources === true) { + map.sourcesContent = [assetTxt] + } + route.set(`${path}.map`, JSON.stringify(map)) + } catch (err) { + reject(new Error(`Path: ${path}\n${err}`)) + } + } + resolve() + }) + }) + })) +} + +module.exports = { + minifyCss, + minifyCssWithMap +} \ No newline at end of file diff --git a/lib/filter.js b/lib/filter.js index 0d17003..9cc9099 100644 --- a/lib/filter.js +++ b/lib/filter.js @@ -1,337 +1,11 @@ 'use strict' - -const { minify: htmlMinify } = require('html-minifier-terser') -const CleanCSS = require('clean-css') -const { minify: terserMinify } = require('terser') -const { optimize: svgOptimize } = require('svgo') -const zlib = require('zlib') -const { promisify } = require('util') -const gzip = promisify(zlib.gzip) -const br = promisify(zlib.brotliCompress) -const { minify: compressXml } = require('minify-xml') -const micromatch = require('micromatch') -const { compress: zstd } = require('@mongodb-js/zstd') - -const isMatch = (path = '', patterns = [], options = {}) => { - if (path && patterns) { - if (path.length && patterns.length) { - if (typeof patterns === 'string') patterns = [patterns] - for (const pattern of patterns) { - // disable basename if a pattern includes a slash - let { basename } = options - // only disable when basename is enabled - basename = basename && !pattern.includes('/') - if (micromatch.isMatch(path, pattern, { ...options, basename })) { - return true - } - } - } - } - return false -} - -const match = (paths = [], patterns = [], options = {}) => { - let input = paths - if (paths && patterns) { - if (paths.length && patterns.length) { - const output = [] - if (typeof patterns === 'string') patterns = [patterns] - const exclude = patterns.filter((pattern) => pattern.startsWith('!')) - const include = patterns.filter((pattern) => !pattern.startsWith('!')) - if (exclude.length) input = micromatch(paths, exclude, options) - if (include.length) { - for (const pattern of include) { - let { basename } = options - basename = basename && !pattern.includes('/') - const tmp = micromatch(input, pattern, { ...options, basename }) - if (tmp.length) output.push(...tmp) - } - return [...new Set(output)] - } - return input - } - } - return paths -} - -function logFn (original, minified, path, ext) { - const saved = ((original.length - minified.length) / original.length * 100).toFixed(2) - const log = this.log || console - log.log(`${ext}: ${path} [${saved}% saved]`) -} - -async function minifyHtml (str, data) { - const hexo = this - const options = hexo.config.minify.html - if (options.enable === false || !str) return - - const { path } = data - const { exclude, globOptions, verbose } = options - - // Return if a path matches exclusion pattern - if (isMatch(path, exclude, globOptions)) return str - - try { - const result = await htmlMinify(str, options) - if (verbose) logFn.call(this, str, result, path, 'html') - - return result - } catch (err) { - throw new Error(`Path: ${path}\n${err}`) - } -} - -async function minifyCss (str, data) { - const hexo = this - const options = hexo.config.minify.css - if (options.enable === false || !str) return - - const { path } = data - const { exclude, globOptions, verbose } = options - - if (isMatch(path, exclude, globOptions)) return str - - try { - const { styles } = await new CleanCSS(options).minify(str) - if (verbose) logFn.call(this, str, styles, path, 'css') - return styles - } catch (err) { - throw new Error(`Path: ${path}\n${err}`) - } -} - -async function minifyJs (str, data) { - const hexo = this - const options = hexo.config.minify.js - if (options.enable === false || !str) return - - const { path } = data - const { exclude, globOptions, verbose } = options - - if (isMatch(path, exclude, globOptions)) return str - - // Terser doesn't like unsupported options - const jsOptions = Object.assign({}, options) - delete jsOptions.enable - delete jsOptions.priority - delete jsOptions.verbose - // Old option, retained to avoid crash when upgrading to v4 - delete jsOptions.logger - delete jsOptions.exclude - delete jsOptions.globOptions - - try { - const { code } = await terserMinify(str, jsOptions) - if (verbose) logFn.call(this, str, code, path, 'js') - return code - } catch (err) { - throw new Error(`Path: ${path}\n${err}`) - } -} - -function minifySvg () { - const hexo = this - const options = hexo.config.minify.svg - if (options.enable === false) return - - const { route } = hexo - const routeList = route.list() - const { globOptions, include, verbose } = options - // const plugins = Array.isArray(options.plugins) ? extendDefaultPlugins(options.plugins) : extendDefaultPlugins([]) - const pluginCfg = Object.prototype.toString.call(options.plugins) === '[object Object]' ? { ...options.plugins } : {} - const plugins = [{ - name: 'preset-default', - params: { - overrides: pluginCfg - } - }] - - return Promise.all((match(routeList, include, globOptions)).map((path) => { - return new Promise((resolve, reject) => { - const assetPath = route.get(path) - let assetTxt = '' - assetPath.on('data', (chunk) => (assetTxt += chunk)) - assetPath.on('end', async () => { - if (assetTxt.length) { - try { - const { data } = svgOptimize(assetTxt, { ...options, plugins }) - if (verbose) logFn.call(this, assetTxt, data, path, 'svg') - resolve(route.set(path, data)) - } catch (err) { - reject(new Error(`Path: ${path}\n${err}`)) - } - } - resolve() - }) - }) - })) -} - -function gzipFn () { - const hexo = this - const options = hexo.config.minify.gzip - if (options.enable === false) return - - const { route } = hexo - const routeList = route.list() - const { globOptions, include, verbose } = options - let { level } = options - if (typeof level !== 'number') level = zlib.constants.Z_BEST_COMPRESSION - - return Promise.all((match(routeList, include, globOptions)).map((path) => { - return new Promise((resolve, reject) => { - const assetPath = route.get(path) - let assetTxt = '' - assetPath.on('data', (chunk) => (assetTxt += chunk)) - assetPath.on('end', async () => { - if (assetTxt.length) { - try { - const result = await gzip(assetTxt, { level }) - if (verbose) logFn.call(this, assetTxt, result, path, 'gzip') - resolve(route.set(path + '.gz', result)) - } catch (err) { - reject(new Error(`Path: ${path}\n${err}`)) - } - } - resolve() - }) - }) - })) -} - -function brotliFn () { - const hexo = this - const options = hexo.config.minify.brotli - if (options.enable === false) return - - const { route } = hexo - const routeList = route.list() - const { globOptions, include, verbose } = options - let { level } = options - if (typeof level !== 'number') level = zlib.constants.BROTLI_MAX_QUALITY - - return Promise.all((match(routeList, include, globOptions)).map((path) => { - return new Promise((resolve, reject) => { - const assetPath = route.get(path) - let assetTxt = '' - assetPath.on('data', (chunk) => (assetTxt += chunk)) - assetPath.on('end', async () => { - if (assetTxt.length) { - try { - const result = await br(assetTxt, { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: level } }) - if (verbose) logFn.call(this, assetTxt, result, path, 'brotli') - resolve(route.set(path + '.br', result)) - } catch (err) { - reject(new Error(`Path: ${path}\n${err}`)) - } - } - resolve() - }) - }) - })) -} - -function zstdFn () { - const hexo = this - const options = hexo.config.minify.zstd - if (options.enable === false) return - - const { route } = hexo - const routeList = route.list() - const { globOptions, include, verbose } = options - let { level } = options - if (typeof level !== 'number') level = undefined - - return Promise.all((match(routeList, include, globOptions)).map((path) => { - return new Promise((resolve, reject) => { - const assetPath = route.get(path) - let assetTxt = '' - assetPath.on('data', (chunk) => (assetTxt += chunk)) - assetPath.on('end', async () => { - if (assetTxt.length) { - try { - const input = Buffer.from(assetTxt, 'utf-8') - const result = await zstd(input, level) - if (verbose) logFn.call(this, assetTxt, result, path, 'zstd') - resolve(route.set(path + '.zst', result)) - } catch (err) { - reject(new Error(`Path: ${path}\n${err}`)) - } - } - resolve() - }) - }) - })) -} - -function minifyXml () { - const hexo = this - const options = hexo.config.minify.xml - if (options.enable === false) return - - const { route } = hexo - const routeList = route.list() - const { globOptions, include, verbose } = options - - return Promise.all((match(routeList, include, globOptions)).map((path) => { - return new Promise((resolve, reject) => { - const assetPath = route.get(path) - let assetTxt = '' - assetPath.on('data', (chunk) => (assetTxt += chunk)) - assetPath.on('end', () => { - if (assetTxt.length) { - try { - const result = compressXml(assetTxt, { ...options }) - if (verbose) logFn.call(this, assetTxt, result, path, 'xml') - resolve(route.set(path, result)) - } catch (err) { - reject(new Error(`Path: ${path}\n${err}`)) - } - } - resolve() - }) - }) - })) -} - -function minifyJson () { - const hexo = this - const options = hexo.config.minify.json - if (options.enable === false) return - - const { route } = hexo - const routeList = route.list() - const { globOptions, include, verbose } = options - - return Promise.all((match(routeList, include, globOptions)).map((path) => { - return new Promise((resolve, reject) => { - const assetPath = route.get(path) - let assetTxt = '' - assetPath.on('data', (chunk) => (assetTxt += chunk)) - assetPath.on('end', () => { - if (assetTxt.length) { - try { - const result = JSON.stringify(JSON.parse(assetTxt)) - if (verbose) logFn.call(this, assetTxt, result, path, 'json') - resolve(route.set(path, result)) - } catch (err) { - reject(new Error(`Path: ${path}\n${err}`)) - } - } - resolve() - }) - }) - })) -} - module.exports = { - minifyHtml, - minifyCss, - minifyJs, - minifySvg, - gzipFn, - brotliFn, - zstdFn, - minifyXml, - minifyJson -} + ... require('./html'), + ... require('./css'), + ... require('./js'), + ... require('./svg'), + ... require('./xml'), + ... require('./json'), + ... require('./zlib'), + ... require('./zstd') +} \ No newline at end of file diff --git a/lib/html.js b/lib/html.js new file mode 100644 index 0000000..863a3b0 --- /dev/null +++ b/lib/html.js @@ -0,0 +1,32 @@ +'use strict' +const { minify: htmlMinify } = require('html-minifier-terser') +const { isMatch, logFn } = require('./tools') + +/** + * @param {string} str + * @param {{ path: string }} data + * @this {import('@types/hexo')} + */ +async function minifyHtml(str, data) { + const hexo = this + const options = hexo.config.minify.html + if (!str) return str + + const path = data.path + const { exclude, globOptions, verbose } = options + + // Return if a path matches exclusion pattern + if (isMatch(path, exclude, globOptions)) return str + + try { + const result = await htmlMinify(str, options) + if (verbose) logFn.call(this, str, result, path, 'html') + return result + } catch (err) { + throw new Error(`Path: ${path}\n${err}`) + } +} + +module.exports = { + minifyHtml +} \ No newline at end of file diff --git a/lib/js.js b/lib/js.js new file mode 100644 index 0000000..251bb2d --- /dev/null +++ b/lib/js.js @@ -0,0 +1,88 @@ +'use strict' +const { minify: terserMinify } = require('terser') +const { isMatch, match, logFn } = require('./tools') + +/** + * @param {string} str + * @param {{ path: string }} data + * @this {import('@types/hexo')} + */ +async function minifyJs(str, data) { + const hexo = this + const options = hexo.config.minify.js + if (!str) return str + + const path = data.path + const { exclude, globOptions, verbose, ...jsOptions } = options + + if (isMatch(path, exclude, globOptions)) return str + + // Terser doesn't like unsupported options + delete jsOptions.enable + delete jsOptions.priority + // Old option, retained to avoid crash when upgrading to v4 + delete jsOptions.logger + + try { + const code = await terserMinify(str, jsOptions).code + if (verbose) logFn.call(this, str, code, path, 'js') + return code + } catch (err) { + throw new Error(`Path: ${path}\n${err}`) + } +} + +/** + * @this {import('@types/hexo')} + */ +function minifyJsWithMap() { + const hexo = this + const options = hexo.config.minify.js + const { parse } = require('path') + + const route = hexo.route + const routeList = route.list() + /** @type {{ exclude: string[] }} */ + const { exclude, globOptions, verbose, ...jsOptions } = options + const include = ['*.js', ...exclude.map(x => `!${x}`)] + + // Terser doesn't like unsupported options + delete jsOptions.enable + delete jsOptions.priority + // Old option, retained to avoid crash when upgrading to v4 + delete jsOptions.logger + + return Promise.all((match(routeList, include, globOptions)).map(path => { + return new Promise((/** @type {(value: void) => void} */ resolve, reject) => { + const assetPath = route.get(path) + let assetTxt = '' + assetPath.on('data', chunk => (assetTxt += chunk)) + assetPath.on('end', async () => { + if (assetTxt.length) { + try { + const { base, ext, name } = parse(path) + jsOptions.sourceMap = { + ...jsOptions.sourceMap, + filename: base, + asObject: true, + url: `${base}.map` + } + const { code, map } = await terserMinify(assetTxt, { ...jsOptions }) + if (verbose) logFn.call(this, assetTxt, result, path, 'js') + route.set(path, code) + map.sources = [`${name}.source${ext}`] + route.set(`${path}.map`, JSON.stringify(map)) + } catch (err) { + reject(new Error(`Path: ${path}\n${err}`)) + } + } + resolve() + }) + }) + })) +} + +module.exports = { + minifyJs, + minifyJsWithMap +} \ No newline at end of file diff --git a/lib/json.js b/lib/json.js new file mode 100644 index 0000000..9f40e51 --- /dev/null +++ b/lib/json.js @@ -0,0 +1,39 @@ +'use strict' +const { match, logFn } = require('./tools') + +/** + * @this {import('@types/hexo')} + */ +function minifyJson() { + const hexo = this + const options = hexo.config.minify.json + + const route = hexo.route + /** @type {string[]} */ + const routeList = route.list() + const { globOptions, include, verbose } = options + + return Promise.all((match(routeList, include, globOptions)).map(path => { + return new Promise((/** @type {(value: void) => void} */ resolve, reject) => { + const assetPath = route.get(path) + let assetTxt = '' + assetPath.on('data', chunk => (assetTxt += chunk)) + assetPath.on('end', () => { + if (assetTxt.length) { + try { + const result = JSON.stringify(JSON.parse(assetTxt)) + if (verbose) logFn.call(this, assetTxt, result, path, 'json') + route.set(path, result) + } catch (err) { + reject(new Error(`Path: ${path}\n${err}`)) + } + } + resolve() + }) + }) + })) +} + +module.exports = { + minifyJson +} \ No newline at end of file diff --git a/lib/svg.js b/lib/svg.js new file mode 100644 index 0000000..0cfb0e0 --- /dev/null +++ b/lib/svg.js @@ -0,0 +1,48 @@ +'use strict' +const { optimize: svgOptimize } = require('svgo') +const { match, logFn } = require('./tools') + +/** + * @this {import('@types/hexo')} + */ +function minifySvg() { + const hexo = this + const options = hexo.config.minify.svg + + const route = hexo.route + /** @type {string[]} */ + const routeList = route.list() + const { globOptions, include, verbose } = options + // const plugins = Array.isArray(options.plugins) ? extendDefaultPlugins(options.plugins) : extendDefaultPlugins([]) + const pluginCfg = typeof options.plugins === 'object' ? { ...options.plugins } : {} + const plugins = [{ + name: 'preset-default', + params: { + overrides: pluginCfg + } + }] + + return Promise.all((match(routeList, include, globOptions)).map(path => { + return new Promise((/** @type {(value: void) => void} */ resolve, reject) => { + const assetPath = route.get(path) + let assetTxt = '' + assetPath.on('data', chunk => (assetTxt += chunk)) + assetPath.on('end', async () => { + if (assetTxt.length) { + try { + const data = svgOptimize(assetTxt, { ...options, plugins }).data + if (verbose) logFn.call(this, assetTxt, data, path, 'svg') + route.set(path, data) + } catch (err) { + reject(new Error(`Path: ${path}\n${err}`)) + } + } + resolve() + }) + }) + })) +} + +module.exports = { + minifySvg +} \ No newline at end of file diff --git a/lib/tools.js b/lib/tools.js new file mode 100644 index 0000000..d381d4c --- /dev/null +++ b/lib/tools.js @@ -0,0 +1,72 @@ +'use strict' +const micromatch = require('micromatch') + +/** + * @param {string | string[]} patterns + * @param {{ basename: string }} options + */ +function isMatch(path = '', patterns = [], options = {}) { + if (path && patterns) { + if (path.length && patterns.length) { + if (typeof patterns === 'string') patterns = [patterns] + for (const pattern of patterns) { + // disable basename if a pattern includes a slash + let basename = options.basename + // only disable when basename is enabled + basename = basename && !pattern.includes('/') + if (micromatch.isMatch(path, pattern, { ...options, basename })) { + return true + } + } + } + } + return false +} + +/** + * @param {string[]} paths + * @param {string | string[]} patterns + * @param {{ basename: string }} options + */ +function match(paths = [], patterns = [], options = {}) { + let input = paths + if (paths && patterns) { + if (paths.length && patterns.length) { + /** @type {string[]} */ + const output = [] + if (typeof patterns === 'string') patterns = [patterns] + const exclude = patterns.filter(pattern => pattern.startsWith('!')) + const include = patterns.filter(pattern => !pattern.startsWith('!')) + if (exclude.length) input = micromatch(paths, exclude, options) + if (include.length) { + for (const pattern of include) { + let basename = options.basename + basename = basename && !pattern.includes('/') + const tmp = micromatch(input, pattern, { ...options, basename }) + if (tmp.length) output.push(...tmp) + } + return [...new Set(output)] + } + return input + } + } + return paths +} + +/** + * @param {{ length: number }} original + * @param {{ length: number }} minified + * @param {string} path + * @param {string} ext + */ +function logFn(original, minified, path, ext) { + const saved = ((original.length - minified.length) / original.length * 100).toFixed(2) + const log = this.log || console + log.log(`${ext}: ${path} [${saved}% saved]`) +} + +module.exports = { + isMatch, + match, + logFn +} \ No newline at end of file diff --git a/lib/xml.js b/lib/xml.js new file mode 100644 index 0000000..d13061c --- /dev/null +++ b/lib/xml.js @@ -0,0 +1,41 @@ +'use strict' +const { match, logFn } = require('./tools') + +/** + * @this {import('@types/hexo')} + */ +async function minifyXml() { + const { minify: compressXml } = await import('minify-xml') + + const hexo = this + const options = hexo.config.minify.xml + + const route = hexo.route + /** @type {string[]} */ + const routeList = route.list() + const { globOptions, include, verbose } = options + + await Promise.all((match(routeList, include, globOptions)).map(path => { + return new Promise((/** @type {(value: void) => void} */ resolve, reject) => { + const assetPath = route.get(path) + let assetTxt = '' + assetPath.on('data', chunk => (assetTxt += chunk)) + assetPath.on('end', () => { + if (assetTxt.length) { + try { + const result = compressXml(assetTxt, { ...options }) + if (verbose) logFn.call(this, assetTxt, result, path, 'xml') + route.set(path, result) + } catch (err) { + reject(new Error(`Path: ${path}\n${err}`)) + } + } + resolve() + }) + }) + })) +} + +module.exports = { + minifyXml +} \ No newline at end of file diff --git a/lib/zlib.js b/lib/zlib.js new file mode 100644 index 0000000..7b0a0c2 --- /dev/null +++ b/lib/zlib.js @@ -0,0 +1,87 @@ +'use strict' +const zlib = require('zlib') +const { promisify } = require('util') +const gzip = promisify(zlib.gzip) +const br = promisify(zlib.brotliCompress) +const { match, logFn } = require('./tools') + +/** + * @this {import('@types/hexo')} + */ +function gzipFn() { + const hexo = this + const options = hexo.config.minify.gzip + + const route = hexo.route + /** @type {string[]} */ + const routeList = route.list() + const { globOptions, include, verbose, ensureCompressed } = options + let level = options.level + if (typeof level !== 'number') level = zlib.constants.Z_BEST_COMPRESSION + + return Promise.all((match(routeList, include, globOptions)).map(path => { + return new Promise((/** @type {(value: void) => void} */ resolve, reject) => { + const assetPath = route.get(path) + let assetTxt = '' + assetPath.on('data', chunk => (assetTxt += chunk)) + assetPath.on('end', async () => { + if (assetTxt.length) { + try { + const result = await gzip(assetTxt, { level }) + const buffer = Buffer.from(assetTxt) + if (verbose) logFn.call(this, buffer, result, path, 'gzip') + if (!ensureCompressed || buffer.length > result.length) { + route.set(path + '.gz', result) + } + } catch (err) { + reject(new Error(`Path: ${path}\n${err}`)) + } + } + resolve() + }) + }) + })) +} + +/** + * @this {import('@types/hexo')} + */ +function brotliFn() { + const hexo = this + const options = hexo.config.minify.brotli + + const route = hexo.route + /** @type {string[]} */ + const routeList = route.list() + const { globOptions, include, verbose, ensureCompressed } = options + let level = options.level + if (typeof level !== 'number') level = zlib.constants.BROTLI_MAX_QUALITY + + return Promise.all((match(routeList, include, globOptions)).map(path => { + return new Promise((/** @type {(value: void) => void} */ resolve, reject) => { + const assetPath = route.get(path) + let assetTxt = '' + assetPath.on('data', chunk => (assetTxt += chunk)) + assetPath.on('end', async () => { + if (assetTxt.length) { + try { + const result = await br(assetTxt, { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: level } }) + const buffer = Buffer.from(assetTxt) + if (verbose) logFn.call(this, buffer, result, path, 'brotli') + if (!ensureCompressed || buffer.length > result.length) { + route.set(path + '.br', result) + } + } catch (err) { + reject(new Error(`Path: ${path}\n${err}`)) + } + } + resolve() + }) + }) + })) +} + +module.exports = { + gzipFn, + brotliFn +} \ No newline at end of file diff --git a/lib/zstd.js b/lib/zstd.js new file mode 100644 index 0000000..1b6701c --- /dev/null +++ b/lib/zstd.js @@ -0,0 +1,72 @@ +'use strict' + +/** + * @returns {{ compress: (data: Buffer, level?: number) => Promise, init: () => Promise }} + */ +function importZstd() { + try { + const { compress } = require('@mongodb-js/zstd') + return { compress }; + } + catch (ex) { + try { + const { init, compress } = require('@bokuweb/zstd-wasm') + return { + compress: async (buf, level) => Buffer.from(compress(buf, level)), + init + } + } + catch { + throw ex; + } + } +} + +const { compress: zstd, init = undefined } = importZstd() +const { match, logFn } = require('./tools') + +/** + * @this {import('@types/hexo')} + */ +async function zstdFn() { + const hexo = this + const options = hexo.config.minify.zstd + + const route = hexo.route + /** @type {string[]} */ + const routeList = route.list() + const { globOptions, include, verbose, ensureCompressed } = options + let level = options.level + if (typeof level !== 'number') level = undefined + + if (typeof init === 'function') { + await init(); + } + + await Promise.all((match(routeList, include, globOptions)).map(path => { + return new Promise((/** @type {(value: void) => void} */ resolve, reject) => { + const assetPath = route.get(path) + let assetTxt = '' + assetPath.on('data', chunk => (assetTxt += chunk)) + assetPath.on('end', async () => { + if (assetTxt.length) { + try { + const input = Buffer.from(assetTxt, 'utf-8') + const result = await zstd(input, level) + if (verbose) logFn.call(this, input, result, path, 'zstd') + if (!ensureCompressed || input.length > result.length) { + route.set(path + '.zst', result) + } + } catch (err) { + reject(new Error(`Path: ${path}\n${err}`)) + } + } + resolve() + }) + }) + })) +} + +module.exports = { + zstdFn +} \ No newline at end of file diff --git a/package.json b/package.json index 1ff3593..82a2b89 100644 --- a/package.json +++ b/package.json @@ -29,16 +29,27 @@ "clean-css": "^5.1.2", "html-minifier-terser": "^7.2.0", "micromatch": "^4.0.2", - "minify-xml": "^3.2.0", + "minify-xml": "^4.5.2", "svgo": "^3.0.0", - "terser": "^5.3.0", - "@mongodb-js/zstd": "^1.2.0" + "terser": "^5.3.0" }, "devDependencies": { "hexo": "^7.1.0", "jest": "^29.1.2", "standard": "^17.0.0" }, + "peerDependencies": { + "@mongodb-js/zstd": "^2.0.0", + "@bokuweb/zstd-wasm": "^0.0.22" + }, + "peerDependenciesMeta": { + "@mongodb-js/zstd": { + "optional": true + }, + "@bokuweb/zstd-wasm": { + "optional": true + } + }, "keywords": [ "minify", "compress",