Files
budget2.0/build.ts
T
Lucas WintherandClaude Opus 5 b9be882c05 fix: four latent defects from TODO section 4
Each of these was recorded with its reasoning already settled; each is fixed
test-first, and two turned out to be worse than the note claimed.

EMPTY_FIGURES is frozen. figuresFor returns it by shared reference for every
(post, month) the fold has no entry for, so one consumer mutating its result
would have moved every other unknown row with it.

Split values now round by what they MEAN. A split's value is a percentage in
"percent" mode but MONEY in the purchase's currency in "fixed" mode; both were
rounded to a hardcoded 2 places. sliceTotal likewise rounded to DKK's minor
unit whatever currency the slices carried. Note this is narrower than TODO
claimed: round2 is still correct for Rule.percent, which is not money at all.
Neither bug is observable while every supported currency uses hundredths, so
the tests force CURRENCY_DIGITS for their duration.

confirmImport guards its write. A failed import was an unhandled rejection with
nothing shown; the dialog now reports it and keeps `pending` so the user can
retry. A source-level guard covers the whole UI, in the spirit of
eventCapture.test.ts.

The manifest has one definition. The dev placeholder did not merely hardcode
scope "/" — it was never served at all: the "/*" catch-all answered
/manifest.webmanifest with the HTML shell, as text/html, and /icon.svg too.
buildManifest moves to src/manifest.ts, the dev server generates both from the
base path, and the source file stays as a valueless resolution stub because
Bun.serve has no `external` option to skip the <link> href.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 14:25:35 +02:00

107 lines
4.6 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");
}
// 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");
}
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`,
);
}