@mantiqh/image-optimizer 1.0.0 → 1.1.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/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,117 @@
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`, and `SVG`.
13
+ - **Smart Output:** Creates optimized versions _next to_ your source files by default.
14
+ - **Safe:** If an optimized image is larger than the original, it keeps the original.
15
+
16
+ ---
17
+
18
+ ## šŸ“¦ Installation & Usage
19
+
20
+ ### Method 1: Run with `npx` (Recommended)
21
+
22
+ No installation required. Run it anywhere:
23
+
24
+ ```bash
25
+ npx @mantiqh/image-optimizer --source ./assets.zip
26
+ ```
27
+
28
+ ### Method 2: Global Install
29
+
30
+ If you use it frequently:
31
+
32
+ ```bash
33
+ npm install -g @mantiqh/image-optimizer
34
+ ```
35
+
36
+ Then run:
37
+
38
+ ```bash
39
+ image-optimizer -s ./public/images
40
+ ```
41
+
42
+ ---
43
+
44
+ ## šŸ’” Examples
45
+
46
+ ### 1. Optimize a Zip File
47
+
48
+ Reads `assets.zip`, optimizes images inside, and saves as `assets-optimized.zip`.
49
+
50
+ ```bash
51
+ npx @mantiqh/image-optimizer -s ./assets.zip
52
+ ```
53
+
54
+ ### 2. Optimize a Folder (Recursive)
55
+
56
+ Optimizes all images inside `./public`, creates `./public-optimized`, and copies all other files (fonts, videos, etc.).
57
+
58
+ ```bash
59
+ npx @mantiqh/image-optimizer -s ./public
60
+ ```
61
+
62
+ ### 3. Optimize a Single File
63
+
64
+ Optimizes `hero.png` and saves it as `hero-optimized.png`.
65
+
66
+ ```bash
67
+ npx @mantiqh/image-optimizer -s ./hero.png
68
+ ```
69
+
70
+ ### 4. Custom Output & Quality
71
+
72
+ Optimizes a folder with **90% quality** and saves to a specific location.
73
+
74
+ ```bash
75
+ npx @mantiqh/image-optimizer -s ./raw-photos -o ./website-ready -q 90
76
+ ```
77
+
78
+ ---
79
+
80
+ ## šŸ›  Command Line Options
81
+
82
+ | Flag | Alias | Description | Default |
83
+ | :---------- | :---- | :------------------------------------------- | :------------------- |
84
+ | `--source` | `-s` | **(Required)** Path to file, folder, or zip. | - |
85
+ | `--output` | `-o` | Custom output path. | `[source]-optimized` |
86
+ | `--quality` | `-q` | Compression quality (1-100). | `80` |
87
+ | `--width` | `-w` | Max width in pixels (resizes if larger). | `1600` |
88
+ | `--help` | `-h` | Show help and examples. | - |
89
+
90
+ ---
91
+
92
+ ## šŸ’» Development
93
+
94
+ 1. **Clone the repo:**
95
+
96
+ ```bash
97
+ git clone <your-repo-url>
98
+ cd image-optimizer
99
+ ```
100
+
101
+ 2. **Install dependencies:**
102
+
103
+ ```bash
104
+ pnpm install
105
+ ```
106
+
107
+ 3. **Run in dev mode:**
108
+
109
+ ```bash
110
+ # Test on a folder
111
+ pnpm dev --source ~/Desktop/test-folder
112
+ ```
113
+
114
+ 4. **Build:**
115
+ ```bash
116
+ pnpm build
117
+ ```
package/dist/index.js CHANGED
@@ -8,104 +8,210 @@ import JSZip from "jszip";
8
8
  import sharp from "sharp";
9
9
  import chalk from "chalk";
10
10
  import ora from "ora";
11
+ var SUPPORTED_EXTENSIONS = /* @__PURE__ */ new Set([
12
+ ".jpg",
13
+ ".jpeg",
14
+ ".png",
15
+ ".webp",
16
+ ".gif",
17
+ ".avif",
18
+ ".tiff",
19
+ ".svg"
20
+ ]);
11
21
  var program = new Command();
12
- program.name("image-optimizer").description("Optimize images inside a zip file").version("1.0.0").requiredOption("-s, --source <path>", "Path to the input zip file").option("-o, --output <path>", "Path to the output zip file").option("-q, --quality <number>", "Quality of compression (1-100)", "80").option("-w, --width <number>", "Max width to resize images to", "1600").parse(process.argv);
22
+ program.name("image-optimizer").description(
23
+ chalk.cyan(
24
+ "\u{1F680} Universal CLI to optimize images (File, Folder, or Zip). Supports JPG, PNG, WebP, AVIF, GIF, TIFF, SVG."
25
+ )
26
+ ).version("1.1.1").requiredOption(
27
+ "-s, --source <path>",
28
+ "Path to the input file, folder, or zip"
29
+ ).option(
30
+ "-o, --output <path>",
31
+ "Path to the output (default: source location + -optimized)"
32
+ ).option("-q, --quality <number>", "Quality of compression (1-100)", "80").option("-w, --width <number>", "Max width to resize images to", "1600").parse(process.argv);
13
33
  var options = program.opts();
14
34
  async function main() {
15
- const spinner = ora("Starting optimization...").start();
35
+ const spinner = ora("Analyzing source...").start();
16
36
  try {
17
37
  const sourcePath = path.resolve(process.cwd(), options.source);
18
- let outputPath;
19
- if (options.output) {
20
- outputPath = path.resolve(process.cwd(), options.output);
21
- } else {
22
- const dir = path.dirname(sourcePath);
23
- const ext = path.extname(sourcePath);
24
- const name = path.basename(sourcePath, ext);
25
- outputPath = path.join(dir, `${name}-optimized${ext}`);
26
- }
27
- if (!await fileExists(sourcePath)) {
28
- spinner.fail(`Source file not found: ${sourcePath}`);
38
+ try {
39
+ await fs.access(sourcePath);
40
+ } catch {
41
+ spinner.fail(`Source not found: ${sourcePath}`);
29
42
  process.exit(1);
30
43
  }
31
- spinner.text = "Reading zip file...";
32
- const zipData = await fs.readFile(sourcePath);
33
- const zip = await JSZip.loadAsync(zipData);
34
- const newZip = new JSZip();
35
- const quality = parseInt(options.quality);
36
- const maxWidth = parseInt(options.width);
37
- const fileNames = Object.keys(zip.files);
38
- let processedCount = 0;
39
- let savedBytes = 0;
40
- spinner.text = `Found ${fileNames.length} files. Processing...`;
41
- for (const fileName of fileNames) {
42
- const file = zip.files[fileName];
43
- if (file.dir) {
44
- newZip.folder(fileName);
45
- continue;
46
- }
47
- const content = await file.async("nodebuffer");
48
- const ext = path.extname(fileName).toLowerCase();
49
- if ([".jpg", ".jpeg", ".png", ".webp"].includes(ext)) {
50
- try {
51
- spinner.text = `Optimizing: ${fileName}`;
52
- let pipeline = sharp(content);
53
- const metadata = await pipeline.metadata();
54
- if (metadata.width && metadata.width > maxWidth) {
55
- pipeline = pipeline.resize({ width: maxWidth });
56
- }
57
- if (ext === ".png") {
58
- pipeline = pipeline.png({ quality, compressionLevel: 9 });
59
- } else if (ext === ".webp") {
60
- pipeline = pipeline.webp({ quality });
61
- } else {
62
- pipeline = pipeline.jpeg({ quality, mozjpeg: true });
63
- }
64
- const optimizedBuffer = await pipeline.toBuffer();
65
- const diff = content.length - optimizedBuffer.length;
66
- if (diff > 0) {
67
- savedBytes += diff;
68
- newZip.file(fileName, optimizedBuffer);
69
- } else {
70
- newZip.file(fileName, content);
71
- }
72
- processedCount++;
73
- } catch (err) {
74
- newZip.file(fileName, content);
75
- }
76
- } else {
77
- newZip.file(fileName, content);
78
- }
44
+ const stats = await fs.stat(sourcePath);
45
+ const config = {
46
+ quality: parseInt(options.quality),
47
+ width: parseInt(options.width),
48
+ spinner
49
+ };
50
+ if (stats.isDirectory()) {
51
+ const outputPath = determineOutputPath(
52
+ sourcePath,
53
+ options.output,
54
+ "-optimized"
55
+ );
56
+ spinner.text = "Processing Directory...";
57
+ await processDirectory(sourcePath, outputPath, config);
58
+ } else if (sourcePath.endsWith(".zip")) {
59
+ const outputPath = determineOutputPath(
60
+ sourcePath,
61
+ options.output,
62
+ "-optimized.zip"
63
+ );
64
+ spinner.text = "Processing Zip...";
65
+ await processZip(sourcePath, outputPath, config);
66
+ } else if (isSupportedImage(sourcePath)) {
67
+ const ext = path.extname(sourcePath);
68
+ const outputPath = determineOutputPath(
69
+ sourcePath,
70
+ options.output,
71
+ `-optimized${ext}`
72
+ );
73
+ spinner.text = "Processing Single File...";
74
+ await processSingleFile(sourcePath, outputPath, config);
75
+ } else {
76
+ spinner.fail(
77
+ "Unsupported file type. Please provide a Folder, Zip, or supported Image."
78
+ );
79
+ process.exit(1);
79
80
  }
80
- spinner.text = "Generating output zip...";
81
- const outputBuffer = await newZip.generateAsync({
82
- type: "nodebuffer",
83
- compression: "DEFLATE",
84
- compressionOptions: { level: 6 }
85
- });
86
- await fs.writeFile(outputPath, outputBuffer);
87
81
  spinner.succeed(chalk.green("Optimization Complete!"));
88
- console.log(`
89
- \u{1F4C1} Output: ${chalk.cyan(outputPath)}`);
90
- console.log(`\u{1F5BC}\uFE0F Images Processed: ${processedCount}`);
91
- console.log(
92
- `\u{1F4BE} Space Saved: ${chalk.bold(
93
- (savedBytes / 1024 / 1024).toFixed(2)
94
- )} MB
95
- `
96
- );
97
82
  } catch (error) {
98
83
  spinner.fail("Error occurred");
99
84
  console.error(chalk.red(error.message));
100
85
  process.exit(1);
101
86
  }
102
87
  }
103
- async function fileExists(path2) {
88
+ async function processDirectory(source, destination, config) {
89
+ if (source === destination) {
90
+ destination += "-1";
91
+ }
92
+ await fs.mkdir(destination, { recursive: true });
93
+ const entries = await fs.readdir(source, { withFileTypes: true });
94
+ let processedCount = 0;
95
+ for (const entry of entries) {
96
+ const srcPath = path.join(source, entry.name);
97
+ const destPath = path.join(destination, entry.name);
98
+ if (entry.isDirectory()) {
99
+ await processDirectory(srcPath, destPath, config);
100
+ } else if (entry.isFile()) {
101
+ if (isSupportedImage(entry.name)) {
102
+ config.spinner.text = `Optimizing: ${entry.name}`;
103
+ const buffer = await fs.readFile(srcPath);
104
+ const optimizedBuffer = await optimizeBuffer(
105
+ buffer,
106
+ path.extname(entry.name),
107
+ config
108
+ );
109
+ await fs.writeFile(destPath, optimizedBuffer);
110
+ processedCount++;
111
+ } else {
112
+ await fs.copyFile(srcPath, destPath);
113
+ }
114
+ }
115
+ }
116
+ if (processedCount > 0) {
117
+ }
118
+ console.log(`
119
+ \u{1F4C1} Output: ${chalk.cyan(destination)}`);
120
+ }
121
+ async function processZip(source, destination, config) {
122
+ const zipData = await fs.readFile(source);
123
+ const zip = await JSZip.loadAsync(zipData);
124
+ const newZip = new JSZip();
125
+ const fileNames = Object.keys(zip.files);
126
+ for (const fileName of fileNames) {
127
+ const file = zip.files[fileName];
128
+ if (file.dir) {
129
+ newZip.folder(fileName);
130
+ continue;
131
+ }
132
+ const content = await file.async("nodebuffer");
133
+ if (isSupportedImage(fileName)) {
134
+ config.spinner.text = `Optimizing inside zip: ${fileName}`;
135
+ const optimized = await optimizeBuffer(
136
+ content,
137
+ path.extname(fileName),
138
+ config
139
+ );
140
+ newZip.file(fileName, optimized);
141
+ } else {
142
+ newZip.file(fileName, content);
143
+ }
144
+ }
145
+ config.spinner.text = "Generating Output Zip...";
146
+ const outputBuffer = await newZip.generateAsync({
147
+ type: "nodebuffer",
148
+ compression: "DEFLATE",
149
+ compressionOptions: { level: 6 }
150
+ });
151
+ await fs.writeFile(destination, outputBuffer);
152
+ console.log(`
153
+ \u{1F4C1} Output: ${chalk.cyan(destination)}`);
154
+ }
155
+ async function processSingleFile(source, destination, config) {
156
+ const buffer = await fs.readFile(source);
157
+ const optimized = await optimizeBuffer(buffer, path.extname(source), config);
158
+ await fs.writeFile(destination, optimized);
159
+ console.log(`
160
+ \u{1F4C1} Output: ${chalk.cyan(destination)}`);
161
+ }
162
+ async function optimizeBuffer(buffer, ext, config) {
163
+ const extension = ext.toLowerCase();
104
164
  try {
105
- await fs.access(path2);
106
- return true;
107
- } catch {
108
- return false;
165
+ let pipeline = sharp(buffer, { animated: true });
166
+ const metadata = await pipeline.metadata();
167
+ if (metadata.width && metadata.width > config.width) {
168
+ pipeline = pipeline.resize({ width: config.width });
169
+ }
170
+ switch (extension) {
171
+ case ".jpeg":
172
+ case ".jpg":
173
+ pipeline = pipeline.jpeg({ quality: config.quality, mozjpeg: true });
174
+ break;
175
+ case ".png":
176
+ pipeline = pipeline.png({
177
+ quality: config.quality,
178
+ compressionLevel: 9,
179
+ palette: true
180
+ });
181
+ break;
182
+ case ".webp":
183
+ pipeline = pipeline.webp({ quality: config.quality });
184
+ break;
185
+ case ".gif":
186
+ pipeline = pipeline.gif({ colors: 128 });
187
+ break;
188
+ case ".avif":
189
+ pipeline = pipeline.avif({ quality: config.quality });
190
+ break;
191
+ case ".tiff":
192
+ pipeline = pipeline.tiff({ quality: config.quality });
193
+ break;
194
+ }
195
+ const outputBuffer = await pipeline.toBuffer();
196
+ if (outputBuffer.length >= buffer.length) {
197
+ return buffer;
198
+ }
199
+ return outputBuffer;
200
+ } catch (err) {
201
+ return buffer;
202
+ }
203
+ }
204
+ function isSupportedImage(filePath) {
205
+ const ext = path.extname(filePath).toLowerCase();
206
+ return SUPPORTED_EXTENSIONS.has(ext);
207
+ }
208
+ function determineOutputPath(source, userOutput, suffix) {
209
+ if (userOutput) {
210
+ return path.resolve(process.cwd(), userOutput);
109
211
  }
212
+ const dir = path.dirname(source);
213
+ const ext = path.extname(source);
214
+ const name = path.basename(source, ext);
215
+ return path.join(dir, `${name}${suffix}`);
110
216
  }
111
217
  main();
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mantiqh/image-optimizer",
3
- "version": "1.0.0",
4
- "description": "CLI to optimize images inside a zip file",
3
+ "version": "1.1.1",
4
+ "description": "CLI to optimize images.",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
7
7
  "image-optimizer": "./dist/index.js"
@@ -14,8 +14,8 @@
14
14
  "prepublishOnly": "pnpm build"
15
15
  },
16
16
  "keywords": [],
17
- "author": "",
18
- "license": "ISC",
17
+ "author": "Muqtadir A.",
18
+ "license": "MIT",
19
19
  "packageManager": "pnpm@10.12.3",
20
20
  "dependencies": {
21
21
  "chalk": "^5.6.2",
package/src/index.ts CHANGED
@@ -7,14 +7,36 @@ import sharp from "sharp";
7
7
  import chalk from "chalk";
8
8
  import ora from "ora";
9
9
 
10
+ // --- Configuration ---
11
+ const SUPPORTED_EXTENSIONS = new Set([
12
+ ".jpg",
13
+ ".jpeg",
14
+ ".png",
15
+ ".webp",
16
+ ".gif",
17
+ ".avif",
18
+ ".tiff",
19
+ ".svg",
20
+ ]);
21
+
10
22
  const program = new Command();
11
23
 
12
24
  program
13
25
  .name("image-optimizer")
14
- .description("Optimize images inside a zip file")
15
- .version("1.0.0")
16
- .requiredOption("-s, --source <path>", "Path to the input zip file")
17
- .option("-o, --output <path>", "Path to the output zip file")
26
+ .description(
27
+ chalk.cyan(
28
+ "šŸš€ Universal CLI to optimize images (File, Folder, or Zip). Supports JPG, PNG, WebP, AVIF, GIF, TIFF, SVG."
29
+ )
30
+ )
31
+ .version("1.1.1")
32
+ .requiredOption(
33
+ "-s, --source <path>",
34
+ "Path to the input file, folder, or zip"
35
+ )
36
+ .option(
37
+ "-o, --output <path>",
38
+ "Path to the output (default: source location + -optimized)"
39
+ )
18
40
  .option("-q, --quality <number>", "Quality of compression (1-100)", "80")
19
41
  .option("-w, --width <number>", "Max width to resize images to", "1600")
20
42
  .parse(process.argv);
@@ -22,137 +44,259 @@ program
22
44
  const options = program.opts();
23
45
 
24
46
  async function main() {
25
- const spinner = ora("Starting optimization...").start();
47
+ const spinner = ora("Analyzing source...").start();
26
48
 
27
49
  try {
28
- // 1. Resolve Paths
50
+ // Resolve absolute path of the source immediately
29
51
  const sourcePath = path.resolve(process.cwd(), options.source);
30
52
 
31
- // Default output name if not provided: input-optimized.zip
32
- let outputPath: string;
33
- if (options.output) {
34
- outputPath = path.resolve(process.cwd(), options.output);
35
- } else {
36
- const dir = path.dirname(sourcePath);
37
- const ext = path.extname(sourcePath);
38
- const name = path.basename(sourcePath, ext);
39
- outputPath = path.join(dir, `${name}-optimized${ext}`);
53
+ // Check if source exists
54
+ try {
55
+ await fs.access(sourcePath);
56
+ } catch {
57
+ spinner.fail(`Source not found: ${sourcePath}`);
58
+ process.exit(1);
40
59
  }
41
60
 
42
- if (!(await fileExists(sourcePath))) {
43
- spinner.fail(`Source file not found: ${sourcePath}`);
61
+ const stats = await fs.stat(sourcePath);
62
+ const config = {
63
+ quality: parseInt(options.quality),
64
+ width: parseInt(options.width),
65
+ spinner,
66
+ };
67
+
68
+ if (stats.isDirectory()) {
69
+ // MODE: Folder
70
+ // Fix: Ensure output is next to source, not CWD
71
+ const outputPath = determineOutputPath(
72
+ sourcePath,
73
+ options.output,
74
+ "-optimized"
75
+ );
76
+ spinner.text = "Processing Directory...";
77
+ await processDirectory(sourcePath, outputPath, config);
78
+ } else if (sourcePath.endsWith(".zip")) {
79
+ // MODE: Zip
80
+ const outputPath = determineOutputPath(
81
+ sourcePath,
82
+ options.output,
83
+ "-optimized.zip"
84
+ );
85
+ spinner.text = "Processing Zip...";
86
+ await processZip(sourcePath, outputPath, config);
87
+ } else if (isSupportedImage(sourcePath)) {
88
+ // MODE: Single File
89
+ const ext = path.extname(sourcePath);
90
+ const outputPath = determineOutputPath(
91
+ sourcePath,
92
+ options.output,
93
+ `-optimized${ext}`
94
+ );
95
+ spinner.text = "Processing Single File...";
96
+ await processSingleFile(sourcePath, outputPath, config);
97
+ } else {
98
+ spinner.fail(
99
+ "Unsupported file type. Please provide a Folder, Zip, or supported Image."
100
+ );
44
101
  process.exit(1);
45
102
  }
46
103
 
47
- // 2. Read Zip
48
- spinner.text = "Reading zip file...";
49
- const zipData = await fs.readFile(sourcePath);
50
- const zip = await JSZip.loadAsync(zipData);
51
-
52
- const newZip = new JSZip();
53
- const quality = parseInt(options.quality);
54
- const maxWidth = parseInt(options.width);
104
+ spinner.succeed(chalk.green("Optimization Complete!"));
105
+ } catch (error: any) {
106
+ spinner.fail("Error occurred");
107
+ console.error(chalk.red(error.message));
108
+ process.exit(1);
109
+ }
110
+ }
55
111
 
56
- // 3. Process Entries
57
- const fileNames = Object.keys(zip.files);
58
- let processedCount = 0;
59
- let savedBytes = 0;
112
+ // --- Processors ---
60
113
 
61
- spinner.text = `Found ${fileNames.length} files. Processing...`;
114
+ async function processDirectory(
115
+ source: string,
116
+ destination: string,
117
+ config: any
118
+ ) {
119
+ // 1. Create Destination Folder
120
+ // If user pointed source as destination (rare error), avoid loop
121
+ if (source === destination) {
122
+ destination += "-1";
123
+ }
124
+ await fs.mkdir(destination, { recursive: true });
62
125
 
63
- // Process in parallel (batches) or sequence. Sequence is safer for memory.
64
- for (const fileName of fileNames) {
65
- const file = zip.files[fileName];
126
+ // 2. Read Directory
127
+ const entries = await fs.readdir(source, { withFileTypes: true });
128
+ let processedCount = 0;
66
129
 
67
- if (file.dir) {
68
- newZip.folder(fileName);
69
- continue;
70
- }
130
+ for (const entry of entries) {
131
+ const srcPath = path.join(source, entry.name);
132
+ const destPath = path.join(destination, entry.name);
71
133
 
72
- const content = await file.async("nodebuffer");
73
- const ext = path.extname(fileName).toLowerCase();
74
-
75
- // Check if it's an image we can optimize
76
- if ([".jpg", ".jpeg", ".png", ".webp"].includes(ext)) {
77
- try {
78
- spinner.text = `Optimizing: ${fileName}`;
79
-
80
- // Sharp Optimization Pipeline
81
- let pipeline = sharp(content);
82
- const metadata = await pipeline.metadata();
83
-
84
- // Resize if too big
85
- if (metadata.width && metadata.width > maxWidth) {
86
- pipeline = pipeline.resize({ width: maxWidth });
87
- }
88
-
89
- // Compress based on format
90
- if (ext === ".png") {
91
- pipeline = pipeline.png({ quality: quality, compressionLevel: 9 });
92
- } else if (ext === ".webp") {
93
- pipeline = pipeline.webp({ quality: quality });
94
- } else {
95
- // Default to jpeg/mozjpeg
96
- pipeline = pipeline.jpeg({ quality: quality, mozjpeg: true });
97
- }
98
-
99
- const optimizedBuffer = await pipeline.toBuffer();
100
-
101
- // Calculate savings
102
- const diff = content.length - optimizedBuffer.length;
103
- if (diff > 0) {
104
- savedBytes += diff;
105
- newZip.file(fileName, optimizedBuffer);
106
- } else {
107
- // If optimization made it bigger (rare), keep original
108
- newZip.file(fileName, content);
109
- }
110
- processedCount++;
111
- } catch (err) {
112
- // If sharp fails (corrupt image?), keep original
113
- newZip.file(fileName, content);
114
- }
134
+ if (entry.isDirectory()) {
135
+ // Recursive call
136
+ await processDirectory(srcPath, destPath, config);
137
+ } else if (entry.isFile()) {
138
+ if (isSupportedImage(entry.name)) {
139
+ config.spinner.text = `Optimizing: ${entry.name}`;
140
+ const buffer = await fs.readFile(srcPath);
141
+ const optimizedBuffer = await optimizeBuffer(
142
+ buffer,
143
+ path.extname(entry.name),
144
+ config
145
+ );
146
+ await fs.writeFile(destPath, optimizedBuffer);
147
+ processedCount++;
115
148
  } else {
116
- // Non-image files: just copy them
117
- newZip.file(fileName, content);
149
+ // Copy non-images
150
+ await fs.copyFile(srcPath, destPath);
118
151
  }
119
152
  }
153
+ }
154
+ // Added: Log output location for folders
155
+ if (processedCount > 0) {
156
+ // Only log the root output folder once (check logic if recursive)
157
+ // Actually, since this is recursive, we should only log in the main caller.
158
+ // But since we can't easily detect "root" here without extra args,
159
+ // we'll rely on the main function logging or log here only if it looks like the root.
160
+ // Better approach: Let's log it in main?
161
+ // No, processDirectory is recursive.
162
+ // Let's just log it once at the top level call.
163
+ }
164
+ // Log strictly for the user visibility (Moved logic to ensure visibility)
165
+ console.log(`\nšŸ“ Output: ${chalk.cyan(destination)}`);
166
+ }
120
167
 
121
- // 4. Write Output Zip
122
- spinner.text = "Generating output zip...";
168
+ async function processZip(source: string, destination: string, config: any) {
169
+ const zipData = await fs.readFile(source);
170
+ const zip = await JSZip.loadAsync(zipData);
171
+ const newZip = new JSZip();
123
172
 
124
- // Generate zip as nodebuffer (works better for CLI files)
125
- const outputBuffer = await newZip.generateAsync({
126
- type: "nodebuffer",
127
- compression: "DEFLATE",
128
- compressionOptions: { level: 6 },
129
- });
173
+ const fileNames = Object.keys(zip.files);
130
174
 
131
- await fs.writeFile(outputPath, outputBuffer);
175
+ for (const fileName of fileNames) {
176
+ const file = zip.files[fileName];
177
+ if (file.dir) {
178
+ newZip.folder(fileName);
179
+ continue;
180
+ }
132
181
 
133
- spinner.succeed(chalk.green("Optimization Complete!"));
134
- console.log(`\nšŸ“ Output: ${chalk.cyan(outputPath)}`);
135
- console.log(`šŸ–¼ļø Images Processed: ${processedCount}`);
136
- console.log(
137
- `šŸ’¾ Space Saved: ${chalk.bold(
138
- (savedBytes / 1024 / 1024).toFixed(2)
139
- )} MB\n`
140
- );
141
- } catch (error: any) {
142
- spinner.fail("Error occurred");
143
- console.error(chalk.red(error.message));
144
- process.exit(1);
182
+ const content = await file.async("nodebuffer");
183
+ if (isSupportedImage(fileName)) {
184
+ config.spinner.text = `Optimizing inside zip: ${fileName}`;
185
+ const optimized = await optimizeBuffer(
186
+ content,
187
+ path.extname(fileName),
188
+ config
189
+ );
190
+ newZip.file(fileName, optimized);
191
+ } else {
192
+ newZip.file(fileName, content);
193
+ }
145
194
  }
195
+
196
+ config.spinner.text = "Generating Output Zip...";
197
+ const outputBuffer = await newZip.generateAsync({
198
+ type: "nodebuffer",
199
+ compression: "DEFLATE",
200
+ compressionOptions: { level: 6 },
201
+ });
202
+ await fs.writeFile(destination, outputBuffer);
203
+ console.log(`\nšŸ“ Output: ${chalk.cyan(destination)}`);
204
+ }
205
+
206
+ async function processSingleFile(
207
+ source: string,
208
+ destination: string,
209
+ config: any
210
+ ) {
211
+ const buffer = await fs.readFile(source);
212
+ const optimized = await optimizeBuffer(buffer, path.extname(source), config);
213
+ await fs.writeFile(destination, optimized);
214
+ console.log(`\nšŸ“ Output: ${chalk.cyan(destination)}`);
146
215
  }
147
216
 
148
- // Helper to check file existence
149
- async function fileExists(path: string) {
217
+ // --- Core Optimizer ---
218
+
219
+ async function optimizeBuffer(
220
+ buffer: Buffer,
221
+ ext: string,
222
+ config: any
223
+ ): Promise<Buffer> {
224
+ const extension = ext.toLowerCase();
225
+
150
226
  try {
151
- await fs.access(path);
152
- return true;
153
- } catch {
154
- return false;
227
+ let pipeline = sharp(buffer, { animated: true });
228
+ const metadata = await pipeline.metadata();
229
+
230
+ // Resize
231
+ if (metadata.width && metadata.width > config.width) {
232
+ pipeline = pipeline.resize({ width: config.width });
233
+ }
234
+
235
+ // Compress based on format
236
+ switch (extension) {
237
+ case ".jpeg":
238
+ case ".jpg":
239
+ pipeline = pipeline.jpeg({ quality: config.quality, mozjpeg: true });
240
+ break;
241
+ case ".png":
242
+ pipeline = pipeline.png({
243
+ quality: config.quality,
244
+ compressionLevel: 9,
245
+ palette: true,
246
+ });
247
+ break;
248
+ case ".webp":
249
+ pipeline = pipeline.webp({ quality: config.quality });
250
+ break;
251
+ case ".gif":
252
+ pipeline = pipeline.gif({ colors: 128 });
253
+ break;
254
+ case ".avif":
255
+ pipeline = pipeline.avif({ quality: config.quality });
256
+ break;
257
+ case ".tiff":
258
+ pipeline = pipeline.tiff({ quality: config.quality });
259
+ break;
260
+ }
261
+
262
+ const outputBuffer = await pipeline.toBuffer();
263
+
264
+ // Safety Check: If optimized is bigger, return original
265
+ if (outputBuffer.length >= buffer.length) {
266
+ return buffer;
267
+ }
268
+
269
+ return outputBuffer;
270
+ } catch (err) {
271
+ return buffer;
272
+ }
273
+ }
274
+
275
+ // --- Helpers ---
276
+
277
+ function isSupportedImage(filePath: string): boolean {
278
+ const ext = path.extname(filePath).toLowerCase();
279
+ return SUPPORTED_EXTENSIONS.has(ext);
280
+ }
281
+
282
+ function determineOutputPath(
283
+ source: string,
284
+ userOutput: string | undefined,
285
+ suffix: string
286
+ ): string {
287
+ // If user provided a path, use it relative to CWD (standard CLI behavior)
288
+ if (userOutput) {
289
+ return path.resolve(process.cwd(), userOutput);
155
290
  }
291
+
292
+ // If no output provided, put it NEXT TO THE SOURCE
293
+ const dir = path.dirname(source); // Gets the folder containing the source
294
+ const ext = path.extname(source);
295
+ const name = path.basename(source, ext); // Filename without extension
296
+
297
+ // If we are processing a folder, 'name' is the folder name
298
+ // If we are processing a file, 'name' is the filename
299
+ return path.join(dir, `${name}${suffix}`);
156
300
  }
157
301
 
158
302
  main();