import tailwind from "bun-plugin-tailwind";
import { rm } from "node:fs/promises";
import path from "node:path";
import { normalizeBase } from "./src/ui/basePath.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
// hrefs. Bun's HTML bundler otherwise treats every 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");
}
// 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, "/"));
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");
}
export function buildManifest(base: string): string {
return JSON.stringify(
{
name: "Budget 2.0",
short_name: "Budget",
start_url: base,
scope: base,
display: "standalone",
background_color: "#0f172a",
theme_color: "#0f172a",
icons: [{ src: `${base}icon.svg`, sizes: "any", type: "image/svg+xml" }],
},
null,
2,
);
}
await Bun.write(path.join(outdir, "manifest.webmanifest"), buildManifest(basePath));
await Bun.write(path.join(outdir, "icon.svg"), Bun.file("src/icon.svg"));
// 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`,
);
}