perf: convert all images to webp, update references
CI/CD / test-and-build (push) Waiting to run
CI/CD / deploy (push) Blocked by required conditions

- 77 jpg/png/gif → webp (kept 7 where webp larger)
- public/ assets: 23.3 MB → ~12 MB (~50% smaller)
- 110 image references updated across data files + components
- scripts/convert-to-webp.mjs + scripts/fix-image-refs.mjs added

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Najjar\NajjarV02
2026-05-21 17:26:39 +04:00
co-authored by Claude Opus 4.7
parent de25d774af
commit dda358b59d
165 changed files with 254 additions and 110 deletions
+74
View File
@@ -0,0 +1,74 @@
import { promises as fs } from 'node:fs';
import path from 'node:path';
import sharp from 'sharp';
const ROOT = path.resolve('public');
// Files NOT to touch
const SKIP = new Set(['favicon.ico', 'apple-touch-icon.png', 'icon.png', 'apple-icon.png']);
const SKIP_EXACT_PATHS = new Set([
'public/favicon.ico',
'public/apple-touch-icon.png',
]);
const exts = new Set(['.jpg', '.jpeg', '.png', '.gif']);
const results = { converted: 0, savedBytes: 0, skipped: 0, failed: 0 };
async function walk(dir) {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const e of entries) {
const p = path.join(dir, e.name);
if (e.isDirectory()) {
await walk(p);
} else if (exts.has(path.extname(p).toLowerCase())) {
await convert(p);
}
}
}
async function convert(input) {
const ext = path.extname(input).toLowerCase();
const base = input.slice(0, -ext.length);
const output = base + '.webp';
const relRoot = path.relative(process.cwd(), input).split(path.sep).join('/');
if (SKIP.has(path.basename(input)) || SKIP_EXACT_PATHS.has(relRoot)) {
results.skipped++;
console.log(`SKIP ${relRoot}`);
return;
}
try {
const stIn = await fs.stat(input);
// Output options per type
const isPng = ext === '.png';
const isGif = ext === '.gif';
const opts = isPng
? { quality: 86, alphaQuality: 90, effort: 5, nearLossless: false }
: isGif
? { quality: 80, effort: 5, loop: 0 } // animated -> animated webp
: { quality: 82, effort: 5 };
let pipeline = sharp(input, { animated: isGif });
await pipeline.webp(opts).toFile(output);
const stOut = await fs.stat(output);
if (stOut.size >= stIn.size) {
// worse than original — keep original, remove webp
await fs.unlink(output);
results.skipped++;
console.log(`KEEP ${relRoot} (webp larger: ${stOut.size} vs ${stIn.size})`);
return;
}
await fs.unlink(input);
results.converted++;
results.savedBytes += stIn.size - stOut.size;
const inKB = (stIn.size / 1024).toFixed(0);
const outKB = (stOut.size / 1024).toFixed(0);
console.log(`OK ${relRoot} ${inKB}KB -> ${outKB}KB`);
} catch (err) {
results.failed++;
console.log(`FAIL ${relRoot}: ${err.message}`);
}
}
await walk(ROOT);
const savedMB = (results.savedBytes / 1024 / 1024).toFixed(2);
console.log('\n---');
console.log(`converted: ${results.converted} skipped: ${results.skipped} failed: ${results.failed}`);
console.log(`saved: ${savedMB} MB`);
+70
View File
@@ -0,0 +1,70 @@
import { promises as fs } from 'node:fs';
import path from 'node:path';
// Build map: relative path that ORIGINALLY existed but now only has .webp
// Walk /public for .webp files, compute their candidate original ext (.jpg/.png/.gif)
// If the candidate no longer exists on disk, that's a swap target.
const PUBLIC_ROOT = path.resolve('public');
const SRC_ROOT = path.resolve('src');
async function walk(dir, out) {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const e of entries) {
const p = path.join(dir, e.name);
if (e.isDirectory()) await walk(p, out);
else out.push(p);
}
}
// 1. Find .webp files and detect which original ext is missing
const all = [];
await walk(PUBLIC_ROOT, all);
const webps = all.filter((p) => p.toLowerCase().endsWith('.webp'));
const existing = new Set(all.map((p) => p.toLowerCase()));
const swaps = []; // { from: /images/foo.jpg, to: /images/foo.webp }
for (const wp of webps) {
const base = wp.slice(0, -5); // strip .webp
for (const ext of ['.jpg', '.jpeg', '.png', '.gif']) {
const candidate = base + ext;
if (!existing.has(candidate.toLowerCase())) {
// Candidate gone => it was converted
const rel = '/' + path.relative(PUBLIC_ROOT, candidate).split(path.sep).join('/');
const relWebp = '/' + path.relative(PUBLIC_ROOT, wp).split(path.sep).join('/');
swaps.push({ from: rel, to: relWebp });
}
}
}
console.log(`detected ${swaps.length} swap pairs`);
// 2. Walk /src for ts/tsx/js/jsx/json/md/css and apply replacements
const srcExts = new Set(['.ts', '.tsx', '.js', '.jsx', '.json', '.md', '.css', '.html', '.mjs']);
const srcFiles = [];
await walk(SRC_ROOT, srcFiles);
const targets = srcFiles.filter((p) => srcExts.has(path.extname(p).toLowerCase()));
let totalReplacements = 0;
let touchedFiles = 0;
for (const file of targets) {
let text = await fs.readFile(file, 'utf8');
let count = 0;
for (const s of swaps) {
if (text.includes(s.from)) {
const before = text;
text = text.split(s.from).join(s.to);
const occurrences = (before.length - text.length) / (s.from.length - s.to.length);
count += occurrences;
}
}
if (count > 0) {
await fs.writeFile(file, text, 'utf8');
totalReplacements += count;
touchedFiles++;
const rel = path.relative(process.cwd(), file).split(path.sep).join('/');
console.log(`UPDATE ${rel} (${count} swaps)`);
}
}
console.log('---');
console.log(`replaced ${totalReplacements} references across ${touchedFiles} files`);