inlineStyles.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379
  1. 'use strict';
  2. /**
  3. * @typedef {import('../lib/types').Specificity} Specificity
  4. * @typedef {import('../lib/types').XastElement} XastElement
  5. * @typedef {import('../lib/types').XastParent} XastParent
  6. */
  7. const csstree = require('css-tree');
  8. // @ts-ignore not defined in @types/csso
  9. const specificity = require('csso/lib/restructure/prepare/specificity');
  10. const stable = require('stable');
  11. const {
  12. visitSkip,
  13. querySelectorAll,
  14. detachNodeFromParent,
  15. } = require('../lib/xast.js');
  16. exports.type = 'visitor';
  17. exports.name = 'inlineStyles';
  18. exports.active = true;
  19. exports.description = 'inline styles (additional options)';
  20. /**
  21. * Compares two selector specificities.
  22. * extracted from https://github.com/keeganstreet/specificity/blob/master/specificity.js#L211
  23. *
  24. * @type {(a: Specificity, b: Specificity) => number}
  25. */
  26. const compareSpecificity = (a, b) => {
  27. for (var i = 0; i < 4; i += 1) {
  28. if (a[i] < b[i]) {
  29. return -1;
  30. } else if (a[i] > b[i]) {
  31. return 1;
  32. }
  33. }
  34. return 0;
  35. };
  36. /**
  37. * Moves + merges styles from style elements to element styles
  38. *
  39. * Options
  40. * onlyMatchedOnce (default: true)
  41. * inline only selectors that match once
  42. *
  43. * removeMatchedSelectors (default: true)
  44. * clean up matched selectors,
  45. * leave selectors that hadn't matched
  46. *
  47. * useMqs (default: ['', 'screen'])
  48. * what media queries to be used
  49. * empty string element for styles outside media queries
  50. *
  51. * usePseudos (default: [''])
  52. * what pseudo-classes/-elements to be used
  53. * empty string element for all non-pseudo-classes and/or -elements
  54. *
  55. * @author strarsis <strarsis@gmail.com>
  56. *
  57. * @type {import('../lib/types').Plugin<{
  58. * onlyMatchedOnce?: boolean,
  59. * removeMatchedSelectors?: boolean,
  60. * useMqs?: Array<string>,
  61. * usePseudos?: Array<string>
  62. * }>}
  63. */
  64. exports.fn = (root, params) => {
  65. const {
  66. onlyMatchedOnce = true,
  67. removeMatchedSelectors = true,
  68. useMqs = ['', 'screen'],
  69. usePseudos = [''],
  70. } = params;
  71. /**
  72. * @type {Array<{ node: XastElement, parentNode: XastParent, cssAst: csstree.StyleSheet }>}
  73. */
  74. const styles = [];
  75. /**
  76. * @type {Array<{
  77. * node: csstree.Selector,
  78. * item: csstree.ListItem<csstree.CssNode>,
  79. * rule: csstree.Rule,
  80. * matchedElements?: Array<XastElement>
  81. * }>}
  82. */
  83. let selectors = [];
  84. return {
  85. element: {
  86. enter: (node, parentNode) => {
  87. // skip <foreignObject /> content
  88. if (node.name === 'foreignObject') {
  89. return visitSkip;
  90. }
  91. // collect only non-empty <style /> elements
  92. if (node.name !== 'style' || node.children.length === 0) {
  93. return;
  94. }
  95. // values other than the empty string or text/css are not used
  96. if (
  97. node.attributes.type != null &&
  98. node.attributes.type !== '' &&
  99. node.attributes.type !== 'text/css'
  100. ) {
  101. return;
  102. }
  103. // parse css in style element
  104. let cssText = '';
  105. for (const child of node.children) {
  106. if (child.type === 'text' || child.type === 'cdata') {
  107. cssText += child.value;
  108. }
  109. }
  110. /**
  111. * @type {null | csstree.CssNode}
  112. */
  113. let cssAst = null;
  114. try {
  115. cssAst = csstree.parse(cssText, {
  116. parseValue: false,
  117. parseCustomProperty: false,
  118. });
  119. } catch {
  120. return;
  121. }
  122. if (cssAst.type === 'StyleSheet') {
  123. styles.push({ node, parentNode, cssAst });
  124. }
  125. // collect selectors
  126. csstree.walk(cssAst, {
  127. visit: 'Selector',
  128. enter(node, item) {
  129. const atrule = this.atrule;
  130. const rule = this.rule;
  131. if (rule == null) {
  132. return;
  133. }
  134. // skip media queries not included into useMqs param
  135. let mq = '';
  136. if (atrule != null) {
  137. mq = atrule.name;
  138. if (atrule.prelude != null) {
  139. mq += ` ${csstree.generate(atrule.prelude)}`;
  140. }
  141. }
  142. if (useMqs.includes(mq) === false) {
  143. return;
  144. }
  145. /**
  146. * @type {Array<{
  147. * item: csstree.ListItem<csstree.CssNode>,
  148. * list: csstree.List<csstree.CssNode>
  149. * }>}
  150. */
  151. const pseudos = [];
  152. if (node.type === 'Selector') {
  153. node.children.each((childNode, childItem, childList) => {
  154. if (
  155. childNode.type === 'PseudoClassSelector' ||
  156. childNode.type === 'PseudoElementSelector'
  157. ) {
  158. pseudos.push({ item: childItem, list: childList });
  159. }
  160. });
  161. }
  162. // skip pseudo classes and pseudo elements not includes into usePseudos param
  163. const pseudoSelectors = csstree.generate({
  164. type: 'Selector',
  165. children: new csstree.List().fromArray(
  166. pseudos.map((pseudo) => pseudo.item.data)
  167. ),
  168. });
  169. if (usePseudos.includes(pseudoSelectors) === false) {
  170. return;
  171. }
  172. // remove pseudo classes and elements to allow querySelector match elements
  173. // TODO this is not very accurate since some pseudo classes like first-child
  174. // are used for selection
  175. for (const pseudo of pseudos) {
  176. pseudo.list.remove(pseudo.item);
  177. }
  178. selectors.push({ node, item, rule });
  179. },
  180. });
  181. },
  182. },
  183. root: {
  184. exit: () => {
  185. if (styles.length === 0) {
  186. return;
  187. }
  188. // stable sort selectors
  189. const sortedSelectors = stable(selectors, (a, b) => {
  190. const aSpecificity = specificity(a.item.data);
  191. const bSpecificity = specificity(b.item.data);
  192. return compareSpecificity(aSpecificity, bSpecificity);
  193. }).reverse();
  194. for (const selector of sortedSelectors) {
  195. // match selectors
  196. const selectorText = csstree.generate(selector.item.data);
  197. /**
  198. * @type {Array<XastElement>}
  199. */
  200. const matchedElements = [];
  201. try {
  202. for (const node of querySelectorAll(root, selectorText)) {
  203. if (node.type === 'element') {
  204. matchedElements.push(node);
  205. }
  206. }
  207. } catch (selectError) {
  208. continue;
  209. }
  210. // nothing selected
  211. if (matchedElements.length === 0) {
  212. continue;
  213. }
  214. // apply styles to matched elements
  215. // skip selectors that match more than once if option onlyMatchedOnce is enabled
  216. if (onlyMatchedOnce && matchedElements.length > 1) {
  217. continue;
  218. }
  219. // apply <style/> to matched elements
  220. for (const selectedEl of matchedElements) {
  221. const styleDeclarationList = csstree.parse(
  222. selectedEl.attributes.style == null
  223. ? ''
  224. : selectedEl.attributes.style,
  225. {
  226. context: 'declarationList',
  227. parseValue: false,
  228. }
  229. );
  230. if (styleDeclarationList.type !== 'DeclarationList') {
  231. continue;
  232. }
  233. const styleDeclarationItems = new Map();
  234. csstree.walk(styleDeclarationList, {
  235. visit: 'Declaration',
  236. enter(node, item) {
  237. styleDeclarationItems.set(node.property, item);
  238. },
  239. });
  240. // merge declarations
  241. csstree.walk(selector.rule, {
  242. visit: 'Declaration',
  243. enter(ruleDeclaration) {
  244. // existing inline styles have higher priority
  245. // no inline styles, external styles, external styles used
  246. // inline styles, external styles same priority as inline styles, inline styles used
  247. // inline styles, external styles higher priority than inline styles, external styles used
  248. const matchedItem = styleDeclarationItems.get(
  249. ruleDeclaration.property
  250. );
  251. const ruleDeclarationItem =
  252. styleDeclarationList.children.createItem(ruleDeclaration);
  253. if (matchedItem == null) {
  254. styleDeclarationList.children.append(ruleDeclarationItem);
  255. } else if (
  256. matchedItem.data.important !== true &&
  257. ruleDeclaration.important === true
  258. ) {
  259. styleDeclarationList.children.replace(
  260. matchedItem,
  261. ruleDeclarationItem
  262. );
  263. styleDeclarationItems.set(
  264. ruleDeclaration.property,
  265. ruleDeclarationItem
  266. );
  267. }
  268. },
  269. });
  270. selectedEl.attributes.style =
  271. csstree.generate(styleDeclarationList);
  272. }
  273. if (
  274. removeMatchedSelectors &&
  275. matchedElements.length !== 0 &&
  276. selector.rule.prelude.type === 'SelectorList'
  277. ) {
  278. // clean up matching simple selectors if option removeMatchedSelectors is enabled
  279. selector.rule.prelude.children.remove(selector.item);
  280. }
  281. selector.matchedElements = matchedElements;
  282. }
  283. // no further processing required
  284. if (removeMatchedSelectors === false) {
  285. return;
  286. }
  287. // clean up matched class + ID attribute values
  288. for (const selector of sortedSelectors) {
  289. if (selector.matchedElements == null) {
  290. continue;
  291. }
  292. if (onlyMatchedOnce && selector.matchedElements.length > 1) {
  293. // skip selectors that match more than once if option onlyMatchedOnce is enabled
  294. continue;
  295. }
  296. for (const selectedEl of selector.matchedElements) {
  297. // class
  298. const classList = new Set(
  299. selectedEl.attributes.class == null
  300. ? null
  301. : selectedEl.attributes.class.split(' ')
  302. );
  303. const firstSubSelector = selector.node.children.first();
  304. if (
  305. firstSubSelector != null &&
  306. firstSubSelector.type === 'ClassSelector'
  307. ) {
  308. classList.delete(firstSubSelector.name);
  309. }
  310. if (classList.size === 0) {
  311. delete selectedEl.attributes.class;
  312. } else {
  313. selectedEl.attributes.class = Array.from(classList).join(' ');
  314. }
  315. // ID
  316. if (
  317. firstSubSelector != null &&
  318. firstSubSelector.type === 'IdSelector'
  319. ) {
  320. if (selectedEl.attributes.id === firstSubSelector.name) {
  321. delete selectedEl.attributes.id;
  322. }
  323. }
  324. }
  325. }
  326. for (const style of styles) {
  327. csstree.walk(style.cssAst, {
  328. visit: 'Rule',
  329. enter: function (node, item, list) {
  330. // clean up <style/> rulesets without any css selectors left
  331. if (
  332. node.type === 'Rule' &&
  333. node.prelude.type === 'SelectorList' &&
  334. node.prelude.children.isEmpty()
  335. ) {
  336. list.remove(item);
  337. }
  338. },
  339. });
  340. if (style.cssAst.children.isEmpty()) {
  341. // remove emtpy style element
  342. detachNodeFromParent(style.node, style.parentNode);
  343. } else {
  344. // update style element if any styles left
  345. const firstChild = style.node.children[0];
  346. if (firstChild.type === 'text' || firstChild.type === 'cdata') {
  347. firstChild.value = csstree.generate(style.cssAst);
  348. }
  349. }
  350. }
  351. },
  352. },
  353. };
  354. };