@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 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 (JPEG, PNG, WebP) inside a zip file recursively."
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.0.0").requiredOption("-s, --source <path>", "Path to the input zip file").option(
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 zip file (defaults to [name]-optimized.zip)"
19
- ).option("-q, --quality <number>", "Quality of compression (1-100)", "80").option("-w, --width <number>", "Max width to resize images to", "1600").addHelpText(
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("Starting optimization...").start();
39
+ const spinner = ora("Analyzing source...").start();
40
40
  try {
41
41
  const sourcePath = path.resolve(process.cwd(), options.source);
42
- let outputPath;
43
- if (options.output) {
44
- outputPath = path.resolve(process.cwd(), options.output);
45
- } else {
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
- spinner.text = "Reading zip file...";
56
- const zipData = await fs.readFile(sourcePath);
57
- const zip = await JSZip.loadAsync(zipData);
58
- const newZip = new JSZip();
59
- const quality = parseInt(options.quality);
60
- const maxWidth = parseInt(options.width);
61
- const fileNames = Object.keys(zip.files);
62
- let processedCount = 0;
63
- let savedBytes = 0;
64
- spinner.text = `Found ${fileNames.length} files. Processing...`;
65
- for (const fileName of fileNames) {
66
- const file = zip.files[fileName];
67
- if (file.dir) {
68
- newZip.folder(fileName);
69
- continue;
70
- }
71
- const content = await file.async("nodebuffer");
72
- const ext = path.extname(fileName).toLowerCase();
73
- if ([".jpg", ".jpeg", ".png", ".webp"].includes(ext)) {
74
- try {
75
- spinner.text = `Optimizing: ${fileName}`;
76
- let pipeline = sharp(content);
77
- const metadata = await pipeline.metadata();
78
- if (metadata.width && metadata.width > maxWidth) {
79
- pipeline = pipeline.resize({ width: maxWidth });
80
- }
81
- if (ext === ".png") {
82
- pipeline = pipeline.png({ quality, compressionLevel: 9 });
83
- } else if (ext === ".webp") {
84
- pipeline = pipeline.webp({ quality });
85
- } else {
86
- pipeline = pipeline.jpeg({ quality, mozjpeg: true });
87
- }
88
- const optimizedBuffer = await pipeline.toBuffer();
89
- const diff = content.length - optimizedBuffer.length;
90
- if (diff > 0) {
91
- savedBytes += diff;
92
- newZip.file(fileName, optimizedBuffer);
93
- } else {
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 fileExists(path2) {
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
- await fs.access(path2);
130
- return true;
131
- } catch {
132
- return false;
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.0.1",
4
- "description": "CLI to optimize images inside a zip file",
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": "ISC",
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 (JPEG, PNG, WebP) inside a zip file recursively."
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.0.0")
20
- .requiredOption("-s, --source <path>", "Path to the input zip file")
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 zip file (defaults to [name]-optimized.zip)"
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("Starting optimization...").start();
51
+ const spinner = ora("Analyzing source...").start();
51
52
 
52
53
  try {
53
- // 1. Resolve Paths
54
+ // Resolve absolute path of the source immediately
54
55
  const sourcePath = path.resolve(process.cwd(), options.source);
55
56
 
56
- // Default output name if not provided: input-optimized.zip
57
- let outputPath: string;
58
- if (options.output) {
59
- outputPath = path.resolve(process.cwd(), options.output);
60
- } else {
61
- const dir = path.dirname(sourcePath);
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
- if (!(await fileExists(sourcePath))) {
68
- spinner.fail(`Source file not found: ${sourcePath}`);
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
- // 2. Read Zip
73
- spinner.text = "Reading zip file...";
74
- const zipData = await fs.readFile(sourcePath);
75
- const zip = await JSZip.loadAsync(zipData);
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
- const newZip = new JSZip();
78
- const quality = parseInt(options.quality);
79
- const maxWidth = parseInt(options.width);
131
+ const entries = await fs.readdir(source, { withFileTypes: true });
80
132
 
81
- // 3. Process Entries
82
- const fileNames = Object.keys(zip.files);
83
- let processedCount = 0;
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
- spinner.text = `Found ${fileNames.length} files. Processing...`;
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
- // Process in parallel (batches) or sequence. Sequence is safer for memory.
89
- for (const fileName of fileNames) {
90
- const file = zip.files[fileName];
142
+ // Process images in parallel batches of 5
143
+ let processedCount = 0;
144
+ const batchSize = 5;
145
+ const totalImages = imageEntries.length;
91
146
 
92
- if (file.dir) {
93
- newZip.folder(fileName);
94
- continue;
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
- const content = await file.async("nodebuffer");
98
- const ext = path.extname(fileName).toLowerCase();
99
-
100
- // Check if it's an image we can optimize
101
- if ([".jpg", ".jpeg", ".png", ".webp"].includes(ext)) {
102
- try {
103
- spinner.text = `Optimizing: ${fileName}`;
104
-
105
- // Sharp Optimization Pipeline
106
- let pipeline = sharp(content);
107
- const metadata = await pipeline.metadata();
108
-
109
- // Resize if too big
110
- if (metadata.width && metadata.width > maxWidth) {
111
- pipeline = pipeline.resize({ width: maxWidth });
112
- }
113
-
114
- // Compress based on format
115
- if (ext === ".png") {
116
- pipeline = pipeline.png({ quality: quality, compressionLevel: 9 });
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
- // 4. Write Output Zip
147
- spinner.text = "Generating output zip...";
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
- // Generate zip as nodebuffer (works better for CLI files)
150
- const outputBuffer = await newZip.generateAsync({
151
- type: "nodebuffer",
152
- compression: "DEFLATE",
153
- compressionOptions: { level: 6 },
154
- });
232
+ // --- Core Optimizer ---
155
233
 
156
- await fs.writeFile(outputPath, outputBuffer);
234
+ async function optimizeBuffer(
235
+ buffer: Buffer,
236
+ ext: string,
237
+ config: any
238
+ ): Promise<Buffer> {
239
+ const extension = ext.toLowerCase();
157
240
 
158
- spinner.succeed(chalk.green("Optimization Complete!"));
159
- console.log(`\n📁 Output: ${chalk.cyan(outputPath)}`);
160
- console.log(`🖼️ Images Processed: ${processedCount}`);
161
- console.log(
162
- `💾 Space Saved: ${chalk.bold(
163
- (savedBytes / 1024 / 1024).toFixed(2)
164
- )} MB\n`
165
- );
166
- } catch (error: any) {
167
- spinner.fail("Error occurred");
168
- console.error(chalk.red(error.message));
169
- process.exit(1);
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
- // Helper to check file existence
174
- async function fileExists(path: string) {
175
- try {
176
- await fs.access(path);
177
- return true;
178
- } catch {
179
- return false;
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();