├── CHANGELOG.md ├── .gitignore ├── .travis.yml ├── .npmignore ├── .editorconfig ├── eslint.config.mjs ├── LICENSE ├── .eslintrc ├── package.json ├── index.js ├── README.md └── test.mjs /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | npm-debug.log 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "6" 4 | - "5" 5 | - "4" 6 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .gitignore 2 | .editorconfig 3 | 4 | node_modules/ 5 | npm-debug.log 6 | 7 | test.js 8 | .travis.yml 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.{json,yml}] 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { FlatCompat } from "@eslint/eslintrc"; 2 | import globals from "globals"; // For specifying global variables like 'test' from ava 3 | 4 | const compat = new FlatCompat(); 5 | 6 | export default [ 7 | // Configuration for .js files (e.g., index.js) using eslint-config-postcss 8 | { 9 | files: ["**/*.js"], 10 | ...compat.extends("eslint-config-postcss").reduce((acc, config) => ({...acc, ...config}), {}) // compat.extends returns an array 11 | }, 12 | // Configuration for .mjs files (e.g., test.mjs) 13 | { 14 | files: ["**/*.mjs"], 15 | languageOptions: { 16 | ecmaVersion: "latest", 17 | sourceType: "module", 18 | globals: { 19 | ...globals.node, // or globals.browser if appropriate, ava tests run in node 20 | test: "readonly" // ava's test function 21 | } 22 | }, 23 | // Add any specific rules for .mjs files here if needed 24 | // If no specific rules, ESLint's recommended rules might apply by default if not overridden 25 | } 26 | ]; 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright 2016 George Adamson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | // "extends": "airbnb", 4 | "env": { 5 | "browser": true, 6 | "node": true, 7 | "mocha": true 8 | }, 9 | "rules": { 10 | "import/default": 0, 11 | "import/no-duplicates": 0, 12 | "import/named": 0, 13 | "import/namespace": 0, 14 | "import/no-unresolved": 0, 15 | "import/no-named-as-default": 0, 16 | "comma-dangle": 0, 17 | "no-console": 0, 18 | "no-alert": 0, 19 | 20 | "valid-jsdoc": 2, 21 | "space-before-function-paren": [2, "always"], 22 | "indent": [2, 2, {"SwitchCase": 1}], 23 | 24 | // Allow 1-char variables such as _ and $: 25 | "id-length": [2, { min: 1 }], 26 | 27 | // Reduce these to warnings for our sanity during dev: 28 | "prefer-const": 1, 29 | "spaced-comment": 1, 30 | "no-unused-vars": 1, 31 | "vars-on-top" : 1, 32 | "key-spacing": [1, { "beforeColon": true, "afterColon": true, "mode": "minimum" }], 33 | 34 | // New rules need on upgrade 35 | "quote-props": [2, "as-needed"], 36 | "prefer-template": 1, 37 | "max-len": 0, 38 | "space-in-parens": 1, 39 | "computed-property-spacing": 1, 40 | 41 | // Rules for compatability with eslint-config-airbnb 9.X 42 | "no-underscore-dangle": 0, 43 | "newline-per-chained-call": 0 44 | }, 45 | "plugins": [ 46 | ], 47 | "settings": { 48 | "import/parser": "babel-eslint", 49 | "import/resolve": { 50 | "moduleDirectory": ["node_modules", "src"] 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "postcss-merge-selectors", 3 | "version": "0.0.6", 4 | "description": "PostCSS plugin to combine selectors that have duplicate rules. Can be configured to skip or only apply to specific rules.", 5 | "keywords": [ 6 | "postcss", 7 | "css", 8 | "postcss-plugin", 9 | "dedupe", 10 | "merge", 11 | "selector", 12 | "rules" 13 | ], 14 | "author": "George Adamson `; 26 | } else { 27 | return ''; 28 | } 29 | } 30 | 31 | function serialiseDeclarations (rule) { 32 | var nodes = rule.nodes ? rule.nodes.filter(byDecl).sort().map(String) : []; 33 | return nodes.join(';').replace(/\s+/g,''); 34 | } 35 | 36 | // Usage: array.filter(unique) 37 | function unique (value, index, self) { 38 | return self.indexOf(value) === index; 39 | } 40 | 41 | function removeRule (rule) { 42 | // Remove the entire parent rule (recursively) if this was the last child left 43 | if (rule.parent.nodes.length === 1 && rule.parent.type !== 'root') { 44 | removeRule(rule.parent); 45 | // Otherwise just remove this rule 46 | } else { 47 | rule.remove(); 48 | } 49 | } 50 | 51 | function selectorMerger (matcherOpts, { list }) { // Added { list } here 52 | const cache = {}; 53 | 54 | return function analyseRule (ruleB) { 55 | 56 | const decl = serializeScope(ruleB) + serialiseDeclarations(ruleB); 57 | 58 | if (cache[decl]) { 59 | 60 | const ruleA = cache[decl]; 61 | const selectorA = list.comma(ruleA.selector); // Renamed 'a' to 'selectorA' for clarity 62 | const selectorB = list.comma(ruleB.selector); // Renamed 'b' to 'selectorB' for clarity 63 | const mergedSelector = selectorA.concat(selectorB).filter(unique).join(', '); 64 | 65 | // Prepend selector to the most recent rule if desired: 66 | if (matcherOpts.promote){ 67 | ruleB.selector = mergedSelector; 68 | removeRule(ruleA); 69 | cache[decl] = ruleB; 70 | // Otherwise append selector to the rule we found first: 71 | } else { 72 | ruleA.selector = mergedSelector; 73 | removeRule(ruleB); 74 | } 75 | 76 | } else { 77 | 78 | cache[decl] = ruleB; 79 | 80 | } 81 | 82 | return; 83 | 84 | }; 85 | 86 | } 87 | 88 | const plugin = (opts = {}) => { 89 | opts = Object.assign({}, DEFAULT_OPTIONS, opts); 90 | const matchers = Object.keys(opts.matchers || DEFAULT_OPTIONS.matchers); 91 | if (!matchers.length) { 92 | throw new Error('postcss-merge-selectors: opts.matchers was specified but appears to be empty.'); 93 | // No return needed after throw 94 | } 95 | 96 | matchers.forEach(name => { 97 | opts.matchers[name] = Object.assign( 98 | { 99 | name, 100 | debug: opts.debug, 101 | selectorFilter: DEFAULT_MATCHER.selectorFilter 102 | }, 103 | DEFAULT_MATCHER, 104 | opts.matchers[name] 105 | ); 106 | }); 107 | 108 | return { 109 | postcssPlugin: 'postcss-merge-selectors', 110 | Once(root, { list }) { // Pass list here 111 | matchers.forEach(name => { 112 | const matcher = opts.matchers[name]; 113 | // Pass list to selectorMerger 114 | root.walkRules(matcher.selectorFilter, selectorMerger(matcher, { list })); 115 | }); 116 | } 117 | }; 118 | }; 119 | plugin.postcss = true; 120 | module.exports = plugin; 121 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PostCSS Merge Selectors [![Build Status][ci-img]][ci] [![NPM Version][npm-img]][npm] 2 | 3 | [PostCSS] plugin to combine selectors that have identical rules. Can be configured to only merge rules who's selectors match specific filters. 4 | 5 | [PostCSS]: https://github.com/postcss/postcss 6 | [ci-img]: https://travis-ci.org/georgeadamson/postcss-merge-selectors.svg 7 | [ci]: https://travis-ci.org/georgeadamson/postcss-merge-selectors 8 | [npm-img]: https://badge.fury.io/js/postcss-merge-selectors.svg 9 | [npm]: https://www.npmjs.com/package/postcss-merge-selectors 10 | [dependencies-img]: https://david-dm.org/georgeadamson/postcss-merge-selectors.svg 11 | [dependencies]: https://david-dm.org/georgeadamson/postcss-merge-selectors 12 | 13 | 14 | Before: 15 | ```css 16 | .foo { top: 0; } 17 | .baz { left: 10px; } 18 | .bar { top: 0; } 19 | ``` 20 | 21 | After: 22 | ```css 23 | .foo, .bar { top: 0; } 24 | .baz { left: 10px; } 25 | ``` 26 | 27 | ## There be dragons :( 28 | 29 | This plugin isn't smart. It hasn't got a chuffing clue what your css is trying to achieve. Combining selectors might satisfy your urge to be tidy, but the warm fluffy feeling will subside pretty quickly when your new bijou css causes styles to be applied differently. In order to merge two selectors we have to move one of them. That means they may now override rules that used to be after them, or they may be overridden by rules that used to be before them. I recommend you use the `selectorFilter` option to only target specific selectors and the `promote` option if you need to move the resulting selectors further down the stylesheet. Test the resulting css carefully. 30 | 31 | ## Install 32 | 33 | ```shell 34 | npm install postcss-merge-selectors --save-dev 35 | ``` 36 | 37 | ## Usage 38 | 39 | ```js 40 | var postcssMerge = require('postcss-merge-selectors'); 41 | postcss([ postcssMerge(opts) ]); 42 | ``` 43 | 44 | ## Options 45 | 46 | You supply a map of one or more "matchers". The key for each can be any name that'll help jog your memory when you look at your code again after returning from your holiday. 47 | 48 | All the selectors found by a matcher will be grouped by their css rules where they're the same. 49 | 50 | If you're into SQL then it's a bit like this (pseudocode) `SELECT CONCAT(selector), styles FROM stylesheet WHERE selector LIKE '%foobar%' GROUP BY styles` (I dunno if that helps but it seemed like a good idea at the time.) 51 | 52 | Options for each matcher: 53 | - `selectorFilter` (String|RegExp) to find several selectors as candidates for merge. Those with identical style declarations will be merged. 54 | - `promote` (Boolean) to place merged selectors where the last rule matching selectorFilter was found in the css. false (default) will place them all where the first match was found. 55 | 56 | Example: 57 | ```js 58 | var opts = { 59 | matchers : { 60 | mergeAllMyFoobars : { 61 | selectorFilter : /.\foobar/, 62 | promote : true 63 | }, 64 | someOtherMerge : { 65 | ... 66 | } 67 | } 68 | } 69 | ``` 70 | 71 | And what is this weird *promote:true* flag about? 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 90 | 98 | 106 | 107 | 108 |
BeforeAfter (default)After (with promote:true)
82 |
83 |
 84 |   .foo { top: 0; }
 85 |   .baz { top: 10px; }
 86 |   .bar { top: 0; }
 87 | 
88 |
89 |
91 |
92 |
 93 |   .foo, .bar { top: 0; }
 94 |   .baz { top: 10px; }
 95 | 
96 |
97 |
99 |
100 |
101 |   .baz { top: 10px; }
102 |   .foo, .bar { top: 0; }
103 | 
104 |
105 |
109 | 110 | See [PostCSS] docs for examples for your environment. 111 | -------------------------------------------------------------------------------- /test.mjs: -------------------------------------------------------------------------------- 1 | import postcss from 'postcss'; 2 | import test from 'ava'; 3 | import plugin from './index.js'; 4 | 5 | function run (t, input, expected, opts = { }) { 6 | return postcss([ plugin(opts) ]).process(input, { from: undefined }) 7 | .then(result => { 8 | t.deepEqual(result.css, expected); 9 | t.deepEqual(result.warnings().length, 0); 10 | }); 11 | } 12 | 13 | 14 | test('Should have no effect when there are no identical styles', t => { 15 | return run(t, 16 | '.foo { width:0; } .bar { height:0; }', 17 | '.foo { width:0; } .bar { height:0; }', 18 | {} 19 | ); 20 | }); 21 | 22 | 23 | test('Should merge adjacent selectors that have identical styles', t => { 24 | return run(t, 25 | '.foo { width:0; height:0 } .bar { height:0; width:0 }', 26 | '.foo, .bar { width:0; height:0 }', 27 | { } 28 | ); 29 | }); 30 | 31 | 32 | test('Should not merge selectors that have different styles', t => { 33 | return run(t, 34 | '.foo { width:0; height:0 } A.foobar { top: 0 } .bar { height:0; width:0 }', 35 | '.foo, .bar { width:0; height:0 } A.foobar { top: 0 }', 36 | {} 37 | ); 38 | }); 39 | 40 | 41 | test('Should merge selectors and dedupe those with same name', t => { 42 | return run(t, 43 | '.foo { top:0 } .bar { top: 0 } .foo { top:0 } .baz { left: 10px }', 44 | '.foo, .bar { top:0 } .baz { left: 10px }', 45 | {} 46 | ); 47 | }); 48 | 49 | 50 | test('Should merge selectors that match config matcher', t => { 51 | return run(t, 52 | '.foo1 { top:0 } .fooDummy { top:0 } .foo2 { top:0 } .fooDummy { top:0 }', 53 | '.foo1, .foo2 { top:0 } .fooDummy { top:0 } .fooDummy { top:0 }', 54 | { matchers : { 55 | whatever : { selectorFilter : /\.foo\d+/ } 56 | }} 57 | ); 58 | }); 59 | 60 | 61 | test('Should not merge selectors that don\'t match config matcher', t => { 62 | return run(t, 63 | '.foo { top:0 } .bar { top:0 } .baz { top:0 }', 64 | '.foo { top:0 } .bar, .baz { top:0 }', 65 | { matchers : { 66 | whatever : { selectorFilter : /bar|baz/ } 67 | }} 68 | ); 69 | }); 70 | 71 | 72 | test('Should merge selectors that are more complex than the over-simplified examples above', t => { 73 | return run(t, 74 | 'div#foo-bar, .foo:before { margin:1rem; padding-top:1vw } [whatever] { top:10px } [data-foo="test"].bar { padding-top:1vw; margin:1rem }', 75 | 'div#foo-bar, .foo:before, [data-foo="test"].bar { margin:1rem; padding-top:1vw } [whatever] { top:10px }', 76 | { matchers : { 77 | whatever : { selectorFilter : /foo/ } 78 | }} 79 | ); 80 | }); 81 | 82 | 83 | test('Should merge selectors at the position of the last occurrence (when "promote" flag set)', t => { 84 | return run(t, 85 | '.foo1 { top:0 } .bar { top:0 } .foo2 { top:0 } .bar { top:0 }', 86 | '.bar { top:0 } .foo1, .foo2 { top:0 } .bar { top:0 }', 87 | { matchers : { 88 | whatever : { selectorFilter : /\.foo/, promote : true } 89 | }} 90 | ); 91 | }); 92 | 93 | 94 | test('Should merge each matcher group separately', t => { 95 | return run(t, 96 | '.foo1 { top:0 } .bar1 { top:0 } .foo2 { top:0 } .bar2 { top:0 }', 97 | '.bar1, .bar2 { top:0 } .foo1, .foo2 { top:0 }', 98 | { matchers : { 99 | whatever1 : { selectorFilter : /\.foo/, promote : true }, 100 | whatever2 : { selectorFilter : /\.bar/, promote : false } 101 | }} 102 | ); 103 | }); 104 | 105 | 106 | test('Should merge identical selectors without being affected by comment nodes', t => { 107 | return run(t, 108 | '.foo1 { /* comment1 */ top:0 } /* comment2 */ .bar1 { top:0 } .foo2 { top:0 /* comment3 */ } .bar2 { left:0 /* comment4 */ }', 109 | '.foo1, .bar1, .foo2 { /* comment1 */ top:0 } /* comment2 */ .bar2 { left:0 /* comment4 */ }', 110 | ); 111 | }); 112 | 113 | 114 | test('Should not merge selectors inside at-rules with those outside', t => { 115 | return run(t, 116 | '.visible { opacity: 1; } @keyframes flash { 50%, from, to { opacity: 1; } 25%, 75% { opacity: 0; } }', 117 | '.visible { opacity: 1; } @keyframes flash { 50%, from, to { opacity: 1; } 25%, 75% { opacity: 0; } }', 118 | ); 119 | }); 120 | 121 | 122 | test('Should merge identical selectors inside the same at-rule', t => { 123 | return run(t, 124 | '@keyframes flash { 50% { opacity: 1; } 25%, 75% { opacity: 0; } from, to { opacity: 1; } }', 125 | '@keyframes flash { 50%, from, to { opacity: 1; } 25%, 75% { opacity: 0; } }' 126 | ); 127 | }); 128 | 129 | 130 | test('Should merge identical selectors inside identical at-rules', t => { 131 | return run(t, 132 | '@media screen and (min-width: 480px) { .a { background-color: black; } } @media screen and (min-width: 480px) { .b { background-color: black; } .c { background-color: white; } }', 133 | '@media screen and (min-width: 480px) { .a, .b { background-color: black; } } @media screen and (min-width: 480px) { .c { background-color: white; } }' 134 | ); 135 | }); 136 | 137 | 138 | test('Should not merge selectors inside different at-rules', t => { 139 | return run(t, 140 | '@media screen and (min-width: 480px) { .a { background-color: black; } } @media screen and (min-width: 481px) { .b { background-color: black; } }', 141 | '@media screen and (min-width: 480px) { .a { background-color: black; } } @media screen and (min-width: 481px) { .b { background-color: black; } }' 142 | ); 143 | }); 144 | 145 | 146 | test('Should delete at-rules rendered empty by merger', t => { 147 | return run(t, 148 | '@media screen and (min-width: 480px) { .a { background-color: black; } } @media screen and (min-width: 480px) { .b { background-color: black; } }', 149 | '@media screen and (min-width: 480px) { .a, .b { background-color: black; } }' 150 | ); 151 | }); 152 | 153 | 154 | test('Does not affect at-rules which never had children', t => { 155 | return run(t, 156 | '@import "other.css"', 157 | '@import "other.css"' 158 | ); 159 | }); 160 | --------------------------------------------------------------------------------