css-minifier.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310
  1. /*
  2. * Copyright 2018 Gildas Lormeau
  3. * contact : gildas.lormeau <at> gmail.com
  4. *
  5. * This file is part of SingleFile.
  6. *
  7. * SingleFile is free software: you can redistribute it and/or modify
  8. * it under the terms of the GNU Lesser General Public License as published by
  9. * the Free Software Foundation, either version 3 of the License, or
  10. * (at your option) any later version.
  11. *
  12. * SingleFile is distributed in the hope that it will be useful,
  13. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  14. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  15. * GNU Lesser General Public License for more details.
  16. *
  17. * You should have received a copy of the GNU Lesser General Public License
  18. * along with SingleFile. If not, see <http://www.gnu.org/licenses/>.
  19. */
  20. /* global CSSRule, cssWhat, parseCss */
  21. this.cssMinifier = this.stylesMinifier || (() => {
  22. const SEPARATOR_TYPES = ["descendant", "child", "sibling", "adjacent"];
  23. const REMOVED_PSEUDO_CLASSES = ["focus", "focus-within", "hover", "link", "visited", "active"];
  24. const REMOVED_PSEUDO_ELEMENTS = ["after", "before", "first-line", "first-letter"];
  25. const MEDIA_ALL = "all";
  26. const PRIORITY_IMPORTANT = "important";
  27. return {
  28. process: doc => {
  29. const mediaAllInfo = createMediaInfo(MEDIA_ALL);
  30. const stats = { processed: 0, discarded: 0 };
  31. doc.querySelectorAll("style").forEach((styleElement, styleIndex) => {
  32. if (styleElement.sheet) {
  33. stats.processed += styleElement.sheet.cssRules.length;
  34. stats.discarded += styleElement.sheet.cssRules.length;
  35. if (styleElement.media && styleElement.media != MEDIA_ALL) {
  36. const mediaInfo = createMediaInfo(styleElement.media);
  37. mediaAllInfo.medias.set(styleElement.media, mediaInfo);
  38. getMatchedElements(doc, styleElement.sheet.cssRules, mediaInfo, styleIndex);
  39. } else {
  40. getMatchedElements(doc, styleElement.sheet.cssRules, mediaAllInfo, styleIndex);
  41. }
  42. }
  43. });
  44. sortRules(mediaAllInfo);
  45. computeCascade(mediaAllInfo);
  46. doc.querySelectorAll("style").forEach(styleElement => {
  47. if (styleElement.sheet) {
  48. replaceRules(doc, styleElement.sheet.cssRules, mediaAllInfo);
  49. styleElement.textContent = serializeRules(styleElement.sheet.cssRules);
  50. stats.discarded -= styleElement.sheet.cssRules.length;
  51. }
  52. });
  53. doc.querySelectorAll("[style]").forEach(element => {
  54. replaceStyle(doc, element.style, mediaAllInfo);
  55. });
  56. return stats;
  57. }
  58. };
  59. function getMatchedElements(doc, cssRules, mediaInfo, sheetIndex) {
  60. Array.from(cssRules).forEach((cssRule, ruleIndex) => {
  61. if (cssRule.type == CSSRule.MEDIA_RULE) {
  62. const ruleMediaInfo = createMediaInfo(cssRule.media);
  63. mediaInfo.medias.set(cssRule.media, ruleMediaInfo);
  64. getMatchedElements(doc, cssRule.cssRules, ruleMediaInfo, sheetIndex);
  65. } else if (cssRule.type == CSSRule.STYLE_RULE) {
  66. if (cssRule.selectorText) {
  67. let selectors = cssWhat.parse(cssRule.selectorText);
  68. let removedSelectors;
  69. selectors.forEach((selector, selectorIndex) => {
  70. const matchedElements = doc.querySelectorAll(cssWhat.stringify([selector]));
  71. if (matchedElements.length) {
  72. matchedElements.forEach(element => {
  73. let elementInfo;
  74. if (mediaInfo.elements.has(element)) {
  75. elementInfo = mediaInfo.elements.get(element);
  76. } else {
  77. elementInfo = [];
  78. const elementStyle = element.getAttribute("style");
  79. if (elementStyle) {
  80. elementInfo.push({ cssStyle: element.style });
  81. }
  82. mediaInfo.elements.set(element, elementInfo);
  83. }
  84. elementInfo.push({ cssRule, specificity: computeSpecificity(selector), selectorIndex, ruleIndex, sheetIndex });
  85. });
  86. } else {
  87. selectors = selectors.filter(s => s != selector);
  88. removedSelectors = true;
  89. }
  90. });
  91. if (selectors.length && removedSelectors) {
  92. cssRule.selectorText = cssWhat.stringify(selectors);
  93. }
  94. }
  95. }
  96. });
  97. }
  98. function createMediaInfo(media) {
  99. const mediaInfo = { media: media, elements: new Map(), medias: new Map(), rules: new Map() };
  100. if (media == MEDIA_ALL) {
  101. mediaInfo.styles = new Map();
  102. }
  103. return mediaInfo;
  104. }
  105. function sortRules(media) {
  106. media.elements.forEach(elementRules => elementRules.sort(compareRules));
  107. media.medias.forEach(sortRules);
  108. }
  109. function computeCascade(mediaInfo, parentMediaInfos = []) {
  110. mediaInfo.elements.forEach((elementInfo) => {
  111. const elementStylesInfo = new Map();
  112. elementInfo.forEach(ruleInfo => {
  113. if (ruleInfo.cssStyle) {
  114. const cssStyle = ruleInfo.cssStyle;
  115. const stylesInfo = parseCss.parseAListOfDeclarations(cssStyle.cssText);
  116. stylesInfo.forEach(styleInfo => {
  117. const important = cssStyle.getPropertyPriority(styleInfo.name) == PRIORITY_IMPORTANT;
  118. const styleValue = cssStyle.getPropertyValue(styleInfo.name) + (important ? " !" + PRIORITY_IMPORTANT : "");
  119. elementStylesInfo.set(styleInfo.name, { styleValue, cssStyle: ruleInfo.cssStyle, important });
  120. });
  121. } else {
  122. const cssStyle = ruleInfo.cssRule.style;
  123. const stylesInfo = parseCss.parseAListOfDeclarations(cssStyle.cssText);
  124. stylesInfo.forEach(styleInfo => {
  125. const important = cssStyle.getPropertyPriority(styleInfo.name) == PRIORITY_IMPORTANT;
  126. const styleValue = cssStyle.getPropertyValue(styleInfo.name) + (important ? " !" + PRIORITY_IMPORTANT : "");
  127. let elementStyleInfo = elementStylesInfo.get(styleInfo.name);
  128. if (!elementStyleInfo || (important && !elementStyleInfo.important)) {
  129. elementStylesInfo.set(styleInfo.name, { styleValue, cssRule: ruleInfo.cssRule, important });
  130. }
  131. });
  132. }
  133. });
  134. elementStylesInfo.forEach((styleInfo, styleName) => {
  135. let ruleInfo, ascendantMedia, allMedia;
  136. if (styleInfo.cssRule) {
  137. ascendantMedia = [mediaInfo, ...parentMediaInfos].find(media => media.rules.get(styleInfo.cssRule)) || mediaInfo;
  138. ruleInfo = ascendantMedia.rules.get(styleInfo.cssRule);
  139. }
  140. if (styleInfo.cssStyle) {
  141. allMedia = parentMediaInfos[parentMediaInfos.length - 1] || mediaInfo;
  142. ruleInfo = allMedia.styles.get(styleInfo.cssStyle);
  143. }
  144. if (!ruleInfo) {
  145. ruleInfo = new Map();
  146. if (styleInfo.cssRule) {
  147. ascendantMedia.rules.set(styleInfo.cssRule, ruleInfo);
  148. } else {
  149. allMedia.styles.set(styleInfo.cssStyle, ruleInfo);
  150. }
  151. }
  152. ruleInfo.set(styleName, styleInfo.styleValue);
  153. });
  154. });
  155. mediaInfo.medias.forEach(childMediaInfo => computeCascade(childMediaInfo, [mediaInfo, ...parentMediaInfos]));
  156. }
  157. function replaceRules(doc, cssRules, ruleMedia) {
  158. Array.from(cssRules).forEach(cssRule => {
  159. if (cssRule.type == CSSRule.MEDIA_RULE) {
  160. replaceRules(doc, cssRule.cssRules, ruleMedia.medias.get(cssRule.media));
  161. } else if (cssRule.type == CSSRule.STYLE_RULE) {
  162. const ruleInfo = ruleMedia.rules.get(cssRule);
  163. if (ruleInfo) {
  164. const stylesInfo = parseCss.parseAListOfDeclarations(cssRule.style.cssText);
  165. const unusedStyles = stylesInfo.filter(style => !ruleInfo.get(style.name));
  166. if (unusedStyles.length) {
  167. unusedStyles.forEach(style => cssRule.style.removeProperty(style.name));
  168. }
  169. } else {
  170. if (!testFilterSelector(cssRule.selectorText) || !doc.querySelector(getFilteredSelector(cssRule.selectorText))) {
  171. const parent = cssRule.parentRule || cssRule.parentStyleSheet;
  172. let indexRule = 0;
  173. while (cssRule != parent.cssRules[indexRule] && indexRule < parent.cssRules.length) {
  174. indexRule++;
  175. }
  176. if (cssRule == parent.cssRules[indexRule]) {
  177. parent.deleteRule(indexRule);
  178. }
  179. }
  180. }
  181. }
  182. });
  183. }
  184. function replaceStyle(doc, cssStyle, ruleMedia) {
  185. const styleInfo = ruleMedia.styles.get(cssStyle);
  186. if (styleInfo) {
  187. const stylesInfo = parseCss.parseAListOfDeclarations(cssStyle.cssText);
  188. const unusedStyles = stylesInfo.filter(style => !styleInfo.get(style.name));
  189. if (unusedStyles.length) {
  190. unusedStyles.forEach(style => cssStyle.removeProperty(style.name));
  191. }
  192. }
  193. }
  194. function serializeRules(rules) {
  195. let sheetContent = "";
  196. Array.from(rules).forEach(rule => {
  197. if (rule.media) {
  198. sheetContent += "@media " + Array.from(rule.media).join(",") + "{";
  199. sheetContent += serializeRules(rule.cssRules);
  200. sheetContent += "}";
  201. } else {
  202. sheetContent += rule.cssText;
  203. }
  204. });
  205. return sheetContent;
  206. }
  207. function computeSpecificity(selector, specificity = { a: 0, b: 0, c: 0 }) {
  208. selector.forEach(token => {
  209. if (token.expandedSelector && token.type == "attribute" && token.name === "id" && token.action === "equals") {
  210. specificity.a++;
  211. }
  212. if ((!token.expandedSelector && token.type == "attribute") ||
  213. (token.expandedSelector && token.type == "attribute" && token.name === "class" && token.action === "element") ||
  214. (token.type == "pseudo" && token.name != "not")) {
  215. specificity.b++;
  216. }
  217. if ((token.type == "tag" && token.value != "*") || (token.type == "pseudo-element")) {
  218. specificity.c++;
  219. }
  220. if (token.data) {
  221. if (Array.isArray(token.data)) {
  222. token.data.forEach(selector => computeSpecificity(selector, specificity));
  223. }
  224. }
  225. });
  226. return specificity;
  227. }
  228. function compareRules(ruleInfo1, ruleInfo2) {
  229. if (ruleInfo1.cssStyle && !ruleInfo2.cssStyle) {
  230. return -1;
  231. } else if (!ruleInfo1.cssStyle && ruleInfo2.cssStyle) {
  232. return 1;
  233. } else if (ruleInfo1.specificity.a > ruleInfo2.specificity.a) {
  234. return -1;
  235. } else if (ruleInfo1.specificity.a < ruleInfo2.specificity.a) {
  236. return 1;
  237. } else if (ruleInfo1.specificity.b > ruleInfo2.specificity.b) {
  238. return -1;
  239. } else if (ruleInfo1.specificity.b < ruleInfo2.specificity.b) {
  240. return 1;
  241. } else if (ruleInfo1.specificity.c > ruleInfo2.specificity.c) {
  242. return -1;
  243. } else if (ruleInfo1.specificity.c < ruleInfo2.specificity.c) {
  244. return 1;
  245. } else if (ruleInfo1.sheetIndex > ruleInfo2.sheetIndex) {
  246. return -1;
  247. } else if (ruleInfo1.sheetIndex < ruleInfo2.sheetIndex) {
  248. return 1;
  249. } else if (ruleInfo1.ruleIndex > ruleInfo2.ruleIndex) {
  250. return -1;
  251. } else if (ruleInfo1.ruleIndex < ruleInfo2.ruleIndex) {
  252. return 1;
  253. } else if (ruleInfo1.selectorIndex > ruleInfo2.selectorIndex) {
  254. return -1;
  255. } else if (ruleInfo1.selectorIndex < ruleInfo2.selectorIndex) {
  256. return 1;
  257. } else {
  258. return -1;
  259. }
  260. }
  261. function testFilterSelector(selector) {
  262. return REMOVED_PSEUDO_CLASSES.find(pseudoClass => selector.includes(":" + pseudoClass)) || REMOVED_PSEUDO_ELEMENTS.find(pseudoElement => selector.includes("::" + pseudoElement));
  263. }
  264. function getFilteredSelector(selector) {
  265. const selectors = cssWhat.parse(selector);
  266. return cssWhat.stringify(selectors.map(selector => filterPseudoClasses(selector)));
  267. function filterPseudoClasses(selector) {
  268. const tokens = selector.filter(token => {
  269. if (token.data) {
  270. if (Array.isArray(token.data)) {
  271. token.data = token.data.map(selector => filterPseudoClasses(selector));
  272. }
  273. }
  274. const test = ((token.type != "pseudo" || !REMOVED_PSEUDO_CLASSES.includes(token.name))
  275. && (token.type != "pseudo-element" || !REMOVED_PSEUDO_ELEMENTS.includes(token.name)));
  276. return test;
  277. });
  278. let insertedTokens = 0;
  279. tokens.forEach((token, index) => {
  280. if (SEPARATOR_TYPES.includes(token.type)) {
  281. if (!tokens[index - 1] || SEPARATOR_TYPES.includes(tokens[index - 1].type)) {
  282. tokens.splice(index + insertedTokens, 0, { type: "universal" });
  283. insertedTokens++;
  284. }
  285. }
  286. });
  287. if (!tokens.length || SEPARATOR_TYPES.includes(tokens[tokens.length - 1].type)) {
  288. tokens.push({ type: "universal" });
  289. }
  290. return tokens;
  291. }
  292. }
  293. })();