import path from 'path'; import prettyBytes from 'pretty-bytes'; import parse5 from 'parse5'; import { selectOne, selectAll } from 'css-select'; import treeAdapter from 'parse5-htmlparser2-tree-adapter'; import { parse, stringify } from 'postcss'; import chalk from 'chalk'; /** * 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. */ const PARSE5_OPTS = { treeAdapter }; /** * 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} */ parse5.parse(html, PARSE5_OPTS); defineProperties(document, DocumentExtensions); // Extend Element.prototype with DOM manipulation methods. const scratch = document.createElement('div'); // Get a reference to the base Node class - used by createTextNode() document.$$Node = scratch.constructor; const elementProto = Object.getPrototypeOf(scratch); defineProperties(elementProto, ElementExtensions); elementProto.ownerDocument = document; return document; } /** * Serialize a Document to an HTML String * @param {HTMLDocument} document A Document, such as one created via `createDocument()` */ function serializeDocument(document) { return parse5.serialize(document, PARSE5_OPTS); } /** @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); treeAdapter.insertBefore(this, child, referenceNode); return child; }, appendChild(child) { treeAdapter.appendChild(this, child); return child; }, removeChild(child) { treeAdapter.detachNode(child); }, remove() { treeAdapter.detachNode(this); }, textContent: { get() { return getText(this); }, set(text) { this.children = []; treeAdapter.insertText(this, 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 }; } }; /** * 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.childNodes.filter(child => String(child.tagName).toLowerCase() === 'html'); } }, compatMode: { get() { const compatMode = { 'no-quirks': 'CSS1Compat', quirks: 'BackCompat', 'limited-quirks': 'CSS1Compat' }; return compatMode[treeAdapter.getDocumentMode(this)]; } }, body: { get() { return this.querySelector('body'); } }, createElement(name) { return treeAdapter.createElement(name, null, []); }, createTextNode(text) { // there is no dedicated createTextNode equivalent exposed in htmlparser2's DOM const Node = this.$$Node; return new Node({ type: 'text', data: text, parent: null, prev: null, next: null }); }, querySelector(sel) { return selectOne(sel, this.documentElement); }, querySelectorAll(sel) { if (sel === ':root') { return this; } return selectAll(sel, this.documentElement); } }; /** * 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); } }; } /** * Helper to get the text content of a node * https://github.com/fb55/domutils/blob/master/src/stringify.ts#L21 * @private */ function getText(node) { if (Array.isArray(node)) return node.map(getText).join(''); if (treeAdapter.isElementNode(node)) return node.name === 'br' ? '\n' : getText(node.children); if (treeAdapter.isTextNode(node)) return node.data; return ''; } /** * 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(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'; } // 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 `