254 lines
7.2 KiB
TypeScript
254 lines
7.2 KiB
TypeScript
// 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<void>[] = [];
|
|
// 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> = T extends Promise<infer R> ? R : T;
|
|
|
|
export function finalizeServerJavaScript(
|
|
backend: Await<ReturnType<typeof bundleServerJavaScript>>,
|
|
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";
|