@mantiqh/image-optimizer 1.1.1 ā 1.2.2
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/README.md +3 -1
- package/dist/index.js +204 -158
- package/package.json +44 -13
- package/src/index.ts +0 -302
- package/tsconfig.json +0 -13
package/README.md
CHANGED
|
@@ -9,7 +9,9 @@ Built for developers to quickly reduce asset sizes before deployment without com
|
|
|
9
9
|
|
|
10
10
|
- **Universal Input:** Works on `.zip` files, local folders, or single images.
|
|
11
11
|
- **Recursive:** Process entire directory trees; copies non-image files (CSS, JS) unchanged.
|
|
12
|
-
- **Expanded Support:** Optimizes `JPG`, `PNG`, `WebP`, `AVIF`, `GIF`, `TIFF
|
|
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.
|
|
13
15
|
- **Smart Output:** Creates optimized versions _next to_ your source files by default.
|
|
14
16
|
- **Safe:** If an optimized image is larger than the original, it keeps the original.
|
|
15
17
|
|
package/dist/index.js
CHANGED
|
@@ -1,13 +1,57 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
// src/
|
|
4
|
-
import { Command } from "commander";
|
|
5
|
-
import fs from "fs/promises";
|
|
6
|
-
import path from "path";
|
|
7
|
-
import JSZip from "jszip";
|
|
3
|
+
// src/optimizer.ts
|
|
8
4
|
import sharp from "sharp";
|
|
5
|
+
async function optimizeBuffer(buffer, ext, config) {
|
|
6
|
+
const extension = ext.toLowerCase();
|
|
7
|
+
try {
|
|
8
|
+
let pipeline = sharp(buffer, { animated: true });
|
|
9
|
+
const metadata = await pipeline.metadata();
|
|
10
|
+
if (metadata.width && metadata.width > config.width) {
|
|
11
|
+
pipeline = pipeline.resize({ width: config.width });
|
|
12
|
+
}
|
|
13
|
+
switch (extension) {
|
|
14
|
+
case ".svg":
|
|
15
|
+
return buffer;
|
|
16
|
+
case ".jpeg":
|
|
17
|
+
case ".jpg":
|
|
18
|
+
pipeline = pipeline.jpeg({ quality: config.quality, mozjpeg: true });
|
|
19
|
+
break;
|
|
20
|
+
case ".png":
|
|
21
|
+
pipeline = pipeline.png({
|
|
22
|
+
quality: config.quality,
|
|
23
|
+
compressionLevel: 9,
|
|
24
|
+
palette: true
|
|
25
|
+
});
|
|
26
|
+
break;
|
|
27
|
+
case ".webp":
|
|
28
|
+
pipeline = pipeline.webp({ quality: config.quality });
|
|
29
|
+
break;
|
|
30
|
+
case ".gif":
|
|
31
|
+
pipeline = pipeline.gif({ colors: 128 });
|
|
32
|
+
break;
|
|
33
|
+
case ".avif":
|
|
34
|
+
pipeline = pipeline.avif({ quality: config.quality });
|
|
35
|
+
break;
|
|
36
|
+
case ".tiff":
|
|
37
|
+
pipeline = pipeline.tiff({ quality: config.quality });
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
const outputBuffer = await pipeline.toBuffer();
|
|
41
|
+
if (outputBuffer.length >= buffer.length) {
|
|
42
|
+
return buffer;
|
|
43
|
+
}
|
|
44
|
+
return outputBuffer;
|
|
45
|
+
} catch {
|
|
46
|
+
return buffer;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// src/helpers.ts
|
|
51
|
+
import path from "path";
|
|
9
52
|
import chalk from "chalk";
|
|
10
|
-
|
|
53
|
+
|
|
54
|
+
// src/constants.ts
|
|
11
55
|
var SUPPORTED_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
12
56
|
".jpg",
|
|
13
57
|
".jpeg",
|
|
@@ -18,111 +62,93 @@ var SUPPORTED_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
|
18
62
|
".tiff",
|
|
19
63
|
".svg"
|
|
20
64
|
]);
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
async function main() {
|
|
35
|
-
const spinner = ora("Analyzing source...").start();
|
|
36
|
-
try {
|
|
37
|
-
const sourcePath = path.resolve(process.cwd(), options.source);
|
|
38
|
-
try {
|
|
39
|
-
await fs.access(sourcePath);
|
|
40
|
-
} catch {
|
|
41
|
-
spinner.fail(`Source not found: ${sourcePath}`);
|
|
42
|
-
process.exit(1);
|
|
43
|
-
}
|
|
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);
|
|
80
|
-
}
|
|
81
|
-
spinner.succeed(chalk.green("Optimization Complete!"));
|
|
82
|
-
} catch (error) {
|
|
83
|
-
spinner.fail("Error occurred");
|
|
84
|
-
console.error(chalk.red(error.message));
|
|
85
|
-
process.exit(1);
|
|
65
|
+
|
|
66
|
+
// src/helpers.ts
|
|
67
|
+
function logOutputPath(outputPath) {
|
|
68
|
+
console.log(`
|
|
69
|
+
\u{1F4C1} Output: ${chalk.cyan(outputPath)}`);
|
|
70
|
+
}
|
|
71
|
+
function isSupportedImage(filePath) {
|
|
72
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
73
|
+
return SUPPORTED_EXTENSIONS.has(ext);
|
|
74
|
+
}
|
|
75
|
+
function determineOutputPath(source, userOutput, suffix) {
|
|
76
|
+
if (userOutput) {
|
|
77
|
+
return path.resolve(process.cwd(), userOutput);
|
|
86
78
|
}
|
|
79
|
+
const dir = path.dirname(source);
|
|
80
|
+
const ext = path.extname(source);
|
|
81
|
+
const name = path.basename(source, ext);
|
|
82
|
+
return path.join(dir, `${name}${suffix}`);
|
|
87
83
|
}
|
|
84
|
+
|
|
85
|
+
// src/processors.ts
|
|
86
|
+
import fs from "fs/promises";
|
|
87
|
+
import path2 from "path";
|
|
88
|
+
import JSZip from "jszip";
|
|
89
|
+
import chalk2 from "chalk";
|
|
88
90
|
async function processDirectory(source, destination, config) {
|
|
89
91
|
if (source === destination) {
|
|
90
92
|
destination += "-1";
|
|
91
93
|
}
|
|
92
94
|
await fs.mkdir(destination, { recursive: true });
|
|
93
95
|
const entries = await fs.readdir(source, { withFileTypes: true });
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
config
|
|
108
|
-
);
|
|
109
|
-
await fs.writeFile(destPath, optimizedBuffer);
|
|
110
|
-
processedCount++;
|
|
111
|
-
} else {
|
|
112
|
-
await fs.copyFile(srcPath, destPath);
|
|
113
|
-
}
|
|
114
|
-
}
|
|
96
|
+
const dirEntries = entries.filter((e) => e.isDirectory());
|
|
97
|
+
const imageEntries = entries.filter(
|
|
98
|
+
(e) => e.isFile() && isSupportedImage(e.name) && path2.extname(e.name).toLowerCase() !== ".svg"
|
|
99
|
+
);
|
|
100
|
+
const nonImageEntries = entries.filter(
|
|
101
|
+
(e) => e.isFile() && (!isSupportedImage(e.name) || path2.extname(e.name).toLowerCase() === ".svg")
|
|
102
|
+
);
|
|
103
|
+
for (const entry of dirEntries) {
|
|
104
|
+
await processDirectory(
|
|
105
|
+
path2.join(source, entry.name),
|
|
106
|
+
path2.join(destination, entry.name),
|
|
107
|
+
config
|
|
108
|
+
);
|
|
115
109
|
}
|
|
116
|
-
|
|
110
|
+
let processedCount = 0;
|
|
111
|
+
const batchSize = 5;
|
|
112
|
+
const totalImages = imageEntries.length;
|
|
113
|
+
for (let i = 0; i < imageEntries.length; i += batchSize) {
|
|
114
|
+
const batch = imageEntries.slice(i, Math.min(i + batchSize, imageEntries.length));
|
|
115
|
+
const results = await Promise.all(
|
|
116
|
+
batch.map(async (entry, j) => {
|
|
117
|
+
const srcPath = path2.join(source, entry.name);
|
|
118
|
+
const destPath = path2.join(destination, entry.name);
|
|
119
|
+
const num = i + j + 1;
|
|
120
|
+
config.spinner.text = `Optimizing [${num}/${totalImages}]: ${entry.name}`;
|
|
121
|
+
try {
|
|
122
|
+
const buffer = await fs.readFile(srcPath);
|
|
123
|
+
const optimizedBuffer = await optimizeBuffer(buffer, path2.extname(entry.name), config);
|
|
124
|
+
await fs.writeFile(destPath, optimizedBuffer);
|
|
125
|
+
return 1;
|
|
126
|
+
} catch (error) {
|
|
127
|
+
console.error(chalk2.red(`Failed to optimize ${entry.name}: ${error.message}`));
|
|
128
|
+
await fs.copyFile(srcPath, destPath);
|
|
129
|
+
return 1;
|
|
130
|
+
}
|
|
131
|
+
})
|
|
132
|
+
);
|
|
133
|
+
processedCount += results.length;
|
|
117
134
|
}
|
|
118
|
-
|
|
119
|
-
|
|
135
|
+
await Promise.all(
|
|
136
|
+
nonImageEntries.map((entry) => {
|
|
137
|
+
return fs.copyFile(path2.join(source, entry.name), path2.join(destination, entry.name));
|
|
138
|
+
})
|
|
139
|
+
);
|
|
140
|
+
return processedCount;
|
|
120
141
|
}
|
|
121
142
|
async function processZip(source, destination, config) {
|
|
122
143
|
const zipData = await fs.readFile(source);
|
|
123
144
|
const zip = await JSZip.loadAsync(zipData);
|
|
124
145
|
const newZip = new JSZip();
|
|
125
146
|
const fileNames = Object.keys(zip.files);
|
|
147
|
+
const imageFiles = fileNames.filter(
|
|
148
|
+
(f) => !zip.files[f].dir && isSupportedImage(f) && path2.extname(f).toLowerCase() !== ".svg"
|
|
149
|
+
);
|
|
150
|
+
const totalImages = imageFiles.length;
|
|
151
|
+
let imageIndex = 0;
|
|
126
152
|
for (const fileName of fileNames) {
|
|
127
153
|
const file = zip.files[fileName];
|
|
128
154
|
if (file.dir) {
|
|
@@ -130,14 +156,19 @@ async function processZip(source, destination, config) {
|
|
|
130
156
|
continue;
|
|
131
157
|
}
|
|
132
158
|
const content = await file.async("nodebuffer");
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
159
|
+
const ext = path2.extname(fileName).toLowerCase();
|
|
160
|
+
if (ext === ".svg") {
|
|
161
|
+
newZip.file(fileName, content);
|
|
162
|
+
} else if (isSupportedImage(fileName)) {
|
|
163
|
+
imageIndex++;
|
|
164
|
+
config.spinner.text = `Optimizing in zip [${imageIndex}/${totalImages}]: ${fileName}`;
|
|
165
|
+
try {
|
|
166
|
+
const optimized = await optimizeBuffer(content, path2.extname(fileName), config);
|
|
167
|
+
newZip.file(fileName, optimized);
|
|
168
|
+
} catch (error) {
|
|
169
|
+
console.error(chalk2.red(`Failed to optimize ${fileName}: ${error.message}`));
|
|
170
|
+
newZip.file(fileName, content);
|
|
171
|
+
}
|
|
141
172
|
} else {
|
|
142
173
|
newZip.file(fileName, content);
|
|
143
174
|
}
|
|
@@ -149,69 +180,84 @@ async function processZip(source, destination, config) {
|
|
|
149
180
|
compressionOptions: { level: 6 }
|
|
150
181
|
});
|
|
151
182
|
await fs.writeFile(destination, outputBuffer);
|
|
152
|
-
console.log(`
|
|
153
|
-
\u{1F4C1} Output: ${chalk.cyan(destination)}`);
|
|
154
183
|
}
|
|
155
184
|
async function processSingleFile(source, destination, config) {
|
|
156
185
|
const buffer = await fs.readFile(source);
|
|
157
|
-
const optimized = await optimizeBuffer(buffer,
|
|
186
|
+
const optimized = await optimizeBuffer(buffer, path2.extname(source), config);
|
|
158
187
|
await fs.writeFile(destination, optimized);
|
|
159
|
-
console.log(`
|
|
160
|
-
\u{1F4C1} Output: ${chalk.cyan(destination)}`);
|
|
161
188
|
}
|
|
162
|
-
|
|
163
|
-
|
|
189
|
+
|
|
190
|
+
// src/cli.ts
|
|
191
|
+
import { createRequire } from "module";
|
|
192
|
+
import { Command } from "commander";
|
|
193
|
+
import fs2 from "fs/promises";
|
|
194
|
+
import path3 from "path";
|
|
195
|
+
import chalk3 from "chalk";
|
|
196
|
+
import ora from "ora";
|
|
197
|
+
var require2 = createRequire(import.meta.url);
|
|
198
|
+
var { version } = require2("../package.json");
|
|
199
|
+
async function main() {
|
|
200
|
+
const program = new Command();
|
|
201
|
+
program.name("image-optimizer").description(
|
|
202
|
+
chalk3.cyan(
|
|
203
|
+
"\u{1F680} Universal CLI to optimize images (File, Folder, or Zip). Supports JPG, PNG, WebP, AVIF, GIF, TIFF, SVG."
|
|
204
|
+
)
|
|
205
|
+
).version(version).requiredOption("-s, --source <path>", "Path to the input file, folder, or zip").option("-o, --output <path>", "Path to the output (default: source location + -optimized)").option("-q, --quality <number>", "Quality of compression (1-100)", "80").option("-w, --width <number>", "Max width to resize images to", "1600").parse(process.argv);
|
|
206
|
+
const options = program.opts();
|
|
207
|
+
const spinner = ora("Analyzing source...").start();
|
|
164
208
|
try {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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;
|
|
209
|
+
const sourcePath = path3.resolve(process.cwd(), options.source);
|
|
210
|
+
try {
|
|
211
|
+
await fs2.access(sourcePath);
|
|
212
|
+
} catch {
|
|
213
|
+
spinner.fail(`Source not found: ${sourcePath}`);
|
|
214
|
+
process.exit(1);
|
|
194
215
|
}
|
|
195
|
-
const
|
|
196
|
-
|
|
197
|
-
|
|
216
|
+
const stats = await fs2.stat(sourcePath);
|
|
217
|
+
const config = {
|
|
218
|
+
quality: parseInt(options.quality),
|
|
219
|
+
width: parseInt(options.width),
|
|
220
|
+
spinner
|
|
221
|
+
};
|
|
222
|
+
if (stats.isDirectory()) {
|
|
223
|
+
const outputPath = determineOutputPath(sourcePath, options.output, "-optimized");
|
|
224
|
+
spinner.text = "Processing Directory...";
|
|
225
|
+
await processDirectory(sourcePath, outputPath, config);
|
|
226
|
+
logOutputPath(outputPath);
|
|
227
|
+
} else if (sourcePath.endsWith(".zip")) {
|
|
228
|
+
const outputPath = determineOutputPath(sourcePath, options.output, "-optimized.zip");
|
|
229
|
+
spinner.text = "Processing Zip...";
|
|
230
|
+
await processZip(sourcePath, outputPath, config);
|
|
231
|
+
logOutputPath(outputPath);
|
|
232
|
+
} else if (isSupportedImage(sourcePath)) {
|
|
233
|
+
const ext = path3.extname(sourcePath);
|
|
234
|
+
const outputPath = determineOutputPath(sourcePath, options.output, `-optimized${ext}`);
|
|
235
|
+
spinner.text = "Processing Single File...";
|
|
236
|
+
await processSingleFile(sourcePath, outputPath, config);
|
|
237
|
+
logOutputPath(outputPath);
|
|
238
|
+
} else {
|
|
239
|
+
spinner.fail("Unsupported file type. Please provide a Folder, Zip, or supported Image.");
|
|
240
|
+
process.exit(1);
|
|
198
241
|
}
|
|
199
|
-
|
|
200
|
-
} catch (
|
|
201
|
-
|
|
242
|
+
spinner.succeed(chalk3.green("Optimization Complete!"));
|
|
243
|
+
} catch (error) {
|
|
244
|
+
spinner.fail("Error occurred");
|
|
245
|
+
console.error(chalk3.red(error.message));
|
|
246
|
+
process.exit(1);
|
|
202
247
|
}
|
|
203
248
|
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
return path.resolve(process.cwd(), userOutput);
|
|
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}`);
|
|
249
|
+
|
|
250
|
+
// src/index.ts
|
|
251
|
+
import { fileURLToPath } from "url";
|
|
252
|
+
var isDirectRun = process.argv[1] === fileURLToPath(import.meta.url);
|
|
253
|
+
if (isDirectRun) {
|
|
254
|
+
main();
|
|
216
255
|
}
|
|
217
|
-
|
|
256
|
+
export {
|
|
257
|
+
determineOutputPath,
|
|
258
|
+
isSupportedImage,
|
|
259
|
+
optimizeBuffer,
|
|
260
|
+
processDirectory,
|
|
261
|
+
processSingleFile,
|
|
262
|
+
processZip
|
|
263
|
+
};
|
package/package.json
CHANGED
|
@@ -1,22 +1,35 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mantiqh/image-optimizer",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.2",
|
|
4
4
|
"description": "CLI to optimize images.",
|
|
5
|
+
"type": "module",
|
|
5
6
|
"main": "dist/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./dist/index.js"
|
|
9
|
+
},
|
|
6
10
|
"bin": {
|
|
7
11
|
"image-optimizer": "./dist/index.js"
|
|
8
12
|
},
|
|
9
|
-
"
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
"
|
|
14
|
-
"prepublishOnly": "pnpm build"
|
|
13
|
+
"files": [
|
|
14
|
+
"dist"
|
|
15
|
+
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=18"
|
|
15
18
|
},
|
|
16
|
-
"keywords": [
|
|
19
|
+
"keywords": [
|
|
20
|
+
"image",
|
|
21
|
+
"optimizer",
|
|
22
|
+
"compress",
|
|
23
|
+
"resize",
|
|
24
|
+
"sharp",
|
|
25
|
+
"cli",
|
|
26
|
+
"png",
|
|
27
|
+
"jpg",
|
|
28
|
+
"webp",
|
|
29
|
+
"avif"
|
|
30
|
+
],
|
|
17
31
|
"author": "Muqtadir A.",
|
|
18
32
|
"license": "MIT",
|
|
19
|
-
"packageManager": "pnpm@10.12.3",
|
|
20
33
|
"dependencies": {
|
|
21
34
|
"chalk": "^5.6.2",
|
|
22
35
|
"commander": "^14.0.2",
|
|
@@ -25,10 +38,28 @@
|
|
|
25
38
|
"sharp": "^0.34.5"
|
|
26
39
|
},
|
|
27
40
|
"devDependencies": {
|
|
28
|
-
"@
|
|
41
|
+
"@eslint/js": "^9.0.0",
|
|
29
42
|
"@types/node": "^25.0.3",
|
|
30
|
-
"
|
|
43
|
+
"eslint": "^9.0.0",
|
|
44
|
+
"eslint-config-prettier": "^10.0.0",
|
|
45
|
+
"eslint-plugin-prettier": "^5.0.0",
|
|
46
|
+
"np": "^10.0.0",
|
|
47
|
+
"prettier": "^3.0.0",
|
|
31
48
|
"tsup": "^8.5.1",
|
|
32
|
-
"typescript": "^5.9.3"
|
|
49
|
+
"typescript": "^5.9.3",
|
|
50
|
+
"typescript-eslint": "^8.0.0",
|
|
51
|
+
"vitest": "^3.0.0"
|
|
52
|
+
},
|
|
53
|
+
"scripts": {
|
|
54
|
+
"build": "tsup src/index.ts --format esm --clean",
|
|
55
|
+
"dev": "tsup src/index.ts --format esm --watch --onSuccess \"node dist/index.js\"",
|
|
56
|
+
"start": "node dist/index.js",
|
|
57
|
+
"test": "vitest run",
|
|
58
|
+
"test:watch": "vitest",
|
|
59
|
+
"lint": "eslint src/ tests/",
|
|
60
|
+
"lint:fix": "eslint src/ tests/ --fix",
|
|
61
|
+
"format": "prettier --write \"src/**/*.ts\" \"tests/**/*.ts\"",
|
|
62
|
+
"format:check": "prettier --check \"src/**/*.ts\" \"tests/**/*.ts\"",
|
|
63
|
+
"release": "np"
|
|
33
64
|
}
|
|
34
|
-
}
|
|
65
|
+
}
|
package/src/index.ts
DELETED
|
@@ -1,302 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { Command } from "commander";
|
|
3
|
-
import fs from "fs/promises";
|
|
4
|
-
import path from "path";
|
|
5
|
-
import JSZip from "jszip";
|
|
6
|
-
import sharp from "sharp";
|
|
7
|
-
import chalk from "chalk";
|
|
8
|
-
import ora from "ora";
|
|
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
|
-
|
|
22
|
-
const program = new Command();
|
|
23
|
-
|
|
24
|
-
program
|
|
25
|
-
.name("image-optimizer")
|
|
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
|
-
)
|
|
40
|
-
.option("-q, --quality <number>", "Quality of compression (1-100)", "80")
|
|
41
|
-
.option("-w, --width <number>", "Max width to resize images to", "1600")
|
|
42
|
-
.parse(process.argv);
|
|
43
|
-
|
|
44
|
-
const options = program.opts();
|
|
45
|
-
|
|
46
|
-
async function main() {
|
|
47
|
-
const spinner = ora("Analyzing source...").start();
|
|
48
|
-
|
|
49
|
-
try {
|
|
50
|
-
// Resolve absolute path of the source immediately
|
|
51
|
-
const sourcePath = path.resolve(process.cwd(), options.source);
|
|
52
|
-
|
|
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);
|
|
59
|
-
}
|
|
60
|
-
|
|
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
|
-
);
|
|
101
|
-
process.exit(1);
|
|
102
|
-
}
|
|
103
|
-
|
|
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
|
-
}
|
|
111
|
-
|
|
112
|
-
// --- Processors ---
|
|
113
|
-
|
|
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 });
|
|
125
|
-
|
|
126
|
-
// 2. Read Directory
|
|
127
|
-
const entries = await fs.readdir(source, { withFileTypes: true });
|
|
128
|
-
let processedCount = 0;
|
|
129
|
-
|
|
130
|
-
for (const entry of entries) {
|
|
131
|
-
const srcPath = path.join(source, entry.name);
|
|
132
|
-
const destPath = path.join(destination, entry.name);
|
|
133
|
-
|
|
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++;
|
|
148
|
-
} else {
|
|
149
|
-
// Copy non-images
|
|
150
|
-
await fs.copyFile(srcPath, destPath);
|
|
151
|
-
}
|
|
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
|
-
}
|
|
167
|
-
|
|
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();
|
|
172
|
-
|
|
173
|
-
const fileNames = Object.keys(zip.files);
|
|
174
|
-
|
|
175
|
-
for (const fileName of fileNames) {
|
|
176
|
-
const file = zip.files[fileName];
|
|
177
|
-
if (file.dir) {
|
|
178
|
-
newZip.folder(fileName);
|
|
179
|
-
continue;
|
|
180
|
-
}
|
|
181
|
-
|
|
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
|
-
}
|
|
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)}`);
|
|
215
|
-
}
|
|
216
|
-
|
|
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
|
-
|
|
226
|
-
try {
|
|
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);
|
|
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}`);
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
main();
|
package/tsconfig.json
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"compilerOptions": {
|
|
3
|
-
"target": "ES2022",
|
|
4
|
-
"module": "NodeNext",
|
|
5
|
-
"moduleResolution": "NodeNext",
|
|
6
|
-
"outDir": "./dist",
|
|
7
|
-
"esModuleInterop": true,
|
|
8
|
-
"forceConsistentCasingInFileNames": true,
|
|
9
|
-
"strict": true,
|
|
10
|
-
"skipLibCheck": true
|
|
11
|
-
},
|
|
12
|
-
"include": ["src/**/*"]
|
|
13
|
-
}
|