Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ npx css-dedup [options] <file…>

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 `-`) |
Expand Down
13 changes: 11 additions & 2 deletions bin/css-dedup.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 preprocessor source.`));
}
if (unsupported.length) process.exitCode = 1;

if (!files.length) {
const targets = positionals.join(', ');
if (discovered > 0) {
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,5 +60,5 @@
},
"type": "module",
"types": "src/index.d.ts",
"version": "1.10.1"
"version": "1.11.0"
}
2 changes: 1 addition & 1 deletion src/cli/options.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ const HELP = `Usage: css-dedup [options] <file…>
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 \`-\`)
Expand Down
33 changes: 26 additions & 7 deletions src/cli/targets.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -62,9 +71,14 @@ 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, 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 = [];

for (const target of targets) {
if (target === '-') {
Expand All @@ -75,19 +89,24 @@ 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);
}

// A path reachable twice—named directly and again through a directory, or
// 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 files = unique.filter(file => (
file === '-' || !ignorePathPatterns.some(pattern => pattern.test(toPortablePath(file)))
));
return { files, discovered: unique.length };
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
Expand Down
96 changes: 95 additions & 1 deletion test/cli.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand All @@ -558,6 +562,96 @@ 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('`--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');
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');
Expand Down