Files
paliad/frontend/build.ts
m 832104af9e Merge remote-tracking branch 'origin/main' into mai/cronus/partner-units-rename
# Conflicts:
#	frontend/build.ts
#	frontend/src/admin.tsx
#	frontend/src/client/i18n.ts
#	internal/handlers/handlers.go
2026-04-29 22:17:32 +02:00

250 lines
13 KiB
TypeScript

import { mkdir, cp, rm, readdir } from "fs/promises";
import { join } from "path";
import { renderIndex } from "./src/index";
import { renderLogin } from "./src/login";
import { renderKostenrechner } from "./src/kostenrechner";
import { renderFristenrechner } from "./src/fristenrechner";
import { renderDownloads } from "./src/downloads";
import { renderLinks } from "./src/links";
import { renderGlossary } from "./src/glossary";
import { renderGebuehrentabellen } from "./src/gebuehrentabellen";
import { renderChecklists } from "./src/checklists";
import { renderChecklistsDetail } from "./src/checklists-detail";
import { renderChecklistsInstance } from "./src/checklists-instance";
import { renderCourts } from "./src/courts";
import { renderProjects } from "./src/projects";
import { renderProjectsNew } from "./src/projects-new";
import { renderProjectsDetail } from "./src/projects-detail";
import { renderDeadlines } from "./src/deadlines";
import { renderDeadlinesNew } from "./src/deadlines-new";
import { renderDeadlinesDetail } from "./src/deadlines-detail";
import { renderDeadlinesCalendar } from "./src/deadlines-calendar";
import { renderAppointments } from "./src/appointments";
import { renderAppointmentsNew } from "./src/appointments-new";
import { renderAppointmentsDetail } from "./src/appointments-detail";
import { renderAppointmentsCalendar } from "./src/appointments-calendar";
import { renderSettings } from "./src/settings";
import { renderDashboard } from "./src/dashboard";
import { renderAgenda } from "./src/agenda";
import { renderOnboarding } from "./src/onboarding";
import { renderChangelog } from "./src/changelog";
import { renderTeam } from "./src/team";
import { renderAdmin } from "./src/admin";
import { renderAdminTeam } from "./src/admin-team";
import { renderAdminAuditLog } from "./src/admin-audit-log";
import { renderAdminPartnerUnits } from "./src/admin-partner-units";
import { renderAdminEmailTemplates } from "./src/admin-email-templates";
import { renderAdminEmailTemplatesEdit } from "./src/admin-email-templates-edit";
import { renderNotFound } from "./src/notfound";
const DIST = join(import.meta.dir, "dist");
// Bundle-scope isolation guard (t-paliad-043).
//
// All client bundles MUST be built with format: "iife" so each bundle's
// top-level `var`/`function` declarations are wrapped in their own scope.
// Without IIFE wrapping, minified identifiers leak to `window` and clobber
// each other across bundles. On Apr 26, app.js's `var d = "patholo-sidebar-pinned"`
// overwrote projects.js's `function d()` (applyTranslations), and the entire
// authenticated surface crashed in initI18n with "TypeError: d is not a function".
//
// The constant below is the single source of truth for the bundle format;
// the post-build inspection further down verifies that every emitted asset
// actually starts with an IIFE prologue, so this guard survives future Bun
// versions, refactors that drop the constant, or anyone trying to silence
// the type system with `as "esm"`.
const BUILD_FORMAT = "iife" as const;
// Bun emits IIFE bundles as either `(()=>{...})()` (arrow form, what we get
// today with minify: true) or `(function(){...})()`. Match either prologue.
const IIFE_PROLOGUE = /^(\(\(\)\s*=>\s*\{|\(function\s*\(\s*\)\s*\{)/;
// Resolve FIRM_NAME once so both the client bundle's `define` substitution
// and the server-side TSX render see the same value. Mirrors the server's
// internal/branding/firm.go default — the two MUST stay in sync because
// users compare a rendered email body against a rendered HTML page and a
// drifted default would produce two different firm names per deploy.
const FIRM_NAME = (process.env.FIRM_NAME ?? "").trim() || "HLC";
async function build() {
// Clean dist/
await rm(DIST, { recursive: true, force: true });
await mkdir(join(DIST, "assets"), { recursive: true });
console.log(`branding: firm="${FIRM_NAME}" (override with FIRM_NAME env)`);
// Bundle client-side JS
const result = await Bun.build({
entrypoints: [
// app.ts is loaded on every page (SW registration + bottom-nav init +
// install prompt). Keep it ahead of per-page bundles so name collisions
// surface fast.
join(import.meta.dir, "src/client/app.ts"),
join(import.meta.dir, "src/client/index.ts"),
join(import.meta.dir, "src/client/login.ts"),
join(import.meta.dir, "src/client/kostenrechner.ts"),
join(import.meta.dir, "src/client/fristenrechner.ts"),
join(import.meta.dir, "src/client/downloads.ts"),
join(import.meta.dir, "src/client/links.ts"),
join(import.meta.dir, "src/client/glossary.ts"),
join(import.meta.dir, "src/client/gebuehrentabellen.ts"),
join(import.meta.dir, "src/client/checklists.ts"),
join(import.meta.dir, "src/client/checklists-detail.ts"),
join(import.meta.dir, "src/client/checklists-instance.ts"),
join(import.meta.dir, "src/client/courts.ts"),
join(import.meta.dir, "src/client/projects.ts"),
join(import.meta.dir, "src/client/projects-new.ts"),
join(import.meta.dir, "src/client/projects-detail.ts"),
join(import.meta.dir, "src/client/deadlines.ts"),
join(import.meta.dir, "src/client/deadlines-new.ts"),
join(import.meta.dir, "src/client/deadlines-detail.ts"),
join(import.meta.dir, "src/client/deadlines-calendar.ts"),
join(import.meta.dir, "src/client/appointments.ts"),
join(import.meta.dir, "src/client/appointments-new.ts"),
join(import.meta.dir, "src/client/appointments-detail.ts"),
join(import.meta.dir, "src/client/appointments-calendar.ts"),
join(import.meta.dir, "src/client/settings.ts"),
join(import.meta.dir, "src/client/dashboard.ts"),
join(import.meta.dir, "src/client/agenda.ts"),
join(import.meta.dir, "src/client/onboarding.ts"),
join(import.meta.dir, "src/client/changelog.ts"),
join(import.meta.dir, "src/client/team.ts"),
join(import.meta.dir, "src/client/admin.ts"),
join(import.meta.dir, "src/client/admin-team.ts"),
join(import.meta.dir, "src/client/admin-audit-log.ts"),
join(import.meta.dir, "src/client/admin-partner-units.ts"),
join(import.meta.dir, "src/client/admin-email-templates.ts"),
join(import.meta.dir, "src/client/admin-email-templates-edit.ts"),
join(import.meta.dir, "src/client/notfound.ts"),
],
outdir: join(DIST, "assets"),
naming: "[name].js",
minify: true,
// See BUILD_FORMAT comment at top of file — bundle-scope isolation
// depends on IIFE wrapping. Reuses the single-source-of-truth constant
// so the post-build guard below can detect a format swap.
format: BUILD_FORMAT,
// Inline the resolved firm name into every browser bundle. branding.ts
// reads `process.env.FIRM_NAME`, which Bun's bundler does NOT replace by
// default for browser targets — so without `define`, client code would
// see undefined and fall back to "HLC" regardless of FIRM_NAME.
define: {
"process.env.FIRM_NAME": JSON.stringify(FIRM_NAME),
},
});
if (!result.success) {
console.error("JS build failed:");
for (const log of result.logs) {
console.error(log);
}
process.exit(1);
}
// Bundle-scope isolation guard (t-paliad-043) — verify every emitted JS
// bundle starts with an IIFE prologue. This catches the case where
// BUILD_FORMAT is changed to "esm", `format` is dropped from the Bun.build
// call, or a future Bun version emits a non-IIFE wrapper despite the
// option. Without this, top-level identifier collisions between bundles
// can take down the whole authenticated surface (see comment at top).
const emittedAssets = await readdir(join(DIST, "assets"));
for (const f of emittedAssets) {
if (!f.endsWith(".js")) continue;
const head = (await Bun.file(join(DIST, "assets", f)).text()).slice(0, 64);
if (!IIFE_PROLOGUE.test(head)) {
console.error(
`Build aborted: dist/assets/${f} is not IIFE-wrapped ` +
`(starts with ${JSON.stringify(head.slice(0, 32))}). ` +
`All client bundles must be built with Bun.build({ format: "iife" }) — ` +
`per-page bundles' top-level identifiers leak to window and clobber ` +
`each other after minification (see t-paliad-043).`,
);
process.exit(1);
}
}
// Copy CSS
await cp(
join(import.meta.dir, "src/styles/global.css"),
join(DIST, "assets/global.css"),
);
// Copy public/ → dist/ (manifest.json, sw.js, icons/) — served at the
// application root so the service worker can claim scope=/ and so the
// manifest is reachable at /manifest.json without a sub-path rewrite.
await cp(
join(import.meta.dir, "public"),
DIST,
{ recursive: true },
);
// Stamp a unique version into sw.js so each deploy opens a fresh cache.
// Activate-time eviction (in sw.js) deletes any prior cache, including
// pre-versioning names like paliad-v1-static — that's what stops a stale
// /assets/projects.js from a previous deploy lingering on a user's device.
const swPath = join(DIST, "sw.js");
const swSrc = await Bun.file(swPath).text();
const buildVersion = `v${Date.now()}`;
await Bun.write(swPath, swSrc.replace("__PALIAD_BUILD_VERSION__", buildVersion));
// Render HTML pages
await Bun.write(join(DIST, "index.html"), renderIndex());
await Bun.write(join(DIST, "login.html"), renderLogin("login.js"));
await Bun.write(join(DIST, "kostenrechner.html"), renderKostenrechner());
await Bun.write(join(DIST, "fristenrechner.html"), renderFristenrechner());
await Bun.write(join(DIST, "downloads.html"), renderDownloads());
await Bun.write(join(DIST, "links.html"), renderLinks());
await Bun.write(join(DIST, "glossary.html"), renderGlossary());
await Bun.write(join(DIST, "gebuehrentabellen.html"), renderGebuehrentabellen());
await Bun.write(join(DIST, "checklists.html"), renderChecklists());
await Bun.write(join(DIST, "checklists-detail.html"), renderChecklistsDetail());
await Bun.write(join(DIST, "checklists-instance.html"), renderChecklistsInstance());
await Bun.write(join(DIST, "courts.html"), renderCourts());
await Bun.write(join(DIST, "projects.html"), renderProjects());
await Bun.write(join(DIST, "projects-new.html"), renderProjectsNew());
await Bun.write(join(DIST, "projects-detail.html"), renderProjectsDetail());
await Bun.write(join(DIST, "deadlines.html"), renderDeadlines());
await Bun.write(join(DIST, "deadlines-new.html"), renderDeadlinesNew());
await Bun.write(join(DIST, "deadlines-detail.html"), renderDeadlinesDetail());
await Bun.write(join(DIST, "deadlines-calendar.html"), renderDeadlinesCalendar());
await Bun.write(join(DIST, "appointments.html"), renderAppointments());
await Bun.write(join(DIST, "appointments-new.html"), renderAppointmentsNew());
await Bun.write(join(DIST, "appointments-detail.html"), renderAppointmentsDetail());
await Bun.write(join(DIST, "appointments-calendar.html"), renderAppointmentsCalendar());
await Bun.write(join(DIST, "settings.html"), renderSettings());
await Bun.write(join(DIST, "dashboard.html"), renderDashboard());
await Bun.write(join(DIST, "agenda.html"), renderAgenda());
await Bun.write(join(DIST, "onboarding.html"), renderOnboarding());
await Bun.write(join(DIST, "changelog.html"), renderChangelog());
await Bun.write(join(DIST, "team.html"), renderTeam());
await Bun.write(join(DIST, "admin.html"), renderAdmin());
await Bun.write(join(DIST, "admin-team.html"), renderAdminTeam());
await Bun.write(join(DIST, "admin-audit-log.html"), renderAdminAuditLog());
await Bun.write(join(DIST, "admin-partner-units.html"), renderAdminPartnerUnits());
await Bun.write(join(DIST, "admin-email-templates.html"), renderAdminEmailTemplates());
await Bun.write(join(DIST, "admin-email-templates-edit.html"), renderAdminEmailTemplatesEdit());
await Bun.write(join(DIST, "notfound.html"), renderNotFound());
// Append ?v=<buildVersion> to every /assets/*.js and /assets/*.css URL in
// every emitted HTML file. Cache-Control alone isn't enough: a browser that
// cached a script in a previous deploy keeps serving it from disk because
// the cache entry was stored without the no-cache directive. Versioning the
// URL changes the cache key, so the next page load fetches a fresh bundle
// unconditionally \u2014 this is what guarantees t-paliad-043's IIFE wrap fix
// actually reaches users on their next visit even without a SW.
const htmlFiles = (await readdir(DIST)).filter((f) => f.endsWith(".html"));
for (const f of htmlFiles) {
const path = join(DIST, f);
const html = await Bun.file(path).text();
const stamped = html.replace(
/(\/assets\/[\w-]+\.(?:js|css))/g,
`$1?v=${buildVersion}`,
);
await Bun.write(path, stamped);
}
console.log("Build complete \u2192 dist/");
}
build();