Files
paliad/frontend/build.ts
mAi ee98db94fa
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
feat(submissions): Composer Slice C — building blocks library (m/paliad#141)
Per the design at docs/design-submission-generator-v2-2026-05-26.md §8
and the Q2 / Q9 ratifications:

- Q2 (m, 2026-05-26): building blocks are plain text paste sources.
  No building_block_id reference is stored on submission_sections.
- Q9 (m, 2026-05-26): four visibility tiers — private / team / firm
  / global.

Schema (mig 149):

- paliad.submission_building_blocks — library catalog. Columns: slug,
  firm (NULL = cross-firm), section_key (binds to one section kind),
  proceeding_family (NULL = any), title_de/_en + description_de/_en
  + content_md_de/_en, author_id, visibility (CHECK in 4-tier set),
  is_published, created_at, updated_at, deleted_at (soft delete).
  RLS: coarse-grained SELECT — every authenticated user sees
  non-deleted non-private rows + own private rows. Tier-specific
  predicate (private/team/firm/global) applied in Go-layer service so
  semantics evolve without RLS migrations. Mutations admin-only (no
  RLS write paths).

- paliad.submission_building_block_admin_versions — append-only
  history per block, retention=20. Admin-side only; NOT referenced
  from submission_sections (per Q2's plain-text-paste model). Exists
  so accidental delete / overwrite are recoverable.

Backend:

- internal/services/submission_building_block_service.go (~510 LoC):
  BuildingBlockService. ListVisible applies tier predicate at query
  time (private = author_id match; firm = firm column NULL OR matches
  branding.Name; team = author shares a project_team with caller via
  paliad.project_teams self-join; global = open). ListAllForAdmin
  drops the predicate. Create + Update + SoftDelete + RestoreVersion
  all transactional; appendVersionTx writes one audit row +
  GC-deletes anything past the retention=20 horizon in the same tx.
  InsertIntoSection (the paste mechanic) clones content_md_<lang>
  into the section row with a "\n\n" separator if section already has
  content. NO building_block_id stamped per Q2.

- internal/handlers/submission_building_blocks.go (~480 LoC): nine
  handlers split between the lawyer-facing picker (list, insert) and
  the admin editor (list, get, create, update, delete, list-versions,
  restore-version, page). buildingBlockUpdateInput uses presence-
  tracking UnmarshalJSON for the four nullable fields (firm,
  proceeding_family, description_de/_en) so PATCH can distinguish
  "no change" from "set to null".

- Routes registered: lawyer-facing under /api/submission-building-blocks,
  admin-gated under /api/admin/submission-building-blocks/* and
  /admin/submission-building-blocks (page).

- Wiring: handlers.Services + dbServices + cmd/server/main.go all
  gain SubmissionBuildingBlock. NewBuildingBlockService takes the
  branding.Name firm hint for the visibility predicate.

Frontend:

- frontend/src/admin-submission-building-blocks.tsx (~85 LoC):
  three-pane admin shell (list / editor / version log) registered
  in build.ts.

- frontend/src/client/admin-submission-building-blocks.ts (~370
  LoC): admin client — list paint, edit form (slug + firm +
  section_key + proceeding_family + title/desc/content per lang +
  visibility radio + is_published toggle), per-block version log
  with restore button. Bilingual labels.

- frontend/src/client/submission-draft.ts: per-section "+ Baustein"
  button on the Composer editor toolbar (Slice B substrate gets one
  more affordance). openBlockPicker opens a modal filtered to the
  section's section_key, 200ms-debounced search by free text against
  title/description/content. Click a hit → POST insert-into-section
  → section row's content_md_<lang> gains the block's content
  appended at the end (Q2's plain-text paste semantic, no lineage).

- ~240 LoC of CSS: modal overlay + picker rows with tier-colored
  visibility chips + admin editor 3-pane grid + form rows + version
  list.

- 12 new i18n keys × 2 langs (admin.building_blocks.*).

Tests:
- TestValidVisibility (8 cases including case-sensitivity + empty).
- TestAppendBlockContent (8 cases covering empty-existing / empty-
  addition / whitespace-only / trailing newline collapse).
- TestBuildingBlockVisibilityConstants pins the 4 string literals
  against drift (RLS predicate + DB CHECK depend on them).

Build hygiene: go build/vet/test -short clean; bun run build clean
(2906 i18n keys, data-i18n scan clean).

Hard rules per ratifications honoured:
- Q2: no building_block_id lineage on sections (paste is plain text).
- Q9: 4 visibility tiers (private/team/firm/global).
- NO behavior change for pre-Composer drafts (the picker just doesn't
  show — section list is hidden for base_id NULL drafts).
- {{rule.X}} aliases preserved (block content goes through the same
  v1 placeholder pass on export as section prose).

NOT in scope per Slice C brief:
- User-authored private blocks (Slice C ships admin curation only;
  any-user create is a follow-up).
- Tier promotion review workflow (admin sets tier directly today).
- Per-section "where is this block used" reverse lookup (no lineage
  to query).
- Slice D's rich-prose features (headings, lists, blockquote) still
  Slice D's job; this Slice doesn't extend the MD walker.

t-paliad-315 Slice C
2026-05-26 20:04:40 +02:00

447 lines
22 KiB
TypeScript

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 { renderAdminSubmissionBuildingBlocks } from "./src/admin-submission-building-blocks";
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 { 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<ReadonlySet<string>> {
const src = await Bun.file(I18N_SOURCE).text();
const re = /^[ \t]*"([A-Za-z][\w.\-]*)"\s*:/gm;
const keys = new Set<string>();
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<string>): Promise<void> {
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<void> {
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-submission-building-blocks.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/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-submission-building-blocks.html"), renderAdminSubmissionBuildingBlocks());
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, "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=<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();