fix: improve style element inlining, support more selectors and optimize

This commit is contained in:
Mikael Sand
2019-10-20 20:25:40 +03:00
parent 0d4f91d3f1
commit 8c9de72bda
+138 -189
View File
@@ -61,24 +61,22 @@ const rnsvgCssSelectAdapter = baseCssAdapter(rnsvgCssSelectAdapterMin);
* *
* @param {Object} document to select elements from * @param {Object} document to select elements from
* @param {String} selectors CSS selector(s) string * @param {String} selectors CSS selector(s) string
* @return {Array} null if no elements matched * @return {Array}
*/ */
function querySelectorAll(document, selectors) { function querySelectorAll(document, selectors) {
const matchedEls = cssSelect(selectors, document, cssSelectOpts); return cssSelect(selectors, document, cssSelectOpts);
return matchedEls.length > 0 ? matchedEls : null;
} }
const cssSelectOpts = { const cssSelectOpts = {
xmlMode: true, xmlMode: true,
adapter: rnsvgCssSelectAdapter, adapter: rnsvgCssSelectAdapter,
}; };
function specificity(simpleSelector) { function specificity(selector) {
let A = 0; let A = 0;
let B = 0; let B = 0;
let C = 0; let C = 0;
simpleSelector.children.each(function walk(node) { selector.children.each(function walk(node) {
switch (node.type) { switch (node.type) {
case 'SelectorList': case 'SelectorList':
case 'Selector': case 'Selector':
@@ -134,49 +132,39 @@ function specificity(simpleSelector) {
* Flatten a CSS AST to a selectors list. * Flatten a CSS AST to a selectors list.
* *
* @param {Object} cssAst css-tree AST to flatten * @param {Object} cssAst css-tree AST to flatten
* @return {Array} selectors * @param {Array} selectors
*/ */
function flattenToSelectors(cssAst) { function flattenToSelectors(cssAst, selectors) {
const selectors = [];
csstree.walk(cssAst, { csstree.walk(cssAst, {
visit: 'Rule', visit: 'Rule',
enter(node) { enter(rule) {
if (node.type !== 'Rule') { const { type, prelude } = rule;
if (type !== 'Rule') {
return; return;
} }
const atrule = this.atrule; const atrule = this.atrule;
const rule = node; prelude.children.each(({ children }, item) => {
const pseudos = [];
node.prelude.children.each((selectorNode, selectorItem) => { selectors.push({
const selector = { item,
item: selectorItem, atrule,
atrule: atrule, rule,
rule: rule, pseudos,
pseudos: [], });
}; children.each(({ type: childType }, pseudoItem, list) => {
if (
selectorNode.children.each( childType === 'PseudoClassSelector' ||
(selectorChildNode, selectorChildItem, selectorChildList) => { childType === 'PseudoElementSelector'
if ( ) {
selectorChildNode.type === 'PseudoClassSelector' || pseudos.push({
selectorChildNode.type === 'PseudoElementSelector' item: pseudoItem,
) { list,
selector.pseudos.push({ });
item: selectorChildItem, }
list: selectorChildList, });
});
}
},
);
selectors.push(selector);
}); });
}, },
}); });
return selectors;
} }
/** /**
@@ -264,20 +252,16 @@ function compareSpecificity(aSpecificity, bSpecificity) {
/** /**
* Compare two simple selectors. * Compare two simple selectors.
* *
* @param {Object} aSimpleSelectorNode Simple selector A * @param {Object} selectorA Simple selector A
* @param {Object} bSimpleSelectorNode Simple selector B * @param {Object} selectorB Simple selector B
* @return {Number} Score of selector A compared to selector B * @return {Number} Score of selector A compared to selector B
*/ */
function compareSimpleSelectorNode(aSimpleSelectorNode, bSimpleSelectorNode) { function bySelectorSpecificity(selectorA, selectorB) {
const aSpecificity = specificity(aSimpleSelectorNode), const aSpecificity = specificity(selectorA.item.data),
bSpecificity = specificity(bSimpleSelectorNode); bSpecificity = specificity(selectorB.item.data);
return compareSpecificity(aSpecificity, bSpecificity); return compareSpecificity(aSpecificity, bSpecificity);
} }
function _bySelectorSpecificity(selectorA, selectorB) {
return compareSimpleSelectorNode(selectorA.item.data, selectorB.item.data);
}
/** /**
* Sort selectors stably by their specificity. * Sort selectors stably by their specificity.
* *
@@ -285,98 +269,97 @@ function _bySelectorSpecificity(selectorA, selectorB) {
* @return {Array} Stable sorted selectors * @return {Array} Stable sorted selectors
*/ */
function sortSelectors(selectors) { function sortSelectors(selectors) {
return stable(selectors, _bySelectorSpecificity); return stable(selectors, bySelectorSpecificity);
}
/**
* 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 || [];
} }
function CSSStyleDeclaration(node) { function CSSStyleDeclaration(node) {
this.style = node.props.style; const style = {
this.properties = new Map(); style: node.props.style,
properties: new Map(),
};
const { styles } = node; const { styles } = node;
if (!styles || styles.length === 0) { if (!styles || styles.length === 0) {
return; return style;
} }
let declarations = {};
try { try {
declarations = csstree.parse(styles, { csstree
context: 'declarationList', .parse(styles, {
parseValue: false, context: 'declarationList',
}); parseValue: false,
})
.children.each(({ property, value, important }) => {
try {
setProperty(style, property, csstree.generate(value), important);
} catch (styleError) {
if (styleError.message !== 'Unknown node type: undefined') {
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: " +
styleError,
);
}
}
});
} catch (parseError) { } catch (parseError) {
console.warn( 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: " + "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: " +
parseError, parseError,
); );
return;
} }
declarations.children.each(declaration => { return style;
try {
const { property, value, important } = declaration;
this.setProperty(property, csstree.generate(value), important);
} catch (styleError) {
if (styleError.message !== 'Unknown node type: undefined') {
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: " +
styleError,
);
}
}
});
} }
CSSStyleDeclaration.prototype.getProperty = function(propertyName) {
if (typeof propertyName === 'undefined') {
throw Error('1 argument required, but only 0 present.');
}
return this.properties.get(propertyName.trim());
};
// writes to properties
/** /**
* Modify an existing CSS property or creates a new CSS property in the declaration block. * Modify an existing CSS property or creates a new CSS property in the declaration block.
* *
* @param {{properties: *, style: *}}
* @param {String} name representing the CSS property name to be modified. * @param {String} name 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} [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} [important] allowing the "important" CSS priority to be set. If not specified, treated as the empty string. * @param {String} [important] allowing the "important" CSS priority to be set. If not specified, treated as the empty string.
* @return {undefined} * @return {undefined}
*/ */
CSSStyleDeclaration.prototype.setProperty = function(name, value, important) { function setProperty({ properties, style }, name, value, important) {
if (typeof name === 'undefined') { if (typeof name === 'undefined') {
throw Error('propertyName argument required, but only not present.'); throw Error('propertyName argument required, but only not present.');
} }
const v = value.trim();
const trimmedValue = value.trim();
const property = {
value: trimmedValue,
important,
};
const key = name.trim(); const key = name.trim();
this.properties.set(key, property); properties.set(key, {
this.style[camelCase(key)] = trimmedValue; value: v,
important,
});
style[camelCase(key)] = v;
}
return property; /**
}; * Find the closest ancestor of the current element.
* @param node
* @param elemName
*
* @return {?Object}
*/
function closestElem(node, elemName) {
let elem = node;
while ((elem = elem.parent) && elem.tag !== elemName) {}
return elem;
}
function initStyle(selectedEl) {
if (!selectedEl.style) {
if (!selectedEl.props.style) {
selectedEl.props.style = {};
}
selectedEl.style = CSSStyleDeclaration(selectedEl);
}
}
/** /**
* Moves + merges styles from style elements to element styles * Moves + merges styles from style elements to element styles
* *
* Options * Options
* onlyMatchedOnce (default: true)
* inline only selectors that match once
*
* useMqs (default: ['', 'screen']) * useMqs (default: ['', 'screen'])
* what media queries to be used * what media queries to be used
* empty string element for styles outside media queries * empty string element for styles outside media queries
@@ -391,55 +374,42 @@ CSSStyleDeclaration.prototype.setProperty = function(name, value, important) {
* @author strarsis <strarsis@gmail.com> * @author strarsis <strarsis@gmail.com>
*/ */
const opts = { const opts = {
onlyMatchedOnce: true,
useMqs: ['', 'screen'], useMqs: ['', 'screen'],
usePseudos: [''], usePseudos: [''],
}; };
function initStyle(selectedEl) {
if (!selectedEl.style) {
if (!selectedEl.props.style) {
selectedEl.props.style = {};
}
selectedEl.style = new CSSStyleDeclaration(selectedEl);
}
}
export function inlineStyles(document) { export function inlineStyles(document) {
// collect <style/>s // collect <style/>s
const styleEls = querySelectorAll(document, 'style'); const styleElements = querySelectorAll(document, 'style');
//no <styles/>s, nothing to do //no <styles/>s, nothing to do
if (styleEls === null) { if (styleElements.length === 0) {
return document; return document;
} }
let selectors = []; const selectors = [];
for (let styleEl of styleEls) { for (let element of styleElements) {
if (!styleEl.children.length /* || styleEl.closestElem('foreignObject')*/) { const { children } = element;
if (!children.length || closestElem(element, 'foreignObject')) {
// skip empty <style/>s or <foreignObject> content. // skip empty <style/>s or <foreignObject> content.
continue; continue;
} }
const cssStr = getCssStr(styleEl);
// collect <style/>s and their css ast // collect <style/>s and their css ast
let cssAst = {};
try { try {
cssAst = csstree.parse(cssStr, { flattenToSelectors(
parseValue: false, csstree.parse(children, {
parseCustomProperty: false, parseValue: false,
}); parseCustomProperty: false,
}),
selectors,
);
} catch (parseError) { } catch (parseError) {
console.warn( console.warn(
'Warning: Parse error of styles of <style/> element, skipped. Error details: ' + 'Warning: Parse error of styles of <style/> element, skipped. Error details: ' +
parseError, parseError,
); );
continue;
} }
selectors = selectors.concat(flattenToSelectors(cssAst));
} }
// filter for mediaqueries to be used or without any mediaquery // filter for mediaqueries to be used or without any mediaquery
@@ -454,72 +424,51 @@ export function inlineStyles(document) {
// stable sort selectors // stable sort selectors
const sortedSelectors = sortSelectors(selectorsPseudo).reverse(); const sortedSelectors = sortSelectors(selectorsPseudo).reverse();
let selector, selectedEl;
// match selectors // match selectors
for (selector of sortedSelectors) { for (let selector of sortedSelectors) {
const selectorStr = csstree.generate(selector.item.data); const selectorStr = csstree.generate(selector.item.data);
let selectedEls = null;
try { try {
selectedEls = querySelectorAll(document, selectorStr); const selected = querySelectorAll(document, selectorStr);
if (selected.length) {
// apply <style/> to matched elements
for (let element of selected) {
if (selector.rule === null) {
continue;
}
initStyle(element);
const { style } = element;
const { properties } = style;
// merge declarations
csstree.walk(selector.rule, {
visit: 'Declaration',
enter({ property, value, important }) {
// existing inline styles have higher priority
// no inline styles, external styles, external styles used
// inline styles, external styles same priority as inline styles, inline styles used
// inline styles, external styles higher priority than inline styles, external styles used
const prop = properties.get(property.trim());
if (prop && prop.important >= important) {
return;
}
setProperty(style, property, csstree.generate(value), important);
},
});
}
}
} catch (selectError) { } catch (selectError) {
if (selectError.constructor === SyntaxError) { if (selectError.constructor === SyntaxError) {
// console.warn('Warning: Syntax error when trying to select \n\n' + selectorStr + '\n\n, skipped. Error details: ' + selectError); console.warn(
'Warning: Syntax error when trying to select \n\n' +
selectorStr +
'\n\n, skipped. Error details: ' +
selectError,
);
continue; continue;
} }
throw selectError; throw selectError;
} }
if (selectedEls === null) {
// nothing selected
continue;
}
selector.selectedEls = selectedEls;
}
// apply <style/> styles to matched elements
for (selector of sortedSelectors) {
if (!selector.selectedEls) {
continue;
}
if (
opts.onlyMatchedOnce &&
selector.selectedEls !== null &&
selector.selectedEls.length > 1
) {
// skip selectors that match more than once if option onlyMatchedOnce is enabled
continue;
}
// apply <style/> to matched elements
for (selectedEl of selector.selectedEls) {
if (selector.rule === null) {
continue;
}
initStyle(selectedEl);
const { style } = selectedEl;
// merge declarations
csstree.walk(selector.rule, {
visit: 'Declaration',
enter(styleCsstreeDeclaration) {
// existing inline styles have higher priority
// no inline styles, external styles, external styles used
// inline styles, external styles same priority as inline styles, inline styles used
// inline styles, external styles higher priority than inline styles, external styles used
const { property, value, important } = styleCsstreeDeclaration;
const styleProperty = style.getProperty(property);
if (styleProperty && styleProperty.important >= important) {
return;
}
const propertyValue = csstree.generate(value);
style.setProperty(property, propertyValue, important);
},
});
}
} }
return document; return document;