380 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			380 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| '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');
 | |
| // @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.active = true;
 | |
| 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
 | |
|  *
 | |
|  * 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
 | |
|  *
 | |
|  * @author strarsis <strarsis@gmail.com>
 | |
|  *
 | |
|  * @type {import('../lib/types').Plugin<{
 | |
|  *   onlyMatchedOnce?: boolean,
 | |
|  *   removeMatchedSelectors?: boolean,
 | |
|  *   useMqs?: Array<string>,
 | |
|  *   usePseudos?: Array<string>
 | |
|  * }>}
 | |
|  */
 | |
| exports.fn = (root, params) => {
 | |
|   const {
 | |
|     onlyMatchedOnce = true,
 | |
|     removeMatchedSelectors = true,
 | |
|     useMqs = ['', 'screen'],
 | |
|     usePseudos = [''],
 | |
|   } = params;
 | |
| 
 | |
|   /**
 | |
|    * @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;
 | |
|         }
 | |
|         // values other than the empty string or text/css are not used
 | |
|         if (
 | |
|           node.attributes.type != null &&
 | |
|           node.attributes.type !== '' &&
 | |
|           node.attributes.type !== 'text/css'
 | |
|         ) {
 | |
|           return;
 | |
|         }
 | |
|         // parse css in style element
 | |
|         let cssText = '';
 | |
|         for (const child of node.children) {
 | |
|           if (child.type === 'text' || child.type === 'cdata') {
 | |
|             cssText += child.value;
 | |
|           }
 | |
|         }
 | |
|         /**
 | |
|          * @type {null | 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: 'Selector',
 | |
|           enter(node, item) {
 | |
|             const atrule = this.atrule;
 | |
|             const rule = this.rule;
 | |
|             if (rule == null) {
 | |
|               return;
 | |
|             }
 | |
| 
 | |
|             // 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 (
 | |
|                   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 (const selectedEl of matchedElements) {
 | |
|             const styleDeclarationList = csstree.parse(
 | |
|               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',
 | |
|               enter(node, item) {
 | |
|                 styleDeclarationItems.set(node.property, 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 matchedItem = styleDeclarationItems.get(
 | |
|                   ruleDeclaration.property
 | |
|                 );
 | |
|                 const ruleDeclarationItem =
 | |
|                   styleDeclarationList.children.createItem(ruleDeclaration);
 | |
|                 if (matchedItem == null) {
 | |
|                   styleDeclarationList.children.append(ruleDeclarationItem);
 | |
|                 } else if (
 | |
|                   matchedItem.data.important !== true &&
 | |
|                   ruleDeclaration.important === true
 | |
|                 ) {
 | |
|                   styleDeclarationList.children.replace(
 | |
|                     matchedItem,
 | |
|                     ruleDeclarationItem
 | |
|                   );
 | |
|                   styleDeclarationItems.set(
 | |
|                     ruleDeclaration.property,
 | |
|                     ruleDeclarationItem
 | |
|                   );
 | |
|                 }
 | |
|               },
 | |
|             });
 | |
|             selectedEl.attributes.style =
 | |
|               csstree.generate(styleDeclarationList);
 | |
|           }
 | |
| 
 | |
|           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 === false) {
 | |
|           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(' ')
 | |
|             );
 | |
|             const firstSubSelector = selector.node.children.first();
 | |
|             if (
 | |
|               firstSubSelector != null &&
 | |
|               firstSubSelector.type === 'ClassSelector'
 | |
|             ) {
 | |
|               classList.delete(firstSubSelector.name);
 | |
|             }
 | |
|             if (classList.size === 0) {
 | |
|               delete selectedEl.attributes.class;
 | |
|             } else {
 | |
|               selectedEl.attributes.class = Array.from(classList).join(' ');
 | |
|             }
 | |
| 
 | |
|             // ID
 | |
|             if (
 | |
|               firstSubSelector != null &&
 | |
|               firstSubSelector.type === 'IdSelector'
 | |
|             ) {
 | |
|               if (selectedEl.attributes.id === firstSubSelector.name) {
 | |
|                 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);
 | |
|               }
 | |
|             },
 | |
|           });
 | |
| 
 | |
|           if (style.cssAst.children.isEmpty()) {
 | |
|             // 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);
 | |
|             }
 | |
|           }
 | |
|         }
 | |
|       },
 | |
|     },
 | |
|   };
 | |
| };
 |