repos
/ orchard main

orchard

mirror

Every 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

3.4 KB · 99 lines · JavaScript Raw History
 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);