larry babby and threejs for glsl

This commit is contained in:
Sam
2024-06-24 21:24:00 +12:00
parent 87d5dc634d
commit 907ebae4c0
6474 changed files with 1279596 additions and 8 deletions

View File

@@ -0,0 +1,86 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.attributesWithLists = void 0;
exports.onAttrs = onAttrs;
var _helpers = require("../helpers.cjs");
const attributesWithLists = exports.attributesWithLists = new Set(['class', 'dropzone', 'rel',
// a, area, link
'ping',
// a, area
'sandbox',
// iframe
/**
* https://github.com/posthtml/htmlnano/issues/180
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/link#attr-sizes
*
* "sizes" of <img> should not be modified, while "sizes" of <link> will only have one entry in most cases.
*/
// 'sizes', // link
'headers' // td, th
]);
/** @type Record<string, string[] | null> */
const attributesWithSingleValue = {
accept: ['input'],
action: ['form'],
accesskey: null,
'accept-charset': ['form'],
cite: ['blockquote', 'del', 'ins', 'q'],
cols: ['textarea'],
colspan: ['td', 'th'],
data: ['object'],
dropzone: null,
formaction: ['button', 'input'],
height: ['canvas', 'embed', 'iframe', 'img', 'input', 'object', 'video'],
high: ['meter'],
href: ['a', 'area', 'base', 'link'],
itemid: null,
low: ['meter'],
manifest: ['html'],
max: ['meter', 'progress'],
maxlength: ['input', 'textarea'],
media: ['source'],
min: ['meter'],
minlength: ['input', 'textarea'],
optimum: ['meter'],
ping: ['a', 'area'],
poster: ['video'],
profile: ['head'],
rows: ['textarea'],
rowspan: ['td', 'th'],
size: ['input', 'select'],
span: ['col', 'colgroup'],
src: ['audio', 'embed', 'iframe', 'img', 'input', 'script', 'source', 'track', 'video'],
start: ['ol'],
step: ['input'],
style: null,
tabindex: null,
usemap: ['img', 'object'],
value: ['li', 'meter', 'progress'],
width: ['canvas', 'embed', 'iframe', 'img', 'input', 'object', 'video']
};
/** Collapse whitespaces inside list-like attributes (e.g. class, rel) */
function onAttrs() {
return (attrs, node) => {
const newAttrs = attrs;
Object.entries(attrs).forEach(([attrName, attrValue]) => {
if (typeof attrValue !== 'string') return;
if (attributesWithLists.has(attrName)) {
const newAttrValue = attrValue.replace(/\s+/g, ' ').trim();
newAttrs[attrName] = newAttrValue;
return;
}
if ((0, _helpers.isEventHandler)(attrName) || Object.prototype.hasOwnProperty.call(attributesWithSingleValue, attrName) && (attributesWithSingleValue[attrName] === null || attributesWithSingleValue[attrName].includes(node.tag))) {
newAttrs[attrName] = minifySingleAttributeValue(attrValue);
}
});
return newAttrs;
};
}
function minifySingleAttributeValue(value) {
return typeof value === 'string' ? value.trim() : value;
}

View File

@@ -0,0 +1,104 @@
import { isEventHandler } from '../helpers.mjs';
export const attributesWithLists = new Set([
'class',
'dropzone',
'rel', // a, area, link
'ping', // a, area
'sandbox', // iframe
/**
* https://github.com/posthtml/htmlnano/issues/180
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/link#attr-sizes
*
* "sizes" of <img> should not be modified, while "sizes" of <link> will only have one entry in most cases.
*/
// 'sizes', // link
'headers' // td, th
]);
/** @type Record<string, string[] | null> */
const attributesWithSingleValue = {
accept: ['input'],
action: ['form'],
accesskey: null,
'accept-charset': ['form'],
cite: ['blockquote', 'del', 'ins', 'q'],
cols: ['textarea'],
colspan: ['td', 'th'],
data: ['object'],
dropzone: null,
formaction: ['button', 'input'],
height: ['canvas', 'embed', 'iframe', 'img', 'input', 'object', 'video'],
high: ['meter'],
href: ['a', 'area', 'base', 'link'],
itemid: null,
low: ['meter'],
manifest: ['html'],
max: ['meter', 'progress'],
maxlength: ['input', 'textarea'],
media: ['source'],
min: ['meter'],
minlength: ['input', 'textarea'],
optimum: ['meter'],
ping: ['a', 'area'],
poster: ['video'],
profile: ['head'],
rows: ['textarea'],
rowspan: ['td', 'th'],
size: ['input', 'select'],
span: ['col', 'colgroup'],
src: [
'audio',
'embed',
'iframe',
'img',
'input',
'script',
'source',
'track',
'video'
],
start: ['ol'],
step: ['input'],
style: null,
tabindex: null,
usemap: ['img', 'object'],
value: ['li', 'meter', 'progress'],
width: ['canvas', 'embed', 'iframe', 'img', 'input', 'object', 'video']
};
/** Collapse whitespaces inside list-like attributes (e.g. class, rel) */
export function onAttrs() {
return (attrs, node) => {
const newAttrs = attrs;
Object.entries(attrs).forEach(([attrName, attrValue]) => {
if (typeof attrValue !== 'string') return;
if (attributesWithLists.has(attrName)) {
const newAttrValue = attrValue.replace(/\s+/g, ' ').trim();
newAttrs[attrName] = newAttrValue;
return;
}
if (
isEventHandler(attrName)
|| (
Object.prototype.hasOwnProperty.call(attributesWithSingleValue, attrName)
&& (
attributesWithSingleValue[attrName] === null
|| attributesWithSingleValue[attrName].includes(node.tag)
)
)
) {
newAttrs[attrName] = minifySingleAttributeValue(attrValue);
}
});
return newAttrs;
};
}
function minifySingleAttributeValue(value) {
return typeof value === 'string' ? value.trim() : value;
}

View File

@@ -0,0 +1,62 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.onAttrs = onAttrs;
// Source: https://github.com/kangax/html-minifier/issues/63
// https://html.spec.whatwg.org/#boolean-attribute
// https://html.spec.whatwg.org/#attributes-1
const htmlBooleanAttributes = new Set(['allowfullscreen', 'allowpaymentrequest', 'allowtransparency', 'async', 'autofocus', 'autoplay', 'checked', 'compact', 'controls', 'declare', 'default', 'defaultchecked', 'defaultmuted', 'defaultselected', 'defer', 'disabled', 'enabled', 'formnovalidate', 'hidden', 'indeterminate', 'inert', 'ismap', 'itemscope', 'loop', 'multiple', 'muted', 'nohref', 'nomodule', 'noresize', 'noshade', 'novalidate', 'nowrap', 'open', 'pauseonexit', 'playsinline', 'readonly', 'required', 'reversed', 'scoped', 'seamless', 'selected', 'sortable', 'truespeed', 'typemustmatch', 'visible']);
const amphtmlBooleanAttributes = new Set(['⚡', 'amp', '⚡4ads', 'amp4ads', '⚡4email', 'amp4email', 'amp-custom', 'amp-boilerplate', 'amp4ads-boilerplate', 'amp4email-boilerplate', 'allow-blocked-ranges', 'amp-access-hide', 'amp-access-template', 'amp-keyframes', 'animate', 'arrows', 'data-block-on-consent', 'data-enable-refresh', 'data-multi-size', 'date-template', 'disable-double-tap', 'disable-session-states', 'disableremoteplayback', 'dots', 'expand-single-section', 'expanded', 'fallback', 'first', 'fullscreen', 'inline', 'lightbox', 'noaudio', 'noautoplay', 'noloading', 'once', 'open-after-clear', 'open-after-select', 'open-button', 'placeholder', 'preload', 'reset-on-refresh', 'reset-on-resize', 'resizable', 'rotate-to-fullscreen', 'second', 'standalone', 'stereo', 'submit-error', 'submit-success', 'submitting', 'subscriptions-actions', 'subscriptions-dialog']);
const missingValueDefaultEmptyStringAttributes = {
// https://html.spec.whatwg.org/#attr-media-preload
audio: {
preload: 'auto'
},
video: {
preload: 'auto'
}
};
const tagsHasMissingValueDefaultEmptyStringAttributes = new Set(Object.keys(missingValueDefaultEmptyStringAttributes));
function onAttrs(options, moduleOptions) {
return (attrs, node) => {
if (!node.tag) return attrs;
const newAttrs = attrs;
if (tagsHasMissingValueDefaultEmptyStringAttributes.has(node.tag)) {
const tagAttributesCanBeReplacedWithEmptyString = missingValueDefaultEmptyStringAttributes[node.tag];
for (const attributesCanBeReplacedWithEmptyString of Object.keys(tagAttributesCanBeReplacedWithEmptyString)) {
if (Object.prototype.hasOwnProperty.call(attrs, attributesCanBeReplacedWithEmptyString) && attrs[attributesCanBeReplacedWithEmptyString] === tagAttributesCanBeReplacedWithEmptyString[attributesCanBeReplacedWithEmptyString]) {
attrs[attributesCanBeReplacedWithEmptyString] = true;
}
}
}
for (const attrName of Object.keys(attrs)) {
if (attrName === 'visible' && node.tag.startsWith('a-')) {
continue;
}
if (htmlBooleanAttributes.has(attrName)) {
newAttrs[attrName] = true;
}
// Fast path optimization.
// The rest of tranformations are only for string type attrValue.
if (typeof newAttrs[attrName] !== 'string') continue;
if (moduleOptions.amphtml && amphtmlBooleanAttributes.has(attrName) && attrs[attrName] === '') {
newAttrs[attrName] = true;
}
// https://html.spec.whatwg.org/#a-quick-introduction-to-html
// The value, along with the "=" character, can be omitted altogether if the value is the empty string.
if (attrs[attrName] === '') {
newAttrs[attrName] = true;
}
// collapse crossorigin attributes
// Specification: https://html.spec.whatwg.org/multipage/urls-and-fetching.html#cors-settings-attributes
if (attrName.toLowerCase() === 'crossorigin' && attrs[attrName] === 'anonymous') {
newAttrs[attrName] = true;
}
}
return newAttrs;
};
}

View File

@@ -0,0 +1,175 @@
// Source: https://github.com/kangax/html-minifier/issues/63
// https://html.spec.whatwg.org/#boolean-attribute
// https://html.spec.whatwg.org/#attributes-1
const htmlBooleanAttributes = new Set([
'allowfullscreen',
'allowpaymentrequest',
'allowtransparency',
'async',
'autofocus',
'autoplay',
'checked',
'compact',
'controls',
'declare',
'default',
'defaultchecked',
'defaultmuted',
'defaultselected',
'defer',
'disabled',
'enabled',
'formnovalidate',
'hidden',
'indeterminate',
'inert',
'ismap',
'itemscope',
'loop',
'multiple',
'muted',
'nohref',
'nomodule',
'noresize',
'noshade',
'novalidate',
'nowrap',
'open',
'pauseonexit',
'playsinline',
'readonly',
'required',
'reversed',
'scoped',
'seamless',
'selected',
'sortable',
'truespeed',
'typemustmatch',
'visible'
]);
const amphtmlBooleanAttributes = new Set([
'⚡',
'amp',
'⚡4ads',
'amp4ads',
'⚡4email',
'amp4email',
'amp-custom',
'amp-boilerplate',
'amp4ads-boilerplate',
'amp4email-boilerplate',
'allow-blocked-ranges',
'amp-access-hide',
'amp-access-template',
'amp-keyframes',
'animate',
'arrows',
'data-block-on-consent',
'data-enable-refresh',
'data-multi-size',
'date-template',
'disable-double-tap',
'disable-session-states',
'disableremoteplayback',
'dots',
'expand-single-section',
'expanded',
'fallback',
'first',
'fullscreen',
'inline',
'lightbox',
'noaudio',
'noautoplay',
'noloading',
'once',
'open-after-clear',
'open-after-select',
'open-button',
'placeholder',
'preload',
'reset-on-refresh',
'reset-on-resize',
'resizable',
'rotate-to-fullscreen',
'second',
'standalone',
'stereo',
'submit-error',
'submit-success',
'submitting',
'subscriptions-actions',
'subscriptions-dialog'
]);
const missingValueDefaultEmptyStringAttributes = {
// https://html.spec.whatwg.org/#attr-media-preload
audio: {
preload: 'auto'
},
video: {
preload: 'auto'
}
};
const tagsHasMissingValueDefaultEmptyStringAttributes = new Set(Object.keys(missingValueDefaultEmptyStringAttributes));
export function onAttrs(options, moduleOptions) {
return (attrs, node) => {
if (!node.tag) return attrs;
const newAttrs = attrs;
if (tagsHasMissingValueDefaultEmptyStringAttributes.has(node.tag)) {
const tagAttributesCanBeReplacedWithEmptyString = missingValueDefaultEmptyStringAttributes[node.tag];
for (const attributesCanBeReplacedWithEmptyString of Object.keys(tagAttributesCanBeReplacedWithEmptyString)) {
if (
Object.prototype.hasOwnProperty.call(attrs, attributesCanBeReplacedWithEmptyString)
&& attrs[attributesCanBeReplacedWithEmptyString] === tagAttributesCanBeReplacedWithEmptyString[attributesCanBeReplacedWithEmptyString]
) {
attrs[attributesCanBeReplacedWithEmptyString] = true;
}
}
}
for (const attrName of Object.keys(attrs)) {
if (attrName === 'visible' && node.tag.startsWith('a-')) {
continue;
}
if (htmlBooleanAttributes.has(attrName)) {
newAttrs[attrName] = true;
}
// Fast path optimization.
// The rest of tranformations are only for string type attrValue.
if (typeof newAttrs[attrName] !== 'string') continue;
if (moduleOptions.amphtml && amphtmlBooleanAttributes.has(attrName) && attrs[attrName] === '') {
newAttrs[attrName] = true;
}
// https://html.spec.whatwg.org/#a-quick-introduction-to-html
// The value, along with the "=" character, can be omitted altogether if the value is the empty string.
if (attrs[attrName] === '') {
newAttrs[attrName] = true;
}
// collapse crossorigin attributes
// Specification: https://html.spec.whatwg.org/multipage/urls-and-fetching.html#cors-settings-attributes
if (
attrName.toLowerCase() === 'crossorigin' && (
attrs[attrName] === 'anonymous'
)
) {
newAttrs[attrName] = true;
}
}
return newAttrs;
};
}

View File

@@ -0,0 +1,100 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.default = collapseWhitespace;
var _helpers = require("../helpers.cjs");
const noWhitespaceCollapseElements = new Set(['script', 'style', 'pre', 'textarea']);
const noTrimWhitespacesArroundElements = new Set([
// non-empty tags that will maintain whitespace around them
'a', 'abbr', 'acronym', 'b', 'bdi', 'bdo', 'big', 'button', 'cite', 'code', 'del', 'dfn', 'em', 'font', 'i', 'ins', 'kbd', 'label', 'mark', 'math', 'nobr', 'object', 'q', 'rp', 'rt', 'rtc', 'ruby', 's', 'samp', 'select', 'small', 'span', 'strike', 'strong', 'sub', 'sup', 'svg', 'textarea', 'time', 'tt', 'u', 'var',
// self-closing tags that will maintain whitespace around them
'comment', 'img', 'input', 'wbr']);
const noTrimWhitespacesInsideElements = new Set([
// non-empty tags that will maintain whitespace within them
'a', 'abbr', 'acronym', 'b', 'big', 'del', 'em', 'font', 'i', 'ins', 'kbd', 'mark', 'nobr', 'rp', 's', 'samp', 'small', 'span', 'strike', 'strong', 'sub', 'sup', 'time', 'tt', 'u', 'var']);
const startsWithWhitespacePattern = /^\s/;
const endsWithWhitespacePattern = /\s$/;
// See https://infra.spec.whatwg.org/#strip-and-collapse-ascii-whitespace and https://infra.spec.whatwg.org/#ascii-whitespace
const multipleWhitespacePattern = /[\t\n\f\r ]+/g;
const NONE = '';
const SINGLE_SPACE = ' ';
const validOptions = ['all', 'aggressive', 'conservative'];
/** Collapses redundant whitespaces */
function collapseWhitespace(tree, options, collapseType, parent) {
collapseType = validOptions.includes(collapseType) ? collapseType : 'conservative';
tree.forEach((node, index) => {
const prevNode = tree[index - 1];
const nextNode = tree[index + 1];
if (typeof node === 'string') {
const parentNodeTag = parent && parent.node && parent.node.tag;
const isTopLevel = !parentNodeTag || parentNodeTag === 'html' || parentNodeTag === 'head';
const shouldTrim = collapseType === 'all' || isTopLevel ||
/*
* When collapseType is set to 'aggressive', and the tag is not inside 'noTrimWhitespacesInsideElements'.
* the first & last space inside the tag will be trimmed
*/
collapseType === 'aggressive';
node = collapseRedundantWhitespaces(node, collapseType, shouldTrim, parent, prevNode, nextNode);
}
const isAllowCollapseWhitespace = !noWhitespaceCollapseElements.has(node.tag);
if (node.content && node.content.length && isAllowCollapseWhitespace) {
node.content = collapseWhitespace(node.content, options, collapseType, {
node,
prevNode,
nextNode
});
}
tree[index] = node;
});
return tree;
}
function collapseRedundantWhitespaces(text, collapseType, shouldTrim = false, parent, prevNode, nextNode) {
if (!text || text.length === 0) {
return NONE;
}
if (!(0, _helpers.isComment)(text)) {
text = text.replace(multipleWhitespacePattern, SINGLE_SPACE);
}
if (shouldTrim) {
if (collapseType === 'aggressive') {
if (!noTrimWhitespacesInsideElements.has(parent && parent.node && parent.node.tag)) {
if (
// It is the first child node of the parent
!prevNode
// It is not the first child node, and prevNode not a text node, and prevNode is safe to trim around
|| prevNode && prevNode.tag && !noTrimWhitespacesArroundElements.has(prevNode.tag)) {
text = text.trimStart();
} else {
// previous node is a "no trim whitespaces arround element"
if (
// but previous node ends with a whitespace
prevNode && prevNode.content && prevNode.content.length && endsWithWhitespacePattern.test(prevNode.content[prevNode.content.length - 1]) && (!nextNode // either the current node is the last child of the parent
||
// or the next node starts with a white space
nextNode && nextNode.content && nextNode.content.length && !startsWithWhitespacePattern.test(nextNode.content[0]))) {
text = text.trimStart();
}
}
if (!nextNode || nextNode && nextNode.tag && !noTrimWhitespacesArroundElements.has(nextNode.tag)) {
text = text.trimEnd();
}
} else {
// now it is a textNode inside a "no trim whitespaces inside elements" node
if (!prevNode // it the textnode is the first child of the node
&& startsWithWhitespacePattern.test(text[0]) // it starts with white space
&& typeof parent.prevNode === 'string' // the prev of the node is a textNode as well
&& endsWithWhitespacePattern.test(parent.prevNode[parent.prevNode.length - 1]) // that prev is ends with a white
) {
text = text.trimStart();
}
}
} else {
// collapseType is 'all', trim spaces
text = text.trim();
}
}
return text;
}

View File

@@ -0,0 +1,132 @@
import { isComment } from '../helpers.mjs';
const noWhitespaceCollapseElements = new Set([
'script',
'style',
'pre',
'textarea'
]);
const noTrimWhitespacesArroundElements = new Set([
// non-empty tags that will maintain whitespace around them
'a', 'abbr', 'acronym', 'b', 'bdi', 'bdo', 'big', 'button', 'cite', 'code', 'del', 'dfn', 'em', 'font', 'i', 'ins', 'kbd', 'label', 'mark', 'math', 'nobr', 'object', 'q', 'rp', 'rt', 'rtc', 'ruby', 's', 'samp', 'select', 'small', 'span', 'strike', 'strong', 'sub', 'sup', 'svg', 'textarea', 'time', 'tt', 'u', 'var',
// self-closing tags that will maintain whitespace around them
'comment', 'img', 'input', 'wbr'
]);
const noTrimWhitespacesInsideElements = new Set([
// non-empty tags that will maintain whitespace within them
'a', 'abbr', 'acronym', 'b', 'big', 'del', 'em', 'font', 'i', 'ins', 'kbd', 'mark', 'nobr', 'rp', 's', 'samp', 'small', 'span', 'strike', 'strong', 'sub', 'sup', 'time', 'tt', 'u', 'var'
]);
const startsWithWhitespacePattern = /^\s/;
const endsWithWhitespacePattern = /\s$/;
// See https://infra.spec.whatwg.org/#strip-and-collapse-ascii-whitespace and https://infra.spec.whatwg.org/#ascii-whitespace
const multipleWhitespacePattern = /[\t\n\f\r ]+/g;
const NONE = '';
const SINGLE_SPACE = ' ';
const validOptions = ['all', 'aggressive', 'conservative'];
/** Collapses redundant whitespaces */
export default function collapseWhitespace(tree, options, collapseType, parent) {
collapseType = validOptions.includes(collapseType) ? collapseType : 'conservative';
tree.forEach((node, index) => {
const prevNode = tree[index - 1];
const nextNode = tree[index + 1];
if (typeof node === 'string') {
const parentNodeTag = parent && parent.node && parent.node.tag;
const isTopLevel = !parentNodeTag || parentNodeTag === 'html' || parentNodeTag === 'head';
const shouldTrim = (
collapseType === 'all' ||
isTopLevel ||
/*
* When collapseType is set to 'aggressive', and the tag is not inside 'noTrimWhitespacesInsideElements'.
* the first & last space inside the tag will be trimmed
*/
collapseType === 'aggressive'
);
node = collapseRedundantWhitespaces(node, collapseType, shouldTrim, parent, prevNode, nextNode);
}
const isAllowCollapseWhitespace = !noWhitespaceCollapseElements.has(node.tag);
if (node.content && node.content.length && isAllowCollapseWhitespace) {
node.content = collapseWhitespace(node.content, options, collapseType, {
node,
prevNode,
nextNode
});
}
tree[index] = node;
});
return tree;
}
function collapseRedundantWhitespaces(text, collapseType, shouldTrim = false, parent, prevNode, nextNode) {
if (!text || text.length === 0) {
return NONE;
}
if (!isComment(text)) {
text = text.replace(multipleWhitespacePattern, SINGLE_SPACE);
}
if (shouldTrim) {
if (collapseType === 'aggressive') {
if (!noTrimWhitespacesInsideElements.has(parent && parent.node && parent.node.tag)) {
if (
// It is the first child node of the parent
!prevNode
// It is not the first child node, and prevNode not a text node, and prevNode is safe to trim around
|| prevNode && prevNode.tag && !noTrimWhitespacesArroundElements.has(prevNode.tag)
) {
text = text.trimStart();
} else {
// previous node is a "no trim whitespaces arround element"
if (
// but previous node ends with a whitespace
prevNode && prevNode.content && prevNode.content.length
&& endsWithWhitespacePattern.test(prevNode.content[prevNode.content.length - 1])
&& (
!nextNode // either the current node is the last child of the parent
|| (
// or the next node starts with a white space
nextNode && nextNode.content && nextNode.content.length
&& !startsWithWhitespacePattern.test(nextNode.content[0])
)
)
) {
text = text.trimStart();
}
}
if (
!nextNode
|| nextNode && nextNode.tag && !noTrimWhitespacesArroundElements.has(nextNode.tag)
) {
text = text.trimEnd();
}
} else {
// now it is a textNode inside a "no trim whitespaces inside elements" node
if (
!prevNode // it the textnode is the first child of the node
&& startsWithWhitespacePattern.test(text[0]) // it starts with white space
&& typeof parent.prevNode === 'string' // the prev of the node is a textNode as well
&& endsWithWhitespacePattern.test(parent.prevNode[parent.prevNode.length - 1]) // that prev is ends with a white
) {
text = text.trimStart();
}
}
} else {
// collapseType is 'all', trim spaces
text = text.trim();
}
}
return text;
}

View File

@@ -0,0 +1,19 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.default = custom;
/** Meta-module that runs custom modules */
function custom(tree, options, customModules) {
if (!customModules) {
return tree;
}
if (!Array.isArray(customModules)) {
customModules = [customModules];
}
customModules.forEach(customModule => {
tree = customModule(tree, options);
});
return tree;
}

View File

@@ -0,0 +1,16 @@
/** Meta-module that runs custom modules */
export default function custom(tree, options, customModules) {
if (! customModules) {
return tree;
}
if (! Array.isArray(customModules)) {
customModules = [customModules];
}
customModules.forEach(customModule => {
tree = customModule(tree, options);
});
return tree;
}

View File

@@ -0,0 +1,38 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.onAttrs = onAttrs;
var _collapseAttributeWhitespace = require("./collapseAttributeWhitespace.cjs");
/** Deduplicate values inside list-like attributes (e.g. class, rel) */
function onAttrs() {
return attrs => {
const newAttrs = attrs;
Object.keys(attrs).forEach(attrName => {
if (!_collapseAttributeWhitespace.attributesWithLists.has(attrName)) {
return;
}
if (typeof attrs[attrName] !== 'string') {
return;
}
const attrValues = attrs[attrName].split(/\s/);
const uniqeAttrValues = new Set();
const deduplicatedAttrValues = [];
attrValues.forEach(attrValue => {
if (!attrValue) {
// Keep whitespaces
deduplicatedAttrValues.push('');
return;
}
if (uniqeAttrValues.has(attrValue)) {
return;
}
deduplicatedAttrValues.push(attrValue);
uniqeAttrValues.add(attrValue);
});
newAttrs[attrName] = deduplicatedAttrValues.join(' ');
});
return newAttrs;
};
}

View File

@@ -0,0 +1,40 @@
import { attributesWithLists } from './collapseAttributeWhitespace.mjs';
/** Deduplicate values inside list-like attributes (e.g. class, rel) */
export function onAttrs() {
return (attrs) => {
const newAttrs = attrs;
Object.keys(attrs).forEach(attrName => {
if (! attributesWithLists.has(attrName)) {
return;
}
if (typeof attrs[attrName] !== 'string') {
return;
}
const attrValues = attrs[attrName].split(/\s/);
const uniqeAttrValues = new Set();
const deduplicatedAttrValues = [];
attrValues.forEach((attrValue) => {
if (! attrValue) {
// Keep whitespaces
deduplicatedAttrValues.push('');
return;
}
if (uniqeAttrValues.has(attrValue)) {
return;
}
deduplicatedAttrValues.push(attrValue);
uniqeAttrValues.add(attrValue);
});
newAttrs[attrName] = deduplicatedAttrValues.join(' ');
});
return newAttrs;
};
}

View File

@@ -0,0 +1,85 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.default = example;
exports.onAttrs = onAttrs;
exports.onContent = onContent;
exports.onNode = onNode;
/**
* It is an example htmlnano module.
*
* A htmlnano module can be modify the attributes of every node (through a "onAttrs" named export),
* modify the content of every node (through an optional "onContent" named export), modify the node
* itself (through an optional "onNode" named export), or modify the entire tree (through an optional
* default export).
*/
/**
* Modify attributes of node. Optional.
*
* @param {object} options - Options that were passed to htmlnano
* @param moduleOptions — Module options. For most modules this is just "true" (indication that the module was enabled)
* @return {Function} - Return a function that takes attribute object and the node (for the context), and returns the modified attribute object
*/
function onAttrs(options, moduleOptions) {
return (attrs, node) => {
// You can modify "attrs" based on "node"
const newAttrs = {
...attrs
};
return newAttrs; // ... then return the modified attrs
};
}
/**
* Modify content of node. Optional.
*
* @param {object} options - Options that were passed to htmlnano
* @param moduleOptions — Module options. For most modules this is just "true" (indication that the module was enabled)
* @return {Function} - Return a function that takes contents (an array of node and string) and the node (for the context), and returns the modified content array.
*/
function onContent(options, moduleOptions) {
return (content, node) => {
// Same goes the "content"
return content; // ... return modified content here
};
}
/**
* It is possible to modify entire ndde as well. Optional.
* @param {object} options - Options that were passed to htmlnano
* @param moduleOptions — Module options. For most modules this is just "true" (indication that the module was enabled)
* @return {Function} - Return a function that takes the node, and returns the new, modified node.
*/
function onNode(options, moduleOptions) {
return node => {
return node; // ... return new node here
};
}
/**
* Modify the entire tree. Optional.
*
* @param {object} tree - PostHTML tree (https://github.com/posthtml/posthtml/blob/master/README.md)
* @param {object} options - Options that were passed to htmlnano
* @param moduleOptions — Module options. For most modules this is just "true" (indication that the module was enabled)
* @return {object | Proimse} - Return the modified tree.
*/
function example(tree, options, moduleOptions) {
// Module filename (example.es6), exported default function name (example),
// and test filename (example.js) must be the same.
// You can traverse the tree...
tree.walk(node => {
// ...and make some minification
});
// At the end you must return the tree
return tree;
// Or a promise with the tree
return somePromise.then(() => tree);
}

View File

@@ -0,0 +1,75 @@
/**
* It is an example htmlnano module.
*
* A htmlnano module can be modify the attributes of every node (through a "onAttrs" named export),
* modify the content of every node (through an optional "onContent" named export), modify the node
* itself (through an optional "onNode" named export), or modify the entire tree (through an optional
* default export).
*/
/**
* Modify attributes of node. Optional.
*
* @param {object} options - Options that were passed to htmlnano
* @param moduleOptions — Module options. For most modules this is just "true" (indication that the module was enabled)
* @return {Function} - Return a function that takes attribute object and the node (for the context), and returns the modified attribute object
*/
export function onAttrs(options, moduleOptions) {
return (attrs, node) => {
// You can modify "attrs" based on "node"
const newAttrs = { ...attrs };
return newAttrs; // ... then return the modified attrs
};
}
/**
* Modify content of node. Optional.
*
* @param {object} options - Options that were passed to htmlnano
* @param moduleOptions — Module options. For most modules this is just "true" (indication that the module was enabled)
* @return {Function} - Return a function that takes contents (an array of node and string) and the node (for the context), and returns the modified content array.
*/
export function onContent(options, moduleOptions) {
return (content, node) => {
// Same goes the "content"
return content; // ... return modified content here
};
}
/**
* It is possible to modify entire ndde as well. Optional.
* @param {object} options - Options that were passed to htmlnano
* @param moduleOptions — Module options. For most modules this is just "true" (indication that the module was enabled)
* @return {Function} - Return a function that takes the node, and returns the new, modified node.
*/
export function onNode(options, moduleOptions) {
return (node) => {
return node; // ... return new node here
};
}
/**
* Modify the entire tree. Optional.
*
* @param {object} tree - PostHTML tree (https://github.com/posthtml/posthtml/blob/master/README.md)
* @param {object} options - Options that were passed to htmlnano
* @param moduleOptions — Module options. For most modules this is just "true" (indication that the module was enabled)
* @return {object | Proimse} - Return the modified tree.
*/
export default function example(tree, options, moduleOptions) {
// Module filename (example.es6), exported default function name (example),
// and test filename (example.js) must be the same.
// You can traverse the tree...
tree.walk(node => {
// ...and make some minification
});
// At the end you must return the tree
return tree;
// Or a promise with the tree
return somePromise.then(() => tree);
}

View File

@@ -0,0 +1,54 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.default = mergeScripts;
/* Merge multiple <script> into one */
function mergeScripts(tree) {
let scriptNodesIndex = {};
let scriptSrcIndex = 1;
tree.match({
tag: 'script'
}, node => {
const nodeAttrs = node.attrs || {};
if ('src' in nodeAttrs
// Skip SRI, reasons are documented in "minifyJs" module
|| 'integrity' in nodeAttrs) {
scriptSrcIndex++;
return node;
}
const scriptType = nodeAttrs.type || 'text/javascript';
if (scriptType !== 'text/javascript' && scriptType !== 'application/javascript') {
return node;
}
const scriptKey = JSON.stringify({
id: nodeAttrs.id,
class: nodeAttrs.class,
type: scriptType,
defer: nodeAttrs.defer !== undefined,
async: nodeAttrs.async !== undefined,
index: scriptSrcIndex
});
if (!scriptNodesIndex[scriptKey]) {
scriptNodesIndex[scriptKey] = [];
}
scriptNodesIndex[scriptKey].push(node);
return node;
});
for (const scriptNodes of Object.values(scriptNodesIndex)) {
let lastScriptNode = scriptNodes.pop();
scriptNodes.reverse().forEach(scriptNode => {
let scriptContent = (scriptNode.content || []).join(' ');
scriptContent = scriptContent.trim();
if (scriptContent.slice(-1) !== ';') {
scriptContent += ';';
}
lastScriptNode.content = lastScriptNode.content || [];
lastScriptNode.content.unshift(scriptContent);
scriptNode.tag = false;
scriptNode.content = [];
});
}
return tree;
}

View File

@@ -0,0 +1,56 @@
/* Merge multiple <script> into one */
export default function mergeScripts (tree) {
let scriptNodesIndex = {};
let scriptSrcIndex = 1;
tree.match({ tag: 'script' }, node => {
const nodeAttrs = node.attrs || {};
if (
'src' in nodeAttrs
// Skip SRI, reasons are documented in "minifyJs" module
|| 'integrity' in nodeAttrs
) {
scriptSrcIndex++;
return node;
}
const scriptType = nodeAttrs.type || 'text/javascript';
if (scriptType !== 'text/javascript' && scriptType !== 'application/javascript') {
return node;
}
const scriptKey = JSON.stringify({
id: nodeAttrs.id,
class: nodeAttrs.class,
type: scriptType,
defer: nodeAttrs.defer !== undefined,
async: nodeAttrs.async !== undefined,
index: scriptSrcIndex,
});
if (!scriptNodesIndex[scriptKey]) {
scriptNodesIndex[scriptKey] = [];
}
scriptNodesIndex[scriptKey].push(node);
return node;
});
for (const scriptNodes of Object.values(scriptNodesIndex)) {
let lastScriptNode = scriptNodes.pop();
scriptNodes.reverse().forEach(scriptNode => {
let scriptContent = (scriptNode.content || []).join(' ');
scriptContent = scriptContent.trim();
if (scriptContent.slice(-1) !== ';') {
scriptContent += ';';
}
lastScriptNode.content = lastScriptNode.content || [];
lastScriptNode.content.unshift(scriptContent);
scriptNode.tag = false;
scriptNode.content = [];
});
}
return tree;
}

View File

@@ -0,0 +1,38 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.default = mergeStyles;
var _helpers = require("../helpers.cjs");
/* Merge multiple <style> into one */
function mergeStyles(tree) {
const styleNodes = {};
tree.match({
tag: 'style'
}, node => {
const nodeAttrs = node.attrs || {};
// Skip <style scoped></style>
// https://developer.mozilla.org/en/docs/Web/HTML/Element/style
//
// Also skip SRI, reasons are documented in "minifyJs" module
if ('scoped' in nodeAttrs || 'integrity' in nodeAttrs) {
return node;
}
if ((0, _helpers.isAmpBoilerplate)(node)) {
return node;
}
const styleType = nodeAttrs.type || 'text/css';
const styleMedia = nodeAttrs.media || 'all';
const styleKey = styleType + '_' + styleMedia;
if (styleNodes[styleKey]) {
const styleContent = (node.content || []).join(' ');
styleNodes[styleKey].content.push(' ' + styleContent);
return '';
}
node.content = node.content || [];
styleNodes[styleKey] = node;
return node;
});
return tree;
}

View File

@@ -0,0 +1,36 @@
import { isAmpBoilerplate } from '../helpers.mjs';
/* Merge multiple <style> into one */
export default function mergeStyles(tree) {
const styleNodes = {};
tree.match({tag: 'style'}, node => {
const nodeAttrs = node.attrs || {};
// Skip <style scoped></style>
// https://developer.mozilla.org/en/docs/Web/HTML/Element/style
//
// Also skip SRI, reasons are documented in "minifyJs" module
if ('scoped' in nodeAttrs || 'integrity' in nodeAttrs) {
return node;
}
if (isAmpBoilerplate(node)) {
return node;
}
const styleType = nodeAttrs.type || 'text/css';
const styleMedia = nodeAttrs.media || 'all';
const styleKey = styleType + '_' + styleMedia;
if (styleNodes[styleKey]) {
const styleContent = (node.content || []).join(' ');
styleNodes[styleKey].content.push(' ' + styleContent);
return '';
}
node.content = node.content || [];
styleNodes[styleKey] = node;
return node;
});
return tree;
}

View File

@@ -0,0 +1,47 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.default = minifyConditionalComments;
var _htmlnano = _interopRequireDefault(require("../htmlnano.cjs"));
var _helpers = require("../helpers.cjs");
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
// Spec: https://docs.microsoft.com/en-us/previous-versions/windows/internet-explorer/ie-developer/compatibility/ms537512(v=vs.85)
const CONDITIONAL_COMMENT_REGEXP = /(<!--\[if\s+?[^<>[\]]+?]>)([\s\S]+?)(<!\[endif\]-->)/gm;
/** Minify content inside conditional comments */
async function minifyConditionalComments(tree, htmlnanoOptions) {
// forEach, tree.walk, tree.match just don't support Promise.
for (let i = 0, len = tree.length; i < len; i++) {
const node = tree[i];
if (typeof node === 'string' && (0, _helpers.isConditionalComment)(node)) {
tree[i] = await minifycontentInsideConditionalComments(node, htmlnanoOptions);
}
if (node.content && node.content.length) {
tree[i].content = await minifyConditionalComments(node.content, htmlnanoOptions);
}
}
return tree;
}
async function minifycontentInsideConditionalComments(text, htmlnanoOptions) {
let match;
const matches = [];
// FIXME!
// String#matchAll is supported since Node.js 12
while ((match = CONDITIONAL_COMMENT_REGEXP.exec(text)) !== null) {
matches.push([match[1], match[2], match[3]]);
}
if (!matches.length) {
return Promise.resolve(text);
}
return Promise.all(matches.map(async match => {
const result = await _htmlnano.default.process(match[1], htmlnanoOptions, {}, {});
let minified = result.html;
if (match[1].includes('<html') && minified.includes('</html>')) {
minified = minified.replace('</html>', '');
}
return match[0] + minified + match[2];
}));
}

View File

@@ -0,0 +1,49 @@
import htmlnano from '../htmlnano.mjs';
import { isConditionalComment } from '../helpers.mjs';
// Spec: https://docs.microsoft.com/en-us/previous-versions/windows/internet-explorer/ie-developer/compatibility/ms537512(v=vs.85)
const CONDITIONAL_COMMENT_REGEXP = /(<!--\[if\s+?[^<>[\]]+?]>)([\s\S]+?)(<!\[endif\]-->)/gm;
/** Minify content inside conditional comments */
export default async function minifyConditionalComments(tree, htmlnanoOptions) {
// forEach, tree.walk, tree.match just don't support Promise.
for (let i = 0, len = tree.length; i < len; i++) {
const node = tree[i];
if (typeof node === 'string' && isConditionalComment(node)) {
tree[i] = await minifycontentInsideConditionalComments(node, htmlnanoOptions);
}
if (node.content && node.content.length) {
tree[i].content = await minifyConditionalComments(node.content, htmlnanoOptions);
}
}
return tree;
}
async function minifycontentInsideConditionalComments(text, htmlnanoOptions) {
let match;
const matches = [];
// FIXME!
// String#matchAll is supported since Node.js 12
while ((match = CONDITIONAL_COMMENT_REGEXP.exec(text)) !== null) {
matches.push([match[1], match[2], match[3]]);
}
if (!matches.length) {
return Promise.resolve(text);
}
return Promise.all(matches.map(async match => {
const result = await htmlnano.process(match[1], htmlnanoOptions, {}, {});
let minified = result.html;
if (match[1].includes('<html') && minified.includes('</html>')) {
minified = minified.replace('</html>', '');
}
return match[0] + minified + match[2];
}));
}

View File

@@ -0,0 +1,73 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.default = minifyCss;
var _helpers = require("../helpers.cjs");
const postcssOptions = {
// Prevent the following warning from being shown:
// > Without `from` option PostCSS could generate wrong source map and will not find Browserslist config.
// > Set it to CSS file path or to `undefined` to prevent this warning.
from: undefined
};
/** Minify CSS with cssnano */
async function minifyCss(tree, options, cssnanoOptions) {
const cssnano = await (0, _helpers.optionalImport)('cssnano');
const postcss = await (0, _helpers.optionalImport)('postcss');
if (!cssnano || !postcss) {
return tree;
}
let promises = [];
tree.walk(node => {
// Skip SRI, reasons are documented in "minifyJs" module
if (node.attrs && 'integrity' in node.attrs) {
return node;
}
if ((0, _helpers.isStyleNode)(node)) {
promises.push(processStyleNode(node, cssnanoOptions, cssnano, postcss));
} else if (node.attrs && node.attrs.style) {
promises.push(processStyleAttr(node, cssnanoOptions, cssnano, postcss));
}
return node;
});
return Promise.all(promises).then(() => tree);
}
function processStyleNode(styleNode, cssnanoOptions, cssnano, postcss) {
let css = (0, _helpers.extractCssFromStyleNode)(styleNode);
// Improve performance by avoiding calling stripCdata again and again
let isCdataWrapped = false;
if (css.includes('CDATA')) {
const strippedCss = stripCdata(css);
isCdataWrapped = css !== strippedCss;
css = strippedCss;
}
return postcss([cssnano(cssnanoOptions)]).process(css, postcssOptions).then(result => {
if (isCdataWrapped) {
return styleNode.content = ['<![CDATA[' + result + ']]>'];
}
return styleNode.content = [result.css];
});
}
function processStyleAttr(node, cssnanoOptions, cssnano, postcss) {
// CSS "color: red;" is invalid. Therefore it should be wrapped inside some selector:
// a{color: red;}
const wrapperStart = 'a{';
const wrapperEnd = '}';
const wrappedStyle = wrapperStart + (node.attrs.style || '') + wrapperEnd;
return postcss([cssnano(cssnanoOptions)]).process(wrappedStyle, postcssOptions).then(result => {
const minifiedCss = result.css;
// Remove wrapperStart at the start and wrapperEnd at the end of minifiedCss
node.attrs.style = minifiedCss.substring(wrapperStart.length, minifiedCss.length - wrapperEnd.length);
});
}
function stripCdata(css) {
const leftStrippedCss = css.replace('<![CDATA[', '');
if (leftStrippedCss === css) {
return css;
}
const strippedCss = leftStrippedCss.replace(']]>', '');
return leftStrippedCss === strippedCss ? css : strippedCss;
}

View File

@@ -0,0 +1,88 @@
import { isStyleNode, extractCssFromStyleNode, optionalImport } from '../helpers.mjs';
const postcssOptions = {
// Prevent the following warning from being shown:
// > Without `from` option PostCSS could generate wrong source map and will not find Browserslist config.
// > Set it to CSS file path or to `undefined` to prevent this warning.
from: undefined,
};
/** Minify CSS with cssnano */
export default async function minifyCss(tree, options, cssnanoOptions) {
const cssnano = await optionalImport('cssnano');
const postcss = await optionalImport('postcss');
if (!cssnano || !postcss) {
return tree;
}
let promises = [];
tree.walk(node => {
// Skip SRI, reasons are documented in "minifyJs" module
if (node.attrs && 'integrity' in node.attrs) {
return node;
}
if (isStyleNode(node)) {
promises.push(processStyleNode(node, cssnanoOptions, cssnano, postcss));
} else if (node.attrs && node.attrs.style) {
promises.push(processStyleAttr(node, cssnanoOptions, cssnano, postcss));
}
return node;
});
return Promise.all(promises).then(() => tree);
}
function processStyleNode(styleNode, cssnanoOptions, cssnano, postcss) {
let css = extractCssFromStyleNode(styleNode);
// Improve performance by avoiding calling stripCdata again and again
let isCdataWrapped = false;
if (css.includes('CDATA')) {
const strippedCss = stripCdata(css);
isCdataWrapped = css !== strippedCss;
css = strippedCss;
}
return postcss([cssnano(cssnanoOptions)])
.process(css, postcssOptions)
.then(result => {
if (isCdataWrapped) {
return styleNode.content = ['<![CDATA[' + result + ']]>'];
}
return styleNode.content = [result.css];
});
}
function processStyleAttr(node, cssnanoOptions, cssnano, postcss) {
// CSS "color: red;" is invalid. Therefore it should be wrapped inside some selector:
// a{color: red;}
const wrapperStart = 'a{';
const wrapperEnd = '}';
const wrappedStyle = wrapperStart + (node.attrs.style || '') + wrapperEnd;
return postcss([cssnano(cssnanoOptions)])
.process(wrappedStyle, postcssOptions)
.then(result => {
const minifiedCss = result.css;
// Remove wrapperStart at the start and wrapperEnd at the end of minifiedCss
node.attrs.style = minifiedCss.substring(
wrapperStart.length,
minifiedCss.length - wrapperEnd.length
);
});
}
function stripCdata(css) {
const leftStrippedCss = css.replace('<![CDATA[', '');
if (leftStrippedCss === css) {
return css;
}
const strippedCss = leftStrippedCss.replace(']]>', '');
return leftStrippedCss === strippedCss ? css : strippedCss;
}

View File

@@ -0,0 +1,103 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.default = minifyJs;
var _helpers = require("../helpers.cjs");
var _removeRedundantAttributes = require("./removeRedundantAttributes.cjs");
/** Minify JS with Terser */
async function minifyJs(tree, options, terserOptions) {
const terser = await (0, _helpers.optionalImport)('terser');
if (!terser) return tree;
let promises = [];
tree.walk(node => {
const nodeAttrs = node.attrs || {};
/**
* Skip SRI
*
* If the input <script /> has an SRI attribute, it means that the original <script /> could be trusted,
* and should not be altered anymore.
*
* htmlnano is exactly an MITM that SRI is designed to protect from. If htmlnano or its dependencies get
* compromised and introduces malicious code, then it is up to the original SRI to protect the end user.
*
* So htmlnano will simply skip <script /> that has SRI.
* If developers do trust htmlnano, they should generate SRI after htmlnano modify the <script />.
*/
if ('integrity' in nodeAttrs) {
return node;
}
if (node.tag && node.tag === 'script') {
const mimeType = nodeAttrs.type || 'text/javascript';
if (_removeRedundantAttributes.redundantScriptTypes.has(mimeType) || mimeType === 'module') {
promises.push(processScriptNode(node, terserOptions, terser));
}
}
if (node.attrs) {
promises = promises.concat(processNodeWithOnAttrs(node, terserOptions, terser));
}
return node;
});
return Promise.all(promises).then(() => tree);
}
function stripCdata(js) {
const leftStrippedJs = js.replace(/\/\/\s*<!\[CDATA\[/, '').replace(/\/\*\s*<!\[CDATA\[\s*\*\//, '');
if (leftStrippedJs === js) {
return js;
}
const strippedJs = leftStrippedJs.replace(/\/\/\s*\]\]>/, '').replace(/\/\*\s*\]\]>\s*\*\//, '');
return leftStrippedJs === strippedJs ? js : strippedJs;
}
function processScriptNode(scriptNode, terserOptions, terser) {
let js = (scriptNode.content || []).join('').trim();
if (!js) {
return scriptNode;
}
// Improve performance by avoiding calling stripCdata again and again
let isCdataWrapped = false;
if (js.includes('CDATA')) {
const strippedJs = stripCdata(js);
isCdataWrapped = js !== strippedJs;
js = strippedJs;
}
return terser.minify(js, terserOptions).then(result => {
if (result.error) {
throw new Error(result.error);
}
if (result.code === undefined) {
return;
}
let content = result.code;
if (isCdataWrapped) {
content = '/*<![CDATA[*/' + content + '/*]]>*/';
}
scriptNode.content = [content];
});
}
function processNodeWithOnAttrs(node, terserOptions, terser) {
const jsWrapperStart = 'a=function(){';
const jsWrapperEnd = '};a();';
const promises = [];
for (const attrName of Object.keys(node.attrs || {})) {
if (!(0, _helpers.isEventHandler)(attrName)) {
continue;
}
// For example onclick="return false" is valid,
// but "return false;" is invalid (error: 'return' outside of function)
// Therefore the attribute's code should be wrapped inside function:
// "function _(){return false;}"
let wrappedJs = jsWrapperStart + node.attrs[attrName] + jsWrapperEnd;
let promise = terser.minify(wrappedJs, terserOptions).then(({
code
}) => {
let minifiedJs = code.substring(jsWrapperStart.length, code.length - jsWrapperEnd.length);
node.attrs[attrName] = minifiedJs;
});
promises.push(promise);
}
return promises;
}

View File

@@ -0,0 +1,121 @@
import { isEventHandler, optionalImport } from '../helpers.mjs';
import { redundantScriptTypes } from './removeRedundantAttributes.mjs';
/** Minify JS with Terser */
export default async function minifyJs (tree, options, terserOptions) {
const terser = await optionalImport('terser');
if (!terser) return tree;
let promises = [];
tree.walk(node => {
const nodeAttrs = node.attrs || {};
/**
* Skip SRI
*
* If the input <script /> has an SRI attribute, it means that the original <script /> could be trusted,
* and should not be altered anymore.
*
* htmlnano is exactly an MITM that SRI is designed to protect from. If htmlnano or its dependencies get
* compromised and introduces malicious code, then it is up to the original SRI to protect the end user.
*
* So htmlnano will simply skip <script /> that has SRI.
* If developers do trust htmlnano, they should generate SRI after htmlnano modify the <script />.
*/
if ('integrity' in nodeAttrs) {
return node;
}
if (node.tag && node.tag === 'script') {
const mimeType = nodeAttrs.type || 'text/javascript';
if (redundantScriptTypes.has(mimeType) || mimeType === 'module') {
promises.push(processScriptNode(node, terserOptions, terser));
}
}
if (node.attrs) {
promises = promises.concat(processNodeWithOnAttrs(node, terserOptions, terser));
}
return node;
});
return Promise.all(promises).then(() => tree);
}
function stripCdata (js) {
const leftStrippedJs = js.replace(/\/\/\s*<!\[CDATA\[/, '').replace(/\/\*\s*<!\[CDATA\[\s*\*\//, '');
if (leftStrippedJs === js) {
return js;
}
const strippedJs = leftStrippedJs.replace(/\/\/\s*\]\]>/, '').replace(/\/\*\s*\]\]>\s*\*\//, '');
return leftStrippedJs === strippedJs ? js : strippedJs;
}
function processScriptNode (scriptNode, terserOptions, terser) {
let js = (scriptNode.content || []).join('').trim();
if (!js) {
return scriptNode;
}
// Improve performance by avoiding calling stripCdata again and again
let isCdataWrapped = false;
if (js.includes('CDATA')) {
const strippedJs = stripCdata(js);
isCdataWrapped = js !== strippedJs;
js = strippedJs;
}
return terser
.minify(js, terserOptions)
.then(result => {
if (result.error) {
throw new Error(result.error);
}
if (result.code === undefined) {
return;
}
let content = result.code;
if (isCdataWrapped) {
content = '/*<![CDATA[*/' + content + '/*]]>*/';
}
scriptNode.content = [content];
});
}
function processNodeWithOnAttrs (node, terserOptions, terser) {
const jsWrapperStart = 'a=function(){';
const jsWrapperEnd = '};a();';
const promises = [];
for (const attrName of Object.keys(node.attrs || {})) {
if (!isEventHandler(attrName)) {
continue;
}
// For example onclick="return false" is valid,
// but "return false;" is invalid (error: 'return' outside of function)
// Therefore the attribute's code should be wrapped inside function:
// "function _(){return false;}"
let wrappedJs = jsWrapperStart + node.attrs[attrName] + jsWrapperEnd;
let promise = terser
.minify(wrappedJs, terserOptions)
.then(({ code }) => {
let minifiedJs = code.substring(
jsWrapperStart.length,
code.length - jsWrapperEnd.length
);
node.attrs[attrName] = minifiedJs;
});
promises.push(promise);
}
return promises;
}

View File

@@ -0,0 +1,24 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.onContent = onContent;
const rNodeAttrsTypeJson = /(\/|\+)json/;
function onContent() {
return (content, node) => {
// Skip SRI, reasons are documented in "minifyJs" module
if (node.attrs && 'integrity' in node.attrs) {
return content;
}
if (node.attrs && node.attrs.type && rNodeAttrsTypeJson.test(node.attrs.type)) {
try {
// cast minified JSON to an array
return [JSON.stringify(JSON.parse((content || []).join('')))];
} catch (error) {
// Invalid JSON
}
}
return content;
};
}

View File

@@ -0,0 +1,21 @@
const rNodeAttrsTypeJson = /(\/|\+)json/;
export function onContent() {
return (content, node) => {
// Skip SRI, reasons are documented in "minifyJs" module
if (node.attrs && 'integrity' in node.attrs) {
return content;
}
if (node.attrs && node.attrs.type && rNodeAttrsTypeJson.test(node.attrs.type)) {
try {
// cast minified JSON to an array
return [JSON.stringify(JSON.parse((content || []).join('')))];
} catch (error) {
// Invalid JSON
}
}
return content;
};
}

View File

@@ -0,0 +1,37 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.default = minifySvg;
var _helpers = require("../helpers.cjs");
/** Minify SVG with SVGO */
async function minifySvg(tree, options, svgoOptions = {}) {
const svgo = await (0, _helpers.optionalImport)('svgo');
if (!svgo) return tree;
tree.match({
tag: 'svg'
}, node => {
let svgStr = tree.render(node, {
closingSingleTag: 'slash',
quoteAllAttributes: true
});
try {
const result = svgo.optimize(svgStr, svgoOptions);
node.tag = false;
node.attrs = {};
// result.data is a string, we need to cast it to an array
node.content = [result.data];
return node;
} catch (error) {
console.error('htmlnano fails to minify the svg:');
console.error(error);
if (error.name === 'SvgoParserError') {
console.error(error.toString());
}
// We return the node as-is
return node;
}
});
return tree;
}

View File

@@ -0,0 +1,30 @@
import { optionalImport } from '../helpers.mjs';
/** Minify SVG with SVGO */
export default async function minifySvg(tree, options, svgoOptions = {}) {
const svgo = await optionalImport('svgo');
if (!svgo) return tree;
tree.match({tag: 'svg'}, node => {
let svgStr = tree.render(node, { closingSingleTag: 'slash', quoteAllAttributes: true });
try {
const result = svgo.optimize(svgStr, svgoOptions);
node.tag = false;
node.attrs = {};
// result.data is a string, we need to cast it to an array
node.content = [result.data];
return node;
} catch (error) {
console.error('htmlnano fails to minify the svg:');
console.error(error);
if (error.name === 'SvgoParserError') {
console.error(error.toString());
}
// We return the node as-is
return node;
}
});
return tree;
}

View File

@@ -0,0 +1,141 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.default = minifyUrls;
var _helpers = require("../helpers.cjs");
// 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 => {
// FIXME!
// relateurl@1.0.0-alpha only supports URL while stable version (0.2.7) only supports string
// should convert input into URL instance after relateurl@1 is stable
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') 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 */
async function minifyUrls(tree, options, moduleOptions) {
const RelateUrl = await (0, _helpers.optionalImport)('relateurl');
const srcset = await (0, _helpers.optionalImport)('srcset');
const terser = await (0, _helpers.optionalImport)('terser');
let 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 (isJavaScriptUrl(attrValue)) {
promises.push(minifyJavaScriptUrl(node, attrName, terser));
} else {
if (relateUrlInstance) {
// FIXME!
// relateurl@1.0.0-alpha only supports URL while stable version (0.2.7) only supports string
// the WHATWG URL API is very strict while attrValue might not be a valid URL
// new URL should be used, and relateUrl#relate should be wrapped in try...catch after relateurl@1 is stable
node.attrs[attrName] = relateUrlInstance.relate(attrValue);
}
}
continue;
}
if (isSrcsetAttribute(node.tag, attrNameLower)) {
if (srcset) {
try {
const parsedSrcset = srcset.parse(attrValue, {
strict: true
});
node.attrs[attrName] = srcset.stringify(parsedSrcset.map(srcset => {
if (relateUrlInstance) {
srcset.url = relateUrlInstance.relate(srcset.url);
}
return srcset;
}));
} catch (e) {
// 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);
}
function isJavaScriptUrl(url) {
return typeof url === 'string' && url.toLowerCase().startsWith(JAVASCRIPT_URL_PROTOCOL);
}
const jsWrapperStart = 'function a(){';
const jsWrapperEnd = '}a();';
function minifyJavaScriptUrl(node, attrName, terser) {
if (!terser) return Promise.resolve();
let result = node.attrs[attrName];
if (result) {
result = jsWrapperStart + result.slice(JAVASCRIPT_URL_PROTOCOL.length) + jsWrapperEnd;
return terser.minify(result, {}) // Default Option is good enough
.then(({
code
}) => {
const minifiedJs = code.substring(jsWrapperStart.length, code.length - jsWrapperEnd.length);
node.attrs[attrName] = JAVASCRIPT_URL_PROTOCOL + minifiedJs;
});
}
return Promise.resolve();
}

View File

@@ -0,0 +1,229 @@
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 => {
// FIXME!
// relateurl@1.0.0-alpha only supports URL while stable version (0.2.7) only supports string
// should convert input into URL instance after relateurl@1 is stable
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') 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 */
export default async function minifyUrls(tree, options, moduleOptions) {
const RelateUrl = await optionalImport('relateurl');
const srcset = await optionalImport('srcset');
const terser = await optionalImport('terser');
let 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 (isJavaScriptUrl(attrValue)) {
promises.push(minifyJavaScriptUrl(node, attrName, terser));
} else {
if (relateUrlInstance) {
// FIXME!
// relateurl@1.0.0-alpha only supports URL while stable version (0.2.7) only supports string
// the WHATWG URL API is very strict while attrValue might not be a valid URL
// new URL should be used, and relateUrl#relate should be wrapped in try...catch after relateurl@1 is stable
node.attrs[attrName] = relateUrlInstance.relate(attrValue);
}
}
continue;
}
if (isSrcsetAttribute(node.tag, attrNameLower)) {
if (srcset) {
try {
const parsedSrcset = srcset.parse(attrValue, { strict: true });
node.attrs[attrName] = srcset.stringify(parsedSrcset.map(srcset => {
if (relateUrlInstance) {
srcset.url = relateUrlInstance.relate(srcset.url);
}
return srcset;
}));
} catch (e) {
// 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);
}
function isJavaScriptUrl(url) {
return typeof url === 'string' && url.toLowerCase().startsWith(JAVASCRIPT_URL_PROTOCOL);
}
const jsWrapperStart = 'function a(){';
const jsWrapperEnd = '}a();';
function minifyJavaScriptUrl(node, attrName, terser) {
if (!terser) return Promise.resolve();
let result = node.attrs[attrName];
if (result) {
result = jsWrapperStart + result.slice(JAVASCRIPT_URL_PROTOCOL.length) + jsWrapperEnd;
return terser
.minify(result, {}) // Default Option is good enough
.then(({ code }) => {
const minifiedJs = code.substring(
jsWrapperStart.length,
code.length - jsWrapperEnd.length
);
node.attrs[attrName] = JAVASCRIPT_URL_PROTOCOL + minifiedJs;
});
}
return Promise.resolve();
}

View File

@@ -0,0 +1,120 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.onAttrs = onAttrs;
const caseInsensitiveAttributes = {
autocomplete: ['form'],
charset: ['meta', 'script'],
contenteditable: null,
crossorigin: ['audio', 'img', 'link', 'script', 'video'],
dir: null,
draggable: null,
dropzone: null,
formmethod: ['button', 'input'],
inputmode: ['input', 'textarea'],
kind: ['track'],
method: ['form'],
preload: ['audio', 'video'],
referrerpolicy: null,
sandbox: ['iframe'],
spellcheck: null,
scope: ['th'],
shape: ['area'],
sizes: ['link'],
step: ['input'],
translate: null,
type: ['a', 'link', 'button', 'embed', 'object', 'script', 'source', 'style', 'input', 'menu', 'menuitem'],
wrap: ['textarea']
};
// https://html.spec.whatwg.org/#invalid-value-default
/** @typedef { [key: string]: { tag: null | string[], default: string, valid: string[] } } */
const invalidValueDefault = {
crossorigin: {
tag: null,
default: 'anonymous',
valid: ['', 'anonymous', 'use-credentials']
},
// https://html.spec.whatwg.org/#referrer-policy-attributes
// The attribute's invalid value default and missing value default are both the empty string state.
referrerpolicy: {
tag: null,
default: '',
valid: ['', 'url', 'origin', 'no-referrer', 'no-referrer-when-downgrade', 'same-origin', 'origin-when-cross-origin', 'strict-origin-when-cross-origin', 'unsafe-url']
},
// https://html.spec.whatwg.org/#lazy-loading-attributes
loading: {
tag: ['img', 'iframe'],
default: 'eager',
valid: ['lazy', 'eager']
},
// https://html.spec.whatwg.org/#the-img-element
// https://html.spec.whatwg.org/#image-decoding-hint
decoding: {
tag: ['img'],
default: 'auto',
valid: ['auto', 'sync', 'async']
},
// https://html.spec.whatwg.org/#the-track-element
kind: {
tag: ['track'],
default: 'metadata',
valid: ['subtitles', 'captions', 'descriptions', 'chapters', 'metadata']
},
type: {
tag: ['button'],
default: 'submit',
valid: ['submit', 'reset', 'button']
},
wrap: {
tag: ['textarea'],
default: 'soft',
valid: ['soft', 'hard']
},
// https://html.spec.whatwg.org/#the-hidden-attribute
hidden: {
tag: null,
default: 'hidden',
valid: ['hidden', 'until-found']
},
// https://html.spec.whatwg.org/#autocapitalization
autocapitalize: {
tag: null,
default: 'sentences',
valid: ['none', 'off', 'on', 'sentences', 'words', 'characters']
},
// https://html.spec.whatwg.org/#the-marquee-element
behavior: {
tag: ['marquee'],
default: 'scroll',
valid: ['scroll', 'slide', 'alternate']
},
direction: {
tag: ['marquee'],
default: 'left',
valid: ['left', 'right', 'up', 'down']
}
};
function onAttrs() {
return (attrs, node) => {
const newAttrs = attrs;
Object.entries(attrs).forEach(([attrName, attrValue]) => {
let newAttrValue = attrValue;
if (Object.hasOwnProperty.call(caseInsensitiveAttributes, attrName) && (caseInsensitiveAttributes[attrName] === null || caseInsensitiveAttributes[attrName].includes(node.tag))) {
newAttrValue = typeof attrValue.toLowerCase === 'function' ? attrValue.toLowerCase() : attrValue;
}
if (Object.hasOwnProperty.call(invalidValueDefault, attrName)) {
const meta = invalidValueDefault[attrName];
if (meta.tag === null || node && node.tag && meta.tag.includes(node.tag)) {
if (!meta.valid.includes(newAttrValue)) {
newAttrValue = meta.default;
}
}
}
newAttrs[attrName] = newAttrValue;
});
return newAttrs;
};
}

View File

@@ -0,0 +1,140 @@
const caseInsensitiveAttributes = {
autocomplete: ['form'],
charset: ['meta', 'script'],
contenteditable: null,
crossorigin: ['audio', 'img', 'link', 'script', 'video'],
dir: null,
draggable: null,
dropzone: null,
formmethod: ['button', 'input'],
inputmode: ['input', 'textarea'],
kind: ['track'],
method: ['form'],
preload: ['audio', 'video'],
referrerpolicy: null,
sandbox: ['iframe'],
spellcheck: null,
scope: ['th'],
shape: ['area'],
sizes: ['link'],
step: ['input'],
translate: null,
type: [
'a',
'link',
'button',
'embed',
'object',
'script',
'source',
'style',
'input',
'menu',
'menuitem'
],
wrap: ['textarea']
};
// https://html.spec.whatwg.org/#invalid-value-default
/** @typedef { [key: string]: { tag: null | string[], default: string, valid: string[] } } */
const invalidValueDefault = {
crossorigin: {
tag: null,
default: 'anonymous',
valid: ['', 'anonymous', 'use-credentials']
},
// https://html.spec.whatwg.org/#referrer-policy-attributes
// The attribute's invalid value default and missing value default are both the empty string state.
referrerpolicy: {
tag: null,
default: '',
valid: ['', 'url', 'origin', 'no-referrer', 'no-referrer-when-downgrade', 'same-origin', 'origin-when-cross-origin', 'strict-origin-when-cross-origin', 'unsafe-url']
},
// https://html.spec.whatwg.org/#lazy-loading-attributes
loading: {
tag: ['img', 'iframe'],
default: 'eager',
valid: ['lazy', 'eager']
},
// https://html.spec.whatwg.org/#the-img-element
// https://html.spec.whatwg.org/#image-decoding-hint
decoding: {
tag: ['img'],
default: 'auto',
valid: ['auto', 'sync', 'async']
},
// https://html.spec.whatwg.org/#the-track-element
kind: {
tag: ['track'],
default: 'metadata',
valid: ['subtitles', 'captions', 'descriptions', 'chapters', 'metadata']
},
type: {
tag: ['button'],
default: 'submit',
valid: ['submit', 'reset', 'button']
},
wrap: {
tag: ['textarea'],
default: 'soft',
valid: ['soft', 'hard']
},
// https://html.spec.whatwg.org/#the-hidden-attribute
hidden: {
tag: null,
default: 'hidden',
valid: ['hidden', 'until-found']
},
// https://html.spec.whatwg.org/#autocapitalization
autocapitalize: {
tag: null,
default: 'sentences',
valid: ['none', 'off', 'on', 'sentences', 'words', 'characters']
},
// https://html.spec.whatwg.org/#the-marquee-element
behavior: {
tag: ['marquee'],
default: 'scroll',
valid: ['scroll', 'slide', 'alternate']
},
direction: {
tag: ['marquee'],
default: 'left',
valid: ['left', 'right', 'up', 'down']
}
};
export function onAttrs() {
return (attrs, node) => {
const newAttrs = attrs;
Object.entries(attrs).forEach(([attrName, attrValue]) => {
let newAttrValue = attrValue;
if (
Object.hasOwnProperty.call(caseInsensitiveAttributes, attrName)
&& (
caseInsensitiveAttributes[attrName] === null
|| caseInsensitiveAttributes[attrName].includes(node.tag)
)
) {
newAttrValue = typeof attrValue.toLowerCase === 'function' ? attrValue.toLowerCase() : attrValue;
}
if (
Object.hasOwnProperty.call(invalidValueDefault, attrName)
) {
const meta = invalidValueDefault[attrName];
if (meta.tag === null || (node && node.tag && meta.tag.includes(node.tag))) {
if (!meta.valid.includes(newAttrValue)) {
newAttrValue = meta.default;
}
}
}
newAttrs[attrName] = newAttrValue;
});
return newAttrs;
};
}

View File

@@ -0,0 +1,17 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.default = removeAttributeQuotes;
// Specification: https://html.spec.whatwg.org/multipage/syntax.html#attributes-2
// See also: https://github.com/posthtml/posthtml-render/pull/30
// See also: https://github.com/posthtml/htmlnano/issues/6#issuecomment-707105334
/** Disable quoteAllAttributes while not overriding the configuration */
function removeAttributeQuotes(tree) {
if (tree.options && typeof tree.options.quoteAllAttributes === 'undefined') {
tree.options.quoteAllAttributes = false;
}
return tree;
}

View File

@@ -0,0 +1,12 @@
// Specification: https://html.spec.whatwg.org/multipage/syntax.html#attributes-2
// See also: https://github.com/posthtml/posthtml-render/pull/30
// See also: https://github.com/posthtml/htmlnano/issues/6#issuecomment-707105334
/** Disable quoteAllAttributes while not overriding the configuration */
export default function removeAttributeQuotes(tree) {
if (tree.options && typeof tree.options.quoteAllAttributes === 'undefined') {
tree.options.quoteAllAttributes = false;
}
return tree;
}

View File

@@ -0,0 +1,86 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.onContent = onContent;
exports.onNode = onNode;
var _helpers = require("../helpers.cjs");
const MATCH_EXCERPT_REGEXP = /<!-- ?more ?-->/i;
/** Removes HTML comments */
function onNode(options, removeType) {
if (removeType !== 'all' && removeType !== 'safe' && !isMatcher(removeType)) {
removeType = 'safe';
}
return node => {
if (isCommentToRemove(node, removeType)) {
return '';
}
return node;
};
}
function onContent(options, removeType) {
if (removeType !== 'all' && removeType !== 'safe' && !isMatcher(removeType)) {
removeType = 'safe';
}
return contents => {
return contents.filter(content => !isCommentToRemove(content, removeType));
};
}
function isCommentToRemove(text, removeType) {
if (typeof text !== 'string') {
return false;
}
if (!(0, _helpers.isComment)(text)) {
// Not HTML comment
return false;
}
if (removeType === 'safe') {
const isNoindex = text === '<!--noindex-->' || text === '<!--/noindex-->';
// Don't remove noindex comments.
// See: https://yandex.com/support/webmaster/controlling-robot/html.xml
if (isNoindex) {
return false;
}
const isServerSideExclude = text === '<!--sse-->' || text === '<!--/sse-->';
// Don't remove sse comments.
// See: https://support.cloudflare.com/hc/en-us/articles/200170036-What-does-Server-Side-Excludes-SSE-do-
if (isServerSideExclude) {
return false;
}
// https://en.wikipedia.org/wiki/Conditional_comment
if ((0, _helpers.isConditionalComment)(text)) {
return false;
}
// Hexo: https://hexo.io/docs/tag-plugins#Post-Excerpt
// Hugo: https://gohugo.io/content-management/summaries/#manual-summary-splitting
// WordPress: https://wordpress.com/support/wordpress-editor/blocks/more-block/2/
// Jekyll: https://jekyllrb.com/docs/posts/#post-excerpts
const isCMSExcerptComment = MATCH_EXCERPT_REGEXP.test(text);
if (isCMSExcerptComment) {
return false;
}
}
if (isMatcher(removeType)) {
return isMatch(text, removeType);
}
return true;
}
function isMatch(input, matcher) {
if (matcher instanceof RegExp) {
return matcher.test(input);
}
if (typeof matcher === 'function') {
return Boolean(matcher(input));
}
return false;
}
function isMatcher(matcher) {
if (matcher instanceof RegExp || typeof matcher === 'function') {
return true;
}
return false;
}

View File

@@ -0,0 +1,92 @@
import { isComment, isConditionalComment } from '../helpers.mjs';
const MATCH_EXCERPT_REGEXP = /<!-- ?more ?-->/i;
/** Removes HTML comments */
export function onNode(options, removeType) {
if (removeType !== 'all' && removeType !== 'safe' && !isMatcher(removeType)) {
removeType = 'safe';
}
return (node) => {
if (isCommentToRemove(node, removeType)) {
return '';
}
return node;
};
}
export function onContent(options, removeType) {
if (removeType !== 'all' && removeType !== 'safe' && !isMatcher(removeType)) {
removeType = 'safe';
}
return (contents) => {
return contents.filter(content => ! isCommentToRemove(content, removeType));
};
}
function isCommentToRemove(text, removeType) {
if (typeof text !== 'string') {
return false;
}
if (! isComment(text)) {
// Not HTML comment
return false;
}
if (removeType === 'safe') {
const isNoindex = text === '<!--noindex-->' || text === '<!--/noindex-->';
// Don't remove noindex comments.
// See: https://yandex.com/support/webmaster/controlling-robot/html.xml
if (isNoindex) {
return false;
}
const isServerSideExclude = text === '<!--sse-->' || text === '<!--/sse-->';
// Don't remove sse comments.
// See: https://support.cloudflare.com/hc/en-us/articles/200170036-What-does-Server-Side-Excludes-SSE-do-
if (isServerSideExclude) {
return false;
}
// https://en.wikipedia.org/wiki/Conditional_comment
if (isConditionalComment(text)) {
return false;
}
// Hexo: https://hexo.io/docs/tag-plugins#Post-Excerpt
// Hugo: https://gohugo.io/content-management/summaries/#manual-summary-splitting
// WordPress: https://wordpress.com/support/wordpress-editor/blocks/more-block/2/
// Jekyll: https://jekyllrb.com/docs/posts/#post-excerpts
const isCMSExcerptComment = MATCH_EXCERPT_REGEXP.test(text);
if (isCMSExcerptComment) {
return false;
}
}
if (isMatcher(removeType)) {
return isMatch(text, removeType);
}
return true;
}
function isMatch(input, matcher) {
if (matcher instanceof RegExp) {
return matcher.test(input);
}
if (typeof matcher === 'function') {
return Boolean(matcher(input));
}
return false;
}
function isMatcher(matcher) {
if (matcher instanceof RegExp || typeof matcher === 'function') {
return true;
}
return false;
}

View File

@@ -0,0 +1,72 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.onAttrs = onAttrs;
var _helpers = require("../helpers.cjs");
const safeToRemoveAttrs = {
id: null,
class: null,
style: null,
title: null,
lang: null,
dir: null,
abbr: ['th'],
accept: ['input'],
'accept-charset': ['form'],
charset: ['meta', 'script'],
action: ['form'],
cols: ['textarea'],
colspan: ['td', 'th'],
coords: ['area'],
dirname: ['input', 'textarea'],
dropzone: null,
headers: ['td', 'th'],
form: ['button', 'fieldset', 'input', 'keygen', 'object', 'output', 'select', 'textarea'],
formaction: ['button', 'input'],
height: ['canvas', 'embed', 'iframe', 'img', 'input', 'object', 'video'],
high: 'meter',
href: 'link',
list: 'input',
low: 'meter',
manifest: 'html',
max: ['meter', 'progress'],
maxLength: ['input', 'textarea'],
menu: 'button',
min: 'meter',
minLength: ['input', 'textarea'],
name: ['button', 'fieldset', 'input', 'keygen', 'output', 'select', 'textarea', 'form', 'map', 'meta', 'param', 'slot'],
pattern: ['input'],
ping: ['a', 'area'],
placeholder: ['input', 'textarea'],
poster: ['video'],
rel: ['a', 'area', 'link'],
rows: 'textarea',
rowspan: ['td', 'th'],
size: ['input', 'select'],
span: ['col', 'colgroup'],
src: ['audio', 'embed', 'iframe', 'img', 'input', 'script', 'source', 'track', 'video'],
start: 'ol',
tabindex: null,
type: ['a', 'link', 'button', 'embed', 'object', 'script', 'source', 'style', 'input', 'menu', 'menuitem', 'ol'],
value: ['button', 'input', 'li'],
width: ['canvas', 'embed', 'iframe', 'img', 'input', 'object', 'video']
};
function onAttrs() {
return (attrs, node) => {
const newAttrs = {
...attrs
};
Object.entries(attrs).forEach(([attrName, attrValue]) => {
if ((0, _helpers.isEventHandler)(attrName) || Object.hasOwnProperty.call(safeToRemoveAttrs, attrName) && (safeToRemoveAttrs[attrName] === null || safeToRemoveAttrs[attrName].includes(node.tag))) {
if (typeof attrValue === 'string') {
if (attrValue === '' || attrValue.trim() === '') {
delete newAttrs[attrName];
}
}
}
});
return newAttrs;
};
}

View File

@@ -0,0 +1,121 @@
import { isEventHandler } from '../helpers.mjs';
const safeToRemoveAttrs = {
id: null,
class: null,
style: null,
title: null,
lang: null,
dir: null,
abbr: ['th'],
accept: ['input'],
'accept-charset': ['form'],
charset: ['meta', 'script'],
action: ['form'],
cols: ['textarea'],
colspan: ['td', 'th'],
coords: ['area'],
dirname: ['input', 'textarea'],
dropzone: null,
headers: ['td', 'th'],
form: [
'button',
'fieldset',
'input',
'keygen',
'object',
'output',
'select',
'textarea'
],
formaction: ['button', 'input'],
height: ['canvas', 'embed', 'iframe', 'img', 'input', 'object', 'video'],
high: 'meter',
href: 'link',
list: 'input',
low: 'meter',
manifest: 'html',
max: ['meter', 'progress'],
maxLength: ['input', 'textarea'],
menu: 'button',
min: 'meter',
minLength: ['input', 'textarea'],
name: [
'button',
'fieldset',
'input',
'keygen',
'output',
'select',
'textarea',
'form',
'map',
'meta',
'param',
'slot'
],
pattern: ['input'],
ping: ['a', 'area'],
placeholder: ['input', 'textarea'],
poster: ['video'],
rel: ['a', 'area', 'link'],
rows: 'textarea',
rowspan: ['td', 'th'],
size: ['input', 'select'],
span: ['col', 'colgroup'],
src: [
'audio',
'embed',
'iframe',
'img',
'input',
'script',
'source',
'track',
'video'
],
start: 'ol',
tabindex: null,
type: [
'a',
'link',
'button',
'embed',
'object',
'script',
'source',
'style',
'input',
'menu',
'menuitem',
'ol'
],
value: ['button', 'input', 'li'],
width: ['canvas', 'embed', 'iframe', 'img', 'input', 'object', 'video']
};
export function onAttrs() {
return (attrs, node) => {
const newAttrs = { ...attrs };
Object.entries(attrs).forEach(([attrName, attrValue]) => {
if (
isEventHandler(attrName)
|| (
Object.hasOwnProperty.call(safeToRemoveAttrs, attrName)
&& (
safeToRemoveAttrs[attrName] === null
|| safeToRemoveAttrs[attrName].includes(node.tag)
)
)
) {
if (typeof attrValue === 'string') {
if (attrValue === '' || attrValue.trim() === '') {
delete newAttrs[attrName];
}
}
}
});
return newAttrs;
};
}

View File

@@ -0,0 +1,183 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.default = removeOptionalTags;
var _helpers = require("../helpers.cjs");
const startWithWhitespacePattern = /^\s+/;
const bodyStartTagCantBeOmittedWithFirstChildTags = new Set(['meta', 'link', 'script', 'style']);
const tbodyStartTagCantBeOmittedWithPrecededTags = new Set(['tbody', 'thead', 'tfoot']);
const tbodyEndTagCantBeOmittedWithFollowedTags = new Set(['tbody', 'tfoot']);
function isEmptyTextNode(node) {
if (typeof node === 'string' && node.trim() === '') {
return true;
}
return false;
}
function isEmptyNode(node) {
if (!node.content) {
return true;
}
if (node.content.length) {
return !node.content.filter(n => typeof n === 'string' && isEmptyTextNode(n) ? false : true).length;
}
return true;
}
function getFirstChildTag(node, nonEmpty = true) {
if (node.content && node.content.length) {
if (nonEmpty) {
for (const childNode of node.content) {
if (childNode.tag) return childNode;
if (typeof childNode === 'string' && !isEmptyTextNode(childNode)) return childNode;
}
} else {
return node.content[0] || null;
}
}
return null;
}
function getPrevNode(tree, currentNodeIndex, nonEmpty = false) {
if (nonEmpty) {
for (let i = currentNodeIndex - 1; i >= 0; i--) {
const node = tree[i];
if (node.tag) return node;
if (typeof node === 'string' && !isEmptyTextNode(node)) return node;
}
} else {
return tree[currentNodeIndex - 1] || null;
}
return null;
}
function getNextNode(tree, currentNodeIndex, nonEmpty = false) {
if (nonEmpty) {
for (let i = currentNodeIndex + 1; i < tree.length; i++) {
const node = tree[i];
if (node.tag) return node;
if (typeof node === 'string' && !isEmptyTextNode(node)) return node;
}
} else {
return tree[currentNodeIndex + 1] || null;
}
return null;
}
// Specification https://html.spec.whatwg.org/multipage/syntax.html#optional-tags
/** Remove optional tag in the DOM */
function removeOptionalTags(tree) {
tree.forEach((node, index) => {
if (!node.tag) return node;
if (node.attrs && Object.keys(node.attrs).length) return node;
// const prevNode = getPrevNode(tree, index);
const prevNonEmptyNode = getPrevNode(tree, index, true);
const nextNode = getNextNode(tree, index);
const nextNonEmptyNode = getNextNode(tree, index, true);
const firstChildNode = getFirstChildTag(node, false);
const firstNonEmptyChildNode = getFirstChildTag(node);
/**
* An "html" element's start tag may be omitted if the first thing inside the "html" element is not a comment.
* An "html" element's end tag may be omitted if the "html" element is not IMMEDIATELY followed by a comment.
*/
if (node.tag === 'html') {
let isHtmlStartTagCanBeOmitted = true;
let isHtmlEndTagCanBeOmitted = true;
if (typeof firstNonEmptyChildNode === 'string' && (0, _helpers.isComment)(firstNonEmptyChildNode)) {
isHtmlStartTagCanBeOmitted = false;
}
if (typeof nextNonEmptyNode === 'string' && (0, _helpers.isComment)(nextNonEmptyNode)) {
isHtmlEndTagCanBeOmitted = false;
}
if (isHtmlStartTagCanBeOmitted && isHtmlEndTagCanBeOmitted) {
node.tag = false;
}
}
/**
* A "head" element's start tag may be omitted if the element is empty, or if the first thing inside the "head" element is an element.
* A "head" element's end tag may be omitted if the "head" element is not IMMEDIATELY followed by ASCII whitespace or a comment.
*/
if (node.tag === 'head') {
let isHeadStartTagCanBeOmitted = false;
let isHeadEndTagCanBeOmitted = true;
if (isEmptyNode(node) || firstNonEmptyChildNode && firstNonEmptyChildNode.tag) {
isHeadStartTagCanBeOmitted = true;
}
if (nextNode && typeof nextNode === 'string' && startWithWhitespacePattern.test(nextNode) || nextNonEmptyNode && typeof nextNonEmptyNode === 'string' && (0, _helpers.isComment)(nextNode)) {
isHeadEndTagCanBeOmitted = false;
}
if (isHeadStartTagCanBeOmitted && isHeadEndTagCanBeOmitted) {
node.tag = false;
}
}
/**
* A "body" element's start tag may be omitted if the element is empty, or if the first thing inside the "body" element is not ASCII whitespace or a comment, except if the first thing inside the "body" element is a "meta", "link", "script", "style", or "template" element.
* A "body" element's end tag may be omitted if the "body" element is not IMMEDIATELY followed by a comment.
*/
if (node.tag === 'body') {
let isBodyStartTagCanBeOmitted = true;
let isBodyEndTagCanBeOmitted = true;
if (typeof firstChildNode === 'string' && startWithWhitespacePattern.test(firstChildNode) || typeof firstNonEmptyChildNode === 'string' && (0, _helpers.isComment)(firstNonEmptyChildNode)) {
isBodyStartTagCanBeOmitted = false;
}
if (firstNonEmptyChildNode && firstNonEmptyChildNode.tag && bodyStartTagCantBeOmittedWithFirstChildTags.has(firstNonEmptyChildNode.tag)) {
isBodyStartTagCanBeOmitted = false;
}
if (nextNode && typeof nextNode === 'string' && (0, _helpers.isComment)(nextNode)) {
isBodyEndTagCanBeOmitted = false;
}
if (isBodyStartTagCanBeOmitted && isBodyEndTagCanBeOmitted) {
node.tag = false;
}
}
/**
* A "colgroup" element's start tag may be omitted if the first thing inside the "colgroup" element is a "col" element, and if the element is not IMMEDIATELY preceded by another "colgroup" element. It can't be omitted if the element is empty.
* A "colgroup" element's end tag may be omitted if the "colgroup" element is not IMMEDIATELY followed by ASCII whitespace or a comment.
*/
if (node.tag === 'colgroup') {
let isColgroupStartTagCanBeOmitted = false;
let isColgroupEndTagCanBeOmitted = true;
if (firstNonEmptyChildNode && firstNonEmptyChildNode.tag && firstNonEmptyChildNode.tag === 'col') {
isColgroupStartTagCanBeOmitted = true;
}
if (prevNonEmptyNode && prevNonEmptyNode.tag && prevNonEmptyNode.tag === 'colgroup') {
isColgroupStartTagCanBeOmitted = false;
}
if (nextNode && typeof nextNode === 'string' && startWithWhitespacePattern.test(nextNode) || nextNonEmptyNode && typeof nextNonEmptyNode === 'string' && (0, _helpers.isComment)(nextNonEmptyNode)) {
isColgroupEndTagCanBeOmitted = false;
}
if (isColgroupStartTagCanBeOmitted && isColgroupEndTagCanBeOmitted) {
node.tag = false;
}
}
/**
* A "tbody" element's start tag may be omitted if the first thing inside the "tbody" element is a "tr" element, and if the element is not immediately preceded by another "tbody", "thead" or "tfoot" element. It can't be omitted if the element is empty.
* A "tbody" element's end tag may be omitted if the "tbody" element is not IMMEDIATELY followed by a "tbody" or "tfoot" element.
*/
if (node.tag === 'tbody') {
let isTbodyStartTagCanBeOmitted = false;
let isTbodyEndTagCanBeOmitted = true;
if (firstNonEmptyChildNode && firstNonEmptyChildNode.tag && firstNonEmptyChildNode.tag === 'tr') {
isTbodyStartTagCanBeOmitted = true;
}
if (prevNonEmptyNode && prevNonEmptyNode.tag && tbodyStartTagCantBeOmittedWithPrecededTags.has(prevNonEmptyNode.tag)) {
isTbodyStartTagCanBeOmitted = false;
}
if (nextNonEmptyNode && nextNonEmptyNode.tag && tbodyEndTagCantBeOmittedWithFollowedTags.has(nextNonEmptyNode.tag)) {
isTbodyEndTagCanBeOmitted = false;
}
if (isTbodyStartTagCanBeOmitted && isTbodyEndTagCanBeOmitted) {
node.tag = false;
}
}
if (node.content && node.content.length) {
removeOptionalTags(node.content);
}
return node;
});
return tree;
}

View File

@@ -0,0 +1,225 @@
import { isComment } from '../helpers.mjs';
const startWithWhitespacePattern = /^\s+/;
const bodyStartTagCantBeOmittedWithFirstChildTags = new Set(['meta', 'link', 'script', 'style']);
const tbodyStartTagCantBeOmittedWithPrecededTags = new Set(['tbody', 'thead', 'tfoot']);
const tbodyEndTagCantBeOmittedWithFollowedTags = new Set(['tbody', 'tfoot']);
function isEmptyTextNode(node) {
if (typeof node === 'string' && node.trim() === '') {
return true;
}
return false;
}
function isEmptyNode(node) {
if (!node.content) {
return true;
}
if (node.content.length) {
return !node.content.filter(n => typeof n === 'string' && isEmptyTextNode(n) ? false : true).length;
}
return true;
}
function getFirstChildTag(node, nonEmpty = true) {
if (node.content && node.content.length) {
if (nonEmpty) {
for (const childNode of node.content) {
if (childNode.tag) return childNode;
if (typeof childNode === 'string' && !isEmptyTextNode(childNode)) return childNode;
}
} else {
return node.content[0] || null;
}
}
return null;
}
function getPrevNode(tree, currentNodeIndex, nonEmpty = false) {
if (nonEmpty) {
for (let i = currentNodeIndex - 1; i >= 0; i--) {
const node = tree[i];
if (node.tag) return node;
if (typeof node === 'string' && !isEmptyTextNode(node)) return node;
}
} else {
return tree[currentNodeIndex - 1] || null;
}
return null;
}
function getNextNode(tree, currentNodeIndex, nonEmpty = false) {
if (nonEmpty) {
for (let i = currentNodeIndex + 1; i < tree.length; i++) {
const node = tree[i];
if (node.tag) return node;
if (typeof node === 'string' && !isEmptyTextNode(node)) return node;
}
} else {
return tree[currentNodeIndex + 1] || null;
}
return null;
}
// Specification https://html.spec.whatwg.org/multipage/syntax.html#optional-tags
/** Remove optional tag in the DOM */
export default function removeOptionalTags(tree) {
tree.forEach((node, index) => {
if (!node.tag) return node;
if (node.attrs && Object.keys(node.attrs).length) return node;
// const prevNode = getPrevNode(tree, index);
const prevNonEmptyNode = getPrevNode(tree, index, true);
const nextNode = getNextNode(tree, index);
const nextNonEmptyNode = getNextNode(tree, index, true);
const firstChildNode = getFirstChildTag(node, false);
const firstNonEmptyChildNode = getFirstChildTag(node);
/**
* An "html" element's start tag may be omitted if the first thing inside the "html" element is not a comment.
* An "html" element's end tag may be omitted if the "html" element is not IMMEDIATELY followed by a comment.
*/
if (node.tag === 'html') {
let isHtmlStartTagCanBeOmitted = true;
let isHtmlEndTagCanBeOmitted = true;
if (typeof firstNonEmptyChildNode === 'string' && isComment(firstNonEmptyChildNode)) {
isHtmlStartTagCanBeOmitted = false;
}
if (typeof nextNonEmptyNode === 'string' && isComment(nextNonEmptyNode)) {
isHtmlEndTagCanBeOmitted = false;
}
if (isHtmlStartTagCanBeOmitted && isHtmlEndTagCanBeOmitted) {
node.tag = false;
}
}
/**
* A "head" element's start tag may be omitted if the element is empty, or if the first thing inside the "head" element is an element.
* A "head" element's end tag may be omitted if the "head" element is not IMMEDIATELY followed by ASCII whitespace or a comment.
*/
if (node.tag === 'head') {
let isHeadStartTagCanBeOmitted = false;
let isHeadEndTagCanBeOmitted = true;
if (
isEmptyNode(node) ||
firstNonEmptyChildNode && firstNonEmptyChildNode.tag
) {
isHeadStartTagCanBeOmitted = true;
}
if (
(nextNode && typeof nextNode === 'string' && startWithWhitespacePattern.test(nextNode)) ||
(nextNonEmptyNode && typeof nextNonEmptyNode === 'string' && isComment(nextNode))
) {
isHeadEndTagCanBeOmitted = false;
}
if (isHeadStartTagCanBeOmitted && isHeadEndTagCanBeOmitted) {
node.tag = false;
}
}
/**
* A "body" element's start tag may be omitted if the element is empty, or if the first thing inside the "body" element is not ASCII whitespace or a comment, except if the first thing inside the "body" element is a "meta", "link", "script", "style", or "template" element.
* A "body" element's end tag may be omitted if the "body" element is not IMMEDIATELY followed by a comment.
*/
if (node.tag === 'body') {
let isBodyStartTagCanBeOmitted = true;
let isBodyEndTagCanBeOmitted = true;
if (
(typeof firstChildNode === 'string' && startWithWhitespacePattern.test(firstChildNode)) ||
(typeof firstNonEmptyChildNode === 'string' && isComment(firstNonEmptyChildNode))
) {
isBodyStartTagCanBeOmitted = false;
}
if (firstNonEmptyChildNode && firstNonEmptyChildNode.tag && bodyStartTagCantBeOmittedWithFirstChildTags.has(firstNonEmptyChildNode.tag)) {
isBodyStartTagCanBeOmitted = false;
}
if (nextNode && typeof nextNode === 'string' && isComment(nextNode)) {
isBodyEndTagCanBeOmitted = false;
}
if (isBodyStartTagCanBeOmitted && isBodyEndTagCanBeOmitted) {
node.tag = false;
}
}
/**
* A "colgroup" element's start tag may be omitted if the first thing inside the "colgroup" element is a "col" element, and if the element is not IMMEDIATELY preceded by another "colgroup" element. It can't be omitted if the element is empty.
* A "colgroup" element's end tag may be omitted if the "colgroup" element is not IMMEDIATELY followed by ASCII whitespace or a comment.
*/
if (node.tag === 'colgroup') {
let isColgroupStartTagCanBeOmitted = false;
let isColgroupEndTagCanBeOmitted = true;
if (firstNonEmptyChildNode && firstNonEmptyChildNode.tag && firstNonEmptyChildNode.tag === 'col') {
isColgroupStartTagCanBeOmitted = true;
}
if (prevNonEmptyNode && prevNonEmptyNode.tag && prevNonEmptyNode.tag === 'colgroup') {
isColgroupStartTagCanBeOmitted = false;
}
if (
(nextNode && typeof nextNode === 'string' && startWithWhitespacePattern.test(nextNode)) ||
(nextNonEmptyNode && typeof nextNonEmptyNode === 'string' && isComment(nextNonEmptyNode))
) {
isColgroupEndTagCanBeOmitted = false;
}
if (isColgroupStartTagCanBeOmitted && isColgroupEndTagCanBeOmitted) {
node.tag = false;
}
}
/**
* A "tbody" element's start tag may be omitted if the first thing inside the "tbody" element is a "tr" element, and if the element is not immediately preceded by another "tbody", "thead" or "tfoot" element. It can't be omitted if the element is empty.
* A "tbody" element's end tag may be omitted if the "tbody" element is not IMMEDIATELY followed by a "tbody" or "tfoot" element.
*/
if (node.tag === 'tbody') {
let isTbodyStartTagCanBeOmitted = false;
let isTbodyEndTagCanBeOmitted = true;
if (firstNonEmptyChildNode && firstNonEmptyChildNode.tag && firstNonEmptyChildNode.tag === 'tr') {
isTbodyStartTagCanBeOmitted = true;
}
if (prevNonEmptyNode && prevNonEmptyNode.tag && tbodyStartTagCantBeOmittedWithPrecededTags.has(prevNonEmptyNode.tag)) {
isTbodyStartTagCanBeOmitted = false;
}
if (nextNonEmptyNode && nextNonEmptyNode.tag && tbodyEndTagCantBeOmittedWithFollowedTags.has(nextNonEmptyNode.tag)) {
isTbodyEndTagCanBeOmitted = false;
}
if (isTbodyStartTagCanBeOmitted && isTbodyEndTagCanBeOmitted) {
node.tag = false;
}
}
if (node.content && node.content.length) {
removeOptionalTags(node.content);
}
return node;
});
return tree;
}

View File

@@ -0,0 +1,112 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.onAttrs = onAttrs;
exports.redundantScriptTypes = void 0;
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types#JavaScript_types
const redundantScriptTypes = exports.redundantScriptTypes = new Set(['application/javascript', 'application/ecmascript', 'application/x-ecmascript', 'application/x-javascript', 'text/javascript', 'text/ecmascript', 'text/javascript1.0', 'text/javascript1.1', 'text/javascript1.2', 'text/javascript1.3', 'text/javascript1.4', 'text/javascript1.5', 'text/jscript', 'text/livescript', 'text/x-ecmascript', 'text/x-javascript']);
// https://html.spec.whatwg.org/multipage/common-microsyntaxes.html#missing-value-default
const missingValueDefaultAttributes = {
'form': {
'method': 'get'
},
input: {
type: 'text'
},
button: {
// https://html.spec.whatwg.org/multipage/form-elements.html#attr-button-type
type: 'submit'
},
'script': {
'language': 'javascript',
'type': attrs => {
for (const [attrName, attrValue] of Object.entries(attrs)) {
if (attrName.toLowerCase() !== 'type') {
continue;
}
return redundantScriptTypes.has(attrValue);
}
return false;
},
// Remove attribute if the function returns false
'charset': attrs => {
// The charset attribute only really makes sense on “external” SCRIPT elements:
// http://perfectionkills.com/optimizing-html/#8_script_charset
return !attrs.src;
}
},
'style': {
'media': 'all',
'type': 'text/css'
},
'link': {
media: 'all',
'type': attrs => {
// https://html.spec.whatwg.org/multipage/links.html#link-type-stylesheet
let isRelStyleSheet = false;
let isTypeTextCSS = false;
if (attrs) {
for (const [attrName, attrValue] of Object.entries(attrs)) {
if (attrName.toLowerCase() === 'rel' && attrValue === 'stylesheet') {
isRelStyleSheet = true;
}
if (attrName.toLowerCase() === 'type' && attrValue === 'text/css') {
isTypeTextCSS = true;
}
}
}
// Only "text/css" is redudant for link[rel=stylesheet]. Otherwise "type" shouldn't be removed
return isRelStyleSheet && isTypeTextCSS;
}
},
// See: https://html.spec.whatwg.org/#lazy-loading-attributes
img: {
'loading': 'eager',
// https://html.spec.whatwg.org/multipage/embedded-content.html#dom-img-decoding
decoding: 'auto'
},
iframe: {
'loading': 'eager'
},
// https://html.spec.whatwg.org/multipage/media.html#htmltrackelement
track: {
kind: 'subtitles'
},
textarea: {
// https://html.spec.whatwg.org/multipage/form-elements.html#dom-textarea-wrap
wrap: 'soft'
},
area: {
// https://html.spec.whatwg.org/multipage/image-maps.html#attr-area-shape
shape: 'rect'
}
};
const tagsHaveMissingValueDefaultAttributes = new Set(Object.keys(missingValueDefaultAttributes));
/** Removes redundant attributes */
function onAttrs() {
return (attrs, node) => {
if (!node.tag) return attrs;
const newAttrs = attrs;
if (tagsHaveMissingValueDefaultAttributes.has(node.tag)) {
const tagRedundantAttributes = missingValueDefaultAttributes[node.tag];
for (const redundantAttributeName of Object.keys(tagRedundantAttributes)) {
let tagRedundantAttributeValue = tagRedundantAttributes[redundantAttributeName];
let isRemove = false;
if (typeof tagRedundantAttributeValue === 'function') {
isRemove = tagRedundantAttributeValue(attrs);
} else if (attrs[redundantAttributeName] === tagRedundantAttributeValue) {
isRemove = true;
}
if (isRemove) {
delete newAttrs[redundantAttributeName];
}
}
}
return newAttrs;
};
}

View File

@@ -0,0 +1,141 @@
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types#JavaScript_types
export const redundantScriptTypes = new Set([
'application/javascript',
'application/ecmascript',
'application/x-ecmascript',
'application/x-javascript',
'text/javascript',
'text/ecmascript',
'text/javascript1.0',
'text/javascript1.1',
'text/javascript1.2',
'text/javascript1.3',
'text/javascript1.4',
'text/javascript1.5',
'text/jscript',
'text/livescript',
'text/x-ecmascript',
'text/x-javascript'
]);
// https://html.spec.whatwg.org/multipage/common-microsyntaxes.html#missing-value-default
const missingValueDefaultAttributes = {
'form': {
'method': 'get'
},
input: {
type: 'text'
},
button: {
// https://html.spec.whatwg.org/multipage/form-elements.html#attr-button-type
type: 'submit'
},
'script': {
'language': 'javascript',
'type': attrs => {
for (const [attrName, attrValue] of Object.entries(attrs)) {
if (attrName.toLowerCase() !== 'type') {
continue;
}
return redundantScriptTypes.has(attrValue);
}
return false;
},
// Remove attribute if the function returns false
'charset': attrs => {
// The charset attribute only really makes sense on “external” SCRIPT elements:
// http://perfectionkills.com/optimizing-html/#8_script_charset
return !attrs.src;
}
},
'style': {
'media': 'all',
'type': 'text/css'
},
'link': {
media: 'all',
'type': attrs => {
// https://html.spec.whatwg.org/multipage/links.html#link-type-stylesheet
let isRelStyleSheet = false;
let isTypeTextCSS = false;
if (attrs) {
for (const [attrName, attrValue] of Object.entries(attrs)) {
if (attrName.toLowerCase() === 'rel' && attrValue === 'stylesheet') {
isRelStyleSheet = true;
}
if (attrName.toLowerCase() === 'type' && attrValue === 'text/css') {
isTypeTextCSS = true;
}
}
}
// Only "text/css" is redudant for link[rel=stylesheet]. Otherwise "type" shouldn't be removed
return isRelStyleSheet && isTypeTextCSS;
}
},
// See: https://html.spec.whatwg.org/#lazy-loading-attributes
img: {
'loading': 'eager',
// https://html.spec.whatwg.org/multipage/embedded-content.html#dom-img-decoding
decoding: 'auto'
},
iframe: {
'loading': 'eager'
},
// https://html.spec.whatwg.org/multipage/media.html#htmltrackelement
track: {
kind: 'subtitles'
},
textarea: {
// https://html.spec.whatwg.org/multipage/form-elements.html#dom-textarea-wrap
wrap: 'soft'
},
area: {
// https://html.spec.whatwg.org/multipage/image-maps.html#attr-area-shape
shape: 'rect'
}
};
const tagsHaveMissingValueDefaultAttributes = new Set(Object.keys(missingValueDefaultAttributes));
/** Removes redundant attributes */
export function onAttrs() {
return (attrs, node) => {
if (!node.tag) return attrs;
const newAttrs = attrs;
if (tagsHaveMissingValueDefaultAttributes.has(node.tag)) {
const tagRedundantAttributes = missingValueDefaultAttributes[node.tag];
for (const redundantAttributeName of Object.keys(tagRedundantAttributes)) {
let tagRedundantAttributeValue = tagRedundantAttributes[redundantAttributeName];
let isRemove = false;
if (typeof tagRedundantAttributeValue === 'function') {
isRemove = tagRedundantAttributeValue(attrs);
} else if (attrs[redundantAttributeName] === tagRedundantAttributeValue) {
isRemove = true;
}
if (isRemove) {
delete newAttrs[redundantAttributeName];
}
}
}
return newAttrs;
};
}

View File

@@ -0,0 +1,113 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.default = removeUnusedCss;
var _helpers = require("../helpers.cjs");
// These options must be set and shouldn't be overriden to ensure uncss doesn't look at linked stylesheets.
const uncssOptions = {
ignoreSheets: [/\s*/],
stylesheets: []
};
function processStyleNodeUnCSS(html, styleNode, uncssOptions, uncss) {
const css = (0, _helpers.extractCssFromStyleNode)(styleNode);
return runUncss(html, css, uncssOptions, uncss).then(css => {
// uncss may have left some style tags empty
if (css.trim().length === 0) {
styleNode.tag = false;
styleNode.content = [];
return;
}
styleNode.content = [css];
});
}
function runUncss(html, css, userOptions, uncss) {
if (typeof userOptions !== 'object') {
userOptions = {};
}
const options = {
...userOptions,
...uncssOptions
};
return new Promise((resolve, reject) => {
options.raw = css;
uncss(html, options, (error, output) => {
if (error) {
reject(error);
return;
}
resolve(output);
});
});
}
const purgeFromHtml = function (tree) {
// content is not used as we can directly used the parsed HTML,
// making the process faster
const selectors = [];
tree.walk(node => {
const classes = node.attrs && node.attrs.class && node.attrs.class.split(' ') || [];
const ids = node.attrs && node.attrs.id && node.attrs.id.split(' ') || [];
selectors.push(...classes, ...ids);
node.tag && selectors.push(node.tag);
return node;
});
return () => selectors;
};
function processStyleNodePurgeCSS(tree, styleNode, purgecssOptions, purgecss) {
const css = (0, _helpers.extractCssFromStyleNode)(styleNode);
return runPurgecss(tree, css, purgecssOptions, purgecss).then(css => {
if (css.trim().length === 0) {
styleNode.tag = false;
styleNode.content = [];
return;
}
styleNode.content = [css];
});
}
function runPurgecss(tree, css, userOptions, purgecss) {
if (typeof userOptions !== 'object') {
userOptions = {};
}
const options = {
...userOptions,
content: [{
raw: tree,
extension: 'html'
}],
css: [{
raw: css,
extension: 'css'
}],
extractors: [{
extractor: purgeFromHtml(tree),
extensions: ['html']
}]
};
return new purgecss.PurgeCSS().purge(options).then(result => {
return result[0].css;
});
}
/** Remove unused CSS */
async function removeUnusedCss(tree, options, userOptions) {
const promises = [];
const html = userOptions.tool !== 'purgeCSS' && tree.render(tree);
const purgecss = await (0, _helpers.optionalImport)('purgecss');
const uncss = await (0, _helpers.optionalImport)('uncss');
tree.walk(node => {
if ((0, _helpers.isStyleNode)(node)) {
if (userOptions.tool === 'purgeCSS') {
if (purgecss) {
promises.push(processStyleNodePurgeCSS(tree, node, userOptions, purgecss));
}
} else {
if (uncss) {
promises.push(processStyleNodeUnCSS(html, node, userOptions, uncss));
}
}
}
return node;
});
return Promise.all(promises).then(() => tree);
}

View File

@@ -0,0 +1,122 @@
import { isStyleNode, extractCssFromStyleNode, optionalImport } from '../helpers.mjs';
// These options must be set and shouldn't be overriden to ensure uncss doesn't look at linked stylesheets.
const uncssOptions = {
ignoreSheets: [/\s*/],
stylesheets: [],
};
function processStyleNodeUnCSS(html, styleNode, uncssOptions, uncss) {
const css = extractCssFromStyleNode(styleNode);
return runUncss(html, css, uncssOptions, uncss).then(css => {
// uncss may have left some style tags empty
if (css.trim().length === 0) {
styleNode.tag = false;
styleNode.content = [];
return;
}
styleNode.content = [css];
});
}
function runUncss(html, css, userOptions, uncss) {
if (typeof userOptions !== 'object') {
userOptions = {};
}
const options = { ...userOptions, ...uncssOptions };
return new Promise((resolve, reject) => {
options.raw = css;
uncss(html, options, (error, output) => {
if (error) {
reject(error);
return;
}
resolve(output);
});
});
}
const purgeFromHtml = function (tree) {
// content is not used as we can directly used the parsed HTML,
// making the process faster
const selectors = [];
tree.walk(node => {
const classes = node.attrs && node.attrs.class && node.attrs.class.split(' ') || [];
const ids = node.attrs && node.attrs.id && node.attrs.id.split(' ') || [];
selectors.push(...classes, ...ids);
node.tag && selectors.push(node.tag);
return node;
});
return () => selectors;
};
function processStyleNodePurgeCSS(tree, styleNode, purgecssOptions, purgecss) {
const css = extractCssFromStyleNode(styleNode);
return runPurgecss(tree, css, purgecssOptions, purgecss)
.then(css => {
if (css.trim().length === 0) {
styleNode.tag = false;
styleNode.content = [];
return;
}
styleNode.content = [css];
});
}
function runPurgecss(tree, css, userOptions, purgecss) {
if (typeof userOptions !== 'object') {
userOptions = {};
}
const options = {
...userOptions,
content: [{
raw: tree,
extension: 'html'
}],
css: [{
raw: css,
extension: 'css'
}],
extractors: [{
extractor: purgeFromHtml(tree),
extensions: ['html']
}]
};
return new purgecss.PurgeCSS()
.purge(options)
.then((result) => {
return result[0].css;
});
}
/** Remove unused CSS */
export default async function removeUnusedCss(tree, options, userOptions) {
const promises = [];
const html = userOptions.tool !== 'purgeCSS' && tree.render(tree);
const purgecss = await optionalImport('purgecss');
const uncss = await optionalImport('uncss');
tree.walk(node => {
if (isStyleNode(node)) {
if (userOptions.tool === 'purgeCSS') {
if (purgecss) {
promises.push(processStyleNodePurgeCSS(tree, node, userOptions, purgecss));
}
} else {
if (uncss) {
promises.push(processStyleNodeUnCSS(html, node, userOptions, uncss));
}
}
}
return node;
});
return Promise.all(promises).then(() => tree);
}

View File

@@ -0,0 +1,100 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.default = sortAttributes;
var _timsort = require("timsort");
const validOptions = new Set(['frequency', 'alphabetical']);
const processModuleOptions = options => {
if (options === true) return 'alphabetical';
return validOptions.has(options) ? options : false;
};
class AttributeTokenChain {
constructor() {
this.freqData = new Map(); // <attr, frequency>[]
}
addFromNodeAttrs(nodeAttrs) {
Object.keys(nodeAttrs).forEach(attrName => {
const attrNameLower = attrName.toLowerCase();
if (this.freqData.has(attrNameLower)) {
this.freqData.set(attrNameLower, this.freqData.get(attrNameLower) + 1);
} else {
this.freqData.set(attrNameLower, 1);
}
});
}
createSortOrder() {
let _sortOrder = [...this.freqData.entries()];
(0, _timsort.sort)(_sortOrder, (a, b) => b[1] - a[1]);
this.sortOrder = _sortOrder.map(i => i[0]);
}
sortFromNodeAttrs(nodeAttrs) {
const newAttrs = {};
// Convert node.attrs attrName into lower case.
const loweredNodeAttrs = {};
Object.entries(nodeAttrs).forEach(([attrName, attrValue]) => {
loweredNodeAttrs[attrName.toLowerCase()] = attrValue;
});
if (!this.sortOrder) {
this.createSortOrder();
}
this.sortOrder.forEach(attrNameLower => {
// The attrName inside "sortOrder" has been lowered
if (loweredNodeAttrs[attrNameLower] != null) {
newAttrs[attrNameLower] = loweredNodeAttrs[attrNameLower];
}
});
return newAttrs;
}
}
/** Sort attibutes */
function sortAttributes(tree, options, moduleOptions) {
const sortType = processModuleOptions(moduleOptions);
if (sortType === 'alphabetical') {
return sortAttributesInAlphabeticalOrder(tree);
}
if (sortType === 'frequency') {
return sortAttributesByFrequency(tree);
}
// Invalid configuration
return tree;
}
function sortAttributesInAlphabeticalOrder(tree) {
tree.walk(node => {
if (!node.attrs) {
return node;
}
const newAttrs = {};
Object.keys(node.attrs).sort((a, b) => typeof a.localeCompare === 'function' ? a.localeCompare(b) : a - b).forEach(attr => newAttrs[attr] = node.attrs[attr]);
node.attrs = newAttrs;
return node;
});
return tree;
}
function sortAttributesByFrequency(tree) {
const tokenchain = new AttributeTokenChain();
// Traverse through tree to get frequency
tree.walk(node => {
if (!node.attrs) {
return node;
}
tokenchain.addFromNodeAttrs(node.attrs);
return node;
});
// Traverse through tree again, this time sort the attributes
tree.walk(node => {
if (!node.attrs) {
return node;
}
node.attrs = tokenchain.sortFromNodeAttrs(node.attrs);
return node;
});
return tree;
}

View File

@@ -0,0 +1,121 @@
import { sort as timSort } from 'timsort';
const validOptions = new Set(['frequency', 'alphabetical']);
const processModuleOptions = options => {
if (options === true) return 'alphabetical';
return validOptions.has(options) ? options : false;
};
class AttributeTokenChain {
constructor() {
this.freqData = new Map(); // <attr, frequency>[]
}
addFromNodeAttrs(nodeAttrs) {
Object.keys(nodeAttrs).forEach(attrName => {
const attrNameLower = attrName.toLowerCase();
if (this.freqData.has(attrNameLower)) {
this.freqData.set(attrNameLower, this.freqData.get(attrNameLower) + 1);
} else {
this.freqData.set(attrNameLower, 1);
}
});
}
createSortOrder() {
let _sortOrder = [...this.freqData.entries()];
timSort(_sortOrder, (a, b) => b[1] - a[1]);
this.sortOrder = _sortOrder.map(i => i[0]);
}
sortFromNodeAttrs(nodeAttrs) {
const newAttrs = {};
// Convert node.attrs attrName into lower case.
const loweredNodeAttrs = {};
Object.entries(nodeAttrs).forEach(([attrName, attrValue]) => {
loweredNodeAttrs[attrName.toLowerCase()] = attrValue;
});
if (!this.sortOrder) {
this.createSortOrder();
}
this.sortOrder.forEach(attrNameLower => {
// The attrName inside "sortOrder" has been lowered
if (loweredNodeAttrs[attrNameLower] != null) {
newAttrs[attrNameLower] = loweredNodeAttrs[attrNameLower];
}
});
return newAttrs;
}
}
/** Sort attibutes */
export default function sortAttributes(tree, options, moduleOptions) {
const sortType = processModuleOptions(moduleOptions);
if (sortType === 'alphabetical') {
return sortAttributesInAlphabeticalOrder(tree);
}
if (sortType === 'frequency') {
return sortAttributesByFrequency(tree);
}
// Invalid configuration
return tree;
}
function sortAttributesInAlphabeticalOrder(tree) {
tree.walk(node => {
if (!node.attrs) {
return node;
}
const newAttrs = {};
Object.keys(node.attrs)
.sort((a, b) => typeof a.localeCompare === 'function' ? a.localeCompare(b) : a - b)
.forEach(attr => newAttrs[attr] = node.attrs[attr]);
node.attrs = newAttrs;
return node;
});
return tree;
}
function sortAttributesByFrequency(tree) {
const tokenchain = new AttributeTokenChain();
// Traverse through tree to get frequency
tree.walk(node => {
if (!node.attrs) {
return node;
}
tokenchain.addFromNodeAttrs(node.attrs);
return node;
});
// Traverse through tree again, this time sort the attributes
tree.walk(node => {
if (!node.attrs) {
return node;
}
node.attrs = tokenchain.sortFromNodeAttrs(node.attrs);
return node;
});
return tree;
}

View File

@@ -0,0 +1,116 @@
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.default = collapseAttributeWhitespace;
var _timsort = require("timsort");
var _collapseAttributeWhitespace = require("./collapseAttributeWhitespace.cjs");
// class, rel, ping
const validOptions = new Set(['frequency', 'alphabetical']);
const processModuleOptions = options => {
if (options === true) return 'alphabetical';
return validOptions.has(options) ? options : false;
};
class AttributeTokenChain {
constructor() {
this.freqData = new Map(); // <attrValue, frequency>[]
}
addFromNodeAttrsArray(attrValuesArray) {
attrValuesArray.forEach(attrValue => {
if (this.freqData.has(attrValue)) {
this.freqData.set(attrValue, this.freqData.get(attrValue) + 1);
} else {
this.freqData.set(attrValue, 1);
}
});
}
createSortOrder() {
let _sortOrder = [...this.freqData.entries()];
(0, _timsort.sort)(_sortOrder, (a, b) => b[1] - a[1]);
this.sortOrder = _sortOrder.map(i => i[0]);
}
sortFromNodeAttrsArray(attrValuesArray) {
const resultArray = [];
if (!this.sortOrder) {
this.createSortOrder();
}
this.sortOrder.forEach(k => {
if (attrValuesArray.includes(k)) {
resultArray.push(k);
}
});
return resultArray;
}
}
/** Sort values inside list-like attributes (e.g. class, rel) */
function collapseAttributeWhitespace(tree, options, moduleOptions) {
const sortType = processModuleOptions(moduleOptions);
if (sortType === 'alphabetical') {
return sortAttributesWithListsInAlphabeticalOrder(tree);
}
if (sortType === 'frequency') {
return sortAttributesWithListsByFrequency(tree);
}
// Invalid configuration
return tree;
}
function sortAttributesWithListsInAlphabeticalOrder(tree) {
tree.walk(node => {
if (!node.attrs) {
return node;
}
Object.keys(node.attrs).forEach(attrName => {
const attrNameLower = attrName.toLowerCase();
if (!_collapseAttributeWhitespace.attributesWithLists.has(attrNameLower)) {
return;
}
const attrValues = node.attrs[attrName].split(/\s/);
node.attrs[attrName] = attrValues.sort((a, b) => {
return typeof a.localeCompare === 'function' ? a.localeCompare(b) : a - b;
}).join(' ');
});
return node;
});
return tree;
}
function sortAttributesWithListsByFrequency(tree) {
const tokenChainObj = {}; // <attrNameLower: AttributeTokenChain>[]
// Traverse through tree to get frequency
tree.walk(node => {
if (!node.attrs) {
return node;
}
Object.entries(node.attrs).forEach(([attrName, attrValues]) => {
const attrNameLower = attrName.toLowerCase();
if (!_collapseAttributeWhitespace.attributesWithLists.has(attrNameLower)) {
return;
}
tokenChainObj[attrNameLower] = tokenChainObj[attrNameLower] || new AttributeTokenChain();
tokenChainObj[attrNameLower].addFromNodeAttrsArray(attrValues.split(/\s/));
});
return node;
});
// Traverse through tree again, this time sort the attribute values
tree.walk(node => {
if (!node.attrs) {
return node;
}
Object.entries(node.attrs).forEach(([attrName, attrValues]) => {
const attrNameLower = attrName.toLowerCase();
if (!_collapseAttributeWhitespace.attributesWithLists.has(attrNameLower)) {
return;
}
if (tokenChainObj[attrNameLower]) {
node.attrs[attrName] = tokenChainObj[attrNameLower].sortFromNodeAttrsArray(attrValues.split(/\s/)).join(' ');
}
});
return node;
});
}

View File

@@ -0,0 +1,135 @@
// class, rel, ping
import { sort as timSort } from 'timsort';
import { attributesWithLists } from './collapseAttributeWhitespace.mjs';
const validOptions = new Set(['frequency', 'alphabetical']);
const processModuleOptions = options => {
if (options === true) return 'alphabetical';
return validOptions.has(options) ? options : false;
};
class AttributeTokenChain {
constructor() {
this.freqData = new Map(); // <attrValue, frequency>[]
}
addFromNodeAttrsArray(attrValuesArray) {
attrValuesArray.forEach(attrValue => {
if (this.freqData.has(attrValue)) {
this.freqData.set(attrValue, this.freqData.get(attrValue) + 1);
} else {
this.freqData.set(attrValue, 1);
}
});
}
createSortOrder() {
let _sortOrder = [...this.freqData.entries()];
timSort(_sortOrder, (a, b) => b[1] - a[1]);
this.sortOrder = _sortOrder.map(i => i[0]);
}
sortFromNodeAttrsArray(attrValuesArray) {
const resultArray = [];
if (!this.sortOrder) {
this.createSortOrder();
}
this.sortOrder.forEach(k => {
if (attrValuesArray.includes(k)) {
resultArray.push(k);
}
});
return resultArray;
}
}
/** Sort values inside list-like attributes (e.g. class, rel) */
export default function collapseAttributeWhitespace(tree, options, moduleOptions) {
const sortType = processModuleOptions(moduleOptions);
if (sortType === 'alphabetical') {
return sortAttributesWithListsInAlphabeticalOrder(tree);
}
if (sortType === 'frequency') {
return sortAttributesWithListsByFrequency(tree);
}
// Invalid configuration
return tree;
}
function sortAttributesWithListsInAlphabeticalOrder(tree) {
tree.walk(node => {
if (!node.attrs) {
return node;
}
Object.keys(node.attrs).forEach(attrName => {
const attrNameLower = attrName.toLowerCase();
if (!attributesWithLists.has(attrNameLower)) {
return;
}
const attrValues = node.attrs[attrName].split(/\s/);
node.attrs[attrName] = attrValues.sort((a, b) => {
return typeof a.localeCompare === 'function' ? a.localeCompare(b) : a - b;
}).join(' ');
});
return node;
});
return tree;
}
function sortAttributesWithListsByFrequency(tree) {
const tokenChainObj = {}; // <attrNameLower: AttributeTokenChain>[]
// Traverse through tree to get frequency
tree.walk(node => {
if (!node.attrs) {
return node;
}
Object.entries(node.attrs).forEach(([attrName, attrValues]) => {
const attrNameLower = attrName.toLowerCase();
if (!attributesWithLists.has(attrNameLower)) {
return;
}
tokenChainObj[attrNameLower] = tokenChainObj[attrNameLower] || new AttributeTokenChain();
tokenChainObj[attrNameLower].addFromNodeAttrsArray(attrValues.split(/\s/));
});
return node;
});
// Traverse through tree again, this time sort the attribute values
tree.walk(node => {
if (!node.attrs) {
return node;
}
Object.entries(node.attrs).forEach(([attrName, attrValues]) => {
const attrNameLower = attrName.toLowerCase();
if (!attributesWithLists.has(attrNameLower)) {
return;
}
if (tokenChainObj[attrNameLower]) {
node.attrs[attrName] = tokenChainObj[attrNameLower].sortFromNodeAttrsArray(attrValues.split(/\s/)).join(' ');
}
});
return node;
});
}