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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ 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.12.0] - 2026-08-12

### Changed

* Switched to analyzing the current directory when no file is given, instead of printing help and exiting “1”
* Changed to ask for confirmation before `--fix` rewrites a working directory nobody named; an explicit target (`css-dedup --fix .`) runs unprompted

## [1.11.0] - 2026-08-05

### Changed
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,10 @@ The two aren’t always aligned, though: Folding a declaration into a shared sel
### CLI

```shell
npx css-dedup [options] <file…>
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).
Pass one or more files—each is analyzed (and, with `--fix`, rewritten) independently. Without a file, CSS Dedup analyzes the current directory. 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.

Expand Down
34 changes: 33 additions & 1 deletion bin/css-dedup.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#!/usr/bin/env node

import { createInterface } from 'node:readline';
import { styleText } from 'node:util';
import { computeFilePass, describePassError } from '../src/cli/file-pass.js';
import { plural, sumBy } from '../src/cli/format.js';
Expand All @@ -22,6 +23,35 @@ function showHelp(text, code) {
process.exit(code);
}

// `--fix` rewrites in place, which is the user’s call to make—but a run that
// names no target picks the whole working directory, and that is easy to hit
// from the wrong folder. Only the implicit tree is confirmed; an explicit
// `css-dedup --fix .` says the same thing out loud and passes straight through.
async function confirmImplicitFix() {
const cwd = process.cwd();

// Nothing can answer, and defaulting either way would be a guess: proceeding
// rewrites a tree nobody named, aborting turns a scripted run into a silent
// no-op. Naming the path in the script settles it.
if (!process.stdin.isTTY) {
fail(styleText('red', `Refusing \`--fix\` without a target—it would rewrite every \`.css\` file under ${cwd}. Name the path (\`css-dedup --fix .\`) to confirm.`));
}

process.stderr.write(
`${styleText('yellow', `\`--fix\` rewrites files in place—without a target, that is every \`.css\` file under ${cwd}. If you want to compare results and be able to revert, do this under version control.`)}\n` +
'Do you want to continue? [y/N] '
);

const rl = createInterface({ input: process.stdin });
const answer = await new Promise(resolve => {
rl.once('line', line => resolve(line.trim().toLowerCase()));
rl.once('close', () => resolve(''));
});
rl.close();

if (answer !== 'y') fail('Consolidation aborted.', 0);
}

// One target’s pass on this thread, in the shape a worker sends back
async function runFilePass(css, options, { fix, quiet, isStdin, label }) {
try {
Expand Down Expand Up @@ -79,10 +109,12 @@ async function resolveFiles(positionals, ignorePathPatterns) {
}

async function main() {
const { values, positionals } = parseCliArgs(process.argv.slice(2), { fail, showHelp });
const { values, positionals, implicitTarget } = parseCliArgs(process.argv.slice(2), { fail, showHelp });
const config = await loadConfig(values.config);
const { options, ignorePathPatterns, exitZero, flags } = buildRunSettings(values, config);

if (flags.fix && implicitTarget) await confirmImplicitFix();

const files = await resolveFiles(positionals, ignorePathPatterns);
const multi = files.length > 1;
const prefetched = await prefetchContents(files);
Expand Down
48 changes: 27 additions & 21 deletions package-lock.json

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

8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@
"css-dedup": "bin/css-dedup.js"
},
"dependencies": {
"postcss": "^8.5.25"
"postcss": "^8.5.26"
},
"description": "CSS declaration deduplicator for maintainability and performance optimization",
"devDependencies": {
"@eslint/js": "^10.0.1",
"eslint": "^10.8.0",
"globals": "^17.9.0",
"eslint": "^10.8.1",
"globals": "^17.10.0",
"typescript": "^7.0.2"
},
"engines": {
Expand Down Expand Up @@ -60,5 +60,5 @@
},
"type": "module",
"types": "src/index.d.ts",
"version": "1.11.0"
"version": "1.12.0"
}
11 changes: 7 additions & 4 deletions src/cli/options.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,12 @@ const OPTIONS_CONFIG = {
help: { type: 'boolean', short: 'h', default: false },
};

const HELP = `Usage: css-dedup [options] <file…>
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. Preprocessor sources (.scss, .sass, .less, .styl) are skipped—run CSS Dedup on the compiled style sheet.
file One or more CSS files or directories to analyze, defaulting to the current directory (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.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Options:
-f, --fix Consolidate declarations that are safe to merge automatically, rewriting each file in place (or printing to STDOUT for \`-\`)
Expand Down Expand Up @@ -78,7 +78,7 @@ export function parseCliArgs(argv, { fail, showHelp }) {

const { values, positionals } = parseArgs({ args: argv, options: OPTIONS_CONFIG, allowPositionals: true, strict: true });

if (values.help || !positionals.length) showHelp(HELP, values.help ? 0 : 1);
if (values.help) showHelp(HELP, 0);
if (positionals.includes('-') && positionals.length > 1) {
fail('Cannot combine STDIN (`-`) with other file arguments.');
}
Expand All @@ -87,7 +87,10 @@ export function parseCliArgs(argv, { fail, showHelp }) {
if (values[key] && !values.fix) fail(`\`${flag}\` only applies together with \`--fix\` (${reason})`);
}

return { values, positionals };
// A run without a target reads the working directory
const implicitTarget = !positionals.length;

return { values, positionals: implicitTarget ? ['.'] : positionals, implicitTarget };
}

// Marks a failure as the user’s to fix (a bad pattern, an unloadable config)
Expand Down
87 changes: 82 additions & 5 deletions test/cli.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import path from 'node:path';
import { availableParallelism } from 'node:os';
import { poolSize, shouldParallelize } from '../src/cli/pool.js';
import { dedup } from '../src/index.js';
import { BEST_CELL, RE_MERGED_AB, RE_MERGED_AC, RE_PAYOFF_FIX, RE_SYNTAX_ERROR, RE_SYNTAX_ERROR_UNCLOSED, RE_WITHHELD_ONE, cssGrowing, cssGrowingAggressive, cssShrinkingAggressive, dirTest, findingsRow, fixturesDir, makeTempDir, run, runColor } from './helpers.js';
import { BEST_CELL, RE_MERGED_AB, RE_MERGED_AC, RE_PAYOFF_FIX, RE_SYNTAX_ERROR, RE_SYNTAX_ERROR_UNCLOSED, RE_WITHHELD_ONE, cssGrowing, cssGrowingAggressive, cssShrinkingAggressive, dirTest, findingsRow, fixturesDir, makeTempDir, run, runColor, runTty } from './helpers.js';

describe('CLI', () => {
test('Shows help with `--help`', () => {
Expand All @@ -14,12 +14,89 @@ describe('CLI', () => {
assert.strictEqual(status, 0);
});

test('Shows help and exits non-zero when no file is given', () => {
const { stdout, status } = run([]);
assert.ok(stdout.includes('Usage:'));
assert.strictEqual(status, 1);
test('Analyzes the working directory when no file is given', () => {
const dirTemp = makeTempDir('temp_implicit_target');
fs.writeFileSync(path.join(dirTemp, 'implicit.css'), '.a { color: red; }\n.b { color: red; }\n');

try {
// Exit 1 is the finding, not a failure—`stderr` stays empty
const { stdout, stderr, status } = run([], { cwd: dirTemp });
assert.ok(stdout.includes('implicit.css'));
assert.ok(stdout.includes('duplicate'));
assert.strictEqual(stderr, '');
assert.strictEqual(status, 1);
} finally {
fs.rmSync(dirTemp, { recursive: true, force: true });
}
});

test('Refuses `--fix` without a target when nothing can answer the prompt', () => {
const dirTemp = makeTempDir('temp_implicit_fix');
const file = path.join(dirTemp, 'implicit.css');
const css = '.a { color: red; }\n.b { color: red; }\n';
fs.writeFileSync(file, css);

try {
const { stderr, status } = run(['--fix'], { cwd: dirTemp });
assert.ok(stderr.includes('Refusing `--fix` without a target'));
assert.strictEqual(status, 1);
assert.strictEqual(fs.readFileSync(file, 'utf8'), css, 'File should be untouched');
} finally {
fs.rmSync(dirTemp, { recursive: true, force: true });
}
});

test('Runs `--fix` without a prompt when the target is named explicitly', () => {
const dirTemp = makeTempDir('temp_explicit_fix');
const file = path.join(dirTemp, 'explicit.css');
fs.writeFileSync(file, '.a { color: red; }\n.b { color: red; }\n');

try {
const { stderr, status } = run(['--fix', '.'], { cwd: dirTemp });
assert.ok(!stderr.includes('Refusing'));
assert.ok(!stderr.includes('Do you want to continue?'));
assert.strictEqual(status, 0);
assert.match(fs.readFileSync(file, 'utf8'), RE_MERGED_AB);
} finally {
fs.rmSync(dirTemp, { recursive: true, force: true });
}
Comment thread
j9t marked this conversation as resolved.
});

test('Runs `--fix` without a target once the prompt is answered', () => {
const dirTemp = makeTempDir('temp_prompt_accept');
const file = path.join(dirTemp, 'prompted.css');
fs.writeFileSync(file, '.a { color: red; }\n.b { color: red; }\n');

try {
const { stderr, status } = runTty(['--fix'], 'y\n', { cwd: dirTemp });
assert.ok(stderr.includes('Do you want to continue?'));
assert.strictEqual(status, 0);
assert.match(fs.readFileSync(file, 'utf8'), RE_MERGED_AB);
} finally {
fs.rmSync(dirTemp, { recursive: true, force: true });
}
});

// Anything that isn’t `y` leaves the tree alone, including the empty answer
// an EOF resolves to—the same outcome by design, so both are asserted here
for (const [label, input] of [['a declined prompt', 'n\n'], ['EOF', '']]) {
test(`Aborts \`--fix\` without a target on ${label}`, () => {
const dirTemp = makeTempDir('temp_prompt_decline');
const file = path.join(dirTemp, 'prompted.css');
const css = '.a { color: red; }\n.b { color: red; }\n';
fs.writeFileSync(file, css);

try {
const { stderr, status } = runTty(['--fix'], input, { cwd: dirTemp });
assert.ok(stderr.includes('Consolidation aborted.'));
assert.strictEqual(status, 0, 'Declining is not a failure');
assert.strictEqual(fs.readFileSync(file, 'utf8'), css, 'File should be untouched');
} finally {
fs.rmSync(dirTemp, { recursive: true, force: true });
}
});
}

test('Excludes a selector via `-i` (short for `--ignore-selector`)', () => {
const dirTemp = makeTempDir('temp_ignore_selector');
const file = path.join(dirTemp, 'legacy.css');
Expand Down
14 changes: 12 additions & 2 deletions test/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,8 @@ export const RE_SYNTAX_ERROR_UNCLOSED = /Unclosed block/;
// leaves `stdout`/`stderr` null and reports the cause on `result.error`—
// defaulted to empty strings here so a caller doesn’t get a confusing throw
// from string handling instead of the actual failure.
function spawnCli(args, { env, ...spawnOptions } = {}) {
const result = spawnSync('node', [scriptPath, ...args], {
function spawnCli(args, { env, nodeArgs = [], ...spawnOptions } = {}) {
const result = spawnSync('node', [...nodeArgs, scriptPath, ...args], {
encoding: 'utf-8',
timeout: 30_000,
...(env ? { env } : {}),
Expand All @@ -88,6 +88,16 @@ export function run(args, spawnOptions = {}) {
};
}

// `--fix` only prompts when STDIN is a TTY, which `spawnSync` never provides.
// The preload flips that one flag, leaving STDIN the ordinary pipe `input`
// writes the answer into—so the prompt is exercised without the CLI needing a
// test-only escape hatch of its own. Pass `''` for `input` to answer with EOF.
const PRELOAD_TTY = 'data:text/javascript,process.stdin.isTTY = true;';
Comment thread
j9t marked this conversation as resolved.

export function runTty(args, input, spawnOptions = {}) {
return run(args, { ...spawnOptions, input, nodeArgs: ['--import', PRELOAD_TTY] });
}

// `run()` strips color codes—`node:util`’s `styleText` skips them itself
// once STDOUT isn’t a TTY, which `spawnSync` never gives it—so highlighting
// tests force color on and read the raw (unstripped) output instead
Expand Down