UNPKG

htmlnano

Version:

Modular HTML minifier, built on top of the PostHTML

281 lines (278 loc) 11.5 kB
import posthtml from 'posthtml'; import { cosmiconfigSync } from 'cosmiconfig'; import safePreset from './presets/safe.mjs'; import ampSafePreset from './presets/ampSafe.mjs'; import maxPreset from './presets/max.mjs'; const presets = { safe: safePreset, ampSafe: ampSafePreset, max: maxPreset }; function loadConfig(options, preset) { const { skipConfigLoading = false, configPath, ...rest } = options || {}; let restConfig = rest; if (!skipConfigLoading) { const explorer = cosmiconfigSync('htmlnano'); const rc = configPath ? explorer.load(configPath) : explorer.search(); if (rc) { const { preset: presetName } = rc.config; if (presetName) { if (!preset && presetName in presets) { preset = presets[presetName]; } delete rc.config.preset; } restConfig = { ...rc.config, ...restConfig }; } } return [ restConfig || {}, preset || safePreset ]; } const optionalDependencies = { minifyCss: [ 'cssnano', 'postcss' ], minifyJs: [ 'terser' ], minifyUrls: [ 'relateurl', 'srcset', 'terser' ], minifySvg: [ 'svgo' ] }; /** * And the old mixing named export and default export again. * * TL; DR: our bundler has bundled our mixed default/named export module into a "exports" object, * and when dynamically importing a CommonJS module using "import" instead of "require", Node.js wraps * another layer of default around the "exports" object. * * The longer version: * * The bundler we are using outputs: * * ESM: export { [named], xxx as default } * CJS: exports.default = xxx; exports.[named] = ...; exports.__esModule = true; * * With ESM, the Module object looks like this: * * ```js * Module { * default: xxx, * [named]: ..., * } * ``` * * With CJS, Node.js handles dynamic import differently. Node.js doesn't respect `__esModule`, * and will wrongly treat a CommonJS module as ESM, i.e. assign the "exports" object on its * own "default" on the "Module" object. * * Now we have: * * ```js * Module { * // this is actually the "exports" inside among "exports.__esModule", "exports.[named]", and "exports.default" * default: { * __esModule: true, * // This is the actual "exports.default" * default: xxx * } * } * ``` */ const interop = (imported)=>imported.then((mod)=>{ let htmlnanoModule; while('default' in mod){ htmlnanoModule = mod; mod = mod.default; // If we find any htmlnano module hook methods, we know this object is a htmlnano module, return directly if ('onAttrs' in mod || 'onContent' in mod || 'onNode' in mod) { return mod; } } if (htmlnanoModule && typeof htmlnanoModule.default === 'function') { return htmlnanoModule; } throw new TypeError('The imported module is not a valid htmlnano module'); }); const modules = { collapseAttributeWhitespace: ()=>interop(import('./_modules/collapseAttributeWhitespace.mjs')), collapseBooleanAttributes: ()=>interop(import('./_modules/collapseBooleanAttributes.mjs')), collapseWhitespace: ()=>interop(import('./_modules/collapseWhitespace.mjs')), custom: ()=>interop(import('./_modules/custom.mjs')), deduplicateAttributeValues: ()=>interop(import('./_modules/deduplicateAttributeValues.mjs')), // example: () => import('./_modules/example.mjs'), mergeScripts: ()=>interop(import('./_modules/mergeScripts.mjs')), mergeStyles: ()=>interop(import('./_modules/mergeStyles.mjs')), minifyCharacterReferences: ()=>interop(import('./_modules/minifyCharacterReferences.mjs')), minifyConditionalComments: ()=>interop(import('./_modules/minifyConditionalComments.mjs')), minifyCss: ()=>interop(import('./_modules/minifyCss.mjs')), minifyHtmlTemplate: ()=>interop(import('./_modules/minifyHtmlTemplate.mjs')), minifyJs: ()=>interop(import('./_modules/minifyJs.mjs')), minifyJson: ()=>interop(import('./_modules/minifyJson.mjs')), minifyAttributes: ()=>interop(import('./_modules/minifyAttributes.mjs')), minifySvg: ()=>interop(import('./_modules/minifySvg.mjs')), minifyUrls: ()=>interop(import('./_modules/minifyUrls.mjs')), normalizeAttributeValues: ()=>interop(import('./_modules/normalizeAttributeValues.mjs')), normalizeDoctype: ()=>interop(import('./_modules/normalizeDoctype.mjs')), removeAttributeQuotes: ()=>interop(import('./_modules/removeAttributeQuotes.mjs')), removeComments: ()=>interop(import('./_modules/removeComments.mjs')), removeEmptyAttributes: ()=>interop(import('./_modules/removeEmptyAttributes.mjs')), removeEmptyElements: ()=>interop(import('./_modules/removeEmptyElements.mjs')), removeOptionalTags: ()=>interop(import('./_modules/removeOptionalTags.mjs')), removeRedundantAttributes: ()=>interop(import('./_modules/removeRedundantAttributes.mjs')), removeUnusedCss: ()=>interop(import('./_modules/removeUnusedCss.mjs')), removeXmlLeftovers: ()=>interop(import('./_modules/removeXmlLeftovers.mjs')), sortAttributes: ()=>interop(import('./_modules/sortAttributes.mjs')), sortAttributesWithLists: ()=>interop(import('./_modules/sortAttributesWithLists.mjs')) }; const htmlnano = Object.assign(function htmlnano(optionsRun = {}, presetRun) { // eslint-disable-next-line prefer-const -- re-assign options let [options, preset] = loadConfig(optionsRun, presetRun); const minifier = async (_tree)=>{ const tree = _tree; const nodeHandlers = []; const attrsHandlers = []; const contentsHandlers = []; options = { ...preset, ...options }; let promise = Promise.resolve(tree); const nonModuleOptions = new Set([ 'skipInternalWarnings' ]); for (const [moduleName, moduleOptions] of Object.entries(options)){ if (nonModuleOptions.has(moduleName)) { continue; } if (!moduleOptions) { continue; } if (!(moduleName in safePreset)) { throw new Error('Module "' + moduleName + '" is not defined'); } if (moduleName in optionalDependencies) { const modules = optionalDependencies[moduleName]; await Promise.all(modules.map(async (dependency)=>{ try { await import(dependency); } catch (e) { if (typeof e === 'object' && e !== null && 'code' in e && typeof e.code === 'string') { if (e.code === 'MODULE_NOT_FOUND' || e.code === 'ERR_MODULE_NOT_FOUND') { if (!options.skipInternalWarnings) { console.warn(`You have to install "${dependency}" in order to use htmlnano's "${moduleName}" module`); return; } } throw e; } } })); } const mod = moduleName in modules ? await modules[moduleName]() : await import(`./_modules/${moduleName}.mjs`); // `moduleName` is a validated module key (see the `safePreset` check // above), so the corresponding option value is typed by HtmlnanoOptions. const typedModuleOptions = moduleOptions; if (typeof mod.onAttrs === 'function') { attrsHandlers.push(mod.onAttrs(options, typedModuleOptions)); } if (typeof mod.onContent === 'function') { contentsHandlers.push(mod.onContent(options, typedModuleOptions)); } if (typeof mod.onNode === 'function') { nodeHandlers.push(mod.onNode(options, typedModuleOptions)); } if (typeof mod.default === 'function') { promise = promise.then(async (tree)=>await mod.default(tree, options, typedModuleOptions)); } } if (attrsHandlers.length + contentsHandlers.length + nodeHandlers.length === 0) { return promise; } return promise.then((tree)=>{ tree.walk((node)=>{ if (node) { if (node.attrs && typeof node.attrs === 'object') { // Convert all attrs' key to lower case let newAttrsObj = {}; Object.entries(node.attrs).forEach(([attrName, attrValue])=>{ newAttrsObj[attrName.toLowerCase()] = attrValue; }); for (const handler of attrsHandlers){ newAttrsObj = handler(newAttrsObj, node); } node.attrs = newAttrsObj; } if (node.content) { node.content = typeof node.content === 'string' ? [ node.content ] : node.content; if (Array.isArray(node.content) && node.content.length > 0) { for (const handler of contentsHandlers){ var _node_content; const result = handler((_node_content = node.content) != null ? _node_content : [], node); node.content = Array.isArray(result) ? result : [ result ]; } } } for (const handler of nodeHandlers){ if (handler) { node = handler(node); } } } return node; }); return tree; }); }; return minifier; }, { presets, getRequiredOptionalDependencies, process, htmlMinimizerWebpackPluginMinify, loadConfig }); function getRequiredOptionalDependencies(optionsRun, presetRun) { const [options] = loadConfig(optionsRun, presetRun); const dependencies = Object.keys(options).flatMap((moduleName)=>{ if (moduleName in optionalDependencies) { return optionalDependencies[moduleName]; } return []; }); return [ ...new Set(dependencies) ]; } function process(html, options, preset, postHtmlOptions) { return posthtml([ htmlnano(options, preset) ]).process(html, postHtmlOptions); } // https://github.com/webpack-contrib/html-minimizer-webpack-plugin/blob/faca00f2219514bc671c5942685721f0b5dbaa70/src/utils.js#L74 function htmlMinimizerWebpackPluginMinify(input, minimizerOptions) { const [[, code]] = Object.entries(input); return process(code, minimizerOptions, presets.safe).then((result)=>{ return { code: result.html }; }); } if (typeof module !== 'undefined') { module.exports = htmlnano; } export { htmlnano as default, getRequiredOptionalDependencies, htmlMinimizerWebpackPluginMinify, loadConfig, presets, process };