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
9 changes: 9 additions & 0 deletions .changeset/allow-empty-output.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@node-minify/types": minor
"@node-minify/utils": minor
"@node-minify/cli": minor
---

feat: add `allowEmptyOutput` option to skip writing empty output files

When minifiers produce empty output (e.g., CSS files with only comments), the new `allowEmptyOutput` option allows gracefully skipping the file write instead of throwing a validation error. Also adds `--allow-empty-output` CLI flag.
3 changes: 2 additions & 1 deletion docs/src/components/Pagination.astro
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ const index = allLinks.findIndex((x) => x.link === cleanPath);

// Guard against page not found in sidebar (index === -1)
const prev = index > 0 ? allLinks[index - 1] : undefined;
const next = index >= 0 && index < allLinks.length - 1 ? allLinks[index + 1] : undefined;
const next =
index >= 0 && index < allLinks.length - 1 ? allLinks[index + 1] : undefined;
---

<nav class="pagination" aria-label="Pagination">
Expand Down
8 changes: 8 additions & 0 deletions docs/src/content/docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,14 @@ You can pass an `option` as a JSON string to the compressor.
node-minify --compressor uglify-js --input 'input.js' --output 'output.js' --option '{"warnings": true, "mangle": false}'
```

## Allowing Empty Output

When minifying files that produce empty output (e.g., CSS with only comments), use `--allow-empty-output` to skip writing instead of throwing an error.

```bash
node-minify --compressor clean-css --input 'comments-only.css' --output 'output.css' --allow-empty-output
```

## Benchmark Command

Compare the performance of different compressors using the `benchmark` command.
Expand Down
23 changes: 23 additions & 0 deletions docs/src/content/docs/options.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,29 @@ minify({
});
```

## Allowing Empty Output

When minifying files that contain only comments (e.g., license headers in CSS), the minifier may produce empty output. By default, this throws a validation error. Use `allowEmptyOutput` to skip writing the file instead.

```js
const minify = require('@node-minify/core');
const cleanCss = require('@node-minify/clean-css');

minify({
compressor: cleanCss,
input: 'styles-with-only-comments.css',
output: 'styles.min.css',
allowEmptyOutput: true, // Skip writing if result is empty
callback: function(err, min) {}
});
```

When `allowEmptyOutput: true`:
- Empty results are silently skipped (no file written, no error)
- Source maps are also skipped when code is empty
- Returns empty string `""` for in-memory mode
- Original file is preserved when using `replaceInPlace`

## Max Buffer Size (only for Java)

In some cases you might need a bigger max buffer size (for example when minifying really large files).
Expand Down
12 changes: 12 additions & 0 deletions packages/cli/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,5 +272,17 @@ describe("CLI Coverage", () => {
"Compression failed: Minify failed"
);
});

test("should return default result when allowEmptyOutput skips writing", async () => {
const settings = {
compressor: () => ({ code: "" }),
content: "/* comment only */",
output: "/tmp/nonexistent-output-file.js",
allowEmptyOutput: true,
};
const result = await compress(settings as any);
expect(result.size).toBe("0");
expect(result.sizeGzip).toBe("0");
});
});
});
58 changes: 32 additions & 26 deletions packages/cli/src/bin/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,31 @@ function setupProgram(): Command {
"file type: js or css (required for esbuild, yui)"
)
.option("-s, --silence", "no output will be printed")
.option(
"--allow-empty-output",
"Skip writing output when result is empty"
)
.option(
"-O, --option [option]",
"option for the compressor as JSON object",
""
);
)
.action(async () => {
const options: SettingsWithCompressor = program.opts();
const hasInput =
Array.isArray(options.input) && options.input.length > 0;
if (!options.compressor || !hasInput || !options.output) {
program.help();
return;
}
try {
await run(options);
process.exit(0);
} catch (error) {
console.error(error);
process.exit(1);
}
});

program
.command("benchmark <input>")
Expand Down Expand Up @@ -112,6 +132,11 @@ function setupProgram(): Command {
return program;
}

/**
* Prints the list of available compressors to standard output.
*
* Outputs a header, each compressor name prefixed with a dash, and a trailing blank line.
*/
function displayCompressorsList() {
console.log(" List of compressors:");
console.log("");
Expand All @@ -121,35 +146,16 @@ function displayCompressorsList() {
console.log("");
}

function validateOptions(options: SettingsWithCompressor, program: Command) {
if (!options.compressor || !options.input || !options.output) {
program.help();
}
}

/**
* Initialize the update notifier and start parsing command-line arguments for the CLI.
*
* Registers the package update notifier and parses process.argv with the configured command-line program.
*/
async function main(): Promise<void> {
updateNotifier({ pkg: packageJson }).notify();

const program = setupProgram();
program.parse(process.argv);

// If no command was executed, validate global options for the main command
if (
program.args.length === 0 ||
(program.args.length > 0 &&
AVAILABLE_MINIFIER.some((m) => m.name === program.args[0]))
) {
const options: SettingsWithCompressor = program.opts();
validateOptions(options, program);

try {
await run(options);
process.exit(0);
} catch (error) {
console.error(error);
process.exit(1);
}
}
await program.parseAsync(process.argv);
}

main();
22 changes: 14 additions & 8 deletions packages/cli/src/compress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,21 +12,21 @@ import type { CompressorOptions, Result, Settings } from "@node-minify/types";
import {
getFilesizeGzippedInBytes,
getFilesizeInBytes,
isValidFileAsync,
} from "@node-minify/utils";

/**
* Run the configured compressor and, when possible, report the output file sizes.
* Run the configured compressor and, when possible, include output file sizes in the result.
*
* The function executes compression using the provided settings. If `options.output`
* is a single file path (not an array and not containing the `$1` pattern), it
* computes and returns the file size and gzipped file size; otherwise it returns
* default sizes of `"0"`.
* When `options.output` is a single file path (not an array and not containing the `$1` pattern),
* the returned result will include `size` and `sizeGzip` with the output file's byte sizes; otherwise
* those fields will be `"0"`.
*
* @param options - Compression settings; when `options.output` is a single path without the `$1` pattern the returned result will include computed `size` and `sizeGzip`.
* @param options - Compression settings; if `options.output` is a single path the returned result may include computed `size` and `sizeGzip`
* @returns The compression result containing:
* - `compressorLabel`: label of the compressor,
* - `size`: output file size in bytes as a string (or `"0"` if not computed),
* - `sizeGzip`: gzipped output size in bytes as a string (or `"0"` if not computed).
* - `sizeGzip`: gzipped output size in bytes as a string (or `"0"` if not computed)
*/
async function compress<T extends CompressorOptions = CompressorOptions>(
options: Settings<T>
Expand All @@ -50,7 +50,13 @@ async function compress<T extends CompressorOptions = CompressorOptions>(
return defaultResult;
}

// Get file sizes
// Check if file exists (may not exist if allowEmptyOutput skipped writing)
const fileExists = await isValidFileAsync(options.output);
if (!fileExists) {
return defaultResult;
}

// Get file sizes - let IO/permission errors propagate
const sizeGzip = await getFilesizeGzippedInBytes(options.output);
const size = getFilesizeInBytes(options.output);

Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ async function runOne(cli: SettingsWithCompressor): Promise<Result> {
output: cli.output,
...(cli.type && { type: cli.type }),
...(cli.option && { options: JSON.parse(cli.option) }),
...(cli.allowEmptyOutput && { allowEmptyOutput: cli.allowEmptyOutput }),
};

if (!silence) spinnerStart(settings);
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/compress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
* MIT Licensed
*/

import { mkdir } from "node:fs/promises";
/**
* Module dependencies.
*/
Expand All @@ -19,7 +20,6 @@ import {
readFileAsync,
run,
} from "@node-minify/utils";
import { mkdir } from "node:fs/promises";

/**
* Run the compressor using the provided settings.
Expand Down
14 changes: 13 additions & 1 deletion packages/google-closure-compiler/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,15 @@ export async function gcc({
type Flags = {
[key: string]: string | boolean | Record<string, unknown>;
};
/**
* Merge allowed user-provided options into the given flags object.
*
* Filters `options` to keys listed in `allowedFlags` and assigns values that are strings, booleans, or plain (non-array) objects into `flags`.
*
* @param flags - Target flags object to populate with allowed option entries.
* @param options - Optional user-supplied options to apply; keys not in `allowedFlags` or values that are arrays or unsupported types are ignored.
* @returns The same `flags` object after applying valid entries from `options`.
*/
function applyOptions(flags: Flags, options?: Record<string, unknown>): Flags {
if (!options || Object.keys(options).length === 0) {
return flags;
Expand All @@ -88,7 +97,10 @@ function applyOptions(flags: Flags, options?: Record<string, unknown>): Flags {
typeof value === "boolean" ||
(typeof value === "object" && !Array.isArray(value))
) {
flags[option] = value as string | boolean | Record<string, unknown>;
flags[option] = value as
| string
| boolean
| Record<string, unknown>;
}
});
return flags;
Expand Down
8 changes: 8 additions & 0 deletions packages/types/src/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,14 @@ export type Settings<TOptions extends CompressorOptions = CompressorOptions> = {
* @default false
*/
replaceInPlace?: boolean;

/**
* Allow empty output without throwing an error.
* When true, if minification results in empty content, the output file will not be written.
* Useful for CSS files containing only comments that get stripped.
* @default false
*/
allowEmptyOutput?: boolean;
};

/**
Expand Down
Loading