1
0
mirror of https://github.com/svg/svgo.git synced 2025-07-31 07:44:22 +03:00

Refactor inlineStyles (#1601)

This is a big one

- got rid from another closestByName usage
- delegated removing empty defs elements to removeEmptyContainers plugin
- got rid from all css-tools usages (most inlineStyles code was there
  for some reason)
- combined a few loops
- fixed useMqs option (I would remove it in v3 for simplicity as it
  seems nobody use it)
This commit is contained in:
Bogdan Chadkin
2021-10-30 01:31:52 +03:00
committed by GitHub
parent c7995035ef
commit 72b972261d
4 changed files with 341 additions and 266 deletions

View File

@ -1,24 +1,43 @@
'use strict';
/**
* @typedef {import('../lib/types').Specificity} Specificity
* @typedef {import('../lib/types').XastElement} XastElement
* @typedef {import('../lib/types').XastParent} XastParent
*/
const csstree = require('css-tree');
const { querySelectorAll, closestByName } = require('../lib/xast.js');
const cssTools = require('../lib/css-tools');
// @ts-ignore not defined in @types/csso
const specificity = require('csso/lib/restructure/prepare/specificity');
const stable = require('stable');
const {
visitSkip,
querySelectorAll,
detachNodeFromParent,
} = require('../lib/xast.js');
exports.type = 'visitor';
exports.name = 'inlineStyles';
exports.type = 'full';
exports.active = true;
exports.params = {
onlyMatchedOnce: true,
removeMatchedSelectors: true,
useMqs: ['', 'screen'],
usePseudos: [''],
};
exports.description = 'inline styles (additional options)';
/**
* Compares two selector specificities.
* extracted from https://github.com/keeganstreet/specificity/blob/master/specificity.js#L211
*
* @type {(a: Specificity, b: Specificity) => number}
*/
const compareSpecificity = (a, b) => {
for (var i = 0; i < 4; i += 1) {
if (a[i] < b[i]) {
return -1;
} else if (a[i] > b[i]) {
return 1;
}
}
return 0;
};
/**
* Moves + merges styles from style elements to element styles
*
@ -38,123 +57,196 @@ exports.description = 'inline styles (additional options)';
* what pseudo-classes/-elements to be used
* empty string element for all non-pseudo-classes and/or -elements
*
* @param {Object} root document element
* @param {Object} opts plugin params
*
* @author strarsis <strarsis@gmail.com>
*
* @type {import('../lib/types').Plugin<{
* onlyMatchedOnce?: boolean,
* removeMatchedSelectors?: boolean,
* useMqs?: Array<string>,
* usePseudos?: Array<string>
* }>}
*/
exports.fn = function (root, opts) {
// collect <style/>s
var styleEls = querySelectorAll(root, 'style');
exports.fn = (root, params) => {
const {
onlyMatchedOnce = true,
removeMatchedSelectors = true,
useMqs = ['', 'screen'],
usePseudos = [''],
} = params;
//no <styles/>s, nothing to do
if (styleEls.length === 0) {
return root;
/**
* @type {Array<{ node: XastElement, parentNode: XastParent, cssAst: csstree.StyleSheet }>}
*/
const styles = [];
/**
* @type {Array<{
* node: csstree.Selector,
* item: csstree.ListItem<csstree.CssNode>,
* rule: csstree.Rule,
* matchedElements?: Array<XastElement>
* }>}
*/
let selectors = [];
return {
element: {
enter: (node, parentNode) => {
// skip <foreignObject /> content
if (node.name === 'foreignObject') {
return visitSkip;
}
// collect only non-empty <style /> elements
if (node.name !== 'style' || node.children.length === 0) {
return;
}
var styles = [],
selectors = [];
for (var styleEl of styleEls) {
// values other than the empty string or text/css are not used
if (
styleEl.attributes.type != null &&
styleEl.attributes.type !== '' &&
styleEl.attributes.type !== 'text/css'
node.attributes.type != null &&
node.attributes.type !== '' &&
node.attributes.type !== 'text/css'
) {
continue;
return;
}
// skip empty <style/>s or <foreignObject> content.
if (
styleEl.children.length === 0 ||
closestByName(styleEl, 'foreignObject')
) {
continue;
// parse css in style element
let cssText = '';
for (const child of node.children) {
if (child.type === 'text' || child.type === 'cdata') {
cssText += child.value;
}
var cssStr = cssTools.getCssStr(styleEl);
// collect <style/>s and their css ast
var cssAst = {};
}
/**
* @type {null | csstree.CssNode}
*/
let cssAst = null;
try {
cssAst = csstree.parse(cssStr, {
cssAst = csstree.parse(cssText, {
parseValue: false,
parseCustomProperty: false,
});
} catch (parseError) {
// console.warn('Warning: Parse error of styles of <style/> element, skipped. Error details: ' + parseError);
continue;
} catch {
return;
}
if (cssAst.type === 'StyleSheet') {
styles.push({ node, parentNode, cssAst });
}
styles.push({
styleEl: styleEl,
cssAst: cssAst,
});
selectors = selectors.concat(cssTools.flattenToSelectors(cssAst));
// collect selectors
csstree.walk(cssAst, {
visit: 'Selector',
enter(node, item) {
const atrule = this.atrule;
const rule = this.rule;
if (rule == null) {
return;
}
// filter for mediaqueries to be used or without any mediaquery
var selectorsMq = cssTools.filterByMqs(selectors, opts.useMqs);
// filter for pseudo elements to be used
var selectorsPseudo = cssTools.filterByPseudos(selectorsMq, opts.usePseudos);
// remove PseudoClass from its SimpleSelector for proper matching
cssTools.cleanPseudos(selectorsPseudo);
// stable sort selectors
var sortedSelectors = cssTools.sortSelectors(selectorsPseudo).reverse();
var selector, selectedEl;
// match selectors
for (selector of sortedSelectors) {
var selectorStr = csstree.generate(selector.item.data),
selectedEls = null;
try {
selectedEls = querySelectorAll(root, selectorStr);
} catch (selectError) {
// console.warn('Warning: Syntax error when trying to select \n\n' + selectorStr + '\n\n, skipped. Error details: ' + selectError);
continue;
}
if (selectedEls.length === 0) {
// nothing selected
continue;
}
selector.selectedEls = selectedEls;
}
// apply <style/> styles to matched elements
for (selector of sortedSelectors) {
if (!selector.selectedEls) {
continue;
// skip media queries not included into useMqs param
let mq = '';
if (atrule != null) {
mq = atrule.name;
if (atrule.prelude != null) {
mq += ` ${csstree.generate(atrule.prelude)}`;
}
}
if (useMqs.includes(mq) === false) {
return;
}
/**
* @type {Array<{
* item: csstree.ListItem<csstree.CssNode>,
* list: csstree.List<csstree.CssNode>
* }>}
*/
const pseudos = [];
if (node.type === 'Selector') {
node.children.each((childNode, childItem, childList) => {
if (
opts.onlyMatchedOnce &&
selector.selectedEls !== null &&
selector.selectedEls.length > 1
childNode.type === 'PseudoClassSelector' ||
childNode.type === 'PseudoElementSelector'
) {
pseudos.push({ item: childItem, list: childList });
}
});
}
// skip pseudo classes and pseudo elements not includes into usePseudos param
const pseudoSelectors = csstree.generate({
type: 'Selector',
children: new csstree.List().fromArray(
pseudos.map((pseudo) => pseudo.item.data)
),
});
if (usePseudos.includes(pseudoSelectors) === false) {
return;
}
// remove pseudo classes and elements to allow querySelector match elements
// TODO this is not very accurate since some pseudo classes like first-child
// are used for selection
for (const pseudo of pseudos) {
pseudo.list.remove(pseudo.item);
}
selectors.push({ node, item, rule });
},
});
},
},
root: {
exit: () => {
if (styles.length === 0) {
return;
}
// stable sort selectors
const sortedSelectors = stable(selectors, (a, b) => {
const aSpecificity = specificity(a.item.data);
const bSpecificity = specificity(b.item.data);
return compareSpecificity(aSpecificity, bSpecificity);
}).reverse();
for (const selector of sortedSelectors) {
// match selectors
const selectorText = csstree.generate(selector.item.data);
/**
* @type {Array<XastElement>}
*/
const matchedElements = [];
try {
for (const node of querySelectorAll(root, selectorText)) {
if (node.type === 'element') {
matchedElements.push(node);
}
}
} catch (selectError) {
continue;
}
// nothing selected
if (matchedElements.length === 0) {
continue;
}
// apply styles to matched elements
// skip selectors that match more than once if option onlyMatchedOnce is enabled
if (onlyMatchedOnce && matchedElements.length > 1) {
continue;
}
// apply <style/> to matched elements
for (selectedEl of selector.selectedEls) {
if (selector.rule === null) {
continue;
}
for (const selectedEl of matchedElements) {
const styleDeclarationList = csstree.parse(
selectedEl.attributes.style == null ? '' : selectedEl.attributes.style,
selectedEl.attributes.style == null
? ''
: selectedEl.attributes.style,
{
context: 'declarationList',
parseValue: false,
}
);
if (styleDeclarationList.type !== 'DeclarationList') {
continue;
}
const styleDeclarationItems = new Map();
csstree.walk(styleDeclarationList, {
visit: 'Declaration',
@ -192,47 +284,49 @@ exports.fn = function (root, opts) {
}
},
});
selectedEl.attributes.style = csstree.generate(styleDeclarationList);
selectedEl.attributes.style =
csstree.generate(styleDeclarationList);
}
if (
opts.removeMatchedSelectors &&
selector.selectedEls !== null &&
selector.selectedEls.length > 0
removeMatchedSelectors &&
matchedElements.length !== 0 &&
selector.rule.prelude.type === 'SelectorList'
) {
// clean up matching simple selectors if option removeMatchedSelectors is enabled
selector.rule.prelude.children.remove(selector.item);
}
selector.matchedElements = matchedElements;
}
if (!opts.removeMatchedSelectors) {
return root; // no further processing required
// no further processing required
if (removeMatchedSelectors === false) {
return;
}
// clean up matched class + ID attribute values
for (selector of sortedSelectors) {
if (!selector.selectedEls) {
for (const selector of sortedSelectors) {
if (selector.matchedElements == null) {
continue;
}
if (
opts.onlyMatchedOnce &&
selector.selectedEls !== null &&
selector.selectedEls.length > 1
) {
if (onlyMatchedOnce && selector.matchedElements.length > 1) {
// skip selectors that match more than once if option onlyMatchedOnce is enabled
continue;
}
for (selectedEl of selector.selectedEls) {
for (const selectedEl of selector.matchedElements) {
// class
const classList = new Set(
selectedEl.attributes.class == null
? null
: selectedEl.attributes.class.split(' ')
);
const firstSubSelector = selector.item.data.children.first();
if (firstSubSelector.type === 'ClassSelector') {
const firstSubSelector = selector.node.children.first();
if (
firstSubSelector != null &&
firstSubSelector.type === 'ClassSelector'
) {
classList.delete(firstSubSelector.name);
}
if (classList.size === 0) {
@ -242,7 +336,10 @@ exports.fn = function (root, opts) {
}
// ID
if (firstSubSelector.type === 'IdSelector') {
if (
firstSubSelector != null &&
firstSubSelector.type === 'IdSelector'
) {
if (selectedEl.attributes.id === firstSubSelector.name) {
delete selectedEl.attributes.id;
}
@ -250,55 +347,33 @@ exports.fn = function (root, opts) {
}
}
// clean up now empty elements
for (var style of styles) {
for (const style of styles) {
csstree.walk(style.cssAst, {
visit: 'Rule',
enter: function (node, item, list) {
// clean up <style/> atrules without any rulesets left
if (
node.type === 'Atrule' &&
// only Atrules containing rulesets
node.block !== null &&
node.block.children.isEmpty()
) {
list.remove(item);
return;
}
// clean up <style/> rulesets without any css selectors left
if (node.type === 'Rule' && node.prelude.children.isEmpty()) {
if (
node.type === 'Rule' &&
node.prelude.type === 'SelectorList' &&
node.prelude.children.isEmpty()
) {
list.remove(item);
}
},
});
if (style.cssAst.children.isEmpty()) {
// clean up now emtpy <style/>s
var styleParentEl = style.styleEl.parentNode;
styleParentEl.spliceContent(
styleParentEl.children.indexOf(style.styleEl),
1
);
if (
styleParentEl.name === 'defs' &&
styleParentEl.children.length === 0
) {
// also clean up now empty <def/>s
var defsParentEl = styleParentEl.parentNode;
defsParentEl.spliceContent(
defsParentEl.children.indexOf(styleParentEl),
1
);
// remove emtpy style element
detachNodeFromParent(style.node, style.parentNode);
} else {
// update style element if any styles left
const firstChild = style.node.children[0];
if (firstChild.type === 'text' || firstChild.type === 'cdata') {
firstChild.value = csstree.generate(style.cssAst);
}
continue;
}
// update existing, left over <style>s
cssTools.setCssStr(style.styleEl, csstree.generate(style.cssAst));
}
return root;
},
},
};
};

View File

@ -19,10 +19,10 @@
<svg id="test" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 81.285 81.285">
<defs>
<style>
@media only screen and (min-device-width:320px) and (max-device-width:480px) and (-webkit-min-device-pixel-ratio:2){.blue{fill:blue}}
@media only screen and (min-device-width:320px) and (max-device-width:480px) and (-webkit-min-device-pixel-ratio:2){}
</style>
</defs>
<rect width="100" height="100" class="blue"/>
<rect width="100" height="100" style="fill:blue"/>
</svg>
@@@

Before

Width:  |  Height:  |  Size: 896 B

After

Width:  |  Height:  |  Size: 885 B

View File

@ -25,6 +25,7 @@
@@@
<svg id="Ebene_1" data-name="Ebene 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 222 57.28">
<defs/>
<title>button</title>
<rect width="222" height="57.28" rx="28.64" ry="28.64" style="stroke:red;fill:#37d0cd"/>
<path d="M312.75,168.66A2.15,2.15,0,0,1,311.2,165L316,160l-4.8-5a2.15,2.15,0,1,1,3.1-3l6.21,6.49a2.15,2.15,0,0,1,0,3L314.31,168a2.14,2.14,0,0,1-1.56.67Zm0,0" transform="translate(-119 -131.36)" style="fill:#fff"/>

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -29,7 +29,6 @@
"plugins/removeDimensions.js",
"plugins/removeNonInheritableGroupAttrs.js",
"plugins/removeXMLNS.js",
"plugins/inlineStyles.js",
"plugins/preset-default.js"
]
}