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