import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { contributionLocalName, moduleContributionDeclarationSchema, moduleDisplayEntrypointSchema, packageRelativePathSchema, } from "@ekklesia/contracts"; import { build, type Plugin } from "esbuild"; /** * The one supported way to turn a module's React/TSX UI sources into the compiled browser artifacts * its manifest declares as `entrypoints.ui` and `entrypoints.display`. * * Module authors do not write bundler configuration. They put their UI at * the fixed role-specific source locations below, declare either or both artifacts, and run one * build; every option below is owned here so one decision applies to every module. */ export const MODULE_UI_SOURCE_ENTRYPOINT = "src/ui/index.tsx"; export const MODULE_DISPLAY_SOURCE_ENTRYPOINT = "src/display/index.tsx"; /** Where a declared browser contribution's source lives: `src/contributions//index.tsx`, * where the local name is the part of the contribution id after the module's own id. Derived rather * than configured, exactly like the two role entrypoints, so an author points the build nowhere. */ export const MODULE_CONTRIBUTION_SOURCE_DIRECTORY = "src/contributions"; export function moduleContributionSourceEntrypoint(contributionId: string): string { return `${MODULE_CONTRIBUTION_SOURCE_DIRECTORY}/${contributionLocalName(contributionId)}/index.tsx`; } export const MODULE_MANIFEST_FILENAME = "ekklesia.module.json"; /** Matches `browser.json`'s `target`, so what TypeScript typechecks and what esbuild emits agree. */ export const MODULE_UI_BUILD_TARGET = "es2022"; export type ModuleUiBuildErrorCode = | "MODULE_UI_MANIFEST_UNREADABLE" | "MODULE_UI_ENTRYPOINT_UNSAFE" | "MODULE_UI_ENTRYPOINT_COLLISION" | "MODULE_UI_SOURCE_MISSING" | "MODULE_UI_STYLESHEET_INVALID" | "MODULE_UI_STYLESHEET_UNDECLARED" | "MODULE_UI_BUILD_FAILED"; export type ModuleUiArtifactKind = "ui" | "display" | "contribution"; const errorMessages: Readonly> = Object.freeze({ MODULE_UI_MANIFEST_UNREADABLE: `${MODULE_MANIFEST_FILENAME} could not be read or parsed.`, MODULE_UI_ENTRYPOINT_UNSAFE: "A module UI entrypoint must be a package-relative path to a compiled .js or .mjs artifact inside the module.", MODULE_UI_ENTRYPOINT_COLLISION: "A display UI entrypoint must not overwrite another declared artifact.", MODULE_UI_SOURCE_MISSING: "A declared module UI source entrypoint was not found.", MODULE_UI_STYLESHEET_INVALID: "A module stylesheet may not define a global Ekklesia theme variable or keep an unresolved @import.", MODULE_UI_STYLESHEET_UNDECLARED: "A module UI build produced a stylesheet beside its artifact. Every style must be compiled into the declared artifact.", MODULE_UI_BUILD_FAILED: "A module UI bundle could not be produced.", }); /** Carries a stable code and a fixed message. esbuild's own diagnostics are printed to stderr by * the CLI, where a developer wants them; the thrown error itself stays sanitized, matching every * other Ekklesia error boundary. */ export class ModuleUiBuildError extends Error { readonly code: ModuleUiBuildErrorCode; /** Safe role vocabulary that lets a developer identify which declared artifact failed without * exposing a filesystem path or raw manifest value. */ readonly kind?: ModuleUiArtifactKind; constructor(code: ModuleUiBuildErrorCode, kind?: ModuleUiArtifactKind) { super(errorMessages[code]); this.name = "ModuleUiBuildError"; this.code = code; if (kind !== undefined) { this.kind = kind; } this.stack = `${this.name}: ${this.message}`; } } /** * A CSS custom property in Ekklesia's own global theme namespace, being **declared** rather than * referenced. `var(--ekklesia-x)` and `var(--ekklesia-x, fallback)` are reads and are fine; only a * `--ekklesia-x:` declaration matches, because that is what would invent a global theme variable * outside the registry a future theme editor enumerates (ADR 0039). */ const GLOBAL_THEME_DECLARATION = /--ekklesia-[a-z0-9-]*\s*:/iu; /** An `@import` that survived compilation — a remote or otherwise unresolvable one. A local import * is inlined by the nested bundle below and leaves nothing behind, so anything still here would make * the artifact fetch a stylesheet at run time, which is exactly what "self-contained" excludes. */ const SURVIVING_IMPORT = /@import\s/iu; /** * Compiles each imported stylesheet into the artifact instead of emitting a sidecar. * * esbuild's default for `import "./styles.css"` from JavaScript is to collect every such stylesheet * into an `index.css` output beside the artifact. Nothing declares that file, no host fetches it, * and the manifest has no field for it — it would work in a local test and silently do nothing in a * real host. ADR 0023 therefore rejected the import outright; ADR 0040 compiles it instead. * * Each `.css` import becomes a JavaScript module whose default export is that stylesheet's compiled * text, produced by a nested esbuild run so local `@import`s are resolved and the result is one * self-contained string. The author passes that string to `defineModuleUi({ styles })` and the host * installs it into the artifact's isolated root. The declared `.js` artifact therefore remains the * only file the platform publishes, exactly as before stylesheets existed. * * Other stylesheet dialects stay rejected. Adding one is a language and toolchain decision, not a * detail of this pass, and would need its own ADR. */ const STYLESHEET_PLUGIN_NAME = "ekklesia-compile-stylesheet-imports"; function compileStylesheetImports(): Plugin { const decoder = new TextDecoder(); return { name: STYLESHEET_PLUGIN_NAME, setup(pluginBuild) { // No `u` flag, deliberately: esbuild compiles a plugin filter to a Go RE2 expression, which // rejects `(?iu)` outright ("filter is not a valid Go regular expression"). These patterns are // pure ASCII, so the flag would change nothing anyway. pluginBuild.onResolve({ filter: /\.(s[ac]ss|less|styl|pcss|postcss)$/i }, (args) => ({ errors: [ { text: "Module UI supports plain .css only. Other stylesheet dialects have no compilation " + "contract in this platform (ADR 0040).", location: { file: args.importer }, }, ], })); pluginBuild.onLoad({ filter: /\.css$/i }, async (args) => { const compiled = await build({ bundle: true, entryPoints: [args.path], logLevel: "silent", minify: false, target: MODULE_UI_BUILD_TARGET, write: false, }); const stylesheet = compiled.outputFiles .map((outputFile) => decoder.decode(outputFile.contents)) .join("\n"); if (GLOBAL_THEME_DECLARATION.test(stylesheet)) { return { errors: [ { text: "A module stylesheet may reference Ekklesia's global theme variables but must " + "never declare one. Derive a private alias under your own prefix instead " + "(ADR 0039).", location: { file: args.path }, }, ], }; } if (SURVIVING_IMPORT.test(stylesheet)) { return { errors: [ { text: "A module stylesheet left an unresolved @import. A declared artifact must be " + "self-contained, so every import has to resolve to a local file (ADR 0040).", location: { file: args.path }, }, ], }; } return { contents: `export default ${JSON.stringify(stylesheet)};`, loader: "js" }; }); }, }; } export interface BuildModuleUiOptions { /** The module package directory containing `ekklesia.module.json`. */ readonly moduleDirectory: string; } export interface ModuleUiBuildResult { /** Built artifacts in stable role order: primary UI, then display, then each declared contribution * in manifest order. Empty for backend-only modules, which makes the canonical command usable * unchanged by every module package. */ readonly artifacts: readonly ModuleUiBuildArtifactResult[]; } export interface ModuleUiBuildArtifactResult { readonly kind: ModuleUiArtifactKind; /** For a contribution, the declared contribution id — so a multi-contribution build reports which * artifact it produced. Absent for the two role entrypoints. */ readonly contributionId?: string; /** Absolute path of the artifact actually written, exactly as the manifest declares. */ readonly outputFile: string; /** Absolute path of the fixed source entrypoint that produced it. */ readonly sourceFile: string; } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } type DeclaredModuleUiArtifact = ModuleUiBuildArtifactResult; /** Reads the inert manifest and returns every declared UI artifact in deterministic role order. */ async function resolveDeclaredArtifacts( moduleDirectory: string, ): Promise { let manifest: unknown; try { manifest = JSON.parse( await readFile(path.join(moduleDirectory, MODULE_MANIFEST_FILENAME), "utf8"), ) as unknown; } catch { throw new ModuleUiBuildError("MODULE_UI_MANIFEST_UNREADABLE"); } const entrypoints = isRecord(manifest) ? manifest.entrypoints : undefined; if (!isRecord(entrypoints)) { throw new ModuleUiBuildError("MODULE_UI_MANIFEST_UNREADABLE"); } const display = entrypoints.display; const manifestContributions = isRecord(manifest) ? manifest.contributions : undefined; const declaredContributions = Array.isArray(manifestContributions) ? manifestContributions : []; for (const contribution of declaredContributions) { if (!moduleContributionDeclarationSchema.safeParse(contribution).success) { throw new ModuleUiBuildError("MODULE_UI_MANIFEST_UNREADABLE", "contribution"); } } const declarations: readonly { readonly contributionId?: string; readonly declared: unknown; readonly kind: ModuleUiArtifactKind; readonly present: boolean; readonly source: string; }[] = [ { declared: entrypoints.ui, kind: "ui", present: entrypoints.ui !== undefined, source: MODULE_UI_SOURCE_ENTRYPOINT, }, { declared: isRecord(display) ? display.artifact : display, kind: "display", present: display !== undefined, source: MODULE_DISPLAY_SOURCE_ENTRYPOINT, }, ...(declaredContributions as { artifact: string; id: string }[]).map((contribution) => ({ contributionId: contribution.id, declared: contribution.artifact, kind: "contribution" as const, present: true, source: moduleContributionSourceEntrypoint(contribution.id), })), ]; const artifacts: DeclaredModuleUiArtifact[] = []; for (const declaration of declarations) { if (!declaration.present) { continue; } const declared = declaration.declared; // The path grammar is Core's, not this tool's: reusing the contracts schema means the build can // never write somewhere discovery would later reject, even if that grammar changes. if ( typeof declared !== "string" || !packageRelativePathSchema.safeParse(declared).success || ![".js", ".mjs"].includes(path.extname(declared).toLowerCase()) ) { throw new ModuleUiBuildError("MODULE_UI_ENTRYPOINT_UNSAFE", declaration.kind); } artifacts.push({ ...(declaration.contributionId === undefined ? {} : { contributionId: declaration.contributionId }), kind: declaration.kind, outputFile: path.resolve(moduleDirectory, declared), sourceFile: path.join(moduleDirectory, declaration.source), }); } if (display !== undefined && !moduleDisplayEntrypointSchema.safeParse(display).success) { throw new ModuleUiBuildError("MODULE_UI_MANIFEST_UNREADABLE", "display"); } // No declared artifact may overwrite another declared file — an entrypoint this build does not // produce (`server`, `schema`, ...), or another artifact it does. Checked before esbuild runs, so // a collision can never destroy output that was already correct. const backendEntrypointPaths = new Set( (["server", "schema", "migrations", "jobs"] as const) .map((entrypointName) => entrypoints[entrypointName]) .filter((configured): configured is string => typeof configured === "string") .map((configured) => path.resolve(moduleDirectory, configured)), ); const claimedOutputs = new Set(); for (const artifact of artifacts) { if ( backendEntrypointPaths.has(artifact.outputFile) || claimedOutputs.has(artifact.outputFile) ) { throw new ModuleUiBuildError("MODULE_UI_ENTRYPOINT_COLLISION", artifact.kind); } claimedOutputs.add(artifact.outputFile); } // Complete preflight before esbuild starts: a missing second source must not leave the first role's // output looking like a successful package build. for (const artifact of artifacts) { try { await readFile(artifact.sourceFile); } catch { throw new ModuleUiBuildError("MODULE_UI_SOURCE_MISSING", artifact.kind); } } return artifacts; } /** * Bundles a module's UI into the artifact its manifest declares. * * Never runs during discovery, startup, or install: this is an authoring-time and packaging-time * step only. Core validates the artifact a module ships; it never produces one (ADR 0021). */ export async function buildModuleUi(options: BuildModuleUiOptions): Promise { const moduleDirectory = path.resolve(options.moduleDirectory); const artifacts = await resolveDeclaredArtifacts(moduleDirectory); const pendingWrites: Array = []; for (const artifact of artifacts) { try { const result = await build({ absWorkingDir: moduleDirectory, // Every dependency, React included, is bundled. The artifact must run in any host, including // one that uses no React at all, so nothing is externalized (ADR 0023). bundle: true, define: { // React reads this at module scope. Without it the bundle would reference `process`, a // Node global that does not exist in a browser, and crash on load. "process.env.NODE_ENV": '"production"', }, entryPoints: [artifact.sourceFile], // ESM only: the artifact is imported by a host with a dynamic `import()`, and the contract // requires a real default export. format: "esm", jsx: "automatic", // Unminified deliberately: an operator is expected to review the artifact they install // (see docs/development/security.md), and nothing else in this repository minifies. minify: false, outfile: artifact.outputFile, platform: "browser", plugins: [compileStylesheetImports()], sourcemap: true, target: MODULE_UI_BUILD_TARGET, // Fail rather than silently shipping a partial bundle. logLevel: "silent", // Compile every role successfully before writing any role. This makes a two-artifact build // atomic with respect to parse/resolve/bundle failures rather than leaving a stale partial set. write: false, }); // Belt and braces on the stylesheet contract. The plugin above turns every `.css` import into // JavaScript, so esbuild has nothing left to emit a stylesheet from — but a future option or // loader change could reintroduce a sidecar, and a sidecar is precisely the failure this // design exists to make impossible: a file nothing declares, no host fetches, and every local // test silently passes without. Producing one fails the build rather than shipping it. const sidecar = result.outputFiles.find( (outputFile) => path.extname(outputFile.path).toLowerCase() === ".css", ); if (sidecar) { throw new ModuleUiBuildError("MODULE_UI_STYLESHEET_UNDECLARED", artifact.kind); } pendingWrites.push( result.outputFiles.map((outputFile) => ({ contents: outputFile.contents, path: outputFile.path, })), ); } catch (error) { if (error instanceof ModuleUiBuildError) { throw error; } // esbuild's structured diagnostics are surfaced by the CLI; rethrow a sanitized error so a // programmatic caller never receives raw paths or internals. if (isRecord(error) && Array.isArray(error.errors)) { // A stylesheet the module authored broke one of this platform's own rules, rather than // failing to compile. Reported with its own code so a caller can tell "your CSS is not // allowed to do that" from "your TSX does not compile". const code = error.errors.some( (diagnostic) => isRecord(diagnostic) && diagnostic.pluginName === STYLESHEET_PLUGIN_NAME, ) ? "MODULE_UI_STYLESHEET_INVALID" : "MODULE_UI_BUILD_FAILED"; throw Object.assign(new ModuleUiBuildError(code, artifact.kind), { diagnostics: error.errors, }); } throw new ModuleUiBuildError("MODULE_UI_BUILD_FAILED", artifact.kind); } } for (const [index, outputs] of pendingWrites.entries()) { try { for (const output of outputs) { await mkdir(path.dirname(output.path), { recursive: true }); await writeFile(output.path, output.contents); } } catch { throw new ModuleUiBuildError("MODULE_UI_BUILD_FAILED", artifacts[index]?.kind); } } return Object.freeze({ artifacts: Object.freeze(artifacts.map((artifact) => Object.freeze({ ...artifact }))), }); }