UNPKG

htmlnano

Version:

Modular HTML minifier, built on top of the PostHTML

221 lines (218 loc) 8.47 kB
import { optionalImport } from '../helpers.mjs'; // 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 optionalImport('relateurl'); const srcset = await optionalImport('srcset'); const terser = await 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); } export { mod as default };