@mantiqh/image-optimizer 1.2.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/dist/index.js +183 -157
- package/package.json +44 -14
- package/AGENTS.md +0 -104
- package/scripts/deploy.sh +0 -28
- package/src/index.ts +0 -319
- package/tsconfig.json +0 -13
package/dist/index.js
CHANGED
|
@@ -1,17 +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";
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
+
}
|
|
14
48
|
}
|
|
49
|
+
|
|
50
|
+
// src/helpers.ts
|
|
51
|
+
import path from "path";
|
|
52
|
+
import chalk from "chalk";
|
|
53
|
+
|
|
54
|
+
// src/constants.ts
|
|
15
55
|
var SUPPORTED_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
16
56
|
".jpg",
|
|
17
57
|
".jpeg",
|
|
@@ -22,76 +62,31 @@ var SUPPORTED_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
|
22
62
|
".tiff",
|
|
23
63
|
".svg"
|
|
24
64
|
]);
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
async function main() {
|
|
39
|
-
const spinner = ora("Analyzing source...").start();
|
|
40
|
-
try {
|
|
41
|
-
const sourcePath = path.resolve(process.cwd(), options.source);
|
|
42
|
-
try {
|
|
43
|
-
await fs.access(sourcePath);
|
|
44
|
-
} catch {
|
|
45
|
-
spinner.fail(`Source not found: ${sourcePath}`);
|
|
46
|
-
process.exit(1);
|
|
47
|
-
}
|
|
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);
|
|
87
|
-
}
|
|
88
|
-
spinner.succeed(chalk.green("Optimization Complete!"));
|
|
89
|
-
} catch (error) {
|
|
90
|
-
spinner.fail("Error occurred");
|
|
91
|
-
console.error(chalk.red(error.message));
|
|
92
|
-
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);
|
|
93
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}`);
|
|
94
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";
|
|
95
90
|
async function processDirectory(source, destination, config) {
|
|
96
91
|
if (source === destination) {
|
|
97
92
|
destination += "-1";
|
|
@@ -99,37 +94,49 @@ async function processDirectory(source, destination, config) {
|
|
|
99
94
|
await fs.mkdir(destination, { recursive: true });
|
|
100
95
|
const entries = await fs.readdir(source, { withFileTypes: true });
|
|
101
96
|
const dirEntries = entries.filter((e) => e.isDirectory());
|
|
102
|
-
const imageEntries = entries.filter(
|
|
103
|
-
|
|
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
|
+
);
|
|
104
103
|
for (const entry of dirEntries) {
|
|
105
|
-
await processDirectory(
|
|
104
|
+
await processDirectory(
|
|
105
|
+
path2.join(source, entry.name),
|
|
106
|
+
path2.join(destination, entry.name),
|
|
107
|
+
config
|
|
108
|
+
);
|
|
106
109
|
}
|
|
107
110
|
let processedCount = 0;
|
|
108
111
|
const batchSize = 5;
|
|
109
112
|
const totalImages = imageEntries.length;
|
|
110
113
|
for (let i = 0; i < imageEntries.length; i += batchSize) {
|
|
111
114
|
const batch = imageEntries.slice(i, Math.min(i + batchSize, imageEntries.length));
|
|
112
|
-
const results = await Promise.all(
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
+
);
|
|
128
133
|
processedCount += results.length;
|
|
129
134
|
}
|
|
130
|
-
await Promise.all(
|
|
131
|
-
|
|
132
|
-
|
|
135
|
+
await Promise.all(
|
|
136
|
+
nonImageEntries.map((entry) => {
|
|
137
|
+
return fs.copyFile(path2.join(source, entry.name), path2.join(destination, entry.name));
|
|
138
|
+
})
|
|
139
|
+
);
|
|
133
140
|
return processedCount;
|
|
134
141
|
}
|
|
135
142
|
async function processZip(source, destination, config) {
|
|
@@ -137,7 +144,9 @@ async function processZip(source, destination, config) {
|
|
|
137
144
|
const zip = await JSZip.loadAsync(zipData);
|
|
138
145
|
const newZip = new JSZip();
|
|
139
146
|
const fileNames = Object.keys(zip.files);
|
|
140
|
-
const imageFiles = fileNames.filter(
|
|
147
|
+
const imageFiles = fileNames.filter(
|
|
148
|
+
(f) => !zip.files[f].dir && isSupportedImage(f) && path2.extname(f).toLowerCase() !== ".svg"
|
|
149
|
+
);
|
|
141
150
|
const totalImages = imageFiles.length;
|
|
142
151
|
let imageIndex = 0;
|
|
143
152
|
for (const fileName of fileNames) {
|
|
@@ -147,17 +156,17 @@ async function processZip(source, destination, config) {
|
|
|
147
156
|
continue;
|
|
148
157
|
}
|
|
149
158
|
const content = await file.async("nodebuffer");
|
|
150
|
-
const ext =
|
|
159
|
+
const ext = path2.extname(fileName).toLowerCase();
|
|
151
160
|
if (ext === ".svg") {
|
|
152
161
|
newZip.file(fileName, content);
|
|
153
162
|
} else if (isSupportedImage(fileName)) {
|
|
154
163
|
imageIndex++;
|
|
155
164
|
config.spinner.text = `Optimizing in zip [${imageIndex}/${totalImages}]: ${fileName}`;
|
|
156
165
|
try {
|
|
157
|
-
const optimized = await optimizeBuffer(content,
|
|
166
|
+
const optimized = await optimizeBuffer(content, path2.extname(fileName), config);
|
|
158
167
|
newZip.file(fileName, optimized);
|
|
159
168
|
} catch (error) {
|
|
160
|
-
console.error(
|
|
169
|
+
console.error(chalk2.red(`Failed to optimize ${fileName}: ${error.message}`));
|
|
161
170
|
newZip.file(fileName, content);
|
|
162
171
|
}
|
|
163
172
|
} else {
|
|
@@ -174,64 +183,81 @@ async function processZip(source, destination, config) {
|
|
|
174
183
|
}
|
|
175
184
|
async function processSingleFile(source, destination, config) {
|
|
176
185
|
const buffer = await fs.readFile(source);
|
|
177
|
-
const optimized = await optimizeBuffer(buffer,
|
|
186
|
+
const optimized = await optimizeBuffer(buffer, path2.extname(source), config);
|
|
178
187
|
await fs.writeFile(destination, optimized);
|
|
179
188
|
}
|
|
180
|
-
|
|
181
|
-
|
|
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();
|
|
182
208
|
try {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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;
|
|
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);
|
|
214
215
|
}
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
|
|
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);
|
|
218
241
|
}
|
|
219
|
-
|
|
220
|
-
} catch (
|
|
221
|
-
|
|
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);
|
|
222
247
|
}
|
|
223
248
|
}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
return path.resolve(process.cwd(), userOutput);
|
|
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}`);
|
|
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();
|
|
236
255
|
}
|
|
237
|
-
|
|
256
|
+
export {
|
|
257
|
+
determineOutputPath,
|
|
258
|
+
isSupportedImage,
|
|
259
|
+
optimizeBuffer,
|
|
260
|
+
processDirectory,
|
|
261
|
+
processSingleFile,
|
|
262
|
+
processZip
|
|
263
|
+
};
|
package/package.json
CHANGED
|
@@ -1,23 +1,35 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mantiqh/image-optimizer",
|
|
3
|
-
"version": "1.2.
|
|
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",
|
|
15
|
-
"deploy": "bash scripts/deploy.sh"
|
|
13
|
+
"files": [
|
|
14
|
+
"dist"
|
|
15
|
+
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=18"
|
|
16
18
|
},
|
|
17
|
-
"keywords": [
|
|
19
|
+
"keywords": [
|
|
20
|
+
"image",
|
|
21
|
+
"optimizer",
|
|
22
|
+
"compress",
|
|
23
|
+
"resize",
|
|
24
|
+
"sharp",
|
|
25
|
+
"cli",
|
|
26
|
+
"png",
|
|
27
|
+
"jpg",
|
|
28
|
+
"webp",
|
|
29
|
+
"avif"
|
|
30
|
+
],
|
|
18
31
|
"author": "Muqtadir A.",
|
|
19
32
|
"license": "MIT",
|
|
20
|
-
"packageManager": "pnpm@10.12.3",
|
|
21
33
|
"dependencies": {
|
|
22
34
|
"chalk": "^5.6.2",
|
|
23
35
|
"commander": "^14.0.2",
|
|
@@ -26,10 +38,28 @@
|
|
|
26
38
|
"sharp": "^0.34.5"
|
|
27
39
|
},
|
|
28
40
|
"devDependencies": {
|
|
29
|
-
"@
|
|
41
|
+
"@eslint/js": "^9.0.0",
|
|
30
42
|
"@types/node": "^25.0.3",
|
|
31
|
-
"
|
|
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",
|
|
32
48
|
"tsup": "^8.5.1",
|
|
33
|
-
"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"
|
|
34
64
|
}
|
|
35
|
-
}
|
|
65
|
+
}
|
package/AGENTS.md
DELETED
|
@@ -1,104 +0,0 @@
|
|
|
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/scripts/deploy.sh
DELETED
|
@@ -1,28 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,319 +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
|
-
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
|
-
|
|
26
|
-
const program = new Command();
|
|
27
|
-
|
|
28
|
-
program
|
|
29
|
-
.name("image-optimizer")
|
|
30
|
-
.description(
|
|
31
|
-
chalk.cyan(
|
|
32
|
-
"🚀 Universal CLI to optimize images (File, Folder, or Zip). Supports JPG, PNG, WebP, AVIF, GIF, TIFF, SVG."
|
|
33
|
-
)
|
|
34
|
-
)
|
|
35
|
-
.version("1.2.1")
|
|
36
|
-
.requiredOption(
|
|
37
|
-
"-s, --source <path>",
|
|
38
|
-
"Path to the input file, folder, or zip"
|
|
39
|
-
)
|
|
40
|
-
.option(
|
|
41
|
-
"-o, --output <path>",
|
|
42
|
-
"Path to the output (default: source location + -optimized)"
|
|
43
|
-
)
|
|
44
|
-
.option("-q, --quality <number>", "Quality of compression (1-100)", "80")
|
|
45
|
-
.option("-w, --width <number>", "Max width to resize images to", "1600")
|
|
46
|
-
.parse(process.argv);
|
|
47
|
-
|
|
48
|
-
const options = program.opts();
|
|
49
|
-
|
|
50
|
-
async function main() {
|
|
51
|
-
const spinner = ora("Analyzing source...").start();
|
|
52
|
-
|
|
53
|
-
try {
|
|
54
|
-
// Resolve absolute path of the source immediately
|
|
55
|
-
const sourcePath = path.resolve(process.cwd(), options.source);
|
|
56
|
-
|
|
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);
|
|
63
|
-
}
|
|
64
|
-
|
|
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
|
-
);
|
|
108
|
-
process.exit(1);
|
|
109
|
-
}
|
|
110
|
-
|
|
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 });
|
|
130
|
-
|
|
131
|
-
const entries = await fs.readdir(source, { withFileTypes: true });
|
|
132
|
-
|
|
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"));
|
|
136
|
-
|
|
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
|
-
}
|
|
141
|
-
|
|
142
|
-
// Process images in parallel batches of 5
|
|
143
|
-
let processedCount = 0;
|
|
144
|
-
const batchSize = 5;
|
|
145
|
-
const totalImages = imageEntries.length;
|
|
146
|
-
|
|
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;
|
|
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;
|
|
185
|
-
|
|
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}`));
|
|
206
|
-
newZip.file(fileName, content);
|
|
207
|
-
}
|
|
208
|
-
} else {
|
|
209
|
-
newZip.file(fileName, content);
|
|
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
|
-
}
|
|
221
|
-
|
|
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
|
-
}
|
|
231
|
-
|
|
232
|
-
// --- Core Optimizer ---
|
|
233
|
-
|
|
234
|
-
async function optimizeBuffer(
|
|
235
|
-
buffer: Buffer,
|
|
236
|
-
ext: string,
|
|
237
|
-
config: any
|
|
238
|
-
): Promise<Buffer> {
|
|
239
|
-
const extension = ext.toLowerCase();
|
|
240
|
-
|
|
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;
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
|
|
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);
|
|
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}`);
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
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
|
-
}
|