提交学习笔记专用
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

427 lines
11 KiB

  1. 'use strict';
  2. const browserslist = require('browserslist');
  3. const { sameParent } = require('cssnano-utils');
  4. const {
  5. ensureCompatibility,
  6. sameVendor,
  7. noVendor,
  8. } = require('./lib/ensureCompatibility');
  9. /**
  10. * @param {import('postcss').Declaration} a
  11. * @param {import('postcss').Declaration} b
  12. * @return {boolean}
  13. */
  14. function declarationIsEqual(a, b) {
  15. return (
  16. a.important === b.important && a.prop === b.prop && a.value === b.value
  17. );
  18. }
  19. /**
  20. * @param {import('postcss').Declaration[]} array
  21. * @param {import('postcss').Declaration} decl
  22. * @return {number}
  23. */
  24. function indexOfDeclaration(array, decl) {
  25. return array.findIndex((d) => declarationIsEqual(d, decl));
  26. }
  27. /**
  28. * Returns filtered array of matched or unmatched declarations
  29. * @param {import('postcss').Declaration[]} a
  30. * @param {import('postcss').Declaration[]} b
  31. * @param {boolean} [not=false]
  32. * @return {import('postcss').Declaration[]}
  33. */
  34. function intersect(a, b, not) {
  35. return a.filter((c) => {
  36. const index = indexOfDeclaration(b, c) !== -1;
  37. return not ? !index : index;
  38. });
  39. }
  40. /**
  41. * @param {import('postcss').Declaration[]} a
  42. * @param {import('postcss').Declaration[]} b
  43. * @return {boolean}
  44. */
  45. function sameDeclarationsAndOrder(a, b) {
  46. if (a.length !== b.length) {
  47. return false;
  48. }
  49. return a.every((d, index) => declarationIsEqual(d, b[index]));
  50. }
  51. /**
  52. * @param {import('postcss').Rule} ruleA
  53. * @param {import('postcss').Rule} ruleB
  54. * @param {string[]=} browsers
  55. * @param {Map<string, boolean>=} compatibilityCache
  56. * @return {boolean}
  57. */
  58. function canMerge(ruleA, ruleB, browsers, compatibilityCache) {
  59. const a = ruleA.selectors;
  60. const b = ruleB.selectors;
  61. const selectors = a.concat(b);
  62. if (!ensureCompatibility(selectors, browsers, compatibilityCache)) {
  63. return false;
  64. }
  65. const parent = sameParent(
  66. /** @type {any} */ (ruleA),
  67. /** @type {any} */ (ruleB)
  68. );
  69. if (
  70. parent &&
  71. ruleA.parent &&
  72. ruleA.parent.type === 'atrule' &&
  73. /** @type {import('postcss').AtRule} */ (ruleA.parent).name.includes(
  74. 'keyframes'
  75. )
  76. ) {
  77. return false;
  78. }
  79. return parent && (selectors.every(noVendor) || sameVendor(a, b));
  80. }
  81. /**
  82. * @param {import('postcss').ChildNode} node
  83. * @return {node is import('postcss').Declaration}
  84. */
  85. function isDeclaration(node) {
  86. return node.type === 'decl';
  87. }
  88. /**
  89. * @param {import('postcss').Rule} rule
  90. * @return {import('postcss').Declaration[]}
  91. */
  92. function getDecls(rule) {
  93. return rule.nodes.filter(isDeclaration);
  94. }
  95. /** @type {(...rules: import('postcss').Rule[]) => string} */
  96. const joinSelectors = (...rules) => rules.map((s) => s.selector).join();
  97. /**
  98. * @param {...import('postcss').Rule} rules
  99. * @return {number}
  100. */
  101. function ruleLength(...rules) {
  102. return rules.map((r) => (r.nodes.length ? String(r) : '')).join('').length;
  103. }
  104. /**
  105. * @param {string} prop
  106. * @return {{prefix: string?, base:string?, rest:string[]}}
  107. */
  108. function splitProp(prop) {
  109. // Treat vendor prefixed properties as if they were unprefixed;
  110. // moving them when combined with non-prefixed properties can
  111. // cause issues. e.g. moving -webkit-background-clip when there
  112. // is a background shorthand definition.
  113. const parts = prop.split('-');
  114. if (prop[0] !== '-') {
  115. return {
  116. prefix: '',
  117. base: parts[0],
  118. rest: parts.slice(1),
  119. };
  120. }
  121. // Don't split css variables
  122. if (prop[1] === '-') {
  123. return {
  124. prefix: null,
  125. base: null,
  126. rest: [prop],
  127. };
  128. }
  129. // Found prefix
  130. return {
  131. prefix: parts[1],
  132. base: parts[2],
  133. rest: parts.slice(3),
  134. };
  135. }
  136. /**
  137. * @param {string} propA
  138. * @param {string} propB
  139. * @return {boolean}
  140. */
  141. function isConflictingProp(propA, propB) {
  142. if (propA === propB) {
  143. // Same specificity
  144. return true;
  145. }
  146. const a = splitProp(propA);
  147. const b = splitProp(propB);
  148. // Don't resort css variables
  149. if (!a.base && !b.base) {
  150. return true;
  151. }
  152. // Different base and none is `place`;
  153. if (a.base !== b.base && a.base !== 'place' && b.base !== 'place') {
  154. return false;
  155. }
  156. // Conflict if rest-count mismatches
  157. if (a.rest.length !== b.rest.length) {
  158. return true;
  159. }
  160. /* Do not merge conflicting border properties */
  161. if (a.base === 'border') {
  162. const allRestProps = new Set([...a.rest, ...b.rest]);
  163. if (
  164. allRestProps.has('image') ||
  165. allRestProps.has('width') ||
  166. allRestProps.has('color') ||
  167. allRestProps.has('style')
  168. ) {
  169. return true;
  170. }
  171. }
  172. // Conflict if rest parameters are equal (same but unprefixed)
  173. return a.rest.every((s, index) => b.rest[index] === s);
  174. }
  175. /**
  176. * @param {import('postcss').Rule} first
  177. * @param {import('postcss').Rule} second
  178. * @return {boolean} merged
  179. */
  180. function mergeParents(first, second) {
  181. // Null check for detached rules
  182. if (!first.parent || !second.parent) {
  183. return false;
  184. }
  185. // Check if parents share node
  186. if (first.parent === second.parent) {
  187. return false;
  188. }
  189. // sameParent() already called by canMerge()
  190. second.remove();
  191. first.parent.append(second);
  192. return true;
  193. }
  194. /**
  195. * @param {import('postcss').Rule} first
  196. * @param {import('postcss').Rule} second
  197. * @return {import('postcss').Rule} mergedRule
  198. */
  199. function partialMerge(first, second) {
  200. let intersection = intersect(getDecls(first), getDecls(second));
  201. if (intersection.length === 0) {
  202. return second;
  203. }
  204. let nextRule = second.next();
  205. if (!nextRule) {
  206. // Grab next cousin
  207. /** @type {any} */
  208. const parentSibling =
  209. /** @type {import('postcss').Container<import('postcss').ChildNode>} */ (
  210. second.parent
  211. ).next();
  212. nextRule = parentSibling && parentSibling.nodes && parentSibling.nodes[0];
  213. }
  214. if (nextRule && nextRule.type === 'rule' && canMerge(second, nextRule)) {
  215. let nextIntersection = intersect(getDecls(second), getDecls(nextRule));
  216. if (nextIntersection.length > intersection.length) {
  217. mergeParents(second, nextRule);
  218. first = second;
  219. second = nextRule;
  220. intersection = nextIntersection;
  221. }
  222. }
  223. const firstDecls = getDecls(first);
  224. // Filter out intersections with later conflicts in First
  225. intersection = intersection.filter((decl, intersectIndex) => {
  226. const indexOfDecl = indexOfDeclaration(firstDecls, decl);
  227. const nextConflictInFirst = firstDecls
  228. .slice(indexOfDecl + 1)
  229. .filter((d) => isConflictingProp(d.prop, decl.prop));
  230. if (nextConflictInFirst.length === 0) {
  231. return true;
  232. }
  233. const nextConflictInIntersection = intersection
  234. .slice(intersectIndex + 1)
  235. .filter((d) => isConflictingProp(d.prop, decl.prop));
  236. if (nextConflictInFirst.length !== nextConflictInIntersection.length) {
  237. return false;
  238. }
  239. return nextConflictInFirst.every((d, index) =>
  240. declarationIsEqual(d, nextConflictInIntersection[index])
  241. );
  242. });
  243. // Filter out intersections with previous conflicts in Second
  244. const secondDecls = getDecls(second);
  245. intersection = intersection.filter((decl) => {
  246. const nextConflictIndex = secondDecls.findIndex((d) =>
  247. isConflictingProp(d.prop, decl.prop)
  248. );
  249. if (nextConflictIndex === -1) {
  250. return false;
  251. }
  252. if (!declarationIsEqual(secondDecls[nextConflictIndex], decl)) {
  253. return false;
  254. }
  255. if (
  256. decl.prop.toLowerCase() !== 'direction' &&
  257. decl.prop.toLowerCase() !== 'unicode-bidi' &&
  258. secondDecls.some(
  259. (declaration) => declaration.prop.toLowerCase() === 'all'
  260. )
  261. ) {
  262. return false;
  263. }
  264. secondDecls.splice(nextConflictIndex, 1);
  265. return true;
  266. });
  267. if (intersection.length === 0) {
  268. // Nothing to merge
  269. return second;
  270. }
  271. const receivingBlock = second.clone();
  272. receivingBlock.selector = joinSelectors(first, second);
  273. receivingBlock.nodes = [];
  274. /** @type {import('postcss').Container<import('postcss').ChildNode>} */ (
  275. second.parent
  276. ).insertBefore(second, receivingBlock);
  277. const firstClone = first.clone();
  278. const secondClone = second.clone();
  279. /**
  280. * @param {function(import('postcss').Declaration):void} callback
  281. * @this {import('postcss').Rule}
  282. * @return {function(import('postcss').Declaration)}
  283. */
  284. function moveDecl(callback) {
  285. return (decl) => {
  286. if (indexOfDeclaration(intersection, decl) !== -1) {
  287. callback.call(this, decl);
  288. }
  289. };
  290. }
  291. firstClone.walkDecls(
  292. moveDecl((decl) => {
  293. decl.remove();
  294. receivingBlock.append(decl);
  295. })
  296. );
  297. secondClone.walkDecls(moveDecl((decl) => decl.remove()));
  298. const merged = ruleLength(firstClone, receivingBlock, secondClone);
  299. const original = ruleLength(first, second);
  300. if (merged < original) {
  301. first.replaceWith(firstClone);
  302. second.replaceWith(secondClone);
  303. [firstClone, receivingBlock, secondClone].forEach((r) => {
  304. if (r.nodes.length === 0) {
  305. r.remove();
  306. }
  307. });
  308. if (!secondClone.parent) {
  309. return receivingBlock;
  310. }
  311. return secondClone;
  312. } else {
  313. receivingBlock.remove();
  314. return second;
  315. }
  316. }
  317. /**
  318. * @param {string[]} browsers
  319. * @param {Map<string, boolean>} compatibilityCache
  320. * @return {function(import('postcss').Rule)}
  321. */
  322. function selectorMerger(browsers, compatibilityCache) {
  323. /** @type {import('postcss').Rule | null} */
  324. let cache = null;
  325. return function (rule) {
  326. // Prime the cache with the first rule, or alternately ensure that it is
  327. // safe to merge both declarations before continuing
  328. if (!cache || !canMerge(rule, cache, browsers, compatibilityCache)) {
  329. cache = rule;
  330. return;
  331. }
  332. // Ensure that we don't deduplicate the same rule; this is sometimes
  333. // caused by a partial merge
  334. if (cache === rule) {
  335. cache = rule;
  336. return;
  337. }
  338. // Parents merge: check if the rules have same parents, but not same parent nodes
  339. mergeParents(cache, rule);
  340. // Merge when declarations are exactly equal
  341. // e.g. h1 { color: red } h2 { color: red }
  342. if (sameDeclarationsAndOrder(getDecls(rule), getDecls(cache))) {
  343. rule.selector = joinSelectors(cache, rule);
  344. cache.remove();
  345. cache = rule;
  346. return;
  347. }
  348. // Merge when both selectors are exactly equal
  349. // e.g. a { color: blue } a { font-weight: bold }
  350. if (cache.selector === rule.selector) {
  351. const cached = getDecls(cache);
  352. rule.walk((node) => {
  353. if (node.type === 'decl' && indexOfDeclaration(cached, node) !== -1) {
  354. node.remove();
  355. return;
  356. }
  357. /** @type {import('postcss').Rule} */ (cache).append(node);
  358. });
  359. rule.remove();
  360. return;
  361. }
  362. // Partial merge: check if the rule contains a subset of the last; if
  363. // so create a joined selector with the subset, if smaller.
  364. cache = partialMerge(cache, rule);
  365. };
  366. }
  367. /**
  368. * @type {import('postcss').PluginCreator<void>}
  369. * @return {import('postcss').Plugin}
  370. */
  371. function pluginCreator() {
  372. return {
  373. postcssPlugin: 'postcss-merge-rules',
  374. prepare(result) {
  375. /** @type {typeof result.opts & browserslist.Options} */
  376. const resultOpts = result.opts || {};
  377. const browsers = browserslist(null, {
  378. stats: resultOpts.stats,
  379. path: __dirname,
  380. env: resultOpts.env,
  381. });
  382. const compatibilityCache = new Map();
  383. return {
  384. OnceExit(css) {
  385. css.walkRules(selectorMerger(browsers, compatibilityCache));
  386. },
  387. };
  388. },
  389. };
  390. }
  391. pluginCreator.postcss = true;
  392. module.exports = pluginCreator;