import { mkdir, cp, rm, readdir } from "fs/promises"; import { join, relative } from "path"; import { renderIndex } from "./src/index"; import { renderLogin } from "./src/login"; import { renderKostenrechner } from "./src/kostenrechner"; import { renderFristenrechner } from "./src/fristenrechner"; import { renderVerfahrensablauf } from "./src/verfahrensablauf"; 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 { renderChecklistsAuthor } from "./src/checklists-author"; 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 { renderProjectsChart } from "./src/projects-chart"; import { renderSubmissionDraft } from "./src/submission-draft"; import { renderSubmissionsIndex } from "./src/submissions-index"; import { renderSubmissionsNew } from "./src/submissions-new"; import { renderEvents } from "./src/events"; import { renderDeadlinesNew } from "./src/deadlines-new"; import { renderDeadlinesDetail } from "./src/deadlines-detail"; import { renderAppointmentsNew } from "./src/appointments-new"; import { renderAppointmentsDetail } from "./src/appointments-detail"; 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 { renderInbox } from "./src/inbox"; import { renderViews } from "./src/views"; import { renderViewsEditor } from "./src/views-editor"; 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 { renderAdminEventTypes } from "./src/admin-event-types"; import { renderAdminApprovalPolicies } from "./src/admin-approval-policies"; import { renderAdminBroadcasts } from "./src/admin-broadcasts"; import { renderAdminRulesList } from "./src/admin-rules-list"; import { renderAdminRulesEdit } from "./src/admin-rules-edit"; import { renderAdminRulesExport } from "./src/admin-rules-export"; import { renderPaliadin } from "./src/paliadin"; import { renderAdminPaliadin } from "./src/admin-paliadin"; import { renderAdminBackups } from "./src/admin-backups"; 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"; // i18n-key codegen + data-i18n scan (t-paliad-078). // // `frontend/src/client/i18n.ts` is the single source of truth for translation // keys. The codegen below extracts every key into a TS literal-union type at // `frontend/src/i18n-keys.ts`, which `t()` and `tOrEmpty()` use to flag // literal-string typos at compile time. The scan downstream cross-checks every // `data-i18n*` attribute literal in `src/**/*.{ts,tsx}` against the same set // — that's the path the runtime `applyTranslations` walks, so a typo there is // just as silent as a `t("typo")` (and how F-04 shipped a raw key in prod). // // The regex over `i18n.ts` source matches only `^[ \t]*"key": value` lines — // both the `de` and `en` blocks. The file is a static literal so this is // robust; if the file shape changes (e.g. someone introduces a function-built // translations object), the explicit zero-key guard below catches it. const I18N_SOURCE = join(import.meta.dir, "src/client/i18n.ts"); const I18N_KEYS_OUT = join(import.meta.dir, "src/i18n-keys.ts"); async function generateI18nKeys(): Promise> { const src = await Bun.file(I18N_SOURCE).text(); const re = /^[ \t]*"([A-Za-z][\w.\-]*)"\s*:/gm; const keys = new Set(); let m: RegExpExecArray | null; while ((m = re.exec(src)) !== null) keys.add(m[1]); if (keys.size === 0) { console.error( "i18n codegen: extracted 0 keys from src/client/i18n.ts. " + "Either the file is empty or the regex no longer matches its shape — " + "fix the codegen before continuing.", ); process.exit(1); } const sorted = [...keys].sort(); const lines: string[] = [ "// GENERATED FILE — do not edit by hand.", "// Regenerated on every build by frontend/build.ts (generateI18nKeys).", "// Source of truth: frontend/src/client/i18n.ts.", "//", "// `t(key: I18nKey)` accepts this union, so a literal-string typo at a", "// call site fails `tsc --noEmit`. Runtime-composed keys go through", "// `tDyn(key: string)` which deliberately bypasses the type check. The", "// build's `data-i18n` scan uses the same set to verify literal", "// `data-i18n*` attributes in TSX/TS sources.", "", "export type I18nKey =", ...sorted.map( (k, i) => ` | ${JSON.stringify(k)}${i === sorted.length - 1 ? ";" : ""}`, ), "", ]; const next = lines.join("\n"); // Skip writing if unchanged — keeps tsc/editor watchers quiet on no-op // builds and avoids spurious git diffs when the type is already current. const existing = await Bun.file(I18N_KEYS_OUT) .text() .catch(() => ""); if (existing !== next) { await Bun.write(I18N_KEYS_OUT, next); console.log(`i18n codegen: ${sorted.length} keys → src/i18n-keys.ts (updated)`); } else { console.log(`i18n codegen: ${sorted.length} keys (unchanged)`); } return keys; } // Scan every TSX/TS source file for literal `data-i18n*` attribute values and // verify each one is a known I18nKey. Skips dynamic forms (`={...}`, // `="${...}"`) since those can't be resolved statically. Mirrors the runtime // behaviour in `applyTranslations` — three attributes, all read literally. async function checkDataI18nUsage(keys: ReadonlySet): Promise { const SRC = join(import.meta.dir, "src"); const ATTR_RE = /\bdata-i18n(?:-placeholder|-title)?\s*=\s*(?:"([^"]*)"|'([^']*)')/g; type Hit = { file: string; line: number; attr: string; key: string }; const unknown: Hit[] = []; async function walk(dir: string): Promise { const entries = await readdir(dir, { withFileTypes: true }); for (const ent of entries) { const full = join(dir, ent.name); if (ent.isDirectory()) { await walk(full); continue; } if (!ent.name.endsWith(".ts") && !ent.name.endsWith(".tsx")) continue; // Skip the generated file itself + the i18n source-of-truth (its // string keys are translation values, not data-i18n attrs). if (full === I18N_KEYS_OUT) continue; if (full === I18N_SOURCE) continue; const text = await Bun.file(full).text(); const lines = text.split("\n"); for (let i = 0; i < lines.length; i++) { const line = lines[i]; let m: RegExpExecArray | null; ATTR_RE.lastIndex = 0; while ((m = ATTR_RE.exec(line)) !== null) { const value = m[1] ?? m[2]; if (value === undefined) continue; // Skip dynamic interpolations — can't statically resolve. if (value.includes("${")) continue; // The full attribute name is up to the `=` for the report. const attr = m[0].slice(0, m[0].indexOf("=")); if (!keys.has(value)) { unknown.push({ file: relative(import.meta.dir, full), line: i + 1, attr: attr.trim(), key: value, }); } } } } } await walk(SRC); if (unknown.length > 0) { console.error( `i18n scan: ${unknown.length} unknown ${unknown.length === 1 ? "key" : "keys"} ` + `referenced via data-i18n* attributes — every literal must exist in i18n.ts:`, ); for (const h of unknown) { console.error(` ${h.file}:${h.line} ${h.attr}="${h.key}"`); } process.exit(1); } console.log("i18n scan: data-i18n attributes clean"); } async function build() { // Clean dist/ await rm(DIST, { recursive: true, force: true }); await mkdir(join(DIST, "assets"), { recursive: true }); // Regenerate the I18nKey union BEFORE bundling. Bun.build runs the TSX // renderers, which import t() — if a recent commit added a key without // regenerating, the renderer would still pass tsc only because the union // is stale, so we always rewrite first. The data-i18n scan runs next so // any unknown literal aborts the build before any artefact is emitted. const i18nKeys = await generateI18nKeys(); await checkDataI18nUsage(i18nKeys); 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/verfahrensablauf.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-author.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/projects-chart.ts"), join(import.meta.dir, "src/client/submission-draft.ts"), join(import.meta.dir, "src/client/submissions-index.ts"), join(import.meta.dir, "src/client/submissions-new.ts"), join(import.meta.dir, "src/client/events.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/appointments-new.ts"), join(import.meta.dir, "src/client/appointments-detail.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/inbox.ts"), join(import.meta.dir, "src/client/views.ts"), join(import.meta.dir, "src/client/views-editor.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/admin-event-types.ts"), join(import.meta.dir, "src/client/admin-approval-policies.ts"), join(import.meta.dir, "src/client/admin-broadcasts.ts"), join(import.meta.dir, "src/client/admin-rules-list.ts"), join(import.meta.dir, "src/client/admin-rules-edit.ts"), join(import.meta.dir, "src/client/admin-rules-export.ts"), join(import.meta.dir, "src/client/paliadin.ts"), // t-paliad-161 — inline Paliadin widget. Loaded via the // PaliadinWidget component on every authenticated page, so the // bundle ships once per deploy and clients with a hot SW cache // skip the re-fetch. join(import.meta.dir, "src/client/paliadin-widget.ts"), join(import.meta.dir, "src/client/admin-paliadin.ts"), join(import.meta.dir, "src/client/admin-backups.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, "verfahrensablauf.html"), renderVerfahrensablauf()); 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-author.html"), renderChecklistsAuthor()); 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, "projects-chart.html"), renderProjectsChart()); await Bun.write(join(DIST, "submission-draft.html"), renderSubmissionDraft()); await Bun.write(join(DIST, "submissions-index.html"), renderSubmissionsIndex()); await Bun.write(join(DIST, "submissions-new.html"), renderSubmissionsNew()); // t-paliad-115 — shared EventsPage at the canonical /events URL. // One HTML output; defaultType="all" baked in. Sidebar Fristen / // Termine entries point at /events?type=… and events.ts re-highlights // the matching one at hydration time based on the active type. await Bun.write(join(DIST, "events.html"), renderEvents()); await Bun.write(join(DIST, "deadlines-new.html"), renderDeadlinesNew()); await Bun.write(join(DIST, "deadlines-detail.html"), renderDeadlinesDetail()); await Bun.write(join(DIST, "appointments-new.html"), renderAppointmentsNew()); await Bun.write(join(DIST, "appointments-detail.html"), renderAppointmentsDetail()); 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, "inbox.html"), renderInbox()); await Bun.write(join(DIST, "views.html"), renderViews()); await Bun.write(join(DIST, "views-editor.html"), renderViewsEditor()); 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, "admin-event-types.html"), renderAdminEventTypes()); await Bun.write(join(DIST, "admin-approval-policies.html"), renderAdminApprovalPolicies()); await Bun.write(join(DIST, "admin-broadcasts.html"), renderAdminBroadcasts()); await Bun.write(join(DIST, "admin-rules-list.html"), renderAdminRulesList()); await Bun.write(join(DIST, "admin-rules-edit.html"), renderAdminRulesEdit()); await Bun.write(join(DIST, "admin-rules-export.html"), renderAdminRulesExport()); await Bun.write(join(DIST, "paliadin.html"), renderPaliadin()); await Bun.write(join(DIST, "admin-paliadin.html"), renderAdminPaliadin()); await Bun.write(join(DIST, "admin-backups.html"), renderAdminBackups()); await Bun.write(join(DIST, "notfound.html"), renderNotFound()); // Append ?v= 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();