From b163cb615d5011f687823e5be39309571aac0266 Mon Sep 17 00:00:00 2001 From: xettri Date: Tue, 12 Aug 2025 00:57:10 +0530 Subject: [PATCH 01/11] refactor code structure --- src/index.d.ts | 14 ++++ src/index.js | 178 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 192 insertions(+) create mode 100644 src/index.d.ts create mode 100644 src/index.js diff --git a/src/index.d.ts b/src/index.d.ts new file mode 100644 index 0000000..b07e7b3 --- /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; \ No newline at end of file diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..dbd4cc8 --- /dev/null +++ b/src/index.js @@ -0,0 +1,178 @@ +"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 + */ + +/** + * function to check is walkSelector is valid selector as per config passed + * @param {Options['selector']} selector + * @param {string} walkSelector + * @returns {boolean} + */ +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; +}; + +/** + * function to check is value is valid fallback value + * @param {string} value + * @returns {boolean} + */ +const isValidFallbackValue = (value) => { + return ( + value.indexOf("-webkit-") !== -1 || + value.indexOf("-moz-") !== -1 || + value.indexOf("-ms-") !== -1 || + value.indexOf("-o-") !== -1 + ); +}; + +/** + * function to check is node is empty or not + * @param {import('postcss').Rule} rule + * @returns {boolean} + */ +const isEmpty = (rule) => { + return ( + rule.nodes.length === 0 || + rule.nodes.filter((v) => v.type !== "comment").length === 0 + ); +}; + +/** + * PostCSS plugin to remove duplicate values from CSS selectors. + * @type {import('postcss').PluginCreator} + * @param {Options} options + * @return {import('postcss').Plugin} + */ +const plugin = (options = {}) => { + const { selector, preserveEmpty = false } = options; + return { + postcssPlugin: PLUGIN_NAME, + prepare({ root }) { + root.walkRules((rule) => { + // if selector is passed and its fail to match with rule selector + // then this plugin opration will not applied + if (selector) { + if (!matchSelector(selector, rule.selector)) { + return; + } + } + + if (isEmpty(rule)) { + // it will remove empty selector if preserveEmpty is not true + if (preserveEmpty !== true) { + rule.remove(); + } + } else { + /** + * @type {RuleDeclarationsMap} + */ + const ruleDeclarations = new Map(); + + /** + * @type {RuleDeclarationsMap} + */ + const fallbackRuleDeclarations = new Map(); + + rule.walkDecls((declaration) => { + const key = declaration.prop; + const value = declaration.value.trim(); + const important = Boolean(declaration.important); + const isValidFallback = isValidFallbackValue(value); + + let currentRemoved = false; + if (isValidFallback) { + const fallbackRuleObject = fallbackRuleDeclarations.get( + `${key}:${value}` + ); + if (fallbackRuleObject) { + if (fallbackRuleObject.important) { + if (important) { + fallbackRuleObject.remove(); + } else { + declaration.remove(); + currentRemoved = true; + } + } else { + fallbackRuleObject.remove(); + } + } + } else if (ruleDeclarations.has(key)) { + const data = ruleDeclarations.get(key); + if (data.important) { + // if current value is important then it will overwrite previous style + if (important) { + data.remove(); + } else { + // if current value is not important older style will overwrite + declaration.remove(); + currentRemoved = true; + } + } else { + data.remove(); + } + } + + // if current node is removed then no need to update map + if (currentRemoved) return; + + if (isValidFallback) { + const k = `${key}:${value}`; + fallbackRuleDeclarations.set(k, { + important, + remove: () => { + declaration.remove(); + // delete entry from map as well + fallbackRuleDeclarations.delete(k); + }, + }); + } else { + ruleDeclarations.set(key, { + value, + important, + remove: () => { + declaration.remove(); + // delete entry from map as well + ruleDeclarations.delete(key); + }, + }); + } + }); + + ruleDeclarations.clear(); + fallbackRuleDeclarations.clear(); + } + }); + }, + }; +}; + +plugin.postcss = true; +module.exports = plugin; From dd34fe852995ffc1bbad2979de38661c566eb32d Mon Sep 17 00:00:00 2001 From: xettri Date: Tue, 12 Aug 2025 01:05:34 +0530 Subject: [PATCH 02/11] added build script to prepare npm ready package and added deploy scripts --- .gitignore | 4 ++- package.json | 21 +++++++-------- scripts/build.mjs | 67 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 12 deletions(-) create mode 100644 scripts/build.mjs diff --git a/.gitignore b/.gitignore index b512c09..dff1ce6 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ -node_modules \ No newline at end of file +node_modules +dist +pnpm-lock.yaml \ No newline at end of file diff --git a/package.json b/package.json index 0f36720..49fdddd 100644 --- a/package.json +++ b/package.json @@ -1,15 +1,17 @@ { "name": "postcss-remove-duplicate-values", + "private": true, "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" - ], + "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" + }, "keywords": [ "css", "postcss", @@ -25,9 +27,6 @@ "email": "imbharatrawat@gmail.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" diff --git a/scripts/build.mjs b/scripts/build.mjs new file mode 100644 index 0000000..4ab22fb --- /dev/null +++ b/scripts/build.mjs @@ -0,0 +1,67 @@ +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; + + 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(); From 472db378d335634373aa472690e7c1fe25c67d32 Mon Sep 17 00:00:00 2001 From: xettri Date: Tue, 12 Aug 2025 01:10:24 +0530 Subject: [PATCH 03/11] refactor: improve plugin code quality and error handling - Enhance JSDoc comments with detailed parameter descriptions - Replace indexOf() with startsWith() for better vendor prefix detection - Add comprehensive try-catch error handling throughout the plugin - Improve duplicate detection logic with better fallback handling - Optimize memory usage by storing declaration references instead of remove functions - Add silent error handling to prevent plugin crashes on malformed CSS - Restructure plugin to use Once() instead of prepare() for better performance --- src/index.js | 235 ++++++++++++++++++++++++++++----------------------- 1 file changed, 128 insertions(+), 107 deletions(-) diff --git a/src/index.js b/src/index.js index dbd4cc8..70c9426 100644 --- a/src/index.js +++ b/src/index.js @@ -23,10 +23,12 @@ const PLUGIN_NAME = "postcss-remove-duplicate-values"; */ /** - * function to check is walkSelector is valid selector as per config passed - * @param {Options['selector']} selector - * @param {string} walkSelector - * @returns {boolean} + * 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") { @@ -40,23 +42,27 @@ const matchSelector = (selector, walkSelector) => { }; /** - * function to check is value is valid fallback value - * @param {string} value - * @returns {boolean} + * 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 = (value) => { +const isValidFallbackValue = (property) => { return ( - value.indexOf("-webkit-") !== -1 || - value.indexOf("-moz-") !== -1 || - value.indexOf("-ms-") !== -1 || - value.indexOf("-o-") !== -1 + property.startsWith("-webkit-") || + property.startsWith("-moz-") || + property.startsWith("-ms-") || + property.startsWith("-o-") ); }; /** - * function to check is node is empty or not - * @param {import('postcss').Rule} rule - * @returns {boolean} + * 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 ( @@ -66,110 +72,125 @@ const isEmpty = (rule) => { }; /** - * PostCSS plugin to remove duplicate values from CSS selectors. + * PostCSS plugin that removes duplicate CSS property values within rules. + * * @type {import('postcss').PluginCreator} - * @param {Options} options - * @return {import('postcss').Plugin} + * @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, - prepare({ root }) { - root.walkRules((rule) => { - // if selector is passed and its fail to match with rule selector - // then this plugin opration will not applied - if (selector) { - if (!matchSelector(selector, rule.selector)) { - return; - } - } - - if (isEmpty(rule)) { - // it will remove empty selector if preserveEmpty is not true - if (preserveEmpty !== true) { - rule.remove(); - } - } else { - /** - * @type {RuleDeclarationsMap} - */ - const ruleDeclarations = new Map(); - - /** - * @type {RuleDeclarationsMap} - */ - const fallbackRuleDeclarations = new Map(); - - rule.walkDecls((declaration) => { - const key = declaration.prop; - const value = declaration.value.trim(); - const important = Boolean(declaration.important); - const isValidFallback = isValidFallbackValue(value); - - let currentRemoved = false; - if (isValidFallback) { - const fallbackRuleObject = fallbackRuleDeclarations.get( - `${key}:${value}` - ); - if (fallbackRuleObject) { - if (fallbackRuleObject.important) { - if (important) { - fallbackRuleObject.remove(); - } else { - declaration.remove(); - currentRemoved = true; - } - } else { - fallbackRuleObject.remove(); - } - } - } else if (ruleDeclarations.has(key)) { - const data = ruleDeclarations.get(key); - if (data.important) { - // if current value is important then it will overwrite previous style - if (important) { - data.remove(); - } else { - // if current value is not important older style will overwrite - declaration.remove(); - currentRemoved = true; - } - } else { - data.remove(); + 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 current node is removed then no need to update map - if (currentRemoved) return; - - if (isValidFallback) { - const k = `${key}:${value}`; - fallbackRuleDeclarations.set(k, { - important, - remove: () => { - declaration.remove(); - // delete entry from map as well - fallbackRuleDeclarations.delete(k); - }, - }); + if (isEmpty(rule)) { + // Remove empty rules unless explicitly preserved + if (preserveEmpty !== true) { + rule.remove(); + } } else { - ruleDeclarations.set(key, { - value, - important, - remove: () => { - declaration.remove(); - // delete entry from map as well - ruleDeclarations.delete(key); - }, + // 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 + } }); - } - }); - ruleDeclarations.clear(); - fallbackRuleDeclarations.clear(); - } - }); + // 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 + } }, }; }; From d4f8d3dfca20e0cadd5412183f05480bedfbccb1 Mon Sep 17 00:00:00 2001 From: xettri Date: Tue, 12 Aug 2025 01:47:01 +0530 Subject: [PATCH 04/11] Added test cases for plugin --- __tests__/core.test.js | 519 ++++++++++++++++++++++++++++++++++ __tests__/edge.test.js | 390 +++++++++++++++++++++++++ __tests__/integration.test.js | 326 +++++++++++++++++++++ jest.config.mjs | 18 ++ package.json | 20 +- scripts/build.mjs | 1 + 6 files changed, 1268 insertions(+), 6 deletions(-) create mode 100644 __tests__/core.test.js create mode 100644 __tests__/edge.test.js create mode 100644 __tests__/integration.test.js create mode 100644 jest.config.mjs diff --git a/__tests__/core.test.js b/__tests__/core.test.js new file mode 100644 index 0000000..08e8aca --- /dev/null +++ b/__tests__/core.test.js @@ -0,0 +1,519 @@ +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..32683e0 --- /dev/null +++ b/__tests__/edge.test.js @@ -0,0 +1,390 @@ +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..2dc372f --- /dev/null +++ b/__tests__/integration.test.js @@ -0,0 +1,326 @@ +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/jest.config.mjs b/jest.config.mjs new file mode 100644 index 0000000..7a28ac3 --- /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 49fdddd..a18ef7a 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "postcss-remove-duplicate-values", "private": true, - "version": "1.0.0", - "description": "A PostCSS plugin that removes duplicate CSS property values within rules, optimizing stylesheet size and improving maintainability.", + "version": "2.0.0-rc.1", + "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": { @@ -10,12 +10,17 @@ "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" + "release:dry": "pnpm release --dry-run --tag dry", + "test": "jest", + "test:watch": "jest --watch", + "test:coverage": "jest --coverage" }, "keywords": [ "css", "postcss", - "postcss-plugin" + "postcss-plugin", + "performance", + "bundle-optimization" ], "repository": { "type": "git", @@ -23,12 +28,15 @@ }, "author": { "name": "Bharat Rawat", - "url": "https://bharatrawat.com", - "email": "imbharatrawat@gmail.com" + "url": "https://bharatrawat.com" }, "license": "MIT", "homepage": "https://github.com/xettri/postcss-remove-duplicate-values#readme", "peerDependencies": { "postcss": "^8.4" + }, + "devDependencies": { + "jest": "^29.7.0", + "postcss": "^8.4.31" } } diff --git a/scripts/build.mjs b/scripts/build.mjs index 4ab22fb..2261dcd 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -47,6 +47,7 @@ function createPackageJson(){ const data = {...pkg} delete data.scripts delete data.private; + delete data.devDependencies; const packageJson = { ...data, From 74f9654bc3bfd32829d6285efa0aba820c51971e Mon Sep 17 00:00:00 2001 From: xettri Date: Tue, 12 Aug 2025 02:22:33 +0530 Subject: [PATCH 05/11] update repository from package.json --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a18ef7a..4683c92 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ ], "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", From cb00f059334f3acdeb4a60983719f20595b7a4e4 Mon Sep 17 00:00:00 2001 From: xettri Date: Tue, 12 Aug 2025 03:44:53 +0530 Subject: [PATCH 06/11] added doc playground --- .gitignore | 3 +- docs/index.html | 442 ++++++++++++++++++++++++++++++ docs/scripts/index.js | 603 +++++++++++++++++++++++++++++++++++++++++ docs/styles/editor.css | 13 + docs/styles/index.css | 350 ++++++++++++++++++++++++ package.json | 6 +- 6 files changed, 1414 insertions(+), 3 deletions(-) create mode 100644 docs/index.html create mode 100644 docs/scripts/index.js create mode 100644 docs/styles/editor.css create mode 100644 docs/styles/index.css diff --git a/.gitignore b/.gitignore index dff1ce6..cc073a2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules dist -pnpm-lock.yaml \ No newline at end of file +pnpm-lock.yaml +coverage diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 0000000..2a00350 --- /dev/null +++ b/docs/index.html @@ -0,0 +1,442 @@ + + + + + + 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/scripts/index.js b/docs/scripts/index.js new file mode 100644 index 0000000..9ed6e1c --- /dev/null +++ b/docs/scripts/index.js @@ -0,0 +1,603 @@ +// ============================================================================ +// PostCSS Remove Duplicate Values - Interactive Playground +// ============================================================================ + +// 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 { +}`, + }, +}; + +// ============================================================================ +// Core Functions +// ============================================================================ + +/** + * 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; +} + +// ============================================================================ +// UI Functions +// ============================================================================ + +/** + * 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; +} + +// ============================================================================ +// Initialization +// ============================================================================ + +/** + * 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); +} + +// ============================================================================ +// Event Listeners +// ============================================================================ + +// 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..11dcafc --- /dev/null +++ b/docs/styles/index.css @@ -0,0 +1,350 @@ +.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); +} diff --git a/package.json b/package.json index 4683c92..dce1903 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "release:dry": "pnpm release --dry-run --tag dry", "test": "jest", "test:watch": "jest --watch", - "test:coverage": "jest --coverage" + "test:coverage": "jest --coverage", + "format": "prettier --write ." }, "keywords": [ "css", @@ -37,6 +38,7 @@ }, "devDependencies": { "jest": "^29.7.0", - "postcss": "^8.4.31" + "postcss": "^8.4.31", + "prettier": "^3.6.2" } } From b99e2822ad04b04e362344c94394e83b99be9169 Mon Sep 17 00:00:00 2001 From: xettri Date: Tue, 12 Aug 2025 03:47:30 +0530 Subject: [PATCH 07/11] format codebase with pretter --- .prettierrc | 8 ++ README.md | 14 +-- __tests__/core.test.js | 140 ++++++++++++++------------ __tests__/edge.test.js | 106 +++++++++++--------- __tests__/integration.test.js | 142 ++++++++++++++++---------- docs/index.html | 32 +++--- docs/scripts/index.js | 184 +++++++++++++++++----------------- docs/styles/index.css | 6 +- index.d.ts | 8 +- index.js | 28 +++--- jest.config.mjs | 10 +- scripts/build.mjs | 39 +++---- src/index.d.ts | 8 +- src/index.js | 26 ++--- 14 files changed, 408 insertions(+), 343 deletions(-) create mode 100644 .prettierrc 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..69721f5 100644 --- a/README.md +++ b/README.md @@ -66,8 +66,8 @@ Here are some CSS examples showcasing the behavior of the plugin: 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"); +const postcss = require('postcss'); +const removeDuplicateValues = require('postcss-remove-duplicate-values'); const css = ` .button { @@ -82,7 +82,7 @@ postcss([ }), ]) .process(css, { from: undefined }) - .then((result) => { + .then(result => { console.log(result.css); }); @@ -91,10 +91,10 @@ postcss() .use( removeDuplicateValues({ /* options */ - }) + }), ) .process(css, { from: undefined }) - .then((result) => { + .then(result => { console.log(result.css); }); ``` @@ -103,14 +103,14 @@ If you are using `postcss.config.js`, you can include it as follows: ```js module.exports = { - plugins: [require("postcss-remove-duplicate-values")], + plugins: [require('postcss-remove-duplicate-values')], }; ``` For more customization, you can pass options to the plugin: ```js -const removeDuplicateValues = require("postcss-remove-duplicate-values"); +const removeDuplicateValues = require('postcss-remove-duplicate-values'); module.exports = { plugins: [ removeDuplicateValues({ diff --git a/__tests__/core.test.js b/__tests__/core.test.js index 08e8aca..905bb72 100644 --- a/__tests__/core.test.js +++ b/__tests__/core.test.js @@ -21,9 +21,9 @@ describe('postcss-remove-duplicate-values', () => { 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*\}/); @@ -38,9 +38,9 @@ describe('postcss-remove-duplicate-values', () => { margin: 20px; } `; - + const output = await getCSS(input); - + expect(output).toContain('display: flex'); expect(output).toContain('margin: 20px'); expect(output).not.toContain('display: block'); @@ -55,9 +55,9 @@ describe('postcss-remove-duplicate-values', () => { font-size: 16px; } `; - + const output = await getCSS(input); - + expect(output).toContain('color: blue'); expect(output).toContain('background: red'); expect(output).toContain('font-size: 16px'); @@ -72,9 +72,9 @@ describe('postcss-remove-duplicate-values', () => { color: blue; } `; - + const output = await getCSS(input); - + expect(output).toContain('color: red !important'); expect(output).not.toContain('color: blue'); }); @@ -87,9 +87,9 @@ describe('postcss-remove-duplicate-values', () => { 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'); @@ -104,9 +104,9 @@ describe('postcss-remove-duplicate-values', () => { 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'); @@ -122,9 +122,9 @@ describe('postcss-remove-duplicate-values', () => { -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)'); @@ -137,9 +137,9 @@ describe('postcss-remove-duplicate-values', () => { transform: rotate(45deg); } `; - + const output = await getCSS(input); - + expect(output).toContain('-moz-transform: rotate(45deg)'); expect(output).toContain('transform: rotate(45deg)'); }); @@ -151,9 +151,9 @@ describe('postcss-remove-duplicate-values', () => { transform: rotate(45deg); } `; - + const output = await getCSS(input); - + expect(output).toContain('-ms-transform: rotate(45deg)'); expect(output).toContain('transform: rotate(45deg)'); }); @@ -165,9 +165,9 @@ describe('postcss-remove-duplicate-values', () => { transform: rotate(45deg); } `; - + const output = await getCSS(input); - + expect(output).toContain('-o-transform: rotate(45deg)'); expect(output).toContain('transform: rotate(45deg)'); }); @@ -179,9 +179,9 @@ describe('postcss-remove-duplicate-values', () => { transform: rotate(45deg); } `; - + const output = await getCSS(input); - + expect(output).toContain('-webkit-transform: rotate(45deg) !important'); expect(output).toContain('transform: rotate(45deg)'); }); @@ -199,9 +199,9 @@ describe('postcss-remove-duplicate-values', () => { 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'); @@ -222,9 +222,9 @@ describe('postcss-remove-duplicate-values', () => { 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'); @@ -243,11 +243,11 @@ describe('postcss-remove-duplicate-values', () => { margin: 20px; } `; - - const output = await getCSS(input, { - selector: (selector) => selector.includes('button') + + 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'); @@ -265,9 +265,9 @@ describe('postcss-remove-duplicate-values', () => { margin: 20px; } `; - + const output = await getCSS(input); - + expect(output).toContain('color: blue'); expect(output).toContain('margin: 20px'); expect(output).not.toContain('color: red'); @@ -284,9 +284,9 @@ describe('postcss-remove-duplicate-values', () => { color: blue; } `; - + const output = await getCSS(input); - + expect(output).not.toContain('.empty-rule'); expect(output).toContain('.non-empty-rule'); expect(output).toContain('color: blue'); @@ -300,9 +300,9 @@ describe('postcss-remove-duplicate-values', () => { 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'); @@ -317,9 +317,9 @@ describe('postcss-remove-duplicate-values', () => { color: blue; } `; - + const output = await getCSS(input); - + expect(output).not.toContain('.comment-only'); expect(output).toContain('.with-property'); expect(output).toContain('color: blue'); @@ -334,9 +334,9 @@ describe('postcss-remove-duplicate-values', () => { color: blue; } `; - + const output = await getCSS(input, { preserveEmpty: true }); - + expect(output).toContain('.comment-only'); expect(output).toContain('.with-property'); expect(output).toContain('color: blue'); @@ -353,9 +353,9 @@ describe('postcss-remove-duplicate-values', () => { color: yellow; } `; - + const output = await getCSS(input); - + expect(output).toContain('color: yellow'); expect(output).not.toContain('color: red'); expect(output).not.toContain('color: blue'); @@ -369,11 +369,15 @@ describe('postcss-remove-duplicate-values', () => { 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)'); + + 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 () => { @@ -383,9 +387,9 @@ describe('postcss-remove-duplicate-values', () => { content: "Goodbye World"; } `; - + const output = await getCSS(input); - + expect(output).toContain('content: "Goodbye World"'); expect(output).not.toContain('content: "Hello World"'); }); @@ -397,9 +401,9 @@ describe('postcss-remove-duplicate-values', () => { --color: blue; } `; - + const output = await getCSS(input); - + expect(output).toContain('--color: blue'); expect(output).not.toContain('--color: red'); }); @@ -424,15 +428,15 @@ describe('postcss-remove-duplicate-values', () => { 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'); }); @@ -445,13 +449,13 @@ describe('postcss-remove-duplicate-values', () => { 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'); @@ -466,7 +470,7 @@ describe('postcss-remove-duplicate-values', () => { color: blue; } `; - + // PostCSS will throw a syntax error for malformed CSS before our plugin runs await expect(async () => { await getCSS(input); @@ -475,14 +479,14 @@ describe('postcss-remove-duplicate-values', () => { 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(''); }); @@ -490,27 +494,33 @@ describe('postcss-remove-duplicate-values', () => { 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 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 index 32683e0..42056ab 100644 --- a/__tests__/edge.test.js +++ b/__tests__/edge.test.js @@ -2,7 +2,9 @@ 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 }); + const result = await postcss([plugin(options)]).process(css, { + from: undefined, + }); return result.css; }; @@ -17,9 +19,9 @@ describe('Edge Cases and Complex Scenarios', () => { } } `; - + const output = await getCSS(input); - + expect(output).toContain('@media (max-width: 768px)'); expect(output).toContain('color: blue'); expect(output).not.toContain('color: red'); @@ -38,9 +40,9 @@ describe('Edge Cases and Complex Scenarios', () => { } } `; - + const output = await getCSS(input); - + expect(output).toContain('@keyframes slide'); expect(output).toContain('transform: translateX(0px)'); expect(output).toContain('transform: translateX(100px)'); @@ -54,9 +56,9 @@ describe('Edge Cases and Complex Scenarios', () => { content: "World"; } `; - + const output = await getCSS(input); - + expect(output).toContain('content: "World"'); expect(output).not.toContain('content: "Hello"'); }); @@ -68,9 +70,9 @@ describe('Edge Cases and Complex Scenarios', () => { border: 2px solid blue; } `; - + const output = await getCSS(input); - + expect(output).toContain('border: 2px solid blue'); expect(output).not.toContain('border: 1px solid red'); }); @@ -84,9 +86,9 @@ describe('Edge Cases and Complex Scenarios', () => { 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")'); }); @@ -98,9 +100,9 @@ describe('Edge Cases and Complex Scenarios', () => { width: calc(100% - 30px); } `; - + const output = await getCSS(input); - + expect(output).toContain('width: calc(100% - 30px)'); expect(output).not.toContain('width: calc(100% - 20px)'); }); @@ -112,9 +114,9 @@ describe('Edge Cases and Complex Scenarios', () => { 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)'); }); @@ -126,9 +128,9 @@ describe('Edge Cases and Complex Scenarios', () => { 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)'); }); @@ -142,10 +144,12 @@ describe('Edge Cases and Complex Scenarios', () => { 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( + '-webkit-transform: translate3d(0, 0, 0) rotate(45deg)', + ); expect(output).toContain('transform: translate3d(0, 0, 0) rotate(45deg)'); }); @@ -157,12 +161,14 @@ describe('Edge Cases and Complex Scenarios', () => { 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'); + expect(output).not.toContain( + '-webkit-transform: rotate(45deg) !important', + ); }); test('should handle multiple vendor prefixes for same property', async () => { @@ -175,9 +181,9 @@ describe('Edge Cases and Complex Scenarios', () => { 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)'); @@ -197,9 +203,9 @@ describe('Edge Cases and Complex Scenarios', () => { color: blue; } `; - + const output = await getCSS(input); - + expect(output).not.toContain('.whitespace-only'); expect(output).toContain('.normal-rule'); expect(output).toContain('color: blue'); @@ -214,9 +220,9 @@ describe('Edge Cases and Complex Scenarios', () => { color: blue; } `; - + const output = await getCSS(input); - + expect(output).not.toContain('.newline-only'); expect(output).toContain('.normal-rule'); expect(output).toContain('color: blue'); @@ -236,9 +242,9 @@ describe('Edge Cases and Complex Scenarios', () => { margin: 10px; } `; - + const output = await getCSS(input); - + expect(output).toContain('/* Comment */'); expect(output).toContain('@import "styles.css"'); expect(output).toContain('color: blue'); @@ -256,29 +262,31 @@ describe('Edge Cases and Complex Scenarios', () => { 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 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'); @@ -300,13 +308,13 @@ describe('Edge Cases and Complex Scenarios', () => { margin: 10px; } `; - + const output = await getCSS(input, { - selector: (selector) => { + 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'); @@ -325,9 +333,9 @@ describe('Edge Cases and Complex Scenarios', () => { 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'); @@ -345,11 +353,11 @@ describe('Edge Cases and Complex Scenarios', () => { margin: 20px; } `; - + const output = await getCSS(input, { - selector: () => false + selector: () => false, }); - + // No selectors should be processed expect(output).toContain('color: red'); expect(output).toContain('color: blue'); @@ -366,9 +374,9 @@ describe('Edge Cases and Complex Scenarios', () => { content: "Goodbye\\"World"; } `; - + const output = await getCSS(input); - + expect(output).toContain('content: "Goodbye\\"World"'); expect(output).not.toContain('content: "Hello\\"World"'); }); @@ -380,9 +388,9 @@ describe('Edge Cases and Complex Scenarios', () => { 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 index 2dc372f..44e6647 100644 --- a/__tests__/integration.test.js +++ b/__tests__/integration.test.js @@ -10,9 +10,11 @@ describe('Integration Tests', () => { color: blue; } `; - - const result = await postcss([plugin()]).process(input, { from: undefined }); - + + 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*\}/); @@ -29,9 +31,12 @@ describe('Integration Tests', () => { margin: 20px; } `; - - const result = await postcss([plugin({ selector: '.button' })]).process(input, { from: undefined }); - + + 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'); @@ -46,13 +51,13 @@ describe('Integration Tests', () => { 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'); @@ -67,9 +72,11 @@ describe('Integration Tests', () => { color: blue; } `; - - const result = await postcss([plugin(undefined)]).process(input, { from: undefined }); - + + const result = await postcss([plugin(undefined)]).process(input, { + from: undefined, + }); + expect(result.css).toContain('color: blue'); expect(result.css).not.toContain('color: red'); }); @@ -81,9 +88,11 @@ describe('Integration Tests', () => { color: blue; } `; - - const result = await postcss([plugin({})]).process(input, { from: undefined }); - + + const result = await postcss([plugin({})]).process(input, { + from: undefined, + }); + expect(result.css).toContain('color: blue'); expect(result.css).not.toContain('color: red'); }); @@ -95,9 +104,12 @@ describe('Integration Tests', () => { color: blue; } `; - - const result = await postcss([plugin({ preserveEmpty: true })]).process(input, { from: undefined }); - + + const result = await postcss([plugin({ preserveEmpty: true })]).process( + input, + { from: undefined }, + ); + expect(result.css).toContain('color: blue'); expect(result.css).not.toContain('color: red'); }); @@ -128,17 +140,19 @@ describe('Integration Tests', () => { border-color: #0056b3; } `; - - const result = await postcss([plugin()]).process(input, { from: undefined }); - + + 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/); @@ -156,9 +170,11 @@ describe('Integration Tests', () => { gap: 30px; } `; - - const result = await postcss([plugin()]).process(input, { from: undefined }); - + + 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'); @@ -176,9 +192,11 @@ describe('Integration Tests', () => { animation: slideIn 0.5s ease-out; } `; - - const result = await postcss([plugin()]).process(input, { from: undefined }); - + + 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'); @@ -197,9 +215,11 @@ describe('Integration Tests', () => { background: white; } `; - - const result = await postcss([plugin()]).process(input, { from: undefined }); - + + 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 */'); @@ -222,9 +242,11 @@ describe('Integration Tests', () => { padding: 5px; } `; - - const result = await postcss([plugin()]).process(input, { from: undefined }); - + + 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'); @@ -241,7 +263,7 @@ describe('Integration Tests', () => { 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 }); @@ -250,8 +272,10 @@ describe('Integration Tests', () => { test('should handle empty CSS input', async () => { const input = ''; - - const result = await postcss([plugin()]).process(input, { from: undefined }); + + const result = await postcss([plugin()]).process(input, { + from: undefined, + }); expect(result.css).toBe(''); }); @@ -260,8 +284,10 @@ describe('Integration Tests', () => { /* This is a comment */ /* Another comment */ `; - - const result = await postcss([plugin()]).process(input, { from: undefined }); + + const result = await postcss([plugin()]).process(input, { + from: undefined, + }); expect(result.css).toContain('/* This is a comment */'); expect(result.css).toContain('/* Another comment */'); }); @@ -270,24 +296,29 @@ describe('Integration Tests', () => { 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) => ` + const rules = Array.from( + { length: 1000 }, + (_, i) => ` .rule-${i} { color: red; color: blue; margin: ${i}px; margin: ${i * 2}px; } - `).join('\n'); - + `, + ).join('\n'); + const input = rules; - + const startTime = Date.now(); - const result = await postcss([plugin()]).process(input, { from: undefined }); + 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'); @@ -296,7 +327,9 @@ describe('Integration Tests', () => { }); test('should handle CSS with many vendor prefixes efficiently', async () => { - const vendorRules = Array.from({ length: 100 }, (_, i) => ` + const vendorRules = Array.from( + { length: 100 }, + (_, i) => ` .vendor-${i} { -webkit-transform: rotate(${i}deg); -moz-transform: rotate(${i}deg); @@ -304,17 +337,20 @@ describe('Integration Tests', () => { -o-transform: rotate(${i}deg); transform: rotate(${i}deg); } - `).join('\n'); - + `, + ).join('\n'); + const input = vendorRules; - + const startTime = Date.now(); - const result = await postcss([plugin()]).process(input, { from: undefined }); + 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)'); diff --git a/docs/index.html b/docs/index.html index 2a00350..8edfb04 100644 --- a/docs/index.html +++ b/docs/index.html @@ -16,26 +16,26 @@ extend: { fontFamily: { mono: [ - "JetBrains Mono", - "Monaco", - "Menlo", - "Ubuntu Mono", - "monospace", + 'JetBrains Mono', + 'Monaco', + 'Menlo', + 'Ubuntu Mono', + 'monospace', ], }, colors: { dark: { - 50: "#f8fafc", - 100: "#f1f5f9", - 200: "#e2e8f0", - 300: "#cbd5e1", - 400: "#94a3b8", - 500: "#64748b", - 600: "#475569", - 700: "#334155", - 800: "#1e293b", - 900: "#0f172a", - 950: "#020617", + 50: '#f8fafc', + 100: '#f1f5f9', + 200: '#e2e8f0', + 300: '#cbd5e1', + 400: '#94a3b8', + 500: '#64748b', + 600: '#475569', + 700: '#334155', + 800: '#1e293b', + 900: '#0f172a', + 950: '#020617', }, }, }, diff --git a/docs/scripts/index.js b/docs/scripts/index.js index 9ed6e1c..dc7d928 100644 --- a/docs/scripts/index.js +++ b/docs/scripts/index.js @@ -5,10 +5,10 @@ // Configuration const CONFIG = { ANIMATION_DURATION: 4000, - SNACKBAR_POSITION: "bottom-left", - TAB_INDENT: " ", + SNACKBAR_POSITION: 'bottom-left', + TAB_INDENT: ' ', SELECTOR_REGEX: /([^{]+)\{([^}]*)\}/g, - DECLARATION_SEPARATOR: ";", + DECLARATION_SEPARATOR: ';', }; // DOM Elements Cache @@ -29,8 +29,8 @@ const DOM = { // Test examples with better organization const EXAMPLES = { basic: { - name: "Basic Duplicates", - description: "Simple duplicate property removal", + name: 'Basic Duplicates', + description: 'Simple duplicate property removal', css: `.button { color: red; color: blue; @@ -40,8 +40,8 @@ const EXAMPLES = { }`, }, important: { - name: "!important Handling", - description: "Handle important declarations", + name: '!important Handling', + description: 'Handle important declarations', css: `.button { color: red !important; color: blue; @@ -52,8 +52,8 @@ const EXAMPLES = { }`, }, vendor: { - name: "Vendor Prefixes", - description: "Browser compatibility handling", + name: 'Vendor Prefixes', + description: 'Browser compatibility handling', css: `.button { -webkit-transform: translateX(10px); -moz-transform: translateX(10px); @@ -63,8 +63,8 @@ const EXAMPLES = { }`, }, colors: { - name: "Color Variations", - description: "Different color formats", + name: 'Color Variations', + description: 'Different color formats', css: `.button { color: #fff; color: #ffffff; @@ -75,8 +75,8 @@ const EXAMPLES = { }`, }, selector: { - name: "Selector Filtering", - description: "Target specific selectors", + name: 'Selector Filtering', + description: 'Target specific selectors', css: `.button { color: red; color: blue; @@ -91,8 +91,8 @@ const EXAMPLES = { }`, }, complex: { - name: "Complex Selectors", - description: "Advanced CSS patterns", + name: 'Complex Selectors', + description: 'Advanced CSS patterns', css: `[data-button="primary"] { color: red; color: blue; @@ -109,8 +109,8 @@ const EXAMPLES = { }`, }, empty: { - name: "Empty Rules", - description: "Handle empty CSS rules", + name: 'Empty Rules', + description: 'Handle empty CSS rules', css: `.button { color: red; color: blue; @@ -135,12 +135,12 @@ const EXAMPLES = { function loadExample(type) { const example = EXAMPLES[type]; if (!example) { - showSnackbar(`Example "${type}" not found`, "error"); + showSnackbar(`Example "${type}" not found`, 'error'); return; } if (!DOM.inputCSS) { - console.error("Input editor not initialized"); + console.error('Input editor not initialized'); return; } @@ -151,7 +151,7 @@ function loadExample(type) { clearOutput(); resetStats(); - showSnackbar(`Loaded: ${example.name}`, "success"); + showSnackbar(`Loaded: ${example.name}`, 'success'); } /** @@ -159,19 +159,19 @@ function loadExample(type) { */ async function processCSS() { if (!DOM.inputCSS || !DOM.outputCSS) { - showSnackbar("Editors not initialized", "error"); + showSnackbar('Editors not initialized', 'error'); return; } const inputText = DOM.inputCSS.value.trim(); if (!inputText) { - showSnackbar("Please enter some CSS to process", "error"); + showSnackbar('Please enter some CSS to process', 'error'); return; } try { const options = { - selector: DOM.selectorInput?.value.trim() || "", + selector: DOM.selectorInput?.value.trim() || '', preserveEmpty: DOM.preserveEmptyToggle?.checked || false, }; @@ -179,10 +179,10 @@ async function processCSS() { updateOutput(result); updateStats(result); - showSnackbar("CSS processed successfully!", "success"); + showSnackbar('CSS processed successfully!', 'success'); } catch (error) { - console.error("Processing error:", error); - showSnackbar(`Error processing CSS: ${error.message}`, "error"); + console.error('Processing error:', error); + showSnackbar(`Error processing CSS: ${error.message}`, 'error'); } } @@ -242,7 +242,7 @@ async function simulatePostCSS(css, options = {}) { rules.push({ selector: selectorText, - content: "", + content: '', isEmpty: true, skipped: false, }); @@ -251,9 +251,9 @@ async function simulatePostCSS(css, options = {}) { // 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"); + .filter(rule => !rule.skipped || rule.content.trim()) + .map(rule => `${rule.selector} {\n ${rule.content}\n}`) + .join('\n\n'); return { css: resultCSS, @@ -273,18 +273,18 @@ function processDeclarations(declarations) { const declarationMap = new Map(); const decls = declarations .split(CONFIG.DECLARATION_SEPARATOR) - .map((d) => d.trim()) - .filter((d) => d); + .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(":"); + const [property, ...valueParts] = decl.split(':'); if (!property || !valueParts.length) continue; const propertyName = property.trim(); - const value = valueParts.join(":").trim(); + const value = valueParts.join(':').trim(); // Skip if already processed (last declaration wins) if (declarationMap.has(propertyName)) continue; @@ -293,7 +293,7 @@ function processDeclarations(declarations) { } // Convert back to string, maintaining order - return Array.from(declarationMap.values()).join(";\n "); + return Array.from(declarationMap.values()).join(';\n '); } /** @@ -306,11 +306,11 @@ 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(".")) { + const patterns = filter.split(',').map(p => p.trim()); + return patterns.some(pattern => { + if (pattern.startsWith('.')) { return selectorText.includes(pattern); - } else if (pattern.startsWith("#")) { + } else if (pattern.startsWith('#')) { return selectorText.includes(pattern); } else { return selectorText.includes(pattern); @@ -324,7 +324,7 @@ function matchSelector(selectorText, filter) { * @returns {number} Declaration count */ function countDeclarations(css) { - return css.split(CONFIG.DECLARATION_SEPARATOR).filter((d) => d.trim()).length; + return css.split(CONFIG.DECLARATION_SEPARATOR).filter(d => d.trim()).length; } // ============================================================================ @@ -336,7 +336,7 @@ function countDeclarations(css) { */ function clearOutput() { if (DOM.outputCSS) { - DOM.outputCSS.value = ""; + DOM.outputCSS.value = ''; hideCopyButton(); } } @@ -353,8 +353,8 @@ function clearResults() { * Reset all statistics to zero */ function resetStats() { - Object.values(DOM.stats).forEach((element) => { - if (element) element.textContent = "0"; + Object.values(DOM.stats).forEach(element => { + if (element) element.textContent = '0'; }); hideCopyButton(); } @@ -390,17 +390,17 @@ function updateStats(result) { * Show action buttons when there's content to work with */ function showCopyButton() { - const copyButton = document.getElementById("copyButton"); - const clearButton = document.getElementById("clearButton"); + const copyButton = document.getElementById('copyButton'); + const clearButton = document.getElementById('clearButton'); if (copyButton) { - copyButton.style.opacity = "1"; - copyButton.style.pointerEvents = "auto"; + copyButton.style.opacity = '1'; + copyButton.style.pointerEvents = 'auto'; } if (clearButton) { - clearButton.style.opacity = "1"; - clearButton.style.pointerEvents = "auto"; + clearButton.style.opacity = '1'; + clearButton.style.pointerEvents = 'auto'; } } @@ -408,17 +408,17 @@ function showCopyButton() { * Hide action buttons when there's no content */ function hideCopyButton() { - const copyButton = document.getElementById("copyButton"); - const clearButton = document.getElementById("clearButton"); + const copyButton = document.getElementById('copyButton'); + const clearButton = document.getElementById('clearButton'); if (copyButton) { - copyButton.style.opacity = "0"; - copyButton.style.pointerEvents = "none"; + copyButton.style.opacity = '0'; + copyButton.style.pointerEvents = 'none'; } if (clearButton) { - clearButton.style.opacity = "0"; - clearButton.style.pointerEvents = "none"; + clearButton.style.opacity = '0'; + clearButton.style.pointerEvents = 'none'; } } @@ -430,13 +430,13 @@ async function copyResults() { const outputText = DOM.outputCSS.value; if (!outputText.trim()) { - showSnackbar("No results to copy", "warning"); + showSnackbar('No results to copy', 'warning'); return; } try { await navigator.clipboard.writeText(outputText); - showSnackbar("Results copied to clipboard!", "success"); + showSnackbar('Results copied to clipboard!', 'success'); } catch (error) { // Fallback for older browsers fallbackCopy(outputText); @@ -448,21 +448,21 @@ async function copyResults() { * @param {string} text - Text to copy */ function fallbackCopy(text) { - const textArea = document.createElement("textarea"); + const textArea = document.createElement('textarea'); textArea.value = text; - textArea.style.position = "fixed"; - textArea.style.left = "-999999px"; - textArea.style.top = "-999999px"; + 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"); + document.execCommand('copy'); + showSnackbar('Results copied to clipboard!', 'success'); } catch (error) { - showSnackbar("Failed to copy results", "error"); + showSnackbar('Failed to copy results', 'error'); } finally { document.body.removeChild(textArea); } @@ -473,18 +473,18 @@ function fallbackCopy(text) { * @param {string} message - Message to display * @param {string} type - Message type (success, error, warning, info) */ -function showSnackbar(message, type = "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", + textColor: '#ffffff', + width: 'auto', showAction: true, - actionText: "Dismiss", + actionText: 'Dismiss', actionTextColor: getSnackbarActionColor(type), - customClass: "custom-snackbar", + customClass: 'custom-snackbar', }; Snackbar.show(config); @@ -497,10 +497,10 @@ function showSnackbar(message, type = "info") { */ function getSnackbarColor(type) { const colors = { - success: "#059669", - error: "#dc2626", - warning: "#d97706", - info: "#3b82f6", + success: '#059669', + error: '#dc2626', + warning: '#d97706', + info: '#3b82f6', }; return colors[type] || colors.info; } @@ -512,10 +512,10 @@ function getSnackbarColor(type) { */ function getSnackbarActionColor(type) { const colors = { - success: "#a7f3d0", - error: "#fecaca", - warning: "#fed7aa", - info: "#bfdbfe", + success: '#a7f3d0', + error: '#fecaca', + warning: '#fed7aa', + info: '#bfdbfe', }; return colors[type] || colors.info; } @@ -529,23 +529,23 @@ function getSnackbarActionColor(type) { */ function initializeDOM() { // Main editors - DOM.inputCSS = document.getElementById("inputCSS"); - DOM.outputCSS = document.getElementById("outputCSS"); + DOM.inputCSS = document.getElementById('inputCSS'); + DOM.outputCSS = document.getElementById('outputCSS'); // Form controls - DOM.selectorInput = document.getElementById("selectorInput"); - DOM.preserveEmptyToggle = document.getElementById("preserveEmptyToggle"); + 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"); + 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"); + console.error('Critical DOM elements not found'); return false; } @@ -559,8 +559,8 @@ function initializeEditors() { if (!DOM.inputCSS) return; // Handle tab key for indentation - DOM.inputCSS.addEventListener("keydown", function (e) { - if (e.key === "Tab") { + DOM.inputCSS.addEventListener('keydown', function (e) { + if (e.key === 'Tab') { e.preventDefault(); const start = this.selectionStart; const end = this.selectionEnd; @@ -583,7 +583,7 @@ function initializeEditors() { */ function initialize() { if (!initializeDOM()) { - console.error("Failed to initialize DOM"); + console.error('Failed to initialize DOM'); return; } @@ -591,7 +591,7 @@ function initialize() { // Load initial example with delay to ensure DOM is ready setTimeout(() => { - loadExample("basic"); + loadExample('basic'); }, 100); } @@ -600,4 +600,4 @@ function initialize() { // ============================================================================ // Initialize when DOM is ready -window.addEventListener("DOMContentLoaded", initialize); +window.addEventListener('DOMContentLoaded', initialize); diff --git a/docs/styles/index.css b/docs/styles/index.css index 11dcafc..5e0e4d1 100644 --- a/docs/styles/index.css +++ b/docs/styles/index.css @@ -76,7 +76,7 @@ textarea::-webkit-scrollbar-thumb:hover { .toggle-slider:before { position: absolute; - content: ""; + content: ''; height: 18px; width: 18px; left: 3px; @@ -288,7 +288,7 @@ button:active { } /* Smooth focus transitions for all form elements */ -input[type="text"], +input[type='text'], textarea { transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); border: 1px solid transparent; @@ -306,7 +306,7 @@ textarea { /* Custom Snackbar styling to match playground theme */ .custom-snackbar { - font-family: "JetBrains Mono", monospace !important; + font-family: 'JetBrains Mono', monospace !important; font-size: 14px !important; font-weight: 500 !important; border-radius: 12px !important; 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 index 7a28ac3..5f2e532 100644 --- a/jest.config.mjs +++ b/jest.config.mjs @@ -1,10 +1,10 @@ export default { - testEnvironment: "node", - testMatch: ["/__tests__/**/*.test.js"], + testEnvironment: 'node', + testMatch: ['/__tests__/**/*.test.js'], collectCoverageFrom: [ - "src/index.js", - "!**/node_modules/**", - "!**/coverage/**", + 'src/index.js', + '!**/node_modules/**', + '!**/coverage/**', ], coverageThreshold: { global: { diff --git a/scripts/build.mjs b/scripts/build.mjs index 2261dcd..e812ee6 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -1,18 +1,18 @@ -import fs from "fs"; -import path from "path"; -import { fileURLToPath } from "url"; -import pkg from "../package.json" with { type: "json" }; +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"); +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 }) + if (fs.existsSync(distDir)) { + fs.rmSync(distDir, { recursive: true, force: true }); } fs.mkdirSync(distDir, { recursive: true }); } @@ -31,7 +31,7 @@ function copySrcFiles() { } function copyPluginRootFiles() { - const rootFiles = ["LICENSE", "README.md"]; + const rootFiles = ['LICENSE', 'README.md']; for (const file of rootFiles) { const rootFilePath = path.join(rootDir, file); const distFilePath = path.join(distDir, file); @@ -43,18 +43,21 @@ function copyPluginRootFiles() { } } -function createPackageJson(){ - const data = {...pkg} - delete data.scripts +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)); + main: './index.js', + types: './index.d.ts', + }; + fs.writeFileSync( + path.join(distDir, 'package.json'), + JSON.stringify(packageJson, null, 2), + ); } async function build() { @@ -62,7 +65,7 @@ async function build() { copySrcFiles(); copyPluginRootFiles(); createPackageJson(); - console.log("๐ŸŽ‰ Build completed successfully!"); + console.log('๐ŸŽ‰ Build completed successfully!'); } build(); diff --git a/src/index.d.ts b/src/index.d.ts index b07e7b3..068cb8b 100644 --- a/src/index.d.ts +++ b/src/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/src/index.js b/src/index.js index 70c9426..18115c6 100644 --- a/src/index.js +++ b/src/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. @@ -31,11 +31,11 @@ const PLUGIN_NAME = "postcss-remove-duplicate-values"; * @returns {boolean} - True if the selector should be processed */ 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; @@ -48,12 +48,12 @@ const matchSelector = (selector, walkSelector) => { * @param {string} property - The CSS property name to check * @returns {boolean} - True if the property is vendor-prefixed */ -const isValidFallbackValue = (property) => { +const isValidFallbackValue = property => { return ( - property.startsWith("-webkit-") || - property.startsWith("-moz-") || - property.startsWith("-ms-") || - property.startsWith("-o-") + property.startsWith('-webkit-') || + property.startsWith('-moz-') || + property.startsWith('-ms-') || + property.startsWith('-o-') ); }; @@ -64,10 +64,10 @@ const isValidFallbackValue = (property) => { * @param {import('postcss').Rule} rule - The CSS rule to check * @returns {boolean} - True if the rule is empty */ -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 ); }; @@ -85,7 +85,7 @@ const plugin = (options = {}) => { postcssPlugin: PLUGIN_NAME, Once(root) { try { - root.walkRules((rule) => { + root.walkRules(rule => { try { // Apply selector filtering if specified - only process matching rules if (selector) { @@ -104,7 +104,7 @@ const plugin = (options = {}) => { const ruleDeclarations = new Map(); const fallbackRuleDeclarations = new Map(); - rule.walkDecls((declaration) => { + rule.walkDecls(declaration => { try { // Validate declaration before processing if (!declaration || !declaration.prop || !declaration.value) { From 20e5e585bff2c05d22da7fddf30d193d459671ea Mon Sep 17 00:00:00 2001 From: xettri Date: Tue, 12 Aug 2025 04:00:44 +0530 Subject: [PATCH 08/11] bump version to latest --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index dce1903..8b1af30 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "postcss-remove-duplicate-values", "private": true, - "version": "2.0.0-rc.1", + "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", From 8328ec066b8b32489f25e9fa1357cb45d03e03d9 Mon Sep 17 00:00:00 2001 From: xettri Date: Tue, 12 Aug 2025 04:35:08 +0530 Subject: [PATCH 09/11] updated readme and added playground link in readme --- README.md | 283 +++++++++++++++++++++++++----------------- docs/index.html | 59 +++++++++ docs/robots.txt | 2 + docs/scripts/index.js | 20 --- docs/styles/index.css | 13 ++ 5 files changed, 243 insertions(+), 134 deletions(-) create mode 100644 docs/robots.txt diff --git a/README.md b/README.md index 69721f5..2502221 100644 --- a/README.md +++ b/README.md @@ -3,201 +3,256 @@ [![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) -[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: +### With PostCSS API +```js +const postcss = require('postcss') +const removeDuplicateValues = require('postcss-remove-duplicate-values') -| Option | Type | Default | -| --------------------------------- | --------------------------------------------------- | ----------- | -| [`selector`](#selector) | `(selector: string) => boolean \| string \| RegExp` | `undefined` | -| [`preserveEmpty`](#preserveempty) | `boolean` | `false` | +const css = ` +.button { + color: red; + color: blue; +}` -#### selector +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/docs/index.html b/docs/index.html index 8edfb04..010ab5f 100644 --- a/docs/index.html +++ b/docs/index.html @@ -4,6 +4,15 @@ PostCSS Remove Duplicate Values - Official Playground + + + + + + + + + + + @@ -433,6 +486,12 @@

Zero Configuration

+
+

PostCSS Remove Duplicate Values plugin developed by

+

Visit bharatrawat.com for more open source contributions and development work

+

Software engineer specializing in web performance, build tools, and developer experience

+
+ 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 index dc7d928..3b4a3e1 100644 --- a/docs/scripts/index.js +++ b/docs/scripts/index.js @@ -1,7 +1,3 @@ -// ============================================================================ -// PostCSS Remove Duplicate Values - Interactive Playground -// ============================================================================ - // Configuration const CONFIG = { ANIMATION_DURATION: 4000, @@ -124,10 +120,6 @@ const EXAMPLES = { }, }; -// ============================================================================ -// Core Functions -// ============================================================================ - /** * Load a CSS example into the input editor * @param {string} type - Example type key @@ -327,10 +319,6 @@ function countDeclarations(css) { return css.split(CONFIG.DECLARATION_SEPARATOR).filter(d => d.trim()).length; } -// ============================================================================ -// UI Functions -// ============================================================================ - /** * Clear output editor and reset statistics */ @@ -520,10 +508,6 @@ function getSnackbarActionColor(type) { return colors[type] || colors.info; } -// ============================================================================ -// Initialization -// ============================================================================ - /** * Initialize DOM element cache */ @@ -595,9 +579,5 @@ function initialize() { }, 100); } -// ============================================================================ -// Event Listeners -// ============================================================================ - // Initialize when DOM is ready window.addEventListener('DOMContentLoaded', initialize); diff --git a/docs/styles/index.css b/docs/styles/index.css index 5e0e4d1..214b332 100644 --- a/docs/styles/index.css +++ b/docs/styles/index.css @@ -348,3 +348,16 @@ textarea { 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; +} From f32b7e63652c7151c1fc42e4cc4d09f8027eff06 Mon Sep 17 00:00:00 2001 From: xettri Date: Tue, 12 Aug 2025 04:43:10 +0530 Subject: [PATCH 10/11] Added better color for badges --- README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 2502221..5fb64bd 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,13 @@ # [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 +

> **Smart PostCSS plugin that removes duplicate CSS properties, reduces bundle size, and improves CSS maintainability.** -## โœจ What It Does +## โœจ What It Does? Automatically removes duplicate CSS properties from your stylesheets while keeping the most important ones. Perfect for cleaning up CSS and improving performance. From 5042f763ca244306a59ca1e826d14c56332d9846 Mon Sep 17 00:00:00 2001 From: xettri Date: Tue, 12 Aug 2025 04:44:13 +0530 Subject: [PATCH 11/11] Added release workflow --- .github/workflows/release.yml | 54 +++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 .github/workflows/release.yml 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