htmlnano
Version:
Modular HTML minifier, built on top of the PostHTML
223 lines (219 loc) • 8.55 kB
JavaScript
Object.defineProperty(exports, '__esModule', { value: true });
var helpers_js = require('../helpers.js');
// Adopts from https://github.com/kangax/html-minifier/blob/51ce10f4daedb1de483ffbcccecc41be1c873da2/src/htmlminifier.js#L209-L221
const tagsHaveUriValuesForAttributes = new Set([
'a',
'area',
'link',
'base',
'object',
'blockquote',
'q',
'del',
'ins',
'form',
'input',
'head',
'audio',
'embed',
'iframe',
'img',
'script',
'track',
'video'
]);
const tagsHasHrefAttributes = new Set([
'a',
'area',
'link',
'base'
]);
const attributesOfImgTagHasUriValues = new Set([
'src',
'longdesc',
'usemap'
]);
const attributesOfObjectTagHasUriValues = new Set([
'classid',
'codebase',
'data',
'usemap'
]);
const tagsHasCiteAttributes = new Set([
'blockquote',
'q',
'ins',
'del'
]);
const tagsHasSrcAttributes = new Set([
'audio',
'embed',
'iframe',
'img',
'input',
'script',
'track',
'video',
/**
* https://html.spec.whatwg.org/#attr-source-src
*
* Although most of browsers recommend not to use "src" in <source>,
* but technically it does comply with HTML Standard.
*/ 'source'
]);
const isUriTypeAttribute = (tag, attr)=>{
return tagsHasHrefAttributes.has(tag) && attr === 'href' || tag === 'img' && attributesOfImgTagHasUriValues.has(attr) || tag === 'object' && attributesOfObjectTagHasUriValues.has(attr) || tagsHasCiteAttributes.has(tag) && attr === 'cite' || tag === 'form' && attr === 'action' || tag === 'input' && attr === 'usemap' || tag === 'head' && attr === 'profile' || tag === 'script' && attr === 'for' || tagsHasSrcAttributes.has(tag) && attr === 'src';
};
const isSrcsetAttribute = (tag, attr)=>{
return tag === 'source' && attr === 'srcset' || tag === 'img' && attr === 'srcset' || tag === 'link' && attr === 'imagesrcset';
};
const processModuleOptions = (options)=>{
// The stable relateurl (0.2.7) works with string base URLs, so normalize a
// URL instance into a string. (relateurl@1.0.0-alpha switched to the WHATWG
// URL API; revisit this once/if that reaches a stable release.)
if (typeof options === 'string') return options;
if (options instanceof URL) return options.toString();
return false;
};
const isLinkRelCanonical = ({ tag, attrs })=>{
// Return false early for non-"link" tag
if (tag !== 'link' || !attrs) return false;
for (const [attrName, attrValue] of Object.entries(attrs)){
if (attrName.toLowerCase() === 'rel' && attrValue === 'canonical') return true;
}
return false;
};
const JAVASCRIPT_URL_PROTOCOL = 'javascript:';
let relateUrlInstance;
let STORED_URL_BASE;
/** Convert absolute url into relative url */ const mod = {
async default (tree, options, moduleOptions) {
const RelateUrl = await helpers_js.optionalImport('relateurl');
const srcset = await helpers_js.optionalImport('srcset');
const terser = await helpers_js.optionalImport('terser');
const promises = [];
const urlBase = processModuleOptions(moduleOptions);
// Invalid configuration, return tree directly
if (!urlBase) return tree;
/** Bring up a reusable RelateUrl instances (only once)
*
* STORED_URL_BASE is used to invalidate RelateUrl instances,
* avoiding require.cache acrossing multiple htmlnano instance with different configuration,
* e.g. unit tests cases.
*/ if (!relateUrlInstance || STORED_URL_BASE !== urlBase) {
if (RelateUrl) {
relateUrlInstance = new RelateUrl(urlBase);
}
STORED_URL_BASE = urlBase;
}
tree.walk((node)=>{
if (!node.attrs) return node;
if (!node.tag) return node;
if (!tagsHaveUriValuesForAttributes.has(node.tag)) return node;
// Prevent link[rel=canonical] being processed
// Can't be excluded by isUriTypeAttribute()
if (isLinkRelCanonical(node)) return node;
for (const [attrName, attrValue] of Object.entries(node.attrs)){
const attrNameLower = attrName.toLowerCase();
if (isUriTypeAttribute(node.tag, attrNameLower)) {
if (typeof attrValue !== 'string') continue;
const javascriptMatch = getJavaScriptUrlMatch(attrValue);
if (javascriptMatch) {
promises.push(minifyJavaScriptUrl(node, attrName, javascriptMatch, terser));
continue;
}
if (relateUrlInstance) {
node.attrs[attrName] = relateUrlValue(relateUrlInstance, attrValue);
}
continue;
}
if (isSrcsetAttribute(node.tag, attrNameLower)) {
if (srcset && typeof attrValue === 'string') {
try {
const parsedSrcset = srcset.parseSrcset(attrValue, {
strict: true
});
node.attrs[attrName] = srcset.stringifySrcset(parsedSrcset.map((item)=>{
if (relateUrlInstance) {
// @ts-expect-error -- not actually readonly
item.url = relateUrlValue(relateUrlInstance, item.url);
}
return item;
}));
} catch {
// srcset will throw an Error for invalid srcset.
}
}
continue;
}
}
return node;
});
if (promises.length > 0) return Promise.all(promises).then(()=>tree);
return Promise.resolve(tree);
}
};
const jsWrapperStart = 'function a(){';
const jsWrapperEnd = '}a();';
const javascriptUrlRegex = /^(\s*)(javascript:)([\s\S]*)$/i;
function getJavaScriptUrlMatch(url) {
const match = javascriptUrlRegex.exec(url);
if (!match) return null;
return {
leadingWhitespace: match[1],
code: match[3]
};
}
function getUrlScheme(value) {
const match = /^[a-z][a-z0-9+.-]*:/i.exec(value);
if (!match) return null;
return match[0].slice(0, -1).toLowerCase();
}
function shouldRelateUrlValue(value) {
const trimmed = value.trim();
if (!trimmed) return false;
if (trimmed.startsWith('#') || trimmed.startsWith('?')) return false;
const scheme = getUrlScheme(trimmed);
if (scheme) return scheme === 'http' || scheme === 'https';
return true;
}
function relateUrlValue(relateUrl, value) {
if (!shouldRelateUrlValue(value)) return value;
// relateUrl#relate is wrapped in try...catch because attrValue might not be
// a valid URL, and relateurl can throw for malformed input.
try {
// relateurl (with its default `defaultPorts`) already strips default ports
// (e.g. `:443` for https, `:80` for http, but not mismatched ones)
// and a trailing empty query ("?"), but it keeps a trailing empty
// fragment ("#"). Normalize the result to also drop that.
return normalizeRelatedUrl(relateUrl.relate(value));
} catch {
return value;
}
}
/**
* Remove a trailing lone "?" (empty query) or "#" (empty fragment).
* A non-empty query ("?x=1") or fragment ("#top") is left untouched.
*/ function normalizeRelatedUrl(value) {
// Drop an empty fragment ("#"). "#top" ends with "p", so it is untouched.
if (value.endsWith('#')) {
value = value.slice(0, -1);
}
// Drop an empty query ("?"), including one exposed by removing a "?#" tail.
// "?x=1" ends with "1", so it is untouched.
if (value.endsWith('?')) {
value = value.slice(0, -1);
}
return value;
}
function minifyJavaScriptUrl(node, attrName, match, terser) {
if (!terser) return Promise.resolve();
const result = jsWrapperStart + match.code + jsWrapperEnd;
return terser.minify(result, {}) // Default Option is good enough
.then(({ code })=>{
if (!code) return;
const minifiedJs = code.substring(jsWrapperStart.length, code.length - jsWrapperEnd.length);
node.attrs[attrName] = match.leadingWhitespace + JAVASCRIPT_URL_PROTOCOL + minifiedJs;
}).catch(()=>undefined);
}
exports.default = mod;