markdown-review/server/build.mjs

82 lines
2.8 KiB
JavaScript

/**
* Build script for the markdown-review MCP server.
*
* Produces two self-contained artifacts in dist/ with ZERO runtime node_modules:
* - dist/index.js Node MCP server, all deps inlined (run: node dist/index.js --stdio)
* - dist/mcp-app.html Single-file webview app (HTML + inlined JS/CSS)
*
* Build-time deps live in node_modules; runtime needs only `node`. Commit dist/
* so a fresh install runs without `npm install`.
*/
import { build, context } from "esbuild";
import { readFile, writeFile, mkdir } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.dirname(fileURLToPath(import.meta.url));
const dist = path.join(root, "dist");
const watch = process.argv.includes("--watch");
const serverOpts = {
entryPoints: [path.join(root, "src/main.ts")],
outfile: path.join(dist, "index.js"),
bundle: true,
platform: "node",
format: "esm",
target: "node20",
// ESM output that bundles CJS deps needs createRequire shimmed in.
banner: {
js: [
"#!/usr/bin/env node",
"import { createRequire as __cr } from 'node:module';",
"const require = __cr(import.meta.url);",
].join("\n"),
},
logLevel: "info",
};
/** Bundle the browser app to a string of JS, then inline it into the HTML shell. */
async function buildApp() {
const result = await build({
entryPoints: [path.join(root, "src/app/mcp-app.ts")],
bundle: true,
platform: "browser",
format: "iife",
target: "es2022",
minify: !watch,
sourcemap: watch ? "inline" : false,
write: false,
logLevel: "info",
});
const js = result.outputFiles[0].text;
// Escape any literal "</script>" inside the bundle, or the HTML parser closes
// the inline <script> early and the whole bundle fails to parse. (markdown-it
// and friends ship such strings.) "<\/script>" is identical JS, inert to HTML.
const safeJs = js.replace(/<\/script>/gi, "<\\/script>");
const shell = await readFile(path.join(root, "src/app/index.html"), "utf-8");
// Use a replacer FUNCTION, not a string: a string replacement interprets "$&",
// "$`", etc., and the minified bundle is full of "$" — which silently
// duplicates/mangles the shell. A function replacement is taken verbatim.
const html = shell.replace("<!--APP_BUNDLE-->", () => `<script>${safeJs}</script>`);
await writeFile(path.join(dist, "mcp-app.html"), html, "utf-8");
console.log("[build] dist/mcp-app.html");
}
async function run() {
await mkdir(dist, { recursive: true });
if (watch) {
const ctx = await context(serverOpts);
await ctx.watch();
await buildApp();
console.log("[build] watching server; rebuild app manually on change");
} else {
await build(serverOpts);
await buildApp();
console.log("[build] done");
}
}
run().catch((e) => {
console.error(e);
process.exit(1);
});