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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,17 @@ As of version 5.0.0, all notable changes to HTML Minifier (HM) are documented in

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).

## [Unreleased]

### Added

* Added a preview of zero-config mode with `--zero --dry`, or `--dry` without input (which used to wait silently for input)

### Changed

* Made the CLI say how to end input, and how to minify files instead, when it reads from STDIN in a terminal
* Made `--dry` and `--verbose` suggest `--preset=comprehensive` when no minification options are set

## [5.1.0] - 2026-11-11

### Fixed
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ Use `npx html-minifier --help` to check all available options:

| Option | Description | Example |
| --- | --- | --- |
| `--zero`, `-z` | Minify all HTML files in the current folder and its subfolders in place (except node_modules), using comprehensive settings (standalone—the flag is ignored when combined with other options) | `npx html-minifier --zero` |
| `--zero`, `-z` | Minify all HTML files in the current folder and its subfolders in place (except node_modules), using comprehensive settings (standalone or with `--dry` to preview—the flag is ignored when combined with other options) | `npx html-minifier --zero` |
| `--input-dir <dir>`, `-I <dir>` | Specify an input directory | `--input-dir=src` |
| `--ignore-dir <patterns>`, `-X <patterns>` | Exclude directories—relative to input directory—from processing (comma-separated, overrides config file setting) | `--ignore-dir=libs`, `--ignore-dir=libs,vendor,node_modules` |
| `--output-dir <dir>`, `-O <dir>` | Specify an output directory | `--output-dir=dist` |
Expand All @@ -45,7 +45,7 @@ Use `npx html-minifier --help` to check all available options:
| `--preset <name>`, `-p <name>` | Use a preset configuration (conservative or comprehensive) | `--preset=conservative` |
| `--config-file <file>`, `-c <file>` | Use a configuration file (defaults to html-minifier.config.json in the working directory, if present) | `--config-file=path/to/config.json` |
| `--verbose`, `-v` | Show detailed processing information (active options, worker thread count, file statistics, and minifier warnings) | `npx html-minifier --input-dir=src --output-dir=dist --verbose --collapse-whitespace` |
| `--dry`, `-d` | Dry run: Process and report statistics without writing output | `npx html-minifier input.html --dry --collapse-whitespace` |
| `--dry`, `-d` | Dry run: Process and report statistics without writing output (without input, previews `--zero`) | `npx html-minifier input.html --dry --collapse-whitespace` |

### Configuration file

Expand Down
2 changes: 1 addition & 1 deletion backtest/backtest.js
Original file line number Diff line number Diff line change
Expand Up @@ -495,7 +495,7 @@ if (isMain && (process.argv.length > 2 || !process.send)) {

// Register cleanup handlers for various exit scenarios
const sigintHandler = async () => {
await cleanup('SIGINT (Ctrl + C)');
await cleanup('SIGINT (Ctrl+C)');
process.exit(130);
};
const sigtermHandler = async () => {
Expand Down
83 changes: 55 additions & 28 deletions cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ Object.entries(optionDefinitions).forEach(function ([key, { description, descrip
program.option('-i --input <file>', 'Specify input file (alternative to positional argument; pair with `--output` for output)');
program.option('-o --output <file>', 'Specify output file (reads from `--input` file argument or STDIN; outputs to STDOUT if not specified)');
program.option('-v --verbose', 'Show detailed processing information (active options, worker thread count, file statistics, and minifier warnings)');
program.option('-d --dry', 'Dry run: Process and report statistics without writing output');
program.option('-d --dry', 'Dry run: Process and report statistics without writing output (without input, previews `--zero`)');
program.addHelpText('after', '\nBoolean options support a `--no-<flag>` form to disable them, overriding a preset or config file (e.g., `--preset=comprehensive --no-collapse-whitespace`).\n\nHTML Minifier documentation: https://meiert.com/hm');

// Lazy import wrapper for HM
Expand Down Expand Up @@ -351,7 +351,7 @@ function normalizeConfig(config) {

/** @type {Record<string, any>} */
let config = {};
program.option('-z, --zero', 'Minify all HTML files in the current folder and its subfolders in place (except node_modules), using comprehensive settings (standalone—the flag is ignored when combined with other options)');
program.option('-z, --zero', 'Minify all HTML files in the current folder and its subfolders in place (except node_modules), using comprehensive settings (standalone or with `--dry` to preview—the flag is ignored when combined with other options)');
program.option('-I --input-dir <dir>', 'Specify an input directory');
program.option('-X --ignore-dir <patterns>', 'Exclude directories—relative to input directory—from processing (comma-separated, overrides config file setting)');
program.option('-O --output-dir <dir>', 'Specify an output directory');
Expand Down Expand Up @@ -393,23 +393,28 @@ program.helpOption('-h, --help', 'Display help for command');
filesProvided = true;
}

// Handle zero config mode (standalone in-place minification of the current folder)
if (programOptions.zero) {
const hasOtherArgs = process.argv.slice(2).some(arg => arg !== '--zero' && arg !== '-z');
if (hasOtherArgs) {
console.error('Note: `--zero` was ignored—it can only be used on its own, to minify the current folder at comprehensive settings.');
// Handle zero config mode (standalone in-place minification of the current folder),
// which `--dry` on its own in a terminal previews, as it would otherwise wait for input
const isZeroOnly = process.argv.slice(2).every(arg => ['--zero', '-z', '--dry', '-d'].includes(arg));
if (programOptions.zero && !isZeroOnly) {
console.error('Note: `--zero` was ignored—it can only be used on its own (or with `--dry`), to minify the current folder at comprehensive settings.');
} else if (isZeroOnly && (programOptions.zero || (programOptions.dry && process.stdin.isTTY))) {
const isDry = Boolean(programOptions.dry);
const cwd = process.cwd();
const [execPath = '', scriptPath = ''] = process.argv;
const commandName = process.env.npm_command === 'exec'
? 'npx html-minifier'
: scriptPath.endsWith('.js')
? `${path.basename(execPath)} ${scriptPath}`
: path.basename(scriptPath);
const commandEquivalent = `Equivalent to: ${commandName} --input-dir=. --output-dir=. --ignore-dir=node_modules --preset=comprehensive${isDry ? ' --dry' : ''}`;

if (isDry) {
console.error(commandEquivalent);
} else {
const cwd = process.cwd();
const [execPath = '', scriptPath = ''] = process.argv;
const commandName = process.env.npm_command === 'exec'
? 'npx html-minifier'
: scriptPath.endsWith('.js')
? `${path.basename(execPath)} ${scriptPath}`
: path.basename(scriptPath);

process.stderr.write(
`${MARK_WARNING}Zero-config mode minifies all HTML files in the current folder and its subfolders (${cwd}) in place, using comprehensive settings. If you want to compare results and be able to revert, do this under version control.${MARK_RESET}\n` +
`Equivalent to: ${commandName} --input-dir=. --output-dir=. --ignore-dir=node_modules --preset=comprehensive\n\n` +
`${commandEquivalent}\n\n` +
`Do you want to continue? [y/N] `
);

Expand All @@ -426,16 +431,26 @@ program.helpOption('-h, --help', 'Display help for command');
process.stderr.write(`${MARK_ERROR}In-place minification aborted.${MARK_RESET}\n`);
process.exit(0);
}
}

programOptions.preset = 'comprehensive';
programOptions.preset = 'comprehensive';

const inputDirResolved = await fs.promises.realpath(cwd).catch(() => cwd);
const { stats, failed } = await processDirectory(cwd, cwd, EXTENSIONS_DEFAULT, false, false, undefined, Boolean(process.stderr.isTTY), ['node_modules'], inputDirResolved);
reportProcessed(stats.length);
reportFailed(failed);
if (isDry) {
getActiveOptionsDisplay(createOptions());
console.error(`[DRY RUN] Would process directory: ${cwd} → ${cwd}`);
}

process.exit();
const inputDirResolved = await fs.promises.realpath(cwd).catch(() => cwd);
const { stats, failed } = await processDirectory(cwd, cwd, EXTENSIONS_DEFAULT, isDry, isDry, undefined, Boolean(process.stderr.isTTY) && !isDry, ['node_modules'], inputDirResolved);
if (isDry) {
reportTotal(stats);
await printCacheStats();
} else {
reportProcessed(stats.length);
}
reportFailed(failed);

process.exit();
}

// Load and normalize config if `--config-file` was specified; otherwise look
Expand Down Expand Up @@ -527,6 +542,9 @@ program.helpOption('-h, --help', 'Display help for command');
if (activeOptions.length > 0) {
console.error('CLI options: ' + activeOptions.join(', '));
}
if (!presetName && Object.keys(minifierOptions).every(k => k === 'log')) {
console.error('Note: No minification options set—for a start, try `--preset=comprehensive`.');
}
}

/**
Expand Down Expand Up @@ -561,6 +579,15 @@ program.helpOption('-h, --help', 'Display help for command');
console.error(` ${MARK_SUCCESS}✓${MARK_RESET} ${label}: ${formatSizeStats(stats)}`);
}

/** @param {{ originalSize: number, minifiedSize: number }[]} stats */
function reportTotal(stats) {
if (!stats.length) return;
const totalOriginal = stats.reduce((sum, s) => sum + s.originalSize, 0);
const totalMinified = stats.reduce((sum, s) => sum + s.minifiedSize, 0);
console.error('---');
console.error(`Total: ${formatSizeStats(calculateSizeStats(totalOriginal, totalMinified))}`);
}

/** @param {number} count */
function reportProcessed(count) {
console.error(`${MARK_SUCCESS}Processed ${count.toLocaleString()} file${count === 1 ? '' : 's'}.${MARK_RESET}`);
Expand Down Expand Up @@ -1092,11 +1119,8 @@ program.helpOption('-h, --help', 'Display help for command');
reportProcessed(stats.length);
}

if (isVerbose && stats.length > 0) {
const totalOriginal = stats.reduce((sum, s) => sum + s.originalSize, 0);
const totalMinified = stats.reduce((sum, s) => sum + s.minifiedSize, 0);
console.error('---');
console.error(`Total: ${formatSizeStats(calculateSizeStats(totalOriginal, totalMinified))}`);
if (isVerbose) {
reportTotal(stats);
}

if (isVerbose) {
Expand Down Expand Up @@ -1140,7 +1164,10 @@ program.helpOption('-h, --help', 'Display help for command');
await reportAndWrite(capturedFiles.join(', '), originals.join(''), outputs.join(''));
process.exit(0);
} else { // Minifying input coming from STDIN
if (process.stdin.isTTY && process.argv.length === 2) program.help();
if (process.stdin.isTTY) {
if (process.argv.length === 2) program.help();
console.error(`Reading HTML from STDIN—end with ${process.platform === 'win32' ? 'Ctrl+Z, Enter' : 'Ctrl+D'}. To minify files instead, pass them as arguments, specify input and output directories (\`--input-dir\`/\`--output-dir\`), or use zero-config mode (\`--zero\`).`);
}

content = '';
process.stdin.setEncoding('utf8');
Expand Down
95 changes: 94 additions & 1 deletion test/cli.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1272,11 +1272,80 @@ describe('CLI', { concurrency: os.availableParallelism() }, () => {
assert.strictEqual(stdout.toString(), '<p>a</p>');
});

test('Says how to end input when reading from a terminal', async () => {
const { stderr } = await spawnAsync('node', ['--import', asTerminal, cliPath, '--collapse-whitespace'], { input: '<p>a</p>' });

assert.ok(stderr.toString().includes('Reading HTML from STDIN'));
});

test('Keeps reading piped input', async () => {
const { stdout, status } = await spawnCli([], { input: '<p>a</p>' });
const { stdout, stderr, status } = await spawnCli(['--collapse-whitespace'], { input: '<p>a</p>' });

assert.strictEqual(status, 0);
assert.strictEqual(stdout.toString(), '<p>a</p>');
assert.strictEqual(stderr.toString(), '');
});

test('Previews `--zero` for `--dry` in a terminal', async () => {
const tempDir = path.resolve(dirSandbox(), 'tmp/dry-terminal');
await fs.promises.mkdir(tempDir, { recursive: true });
await fs.promises.copyFile(path.resolve(dirSandbox(), 'default.html'), path.resolve(tempDir, 'default.html'));
const original = await fs.promises.readFile(path.resolve(tempDir, 'default.html'), 'utf-8');

const { stderr, status } = await spawnAsync('node', ['--import', asTerminal, cliPath, '--dry'], { cwd: tempDir });

assert.strictEqual(status, 0);
assert.ok(stderr.toString().includes('[DRY RUN] Would process directory'));
assert.ok(stderr.toString().includes('Using preset: comprehensive'));
assert.ok(stderr.toString().includes('Total:'));
assert.strictEqual(await fs.promises.readFile(path.resolve(tempDir, 'default.html'), 'utf-8'), original);
});

test('Keeps reading piped input for `--dry`', async () => {
const { stdout, stderr, status } = await spawnCli(['--dry'], { input: '<p>a</p>' });

assert.strictEqual(status, 0);
assert.strictEqual(stdout.toString(), '');
assert.ok(stderr.toString().includes('[DRY RUN] Would minify: STDIN'));
});
});

describe('Without minification options', () => {
test('Suggests a preset for `--dry`', async () => {
const { stderr } = await spawnCli(['--dry'], { input: '<p>a</p>' });

assert.ok(stderr.toString().includes('No minification options set'));
assert.ok(stderr.toString().includes('`--preset=comprehensive`'));
});

test('Suggests a preset for `--verbose`', async () => {
const { stderr } = await spawnCli(['--verbose'], { input: '<p>a</p>' });

assert.ok(stderr.toString().includes('No minification options set'));
});

test('Does not suggest a preset with options set', async () => {
const { stderr: withOption } = await spawnCli(['--dry', '--collapse-whitespace'], { input: '<p>a</p>' });
const { stderr: withPreset } = await spawnCli(['--dry', '--preset=conservative'], { input: '<p>a</p>' });

assert.ok(!withOption.toString().includes('No minification options set'));
assert.ok(!withPreset.toString().includes('No minification options set'));
});

test('Does not suggest a preset with options from a config file', async () => {
const configPath = path.resolve(dirSandbox(), 'tmp/no-options.json');
await fs.promises.mkdir(path.dirname(configPath), { recursive: true });
await fs.promises.writeFile(configPath, JSON.stringify({ collapseWhitespace: true }));

const { stderr } = await spawnCli(['--dry', `--config-file=${configPath}`], { input: '<p>a</p>' });

assert.ok(!stderr.toString().includes('No minification options set'));
});

test('Does not suggest a preset without `--dry` or `--verbose`', async () => {
const { stderr } = await spawnCli([], { input: '<p>a</p>' });

assert.strictEqual(stderr.toString(), '');
});
});

Expand Down Expand Up @@ -1915,6 +1984,30 @@ describe('CLI', { concurrency: os.availableParallelism() }, () => {
assert.ok(result.stderr.toString().includes('[y/N]'));
});

test('Previews `--zero` with `--dry` without prompting or writing', async () => {
const tempDir = path.resolve(dirSandbox(), 'tmp/zero-dry');
await fs.promises.mkdir(tempDir, { recursive: true });
await fs.promises.copyFile(
path.resolve(dirSandbox(), 'default.html'),
path.resolve(tempDir, 'default.html')
);
const original = await fs.promises.readFile(path.resolve(tempDir, 'default.html'), 'utf-8');

for (const args of [['--zero', '--dry'], ['-d', '-z']]) {
const result = await spawnCli(args, { cwd: tempDir });
const stderr = result.stderr.toString();

assert.strictEqual(result.status, 0);
assert.ok(!stderr.includes('[y/N]'));
assert.ok(!stderr.includes('`--zero` was ignored'));
assert.ok(stderr.includes('--preset=comprehensive --dry'));
assert.ok(stderr.includes('[DRY RUN] Would process directory'));
assert.ok(/default\.html: [\d,]+ → [\d,]+ bytes/.test(stderr));
assert.ok(stderr.includes('Total:'));
assert.strictEqual(await fs.promises.readFile(path.resolve(tempDir, 'default.html'), 'utf-8'), original);
}
});

// Boolean flag negation tests
test('Lets `--no-X` override a boolean option enabled by a preset', async () => {
const input = '<p> hello world</p>';
Expand Down
Loading