Relative hrefs in the built index.html ("manifest.webmanifest", "icon.svg")
resolve against the *document* URL, not the site root. Since this is an
SPA where every route serves the same shell, a route with more path
segments than the base path (e.g. /budget2.0/month/2026-09, which is
where the app's own catch-all route redirects on first load) resolved
these to a wrong, 404ing path — breaking the manifest and favicon, and
with them installability, on the app's default landing URL.
build.ts now rewrites both hrefs to be absolute (prefixed with basePath)
as part of the existing dist/index.html -> dist/404.html step, keeping
the two files byte-identical. src/index.html itself keeps the plain
relative hrefs unchanged, since the substitution only happens in the
built dist/ output.
Also fixes a pre-existing, previously-uncaught bug surfaced while
verifying the dev server: bun --hot src/index.ts 500'd on every request
because Bun's dev-server HTML bundler (unlike Bun.build) has no
equivalent of the `external` build option and always tries to resolve
<link> hrefs as module imports — manifest.webmanifest never existed as a
real file under src/, only as build.ts's generated dist/ output. Added
src/manifest.webmanifest as a small static placeholder so the dev server
can resolve it; the production build still ignores this file entirely
(external: ["*.webmanifest"] in build.ts) and writes its own generated
manifest, unaffected by this addition.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
123 lines
4.9 KiB
TypeScript
123 lines
4.9 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";
|
|
|
|
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");
|
|
}
|
|
|
|
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`,
|
|
);
|
|
}
|