Skip to content

Commit c1194a1

Browse files
committed
feat: add PNG to WebP optimization script and corresponding npm command
1 parent ad2f04d commit c1194a1

2 files changed

Lines changed: 230 additions & 0 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"generate:sitemap": "tsc --noEmit false src/scripts/generate-sitemap.ts --outDir build/scripts && node build/scripts/scripts/generate-sitemap.js",
1010
"generate:images": "tsc src/scripts/generate-images-sitemap.ts --outDir build/scripts && node build/scripts/generate-images-sitemap.js",
1111
"generate:feeds": "tsc --noEmit false src/scripts/generate-feeds.ts --outDir build/scripts && node build/scripts/scripts/generate-feeds.js",
12+
"optimize:png:webp": "tsc --noEmit false --esModuleInterop src/scripts/optimize-png-to-webp.ts --outDir build/scripts && node build/scripts/optimize-png-to-webp.js",
1213
"validate:sitemap": "tsc src/scripts/validate-sitemap.ts --outDir build/scripts && node build/scripts/validate-sitemap.js",
1314
"seo:indexing": "tsc --noEmit false src/scripts/seo-google-indexing-fix.ts --outDir build/scripts && node build/scripts/seo-google-indexing-fix.js",
1415
"test:seo": "tsc src/scripts/test-seo-config.ts --outDir build/test && node build/test/test-seo-config.js",
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
#!/usr/bin/env node
2+
3+
import { readdir, mkdir, rm, stat } from "fs/promises";
4+
import { dirname, extname, join, relative, resolve } from "path";
5+
import sharp from "sharp";
6+
7+
type CliOptions = {
8+
inputDir: string;
9+
outputDir: string;
10+
quality: number;
11+
effort: number;
12+
lossless: boolean;
13+
overwrite: boolean;
14+
deleteOriginal: boolean;
15+
};
16+
17+
type ConversionSummary = {
18+
scanned: number;
19+
converted: number;
20+
skipped: number;
21+
failed: number;
22+
};
23+
24+
function printHelp() {
25+
console.log(`\nPNG -> WebP optimizer\n
26+
Usage:
27+
npm run optimize:png:webp -- [options]\n
28+
Options:
29+
--input <dir> Source directory to scan recursively (default: public/assets)
30+
--output <dir> Output directory (default: same as --input)
31+
--quality <1-100> WebP quality (default: 82)
32+
--effort <0-6> WebP encoding effort (default: 4)
33+
--lossless Enable lossless WebP
34+
--overwrite Overwrite existing .webp files
35+
--delete-original Delete source .png after successful conversion
36+
--help Show this help\n
37+
`);
38+
}
39+
40+
function parseNumber(value: string | undefined, fallback: number, min: number, max: number): number {
41+
if (!value) return fallback;
42+
const numeric = Number(value);
43+
if (!Number.isFinite(numeric) || numeric < min || numeric > max) {
44+
throw new Error(`Invalid numeric value: ${value}. Expected ${min}-${max}.`);
45+
}
46+
return Math.round(numeric);
47+
}
48+
49+
function parseArgs(argv: string[]): CliOptions {
50+
let inputDir = resolve(process.cwd(), "public", "assets");
51+
let outputDir = inputDir;
52+
let quality = 82;
53+
let effort = 4;
54+
let lossless = false;
55+
let overwrite = false;
56+
let deleteOriginal = false;
57+
58+
for (let index = 0; index < argv.length; index += 1) {
59+
const arg = argv[index];
60+
61+
switch (arg) {
62+
case "--input": {
63+
const value = argv[index + 1];
64+
if (!value) throw new Error("--input requires a directory path.");
65+
inputDir = resolve(process.cwd(), value);
66+
if (outputDir === resolve(process.cwd(), "public", "assets")) {
67+
outputDir = inputDir;
68+
}
69+
index += 1;
70+
break;
71+
}
72+
case "--output": {
73+
const value = argv[index + 1];
74+
if (!value) throw new Error("--output requires a directory path.");
75+
outputDir = resolve(process.cwd(), value);
76+
index += 1;
77+
break;
78+
}
79+
case "--quality":
80+
quality = parseNumber(argv[index + 1], quality, 1, 100);
81+
index += 1;
82+
break;
83+
case "--effort":
84+
effort = parseNumber(argv[index + 1], effort, 0, 6);
85+
index += 1;
86+
break;
87+
case "--lossless":
88+
lossless = true;
89+
break;
90+
case "--overwrite":
91+
overwrite = true;
92+
break;
93+
case "--delete-original":
94+
deleteOriginal = true;
95+
break;
96+
case "--help":
97+
case "-h":
98+
printHelp();
99+
process.exit(0);
100+
break;
101+
default:
102+
throw new Error(`Unknown argument: ${arg}`);
103+
}
104+
}
105+
106+
return {
107+
inputDir,
108+
outputDir,
109+
quality,
110+
effort,
111+
lossless,
112+
overwrite,
113+
deleteOriginal,
114+
};
115+
}
116+
117+
async function collectPngFiles(directory: string): Promise<string[]> {
118+
const entries = await readdir(directory, { withFileTypes: true });
119+
const files: string[] = [];
120+
121+
for (const entry of entries) {
122+
const fullPath = join(directory, entry.name);
123+
if (entry.isDirectory()) {
124+
files.push(...(await collectPngFiles(fullPath)));
125+
continue;
126+
}
127+
128+
if (entry.isFile() && extname(entry.name).toLowerCase() === ".png") {
129+
files.push(fullPath);
130+
}
131+
}
132+
133+
return files;
134+
}
135+
136+
function toWebpPath(sourceFile: string, options: CliOptions): string {
137+
const relativePath = relative(options.inputDir, sourceFile);
138+
return join(options.outputDir, relativePath.replace(/\.png$/i, ".webp"));
139+
}
140+
141+
async function convertOne(sourceFile: string, options: CliOptions): Promise<"converted" | "skipped"> {
142+
const destinationFile = toWebpPath(sourceFile, options);
143+
await mkdir(dirname(destinationFile), { recursive: true });
144+
145+
if (!options.overwrite) {
146+
try {
147+
const [sourceStats, destinationStats] = await Promise.all([
148+
stat(sourceFile),
149+
stat(destinationFile),
150+
]);
151+
152+
if (destinationStats.mtimeMs >= sourceStats.mtimeMs) {
153+
return "skipped";
154+
}
155+
} catch {
156+
// Destination doesn't exist yet, continue.
157+
}
158+
}
159+
160+
await sharp(sourceFile)
161+
.rotate()
162+
.webp({
163+
quality: options.quality,
164+
effort: options.effort,
165+
lossless: options.lossless,
166+
})
167+
.toFile(destinationFile);
168+
169+
if (options.deleteOriginal) {
170+
await rm(sourceFile);
171+
}
172+
173+
return "converted";
174+
}
175+
176+
async function run() {
177+
const options = parseArgs(process.argv.slice(2));
178+
const summary: ConversionSummary = {
179+
scanned: 0,
180+
converted: 0,
181+
skipped: 0,
182+
failed: 0,
183+
};
184+
185+
console.log("Scanning:", options.inputDir);
186+
const sourceFiles = await collectPngFiles(options.inputDir);
187+
summary.scanned = sourceFiles.length;
188+
189+
if (sourceFiles.length === 0) {
190+
console.log("No .png files found.");
191+
return;
192+
}
193+
194+
console.log(`Found ${sourceFiles.length} PNG files.`);
195+
console.log("Output directory:", options.outputDir);
196+
197+
for (const file of sourceFiles) {
198+
try {
199+
const result = await convertOne(file, options);
200+
if (result === "converted") {
201+
summary.converted += 1;
202+
console.log(`Converted: ${relative(process.cwd(), file)}`);
203+
} else {
204+
summary.skipped += 1;
205+
console.log(`Skipped: ${relative(process.cwd(), file)} (up-to-date)`);
206+
}
207+
} catch (error) {
208+
summary.failed += 1;
209+
const message = error instanceof Error ? error.message : String(error);
210+
console.error(`Failed: ${relative(process.cwd(), file)} -> ${message}`);
211+
}
212+
}
213+
214+
console.log("\nSummary");
215+
console.log("- scanned: ", summary.scanned);
216+
console.log("- converted: ", summary.converted);
217+
console.log("- skipped: ", summary.skipped);
218+
console.log("- failed: ", summary.failed);
219+
220+
if (summary.failed > 0) {
221+
process.exitCode = 1;
222+
}
223+
}
224+
225+
run().catch((error) => {
226+
const message = error instanceof Error ? error.message : String(error);
227+
console.error("optimize-png-to-webp failed:", message);
228+
process.exit(1);
229+
});

0 commit comments

Comments
 (0)