├── .editorconfig ├── .eslintignore ├── .gitattributes ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── src └── index.js ├── test ├── _test-factory.js ├── duplicated-css.js ├── duplicated-extension.js ├── duplicated-properties.js ├── unique-css.js └── unique-extension.js └── types ├── index.d.ts ├── index.test-d.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | [*] 8 | 9 | # Change these settings to your own preference 10 | indent_style = space 11 | indent_size = 2 12 | 13 | # We recommend you to keep these unchanged 14 | end_of_line = lf 15 | charset = utf-8 16 | trim_trailing_whitespace = true 17 | insert_final_newline = true 18 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | _test-factory.js 4 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * eol=lf 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | branches: 8 | - main 9 | jobs: 10 | build-and-test: 11 | name: '${{ matrix.platform }}: node.js ${{ matrix.node-version }}' 12 | timeout-minutes: 2 13 | strategy: 14 | matrix: 15 | platform: 16 | - ubuntu-latest 17 | - windows-latest 18 | - macos-latest 19 | node-version: 20 | - 18 21 | - 20 22 | - 22 23 | runs-on: ${{ matrix.platform }} 24 | steps: 25 | - uses: actions/checkout@v4 26 | - uses: actions/setup-node@v4 27 | with: 28 | node-version: ${{ matrix.node-version }} 29 | - run: npm ci 30 | - run: npm test 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Node ### 2 | # Logs 3 | logs 4 | *.log 5 | npm-debug.log* 6 | 7 | # Runtime data 8 | pids 9 | *.pid 10 | *.seed 11 | 12 | # Directory for instrumented libs generated by jscoverage/JSCover 13 | lib-cov 14 | 15 | # Coverage directory used by tools like istanbul 16 | coverage 17 | 18 | # nyc test coverage 19 | .nyc_output 20 | 21 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 22 | .grunt 23 | 24 | # node-waf configuration 25 | .lock-wscript 26 | 27 | # Compiled binary addons (http://nodejs.org/api/addons.html) 28 | build/Release 29 | 30 | # Dependency directories 31 | node_modules 32 | jspm_packages 33 | 34 | # Optional npm cache directory 35 | .npm 36 | 37 | # Optional REPL history 38 | .node_repl_history 39 | 40 | # VSCode config 41 | .vscode/ -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no-install commitlint --edit $1 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npm test 5 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guide 2 | 3 | ## Requesting a feature 4 | 5 | 1. [Open an issue on GitHub](https://github.com/ChristianMurphy/postcss-combine-duplicated-selectors/issues/new) 6 | 2. Include a description of the desired feature 7 | 3. Include an example input and output 8 | 4. Consider opening a [Pull Request](https://github.com/Roshanjossey/first-contributions#readme) 9 | 10 | ## Reporting an Issue 11 | 12 | 1. [Open an issue on GitHub](https://github.com/ChristianMurphy/postcss-combine-duplicated-selectors/issues/new) 13 | 2. Include any logs or stacktraces in a [Markdown fenced block](https://help.github.com/articles/creating-and-highlighting-code-blocks/) 14 | 3. Consider opening a [Pull Request](https://github.com/Roshanjossey/first-contributions#readme) resolving the issue. 15 | 16 | ## Contributing Code 17 | 18 | 1. First time opening a Pull Request? [Checkout this Pull Request guide!](https://github.com/Roshanjossey/first-contributions#readme) 19 | 2. Create a fork of the repository 20 | 3. Clone the fork 21 | 4. Install npm dependencies `npm install` 22 | 5. Make changes 23 | 6. Ensure changes pass tests `npm test` 24 | 7. Commit changes `npm run-script commit` 25 | 8. Push changes to GitHub 26 | 9. Open a Pull Request 27 | 28 | ## Additional Resources 29 | 30 | * [PostCSS API](http://api.postcss.org) 31 | * [PostCSS Selector Parser API](https://github.com/postcss/postcss-selector-parser/blob/master/API.md) 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Christian Murphy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, 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, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Postcss combine duplicated selectors 2 | 3 | 4 | 5 | [![npm](https://img.shields.io/npm/v/postcss-combine-duplicated-selectors.svg)](https://www.npmjs.com/package/postcss-combine-duplicated-selectors) 6 | [![build status](https://github.com/ChristianMurphy/postcss-combine-duplicated-selectors/workflows/CI/badge.svg)](https://github.com/ChristianMurphy/postcss-combine-duplicated-selectors/actions) 7 | [![dependency status](https://david-dm.org/ChristianMurphy/postcss-combine-duplicated-selectors.svg)](https://david-dm.org/ChristianMurphy/postcss-combine-duplicated-selectors) 8 | [![devDependency status](https://david-dm.org/ChristianMurphy/postcss-combine-duplicated-selectors/dev-status.svg)](https://david-dm.org/ChristianMurphy/postcss-combine-duplicated-selectors?type=dev) 9 | 10 | Automatically detects and combines duplicated css selectors so you don't have to 11 | :smile: 12 | 13 | ## Usage 14 | 15 | ### Requirements 16 | 17 | In order to use this you will need to have [postcss](https://github.com/postcss/postcss) installed. Depending on whether or not you want to use the CLI you need to install [postcss-cli](https://github.com/postcss/postcss-cli). 18 | 19 | ```bash 20 | npm install --save-dev postcss postcss-combine-duplicated-selectors 21 | # or 22 | yarn add --dev postcss postcss-combine-duplicated-selectors 23 | ``` 24 | 25 | ### Using PostCSS JS API 26 | 27 | ```js 28 | 'use strict'; 29 | 30 | const fs = require('fs'); 31 | const postcss = require('postcss'); 32 | const css = fs.readFileSync('src/app.css'); 33 | 34 | postcss([require('postcss-combine-duplicated-selectors')]) 35 | .process(css, {from: 'src/app.css', to: 'app.css'}) 36 | .then((result) => { 37 | fs.writeFileSync('app.css', result.css); 38 | if (result.map) fs.writeFileSync('app.css.map', result.map); 39 | }); 40 | ``` 41 | 42 | ### Using PostCSS CLI 43 | 44 | ```sh 45 | postcss style.css --use postcss-combine-duplicated-selectors --output newcss.css 46 | ``` 47 | 48 | ### Using Vite 49 | 50 | In a `postcss.config.js` file : 51 | 52 | ```js 53 | module.exports = { 54 | plugins: [require('postcss-combine-duplicated-selectors')], 55 | }; 56 | ``` 57 | 58 | ## Example 59 | 60 | Input 61 | 62 | ```css 63 | .module { 64 | color: green; 65 | } 66 | .another-module { 67 | color: blue; 68 | } 69 | .module { 70 | background: red; 71 | } 72 | .another-module { 73 | background: yellow; 74 | } 75 | ``` 76 | 77 | Output 78 | 79 | ```css 80 | .module { 81 | color: green; 82 | background: red; 83 | } 84 | .another-module { 85 | color: blue; 86 | background: yellow; 87 | } 88 | ``` 89 | 90 | ### Duplicated Properties 91 | 92 | Duplicated properties can optionally be combined. 93 | 94 | Set the `removeDuplicatedProperties` option to `true` to enable. 95 | 96 | ```js 97 | const postcss = require('postcss'); 98 | const combineSelectors = require('postcss-combine-duplicated-selectors'); 99 | 100 | postcss([combineSelectors({removeDuplicatedProperties: true})]); 101 | ``` 102 | 103 | When enabled the following css 104 | 105 | ```css 106 | .a { 107 | height: 10px; 108 | background: orange; 109 | background: rgba(255, 165, 0, 0.5); 110 | } 111 | ``` 112 | 113 | will combine into 114 | 115 | ```css 116 | .a { 117 | height: 10px; 118 | background: rgba(255, 165, 0, 0.5); 119 | } 120 | ``` 121 | 122 | In order to limit this to only combining properties when the values are equal, set the `removeDuplicatedValues` option to `true` instead. This could clean up duplicated properties, but allow for conscious duplicates such as fallbacks for custom properties. 123 | 124 | ```js 125 | const postcss = require('postcss'); 126 | const combineSelectors = require('postcss-combine-duplicated-selectors'); 127 | 128 | postcss([combineSelectors({removeDuplicatedValues: true})]); 129 | ``` 130 | 131 | This will transform the following css 132 | 133 | ```css 134 | .a { 135 | height: 10px; 136 | } 137 | 138 | .a { 139 | width: 20px; 140 | background: var(--custom-color); 141 | background: rgba(255, 165, 0, 0.5); 142 | } 143 | ``` 144 | 145 | into 146 | 147 | ```css 148 | .a { 149 | height: 10px; 150 | width: 20px; 151 | background: var(--custom-color); 152 | background: rgba(255, 165, 0, 0.5); 153 | } 154 | ``` 155 | 156 | ### Media Queries 157 | 158 | If you have code with media queries, pass code through [_postcss-combine-media-query_](https://github.com/SassNinja/postcss-combine-media-query) or [_css-mquery-packer_](https://github.com/n19htz/css-mquery-packer) before _postcss-combine-duplicated-selectors_ to ensure optimal results. 159 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "postcss-combine-duplicated-selectors", 3 | "version": "10.0.3", 4 | "description": "automatically keep css selectors unique", 5 | "main": "src/index.js", 6 | "types": "types/index.d.ts", 7 | "files": [ 8 | "src", 9 | "types/index.d.ts" 10 | ], 11 | "scripts": { 12 | "commit": "commit", 13 | "format": "eslint --fix --ext md,js .", 14 | "test": "run-s test:*", 15 | "test:unit": "node --test", 16 | "test:lint-js": "eslint --ext md,js .", 17 | "test:lint-md": "remark *.md -q --no-stdout", 18 | "test:types": "tsd", 19 | "commitlint": "commitlint --from HEAD~1", 20 | "prepare": "husky install" 21 | }, 22 | "keywords": [ 23 | "postcss-plugin", 24 | "selector" 25 | ], 26 | "author": { 27 | "name": "Christian Murphy", 28 | "email": "christian.murphy.42@gmail.com", 29 | "url": "https://github.com/ChristianMurphy" 30 | }, 31 | "repository": { 32 | "type": "git", 33 | "url": "git+https://github.com/ChristianMurphy/postcss-combine-duplicated-selectors.git" 34 | }, 35 | "homepage": "https://github.com/ChristianMurphy/postcss-combine-duplicated-selectors", 36 | "bugs": { 37 | "url": "https://github.com/ChristianMurphy/postcss-combine-duplicated-selectors/issues" 38 | }, 39 | "license": "MIT", 40 | "peerDependencies": { 41 | "postcss": "^8.1.0" 42 | }, 43 | "dependencies": { 44 | "postcss-selector-parser": "^7.0.0" 45 | }, 46 | "devDependencies": { 47 | "@commitlint/cli": "17.8.1", 48 | "@commitlint/config-conventional": "17.8.1", 49 | "@commitlint/prompt-cli": "17.8.1", 50 | "eslint": "8.57.1", 51 | "eslint-config-google": "0.14.0", 52 | "eslint-plugin-markdown": "3.0.1", 53 | "husky": "8.0.3", 54 | "npm-run-all2": "7.0.2", 55 | "postcss": "8.5.4", 56 | "postcss-less": "6.0.0", 57 | "postcss-nested": "7.0.2", 58 | "postcss-scss": "4.0.9", 59 | "remark-cli": "12.0.1", 60 | "remark-preset-lint-consistent": "6.0.1", 61 | "remark-preset-lint-recommended": "7.0.1", 62 | "remark-validate-links": "13.1.0", 63 | "tsd": "^0.32.0", 64 | "typescript": "5.8.3" 65 | }, 66 | "engines": { 67 | "node": "^18.0.0 || ^20.0.0 || >=22.0.0" 68 | }, 69 | "eslintConfig": { 70 | "root": true, 71 | "parserOptions": { 72 | "ecmaVersion": "2021" 73 | }, 74 | "env": { 75 | "es6": true, 76 | "node": true 77 | }, 78 | "extends": [ 79 | "eslint:recommended", 80 | "google", 81 | "plugin:markdown/recommended" 82 | ], 83 | "rules": { 84 | "prefer-arrow-callback": "error", 85 | "prefer-const": "error", 86 | "prefer-template": "error" 87 | } 88 | }, 89 | "remarkConfig": { 90 | "plugins": [ 91 | "preset-lint-recommended", 92 | "preset-lint-consistent", 93 | "validate-links" 94 | ] 95 | }, 96 | "commitlint": { 97 | "extends": [ 98 | "@commitlint/config-conventional" 99 | ] 100 | }, 101 | "renovate": { 102 | "extends": [ 103 | "config:base" 104 | ], 105 | "automerge": true, 106 | "major": { 107 | "automerge": false 108 | }, 109 | "lockFileMaintenance": { 110 | "enabled": true 111 | }, 112 | "semanticPrefix": "chore:", 113 | "semanticCommitScope": "" 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const parser = require('postcss-selector-parser'); 2 | const {name} = require('../package.json'); 3 | 4 | /** 5 | * Ensure that attributes with different quotes match. 6 | * @param {Object} selector - postcss selector node 7 | */ 8 | function normalizeAttributes(selector) { 9 | selector.walkAttributes((node) => { 10 | if (node.value) { 11 | // remove quotes 12 | node.value = node.value.replace(/'|\\'|"|\\"/g, ''); 13 | } 14 | }); 15 | } 16 | 17 | /** 18 | * Sort class and id groups alphabetically 19 | * @param {Object} selector - postcss selector node 20 | */ 21 | function sortGroups(selector) { 22 | selector.each((subSelector) => { 23 | subSelector.nodes.sort((a, b) => { 24 | // different types cannot be sorted 25 | if (a.type !== b.type) { 26 | return 0; 27 | } 28 | 29 | // sort alphabetically 30 | return a.value < b.value ? -1 : 1; 31 | }); 32 | }); 33 | 34 | selector.sort((a, b) => (a.nodes.join('') < b.nodes.join('') ? -1 : 1)); 35 | } 36 | 37 | /** 38 | * Remove duplicated properties 39 | * @param {Object} selector - postcss selector node 40 | * @param {Boolean} exact 41 | */ 42 | function removeDupProperties(selector, exact) { 43 | // Remove duplicated properties from bottom to top () 44 | for (let actIndex = selector.nodes.length - 1; actIndex >= 1; actIndex--) { 45 | for (let befIndex = actIndex - 1; befIndex >= 0; befIndex--) { 46 | if (selector.nodes[actIndex].prop === selector.nodes[befIndex].prop) { 47 | if ( 48 | !exact || 49 | (exact && 50 | selector.nodes[actIndex].value === selector.nodes[befIndex].value) 51 | ) { 52 | selector.nodes[befIndex].remove(); 53 | actIndex--; 54 | } 55 | } 56 | } 57 | } 58 | } 59 | 60 | const uniformStyle = parser((selector) => { 61 | normalizeAttributes(selector); 62 | sortGroups(selector); 63 | }); 64 | 65 | const defaultOptions = { 66 | removeDuplicatedProperties: false, 67 | }; 68 | 69 | module.exports = (options) => { 70 | options = Object.assign({}, defaultOptions, options); 71 | return { 72 | postcssPlugin: name, 73 | prepare() { 74 | // Create a map to store maps 75 | const mapTable = new Map(); 76 | // root map to store root selectors 77 | mapTable.set('root', new Map()); 78 | 79 | return { 80 | Rule: (rule) => { 81 | let map; 82 | // Check selector parent for any at rule 83 | if (rule.parent.type === 'atrule') { 84 | // Use name and query params as the key 85 | const query = 86 | rule.parent.name.toLowerCase() + 87 | rule.parent.params.replace(/\s+/g, ''); 88 | 89 | // See if this query key is already in the map table 90 | map = mapTable.has(query) ? // If it is use it 91 | mapTable.get(query) : // if not set it and get it 92 | mapTable.set(query, new Map()).get(query); 93 | } else { 94 | // Otherwise we are dealing with a selector in the root 95 | map = mapTable.get('root'); 96 | } 97 | 98 | // create a uniform selector 99 | const selector = uniformStyle.processSync(rule.selector, { 100 | lossless: false, 101 | }); 102 | 103 | if (map.has(selector)) { 104 | // store original rule as destination 105 | const destination = map.get(selector); 106 | 107 | // check if node has already been processed 108 | if (destination === rule) return; 109 | 110 | // move declarations to original rule 111 | while (rule.nodes.length > 0) { 112 | destination.append(rule.nodes[0]); 113 | } 114 | 115 | // store the original rule parent before removal in case an atrule 116 | // becomes empty as a result of the removal 117 | const ruleParent = rule.parent; 118 | 119 | // remove duplicated rule 120 | rule.remove(); 121 | 122 | // on removal of the node, the parent atrule could have no 123 | // declarations associated. This is an issue for @keyframes that 124 | // interpret @keyframes {} as overwriting existing keyframe 125 | // transitions. 126 | if (ruleParent.type === 'atrule' && ruleParent.nodes.length === 0) { 127 | const ruleParentIndex = ruleParent.parent.index(ruleParent); 128 | ruleParent.parent.nodes[ruleParentIndex].remove(); 129 | } 130 | 131 | if ( 132 | options.removeDuplicatedProperties || 133 | options.removeDuplicatedValues 134 | ) { 135 | removeDupProperties( 136 | destination, 137 | options.removeDuplicatedValues, 138 | ); 139 | } 140 | } else { 141 | if ( 142 | options.removeDuplicatedProperties || 143 | options.removeDuplicatedValues 144 | ) { 145 | removeDupProperties(rule, options.removeDuplicatedValues); 146 | } 147 | // add new selector to symbol table 148 | map.set(selector, rule); 149 | } 150 | }, 151 | }; 152 | }, 153 | }; 154 | }; 155 | 156 | module.exports.postcss = true; 157 | -------------------------------------------------------------------------------- /test/_test-factory.js: -------------------------------------------------------------------------------- 1 | const postcss = require('postcss'); 2 | const assert = require('node:assert/strict'); 3 | 4 | /** 5 | * Generates test functions and test titles 6 | * 7 | * @param {string} version - string description of parser version 8 | * @param {Array} plugins - postcss plugins to use with tests 9 | * @param {Object} [syntax] - optional alternative syntax parser 10 | * @return {function} test function 11 | */ 12 | module.exports = function testFactory(version, plugins, syntax) { 13 | let tester; 14 | if (syntax) { 15 | tester = (_t, input, expected) => { 16 | const actual = postcss(plugins).process(input, { syntax }).css; 17 | assert.strictEqual(actual, expected); 18 | }; 19 | } else { 20 | tester = (_t, input, expected) => { 21 | const actual = postcss(plugins).process(input).css; 22 | assert.strictEqual(actual, expected); 23 | }; 24 | } 25 | // Setup test macro title generator 26 | tester.title = (providedTitle, input, expected) => 27 | providedTitle 28 | ? `${providedTitle} in ${version}` 29 | : `"${input}" becomes "${expected}" in ${version}`; 30 | return tester; 31 | }; 32 | -------------------------------------------------------------------------------- /test/duplicated-css.js: -------------------------------------------------------------------------------- 1 | const {describe, it} = require('node:test'); 2 | const testFactory = require('./_test-factory'); 3 | const plugin = require('../src'); 4 | 5 | /** 6 | * These tests check css selectors that the plugin CAN combine together. 7 | * Meaning selectors provided are logically the same. 8 | * These tests check only standard css syntax. 9 | */ 10 | 11 | /** 12 | * Take string literals are remove newlines and extra spacing so results print 13 | * as expected in logs 14 | * @return {string} string without newlines and tabs 15 | */ 16 | function minify([string]) { 17 | return string.replace(/\s+/gm, ' '); 18 | } 19 | 20 | const css = testFactory('css', [plugin]); 21 | 22 | const cases = [ 23 | {label: 'class', input: '.module {} .module {}', expected: '.module {}'}, 24 | {label: 'id', input: '#one {} #one {}', expected: '#one {}'}, 25 | {label: 'tag', input: 'a {} a {}', expected: 'a {}'}, 26 | {label: 'universal', input: '* {} * {}', expected: '* {}'}, 27 | { 28 | label: 'classes with " " combinator', 29 | input: '.one .two {} .one .two {}', 30 | expected: '.one .two {}', 31 | }, 32 | { 33 | label: 'classes with ">" combinator', 34 | input: '.one>.two {} .one > .two {}', 35 | expected: '.one>.two {}', 36 | }, 37 | { 38 | label: 'classes with "+" combinator', 39 | input: '.one+.two {} .one + .two {}', 40 | expected: '.one+.two {}', 41 | }, 42 | { 43 | label: 'classes with "~" combinator', 44 | input: '.one~.two {} .one ~ .two {}', 45 | expected: '.one~.two {}', 46 | }, 47 | { 48 | label: 'ids with " " combinator', 49 | input: '#one #two {} #one #two {}', 50 | expected: '#one #two {}', 51 | }, 52 | { 53 | label: 'ids with ">" combinator', 54 | input: '#one>#two {} #one > #two {}', 55 | expected: '#one>#two {}', 56 | }, 57 | { 58 | label: 'ids with "+" combinator', 59 | input: '#one+#two {} #one + #two {}', 60 | expected: '#one+#two {}', 61 | }, 62 | { 63 | label: 'ids with "~" combinator', 64 | input: '#one~#two {} #one ~ #two {}', 65 | expected: '#one~#two {}', 66 | }, 67 | { 68 | label: 'tags with " " combinator', 69 | input: 'a b {} a b {}', 70 | expected: 'a b {}', 71 | }, 72 | { 73 | label: 'tags with ">" combinator', 74 | input: 'a>b {} a > b {}', 75 | expected: 'a>b {}', 76 | }, 77 | { 78 | label: 'tags with "+" combinator', 79 | input: 'a+b {} a + b {}', 80 | expected: 'a+b {}', 81 | }, 82 | { 83 | label: 'tags with "~" combinator', 84 | input: 'a~b {} a ~ b {}', 85 | expected: 'a~b {}', 86 | }, 87 | { 88 | label: 'universals with " " combinator', 89 | input: '* * {} * * {}', 90 | expected: '* * {}', 91 | }, 92 | { 93 | label: 'universals with ">" combinator', 94 | input: '*>* {} * > * {}', 95 | expected: '*>* {}', 96 | }, 97 | { 98 | label: 'universals with "+" combinator', 99 | input: '*+* {} * + * {}', 100 | expected: '*+* {}', 101 | }, 102 | { 103 | label: 'universals with "~" combinator', 104 | input: '*~* {} * ~ * {}', 105 | expected: '*~* {}', 106 | }, 107 | { 108 | label: 'class with declarations', 109 | input: '.module {color: green} .module {background: red}', 110 | expected: '.module {color: green;background: red}', 111 | }, 112 | { 113 | label: 'id with declarations', 114 | input: '#one {color: green} #one {background: red}', 115 | expected: '#one {color: green;background: red}', 116 | }, 117 | { 118 | label: 'tag with declarations', 119 | input: 'a {color: green} a {background: red}', 120 | expected: 'a {color: green;background: red}', 121 | }, 122 | { 123 | label: 'universal with declarations', 124 | input: '* {color: green} * {background: red}', 125 | expected: '* {color: green;background: red}', 126 | }, 127 | { 128 | label: 'classes with different spacing and declarations', 129 | input: '.one .two {color: green} .one .two {background: red}', 130 | expected: '.one .two {color: green;background: red}', 131 | }, 132 | { 133 | label: 'ids with different spacing and declarations', 134 | input: '#one #two {color: green} #one #two {background: red}', 135 | expected: '#one #two {color: green;background: red}', 136 | }, 137 | { 138 | label: 'tags with different spacing and declarations', 139 | input: 'a b {color: green} a b {background: red}', 140 | expected: 'a b {color: green;background: red}', 141 | }, 142 | { 143 | label: 'universals with different spacing and declarations', 144 | input: '* * {color: green} * * {background: red}', 145 | expected: '* * {color: green;background: red}', 146 | }, 147 | { 148 | label: 'selectors with multiple properties', 149 | // eslint-disable-next-line max-len 150 | input: '.a {color: black; height: 10px} .a {background-color: red; width: 20px}', 151 | // eslint-disable-next-line max-len 152 | expected: '.a {color: black; height: 10px;background-color: red; width: 20px}', 153 | }, 154 | { 155 | label: 'attribute selectors', 156 | input: '.a[href] {} .a[href] {}', 157 | expected: '.a[href] {}', 158 | }, 159 | { 160 | label: 'attribute property selectors with different spacing', 161 | input: '.a[href="a"] {} .a[href = "a"] {}', 162 | expected: '.a[href="a"] {}', 163 | }, 164 | { 165 | label: 'attribute property selectors with different quoting', 166 | input: '.a[href="a"] {} .a[href=a] {}', 167 | expected: '.a[href="a"] {}', 168 | }, 169 | { 170 | label: 'attribute property selectors with different quote marks', 171 | input: '.a[href="a"] {} .a[href=\'a\'] {}', 172 | expected: '.a[href="a"] {}', 173 | }, 174 | { 175 | label: 'attribute selectors with different spacing', 176 | input: '.a[href] {} .a[ href ] {}', 177 | expected: '.a[href] {}', 178 | }, 179 | { 180 | label: 'pseudo classes', 181 | input: 'a:link {} a:link {}', 182 | expected: 'a:link {}', 183 | }, 184 | { 185 | label: 'pseudo elements', 186 | input: 'p::first-line {} p::first-line {}', 187 | expected: 'p::first-line {}', 188 | }, 189 | { 190 | label: 'selectors with different order', 191 | input: '.one.two {} .two.one {}', 192 | expected: '.one.two {}', 193 | }, 194 | { 195 | label: 'selector groups', 196 | input: '.one .two, .one .three {} .one .two, .one .three {}', 197 | expected: '.one .two, .one .three {}', 198 | }, 199 | { 200 | label: 'selector groups with different order', 201 | input: '.one .two, .one .three {} .one .three, .one .two {}', 202 | expected: '.one .two, .one .three {}', 203 | }, 204 | { 205 | label: 'selectors and separately selectors within media query', 206 | input: '.one{} .one{} @media print { .one{} .one{} }', 207 | expected: '.one{} @media print { .one{} }', 208 | }, 209 | { 210 | label: 'multiple print media queries', 211 | input: minify` 212 | @media print { 213 | a { 214 | color: blue; 215 | } 216 | } 217 | @media print { 218 | a { 219 | background: green; 220 | } 221 | } 222 | `, 223 | expected: minify` 224 | @media print { 225 | a { 226 | color: blue; 227 | background: green; 228 | } 229 | } 230 | `, 231 | }, 232 | { 233 | label: 'keyframe selectors with same percentage', 234 | input: '@keyframes a {0% { color: blue; } 0% { background: green; }}', 235 | expected: '@keyframes a {0% { color: blue; background: green; }}', 236 | }, 237 | { 238 | label: 'keyframe selectors with duplicate animation properties', 239 | input: minify` 240 | @keyframes ping { 241 | 75%, 242 | to { 243 | transform: scale(2); 244 | } 245 | } 246 | @keyframes ping { 247 | 75%, 248 | to { 249 | opacity: 0; 250 | } 251 | } 252 | `, 253 | expected: minify` 254 | @keyframes ping { 255 | 75%, 256 | to { 257 | transform: scale(2); 258 | opacity: 0; 259 | } 260 | } 261 | `, 262 | }, 263 | { 264 | label: 'multiple print media queries with different case', 265 | input: minify` 266 | @media print { 267 | a { 268 | color: blue; 269 | } 270 | } 271 | @MEDIA print { 272 | a { 273 | background: green; 274 | } 275 | } 276 | `, 277 | expected: minify` 278 | @media print { 279 | a { 280 | color: blue; 281 | background: green; 282 | } 283 | } 284 | `, 285 | }, 286 | { 287 | label: 'example from issue #219', 288 | input: minify` 289 | * { 290 | box-sizing: border-box; 291 | } 292 | 293 | html, 294 | body { 295 | margin: 0; 296 | padding: 0; 297 | width: 100%; 298 | height: 100%; 299 | font: 24px/1 Arial, Helvetica, sans-serif; 300 | } 301 | 302 | .bg-gold { 303 | background-color: #ffd700; 304 | } 305 | 306 | .i { 307 | font-style: italic; 308 | } 309 | 310 | .fw4 { 311 | font-weight: 400; 312 | } 313 | 314 | .home-ac { 315 | height: 100%; 316 | } 317 | 318 | .home-ac { 319 | position: fixed; 320 | } 321 | 322 | .bg-black-80 { 323 | background-color: rgba(0, 0, 0, 0.8); 324 | } 325 | 326 | .white-80 { 327 | color: rgba(255, 255, 255, 0.8); 328 | } 329 | 330 | .home-ac { 331 | width: 100%; 332 | } 333 | `, 334 | expected: minify` 335 | * { 336 | box-sizing: border-box; 337 | } 338 | 339 | html, 340 | body { 341 | margin: 0; 342 | padding: 0; 343 | width: 100%; 344 | height: 100%; 345 | font: 24px/1 Arial, Helvetica, sans-serif; 346 | } 347 | 348 | .bg-gold { 349 | background-color: #ffd700; 350 | } 351 | 352 | .i { 353 | font-style: italic; 354 | } 355 | 356 | .fw4 { 357 | font-weight: 400; 358 | } 359 | 360 | .home-ac { 361 | height: 100%; 362 | position: fixed; 363 | width: 100%; 364 | } 365 | 366 | .bg-black-80 { 367 | background-color: rgba(0, 0, 0, 0.8); 368 | } 369 | 370 | .white-80 { 371 | color: rgba(255, 255, 255, 0.8); 372 | } 373 | `, 374 | }, 375 | ]; 376 | 377 | describe('Duplicated CSS Tests', () => { 378 | for (const {label, input, expected} of cases) { 379 | it(label, () => { 380 | css({}, input, expected); 381 | }); 382 | } 383 | }); 384 | -------------------------------------------------------------------------------- /test/duplicated-extension.js: -------------------------------------------------------------------------------- 1 | const {describe, it} = require('node:test'); 2 | const testFactory = require('./_test-factory'); 3 | const postcssNested = require('postcss-nested'); 4 | const postcssScss = require('postcss-scss'); 5 | const plugin = require('../src'); 6 | 7 | /** 8 | * These tests check selectors that the plugin CAN combine together. 9 | * Meaning selectors provided are logically the same. 10 | * These tests check against css super set languages: 11 | * less, sass, and postcss-nested. 12 | */ 13 | 14 | const nestedCSS = testFactory('nested css', [postcssNested, plugin]); 15 | const scss = testFactory('scss', [postcssNested, plugin], postcssScss); 16 | 17 | const cases = [ 18 | { 19 | label: 'nested class selectors', 20 | input: '.one.two {color: green} .one {&.two {background: red}}', 21 | expected: '.one.two {color: green;background: red}', 22 | }, 23 | { 24 | label: 'nested class selectors with " " combinator', 25 | input: '.one .two {color: green} .one {.two {background: red}}', 26 | expected: '.one .two {color: green;background: red}', 27 | }, 28 | { 29 | label: 'reordered nested selectors', 30 | input: '.one.two {} .two { .one& {} }', 31 | expected: '.one.two {}', 32 | }, 33 | { 34 | label: 'multi-level nested selectors', 35 | input: '.one .two .three {} .one { .two { .three {} } }', 36 | expected: '.one .two .three {}', 37 | }, 38 | { 39 | label: 'nested selectors with different order', 40 | input: '.one {&.two {}} .two{&.one {}}', 41 | expected: '.one.two {}', 42 | }, 43 | { 44 | label: 'nested and un-nested selectors with different order', 45 | input: '.one.two {} .two{&.one {}}', 46 | expected: '.one.two {}', 47 | }, 48 | { 49 | label: 'nested selector grouping', 50 | input: '.one {&.two, .two& {}} .one {.two&, &.two {}}', 51 | expected: '.one.two, .two.one {}', 52 | }, 53 | ]; 54 | 55 | describe('Duplicated Extension Tests', () => { 56 | for (const {label, input, expected} of cases) { 57 | it(label, () => { 58 | nestedCSS({}, input, expected); 59 | scss({}, input, expected); 60 | }); 61 | } 62 | }); 63 | -------------------------------------------------------------------------------- /test/duplicated-properties.js: -------------------------------------------------------------------------------- 1 | const {describe, it} = require('node:test'); 2 | const testFactory = require('./_test-factory'); 3 | const plugin = require('../src'); 4 | 5 | /** 6 | * These tests check if duplicated properties are deleted or maintained 7 | * according configuration settings. 8 | */ 9 | 10 | /** 11 | * Take string literals and remove newlines and extra spacing so results print 12 | * as expected in logs 13 | * @return {string} string without newlines and tabs 14 | */ 15 | function minify([string]) { 16 | return string.replace(/\s+/gm, ' '); 17 | } 18 | 19 | // Duplicated properties should be removed 20 | const removeDuplicates = testFactory('css', [ 21 | plugin({removeDuplicatedProperties: true}), 22 | ]); 23 | 24 | describe('Duplicated Properties - Removed', () => { 25 | const cases = [ 26 | { 27 | label: 'remove duplicated properties when combining selectors', 28 | input: '.a {height: 10px; color: black;} .a {color: blue; width: 20px;}', 29 | expected: '.a {height: 10px;color: blue; width: 20px;}', 30 | }, 31 | { 32 | label: 'remove duplicated properties in a selector', 33 | input: minify` 34 | .a { 35 | height: 10px; 36 | background: orange; 37 | background: rgba(255, 165, 0, 0.5); 38 | } 39 | `, 40 | expected: minify` 41 | .a { 42 | height: 10px; 43 | background: rgba(255, 165, 0, 0.5); 44 | } 45 | `, 46 | }, 47 | ]; 48 | 49 | for (const {label, input, expected} of cases) { 50 | it(label, () => { 51 | removeDuplicates({}, input, expected); 52 | }); 53 | } 54 | }); 55 | 56 | // Duplicated properties should be maintained 57 | const keepDuplicates = testFactory('css', [ 58 | plugin({removeDuplicatedProperties: false}), 59 | ]); 60 | 61 | describe('Duplicated Properties - Kept', () => { 62 | const cases = [ 63 | { 64 | label: 'maintain duplicated properties when combining selectors', 65 | input: '.a {height: 10px; color: black;} .a {color: blue; width: 20px;}', 66 | expected: '.a {height: 10px; color: black;color: blue; width: 20px;}', 67 | }, 68 | { 69 | label: 'maintain duplicated properties in a selector', 70 | input: minify` 71 | .a { 72 | height: 10px; 73 | background: orange; 74 | background: rgba(255, 165, 0, 0.5); 75 | } 76 | `, 77 | expected: minify` 78 | .a { 79 | height: 10px; 80 | background: orange; 81 | background: rgba(255, 165, 0, 0.5); 82 | } 83 | `, 84 | }, 85 | ]; 86 | 87 | for (const {label, input, expected} of cases) { 88 | it(label, () => { 89 | keepDuplicates({}, input, expected); 90 | }); 91 | } 92 | }); 93 | 94 | // Only duplicated properties with matching values should be removed 95 | const removeExactDuplicates = testFactory('css', [ 96 | plugin({removeDuplicatedValues: true}), 97 | ]); 98 | 99 | describe('Duplicated Properties - Remove Exact Duplicates', () => { 100 | const cases = [ 101 | { 102 | label: 103 | // eslint-disable-next-line max-len 104 | 'remove duplicated properties with matching values (combined selectors)', 105 | input: 106 | // eslint-disable-next-line max-len 107 | '.a {height: 10px; color: red;} .a {color: red; color: blue; width: 20px;}', 108 | expected: '.a {height: 10px;color: red; color: blue; width: 20px;}', 109 | }, 110 | { 111 | label: 'remove duplicated properties with matching values in a selector', 112 | input: minify` 113 | .a { 114 | height: 10px; 115 | background: orange; 116 | background: orange; 117 | background: rgba(255, 165, 0, 0.5); 118 | } 119 | `, 120 | expected: minify` 121 | .a { 122 | height: 10px; 123 | background: orange; 124 | background: rgba(255, 165, 0, 0.5); 125 | } 126 | `, 127 | }, 128 | { 129 | label: 'remove duplicate property with matching value, allow fallback', 130 | input: minify` 131 | .a { 132 | height: 10px; 133 | } 134 | .a { 135 | height: 10px; 136 | height: var(--linkHeight); 137 | } 138 | `, 139 | expected: minify` 140 | .a { 141 | height: 10px; 142 | height: var(--linkHeight); 143 | } 144 | `, 145 | }, 146 | ]; 147 | 148 | for (const {label, input, expected} of cases) { 149 | it(label, () => { 150 | removeExactDuplicates({}, input, expected); 151 | }); 152 | } 153 | }); 154 | -------------------------------------------------------------------------------- /test/unique-css.js: -------------------------------------------------------------------------------- 1 | const {describe, it} = require('node:test'); 2 | const testFactory = require('./_test-factory'); 3 | const plugin = require('../src'); // Adjust path if needed 4 | 5 | /** 6 | * These tests check css selectors that the plugin CANNOT combined together. 7 | * Meaning that the selectors provided are unique. 8 | * These tests check only standard css syntax. 9 | */ 10 | 11 | const css = testFactory('css', [plugin]); 12 | 13 | const cases = [ 14 | {label: 'class', input: '.module {}', expected: '.module {}'}, 15 | {label: 'id', input: '#one {}', expected: '#one {}'}, 16 | {label: 'tag', input: 'a {}', expected: 'a {}'}, 17 | {label: 'universal', input: '* {}', expected: '* {}'}, 18 | {label: 'classes', input: '.one {} .two {}', expected: '.one {} .two {}'}, 19 | {label: 'ids', input: '#one {} #two {}', expected: '#one {} #two {}'}, 20 | {label: 'tags', input: 'a {} b {}', expected: 'a {} b {}'}, 21 | {label: 'universals', input: '* a {} * b {}', expected: '* a {} * b {}'}, 22 | { 23 | label: 'combinations of classes', 24 | input: '.one.two {} .one .two {}', 25 | expected: '.one.two {} .one .two {}', 26 | }, 27 | { 28 | label: 'combinations of ids', 29 | input: '#one#two {} #one #two {}', 30 | expected: '#one#two {} #one #two {}', 31 | }, 32 | { 33 | label: 'attribute selectors', 34 | input: '.a[href] {} .a[title] {}', 35 | expected: '.a[href] {} .a[title] {}', 36 | }, 37 | { 38 | label: 'selectors with same attribute property and unique values', 39 | input: '.a[href="a"] {} .a[href="b"] {}', 40 | expected: '.a[href="a"] {} .a[href="b"] {}', 41 | }, 42 | { 43 | label: 'selectors with same attribute', 44 | input: '.a [href] {} .a[href] {}', 45 | expected: '.a [href] {} .a[href] {}', 46 | }, 47 | { 48 | label: 'pseudo classes', 49 | input: 'a:link {} a:visited {}', 50 | expected: 'a:link {} a:visited {}', 51 | }, 52 | { 53 | label: 'pseudo class and non pseudo class', 54 | input: 'a:link {} a {}', 55 | expected: 'a:link {} a {}', 56 | }, 57 | { 58 | label: 'pseudo elements', 59 | input: 'p::first-line {} p::last-line {}', 60 | expected: 'p::first-line {} p::last-line {}', 61 | }, 62 | { 63 | label: 'pseudo element and non pseudo element', 64 | input: 'p::first-line {} p {}', 65 | expected: 'p::first-line {} p {}', 66 | }, 67 | { 68 | label: 'pseudo class and pseudo element', 69 | input: 'p::first-line {} p:hover {}', 70 | expected: 'p::first-line {} p:hover {}', 71 | }, 72 | { 73 | label: 'selectors same classes', 74 | input: '.one .two {} .one.two {}', 75 | expected: '.one .two {} .one.two {}', 76 | }, 77 | { 78 | label: 'selectors with partial class selector match', 79 | input: '.one.two {} .one.two.three {}', 80 | expected: '.one.two {} .one.two.three {}', 81 | }, 82 | { 83 | label: 'keyframe selectors with different names', 84 | input: '@keyframes a {0% {} 100% {}} @keyframes b {0% {} 100% {}}', 85 | expected: '@keyframes a {0% {} 100% {}} @keyframes b {0% {} 100% {}}', 86 | }, 87 | { 88 | label: 'keyframe selectors with different prefixes', 89 | input: '@keyframes a {0% {} 100% {}} @-webkit-keyframes a {0% {} 100% {}}', 90 | expected: 91 | '@keyframes a {0% {} 100% {}} @-webkit-keyframes a {0% {} 100% {}}', 92 | }, 93 | { 94 | label: 'selector groups partially overlapping', 95 | input: '.one, .two {} .one, .two, .three {}', 96 | expected: '.one, .two {} .one, .two, .three {}', 97 | }, 98 | { 99 | label: 'media query', 100 | input: 101 | // eslint-disable-next-line max-len 102 | '@media (prefers-color-scheme: light) {:root {--text-color: oklch(0% 0 0);}} @media (prefers-color-scheme: dark) {:root {--text-color: oklch(100% 0 0);}}', 103 | expected: 104 | // eslint-disable-next-line max-len 105 | '@media (prefers-color-scheme: light) {:root {--text-color: oklch(0% 0 0);}} @media (prefers-color-scheme: dark) {:root {--text-color: oklch(100% 0 0);}}', 106 | }, 107 | ]; 108 | 109 | describe('Unique CSS Tests', () => { 110 | for (const {label, input, expected} of cases) { 111 | it(label, () => { 112 | css({}, input, expected); 113 | }); 114 | } 115 | }); 116 | -------------------------------------------------------------------------------- /test/unique-extension.js: -------------------------------------------------------------------------------- 1 | const {describe, it} = require('node:test'); 2 | const testFactory = require('./_test-factory'); 3 | const postcssNested = require('postcss-nested'); 4 | const postcssScss = require('postcss-scss'); 5 | const plugin = require('../src'); 6 | 7 | const nestedCSS = testFactory('nested css', [postcssNested, plugin]); 8 | const scss = testFactory('scss', [postcssNested, plugin], postcssScss); 9 | 10 | const cases = [ 11 | { 12 | label: 'nested selectors same with classes', 13 | input: '.one {.two {}} .one{&.two {}}', 14 | expected: '.one .two {} .one.two {}', 15 | }, 16 | { 17 | label: 'selectors with different specifity', 18 | input: '.one {.two {}} .one {.two {.three {}}}', 19 | expected: '.one .two {} .one .two .three {}', 20 | }, 21 | ]; 22 | 23 | describe('Unique Extension Tests', () => { 24 | for (const {label, input, expected} of cases) { 25 | it(label, () => { 26 | nestedCSS({}, input, expected); 27 | scss({}, input, expected); 28 | }); 29 | } 30 | }); 31 | -------------------------------------------------------------------------------- /types/index.d.ts: -------------------------------------------------------------------------------- 1 | // TypeScript Version: 4.0 2 | import { PluginCreator } from "postcss"; 3 | 4 | declare namespace postcssCombineDuplicatedSelectors { 5 | /** 6 | * Options for postcss-combine-duplicated selectors 7 | */ 8 | type Options = 9 | | { 10 | removeDuplicatedValues?: false; 11 | removeDuplicatedProperties?: false; 12 | } 13 | | { 14 | removeDuplicatedProperties: true; 15 | removeDuplicatedValues?: false; 16 | } 17 | | { 18 | removeDuplicatedProperties?: false; 19 | removeDuplicatedValues: true; 20 | }; 21 | 22 | /** 23 | * Plugin provides a creator with specific options supported 24 | */ 25 | type Plugin = PluginCreator; 26 | } 27 | 28 | /** 29 | * Automatically detects and combines duplicated css selectors 30 | * 31 | * @example 32 | * ```typescript 33 | * postcss([postcssCombineDuplicatedSelectors()]); 34 | * ``` 35 | */ 36 | declare const postcssCombineDuplicatedSelectors: postcssCombineDuplicatedSelectors.Plugin; 37 | 38 | export = postcssCombineDuplicatedSelectors; 39 | -------------------------------------------------------------------------------- /types/index.test-d.ts: -------------------------------------------------------------------------------- 1 | import {expectError} from 'tsd'; 2 | import postcss from "postcss"; 3 | 4 | // root export 5 | import postcssCombineDuplicatedSelectors from "./index"; 6 | 7 | postcss([postcssCombineDuplicatedSelectors()]); 8 | postcss([ 9 | postcssCombineDuplicatedSelectors({ removeDuplicatedProperties: true }), 10 | ]); 11 | postcss([ 12 | postcssCombineDuplicatedSelectors({ removeDuplicatedProperties: false }), 13 | ]); 14 | postcss([postcssCombineDuplicatedSelectors({ removeDuplicatedValues: true })]); 15 | postcss([ 16 | postcssCombineDuplicatedSelectors({ removeDuplicatedValues: false }), 17 | ]); 18 | expectError( 19 | postcss([ 20 | postcssCombineDuplicatedSelectors({ 21 | removeDuplicatedValues: true, 22 | removeDuplicatedProperties: true, // $ExpectError 23 | }), 24 | ]) 25 | ); 26 | -------------------------------------------------------------------------------- /types/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2021", 4 | "lib": ["es2021"], 5 | "module": "node16", 6 | "strict": true, 7 | "baseUrl": ".", 8 | "paths": { 9 | "postcss-combine-duplicated-selectors": ["index.d.ts"] 10 | } 11 | } 12 | } 13 | --------------------------------------------------------------------------------