@mantiqh/image-optimizer 1.0.1 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +104 -0
- package/LICENSE +21 -0
- package/README.md +119 -0
- package/dist/index.js +204 -102
- package/package.json +6 -5
- package/scripts/deploy.sh +28 -0
- package/src/index.ts +262 -126
package/AGENTS.md
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# AGENTS.md
|
|
2
|
+
|
|
3
|
+
## Project Overview
|
|
4
|
+
|
|
5
|
+
TypeScript CLI tool for image optimization (files, folders, zip archives).
|
|
6
|
+
Single source file: `src/index.ts`. Built with `tsup`, uses `pnpm`.
|
|
7
|
+
|
|
8
|
+
## Commands
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pnpm build # tsup build → dist/index.js (ESM)
|
|
12
|
+
pnpm dev # watch mode, auto-runs on change
|
|
13
|
+
pnpm start # run built output
|
|
14
|
+
pnpm build && pnpm start -- -s <path> # build + run against a path
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
No test framework, linter, or formatter configured. No `pnpm test` or `pnpm lint`.
|
|
18
|
+
|
|
19
|
+
## Testing
|
|
20
|
+
|
|
21
|
+
No automated tests exist. To verify changes, build and run manually:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pnpm build && node dist/index.js -s ./test-images
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Project Structure
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
src/index.ts # entire application (~314 lines)
|
|
31
|
+
dist/index.js # built output (ESM bundle)
|
|
32
|
+
test-images/ # manual test assets
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Code Style
|
|
36
|
+
|
|
37
|
+
### Formatting
|
|
38
|
+
- 2-space indentation
|
|
39
|
+
- Double quotes for strings
|
|
40
|
+
- No trailing semicolons (inconsistent in codebase, but prefer omitting)
|
|
41
|
+
|
|
42
|
+
### Imports
|
|
43
|
+
- ESM `import` syntax only (`"type": "module"` in package.json)
|
|
44
|
+
- Node built-ins first (`fs/promises`, `path`), then npm packages
|
|
45
|
+
- No `import *` — use named/default imports
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
import { Command } from "commander";
|
|
49
|
+
import fs from "fs/promises";
|
|
50
|
+
import path from "path";
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Naming
|
|
54
|
+
- `camelCase` for functions and variables (`processDirectory`, `optimizeBuffer`)
|
|
55
|
+
- `UPPER_SNAKE_CASE` for constants (`SUPPORTED_EXTENSIONS`)
|
|
56
|
+
- No classes — use pure functions
|
|
57
|
+
|
|
58
|
+
### Types
|
|
59
|
+
- TypeScript strict mode enabled (`tsconfig.json: strict: true`)
|
|
60
|
+
- `config` parameter is loosely typed as `any` throughout — follow this pattern for now
|
|
61
|
+
- Declare explicit return types on key functions (`Promise<Buffer>`, `void`)
|
|
62
|
+
- Use `catch (error: any)` pattern for error handling
|
|
63
|
+
|
|
64
|
+
### Error Handling
|
|
65
|
+
- `try/catch` blocks around processing logic
|
|
66
|
+
- Silent fallback: return original data on optimization failure (see `optimizeBuffer`)
|
|
67
|
+
- `process.exit(1)` for fatal errors (missing source, unsupported type)
|
|
68
|
+
- Use `spinner.fail()` for user-facing error messages via `ora`
|
|
69
|
+
|
|
70
|
+
### Async
|
|
71
|
+
- `async/await` everywhere — no callbacks, no `.then()` chains
|
|
72
|
+
|
|
73
|
+
### Comments
|
|
74
|
+
- Section headers: `// --- Section Name ---` (e.g. `// --- Processors ---`)
|
|
75
|
+
- Inline `//` comments for clarification
|
|
76
|
+
- No JSDoc
|
|
77
|
+
|
|
78
|
+
### Architecture Pattern
|
|
79
|
+
- `main()` — entry point, CLI setup via `commander`, dispatches to processors
|
|
80
|
+
- `processDirectory()` / `processZip()` / `processSingleFile()` — mode-specific handlers
|
|
81
|
+
- `optimizeBuffer()` — core sharp-based optimization, returns original on failure
|
|
82
|
+
- `isSupportedImage()` / `determineOutputPath()` — helpers
|
|
83
|
+
|
|
84
|
+
## Dependencies
|
|
85
|
+
|
|
86
|
+
- **sharp** — image processing (resize, compress, format conversion)
|
|
87
|
+
- **commander** — CLI argument parsing
|
|
88
|
+
- **chalk** — terminal colors
|
|
89
|
+
- **ora** — spinner/progress
|
|
90
|
+
- **jszip** — zip file handling
|
|
91
|
+
|
|
92
|
+
## Key Config
|
|
93
|
+
|
|
94
|
+
- **tsconfig**: ES2022 target, NodeNext module resolution, strict mode
|
|
95
|
+
- **package manager**: pnpm 10.12.3
|
|
96
|
+
- **module type**: ESM (`"type": "module"`)
|
|
97
|
+
- **Node version**: ES2022 compatible (Node 18+)
|
|
98
|
+
|
|
99
|
+
## Gotchas
|
|
100
|
+
|
|
101
|
+
- SVG files are returned as-is (sharp corrupts SVGs)
|
|
102
|
+
- If optimized output is larger than input, original is returned
|
|
103
|
+
- Directory output appends `-1` if source === destination (loop prevention)
|
|
104
|
+
- `optimizeBuffer` silently catches all errors — don't add throw/rethrow without checking callers
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Muqtadir A.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# @mantiqh/image-optimizer
|
|
2
|
+
|
|
3
|
+
A universal, high-performance CLI tool to optimize images.
|
|
4
|
+
It works on **Zips, Folders, and Single Files**, recursively processing nested directories and maintaining exact file structures.
|
|
5
|
+
|
|
6
|
+
Built for developers to quickly reduce asset sizes before deployment without complex configs.
|
|
7
|
+
|
|
8
|
+
## 🚀 Features
|
|
9
|
+
|
|
10
|
+
- **Universal Input:** Works on `.zip` files, local folders, or single images.
|
|
11
|
+
- **Recursive:** Process entire directory trees; copies non-image files (CSS, JS) unchanged.
|
|
12
|
+
- **Expanded Support:** Optimizes `JPG`, `PNG`, `WebP`, `AVIF`, `GIF`, `TIFF`. SVGs are skipped (copied as-is).
|
|
13
|
+
- **Fast:** Parallel processing — optimizes up to 5 images concurrently per directory.
|
|
14
|
+
- **Progress Tracking:** Shows `[N/total]` progress while optimizing.
|
|
15
|
+
- **Smart Output:** Creates optimized versions _next to_ your source files by default.
|
|
16
|
+
- **Safe:** If an optimized image is larger than the original, it keeps the original.
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## 📦 Installation & Usage
|
|
21
|
+
|
|
22
|
+
### Method 1: Run with `npx` (Recommended)
|
|
23
|
+
|
|
24
|
+
No installation required. Run it anywhere:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npx @mantiqh/image-optimizer --source ./assets.zip
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### Method 2: Global Install
|
|
31
|
+
|
|
32
|
+
If you use it frequently:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
npm install -g @mantiqh/image-optimizer
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Then run:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
image-optimizer -s ./public/images
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## 💡 Examples
|
|
47
|
+
|
|
48
|
+
### 1. Optimize a Zip File
|
|
49
|
+
|
|
50
|
+
Reads `assets.zip`, optimizes images inside, and saves as `assets-optimized.zip`.
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
npx @mantiqh/image-optimizer -s ./assets.zip
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### 2. Optimize a Folder (Recursive)
|
|
57
|
+
|
|
58
|
+
Optimizes all images inside `./public`, creates `./public-optimized`, and copies all other files (fonts, videos, etc.).
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
npx @mantiqh/image-optimizer -s ./public
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### 3. Optimize a Single File
|
|
65
|
+
|
|
66
|
+
Optimizes `hero.png` and saves it as `hero-optimized.png`.
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
npx @mantiqh/image-optimizer -s ./hero.png
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### 4. Custom Output & Quality
|
|
73
|
+
|
|
74
|
+
Optimizes a folder with **90% quality** and saves to a specific location.
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
npx @mantiqh/image-optimizer -s ./raw-photos -o ./website-ready -q 90
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## 🛠 Command Line Options
|
|
83
|
+
|
|
84
|
+
| Flag | Alias | Description | Default |
|
|
85
|
+
| :---------- | :---- | :------------------------------------------- | :------------------- |
|
|
86
|
+
| `--source` | `-s` | **(Required)** Path to file, folder, or zip. | - |
|
|
87
|
+
| `--output` | `-o` | Custom output path. | `[source]-optimized` |
|
|
88
|
+
| `--quality` | `-q` | Compression quality (1-100). | `80` |
|
|
89
|
+
| `--width` | `-w` | Max width in pixels (resizes if larger). | `1600` |
|
|
90
|
+
| `--help` | `-h` | Show help and examples. | - |
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## 💻 Development
|
|
95
|
+
|
|
96
|
+
1. **Clone the repo:**
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
git clone <your-repo-url>
|
|
100
|
+
cd image-optimizer
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
2. **Install dependencies:**
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
pnpm install
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
3. **Run in dev mode:**
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
# Test on a folder
|
|
113
|
+
pnpm dev --source ~/Desktop/test-folder
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
4. **Build:**
|
|
117
|
+
```bash
|
|
118
|
+
pnpm build
|
|
119
|
+
```
|
package/dist/index.js
CHANGED
|
@@ -8,128 +8,230 @@ import JSZip from "jszip";
|
|
|
8
8
|
import sharp from "sharp";
|
|
9
9
|
import chalk from "chalk";
|
|
10
10
|
import ora from "ora";
|
|
11
|
+
function logOutputPath(outputPath) {
|
|
12
|
+
console.log(`
|
|
13
|
+
\u{1F4C1} Output: ${chalk.cyan(outputPath)}`);
|
|
14
|
+
}
|
|
15
|
+
var SUPPORTED_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
16
|
+
".jpg",
|
|
17
|
+
".jpeg",
|
|
18
|
+
".png",
|
|
19
|
+
".webp",
|
|
20
|
+
".gif",
|
|
21
|
+
".avif",
|
|
22
|
+
".tiff",
|
|
23
|
+
".svg"
|
|
24
|
+
]);
|
|
11
25
|
var program = new Command();
|
|
12
26
|
program.name("image-optimizer").description(
|
|
13
27
|
chalk.cyan(
|
|
14
|
-
"\u{1F680} CLI to optimize images (
|
|
28
|
+
"\u{1F680} Universal CLI to optimize images (File, Folder, or Zip). Supports JPG, PNG, WebP, AVIF, GIF, TIFF, SVG."
|
|
15
29
|
)
|
|
16
|
-
).version("1.
|
|
30
|
+
).version("1.2.1").requiredOption(
|
|
31
|
+
"-s, --source <path>",
|
|
32
|
+
"Path to the input file, folder, or zip"
|
|
33
|
+
).option(
|
|
17
34
|
"-o, --output <path>",
|
|
18
|
-
"Path to the output
|
|
19
|
-
).option("-q, --quality <number>", "Quality of compression (1-100)", "80").option("-w, --width <number>", "Max width to resize images to", "1600").
|
|
20
|
-
"after",
|
|
21
|
-
`
|
|
22
|
-
${chalk.yellow("Examples:")}
|
|
23
|
-
${chalk.green("$ npx @mantiqh/image-optimizer --source assets.zip")}
|
|
24
|
-
${chalk.gray(
|
|
25
|
-
"# Optimizes assets.zip and saves as assets-optimized.zip (default settings)"
|
|
26
|
-
)}
|
|
27
|
-
|
|
28
|
-
${chalk.green(
|
|
29
|
-
"$ npx @mantiqh/image-optimizer -s ./raw.zip -o ./final.zip -q 90"
|
|
30
|
-
)}
|
|
31
|
-
${chalk.gray("# Optimizes raw.zip to final.zip with 90% quality")}
|
|
32
|
-
|
|
33
|
-
${chalk.green("$ npx @mantiqh/image-optimizer -s huge-images.zip -w 800")}
|
|
34
|
-
${chalk.gray("# Resizes all images to max 800px width")}
|
|
35
|
-
`
|
|
36
|
-
).parse(process.argv);
|
|
35
|
+
"Path to the output (default: source location + -optimized)"
|
|
36
|
+
).option("-q, --quality <number>", "Quality of compression (1-100)", "80").option("-w, --width <number>", "Max width to resize images to", "1600").parse(process.argv);
|
|
37
37
|
var options = program.opts();
|
|
38
38
|
async function main() {
|
|
39
|
-
const spinner = ora("
|
|
39
|
+
const spinner = ora("Analyzing source...").start();
|
|
40
40
|
try {
|
|
41
41
|
const sourcePath = path.resolve(process.cwd(), options.source);
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const dir = path.dirname(sourcePath);
|
|
47
|
-
const ext = path.extname(sourcePath);
|
|
48
|
-
const name = path.basename(sourcePath, ext);
|
|
49
|
-
outputPath = path.join(dir, `${name}-optimized${ext}`);
|
|
50
|
-
}
|
|
51
|
-
if (!await fileExists(sourcePath)) {
|
|
52
|
-
spinner.fail(`Source file not found: ${sourcePath}`);
|
|
42
|
+
try {
|
|
43
|
+
await fs.access(sourcePath);
|
|
44
|
+
} catch {
|
|
45
|
+
spinner.fail(`Source not found: ${sourcePath}`);
|
|
53
46
|
process.exit(1);
|
|
54
47
|
}
|
|
55
|
-
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
newZip.file(fileName, content);
|
|
95
|
-
}
|
|
96
|
-
processedCount++;
|
|
97
|
-
} catch (err) {
|
|
98
|
-
newZip.file(fileName, content);
|
|
99
|
-
}
|
|
100
|
-
} else {
|
|
101
|
-
newZip.file(fileName, content);
|
|
102
|
-
}
|
|
48
|
+
const stats = await fs.stat(sourcePath);
|
|
49
|
+
const config = {
|
|
50
|
+
quality: parseInt(options.quality),
|
|
51
|
+
width: parseInt(options.width),
|
|
52
|
+
spinner
|
|
53
|
+
};
|
|
54
|
+
if (stats.isDirectory()) {
|
|
55
|
+
const outputPath = determineOutputPath(
|
|
56
|
+
sourcePath,
|
|
57
|
+
options.output,
|
|
58
|
+
"-optimized"
|
|
59
|
+
);
|
|
60
|
+
spinner.text = "Processing Directory...";
|
|
61
|
+
await processDirectory(sourcePath, outputPath, config);
|
|
62
|
+
logOutputPath(outputPath);
|
|
63
|
+
} else if (sourcePath.endsWith(".zip")) {
|
|
64
|
+
const outputPath = determineOutputPath(
|
|
65
|
+
sourcePath,
|
|
66
|
+
options.output,
|
|
67
|
+
"-optimized.zip"
|
|
68
|
+
);
|
|
69
|
+
spinner.text = "Processing Zip...";
|
|
70
|
+
await processZip(sourcePath, outputPath, config);
|
|
71
|
+
logOutputPath(outputPath);
|
|
72
|
+
} else if (isSupportedImage(sourcePath)) {
|
|
73
|
+
const ext = path.extname(sourcePath);
|
|
74
|
+
const outputPath = determineOutputPath(
|
|
75
|
+
sourcePath,
|
|
76
|
+
options.output,
|
|
77
|
+
`-optimized${ext}`
|
|
78
|
+
);
|
|
79
|
+
spinner.text = "Processing Single File...";
|
|
80
|
+
await processSingleFile(sourcePath, outputPath, config);
|
|
81
|
+
logOutputPath(outputPath);
|
|
82
|
+
} else {
|
|
83
|
+
spinner.fail(
|
|
84
|
+
"Unsupported file type. Please provide a Folder, Zip, or supported Image."
|
|
85
|
+
);
|
|
86
|
+
process.exit(1);
|
|
103
87
|
}
|
|
104
|
-
spinner.text = "Generating output zip...";
|
|
105
|
-
const outputBuffer = await newZip.generateAsync({
|
|
106
|
-
type: "nodebuffer",
|
|
107
|
-
compression: "DEFLATE",
|
|
108
|
-
compressionOptions: { level: 6 }
|
|
109
|
-
});
|
|
110
|
-
await fs.writeFile(outputPath, outputBuffer);
|
|
111
88
|
spinner.succeed(chalk.green("Optimization Complete!"));
|
|
112
|
-
console.log(`
|
|
113
|
-
\u{1F4C1} Output: ${chalk.cyan(outputPath)}`);
|
|
114
|
-
console.log(`\u{1F5BC}\uFE0F Images Processed: ${processedCount}`);
|
|
115
|
-
console.log(
|
|
116
|
-
`\u{1F4BE} Space Saved: ${chalk.bold(
|
|
117
|
-
(savedBytes / 1024 / 1024).toFixed(2)
|
|
118
|
-
)} MB
|
|
119
|
-
`
|
|
120
|
-
);
|
|
121
89
|
} catch (error) {
|
|
122
90
|
spinner.fail("Error occurred");
|
|
123
91
|
console.error(chalk.red(error.message));
|
|
124
92
|
process.exit(1);
|
|
125
93
|
}
|
|
126
94
|
}
|
|
127
|
-
async function
|
|
95
|
+
async function processDirectory(source, destination, config) {
|
|
96
|
+
if (source === destination) {
|
|
97
|
+
destination += "-1";
|
|
98
|
+
}
|
|
99
|
+
await fs.mkdir(destination, { recursive: true });
|
|
100
|
+
const entries = await fs.readdir(source, { withFileTypes: true });
|
|
101
|
+
const dirEntries = entries.filter((e) => e.isDirectory());
|
|
102
|
+
const imageEntries = entries.filter((e) => e.isFile() && isSupportedImage(e.name) && path.extname(e.name).toLowerCase() !== ".svg");
|
|
103
|
+
const nonImageEntries = entries.filter((e) => e.isFile() && (!isSupportedImage(e.name) || path.extname(e.name).toLowerCase() === ".svg"));
|
|
104
|
+
for (const entry of dirEntries) {
|
|
105
|
+
await processDirectory(path.join(source, entry.name), path.join(destination, entry.name), config);
|
|
106
|
+
}
|
|
107
|
+
let processedCount = 0;
|
|
108
|
+
const batchSize = 5;
|
|
109
|
+
const totalImages = imageEntries.length;
|
|
110
|
+
for (let i = 0; i < imageEntries.length; i += batchSize) {
|
|
111
|
+
const batch = imageEntries.slice(i, Math.min(i + batchSize, imageEntries.length));
|
|
112
|
+
const results = await Promise.all(batch.map(async (entry, j) => {
|
|
113
|
+
const srcPath = path.join(source, entry.name);
|
|
114
|
+
const destPath = path.join(destination, entry.name);
|
|
115
|
+
const num = i + j + 1;
|
|
116
|
+
config.spinner.text = `Optimizing [${num}/${totalImages}]: ${entry.name}`;
|
|
117
|
+
try {
|
|
118
|
+
const buffer = await fs.readFile(srcPath);
|
|
119
|
+
const optimizedBuffer = await optimizeBuffer(buffer, path.extname(entry.name), config);
|
|
120
|
+
await fs.writeFile(destPath, optimizedBuffer);
|
|
121
|
+
return 1;
|
|
122
|
+
} catch (error) {
|
|
123
|
+
console.error(chalk.red(`Failed to optimize ${entry.name}: ${error.message}`));
|
|
124
|
+
await fs.copyFile(srcPath, destPath);
|
|
125
|
+
return 1;
|
|
126
|
+
}
|
|
127
|
+
}));
|
|
128
|
+
processedCount += results.length;
|
|
129
|
+
}
|
|
130
|
+
await Promise.all(nonImageEntries.map((entry) => {
|
|
131
|
+
return fs.copyFile(path.join(source, entry.name), path.join(destination, entry.name));
|
|
132
|
+
}));
|
|
133
|
+
return processedCount;
|
|
134
|
+
}
|
|
135
|
+
async function processZip(source, destination, config) {
|
|
136
|
+
const zipData = await fs.readFile(source);
|
|
137
|
+
const zip = await JSZip.loadAsync(zipData);
|
|
138
|
+
const newZip = new JSZip();
|
|
139
|
+
const fileNames = Object.keys(zip.files);
|
|
140
|
+
const imageFiles = fileNames.filter((f) => !zip.files[f].dir && isSupportedImage(f) && path.extname(f).toLowerCase() !== ".svg");
|
|
141
|
+
const totalImages = imageFiles.length;
|
|
142
|
+
let imageIndex = 0;
|
|
143
|
+
for (const fileName of fileNames) {
|
|
144
|
+
const file = zip.files[fileName];
|
|
145
|
+
if (file.dir) {
|
|
146
|
+
newZip.folder(fileName);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
const content = await file.async("nodebuffer");
|
|
150
|
+
const ext = path.extname(fileName).toLowerCase();
|
|
151
|
+
if (ext === ".svg") {
|
|
152
|
+
newZip.file(fileName, content);
|
|
153
|
+
} else if (isSupportedImage(fileName)) {
|
|
154
|
+
imageIndex++;
|
|
155
|
+
config.spinner.text = `Optimizing in zip [${imageIndex}/${totalImages}]: ${fileName}`;
|
|
156
|
+
try {
|
|
157
|
+
const optimized = await optimizeBuffer(content, path.extname(fileName), config);
|
|
158
|
+
newZip.file(fileName, optimized);
|
|
159
|
+
} catch (error) {
|
|
160
|
+
console.error(chalk.red(`Failed to optimize ${fileName}: ${error.message}`));
|
|
161
|
+
newZip.file(fileName, content);
|
|
162
|
+
}
|
|
163
|
+
} else {
|
|
164
|
+
newZip.file(fileName, content);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
config.spinner.text = "Generating Output Zip...";
|
|
168
|
+
const outputBuffer = await newZip.generateAsync({
|
|
169
|
+
type: "nodebuffer",
|
|
170
|
+
compression: "DEFLATE",
|
|
171
|
+
compressionOptions: { level: 6 }
|
|
172
|
+
});
|
|
173
|
+
await fs.writeFile(destination, outputBuffer);
|
|
174
|
+
}
|
|
175
|
+
async function processSingleFile(source, destination, config) {
|
|
176
|
+
const buffer = await fs.readFile(source);
|
|
177
|
+
const optimized = await optimizeBuffer(buffer, path.extname(source), config);
|
|
178
|
+
await fs.writeFile(destination, optimized);
|
|
179
|
+
}
|
|
180
|
+
async function optimizeBuffer(buffer, ext, config) {
|
|
181
|
+
const extension = ext.toLowerCase();
|
|
128
182
|
try {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
183
|
+
let pipeline = sharp(buffer, { animated: true });
|
|
184
|
+
const metadata = await pipeline.metadata();
|
|
185
|
+
if (metadata.width && metadata.width > config.width) {
|
|
186
|
+
pipeline = pipeline.resize({ width: config.width });
|
|
187
|
+
}
|
|
188
|
+
switch (extension) {
|
|
189
|
+
case ".svg":
|
|
190
|
+
return buffer;
|
|
191
|
+
case ".jpeg":
|
|
192
|
+
case ".jpg":
|
|
193
|
+
pipeline = pipeline.jpeg({ quality: config.quality, mozjpeg: true });
|
|
194
|
+
break;
|
|
195
|
+
case ".png":
|
|
196
|
+
pipeline = pipeline.png({
|
|
197
|
+
quality: config.quality,
|
|
198
|
+
compressionLevel: 9,
|
|
199
|
+
palette: true
|
|
200
|
+
});
|
|
201
|
+
break;
|
|
202
|
+
case ".webp":
|
|
203
|
+
pipeline = pipeline.webp({ quality: config.quality });
|
|
204
|
+
break;
|
|
205
|
+
case ".gif":
|
|
206
|
+
pipeline = pipeline.gif({ colors: 128 });
|
|
207
|
+
break;
|
|
208
|
+
case ".avif":
|
|
209
|
+
pipeline = pipeline.avif({ quality: config.quality });
|
|
210
|
+
break;
|
|
211
|
+
case ".tiff":
|
|
212
|
+
pipeline = pipeline.tiff({ quality: config.quality });
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
const outputBuffer = await pipeline.toBuffer();
|
|
216
|
+
if (outputBuffer.length >= buffer.length) {
|
|
217
|
+
return buffer;
|
|
218
|
+
}
|
|
219
|
+
return outputBuffer;
|
|
220
|
+
} catch (err) {
|
|
221
|
+
return buffer;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
function isSupportedImage(filePath) {
|
|
225
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
226
|
+
return SUPPORTED_EXTENSIONS.has(ext);
|
|
227
|
+
}
|
|
228
|
+
function determineOutputPath(source, userOutput, suffix) {
|
|
229
|
+
if (userOutput) {
|
|
230
|
+
return path.resolve(process.cwd(), userOutput);
|
|
133
231
|
}
|
|
232
|
+
const dir = path.dirname(source);
|
|
233
|
+
const ext = path.extname(source);
|
|
234
|
+
const name = path.basename(source, ext);
|
|
235
|
+
return path.join(dir, `${name}${suffix}`);
|
|
134
236
|
}
|
|
135
237
|
main();
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mantiqh/image-optimizer",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "CLI to optimize images
|
|
3
|
+
"version": "1.2.1",
|
|
4
|
+
"description": "CLI to optimize images.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
7
7
|
"image-optimizer": "./dist/index.js"
|
|
@@ -11,11 +11,12 @@
|
|
|
11
11
|
"build": "tsup src/index.ts --format esm --clean",
|
|
12
12
|
"dev": "tsup src/index.ts --format esm --watch --onSuccess \"node dist/index.js\"",
|
|
13
13
|
"start": "node dist/index.js",
|
|
14
|
-
"prepublishOnly": "pnpm build"
|
|
14
|
+
"prepublishOnly": "pnpm build",
|
|
15
|
+
"deploy": "bash scripts/deploy.sh"
|
|
15
16
|
},
|
|
16
17
|
"keywords": [],
|
|
17
|
-
"author": "",
|
|
18
|
-
"license": "
|
|
18
|
+
"author": "Muqtadir A.",
|
|
19
|
+
"license": "MIT",
|
|
19
20
|
"packageManager": "pnpm@10.12.3",
|
|
20
21
|
"dependencies": {
|
|
21
22
|
"chalk": "^5.6.2",
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
BUMP="${1:-patch}"
|
|
5
|
+
|
|
6
|
+
echo "==> Bumping version ($BUMP)..."
|
|
7
|
+
|
|
8
|
+
# Bump version in package.json, creates git commit + tag
|
|
9
|
+
VERSION=$(npm version "$BUMP" --no-git-tag-version)
|
|
10
|
+
|
|
11
|
+
# Sync version to CLI --version output
|
|
12
|
+
sed -i '' "s/.version(\"[^\"]*\")/.version(\"${VERSION#v}\")/" src/index.ts
|
|
13
|
+
|
|
14
|
+
echo "==> Building..."
|
|
15
|
+
pnpm build
|
|
16
|
+
|
|
17
|
+
echo "==> Committing version bump..."
|
|
18
|
+
git add package.json src/index.ts
|
|
19
|
+
git commit -m "$VERSION"
|
|
20
|
+
git tag "$VERSION"
|
|
21
|
+
|
|
22
|
+
echo "==> Pushing to git..."
|
|
23
|
+
git push && git push --tags
|
|
24
|
+
|
|
25
|
+
echo "==> Publishing to npm..."
|
|
26
|
+
npm publish --access public
|
|
27
|
+
|
|
28
|
+
echo "==> Done! Published $VERSION"
|
package/src/index.ts
CHANGED
|
@@ -7,177 +7,313 @@ import sharp from "sharp";
|
|
|
7
7
|
import chalk from "chalk";
|
|
8
8
|
import ora from "ora";
|
|
9
9
|
|
|
10
|
+
function logOutputPath(outputPath: string): void {
|
|
11
|
+
console.log(`\n📁 Output: ${chalk.cyan(outputPath)}`);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// --- Configuration ---
|
|
15
|
+
const SUPPORTED_EXTENSIONS = new Set([
|
|
16
|
+
".jpg",
|
|
17
|
+
".jpeg",
|
|
18
|
+
".png",
|
|
19
|
+
".webp",
|
|
20
|
+
".gif",
|
|
21
|
+
".avif",
|
|
22
|
+
".tiff",
|
|
23
|
+
".svg",
|
|
24
|
+
]);
|
|
25
|
+
|
|
10
26
|
const program = new Command();
|
|
11
27
|
|
|
12
28
|
program
|
|
13
29
|
.name("image-optimizer")
|
|
14
30
|
.description(
|
|
15
31
|
chalk.cyan(
|
|
16
|
-
"🚀 CLI to optimize images (
|
|
32
|
+
"🚀 Universal CLI to optimize images (File, Folder, or Zip). Supports JPG, PNG, WebP, AVIF, GIF, TIFF, SVG."
|
|
17
33
|
)
|
|
18
34
|
)
|
|
19
|
-
.version("1.
|
|
20
|
-
.requiredOption(
|
|
35
|
+
.version("1.2.1")
|
|
36
|
+
.requiredOption(
|
|
37
|
+
"-s, --source <path>",
|
|
38
|
+
"Path to the input file, folder, or zip"
|
|
39
|
+
)
|
|
21
40
|
.option(
|
|
22
41
|
"-o, --output <path>",
|
|
23
|
-
"Path to the output
|
|
42
|
+
"Path to the output (default: source location + -optimized)"
|
|
24
43
|
)
|
|
25
44
|
.option("-q, --quality <number>", "Quality of compression (1-100)", "80")
|
|
26
45
|
.option("-w, --width <number>", "Max width to resize images to", "1600")
|
|
27
|
-
.addHelpText(
|
|
28
|
-
"after",
|
|
29
|
-
`
|
|
30
|
-
${chalk.yellow("Examples:")}
|
|
31
|
-
${chalk.green("$ npx @mantiqh/image-optimizer --source assets.zip")}
|
|
32
|
-
${chalk.gray(
|
|
33
|
-
"# Optimizes assets.zip and saves as assets-optimized.zip (default settings)"
|
|
34
|
-
)}
|
|
35
|
-
|
|
36
|
-
${chalk.green(
|
|
37
|
-
"$ npx @mantiqh/image-optimizer -s ./raw.zip -o ./final.zip -q 90"
|
|
38
|
-
)}
|
|
39
|
-
${chalk.gray("# Optimizes raw.zip to final.zip with 90% quality")}
|
|
40
|
-
|
|
41
|
-
${chalk.green("$ npx @mantiqh/image-optimizer -s huge-images.zip -w 800")}
|
|
42
|
-
${chalk.gray("# Resizes all images to max 800px width")}
|
|
43
|
-
`
|
|
44
|
-
)
|
|
45
46
|
.parse(process.argv);
|
|
46
47
|
|
|
47
48
|
const options = program.opts();
|
|
48
49
|
|
|
49
50
|
async function main() {
|
|
50
|
-
const spinner = ora("
|
|
51
|
+
const spinner = ora("Analyzing source...").start();
|
|
51
52
|
|
|
52
53
|
try {
|
|
53
|
-
//
|
|
54
|
+
// Resolve absolute path of the source immediately
|
|
54
55
|
const sourcePath = path.resolve(process.cwd(), options.source);
|
|
55
56
|
|
|
56
|
-
//
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const ext = path.extname(sourcePath);
|
|
63
|
-
const name = path.basename(sourcePath, ext);
|
|
64
|
-
outputPath = path.join(dir, `${name}-optimized${ext}`);
|
|
57
|
+
// Check if source exists
|
|
58
|
+
try {
|
|
59
|
+
await fs.access(sourcePath);
|
|
60
|
+
} catch {
|
|
61
|
+
spinner.fail(`Source not found: ${sourcePath}`);
|
|
62
|
+
process.exit(1);
|
|
65
63
|
}
|
|
66
64
|
|
|
67
|
-
|
|
68
|
-
|
|
65
|
+
const stats = await fs.stat(sourcePath);
|
|
66
|
+
const config = {
|
|
67
|
+
quality: parseInt(options.quality),
|
|
68
|
+
width: parseInt(options.width),
|
|
69
|
+
spinner,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
if (stats.isDirectory()) {
|
|
73
|
+
// MODE: Folder
|
|
74
|
+
// Fix: Ensure output is next to source, not CWD
|
|
75
|
+
const outputPath = determineOutputPath(
|
|
76
|
+
sourcePath,
|
|
77
|
+
options.output,
|
|
78
|
+
"-optimized"
|
|
79
|
+
);
|
|
80
|
+
spinner.text = "Processing Directory...";
|
|
81
|
+
await processDirectory(sourcePath, outputPath, config);
|
|
82
|
+
logOutputPath(outputPath); // Log the output path after processing
|
|
83
|
+
} else if (sourcePath.endsWith(".zip")) {
|
|
84
|
+
// MODE: Zip
|
|
85
|
+
const outputPath = determineOutputPath(
|
|
86
|
+
sourcePath,
|
|
87
|
+
options.output,
|
|
88
|
+
"-optimized.zip"
|
|
89
|
+
);
|
|
90
|
+
spinner.text = "Processing Zip...";
|
|
91
|
+
await processZip(sourcePath, outputPath, config);
|
|
92
|
+
logOutputPath(outputPath); // Log the output path after processing
|
|
93
|
+
} else if (isSupportedImage(sourcePath)) {
|
|
94
|
+
// MODE: Single File
|
|
95
|
+
const ext = path.extname(sourcePath);
|
|
96
|
+
const outputPath = determineOutputPath(
|
|
97
|
+
sourcePath,
|
|
98
|
+
options.output,
|
|
99
|
+
`-optimized${ext}`
|
|
100
|
+
);
|
|
101
|
+
spinner.text = "Processing Single File...";
|
|
102
|
+
await processSingleFile(sourcePath, outputPath, config);
|
|
103
|
+
logOutputPath(outputPath); // Log the output path after processing
|
|
104
|
+
} else {
|
|
105
|
+
spinner.fail(
|
|
106
|
+
"Unsupported file type. Please provide a Folder, Zip, or supported Image."
|
|
107
|
+
);
|
|
69
108
|
process.exit(1);
|
|
70
109
|
}
|
|
71
110
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
111
|
+
spinner.succeed(chalk.green("Optimization Complete!"));
|
|
112
|
+
} catch (error: any) {
|
|
113
|
+
spinner.fail("Error occurred");
|
|
114
|
+
console.error(chalk.red(error.message));
|
|
115
|
+
process.exit(1);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// --- Processors ---
|
|
120
|
+
|
|
121
|
+
async function processDirectory(
|
|
122
|
+
source: string,
|
|
123
|
+
destination: string,
|
|
124
|
+
config: any
|
|
125
|
+
): Promise<number> {
|
|
126
|
+
if (source === destination) {
|
|
127
|
+
destination += "-1";
|
|
128
|
+
}
|
|
129
|
+
await fs.mkdir(destination, { recursive: true });
|
|
76
130
|
|
|
77
|
-
|
|
78
|
-
const quality = parseInt(options.quality);
|
|
79
|
-
const maxWidth = parseInt(options.width);
|
|
131
|
+
const entries = await fs.readdir(source, { withFileTypes: true });
|
|
80
132
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
let savedBytes = 0;
|
|
133
|
+
const dirEntries = entries.filter(e => e.isDirectory());
|
|
134
|
+
const imageEntries = entries.filter(e => e.isFile() && isSupportedImage(e.name) && path.extname(e.name).toLowerCase() !== ".svg");
|
|
135
|
+
const nonImageEntries = entries.filter(e => e.isFile() && (!isSupportedImage(e.name) || path.extname(e.name).toLowerCase() === ".svg"));
|
|
85
136
|
|
|
86
|
-
|
|
137
|
+
// Process directories sequentially (recursive)
|
|
138
|
+
for (const entry of dirEntries) {
|
|
139
|
+
await processDirectory(path.join(source, entry.name), path.join(destination, entry.name), config);
|
|
140
|
+
}
|
|
87
141
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
142
|
+
// Process images in parallel batches of 5
|
|
143
|
+
let processedCount = 0;
|
|
144
|
+
const batchSize = 5;
|
|
145
|
+
const totalImages = imageEntries.length;
|
|
91
146
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
147
|
+
for (let i = 0; i < imageEntries.length; i += batchSize) {
|
|
148
|
+
const batch = imageEntries.slice(i, Math.min(i + batchSize, imageEntries.length));
|
|
149
|
+
const results = await Promise.all(batch.map(async (entry, j) => {
|
|
150
|
+
const srcPath = path.join(source, entry.name);
|
|
151
|
+
const destPath = path.join(destination, entry.name);
|
|
152
|
+
const num = i + j + 1;
|
|
153
|
+
config.spinner.text = `Optimizing [${num}/${totalImages}]: ${entry.name}`;
|
|
154
|
+
try {
|
|
155
|
+
const buffer = await fs.readFile(srcPath);
|
|
156
|
+
const optimizedBuffer = await optimizeBuffer(buffer, path.extname(entry.name), config);
|
|
157
|
+
await fs.writeFile(destPath, optimizedBuffer);
|
|
158
|
+
return 1;
|
|
159
|
+
} catch (error: any) {
|
|
160
|
+
console.error(chalk.red(`Failed to optimize ${entry.name}: ${error.message}`));
|
|
161
|
+
await fs.copyFile(srcPath, destPath);
|
|
162
|
+
return 1;
|
|
95
163
|
}
|
|
164
|
+
}));
|
|
165
|
+
processedCount += results.length;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Copy non-image files in parallel
|
|
169
|
+
await Promise.all(nonImageEntries.map(entry => {
|
|
170
|
+
return fs.copyFile(path.join(source, entry.name), path.join(destination, entry.name));
|
|
171
|
+
}));
|
|
172
|
+
|
|
173
|
+
return processedCount;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function processZip(source: string, destination: string, config: any) {
|
|
177
|
+
const zipData = await fs.readFile(source);
|
|
178
|
+
const zip = await JSZip.loadAsync(zipData);
|
|
179
|
+
const newZip = new JSZip();
|
|
180
|
+
|
|
181
|
+
const fileNames = Object.keys(zip.files);
|
|
182
|
+
const imageFiles = fileNames.filter(f => !zip.files[f].dir && isSupportedImage(f) && path.extname(f).toLowerCase() !== ".svg");
|
|
183
|
+
const totalImages = imageFiles.length;
|
|
184
|
+
let imageIndex = 0;
|
|
96
185
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
} else if (ext === ".webp") {
|
|
118
|
-
pipeline = pipeline.webp({ quality: quality });
|
|
119
|
-
} else {
|
|
120
|
-
// Default to jpeg/mozjpeg
|
|
121
|
-
pipeline = pipeline.jpeg({ quality: quality, mozjpeg: true });
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
const optimizedBuffer = await pipeline.toBuffer();
|
|
125
|
-
|
|
126
|
-
// Calculate savings
|
|
127
|
-
const diff = content.length - optimizedBuffer.length;
|
|
128
|
-
if (diff > 0) {
|
|
129
|
-
savedBytes += diff;
|
|
130
|
-
newZip.file(fileName, optimizedBuffer);
|
|
131
|
-
} else {
|
|
132
|
-
// If optimization made it bigger (rare), keep original
|
|
133
|
-
newZip.file(fileName, content);
|
|
134
|
-
}
|
|
135
|
-
processedCount++;
|
|
136
|
-
} catch (err) {
|
|
137
|
-
// If sharp fails (corrupt image?), keep original
|
|
138
|
-
newZip.file(fileName, content);
|
|
139
|
-
}
|
|
140
|
-
} else {
|
|
141
|
-
// Non-image files: just copy them
|
|
186
|
+
for (const fileName of fileNames) {
|
|
187
|
+
const file = zip.files[fileName];
|
|
188
|
+
if (file.dir) {
|
|
189
|
+
newZip.folder(fileName);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const content = await file.async("nodebuffer");
|
|
194
|
+
const ext = path.extname(fileName).toLowerCase();
|
|
195
|
+
|
|
196
|
+
if (ext === ".svg") {
|
|
197
|
+
newZip.file(fileName, content);
|
|
198
|
+
} else if (isSupportedImage(fileName)) {
|
|
199
|
+
imageIndex++;
|
|
200
|
+
config.spinner.text = `Optimizing in zip [${imageIndex}/${totalImages}]: ${fileName}`;
|
|
201
|
+
try {
|
|
202
|
+
const optimized = await optimizeBuffer(content, path.extname(fileName), config);
|
|
203
|
+
newZip.file(fileName, optimized);
|
|
204
|
+
} catch (error: any) {
|
|
205
|
+
console.error(chalk.red(`Failed to optimize ${fileName}: ${error.message}`));
|
|
142
206
|
newZip.file(fileName, content);
|
|
143
207
|
}
|
|
208
|
+
} else {
|
|
209
|
+
newZip.file(fileName, content);
|
|
144
210
|
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
config.spinner.text = "Generating Output Zip...";
|
|
214
|
+
const outputBuffer = await newZip.generateAsync({
|
|
215
|
+
type: "nodebuffer",
|
|
216
|
+
compression: "DEFLATE",
|
|
217
|
+
compressionOptions: { level: 6 },
|
|
218
|
+
});
|
|
219
|
+
await fs.writeFile(destination, outputBuffer);
|
|
220
|
+
}
|
|
145
221
|
|
|
146
|
-
|
|
147
|
-
|
|
222
|
+
async function processSingleFile(
|
|
223
|
+
source: string,
|
|
224
|
+
destination: string,
|
|
225
|
+
config: any
|
|
226
|
+
) {
|
|
227
|
+
const buffer = await fs.readFile(source);
|
|
228
|
+
const optimized = await optimizeBuffer(buffer, path.extname(source), config);
|
|
229
|
+
await fs.writeFile(destination, optimized);
|
|
230
|
+
}
|
|
148
231
|
|
|
149
|
-
|
|
150
|
-
const outputBuffer = await newZip.generateAsync({
|
|
151
|
-
type: "nodebuffer",
|
|
152
|
-
compression: "DEFLATE",
|
|
153
|
-
compressionOptions: { level: 6 },
|
|
154
|
-
});
|
|
232
|
+
// --- Core Optimizer ---
|
|
155
233
|
|
|
156
|
-
|
|
234
|
+
async function optimizeBuffer(
|
|
235
|
+
buffer: Buffer,
|
|
236
|
+
ext: string,
|
|
237
|
+
config: any
|
|
238
|
+
): Promise<Buffer> {
|
|
239
|
+
const extension = ext.toLowerCase();
|
|
157
240
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
)
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
241
|
+
try {
|
|
242
|
+
let pipeline = sharp(buffer, { animated: true });
|
|
243
|
+
const metadata = await pipeline.metadata();
|
|
244
|
+
|
|
245
|
+
// Resize
|
|
246
|
+
if (metadata.width && metadata.width > config.width) {
|
|
247
|
+
pipeline = pipeline.resize({ width: config.width });
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Compress based on format
|
|
251
|
+
switch (extension) {
|
|
252
|
+
case ".svg":
|
|
253
|
+
return buffer;
|
|
254
|
+
case ".jpeg":
|
|
255
|
+
case ".jpg":
|
|
256
|
+
pipeline = pipeline.jpeg({ quality: config.quality, mozjpeg: true });
|
|
257
|
+
break;
|
|
258
|
+
case ".png":
|
|
259
|
+
pipeline = pipeline.png({
|
|
260
|
+
quality: config.quality,
|
|
261
|
+
compressionLevel: 9,
|
|
262
|
+
palette: true,
|
|
263
|
+
});
|
|
264
|
+
break;
|
|
265
|
+
case ".webp":
|
|
266
|
+
pipeline = pipeline.webp({ quality: config.quality });
|
|
267
|
+
break;
|
|
268
|
+
case ".gif":
|
|
269
|
+
pipeline = pipeline.gif({ colors: 128 });
|
|
270
|
+
break;
|
|
271
|
+
case ".avif":
|
|
272
|
+
pipeline = pipeline.avif({ quality: config.quality });
|
|
273
|
+
break;
|
|
274
|
+
case ".tiff":
|
|
275
|
+
pipeline = pipeline.tiff({ quality: config.quality });
|
|
276
|
+
break;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const outputBuffer = await pipeline.toBuffer();
|
|
280
|
+
|
|
281
|
+
// Safety Check: If optimized is bigger, return original
|
|
282
|
+
if (outputBuffer.length >= buffer.length) {
|
|
283
|
+
return buffer;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return outputBuffer;
|
|
287
|
+
} catch (err) {
|
|
288
|
+
return buffer;
|
|
170
289
|
}
|
|
171
290
|
}
|
|
172
291
|
|
|
173
|
-
//
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
292
|
+
// --- Helpers ---
|
|
293
|
+
|
|
294
|
+
function isSupportedImage(filePath: string): boolean {
|
|
295
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
296
|
+
return SUPPORTED_EXTENSIONS.has(ext);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function determineOutputPath(
|
|
300
|
+
source: string,
|
|
301
|
+
userOutput: string | undefined,
|
|
302
|
+
suffix: string
|
|
303
|
+
): string {
|
|
304
|
+
// If user provided a path, use it relative to CWD (standard CLI behavior)
|
|
305
|
+
if (userOutput) {
|
|
306
|
+
return path.resolve(process.cwd(), userOutput);
|
|
180
307
|
}
|
|
308
|
+
|
|
309
|
+
// If no output provided, put it NEXT TO THE SOURCE
|
|
310
|
+
const dir = path.dirname(source); // Gets the folder containing the source
|
|
311
|
+
const ext = path.extname(source);
|
|
312
|
+
const name = path.basename(source, ext); // Filename without extension
|
|
313
|
+
|
|
314
|
+
// If we are processing a folder, 'name' is the folder name
|
|
315
|
+
// If we are processing a file, 'name' is the filename
|
|
316
|
+
return path.join(dir, `${name}${suffix}`);
|
|
181
317
|
}
|
|
182
318
|
|
|
183
319
|
main();
|