#!/usr/bin/env node
/**
* Delete unreferenced original images from a built site.
*
* Astro emits every imported image asset into dist/_astro/, including the
* untouched original, even when no markup points at it. With <Picture> nothing
* links to that original — but it is still sitting on the server at a
* predictable path, still full-size, and still carrying whatever metadata the
* camera wrote. Serving a copy of every photograph that nobody asked for is
* both a privacy leak and wasted disk.
*
* This removes image files under _astro/ that no built file mentions. It is
* deliberately conservative: only raster image extensions, only when the exact
* filename appears nowhere in any text output, and it prints what it removed.
*
* Run as part of `npm run build` for each site.
*/
import { readdir, readFile, stat, unlink } from 'node:fs/promises';
import { join, extname, basename } from 'node:path';
const distDir = process.argv[2];
if (!distDir) {
console.error('usage: prune-originals.mjs <dist-dir>');
process.exit(1);
}
const PRUNABLE = new Set(['.jpg', '.jpeg', '.png', '.tif', '.tiff', '.heic', '.heif']);
const TEXT_EXT = new Set(['.html', '.css', '.js', '.mjs', '.json', '.xml', '.xsl', '.txt', '.map']);
async function walk(dir, found = []) {
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
return found;
}
for (const entry of entries) {
const path = join(dir, entry.name);
if (entry.isDirectory()) await walk(path, found);
else found.push(path);
}
return found;
}
const all = await walk(distDir);
// Everything a browser could ever read as text, concatenated once. Cheaper and
// far more reliable than trying to predict which file might link an asset.
const haystack = (
await Promise.all(
all
.filter((file) => TEXT_EXT.has(extname(file).toLowerCase()))
.map((file) => readFile(file, 'utf8').catch(() => '')),
)
).join('\n');
const candidates = all.filter(
(file) => file.includes('_astro') && PRUNABLE.has(extname(file).toLowerCase()),
);
let removed = 0;
let bytes = 0;
for (const file of candidates) {
const name = basename(file);
if (haystack.includes(name)) continue;
const { size } = await stat(file);
await unlink(file);
removed += 1;
bytes += size;
}
if (removed > 0) {
console.log(
`✓ pruned ${removed} unreferenced original image(s), ${(bytes / 1024).toFixed(0)} kB`,
);
}