Skip to content

Commit 5689da7

Browse files
authored
feat(scripts): add image compression script (#55)
1 parent 128cc12 commit 5689da7

2 files changed

Lines changed: 157 additions & 1 deletion

File tree

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
"preview": "astro preview",
99
"astro": "astro",
1010
"new": "node scripts/new-post.js",
11-
"cover": "node scripts/generate-cover.js"
11+
"cover": "node scripts/generate-cover.js",
12+
"compress": "node scripts/compress-images.js"
1213
},
1314
"dependencies": {
1415
"@astrojs/rss": "^4.0.14",

scripts/compress-images.js

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import sharp from "sharp";
2+
import { readdir, stat } from "fs/promises";
3+
import { join, extname } from "path";
4+
import { fileURLToPath } from "url";
5+
import { dirname } from "path";
6+
7+
const __dirname = dirname(fileURLToPath(import.meta.url));
8+
const blogDir = join(__dirname, "..", "src", "content", "blog");
9+
10+
// Compression settings
11+
const PNG_QUALITY = 80; // 1-100, lower = smaller file
12+
const PNG_COMPRESSION = 9; // 0-9, higher = more compression (slower)
13+
const MIN_SAVINGS_PERCENT = 5; // Only replace if we save at least this much
14+
15+
async function findImages(dir) {
16+
const images = [];
17+
18+
async function scan(currentDir) {
19+
const entries = await readdir(currentDir, { withFileTypes: true });
20+
21+
for (const entry of entries) {
22+
const fullPath = join(currentDir, entry.name);
23+
24+
if (entry.isDirectory()) {
25+
await scan(fullPath);
26+
} else if (entry.isFile()) {
27+
const ext = extname(entry.name).toLowerCase();
28+
if ([".png", ".jpg", ".jpeg"].includes(ext)) {
29+
images.push(fullPath);
30+
}
31+
}
32+
}
33+
}
34+
35+
await scan(dir);
36+
return images;
37+
}
38+
39+
async function compressImage(imagePath) {
40+
const ext = extname(imagePath).toLowerCase();
41+
const originalStats = await stat(imagePath);
42+
const originalSize = originalStats.size;
43+
44+
let pipeline = sharp(imagePath);
45+
const metadata = await pipeline.metadata();
46+
47+
// Skip if already very small
48+
if (originalSize < 10000) {
49+
return { skipped: true, reason: "already small" };
50+
}
51+
52+
let outputBuffer;
53+
54+
if (ext === ".png") {
55+
// For PNGs: use palette-based compression when possible
56+
outputBuffer = await sharp(imagePath)
57+
.png({
58+
compressionLevel: PNG_COMPRESSION,
59+
palette: true,
60+
quality: PNG_QUALITY,
61+
effort: 10, // max effort for smallest size
62+
})
63+
.toBuffer();
64+
} else if (ext === ".jpg" || ext === ".jpeg") {
65+
outputBuffer = await sharp(imagePath)
66+
.jpeg({
67+
quality: 85,
68+
mozjpeg: true,
69+
})
70+
.toBuffer();
71+
}
72+
73+
const newSize = outputBuffer.length;
74+
const savingsPercent = ((originalSize - newSize) / originalSize) * 100;
75+
76+
if (savingsPercent >= MIN_SAVINGS_PERCENT) {
77+
await sharp(outputBuffer).toFile(imagePath);
78+
return {
79+
compressed: true,
80+
originalSize,
81+
newSize,
82+
savingsPercent: savingsPercent.toFixed(1),
83+
};
84+
}
85+
86+
return {
87+
skipped: true,
88+
reason: `savings too small (${savingsPercent.toFixed(1)}%)`,
89+
};
90+
}
91+
92+
function formatBytes(bytes) {
93+
if (bytes < 1024) return bytes + " B";
94+
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
95+
return (bytes / (1024 * 1024)).toFixed(1) + " MB";
96+
}
97+
98+
async function main() {
99+
const args = process.argv.slice(2);
100+
let targetDir = blogDir;
101+
102+
// Allow passing a specific directory
103+
if (args[0]) {
104+
targetDir = args[0];
105+
}
106+
107+
console.log(`\nScanning for images in: ${targetDir}\n`);
108+
109+
const images = await findImages(targetDir);
110+
console.log(`Found ${images.length} images\n`);
111+
112+
let totalOriginal = 0;
113+
let totalNew = 0;
114+
let compressedCount = 0;
115+
let skippedCount = 0;
116+
117+
for (const imagePath of images) {
118+
const relativePath = imagePath.replace(blogDir, "").replace(/^[/\\]/, "");
119+
process.stdout.write(`Processing: ${relativePath}... `);
120+
121+
try {
122+
const result = await compressImage(imagePath);
123+
124+
if (result.compressed) {
125+
console.log(
126+
`✓ ${formatBytes(result.originalSize)}${formatBytes(result.newSize)} (-${result.savingsPercent}%)`
127+
);
128+
totalOriginal += result.originalSize;
129+
totalNew += result.newSize;
130+
compressedCount++;
131+
} else {
132+
console.log(`⊘ skipped (${result.reason})`);
133+
skippedCount++;
134+
}
135+
} catch (err) {
136+
console.log(`✗ error: ${err.message}`);
137+
}
138+
}
139+
140+
console.log(`\n${"─".repeat(60)}`);
141+
console.log(`Compressed: ${compressedCount} images`);
142+
console.log(`Skipped: ${skippedCount} images`);
143+
144+
if (compressedCount > 0) {
145+
const totalSavings = totalOriginal - totalNew;
146+
const totalPercent = ((totalSavings / totalOriginal) * 100).toFixed(1);
147+
console.log(
148+
`Total savings: ${formatBytes(totalSavings)} (${totalPercent}%)`
149+
);
150+
}
151+
152+
console.log();
153+
}
154+
155+
main().catch(console.error);

0 commit comments

Comments
 (0)