diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..1975edc --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,54 @@ +name: Release Package + +on: + workflow_dispatch: + inputs: + publish_type: + description: 'Choose publish type: dry-run, main' + required: true + default: 'dry-run' + type: choice + options: + - dry-run + - main + confirm_publish: + description: 'Type YES to confirm publishing (required for main release)' + required: false + default: '' + +permissions: + id-token: write + +jobs: + publish: + if: ${{ (github.event.inputs.publish_type == 'main' && github.event.inputs.confirm_publish == 'YES' && github.ref_name == 'main') || github.event.inputs.publish_type == 'dry-run' }} + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Set up Node.js + uses: actions/setup-node@v3 + with: + node-version: '20' + registry-url: 'https://registry.npmjs.org' + + - name: Set up pnpm + uses: pnpm/action-setup@v2 + with: + version: 8.9.2 + + - name: Install dependencies + run: pnpm i + + - name: Publish package (dry) + if: ${{github.event.inputs.publish_type == 'dry-run' }} + run: pnpm release:dry --no-git-checks + + - name: Publish package + if: ${{ github.event.inputs.publish_type == 'main' }} + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + REPOSITORY: ${{ github.repository }} + REF: ${{ github.ref }} + run: pnpm release:latest diff --git a/.gitignore b/.gitignore index b512c09..cc073a2 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ -node_modules \ No newline at end of file +node_modules +dist +pnpm-lock.yaml +coverage diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..35fa266 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,8 @@ +{ + "printWidth": 80, + "tabWidth": 2, + "trailingComma": "all", + "singleQuote": true, + "arrowParens": "avoid", + "endOfLine": "lf" +} diff --git a/README.md b/README.md index 85fd79a..5fb64bd 100644 --- a/README.md +++ b/README.md @@ -1,203 +1,260 @@ # [postcss][git_url]-remove-duplicate-values -[![npm version](https://img.shields.io/npm/v/postcss-remove-duplicate-values.svg)][npm_url] -[![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://opensource.org/licenses/MIT) +

+ npm version + license +

-[PostCSS Remove Duplicate Values] is a plugin for [PostCSS] that removes duplicate CSS property values within rules, optimizing stylesheet size and improving maintainability. +> **Smart PostCSS plugin that removes duplicate CSS properties, reduces bundle size, and improves CSS maintainability.** -## Installation +## โœจ What It Does? -You can install the plugin via npm, pnpm, or yarn: +Automatically removes duplicate CSS properties from your stylesheets while keeping the most important ones. Perfect for cleaning up CSS and improving performance. +### ๐ŸŽฏ Key Features +- **๐Ÿงน Removes duplicate properties** (keeps the last one) +- **โšก Handles `!important` declarations** intelligently +- **๐ŸŽจ Supports vendor prefixes** and modern CSS +- **๐ŸŽฏ Filters specific selectors** (optional) +- **๐Ÿ—‘๏ธ Cleans empty rules** (configurable) +- **๐Ÿš€ Zero configuration** needed + +## ๐Ÿš€ Quick Start + +### 1. Install ```bash npm install postcss-remove-duplicate-values --save-dev +# or pnpm add postcss-remove-duplicate-values -D +# or yarn add postcss-remove-duplicate-values -D ``` -## What does it do? - -This plugin identifies and removes duplicate CSS property values within rules, considering specificity and preserving `!important` declarations. When you have multiple declarations with the same property within a single rule, it retains only the last declaration, prioritizing `!important` values over non-`!important` values. - -### Examples: +### 2. Use in PostCSS +```js +// postcss.config.js +module.exports = { + plugins: [ + require('postcss-remove-duplicate-values') + ] +} +``` -Here are some CSS examples showcasing the behavior of the plugin: +### 3. That's it! ๐ŸŽ‰ +The plugin automatically removes duplicates from your CSS. -#### Example A: Without !important +## ๐Ÿ“– Examples +### Basic Duplicate Removal ```css -/* Input A */ +/* Before */ .button { color: red; color: blue; + margin: 10px; + margin: 20px; } -/* Output A */ +/* After */ .button { color: blue; + margin: 20px; } ``` -#### Example B: With !important - +### `!important` Handling ```css -/* Input B */ +/* Before */ .button { color: red !important; - color: yellow !important; color: blue; + font-weight: normal; + font-weight: bold !important; } -.card { - display: flex !important; - display: block; + +/* After */ +.button { + color: red !important; + font-weight: bold !important; } +``` -/* Output B */ +### Vendor Prefixes +```css +/* Before */ .button { - color: yellow !important; + transform: translateX(40px); + -webkit-transform: translateX(10px); + -moz-transform: translateX(10px); + transform: translateX(10px); } -.card { - display: flex !important; + +/* After */ +.button { + /* Plugin removes duplicate 'transform' properties, keeping the last one */ + /* Vendor prefixes are preserved */ + -webkit-transform: translateX(10px); + -moz-transform: translateX(10px); + transform: translateX(10px); } ``` -## Configuration +## โš™๏ธ Configuration Options -Integrating [PostCSS Remove Duplicate Values] into your [PostCSS] configuration is straightforward. Add it to your list of plugins: - -```js -const postcss = require("postcss"); -const removeDuplicateValues = require("postcss-remove-duplicate-values"); +Before applying the plugin, you can configure the following options: -const css = ` -.button { - color: red; - color: blue; -}`; - -// Example 1: Using plugins array -postcss([ - removeDuplicateValues({ - // options here - }), -]) - .process(css, { from: undefined }) - .then((result) => { - console.log(result.css); - }); - -// Example 2: Using use() method -postcss() - .use( - removeDuplicateValues({ - /* options */ - }) - ) - .process(css, { from: undefined }) - .then((result) => { - console.log(result.css); - }); -``` +| Option | Type | Default | +| --------------------------------- | --------------------------------------------------- | ----------- | +| [`selector`](#selector) | `(selector: string) => boolean \| string \| RegExp` | `undefined` | +| [`preserveEmpty`](#preserveempty) | `boolean` | `false` | -If you are using `postcss.config.js`, you can include it as follows: +### selector +Filter which CSS selectors to process. ```js -module.exports = { - plugins: [require("postcss-remove-duplicate-values")], -}; +// Only process .button selectors +removeDuplicateValues({ + selector: '.button' +}) + +// Process selectors matching regex +removeDuplicateValues({ + selector: /^\.btn-/ +}) + +// Custom function +removeDuplicateValues({ + selector: (selector) => selector.includes('button') +}) ``` -For more customization, you can pass options to the plugin: +### preserveEmpty +Keep or remove empty CSS rules. ```js -const removeDuplicateValues = require("postcss-remove-duplicate-values"); -module.exports = { - plugins: [ - removeDuplicateValues({ - // options here - }), - ], -}; +// Remove empty rules (default) +removeDuplicateValues({ + preserveEmpty: false +}) + +// Keep empty rules +removeDuplicateValues({ + preserveEmpty: true +}) ``` -## Options +## ๐Ÿ”ง Advanced Usage -Before applying the plugin, you can configure the following options: - -| Option | Type | Default | -| --------------------------------- | --------------------------------------------------- | ----------- | -| [`selector`](#selector) | `(selector: string) => boolean \| string \| RegExp` | `undefined` | -| [`preserveEmpty`](#preserveempty) | `boolean` | `false` | +### With PostCSS API +```js +const postcss = require('postcss') +const removeDuplicateValues = require('postcss-remove-duplicate-values') -#### selector +const css = ` +.button { + color: red; + color: blue; +}` + +postcss([removeDuplicateValues()]) + .process(css) + .then(result => { + console.log(result.css) + // Output: .button { color: blue; } + }) +``` -The selector option specifies the selector to consider while removing duplicate values. This option allows you to target specific selectors for duplicate value removal. Default its undefined i.e. apply to all rules. Selector can be defined as: +### With Build Tools +```js +// webpack.config.js +module.exports = { + module: { + rules: [ + { + test: /\.css$/, + use: [ + 'style-loader', + 'css-loader', + { + loader: 'postcss-loader', + options: { + plugins: [ + require('postcss-remove-duplicate-values') + ] + } + } + ] + } + ] + } +} +``` -- **String**: A CSS selector string. Only rules matching this selector will have their duplicate values removed. -- **RegExp**: A regular expression. Rules with selectors matching this regular expression will have their duplicate values removed. -- **Function**: `(selector: string) => boolean` A function that takes a selector string as input and returns a boolean value indicating whether the selector should be considered for duplicate value removal. -**Example**: +## ๐Ÿ“š More Examples +### Selector Filtering ```css /* Input CSS */ .container { - display: block; color: red; color: blue; } - .button { - display: flex; - color: green; + margin: 10px; + margin: 20px; } -``` -If we set selector `.container`, only the properties within the .container selector will be considered for duplicate value removal. Similarly, you can use regular expressions or custom functions to match specific selectors for this operation. - -```css -/* Output CSS */ +/* With selector: '.container' */ .container { - display: block; color: blue; } - .button { - display: flex; - color: green; + margin: 10px; + margin: 20px; /* Not processed */ } ``` -### preserveEmpty - -The `preserveEmpty` option determines whether empty selectors should be preserved or removed during the process of removing duplicate values. An empty selector is a selector without any properties. - -**Example**: -Consider the following CSS: - +### Empty Rule Handling ```css /* Input CSS */ -.classA { +.empty-rule { } - -.classB { - /* some comment */ +.button { + color: blue; } +/* With preserveEmpty: false */ .button { - display: block; + color: blue; } -``` - -If `preserveEmpty` is set to false, the empty selector `.somecss` will be removed during the process. If set to true, the empty selector will be preserved in the output. +/* .empty-rule removed */ -```css -/* Output CSS */ +/* With preserveEmpty: true */ +.empty-rule { +} .button { - display: block; + color: blue; } ``` +## ๐ŸŽฎ Try It Live! + +**Test the plugin in real-time with our interactive playground:** + +[๐ŸŽฎ **Try the Playground** โ†’](https://xettri.github.io/postcss-remove-duplicate-values) + +### What You Can Do in the Playground: +- โœจ **Test CSS processing** in real-time +- ๐ŸŽฏ **Experiment with options** (selector filtering, empty rule preservation) +- ๐Ÿ“š **Try pre-built examples** for common scenarios +- ๐Ÿ“Š **See live statistics** of duplicate removal results +- ๐ŸŽจ **Understand plugin behavior** through interactive examples + +
+ +**Made with โค๏ธ by [Bharat Rawat](https://bharatrawat.com)** + [PostCSS Remove Duplicate Values]: https://github.com/xettri/postcss-remove-duplicate-values [npm_url]: https://www.npmjs.com/package/postcss-remove-duplicate-values [git_url]: https://github.com/xettri/postcss-remove-duplicate-values diff --git a/__tests__/core.test.js b/__tests__/core.test.js new file mode 100644 index 0000000..905bb72 --- /dev/null +++ b/__tests__/core.test.js @@ -0,0 +1,529 @@ +const postcss = require('postcss'); +const plugin = require('../src/index.js'); + +// Helper function to process CSS with the plugin +const processCSS = (css, options = {}) => { + return postcss([plugin(options)]).process(css, { from: undefined }); +}; + +// Helper function to get CSS output +const getCSS = async (css, options = {}) => { + const result = await processCSS(css, options); + return result.css; +}; + +describe('postcss-remove-duplicate-values', () => { + describe('Basic functionality', () => { + test('should remove duplicate properties without !important', async () => { + const input = ` + .button { + color: red; + color: blue; + } + `; + + const output = await getCSS(input); + + expect(output).toContain('color: blue'); + expect(output).not.toContain('color: red'); + expect(output).toMatch(/\.button\s*\{\s*color:\s*blue;\s*\}/); + }); + + test('should remove duplicate properties with different values', async () => { + const input = ` + .card { + display: block; + display: flex; + margin: 10px; + margin: 20px; + } + `; + + const output = await getCSS(input); + + expect(output).toContain('display: flex'); + expect(output).toContain('margin: 20px'); + expect(output).not.toContain('display: block'); + expect(output).not.toContain('margin: 10px'); + }); + + test('should preserve non-duplicate properties', async () => { + const input = ` + .element { + color: blue; + background: red; + font-size: 16px; + } + `; + + const output = await getCSS(input); + + expect(output).toContain('color: blue'); + expect(output).toContain('background: red'); + expect(output).toContain('font-size: 16px'); + }); + }); + + describe('!important handling', () => { + test('should preserve !important declarations over non-important ones', async () => { + const input = ` + .button { + color: red !important; + color: blue; + } + `; + + const output = await getCSS(input); + + expect(output).toContain('color: red !important'); + expect(output).not.toContain('color: blue'); + }); + + test('should keep the last !important declaration when multiple exist', async () => { + const input = ` + .button { + color: red !important; + color: yellow !important; + color: blue !important; + } + `; + + const output = await getCSS(input); + + expect(output).toContain('color: blue !important'); + expect(output).not.toContain('color: red !important'); + expect(output).not.toContain('color: yellow !important'); + }); + + test('should handle mixed important and non-important declarations', async () => { + const input = ` + .card { + display: block; + display: flex !important; + margin: 10px !important; + margin: 20px; + } + `; + + const output = await getCSS(input); + + expect(output).toContain('display: flex !important'); + expect(output).toContain('margin: 10px !important'); + expect(output).not.toContain('display: block'); + expect(output).not.toContain('margin: 20px'); + }); + }); + + describe('Vendor prefix handling', () => { + test('should handle -webkit- prefixed values', async () => { + const input = ` + .element { + transform: rotate(45deg); + -webkit-transform: rotate(45deg); + } + `; + + const output = await getCSS(input); + + // Should keep the vendor prefix as it's treated as a fallback + expect(output).toContain('-webkit-transform: rotate(45deg)'); + expect(output).toContain('transform: rotate(45deg)'); + }); + + test('should handle -moz- prefixed values', async () => { + const input = ` + .element { + -moz-transform: rotate(45deg); + transform: rotate(45deg); + } + `; + + const output = await getCSS(input); + + expect(output).toContain('-moz-transform: rotate(45deg)'); + expect(output).toContain('transform: rotate(45deg)'); + }); + + test('should handle -ms- prefixed values', async () => { + const input = ` + .element { + -ms-transform: rotate(45deg); + transform: rotate(45deg); + } + `; + + const output = await getCSS(input); + + expect(output).toContain('-ms-transform: rotate(45deg)'); + expect(output).toContain('transform: rotate(45deg)'); + }); + + test('should handle -o- prefixed values', async () => { + const input = ` + .element { + -o-transform: rotate(45deg); + transform: rotate(45deg); + } + `; + + const output = await getCSS(input); + + expect(output).toContain('-o-transform: rotate(45deg)'); + expect(output).toContain('transform: rotate(45deg)'); + }); + + test('should handle vendor prefixes with !important', async () => { + const input = ` + .element { + -webkit-transform: rotate(45deg) !important; + transform: rotate(45deg); + } + `; + + const output = await getCSS(input); + + expect(output).toContain('-webkit-transform: rotate(45deg) !important'); + expect(output).toContain('transform: rotate(45deg)'); + }); + }); + + describe('Selector filtering', () => { + test('should process only specified string selector', async () => { + const input = ` + .container { + color: red; + color: blue; + } + .button { + color: green; + color: yellow; + } + `; + + const output = await getCSS(input, { selector: '.container' }); + + expect(output).toContain('color: blue'); + expect(output).not.toContain('color: red'); + expect(output).toContain('color: green'); + expect(output).toContain('color: yellow'); + }); + + test('should process only regex matching selectors', async () => { + const input = ` + .btn-primary { + color: red; + color: blue; + } + .btn-secondary { + color: green; + color: yellow; + } + .card { + margin: 10px; + } + `; + + const output = await getCSS(input, { selector: /\.btn-/ }); + + expect(output).toContain('color: blue'); + expect(output).toContain('color: yellow'); + expect(output).not.toContain('color: red'); + expect(output).not.toContain('color: green'); + expect(output).toContain('margin: 10px'); + }); + + test('should process only function matching selectors', async () => { + const input = ` + .button { + color: red; + color: blue; + } + .card { + margin: 10px; + margin: 20px; + } + `; + + const output = await getCSS(input, { + selector: selector => selector.includes('button'), + }); + + expect(output).toContain('color: blue'); + expect(output).not.toContain('color: red'); + expect(output).toContain('margin: 10px'); + expect(output).toContain('margin: 20px'); + }); + + test('should process all selectors when no selector option is provided', async () => { + const input = ` + .button { + color: red; + color: blue; + } + .card { + margin: 10px; + margin: 20px; + } + `; + + const output = await getCSS(input); + + expect(output).toContain('color: blue'); + expect(output).toContain('margin: 20px'); + expect(output).not.toContain('color: red'); + expect(output).not.toContain('margin: 10px'); + }); + }); + + describe('Empty rule handling', () => { + test('should remove empty rules by default', async () => { + const input = ` + .empty-rule { + } + .non-empty-rule { + color: blue; + } + `; + + const output = await getCSS(input); + + expect(output).not.toContain('.empty-rule'); + expect(output).toContain('.non-empty-rule'); + expect(output).toContain('color: blue'); + }); + + test('should preserve empty rules when preserveEmpty is true', async () => { + const input = ` + .empty-rule { + } + .non-empty-rule { + color: blue; + } + `; + + const output = await getCSS(input, { preserveEmpty: true }); + + expect(output).toContain('.empty-rule'); + expect(output).toContain('.non-empty-rule'); + expect(output).toContain('color: blue'); + }); + + test('should handle rules with only comments as empty', async () => { + const input = ` + .comment-only { + /* This is a comment */ + } + .with-property { + color: blue; + } + `; + + const output = await getCSS(input); + + expect(output).not.toContain('.comment-only'); + expect(output).toContain('.with-property'); + expect(output).toContain('color: blue'); + }); + + test('should preserve rules with only comments when preserveEmpty is true', async () => { + const input = ` + .comment-only { + /* This is a comment */ + } + .with-property { + color: blue; + } + `; + + const output = await getCSS(input, { preserveEmpty: true }); + + expect(output).toContain('.comment-only'); + expect(output).toContain('.with-property'); + expect(output).toContain('color: blue'); + }); + }); + + describe('Edge cases and complex scenarios', () => { + test('should handle multiple duplicate properties in sequence', async () => { + const input = ` + .element { + color: red; + color: blue; + color: green; + color: yellow; + } + `; + + const output = await getCSS(input); + + expect(output).toContain('color: yellow'); + expect(output).not.toContain('color: red'); + expect(output).not.toContain('color: blue'); + expect(output).not.toContain('color: green'); + }); + + test('should handle properties with complex values', async () => { + const input = ` + .element { + background: linear-gradient(to right, #ff0000, #00ff00); + background: radial-gradient(circle, #0000ff, #ffff00); + } + `; + + const output = await getCSS(input); + + expect(output).toContain( + 'background: radial-gradient(circle, #0000ff, #ffff00)', + ); + expect(output).not.toContain( + 'background: linear-gradient(to right, #ff0000, #00ff00)', + ); + }); + + test('should handle properties with spaces and special characters', async () => { + const input = ` + .element { + content: "Hello World"; + content: "Goodbye World"; + } + `; + + const output = await getCSS(input); + + expect(output).toContain('content: "Goodbye World"'); + expect(output).not.toContain('content: "Hello World"'); + }); + + test('should handle CSS custom properties', async () => { + const input = ` + .element { + --color: red; + --color: blue; + } + `; + + const output = await getCSS(input); + + expect(output).toContain('--color: blue'); + expect(output).not.toContain('--color: red'); + }); + }); + + describe('Integration and real-world scenarios', () => { + test('should handle complex CSS with multiple rules', async () => { + const input = ` + .header { + background: #fff; + background: #f0f0f0; + color: #333; + } + + .nav { + display: block; + display: flex; + } + + .content { + margin: 0; + padding: 20px; + } + `; + + const output = await getCSS(input); + + expect(output).toContain('background: #f0f0f0'); + expect(output).toContain('color: #333'); + expect(output).toContain('display: flex'); + expect(output).toContain('margin: 0'); + expect(output).toContain('padding: 20px'); + + expect(output).not.toContain('background: #fff'); + expect(output).not.toContain('display: block'); + }); + + test('should work with PostCSS plugins chain', async () => { + const input = ` + .button { + color: red; + color: blue; + background: white; + } + `; + + // Simulate plugin chain + const result = await postcss([ + plugin(), + // Add another plugin here if needed + ]).process(input, { from: undefined }); + + expect(result.css).toContain('color: blue'); + expect(result.css).toContain('background: white'); + expect(result.css).not.toContain('color: red'); + }); + }); + + describe('Error handling and robustness', () => { + test('should handle malformed CSS gracefully', async () => { + const input = ` + .malformed { + color: red + color: blue; + } + `; + + // PostCSS will throw a syntax error for malformed CSS before our plugin runs + await expect(async () => { + await getCSS(input); + }).rejects.toThrow('Missed semicolon'); + }); + + test('should handle empty CSS input', async () => { + const input = ''; + + const output = await getCSS(input); + expect(output).toBe(''); + }); + + test('should handle CSS with only whitespace', async () => { + const input = ' \n \t '; + + const output = await getCSS(input); + expect(output.trim()).toBe(''); + }); + }); + + describe('Performance and memory', () => { + test('should handle large number of properties efficiently', async () => { + const properties = Array.from( + { length: 1000 }, + (_, i) => `prop${i}: value${i};`, + ); + const duplicateProperties = Array.from( + { length: 1000 }, + (_, i) => `prop${i}: duplicate${i};`, + ); + + const input = ` + .large-rule { + ${properties.join('\n ')} + ${duplicateProperties.join('\n ')} + } + `; + + const startTime = Date.now(); + const output = await getCSS(input); + const endTime = Date.now(); + + // Should complete within reasonable time (less than 1 second) + expect(endTime - startTime).toBeLessThan(1000); + + // Should contain the duplicate values (last ones) + expect(output).toContain('prop0: duplicate0'); + expect(output).toContain('prop999: duplicate999'); + + // Should not contain the original values + expect(output).not.toContain('prop0: value0'); + expect(output).not.toContain('prop999: value999'); + }); + }); +}); diff --git a/__tests__/edge.test.js b/__tests__/edge.test.js new file mode 100644 index 0000000..42056ab --- /dev/null +++ b/__tests__/edge.test.js @@ -0,0 +1,398 @@ +const postcss = require('postcss'); +const plugin = require('../src/index.js'); + +const getCSS = async (css, options = {}) => { + const result = await postcss([plugin(options)]).process(css, { + from: undefined, + }); + return result.css; +}; + +describe('Edge Cases and Complex Scenarios', () => { + describe('CSS at-rules and complex selectors', () => { + test('should handle media queries', async () => { + const input = ` + @media (max-width: 768px) { + .responsive { + color: red; + color: blue; + } + } + `; + + const output = await getCSS(input); + + expect(output).toContain('@media (max-width: 768px)'); + expect(output).toContain('color: blue'); + expect(output).not.toContain('color: red'); + }); + + test('should handle keyframes', async () => { + const input = ` + @keyframes slide { + 0% { + transform: translateX(0); + transform: translateX(0px); + } + 100% { + transform: translateX(100px); + transform: translateX(100px); + } + } + `; + + const output = await getCSS(input); + + expect(output).toContain('@keyframes slide'); + expect(output).toContain('transform: translateX(0px)'); + expect(output).toContain('transform: translateX(100px)'); + expect(output).not.toContain('transform: translateX(0)'); + }); + + test('should handle complex selectors with pseudo-elements', async () => { + const input = ` + .button:hover::before { + content: "Hello"; + content: "World"; + } + `; + + const output = await getCSS(input); + + expect(output).toContain('content: "World"'); + expect(output).not.toContain('content: "Hello"'); + }); + + test('should handle attribute selectors', async () => { + const input = ` + [data-test="value"] { + border: 1px solid red; + border: 2px solid blue; + } + `; + + const output = await getCSS(input); + + expect(output).toContain('border: 2px solid blue'); + expect(output).not.toContain('border: 1px solid red'); + }); + }); + + describe('CSS values with special characters', () => { + test('should handle URLs with quotes', async () => { + const input = ` + .background { + background-image: url("image1.jpg"); + background-image: url('image2.jpg'); + } + `; + + const output = await getCSS(input); + + expect(output).toContain("background-image: url('image2.jpg')"); + expect(output).not.toContain('background-image: url("image1.jpg")'); + }); + + test('should handle calc() functions', async () => { + const input = ` + .element { + width: calc(100% - 20px); + width: calc(100% - 30px); + } + `; + + const output = await getCSS(input); + + expect(output).toContain('width: calc(100% - 30px)'); + expect(output).not.toContain('width: calc(100% - 20px)'); + }); + + test('should handle CSS variables with fallbacks', async () => { + const input = ` + .element { + color: var(--primary-color, red); + color: var(--primary-color, blue); + } + `; + + const output = await getCSS(input); + + expect(output).toContain('color: var(--primary-color, blue)'); + expect(output).not.toContain('color: var(--primary-color, red)'); + }); + + test('should handle rgba/hsla values', async () => { + const input = ` + .element { + background-color: rgba(255, 0, 0, 0.5); + background-color: rgba(0, 255, 0, 0.8); + } + `; + + const output = await getCSS(input); + + expect(output).toContain('background-color: rgba(0, 255, 0, 0.8)'); + expect(output).not.toContain('background-color: rgba(255, 0, 0, 0.5)'); + }); + }); + + describe('Vendor prefix edge cases', () => { + test('should handle vendor prefixes in complex values', async () => { + const input = ` + .element { + -webkit-transform: translate3d(0, 0, 0) rotate(45deg); + transform: translate3d(0, 0, 0) rotate(45deg); + } + `; + + const output = await getCSS(input); + + expect(output).toContain( + '-webkit-transform: translate3d(0, 0, 0) rotate(45deg)', + ); + expect(output).toContain('transform: translate3d(0, 0, 0) rotate(45deg)'); + }); + + test('should handle vendor prefixes with !important', async () => { + const input = ` + .element { + -webkit-transform: rotate(45deg) !important; + -webkit-transform: rotate(90deg) !important; + transform: rotate(45deg); + } + `; + + const output = await getCSS(input); + + expect(output).toContain('-webkit-transform: rotate(90deg) !important'); + expect(output).toContain('transform: rotate(45deg)'); + expect(output).not.toContain( + '-webkit-transform: rotate(45deg) !important', + ); + }); + + test('should handle multiple vendor prefixes for same property', async () => { + const input = ` + .element { + -webkit-transform: rotate(45deg); + -moz-transform: rotate(45deg); + -ms-transform: rotate(45deg); + -o-transform: rotate(45deg); + transform: rotate(45deg); + } + `; + + const output = await getCSS(input); + + // All vendor prefixes should be preserved + expect(output).toContain('-webkit-transform: rotate(45deg)'); + expect(output).toContain('-moz-transform: rotate(45deg)'); + expect(output).toContain('-ms-transform: rotate(45deg)'); + expect(output).toContain('-o-transform: rotate(45deg)'); + expect(output).toContain('transform: rotate(45deg)'); + }); + }); + + describe('Empty and malformed CSS handling', () => { + test('should handle rules with only whitespace', async () => { + const input = ` + .whitespace-only { + + } + .normal-rule { + color: blue; + } + `; + + const output = await getCSS(input); + + expect(output).not.toContain('.whitespace-only'); + expect(output).toContain('.normal-rule'); + expect(output).toContain('color: blue'); + }); + + test('should handle rules with only newlines', async () => { + const input = ` + .newline-only { + + } + .normal-rule { + color: blue; + } + `; + + const output = await getCSS(input); + + expect(output).not.toContain('.newline-only'); + expect(output).toContain('.normal-rule'); + expect(output).toContain('color: blue'); + }); + + test('should handle CSS with mixed content types', async () => { + const input = ` + /* Comment */ + .rule1 { + color: red; + color: blue; + } + + @import "styles.css"; + + .rule2 { + margin: 10px; + } + `; + + const output = await getCSS(input); + + expect(output).toContain('/* Comment */'); + expect(output).toContain('@import "styles.css"'); + expect(output).toContain('color: blue'); + expect(output).toContain('margin: 10px'); + expect(output).not.toContain('color: red'); + }); + }); + + describe('Performance edge cases', () => { + test('should handle extremely long property values', async () => { + const longValue = 'a'.repeat(10000); + const input = ` + .long-value { + content: "${longValue}"; + content: "short"; + } + `; + + const startTime = Date.now(); + const output = await getCSS(input); + const endTime = Date.now(); + + expect(endTime - startTime).toBeLessThan(1000); + expect(output).toContain('content: "short"'); + expect(output).not.toContain(longValue); + }); + + test('should handle many duplicate properties efficiently', async () => { + const duplicates = Array.from({ length: 100 }, () => 'color: red;').join( + '\n', + ); + const input = ` + .many-duplicates { + ${duplicates} + color: blue; + } + `; + + const startTime = Date.now(); + const output = await getCSS(input); + const endTime = Date.now(); + + expect(endTime - startTime).toBeLessThan(500); + expect(output).toContain('color: blue'); + expect(output).not.toContain('color: red'); + }); + }); + + describe('Selector filtering edge cases', () => { + test('should handle complex selector functions', async () => { + const input = ` + .button-primary { + color: red; + color: blue; + } + .button-secondary { + color: green; + color: yellow; + } + .card { + margin: 10px; + } + `; + + const output = await getCSS(input, { + selector: selector => { + return selector.includes('button') && selector.includes('primary'); + }, + }); + + expect(output).toContain('color: blue'); + expect(output).not.toContain('color: red'); + expect(output).toContain('color: green'); + expect(output).toContain('color: yellow'); + expect(output).toContain('margin: 10px'); + }); + + test('should handle regex with special characters', async () => { + const input = ` + .btn-primary { + color: red; + color: blue; + } + .btn-secondary { + color: green; + color: yellow; + } + `; + + const output = await getCSS(input, { selector: /\.btn-/ }); + + expect(output).toContain('color: blue'); + expect(output).toContain('color: yellow'); + expect(output).not.toContain('color: red'); + expect(output).not.toContain('color: green'); + }); + + test('should handle function that returns false for all selectors', async () => { + const input = ` + .button { + color: red; + color: blue; + } + .card { + margin: 10px; + margin: 20px; + } + `; + + const output = await getCSS(input, { + selector: () => false, + }); + + // No selectors should be processed + expect(output).toContain('color: red'); + expect(output).toContain('color: blue'); + expect(output).toContain('margin: 10px'); + expect(output).toContain('margin: 20px'); + }); + }); + + describe('CSS parsing edge cases', () => { + test('should handle CSS with escaped characters', async () => { + const input = ` + .escaped { + content: "Hello\\"World"; + content: "Goodbye\\"World"; + } + `; + + const output = await getCSS(input); + + expect(output).toContain('content: "Goodbye\\"World"'); + expect(output).not.toContain('content: "Hello\\"World"'); + }); + + test('should handle CSS with unicode characters', async () => { + const input = ` + .unicode { + content: "๐ŸŽ‰"; + content: "๐ŸŽŠ"; + } + `; + + const output = await getCSS(input); + + expect(output).toContain('content: "๐ŸŽŠ"'); + expect(output).not.toContain('content: "๐ŸŽ‰"'); + }); + }); +}); diff --git a/__tests__/integration.test.js b/__tests__/integration.test.js new file mode 100644 index 0000000..44e6647 --- /dev/null +++ b/__tests__/integration.test.js @@ -0,0 +1,362 @@ +const postcss = require('postcss'); +const plugin = require('../src/index.js'); + +describe('Integration Tests', () => { + describe('PostCSS Plugin Integration', () => { + test('should work as a PostCSS plugin', async () => { + const input = ` + .test { + color: red; + color: blue; + } + `; + + const result = await postcss([plugin()]).process(input, { + from: undefined, + }); + + expect(result.css).toContain('color: blue'); + expect(result.css).not.toContain('color: red'); + expect(result.css).toMatch(/\.test\s*\{\s*color:\s*blue;\s*\}/); + }); + + test('should work with plugin options', async () => { + const input = ` + .button { + color: red; + color: blue; + } + .card { + margin: 10px; + margin: 20px; + } + `; + + const result = await postcss([plugin({ selector: '.button' })]).process( + input, + { from: undefined }, + ); + + expect(result.css).toContain('color: blue'); + expect(result.css).not.toContain('color: red'); + expect(result.css).toContain('margin: 10px'); + expect(result.css).toContain('margin: 20px'); + }); + + test('should work in plugin chain', async () => { + const input = ` + .element { + color: red; + color: blue; + background: white; + } + `; + + // Simulate a plugin chain + const result = await postcss([ + plugin(), + // Add another plugin here if needed + ]).process(input, { from: undefined }); + + expect(result.css).toContain('color: blue'); + expect(result.css).toContain('background: white'); + expect(result.css).not.toContain('color: red'); + }); + }); + + describe('Configuration Options Integration', () => { + test('should handle undefined options gracefully', async () => { + const input = ` + .test { + color: red; + color: blue; + } + `; + + const result = await postcss([plugin(undefined)]).process(input, { + from: undefined, + }); + + expect(result.css).toContain('color: blue'); + expect(result.css).not.toContain('color: red'); + }); + + test('should handle empty options object', async () => { + const input = ` + .test { + color: red; + color: blue; + } + `; + + const result = await postcss([plugin({})]).process(input, { + from: undefined, + }); + + expect(result.css).toContain('color: blue'); + expect(result.css).not.toContain('color: red'); + }); + + test('should handle partial options', async () => { + const input = ` + .test { + color: red; + color: blue; + } + `; + + const result = await postcss([plugin({ preserveEmpty: true })]).process( + input, + { from: undefined }, + ); + + expect(result.css).toContain('color: blue'); + expect(result.css).not.toContain('color: red'); + }); + }); + + describe('Real-world CSS Scenarios', () => { + test('should handle Bootstrap-like CSS', async () => { + const input = ` + .btn { + display: inline-block; + font-weight: 400; + text-align: center; + vertical-align: middle; + cursor: pointer; + padding: 0.375rem 0.75rem; + font-size: 1rem; + line-height: 1.5; + border-radius: 0.25rem; + transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; + } + + .btn-primary { + color: #fff; + background-color: #007bff; + border-color: #007bff; + color: #ffffff; + background-color: #0056b3; + border-color: #0056b3; + } + `; + + const result = await postcss([plugin()]).process(input, { + from: undefined, + }); + + // Check for exact property declarations, not substrings + const output = result.css; + + // The plugin should keep the last declaration of each property + expect(output).toMatch(/\bcolor:\s*#ffffff\b/); + expect(output).toMatch(/\bbackground-color:\s*#0056b3\b/); + expect(output).toMatch(/\bborder-color:\s*#0056b3\b/); + + // The plugin should remove the first declaration of each property + expect(output).not.toMatch(/\bcolor:\s*#fff\b/); + expect(output).not.toMatch(/\bbackground-color:\s*#007bff\b/); + expect(output).not.toMatch(/\bborder-color:\s*#007bff\b/); + }); + + test('should handle CSS Grid/Flexbox properties', async () => { + const input = ` + .grid-container { + display: grid; + display: flex; + grid-template-columns: repeat(3, 1fr); + grid-template-columns: repeat(4, 1fr); + gap: 20px; + gap: 30px; + } + `; + + const result = await postcss([plugin()]).process(input, { + from: undefined, + }); + + expect(result.css).toContain('display: flex'); + expect(result.css).toContain('grid-template-columns: repeat(4, 1fr)'); + expect(result.css).toContain('gap: 30px'); + expect(result.css).not.toContain('display: grid'); + expect(result.css).not.toContain('grid-template-columns: repeat(3, 1fr)'); + expect(result.css).not.toContain('gap: 20px'); + }); + + test('should handle CSS animations and transitions', async () => { + const input = ` + .animated { + transition: all 0.3s ease; + transition: all 0.5s ease-in-out; + animation: slideIn 0.3s ease; + animation: slideIn 0.5s ease-out; + } + `; + + const result = await postcss([plugin()]).process(input, { + from: undefined, + }); + + expect(result.css).toContain('transition: all 0.5s ease-in-out'); + expect(result.css).toContain('animation: slideIn 0.5s ease-out'); + expect(result.css).not.toContain('transition: all 0.3s ease'); + expect(result.css).not.toContain('animation: slideIn 0.3s ease'); + }); + }); + + describe('CSS Output Formatting', () => { + test('should preserve CSS formatting and structure', async () => { + const input = ` + .formatted { + color: red; + color: blue; + + /* Comment */ + background: white; + } + `; + + const result = await postcss([plugin()]).process(input, { + from: undefined, + }); + + expect(result.css).toContain('color: blue'); + expect(result.css).toContain('background: white'); + expect(result.css).toContain('/* Comment */'); + expect(result.css).not.toContain('color: red'); + }); + + test('should handle CSS with multiple rules and spacing', async () => { + const input = ` + .rule1 { + color: red; + color: blue; + } + + .rule2 { + margin: 10px; + margin: 20px; + } + + .rule3 { + padding: 5px; + } + `; + + const result = await postcss([plugin()]).process(input, { + from: undefined, + }); + + expect(result.css).toContain('color: blue'); + expect(result.css).toContain('margin: 20px'); + expect(result.css).toContain('padding: 5px'); + expect(result.css).not.toContain('color: red'); + expect(result.css).not.toContain('margin: 10px'); + }); + }); + + describe('Error Handling Integration', () => { + test('should handle malformed CSS without crashing', async () => { + const input = ` + .malformed { + color: red + color: blue; + } + `; + + // PostCSS will throw a syntax error for malformed CSS before our plugin runs + await expect(async () => { + await postcss([plugin()]).process(input, { from: undefined }); + }).rejects.toThrow('Missed semicolon'); + }); + + test('should handle empty CSS input', async () => { + const input = ''; + + const result = await postcss([plugin()]).process(input, { + from: undefined, + }); + expect(result.css).toBe(''); + }); + + test('should handle CSS with only comments', async () => { + const input = ` + /* This is a comment */ + /* Another comment */ + `; + + const result = await postcss([plugin()]).process(input, { + from: undefined, + }); + expect(result.css).toContain('/* This is a comment */'); + expect(result.css).toContain('/* Another comment */'); + }); + }); + + describe('Performance Integration', () => { + test('should handle large CSS files efficiently', async () => { + // Generate a large CSS file with many rules + const rules = Array.from( + { length: 1000 }, + (_, i) => ` + .rule-${i} { + color: red; + color: blue; + margin: ${i}px; + margin: ${i * 2}px; + } + `, + ).join('\n'); + + const input = rules; + + const startTime = Date.now(); + const result = await postcss([plugin()]).process(input, { + from: undefined, + }); + const endTime = Date.now(); + + // Should complete within reasonable time + expect(endTime - startTime).toBeLessThan(2000); + + // Should contain the expected output + expect(result.css).toContain('color: blue'); + expect(result.css).toContain('margin: 1998px'); + expect(result.css).not.toContain('color: red'); + expect(result.css).not.toContain('margin: 999px'); + }); + + test('should handle CSS with many vendor prefixes efficiently', async () => { + const vendorRules = Array.from( + { length: 100 }, + (_, i) => ` + .vendor-${i} { + -webkit-transform: rotate(${i}deg); + -moz-transform: rotate(${i}deg); + -ms-transform: rotate(${i}deg); + -o-transform: rotate(${i}deg); + transform: rotate(${i}deg); + } + `, + ).join('\n'); + + const input = vendorRules; + + const startTime = Date.now(); + const result = await postcss([plugin()]).process(input, { + from: undefined, + }); + const endTime = Date.now(); + + // Should complete within reasonable time + expect(endTime - startTime).toBeLessThan(1000); + + // All vendor prefixes should be preserved + expect(result.css).toContain('-webkit-transform: rotate(0deg)'); + expect(result.css).toContain('-moz-transform: rotate(0deg)'); + expect(result.css).toContain('-ms-transform: rotate(0deg)'); + expect(result.css).toContain('-o-transform: rotate(0deg)'); + expect(result.css).toContain('transform: rotate(0deg)'); + }); + }); +}); diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 0000000..010ab5f --- /dev/null +++ b/docs/index.html @@ -0,0 +1,501 @@ + + + + + + PostCSS Remove Duplicate Values - Official Playground + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+ +
+
+

+ PostCSS Remove Duplicate Values +

+

+ Official Interactive Playground +

+
+
+ +
+
+
+ +
+ +
+
+

+
+ +
+ Plugin Configuration +

+
+
+ + +

+ Leave empty to process all selectors +

+
+
+ +
+
+

+ Remove empty rules by default +

+

+ Toggle to preserve empty rules +

+
+ +
+
+
+
+
+ + +
+
+
+

+ Quick Test Examples +

+

Select a test scenario to begin

+
+ +
+ + + + + + + +
+
+
+ + +
+
+
+

+
+ +
+ Input CSS +

+
+ +
+ +
+
+

+
+
+ +
+ Output CSS +
+
+ + +
+

+
+ +
+
+ + +
+ +
+ + +
+
+

+
+ +
+ Processing Results +

+ +
+
+
0
+
Input Rules
+
+
+
0
+
Output Rules
+
+
+
+ 0 +
+
Duplicates Removed
+
+
+
+ 0 +
+
Empty Rules Removed
+
+
+
0
+
Rules Skipped
+
+
+
+
+ + +
+
+

+
+ +
+ Plugin Features +

+ +
+
+
+ +
+

Duplicate Removal

+

+ Intelligently removes duplicate CSS properties while preserving + the last declaration +

+
+
+
+ +
+

!important Handling

+

+ Properly handles !important declarations with priority-based + logic +

+
+
+
+ +
+

Vendor Prefixes

+

+ Smart handling of vendor-prefixed properties and fallbacks +

+
+
+
+ +
+

Selector Filtering

+

+ Process only specific selectors using string, RegExp, or + function matching +

+
+
+
+ +
+

Empty Rule Cleanup

+

+ Optionally remove empty CSS rules for cleaner output +

+
+
+
+ +
+

Zero Configuration

+

+ Works out of the box with sensible defaults and optional + customization +

+
+
+
+
+
+ + + + + + + + + + diff --git a/docs/robots.txt b/docs/robots.txt new file mode 100644 index 0000000..14267e9 --- /dev/null +++ b/docs/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Allow: / \ No newline at end of file diff --git a/docs/scripts/index.js b/docs/scripts/index.js new file mode 100644 index 0000000..3b4a3e1 --- /dev/null +++ b/docs/scripts/index.js @@ -0,0 +1,583 @@ +// Configuration +const CONFIG = { + ANIMATION_DURATION: 4000, + SNACKBAR_POSITION: 'bottom-left', + TAB_INDENT: ' ', + SELECTOR_REGEX: /([^{]+)\{([^}]*)\}/g, + DECLARATION_SEPARATOR: ';', +}; + +// DOM Elements Cache +const DOM = { + inputCSS: null, + outputCSS: null, + selectorInput: null, + preserveEmptyToggle: null, + stats: { + inputRules: null, + outputRules: null, + duplicatesRemoved: null, + emptyRulesRemoved: null, + rulesSkipped: null, + }, +}; + +// Test examples with better organization +const EXAMPLES = { + basic: { + name: 'Basic Duplicates', + description: 'Simple duplicate property removal', + css: `.button { + color: red; + color: blue; + margin: 10px; + margin: 20px; + padding: 5px; +}`, + }, + important: { + name: '!important Handling', + description: 'Handle important declarations', + css: `.button { + color: red !important; + color: blue; + font-weight: normal; + font-weight: bold !important; + margin: 10px; + margin: 20px !important; +}`, + }, + vendor: { + name: 'Vendor Prefixes', + description: 'Browser compatibility handling', + css: `.button { + -webkit-transform: translateX(10px); + -moz-transform: translateX(10px); + transform: translateX(10px); + -webkit-border-radius: 5px; + border-radius: 5px; +}`, + }, + colors: { + name: 'Color Variations', + description: 'Different color formats', + css: `.button { + color: #fff; + color: #ffffff; + background: rgb(255, 255, 255); + background: white; + border: 1px solid #000; + border: 1px solid black; +}`, + }, + selector: { + name: 'Selector Filtering', + description: 'Target specific selectors', + css: `.button { + color: red; + color: blue; + margin: 10px; + margin: 20px; + padding: 5px; +} + +.demo { + color: red; + color: blue; +}`, + }, + complex: { + name: 'Complex Selectors', + description: 'Advanced CSS patterns', + css: `[data-button="primary"] { + color: red; + color: blue; +} + +.button.primary:hover { + background: white; + background: #fff; +} + +#main .container > .row .col { + margin: 10px; + margin: 20px; +}`, + }, + empty: { + name: 'Empty Rules', + description: 'Handle empty CSS rules', + css: `.button { + color: red; + color: blue; +} + +.empty-rule { +} + +.another-empty { +}`, + }, +}; + +/** + * Load a CSS example into the input editor + * @param {string} type - Example type key + */ +function loadExample(type) { + const example = EXAMPLES[type]; + if (!example) { + showSnackbar(`Example "${type}" not found`, 'error'); + return; + } + + if (!DOM.inputCSS) { + console.error('Input editor not initialized'); + return; + } + + // Load example CSS + DOM.inputCSS.value = example.css; + + // Clear previous output and reset stats + clearOutput(); + resetStats(); + + showSnackbar(`Loaded: ${example.name}`, 'success'); +} + +/** + * Process CSS using the PostCSS simulation + */ +async function processCSS() { + if (!DOM.inputCSS || !DOM.outputCSS) { + showSnackbar('Editors not initialized', 'error'); + return; + } + + const inputText = DOM.inputCSS.value.trim(); + if (!inputText) { + showSnackbar('Please enter some CSS to process', 'error'); + return; + } + + try { + const options = { + selector: DOM.selectorInput?.value.trim() || '', + preserveEmpty: DOM.preserveEmptyToggle?.checked || false, + }; + + const result = await simulatePostCSS(inputText, options); + updateOutput(result); + updateStats(result); + + showSnackbar('CSS processed successfully!', 'success'); + } catch (error) { + console.error('Processing error:', error); + showSnackbar(`Error processing CSS: ${error.message}`, 'error'); + } +} + +/** + * Simulate PostCSS processing with optimized parsing + * @param {string} css - Input CSS + * @param {Object} options - Processing options + * @returns {Object} Processing results + */ +async function simulatePostCSS(css, options = {}) { + const { selector, preserveEmpty = false } = options; + + const rules = []; + let duplicates = 0; + let emptyRulesRemoved = 0; + let rulesSkipped = 0; + + // Use cached regex for better performance + const ruleRegex = CONFIG.SELECTOR_REGEX; + let match; + + while ((match = ruleRegex.exec(css)) !== null) { + const selectorText = match[1].trim(); + const declarations = match[2].trim(); + + // Handle selector filtering + if (selector && !matchSelector(selectorText, selector)) { + rules.push({ + selector: selectorText, + content: declarations, + isEmpty: !declarations.trim(), + skipped: true, + }); + rulesSkipped++; + continue; + } + + // Process declarations + if (declarations) { + const processedDeclarations = processDeclarations(declarations); + const originalCount = countDeclarations(declarations); + const processedCount = countDeclarations(processedDeclarations); + duplicates += originalCount - processedCount; + + rules.push({ + selector: selectorText, + content: processedDeclarations, + isEmpty: !processedDeclarations.trim(), + skipped: false, + }); + } else { + // Handle empty rules + if (!preserveEmpty) { + emptyRulesRemoved++; + continue; + } + + rules.push({ + selector: selectorText, + content: '', + isEmpty: true, + skipped: false, + }); + } + } + + // Build result CSS efficiently + const resultCSS = rules + .filter(rule => !rule.skipped || rule.content.trim()) + .map(rule => `${rule.selector} {\n ${rule.content}\n}`) + .join('\n\n'); + + return { + css: resultCSS, + rules: rules.length, + duplicates, + emptyRulesRemoved, + rulesSkipped, + }; +} + +/** + * Process CSS declarations to remove duplicates + * @param {string} declarations - Raw declarations string + * @returns {string} Processed declarations + */ +function processDeclarations(declarations) { + const declarationMap = new Map(); + const decls = declarations + .split(CONFIG.DECLARATION_SEPARATOR) + .map(d => d.trim()) + .filter(d => d); + + // Process declarations in reverse order (last wins) + for (let i = decls.length - 1; i >= 0; i--) { + const decl = decls[i]; + const [property, ...valueParts] = decl.split(':'); + + if (!property || !valueParts.length) continue; + + const propertyName = property.trim(); + const value = valueParts.join(':').trim(); + + // Skip if already processed (last declaration wins) + if (declarationMap.has(propertyName)) continue; + + declarationMap.set(propertyName, `${propertyName}: ${value}`); + } + + // Convert back to string, maintaining order + return Array.from(declarationMap.values()).join(';\n '); +} + +/** + * Check if selector matches filter pattern + * @param {string} selectorText - CSS selector + * @param {string} filter - Filter pattern + * @returns {boolean} True if matches + */ +function matchSelector(selectorText, filter) { + if (!filter) return true; + + // Simple pattern matching + const patterns = filter.split(',').map(p => p.trim()); + return patterns.some(pattern => { + if (pattern.startsWith('.')) { + return selectorText.includes(pattern); + } else if (pattern.startsWith('#')) { + return selectorText.includes(pattern); + } else { + return selectorText.includes(pattern); + } + }); +} + +/** + * Count declarations in a CSS string + * @param {string} css - CSS string + * @returns {number} Declaration count + */ +function countDeclarations(css) { + return css.split(CONFIG.DECLARATION_SEPARATOR).filter(d => d.trim()).length; +} + +/** + * Clear output editor and reset statistics + */ +function clearOutput() { + if (DOM.outputCSS) { + DOM.outputCSS.value = ''; + hideCopyButton(); + } +} + +/** + * Clear all results (output and statistics) + */ +function clearResults() { + clearOutput(); + resetStats(); +} + +/** + * Reset all statistics to zero + */ +function resetStats() { + Object.values(DOM.stats).forEach(element => { + if (element) element.textContent = '0'; + }); + hideCopyButton(); +} + +/** + * Update output with processed CSS + * @param {Object} result - Processing result + */ +function updateOutput(result) { + if (DOM.outputCSS) { + DOM.outputCSS.value = result.css; + showCopyButton(); + } +} + +/** + * Update statistics display + * @param {Object} result - Processing result + */ +function updateStats(result) { + const { rules, duplicates, emptyRulesRemoved, rulesSkipped } = result; + + if (DOM.stats.inputRules) DOM.stats.inputRules.textContent = rules; + if (DOM.stats.outputRules) DOM.stats.outputRules.textContent = rules; + if (DOM.stats.duplicatesRemoved) + DOM.stats.duplicatesRemoved.textContent = duplicates; + if (DOM.stats.emptyRulesRemoved) + DOM.stats.emptyRulesRemoved.textContent = emptyRulesRemoved; + if (DOM.stats.rulesSkipped) DOM.stats.rulesSkipped.textContent = rulesSkipped; +} + +/** + * Show action buttons when there's content to work with + */ +function showCopyButton() { + const copyButton = document.getElementById('copyButton'); + const clearButton = document.getElementById('clearButton'); + + if (copyButton) { + copyButton.style.opacity = '1'; + copyButton.style.pointerEvents = 'auto'; + } + + if (clearButton) { + clearButton.style.opacity = '1'; + clearButton.style.pointerEvents = 'auto'; + } +} + +/** + * Hide action buttons when there's no content + */ +function hideCopyButton() { + const copyButton = document.getElementById('copyButton'); + const clearButton = document.getElementById('clearButton'); + + if (copyButton) { + copyButton.style.opacity = '0'; + copyButton.style.pointerEvents = 'none'; + } + + if (clearButton) { + clearButton.style.opacity = '0'; + clearButton.style.pointerEvents = 'none'; + } +} + +/** + * Copy results to clipboard + */ +async function copyResults() { + if (!DOM.outputCSS) return; + + const outputText = DOM.outputCSS.value; + if (!outputText.trim()) { + showSnackbar('No results to copy', 'warning'); + return; + } + + try { + await navigator.clipboard.writeText(outputText); + showSnackbar('Results copied to clipboard!', 'success'); + } catch (error) { + // Fallback for older browsers + fallbackCopy(outputText); + } +} + +/** + * Fallback copy method for older browsers + * @param {string} text - Text to copy + */ +function fallbackCopy(text) { + const textArea = document.createElement('textarea'); + textArea.value = text; + textArea.style.position = 'fixed'; + textArea.style.left = '-999999px'; + textArea.style.top = '-999999px'; + + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + + try { + document.execCommand('copy'); + showSnackbar('Results copied to clipboard!', 'success'); + } catch (error) { + showSnackbar('Failed to copy results', 'error'); + } finally { + document.body.removeChild(textArea); + } +} + +/** + * Show snackbar notification + * @param {string} message - Message to display + * @param {string} type - Message type (success, error, warning, info) + */ +function showSnackbar(message, type = 'info') { + const config = { + text: message, + duration: CONFIG.ANIMATION_DURATION, + pos: CONFIG.SNACKBAR_POSITION, + backgroundColor: getSnackbarColor(type), + textColor: '#ffffff', + width: 'auto', + showAction: true, + actionText: 'Dismiss', + actionTextColor: getSnackbarActionColor(type), + customClass: 'custom-snackbar', + }; + + Snackbar.show(config); +} + +/** + * Get snackbar background color based on type + * @param {string} type - Message type + * @returns {string} Hex color + */ +function getSnackbarColor(type) { + const colors = { + success: '#059669', + error: '#dc2626', + warning: '#d97706', + info: '#3b82f6', + }; + return colors[type] || colors.info; +} + +/** + * Get snackbar action color based on type + * @param {string} type - Message type + * @returns {string} Hex color + */ +function getSnackbarActionColor(type) { + const colors = { + success: '#a7f3d0', + error: '#fecaca', + warning: '#fed7aa', + info: '#bfdbfe', + }; + return colors[type] || colors.info; +} + +/** + * Initialize DOM element cache + */ +function initializeDOM() { + // Main editors + DOM.inputCSS = document.getElementById('inputCSS'); + DOM.outputCSS = document.getElementById('outputCSS'); + + // Form controls + DOM.selectorInput = document.getElementById('selectorInput'); + DOM.preserveEmptyToggle = document.getElementById('preserveEmptyToggle'); + + // Statistics elements + DOM.stats.inputRules = document.getElementById('inputRules'); + DOM.stats.outputRules = document.getElementById('outputRules'); + DOM.stats.duplicatesRemoved = document.getElementById('duplicatesRemoved'); + DOM.stats.emptyRulesRemoved = document.getElementById('emptyRulesRemoved'); + DOM.stats.rulesSkipped = document.getElementById('rulesSkipped'); + + // Validate critical elements + if (!DOM.inputCSS || !DOM.outputCSS) { + console.error('Critical DOM elements not found'); + return false; + } + + return true; +} + +/** + * Initialize editor event listeners + */ +function initializeEditors() { + if (!DOM.inputCSS) return; + + // Handle tab key for indentation + DOM.inputCSS.addEventListener('keydown', function (e) { + if (e.key === 'Tab') { + e.preventDefault(); + const start = this.selectionStart; + const end = this.selectionEnd; + + // Insert indentation + this.value = + this.value.substring(0, start) + + CONFIG.TAB_INDENT + + this.value.substring(end); + + // Update cursor position + this.selectionStart = this.selectionEnd = + start + CONFIG.TAB_INDENT.length; + } + }); +} + +/** + * Main initialization function + */ +function initialize() { + if (!initializeDOM()) { + console.error('Failed to initialize DOM'); + return; + } + + initializeEditors(); + + // Load initial example with delay to ensure DOM is ready + setTimeout(() => { + loadExample('basic'); + }, 100); +} + +// Initialize when DOM is ready +window.addEventListener('DOMContentLoaded', initialize); diff --git a/docs/styles/editor.css b/docs/styles/editor.css new file mode 100644 index 0000000..a7f42df --- /dev/null +++ b/docs/styles/editor.css @@ -0,0 +1,13 @@ +.code-editor { + font-family: monospace; + font-size: 14px; + line-height: 1.6; + background: 0; + border: none !important; + outline: none !important; +} + +.code-editor:focus { + border: none !important; + outline: none !important; +} diff --git a/docs/styles/index.css b/docs/styles/index.css new file mode 100644 index 0000000..214b332 --- /dev/null +++ b/docs/styles/index.css @@ -0,0 +1,363 @@ +.gradient-bg { + background: linear-gradient(135deg, #1e293b 0%, #0f172a 100%); +} + +.glass-effect { + background: rgba(30, 41, 59, 0.8); + backdrop-filter: blur(10px); + border: 1px solid rgba(148, 163, 184, 0.2); +} + +.scrollbar-thin { + scrollbar-width: thin; + scrollbar-color: #475569 #1e293b; +} + +.scrollbar-thin::-webkit-scrollbar { + width: 6px; +} + +.scrollbar-thin::-webkit-scrollbar-track { + background: #1e293b; + border-radius: 3px; +} + +.scrollbar-thin::-webkit-scrollbar-thumb { + background: #475569; + border-radius: 3px; +} + +.scrollbar-thin::-webkit-scrollbar-thumb:hover { + background: #64748b; +} + +textarea::-webkit-scrollbar { + width: 8px; +} + +textarea::-webkit-scrollbar-track { + background: #1e293b; +} + +textarea::-webkit-scrollbar-thumb { + background: #475569; + border-radius: 4px; +} + +textarea::-webkit-scrollbar-thumb:hover { + background: #64748b; +} + +.toggle-switch { + position: relative; + display: inline-block; + width: 50px; + height: 24px; +} + +.toggle-switch input { + opacity: 0; + width: 0; + height: 0; +} + +.toggle-slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: #475569; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + border-radius: 24px; + box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.2); +} + +.toggle-slider:before { + position: absolute; + content: ''; + height: 18px; + width: 18px; + left: 3px; + bottom: 3px; + background-color: white; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + border-radius: 50%; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); +} + +input:checked + .toggle-slider { + background-color: #3b82f6; +} + +input:checked + .toggle-slider:before { + transform: translateX(26px); +} + +.fade-in-up { + animation: fadeInUp 0.6s ease-out forwards; + opacity: 0; + transform: translateY(20px); +} + +@keyframes fadeInUp { + to { + opacity: 1; + transform: translateY(0); + } +} + +.example-btn { + background-color: #334155; + border: 1px solid #475569; + color: #cbd5e1; + padding: 12px 16px; + border-radius: 8px; + font-size: 14px; + font-weight: 500; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + cursor: pointer; + position: relative; + overflow: hidden; +} + +.example-btn:hover { + background-color: #475569; + border-color: #64748b; + color: #ffffff; + box-shadow: 0 8px 25px -8px rgba(0, 0, 0, 0.2); + transform: translateY(-2px); +} + +.example-btn:focus { + outline: none; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.3); + transform: translateY(-1px); +} + +.example-btn:active { + transform: translateY(0); + transition: all 0.1s ease; +} + +.example-btn:focus { + outline: none; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.3); +} + +.example-text { + display: block; + text-align: center; + position: relative; + z-index: 1; +} + +.stat-card { + padding: 24px; + border-radius: 8px; + border: 1px solid; + text-align: center; +} + +.stat-number { + font-size: 24px; + font-weight: 700; + margin-bottom: 4px; +} + +.stat-label { + font-size: 14px; + font-weight: 500; +} + +.feature-card { + padding: 16px; + border-radius: 8px; + background-color: #334155; + border: 1px solid #475569; + text-align: center; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + cursor: pointer; +} + +.feature-card:hover { + box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.2); + border-color: #64748b; + transform: translateY(-2px); +} + +.feature-card:focus { + outline: none; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2); +} + +/* Additional utility classes */ +.bg-dark-900 { + background-color: #0f172a; +} + +.bg-dark-800 { + background-color: #1e293b; +} + +.bg-dark-700 { + background-color: #334155; +} + +.bg-dark-600 { + background-color: #475569; +} + +.text-green-400 { + color: #4ade80; +} + +.text-blue-400 { + color: #60a5fa; +} + +/* Fix header alignment for CSS editors */ +.bg-gradient-to-r.from-blue-600.to-blue-700, +.bg-gradient-to-r.from-green-600.to-green-700 { + border-radius: 12px 12px 0 0; + border-bottom: none; + margin: 0; +} + +@media (max-width: 768px) { + .grid { + grid-template-columns: repeat(2, 1fr); + } + + .stat-card { + padding: 16px; + } + + .stat-number { + font-size: 20px; + } +} + +button, +input { + outline: none; +} + +/* Smooth button focus states */ +button:focus-visible, +input:focus-visible { + outline: none; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.3); + transform: translateY(-1px); +} + +/* Enhanced button transitions */ +button { + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +button:hover { + transform: translateY(-2px); + box-shadow: 0 8px 25px -8px rgba(0, 0, 0, 0.3); +} + +button:active { + transform: translateY(0); + transition: all 0.1s ease; +} + +* { + transition-property: + color, background-color, border-color, text-decoration-color, fill, stroke, + opacity, box-shadow, transform, filter, backdrop-filter; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} + +.shadow-3xl { + box-shadow: 0 35px 60px -12px rgba(0, 0, 0, 0.25); +} + +.backdrop-blur-xl { + backdrop-filter: blur(24px); +} + +.bg-gradient-to-br { + background-image: linear-gradient(to bottom right, var(--tw-gradient-stops)); +} + +/* Smooth focus transitions for all form elements */ +input[type='text'], +textarea { + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + border: 1px solid transparent; +} + +/* Specific styling for the CSS textareas */ +#inputCSS, +#outputCSS { + border: 1px solid #475569; + border-top: none; + border-radius: 0 0 12px 12px; + margin: 0; + transition: border-color 0.2s ease; +} + +/* Custom Snackbar styling to match playground theme */ +.custom-snackbar { + font-family: 'JetBrains Mono', monospace !important; + font-size: 14px !important; + font-weight: 500 !important; + border-radius: 12px !important; + box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.3) !important; + border: 1px solid rgba(255, 255, 255, 0.1) !important; + backdrop-filter: blur(10px) !important; +} + +.custom-snackbar .snackbar-content { + padding: 16px 20px !important; +} + +.custom-snackbar .snackbar-action { + font-weight: 600 !important; + text-transform: uppercase !important; + letter-spacing: 0.5px !important; + border-radius: 8px !important; + padding: 8px 16px !important; + transition: all 0.2s ease !important; +} + +.custom-snackbar .snackbar-action:hover { + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2) !important; +} + +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(30px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.fade-in-up { + animation: fadeInUp 0.8s cubic-bezier(0.4, 0, 0.2, 1) forwards; + opacity: 0; + transform: translateY(30px); +} + +/* Screen Reader Only - Hidden but indexed by search engines */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} diff --git a/index.d.ts b/index.d.ts index b07e7b3..068cb8b 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,14 +1,14 @@ import { Processor, Plugin } from 'postcss'; type Options = { - selector?: (selector: string) => boolean | string | RegExp; - preserveEmpty?: boolean; + selector?: (selector: string) => boolean | string | RegExp; + preserveEmpty?: boolean; }; declare const postcss: true; declare function pluginCreator(options?: Options): Plugin | Processor; declare namespace pluginCreator { - export { postcss, Options }; + export { postcss, Options }; } -export = pluginCreator; \ No newline at end of file +export = pluginCreator; diff --git a/index.js b/index.js index dbd4cc8..f94783e 100644 --- a/index.js +++ b/index.js @@ -1,6 +1,6 @@ -"use strict"; +'use strict'; -const PLUGIN_NAME = "postcss-remove-duplicate-values"; +const PLUGIN_NAME = 'postcss-remove-duplicate-values'; /** * Options For The Plugin. @@ -29,11 +29,11 @@ const PLUGIN_NAME = "postcss-remove-duplicate-values"; * @returns {boolean} */ const matchSelector = (selector, walkSelector) => { - if (typeof selector === "string") { + if (typeof selector === 'string') { if (walkSelector.indexOf(selector) !== -1) return true; } else if (selector instanceof RegExp) { if (selector.test(walkSelector)) return true; - } else if (typeof selector === "function") { + } else if (typeof selector === 'function') { if (selector(walkSelector)) return true; } return false; @@ -44,12 +44,12 @@ const matchSelector = (selector, walkSelector) => { * @param {string} value * @returns {boolean} */ -const isValidFallbackValue = (value) => { +const isValidFallbackValue = value => { return ( - value.indexOf("-webkit-") !== -1 || - value.indexOf("-moz-") !== -1 || - value.indexOf("-ms-") !== -1 || - value.indexOf("-o-") !== -1 + value.indexOf('-webkit-') !== -1 || + value.indexOf('-moz-') !== -1 || + value.indexOf('-ms-') !== -1 || + value.indexOf('-o-') !== -1 ); }; @@ -58,10 +58,10 @@ const isValidFallbackValue = (value) => { * @param {import('postcss').Rule} rule * @returns {boolean} */ -const isEmpty = (rule) => { +const isEmpty = rule => { return ( rule.nodes.length === 0 || - rule.nodes.filter((v) => v.type !== "comment").length === 0 + rule.nodes.filter(v => v.type !== 'comment').length === 0 ); }; @@ -76,7 +76,7 @@ const plugin = (options = {}) => { return { postcssPlugin: PLUGIN_NAME, prepare({ root }) { - root.walkRules((rule) => { + root.walkRules(rule => { // if selector is passed and its fail to match with rule selector // then this plugin opration will not applied if (selector) { @@ -101,7 +101,7 @@ const plugin = (options = {}) => { */ const fallbackRuleDeclarations = new Map(); - rule.walkDecls((declaration) => { + rule.walkDecls(declaration => { const key = declaration.prop; const value = declaration.value.trim(); const important = Boolean(declaration.important); @@ -110,7 +110,7 @@ const plugin = (options = {}) => { let currentRemoved = false; if (isValidFallback) { const fallbackRuleObject = fallbackRuleDeclarations.get( - `${key}:${value}` + `${key}:${value}`, ); if (fallbackRuleObject) { if (fallbackRuleObject.important) { diff --git a/jest.config.mjs b/jest.config.mjs new file mode 100644 index 0000000..5f2e532 --- /dev/null +++ b/jest.config.mjs @@ -0,0 +1,18 @@ +export default { + testEnvironment: 'node', + testMatch: ['/__tests__/**/*.test.js'], + collectCoverageFrom: [ + 'src/index.js', + '!**/node_modules/**', + '!**/coverage/**', + ], + coverageThreshold: { + global: { + branches: 90, + functions: 90, + lines: 90, + statements: 90, + }, + }, + verbose: true, +}; diff --git a/package.json b/package.json index 0f36720..8b1af30 100644 --- a/package.json +++ b/package.json @@ -1,35 +1,44 @@ { "name": "postcss-remove-duplicate-values", - "version": "1.0.0", - "description": "A PostCSS plugin that removes duplicate CSS property values within rules, optimizing stylesheet size and improving maintainability.", - "main": "index.js", - "types": "index.d.ts", - "files": [ - "index.js", - "index.d.ts", - "LICENSE", - "README.md" - ], + "private": true, + "version": "2.0.0", + "description": "๐Ÿš€ PostCSS plugin that intelligently removes duplicate CSS properties, reduces bundle size, and improves CSS maintainability. Handles !important declarations, vendor prefixes, and selector filtering with zero configuration.", + "main": "./src/index.js", + "types": "./src/index.d.ts", + "scripts": { + "build": "node scripts/build.mjs", + "release": "pnpm build && cd dist && npm publish --access public --tag latest", + "release:latest": "pnpm release --tag latest", + "release:rc": "pnpm release --tag rc", + "release:dry": "pnpm release --dry-run --tag dry", + "test": "jest", + "test:watch": "jest --watch", + "test:coverage": "jest --coverage", + "format": "prettier --write ." + }, "keywords": [ "css", "postcss", - "postcss-plugin" + "postcss-plugin", + "performance", + "bundle-optimization" ], "repository": { "type": "git", - "url": "git+https://github.com/xettri/postcss-remove-duplicate-values.git" + "url": "https://github.com/xettri/postcss-remove-duplicate-values.git" }, "author": { "name": "Bharat Rawat", - "url": "https://bharatrawat.com", - "email": "imbharatrawat@gmail.com" + "url": "https://bharatrawat.com" }, "license": "MIT", - "bugs": { - "url": "https://github.com/xettri/postcss-remove-duplicate-values/issues" - }, "homepage": "https://github.com/xettri/postcss-remove-duplicate-values#readme", "peerDependencies": { "postcss": "^8.4" + }, + "devDependencies": { + "jest": "^29.7.0", + "postcss": "^8.4.31", + "prettier": "^3.6.2" } } diff --git a/scripts/build.mjs b/scripts/build.mjs new file mode 100644 index 0000000..e812ee6 --- /dev/null +++ b/scripts/build.mjs @@ -0,0 +1,71 @@ +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import pkg from '../package.json' with { type: 'json' }; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const rootDir = path.join(__dirname, '..'); +const srcDir = path.resolve(rootDir, 'src'); +const distDir = path.resolve(rootDir, 'dist'); + +function ensureDist() { + if (fs.existsSync(distDir)) { + fs.rmSync(distDir, { recursive: true, force: true }); + } + fs.mkdirSync(distDir, { recursive: true }); +} + +function copySrcFiles() { + const srcFiles = fs.readdirSync(srcDir); + for (const file of srcFiles) { + const srcFilePath = path.join(srcDir, file); + const distFilePath = path.join(distDir, file); + + if (fs.statSync(srcFilePath).isFile()) { + fs.copyFileSync(srcFilePath, distFilePath); + console.log(`โœ… Copied: ${file}`); + } + } +} + +function copyPluginRootFiles() { + const rootFiles = ['LICENSE', 'README.md']; + for (const file of rootFiles) { + const rootFilePath = path.join(rootDir, file); + const distFilePath = path.join(distDir, file); + + if (fs.existsSync(rootFilePath)) { + fs.copyFileSync(rootFilePath, distFilePath); + console.log(`โœ… Copied: ${file}`); + } + } +} + +function createPackageJson() { + const data = { ...pkg }; + delete data.scripts; + delete data.private; + delete data.devDependencies; + + const packageJson = { + ...data, + main: './index.js', + types: './index.d.ts', + }; + fs.writeFileSync( + path.join(distDir, 'package.json'), + JSON.stringify(packageJson, null, 2), + ); +} + +async function build() { + ensureDist(); + copySrcFiles(); + copyPluginRootFiles(); + createPackageJson(); + console.log('๐ŸŽ‰ Build completed successfully!'); +} + +build(); diff --git a/src/index.d.ts b/src/index.d.ts new file mode 100644 index 0000000..068cb8b --- /dev/null +++ b/src/index.d.ts @@ -0,0 +1,14 @@ +import { Processor, Plugin } from 'postcss'; + +type Options = { + selector?: (selector: string) => boolean | string | RegExp; + preserveEmpty?: boolean; +}; + +declare const postcss: true; +declare function pluginCreator(options?: Options): Plugin | Processor; +declare namespace pluginCreator { + export { postcss, Options }; +} + +export = pluginCreator; diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..18115c6 --- /dev/null +++ b/src/index.js @@ -0,0 +1,199 @@ +'use strict'; + +const PLUGIN_NAME = 'postcss-remove-duplicate-values'; + +/** + * Options For The Plugin. + * @typedef {Object} Options + * @property {string | RegExp | ((selector: string) => boolean)} [selector] + * @property {boolean} [preserveEmpty=false] + */ + +/** + * Rule Declarations Map Value + * @typedef {Object} RuleDeclarationsMapValue + * @property {string} [value] + * @property {() => void} remove + * @property {boolean} important + */ + +/** + * Rule Declarations Map + * @typedef {Map} RuleDeclarationsMap + */ + +/** + * Determines if a CSS selector should be processed based on the provided selector filter. + * This allows targeting specific selectors for duplicate value removal. + * + * @param {Options['selector']} selector - The selector filter (string, regex, or function) + * @param {string} walkSelector - The CSS selector to check + * @returns {boolean} - True if the selector should be processed + */ +const matchSelector = (selector, walkSelector) => { + if (typeof selector === 'string') { + if (walkSelector.indexOf(selector) !== -1) return true; + } else if (selector instanceof RegExp) { + if (selector.test(walkSelector)) return true; + } else if (typeof selector === 'function') { + if (selector(walkSelector)) return true; + } + return false; +}; + +/** + * Identifies vendor-prefixed CSS properties that should be treated as fallbacks. + * Vendor prefixes are preserved alongside standard properties to maintain browser compatibility. + * + * @param {string} property - The CSS property name to check + * @returns {boolean} - True if the property is vendor-prefixed + */ +const isValidFallbackValue = property => { + return ( + property.startsWith('-webkit-') || + property.startsWith('-moz-') || + property.startsWith('-ms-') || + property.startsWith('-o-') + ); +}; + +/** + * Determines if a CSS rule is empty (contains no properties or only comments). + * Empty rules can be optionally removed to clean up the stylesheet. + * + * @param {import('postcss').Rule} rule - The CSS rule to check + * @returns {boolean} - True if the rule is empty + */ +const isEmpty = rule => { + return ( + rule.nodes.length === 0 || + rule.nodes.filter(v => v.type !== 'comment').length === 0 + ); +}; + +/** + * PostCSS plugin that removes duplicate CSS property values within rules. + * + * @type {import('postcss').PluginCreator} + * @param {Options} options - Plugin configuration options + * @return {import('postcss').Plugin} - PostCSS plugin instance + */ +const plugin = (options = {}) => { + const { selector, preserveEmpty = false } = options; + + return { + postcssPlugin: PLUGIN_NAME, + Once(root) { + try { + root.walkRules(rule => { + try { + // Apply selector filtering if specified - only process matching rules + if (selector) { + if (!matchSelector(selector, rule.selector)) { + return; + } + } + + if (isEmpty(rule)) { + // Remove empty rules unless explicitly preserved + if (preserveEmpty !== true) { + rule.remove(); + } + } else { + // Track regular properties and vendor-prefixed properties separately + const ruleDeclarations = new Map(); + const fallbackRuleDeclarations = new Map(); + + rule.walkDecls(declaration => { + try { + // Validate declaration before processing + if (!declaration || !declaration.prop || !declaration.value) { + return; // Skip invalid declarations silently + } + + const key = declaration.prop; + const value = declaration.value.trim(); + const important = Boolean(declaration.important); + const isValidFallback = isValidFallbackValue(key); + + let currentRemoved = false; + + if (isValidFallback) { + // Handle vendor-prefixed properties as fallbacks + // These are preserved alongside standard properties for browser compatibility + const existingFallback = fallbackRuleDeclarations.get(key); + if (existingFallback) { + if (existingFallback.important) { + if (important) { + // Both are important - remove the old one (last wins) + existingFallback.declaration.remove(); + } else { + // Current is not important - remove it + declaration.remove(); + currentRemoved = true; + } + } else { + // Old one is not important - remove it + existingFallback.declaration.remove(); + } + } + } else if (ruleDeclarations.has(key)) { + // Handle duplicate standard properties + const data = ruleDeclarations.get(key); + if (data.important) { + if (important) { + // Both are important - remove the old one (last wins) + data.declaration.remove(); + } else { + // Current is not important - remove it (important wins) + declaration.remove(); + currentRemoved = true; + } + } else { + // Remove the old declaration (last wins) + data.declaration.remove(); + } + } + + // Skip map updates if current declaration was removed + if (currentRemoved) return; + + // Store the current declaration for future duplicate detection + if (isValidFallback) { + fallbackRuleDeclarations.set(key, { + important, + declaration, + }); + } else { + ruleDeclarations.set(key, { + value, + important, + declaration, + }); + } + } catch (declarationError) { + // Continue processing other declarations silently + // In production, we don't want to spam logs + } + }); + + // Clean up maps to prevent memory leaks + ruleDeclarations.clear(); + fallbackRuleDeclarations.clear(); + } + } catch (ruleError) { + // Continue processing other rules silently + // In production, we don't want to spam logs + } + }); + } catch (rootError) { + // Only log critical errors that prevent the plugin from working + console.error(`[${PLUGIN_NAME}] Critical error:`, rootError); + throw rootError; // Re-throw critical errors + } + }, + }; +}; + +plugin.postcss = true; +module.exports = plugin;