| '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); |
| } |
| } |
| } |
| }, |
| }, |
| }; |
| }; |