Files
template-react/scripts/gen-index.ts
T
pastilhasandClaude Opus 5 c08dab4243 Initial commit: Bun · Hono · React SPA · Postgres/Drizzle template
The starting point for an app with a React frontend. Same server, env,
database and API skeleton as template-htmx — the two differ only in what is
served at / — with a React 19 SPA, Tailwind v4 and shadcn/ui on top.

Carried from a working project rather than designed in the abstract, and
carrying the lessons that cost it time:

- The dev script states NODE_ENV=development rather than inheriting it. React
  resolves its runtime against NODE_ENV: Bun emits jsxDEV() in development
  and React's production jsx-dev-runtime defines jsxDEV as void 0. Inherit
  production while transforming for development and the app dies at first
  paint. Verified: the dev bundle resolves the development runtime and
  contains no void-0 stub.
- Asset URLs in components are relative and <base href="/"> is in the HTML,
  so a path-prefix proxy can rewrite one attribute and every asset follows. A
  test enforces it.
- Routing is in the hash, because the app may be mounted under a prefix it
  only learns at runtime and a hash means the same thing at either mount.
- index.html holds __PUBLIC_URL__ and gen-index.ts substitutes it, because
  Bun's HTML bundler fails the build on a root-relative favicon href.
- APP_ENV failing closed, env files before the ambient environment, the
  database enforced by name, one Postgres client per process — as in the
  htmx template.

The database has no tables. The App is a shell with one tab and no features,
named as a placeholder. Renaming `app` is the first step and the README lists
every place it appears.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 02:02:52 +00:00

74 lines
3.3 KiB
TypeScript

#!/usr/bin/env bun
// Generates src/apps/frontend/index.gen.html from index.html, substituting __PUBLIC_URL__ with
// PUBLIC_URL from .env.
//
// Favicon and OpenGraph hrefs have to be absolute. Bun's HTML bundler treats a root-relative href as
// an asset to resolve on disk and fails the build ("Could not resolve: /favicon.ico"), and crawlers
// fetch OG tags standalone so they cannot resolve a relative URL either. Hardcoding the domain would
// force every deployment to carry its own edit of index.html, and Bun's HTML import offers no
// substitution hook — so the swap happens here and the server imports the generated file.
//
// index.gen.html is gitignored; index.html is the tracked template and is identical on every instance.
import { existsSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const template = join(root, "src/apps/frontend/index.html");
const output = join(root, "src/apps/frontend/index.gen.html");
// Where the URL comes from, most specific first:
//
// 1. the first argument `bun gen:index https://example.com`
// 2. PUBLIC_URL in this project's .env
// 3. PUBLIC_URL in the environment
//
// Note the order: .env BEATS the ambient environment, which is the reverse of the usual convention.
// A host that exports its own PUBLIC_URL into every process it spawns would otherwise stamp its
// domain onto this app's favicon hrefs. A value in this project's own .env is a statement about this
// project; an inherited one is a statement about whatever launched it.
const argUrl = process.argv[2]?.trim();
let dotenvUrl: string | undefined;
const envPath = join(root, ".env");
if (!argUrl && existsSync(envPath)) {
for (const line of (await Bun.file(envPath).text()).split("\n")) {
const match = line.match(/^\s*PUBLIC_URL\s*=\s*(.*)$/);
if (match) dotenvUrl = match[1]!.trim().replace(/^["']|["']$/g, "");
}
}
const publicUrl = (argUrl || dotenvUrl || process.env.PUBLIC_URL || "").replace(/\/+$/, "");
if (!publicUrl) {
console.error("[gen-index] no public URL. Pass one — `bun gen:index http://localhost:3001` —");
console.error("[gen-index] or set PUBLIC_URL in .env.");
process.exit(1);
}
// Caught here rather than left to a crawler: a relative or scheme-less value substitutes without
// complaint and produces tags nothing can resolve, which stays invisible until someone shares a link.
try {
const parsed = new URL(publicUrl);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error("not http(s)");
} catch {
console.error(`[gen-index] "${publicUrl}" is not an absolute http(s) URL.`);
process.exit(1);
}
const html = await Bun.file(template).text();
const generated = html.replaceAll("__PUBLIC_URL__", publicUrl);
if (generated.includes("__PUBLIC_URL__")) {
console.error("[gen-index] substitution left placeholders behind");
process.exit(1);
}
// Only write when the content actually changes, so `bun --hot` does not restart in a loop.
if (existsSync(output) && (await Bun.file(output).text()) === generated) {
console.log(`[gen-index] up to date (${publicUrl})`);
} else {
await Bun.write(output, generated);
console.log(`[gen-index] wrote index.gen.html (${publicUrl})`);
}