UNPKG

svgo

Version:

Nodejs-based tool for optimizing SVG vector graphics files

391 lines (353 loc) 12.6 kB
'use strict'; /** * @typedef {import('../lib/types').XastElement} XastElement * @typedef {import('../lib/types').XastParent} XastParent */ const csstree = require('css-tree'); const { syntax: { specificity }, } = require('csso'); const { visitSkip, querySelectorAll, detachNodeFromParent, } = require('../lib/xast.js'); const { compareSpecificity, includesAttrSelector } = require('../lib/style'); const { attrsGroups, pseudoClasses } = require('./_collections'); exports.name = 'inlineStyles'; exports.description = 'inline styles (additional options)'; /** * Some pseudo-classes can only be calculated by clients, like :visited, * :future, or :hover, but there are other pseudo-classes that we can evaluate * during optimization. * * The list of pseudo-classes that we can evaluate during optimization, and so * shouldn't be toggled conditionally through the `usePseudos` parameter. * * @see https://developer.mozilla.org/docs/Web/CSS/Pseudo-classes */ const preservedPseudos = [ ...pseudoClasses.functional, ...pseudoClasses.treeStructural, ]; /** * Merges styles from style nodes into inline styles. * * @type {import('./plugins-types').Plugin<'inlineStyles'>} * @author strarsis <strarsis@gmail.com> */ exports.fn = (root, params) => { const { onlyMatchedOnce = true, removeMatchedSelectors = true, useMqs = ['', 'screen'], usePseudos = [''], } = params; /** * @type {{ node: XastElement, parentNode: XastParent, cssAst: csstree.StyleSheet }[]} */ const styles = []; /** * @type {{ * node: csstree.Selector, * item: csstree.ListItem<csstree.CssNode>, * rule: csstree.Rule, * matchedElements?: XastElement[] * }[]} */ let selectors = []; return { element: { enter: (node, parentNode) => { if (node.name === 'foreignObject') { return visitSkip; } if (node.name !== 'style' || node.children.length === 0) { return; } if ( node.attributes.type != null && node.attributes.type !== '' && node.attributes.type !== 'text/css' ) { return; } const cssText = node.children .filter((child) => child.type === 'text' || child.type === 'cdata') // @ts-ignore .map((child) => child.value) .join(''); /** @type {?csstree.CssNode} */ let cssAst = null; try { cssAst = csstree.parse(cssText, { parseValue: false, parseCustomProperty: false, }); } catch { return; } if (cssAst.type === 'StyleSheet') { styles.push({ node, parentNode, cssAst }); } // collect selectors csstree.walk(cssAst, { visit: 'Rule', enter(node) { const atrule = this.atrule; // skip media queries not included into useMqs param let mediaQuery = ''; if (atrule != null) { mediaQuery = atrule.name; if (atrule.prelude != null) { mediaQuery += ` ${csstree.generate(atrule.prelude)}`; } } if (!useMqs.includes(mediaQuery)) { return; } if (node.prelude.type === 'SelectorList') { node.prelude.children.forEach((childNode, item) => { if (childNode.type === 'Selector') { /** * @type {{ * item: csstree.ListItem<csstree.CssNode>, * list: csstree.List<csstree.CssNode> * }[]} */ const pseudos = []; childNode.children.forEach( (grandchildNode, grandchildItem, grandchildList) => { const isPseudo = grandchildNode.type === 'PseudoClassSelector' || grandchildNode.type === 'PseudoElementSelector'; if ( isPseudo && !preservedPseudos.includes(grandchildNode.name) ) { pseudos.push({ item: grandchildItem, list: grandchildList, }); } }, ); const pseudoSelectors = csstree.generate({ type: 'Selector', children: new csstree.List().fromArray( pseudos.map((pseudo) => pseudo.item.data), ), }); if (usePseudos.includes(pseudoSelectors)) { for (const pseudo of pseudos) { pseudo.list.remove(pseudo.item); } } selectors.push({ node: childNode, rule: node, item: item }); } }); } }, }); }, }, root: { exit: () => { if (styles.length === 0) { return; } const sortedSelectors = selectors .slice() .sort((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 {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 (const selectedEl of matchedElements) { const styleDeclarationList = csstree.parse( selectedEl.attributes.style ?? '', { context: 'declarationList', parseValue: false, }, ); if (styleDeclarationList.type !== 'DeclarationList') { continue; } const styleDeclarationItems = new Map(); /** @type {csstree.ListItem<csstree.CssNode>} */ let firstListItem; csstree.walk(styleDeclarationList, { visit: 'Declaration', enter(node, item) { if (firstListItem == null) { firstListItem = item; } styleDeclarationItems.set(node.property.toLowerCase(), item); }, }); // merge declarations csstree.walk(selector.rule, { visit: 'Declaration', enter(ruleDeclaration) { // 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 = ruleDeclaration.property; if ( attrsGroups.presentation.has(property) && !selectors.some((selector) => includesAttrSelector(selector.item, property), ) ) { delete selectedEl.attributes[property]; } const matchedItem = styleDeclarationItems.get(property); const ruleDeclarationItem = styleDeclarationList.children.createItem(ruleDeclaration); if (matchedItem == null) { styleDeclarationList.children.insert( ruleDeclarationItem, firstListItem, ); } else if ( matchedItem.data.important !== true && ruleDeclaration.important === true ) { styleDeclarationList.children.replace( matchedItem, ruleDeclarationItem, ); styleDeclarationItems.set(property, ruleDeclarationItem); } }, }); const newStyles = csstree.generate(styleDeclarationList); if (newStyles.length !== 0) { selectedEl.attributes.style = newStyles; } } if ( 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; } // no further processing required if (!removeMatchedSelectors) { return; } // clean up matched class + ID attribute values for (const selector of sortedSelectors) { if (selector.matchedElements == null) { continue; } if (onlyMatchedOnce && selector.matchedElements.length > 1) { // skip selectors that match more than once if option onlyMatchedOnce is enabled continue; } for (const selectedEl of selector.matchedElements) { // class const classList = new Set( selectedEl.attributes.class == null ? null : selectedEl.attributes.class.split(' '), ); for (const child of selector.node.children) { if ( child.type === 'ClassSelector' && !selectors.some((selector) => includesAttrSelector( selector.item, 'class', child.name, true, ), ) ) { classList.delete(child.name); } } if (classList.size === 0) { delete selectedEl.attributes.class; } else { selectedEl.attributes.class = Array.from(classList).join(' '); } // ID const firstSubSelector = selector.node.children.first; if ( firstSubSelector?.type === 'IdSelector' && selectedEl.attributes.id === firstSubSelector.name && !selectors.some((selector) => includesAttrSelector( selector.item, 'id', firstSubSelector.name, true, ), ) ) { delete selectedEl.attributes.id; } } } for (const style of styles) { csstree.walk(style.cssAst, { visit: 'Rule', enter: function (node, item, list) { // clean up <style/> rulesets without any css selectors left if ( node.type === 'Rule' && node.prelude.type === 'SelectorList' && node.prelude.children.isEmpty ) { list.remove(item); } }, }); // csstree v2 changed this type if (style.cssAst.children.isEmpty) { // remove empty 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); } } } }, }, }; };