= {
- "babel-minify":
- "babel-minify only supports Babel 6 and is no longer maintained. Use 'terser' instead.",
- "uglify-es": "uglify-es is no longer maintained. Use 'terser' instead.",
- yui: "YUI Compressor was deprecated by Yahoo in 2013. Use 'terser' for JS or 'lightningcss' for CSS.",
- crass: "crass is no longer maintained. Use 'lightningcss' or 'clean-css' instead.",
- sqwish: "sqwish is no longer maintained. Use 'lightningcss' or 'clean-css' instead.",
-};
+const TYPE_REQUIRED_COMPRESSORS = ["esbuild"];
/**
* Parse comma-separated string into array of trimmed non-empty strings.
@@ -168,18 +159,25 @@ export function parseInputs(): ActionInputs {
}
/**
- * Validates a compressor identifier and emits warnings for deprecated or non-built-in compressors.
+ * Validates a compressor identifier and throws for removed compressors.
*
- * Emits a warning when the compressor is listed as deprecated and emits a separate warning
- * when the compressor is not recognized as a built-in compressor (indicating it will be
- * treated as a custom npm package or local file).
+ * Throws an error when the compressor has been removed from node-minify,
+ * providing the recommended replacement. Emits a warning when the compressor
+ * is not recognized as a built-in compressor (indicating it will be treated
+ * as a custom npm package or local file).
*
* @param compressor - The compressor name or identifier to validate (e.g., "terser", "esbuild", or a custom package)
+ * @returns Nothing; throws when the compressor has been removed.
+ * @throws Error if the compressor has been removed
*/
export function validateCompressor(compressor: string): void {
- const deprecationMessage = DEPRECATED_COMPRESSORS[compressor];
- if (deprecationMessage) {
- warning(`⚠️ Deprecated: ${deprecationMessage}`);
+ const entry = getCompressorEntry(compressor);
+ if (entry?.status === "removed") {
+ const replacement = entry.replacement || "terser";
+ throw new Error(
+ `Compressor '${compressor}' has been removed from node-minify. ` +
+ `Use '${replacement}' instead.`
+ );
}
if (!isBuiltInCompressor(compressor)) {
@@ -190,4 +188,4 @@ export function validateCompressor(compressor: string): void {
}
}
-export { DEPRECATED_COMPRESSORS, TYPE_REQUIRED_COMPRESSORS };
+export { TYPE_REQUIRED_COMPRESSORS };
diff --git a/packages/action/src/types.ts b/packages/action/src/types.ts
index b9f5bb30d..1e271b8de 100644
--- a/packages/action/src/types.ts
+++ b/packages/action/src/types.ts
@@ -18,7 +18,7 @@ export interface ActionInputs {
compressor: string;
/**
* File type hint for compressors that handle multiple types.
- * Only required for `esbuild` (supports both JS and CSS) and deprecated `yui`.
+ * Only required for `esbuild` (supports both JS and CSS).
* Other compressors auto-detect or only support one type.
*/
type?: "js" | "css";
diff --git a/packages/babel-minify/CHANGELOG.md b/packages/babel-minify/CHANGELOG.md
deleted file mode 100644
index cab8c82cb..000000000
--- a/packages/babel-minify/CHANGELOG.md
+++ /dev/null
@@ -1,136 +0,0 @@
-# @node-minify/babel-minify
-
-## 10.5.0
-
-### Patch Changes
-
-- Updated dependencies [43c11f7]
-- Updated dependencies [1d5e3ee]
-- Updated dependencies [c21e335]
- - @node-minify/utils@10.5.0
-
-## 10.4.0
-
-### Patch Changes
-
-- Updated dependencies [2e64877]
-- Updated dependencies [3d4d2d0]
-- Updated dependencies [0a51025]
- - @node-minify/utils@10.4.0
-
-## 10.3.0
-
-### Patch Changes
-
-- Updated dependencies [1e06c03]
- - @node-minify/utils@10.3.0
-
-## 10.2.0
-
-### Patch Changes
-
-- Updated dependencies [3c98739]
- - @node-minify/utils@10.2.0
-
-## 10.1.1
-
-### Patch Changes
-
-- eb785b0: Fix npm install error caused by unresolved workspace:\* references in published packages
-- Updated dependencies [eb785b0]
- - @node-minify/utils@10.1.1
-
-## 10.1.0
-
-### Patch Changes
-
-- @node-minify/utils@10.1.0
-
-## 10.0.2
-
-### Patch Changes
-
-- 156a53d: test: verify OIDC publishing with fixed workflow config
-- Updated dependencies [156a53d]
- - @node-minify/utils@10.0.2
-
-## 10.0.1
-
-### Patch Changes
-
-- d722b73: test: verify OIDC publishing with fixed workflow config
-- Updated dependencies [d722b73]
- - @node-minify/utils@10.0.1
-
-## 10.0.0
-
-### Major Changes
-
-- 4406c0c: ## v10.0.0
-
- ### Breaking Changes
-
- - **ESM Only**: The package is now pure ESM. Requires Node.js 20+.
- - **Async API**: Callback support has been removed. All `minify()` calls must use `await` or `.then()`.
- - **Named Exports**: All packages now use named exports (e.g., `import { minify } from '@node-minify/core'`).
- - **Sync/Async Split**: Sync functions have been removed or split.
- - **Deprecations**:
- - `@node-minify/babel-minify` (deprecated)
- - `@node-minify/uglify-es` (deprecated)
- - `@node-minify/yui` (deprecated)
- - `@node-minify/sqwish` (deprecated)
- - `@node-minify/crass` (deprecated)
-
- ### Features & Improvements
-
- - **Build System**: Switched from `tsup` to `tsdown` for faster and more reliable builds.
- - **Core**: Moved file I/O operations from compressors to core for better consistency.
- - **Output**: Support for array output with input/output validation.
- - **Security**: Replaced `html-minifier` with `html-minifier-next`.
- - **Typings**: Improved TypeScript definitions and coverage.
- - **Dependencies**: Updated all dependencies.
-
- ### Bug Fixes
-
- - Fixed various import issues and build warnings.
- - Corrected explicit file extensions in imports.
-
-### Patch Changes
-
-- Updated dependencies [4406c0c]
- - @node-minify/utils@10.0.0
-
-## 10.0.0-next.0
-
-### Major Changes
-
-- 4406c0c: Bump version 10.0.0 next
-
-### Patch Changes
-
-- Updated dependencies [4406c0c]
- - @node-minify/utils@10.0.0-next.0
-
-## 9.0.1
-
-### Patch Changes
-
-- c4fcf63: Fixing packages exports and mkdirp import
-- Updated dependencies [c4fcf63]
- - @node-minify/utils@9.0.1
-
-## 9.0.0
-
-### Major Changes
-
-- 7ab9745: Version 9.0.0
-
- - feat(node): remove node 16, add node 18 (#2092) (c9acdaa4a9906d4019d9381129d66235f3139198)
- - feat(biome): add biome (#2113) (50e9ec46c11c218453de743bed2defa9a83ace7b)
- - fix(yui): fixing yui tests (dd8629712c03b0ac1fe2b94acbb95bc896f8f22f)
- - bump dependencies
-
-### Patch Changes
-
-- Updated dependencies [7ab9745]
- - @node-minify/utils@9.0.0
diff --git a/packages/babel-minify/LICENSE b/packages/babel-minify/LICENSE
deleted file mode 100644
index aed47f87e..000000000
--- a/packages/babel-minify/LICENSE
+++ /dev/null
@@ -1,21 +0,0 @@
-MIT License
-
-Copyright (c) 2011-2026 Rodolphe Stoclin
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
diff --git a/packages/babel-minify/README.md b/packages/babel-minify/README.md
deleted file mode 100644
index e58577733..000000000
--- a/packages/babel-minify/README.md
+++ /dev/null
@@ -1,46 +0,0 @@
-
-
-A very light minifier Node.js module.
-
-
-
-
-
-
-
-
-
-# babel-minify
-
-> **⚠️ Deprecation Notice**: This package uses Babel 6 which is no longer maintained. Consider using [`@node-minify/terser`](https://github.com/srod/node-minify/tree/main/packages/terser) instead for actively maintained JavaScript minification.
-
-`babel-minify` is a plugin for [`node-minify`](https://github.com/srod/node-minify)
-
-It allow you to compress JavaScript files.
-
-## Installation
-
-```bash
-npm install @node-minify/core @node-minify/babel-minify
-```
-
-## Usage
-
-```js
-import { minify } from '@node-minify/core';
-import { babelMinify } from '@node-minify/babel-minify';
-
-await minify({
- compressor: babelMinify,
- input: 'foo.js',
- output: 'bar.js'
-});
-```
-
-## Documentation
-
-Visit https://node-minify.2clics.net/compressors/babel-minify.html for full documentation
-
-## License
-
-[MIT](https://github.com/srod/node-minify/blob/main/LICENSE)
diff --git a/packages/babel-minify/__tests__/babel-minify.test.ts b/packages/babel-minify/__tests__/babel-minify.test.ts
deleted file mode 100644
index 49d736b30..000000000
--- a/packages/babel-minify/__tests__/babel-minify.test.ts
+++ /dev/null
@@ -1,59 +0,0 @@
-/*!
- * node-minify
- * Copyright (c) 2011-2026 Rodolphe Stoclin
- * MIT Licensed
- */
-
-import { describe, expect, test, vi } from "vitest";
-import { runOneTest, tests } from "../../../tests/fixtures.ts";
-import { babelMinify } from "../src/index.ts";
-
-const compressorLabel = "babel-minify";
-const compressor = babelMinify;
-
-describe("Package: babel-minify", async () => {
- if (!tests.commonjs || !tests.babelMinify) {
- throw new Error("Tests not found");
- }
-
- // Run commonjs tests
- for (const options of tests.commonjs) {
- await runOneTest({
- options,
- compressorLabel,
- compressor,
- });
- }
-
- // Run babelMinify tests
- for (const options of tests.babelMinify) {
- await runOneTest({ options, compressorLabel, compressor });
- }
-
- describe("babel-minify coverage", () => {
- test("should throw if babel-core returns non-string code", async () => {
- const babel = await import("babel-core");
- const spy = vi
- .spyOn(babel, "transform")
- .mockReturnValueOnce({ code: null } as any);
- await expect(
- babelMinify({ content: "code" } as any)
- ).rejects.toThrow("Babel minification failed: empty result");
- spy.mockRestore();
- });
-
- test("should pass through non-string presets unchanged", async () => {
- const minifyPreset = (await import("babel-preset-minify")).default;
- const result = await babelMinify({
- content: "var x = 1;",
- settings: {
- options: {
- // Pass the preset module directly instead of a string
- presets: [minifyPreset],
- },
- },
- } as any);
- expect(result.code).toBeDefined();
- });
- });
-});
diff --git a/packages/babel-minify/package.json b/packages/babel-minify/package.json
deleted file mode 100644
index a3d06623f..000000000
--- a/packages/babel-minify/package.json
+++ /dev/null
@@ -1,65 +0,0 @@
-{
- "name": "@node-minify/babel-minify",
- "version": "10.5.0",
- "deprecated": "babel-minify uses Babel 6 which is no longer maintained. Please use @node-minify/terser instead.",
- "description": "babel-minify plugin for @node-minify (DEPRECATED - use @node-minify/terser)",
- "keywords": [
- "compressor",
- "minify",
- "minifier",
- "babel-minify"
- ],
- "author": "Rodolphe Stoclin ",
- "homepage": "https://github.com/srod/node-minify/tree/main/packages/babel-minify#readme",
- "license": "MIT",
- "type": "module",
- "engines": {
- "node": ">=20.0.0"
- },
- "directories": {
- "lib": "dist",
- "test": "__tests__"
- },
- "types": "./dist/index.d.ts",
- "main": "./dist/index.js",
- "exports": {
- "types": "./dist/index.d.ts",
- "default": "./dist/index.js"
- },
- "sideEffects": false,
- "files": [
- "dist/**/*"
- ],
- "publishConfig": {
- "access": "public"
- },
- "repository": {
- "type": "git",
- "url": "git+https://github.com/srod/node-minify.git"
- },
- "bugs": {
- "url": "https://github.com/srod/node-minify/issues"
- },
- "scripts": {
- "build": "tsdown src/index.ts",
- "check-exports": "attw --pack . --profile esm-only",
- "format:check": "biome check .",
- "lint": "biome lint .",
- "prepublishOnly": "bun run build",
- "test": "vitest run",
- "test:coverage": "vitest run --coverage",
- "test:watch": "vitest",
- "typecheck": "tsc --noEmit",
- "dev": "tsdown src/index.ts --watch"
- },
- "dependencies": {
- "@node-minify/utils": "workspace:*",
- "babel-core": "6.26.3",
- "babel-preset-env": "1.7.0",
- "babel-preset-minify": "0.5.2"
- },
- "devDependencies": {
- "@node-minify/types": "workspace:*",
- "@types/babel-core": "^6.25.10"
- }
-}
diff --git a/packages/babel-minify/src/index.ts b/packages/babel-minify/src/index.ts
deleted file mode 100644
index 4d9422f17..000000000
--- a/packages/babel-minify/src/index.ts
+++ /dev/null
@@ -1,89 +0,0 @@
-/*!
- * node-minify
- * Copyright (c) 2011-2026 Rodolphe Stoclin
- * MIT Licensed
- */
-
-import type { CompressorResult, MinifierOptions } from "@node-minify/types";
-import {
- ensureStringContent,
- readFile,
- warnDeprecation,
-} from "@node-minify/utils";
-import { transform } from "babel-core";
-import env from "babel-preset-env";
-import minify from "babel-preset-minify";
-
-type BabelPreset = typeof minify | typeof env;
-
-type BabelOptions = {
- presets: (string | BabelPreset)[];
-};
-
-/**
- * Known presets that we can resolve directly to avoid Babel 6's runtime module resolution,
- * which can fail in monorepos or when the working directory differs from the package location.
- */
-const knownPresets: Record = {
- env,
- minify,
-};
-
-/**
- * Minifies JavaScript using Babel (ensures the `babel-preset-minify` preset is applied).
- *
- * Accepts optional Babel configuration via `settings.options.babelrc` (path to a .babelrc file)
- * and `settings.options.presets` (array of presets or preset names). Known preset names are
- * resolved to their bundled implementations before running Babel.
- *
- * @deprecated babel-minify uses Babel 6 which is no longer maintained. Migrate to @node-minify/terser for ongoing support.
- * @param settings - Minifier settings; may include `options.babelrc` (string path) and `options.presets` (string[] or preset entries)
- * @param content - Source content to be minified
- * @returns An object with the minified code: `{ code: string }`
- */
-export async function babelMinify({
- settings,
- content,
-}: MinifierOptions): Promise {
- const contentStr = ensureStringContent(content, "babel-minify");
-
- warnDeprecation(
- "babel-minify",
- "babel-minify uses Babel 6 which is no longer maintained. " +
- "Please migrate to @node-minify/terser for continued support and modern JavaScript features."
- );
-
- let babelOptions: BabelOptions = { presets: [] };
- const babelrc = settings?.options?.babelrc as string | undefined;
- const presets = settings?.options?.presets as string[] | undefined;
-
- if (babelrc) {
- babelOptions = JSON.parse(readFile(babelrc));
- }
-
- if (presets && Array.isArray(presets)) {
- const babelrcPresets = babelOptions.presets || [];
- babelOptions.presets = presets.concat(babelrcPresets);
- }
-
- // Resolve known preset strings to their imported modules to avoid
- // Babel 6's runtime resolution which fails in monorepos/different cwd
- babelOptions.presets = babelOptions.presets.map((preset) =>
- typeof preset === "string" && preset in knownPresets
- ? knownPresets[preset]
- : preset
- );
-
- // Ensure minify preset is always included
- if (!babelOptions.presets.includes(minify)) {
- babelOptions.presets = babelOptions.presets.concat([minify]);
- }
-
- const result = transform(contentStr, babelOptions);
-
- if (typeof result.code !== "string") {
- throw new Error("Babel minification failed: empty result");
- }
-
- return { code: result.code };
-}
diff --git a/packages/babel-minify/src/types.d.ts b/packages/babel-minify/src/types.d.ts
deleted file mode 100644
index b49e03ad4..000000000
--- a/packages/babel-minify/src/types.d.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-declare module "babel-preset-env";
-declare module "babel-preset-minify";
diff --git a/packages/babel-minify/tsconfig.json b/packages/babel-minify/tsconfig.json
deleted file mode 100644
index 8ffe5db9f..000000000
--- a/packages/babel-minify/tsconfig.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "extends": "../../tsconfig.json",
- "compilerOptions": {
- "outDir": "./dist",
- "rootDir": "./src"
- },
- "include": ["src/**/*"]
-}
diff --git a/packages/babel-minify/vitest.config.ts b/packages/babel-minify/vitest.config.ts
deleted file mode 100644
index d7b613c2a..000000000
--- a/packages/babel-minify/vitest.config.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-import { defineProject } from "vitest/config";
-
-export default defineProject({});
diff --git a/packages/benchmark/__tests__/runner-warmup.test.ts b/packages/benchmark/__tests__/runner-warmup.test.ts
new file mode 100644
index 000000000..4fc732a71
--- /dev/null
+++ b/packages/benchmark/__tests__/runner-warmup.test.ts
@@ -0,0 +1,72 @@
+/*!
+ * node-minify
+ * Copyright (c) 2011-2026 Rodolphe Stoclin
+ * MIT Licensed
+ */
+
+import { afterEach, describe, expect, test, vi } from "vitest";
+
+describe("runWarmup", () => {
+ afterEach(() => {
+ vi.resetModules();
+ vi.doUnmock("@node-minify/core");
+ });
+
+ test("uses a distinct output path for each warmup iteration", async () => {
+ const minify = vi.fn().mockResolvedValue("ok");
+
+ vi.doMock("@node-minify/core", () => ({
+ minify,
+ }));
+
+ const { runWarmup } = await import("../src/runner.ts");
+
+ const warmupFiles = await runWarmup(
+ "fixture.js",
+ vi.fn() as never,
+ "fixture.js.warmup.tmp",
+ 2,
+ {}
+ );
+
+ expect(minify).toHaveBeenCalledTimes(2);
+
+ const outputs = minify.mock.calls.map(
+ ([args]) => (args as { output: string }).output
+ );
+
+ expect(new Set(outputs).size).toBe(2);
+ // The returned paths are exactly the outputs passed to minify.
+ expect(warmupFiles).toEqual(outputs);
+ });
+
+ test("tracks partial warmup paths for cleanup when minify throws mid-loop", async () => {
+ const minify = vi
+ .fn()
+ .mockResolvedValueOnce("ok")
+ .mockRejectedValueOnce(new Error("boom"));
+
+ vi.doMock("@node-minify/core", () => ({ minify }));
+
+ const { runWarmup } = await import("../src/runner.ts");
+
+ const collected: string[] = [];
+ await expect(
+ runWarmup(
+ "fixture.js",
+ vi.fn() as never,
+ "fixture.js.warmup.tmp",
+ 3,
+ {},
+ collected
+ )
+ ).rejects.toThrow("boom");
+
+ // Both attempted iterations (the successful first and the failing
+ // second) registered their paths so the caller can delete them.
+ expect(collected).toEqual([
+ "fixture.js.warmup.tmp.1",
+ "fixture.js.warmup.tmp.2",
+ ]);
+ });
+});
diff --git a/packages/benchmark/package.json b/packages/benchmark/package.json
index 9772167eb..69f71c637 100644
--- a/packages/benchmark/package.json
+++ b/packages/benchmark/package.json
@@ -13,7 +13,7 @@
"license": "MIT",
"type": "module",
"engines": {
- "node": ">=20.0.0"
+ "node": ">=22.0.0"
},
"directories": {
"lib": "dist",
diff --git a/packages/benchmark/src/runner.ts b/packages/benchmark/src/runner.ts
index da16068b3..05663f961 100644
--- a/packages/benchmark/src/runner.ts
+++ b/packages/benchmark/src/runner.ts
@@ -32,23 +32,36 @@ import type {
* @param warmupFile - Path for warmup output file
* @param warmupCount - Number of warmup iterations
* @param options - Benchmark options containing type and compressor options
+ * @param collected - Array that receives each warmup output path as it is
+ * scheduled (defaults to a fresh array). Pass one in to clean up partial files
+ * even if `minify` throws part-way through the loop.
+ * @returns The warmup output paths (the same array passed as `collected`)
*/
export async function runWarmup(
file: string,
compressor: Compressor,
warmupFile: string,
warmupCount: number,
- options: Pick
-): Promise {
+ options: Pick,
+ collected: string[] = []
+): Promise {
for (let i = 0; i < warmupCount; i++) {
+ const warmupOutput = `${warmupFile}.${i + 1}`;
+
+ // Track the path before minifying so a mid-loop failure still lets the
+ // caller delete the partial file instead of orphaning it.
+ collected.push(warmupOutput);
+
await minify({
compressor,
input: file,
- output: warmupFile,
+ output: warmupOutput,
...(options.type && { type: options.type as "js" | "css" }),
options: options.compressorOptions,
});
}
+
+ return collected;
}
/**
@@ -298,8 +311,16 @@ async function benchmarkCompressor(
try {
const warmupFile = `${file}.warmup.${uniqueId}.tmp`;
if (warmup > 0) {
- await runWarmup(file, compressor, warmupFile, warmup, options);
- tempFiles.push(warmupFile);
+ // Pass tempFiles so partial warmup outputs are tracked for cleanup
+ // even if runWarmup throws before completing all iterations.
+ await runWarmup(
+ file,
+ compressor,
+ warmupFile,
+ warmup,
+ options,
+ tempFiles
+ );
}
const outputFile = `${file}.${name}.${uniqueId}.tmp`;
diff --git a/packages/clean-css/package.json b/packages/clean-css/package.json
index 95d8651b1..f8dec16b3 100644
--- a/packages/clean-css/package.json
+++ b/packages/clean-css/package.json
@@ -13,7 +13,7 @@
"license": "MIT",
"type": "module",
"engines": {
- "node": ">=20.0.0"
+ "node": ">=22.0.0"
},
"directories": {
"lib": "dist",
diff --git a/packages/cli/README.md b/packages/cli/README.md
index 186b1b746..55363ac91 100644
--- a/packages/cli/README.md
+++ b/packages/cli/README.md
@@ -40,6 +40,14 @@ node-minify --compressor my-custom-compressor --input 'input.js' --output 'outpu
node-minify --compressor ./my-compressor.js --input 'input.js' --output 'output.js'
```
+## Doctor
+
+Scan your project for v11 migration issues. Read-only; exits `1` when a removed compressor is found.
+
+```bash
+npx --package=@node-minify/cli -- node-minify doctor
+```
+
## Documentation
Visit https://node-minify.2clics.net/cli.html for full documentation
diff --git a/packages/cli/__tests__/cli.test.ts b/packages/cli/__tests__/cli.test.ts
index 399f8d118..68df09e2b 100644
--- a/packages/cli/__tests__/cli.test.ts
+++ b/packages/cli/__tests__/cli.test.ts
@@ -160,6 +160,80 @@ describe("Image compressors", () => {
});
});
+describe("Removed compressors", () => {
+ test("should fail-fast for removed compressor: babel-minify", async () => {
+ const settings: SettingsWithCompressor = {
+ compressor: "babel-minify",
+ input: filesJS.oneFile,
+ output: filesJS.fileJSOut,
+ silence: true,
+ };
+ await expect(cli.run(settings)).rejects.toThrow(
+ "Compressor 'babel-minify' was removed in v11. Use 'terser' instead."
+ );
+ });
+
+ test("should fail-fast for removed compressor: uglify-es", async () => {
+ const settings: SettingsWithCompressor = {
+ compressor: "uglify-es",
+ input: filesJS.oneFile,
+ output: filesJS.fileJSOut,
+ silence: true,
+ };
+ await expect(cli.run(settings)).rejects.toThrow(
+ "Compressor 'uglify-es' was removed in v11. Use 'terser' instead."
+ );
+ });
+
+ test("should fail-fast for removed compressor: yui", async () => {
+ const settings: SettingsWithCompressor = {
+ compressor: "yui",
+ input: filesJS.oneFile,
+ output: filesJS.fileJSOut,
+ silence: true,
+ };
+ await expect(cli.run(settings)).rejects.toThrow(
+ "Compressor 'yui' was removed in v11. Use 'terser or lightningcss' instead."
+ );
+ });
+
+ test("should fail-fast for removed compressor: sqwish", async () => {
+ const settings: SettingsWithCompressor = {
+ compressor: "sqwish",
+ input: filesCSS.fileCSS,
+ output: filesCSS.fileCSSOut,
+ silence: true,
+ };
+ await expect(cli.run(settings)).rejects.toThrow(
+ "Compressor 'sqwish' was removed in v11. Use 'lightningcss' instead."
+ );
+ });
+
+ test("should fail-fast for removed compressor: crass", async () => {
+ const settings: SettingsWithCompressor = {
+ compressor: "crass",
+ input: filesCSS.fileCSS,
+ output: filesCSS.fileCSSOut,
+ silence: true,
+ };
+ await expect(cli.run(settings)).rejects.toThrow(
+ "Compressor 'crass' was removed in v11. Use 'lightningcss' instead."
+ );
+ });
+
+ test("should fail-fast for removed compressor by scoped package name", async () => {
+ const settings: SettingsWithCompressor = {
+ compressor: "@node-minify/yui",
+ input: filesJS.oneFile,
+ output: filesJS.fileJSOut,
+ silence: true,
+ };
+ await expect(cli.run(settings)).rejects.toThrow(
+ "Compressor '@node-minify/yui' was removed in v11. Use 'terser or lightningcss' instead."
+ );
+ });
+});
+
describe("cli error", () => {
beforeAll(() => {
const spy = vi.spyOn(childProcess, "spawn");
diff --git a/packages/cli/__tests__/doctor.test.ts b/packages/cli/__tests__/doctor.test.ts
new file mode 100644
index 000000000..1b8975fef
--- /dev/null
+++ b/packages/cli/__tests__/doctor.test.ts
@@ -0,0 +1,1019 @@
+/*!
+ * node-minify
+ * Copyright (c) 2011-2026 Rodolphe Stoclin
+ * MIT Licensed
+ */
+
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
+import { doctor, runDoctor } from "../src/doctor.ts";
+
+/**
+ * Create an isolated temp directory for each test.
+ */
+function createTmpDir(): string {
+ return mkdtempSync(join(tmpdir(), "node-minify-doctor-"));
+}
+
+/**
+ * Helper: write a package.json with given dependencies.
+ *
+ * @param dir - Directory to write package.json in
+ * @param deps - Dependencies object
+ * @param devDeps - Dev dependencies object
+ */
+function writePackageJson(
+ dir: string,
+ deps: Record = {},
+ devDeps: Record = {}
+): void {
+ const pkg = {
+ name: "test-project",
+ version: "1.0.0",
+ ...(Object.keys(deps).length > 0 ? { dependencies: deps } : {}),
+ ...(Object.keys(devDeps).length > 0
+ ? { devDependencies: devDeps }
+ : {}),
+ };
+ writeFileSync(join(dir, "package.json"), JSON.stringify(pkg, null, 2));
+}
+
+/**
+ * Helper: write a source file with given content.
+ *
+ * @param dir - Base directory
+ * @param relPath - Relative file path (directories created automatically)
+ * @param content - File content
+ */
+function writeSourceFile(dir: string, relPath: string, content: string): void {
+ const fullPath = join(dir, relPath);
+ const parentDir = join(fullPath, "..");
+ mkdirSync(parentDir, { recursive: true });
+ writeFileSync(fullPath, content);
+}
+
+/**
+ * Helper: write a workflow YAML file.
+ *
+ * @param dir - Base directory
+ * @param fileName - Workflow file name (e.g. "ci.yml")
+ * @param content - YAML content
+ */
+function writeWorkflowFile(
+ dir: string,
+ fileName: string,
+ content: string
+): void {
+ const workflowDir = join(dir, ".github", "workflows");
+ mkdirSync(workflowDir, { recursive: true });
+ writeFileSync(join(workflowDir, fileName), content);
+}
+
+/**
+ * Capture all console.log output during a function call.
+ *
+ * @param fn - Async function to execute while capturing output
+ * @returns Object with the function's return value and captured output lines
+ */
+async function captureOutput(
+ fn: () => Promise
+): Promise<{ result: T; output: string }> {
+ const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
+ try {
+ const result = await fn();
+ const output = logSpy.mock.calls
+ .map((call) => call.join(" "))
+ .join("\n");
+ return { result, output };
+ } finally {
+ logSpy.mockRestore();
+ }
+}
+
+describe("Package: doctor", () => {
+ let tmpDir: string;
+
+ beforeEach(() => {
+ tmpDir = createTmpDir();
+ });
+
+ afterEach(() => {
+ rmSync(tmpDir, { recursive: true, force: true });
+ });
+
+ describe("clean project", () => {
+ test("should return 0 with no output for clean project", async () => {
+ writePackageJson(tmpDir, {
+ "@node-minify/core": "^11.0.0",
+ "@node-minify/terser": "^11.0.0",
+ });
+
+ const { result, output } = await captureOutput(() =>
+ runDoctor(tmpDir)
+ );
+
+ expect(result).toBe(0);
+ expect(output).toBe("");
+ });
+
+ test("should return 0 for empty directory", async () => {
+ const { result, output } = await captureOutput(() =>
+ runDoctor(tmpDir)
+ );
+
+ expect(result).toBe(0);
+ expect(output).toBe("");
+ });
+ });
+
+ describe("package.json scanner", () => {
+ test("should detect removed dep in dependencies", async () => {
+ writePackageJson(tmpDir, {
+ "@node-minify/babel-minify": "^10.0.0",
+ });
+
+ const { result, output } = await captureOutput(() =>
+ runDoctor(tmpDir)
+ );
+
+ expect(result).toBe(1);
+ expect(output).toContain("ERROR");
+ expect(output).toContain("@node-minify/babel-minify");
+ expect(output).toContain("removed");
+ expect(output).toContain("terser");
+ });
+
+ test("should detect removed dep in devDependencies", async () => {
+ writePackageJson(
+ tmpDir,
+ {},
+ {
+ "@node-minify/uglify-es": "^10.0.0",
+ }
+ );
+
+ const { result, output } = await captureOutput(() =>
+ runDoctor(tmpDir)
+ );
+
+ expect(result).toBe(1);
+ expect(output).toContain("@node-minify/uglify-es");
+ expect(output).toContain("terser");
+ });
+
+ test("should detect multiple removed deps", async () => {
+ writePackageJson(tmpDir, {
+ "@node-minify/babel-minify": "^10.0.0",
+ "@node-minify/sqwish": "^10.0.0",
+ "@node-minify/crass": "^10.0.0",
+ });
+
+ const { result, output } = await captureOutput(() =>
+ runDoctor(tmpDir)
+ );
+
+ expect(result).toBe(1);
+ expect(output).toContain("@node-minify/babel-minify");
+ expect(output).toContain("@node-minify/sqwish");
+ expect(output).toContain("@node-minify/crass");
+ });
+
+ test("should detect removed dep in monorepo packages/*/package.json", async () => {
+ writePackageJson(tmpDir); // root with no deps
+ const subPkgDir = join(tmpDir, "packages", "my-app");
+ mkdirSync(subPkgDir, { recursive: true });
+ writePackageJson(subPkgDir, {
+ "@node-minify/yui": "^10.0.0",
+ });
+
+ const { result, output } = await captureOutput(() =>
+ runDoctor(tmpDir)
+ );
+
+ expect(result).toBe(1);
+ expect(output).toContain("@node-minify/yui");
+ expect(output).toContain("packages");
+ });
+ });
+
+ describe("source import scanner", () => {
+ test("should detect removed package in ES import", async () => {
+ writePackageJson(tmpDir);
+ writeSourceFile(
+ tmpDir,
+ "src/app.ts",
+ 'import { babelMinify } from "@node-minify/babel-minify";\n\nconsole.log(babelMinify);\n'
+ );
+
+ const { result, output } = await captureOutput(() =>
+ runDoctor(tmpDir)
+ );
+
+ expect(result).toBe(1);
+ expect(output).toContain("src/app.ts");
+ expect(output).toContain("@node-minify/babel-minify");
+ expect(output).toContain(":1");
+ });
+
+ test("should detect removed package in require()", async () => {
+ writePackageJson(tmpDir);
+ writeSourceFile(
+ tmpDir,
+ "lib/index.js",
+ 'const sqwish = require("@node-minify/sqwish");\n'
+ );
+
+ const { result, output } = await captureOutput(() =>
+ runDoctor(tmpDir)
+ );
+
+ expect(result).toBe(1);
+ expect(output).toContain("lib/index.js");
+ expect(output).toContain("@node-minify/sqwish");
+ });
+
+ test("should detect legacy package in imports", async () => {
+ writePackageJson(tmpDir);
+ writeSourceFile(
+ tmpDir,
+ "src/config.ts",
+ 'import { jsonminify } from "@node-minify/jsonminify";\n'
+ );
+
+ const { result, output } = await captureOutput(() =>
+ runDoctor(tmpDir)
+ );
+
+ expect(result).toBe(0);
+ expect(output).toContain("WARNING");
+ expect(output).toContain("@node-minify/jsonminify");
+ expect(output).toContain("legacy");
+ });
+
+ test("should skip files in node_modules", async () => {
+ writePackageJson(tmpDir);
+ writeSourceFile(
+ tmpDir,
+ "node_modules/some-pkg/index.js",
+ 'const babel = require("@node-minify/babel-minify");\n'
+ );
+
+ const { result, output } = await captureOutput(() =>
+ runDoctor(tmpDir)
+ );
+
+ expect(result).toBe(0);
+ expect(output).toBe("");
+ });
+
+ test("should skip files in dist", async () => {
+ writePackageJson(tmpDir);
+ writeSourceFile(
+ tmpDir,
+ "dist/bundle.js",
+ 'import { crass } from "@node-minify/crass";\n'
+ );
+
+ const { result, output } = await captureOutput(() =>
+ runDoctor(tmpDir)
+ );
+
+ expect(result).toBe(0);
+ expect(output).toBe("");
+ });
+ });
+
+ describe("workflow YAML scanner", () => {
+ test("should detect removed compressor in workflow", async () => {
+ writePackageJson(tmpDir);
+ writeWorkflowFile(
+ tmpDir,
+ "ci.yml",
+ [
+ "name: CI",
+ "on: push",
+ "jobs:",
+ " minify:",
+ " runs-on: ubuntu-latest",
+ " steps:",
+ " - uses: srod/node-minify@v1",
+ " with:",
+ " compressor: babel-minify",
+ ' input: "src/app.js"',
+ ].join("\n")
+ );
+
+ const { result, output } = await captureOutput(() =>
+ runDoctor(tmpDir)
+ );
+
+ expect(result).toBe(1);
+ expect(output).toContain("babel-minify");
+ expect(output).toContain(".github/workflows/ci.yml");
+ });
+
+ test("should detect quoted compressor name", async () => {
+ writePackageJson(tmpDir);
+ writeWorkflowFile(
+ tmpDir,
+ "build.yaml",
+ [
+ "name: Build",
+ "on: push",
+ "jobs:",
+ " minify:",
+ " steps:",
+ " - with:",
+ ' compressor: "sqwish"',
+ ].join("\n")
+ );
+
+ const { result, output } = await captureOutput(() =>
+ runDoctor(tmpDir)
+ );
+
+ expect(result).toBe(1);
+ expect(output).toContain("sqwish");
+ });
+
+ test("should not flag valid compressor in workflow", async () => {
+ writePackageJson(tmpDir);
+ writeWorkflowFile(
+ tmpDir,
+ "ci.yml",
+ [
+ "name: CI",
+ "on: push",
+ "jobs:",
+ " minify:",
+ " steps:",
+ " - with:",
+ " compressor: terser",
+ ].join("\n")
+ );
+
+ const { result, output } = await captureOutput(() =>
+ runDoctor(tmpDir)
+ );
+
+ expect(result).toBe(0);
+ expect(output).toBe("");
+ });
+ });
+
+ describe("compressor assignment scanner", () => {
+ test("should detect removed compressor in string assignment", async () => {
+ writePackageJson(tmpDir);
+ writeSourceFile(
+ tmpDir,
+ "src/config.ts",
+ 'const config = {\n compressor: "babel-minify",\n input: "src/*.js"\n};\n'
+ );
+
+ const { result, output } = await captureOutput(() =>
+ runDoctor(tmpDir)
+ );
+
+ expect(result).toBe(1);
+ expect(output).toContain("babel-minify");
+ expect(output).toContain("src/config.ts");
+ expect(output).toContain(":2");
+ });
+
+ test("should detect removed compressor in quoted assignment", async () => {
+ writePackageJson(tmpDir);
+ writeSourceFile(
+ tmpDir,
+ "src/build.js",
+ "const opts = {\n compressor: 'yui',\n};\n"
+ );
+
+ const { result, output } = await captureOutput(() =>
+ runDoctor(tmpDir)
+ );
+
+ expect(result).toBe(1);
+ expect(output).toContain("yui");
+ });
+
+ test("should not flag active compressor in assignment", async () => {
+ writePackageJson(tmpDir);
+ writeSourceFile(
+ tmpDir,
+ "src/config.ts",
+ 'const config = {\n compressor: "terser",\n};\n'
+ );
+
+ const { result, output } = await captureOutput(() =>
+ runDoctor(tmpDir)
+ );
+
+ expect(result).toBe(0);
+ expect(output).toBe("");
+ });
+ });
+
+ describe("recursive workspace scanning", () => {
+ test("should detect removed dep in apps/*/package.json", async () => {
+ writePackageJson(tmpDir); // root
+ const appDir = join(tmpDir, "apps", "web");
+ mkdirSync(appDir, { recursive: true });
+ writePackageJson(appDir, {
+ "@node-minify/babel-minify": "^10.0.0",
+ });
+
+ const { result, output } = await captureOutput(() =>
+ runDoctor(tmpDir)
+ );
+
+ expect(result).toBe(1);
+ expect(output).toContain("@node-minify/babel-minify");
+ expect(output).toContain("apps");
+ });
+
+ test("should detect removed dep in deeply nested package.json", async () => {
+ writePackageJson(tmpDir); // root
+ const deepDir = join(tmpDir, "services", "api", "functions");
+ mkdirSync(deepDir, { recursive: true });
+ writePackageJson(deepDir, {
+ "@node-minify/uglify-es": "^10.0.0",
+ });
+
+ const { result, output } = await captureOutput(() =>
+ runDoctor(tmpDir)
+ );
+
+ expect(result).toBe(1);
+ expect(output).toContain("@node-minify/uglify-es");
+ expect(output).toContain("services");
+ });
+
+ test("should skip package.json in node_modules", async () => {
+ writePackageJson(tmpDir);
+ const nmDir = join(tmpDir, "node_modules", "some-pkg");
+ mkdirSync(nmDir, { recursive: true });
+ writePackageJson(nmDir, {
+ "@node-minify/babel-minify": "^10.0.0",
+ });
+
+ const { result, output } = await captureOutput(() =>
+ runDoctor(tmpDir)
+ );
+
+ expect(result).toBe(0);
+ expect(output).toBe("");
+ });
+ });
+
+ describe("peer and optional dependencies", () => {
+ test("should detect removed dep in peerDependencies", async () => {
+ const pkg = {
+ name: "test-project",
+ version: "1.0.0",
+ peerDependencies: {
+ "@node-minify/sqwish": "^10.0.0",
+ },
+ };
+ writeFileSync(
+ join(tmpDir, "package.json"),
+ JSON.stringify(pkg, null, 2)
+ );
+
+ const { result, output } = await captureOutput(() =>
+ runDoctor(tmpDir)
+ );
+
+ expect(result).toBe(1);
+ expect(output).toContain("@node-minify/sqwish");
+ });
+
+ test("should detect removed dep in optionalDependencies", async () => {
+ const pkg = {
+ name: "test-project",
+ version: "1.0.0",
+ optionalDependencies: {
+ "@node-minify/crass": "^10.0.0",
+ },
+ };
+ writeFileSync(
+ join(tmpDir, "package.json"),
+ JSON.stringify(pkg, null, 2)
+ );
+
+ const { result, output } = await captureOutput(() =>
+ runDoctor(tmpDir)
+ );
+
+ expect(result).toBe(1);
+ expect(output).toContain("@node-minify/crass");
+ });
+ });
+
+ describe("legacy-only findings", () => {
+ test("should return 0 with warnings for legacy deps only", async () => {
+ writePackageJson(tmpDir, {
+ "@node-minify/jsonminify": "^10.0.0",
+ });
+
+ const { result, output } = await captureOutput(() =>
+ runDoctor(tmpDir)
+ );
+
+ expect(result).toBe(0);
+ expect(output).toContain("WARNING");
+ expect(output).toContain("@node-minify/jsonminify");
+ expect(output).toContain("legacy");
+ });
+ });
+
+ describe("mixed findings", () => {
+ test("should return 1 when both removed and legacy deps exist", async () => {
+ writePackageJson(tmpDir, {
+ "@node-minify/babel-minify": "^10.0.0",
+ "@node-minify/jsonminify": "^10.0.0",
+ });
+
+ const { result, output } = await captureOutput(() =>
+ runDoctor(tmpDir)
+ );
+
+ expect(result).toBe(1);
+ expect(output).toContain("ERROR");
+ expect(output).toContain("WARNING");
+ });
+
+ test("should report errors before warnings", async () => {
+ writePackageJson(tmpDir, {
+ "@node-minify/jsonminify": "^10.0.0",
+ "@node-minify/babel-minify": "^10.0.0",
+ });
+
+ const { result, output } = await captureOutput(() =>
+ runDoctor(tmpDir)
+ );
+
+ expect(result).toBe(1);
+ const errorIdx = output.indexOf("ERROR");
+ const warningIdx = output.indexOf("WARNING");
+ expect(errorIdx).toBeLessThan(warningIdx);
+ });
+ });
+
+ describe("internal edge cases", () => {
+ test("returns 0 when the target directory does not exist", async () => {
+ // readdirSync throws for a missing dir; scanners swallow it and yield [].
+ const { result, output } = await captureOutput(() =>
+ runDoctor(join(tmpDir, "does-not-exist"))
+ );
+
+ expect(result).toBe(0);
+ expect(output).toBe("");
+ });
+
+ test("ignores .github/workflows when it is not a directory", async () => {
+ writePackageJson(tmpDir);
+ mkdirSync(join(tmpDir, ".github"), { recursive: true });
+ // A file (not a dir) at the workflows path makes readdirSync throw.
+ writeFileSync(join(tmpDir, ".github", "workflows"), "oops");
+
+ const { result, output } = await captureOutput(() =>
+ runDoctor(tmpDir)
+ );
+
+ expect(result).toBe(0);
+ expect(output).toBe("");
+ });
+
+ test("skips a package.json whose top-level JSON is not an object", async () => {
+ writeFileSync(join(tmpDir, "package.json"), '"not an object"');
+
+ const { result, output } = await captureOutput(() =>
+ runDoctor(tmpDir)
+ );
+
+ expect(result).toBe(0);
+ expect(output).toBe("");
+ });
+
+ test("ignores imports of supported (non-removed) @node-minify packages", async () => {
+ writePackageJson(tmpDir);
+ writeSourceFile(
+ tmpDir,
+ "src/app.ts",
+ 'import { terser } from "@node-minify/terser";\n'
+ );
+
+ const { result, output } = await captureOutput(() =>
+ runDoctor(tmpDir)
+ );
+
+ expect(result).toBe(0);
+ expect(output).toBe("");
+ });
+
+ test("skips blank lines while still flagging workflow compressors", async () => {
+ writePackageJson(tmpDir);
+ writeWorkflowFile(
+ tmpDir,
+ "ci.yml",
+ [
+ "name: CI",
+ "",
+ "jobs:",
+ " minify:",
+ " steps:",
+ " - with:",
+ " compressor: yui",
+ ].join("\n")
+ );
+
+ const { result, output } = await captureOutput(() =>
+ runDoctor(tmpDir)
+ );
+
+ expect(result).toBe(1);
+ expect(output).toContain("yui");
+ });
+
+ test("defaults to process.cwd() when no directory is given", async () => {
+ const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(tmpDir);
+ try {
+ const { result, output } = await captureOutput(() =>
+ runDoctor()
+ );
+ expect(result).toBe(0);
+ expect(output).toBe("");
+ } finally {
+ cwdSpy.mockRestore();
+ }
+ });
+
+ test("doctor() scans cwd and exits with the resulting code", async () => {
+ const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(tmpDir);
+ const exitSpy = vi
+ .spyOn(process, "exit")
+ .mockImplementation((() => undefined) as never);
+ try {
+ await captureOutput(() => doctor());
+ expect(exitSpy).toHaveBeenCalledWith(0);
+ } finally {
+ cwdSpy.mockRestore();
+ exitSpy.mockRestore();
+ }
+ });
+ });
+
+ describe("removed non-compressor packages", () => {
+ test("should detect @node-minify/run in dependencies", async () => {
+ writePackageJson(tmpDir, { "@node-minify/run": "^10.0.0" });
+
+ const { result, output } = await captureOutput(() =>
+ runDoctor(tmpDir)
+ );
+
+ expect(result).toBe(1);
+ expect(output).toContain("ERROR");
+ expect(output).toContain("@node-minify/run");
+ expect(output).toContain("removed in v11");
+ });
+
+ test("should detect @node-minify/run imports in source", async () => {
+ writeSourceFile(
+ tmpDir,
+ "src/build.js",
+ 'import { runCommandLine } from "@node-minify/run";\n'
+ );
+
+ const { result, output } = await captureOutput(() =>
+ runDoctor(tmpDir)
+ );
+
+ expect(result).toBe(1);
+ expect(output).toContain("src/build.js:1");
+ expect(output).toContain("@node-minify/run");
+ });
+
+ test("should detect side-effect imports of removed packages", async () => {
+ writeSourceFile(
+ tmpDir,
+ "src/side.ts",
+ 'import "@node-minify/crass";\n'
+ );
+
+ const { result, output } = await captureOutput(() =>
+ runDoctor(tmpDir)
+ );
+
+ expect(result).toBe(1);
+ expect(output).toContain("src/side.ts:1");
+ expect(output).toContain("@node-minify/crass");
+ });
+
+ test("should detect compressor keys with whitespace before the colon", async () => {
+ writeSourceFile(
+ tmpDir,
+ "src/space.ts",
+ 'const config = { compressor : "yui" };\n'
+ );
+
+ const { result, output } = await captureOutput(() =>
+ runDoctor(tmpDir)
+ );
+
+ expect(result).toBe(1);
+ expect(output).toContain("yui");
+ });
+ });
+
+ describe("removed type aliases", () => {
+ test("should detect CompressorReturnType and MinifyOptions", async () => {
+ writeSourceFile(
+ tmpDir,
+ "src/types.ts",
+ 'import type { CompressorReturnType, MinifyOptions } from "@node-minify/types";\n'
+ );
+
+ const { result, output } = await captureOutput(() =>
+ runDoctor(tmpDir)
+ );
+
+ expect(result).toBe(1);
+ expect(output).toContain("CompressorReturnType");
+ expect(output).toContain("CompressorResult");
+ expect(output).toContain("MinifyOptions");
+ expect(output).toContain("Settings");
+ });
+
+ test("should detect aliases across multi-line import blocks", async () => {
+ writeSourceFile(
+ tmpDir,
+ "src/multi.ts",
+ [
+ "import {",
+ " type CompressorReturnType,",
+ " type MinifierOptions,",
+ '} from "@node-minify/types";',
+ "",
+ ].join("\n")
+ );
+
+ const { result, output } = await captureOutput(() =>
+ runDoctor(tmpDir)
+ );
+
+ expect(result).toBe(1);
+ expect(output).toContain("src/multi.ts:1");
+ expect(output).toContain("CompressorReturnType");
+ });
+
+ test("should report the line the import actually starts on", async () => {
+ writeSourceFile(
+ tmpDir,
+ "src/late.ts",
+ [
+ "// header comment",
+ 'import { readFile } from "node:fs";',
+ "",
+ 'import type { MinifyOptions } from "@node-minify/types";',
+ "",
+ ].join("\n")
+ );
+
+ const { result, output } = await captureOutput(() =>
+ runDoctor(tmpDir)
+ );
+
+ expect(result).toBe(1);
+ expect(output).toContain("src/late.ts:4");
+ });
+
+ test("should resolve renamed imports to the original alias", async () => {
+ writeSourceFile(
+ tmpDir,
+ "src/renamed.ts",
+ 'import { MinifyOptions as Opts } from "@node-minify/types";\n'
+ );
+
+ const { result, output } = await captureOutput(() =>
+ runDoctor(tmpDir)
+ );
+
+ expect(result).toBe(1);
+ expect(output).toContain("MinifyOptions");
+ });
+
+ test("should detect removed aliases in re-exports", async () => {
+ writeSourceFile(
+ tmpDir,
+ "src/reexport.ts",
+ 'export type { MinifyOptions } from "@node-minify/types";\n'
+ );
+
+ const { result, output } = await captureOutput(() =>
+ runDoctor(tmpDir)
+ );
+
+ expect(result).toBe(1);
+ expect(output).toContain("src/reexport.ts:1");
+ expect(output).toContain("MinifyOptions");
+ });
+
+ test("should ignore removed aliases inside comments", async () => {
+ writeSourceFile(
+ tmpDir,
+ "src/doc.ts",
+ [
+ "/**",
+ ' * Example: import type { CompressorReturnType } from "@node-minify/types";',
+ " */",
+ '// import type { MinifyOptions } from "@node-minify/types";',
+ 'export const note = "use Settings";',
+ "",
+ ].join("\n")
+ );
+
+ const { result, output } = await captureOutput(() =>
+ runDoctor(tmpDir)
+ );
+
+ expect(result).toBe(0);
+ expect(output).toBe("");
+ });
+
+ test("should still flag a real import that follows comment examples", async () => {
+ writeSourceFile(
+ tmpDir,
+ "src/mix.ts",
+ [
+ "/**",
+ ' * Example: import type { CompressorReturnType } from "@node-minify/types";',
+ " */",
+ 'import type { MinifyOptions } from "@node-minify/types";',
+ "",
+ ].join("\n")
+ );
+
+ const { result, output } = await captureOutput(() =>
+ runDoctor(tmpDir)
+ );
+
+ expect(result).toBe(1);
+ expect(output).toContain("src/mix.ts:4");
+ expect(output).not.toContain("CompressorReturnType");
+ });
+
+ test("should not let comment markers inside strings hide a real import", async () => {
+ writeSourceFile(
+ tmpDir,
+ "src/swallow.ts",
+ [
+ 'const open = "/*";',
+ 'import type { MinifyOptions } from "@node-minify/types";',
+ 'const close = "*/";',
+ "",
+ ].join("\n")
+ );
+
+ const { result, output } = await captureOutput(() =>
+ runDoctor(tmpDir)
+ );
+
+ expect(result).toBe(1);
+ expect(output).toContain("src/swallow.ts:2");
+ });
+
+ test("should treat an escaped quote as part of the string", async () => {
+ // Without backslash handling the escaped quote ends the string
+ // early, so the following /* opens a comment that swallows the
+ // import on the next line.
+ writeSourceFile(
+ tmpDir,
+ "src/escaped.ts",
+ [
+ 'const quoted = "he said \\" /*";',
+ 'import type { MinifyOptions } from "@node-minify/types";',
+ 'const close = "*/";',
+ "",
+ ].join("\n")
+ );
+
+ const { result, output } = await captureOutput(() =>
+ runDoctor(tmpDir)
+ );
+
+ expect(result).toBe(1);
+ expect(output).toContain("src/escaped.ts:2");
+ });
+
+ test("should ignore removed aliases embedded in template literals", async () => {
+ writeSourceFile(
+ tmpDir,
+ "src/tpl.ts",
+ [
+ "const doc = `",
+ ' import type { MinifyOptions } from "@node-minify/types";',
+ "`;",
+ "export const ok = doc.length > 0;",
+ "",
+ ].join("\n")
+ );
+
+ const { result, output } = await captureOutput(() =>
+ runDoctor(tmpDir)
+ );
+
+ expect(result).toBe(0);
+ expect(output).toBe("");
+ });
+
+ test("should ignore local identifiers that share a removed alias name", async () => {
+ writeSourceFile(
+ tmpDir,
+ "src/local.ts",
+ [
+ 'import type { Settings } from "@node-minify/types";',
+ "const MinifyOptions = 1;",
+ "export default { MinifyOptions, Settings };",
+ "",
+ ].join("\n")
+ );
+
+ const { result, output } = await captureOutput(() =>
+ runDoctor(tmpDir)
+ );
+
+ expect(result).toBe(0);
+ expect(output).toBe("");
+ });
+ });
+
+ describe("engines.node baseline", () => {
+ test("should warn when engines.node allows a release below 22", async () => {
+ writeFileSync(
+ join(tmpDir, "package.json"),
+ JSON.stringify({
+ name: "test-project",
+ engines: { node: ">=20.0.0" },
+ })
+ );
+
+ const { result, output } = await captureOutput(() =>
+ runDoctor(tmpDir)
+ );
+
+ // Engine drift is a warning, so it must not fail the run.
+ expect(result).toBe(0);
+ expect(output).toContain("WARNING");
+ expect(output).toContain("engines.node");
+ expect(output).toContain("Node 20");
+ });
+
+ test("should accept a compound range whose lowest major is 22", async () => {
+ writeFileSync(
+ join(tmpDir, "package.json"),
+ JSON.stringify({
+ name: "test-project",
+ engines: { node: "^22.0.0 || >=24" },
+ })
+ );
+
+ const { result, output } = await captureOutput(() =>
+ runDoctor(tmpDir)
+ );
+
+ expect(result).toBe(0);
+ expect(output).toBe("");
+ });
+
+ test("should warn when engines.node places no lower bound", async () => {
+ writeFileSync(
+ join(tmpDir, "package.json"),
+ JSON.stringify({
+ name: "test-project",
+ engines: { node: "*" },
+ })
+ );
+
+ const { result, output } = await captureOutput(() =>
+ runDoctor(tmpDir)
+ );
+
+ expect(result).toBe(0);
+ expect(output).toContain("WARNING");
+ expect(output).toContain("no lower bound");
+ });
+
+ test("should ignore package.json without an engines field", async () => {
+ writePackageJson(tmpDir, { "@node-minify/terser": "^11.0.0" });
+
+ const { result, output } = await captureOutput(() =>
+ runDoctor(tmpDir)
+ );
+
+ expect(result).toBe(0);
+ expect(output).toBe("");
+ });
+ });
+});
diff --git a/packages/cli/__tests__/spinner.test.ts b/packages/cli/__tests__/spinner.test.ts
new file mode 100644
index 000000000..73607c7dd
--- /dev/null
+++ b/packages/cli/__tests__/spinner.test.ts
@@ -0,0 +1,45 @@
+/*! node-minify spinner tests - MIT Licensed */
+
+import type { Result, Settings } from "@node-minify/types";
+import { beforeEach, describe, expect, test, vi } from "vitest";
+
+const oraInstance = vi.hoisted(() => ({
+ text: "",
+ start: vi.fn(),
+ succeed: vi.fn(),
+ fail: vi.fn(),
+}));
+
+vi.mock("ora", () => ({ default: vi.fn(() => oraInstance) }));
+
+import { spinnerError, spinnerStart, spinnerStop } from "../src/spinner.ts";
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ oraInstance.text = "";
+});
+
+describe("spinner", () => {
+ test("spinnerStart sets a compressing message and starts", () => {
+ spinnerStart({ compressorLabel: "terser" } as unknown as Settings);
+ expect(oraInstance.text).toContain("Compressing file(s)");
+ expect(oraInstance.start).toHaveBeenCalled();
+ });
+
+ test("spinnerStop sets a success message and succeeds", () => {
+ spinnerStop({
+ compressorLabel: "terser",
+ size: "1 kB",
+ sizeGzip: "0.5 kB",
+ } as unknown as Result);
+ expect(oraInstance.text).toContain("compressed successfully");
+ expect(oraInstance.succeed).toHaveBeenCalled();
+ });
+
+ test("spinnerError sets a failure message and fails", () => {
+ spinnerError({ compressorLabel: "terser" } as unknown as Settings);
+ expect(oraInstance.text).toContain("Error - file(s) not compressed");
+ expect(oraInstance.text).toContain("terser");
+ expect(oraInstance.fail).toHaveBeenCalled();
+ });
+});
diff --git a/packages/cli/package.json b/packages/cli/package.json
index 5bb945d53..d9f490c3c 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -15,7 +15,7 @@
"node-minify": "dist/bin/cli.js"
},
"engines": {
- "node": ">=20.0.0"
+ "node": ">=22.0.0"
},
"directories": {
"lib": "dist",
@@ -59,7 +59,7 @@
"@node-minify/utils": "workspace:*",
"chalk": "5.6.2",
"commander": "14.0.3",
- "ora": "9.3.0",
+ "ora": "9.4.1",
"update-notifier": "7.3.1"
},
"devDependencies": {
@@ -77,7 +77,6 @@
"@node-minify/swc": "workspace:*",
"@node-minify/terser": "workspace:*",
"@node-minify/types": "workspace:*",
- "@node-minify/uglify-js": "workspace:*",
- "@node-minify/yui": "workspace:*"
+ "@node-minify/uglify-js": "workspace:*"
}
}
diff --git a/packages/cli/src/bin/cli.ts b/packages/cli/src/bin/cli.ts
index 719df93e8..e067faed9 100644
--- a/packages/cli/src/bin/cli.ts
+++ b/packages/cli/src/bin/cli.ts
@@ -7,11 +7,12 @@
*/
import { benchmark, getReporter } from "@node-minify/benchmark";
+import { getCompressorsByStatus } from "@node-minify/utils";
import { Command } from "commander";
import ora from "ora";
import updateNotifier from "update-notifier";
import packageJson from "../../package.json" with { type: "json" };
-import { AVAILABLE_MINIFIER } from "../config.ts";
+import { doctor } from "../doctor.ts";
import type { SettingsWithCompressor } from "../index.ts";
import { run } from "../index.ts";
@@ -49,7 +50,7 @@ function setupProgram(): Command {
.option("-o, --output [file]", "output file path")
.option(
"-t, --type [type]",
- "file type: js or css (required for esbuild, yui)"
+ "file type: js or css (required for esbuild)"
)
.option("-s, --silence", "no output will be printed")
.option(
@@ -130,23 +131,55 @@ function setupProgram(): Command {
}
});
+ program
+ .command("doctor")
+ .description("Scan project for v11 migration issues")
+ .action(async () => {
+ await doctor();
+ });
+
program.on("--help", displayCompressorsList);
return program;
}
/**
- * Prints the list of available compressors to standard output.
+ * Prints the list of available compressors grouped by tier to standard output.
*
- * Outputs a header, each compressor name prefixed with a dash, and a trailing blank line.
+ * Outputs compressors organized by status (recommended, supported, legacy),
+ * with each tier clearly labeled.
*/
function displayCompressorsList() {
console.log(" List of compressors:");
console.log("");
- AVAILABLE_MINIFIER.forEach((compressor) => {
- console.log(` - ${compressor.name}`);
- });
- console.log("");
+
+ const recommended = getCompressorsByStatus("recommended");
+ const supported = getCompressorsByStatus("supported");
+ const legacy = getCompressorsByStatus("legacy");
+
+ if (recommended.length > 0) {
+ console.log(" Recommended:");
+ recommended.forEach((compressor) => {
+ console.log(` - ${compressor.name}`);
+ });
+ console.log("");
+ }
+
+ if (supported.length > 0) {
+ console.log(" Supported:");
+ supported.forEach((compressor) => {
+ console.log(` - ${compressor.name}`);
+ });
+ console.log("");
+ }
+
+ if (legacy.length > 0) {
+ console.log(" Legacy:");
+ legacy.forEach((compressor) => {
+ console.log(` - ${compressor.name}`);
+ });
+ console.log("");
+ }
}
/**
diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts
index 473d59db7..57e970ade 100644
--- a/packages/cli/src/config.ts
+++ b/packages/cli/src/config.ts
@@ -12,18 +12,11 @@ export const AVAILABLE_MINIFIER = [
{ name: "swc", export: "swc" },
{ name: "terser", export: "terser" },
{ name: "uglify-js", export: "uglifyJs" },
- // Deprecated JS compressors
- { name: "babel-minify", export: "babelMinify" },
- { name: "uglify-es", export: "uglifyEs" },
- { name: "yui", export: "yui" },
// CSS compressors
{ name: "clean-css", export: "cleanCss", cssOnly: true },
{ name: "cssnano", export: "cssnano", cssOnly: true },
{ name: "csso", export: "csso", cssOnly: true },
{ name: "lightningcss", export: "lightningCss", cssOnly: true },
- // Deprecated CSS compressors
- { name: "crass", export: "crass", cssOnly: true },
- { name: "sqwish", export: "sqwish", cssOnly: true },
// HTML compressors
{ name: "html-minifier", export: "htmlMinifier" },
{ name: "minify-html", export: "minifyHtml" },
diff --git a/packages/cli/src/doctor.ts b/packages/cli/src/doctor.ts
new file mode 100644
index 000000000..e6a69ae3a
--- /dev/null
+++ b/packages/cli/src/doctor.ts
@@ -0,0 +1,659 @@
+/*!
+ * node-minify
+ * Copyright (c) 2011-2026 Rodolphe Stoclin
+ * MIT Licensed
+ */
+
+import { type Dirent, existsSync, readdirSync, readFileSync } from "node:fs";
+import { readdir } from "node:fs/promises";
+import { extname, join, relative } from "node:path";
+import process from "node:process";
+import type { CompressorEntry } from "@node-minify/utils";
+import { COMPRESSOR_REGISTRY } from "@node-minify/utils";
+
+/**
+ * Severity levels for doctor findings.
+ * "error" exits 1; "warning" exits 0.
+ */
+type DiagnosticSeverity = "error" | "warning";
+
+/**
+ * Compressor registry statuses that produce a diagnostic.
+ * "removed" maps to an error, "legacy" to a warning.
+ */
+type DiagnosticStatus = "removed" | "legacy";
+
+/**
+ * A single diagnostic finding from the doctor scan.
+ */
+interface Finding {
+ /** Relative file path from the scanned project root */
+ file: string;
+ /** 1-indexed line number where the issue was found */
+ line?: number;
+ /** Human-readable description of the problem and its fix */
+ message: string;
+ /** Severity: error → exit 1, warning → exit 0 */
+ severity: DiagnosticSeverity;
+}
+
+const EXCLUDED_DIRS = new Set([
+ "node_modules",
+ "dist",
+ ".git",
+ "coverage",
+ "build",
+ ".next",
+ "__tests__",
+]);
+const SOURCE_EXTENSIONS = new Set([
+ ".js",
+ ".ts",
+ ".jsx",
+ ".tsx",
+ ".mjs",
+ ".cjs",
+ ".mts",
+ ".cts",
+]);
+/**
+ * A @node-minify specifier reached through `from "..."`, `require("...")`,
+ * `import("...")`, or a side-effect `import "..."`.
+ */
+const IMPORT_REGEX =
+ /(?:from\s+["']|(?:require|import)\s*\(\s*["']|import\s+["'])(@node-minify\/[^"']+)["']/g;
+/**
+ * A `compressor:` assignment. Whitespace is allowed before the colon so
+ * formatted config objects are not skipped.
+ */
+const COMPRESSOR_REGEX =
+ /(?:^|[\s,{])["']?compressor["']?\s*:\s*["']?([a-zA-Z][\w-]*)["']?/;
+/**
+ * Named import or re-export block from a @node-minify package. Matches
+ * multi-line forms so a wrapped list cannot slip past the type-alias scanner,
+ * and `export ... from` so re-exported aliases are still reported.
+ */
+const NAMED_IMPORT_REGEX =
+ /(?:import|export)\s+(?:type\s+)?\{([^}]*)\}\s*from\s*["'](@node-minify\/[^"']+)["']/g;
+
+/** Minimum Node.js major version required by v11. */
+const MIN_NODE_MAJOR = 22;
+
+/**
+ * Non-compressor packages removed in v11, mapped to migration guidance.
+ * Compressor removals live in COMPRESSOR_REGISTRY instead.
+ */
+const REMOVED_PACKAGES: Record = {
+ "@node-minify/run":
+ "It was an internal Java/process-spawn helper with no public replacement; remove it from your dependencies.",
+};
+
+/** Type aliases removed in v11, mapped to their replacements. */
+const REMOVED_TYPE_ALIASES: Record = {
+ CompressorReturnType: "CompressorResult",
+ MinifyOptions: "Settings",
+};
+
+/** File extensions that can carry TypeScript type imports. */
+const TYPE_SOURCE_EXTENSIONS = new Set([".ts", ".tsx", ".mts", ".cts"]);
+
+/**
+ * Type guard narrowing a compressor registry status to one that is reported.
+ *
+ * @param status - Registry status string
+ * @returns True when the status should produce a diagnostic
+ */
+function isDiagnosticStatus(status: string): status is DiagnosticStatus {
+ return status === "removed" || status === "legacy";
+}
+
+/**
+ * Build a lookup map of removed/legacy compressors keyed by the given field.
+ *
+ * @param key - Entry field to key by ("name" or "packageName")
+ * @returns Map of diagnostic-severity entries keyed by the chosen field
+ */
+function buildEntryMap(
+ key: "name" | "packageName"
+): Map {
+ const map = new Map();
+ for (const entry of COMPRESSOR_REGISTRY) {
+ if (isDiagnosticStatus(entry.status)) {
+ map.set(entry[key], entry);
+ }
+ }
+ return map;
+}
+
+/**
+ * Build the message and severity for a compressor registry entry.
+ *
+ * @param entry - Registry entry describing the compressor
+ * @param displayName - Name to show in the diagnostic (bare or scoped)
+ * @returns The message and severity for the finding
+ */
+function describeEntry(
+ entry: CompressorEntry,
+ displayName: string
+): { message: string; severity: DiagnosticSeverity } {
+ if (entry.status === "removed") {
+ const replacement = entry.replacement ?? "a supported compressor";
+ return {
+ message: `${displayName} was removed in v11. Use ${replacement} instead.`,
+ severity: "error",
+ };
+ }
+
+ return {
+ message: `${displayName} is legacy tier. Consider migrating.`,
+ severity: "warning",
+ };
+}
+
+/**
+ * Resolve the 1-indexed line number containing a character offset.
+ *
+ * @param content - Full file contents
+ * @param index - Character offset into `content`
+ * @returns 1-indexed line number
+ */
+function lineNumberAt(content: string, index: number): number {
+ let line = 1;
+ for (let i = 0; i < index && i < content.length; i++) {
+ if (content[i] === "\n") line++;
+ }
+ return line;
+}
+
+/**
+ * Blank out comments and template-literal bodies, replacing each character with
+ * a space (newlines preserved) so byte offsets and line numbers are unchanged.
+ *
+ * Quoted strings are walked but left intact, because an import specifier lives
+ * inside quotes and must still match. Walking them is what stops a comment
+ * marker inside a string from swallowing the code that follows. Template
+ * literals are blanked, since a migration example embedded in one is prose, not
+ * a real import.
+ *
+ * @param content - Full file contents
+ * @returns The contents with comment and template bodies replaced by spaces
+ */
+function stripComments(content: string): string {
+ const out = content.split("");
+ const blank = (from: number, to: number): void => {
+ for (let j = from; j < to && j < out.length; j++) {
+ if (out[j] !== "\n") out[j] = " ";
+ }
+ };
+ let i = 0;
+
+ while (i < content.length) {
+ const char = content[i];
+ const next = content[i + 1];
+
+ if (char === '"' || char === "'" || char === "`") {
+ const quote = char;
+ const start = i;
+ i++;
+ while (i < content.length) {
+ if (content[i] === "\\") {
+ i += 2;
+ continue;
+ }
+ if (content[i] === quote) {
+ i++;
+ break;
+ }
+ i++;
+ }
+ if (quote === "`") blank(start + 1, i - 1);
+ continue;
+ }
+
+ if (char === "/" && next === "*") {
+ const end = content.indexOf("*/", i + 2);
+ const stop = end === -1 ? content.length : end + 2;
+ blank(i, stop);
+ i = stop;
+ continue;
+ }
+
+ if (char === "/" && next === "/") {
+ const start = i;
+ while (i < content.length && content[i] !== "\n") i++;
+ blank(start, i);
+ continue;
+ }
+
+ i++;
+ }
+
+ return out.join("");
+}
+
+/**
+ * Report a package.json whose engines.node range still admits a Node release
+ * below the v11 minimum.
+ *
+ * The lowest major version mentioned in the range is used, which is accurate for
+ * the range styles seen in practice (">=20.0.0", "^20 || ^22", "20.x", ">=20 <24").
+ * A range naming no version at all ("*", "x") places no floor on the runtime, so
+ * it is reported rather than assumed safe.
+ *
+ * @param pkg - Parsed package.json object
+ * @param relPath - Relative path used in the diagnostic
+ * @returns A warning finding, or undefined when the range is already v11-safe
+ */
+function checkNodeEngine(
+ pkg: Record,
+ relPath: string
+): Finding | undefined {
+ const engines = pkg.engines;
+ if (typeof engines !== "object" || engines === null) return undefined;
+
+ const nodeRange = (engines as Record).node;
+ if (typeof nodeRange !== "string") return undefined;
+
+ const majors = [...nodeRange.matchAll(/(\d+)(?:\.\d+)*/g)]
+ .map((match) => Number(match[1]))
+ .filter((major) => Number.isFinite(major));
+
+ if (majors.length === 0) {
+ return {
+ file: relPath,
+ message: `engines.node is "${nodeRange}", which places no lower bound on the runtime. v11 requires Node >=${MIN_NODE_MAJOR}.`,
+ severity: "warning",
+ };
+ }
+
+ const lowest = Math.min(...majors);
+ if (lowest >= MIN_NODE_MAJOR) return undefined;
+
+ return {
+ file: relPath,
+ message: `engines.node is "${nodeRange}", which allows Node ${lowest}. v11 requires Node >=${MIN_NODE_MAJOR}.`,
+ severity: "warning",
+ };
+}
+
+/**
+ * Recursively collect files under `dir`, pruning EXCLUDED_DIRS during traversal
+ * so heavy directories like node_modules are never descended into (rather than
+ * walked and filtered afterwards).
+ *
+ * @param dir - Directory to walk
+ * @param cwd - Project root used to compute relative paths
+ * @param accept - Predicate deciding whether a relative file path is kept
+ * @returns Relative file paths (from `cwd`) that satisfy `accept`
+ */
+async function collectFiles(
+ dir: string,
+ cwd: string,
+ accept: (relativePath: string) => boolean
+): Promise {
+ let entries: Dirent[];
+ try {
+ entries = await readdir(dir, { withFileTypes: true });
+ } catch {
+ return [];
+ }
+
+ const results: string[] = [];
+ for (const entry of entries) {
+ const fullPath = join(dir, entry.name);
+ if (entry.isDirectory()) {
+ if (EXCLUDED_DIRS.has(entry.name)) continue;
+ results.push(...(await collectFiles(fullPath, cwd, accept)));
+ } else if (entry.isFile()) {
+ const relativePath = relative(cwd, fullPath);
+ if (accept(relativePath)) results.push(relativePath);
+ }
+ }
+ return results;
+}
+
+/**
+ * Collect all source files under cwd, excluding node_modules/dist/.git etc.
+ *
+ * @param cwd - Root directory to scan
+ * @returns Array of relative file paths matching source extensions
+ */
+function getSourceFiles(cwd: string): Promise {
+ return collectFiles(cwd, cwd, (relativePath) =>
+ SOURCE_EXTENSIONS.has(extname(relativePath))
+ );
+}
+
+/**
+ * Collect all GitHub Actions workflow YAML files under cwd/.github/workflows/.
+ *
+ * @param cwd - Root directory to scan
+ * @returns Array of relative file paths to workflow files
+ */
+function getWorkflowFiles(cwd: string): string[] {
+ const workflowDir = join(cwd, ".github", "workflows");
+ if (!existsSync(workflowDir)) return [];
+ try {
+ const entries = readdirSync(workflowDir, { encoding: "utf-8" });
+ return entries
+ .filter((entry) => {
+ const ext = extname(entry);
+ return ext === ".yml" || ext === ".yaml";
+ })
+ .map((entry) => join(".github", "workflows", entry));
+ } catch {
+ return [];
+ }
+}
+
+/**
+ * Collect all package.json files under cwd, excluding node_modules/dist/.git etc.
+ *
+ * @param cwd - Root directory to scan
+ * @returns Array of absolute file paths to package.json files
+ */
+async function getPackageJsonFiles(cwd: string): Promise {
+ const result: string[] = [];
+
+ // Always include root package.json
+ const rootPkg = join(cwd, "package.json");
+ if (existsSync(rootPkg)) {
+ result.push(rootPkg);
+ }
+
+ // Recursively find all other package.json files (root added above).
+ const nested = await collectFiles(cwd, cwd, (relativePath) => {
+ const parts = relativePath.split(/[\\/]/);
+ // Must be exactly "package.json", not "template-package.json" etc., and
+ // not the already-added root (which has a single path segment).
+ return parts[parts.length - 1] === "package.json" && parts.length > 1;
+ });
+ for (const relativePath of nested) {
+ result.push(join(cwd, relativePath));
+ }
+
+ return result;
+}
+
+/**
+ * Scanner 1: Check package.json files for removed/legacy @node-minify dependencies,
+ * non-compressor packages removed in v11, and an engines.node range that still
+ * allows a Node release below the v11 minimum.
+ * Recursively scans all package.json files in the project, excluding node_modules, dist, etc.
+ *
+ * @param cwd - Project root directory
+ * @returns Array of findings for problematic dependencies
+ */
+async function scanPackageJsonFiles(cwd: string): Promise {
+ const findings: Finding[] = [];
+ const packageMap = buildEntryMap("packageName");
+ const packageJsonPaths = await getPackageJsonFiles(cwd);
+
+ for (const pkgPath of packageJsonPaths) {
+ try {
+ const content = readFileSync(pkgPath, "utf-8");
+ const pkg: unknown = JSON.parse(content);
+ if (typeof pkg !== "object" || pkg === null) continue;
+
+ const relPath = relative(cwd, pkgPath);
+ const depSections = [
+ "dependencies",
+ "devDependencies",
+ "peerDependencies",
+ "optionalDependencies",
+ ] as const;
+
+ for (const section of depSections) {
+ const deps: unknown = (pkg as Record)[section];
+ if (typeof deps !== "object" || deps === null) continue;
+
+ for (const depName of Object.keys(deps)) {
+ const entry = packageMap.get(depName);
+ if (entry) {
+ findings.push({
+ file: relPath,
+ ...describeEntry(entry, entry.packageName),
+ });
+ continue;
+ }
+
+ const guidance = REMOVED_PACKAGES[depName];
+ if (guidance) {
+ findings.push({
+ file: relPath,
+ message: `${depName} was removed in v11. ${guidance}`,
+ severity: "error",
+ });
+ }
+ }
+ }
+
+ const engineFinding = checkNodeEngine(
+ pkg as Record,
+ relPath
+ );
+ if (engineFinding) findings.push(engineFinding);
+ } catch {
+ // Skip files with parse errors
+ }
+ }
+
+ return findings;
+}
+
+/**
+ * Scanner 2: Check source files for imports/requires of removed/legacy @node-minify
+ * packages, for compressor name assignments (e.g. `compressor: "babel-minify"`), and
+ * for removed type aliases imported from @node-minify packages.
+ * Scans all .js/.ts/.mjs/.cjs files, excluding node_modules, dist, and .git directories.
+ *
+ * @param cwd - Project root directory
+ * @returns Array of findings with file path and line number
+ */
+async function scanSourceImports(cwd: string): Promise {
+ const findings: Finding[] = [];
+ const packageMap = buildEntryMap("packageName");
+ const compressorMap = buildEntryMap("name");
+ const sourceFiles = await getSourceFiles(cwd);
+
+ for (const relPath of sourceFiles) {
+ try {
+ const fullPath = join(cwd, relPath);
+ const content = readFileSync(fullPath, "utf-8");
+ const lines = content.split("\n");
+
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i];
+ if (!line) continue;
+
+ // Check import/require of @node-minify/* packages
+ for (const match of line.matchAll(IMPORT_REGEX)) {
+ const pkgName = match[1];
+ if (!pkgName) continue;
+
+ const entry = packageMap.get(pkgName);
+ if (entry) {
+ findings.push({
+ file: relPath,
+ line: i + 1,
+ ...describeEntry(entry, pkgName),
+ });
+ continue;
+ }
+
+ const guidance = REMOVED_PACKAGES[pkgName];
+ if (guidance) {
+ findings.push({
+ file: relPath,
+ line: i + 1,
+ message: `${pkgName} was removed in v11. ${guidance}`,
+ severity: "error",
+ });
+ }
+ }
+
+ // Check compressor: "name" assignments in config objects
+ const compressorMatch = COMPRESSOR_REGEX.exec(line);
+ if (compressorMatch) {
+ const compressorName = compressorMatch[1];
+ if (compressorName) {
+ const entry = compressorMap.get(compressorName);
+ if (entry) {
+ findings.push({
+ file: relPath,
+ line: i + 1,
+ ...describeEntry(entry, compressorName),
+ });
+ }
+ }
+ }
+ }
+
+ // Removed type aliases, matched across the whole file so multi-line
+ // named-import blocks are covered. Comments are blanked first so a
+ // migration example in a doc comment is not reported as a real
+ // import; blanking preserves offsets so line numbers stay correct.
+ if (TYPE_SOURCE_EXTENSIONS.has(extname(relPath))) {
+ const code = stripComments(content);
+ for (const match of code.matchAll(NAMED_IMPORT_REGEX)) {
+ const specifiers = match[1];
+ if (!specifiers) continue;
+
+ for (const specifier of specifiers.split(",")) {
+ // Strip "type " prefixes and " as alias" suffixes.
+ const imported = specifier
+ .trim()
+ .replace(/^type\s+/, "")
+ .split(/\s+as\s+/)[0]
+ ?.trim();
+ if (!imported) continue;
+
+ const replacement = REMOVED_TYPE_ALIASES[imported];
+ if (replacement) {
+ findings.push({
+ file: relPath,
+ line: lineNumberAt(content, match.index),
+ message: `type ${imported} was removed in v11. Use ${replacement} instead.`,
+ severity: "error",
+ });
+ }
+ }
+ }
+ }
+ } catch {
+ // Skip unreadable files
+ }
+ }
+
+ return findings;
+}
+
+/**
+ * Scanner 3: Check GitHub Actions workflow YAML files for removed compressor names
+ * in `compressor:` fields.
+ *
+ * @param cwd - Project root directory
+ * @returns Array of findings with file path and line number
+ */
+function scanWorkflowYaml(cwd: string): Finding[] {
+ const findings: Finding[] = [];
+ const compressorMap = buildEntryMap("name");
+ const workflowFiles = getWorkflowFiles(cwd);
+
+ for (const relPath of workflowFiles) {
+ try {
+ const fullPath = join(cwd, relPath);
+ const content = readFileSync(fullPath, "utf-8");
+ const lines = content.split("\n");
+
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i];
+ if (!line) continue;
+ const match = COMPRESSOR_REGEX.exec(line);
+ if (match) {
+ const compressorName = match[1];
+ if (!compressorName) continue;
+ const entry = compressorMap.get(compressorName);
+ if (entry) {
+ findings.push({
+ file: relPath,
+ line: i + 1,
+ ...describeEntry(entry, compressorName),
+ });
+ }
+ }
+ }
+ } catch {
+ // Skip unreadable files
+ }
+ }
+
+ return findings;
+}
+
+/**
+ * Format a single finding into a human-readable diagnostic line.
+ *
+ * @param finding - The diagnostic finding to format
+ * @returns Formatted string like "ERROR: file:line - message"
+ */
+function formatFinding(finding: Finding): string {
+ const prefix = finding.severity === "error" ? "ERROR" : "WARNING";
+ // Normalize to forward slashes so output is stable across OSes (Windows uses "\").
+ const file = finding.file.replaceAll("\\", "/");
+ const location =
+ finding.line !== undefined ? `${file}:${finding.line}` : file;
+
+ return `${prefix}: ${location} - ${finding.message}`;
+}
+
+/**
+ * Print all findings to stdout, grouped by severity (errors first, then warnings).
+ * Silent when no findings exist.
+ *
+ * @param findings - Array of diagnostic findings to report
+ */
+function reportFindings(findings: Finding[]): void {
+ const errors = findings.filter((f) => f.severity === "error");
+ const warnings = findings.filter((f) => f.severity === "warning");
+
+ for (const finding of [...errors, ...warnings]) {
+ console.log(formatFinding(finding));
+ }
+}
+
+/**
+ * Run the doctor diagnostic scan on a project directory.
+ * Scans package.json files (dependencies and engines.node), source imports and
+ * type imports, and workflow YAML for v11 migration issues.
+ *
+ * @param cwd - Project root directory to scan (defaults to process.cwd())
+ * @returns Exit code: 0 if no errors (warnings are OK), 1 if errors found
+ */
+export async function runDoctor(cwd?: string): Promise {
+ const projectDir = cwd ?? process.cwd();
+
+ const findings: Finding[] = [
+ ...(await scanPackageJsonFiles(projectDir)),
+ ...(await scanSourceImports(projectDir)),
+ ...scanWorkflowYaml(projectDir),
+ ];
+
+ reportFindings(findings);
+
+ const hasErrors = findings.some((f) => f.severity === "error");
+ return hasErrors ? 1 : 0;
+}
+
+/**
+ * CLI entry point for the doctor command.
+ * Runs the diagnostic scan on the current working directory and exits the process
+ * with the appropriate code.
+ *
+ * @returns A promise that does not resolve normally; the process exits with code
+ * 0 (no errors) or 1 (removed-package errors found).
+ */
+export async function doctor(): Promise {
+ const code = await runDoctor(process.cwd());
+ process.exit(code);
+}
diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts
index 633ad2573..c09202da7 100644
--- a/packages/cli/src/index.ts
+++ b/packages/cli/src/index.ts
@@ -8,7 +8,7 @@
* Module dependencies.
*/
import type { Result, Settings } from "@node-minify/types";
-import { resolveCompressor } from "@node-minify/utils";
+import { getCompressorEntry, resolveCompressor } from "@node-minify/utils";
import chalk from "chalk";
import { compress } from "./compress.ts";
import { AVAILABLE_MINIFIER } from "./config.ts";
@@ -35,8 +35,19 @@ let silence = false;
* @throws Error if the specified compressor is not found
* @throws Error if the compressor implementation is missing or not a function
* @throws Error if the compressor only supports CSS but a non-`css` type is provided
+ * @throws Error if the compressor has been removed
*/
async function runOne(cli: SettingsWithCompressor): Promise {
+ // Fail-fast for removed compressors
+ const removedEntry = getCompressorEntry(cli.compressor);
+ if (removedEntry?.status === "removed") {
+ const replacement =
+ removedEntry.replacement ?? "a supported compressor";
+ throw new Error(
+ `Compressor '${cli.compressor}' was removed in v11. Use '${replacement}' instead.`
+ );
+ }
+
const resolution = await resolveCompressor(cli.compressor);
const { compressor: minifierImplementation, label: compressorLabel } =
resolution;
diff --git a/packages/core/__tests__/compress-paths.test.ts b/packages/core/__tests__/compress-paths.test.ts
index 1a3dd2913..df41e1a33 100644
--- a/packages/core/__tests__/compress-paths.test.ts
+++ b/packages/core/__tests__/compress-paths.test.ts
@@ -73,4 +73,40 @@ describe("compress path handling", () => {
});
expect(vi.mocked(compressSingleFile)).toHaveBeenCalledTimes(1);
});
+
+ test("compresses in-memory content without creating directories", async () => {
+ const compressor: Compressor = async () => ({ code: "ok" });
+ const result = await compress({
+ compressor,
+ content: "var x = 1;",
+ } as Settings);
+
+ expect(result).toBe("ok");
+ expect(vi.mocked(mkdir)).not.toHaveBeenCalled();
+ expect(vi.mocked(compressSingleFile)).toHaveBeenCalledTimes(1);
+ });
+
+ test("throws for an array input containing an empty string", async () => {
+ const compressor: Compressor = async () => ({ code: "ok" });
+
+ await expect(
+ compress({
+ compressor,
+ input: ["", "b.js"],
+ output: ["a.min.js", "b.min.js"],
+ } as Settings)
+ ).rejects.toThrow("expected non-empty string, got empty string");
+ });
+
+ test("throws for an array input containing a non-string", async () => {
+ const compressor: Compressor = async () => ({ code: "ok" });
+
+ await expect(
+ compress({
+ compressor,
+ input: [123, "b.js"],
+ output: ["a.min.js", "b.min.js"],
+ } as unknown as Settings)
+ ).rejects.toThrow("got number");
+ });
});
diff --git a/packages/core/__tests__/core.test.ts b/packages/core/__tests__/core.test.ts
index ce7f57aa8..610c41da5 100644
--- a/packages/core/__tests__/core.test.ts
+++ b/packages/core/__tests__/core.test.ts
@@ -4,20 +4,11 @@
* MIT Licensed
*/
-import childProcess from "node:child_process";
import { statSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { Compressor, Settings } from "@node-minify/types";
-import {
- afterAll,
- beforeAll,
- beforeEach,
- describe,
- expect,
- test,
- vi,
-} from "vitest";
+import { beforeEach, describe, expect, test, vi } from "vitest";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -31,27 +22,13 @@ vi.mock("node:fs", async (importOriginal) => {
});
import { filesJS } from "../../../tests/files-path.ts";
-import { runOneTest, tests } from "../../../tests/fixtures.ts";
import { gcc } from "../../google-closure-compiler/src/index.ts";
import { htmlMinifier } from "../../html-minifier/src/index.ts";
import { noCompress } from "../../no-compress/src/index.ts";
-import { uglifyEs } from "../../uglify-es/src/index.ts";
-import { yui } from "../../yui/src/index.ts";
import { minify } from "../src/index.ts";
import { setup } from "../src/setup.ts";
-const compressorLabel = "uglify-es";
-const compressor = uglifyEs;
-
describe("Package: core", async () => {
- if (!tests.commonjs) {
- throw new Error("Tests not found");
- }
-
- for (const options of tests.commonjs) {
- await runOneTest({ options, compressorLabel, compressor });
- }
-
describe("Fake binary", () => {
test("should throw an error if binary does not exist", async () => {
const settings: Settings = {
@@ -125,52 +102,6 @@ describe("Package: core", async () => {
});
});
- describe("Create errors", () => {
- test("should catch an error if yui with bad options", async () => {
- const settings: Settings = {
- compressor: yui,
- type: "js",
- input: filesJS.oneFile,
- output: filesJS.fileJSOut,
- options: {
- fake: true,
- },
- };
-
- try {
- return await minify(settings);
- } catch (err: unknown) {
- if (err instanceof Error) {
- return expect(err.toString()).toMatch("Error");
- }
- }
- });
- });
-
- describe("Create errors", () => {
- beforeAll(() => {
- const spy = vi.spyOn(childProcess, "spawn");
- spy.mockImplementation(() => {
- throw new Error();
- });
- });
- test("should throw an error on spawn", async () => {
- const settings: Settings = {
- compressor: yui,
- input: filesJS.oneFile,
- output: filesJS.fileJSOut,
- options: {
- fake: true,
- },
- };
-
- await expect(minify(settings)).rejects.toThrow();
- });
- afterAll(() => {
- vi.restoreAllMocks();
- });
- });
-
describe("Mandatory", () => {
test("should show throw on type option", async () => {
const settings: Partial = {
diff --git a/packages/core/__tests__/setup.test.ts b/packages/core/__tests__/setup.test.ts
new file mode 100644
index 000000000..a64d98573
--- /dev/null
+++ b/packages/core/__tests__/setup.test.ts
@@ -0,0 +1,52 @@
+/*! node-minify core setup tests - MIT Licensed */
+
+import type { Settings } from "@node-minify/types";
+import { describe, expect, test, vi } from "vitest";
+
+// Keep input untouched so checkOutput receives the literal paths instead of
+// glob-expanded results.
+vi.mock("@node-minify/utils", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ wildcards: vi.fn((input: string | string[]) => ({ input })),
+ };
+});
+
+import { setup } from "../src/setup.ts";
+
+const compressor = (() => ({ code: "" })) as unknown as Settings["compressor"];
+
+describe("setup $1 output handling", () => {
+ test("rewrites the $1 placeholder for a single file", () => {
+ const settings = setup({
+ compressor,
+ input: "a.js",
+ output: "$1.min.js",
+ });
+ expect(typeof settings.output).toBe("string");
+ expect(settings.output).not.toContain("$1");
+ expect(settings.output).toContain("min.js");
+ });
+
+ test("rewrites the $1 placeholder per file for an array input", () => {
+ const settings = setup({
+ compressor,
+ input: ["a.js", "b.js"],
+ output: "$1.min.js",
+ });
+ expect(Array.isArray(settings.output)).toBe(true);
+ const outputs = settings.output as string[];
+ expect(outputs).toHaveLength(2);
+ expect(outputs.every((o) => !o.includes("$1"))).toBe(true);
+ });
+
+ test("leaves a plain output without a placeholder unchanged", () => {
+ const settings = setup({
+ compressor,
+ input: "a.js",
+ output: "dist/a.min.js",
+ });
+ expect(settings.output).toBe("dist/a.min.js");
+ });
+});
diff --git a/packages/core/package.json b/packages/core/package.json
index 331fcd0a3..ad2d7038b 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -12,7 +12,7 @@
"license": "MIT",
"type": "module",
"engines": {
- "node": ">=20.0.0"
+ "node": ">=22.0.0"
},
"directories": {
"lib": "dist",
diff --git a/packages/crass/CHANGELOG.md b/packages/crass/CHANGELOG.md
deleted file mode 100644
index e6f5727aa..000000000
--- a/packages/crass/CHANGELOG.md
+++ /dev/null
@@ -1,136 +0,0 @@
-# @node-minify/crass
-
-## 10.5.0
-
-### Patch Changes
-
-- Updated dependencies [43c11f7]
-- Updated dependencies [1d5e3ee]
-- Updated dependencies [c21e335]
- - @node-minify/utils@10.5.0
-
-## 10.4.0
-
-### Patch Changes
-
-- Updated dependencies [2e64877]
-- Updated dependencies [3d4d2d0]
-- Updated dependencies [0a51025]
- - @node-minify/utils@10.4.0
-
-## 10.3.0
-
-### Patch Changes
-
-- Updated dependencies [1e06c03]
- - @node-minify/utils@10.3.0
-
-## 10.2.0
-
-### Patch Changes
-
-- Updated dependencies [3c98739]
- - @node-minify/utils@10.2.0
-
-## 10.1.1
-
-### Patch Changes
-
-- eb785b0: Fix npm install error caused by unresolved workspace:\* references in published packages
-- Updated dependencies [eb785b0]
- - @node-minify/utils@10.1.1
-
-## 10.1.0
-
-### Patch Changes
-
-- @node-minify/utils@10.1.0
-
-## 10.0.2
-
-### Patch Changes
-
-- 156a53d: test: verify OIDC publishing with fixed workflow config
-- Updated dependencies [156a53d]
- - @node-minify/utils@10.0.2
-
-## 10.0.1
-
-### Patch Changes
-
-- d722b73: test: verify OIDC publishing with fixed workflow config
-- Updated dependencies [d722b73]
- - @node-minify/utils@10.0.1
-
-## 10.0.0
-
-### Major Changes
-
-- 4406c0c: ## v10.0.0
-
- ### Breaking Changes
-
- - **ESM Only**: The package is now pure ESM. Requires Node.js 20+.
- - **Async API**: Callback support has been removed. All `minify()` calls must use `await` or `.then()`.
- - **Named Exports**: All packages now use named exports (e.g., `import { minify } from '@node-minify/core'`).
- - **Sync/Async Split**: Sync functions have been removed or split.
- - **Deprecations**:
- - `@node-minify/babel-minify` (deprecated)
- - `@node-minify/uglify-es` (deprecated)
- - `@node-minify/yui` (deprecated)
- - `@node-minify/sqwish` (deprecated)
- - `@node-minify/crass` (deprecated)
-
- ### Features & Improvements
-
- - **Build System**: Switched from `tsup` to `tsdown` for faster and more reliable builds.
- - **Core**: Moved file I/O operations from compressors to core for better consistency.
- - **Output**: Support for array output with input/output validation.
- - **Security**: Replaced `html-minifier` with `html-minifier-next`.
- - **Typings**: Improved TypeScript definitions and coverage.
- - **Dependencies**: Updated all dependencies.
-
- ### Bug Fixes
-
- - Fixed various import issues and build warnings.
- - Corrected explicit file extensions in imports.
-
-### Patch Changes
-
-- Updated dependencies [4406c0c]
- - @node-minify/utils@10.0.0
-
-## 10.0.0-next.0
-
-### Major Changes
-
-- 4406c0c: Bump version 10.0.0 next
-
-### Patch Changes
-
-- Updated dependencies [4406c0c]
- - @node-minify/utils@10.0.0-next.0
-
-## 9.0.1
-
-### Patch Changes
-
-- c4fcf63: Fixing packages exports and mkdirp import
-- Updated dependencies [c4fcf63]
- - @node-minify/utils@9.0.1
-
-## 9.0.0
-
-### Major Changes
-
-- 7ab9745: Version 9.0.0
-
- - feat(node): remove node 16, add node 18 (#2092) (c9acdaa4a9906d4019d9381129d66235f3139198)
- - feat(biome): add biome (#2113) (50e9ec46c11c218453de743bed2defa9a83ace7b)
- - fix(yui): fixing yui tests (dd8629712c03b0ac1fe2b94acbb95bc896f8f22f)
- - bump dependencies
-
-### Patch Changes
-
-- Updated dependencies [7ab9745]
- - @node-minify/utils@9.0.0
diff --git a/packages/crass/LICENSE b/packages/crass/LICENSE
deleted file mode 100644
index aed47f87e..000000000
--- a/packages/crass/LICENSE
+++ /dev/null
@@ -1,21 +0,0 @@
-MIT License
-
-Copyright (c) 2011-2026 Rodolphe Stoclin
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
diff --git a/packages/crass/README.md b/packages/crass/README.md
deleted file mode 100644
index a8e65f3a3..000000000
--- a/packages/crass/README.md
+++ /dev/null
@@ -1,47 +0,0 @@
-
-
-A very light minifier Node.js module.
-
-
-
-
-
-
-
-
-
-# crass
-
-> **DEPRECATED**: crass is no longer maintained (last update ~2018).
-> Please use [`@node-minify/cssnano`](https://github.com/srod/node-minify/tree/main/packages/cssnano) or [`@node-minify/clean-css`](https://github.com/srod/node-minify/tree/main/packages/clean-css) instead.
-
-`crass` is a plugin for [`node-minify`](https://github.com/srod/node-minify)
-
-It allow you to compress CSS files.
-
-## Installation
-
-```bash
-npm install @node-minify/core @node-minify/crass
-```
-
-## Usage
-
-```js
-import { minify } from '@node-minify/core';
-import { crass } from '@node-minify/crass';
-
-await minify({
- compressor: crass,
- input: 'foo.css',
- output: 'bar.css'
-});
-```
-
-## Documentation
-
-Visit https://node-minify.2clics.net/compressors/crass.html for full documentation
-
-## License
-
-[MIT](https://github.com/srod/node-minify/blob/main/LICENSE)
diff --git a/packages/crass/__tests__/crass-error.test.ts b/packages/crass/__tests__/crass-error.test.ts
deleted file mode 100644
index 63cc80475..000000000
--- a/packages/crass/__tests__/crass-error.test.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-/*!
- * node-minify
- * Copyright (c) 2011-2026 Rodolphe Stoclin
- * MIT Licensed
- */
-
-import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
-
-describe("Package: crass error handling", () => {
- beforeEach(() => {
- vi.resetModules();
- });
-
- afterEach(() => {
- vi.doUnmock("crass");
- });
-
- test("should wrap parse errors", async () => {
- vi.doMock("crass", () => ({
- default: {
- parse: () => {
- throw new Error("Parse error");
- },
- },
- }));
-
- const { crass } = await import("../src/index.ts");
-
- await expect(
- crass({ settings: {} as any, content: ".a { color: red; }" })
- ).rejects.toThrow("crass minification failed: Parse error");
- });
-});
diff --git a/packages/crass/__tests__/crass.test.ts b/packages/crass/__tests__/crass.test.ts
deleted file mode 100644
index c598e4788..000000000
--- a/packages/crass/__tests__/crass.test.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-/*!
- * node-minify
- * Copyright (c) 2011-2026 Rodolphe Stoclin
- * MIT Licensed
- */
-
-import { describe } from "vitest";
-import { runOneTest, tests } from "../../../tests/fixtures.ts";
-import { crass } from "../src/index.ts";
-
-const compressorLabel = "crass";
-const compressor = crass;
-
-describe("Package: crass", async () => {
- if (!tests.commoncss) {
- throw new Error("Tests not found");
- }
-
- // Run commoncss tests
- for (const options of tests.commoncss) {
- await runOneTest({ options, compressorLabel, compressor });
- }
-});
diff --git a/packages/crass/package.json b/packages/crass/package.json
deleted file mode 100644
index 447bdad2c..000000000
--- a/packages/crass/package.json
+++ /dev/null
@@ -1,62 +0,0 @@
-{
- "name": "@node-minify/crass",
- "version": "10.5.0",
- "deprecated": "crass is no longer maintained. Please use @node-minify/cssnano or @node-minify/clean-css instead.",
- "description": "crass plugin for @node-minify (DEPRECATED - use @node-minify/cssnano)",
- "keywords": [
- "compressor",
- "minify",
- "minifier",
- "crass"
- ],
- "author": "Rodolphe Stoclin ",
- "homepage": "https://github.com/srod/node-minify/tree/main/packages/crass#readme",
- "license": "MIT",
- "type": "module",
- "engines": {
- "node": ">=20.0.0"
- },
- "directories": {
- "lib": "dist",
- "test": "__tests__"
- },
- "types": "./dist/index.d.ts",
- "main": "./dist/index.js",
- "exports": {
- "types": "./dist/index.d.ts",
- "default": "./dist/index.js"
- },
- "sideEffects": false,
- "files": [
- "dist/**/*"
- ],
- "publishConfig": {
- "access": "public"
- },
- "repository": {
- "type": "git",
- "url": "git+https://github.com/srod/node-minify.git"
- },
- "bugs": {
- "url": "https://github.com/srod/node-minify/issues"
- },
- "scripts": {
- "build": "tsdown src/index.ts",
- "check-exports": "attw --pack . --profile esm-only",
- "format:check": "biome check .",
- "lint": "biome lint .",
- "prepublishOnly": "bun run build",
- "test": "vitest run",
- "test:coverage": "vitest run --coverage",
- "test:watch": "vitest",
- "typecheck": "tsc --noEmit",
- "dev": "tsdown src/index.ts --watch"
- },
- "dependencies": {
- "@node-minify/utils": "workspace:*",
- "crass": "0.12.3"
- },
- "devDependencies": {
- "@node-minify/types": "workspace:*"
- }
-}
diff --git a/packages/crass/src/index.ts b/packages/crass/src/index.ts
deleted file mode 100644
index 0aa92410d..000000000
--- a/packages/crass/src/index.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-/*!
- * node-minify
- * Copyright (c) 2011-2026 Rodolphe Stoclin
- * MIT Licensed
- */
-
-import type { CompressorResult, MinifierOptions } from "@node-minify/types";
-import {
- ensureStringContent,
- warnDeprecation,
- wrapMinificationError,
-} from "@node-minify/utils";
-import minify from "crass";
-
-/**
- * Minifies CSS content using the crass library.
- *
- * @deprecated crass is no longer maintained. Use @node-minify/cssnano or @node-minify/clean-css instead.
- * @param content - Input CSS content to minify
- * @returns An object whose `code` property contains the minified CSS
- */
-export async function crass({
- content,
-}: MinifierOptions): Promise {
- const contentStr = ensureStringContent(content, "crass");
-
- warnDeprecation(
- "crass",
- "crass is no longer maintained. " +
- "Please migrate to @node-minify/cssnano or @node-minify/clean-css."
- );
-
- try {
- const code = minify.parse(contentStr).optimize().toString();
- return { code };
- } catch (error) {
- throw wrapMinificationError("crass", error);
- }
-}
diff --git a/packages/crass/src/types.d.ts b/packages/crass/src/types.d.ts
deleted file mode 100644
index c37428d64..000000000
--- a/packages/crass/src/types.d.ts
+++ /dev/null
@@ -1 +0,0 @@
-declare module "crass";
diff --git a/packages/crass/tsconfig.json b/packages/crass/tsconfig.json
deleted file mode 100644
index 8ffe5db9f..000000000
--- a/packages/crass/tsconfig.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "extends": "../../tsconfig.json",
- "compilerOptions": {
- "outDir": "./dist",
- "rootDir": "./src"
- },
- "include": ["src/**/*"]
-}
diff --git a/packages/crass/vitest.config.ts b/packages/crass/vitest.config.ts
deleted file mode 100644
index d7b613c2a..000000000
--- a/packages/crass/vitest.config.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-import { defineProject } from "vitest/config";
-
-export default defineProject({});
diff --git a/packages/cssnano/package.json b/packages/cssnano/package.json
index fafa55690..8cfac907c 100644
--- a/packages/cssnano/package.json
+++ b/packages/cssnano/package.json
@@ -13,7 +13,7 @@
"license": "MIT",
"type": "module",
"engines": {
- "node": ">=20.0.0"
+ "node": ">=22.0.0"
},
"directories": {
"lib": "dist",
@@ -54,7 +54,7 @@
"dependencies": {
"@node-minify/utils": "workspace:*",
"cssnano": "^7.1.3",
- "postcss": "8.5.6"
+ "postcss": "8.5.26"
},
"devDependencies": {
"@node-minify/types": "workspace:*"
diff --git a/packages/csso/package.json b/packages/csso/package.json
index 51e25b112..0e48b9b00 100644
--- a/packages/csso/package.json
+++ b/packages/csso/package.json
@@ -13,7 +13,7 @@
"license": "MIT",
"type": "module",
"engines": {
- "node": ">=20.0.0"
+ "node": ">=22.0.0"
},
"directories": {
"lib": "dist",
diff --git a/packages/esbuild/package.json b/packages/esbuild/package.json
index f84683c65..78ae0b0eb 100644
--- a/packages/esbuild/package.json
+++ b/packages/esbuild/package.json
@@ -13,7 +13,7 @@
"license": "MIT",
"type": "module",
"engines": {
- "node": ">=20.0.0"
+ "node": ">=22.0.0"
},
"directories": {
"lib": "dist",
@@ -53,7 +53,7 @@
},
"dependencies": {
"@node-minify/utils": "workspace:*",
- "esbuild": "^0.27.3"
+ "esbuild": "^0.28.0"
},
"devDependencies": {
"@node-minify/types": "workspace:*"
diff --git a/packages/google-closure-compiler/__tests__/google-closure-compiler.test.ts b/packages/google-closure-compiler/__tests__/google-closure-compiler.test.ts
index aa478e0c7..cdd9487ac 100644
--- a/packages/google-closure-compiler/__tests__/google-closure-compiler.test.ts
+++ b/packages/google-closure-compiler/__tests__/google-closure-compiler.test.ts
@@ -4,28 +4,19 @@
* MIT Licensed
*/
+import { execFileSync } from "node:child_process";
+import path from "node:path";
+import { fileURLToPath, pathToFileURL } from "node:url";
import type { Settings } from "@node-minify/types";
-import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
+import { describe, expect, test } from "vitest";
import { filesJS } from "../../../tests/files-path.ts";
import { runOneTest, tests } from "../../../tests/fixtures.ts";
import { minify } from "../../core/src/index.ts";
-import { gcc } from "../src/index.ts";
-
-const mocks = vi.hoisted(() => ({
- runCommandLine: vi.fn(),
- original: null as typeof import("@node-minify/run").runCommandLine | null,
-}));
-
-vi.mock("@node-minify/run", async (importOriginal) => {
- const actual = await importOriginal();
- mocks.original = actual.runCommandLine;
- mocks.runCommandLine.mockImplementation(actual.runCommandLine);
- return {
- ...actual,
- runCommandLine: mocks.runCommandLine,
- };
-});
+import { applyOptions, gcc } from "../src/index.ts";
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const packageRoot = path.resolve(__dirname, "..");
+const distEntry = path.join(packageRoot, "dist", "index.js");
const compressorLabel = "google-closure-compiler";
const compressor = gcc;
@@ -95,26 +86,142 @@ describe("Package: google-closure-compiler", async () => {
expect(result).not.toBeNull();
});
- describe("Error handling", () => {
- beforeAll(() => {
- mocks.runCommandLine.mockResolvedValue(undefined);
+ test("normalizes object flag values to KEY=value entries", () => {
+ // An object define must become ["DEBUG=false"], not "[object Object]".
+ expect(applyOptions({}, { define: { DEBUG: false } })).toEqual({
+ define: ["DEBUG=false"],
+ });
+ // Strings, booleans, and string arrays pass through unchanged.
+ expect(
+ applyOptions(
+ {},
+ {
+ compilation_level: "SIMPLE",
+ rewrite_polyfills: true,
+ define: ["A=1", "B=2"],
+ }
+ )
+ ).toEqual({
+ compilation_level: "SIMPLE",
+ rewrite_polyfills: true,
+ define: ["A=1", "B=2"],
+ });
+ // Unknown flags are dropped.
+ expect(applyOptions({}, { not_a_flag: "x" })).toEqual({});
+ });
+
+ test("quotes string defines so they are not coerced to boolean/number", () => {
+ // A string value must stay a string literal: { NAME: "false" } must not
+ // define the boolean false, and { VERSION: "5" } must not define 5.
+ expect(
+ applyOptions({}, { define: { NAME: "false", VERSION: "5" } })
+ ).toEqual({
+ define: ['NAME="false"', 'VERSION="5"'],
+ });
+ // Numbers stay unquoted; mixed types are each handled by their kind.
+ expect(
+ applyOptions({}, { define: { LEVEL: 5, DEBUG: true, TAG: "rc" } })
+ ).toEqual({
+ define: ["LEVEL=5", "DEBUG=true", 'TAG="rc"'],
});
+ });
+
+ test("skips null and nested object/array define values", () => {
+ // Only string/number/boolean entries are emitted; the rest are dropped.
+ expect(
+ applyOptions(
+ {},
+ {
+ define: {
+ KEEP: "x",
+ NIL: null,
+ NESTED: { a: 1 },
+ LIST: [1, 2],
+ },
+ }
+ )
+ ).toEqual({ define: ['KEEP="x"'] });
+ // An object with no usable entries drops the flag entirely.
+ expect(applyOptions({}, { define: { NIL: null } })).toEqual({});
+ });
+
+ test("drops flag values that are not string/boolean/array/object", () => {
+ // Array with non-string entries is not a valid repeated-flag list.
+ expect(applyOptions({}, { language_in: [1, 2] })).toEqual({});
+ // A bare number is neither a flag value nor a KEY=value object.
+ expect(applyOptions({}, { language_in: 5 })).toEqual({});
+ // null is dropped (typeof null === "object" but value === null).
+ expect(applyOptions({}, { language_in: null })).toEqual({});
+ });
- afterAll(() => {
- if (mocks.original) {
- mocks.runCommandLine.mockImplementation(mocks.original);
- }
+ test("should compress in-memory content", async (): Promise => {
+ const result = await gcc({
+ settings: { compressor: gcc },
+ content: "var x = 1; var y = 2;",
});
- test("should throw when gcc returns empty result", async () => {
- const settings: Settings = {
- compressor: gcc,
- input: filesJS.oneFile,
- output: filesJS.fileJSOut,
- };
- await expect(
- gcc({ settings, content: "var x = 1;" })
- ).rejects.toThrow("Google Closure Compiler failed: empty result");
+ expect(result.code).toBeDefined();
+ expect(typeof result.code).toBe("string");
+ expect(result.code.length).toBeGreaterThan(0);
+ });
+
+ test("should load the built package in Node", () => {
+ execFileSync("bun", ["run", "build"], {
+ cwd: packageRoot,
+ stdio: "pipe",
});
+
+ expect(() => {
+ execFileSync(
+ "node",
+ [
+ "--input-type=module",
+ "-e",
+ `await import(${JSON.stringify(pathToFileURL(distEntry).href)});`,
+ ],
+ {
+ cwd: packageRoot,
+ stdio: "pipe",
+ }
+ );
+ }).not.toThrow();
+ }, 60000);
+
+ test("should honor the configured buffer limit", async () => {
+ await expect(
+ gcc({
+ settings: { compressor: gcc, buffer: 1 },
+ content: "var x = 1; var y = 2;",
+ })
+ ).rejects.toThrow("maxBuffer exceeded");
+ }, 60000);
+
+ test("should throw on invalid JavaScript", async () => {
+ await expect(
+ gcc({
+ settings: { compressor: gcc },
+ content: "function( {{{ invalid",
+ })
+ ).rejects.toThrow();
});
+
+ test("should timeout with very short timeout", async () => {
+ await expect(
+ gcc({
+ settings: { compressor: gcc, timeout: 1 },
+ content:
+ "var x = 1; var y = 2; var z = 3; function foo() { return x + y + z; }",
+ })
+ ).rejects.toThrow("timed out");
+ }, 60000);
+
+ test("should suppress stderr details when silence is true", async () => {
+ const promise = gcc({
+ settings: { compressor: gcc, silence: true },
+ content: "function( {{{ invalid",
+ });
+ // Must reject; with silence the message keeps the exit code but drops stderr detail.
+ await expect(promise).rejects.toThrow("exited with code");
+ await expect(promise).rejects.not.toThrow("ERROR -");
+ }, 60000);
});
diff --git a/packages/google-closure-compiler/__tests__/runner-edge-cases.test.ts b/packages/google-closure-compiler/__tests__/runner-edge-cases.test.ts
new file mode 100644
index 000000000..1e3e6eddd
--- /dev/null
+++ b/packages/google-closure-compiler/__tests__/runner-edge-cases.test.ts
@@ -0,0 +1,183 @@
+/*!
+ * node-minify
+ * Copyright (c) 2011-2026 Rodolphe Stoclin
+ * MIT Licensed
+ */
+
+import { afterEach, describe, expect, test, vi } from "vitest";
+
+/**
+ * Minimal event-emitter surface used by the fake child process.
+ */
+interface MinimalEmitter {
+ on(event: string, handler: (...args: unknown[]) => void): unknown;
+ emit(event: string, ...args: unknown[]): boolean;
+}
+
+/**
+ * Minimal shape of the fake child process handed to a scenario callback.
+ */
+interface FakeChild {
+ stdout: MinimalEmitter;
+ stderr: MinimalEmitter;
+ stdin: MinimalEmitter | null;
+ kill: () => void;
+ emit: (event: string, ...args: unknown[]) => boolean;
+}
+
+type RunCallback = (exitCode: number, stdOut: string, stdErr: string) => void;
+
+// Hoisted control surface for the mocked google-closure-compiler module. Each
+// test sets `onRun` (what the fake child does once gcc has attached its
+// listeners and written stdin) and `stdinEnabled` (whether the child exposes a
+// stdin stream, to exercise the "no stdin" branch).
+const mock = vi.hoisted(() => ({
+ onRun: null as null | ((child: FakeChild, callback: RunCallback) => void),
+ stdinEnabled: true,
+}));
+
+vi.mock("google-closure-compiler", () => {
+ class Emitter {
+ private handlers: Record void)[]> = {};
+ on(event: string, handler: (...a: unknown[]) => void): this {
+ (this.handlers[event] ??= []).push(handler);
+ return this;
+ }
+ emit(event: string, ...args: unknown[]): boolean {
+ const hs = this.handlers[event] ?? [];
+ for (const h of hs) h(...args);
+ return hs.length > 0;
+ }
+ }
+
+ class FakeStdin extends Emitter {
+ write(): void {}
+ end(): void {}
+ }
+
+ class FakeChildProcess extends Emitter {
+ stdout = new Emitter();
+ stderr = new Emitter();
+ stdin = mock.stdinEnabled ? new FakeStdin() : null;
+ kill(): void {}
+ }
+
+ class MockCompiler {
+ run(callback: RunCallback): FakeChildProcess {
+ const child = new FakeChildProcess();
+ // Defer until gcc has wired up its listeners + written stdin.
+ setImmediate(() =>
+ mock.onRun?.(child as unknown as FakeChild, callback)
+ );
+ return child;
+ }
+ }
+
+ return { default: { compiler: MockCompiler } };
+});
+
+import { gcc } from "../src/index.ts";
+
+const baseSettings = { compressor: gcc } as const;
+
+afterEach(() => {
+ mock.onRun = null;
+ mock.stdinEnabled = true;
+});
+
+describe("Package: google-closure-compiler (runCompiler edge paths)", () => {
+ test("resolves with the compiler stdout", async () => {
+ mock.onRun = (_child, cb) => cb(0, "var a=1;", "");
+ const result = await gcc({
+ settings: baseSettings,
+ content: "var a = 1;",
+ });
+ expect(result.code).toBe("var a=1;");
+ });
+
+ test("resolves with empty output when the compiler emits an empty string", async () => {
+ // Empty output is valid (e.g. dead-code elimination); the shared
+ // validateMinifyResult / allowEmptyOutput layer decides what to do with it.
+ mock.onRun = (_child, cb) => cb(0, "", "");
+ const result = await gcc({
+ settings: baseSettings,
+ content: "var a = 1;",
+ });
+ expect(result.code).toBe("");
+ });
+
+ test("rejects when the compiler returns a non-string result", async () => {
+ mock.onRun = (_child, cb) =>
+ (cb as (code: number, out: unknown, err: string) => void)(
+ 0,
+ null,
+ ""
+ );
+ await expect(
+ gcc({ settings: baseSettings, content: "var a = 1;" })
+ ).rejects.toThrow("invalid result");
+ });
+
+ test("rejects on a child process 'error' event", async () => {
+ mock.onRun = (child) => {
+ child.emit("error", new Error("spawn ENOENT"));
+ };
+ await expect(
+ gcc({ settings: baseSettings, content: "var a = 1;" })
+ ).rejects.toThrow("process error: spawn ENOENT");
+ });
+
+ test("rejects on a stdin 'error' event", async () => {
+ mock.onRun = (child) => {
+ child.stdin?.emit("error", new Error("EPIPE"));
+ };
+ await expect(
+ gcc({ settings: baseSettings, content: "var a = 1;" })
+ ).rejects.toThrow("stdin error: EPIPE");
+ });
+
+ test("rejects when stdout exceeds the buffer limit (Buffer chunk)", async () => {
+ mock.onRun = (child) => {
+ child.stdout.emit("data", Buffer.alloc(64));
+ };
+ await expect(
+ gcc({
+ settings: { compressor: gcc, buffer: 8 },
+ content: "var a = 1;",
+ })
+ ).rejects.toThrow("stdout maxBuffer exceeded");
+ });
+
+ test("rejects when stdout exceeds the buffer limit (string chunk)", async () => {
+ mock.onRun = (child) => {
+ child.stdout.emit("data", "x".repeat(64));
+ };
+ await expect(
+ gcc({
+ settings: { compressor: gcc, buffer: 8 },
+ content: "var a = 1;",
+ })
+ ).rejects.toThrow("stdout maxBuffer exceeded");
+ });
+
+ test("settles only once when multiple errors fire", async () => {
+ mock.onRun = (child) => {
+ child.emit("error", new Error("first"));
+ // The second rejection must be ignored (already settled).
+ child.stdin?.emit("error", new Error("second"));
+ };
+ await expect(
+ gcc({ settings: baseSettings, content: "var a = 1;" })
+ ).rejects.toThrow("process error: first");
+ });
+
+ test("resolves when the child exposes no stdin stream", async () => {
+ mock.stdinEnabled = false;
+ mock.onRun = (_child, cb) => cb(0, "var b=2;", "");
+ const result = await gcc({
+ settings: baseSettings,
+ content: "var b = 2;",
+ });
+ expect(result.code).toBe("var b=2;");
+ });
+});
diff --git a/packages/google-closure-compiler/package.json b/packages/google-closure-compiler/package.json
index 66e93c6ce..69e794f27 100644
--- a/packages/google-closure-compiler/package.json
+++ b/packages/google-closure-compiler/package.json
@@ -14,7 +14,7 @@
"license": "MIT",
"type": "module",
"engines": {
- "node": ">=20.0.0"
+ "node": ">=22.0.0"
},
"directories": {
"lib": "dist",
@@ -53,9 +53,8 @@
"dev": "tsdown src/index.ts --watch"
},
"dependencies": {
- "@node-minify/run": "workspace:*",
"@node-minify/utils": "workspace:*",
- "google-closure-compiler-java": "20240317.0.0"
+ "google-closure-compiler": "20240317.0.0"
},
"devDependencies": {
"@node-minify/types": "workspace:*"
diff --git a/packages/google-closure-compiler/src/index.ts b/packages/google-closure-compiler/src/index.ts
index 13eaf9830..3252ec5e1 100644
--- a/packages/google-closure-compiler/src/index.ts
+++ b/packages/google-closure-compiler/src/index.ts
@@ -4,14 +4,9 @@
* MIT Licensed
*/
-import { runCommandLine } from "@node-minify/run";
import type { CompressorResult, MinifierOptions } from "@node-minify/types";
-import {
- buildArgs,
- ensureStringContent,
- toBuildArgsOptions,
-} from "@node-minify/utils";
-import compilerPath from "google-closure-compiler-java";
+import { ensureStringContent, wrapMinificationError } from "@node-minify/utils";
+import googleClosureCompiler from "google-closure-compiler";
// the allowed flags, taken from https://github.com/google/closure-compiler/wiki/Flags-and-Options
const allowedFlags = [
@@ -36,6 +31,11 @@ const allowedFlags = [
"warning_level",
];
+// Default per-stream cap for compiler stdout/stderr. Generous enough for large
+// minified bundles while still bounding runaway output. Set `settings.buffer` to
+// 0 to disable the limit entirely.
+const DEFAULT_MAX_BUFFER = 100 * 1024 * 1024;
+
/**
* Minifies JavaScript using the Google Closure Compiler.
*
@@ -49,71 +49,229 @@ export async function gcc({
}: MinifierOptions): Promise {
const contentStr = ensureStringContent(content, "google-closure-compiler");
- const options = applyOptions({}, settings?.options ?? {});
-
- const result = await runCommandLine({
- args: gccCommand(options),
- data: contentStr,
- maxBuffer: settings?.buffer,
- timeout: settings?.timeout,
- silence: settings?.silence,
- });
+ const flags = applyOptions({}, settings?.options ?? {});
+ const maxBuffer = settings?.buffer;
+ const timeout = settings?.timeout;
+ const silence = settings?.silence ?? false;
- if (typeof result !== "string") {
- throw new Error("Google Closure Compiler failed: empty result");
+ try {
+ const result = await runCompiler(
+ flags,
+ contentStr,
+ maxBuffer,
+ timeout,
+ silence
+ );
+ return { code: result };
+ } catch (error) {
+ throw wrapMinificationError("google-closure-compiler", error);
}
-
- return { code: result };
}
/**
- * Adds any valid options passed in the options parameters to the flags parameter and returns the flags object.
- * @param flags the flags object to add options to
- * @param options the options object to add to the flags object
- * @returns the flags object with the options added
+ * Runs the Google Closure Compiler with the given flags, piping source code via stdin.
+ *
+ * @param flags - Compiler flags object (e.g. `{ compilation_level: "SIMPLE" }`)
+ * @param source - JavaScript source code to compile
+ * @param maxBuffer - Maximum combined stdout/stderr buffer per stream in bytes
+ * @param timeout - Optional timeout in milliseconds; kills the compiler process if exceeded
+ * @param silence - When true, suppresses stderr warnings in error messages
+ * @returns The compiled output string
*/
+function runCompiler(
+ flags: Flags,
+ source: string,
+ maxBuffer = DEFAULT_MAX_BUFFER,
+ timeout?: number,
+ silence?: boolean
+): Promise {
+ return new Promise((resolve, reject) => {
+ let stdoutLength = 0;
+ let stderrLength = 0;
+ let settled = false;
+ let timeoutId: ReturnType | undefined;
+ const { compiler: Compiler } = googleClosureCompiler;
+
+ const clearTimer = () => {
+ if (timeoutId !== undefined) {
+ clearTimeout(timeoutId);
+ }
+ };
+ const resolveOnce = (value: string) => {
+ if (settled) return;
+ settled = true;
+ clearTimer();
+ resolve(value);
+ };
+ const rejectOnce = (error: Error) => {
+ if (settled) return;
+ settled = true;
+ clearTimer();
+ reject(error);
+ };
+ const trackChunk = (
+ chunk: Buffer | string,
+ stream: "stdout" | "stderr"
+ ) => {
+ const size = Buffer.isBuffer(chunk)
+ ? chunk.length
+ : Buffer.byteLength(chunk);
+
+ if (stream === "stdout") {
+ stdoutLength += size;
+ if (maxBuffer > 0 && stdoutLength > maxBuffer) {
+ childProcess.kill();
+ rejectOnce(new Error("stdout maxBuffer exceeded"));
+ }
+ return;
+ }
+
+ stderrLength += size;
+ if (maxBuffer > 0 && stderrLength > maxBuffer) {
+ childProcess.kill();
+ rejectOnce(new Error("stderr maxBuffer exceeded"));
+ }
+ };
+
+ const childProcess = new Compiler(flags).run(
+ (exitCode: number, stdOut: string, stdErr: string) => {
+ if (settled) return;
+
+ if (exitCode !== 0) {
+ const detail = silence ? "" : `: ${stdErr}`;
+ rejectOnce(
+ new Error(
+ `Google Closure Compiler exited with code ${exitCode}${detail}`
+ )
+ );
+ return;
+ }
+
+ if (typeof stdOut !== "string") {
+ rejectOnce(
+ new Error(
+ "Google Closure Compiler failed: invalid result"
+ )
+ );
+ return;
+ }
+
+ resolveOnce(stdOut);
+ }
+ );
+
+ childProcess.on("error", (error) => {
+ rejectOnce(
+ new Error(
+ `Google Closure Compiler process error: ${error.message}`
+ )
+ );
+ });
+ childProcess.stdout?.on("data", (chunk) => {
+ trackChunk(chunk, "stdout");
+ });
+ childProcess.stderr?.on("data", (chunk) => {
+ trackChunk(chunk, "stderr");
+ });
+ childProcess.stdin?.on("error", (error) => {
+ rejectOnce(
+ new Error(
+ `Google Closure Compiler stdin error: ${error.message}`
+ )
+ );
+ });
+
+ if (timeout !== undefined && timeout > 0) {
+ timeoutId = setTimeout(() => {
+ childProcess.kill();
+ rejectOnce(
+ new Error(
+ `Google Closure Compiler timed out after ${String(timeout)}ms`
+ )
+ );
+ }, timeout);
+ }
+
+ if (childProcess.stdin) {
+ childProcess.stdin.write(source);
+ childProcess.stdin.end();
+ }
+ });
+}
+
+type FlagValue = string | boolean | string[];
type Flags = {
- [key: string]: string | boolean | Record;
+ [key: string]: FlagValue;
};
+
/**
- * Merge allowed user-provided options into the given flags object.
+ * Normalize a user-supplied option value into a Closure-compatible flag value.
*
- * Filters `options` to keys listed in `allowedFlags` and assigns values that are strings, booleans, or plain (non-array) objects into `flags`.
+ * Strings and booleans pass through. Repeated flags like `define` are expressed
+ * as `KEY=value` entries: an object such as `{ DEBUG: false }` becomes
+ * `["DEBUG=false"]`. String values are quoted (`{ NAME: "false" }` becomes
+ * `['NAME="false"']`) so the compiler keeps them as string literals instead of
+ * coercing them to a boolean or number. Boolean and number values are emitted
+ * unquoted; `null`, `undefined`, and nested object/array values are skipped. A
+ * `string[]` is kept as-is. Anything else is dropped.
*
- * @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`.
+ * Without this, an object value reaches the compiler as `--define=[object Object]`.
+ *
+ * @param value - The raw option value to normalize
+ * @returns A string, boolean, or string array flag value, or undefined to skip
*/
-function applyOptions(flags: Flags, options?: Record): Flags {
- if (!options || Object.keys(options).length === 0) {
- return flags;
+function normalizeFlagValue(value: unknown): FlagValue | undefined {
+ if (typeof value === "string" || typeof value === "boolean") {
+ return value;
}
- Object.keys(options)
- .filter((option) => allowedFlags.indexOf(option) > -1)
- .forEach((option) => {
- const value = options[option];
- if (
- typeof value === "string" ||
- typeof value === "boolean" ||
- (typeof value === "object" && !Array.isArray(value))
- ) {
- flags[option] = value as
- | string
- | boolean
- | Record;
+ if (Array.isArray(value)) {
+ return value.every((entry) => typeof entry === "string")
+ ? (value as string[])
+ : undefined;
+ }
+ if (typeof value === "object" && value !== null) {
+ const entries = Object.entries(
+ value as Record
+ ).flatMap(([key, entry]) => {
+ if (typeof entry === "string") {
+ // Quote strings so the compiler keeps them as string literals;
+ // an unquoted `false` or `5` would become a boolean or number.
+ return [`${key}=${JSON.stringify(entry)}`];
+ }
+ if (typeof entry === "boolean" || typeof entry === "number") {
+ return [`${key}=${String(entry)}`];
}
+ // Skip null, undefined, and nested object/array values.
+ return [];
});
- return flags;
+ return entries.length > 0 ? entries : undefined;
+ }
+ return undefined;
}
/**
- * GCC command line.
- * @param options the options to pass to GCC
- * @returns the command line arguments to pass to GCC
+ * Merge allowed user-provided options into the given flags object.
+ *
+ * Filters `options` to keys listed in `allowedFlags` and normalizes each value
+ * (see {@link normalizeFlagValue}) before assigning it 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 normalize to undefined are ignored.
+ * @returns The same `flags` object after applying valid entries from `options`.
*/
-
-function gccCommand(options: Record) {
- return ["-jar", compilerPath].concat(
- buildArgs(toBuildArgsOptions(options))
- );
+export function applyOptions(
+ flags: Flags,
+ options?: Record
+): Flags {
+ if (!options || Object.keys(options).length === 0) {
+ return flags;
+ }
+ for (const option of Object.keys(options)) {
+ if (allowedFlags.indexOf(option) === -1) continue;
+ const value = normalizeFlagValue(options[option]);
+ if (value !== undefined) {
+ flags[option] = value;
+ }
+ }
+ return flags;
}
diff --git a/packages/google-closure-compiler/src/types.d.ts b/packages/google-closure-compiler/src/types.d.ts
index 2b071f54e..6bca91847 100644
--- a/packages/google-closure-compiler/src/types.d.ts
+++ b/packages/google-closure-compiler/src/types.d.ts
@@ -1 +1,27 @@
-declare module "google-closure-compiler-java";
+/*!
+ * node-minify
+ * Copyright (c) 2011-2026 Rodolphe Stoclin
+ * MIT Licensed
+ */
+
+declare module "google-closure-compiler" {
+ import type { ChildProcess } from "node:child_process";
+
+ export class compiler {
+ constructor(
+ args: Record | string[]
+ );
+ commandArguments: string[];
+ javaPath: string;
+ JAR_PATH: string;
+ run(
+ callback: (exitCode: number, stdOut: string, stdErr: string) => void
+ ): ChildProcess;
+ }
+
+ const googleClosureCompiler: {
+ compiler: typeof compiler;
+ };
+
+ export default googleClosureCompiler;
+}
diff --git a/packages/html-minifier/package.json b/packages/html-minifier/package.json
index bb571718e..b4e2d705b 100644
--- a/packages/html-minifier/package.json
+++ b/packages/html-minifier/package.json
@@ -13,7 +13,7 @@
"license": "MIT",
"type": "module",
"engines": {
- "node": ">=20.0.0"
+ "node": ">=22.0.0"
},
"directories": {
"lib": "dist",
diff --git a/packages/imagemin/package.json b/packages/imagemin/package.json
index 572f2be14..6f5337538 100644
--- a/packages/imagemin/package.json
+++ b/packages/imagemin/package.json
@@ -18,7 +18,7 @@
"license": "MIT",
"type": "module",
"engines": {
- "node": ">=20.0.0"
+ "node": ">=22.0.0"
},
"directories": {
"lib": "dist",
diff --git a/packages/jsonminify/package.json b/packages/jsonminify/package.json
index a85bf7332..89e3ebf52 100644
--- a/packages/jsonminify/package.json
+++ b/packages/jsonminify/package.json
@@ -14,7 +14,7 @@
"license": "MIT",
"type": "module",
"engines": {
- "node": ">=20.0.0"
+ "node": ">=22.0.0"
},
"directories": {
"lib": "dist",
diff --git a/packages/lightningcss/package.json b/packages/lightningcss/package.json
index 3762fbff7..82f3d03f8 100644
--- a/packages/lightningcss/package.json
+++ b/packages/lightningcss/package.json
@@ -14,7 +14,7 @@
"license": "MIT",
"type": "module",
"engines": {
- "node": ">=20.0.0"
+ "node": ">=22.0.0"
},
"directories": {
"lib": "dist",
diff --git a/packages/minify-html/__tests__/minify-html-error.test.ts b/packages/minify-html/__tests__/minify-html-error.test.ts
index 8fa4560df..81e2455f8 100644
--- a/packages/minify-html/__tests__/minify-html-error.test.ts
+++ b/packages/minify-html/__tests__/minify-html-error.test.ts
@@ -16,9 +16,13 @@ describe("Package: minify-html error handling", () => {
});
test("should wrap minification errors", async () => {
+ // Mirrors the real module shape: @minify-html/node is CommonJS, so its
+ // exports are reached through the default export.
vi.doMock("@minify-html/node", () => ({
- minify: () => {
- throw new Error("Invalid HTML syntax");
+ default: {
+ minify: () => {
+ throw new Error("Invalid HTML syntax");
+ },
},
}));
@@ -33,4 +37,12 @@ describe("Package: minify-html error handling", () => {
"minify-html minification failed: Invalid HTML syntax"
);
});
+
+ test("should call minify through the CommonJS default export", async () => {
+ // Regression guard: importing `minify` as a named export resolves to
+ // undefined under Node's ESM loader, which shipped broken in v10.
+ const lib = await import("@minify-html/node");
+
+ expect(typeof lib.default.minify).toBe("function");
+ });
});
diff --git a/packages/minify-html/package.json b/packages/minify-html/package.json
index a9052dd52..1bf209a22 100644
--- a/packages/minify-html/package.json
+++ b/packages/minify-html/package.json
@@ -15,7 +15,7 @@
"license": "MIT",
"type": "module",
"engines": {
- "node": ">=20.0.0"
+ "node": ">=22.0.0"
},
"directories": {
"lib": "dist",
diff --git a/packages/minify-html/src/index.ts b/packages/minify-html/src/index.ts
index 8b88c4bdd..a61f4b710 100644
--- a/packages/minify-html/src/index.ts
+++ b/packages/minify-html/src/index.ts
@@ -4,6 +4,9 @@
* MIT Licensed
*/
+// @minify-html/node is a CommonJS native addon: Node's ESM loader exposes it
+// only through the default export, so named imports fail at runtime.
+import minifyHtmlLib from "@minify-html/node";
import type { CompressorResult, MinifierOptions } from "@node-minify/types";
import {
ensureStringContent,
@@ -39,7 +42,6 @@ export async function minifyHtml({
const contentStr = ensureStringContent(content, "minify-html");
try {
- const minifyHtmlLib = await import("@minify-html/node");
const options = { ...defaultOptions, ...settings?.options };
const inputBuffer = Buffer.from(contentStr);
diff --git a/packages/no-compress/package.json b/packages/no-compress/package.json
index 90c267377..8547c4ad6 100644
--- a/packages/no-compress/package.json
+++ b/packages/no-compress/package.json
@@ -10,7 +10,7 @@
"license": "MIT",
"type": "module",
"engines": {
- "node": ">=20.0.0"
+ "node": ">=22.0.0"
},
"directories": {
"lib": "dist",
diff --git a/packages/oxc/package.json b/packages/oxc/package.json
index f0a1eff86..b8c9061fe 100644
--- a/packages/oxc/package.json
+++ b/packages/oxc/package.json
@@ -13,7 +13,7 @@
"license": "MIT",
"type": "module",
"engines": {
- "node": ">=20.0.0"
+ "node": ">=22.12.0"
},
"directories": {
"lib": "dist",
@@ -53,7 +53,7 @@
},
"dependencies": {
"@node-minify/utils": "workspace:*",
- "oxc-minify": "^0.112.0"
+ "oxc-minify": "^0.148.0"
},
"devDependencies": {
"@node-minify/types": "workspace:*"
diff --git a/packages/run/CHANGELOG.md b/packages/run/CHANGELOG.md
deleted file mode 100644
index d3e6da4a8..000000000
--- a/packages/run/CHANGELOG.md
+++ /dev/null
@@ -1,89 +0,0 @@
-# @node-minify/run
-
-## 10.5.0
-
-### Patch Changes
-
-- 43c11f7: Fix empty in-memory content handling, benchmark CLI defaults, subprocess close handling, and multi-output routing regressions.
-
-## 10.4.0
-
-## 10.3.0
-
-## 10.2.0
-
-## 10.1.1
-
-### Patch Changes
-
-- eb785b0: Fix npm install error caused by unresolved workspace:\* references in published packages
-
-## 10.1.0
-
-## 10.0.2
-
-### Patch Changes
-
-- 156a53d: test: verify OIDC publishing with fixed workflow config
-
-## 10.0.1
-
-### Patch Changes
-
-- d722b73: test: verify OIDC publishing with fixed workflow config
-
-## 10.0.0
-
-### Major Changes
-
-- 4406c0c: ## v10.0.0
-
- ### Breaking Changes
-
- - **ESM Only**: The package is now pure ESM. Requires Node.js 20+.
- - **Async API**: Callback support has been removed. All `minify()` calls must use `await` or `.then()`.
- - **Named Exports**: All packages now use named exports (e.g., `import { minify } from '@node-minify/core'`).
- - **Sync/Async Split**: Sync functions have been removed or split.
- - **Deprecations**:
- - `@node-minify/babel-minify` (deprecated)
- - `@node-minify/uglify-es` (deprecated)
- - `@node-minify/yui` (deprecated)
- - `@node-minify/sqwish` (deprecated)
- - `@node-minify/crass` (deprecated)
-
- ### Features & Improvements
-
- - **Build System**: Switched from `tsup` to `tsdown` for faster and more reliable builds.
- - **Core**: Moved file I/O operations from compressors to core for better consistency.
- - **Output**: Support for array output with input/output validation.
- - **Security**: Replaced `html-minifier` with `html-minifier-next`.
- - **Typings**: Improved TypeScript definitions and coverage.
- - **Dependencies**: Updated all dependencies.
-
- ### Bug Fixes
-
- - Fixed various import issues and build warnings.
- - Corrected explicit file extensions in imports.
-
-## 10.0.0-next.0
-
-### Major Changes
-
-- 4406c0c: Bump version 10.0.0 next
-
-## 9.0.1
-
-### Patch Changes
-
-- c4fcf63: Fixing packages exports and mkdirp import
-
-## 9.0.0
-
-### Major Changes
-
-- 7ab9745: Version 9.0.0
-
- - feat(node): remove node 16, add node 18 (#2092) (c9acdaa4a9906d4019d9381129d66235f3139198)
- - feat(biome): add biome (#2113) (50e9ec46c11c218453de743bed2defa9a83ace7b)
- - fix(yui): fixing yui tests (dd8629712c03b0ac1fe2b94acbb95bc896f8f22f)
- - bump dependencies
diff --git a/packages/run/LICENSE b/packages/run/LICENSE
deleted file mode 100644
index aed47f87e..000000000
--- a/packages/run/LICENSE
+++ /dev/null
@@ -1,21 +0,0 @@
-MIT License
-
-Copyright (c) 2011-2026 Rodolphe Stoclin
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
diff --git a/packages/run/README.md b/packages/run/README.md
deleted file mode 100644
index a99998088..000000000
--- a/packages/run/README.md
+++ /dev/null
@@ -1,66 +0,0 @@
-
-
-A very light minifier Node.js module.
-
-
-
-
-
-
-
-
-
-# @node-minify/run
-
-Command execution wrapper for Java-based compressors in [`node-minify`](https://github.com/srod/node-minify).
-
-This package provides utilities to spawn Java processes for compressors like YUI Compressor and Google Closure Compiler.
-
-## Installation
-
-```bash
-npm install @node-minify/run
-```
-
-## Usage
-
-```ts
-import { runCommandLine } from '@node-minify/run';
-
-const result = await runCommandLine({
- args: ['-jar', 'path/to/compiler.jar'],
- data: 'var foo = 1;'
-});
-
-console.log(result); // Minified output
-```
-
-## API
-
-### `runCommandLine(params)`
-
-Executes a Java command with the provided arguments and pipes data to stdin.
-
-#### Parameters
-
-| Name | Type | Description |
-|------|------|-------------|
-| `params.args` | `string[]` | Command line arguments for the Java process |
-| `params.data` | `string` | Content to minify (piped to stdin) |
-
-#### Returns
-
-`Promise` - The minified content from stdout.
-
-#### Throws
-
-- `Error` if the Java process exits with a non-zero code
-- `Error` if there's a process spawn error
-
-## Requirements
-
-- Java Runtime Environment (JRE) must be installed and available in PATH
-
-## License
-
-[MIT](https://github.com/srod/node-minify/blob/main/LICENSE)
diff --git a/packages/run/__tests__/run.test.ts b/packages/run/__tests__/run.test.ts
deleted file mode 100644
index 3c5d1f0ee..000000000
--- a/packages/run/__tests__/run.test.ts
+++ /dev/null
@@ -1,382 +0,0 @@
-/*!
- * node-minify
- * Copyright (c) 2011-2026 Rodolphe Stoclin
- * MIT Licensed
- */
-
-import childProcess from "node:child_process";
-import { EventEmitter } from "node:events";
-import path from "node:path";
-import { fileURLToPath } from "node:url";
-import {
- afterAll,
- beforeAll,
- describe,
- expect,
- type MockInstance,
- test,
- vi,
-} from "vitest";
-import { type RunCommandLineParams, runCommandLine } from "../src/index.ts";
-
-const __dirname = path.dirname(fileURLToPath(import.meta.url));
-const jar = `${__dirname}/../../yui/src/binaries/yuicompressor-2.4.7.jar`;
-
-type Command = {
- args: string[];
- data: string;
- maxBuffer?: number;
- timeout?: number;
-};
-
-describe("Package: run", () => {
- describe("Base", () => {
- test("should be OK with YUI", async () => {
- const command: Command = {
- args: ["-jar", "-Xss2048k", jar, "--type", "js"],
- data: 'console.log("foo");',
- };
-
- const result = await runCommandLine(
- command as unknown as RunCommandLineParams
- );
- expect(result).toBeDefined();
- });
-
- test("should not be OK with YUI, fake arg", async () => {
- const command: Command = {
- args: ["-jar", "-Xss2048k", jar, "--type", "js", "--fake"],
- data: 'console.log("foo");',
- };
-
- await expect(
- runCommandLine(command as unknown as RunCommandLineParams)
- ).rejects.toThrow();
- });
-
- test("should handle empty data input", async () => {
- const command: Command = {
- args: ["-jar", "-Xss2048k", jar, "--type", "js"],
- data: "",
- };
-
- const result = await runCommandLine(
- command as unknown as RunCommandLineParams
- );
- expect(result).toBe("");
- });
-
- test("should minify JavaScript input", async () => {
- const command: Command = {
- args: ["-jar", "-Xss2048k", jar, "--type", "js"],
- data: 'console.log("foo");',
- };
-
- const result = await runCommandLine(
- command as unknown as RunCommandLineParams
- );
- expect(result).toBeDefined();
- });
- });
-
- describe("Process error handling", () => {
- let spy: MockInstance;
-
- beforeAll(() => {
- spy = vi.spyOn(childProcess, "spawn");
- });
-
- afterAll(() => {
- vi.restoreAllMocks();
- });
-
- test("should reject when child process emits error", async () => {
- const mockChild = new EventEmitter() as ReturnType<
- typeof childProcess.spawn
- >;
- const mockStdin = new EventEmitter();
- const mockStdout = new EventEmitter();
- const mockStderr = new EventEmitter();
-
- Object.assign(mockChild, {
- stdin: Object.assign(mockStdin, {
- end: vi.fn(),
- }),
- stdout: mockStdout,
- stderr: mockStderr,
- kill: vi.fn(),
- });
-
- spy.mockReturnValue(mockChild);
-
- const command: Command = {
- args: ["-jar", "fake.jar"],
- data: "test",
- };
-
- const promise = runCommandLine(
- command as unknown as RunCommandLineParams
- );
-
- // Emit process error
- setImmediate(() => {
- mockChild.emit("error", new Error("spawn ENOENT"));
- });
-
- await expect(promise).rejects.toThrow(
- "Process error: spawn ENOENT"
- );
- });
-
- test("should handle stream errors gracefully", async () => {
- const consoleSpy = vi
- .spyOn(console, "error")
- .mockImplementation(() => {});
-
- const mockChild = new EventEmitter() as ReturnType<
- typeof childProcess.spawn
- >;
- const mockStdin = new EventEmitter();
- const mockStdout = new EventEmitter();
- const mockStderr = new EventEmitter();
-
- Object.assign(mockChild, {
- stdin: Object.assign(mockStdin, {
- end: vi.fn(),
- }),
- stdout: mockStdout,
- stderr: mockStderr,
- kill: vi.fn(),
- });
-
- spy.mockReturnValue(mockChild);
-
- const command: Command = {
- args: ["-jar", "fake.jar"],
- data: "test",
- };
-
- const promise = runCommandLine(
- command as unknown as RunCommandLineParams
- );
-
- // Emit stream error and then exit successfully
- setImmediate(() => {
- mockStdin.emit("error", new Error("stdin error"));
- mockStdout.emit("error", new Error("stdout error"));
- mockStderr.emit("error", new Error("stderr error"));
- mockStdout.emit("data", Buffer.from("output"));
- mockChild.emit("close", 0);
- });
-
- const result = await promise;
- expect(result).toBe("output");
- expect(consoleSpy).toHaveBeenCalledWith(
- "Error in child.stdin:",
- expect.any(Error)
- );
- expect(consoleSpy).toHaveBeenCalledWith(
- "Error in child.stdout:",
- expect.any(Error)
- );
- expect(consoleSpy).toHaveBeenCalledWith(
- "Error in child.stderr:",
- expect.any(Error)
- );
-
- consoleSpy.mockRestore();
- });
-
- test("should not call console.error when silence is true", async () => {
- const consoleSpy = vi
- .spyOn(console, "error")
- .mockImplementation(() => {});
-
- const mockChild = new EventEmitter() as ReturnType<
- typeof childProcess.spawn
- >;
- const mockStdin = new EventEmitter();
- const mockStdout = new EventEmitter();
- const mockStderr = new EventEmitter();
-
- Object.assign(mockChild, {
- stdin: Object.assign(mockStdin, {
- end: vi.fn(),
- }),
- stdout: mockStdout,
- stderr: mockStderr,
- kill: vi.fn(),
- });
-
- spy.mockReturnValue(mockChild);
-
- const command: RunCommandLineParams = {
- args: ["-jar", "fake.jar"],
- data: "test",
- silence: true,
- };
-
- const promise = runCommandLine(command);
-
- setImmediate(() => {
- mockStdin.emit("error", new Error("stdin error"));
- mockStdout.emit("data", Buffer.from("output"));
- mockChild.emit("close", 0);
- });
-
- const result = await promise;
- expect(result).toBe("output");
- expect(consoleSpy).not.toHaveBeenCalled();
-
- consoleSpy.mockRestore();
- });
-
- test("should wait for close before resolving stdout", async () => {
- const mockChild = new EventEmitter() as ReturnType<
- typeof childProcess.spawn
- >;
- const mockStdin = new EventEmitter();
- const mockStdout = new EventEmitter();
- const mockStderr = new EventEmitter();
-
- Object.assign(mockChild, {
- stdin: Object.assign(mockStdin, {
- end: vi.fn(),
- }),
- stdout: mockStdout,
- stderr: mockStderr,
- kill: vi.fn(),
- });
-
- spy.mockReturnValue(mockChild);
-
- const promise = runCommandLine({
- args: ["-jar", "fake.jar"],
- data: "test",
- });
-
- setImmediate(() => {
- mockChild.emit("exit", 0);
- mockStdout.emit("data", Buffer.from("delayed-output"));
- mockChild.emit("close", 0);
- });
-
- await expect(promise).resolves.toBe("delayed-output");
- });
-
- test("should reject when maxBuffer is exceeded", async () => {
- const mockChild = new EventEmitter() as ReturnType<
- typeof childProcess.spawn
- >;
- const mockStdin = new EventEmitter();
- const mockStdout = new EventEmitter();
- const mockStderr = new EventEmitter();
-
- Object.assign(mockChild, {
- stdin: Object.assign(mockStdin, {
- end: vi.fn(),
- }),
- stdout: mockStdout,
- stderr: mockStderr,
- kill: vi.fn(),
- });
-
- spy.mockReturnValue(mockChild);
-
- const command: Command = {
- args: ["-jar", "fake.jar"],
- data: "test",
- maxBuffer: 10,
- };
-
- const promise = runCommandLine(
- command as unknown as RunCommandLineParams
- );
-
- // Emit data exceeding buffer
- setImmediate(() => {
- mockStdout.emit("data", Buffer.from("12345678901"));
- });
-
- await expect(promise).rejects.toThrow("stdout maxBuffer exceeded");
- expect(mockChild.kill).toHaveBeenCalled();
- });
-
- test("should reject when stderr maxBuffer is exceeded", async () => {
- const mockChild = new EventEmitter() as ReturnType<
- typeof childProcess.spawn
- >;
- const mockStdin = new EventEmitter();
- const mockStdout = new EventEmitter();
- const mockStderr = new EventEmitter();
-
- Object.assign(mockChild, {
- stdin: Object.assign(mockStdin, {
- end: vi.fn(),
- }),
- stdout: mockStdout,
- stderr: mockStderr,
- kill: vi.fn(),
- });
-
- spy.mockReturnValue(mockChild);
-
- const command: Command = {
- args: ["-jar", "fake.jar"],
- data: "test",
- maxBuffer: 10,
- };
-
- const promise = runCommandLine(
- command as unknown as RunCommandLineParams
- );
-
- setImmediate(() => {
- mockStderr.emit("data", Buffer.from("12345678901"));
- });
-
- await expect(promise).rejects.toThrow("stderr maxBuffer exceeded");
- expect(mockChild.kill).toHaveBeenCalled();
- });
-
- test("should reject when timeout is exceeded", async () => {
- // Use real timers because vi.useFakeTimers causes issues with internal node timers
- const mockChild = new EventEmitter() as ReturnType<
- typeof childProcess.spawn
- >;
- const mockStdin = new EventEmitter();
- const mockStdout = new EventEmitter();
- const mockStderr = new EventEmitter();
-
- Object.assign(mockChild, {
- stdin: Object.assign(mockStdin, {
- end: vi.fn(),
- }),
- stdout: mockStdout,
- stderr: mockStderr,
- kill: vi.fn(),
- });
-
- spy.mockReturnValue(mockChild);
-
- const command: Command = {
- args: ["-jar", "fake.jar"],
- data: "test",
- timeout: 50,
- };
-
- const promise = runCommandLine(
- command as unknown as RunCommandLineParams
- );
-
- await expect(promise).rejects.toThrow(
- "Process timed out after 50ms"
- );
- expect(mockChild.kill).toHaveBeenCalled();
- });
- });
-
- afterAll(() => {
- vi.restoreAllMocks();
- });
-});
diff --git a/packages/run/package.json b/packages/run/package.json
deleted file mode 100644
index 15b0498aa..000000000
--- a/packages/run/package.json
+++ /dev/null
@@ -1,57 +0,0 @@
-{
- "name": "@node-minify/run",
- "version": "10.5.0",
- "description": "exec commands for @node-minify",
- "keywords": [
- "compressor",
- "minify",
- "minifier",
- "run"
- ],
- "author": "Rodolphe Stoclin ",
- "homepage": "https://github.com/srod/node-minify/tree/main/packages/run#readme",
- "license": "MIT",
- "type": "module",
- "engines": {
- "node": ">=20.0.0"
- },
- "directories": {
- "lib": "dist",
- "test": "__tests__"
- },
- "types": "./dist/index.d.ts",
- "main": "./dist/index.js",
- "exports": {
- "types": "./dist/index.d.ts",
- "default": "./dist/index.js"
- },
- "sideEffects": false,
- "files": [
- "dist/**/*"
- ],
- "publishConfig": {
- "access": "public"
- },
- "repository": {
- "type": "git",
- "url": "git+https://github.com/srod/node-minify.git"
- },
- "bugs": {
- "url": "https://github.com/srod/node-minify/issues"
- },
- "scripts": {
- "build": "tsdown src/index.ts",
- "check-exports": "attw --pack . --profile esm-only",
- "format:check": "biome check .",
- "lint": "biome lint .",
- "prepublishOnly": "bun run build",
- "test": "vitest run",
- "test:coverage": "vitest run --coverage",
- "test:watch": "vitest",
- "typecheck": "tsc --noEmit",
- "dev": "tsdown src/index.ts --watch"
- },
- "devDependencies": {
- "@node-minify/types": "workspace:*"
- }
-}
diff --git a/packages/run/src/index.ts b/packages/run/src/index.ts
deleted file mode 100644
index ff1224a5d..000000000
--- a/packages/run/src/index.ts
+++ /dev/null
@@ -1,142 +0,0 @@
-/*!
- * node-minify
- * Copyright (c) 2011-2026 Rodolphe Stoclin
- * MIT Licensed
- */
-
-import childProcess from "node:child_process";
-
-export type RunCommandLineParams = {
- args: string[];
- data: string;
- maxBuffer?: number;
- timeout?: number;
- silence?: boolean;
-};
-
-/**
- * Run the command line with spawn.
- * @param args - Command line arguments for the Java process
- * @param data - Data to minify (piped to stdin)
- * @param maxBuffer - Optional buffer limit in bytes. Defaults to 1024 * 1024 (1MB).
- * @param timeout - Optional timeout in milliseconds. Process will be killed if it exceeds this limit.
- * @param silence - Optional boolean to suppress console logging.
- * @returns Promise with minified content from stdout
- */
-export async function runCommandLine({
- args,
- data,
- maxBuffer,
- timeout,
- silence,
-}: RunCommandLineParams): Promise {
- return run({ data, args, maxBuffer, timeout, silence });
-}
-
-type RunParams = {
- data: string;
- args: string[];
- maxBuffer?: number;
- timeout?: number;
- silence?: boolean;
-};
-
-/**
- * Execute command with Java process.
- * @param data - Data to minify (piped to stdin)
- * @param args - Command line arguments
- * @param maxBuffer - Optional buffer limit in bytes. Defaults to 1024 * 1024 (1MB).
- * @param timeout - Optional timeout in milliseconds. Process will be killed if it exceeds this limit.
- * @param silence - Optional boolean to suppress console logging.
- * @returns Promise with minified content from stdout
- */
-export async function run({
- data,
- args,
- maxBuffer = 1024 * 1024,
- timeout,
- silence = false,
-}: RunParams): Promise {
- return new Promise((resolve, reject) => {
- const stdoutChunks: Buffer[] = [];
- const stderrChunks: Buffer[] = [];
- let stdoutLength = 0;
- let stderrLength = 0;
- let timeoutId: NodeJS.Timeout | undefined;
- let settled = false;
-
- const child = childProcess.spawn("java", args, {
- stdio: "pipe",
- });
-
- if (timeout) {
- timeoutId = setTimeout(() => {
- if (settled || child.killed) return;
- settled = true;
- child.kill();
- reject(new Error(`Process timed out after ${timeout}ms`));
- }, timeout);
- }
-
- const handleError = (source: string) => (error: Error) => {
- if (!silence) {
- console.error(`Error in ${source}:`, error);
- }
- };
-
- child.on("error", (error) => {
- if (settled) return;
- settled = true;
- if (timeoutId) clearTimeout(timeoutId);
- handleError("child")(error);
- reject(new Error(`Process error: ${error.message}`));
- });
-
- child.stdin?.on("error", handleError("child.stdin"));
- child.stdout?.on("error", handleError("child.stdout"));
- child.stderr?.on("error", handleError("child.stderr"));
-
- child.on("close", (code: number | null) => {
- if (settled) return;
- settled = true;
- if (timeoutId) clearTimeout(timeoutId);
- const stderr = Buffer.concat(stderrChunks).toString("utf8");
- if (code !== 0) {
- reject(new Error(stderr || `Process exited with code ${code}`));
- return;
- }
-
- resolve(Buffer.concat(stdoutChunks).toString("utf8"));
- });
-
- child.stdout?.on("data", (chunk: Buffer) => {
- stdoutChunks.push(chunk);
- stdoutLength += chunk.length;
-
- if (maxBuffer > 0 && stdoutLength > maxBuffer) {
- if (settled) return;
- settled = true;
- if (timeoutId) clearTimeout(timeoutId);
- child.kill();
- reject(new Error("stdout maxBuffer exceeded"));
- return;
- }
- });
-
- child.stderr?.on("data", (chunk: Buffer) => {
- stderrChunks.push(chunk);
- stderrLength += chunk.length;
-
- if (maxBuffer > 0 && stderrLength > maxBuffer) {
- if (settled) return;
- settled = true;
- if (timeoutId) clearTimeout(timeoutId);
- child.kill();
- reject(new Error("stderr maxBuffer exceeded"));
- return;
- }
- });
-
- child.stdin?.end(data);
- });
-}
diff --git a/packages/run/tsconfig.json b/packages/run/tsconfig.json
deleted file mode 100644
index 8ffe5db9f..000000000
--- a/packages/run/tsconfig.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "extends": "../../tsconfig.json",
- "compilerOptions": {
- "outDir": "./dist",
- "rootDir": "./src"
- },
- "include": ["src/**/*"]
-}
diff --git a/packages/run/vitest.config.ts b/packages/run/vitest.config.ts
deleted file mode 100644
index d7b613c2a..000000000
--- a/packages/run/vitest.config.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-import { defineProject } from "vitest/config";
-
-export default defineProject({});
diff --git a/packages/sharp/package.json b/packages/sharp/package.json
index 9f1246d34..1d0437d06 100644
--- a/packages/sharp/package.json
+++ b/packages/sharp/package.json
@@ -16,7 +16,7 @@
"license": "MIT",
"type": "module",
"engines": {
- "node": ">=20.3.0"
+ "node": ">=22.0.0"
},
"directories": {
"lib": "dist",
@@ -57,7 +57,7 @@
"dependencies": {
"@node-minify/core": "workspace:*",
"@node-minify/utils": "workspace:*",
- "sharp": "^0.34.5"
+ "sharp": "^0.35.0"
},
"devDependencies": {
"@node-minify/types": "workspace:*"
diff --git a/packages/sqwish/CHANGELOG.md b/packages/sqwish/CHANGELOG.md
deleted file mode 100644
index b0910d420..000000000
--- a/packages/sqwish/CHANGELOG.md
+++ /dev/null
@@ -1,136 +0,0 @@
-# @node-minify/sqwish
-
-## 10.5.0
-
-### Patch Changes
-
-- Updated dependencies [43c11f7]
-- Updated dependencies [1d5e3ee]
-- Updated dependencies [c21e335]
- - @node-minify/utils@10.5.0
-
-## 10.4.0
-
-### Patch Changes
-
-- Updated dependencies [2e64877]
-- Updated dependencies [3d4d2d0]
-- Updated dependencies [0a51025]
- - @node-minify/utils@10.4.0
-
-## 10.3.0
-
-### Patch Changes
-
-- Updated dependencies [1e06c03]
- - @node-minify/utils@10.3.0
-
-## 10.2.0
-
-### Patch Changes
-
-- Updated dependencies [3c98739]
- - @node-minify/utils@10.2.0
-
-## 10.1.1
-
-### Patch Changes
-
-- eb785b0: Fix npm install error caused by unresolved workspace:\* references in published packages
-- Updated dependencies [eb785b0]
- - @node-minify/utils@10.1.1
-
-## 10.1.0
-
-### Patch Changes
-
-- @node-minify/utils@10.1.0
-
-## 10.0.2
-
-### Patch Changes
-
-- 156a53d: test: verify OIDC publishing with fixed workflow config
-- Updated dependencies [156a53d]
- - @node-minify/utils@10.0.2
-
-## 10.0.1
-
-### Patch Changes
-
-- d722b73: test: verify OIDC publishing with fixed workflow config
-- Updated dependencies [d722b73]
- - @node-minify/utils@10.0.1
-
-## 10.0.0
-
-### Major Changes
-
-- 4406c0c: ## v10.0.0
-
- ### Breaking Changes
-
- - **ESM Only**: The package is now pure ESM. Requires Node.js 20+.
- - **Async API**: Callback support has been removed. All `minify()` calls must use `await` or `.then()`.
- - **Named Exports**: All packages now use named exports (e.g., `import { minify } from '@node-minify/core'`).
- - **Sync/Async Split**: Sync functions have been removed or split.
- - **Deprecations**:
- - `@node-minify/babel-minify` (deprecated)
- - `@node-minify/uglify-es` (deprecated)
- - `@node-minify/yui` (deprecated)
- - `@node-minify/sqwish` (deprecated)
- - `@node-minify/crass` (deprecated)
-
- ### Features & Improvements
-
- - **Build System**: Switched from `tsup` to `tsdown` for faster and more reliable builds.
- - **Core**: Moved file I/O operations from compressors to core for better consistency.
- - **Output**: Support for array output with input/output validation.
- - **Security**: Replaced `html-minifier` with `html-minifier-next`.
- - **Typings**: Improved TypeScript definitions and coverage.
- - **Dependencies**: Updated all dependencies.
-
- ### Bug Fixes
-
- - Fixed various import issues and build warnings.
- - Corrected explicit file extensions in imports.
-
-### Patch Changes
-
-- Updated dependencies [4406c0c]
- - @node-minify/utils@10.0.0
-
-## 10.0.0-next.0
-
-### Major Changes
-
-- 4406c0c: Bump version 10.0.0 next
-
-### Patch Changes
-
-- Updated dependencies [4406c0c]
- - @node-minify/utils@10.0.0-next.0
-
-## 9.0.1
-
-### Patch Changes
-
-- c4fcf63: Fixing packages exports and mkdirp import
-- Updated dependencies [c4fcf63]
- - @node-minify/utils@9.0.1
-
-## 9.0.0
-
-### Major Changes
-
-- 7ab9745: Version 9.0.0
-
- - feat(node): remove node 16, add node 18 (#2092) (c9acdaa4a9906d4019d9381129d66235f3139198)
- - feat(biome): add biome (#2113) (50e9ec46c11c218453de743bed2defa9a83ace7b)
- - fix(yui): fixing yui tests (dd8629712c03b0ac1fe2b94acbb95bc896f8f22f)
- - bump dependencies
-
-### Patch Changes
-
-- Updated dependencies [7ab9745]
- - @node-minify/utils@9.0.0
diff --git a/packages/sqwish/LICENSE b/packages/sqwish/LICENSE
deleted file mode 100644
index aed47f87e..000000000
--- a/packages/sqwish/LICENSE
+++ /dev/null
@@ -1,21 +0,0 @@
-MIT License
-
-Copyright (c) 2011-2026 Rodolphe Stoclin
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
diff --git a/packages/sqwish/README.md b/packages/sqwish/README.md
deleted file mode 100644
index aaab570a0..000000000
--- a/packages/sqwish/README.md
+++ /dev/null
@@ -1,47 +0,0 @@
-
-
-A very light minifier Node.js module.
-
-
-
-
-
-
-
-
-
-# sqwish
-
-> **DEPRECATED**: sqwish is no longer maintained (last update ~2014).
-> Please use [`@node-minify/cssnano`](https://github.com/srod/node-minify/tree/main/packages/cssnano) or [`@node-minify/clean-css`](https://github.com/srod/node-minify/tree/main/packages/clean-css) instead.
-
-`sqwish` is a plugin for [`node-minify`](https://github.com/srod/node-minify)
-
-It allow you to compress CSS files.
-
-## Installation
-
-```bash
-npm install @node-minify/core @node-minify/sqwish
-```
-
-## Usage
-
-```js
-import { minify } from '@node-minify/core';
-import { sqwish } from '@node-minify/sqwish';
-
-await minify({
- compressor: sqwish,
- input: 'foo.css',
- output: 'bar.css'
-});
-```
-
-## Documentation
-
-Visit https://node-minify.2clics.net/compressors/sqwish.html for full documentation
-
-## License
-
-[MIT](https://github.com/srod/node-minify/blob/main/LICENSE)
diff --git a/packages/sqwish/__tests__/sqwish-error.test.ts b/packages/sqwish/__tests__/sqwish-error.test.ts
deleted file mode 100644
index e686e14bc..000000000
--- a/packages/sqwish/__tests__/sqwish-error.test.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-/*!
- * node-minify
- * Copyright (c) 2011-2026 Rodolphe Stoclin
- * MIT Licensed
- */
-
-import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
-
-describe("Package: sqwish error handling", () => {
- beforeEach(() => {
- vi.resetModules();
- });
-
- afterEach(() => {
- vi.doUnmock("sqwish");
- });
-
- test("should wrap minification errors", async () => {
- vi.doMock("sqwish", () => ({
- default: {
- minify: () => {
- throw new Error("CSS parse error");
- },
- },
- }));
-
- const { sqwish } = await import("../src/index.ts");
-
- await expect(
- sqwish({ settings: {} as any, content: ".a { color: red; }" })
- ).rejects.toThrow("sqwish minification failed: CSS parse error");
- });
-});
diff --git a/packages/sqwish/__tests__/sqwish.test.ts b/packages/sqwish/__tests__/sqwish.test.ts
deleted file mode 100644
index 7dc48e4a5..000000000
--- a/packages/sqwish/__tests__/sqwish.test.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-/*!
- * node-minify
- * Copyright (c) 2011-2026 Rodolphe Stoclin
- * MIT Licensed
- */
-
-import { describe } from "vitest";
-import { runOneTest, tests } from "../../../tests/fixtures.ts";
-import { sqwish } from "../src/index.ts";
-
-const compressorLabel = "sqwish";
-const compressor = sqwish;
-
-describe("Package: sqwish", async () => {
- if (!tests.commoncss) {
- throw new Error("Tests not found");
- }
-
- // Run commoncss tests
- for (const options of tests.commoncss) {
- await runOneTest({ options, compressorLabel, compressor });
- }
-});
diff --git a/packages/sqwish/package.json b/packages/sqwish/package.json
deleted file mode 100644
index 1b9985264..000000000
--- a/packages/sqwish/package.json
+++ /dev/null
@@ -1,62 +0,0 @@
-{
- "name": "@node-minify/sqwish",
- "version": "10.5.0",
- "deprecated": "sqwish is no longer maintained. Please use @node-minify/cssnano or @node-minify/clean-css instead.",
- "description": "sqwish plugin for @node-minify (DEPRECATED - use @node-minify/cssnano)",
- "keywords": [
- "compressor",
- "minify",
- "minifier",
- "sqwish"
- ],
- "author": "Rodolphe Stoclin ",
- "homepage": "https://github.com/srod/node-minify/tree/main/packages/sqwish#readme",
- "license": "MIT",
- "type": "module",
- "engines": {
- "node": ">=20.0.0"
- },
- "directories": {
- "lib": "dist",
- "test": "__tests__"
- },
- "types": "./dist/index.d.ts",
- "main": "./dist/index.js",
- "exports": {
- "types": "./dist/index.d.ts",
- "default": "./dist/index.js"
- },
- "sideEffects": false,
- "files": [
- "dist/**/*"
- ],
- "publishConfig": {
- "access": "public"
- },
- "repository": {
- "type": "git",
- "url": "git+https://github.com/srod/node-minify.git"
- },
- "bugs": {
- "url": "https://github.com/srod/node-minify/issues"
- },
- "scripts": {
- "build": "tsdown src/index.ts",
- "check-exports": "attw --pack . --profile esm-only",
- "format:check": "biome check .",
- "lint": "biome lint .",
- "prepublishOnly": "bun run build",
- "test": "vitest run",
- "test:coverage": "vitest run --coverage",
- "test:watch": "vitest",
- "typecheck": "tsc --noEmit",
- "dev": "tsdown src/index.ts --watch"
- },
- "dependencies": {
- "@node-minify/utils": "workspace:*",
- "sqwish": "0.2.2"
- },
- "devDependencies": {
- "@node-minify/types": "workspace:*"
- }
-}
diff --git a/packages/sqwish/src/index.ts b/packages/sqwish/src/index.ts
deleted file mode 100644
index 536b4847a..000000000
--- a/packages/sqwish/src/index.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-/*!
- * node-minify
- * Copyright (c) 2011-2026 Rodolphe Stoclin
- * MIT Licensed
- */
-
-import type { CompressorResult, MinifierOptions } from "@node-minify/types";
-import {
- ensureStringContent,
- warnDeprecation,
- wrapMinificationError,
-} from "@node-minify/utils";
-import minify from "sqwish";
-
-/**
- * Minify CSS content with the Sqwish minifier and emit a deprecation warning.
- *
- * @deprecated sqwish is no longer maintained. Use @node-minify/cssnano or @node-minify/clean-css instead.
- * @param settings - Minifier options; `settings.options.strict` (if present) controls Sqwish strict mode
- * @param content - Content to minify; will be converted to a string if necessary
- * @returns An object containing the minified code in the `code` property
- */
-export async function sqwish({
- settings,
- content,
-}: MinifierOptions): Promise {
- const contentStr = ensureStringContent(content, "sqwish");
-
- warnDeprecation(
- "sqwish",
- "sqwish is no longer maintained. " +
- "Please migrate to @node-minify/cssnano or @node-minify/clean-css."
- );
-
- try {
- const strict = settings?.options?.strict as boolean | undefined;
- const code = minify.minify(contentStr, strict);
- return { code };
- } catch (error) {
- throw wrapMinificationError("sqwish", error);
- }
-}
diff --git a/packages/sqwish/src/types.d.ts b/packages/sqwish/src/types.d.ts
deleted file mode 100644
index 734216530..000000000
--- a/packages/sqwish/src/types.d.ts
+++ /dev/null
@@ -1 +0,0 @@
-declare module "sqwish";
diff --git a/packages/sqwish/tsconfig.json b/packages/sqwish/tsconfig.json
deleted file mode 100644
index 8ffe5db9f..000000000
--- a/packages/sqwish/tsconfig.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "extends": "../../tsconfig.json",
- "compilerOptions": {
- "outDir": "./dist",
- "rootDir": "./src"
- },
- "include": ["src/**/*"]
-}
diff --git a/packages/sqwish/vitest.config.ts b/packages/sqwish/vitest.config.ts
deleted file mode 100644
index d7b613c2a..000000000
--- a/packages/sqwish/vitest.config.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-import { defineProject } from "vitest/config";
-
-export default defineProject({});
diff --git a/packages/svgo/package.json b/packages/svgo/package.json
index 2f93b5f83..ec82a6673 100644
--- a/packages/svgo/package.json
+++ b/packages/svgo/package.json
@@ -15,7 +15,7 @@
"license": "MIT",
"type": "module",
"engines": {
- "node": ">=20.0.0"
+ "node": ">=22.0.0"
},
"directories": {
"lib": "dist",
diff --git a/packages/swc/package.json b/packages/swc/package.json
index 25e049264..b3a6c25c9 100644
--- a/packages/swc/package.json
+++ b/packages/swc/package.json
@@ -13,7 +13,7 @@
"license": "MIT",
"type": "module",
"engines": {
- "node": ">=20.0.0"
+ "node": ">=22.0.0"
},
"directories": {
"lib": "dist",
diff --git a/packages/terser/package.json b/packages/terser/package.json
index f8cf956c0..2c4eabfe8 100644
--- a/packages/terser/package.json
+++ b/packages/terser/package.json
@@ -13,7 +13,7 @@
"license": "MIT",
"type": "module",
"engines": {
- "node": ">=20.0.0"
+ "node": ">=22.0.0"
},
"directories": {
"lib": "dist",
@@ -53,7 +53,7 @@
},
"dependencies": {
"@node-minify/utils": "workspace:*",
- "terser": "5.46.0"
+ "terser": "5.51.2"
},
"devDependencies": {
"@node-minify/types": "workspace:*"
diff --git a/packages/types/package.json b/packages/types/package.json
index 7225321a3..0e10d338e 100644
--- a/packages/types/package.json
+++ b/packages/types/package.json
@@ -13,7 +13,7 @@
"license": "MIT",
"type": "module",
"engines": {
- "node": ">=20.0.0"
+ "node": ">=22.0.0"
},
"main": "src/types.d.ts",
"types": "src/types.d.ts",
diff --git a/packages/types/src/types.d.ts b/packages/types/src/types.d.ts
index 052d4b28d..3f5ae7c44 100644
--- a/packages/types/src/types.d.ts
+++ b/packages/types/src/types.d.ts
@@ -4,12 +4,6 @@
* MIT Licensed
*/
-/**
- * The return type of a compressor function.
- * @deprecated Use `CompressorResult` instead. Will be removed in v11.
- */
-export type CompressorReturnType = string;
-
/**
* Supported image formats for image compression.
*/
@@ -84,7 +78,7 @@ export type Compressor =
(args: MinifierOptions) => Promise;
/**
- * File type for compressors that support multiple types (e.g., YUI).
+ * File type for compressors that support multiple types (e.g., esbuild).
*/
export type FileType = "js" | "css";
@@ -173,7 +167,7 @@ export type Settings = {
/**
* File type for compressors that support multiple types.
- * Required for YUI compressor.
+ * Required for compressors like esbuild that handle both JS and CSS.
*/
type?: FileType;
@@ -252,11 +246,3 @@ export type Result = {
*/
sizeGzip: string;
};
-
-/**
- * Type alias for user convenience.
- * @deprecated Use `Settings` instead. Will be removed in v11.
- */
-export type MinifyOptions<
- TOptions extends CompressorOptions = CompressorOptions,
-> = Settings;
diff --git a/packages/uglify-es/CHANGELOG.md b/packages/uglify-es/CHANGELOG.md
deleted file mode 100644
index c12c434ec..000000000
--- a/packages/uglify-es/CHANGELOG.md
+++ /dev/null
@@ -1,136 +0,0 @@
-# @node-minify/uglify-es
-
-## 10.5.0
-
-### Patch Changes
-
-- Updated dependencies [43c11f7]
-- Updated dependencies [1d5e3ee]
-- Updated dependencies [c21e335]
- - @node-minify/utils@10.5.0
-
-## 10.4.0
-
-### Patch Changes
-
-- Updated dependencies [2e64877]
-- Updated dependencies [3d4d2d0]
-- Updated dependencies [0a51025]
- - @node-minify/utils@10.4.0
-
-## 10.3.0
-
-### Patch Changes
-
-- Updated dependencies [1e06c03]
- - @node-minify/utils@10.3.0
-
-## 10.2.0
-
-### Patch Changes
-
-- Updated dependencies [3c98739]
- - @node-minify/utils@10.2.0
-
-## 10.1.1
-
-### Patch Changes
-
-- eb785b0: Fix npm install error caused by unresolved workspace:\* references in published packages
-- Updated dependencies [eb785b0]
- - @node-minify/utils@10.1.1
-
-## 10.1.0
-
-### Patch Changes
-
-- @node-minify/utils@10.1.0
-
-## 10.0.2
-
-### Patch Changes
-
-- 156a53d: test: verify OIDC publishing with fixed workflow config
-- Updated dependencies [156a53d]
- - @node-minify/utils@10.0.2
-
-## 10.0.1
-
-### Patch Changes
-
-- d722b73: test: verify OIDC publishing with fixed workflow config
-- Updated dependencies [d722b73]
- - @node-minify/utils@10.0.1
-
-## 10.0.0
-
-### Major Changes
-
-- 4406c0c: ## v10.0.0
-
- ### Breaking Changes
-
- - **ESM Only**: The package is now pure ESM. Requires Node.js 20+.
- - **Async API**: Callback support has been removed. All `minify()` calls must use `await` or `.then()`.
- - **Named Exports**: All packages now use named exports (e.g., `import { minify } from '@node-minify/core'`).
- - **Sync/Async Split**: Sync functions have been removed or split.
- - **Deprecations**:
- - `@node-minify/babel-minify` (deprecated)
- - `@node-minify/uglify-es` (deprecated)
- - `@node-minify/yui` (deprecated)
- - `@node-minify/sqwish` (deprecated)
- - `@node-minify/crass` (deprecated)
-
- ### Features & Improvements
-
- - **Build System**: Switched from `tsup` to `tsdown` for faster and more reliable builds.
- - **Core**: Moved file I/O operations from compressors to core for better consistency.
- - **Output**: Support for array output with input/output validation.
- - **Security**: Replaced `html-minifier` with `html-minifier-next`.
- - **Typings**: Improved TypeScript definitions and coverage.
- - **Dependencies**: Updated all dependencies.
-
- ### Bug Fixes
-
- - Fixed various import issues and build warnings.
- - Corrected explicit file extensions in imports.
-
-### Patch Changes
-
-- Updated dependencies [4406c0c]
- - @node-minify/utils@10.0.0
-
-## 10.0.0-next.0
-
-### Major Changes
-
-- 4406c0c: Bump version 10.0.0 next
-
-### Patch Changes
-
-- Updated dependencies [4406c0c]
- - @node-minify/utils@10.0.0-next.0
-
-## 9.0.1
-
-### Patch Changes
-
-- c4fcf63: Fixing packages exports and mkdirp import
-- Updated dependencies [c4fcf63]
- - @node-minify/utils@9.0.1
-
-## 9.0.0
-
-### Major Changes
-
-- 7ab9745: Version 9.0.0
-
- - feat(node): remove node 16, add node 18 (#2092) (c9acdaa4a9906d4019d9381129d66235f3139198)
- - feat(biome): add biome (#2113) (50e9ec46c11c218453de743bed2defa9a83ace7b)
- - fix(yui): fixing yui tests (dd8629712c03b0ac1fe2b94acbb95bc896f8f22f)
- - bump dependencies
-
-### Patch Changes
-
-- Updated dependencies [7ab9745]
- - @node-minify/utils@9.0.0
diff --git a/packages/uglify-es/LICENSE b/packages/uglify-es/LICENSE
deleted file mode 100644
index aed47f87e..000000000
--- a/packages/uglify-es/LICENSE
+++ /dev/null
@@ -1,21 +0,0 @@
-MIT License
-
-Copyright (c) 2011-2026 Rodolphe Stoclin
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
diff --git a/packages/uglify-es/README.md b/packages/uglify-es/README.md
deleted file mode 100644
index 3d87bed5f..000000000
--- a/packages/uglify-es/README.md
+++ /dev/null
@@ -1,47 +0,0 @@
-
-
-A very light minifier Node.js module.
-
-
-
-
-
-
-
-
-
-# uglify-es
-
-> **DEPRECATED**: This package is deprecated because `uglify-es` is no longer maintained upstream.
-> Please use [`@node-minify/terser`](https://github.com/srod/node-minify/tree/main/packages/terser) instead, which is actively maintained and supports modern JavaScript.
-
-`uglify-es` is a plugin for [`node-minify`](https://github.com/srod/node-minify)
-
-It allow you to compress JavaScript files.
-
-## Installation
-
-```bash
-npm install @node-minify/core @node-minify/uglify-es
-```
-
-## Usage
-
-```js
-import { minify } from '@node-minify/core';
-import { uglifyEs } from '@node-minify/uglify-es';
-
-await minify({
- compressor: uglifyEs,
- input: 'foo.js',
- output: 'bar.js'
-});
-```
-
-## Documentation
-
-Visit https://node-minify.2clics.net/compressors/uglify-es.html for full documentation
-
-## License
-
-[MIT](https://github.com/srod/node-minify/blob/main/LICENSE)
diff --git a/packages/uglify-es/__tests__/uglify-es.test.ts b/packages/uglify-es/__tests__/uglify-es.test.ts
deleted file mode 100644
index 4243f4ae4..000000000
--- a/packages/uglify-es/__tests__/uglify-es.test.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-/*!
- * node-minify
- * Copyright (c) 2011-2026 Rodolphe Stoclin
- * MIT Licensed
- */
-
-import type { Settings } from "@node-minify/types";
-import { describe, expect, test } from "vitest";
-import { filesJS } from "../../../tests/files-path.ts";
-import { runOneTest, tests } from "../../../tests/fixtures.ts";
-import { minify } from "../../core/src/index.ts";
-import { uglifyEs } from "../src/index.ts";
-
-const compressorLabel = "uglify-es";
-const compressor = uglifyEs;
-
-describe("Package: uglify-es", async () => {
- if (!tests.commonjs || !tests.uglifyjs) {
- throw new Error("Tests not found");
- }
-
- // Run commonjs tests
- for (const options of tests.commonjs) {
- await runOneTest({ options, compressorLabel, compressor });
- }
-
- for (const options of tests.uglifyjs) {
- await runOneTest({ options, compressorLabel, compressor });
- }
-
- test("should throw an error", async () => {
- const settings: Settings = {
- compressor: uglifyEs,
- input: filesJS.errors,
- output: filesJS.fileJSOut,
- };
-
- try {
- return await minify(settings);
- } catch (err) {
- return expect(err).not.toBeNull();
- }
- });
-});
diff --git a/packages/uglify-es/package.json b/packages/uglify-es/package.json
deleted file mode 100644
index 18b8e2280..000000000
--- a/packages/uglify-es/package.json
+++ /dev/null
@@ -1,63 +0,0 @@
-{
- "name": "@node-minify/uglify-es",
- "version": "10.5.0",
- "deprecated": "uglify-es is no longer maintained. Please use @node-minify/terser instead.",
- "description": "uglify-es plugin for @node-minify (DEPRECATED - use @node-minify/terser)",
- "keywords": [
- "compressor",
- "minify",
- "minifier",
- "uglify-es"
- ],
- "author": "Rodolphe Stoclin ",
- "homepage": "https://github.com/srod/node-minify/tree/main/packages/uglify-es#readme",
- "license": "MIT",
- "type": "module",
- "engines": {
- "node": ">=20.0.0"
- },
- "directories": {
- "lib": "dist",
- "test": "__tests__"
- },
- "types": "./dist/index.d.ts",
- "main": "./dist/index.js",
- "exports": {
- "types": "./dist/index.d.ts",
- "default": "./dist/index.js"
- },
- "sideEffects": false,
- "files": [
- "dist/**/*"
- ],
- "publishConfig": {
- "access": "public"
- },
- "repository": {
- "type": "git",
- "url": "git+https://github.com/srod/node-minify.git"
- },
- "bugs": {
- "url": "https://github.com/srod/node-minify/issues"
- },
- "scripts": {
- "build": "tsdown src/index.ts",
- "check-exports": "attw --pack . --profile esm-only",
- "format:check": "biome check .",
- "lint": "biome lint .",
- "prepublishOnly": "bun run build",
- "test": "vitest run",
- "test:coverage": "vitest run --coverage",
- "test:watch": "vitest",
- "typecheck": "tsc --noEmit",
- "dev": "tsdown src/index.ts --watch"
- },
- "dependencies": {
- "@node-minify/utils": "workspace:*",
- "uglify-es": "3.3.9"
- },
- "devDependencies": {
- "@node-minify/types": "workspace:*",
- "@types/uglify-es": "^3.0.3"
- }
-}
diff --git a/packages/uglify-es/src/index.ts b/packages/uglify-es/src/index.ts
deleted file mode 100644
index 0a49c416d..000000000
--- a/packages/uglify-es/src/index.ts
+++ /dev/null
@@ -1,53 +0,0 @@
-/*!
- * node-minify
- * Copyright (c) 2011-2026 Rodolphe Stoclin
- * MIT Licensed
- */
-
-import type { CompressorResult, MinifierOptions } from "@node-minify/types";
-import { ensureStringContent, warnDeprecation } from "@node-minify/utils";
-import uglifyES from "uglify-es";
-
-/**
- * Minifies JavaScript content using uglify-es.
- *
- * @deprecated uglify-es is no longer maintained. Use @node-minify/terser instead.
- * @param settings - Minifier settings and uglify-es options
- * @param content - Input content to minify
- * @returns The minified code as `code` and the source map as `map` if produced
- * @throws The error produced by uglify-es when minification fails
- */
-export async function uglifyEs({
- settings,
- content,
-}: MinifierOptions): Promise {
- const contentStr = ensureStringContent(content, "uglify-es");
-
- warnDeprecation(
- "uglify-es",
- "uglify-es is no longer maintained. " +
- "Please migrate to @node-minify/terser for continued support and modern JavaScript features."
- );
-
- let inputContent: string | Record = contentStr;
- const sourceMapOptions = settings.options?.sourceMap as
- | { filename?: string }
- | undefined;
-
- if (typeof sourceMapOptions === "object") {
- inputContent = {
- [sourceMapOptions.filename ?? ""]: contentStr,
- };
- }
-
- const result = uglifyES.minify(inputContent, settings.options);
-
- if (result.error) {
- throw result.error;
- }
-
- return {
- code: result.code,
- map: result.map,
- };
-}
diff --git a/packages/uglify-es/tsconfig.json b/packages/uglify-es/tsconfig.json
deleted file mode 100644
index 8ffe5db9f..000000000
--- a/packages/uglify-es/tsconfig.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "extends": "../../tsconfig.json",
- "compilerOptions": {
- "outDir": "./dist",
- "rootDir": "./src"
- },
- "include": ["src/**/*"]
-}
diff --git a/packages/uglify-es/vitest.config.ts b/packages/uglify-es/vitest.config.ts
deleted file mode 100644
index d7b613c2a..000000000
--- a/packages/uglify-es/vitest.config.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-import { defineProject } from "vitest/config";
-
-export default defineProject({});
diff --git a/packages/uglify-js/package.json b/packages/uglify-js/package.json
index b70dfbeac..c1cf01720 100644
--- a/packages/uglify-js/package.json
+++ b/packages/uglify-js/package.json
@@ -13,7 +13,7 @@
"license": "MIT",
"type": "module",
"engines": {
- "node": ">=20.0.0"
+ "node": ">=22.0.0"
},
"directories": {
"lib": "dist",
diff --git a/packages/utils/__tests__/compressor-registry.test.ts b/packages/utils/__tests__/compressor-registry.test.ts
new file mode 100644
index 000000000..1cb8780d3
--- /dev/null
+++ b/packages/utils/__tests__/compressor-registry.test.ts
@@ -0,0 +1,152 @@
+import { describe, expect, test } from "vitest";
+import {
+ COMPRESSOR_REGISTRY,
+ getCompressorEntry,
+ getCompressorsByStatus,
+} from "../src/compressor-registry.ts";
+
+describe("COMPRESSOR_REGISTRY", () => {
+ test("contains exactly 22 compressors", () => {
+ expect(COMPRESSOR_REGISTRY).toHaveLength(22);
+ });
+
+ test("has 9 recommended compressors", () => {
+ const recommended = getCompressorsByStatus("recommended");
+ expect(recommended).toHaveLength(9);
+ });
+
+ test("has 6 supported compressors", () => {
+ const supported = getCompressorsByStatus("supported");
+ expect(supported).toHaveLength(6);
+ });
+
+ test("has 2 legacy compressors", () => {
+ const legacy = getCompressorsByStatus("legacy");
+ expect(legacy).toHaveLength(2);
+ });
+
+ test("has 5 removed compressors", () => {
+ const removed = getCompressorsByStatus("removed");
+ expect(removed).toHaveLength(5);
+ });
+
+ test("all entries have required fields", () => {
+ COMPRESSOR_REGISTRY.forEach((entry) => {
+ expect(entry).toHaveProperty("name");
+ expect(entry).toHaveProperty("status");
+ expect(entry).toHaveProperty("packageName");
+ expect(typeof entry.name).toBe("string");
+ expect(typeof entry.status).toBe("string");
+ expect(typeof entry.packageName).toBe("string");
+ });
+ });
+
+ test("all packageNames follow @node-minify/ format", () => {
+ COMPRESSOR_REGISTRY.forEach((entry) => {
+ expect(entry.packageName).toMatch(/^@node-minify\//);
+ });
+ });
+
+ test("removed compressors have replacement field", () => {
+ const removed = getCompressorsByStatus("removed");
+ removed.forEach((entry) => {
+ expect(entry.replacement).toBeDefined();
+ expect(typeof entry.replacement).toBe("string");
+ });
+ });
+});
+
+describe("getCompressorsByStatus", () => {
+ test("returns all recommended compressors", () => {
+ const recommended = getCompressorsByStatus("recommended");
+ const names = recommended.map((e) => e.name);
+ expect(names).toContain("terser");
+ expect(names).toContain("oxc");
+ expect(names).toContain("swc");
+ expect(names).toContain("esbuild");
+ expect(names).toContain("lightningcss");
+ expect(names).toContain("cssnano");
+ expect(names).toContain("minify-html");
+ expect(names).toContain("sharp");
+ expect(names).toContain("svgo");
+ });
+
+ test("returns all supported compressors", () => {
+ const supported = getCompressorsByStatus("supported");
+ const names = supported.map((e) => e.name);
+ expect(names).toContain("clean-css");
+ expect(names).toContain("csso");
+ expect(names).toContain("uglify-js");
+ expect(names).toContain("google-closure-compiler");
+ expect(names).toContain("imagemin");
+ expect(names).toContain("html-minifier");
+ });
+
+ test("returns legacy compressor", () => {
+ const legacy = getCompressorsByStatus("legacy");
+ expect(legacy).toHaveLength(2);
+ const names = legacy.map((entry) => entry.name);
+ expect(names).toContain("jsonminify");
+ expect(names).toContain("no-compress");
+ });
+
+ test("returns all removed compressors", () => {
+ const removed = getCompressorsByStatus("removed");
+ const names = removed.map((e) => e.name);
+ expect(names).toContain("babel-minify");
+ expect(names).toContain("uglify-es");
+ expect(names).toContain("yui");
+ expect(names).toContain("sqwish");
+ expect(names).toContain("crass");
+ });
+});
+
+describe("getCompressorEntry", () => {
+ test("returns entry for existing compressor", () => {
+ const entry = getCompressorEntry("terser");
+ expect(entry).toBeDefined();
+ expect(entry?.name).toBe("terser");
+ expect(entry?.status).toBe("recommended");
+ expect(entry?.packageName).toBe("@node-minify/terser");
+ });
+
+ test("returns undefined for non-existent compressor", () => {
+ const entry = getCompressorEntry("non-existent");
+ expect(entry).toBeUndefined();
+ });
+
+ test("returns entry with replacement for removed compressor", () => {
+ const entry = getCompressorEntry("babel-minify");
+ expect(entry).toBeDefined();
+ expect(entry?.status).toBe("removed");
+ expect(entry?.replacement).toBe("terser");
+ });
+
+ test("returns entry when looked up by scoped package name", () => {
+ const entry = getCompressorEntry("@node-minify/yui");
+ expect(entry).toBeDefined();
+ expect(entry?.name).toBe("yui");
+ expect(entry?.status).toBe("removed");
+ });
+
+ test("returns entry for yui with replacement", () => {
+ const entry = getCompressorEntry("yui");
+ expect(entry).toBeDefined();
+ if (entry) {
+ expect(entry.status).toBe("removed");
+ expect(entry.replacement).toBeDefined();
+ }
+ });
+
+ test("returns entry for legacy compressor", () => {
+ const entry = getCompressorEntry("jsonminify");
+ expect(entry).toBeDefined();
+ expect(entry?.status).toBe("legacy");
+ });
+
+ test("returns entry for no-compress", () => {
+ const entry = getCompressorEntry("no-compress");
+ expect(entry).toBeDefined();
+ expect(entry?.status).toBe("legacy");
+ });
+});
diff --git a/packages/utils/__tests__/compressor-resolver.test.ts b/packages/utils/__tests__/compressor-resolver.test.ts
index e6f1fc26e..8b70d09e5 100644
--- a/packages/utils/__tests__/compressor-resolver.test.ts
+++ b/packages/utils/__tests__/compressor-resolver.test.ts
@@ -77,6 +77,14 @@ describe("Package: utils/compressor-resolver", () => {
expect(result?.isBuiltIn).toBe(true);
});
+ test("should resolve the 'gcc' alias to google-closure-compiler", async () => {
+ const result = await tryResolveBuiltIn("gcc");
+ expect(result).not.toBeNull();
+ expect(result?.compressor).toBeTypeOf("function");
+ expect(result?.label).toBe("google-closure-compiler");
+ expect(result?.isBuiltIn).toBe(true);
+ });
+
test("should return null for unknown compressor name", async () => {
const result = await tryResolveBuiltIn("unknown-compressor");
expect(result).toBeNull();
@@ -176,6 +184,10 @@ describe("Package: utils/compressor-resolver", () => {
expect(isBuiltInCompressor("clean-css")).toBe(true);
});
+ test("should resolve the 'gcc' alias to a built-in", () => {
+ expect(isBuiltInCompressor("gcc")).toBe(true);
+ });
+
test("should return false for unknown compressors", () => {
expect(isBuiltInCompressor("unknown-compressor")).toBe(false);
expect(isBuiltInCompressor("my-custom-pkg")).toBe(false);
@@ -191,6 +203,10 @@ describe("Package: utils/compressor-resolver", () => {
expect(getKnownExportName("clean-css")).toBe("cleanCss");
});
+ test("should resolve the 'gcc' alias to its export name", () => {
+ expect(getKnownExportName("gcc")).toBe("gcc");
+ });
+
test("should return undefined for unknown compressors", () => {
expect(getKnownExportName("unknown")).toBeUndefined();
expect(getKnownExportName("my-custom")).toBeUndefined();
diff --git a/packages/utils/__tests__/filesize-error-paths.test.ts b/packages/utils/__tests__/filesize-error-paths.test.ts
new file mode 100644
index 000000000..82026692d
--- /dev/null
+++ b/packages/utils/__tests__/filesize-error-paths.test.ts
@@ -0,0 +1,63 @@
+/*! node-minify filesize error-path tests - MIT Licensed */
+
+import { mkdtempSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { describe, expect, test, vi } from "vitest";
+
+// Force the read to fail with a generic (non-FileOperationError) error so the
+// size helpers exercise their error-wrapping branch. The file still exists, so
+// the existsSync / isValidFile guards pass first.
+vi.mock("node:fs/promises", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ readFile: vi.fn().mockRejectedValue(new Error("boom read")),
+ };
+});
+
+import { FileOperationError } from "../src/error.ts";
+import {
+ getFilesizeBrotliInBytes,
+ getFilesizeBrotliRaw,
+} from "../src/getFilesizeBrotliInBytes.ts";
+import {
+ getFilesizeGzippedInBytes,
+ getFilesizeGzippedRaw,
+} from "../src/getFilesizeGzippedInBytes.ts";
+
+const dir = mkdtempSync(join(tmpdir(), "nm-filesize-"));
+const file = join(dir, "f.js");
+writeFileSync(file, "console.log(1);");
+
+describe("filesize helpers wrap non-FileOperationError failures", () => {
+ test("getFilesizeGzippedInBytes wraps a read failure", async () => {
+ await expect(getFilesizeGzippedInBytes(file)).rejects.toBeInstanceOf(
+ FileOperationError
+ );
+ await expect(getFilesizeGzippedInBytes(file)).rejects.toThrow(
+ "get gzipped size of"
+ );
+ });
+
+ test("getFilesizeGzippedRaw wraps a read failure", async () => {
+ await expect(getFilesizeGzippedRaw(file)).rejects.toThrow(
+ "get gzipped size of"
+ );
+ });
+
+ test("getFilesizeBrotliInBytes wraps a read failure", async () => {
+ await expect(getFilesizeBrotliInBytes(file)).rejects.toBeInstanceOf(
+ FileOperationError
+ );
+ await expect(getFilesizeBrotliInBytes(file)).rejects.toThrow(
+ "get brotli size of"
+ );
+ });
+
+ test("getFilesizeBrotliRaw wraps a read failure", async () => {
+ await expect(getFilesizeBrotliRaw(file)).rejects.toThrow(
+ "get brotli size of"
+ );
+ });
+});
diff --git a/packages/utils/__tests__/utils.test.ts b/packages/utils/__tests__/utils.test.ts
index ce6174eed..92957bf40 100644
--- a/packages/utils/__tests__/utils.test.ts
+++ b/packages/utils/__tests__/utils.test.ts
@@ -1672,10 +1672,12 @@ describe("Package: utils", () => {
writeFile({ file: secondInput, content: "second" });
const webpContent = Buffer.from("SECOND_INPUT_WEBP");
- const compressor: Settings["compressor"] = vi.fn().mockResolvedValue({
- code: "",
- outputs: [{ format: "webp", content: webpContent }],
- });
+ const compressor: Settings["compressor"] = vi
+ .fn()
+ .mockResolvedValue({
+ code: "",
+ outputs: [{ format: "webp", content: webpContent }],
+ });
const settings: Settings = {
compressor,
input: [firstInput, secondInput],
diff --git a/packages/utils/package.json b/packages/utils/package.json
index 8106f0464..5b3fd1529 100644
--- a/packages/utils/package.json
+++ b/packages/utils/package.json
@@ -13,7 +13,7 @@
"license": "MIT",
"type": "module",
"engines": {
- "node": ">=20.0.0"
+ "node": ">=22.0.0"
},
"directories": {
"lib": "dist",
diff --git a/packages/utils/src/compressor-registry.ts b/packages/utils/src/compressor-registry.ts
new file mode 100644
index 000000000..309ad52bf
--- /dev/null
+++ b/packages/utils/src/compressor-registry.ts
@@ -0,0 +1,158 @@
+/*!
+ * node-minify
+ * Copyright (c) 2011-2026 Rodolphe Stoclin
+ * MIT Licensed
+ */
+
+/**
+ * Status of a compressor in the node-minify ecosystem.
+ */
+export type CompressorStatus =
+ | "recommended"
+ | "supported"
+ | "legacy"
+ | "removed";
+
+/**
+ * Entry in the compressor registry.
+ */
+export interface CompressorEntry {
+ /** Compressor name (e.g., "terser", "babel-minify") */
+ name: string;
+ /** Current status in the ecosystem */
+ status: CompressorStatus;
+ /** NPM package name (e.g., "@node-minify/terser") */
+ packageName: string;
+ /** Recommended replacement for removed compressors */
+ replacement?: string;
+}
+
+/**
+ * Static registry of all compressors in node-minify v11.
+ * Covers 22 compressors across JS, CSS, HTML, JSON, image, and passthrough use cases.
+ */
+export const COMPRESSOR_REGISTRY = [
+ // Recommended (9)
+ {
+ name: "terser",
+ status: "recommended",
+ packageName: "@node-minify/terser",
+ },
+ { name: "oxc", status: "recommended", packageName: "@node-minify/oxc" },
+ { name: "swc", status: "recommended", packageName: "@node-minify/swc" },
+ {
+ name: "esbuild",
+ status: "recommended",
+ packageName: "@node-minify/esbuild",
+ },
+ {
+ name: "lightningcss",
+ status: "recommended",
+ packageName: "@node-minify/lightningcss",
+ },
+ {
+ name: "cssnano",
+ status: "recommended",
+ packageName: "@node-minify/cssnano",
+ },
+ {
+ name: "minify-html",
+ status: "recommended",
+ packageName: "@node-minify/minify-html",
+ },
+ { name: "sharp", status: "recommended", packageName: "@node-minify/sharp" },
+ { name: "svgo", status: "recommended", packageName: "@node-minify/svgo" },
+ // Supported (6)
+ {
+ name: "clean-css",
+ status: "supported",
+ packageName: "@node-minify/clean-css",
+ },
+ { name: "csso", status: "supported", packageName: "@node-minify/csso" },
+ {
+ name: "uglify-js",
+ status: "supported",
+ packageName: "@node-minify/uglify-js",
+ },
+ {
+ name: "google-closure-compiler",
+ status: "supported",
+ packageName: "@node-minify/google-closure-compiler",
+ },
+ {
+ name: "imagemin",
+ status: "supported",
+ packageName: "@node-minify/imagemin",
+ },
+ {
+ name: "html-minifier",
+ status: "supported",
+ packageName: "@node-minify/html-minifier",
+ },
+ // Legacy (2)
+ {
+ name: "jsonminify",
+ status: "legacy",
+ packageName: "@node-minify/jsonminify",
+ },
+ {
+ name: "no-compress",
+ status: "legacy",
+ packageName: "@node-minify/no-compress",
+ },
+ // Removed (5)
+ {
+ name: "babel-minify",
+ status: "removed",
+ packageName: "@node-minify/babel-minify",
+ replacement: "terser",
+ },
+ {
+ name: "uglify-es",
+ status: "removed",
+ packageName: "@node-minify/uglify-es",
+ replacement: "terser",
+ },
+ {
+ name: "yui",
+ status: "removed",
+ packageName: "@node-minify/yui",
+ replacement: "terser or lightningcss",
+ },
+ {
+ name: "sqwish",
+ status: "removed",
+ packageName: "@node-minify/sqwish",
+ replacement: "lightningcss",
+ },
+ {
+ name: "crass",
+ status: "removed",
+ packageName: "@node-minify/crass",
+ replacement: "lightningcss",
+ },
+] as const;
+
+/**
+ * Get all compressors with a specific status.
+ *
+ * @param status - The status to filter by
+ * @returns Array of compressor entries matching the status
+ */
+export function getCompressorsByStatus(
+ status: CompressorStatus
+): CompressorEntry[] {
+ return COMPRESSOR_REGISTRY.filter((entry) => entry.status === status);
+}
+
+/**
+ * Get a compressor entry by its name or its scoped package name.
+ *
+ * @param name - The compressor name (e.g. "yui") or package name (e.g. "@node-minify/yui")
+ * @returns The compressor entry, or undefined if not found
+ */
+export function getCompressorEntry(name: string): CompressorEntry | undefined {
+ return COMPRESSOR_REGISTRY.find(
+ (entry) => entry.name === name || entry.packageName === name
+ );
+}
diff --git a/packages/utils/src/compressor-resolver.ts b/packages/utils/src/compressor-resolver.ts
index 0917f45a5..c25f60258 100644
--- a/packages/utils/src/compressor-resolver.ts
+++ b/packages/utils/src/compressor-resolver.ts
@@ -19,15 +19,10 @@ const KNOWN_COMPRESSOR_EXPORTS: Record = {
swc: "swc",
terser: "terser",
"uglify-js": "uglifyJs",
- "babel-minify": "babelMinify",
- "uglify-es": "uglifyEs",
- yui: "yui",
"clean-css": "cleanCss",
cssnano: "cssnano",
csso: "csso",
lightningcss: "lightningCss",
- crass: "crass",
- sqwish: "sqwish",
"html-minifier": "htmlMinifier",
"minify-html": "minifyHtml",
jsonminify: "jsonMinify",
@@ -37,6 +32,24 @@ const KNOWN_COMPRESSOR_EXPORTS: Record = {
"no-compress": "noCompress",
};
+/**
+ * Friendly aliases mapping a user-facing compressor name to its canonical
+ * built-in package name (e.g. `gcc` → `google-closure-compiler`).
+ */
+const COMPRESSOR_ALIASES: Record = {
+ gcc: "google-closure-compiler",
+};
+
+/**
+ * Resolve a compressor alias to its canonical name.
+ *
+ * @param name - The compressor name or alias
+ * @returns The canonical compressor name, or `name` unchanged if it is not an alias
+ */
+function resolveAlias(name: string): string {
+ return COMPRESSOR_ALIASES[name] ?? name;
+}
+
/**
* Result from resolving a compressor.
*/
@@ -145,21 +158,22 @@ function generateLabel(name: string): string {
export async function tryResolveBuiltIn(
name: string
): Promise {
- if (!(name in KNOWN_COMPRESSOR_EXPORTS)) {
+ const canonical = resolveAlias(name);
+ if (!(canonical in KNOWN_COMPRESSOR_EXPORTS)) {
return null;
}
try {
- const mod = (await import(`@node-minify/${name}`)) as Record<
+ const mod = (await import(`@node-minify/${canonical}`)) as Record<
string,
unknown
>;
- const compressor = extractCompressor(mod, name);
+ const compressor = extractCompressor(mod, canonical);
if (compressor) {
return {
compressor,
- label: name,
+ label: canonical,
isBuiltIn: true,
};
}
@@ -300,7 +314,7 @@ export async function resolveCompressor(
* @returns `true` if the name corresponds to a known built-in compressor, `false` otherwise.
*/
export function isBuiltInCompressor(name: string): boolean {
- return name in KNOWN_COMPRESSOR_EXPORTS;
+ return resolveAlias(name) in KNOWN_COMPRESSOR_EXPORTS;
}
/**
@@ -310,5 +324,5 @@ export function isBuiltInCompressor(name: string): boolean {
* @returns The export name used by the built-in package, or `undefined` if the compressor is not a known built-in
*/
export function getKnownExportName(name: string): string | undefined {
- return KNOWN_COMPRESSOR_EXPORTS[name];
+ return KNOWN_COMPRESSOR_EXPORTS[resolveAlias(name)];
}
diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts
index ad4184c23..486574ef7 100644
--- a/packages/utils/src/index.ts
+++ b/packages/utils/src/index.ts
@@ -1,4 +1,9 @@
import { buildArgs, toBuildArgsOptions } from "./buildArgs.ts";
+import {
+ COMPRESSOR_REGISTRY,
+ getCompressorEntry,
+ getCompressorsByStatus,
+} from "./compressor-registry.ts";
import {
getKnownExportName,
isBuiltInCompressor,
@@ -41,11 +46,14 @@ import { writeFile, writeFileAsync } from "./writeFile.ts";
export {
buildArgs,
+ COMPRESSOR_REGISTRY,
compressSingleFile,
DEFAULT_IGNORES,
deleteFile,
ensureStringContent,
extractSourceMapOption,
+ getCompressorsByStatus,
+ getCompressorEntry,
getContentFromFiles,
getContentFromFilesAsync,
getFilesizeBrotliInBytes,
@@ -81,5 +89,9 @@ export {
};
export type { BuildArgsOptions };
+export type {
+ CompressorEntry,
+ CompressorStatus,
+} from "./compressor-registry.ts";
export type { CompressorResolution } from "./compressor-resolver.ts";
export type { WildcardOptions };
diff --git a/packages/yui/CHANGELOG.md b/packages/yui/CHANGELOG.md
deleted file mode 100644
index 66665b90a..000000000
--- a/packages/yui/CHANGELOG.md
+++ /dev/null
@@ -1,148 +0,0 @@
-# @node-minify/yui
-
-## 10.5.0
-
-### Patch Changes
-
-- Updated dependencies [43c11f7]
-- Updated dependencies [1d5e3ee]
-- Updated dependencies [c21e335]
- - @node-minify/run@10.5.0
- - @node-minify/utils@10.5.0
-
-## 10.4.0
-
-### Patch Changes
-
-- Updated dependencies [2e64877]
-- Updated dependencies [3d4d2d0]
-- Updated dependencies [0a51025]
- - @node-minify/utils@10.4.0
- - @node-minify/run@10.4.0
-
-## 10.3.0
-
-### Patch Changes
-
-- Updated dependencies [1e06c03]
- - @node-minify/utils@10.3.0
- - @node-minify/run@10.3.0
-
-## 10.2.0
-
-### Patch Changes
-
-- Updated dependencies [3c98739]
- - @node-minify/utils@10.2.0
- - @node-minify/run@10.2.0
-
-## 10.1.1
-
-### Patch Changes
-
-- eb785b0: Fix npm install error caused by unresolved workspace:\* references in published packages
-- Updated dependencies [eb785b0]
- - @node-minify/run@10.1.1
- - @node-minify/utils@10.1.1
-
-## 10.1.0
-
-### Patch Changes
-
-- @node-minify/run@10.1.0
-- @node-minify/utils@10.1.0
-
-## 10.0.2
-
-### Patch Changes
-
-- 156a53d: test: verify OIDC publishing with fixed workflow config
-- Updated dependencies [156a53d]
- - @node-minify/run@10.0.2
- - @node-minify/utils@10.0.2
-
-## 10.0.1
-
-### Patch Changes
-
-- d722b73: test: verify OIDC publishing with fixed workflow config
-- Updated dependencies [d722b73]
- - @node-minify/run@10.0.1
- - @node-minify/utils@10.0.1
-
-## 10.0.0
-
-### Major Changes
-
-- 4406c0c: ## v10.0.0
-
- ### Breaking Changes
-
- - **ESM Only**: The package is now pure ESM. Requires Node.js 20+.
- - **Async API**: Callback support has been removed. All `minify()` calls must use `await` or `.then()`.
- - **Named Exports**: All packages now use named exports (e.g., `import { minify } from '@node-minify/core'`).
- - **Sync/Async Split**: Sync functions have been removed or split.
- - **Deprecations**:
- - `@node-minify/babel-minify` (deprecated)
- - `@node-minify/uglify-es` (deprecated)
- - `@node-minify/yui` (deprecated)
- - `@node-minify/sqwish` (deprecated)
- - `@node-minify/crass` (deprecated)
-
- ### Features & Improvements
-
- - **Build System**: Switched from `tsup` to `tsdown` for faster and more reliable builds.
- - **Core**: Moved file I/O operations from compressors to core for better consistency.
- - **Output**: Support for array output with input/output validation.
- - **Security**: Replaced `html-minifier` with `html-minifier-next`.
- - **Typings**: Improved TypeScript definitions and coverage.
- - **Dependencies**: Updated all dependencies.
-
- ### Bug Fixes
-
- - Fixed various import issues and build warnings.
- - Corrected explicit file extensions in imports.
-
-### Patch Changes
-
-- Updated dependencies [4406c0c]
- - @node-minify/utils@10.0.0
- - @node-minify/run@10.0.0
-
-## 10.0.0-next.0
-
-### Major Changes
-
-- 4406c0c: Bump version 10.0.0 next
-
-### Patch Changes
-
-- Updated dependencies [4406c0c]
- - @node-minify/utils@10.0.0-next.0
- - @node-minify/run@10.0.0-next.0
-
-## 9.0.1
-
-### Patch Changes
-
-- c4fcf63: Fixing packages exports and mkdirp import
-- Updated dependencies [c4fcf63]
- - @node-minify/utils@9.0.1
- - @node-minify/run@9.0.1
-
-## 9.0.0
-
-### Major Changes
-
-- 7ab9745: Version 9.0.0
-
- - feat(node): remove node 16, add node 18 (#2092) (c9acdaa4a9906d4019d9381129d66235f3139198)
- - feat(biome): add biome (#2113) (50e9ec46c11c218453de743bed2defa9a83ace7b)
- - fix(yui): fixing yui tests (dd8629712c03b0ac1fe2b94acbb95bc896f8f22f)
- - bump dependencies
-
-### Patch Changes
-
-- Updated dependencies [7ab9745]
- - @node-minify/utils@9.0.0
- - @node-minify/run@9.0.0
diff --git a/packages/yui/LICENSE b/packages/yui/LICENSE
deleted file mode 100644
index aed47f87e..000000000
--- a/packages/yui/LICENSE
+++ /dev/null
@@ -1,21 +0,0 @@
-MIT License
-
-Copyright (c) 2011-2026 Rodolphe Stoclin
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
diff --git a/packages/yui/README.md b/packages/yui/README.md
deleted file mode 100644
index d21d908a3..000000000
--- a/packages/yui/README.md
+++ /dev/null
@@ -1,55 +0,0 @@
-
-
-A very light minifier Node.js module.
-
-
-
-
-
-
-
-
-
-# YUI Compressor
-
-> **DEPRECATED**: YUI Compressor was deprecated by Yahoo in 2013 and is no longer maintained.
-> Please use [`@node-minify/terser`](https://github.com/srod/node-minify/tree/main/packages/terser) for JavaScript or [`@node-minify/cssnano`](https://github.com/srod/node-minify/tree/main/packages/cssnano) for CSS instead.
-
-`Yahoo Compressor` is a plugin for [`node-minify`](https://github.com/srod/node-minify)
-
-It allow you to compress both JavaScript and CSS files.
-
-## Installation
-
-```bash
-npm install @node-minify/core @node-minify/yui
-```
-
-## Usage
-
-```js
-import { minify } from '@node-minify/core';
-import { yui } from '@node-minify/yui';
-
-await minify({
- compressor: yui,
- type: 'js',
- input: 'foo.js',
- output: 'bar.js'
-});
-
-await minify({
- compressor: yui,
- type: 'css',
- input: 'foo.css',
- output: 'bar.css'
-});
-```
-
-## Documentation
-
-Visit https://node-minify.2clics.net/compressors/yui.html for full documentation
-
-## License
-
-[MIT](https://github.com/srod/node-minify/blob/main/LICENSE)
diff --git a/packages/yui/__tests__/yui.test.ts b/packages/yui/__tests__/yui.test.ts
deleted file mode 100644
index 65ccd1ed1..000000000
--- a/packages/yui/__tests__/yui.test.ts
+++ /dev/null
@@ -1,109 +0,0 @@
-/*!
- * node-minify
- * Copyright (c) 2011-2026 Rodolphe Stoclin
- * MIT Licensed
- */
-
-import childProcess from "node:child_process";
-import type { Settings } from "@node-minify/types";
-import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
-import { filesJS } from "../../../tests/files-path.ts";
-import { runOneTest, tests } from "../../../tests/fixtures.ts";
-import { minify } from "../../core/src/index.ts";
-import { yui } from "../src/index.ts";
-
-const compressorLabel = "yui";
-const compressor = yui;
-
-describe("Package: YUI", async () => {
- if (!tests.commonjs || !tests.commoncss) {
- throw new Error("Tests not found");
- }
-
- // Run commonjs tests
- for (const options of tests.commonjs) {
- options.minify.type = "js";
- await runOneTest({ options, compressorLabel, compressor });
- }
-
- // Run commoncss tests
- for (const options of tests.commoncss) {
- await runOneTest({ options, compressorLabel, compressor });
- }
-
- test("should compress with some options", async (): Promise => {
- const settings: Settings = {
- compressor: yui,
- type: "js",
- input: filesJS.oneFileWithWildcards,
- output: filesJS.fileJSOut,
- options: {
- charset: "utf8",
- },
- };
-
- const result = await minify(settings);
- expect(result).not.toBeNull();
- });
-
- test("should catch an error if yui with bad options", async () => {
- const settings: Settings = {
- compressor: yui,
- type: "js",
- input: filesJS.oneFile,
- output: filesJS.fileJSOut,
- options: {
- fake: true,
- },
- };
-
- try {
- return await minify(settings);
- } catch (err: unknown) {
- if (err instanceof Error) {
- return expect(err.toString()).toMatch("Error");
- }
- }
- });
-
- describe("Create errors", () => {
- beforeAll(() => {
- const spy = vi.spyOn(childProcess, "spawn");
- spy.mockImplementation(() => {
- throw new Error();
- });
- });
- test("should throw an error on spawn", async () => {
- const settings: Settings = {
- compressor: yui,
- input: filesJS.oneFile,
- output: filesJS.fileJSOut,
- options: {
- fake: true,
- },
- };
- try {
- await minify(settings);
- } catch (err) {
- return expect(err).not.toBeNull();
- }
- });
- });
-
- describe("yui coverage", () => {
- test("should throw if runCommandLine returns non-string", async () => {
- const run = await import("@node-minify/run");
- const spy = vi
- .spyOn(run, "runCommandLine")
- .mockResolvedValueOnce(null as any);
- await expect(
- yui({ settings: { type: "js" }, content: "code" } as any)
- ).rejects.toThrow("YUI Compressor failed: empty result");
- spy.mockRestore();
- });
- });
-
- afterAll(() => {
- vi.restoreAllMocks();
- });
-});
diff --git a/packages/yui/package.json b/packages/yui/package.json
deleted file mode 100644
index af14c4462..000000000
--- a/packages/yui/package.json
+++ /dev/null
@@ -1,63 +0,0 @@
-{
- "name": "@node-minify/yui",
- "version": "10.5.0",
- "deprecated": "YUI Compressor was deprecated by Yahoo in 2013. Please use @node-minify/terser for JS or @node-minify/cssnano for CSS instead.",
- "description": "yui - yahoo compressor plugin for @node-minify (DEPRECATED)",
- "keywords": [
- "compressor",
- "minify",
- "minifier",
- "yui"
- ],
- "author": "Rodolphe Stoclin ",
- "homepage": "https://github.com/srod/node-minify/tree/main/packages/yui#readme",
- "license": "MIT",
- "type": "module",
- "engines": {
- "node": ">=20.0.0"
- },
- "directories": {
- "lib": "dist",
- "test": "__tests__"
- },
- "types": "./dist/index.d.ts",
- "main": "./dist/index.js",
- "exports": {
- "types": "./dist/index.d.ts",
- "default": "./dist/index.js"
- },
- "sideEffects": false,
- "files": [
- "dist/**/*"
- ],
- "publishConfig": {
- "access": "public"
- },
- "repository": {
- "type": "git",
- "url": "git+https://github.com/srod/node-minify.git"
- },
- "bugs": {
- "url": "https://github.com/srod/node-minify/issues"
- },
- "scripts": {
- "build": "tsdown src/index.ts",
- "check-exports": "attw --pack . --profile esm-only",
- "format:check": "biome check .",
- "lint": "biome lint .",
- "postbuild": "bunx copyfiles -u 1 src/binaries/*.jar dist/",
- "prepublishOnly": "bun run build",
- "test": "vitest run",
- "test:coverage": "vitest run --coverage",
- "test:watch": "vitest",
- "typecheck": "tsc --noEmit",
- "dev": "tsdown src/index.ts --watch"
- },
- "dependencies": {
- "@node-minify/run": "workspace:*",
- "@node-minify/utils": "workspace:*"
- },
- "devDependencies": {
- "@node-minify/types": "workspace:*"
- }
-}
diff --git a/packages/yui/src/binaries/yuicompressor-2.4.7.jar b/packages/yui/src/binaries/yuicompressor-2.4.7.jar
deleted file mode 100644
index 3c9a408aa..000000000
Binary files a/packages/yui/src/binaries/yuicompressor-2.4.7.jar and /dev/null differ
diff --git a/packages/yui/src/index.ts b/packages/yui/src/index.ts
deleted file mode 100644
index 629d12607..000000000
--- a/packages/yui/src/index.ts
+++ /dev/null
@@ -1,72 +0,0 @@
-/*!
- * node-minify
- * Copyright (c) 2011-2026 Rodolphe Stoclin
- * MIT Licensed
- */
-
-import path from "node:path";
-import { fileURLToPath } from "node:url";
-import { runCommandLine } from "@node-minify/run";
-import type { CompressorResult, MinifierOptions } from "@node-minify/types";
-import {
- buildArgs,
- ensureStringContent,
- toBuildArgsOptions,
- warnDeprecation,
-} from "@node-minify/utils";
-
-const __dirname = path.dirname(fileURLToPath(import.meta.url));
-const binYui = `${__dirname}/binaries/yuicompressor-2.4.7.jar`;
-
-/**
- * Run YUI Compressor.
- * @deprecated YUI Compressor was deprecated by Yahoo in 2013. Use @node-minify/terser for JS or @node-minify/cssnano for CSS.
- * @param settings - YUI Compressor options
- * @param content - Content to minify
- * @returns Minified content
- */
-export async function yui({
- settings,
- content,
-}: MinifierOptions): Promise {
- const contentStr = ensureStringContent(content, "yui");
-
- warnDeprecation(
- "yui",
- "YUI Compressor was deprecated by Yahoo in 2013. " +
- "Please migrate to @node-minify/terser for JS or @node-minify/cssnano for CSS."
- );
-
- if (
- !settings?.type ||
- (settings.type !== "js" && settings.type !== "css")
- ) {
- throw new Error("You must specify a type: js or css");
- }
-
- const result = await runCommandLine({
- args: yuiCommand(settings.type, settings?.options ?? {}),
- data: contentStr,
- maxBuffer: settings?.buffer,
- timeout: settings?.timeout,
- silence: settings?.silence,
- });
-
- if (typeof result !== "string") {
- throw new Error("YUI Compressor failed: empty result");
- }
-
- return { code: result };
-}
-
-/**
- * Build YUI Compressor command line arguments.
- * @param type - File type (js or css)
- * @param options - Compressor options
- * @returns Command line arguments array
- */
-function yuiCommand(type: "js" | "css", options: Record) {
- return ["-jar", "-Xss2048k", binYui, "--type", type].concat(
- buildArgs(toBuildArgsOptions(options))
- );
-}
diff --git a/packages/yui/tsconfig.json b/packages/yui/tsconfig.json
deleted file mode 100644
index 8ffe5db9f..000000000
--- a/packages/yui/tsconfig.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "extends": "../../tsconfig.json",
- "compilerOptions": {
- "outDir": "./dist",
- "rootDir": "./src"
- },
- "include": ["src/**/*"]
-}
diff --git a/packages/yui/vitest.config.ts b/packages/yui/vitest.config.ts
deleted file mode 100644
index b1024e6e0..000000000
--- a/packages/yui/vitest.config.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import { defineProject } from "vitest/config";
-
-export default defineProject({
- test: {
- testTimeout: 60000, // YUI tests can take ~8s
- },
-});
diff --git a/scripts/ci-guard-removed-compressors.sh b/scripts/ci-guard-removed-compressors.sh
new file mode 100755
index 000000000..bacb153d1
--- /dev/null
+++ b/scripts/ci-guard-removed-compressors.sh
@@ -0,0 +1,85 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# Scoped package names only ever appear in real imports/deps. Migration docs use
+# bare names ("yui"), so scanning the scoped form broadly is false-positive safe.
+REMOVED_PACKAGES="@node-minify/babel-minify|@node-minify/uglify-es|@node-minify/yui|@node-minify/sqwish|@node-minify/crass|@node-minify/run"
+REMOVED_NAMES="babel-minify|uglify-es|yui|sqwish|crass"
+
+# Path-based exclusions applied natively by grep (--exclude-dir / --exclude): grep
+# never descends into these directories and never opens these files. Filtering by
+# path instead of by matched line means a removed name mentioned *inside* a line
+# of code (e.g. a comment referencing node_modules or CHANGELOG) can never mask a
+# real violation. These locations intentionally name removed packages: changelogs,
+# deps, plans, migration guides, changesets, the registry data, tests, and bundled
+# dist output. cli.md documents the doctor command, so it quotes the removed
+# packages doctor reports on; its only other scoped reference is @node-minify/cli,
+# which is not a removed package.
+EXCLUDE_PATHS=(
+ --exclude-dir="node_modules"
+ --exclude-dir="dist"
+ --exclude-dir="__tests__"
+ --exclude-dir=".changeset"
+ --exclude-dir="plans"
+ --exclude="CHANGELOG*"
+ --exclude="Migrate.md"
+ --exclude="v11-migration*"
+ --exclude="cli.md"
+ --exclude="compressor-registry*"
+)
+
+SCOPED_INCLUDES=(
+ --include="*.ts" --include="*.tsx" --include="*.mts" --include="*.cts"
+ --include="*.js" --include="*.jsx" --include="*.mjs" --include="*.cjs"
+ --include="*.astro" --include="*.json"
+ --include="*.md" --include="*.mdx" --include="*.yml" --include="*.yaml"
+)
+
+# 1. Scoped @node-minify/ across source, examples, the docs site, the
+# Actions, the root README/manifest, and the shipped skill/agent docs — anywhere
+# a real usage would live. Recursive directories honor EXCLUDE_PATHS; the
+# explicitly named files are hand-picked and always scanned.
+# doctor.ts is excluded for the same reason as in scan 3: it is the removal
+# detector and must name the packages it reports on, including the scoped
+# @node-minify/run form.
+SCOPED_MATCHES=$(grep -rnE "$REMOVED_PACKAGES" \
+ "${SCOPED_INCLUDES[@]}" "${EXCLUDE_PATHS[@]}" --exclude="doctor.ts" \
+ packages/ examples/ docs/src/ .github/ \
+ action.yml Readme.md SKILL.md AGENTS.md package.json \
+ 2>/dev/null || true)
+
+# 2. Bare removed-compressor identifiers used as an Action/workflow `compressor:`
+# value. Anchored to `compressor:` so prose and migration tables don't trip it.
+YAML_MATCHES=$(grep -rnE "compressor:[[:space:]]*['\"]?($REMOVED_NAMES)\b" \
+ --exclude-dir="node_modules" \
+ action.yml .github/ packages/action/action.yml \
+ 2>/dev/null || true)
+
+# 3. Bare removed-compressor names in shipped CODE and action manifests: CLI and
+# Action source, the composite actions, and the JS action's YAML manifest.
+# These can never legitimately name a removed compressor, so NO content filter
+# is applied — a removed name in code or workflow YAML is always a violation.
+# `*.yml`/`*.yaml` are scanned so a composite-action reference cannot bypass
+# the guard. The removal detector (doctor.ts) names them by design and is the
+# only exception.
+BARE_CODE_MATCHES=$(grep -rnE "\b($REMOVED_NAMES)\b" \
+ --include="*.ts" --include="*.tsx" --include="*.mts" --include="*.cts" \
+ --include="*.js" --include="*.jsx" --include="*.mjs" --include="*.cjs" \
+ --include="*.yml" --include="*.yaml" \
+ "${EXCLUDE_PATHS[@]}" --exclude="doctor.ts" \
+ packages/cli/src/ packages/action/src/ .github/actions/ \
+ packages/action/action.yml \
+ 2>/dev/null || true)
+
+MATCHES=$(printf '%s\n%s\n%s' \
+ "$SCOPED_MATCHES" "$YAML_MATCHES" "$BARE_CODE_MATCHES" \
+ | grep -v '^[[:space:]]*$' || true)
+
+if [ -n "$MATCHES" ]; then
+ echo "ERROR: Found references to removed packages/compressors:"
+ echo "$MATCHES"
+ exit 1
+fi
+
+echo "Guard passed: no removed compressor references found."
+exit 0