// This file implements client-side bundling, mostly wrapping esbuild. const clientPlugins: esbuild.Plugin[] = [ // There are currently no plugins needed by 'paperclover.net' ]; export async function bundleClientJavaScript( referencedScripts: string[], extraPublicScripts: string[], incr: Incremental, dev: boolean = false, ) { const entryPoints = [ ...new Set([ ...referencedScripts, ...extraPublicScripts, ]), ]; if (entryPoints.length === 0) return; const invalidFiles = entryPoints .filter((file) => !file.match(/\.client\.[tj]sx?/)); if (invalidFiles.length > 0) { const cwd = process.cwd(); throw new Error( "All client-side scripts should be named like '.client.ts'. Exceptions: \n" + invalidFiles.map((x) => path.join(cwd, x)).join("\n"), ); } const bundle = await esbuild.build({ bundle: true, chunkNames: "/js/c.[hash]", entryNames: "/js/[name]", assetNames: "/asset/[hash]", entryPoints, format: "esm", minify: !dev, outdir: "/out!", plugins: clientPlugins, write: false, metafile: true, external: ["node_modules/"], }); if (bundle.errors.length || bundle.warnings.length) { throw new AggregateError( bundle.errors.concat(bundle.warnings), "JS bundle failed", ); } const publicScriptRoutes = extraPublicScripts.map((file) => path.basename(file).replace(/\.client\.[tj]sx?/, "") ); const { metafile } = bundle; const promises: Promise[] = []; // TODO: add a shared build hash to entrypoints, derived from all the chunk hashes. for (const file of bundle.outputFiles) { const { text } = file; let route = file.path.replace(/^.*!/, "").replaceAll("\\", "/"); const { inputs } = UNWRAP(metafile.outputs["out!" + route]); const sources = Object.keys(inputs); // Register non-chunks as script entries. const chunk = route.startsWith("/js/c."); if (!chunk) { const key = hot.getScriptId(sources[0]); route = "/js/" + key + ".js"; incr.put({ sources, type: "script", key, value: text, }); } // Register chunks and public scripts as assets. if (chunk || publicScriptRoutes.includes(route)) { promises.push(incr.putAsset({ sources, key: route, body: text, })); } } await Promise.all(promises); } type ServerPlatform = "node" | "passthru"; export async function bundleServerJavaScript( /** Has 'export default app;' */ _: string, /** Views for dynamic loading */ viewEntryPoints: FileItem[], platform: ServerPlatform = "node", ) { const scriptMagic = "CLOVER_CLIENT_SCRIPTS_DEFINITION"; const viewModules = viewEntryPoints.map((view) => { const module = require(view.file); if (!module.meta) { throw new Error(`${view.file} is missing 'export const meta'`); } if (!module.default) { throw new Error(`${view.file} is missing a default export.`); } return { module, view }; }); const viewSource = [ ...viewEntryPoints.map((view, i) => `import * as view${i} from ${JSON.stringify(view.file)}` ), `const styles = ${scriptMagic}[-2]`, `export const scripts = ${scriptMagic}[-1]`, "export const views = {", ...viewModules.flatMap(({ view, module }, i) => [ ` ${JSON.stringify(view.id)}: {`, ` component: view${i}.default,`, ` meta: view${i}.meta,`, ` layout: ${ module.layout?.default ? `view${i}.layout?.default` : "undefined" },`, ` theme: ${ module.layout?.theme ? `view${i}.layout?.theme` : module.theme ? `view${i}.theme` : "undefined" },`, ` inlineCss: styles[${scriptMagic}[${i}]]`, ` },`, ]), "}", ].join("\n"); const serverPlugins: esbuild.Plugin[] = [ virtualFiles({ "$views": viewSource, }), projectRelativeResolution(), { name: "marko via build cache", setup(b) { b.onLoad({ filter: /\.marko$/ }, async ({ path: file }) => ({ loader: "ts", contents: hot.getSourceCode(file), })); }, }, { name: "mark css external", setup(b) { b.onResolve( { filter: /\.css$/ }, () => ({ path: ".", namespace: "dropped" }), ); b.onLoad( { filter: /./, namespace: "dropped" }, () => ({ contents: "" }), ); }, }, ]; const bundle = await esbuild.build({ bundle: true, chunkNames: "c.[hash]", entryNames: "[name]", entryPoints: [ path.join(import.meta.dirname, "backend/entry-" + platform + ".ts"), ], platform: "node", format: "esm", minify: false, outdir: "/out!", plugins: serverPlugins, splitting: true, write: false, metafile: true, }); const viewData = viewModules.map(({ module, view }) => { return { id: view.id, theme: module.theme, cssImports: hot.getCssImports(view.file) .concat("src/global.css") .map((file) => path.resolve(file)), clientRefs: hot.getClientScriptRefs(view.file), }; }); return { views: viewData, bundle, scriptMagic, }; } type Await = T extends Promise ? R : T; export function finalizeServerJavaScript( backend: Await>, viewCssBundles: css.Output[], incr: Incremental, ) { const { metafile, outputFiles } = backend.bundle; // Only the reachable script files need to be inserted into the bundle. const reachableScriptKeys = new Set( backend.views.flatMap((view) => view.clientRefs), ); const reachableScripts = Object.fromEntries( Array.from(incr.out.script) .filter(([k]) => reachableScriptKeys.has(k)), ); // Deduplicate styles const styleList = Array.from(new Set(viewCssBundles)); for (const output of outputFiles) { const basename = output.path.replace(/^.*?!/, ""); const key = "out!" + basename.replaceAll("\\", "/"); // If this contains the generated "$views" file, then // replace the IDs with the bundled results. let text = output.text; if (metafile.outputs[key].inputs["framework/lib/view.ts"]) { text = text.replace( /CLOVER_CLIENT_SCRIPTS_DEFINITION\[(-?\d)\]/gs, (_, i) => { i = Number(i); // Inline the styling data if (i === -2) { return JSON.stringify(styleList.map((item) => item.text)); } // Inline the script data if (i === -1) { return JSON.stringify(Object.fromEntries(incr.out.script)); } // Reference an index into `styleList` return `${styleList.indexOf(viewCssBundles[i])}`; }, ); } fs.writeMkdirSync(path.join(".clover/backend/" + basename), text); } } import * as esbuild from "esbuild"; import * as path from "node:path"; import process from "node:process"; import * as hot from "./hot.ts"; import { banFiles, projectRelativeResolution, virtualFiles, } from "./esbuild-support.ts"; import { Incremental } from "./incremental.ts"; import type { FileItem } from "#sitegen"; import * as marko from "@marko/compiler"; import * as css from "./css.ts"; import * as fs from "./lib/fs.ts";