htmlnano
Version:
Modular HTML minifier, built on top of the PostHTML
200 lines (197 loc) • 7.71 kB
JavaScript
import { isEventHandler } from '../helpers.mjs';
import { isListAttribute, isSingleValueAttribute } from './collapseAttributeWhitespace.mjs';
const asciiWhitespace = new Set([
'\t',
'\n',
'\f',
'\r',
' '
]);
const defaultOptions = {
metaContent: true,
redundantWhitespaces: 'safe'
};
function isAsciiWhitespace(char) {
return asciiWhitespace.has(char);
}
function isAsciiDigit(char) {
return char >= '0' && char <= '9';
}
function skipAsciiWhitespace(input, start) {
let pos = start;
while(pos < input.length && isAsciiWhitespace(input[pos])){
pos += 1;
}
return pos;
}
function minifyMetaRefreshValue(value) {
const input = value;
let pos = skipAsciiWhitespace(input, 0);
const timeStart = pos;
while(pos < input.length && isAsciiDigit(input[pos])){
pos += 1;
}
if (pos === timeStart) return null;
const time = input.slice(timeStart, pos);
while(pos < input.length){
const ch = input[pos];
if (isAsciiDigit(ch) || ch === '.') {
pos += 1;
continue;
}
break;
}
pos = skipAsciiWhitespace(input, pos);
if (pos >= input.length) return time;
const separator = input[pos];
if (separator !== ';' && separator !== ',') return null;
pos += 1;
pos = skipAsciiWhitespace(input, pos);
if (pos >= input.length) return time;
let hasUrlPrefix = false;
if (input[pos] === 'u' || input[pos] === 'U') {
if (pos + 2 < input.length) {
const maybeUrl = input.slice(pos, pos + 3);
if (maybeUrl.toLowerCase() === 'url') {
let prefixPos = skipAsciiWhitespace(input, pos + 3);
if (prefixPos < input.length && input[prefixPos] === '=') {
prefixPos = skipAsciiWhitespace(input, prefixPos + 1);
hasUrlPrefix = true;
pos = prefixPos;
}
}
}
}
if (pos >= input.length) return time;
const firstUrlChar = input[pos];
if (firstUrlChar === '"' || firstUrlChar === '\'') {
if (!hasUrlPrefix) return null;
const quote = firstUrlChar;
const urlStart = pos + 1;
const quoteIndex = input.indexOf(quote, urlStart);
const url = quoteIndex === -1 ? input.slice(urlStart) : input.slice(urlStart, quoteIndex);
if (!url) return time;
const closingQuote = quoteIndex === -1 ? '' : quote;
return `${time}${separator} URL=${quote}${url}${closingQuote}`;
}
const url = input.slice(pos).trim();
if (!url) return time;
return `${time}${separator} ${url}`;
}
function minifyViewportNumber(value) {
// Only strip a redundant trailing ".0"/".00"/"0" fraction when the result
// parses to the same number, e.g. "1.0" -> "1", "2.50" -> "2.5".
if (!/^[+-]?(?:\d+\.\d+|\.\d+|\d+\.)$/.test(value)) {
return value;
}
const trimmed = value.replace(/\.?0+$/, '');
const normalized = trimmed === '' || trimmed === '+' || trimmed === '-' ? value : trimmed;
return Number(normalized) === Number(value) ? normalized : value;
}
function minifyMetaViewportValue(value) {
// Viewport content is a comma/semicolon-separated list of key=value pairs.
// We normalize whitespace around separators and "=", and strip redundant
// trailing zeros in numeric values. The author's separator characters are
// preserved to stay conservative.
return value.trim().split(/([,;])/).map((part)=>{
if (part === ',' || part === ';') return part;
const trimmed = part.trim();
if (trimmed === '') return trimmed;
const eqIndex = trimmed.indexOf('=');
if (eqIndex === -1) return trimmed;
const key = trimmed.slice(0, eqIndex).trim();
const rawValue = trimmed.slice(eqIndex + 1).trim();
return `${key}=${minifyViewportNumber(rawValue)}`;
}).join('');
}
function isMetaViewport(attrs, tagName) {
if (!tagName || tagName.toLowerCase() !== 'meta') return false;
const name = attrs.name;
if (typeof name !== 'string') return false;
return name.trim().toLowerCase() === 'viewport';
}
function isMetaRefresh(attrs, tagName) {
if (!tagName || tagName.toLowerCase() !== 'meta') return false;
const httpEquiv = attrs['http-equiv'];
if (typeof httpEquiv !== 'string') return false;
return httpEquiv.trim().toLowerCase() === 'refresh';
}
function normalizeOptions(moduleOptions) {
if (moduleOptions && typeof moduleOptions === 'object') {
let redundantWhitespaces = defaultOptions.redundantWhitespaces;
if (moduleOptions.redundantWhitespaces === 'agressive') {
// Deprecated alias for 'aggressive'.
redundantWhitespaces = 'aggressive';
} else if (moduleOptions.redundantWhitespaces === 'safe' || moduleOptions.redundantWhitespaces === 'aggressive' || moduleOptions.redundantWhitespaces === false) {
redundantWhitespaces = moduleOptions.redundantWhitespaces;
}
return {
metaContent: moduleOptions.metaContent !== false,
redundantWhitespaces
};
}
return defaultOptions;
}
function collapseWhitespace(value) {
return value.replace(/\s+/g, ' ').trim();
}
function minifyAttributeWhitespace(mode, attrName, attrValue, tagName) {
if (!mode) {
return null;
}
const attrNameLower = attrName.toLowerCase();
if (isListAttribute(attrNameLower, tagName)) {
const collapsed = collapseWhitespace(attrValue);
return collapsed === attrValue ? null : collapsed;
}
if (isEventHandler(attrName)) {
const trimmed = attrValue.trim();
return trimmed === attrValue ? null : trimmed;
}
if (isSingleValueAttribute(attrNameLower, tagName)) {
const trimmed = attrValue.trim();
return trimmed === attrValue ? null : trimmed;
}
if (mode === 'aggressive') {
const trimmed = attrValue.trim();
return trimmed === attrValue ? null : trimmed;
}
return null;
}
const mod = {
onAttrs (_options, moduleOptions) {
const normalizedOptions = normalizeOptions(moduleOptions);
return (attrs, node)=>{
const tagName = typeof node.tag === 'string' ? node.tag.toLowerCase() : undefined;
if (normalizedOptions.metaContent && isMetaRefresh(attrs, tagName)) {
const content = attrs.content;
if (typeof content === 'string') {
const minified = minifyMetaRefreshValue(content);
if (minified !== null && minified !== content) {
attrs.content = minified;
}
}
}
if (normalizedOptions.metaContent && isMetaViewport(attrs, tagName)) {
const content = attrs.content;
if (typeof content === 'string') {
const minified = minifyMetaViewportValue(content);
if (minified !== content) {
attrs.content = minified;
}
}
}
if (normalizedOptions.redundantWhitespaces) {
Object.entries(attrs).forEach(([attrName, attrValue])=>{
if (typeof attrValue !== 'string') return;
const minified = minifyAttributeWhitespace(normalizedOptions.redundantWhitespaces, attrName, attrValue, tagName);
if (minified !== null) {
attrs[attrName] = minified;
}
});
}
return attrs;
};
}
};
export { mod as default };