Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
b8f0241
docs: add custom compressors guide
srod Jan 3, 2026
29e307f
feat(cli,benchmark): add support for custom compressors
srod Jan 3, 2026
799fcc4
test(utils): improve compressor-resolver coverage
srod Jan 3, 2026
619ce11
fix(test): handle Windows path separators in relative path test
srod Jan 3, 2026
9081e03
fix: address code review feedback
srod Jan 3, 2026
e33531d
fix(utils): add compressor result validation and address reviews
srod Jan 3, 2026
134ba26
test: add retry logic for fs.cpSync in fixtures to reduce Windows CI …
srod Jan 3, 2026
ce47884
docs: correct export resolution order in custom-compressors.md
srod Jan 3, 2026
62e913c
docs: clarify compressor registration steps
srod Jan 3, 2026
41c5ee0
test: clear module cache in compressor-resolver tests
srod Jan 3, 2026
f87f112
test: use error.code instead of message for fs retry logic
srod Jan 3, 2026
2f84484
test: use Atomics.wait for blocking sleep instead of busy-wait
srod Jan 3, 2026
64aeec1
docs: explain why two registration steps are needed
srod Jan 3, 2026
d0dab12
style: fix non-null assertion lint warning
srod Jan 3, 2026
565662c
📝 Add docstrings to `docs/custom-compressors`
coderabbitai[bot] Jan 3, 2026
7184d5e
Merge pull request #2737 from srod/coderabbitai/docstrings/d0dab12
srod Jan 3, 2026
a7171d8
test(utils): add coverage for local file error handling branches
srod Jan 3, 2026
1eea831
test(utils): improve compressor-resolver test coverage to 100%
srod Jan 3, 2026
1c27a57
style: add missing newlines at end of files
srod Jan 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
186 changes: 186 additions & 0 deletions .plans/custom_compressor_cli_support.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
# Plan: Enable CLI & Benchmark Support for Custom Compressors

## Objective
Allow developers to use custom compressors (external packages or local files) with the `node-minify` CLI and Benchmark tools, without requiring them to be hardcoded in the core repository's registry files.

---

## Status: IMPLEMENTED ✅

Completed January 2026. The CLI and Benchmark tools now support custom compressors via:
- Built-in `@node-minify/*` packages (existing behavior)
- External npm packages (`node-minify --compressor my-custom-compressor`)
- Local file paths (`node-minify --compressor ./my-compressor.js`)

Shared resolver utility added to `@node-minify/utils` (`compressor-resolver.ts`).

---

## Problem
Currently, the CLI and Benchmark tools rely on static arrays/objects (`AVAILABLE_MINIFIER` in `packages/cli/src/config.ts` and `COMPRESSOR_EXPORTS` in `packages/benchmark/src/compressor-loader.ts`) to map a string name (e.g., "terser") to a package import. This prevents users from using their own compressors via the command line, e.g., `node-minify --compressor my-custom-compressor`.

## Proposed Solution
Modify the CLI and Benchmark tools to support dynamic resolution of compressor names.

### 1. CLI Logic Update (`packages/cli/src/index.ts`)
Current logic:
1. Look up name in `AVAILABLE_MINIFIER`.
2. If found, dynamic import `@node-minify/<name>`.

New logic:
1. Look up name in `AVAILABLE_MINIFIER`.
2. If found, use the standard logic (import `@node-minify/<name>`).
3. **If NOT found**:
* Attempt to dynamic import the name directly (e.g., `import("my-custom-pkg")`).
* If that fails, attempt to resolve it relative to the current working directory (for local files).
* Expect the module to export a compressor function (default export or matching name).

### 2. Benchmark Logic Update (`packages/benchmark/src/compressor-loader.ts`)
Current logic:
1. Look up name in `COMPRESSOR_EXPORTS`.
2. Import `@node-minify/<name>`.

New logic:
1. Similar fallback strategy as the CLI.
2. If the known export map fails, try importing the package/file directly.

## Implementation Details

### Step 1: Update CLI (`packages/cli`)
* **File**: `packages/cli/src/index.ts` (function `runOne`, lines 38-94)
* **Change**: Refactor the "Find compressor" block (lines 40-46).
* If `minifierDefinition` is missing, do NOT throw immediately.
* Instead, try to load the module using the provided string.
* Check for exports in this priority order:
1. Named export matching camelCase of package name (e.g., `myTool` from `my-tool`)
2. Named export `compressor`
3. Default export
4. First function export

### Step 2: Update Benchmark (`packages/benchmark`)
* **File**: `packages/benchmark/src/compressor-loader.ts` (function `loadCompressor`, lines 33-45)
* **Change**: Refactor to support arbitrary package names.
* If `COMPRESSOR_EXPORTS[name]` is undefined, assume the package name is the CLI argument.
* Try `import(name)`.
* Fallback to `mod.default`.

### Step 3: Update Documentation
* Update `docs/src/content/docs/custom-compressors.md` to explain how to use this new feature.
* Example: `node-minify --compressor my-published-package ...`
* Example: `node-minify --compressor ./local-compressor.js ...`

### Step 4: Update CLI Help Text
* Update `--compressor` option description:
```
--compressor <name> Built-in compressor name, npm package, or path to local file
```

## Risks & Considerations
* **Security**: dynamically importing arbitrary strings from CLI arguments can be risky if inputs aren't sanitized, but in a CLI tool context, the user is already executing code they control. We should ensure we handle import errors gracefully.
* **ESM vs CJS**: We are using `import()`, which should handle both in modern Node/Bun environments, but we need to ensure local file paths are absolute or properly formatted for `import()` (e.g., `file://...`).

---

## Additional Considerations (Added During Review)

### TypeScript Type Changes
The current type in `packages/cli/src/index.ts` (line 16-18):
```typescript
export type SettingsWithCompressor = Omit<Settings, "compressor"> & {
compressor: (typeof AVAILABLE_MINIFIER)[number]["name"];
};
```
After allowing arbitrary strings, this needs to become:
```typescript
export type SettingsWithCompressor = Omit<Settings, "compressor"> & {
compressor: string;
};
```

### File URL Handling for Local Paths
For local file resolution, use proper `file://` URL format:
```typescript
import path from "node:path";

async function resolveCompressor(name: string) {
// Try known compressors first
const known = AVAILABLE_MINIFIER.find(c => c.name === name);
if (known) {
return import(`@node-minify/${name}`);
}

// Try as npm package
try {
return await import(name);
} catch {
// Try as local file
const isLocalPath = name.startsWith('./') || name.startsWith('/') || name.startsWith('../');
Comment thread
srod marked this conversation as resolved.
if (isLocalPath) {
const absolutePath = path.resolve(process.cwd(), name);
const fileUrl = new URL(`file://${absolutePath}`).href;
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
return await import(fileUrl);
}
throw new Error(`Could not resolve compressor '${name}'. Is it installed?`);
}
}
```

### Error Messages
Add specific error messages for different failure modes:
- Package not found: `"Could not resolve compressor '{name}'. Is it installed?"`
- No valid export: `"Package '{name}' doesn't export a valid compressor function. Expected a function as default export or named export 'compressor'."`
- Invalid return type: `"Compressor '{name}' returned invalid result. Expected { code: string }."`

### Compressor Return Validation
Add runtime validation that the dynamically loaded function returns a proper `CompressorResult`:
```typescript
function validateCompressorResult(result: unknown, name: string): asserts result is CompressorResult {
if (!result || typeof result !== 'object') {
throw new Error(`Compressor '${name}' returned invalid result`);
}
if (!('code' in result) || typeof (result as any).code !== 'string') {
throw new Error(`Compressor '${name}' must return { code: string }`);
}
}
```

### Registry Deduplication
Consider extracting shared resolution logic to avoid duplication between:
- `packages/cli/src/config.ts` (`AVAILABLE_MINIFIER`)
- `packages/benchmark/src/compressor-loader.ts` (`COMPRESSOR_EXPORTS`)

Possible shared utility in `@node-minify/utils`:
```typescript
// packages/utils/src/compressor-resolver.ts
export async function resolveCompressor(name: string): Promise<Compressor>
```

---

## Test Cases

### Required Tests
1. **Built-in compressor**: `node-minify --compressor terser` (existing behavior)
2. **Published npm package**: `node-minify --compressor my-custom-compressor`
3. **Local relative path**: `node-minify --compressor ./compressor.js`
4. **Local absolute path**: `node-minify --compressor /path/to/compressor.js`
5. **Invalid package**: Should fail gracefully with helpful error
6. **Package without valid export**: Should fail with clear message about expected exports
7. **Compressor returns invalid result**: Should fail with validation error

Comment thread
srod marked this conversation as resolved.
### Test File Template
```javascript
// test-compressor.js
export default async function({ content }) {
return { code: content.replace(/\s+/g, ' ') };
}
```

---

## Verification
* Create a temporary local compressor file.
* Run the CLI pointing to it.
* Verify it executes.
* Run benchmark with custom compressor.
* Verify error messages for failure cases.
101 changes: 101 additions & 0 deletions .plans/custom_compressor_guidelines.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# Plan: Custom & Contributed Compressor Guidelines

## Objective
Enable developers to create their own compressors for `node-minify`. This covers two use cases:
1. **External Plugins**: Developers creating standalone packages or local functions for their own projects.
2. **Official Contributions**: Developers adding a new compressor to the `node-minify` monorepo.

---

## Status: IMPLEMENTED ✅

Completed January 2026.

### Completed
- [x] Documentation created at `docs/src/content/docs/custom-compressors.md`
- [x] Sidebar updated in `docs/src/consts.ts` (line 12)
- [x] Compressor interface documented with TypeScript types
- [x] Minimal example with inline compressor
- [x] Handling options example
- [x] Contributing to Core section (package structure, CLI registration, Benchmark registration, testing)
- [x] CLI and Benchmark support for custom compressors (npm packages and local files)

### Remaining Items
- [ ] **Binary content support**: Add note that compressors can handle `Buffer` content (for image compressors like `sharp`, `imagemin`)
- [ ] **Source map support**: Document that compressors can optionally return `map` field
- [ ] **CONTRIBUTING.md**: Consider adding/linking compressor contribution guide in root `CONTRIBUTING.md`

---

## 1. External Plugins (Library Mode)
*Context: You are a user who wants to use a custom tool with `node-minify`.*

**Concept:**
The `minify()` function accepts a `Compressor` function. You do **not** need to register your compressor in any static lists (`AVAILABLE_MINIFIER`, etc.). Those lists are only for built-in compressors.

**Action Items:**
* ~~Create `docs/src/content/docs/custom-compressors.md`.~~ DONE
* **Guide Content**:
* ~~**The Compressor Interface**: Explain `MinifierOptions` and `CompressorResult`.~~ DONE
* ~~**Example**: Writing a simple in-memory replacement compressor.~~ DONE
* ~~**Usage**: Passing the function directly to `minify({ compressor: myFunc })`.~~ DONE
* ~~**CLI Support**: Document npm package and local file path usage.~~ DONE
Comment thread
srod marked this conversation as resolved.

## 2. Official Contributions (Core Integration)
*Context: You are contributing a new compressor package (e.g., `@node-minify/new-tool`) to the repository.*

**Concept:**
To expose a new compressor via the CLI and Benchmark tools, it must be registered in the static configuration files.

**Action Items:**
* ~~Add a "Contributing a Compressor" section to the docs.~~ DONE (in custom-compressors.md)
* Consider also adding to a separate internal guide `CONTRIBUTING.md` **TODO**
* **Checklist for New Compressors**: (All documented in custom-compressors.md)
1. ~~**Package Creation**: Create `packages/<name>` with standard structure.~~ DONE
2. ~~**CLI Registration**: Update `packages/cli/src/config.ts`: Add to `AVAILABLE_MINIFIER` array.~~ DONE
3. ~~**Benchmark Registration**: Update `packages/benchmark/src/compressor-loader.ts`: Add to `COMPRESSOR_EXPORTS`.~~ DONE
4. ~~**Tests**: Add tests using shared helpers.~~ DONE

## Implementation Steps

### Documentation
1. ~~**Create** `docs/src/content/docs/custom-compressors.md`~~ DONE
2. ~~**Update** `docs/src/consts.ts`: Add "Custom Compressors" to the sidebar.~~ DONE

## Verification
* ~~Verify that `AVAILABLE_MINIFIER` and `COMPRESSOR_EXPORTS` are the only two registry files.~~ Confirmed: Yes
* Note: `tests/fixtures.ts` does not have a static list of compressors - tests are defined per-package

---

## Suggested Additions to Existing Docs

### 1. Add Binary Content Note
```markdown
### Binary Content (Images)

For image compressors, the `content` parameter may be a `Buffer` instead of a string.
Your compressor should return `buffer` in the result for binary output:

\`\`\`typescript
export const myImageCompressor: Compressor = async ({ content }) => {
const buffer = content as Buffer;
const optimized = await processImage(buffer);
return { code: "", buffer: optimized };
};
\`\`\`
```

### 2. Add Source Map Note
```markdown
### Source Maps

Compressors can optionally return source maps:

\`\`\`typescript
return {
code: minifiedCode,
map: sourceMapString // Optional
};
\`\`\`
```
15 changes: 15 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,21 @@ import { warnDeprecation } from "@node-minify/utils";
warnDeprecation("@node-minify/old-package", "Use @node-minify/new-package instead");
```

### Dynamic Compressor Resolution
```ts
import { resolveCompressor, isBuiltInCompressor } from "@node-minify/utils";

// Resolve a compressor by name (built-in, npm package, or local file)
const { compressor, label, isBuiltIn } = await resolveCompressor("terser");
const { compressor: custom } = await resolveCompressor("./my-compressor.js");
const { compressor: pkg } = await resolveCompressor("my-custom-package");

// Check if a name is a built-in compressor
if (isBuiltInCompressor("terser")) {
// ...
}
```

### Async / Parallel Patterns

The codebase uses async functions for file operations and parallel compression.
Expand Down
1 change: 1 addition & 0 deletions docs/src/consts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export const SIDEBAR: Sidebar = {
"": [
{ text: "Introduction", link: "introduction" },
{ text: "Getting Started", link: "getting-started" },
{ text: "Custom Compressors", link: "custom-compressors" },
{ text: "Options", link: "options" },
{ text: "CLI", link: "cli" },
{ text: "Benchmark", link: "benchmark" },
Expand Down
11 changes: 11 additions & 0 deletions docs/src/content/docs/benchmark.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,17 @@ node-minify benchmark src/app.js
node-minify benchmark src/app.js --compressors terser,esbuild,swc
```

### Custom Compressors

You can benchmark custom compressors (npm packages or local files):

```bash
# Mix built-in and custom compressors
node-minify benchmark src/app.js --compressors terser,./my-compressor.js,my-custom-package
```

See the [Custom Compressors](/custom-compressors) documentation for details.

### With Options

```bash
Expand Down
14 changes: 14 additions & 0 deletions docs/src/content/docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,20 @@ await run({
});
```

## Custom Compressors

The CLI supports custom compressors in addition to built-in ones:

```bash
# Use an npm package as compressor
node-minify --compressor my-custom-compressor --input 'input.js' --output 'output.js'

# Use a local file as compressor
node-minify --compressor ./my-compressor.js --input 'input.js' --output 'output.js'
```

See the [Custom Compressors](/custom-compressors) documentation for details on creating your own compressor.

## Options

You can pass an `option` as a JSON string to the compressor.
Expand Down
Loading
Loading