Files
gstack/lib/diagram-render/scripts/build.ts
T
Garry Tan 9db479a38d fix(make-pdf): pre-landing review wave — fence fidelity, injection hardening, Windows paths, transport rework
Review army (6 specialists + red team) findings, all fixed:

- Indented fences replay byte-for-byte and indented diagram fences are NOT
  extracted (red-team conf-9: the pre-pass reconstructed fences at column 0,
  splitting any list containing fenced code — every ordinary document).
- String.replace $-pattern injection killed at every seam: substituteSlots,
  mergeStyle, img/src rewrites all use function replacements (a diagram label
  containing $' duplicated the document tail).
- Big-expression transport reworked: browse `eval <file>` (one spawn, any
  size, Windows-safe) replaces the 64KB chunked window-buffer eval — fixes
  the per-chunk spawn cost, the char-vs-byte argv units, AND the Windows
  32,767-char command-line ceiling in one move.
- Staged-bundle trust: content verified by hash even when the file exists,
  and the rename-failure path re-hashes the survivor (sticky-bit /tmp EPERM
  would otherwise ride a pre-planted file past the check).
- Windows drive-letter img srcs (C:/x.png) reach the local-path branch
  instead of being swallowed as unknown URL schemes.
- DOCX rasterize-failure now embeds the decoded source as visible text —
  returning the figure made diagrams vanish silently (converter drops svg).
- Fence source preserved as base64 data-gstack-source attribute (the comment
  encoding corrupted every '-->' arrow); decodeFigureSource() round-trips.
- inlineLocalImages memoizes per path; file:// uses fileURLToPath; preview
  prints a divergence note for fences/local images; --to docx strips the
  watermark div and warns about print-only flags; TOC links resolve in
  html/docx (heading ids assigned); waitForExpression sleeps instead of
  busy-spinning; escapeHtml/svg-dims deduped to single definitions;
  typography stragglers (blockquote 12pt, footnotes 10pt, 42em screen
  measure); bundle BUILD_INFO gains srcSha256 for no-node_modules drift
  detection; MAX_TARGET_PX shared guard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 07:57:42 -07:00

100 lines
3.3 KiB
TypeScript

/**
* Build dist/diagram-render.html — the single-file offline render page.
*
* One command updates everything: `bun run build` (in this directory) or
* `bun run build:diagram-render` (repo root). To bump a dependency: edit the
* exact pin in package.json, `bun install`, rebuild, commit src + dist +
* BUILD_INFO.json together. The drift test (test/diagram-render-drift.test.ts)
* fails CI when dist and BUILD_INFO disagree.
*
* Page assembly notes (learned in the spike, do not "simplify" away):
* - The script MUST be `type="module"` — mermaid's bundle contains
* import.meta, which throws in a classic script.
* - `</scri` sequences inside the minified JS MUST be escaped to `<\/scri`,
* or the inline <script> terminates early ("Unexpected end of input").
* - A <base href> with an absolute URL is required: the page lives at
* about:blank (page.setContent), where relative URL construction throws.
*/
import { createHash } from "node:crypto";
import path from "node:path";
const ROOT = path.resolve(import.meta.dir, "..");
const ENTRY = path.join(ROOT, "src", "entry.ts");
const DIST_DIR = path.join(ROOT, "dist");
const DIST_HTML = path.join(DIST_DIR, "diagram-render.html");
const BUILD_INFO = path.join(DIST_DIR, "BUILD_INFO.json");
const pkg = await Bun.file(path.join(ROOT, "package.json")).json();
const deps: Record<string, string> = pkg.dependencies;
const result = await Bun.build({
entrypoints: [ENTRY],
target: "browser",
minify: true,
define: {
__BUNDLE_INFO_DEPS__: JSON.stringify(deps),
"process.env.NODE_ENV": '"production"',
},
});
if (!result.success) {
for (const log of result.logs) console.error(log);
process.exit(1);
}
const js = await result.outputs[0].text();
// Escape inline-script terminators (see header note).
const inlineJs = js.replaceAll("</scri", "<\\/scri");
const head = `<!doctype html>
<html>
<head>
<meta charset="utf-8">
<base href="https://gstack-render.localhost/">
<title>gstack diagram-render</title>
<style>
body { font-family: Helvetica, "Liberation Sans", Arial, sans-serif; margin: 0; }
</style>
<script>
window.__errors = [];
window.onerror = function (msg, src, line, col, err) {
window.__errors.push(String(msg) + " @" + line + ":" + col);
};
window.addEventListener("unhandledrejection", function (e) {
window.__errors.push("unhandledrejection: " + String(e.reason).slice(0, 500));
});
</script>
</head>
<body>
<div id="status">loading</div>
<script type="module">
`;
const tail = `
</script>
</body>
</html>
`;
const html = head + inlineJs + tail;
await Bun.write(DIST_HTML, html);
const sha256 = createHash("sha256").update(html).digest("hex");
// Source fingerprint: lets the drift test catch "edited src, forgot to
// rebuild dist" WITHOUT needing node_modules for a full rebuild (the deep
// rebuild check only runs where deps are installed).
const srcSha256 = createHash("sha256")
.update(await Bun.file(ENTRY).text())
.update(await Bun.file(import.meta.path).text())
.digest("hex");
const info = {
name: "gstack-diagram-render",
sha256,
srcSha256,
bytes: Buffer.byteLength(html),
bunVersion: Bun.version,
deps,
};
await Bun.write(BUILD_INFO, JSON.stringify(info, null, 2) + "\n");
console.log(`built ${path.relative(process.cwd(), DIST_HTML)} (${(info.bytes / 1024 / 1024).toFixed(2)} MB)`);
console.log(`sha256 ${sha256}`);