html-minifier-next
Version:
Super-configurable and well-tested web page minifier (enhanced successor of HTML Minifier)
411 lines (360 loc) • 15 kB
JavaScript
// Imports
import { createUrlMinifier } from './urls.js';
import { LRU, stableStringify, identity, lowercase, replaceAsync, parseRegExp } from './utils.js';
import { RE_TRAILING_SEMICOLON } from './constants.js';
import { canCollapseWhitespace, canTrimWhitespace } from './whitespace.js';
import { wrapCSS, unwrapCSS } from './content.js';
import { getPreset, getPresetNames } from '../presets.js';
import { optionDefaults } from './option-definitions.js';
// Helper functions
function shouldMinifyInnerHTML(options) {
return Boolean(
options.collapseWhitespace ||
options.removeComments ||
options.removeOptionalTags ||
options.minifyJS !== identity ||
options.minifyCSS !== identity ||
options.minifyURLs !== identity ||
options.minifySVG
);
}
// Main options processor
/**
* @param {Partial<MinifierOptions>} inputOptions - User-provided options
* @param {Object} deps - Dependencies from htmlminifier.js
* @param {Function} deps.getLightningCSS - Function to lazily load Lightning CSS
* @param {Function} deps.getTerser - Function to lazily load Terser
* @param {Function} deps.getSwc - Function to lazily load @swc/core
* @param {Function} deps.getSvgo - Function to lazily load SVGO
* @param {LRU} deps.cssMinifyCache - CSS minification cache
* @param {LRU} deps.jsMinifyCache - JS minification cache
* @param {LRU} deps.svgMinifyCache - SVG minification cache
* @returns {MinifierOptions} Normalized options with defaults applied
*/
const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, getSvgo, cssMinifyCache, jsMinifyCache, svgMinifyCache } = {}) => {
const options = {
name: lowercase,
canCollapseWhitespace,
canTrimWhitespace,
...optionDefaults,
log: identity,
minifyCSS: identity,
minifyJS: identity,
minifyURLs: identity,
minifySVG: null
};
const parseRegExpArray = (arr) => {
return Array.isArray(arr) ? arr.map(parseRegExp) : arr;
};
// Helper for nested arrays (e.g., `customAttrSurround: [[start, end], …]`)
const parseNestedRegExpArray = (arr) => {
if (!Array.isArray(arr)) return arr;
return arr.map(item => {
// If item is an array (a pair), recursively convert each element
if (Array.isArray(item)) {
return item.map(parseRegExp);
}
// Otherwise, convert single item
return parseRegExp(item);
});
};
// Merge preset with user options so all values go through normalization
// User options take precedence over preset values
let effectiveInput = inputOptions;
if (inputOptions.preset) {
const preset = getPreset(inputOptions.preset);
if (preset) {
effectiveInput = { ...preset, ...inputOptions };
} else {
const available = getPresetNames().join(', ');
console.warn(`HTML Minifier Next: Unknown preset “${inputOptions.preset}”. Available presets: ${available}`);
}
}
Object.keys(effectiveInput).forEach(function (key) {
const option = effectiveInput[key];
// Skip preset key—it’s already been processed
if (key === 'preset') {
return;
}
if (key === 'caseSensitive') {
if (option) {
options.name = identity;
}
} else if (key === 'log') {
if (typeof option === 'function') {
options.log = option;
}
} else if (key === 'minifyCSS' && typeof option !== 'function') {
if (!option) {
return;
}
const lightningCssOptions = typeof option === 'object' ? option : {};
options.minifyCSS = async function (text, type) {
// Fast path: Nothing to minify
if (!text || !text.trim()) {
return text;
}
// Optimization: Only process URLs if minification is enabled (not identity function)
// This avoids expensive `replaceAsync` when URL minification is disabled
if (options.minifyURLs !== identity) {
text = await replaceAsync(
text,
/(url\s*\(\s*)(?:"([^"]*)"|'([^']*)'|([^\s)]+))(\s*\))/ig,
async function (match, prefix, dq, sq, unq, suffix) {
const quote = dq != null ? '"' : (sq != null ? "'" : '');
const url = dq ?? sq ?? unq ?? '';
try {
const out = await options.minifyURLs(url);
return prefix + quote + (typeof out === 'string' ? out : url) + quote + suffix;
} catch (err) {
if (!options.continueOnMinifyError) {
throw err;
}
options.log && options.log(err);
return match;
}
}
);
}
// Cache key: Wrapped content, type, options signature
const inputCSS = wrapCSS(text, type);
const cssSig = stableStringify({ type, opts: lightningCssOptions, cont: !!options.continueOnMinifyError });
// For large inputs, use length and content fingerprint (first/last 50 chars) to prevent collisions
const cssKey = inputCSS.length > 2048
? (inputCSS.length + '|' + inputCSS.slice(0, 50) + inputCSS.slice(-50) + '|' + type + '|' + cssSig)
: (inputCSS + '|' + type + '|' + cssSig);
try {
const cached = cssMinifyCache.get(cssKey);
if (cached) {
// Support both resolved values and in-flight promises
return await cached;
}
// In-flight promise caching: Prevent duplicate concurrent minifications
// of the same CSS content (same pattern as JS minification)
const inFlight = (async () => {
const transformCSS = await getLightningCSS();
// Note: `Buffer.from()` is required—Lightning CSS API expects Uint8Array
const result = transformCSS({
filename: 'input.css',
code: Buffer.from(inputCSS),
minify: true,
errorRecovery: !!options.continueOnMinifyError,
...lightningCssOptions
});
const outputCSS = unwrapCSS(result.code.toString(), type);
// If Lightning CSS removed significant content that looks like template syntax or UIDs, return original
// This preserves:
// 1. Template code like `<?php ?>`, `<%= ?>`, `{{ }}`, etc. (contain `<` or `>` but not `CDATA`)
// 2. UIDs representing custom fragments (only lowercase letters and digits, no spaces)
// CDATA sections, HTML entities, and other invalid CSS are allowed to be removed
const isCDATA = text.includes('<![CDATA[');
const uidPattern = /[a-z0-9]{10,}/; // UIDs are long alphanumeric strings
const hasUID = uidPattern.test(text) && !isCDATA; // Exclude CDATA from UID detection
const looksLikeTemplate = (text.includes('<') || text.includes('>')) && !isCDATA;
// Preserve if output is empty and input had template syntax or UIDs
// This catches cases where Lightning CSS removed content that should be preserved
return (text.trim() && !outputCSS.trim() && (looksLikeTemplate || hasUID)) ? text : outputCSS;
})();
cssMinifyCache.set(cssKey, inFlight);
const resolved = await inFlight;
cssMinifyCache.set(cssKey, resolved);
return resolved;
} catch (err) {
cssMinifyCache.delete(cssKey);
if (!options.continueOnMinifyError) {
throw err;
}
options.log && options.log(err);
return text;
}
};
} else if (key === 'minifyJS' && typeof option !== 'function') {
if (!option) {
return;
}
// Parse configuration
const config = typeof option === 'object' ? option : {};
const engine = (config.engine || 'terser').toLowerCase();
// Validate engine
const supportedEngines = ['terser', 'swc'];
if (!supportedEngines.includes(engine)) {
throw new Error(`Unsupported JS minifier engine: “${engine}”. Supported engines: ${supportedEngines.join(', ')}`);
}
// Extract engine-specific options (excluding `engine` field itself)
const engineOptions = { ...config };
delete engineOptions.engine;
// Terser options (needed for inline JS and when engine is `terser`)
const terserOptions = engine === 'terser' ? engineOptions : {};
terserOptions.parse = {
...terserOptions.parse,
bare_returns: false
};
// SWC options (when engine is `swc`)
const swcOptions = engine === 'swc' ? engineOptions : {};
// Pre-compute option signatures once for performance (avoid repeated stringification)
const terserSig = stableStringify({
...terserOptions,
cont: !!options.continueOnMinifyError
});
const swcSig = stableStringify({
...swcOptions,
cont: !!options.continueOnMinifyError
});
options.minifyJS = async function (text, inline) {
const start = text.match(/^\s*<!--.*/);
const code = start ? text.slice(start[0].length).replace(/\n\s*-->\s*$/, '') : text;
// Fast path: Avoid invoking minifier for empty/whitespace-only content
if (!code || !code.trim()) {
return '';
}
// Hybrid strategy: Always use Terser for inline JS (needs bare returns support)
// Use user’s chosen engine for script blocks
const useEngine = inline ? 'terser' : engine;
let jsKey;
try {
// Select pre-computed signature based on engine
const optsSig = useEngine === 'terser' ? terserSig : swcSig;
// For large inputs, use length and content fingerprint to prevent collisions
jsKey = (code.length > 2048 ? (code.length + '|' + code.slice(0, 50) + code.slice(-50) + '|') : (code + '|'))
+ (inline ? '1' : '0') + '|' + useEngine + '|' + optsSig;
const cached = jsMinifyCache.get(jsKey);
if (cached) {
return await cached;
}
const inFlight = (async () => {
// Dispatch to appropriate minifier
if (useEngine === 'terser') {
// Create a copy to avoid mutating shared `terserOptions` (race condition)
const terserCallOptions = {
...terserOptions,
parse: {
...terserOptions.parse,
bare_returns: inline
}
};
const terser = await getTerser();
const result = await terser(code, terserCallOptions);
return result.code.replace(RE_TRAILING_SEMICOLON, '');
} else if (useEngine === 'swc') {
const swc = await getSwc();
// `swc.minify()` takes compress and mangle directly as options
const result = await swc.minify(code, {
compress: true,
mangle: true,
...swcOptions, // User options override defaults
});
return result.code.replace(RE_TRAILING_SEMICOLON, '');
}
throw new Error(`Unknown JS minifier engine: ${useEngine}`);
})();
jsMinifyCache.set(jsKey, inFlight);
const resolved = await inFlight;
jsMinifyCache.set(jsKey, resolved);
return resolved;
} catch (err) {
if (jsKey) jsMinifyCache.delete(jsKey);
if (!options.continueOnMinifyError) {
throw err;
}
options.log && options.log(err);
return text;
}
};
} else if (key === 'minifyURLs' && typeof option !== 'function') {
if (!option) {
return;
}
let urlOptions = option;
if (typeof option === 'string') {
urlOptions = { site: option };
} else if (typeof option !== 'object') {
urlOptions = {};
}
const relate = createUrlMinifier(urlOptions.site || '');
// Create instance-specific cache (results depend on site configuration)
const instanceCache = new LRU(500);
options.minifyURLs = function (text) {
// Fast-path: Skip if text doesn’t look like a URL that needs processing
// Only process if contains URL-like characters (`/`, `:`, `#`, `?`) or spaces that need encoding
if (!/[/:?#\s]/.test(text)) {
return text;
}
// Check cache
const cached = instanceCache.get(text);
if (cached !== undefined) {
return cached;
}
try {
const result = relate(text);
instanceCache.set(text, result);
return result;
} catch (err) {
// Don’t cache errors
if (!options.continueOnMinifyError) {
throw err;
}
options.log && options.log(err);
return text;
}
};
} else if (key === 'minifySVG' && typeof option !== 'function') {
if (!option) {
return;
}
const svgoOptions = typeof option === 'object' ? option : {};
// Pre-compute option signature for cache keys
const svgSig = stableStringify({
...svgoOptions,
cont: !!options.continueOnMinifyError
});
options.minifySVG = async function (svgContent) {
if (!svgContent || !svgContent.trim()) {
return svgContent;
}
// Cache key
const svgKey = svgContent.length > 2048
? (svgContent.length + '|' + svgContent.slice(0, 50) + svgContent.slice(-50) + '|' + svgSig)
: (svgContent + '|' + svgSig);
try {
const cached = svgMinifyCache.get(svgKey);
if (cached) {
return await cached;
}
const inFlight = (async () => {
const optimize = await getSvgo();
const result = optimize(svgContent, svgoOptions);
return result.data;
})();
svgMinifyCache.set(svgKey, inFlight);
const resolved = await inFlight;
svgMinifyCache.set(svgKey, resolved);
return resolved;
} catch (err) {
svgMinifyCache.delete(svgKey);
if (!options.continueOnMinifyError) {
throw err;
}
options.log && options.log(err);
return svgContent;
}
};
} else if (key === 'customAttrCollapse') {
// Single regex pattern
options[key] = parseRegExp(option);
} else if (key === 'customAttrSurround') {
// Nested array of RegExp pairs: `[[openRegExp, closeRegExp], …]`
options[key] = parseNestedRegExpArray(option);
} else if (['customAttrAssign', 'customEventAttributes', 'ignoreCustomComments', 'ignoreCustomFragments'].includes(key)) {
// Array of regex patterns
options[key] = parseRegExpArray(option);
} else {
options[key] = option;
}
});
return options;
};
// Exports
export {
shouldMinifyInnerHTML,
processOptions
};