From 5f3852bf9cb41e79d50a4fcb800fdd7ba895f023 Mon Sep 17 00:00:00 2001 From: Mikael Sand Date: Sun, 20 Oct 2019 15:35:33 +0300 Subject: [PATCH] feat: implement experiment to inline css from style elements --- package-lock.json | 6 + package.json | 8 +- src/css.js | 900 ++++++++++++++++++++++++++++++++++++++++++++++ src/xml.tsx | 11 +- 4 files changed, 922 insertions(+), 3 deletions(-) create mode 100644 src/css.js diff --git a/package-lock.json b/package-lock.json index 13551436..91809e44 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1816,6 +1816,12 @@ "defer-to-connect": "^1.0.1" } }, + "@types/css-tree": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@types/css-tree/-/css-tree-1.0.1.tgz", + "integrity": "sha512-qkDJqrkglGOrHMCZ7YB/FJ3ce+AbWFU++u5LE2/yw8ZXEXcrTGzbL0bbkT/C74Ls7/wH9R9s5PZAQs9tI+YgVw==", + "dev": true + }, "@types/eslint-visitor-keys": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@types/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz", diff --git a/package.json b/package.json index 92d972b1..86102087 100644 --- a/package.json +++ b/package.json @@ -48,12 +48,18 @@ "react": "*", "react-native": ">=0.50.0" }, - "dependencies": {}, + "dependencies": { + "css-select": "^2.0.2", + "css-select-base-adapter": "^0.1.1", + "css-tree": "^1.0.0-alpha.36", + "stable": "^0.1.8" + }, "devDependencies": { "@react-native-community/bob": "^0.7.0", "@react-native-community/eslint-config": "^0.0.5", "@semantic-release/changelog": "^3.0.4", "@semantic-release/git": "^7.0.16", + "@types/css-tree": "^1.0.1", "@types/react": "^16.9.5", "@types/react-native": "^0.60.19", "babel-eslint": "^10.0.3", diff --git a/src/css.js b/src/css.js new file mode 100644 index 00000000..e4d5a750 --- /dev/null +++ b/src/css.js @@ -0,0 +1,900 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { parse, SvgAst } from './xml'; +import baseCssAdapter from 'css-select-base-adapter'; +import csstree, { List } from 'css-tree'; +import cssSelect from 'css-select'; +import stable from 'stable'; + +/* + * Style element inlining experiment based on SVGO + * */ + +/** + * DOMUtils API for rnsvg AST (used by css-select) + */ +var rnsvgCssSelectAdapterMin = { + // is the node a tag? + // isTag: ( node:Node ) => isTag:Boolean + isTag: function(node) { + return node.tag; + }, + + // get the parent of the node + // getParent: ( node:Node ) => parentNode:Node + // returns null when no parent exists + getParent: function(node) { + return node.parent || null; + }, + + // get the node's children + // getChildren: ( node:Node ) => children:[Node] + getChildren: function(node) { + return node.children || []; + }, + + // get the name of the tag + // getName: ( elem:ElementNode ) => tagName:String + getName: function(elemAst) { + return elemAst.tag; + }, + + // get the text content of the node, and its children if it has any + // getText: ( node:Node ) => text:String + // returns empty string when there is no text + getText: function(node) { + return ''; + }, + + // get the attribute value + // getAttributeValue: ( elem:ElementNode, name:String ) => value:String + // returns null when attribute doesn't exist + getAttributeValue: function(elem, name) { + return elem && elem.props.hasOwnProperty(name) ? elem.props[name] : null; + }, +}; + +// use base adapter for default implementation +var rnsvgCssSelectAdapter = baseCssAdapter(rnsvgCssSelectAdapterMin); + +/** + * Evaluate a string of CSS selectors against the element and returns matched elements. + * + * @param {Object} document to select elements from + * @param {String} selectors CSS selector(s) string + * @return {Array} null if no elements matched + */ +function querySelectorAll(document, selectors) { + var matchedEls = cssSelect(selectors, document, cssSelectOpts); + + return matchedEls.length > 0 ? matchedEls : null; +} +const cssSelectOpts = { + xmlMode: true, + adapter: rnsvgCssSelectAdapter, +}; + +function specificity(simpleSelector) { + var A = 0; + var B = 0; + var C = 0; + + simpleSelector.children.each(function walk(node) { + switch (node.type) { + case 'SelectorList': + case 'Selector': + node.children.each(walk); + break; + + case 'IdSelector': + A++; + break; + + case 'ClassSelector': + case 'AttributeSelector': + B++; + break; + + case 'PseudoClassSelector': + switch (node.name.toLowerCase()) { + case 'not': + node.children.each(walk); + break; + + case 'before': + case 'after': + case 'first-line': + case 'first-letter': + C++; + break; + + // TODO: support for :nth-*(.. of ), :matches(), :has() + + default: + B++; + } + break; + + case 'PseudoElementSelector': + C++; + break; + + case 'TypeSelector': + // ignore universal selector + if (node.name.charAt(node.name.length - 1) !== '*') { + C++; + } + break; + } + }); + + return [A, B, C]; +} + +/** + * Flatten a CSS AST to a selectors list. + * + * @param {Object} cssAst css-tree AST to flatten + * @return {Array} selectors + */ +function flattenToSelectors(cssAst) { + var selectors = []; + + csstree.walk(cssAst, { + visit: 'Rule', + enter: function(node) { + if (node.type !== 'Rule') { + return; + } + + var atrule = this.atrule; + var rule = node; + + node.prelude.children.each(function(selectorNode, selectorItem) { + var selector = { + item: selectorItem, + atrule: atrule, + rule: rule, + pseudos: [], + }; + + selectorNode.children.each(function( + selectorChildNode, + selectorChildItem, + selectorChildList, + ) { + if ( + selectorChildNode.type === 'PseudoClassSelector' || + selectorChildNode.type === 'PseudoElementSelector' + ) { + selector.pseudos.push({ + item: selectorChildItem, + list: selectorChildList, + }); + } + }); + + selectors.push(selector); + }); + }, + }); + + return selectors; +} + +/** + * Filter selectors by Media Query. + * + * @param {Array} selectors to filter + * @param {Array} useMqs Array with strings of media queries that should pass ( ) + * @return {Array} Filtered selectors that match the passed media queries + */ +function filterByMqs(selectors, useMqs) { + return selectors.filter(function(selector) { + if (selector.atrule === null) { + return ~useMqs.indexOf(''); + } + + var mqName = selector.atrule.name; + var mqStr = mqName; + if ( + selector.atrule.expression && + selector.atrule.expression.children.first().type === 'MediaQueryList' + ) { + var mqExpr = csstree.generate(selector.atrule.expression); + mqStr = [mqName, mqExpr].join(' '); + } + + return ~useMqs.indexOf(mqStr); + }); +} + +/** + * Filter selectors by the pseudo-elements and/or -classes they contain. + * + * @param {Array} selectors to filter + * @param {Array} usePseudos Array with strings of single or sequence of pseudo-elements and/or -classes that should pass + * @return {Array} Filtered selectors that match the passed pseudo-elements and/or -classes + */ +function filterByPseudos(selectors, usePseudos) { + return selectors.filter(function(selector) { + var pseudoSelectorsStr = csstree.generate({ + type: 'Selector', + children: new List().fromArray( + selector.pseudos.map(function(pseudo) { + return pseudo.item.data; + }), + ), + }); + return ~usePseudos.indexOf(pseudoSelectorsStr); + }); +} + +/** + * Remove pseudo-elements and/or -classes from the selectors for proper matching. + * + * @param {Array} selectors to clean + * @return {Array} Selectors without pseudo-elements and/or -classes + */ +function cleanPseudos(selectors) { + selectors.forEach(function(selector) { + selector.pseudos.forEach(function(pseudo) { + pseudo.list.remove(pseudo.item); + }); + }); +} + +/** + * Compares two selector specificities. + * extracted from https://github.com/keeganstreet/specificity/blob/master/specificity.js#L211 + * + * @param {Array} aSpecificity Specificity of selector A + * @param {Array} bSpecificity Specificity of selector B + * @return {Number} Score of selector specificity A compared to selector specificity B + */ +function compareSpecificity(aSpecificity, bSpecificity) { + for (var i = 0; i < 4; i += 1) { + if (aSpecificity[i] < bSpecificity[i]) { + return -1; + } else if (aSpecificity[i] > bSpecificity[i]) { + return 1; + } + } + + return 0; +} + +/** + * Compare two simple selectors. + * + * @param {Object} aSimpleSelectorNode Simple selector A + * @param {Object} bSimpleSelectorNode Simple selector B + * @return {Number} Score of selector A compared to selector B + */ +function compareSimpleSelectorNode(aSimpleSelectorNode, bSimpleSelectorNode) { + var aSpecificity = specificity(aSimpleSelectorNode), + bSpecificity = specificity(bSimpleSelectorNode); + return compareSpecificity(aSpecificity, bSpecificity); +} + +function _bySelectorSpecificity(selectorA, selectorB) { + return compareSimpleSelectorNode(selectorA.item.data, selectorB.item.data); +} + +/** + * Sort selectors stably by their specificity. + * + * @param {Array} selectors to be sorted + * @return {Array} Stable sorted selectors + */ +function sortSelectors(selectors) { + return stable(selectors, _bySelectorSpecificity); +} + +/** + * Convert a css-tree AST style declaration to CSSStyleDeclaration property. + * + * @param {Object} declaration css-tree style declaration + * @return {Object} CSSStyleDeclaration property + */ +function csstreeToStyleDeclaration(declaration) { + var propertyName = declaration.property, + propertyValue = csstree.generate(declaration.value), + propertyPriority = declaration.important ? 'important' : ''; + return { + name: propertyName, + value: propertyValue, + priority: propertyPriority, + }; +} + +/** + * Gets the CSS string of a style element + * + * @param {Object} element style element + * @return {String|Array} CSS string or empty array if no styles are set + */ +function getCssStr(element) { + return element.children || []; +} + +var CSSStyleDeclaration = function(node) { + this.parentNode = node; + + this.properties = new Map(); + this.hasSynced = false; + + this.styleAttr = null; + this.styleValue = null; + + this.parseError = false; +}; + +/** + * Performs a deep clone of this object. + * + * @param parentNode the parentNode to assign to the cloned result + */ +CSSStyleDeclaration.prototype.clone = function(parentNode) { + var node = this; + var nodeData = {}; + + Object.keys(node).forEach(function(key) { + if (key !== 'parentNode') { + nodeData[key] = node[key]; + } + }); + + // Deep-clone node data. + nodeData = JSON.parse(JSON.stringify(nodeData)); + + var clone = new CSSStyleDeclaration(parentNode); + Object.assign(clone, nodeData); + return clone; +}; + +CSSStyleDeclaration.prototype.hasStyle = function() { + this.addStyleHandler(); +}; + +// attr.style + +CSSStyleDeclaration.prototype.addStyleHandler = function() { + this.styleAttr = { + // empty style attr + name: 'style', + value: null, + }; + + Object.defineProperty(this.parentNode, 'styles', { + get: this.getStyleAttr.bind(this), + set: this.setStyleAttr.bind(this), + enumerable: true, + configurable: true, + }); + + this.addStyleValueHandler(); +}; + +// attr.style.value + +CSSStyleDeclaration.prototype.addStyleValueHandler = function() { + Object.defineProperty(this.styleAttr, 'value', { + get: this.getStyleValue.bind(this), + set: this.setStyleValue.bind(this), + enumerable: true, + configurable: true, + }); +}; + +CSSStyleDeclaration.prototype.getStyleAttr = function() { + return this.styleAttr; +}; + +CSSStyleDeclaration.prototype.setStyleAttr = function(newStyleAttr) { + this.setStyleValue(newStyleAttr.value); // must before applying value handler! + + this.styleAttr = newStyleAttr; + this.addStyleValueHandler(); + this.hasSynced = false; // raw css changed +}; + +CSSStyleDeclaration.prototype.getStyleValue = function() { + return this.getCssText(); +}; + +CSSStyleDeclaration.prototype.setStyleValue = function(newValue) { + this.properties.clear(); // reset all existing properties + this.styleValue = newValue; + this.hasSynced = false; // raw css changed +}; + +CSSStyleDeclaration.prototype._loadCssText = function() { + if (this.hasSynced) { + return; + } + this.hasSynced = true; // must be set here to prevent loop in setProperty(...) + + if (!this.styleValue || this.styleValue.length === 0) { + return; + } + var inlineCssStr = this.styleValue; + + var declarations = {}; + try { + declarations = csstree.parse(inlineCssStr, { + context: 'declarationList', + parseValue: false, + }); + } catch (parseError) { + this.parseError = parseError; + return; + } + this.parseError = false; + + var self = this; + declarations.children.each(function(declaration) { + try { + var styleDeclaration = csstreeToStyleDeclaration(declaration); + self.setProperty( + styleDeclaration.name, + styleDeclaration.value, + styleDeclaration.priority, + ); + } catch (styleError) { + if (styleError.message !== 'Unknown node type: undefined') { + self.parseError = styleError; + } + } + }); +}; + +// only reads from properties + +/** + * Get the textual representation of the declaration block (equivalent to .cssText attribute). + * + * @return {String} Textual representation of the declaration block (empty string for no properties) + */ +CSSStyleDeclaration.prototype.getCssText = function() { + var properties = this.getProperties(); + + if (this.parseError) { + // in case of a parse error, pass through original styles + return this.styleValue; + } + + var cssText = []; + properties.forEach(function(property, propertyName) { + var strImportant = property.priority === 'important' ? '!important' : ''; + cssText.push( + propertyName.trim() + ':' + property.value.trim() + strImportant, + ); + }); + return cssText.join(';'); +}; + +CSSStyleDeclaration.prototype._handleParseError = function() { + if (this.parseError) { + console.warn( + "Warning: Parse error when parsing inline styles, style properties of this element cannot be used. The raw styles can still be get/set using .attr('style').value. Error details: " + + this.parseError, + ); + } +}; + +CSSStyleDeclaration.prototype._getProperty = function(propertyName) { + if (typeof propertyName === 'undefined') { + throw Error('1 argument required, but only 0 present.'); + } + + var properties = this.getProperties(); + this._handleParseError(); + + var property = properties.get(propertyName.trim()); + return property; +}; + +/** + * Return the optional priority, "important". + * + * @param {String} propertyName representing the property name to be checked. + * @return {String} priority that represents the priority (e.g. "important") if one exists. If none exists, returns the empty string. + */ +CSSStyleDeclaration.prototype.getPropertyPriority = function(propertyName) { + var property = this._getProperty(propertyName); + return property ? property.priority : ''; +}; + +/** + * Return the property value given a property name. + * + * @param {String} propertyName representing the property name to be checked. + * @return {String} value containing the value of the property. If not set, returns the empty string. + */ +CSSStyleDeclaration.prototype.getPropertyValue = function(propertyName) { + var property = this._getProperty(propertyName); + return property ? property.value : null; +}; + +/** + * Return a property name. + * + * @param {Number} index of the node to be fetched. The index is zero-based. + * @return {String} propertyName that is the name of the CSS property at the specified index. + */ +CSSStyleDeclaration.prototype.item = function(index) { + if (typeof index === 'undefined') { + throw Error('1 argument required, but only 0 present.'); + } + + var properties = this.getProperties(); + this._handleParseError(); + + return Array.from(properties.keys())[index]; +}; + +/** + * Return all properties of the node. + * + * @return {Map} properties that is a Map with propertyName as key and property (propertyValue + propertyPriority) as value. + */ +CSSStyleDeclaration.prototype.getProperties = function() { + this._loadCssText(); + return this.properties; +}; + +// writes to properties + +/** + * Remove a property from the CSS declaration block. + * + * @param {String} propertyName representing the property name to be removed. + * @return {String} oldValue equal to the value of the CSS property before it was removed. + */ +CSSStyleDeclaration.prototype.removeProperty = function(propertyName) { + if (typeof propertyName === 'undefined') { + throw Error('1 argument required, but only 0 present.'); + } + + this.hasStyle(); + + var properties = this.getProperties(); + this._handleParseError(); + + var oldValue = this.getPropertyValue(propertyName); + properties.delete(propertyName.trim()); + return oldValue; +}; + +/** + * Modify an existing CSS property or creates a new CSS property in the declaration block. + * + * @param {String} propertyName representing the CSS property name to be modified. + * @param {String} [value] containing the new property value. If not specified, treated as the empty string. value must not contain "!important" -- that should be set using the priority parameter. + * @param {String} [priority] allowing the "important" CSS priority to be set. If not specified, treated as the empty string. + * @return {undefined} + */ +CSSStyleDeclaration.prototype.setProperty = function( + propertyName, + value, + priority, +) { + if (typeof propertyName === 'undefined') { + throw Error('propertyName argument required, but only not present.'); + } + + this.hasStyle(); + + var properties = this.getProperties(); + this._handleParseError(); + + let trimmedValue = value.trim(); + var property = { + value: trimmedValue, + priority: priority.trim(), + }; + let key = propertyName.trim(); + properties.set(key, property); + this.parentNode.props.style[key] = trimmedValue; + + return property; +}; + +/** + * Moves + merges styles from style elements to element styles + * + * Options + * onlyMatchedOnce (default: true) + * inline only selectors that match once + * + * removeMatchedSelectors (default: true) + * clean up matched selectors, + * leave selectors that hadn't matched + * + * useMqs (default: ['', 'screen']) + * what media queries to be used + * empty string element for styles outside media queries + * + * usePseudos (default: ['']) + * what pseudo-classes/-elements to be used + * empty string element for all non-pseudo-classes and/or -elements + * + * @param {Object} document document element + * @param {Object} opts plugin params + * + * @author strarsis + */ +const opts = { + onlyMatchedOnce: true, + removeMatchedSelectors: true, + useMqs: ['', 'screen'], + usePseudos: [''], +}; + +function initStyle(selectedEl) { + if (!selectedEl.style) { + let value = selectedEl.styles || ''; + selectedEl.props.style = {}; + selectedEl.style = new CSSStyleDeclaration(selectedEl); + selectedEl.style.addStyleHandler(); + selectedEl.styles = { + name: 'style', + value: value, + prefix: '', + local: '', + }; + } +} + +export function inlineStyles(document) { + // collect