From 9047038cd72a7ad34152e0f1d3e62e7a249f3d7a Mon Sep 17 00:00:00 2001 From: Jens Oliver Meiert Date: Wed, 5 Aug 2026 11:37:33 +0200 Subject: [PATCH 1/2] feat: skip preprocessor sources and improve CLI messaging Preprocessor sources (e.g., `.scss`, `.sass`, `.less`, `.styl`) are now skipped when passed as CLI arguments, with a clear note provided and a non-zero exit code if unsupported files are detected. Updated tests, documentation, and error handling to reflect this change. (This commit message was AI-generated.) Signed-off-by: Jens Oliver Meiert --- CHANGELOG.md | 6 ++++ README.md | 2 ++ bin/css-dedup.js | 13 ++++++-- package-lock.json | 4 +-- package.json | 2 +- src/cli/options.js | 2 +- src/cli/targets.js | 20 ++++++++++-- test/cli.test.js | 79 +++++++++++++++++++++++++++++++++++++++++++++- 8 files changed, 118 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da57398..387b8b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to CSS Dedup are documented in this file, which is (mostly) The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.11.0] - 2026-08-05 + +### Changed + +* Skipped preprocessor sources (`.scss`, `.sass`, `.less`, `.styl`) named as a CLI argument, with a note and a non-zero exit + ## [1.10.1] - 2026-07-31 ### Fixed diff --git a/README.md b/README.md index ca2549f..38d06e3 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,8 @@ npx css-dedup [options] Pass one or more files—each is analyzed (and, with `--fix`, rewritten) independently. A directory is searched recursively for .css files (skipping node_modules and dotfolders); the result is unrolled into that same per-file list, so mixing files and directories works, too. Pass `-` instead of a file to read CSS from STDIN (can’t be combined with other file arguments); in `--fix` mode this prints the consolidated CSS to STDOUT, rather than writing a file, so it composes in a pipeline (status/summary output moves to STDERR in that case, keeping STDOUT pure CSS). +The input is CSS. A preprocessor source named as an argument (.scss, .sass, .less, .styl) is skipped. Run CSS Dedup on the compiled style sheet instead—duplication in a preprocessor source is often deliberate (one mixin used in ten places), it only becomes real duplication after compilation, and the byte figures the report is built around describe what actually ships. The reason for skipping rather than trying: Constructs like `@include`, `@extend`, `#{…}`, and `@if` decide what a rule finally contains, which is exactly what the merge-safety checks would need to see to know whether moving a declaration across rules is safe. + | Option | Description | | --- | --- | | `--fix`, `-f` | Consolidate declarations that are safe to merge automatically, rewriting each file in place (or printing to STDOUT for `-`) | diff --git a/bin/css-dedup.js b/bin/css-dedup.js index b15588c..7d4f98d 100644 --- a/bin/css-dedup.js +++ b/bin/css-dedup.js @@ -51,13 +51,22 @@ async function resolveFiles(positionals, ignorePathPatterns) { // `stat()`/`readdir()` aren’t wrapped inside `expandTargets()`, so a missing // path or unreadable directory would otherwise surface as a raw stack trace // instead of the clean, styled message every other resolution error gets - let files, discovered; + let files, discovered, unsupported; try { - ({ files, discovered } = await expandTargets(positionals, ignorePathPatterns)); + ({ files, discovered, unsupported } = await expandTargets(positionals, ignorePathPatterns)); } catch (err) { fail(styleText('red', `Could not resolve ${positionals.join(', ')}: ${err.message}`)); } + // A named file the run can’t speak for, so it fails the run the way an + // unreadable or unparsable one does—out of `--exit-zero`’s reach, which only + // ever forgives findings. Reported before the per-file output starts, since + // the remaining targets still process normally. + for (const file of unsupported) { + console.error(styleText('red', `Skipped ${file}: not a \`.css\` file—CSS Dedup analyzes CSS, so point it at the compiled style sheet rather than at a Sass or Less source.`)); + } + if (unsupported.length) process.exitCode = 1; + if (!files.length) { const targets = positionals.join(', '); if (discovered > 0) { diff --git a/package-lock.json b/package-lock.json index e71e008..d80454a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "css-dedup", - "version": "1.10.1", + "version": "1.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "css-dedup", - "version": "1.10.1", + "version": "1.11.0", "license": "MIT", "dependencies": { "postcss": "^8.5.25" diff --git a/package.json b/package.json index 736481c..c03d577 100644 --- a/package.json +++ b/package.json @@ -60,5 +60,5 @@ }, "type": "module", "types": "src/index.d.ts", - "version": "1.10.1" + "version": "1.11.0" } diff --git a/src/cli/options.js b/src/cli/options.js index fef5405..9f4d6ee 100644 --- a/src/cli/options.js +++ b/src/cli/options.js @@ -27,7 +27,7 @@ const HELP = `Usage: css-dedup [options] Find (and optionally consolidate) duplicate CSS declarations. Arguments: - file One or more CSS files or directories to analyze (directories are searched recursively for .css files, skipping node_modules and dotfolders); pass \`-\` to read from STDIN instead + file One or more CSS files or directories to analyze (directories are searched recursively for .css files, skipping node_modules and dotfolders); pass \`-\` to read from STDIN instead. Preprocessor sources (.scss, .sass, .less, .styl) are skipped—run CSS Dedup on the compiled style sheet. Options: -f, --fix Consolidate declarations that are safe to merge automatically, rewriting each file in place (or printing to STDOUT for \`-\`) diff --git a/src/cli/targets.js b/src/cli/targets.js index 66a8d70..4e667e6 100644 --- a/src/cli/targets.js +++ b/src/cli/targets.js @@ -7,6 +7,15 @@ import { resolve, relative, join, extname, sep } from 'node:path'; // Directories skipped when recursing into a target directory const DIRS_IGNORED = new Set(['node_modules']); +// Preprocessor sources, skipped when named directly as an argument (a +// directory scan never reaches them—it collects `.css` only). Most of their +// syntax fails the standard parser anyway, but the subset that parses—nesting +// alongside `@include`/`@extend`—would be consolidated as if those at-rules +// contributed no declarations, and `--fix` would write that back. A denylist +// rather than a `.css` allowlist, so an extension-less path (a process +// substitution, say) still works. +const EXTENSIONS_PREPROCESSOR = new Set(['.less', '.sass', '.scss', '.styl']); + // Concurrency cap for `prefetchContents()` const CONCURRENCY_READ = 8; @@ -62,9 +71,12 @@ async function collectCssFiles(dirPath) { // // Returns `discovered` alongside the filtered `files` so the caller can tell // “nothing under these targets” from “everything under these targets got -// excluded”—two situations deserving two different error messages. +// excluded”—two situations deserving two different error messages. Preprocessor +// sources come back under `unsupported`, kept out of `discovered` so neither +// message counts a file this function already declined. export async function expandTargets(targets, ignorePathPatterns) { const expanded = []; + const declined = []; for (const target of targets) { if (target === '-') { @@ -75,6 +87,7 @@ export async function expandTargets(targets, ignorePathPatterns) { const pathResolved = resolve(target); const stats = await stat(pathResolved); if (stats.isDirectory()) expanded.push(...(await collectCssFiles(pathResolved)).sort()); + else if (EXTENSIONS_PREPROCESSOR.has(extname(pathResolved).toLowerCase())) declined.push(pathResolved); else expanded.push(pathResolved); } @@ -82,12 +95,13 @@ export async function expandTargets(targets, ignorePathPatterns) { // simply repeated—is one file. Deduplicated before the count, so `discovered` // speaks for real files rather than argument spellings. const unique = [...new Set(expanded)]; - if (!ignorePathPatterns.length) return { files: unique, discovered: unique.length }; + const unsupported = [...new Set(declined)]; + if (!ignorePathPatterns.length) return { files: unique, discovered: unique.length, unsupported }; const files = unique.filter(file => ( file === '-' || !ignorePathPatterns.some(pattern => pattern.test(toPortablePath(file))) )); - return { files, discovered: unique.length }; + return { files, discovered: unique.length, unsupported }; } // Reads non-STDIN targets concurrently, ahead of the per-file processing diff --git a/test/cli.test.js b/test/cli.test.js index 7b67593..32d6265 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -533,13 +533,17 @@ describe('CLI', () => { fs.writeFileSync(path.join(dirTemp, 'sub', 'node_modules', 'ignored.css'), '.z { color: red; }\n.y { color: red; }\n'); fs.writeFileSync(path.join(dirTemp, 'sub', '.hidden', 'ignored.css'), '.x { color: red; }\n.w { color: red; }\n'); fs.writeFileSync(path.join(dirTemp, 'readme.txt'), 'not css'); + fs.writeFileSync(path.join(dirTemp, 'theme.scss'), '.d { color: red; }\n.e { color: red; }\n'); try { - const { stdout, status } = run([dirTemp]); + const { stdout, stderr, status } = run([dirTemp]); assert.ok(stdout.includes(path.join(dirTemp, 'one.css'))); assert.ok(stdout.includes(path.join(dirTemp, 'sub', 'two.css'))); assert.ok(!stdout.includes('node_modules')); assert.ok(!stdout.includes('.hidden')); + // Never collected, so never worth a skip message either + assert.ok(!stdout.includes('theme.scss')); + assert.ok(!stderr.includes('theme.scss')); assert.strictEqual(status, 1); } finally { fs.rmSync(dirTemp, { recursive: true, force: true }); @@ -558,6 +562,79 @@ describe('CLI', () => { } }); + // The SCSS subset the standard parser accepts—nesting alongside at-rules it + // reads as generic—which is why an extension check has to catch it: No + // syntax error stands in for one + const scssParsable = [ + '.a { color: red; @include reset; color: blue; &:hover { color: green; } }', + '.b { @extend .a; color: red; }', + '.c { color: red; }', + '', + ].join('\n'); + + test('Skips a named preprocessor source rather than consolidating it, leaving the file untouched', () => { + const dirTemp = makeTempDir('temp_preprocessor'); + const file = path.join(dirTemp, 'theme.scss'); + fs.writeFileSync(file, scssParsable); + + try { + const { stderr, status } = run(['--fix', file]); + assert.ok(stderr.includes(`Skipped ${file}`)); + assert.ok(stderr.includes('not a `.css` file')); + assert.strictEqual(fs.readFileSync(file, 'utf8'), scssParsable); + assert.strictEqual(status, 1); + } finally { + fs.rmSync(dirTemp, { recursive: true, force: true }); + } + }); + + test('A skipped preprocessor source does not stop a `.css` file named alongside it', () => { + const dirTemp = makeTempDir('temp_preprocessor_multi'); + const fileScss = path.join(dirTemp, 'theme.scss'); + const fileCss = path.join(dirTemp, 'main.css'); + fs.writeFileSync(fileScss, scssParsable); + fs.writeFileSync(fileCss, '.a { color: red; }\n.b { color: red; }\n'); + + try { + const { stdout, stderr, status } = run([fileScss, fileCss]); + assert.ok(stderr.includes('not a `.css` file')); + assert.ok(stdout.includes(fileCss)); + assert.match(stdout, findingsRow(1)); + assert.strictEqual(status, 1); + } finally { + fs.rmSync(dirTemp, { recursive: true, force: true }); + } + }); + + test('`--exit-zero` does not forgive a skipped preprocessor source', () => { + const dirTemp = makeTempDir('temp_preprocessor_exit_zero'); + const fileScss = path.join(dirTemp, 'theme.scss'); + const fileCss = path.join(dirTemp, 'main.css'); + fs.writeFileSync(fileScss, scssParsable); + fs.writeFileSync(fileCss, '.a { color: red; }\n'); + + try { + const { status } = run(['--exit-zero', fileScss, fileCss]); + assert.strictEqual(status, 1); + } finally { + fs.rmSync(dirTemp, { recursive: true, force: true }); + } + }); + + test('Accepts a named file without an extension, which is no preprocessor source', () => { + const dirTemp = makeTempDir('temp_no_extension'); + const file = path.join(dirTemp, 'styles'); + fs.writeFileSync(file, '.a { color: red; }\n.b { color: red; }\n'); + + try { + const { stdout, stderr } = run([file]); + assert.ok(!stderr.includes('not a `.css` file')); + assert.match(stdout, findingsRow(1)); + } finally { + fs.rmSync(dirTemp, { recursive: true, force: true }); + } + }); + test('Reports a concise, zoomed-in error for a CSS syntax error, not the whole source', () => { const dirTemp = makeTempDir('temp_syntax_error'); const file = path.join(dirTemp, 'bad.css'); From 88d74766f34ba04184162a6b01f81c26527b84a6 Mon Sep 17 00:00:00 2001 From: Jens Oliver Meiert Date: Wed, 5 Aug 2026 12:15:11 +0200 Subject: [PATCH 2/2] feat: improve preprocessor handling and CLI ignore logic Enhance handling of preprocessor sources by applying ignore patterns before processing them as unsupported files. Update tests to validate the new behavior and ensure consistent handling of ignored paths. (This commit message was AI-generated.) Signed-off-by: Jens Oliver Meiert --- bin/css-dedup.js | 2 +- src/cli/targets.js | 21 +++++++++++++-------- test/cli.test.js | 17 +++++++++++++++++ 3 files changed, 31 insertions(+), 9 deletions(-) diff --git a/bin/css-dedup.js b/bin/css-dedup.js index 7d4f98d..5be18c9 100644 --- a/bin/css-dedup.js +++ b/bin/css-dedup.js @@ -63,7 +63,7 @@ async function resolveFiles(positionals, ignorePathPatterns) { // ever forgives findings. Reported before the per-file output starts, since // the remaining targets still process normally. for (const file of unsupported) { - console.error(styleText('red', `Skipped ${file}: not a \`.css\` file—CSS Dedup analyzes CSS, so point it at the compiled style sheet rather than at a Sass or Less source.`)); + console.error(styleText('red', `Skipped ${file}: not a \`.css\` file—CSS Dedup analyzes CSS, so point it at the compiled style sheet rather than at a preprocessor source.`)); } if (unsupported.length) process.exitCode = 1; diff --git a/src/cli/targets.js b/src/cli/targets.js index 4e667e6..46cdeb3 100644 --- a/src/cli/targets.js +++ b/src/cli/targets.js @@ -73,7 +73,9 @@ async function collectCssFiles(dirPath) { // “nothing under these targets” from “everything under these targets got // excluded”—two situations deserving two different error messages. Preprocessor // sources come back under `unsupported`, kept out of `discovered` so neither -// message counts a file this function already declined. +// message counts a file this function already declined, and filtered by +// `ignorePathPatterns` the same way `files` is: An excluded path is excluded +// whatever its extension, and has nothing to be reported about. export async function expandTargets(targets, ignorePathPatterns) { const expanded = []; const declined = []; @@ -95,13 +97,16 @@ export async function expandTargets(targets, ignorePathPatterns) { // simply repeated—is one file. Deduplicated before the count, so `discovered` // speaks for real files rather than argument spellings. const unique = [...new Set(expanded)]; - const unsupported = [...new Set(declined)]; - if (!ignorePathPatterns.length) return { files: unique, discovered: unique.length, unsupported }; - - const files = unique.filter(file => ( - file === '-' || !ignorePathPatterns.some(pattern => pattern.test(toPortablePath(file))) - )); - return { files, discovered: unique.length, unsupported }; + const declinedUnique = [...new Set(declined)]; + if (!ignorePathPatterns.length) return { files: unique, discovered: unique.length, unsupported: declinedUnique }; + + // `-` never reaches `declined`, so only `files` needs the STDIN exception + const ignored = file => ignorePathPatterns.some(pattern => pattern.test(toPortablePath(file))); + return { + files: unique.filter(file => file === '-' || !ignored(file)), + discovered: unique.length, + unsupported: declinedUnique.filter(file => !ignored(file)), + }; } // Reads non-STDIN targets concurrently, ahead of the per-file processing diff --git a/test/cli.test.js b/test/cli.test.js index 32d6265..f39c51c 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -621,6 +621,23 @@ describe('CLI', () => { } }); + test('`--ignore-path` excludes a preprocessor source before it is reported as skipped', () => { + const dirTemp = makeTempDir('temp_preprocessor_ignore_path'); + const fileScss = path.join(dirTemp, 'theme.scss'); + const fileCss = path.join(dirTemp, 'main.css'); + fs.writeFileSync(fileScss, scssParsable); + fs.writeFileSync(fileCss, '.a { color: red; }\n'); + + try { + const { stdout, stderr, status } = run(['-p', 'theme\\.scss$', fileScss, fileCss]); + assert.ok(!stderr.includes('not a `.css` file')); + assert.ok(!stdout.includes('theme.scss')); + assert.strictEqual(status, 0); + } finally { + fs.rmSync(dirTemp, { recursive: true, force: true }); + } + }); + test('Accepts a named file without an extension, which is no preprocessor source', () => { const dirTemp = makeTempDir('temp_no_extension'); const file = path.join(dirTemp, 'styles');