import path from 'path'; import { readFile } from 'fs'; import prettyBytes from 'pretty-bytes'; import { selectOne, selectAll } from 'css-select'; import { parseDocument, DomUtils } from 'htmlparser2'; import { Element, Text } from 'domhandler'; import render from 'dom-serializer'; import { parse as parse$1, stringify } from 'postcss'; import chalk from 'chalk'; var SelectorType; (function (SelectorType) { SelectorType["Attribute"] = "attribute"; SelectorType["Pseudo"] = "pseudo"; SelectorType["PseudoElement"] = "pseudo-element"; SelectorType["Tag"] = "tag"; SelectorType["Universal"] = "universal"; // Traversals SelectorType["Adjacent"] = "adjacent"; SelectorType["Child"] = "child"; SelectorType["Descendant"] = "descendant"; SelectorType["Parent"] = "parent"; SelectorType["Sibling"] = "sibling"; SelectorType["ColumnCombinator"] = "column-combinator"; })(SelectorType || (SelectorType = {})); var AttributeAction; (function (AttributeAction) { AttributeAction["Any"] = "any"; AttributeAction["Element"] = "element"; AttributeAction["End"] = "end"; AttributeAction["Equals"] = "equals"; AttributeAction["Exists"] = "exists"; AttributeAction["Hyphen"] = "hyphen"; AttributeAction["Not"] = "not"; AttributeAction["Start"] = "start"; })(AttributeAction || (AttributeAction = {})); const reName = /^[^\\#]?(?:\\(?:[\da-f]{1,6}\s?|.)|[\w\-\u00b0-\uFFFF])+/; const reEscape = /\\([\da-f]{1,6}\s?|(\s)|.)/gi; const actionTypes = new Map([ [126 /* Tilde */, AttributeAction.Element], [94 /* Circumflex */, AttributeAction.Start], [36 /* Dollar */, AttributeAction.End], [42 /* Asterisk */, AttributeAction.Any], [33 /* ExclamationMark */, AttributeAction.Not], [124 /* Pipe */, AttributeAction.Hyphen], ]); // Pseudos, whose data property is parsed as well. const unpackPseudos = new Set([ "has", "not", "matches", "is", "where", "host", "host-context", ]); /** * Checks whether a specific selector is a traversal. * This is useful eg. in swapping the order of elements that * are not traversals. * * @param selector Selector to check. */ function isTraversal(selector) { switch (selector.type) { case SelectorType.Adjacent: case SelectorType.Child: case SelectorType.Descendant: case SelectorType.Parent: case SelectorType.Sibling: case SelectorType.ColumnCombinator: return true; default: return false; } } const stripQuotesFromPseudos = new Set(["contains", "icontains"]); // Unescape function taken from https://github.com/jquery/sizzle/blob/master/src/sizzle.js#L152 function funescape(_, escaped, escapedWhitespace) { const high = parseInt(escaped, 16) - 0x10000; // NaN means non-codepoint return high !== high || escapedWhitespace ? escaped : high < 0 ? // BMP codepoint String.fromCharCode(high + 0x10000) : // Supplemental Plane codepoint (surrogate pair) String.fromCharCode((high >> 10) | 0xd800, (high & 0x3ff) | 0xdc00); } function unescapeCSS(str) { return str.replace(reEscape, funescape); } function isQuote(c) { return c === 39 /* SingleQuote */ || c === 34 /* DoubleQuote */; } function isWhitespace(c) { return (c === 32 /* Space */ || c === 9 /* Tab */ || c === 10 /* NewLine */ || c === 12 /* FormFeed */ || c === 13 /* CarriageReturn */); } /** * Parses `selector`, optionally with the passed `options`. * * @param selector Selector to parse. * @param options Options for parsing. * @returns Returns a two-dimensional array. * The first dimension represents selectors separated by commas (eg. `sub1, sub2`), * the second contains the relevant tokens for that selector. */ function parse(selector) { const subselects = []; const endIndex = parseSelector(subselects, `${selector}`, 0); if (endIndex < selector.length) { throw new Error(`Unmatched selector: ${selector.slice(endIndex)}`); } return subselects; } function parseSelector(subselects, selector, selectorIndex) { let tokens = []; function getName(offset) { const match = selector.slice(selectorIndex + offset).match(reName); if (!match) { throw new Error(`Expected name, found ${selector.slice(selectorIndex)}`); } const [name] = match; selectorIndex += offset + name.length; return unescapeCSS(name); } function stripWhitespace(offset) { selectorIndex += offset; while (selectorIndex < selector.length && isWhitespace(selector.charCodeAt(selectorIndex))) { selectorIndex++; } } function readValueWithParenthesis() { selectorIndex += 1; const start = selectorIndex; let counter = 1; for (; counter > 0 && selectorIndex < selector.length; selectorIndex++) { if (selector.charCodeAt(selectorIndex) === 40 /* LeftParenthesis */ && !isEscaped(selectorIndex)) { counter++; } else if (selector.charCodeAt(selectorIndex) === 41 /* RightParenthesis */ && !isEscaped(selectorIndex)) { counter--; } } if (counter) { throw new Error("Parenthesis not matched"); } return unescapeCSS(selector.slice(start, selectorIndex - 1)); } function isEscaped(pos) { let slashCount = 0; while (selector.charCodeAt(--pos) === 92 /* BackSlash */) slashCount++; return (slashCount & 1) === 1; } function ensureNotTraversal() { if (tokens.length > 0 && isTraversal(tokens[tokens.length - 1])) { throw new Error("Did not expect successive traversals."); } } function addTraversal(type) { if (tokens.length > 0 && tokens[tokens.length - 1].type === SelectorType.Descendant) { tokens[tokens.length - 1].type = type; return; } ensureNotTraversal(); tokens.push({ type }); } function addSpecialAttribute(name, action) { tokens.push({ type: SelectorType.Attribute, name, action, value: getName(1), namespace: null, ignoreCase: "quirks", }); } /** * We have finished parsing the current part of the selector. * * Remove descendant tokens at the end if they exist, * and return the last index, so that parsing can be * picked up from here. */ function finalizeSubselector() { if (tokens.length && tokens[tokens.length - 1].type === SelectorType.Descendant) { tokens.pop(); } if (tokens.length === 0) { throw new Error("Empty sub-selector"); } subselects.push(tokens); } stripWhitespace(0); if (selector.length === selectorIndex) { return selectorIndex; } loop: while (selectorIndex < selector.length) { const firstChar = selector.charCodeAt(selectorIndex); switch (firstChar) { // Whitespace case 32 /* Space */: case 9 /* Tab */: case 10 /* NewLine */: case 12 /* FormFeed */: case 13 /* CarriageReturn */: { if (tokens.length === 0 || tokens[0].type !== SelectorType.Descendant) { ensureNotTraversal(); tokens.push({ type: SelectorType.Descendant }); } stripWhitespace(1); break; } // Traversals case 62 /* GreaterThan */: { addTraversal(SelectorType.Child); stripWhitespace(1); break; } case 60 /* LessThan */: { addTraversal(SelectorType.Parent); stripWhitespace(1); break; } case 126 /* Tilde */: { addTraversal(SelectorType.Sibling); stripWhitespace(1); break; } case 43 /* Plus */: { addTraversal(SelectorType.Adjacent); stripWhitespace(1); break; } // Special attribute selectors: .class, #id case 46 /* Period */: { addSpecialAttribute("class", AttributeAction.Element); break; } case 35 /* Hash */: { addSpecialAttribute("id", AttributeAction.Equals); break; } case 91 /* LeftSquareBracket */: { stripWhitespace(1); // Determine attribute name and namespace let name; let namespace = null; if (selector.charCodeAt(selectorIndex) === 124 /* Pipe */) { // Equivalent to no namespace name = getName(1); } else if (selector.startsWith("*|", selectorIndex)) { namespace = "*"; name = getName(2); } else { name = getName(0); if (selector.charCodeAt(selectorIndex) === 124 /* Pipe */ && selector.charCodeAt(selectorIndex + 1) !== 61 /* Equal */) { namespace = name; name = getName(1); } } stripWhitespace(0); // Determine comparison operation let action = AttributeAction.Exists; const possibleAction = actionTypes.get(selector.charCodeAt(selectorIndex)); if (possibleAction) { action = possibleAction; if (selector.charCodeAt(selectorIndex + 1) !== 61 /* Equal */) { throw new Error("Expected `=`"); } stripWhitespace(2); } else if (selector.charCodeAt(selectorIndex) === 61 /* Equal */) { action = AttributeAction.Equals; stripWhitespace(1); } // Determine value let value = ""; let ignoreCase = null; if (action !== "exists") { if (isQuote(selector.charCodeAt(selectorIndex))) { const quote = selector.charCodeAt(selectorIndex); let sectionEnd = selectorIndex + 1; while (sectionEnd < selector.length && (selector.charCodeAt(sectionEnd) !== quote || isEscaped(sectionEnd))) { sectionEnd += 1; } if (selector.charCodeAt(sectionEnd) !== quote) { throw new Error("Attribute value didn't end"); } value = unescapeCSS(selector.slice(selectorIndex + 1, sectionEnd)); selectorIndex = sectionEnd + 1; } else { const valueStart = selectorIndex; while (selectorIndex < selector.length && ((!isWhitespace(selector.charCodeAt(selectorIndex)) && selector.charCodeAt(selectorIndex) !== 93 /* RightSquareBracket */) || isEscaped(selectorIndex))) { selectorIndex += 1; } value = unescapeCSS(selector.slice(valueStart, selectorIndex)); } stripWhitespace(0); // See if we have a force ignore flag const forceIgnore = selector.charCodeAt(selectorIndex) | 0x20; // If the forceIgnore flag is set (either `i` or `s`), use that value if (forceIgnore === 115 /* LowerS */) { ignoreCase = false; stripWhitespace(1); } else if (forceIgnore === 105 /* LowerI */) { ignoreCase = true; stripWhitespace(1); } } if (selector.charCodeAt(selectorIndex) !== 93 /* RightSquareBracket */) { throw new Error("Attribute selector didn't terminate"); } selectorIndex += 1; const attributeSelector = { type: SelectorType.Attribute, name, action, value, namespace, ignoreCase, }; tokens.push(attributeSelector); break; } case 58 /* Colon */: { if (selector.charCodeAt(selectorIndex + 1) === 58 /* Colon */) { tokens.push({ type: SelectorType.PseudoElement, name: getName(2).toLowerCase(), data: selector.charCodeAt(selectorIndex) === 40 /* LeftParenthesis */ ? readValueWithParenthesis() : null, }); continue; } const name = getName(1).toLowerCase(); let data = null; if (selector.charCodeAt(selectorIndex) === 40 /* LeftParenthesis */) { if (unpackPseudos.has(name)) { if (isQuote(selector.charCodeAt(selectorIndex + 1))) { throw new Error(`Pseudo-selector ${name} cannot be quoted`); } data = []; selectorIndex = parseSelector(data, selector, selectorIndex + 1); if (selector.charCodeAt(selectorIndex) !== 41 /* RightParenthesis */) { throw new Error(`Missing closing parenthesis in :${name} (${selector})`); } selectorIndex += 1; } else { data = readValueWithParenthesis(); if (stripQuotesFromPseudos.has(name)) { const quot = data.charCodeAt(0); if (quot === data.charCodeAt(data.length - 1) && isQuote(quot)) { data = data.slice(1, -1); } } data = unescapeCSS(data); } } tokens.push({ type: SelectorType.Pseudo, name, data }); break; } case 44 /* Comma */: { finalizeSubselector(); tokens = []; stripWhitespace(1); break; } default: { if (selector.startsWith("/*", selectorIndex)) { const endIndex = selector.indexOf("*/", selectorIndex + 2); if (endIndex < 0) { throw new Error("Comment was not terminated"); } selectorIndex = endIndex + 2; // Remove leading whitespace if (tokens.length === 0) { stripWhitespace(0); } break; } let namespace = null; let name; if (firstChar === 42 /* Asterisk */) { selectorIndex += 1; name = "*"; } else if (firstChar === 124 /* Pipe */) { name = ""; if (selector.charCodeAt(selectorIndex + 1) === 124 /* Pipe */) { addTraversal(SelectorType.ColumnCombinator); stripWhitespace(2); break; } } else if (reName.test(selector.slice(selectorIndex))) { name = getName(0); } else { break loop; } if (selector.charCodeAt(selectorIndex) === 124 /* Pipe */ && selector.charCodeAt(selectorIndex + 1) !== 124 /* Pipe */) { namespace = name; if (selector.charCodeAt(selectorIndex + 1) === 42 /* Asterisk */) { name = "*"; selectorIndex += 2; } else { name = getName(1); } } tokens.push(name === "*" ? { type: SelectorType.Universal, namespace } : { type: SelectorType.Tag, name, namespace }); } } } finalizeSubselector(); return selectorIndex; } /** * Copyright 2018 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); you may not * use this file except in compliance with the License. You may obtain a copy of * the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the * License for the specific language governing permissions and limitations under * the License. */ let classCache = null; let idCache = null; function buildCache(container) { classCache = new Set(); idCache = new Set(); const queue = [container]; while (queue.length) { const node = queue.shift(); if (node.hasAttribute('class')) { const classList = node.getAttribute('class').trim().split(' '); classList.forEach(cls => { classCache.add(cls); }); } if (node.hasAttribute('id')) { const id = node.getAttribute('id').trim(); idCache.add(id); } queue.push(...node.children.filter(child => child.type === 'tag')); } } /** * Parse HTML into a mutable, serializable DOM Document. * The DOM implementation is an htmlparser2 DOM enhanced with basic DOM mutation methods. * @param {String} html HTML to parse into a Document instance */ function createDocument(html) { const document = /** @type {HTMLDocument} */ parseDocument(html, { decodeEntities: false }); defineProperties(document, DocumentExtensions); // Extend Element.prototype with DOM manipulation methods. defineProperties(Element.prototype, ElementExtensions); // Critters container is the viewport to evaluate critical CSS let crittersContainer = document.querySelector('[data-critters-container]'); if (!crittersContainer) { document.documentElement.setAttribute('data-critters-container', ''); crittersContainer = document.documentElement; } document.crittersContainer = crittersContainer; buildCache(crittersContainer); return document; } /** * Serialize a Document to an HTML String * @param {HTMLDocument} document A Document, such as one created via `createDocument()` */ function serializeDocument(document) { return render(document, { decodeEntities: false }); } /** @typedef {treeAdapter.Document & typeof ElementExtensions} HTMLDocument */ /** * Methods and descriptors to mix into Element.prototype * @private */ const ElementExtensions = { /** @extends treeAdapter.Element.prototype */ nodeName: { get() { return this.tagName.toUpperCase(); } }, id: reflectedProperty('id'), className: reflectedProperty('class'), insertBefore(child, referenceNode) { if (!referenceNode) return this.appendChild(child); DomUtils.prepend(referenceNode, child); return child; }, appendChild(child) { DomUtils.appendChild(this, child); return child; }, removeChild(child) { DomUtils.removeElement(child); }, remove() { DomUtils.removeElement(this); }, textContent: { get() { return DomUtils.getText(this); }, set(text) { this.children = []; DomUtils.appendChild(this, new Text(text)); } }, setAttribute(name, value) { if (this.attribs == null) this.attribs = {}; if (value == null) value = ''; this.attribs[name] = value; }, removeAttribute(name) { if (this.attribs != null) { delete this.attribs[name]; } }, getAttribute(name) { return this.attribs != null && this.attribs[name]; }, hasAttribute(name) { return this.attribs != null && this.attribs[name] != null; }, getAttributeNode(name) { const value = this.getAttribute(name); if (value != null) return { specified: true, value }; }, exists(sel) { return cachedQuerySelector(sel, this); }, querySelector(sel) { return selectOne(sel, this); }, querySelectorAll(sel) { return selectAll(sel, this); } }; /** * Methods and descriptors to mix into the global document instance * @private */ const DocumentExtensions = { /** @extends treeAdapter.Document.prototype */ // document is just an Element in htmlparser2, giving it a nodeType of ELEMENT_NODE. // TODO: verify if these are needed for css-select nodeType: { get() { return 9; } }, contentType: { get() { return 'text/html'; } }, nodeName: { get() { return '#document'; } }, documentElement: { get() { // Find the first element within the document return this.children.find(child => String(child.tagName).toLowerCase() === 'html'); } }, head: { get() { return this.querySelector('head'); } }, body: { get() { return this.querySelector('body'); } }, createElement(name) { return new Element(name); }, createTextNode(text) { // there is no dedicated createTextNode equivalent exposed in htmlparser2's DOM return new Text(text); }, exists(sel) { return cachedQuerySelector(sel, this); }, querySelector(sel) { return selectOne(sel, this); }, querySelectorAll(sel) { if (sel === ':root') { return this; } return selectAll(sel, this); } }; /** * Essentially `Object.defineProperties()`, except function values are assigned as value descriptors for convenience. * @private */ function defineProperties(obj, properties) { for (const i in properties) { const value = properties[i]; Object.defineProperty(obj, i, typeof value === 'function' ? { value } : value); } } /** * Create a property descriptor defining a getter/setter pair alias for a named attribute. * @private */ function reflectedProperty(attributeName) { return { get() { return this.getAttribute(attributeName); }, set(value) { this.setAttribute(attributeName, value); } }; } function cachedQuerySelector(sel, node) { const selectorTokens = parse(sel); for (const tokens of selectorTokens) { // Check if the selector is a class selector if (tokens.length === 1) { const token = tokens[0]; if (token.type === 'attribute' && token.name === 'class') { return classCache.has(token.value); } if (token.type === 'attribute' && token.name === 'id') { return idCache.has(token.value); } } } return !!selectOne(sel, node); } /** * Copyright 2018 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); you may not * use this file except in compliance with the License. You may obtain a copy of * the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the * License for the specific language governing permissions and limitations under * the License. */ /** * Parse a textual CSS Stylesheet into a Stylesheet instance. * Stylesheet is a mutable postcss AST with format similar to CSSOM. * @see https://github.com/postcss/postcss/ * @private * @param {String} stylesheet * @returns {css.Stylesheet} ast */ function parseStylesheet(stylesheet) { return parse$1(stylesheet); } /** * Serialize a postcss Stylesheet to a String of CSS. * @private * @param {css.Stylesheet} ast A Stylesheet to serialize, such as one returned from `parseStylesheet()` * @param {Object} options Options used by the stringify logic * @param {Boolean} [options.compress] Compress CSS output (removes comments, whitespace, etc) */ function serializeStylesheet(ast, options) { let cssStr = ''; stringify(ast, (result, node, type) => { var _node$raws; if (!options.compress) { cssStr += result; return; } // Simple minification logic if ((node == null ? void 0 : node.type) === 'comment') return; if ((node == null ? void 0 : node.type) === 'decl') { const prefix = node.prop + node.raws.between; cssStr += result.replace(prefix, prefix.trim()); return; } if (type === 'start') { if (node.type === 'rule' && node.selectors) { cssStr += node.selectors.join(',') + '{'; } else { cssStr += result.replace(/\s\{$/, '{'); } return; } if (type === 'end' && result === '}' && node != null && (_node$raws = node.raws) != null && _node$raws.semicolon) { cssStr = cssStr.slice(0, -1); } cssStr += result.trim(); }); return cssStr; } /** * Converts a walkStyleRules() iterator to mark nodes with `.$$remove=true` instead of actually removing them. * This means they can be removed in a second pass, allowing the first pass to be nondestructive (eg: to preserve mirrored sheets). * @private * @param {Function} iterator Invoked on each node in the tree. Return `false` to remove that node. * @returns {(rule) => void} nonDestructiveIterator */ function markOnly(predicate) { return rule => { const sel = rule.selectors; if (predicate(rule) === false) { rule.$$remove = true; } rule.$$markedSelectors = rule.selectors; if (rule._other) { rule._other.$$markedSelectors = rule._other.selectors; } rule.selectors = sel; }; } /** * Apply filtered selectors to a rule from a previous markOnly run. * @private * @param {css.Rule} rule The Rule to apply marked selectors to (if they exist). */ function applyMarkedSelectors(rule) { if (rule.$$markedSelectors) { rule.selectors = rule.$$markedSelectors; } if (rule._other) { applyMarkedSelectors(rule._other); } } /** * Recursively walk all rules in a stylesheet. * @private * @param {css.Rule} node A Stylesheet or Rule to descend into. * @param {Function} iterator Invoked on each node in the tree. Return `false` to remove that node. */ function walkStyleRules(node, iterator) { node.nodes = node.nodes.filter(rule => { if (hasNestedRules(rule)) { walkStyleRules(rule, iterator); } rule._other = undefined; rule.filterSelectors = filterSelectors; return iterator(rule) !== false; }); } /** * Recursively walk all rules in two identical stylesheets, filtering nodes into one or the other based on a predicate. * @private * @param {css.Rule} node A Stylesheet or Rule to descend into. * @param {css.Rule} node2 A second tree identical to `node` * @param {Function} iterator Invoked on each node in the tree. Return `false` to remove that node from the first tree, true to remove it from the second. */ function walkStyleRulesWithReverseMirror(node, node2, iterator) { if (node2 === null) return walkStyleRules(node, iterator); [node.nodes, node2.nodes] = splitFilter(node.nodes, node2.nodes, (rule, index, rules, rules2) => { const rule2 = rules2[index]; if (hasNestedRules(rule)) { walkStyleRulesWithReverseMirror(rule, rule2, iterator); } rule._other = rule2; rule.filterSelectors = filterSelectors; return iterator(rule) !== false; }); } // Checks if a node has nested rules, like @media // @keyframes are an exception since they are evaluated as a whole function hasNestedRules(rule) { return rule.nodes && rule.nodes.length && rule.nodes.some(n => n.type === 'rule' || n.type === 'atrule') && rule.name !== 'keyframes' && rule.name !== '-webkit-keyframes'; } // Like [].filter(), but applies the opposite filtering result to a second copy of the Array without a second pass. // This is just a quicker version of generating the compliment of the set returned from a filter operation. function splitFilter(a, b, predicate) { const aOut = []; const bOut = []; for (let index = 0; index < a.length; index++) { if (predicate(a[index], index, a, b)) { aOut.push(a[index]); } else { bOut.push(a[index]); } } return [aOut, bOut]; } // can be invoked on a style rule to subset its selectors (with reverse mirroring) function filterSelectors(predicate) { if (this._other) { const [a, b] = splitFilter(this.selectors, this._other.selectors, predicate); this.selectors = a; this._other.selectors = b; } else { this.selectors = this.selectors.filter(predicate); } } const LOG_LEVELS = ['trace', 'debug', 'info', 'warn', 'error', 'silent']; const defaultLogger = { trace(msg) { console.trace(msg); }, debug(msg) { console.debug(msg); }, warn(msg) { console.warn(chalk.yellow(msg)); }, error(msg) { console.error(chalk.bold.red(msg)); }, info(msg) { console.info(chalk.bold.blue(msg)); }, silent() {} }; function createLogger(logLevel) { const logLevelIdx = LOG_LEVELS.indexOf(logLevel); return LOG_LEVELS.reduce((logger, type, index) => { if (index >= logLevelIdx) { logger[type] = defaultLogger[type]; } else { logger[type] = defaultLogger.silent; } return logger; }, {}); } /** * Copyright 2018 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); you may not * use this file except in compliance with the License. You may obtain a copy of * the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the * License for the specific language governing permissions and limitations under * the License. */ /** * The mechanism to use for lazy-loading stylesheets. * * Note: JS indicates a strategy requiring JavaScript (falls back to `