@mantiqh/image-optimizer 1.0.0
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 +111 -0
- package/package.json +34 -0
- package/src/index.ts +158 -0
- package/tsconfig.json +13 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
import fs from "fs/promises";
|
|
6
|
+
import path from "path";
|
|
7
|
+
import JSZip from "jszip";
|
|
8
|
+
import sharp from "sharp";
|
|
9
|
+
import chalk from "chalk";
|
|
10
|
+
import ora from "ora";
|
|
11
|
+
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);
|
|
13
|
+
var options = program.opts();
|
|
14
|
+
async function main() {
|
|
15
|
+
const spinner = ora("Starting optimization...").start();
|
|
16
|
+
try {
|
|
17
|
+
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}`);
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
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
|
+
}
|
|
79
|
+
}
|
|
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
|
+
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
|
+
} catch (error) {
|
|
98
|
+
spinner.fail("Error occurred");
|
|
99
|
+
console.error(chalk.red(error.message));
|
|
100
|
+
process.exit(1);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
async function fileExists(path2) {
|
|
104
|
+
try {
|
|
105
|
+
await fs.access(path2);
|
|
106
|
+
return true;
|
|
107
|
+
} catch {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
main();
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mantiqh/image-optimizer",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "CLI to optimize images inside a zip file",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"image-optimizer": "./dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"type": "module",
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsup src/index.ts --format esm --clean",
|
|
12
|
+
"dev": "tsup src/index.ts --format esm --watch --onSuccess \"node dist/index.js\"",
|
|
13
|
+
"start": "node dist/index.js",
|
|
14
|
+
"prepublishOnly": "pnpm build"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [],
|
|
17
|
+
"author": "",
|
|
18
|
+
"license": "ISC",
|
|
19
|
+
"packageManager": "pnpm@10.12.3",
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"chalk": "^5.6.2",
|
|
22
|
+
"commander": "^14.0.2",
|
|
23
|
+
"jszip": "^3.10.1",
|
|
24
|
+
"ora": "^9.0.0",
|
|
25
|
+
"sharp": "^0.34.5"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@types/jszip": "^3.4.1",
|
|
29
|
+
"@types/node": "^25.0.3",
|
|
30
|
+
"@types/sharp": "^0.32.0",
|
|
31
|
+
"tsup": "^8.5.1",
|
|
32
|
+
"typescript": "^5.9.3"
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
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
|
+
const program = new Command();
|
|
11
|
+
|
|
12
|
+
program
|
|
13
|
+
.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")
|
|
18
|
+
.option("-q, --quality <number>", "Quality of compression (1-100)", "80")
|
|
19
|
+
.option("-w, --width <number>", "Max width to resize images to", "1600")
|
|
20
|
+
.parse(process.argv);
|
|
21
|
+
|
|
22
|
+
const options = program.opts();
|
|
23
|
+
|
|
24
|
+
async function main() {
|
|
25
|
+
const spinner = ora("Starting optimization...").start();
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
// 1. Resolve Paths
|
|
29
|
+
const sourcePath = path.resolve(process.cwd(), options.source);
|
|
30
|
+
|
|
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}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (!(await fileExists(sourcePath))) {
|
|
43
|
+
spinner.fail(`Source file not found: ${sourcePath}`);
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
|
|
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);
|
|
55
|
+
|
|
56
|
+
// 3. Process Entries
|
|
57
|
+
const fileNames = Object.keys(zip.files);
|
|
58
|
+
let processedCount = 0;
|
|
59
|
+
let savedBytes = 0;
|
|
60
|
+
|
|
61
|
+
spinner.text = `Found ${fileNames.length} files. Processing...`;
|
|
62
|
+
|
|
63
|
+
// Process in parallel (batches) or sequence. Sequence is safer for memory.
|
|
64
|
+
for (const fileName of fileNames) {
|
|
65
|
+
const file = zip.files[fileName];
|
|
66
|
+
|
|
67
|
+
if (file.dir) {
|
|
68
|
+
newZip.folder(fileName);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
|
|
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
|
+
}
|
|
115
|
+
} else {
|
|
116
|
+
// Non-image files: just copy them
|
|
117
|
+
newZip.file(fileName, content);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// 4. Write Output Zip
|
|
122
|
+
spinner.text = "Generating output zip...";
|
|
123
|
+
|
|
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
|
+
});
|
|
130
|
+
|
|
131
|
+
await fs.writeFile(outputPath, outputBuffer);
|
|
132
|
+
|
|
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);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Helper to check file existence
|
|
149
|
+
async function fileExists(path: string) {
|
|
150
|
+
try {
|
|
151
|
+
await fs.access(path);
|
|
152
|
+
return true;
|
|
153
|
+
} catch {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
main();
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
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
|
+
}
|