orchard
mirrorEvery site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.
blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics
1// Turns frontend/images/ (committed sources) into frontend/public/images/
2// (generated, gitignored), which Vite copies into dist/. Go has no next/image,
3// so every width a browser might pick has to exist as a real file.
4//
5// The width ladder lives in ../../images.json so this and images.go read the
6// same numbers.
7
8import { mkdir, readdir, rm, copyFile, stat, readFile } from "node:fs/promises";
9import { dirname, join, resolve } from "node:path";
10import { fileURLToPath } from "node:url";
11import sharp from "sharp";
12
13const HERE = resolve(dirname(fileURLToPath(import.meta.url)), "..");
14const SRC = join(HERE, "images");
15const DST = join(HERE, "public/images");
16
17const spec = JSON.parse(await readFile(resolve(HERE, "..", "images.json"), "utf8"));
18
19// libvips effort, 0 to 9. Higher is slower for very little extra saving, and 4
20// keeps a full regeneration down to a couple of minutes.
21const EFFORT = 4;
22
23const widthsFor = (name) => {
24 const w = [...spec.cardWidths, spec.lightboxWidth];
25 if (name === spec.hero) w.push(spec.heroWidth);
26 return w;
27};
28
29const qualityFor = (width) => {
30 const q = spec.quality[String(width)];
31 if (q === undefined) throw new Error(`images.json has no quality for width ${width}`);
32 return q;
33};
34
35const encode = (pipeline, quality) => {
36 switch (spec.format) {
37 case "avif":
38 return pipeline.avif({ quality, effort: EFFORT });
39 case "webp":
40 return pipeline.webp({ quality, effort: 6 });
41 case "jpeg":
42 return pipeline.jpeg({ quality, mozjpeg: true, chromaSubsampling: "4:4:4" });
43 default:
44 throw new Error(`unsupported format in images.json: ${spec.format}`);
45 }
46};
47
48const kb = (n) => `${(n / 1000).toFixed(0)}kB`.padStart(7);
49
50const poursSrc = join(SRC, "art/acrylic-pours");
51const poursDst = join(DST, "art/acrylic-pours");
52
53// Cleared first, so a source that was deleted cannot leave an orphan behind
54// that a template still links to.
55await rm(poursDst, { recursive: true, force: true });
56await mkdir(poursDst, { recursive: true });
57
58const originals = (await readdir(poursSrc)).filter((f) => f.endsWith(".webp")).sort();
59if (originals.length === 0) throw new Error(`no sources in ${poursSrc}`);
60
61const started = Date.now();
62let total = 0;
63let count = 0;
64
65for (const file of originals) {
66 const name = file.replace(/\.webp$/, "");
67 let line = ` ${file.padEnd(10)}`;
68 for (const width of widthsFor(name)) {
69 const dest = join(poursDst, `${name}-${width}.${spec.format}`);
70 // withoutEnlargement, or a source smaller than the target is upscaled into
71 // a bigger and blurrier file.
72 const { size } = await encode(
73 sharp(join(poursSrc, file)).resize({ width, withoutEnlargement: true }),
74 qualityFor(width),
75 ).toFile(dest);
76 total += size;
77 count += 1;
78 line += ` ${width}w ${kb(size)}`;
79 }
80 console.log(line);
81}
82
83const { size: avatarSize } = await encode(
84 sharp(join(SRC, "avatar.webp")).resize({ width: spec.avatar.width, withoutEnlargement: true }),
85 spec.avatar.quality,
86).toFile(join(DST, `avatar.${spec.format}`));
87total += avatarSize;
88count += 1;
89console.log(` ${"avatar".padEnd(10)} ${spec.avatar.width}w ${kb(avatarSize)}`);
90
91// Referenced at 512x512 by the web manifest, so it passes through as PNG.
92await copyFile(join(SRC, "favicon.png"), join(DST, "favicon.png"));
93total += (await stat(join(DST, "favicon.png"))).size;
94
95console.log(
96 `\n${count} variants in ${spec.format}, ${(total / 1e6).toFixed(1)}MB, ` +
97 `in ${((Date.now() - started) / 1000).toFixed(1)}s`,
98);