fix(pwa): make manifest/icon hrefs absolute for multi-segment SPA routes

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) <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5 committed 2026-09-01 21:26:45 +02:00
1 parent c069ed3a94
commit 4478c46a43
2 files changed
+25 -1

No files matched your search

+15 -1
View File
@@ -95,9 +95,23 @@ export function buildManifest(base: string): string {
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.
const indexHtml = await Bun.file(path.join(outdir, "index.html")).text();
await Bun.write(path.join(outdir, "404.html"), indexHtml);
console.log(`Built for base path ${basePath}`);
+10
View File
@@ -0,0 +1,10 @@
{
"name": "Budget 2.0",
"short_name": "Budget",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#0f172a",
"theme_color": "#0f172a",
"icons": [{ "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml" }]
}