eratic / scripts / prune-originals.mjs
354 words
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
#!/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`,
	);
}

static mirror of HEAD · about · clone: git clone https://git.makefuture.ro/eratic.git