TODO recorded these as needing a human because binary assets cannot be generated in the agent environment. That turned out to be wrong: Chrome is on PATH here and rasterizes the SVG exactly, so scripts/make-icons.ts generates them and the output is committed. Verified pixel-accurate against src/icon.svg before trusting it (background #0f172a, bars #38bdf8, underline #e2e8f0, transparent rounded corners). The generator uses an explicit Bun.spawn with a timeout rather than a shell template, because Chrome did not always exit on its own here. Both sizes now appear in the manifest, get copied into dist, are served by the dev server, and are listed in the service worker precache — along with icon.svg and manifest.webmanifest, which were also absent from it because the list is derived from bundler outputs and these are written separately. Without that an installed app could not render its own icon offline. src/icons.test.ts checks the committed files are genuine 8-bit RGBA images of the right dimensions by inflating IDAT and asserting the exact pixel-data length, so a truncated or stub file cannot pass. It cannot detect staleness if src/icon.svg changes — re-run the generator. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
129 lines
5.1 KiB
TypeScript
129 lines
5.1 KiB
TypeScript
import tailwind from "bun-plugin-tailwind";
|
|
import { rm } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { normalizeBase } from "./src/ui/basePath.ts";
|
|
import { buildManifest } from "./src/manifest.ts";
|
|
|
|
const basePath = normalizeBase(process.env.BUN_PUBLIC_BASE_PATH);
|
|
const outdir = path.join(process.cwd(), "dist");
|
|
const version = process.env.BUILD_VERSION ?? String(Date.now());
|
|
|
|
await rm(outdir, { recursive: true, force: true });
|
|
|
|
const result = await Bun.build({
|
|
entrypoints: [...new Bun.Glob("src/**/*.html").scanSync()],
|
|
outdir,
|
|
plugins: [tailwind],
|
|
minify: true,
|
|
target: "browser",
|
|
sourcemap: "linked",
|
|
// publicPath prefixes asset and chunk imports, which is what makes a subpath
|
|
// deploy work without hardcoding it anywhere.
|
|
publicPath: basePath,
|
|
naming: {
|
|
// NOTE: the brief specified "[dir]/[name].[ext]" here. With a single HTML
|
|
// entrypoint at src/index.html, [dir] resolves to "src", which puts the
|
|
// built file at dist/src/index.html instead of dist/index.html. That
|
|
// breaks the dist/404.html copy below (it reads dist/index.html) and the
|
|
// Task 25 static server (which must find index.html at the dist root).
|
|
// Verified by building with both values and inspecting `ls -R dist`.
|
|
entry: "[name].[ext]",
|
|
chunk: "[name]-[hash].[ext]",
|
|
asset: "[name]-[hash].[ext]",
|
|
},
|
|
// index.html references manifest.webmanifest and icon.svg via plain <link>
|
|
// hrefs. Bun's HTML bundler otherwise treats every <link href> as a module
|
|
// reference to resolve, bundle, and (for assets) hash — which both fails
|
|
// outright for manifest.webmanifest (it doesn't exist as a source file; it
|
|
// is generated below, after this build step) and would hash icon.svg,
|
|
// breaking the fixed "icon.svg" paths that manifest.webmanifest and the
|
|
// service worker precache list depend on. Marking these globs external
|
|
// leaves both hrefs untouched in the output HTML, so they resolve as plain
|
|
// relative URLs against the page, exactly as the spec intends.
|
|
external: ["*.webmanifest", "*.svg"],
|
|
define: {
|
|
"process.env.NODE_ENV": JSON.stringify("production"),
|
|
"process.env.BUN_PUBLIC_BASE_PATH": JSON.stringify(basePath),
|
|
},
|
|
});
|
|
|
|
if (!result.success) {
|
|
for (const log of result.logs) console.error(log);
|
|
throw new Error("Build failed");
|
|
}
|
|
|
|
/**
|
|
* Written to dist below rather than emitted by the bundler, so they are absent
|
|
* from `result.outputs` and have to be listed for the precache by name.
|
|
* Without them an installed app cannot render its own icon offline, and the
|
|
* manifest 404s.
|
|
*/
|
|
const STATIC_ASSETS = [
|
|
"manifest.webmanifest",
|
|
"icon.svg",
|
|
"icon-192.png",
|
|
"icon-512.png",
|
|
];
|
|
|
|
// The precache list comes from the build's own outputs, so hashed filenames can
|
|
// never drift out of sync with the service worker.
|
|
const precache = [
|
|
...result.outputs
|
|
.filter((output) => !output.path.endsWith(".map"))
|
|
.map((output) => basePath + path.relative(outdir, output.path).replaceAll(path.sep, "/")),
|
|
...STATIC_ASSETS.map((file) => basePath + file),
|
|
];
|
|
|
|
const swResult = await Bun.build({
|
|
entrypoints: ["src/sw.ts"],
|
|
outdir,
|
|
target: "browser",
|
|
minify: true,
|
|
naming: "sw.js", // must be a stable URL, so no hash
|
|
define: {
|
|
"process.env.PRECACHE_MANIFEST": JSON.stringify(JSON.stringify(precache)),
|
|
"process.env.SW_VERSION": JSON.stringify(version),
|
|
"process.env.BUN_PUBLIC_BASE_PATH": JSON.stringify(basePath),
|
|
},
|
|
});
|
|
|
|
if (!swResult.success) {
|
|
for (const log of swResult.logs) console.error(log);
|
|
throw new Error("Service worker build failed");
|
|
}
|
|
|
|
await Bun.write(path.join(outdir, "manifest.webmanifest"), buildManifest(basePath));
|
|
await Bun.write(path.join(outdir, "icon.svg"), Bun.file("src/icon.svg"));
|
|
for (const size of [192, 512]) {
|
|
await Bun.write(
|
|
path.join(outdir, `icon-${size}.png`),
|
|
Bun.file(`src/icon-${size}.png`),
|
|
);
|
|
}
|
|
|
|
// This is an SPA: every route serves the same shell, so the *document* URL
|
|
// can be several segments deep (e.g. /budget2.0/month/2026-09). Relative
|
|
// hrefs in the built HTML resolve against that document URL, not the site
|
|
// root, so "manifest.webmanifest" and "icon.svg" would 404 on any route
|
|
// with more path segments than the base path itself. Rewrite them to be
|
|
// absolute (prefixed with basePath, which always ends in "/") so they
|
|
// resolve correctly no matter how deep the current route is. Done here
|
|
// rather than hardcoded in src/index.html so the dev server (which always
|
|
// serves from "/") keeps working unchanged.
|
|
let indexHtml = await Bun.file(path.join(outdir, "index.html")).text();
|
|
indexHtml = indexHtml
|
|
.replace('href="manifest.webmanifest"', `href="${basePath}manifest.webmanifest"`)
|
|
.replace('href="icon.svg"', `href="${basePath}icon.svg"`);
|
|
await Bun.write(path.join(outdir, "index.html"), indexHtml);
|
|
|
|
// GitHub Pages has no rewrite rules, so a deep link on a cold load lands on
|
|
// 404.html. Serving the shell from there makes client-side routing work.
|
|
await Bun.write(path.join(outdir, "404.html"), indexHtml);
|
|
|
|
console.log(`Built for base path ${basePath}`);
|
|
for (const output of result.outputs) {
|
|
console.log(
|
|
` ${path.relative(process.cwd(), output.path)} ${(output.size / 1024).toFixed(1)} KB`,
|
|
);
|
|
}
|