diff --git a/deno.json b/deno.json index 7040429..02d6172 100644 --- a/deno.json +++ b/deno.json @@ -8,7 +8,7 @@ "./packages/wc" ], "tasks": { - "build": "deno run --allow-env --allow-read --allow-write --allow-run packages/cli/main.ts build", + "build": "deno run --allow-net --allow-env --allow-read --allow-write --allow-run packages/cli/main.ts build", "dev": "deno run --watch --unstable-broadcast-channel --allow-net --allow-env --allow-read --allow-write --allow-run packages/cli/main.ts dev" }, "compilerOptions": { diff --git a/deno.lock b/deno.lock index 2d736ca..93e737d 100644 --- a/deno.lock +++ b/deno.lock @@ -1,30 +1,45 @@ { "version": "5", "specifiers": { + "jsr:@astral/astral@~0.5.6": "0.5.6", "jsr:@cliffy/command@1.0.0-rc.8": "1.0.0-rc.8", "jsr:@cliffy/flags@1.0.0-rc.8": "1.0.0-rc.8", "jsr:@cliffy/internal@1.0.0-rc.8": "1.0.0-rc.8", "jsr:@cliffy/table@1.0.0-rc.8": "1.0.0-rc.8", + "jsr:@deno-library/progress@^1.5.1": "1.5.1", + "jsr:@deno/cache-dir@0.22.2": "0.22.2", "jsr:@deno/esbuild-plugin@^1.1.5": "1.1.5", "jsr:@deno/loader@~0.3.3": "0.3.4", "jsr:@es-toolkit/es-toolkit@^1.39.9": "1.39.9", "jsr:@eta-dev/eta@^3.5.0": "3.5.0", + "jsr:@std/async@1": "1.0.14", "jsr:@std/async@^1.0.14": "1.0.14", + "jsr:@std/bytes@^1.0.2": "1.0.5", "jsr:@std/cli@^1.0.21": "1.0.21", "jsr:@std/crypto@^1.0.5": "1.0.5", + "jsr:@std/encoding@1": "1.0.10", "jsr:@std/encoding@^1.0.10": "1.0.10", + "jsr:@std/fmt@1.0.3": "1.0.3", + "jsr:@std/fmt@^1.0.3": "1.0.8", "jsr:@std/fmt@^1.0.8": "1.0.8", "jsr:@std/fmt@~1.0.2": "1.0.8", + "jsr:@std/fs@1": "1.0.19", "jsr:@std/fs@^1.0.19": "1.0.19", + "jsr:@std/fs@^1.0.6": "1.0.19", "jsr:@std/html@^1.0.4": "1.0.4", "jsr:@std/http@^1.0.20": "1.0.20", "jsr:@std/internal@^1.0.6": "1.0.9", "jsr:@std/internal@^1.0.9": "1.0.9", + "jsr:@std/io@0.225": "0.225.0", + "jsr:@std/io@0.225.0": "0.225.0", "jsr:@std/media-types@^1.1.0": "1.1.0", "jsr:@std/net@^1.0.4": "1.0.4", + "jsr:@std/path@1": "1.1.1", + "jsr:@std/path@^1.0.8": "1.1.1", "jsr:@std/path@^1.1.1": "1.1.1", "jsr:@std/streams@^1.0.10": "1.0.10", "jsr:@std/text@~1.0.7": "1.0.15", + "jsr:@zip-js/zip-js@^2.7.52": "2.8.26", "npm:@chenglou/pretext@*": "0.0.7", "npm:@chenglou/pretext@^0.0.7": "0.0.7", "npm:@comark/html@*": "0.3.1_shiki@4.0.2", @@ -49,6 +64,18 @@ "npm:xstate@^5.20.2": "5.20.2" }, "jsr": { + "@astral/astral@0.5.6": { + "integrity": "580e36211645c4346eabd9af71b7e2db312669fadc4331f9e7309bf62e6ab2ff", + "dependencies": [ + "jsr:@deno-library/progress", + "jsr:@deno/cache-dir", + "jsr:@std/async@1", + "jsr:@std/encoding@1", + "jsr:@std/fs@1", + "jsr:@std/path@1", + "jsr:@zip-js/zip-js" + ] + }, "@cliffy/command@1.0.0-rc.8": { "integrity": "758147790797c74a707e5294cc7285df665422a13d2a483437092ffce40b5557", "dependencies": [ @@ -74,11 +101,27 @@ "jsr:@std/fmt@~1.0.2" ] }, + "@deno-library/progress@1.5.1": { + "integrity": "966611826b8bb27baae73ab1c4fa4317cd4edd2abb99750cd6f8488d22d5b121", + "dependencies": [ + "jsr:@std/fmt@1.0.3", + "jsr:@std/io@0.225.0" + ] + }, + "@deno/cache-dir@0.22.2": { + "integrity": "0c84b8db6175618cc2e25ed7d7648d83b38e298c14c1aae1e4b4e1b2219b840c", + "dependencies": [ + "jsr:@std/fmt@^1.0.3", + "jsr:@std/fs@^1.0.6", + "jsr:@std/io@0.225", + "jsr:@std/path@^1.0.8" + ] + }, "@deno/esbuild-plugin@1.1.5": { "integrity": "c9cde95990b97802a0da6c73c26ab4a48f30d286818845e365dddcd8297abd7d", "dependencies": [ "jsr:@deno/loader", - "jsr:@std/path", + "jsr:@std/path@^1.1.1", "npm:esbuild@~0.25.5" ] }, @@ -94,6 +137,9 @@ "@std/async@1.0.14": { "integrity": "62e954a418652c704d37563a3e54a37d4cf0268a9dcaeac1660cc652880b5326" }, + "@std/bytes@1.0.5": { + "integrity": "4465dd739d7963d964c809202ebea6d5c6b8e3829ef25c6a224290fbb8a1021e" + }, "@std/cli@1.0.21": { "integrity": "cd25b050bdf6282e321854e3822bee624f07aca7636a3a76d95f77a3a919ca2a" }, @@ -103,6 +149,9 @@ "@std/encoding@1.0.10": { "integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1" }, + "@std/fmt@1.0.3": { + "integrity": "97765c16aa32245ff4e2204ecf7d8562496a3cb8592340a80e7e554e0bb9149f" + }, "@std/fmt@1.0.8": { "integrity": "71e1fc498787e4434d213647a6e43e794af4fd393ef8f52062246e06f7e372b7" }, @@ -116,19 +165,25 @@ "integrity": "b5cc33fc001bccce65ed4c51815668c9891c69ccd908295997e983d8f56070a1", "dependencies": [ "jsr:@std/cli", - "jsr:@std/encoding", + "jsr:@std/encoding@^1.0.10", "jsr:@std/fmt@^1.0.8", - "jsr:@std/fs", + "jsr:@std/fs@^1.0.19", "jsr:@std/html", "jsr:@std/media-types", "jsr:@std/net", - "jsr:@std/path", + "jsr:@std/path@^1.1.1", "jsr:@std/streams" ] }, "@std/internal@1.0.9": { "integrity": "bdfb97f83e4db7a13e8faab26fb1958d1b80cc64366501af78a0aee151696eb8" }, + "@std/io@0.225.0": { + "integrity": "c1db7c5e5a231629b32d64b9a53139445b2ca640d828c26bf23e1c55f8c079b3", + "dependencies": [ + "jsr:@std/bytes" + ] + }, "@std/media-types@1.1.0": { "integrity": "c9d093f0c05c3512932b330e3cc1fe1d627b301db33a4c2c2185c02471d6eaa4" }, @@ -146,6 +201,9 @@ }, "@std/text@1.0.15": { "integrity": "91f5cc1e12779a3d95f1be34e763f9c28a75a078b7360e6fcaef0d8d9b1e3e7f" + }, + "@zip-js/zip-js@2.8.26": { + "integrity": "2b78bfa2c5d43766c9a487fc0274981d45732de41bb5b82433d29d235be67191" } }, "npm": { @@ -1741,9 +1799,11 @@ }, "packages/core": { "dependencies": [ + "jsr:@astral/astral@~0.5.6", "jsr:@eta-dev/eta@^3.5.0", "jsr:@std/crypto@^1.0.5", "jsr:@std/encoding@^1.0.10", + "jsr:@std/fs@1", "jsr:@std/path@^1.1.1", "npm:@comark/html@*", "npm:@shikijs/themes@4", diff --git a/packages/cli/build.ts b/packages/cli/build.ts index 18dd376..0bb1f11 100644 --- a/packages/cli/build.ts +++ b/packages/cli/build.ts @@ -1,5 +1,5 @@ import { Command } from "@cliffy/command"; -import { renderPresentationHtml } from "@morkdeck/core"; +import { renderPresentationHtml, shutdownMermaidRenderer } from "@morkdeck/core"; export const build = new Command() .arguments("") @@ -11,9 +11,15 @@ export const build = new Command() const { output } = options; await Deno.mkdir(output, { recursive: true }); - const html = await renderPresentationHtml(source); - - const outputPath = `${output}/index.html`; - await Deno.writeTextFile(outputPath, html); - console.log(`Static build complete! Output: ${output}/index.html`); + try { + const html = await renderPresentationHtml(source); + const outputPath = `${output}/index.html`; + await Deno.writeTextFile(outputPath, html); + console.log(`Static build complete! Output: ${output}/index.html`); + } finally { + // Ensure the headless Chrome the mermaid renderer launched gets + // torn down before we exit — orphaned Chromium processes are + // exactly the kind of thing that bites you a week later. + await shutdownMermaidRenderer(); + } }); diff --git a/packages/core/deno.json b/packages/core/deno.json index 380b413..a65186c 100644 --- a/packages/core/deno.json +++ b/packages/core/deno.json @@ -3,10 +3,12 @@ "version": "0.0.0", "exports": "./mod.ts", "imports": { + "@astral/astral": "jsr:@astral/astral@^0.5.6", "@comark/html": "npm:@comark/html", "@eta-dev/eta": "jsr:@eta-dev/eta@^3.5.0", "@std/crypto": "jsr:@std/crypto@^1.0.5", "@std/encoding": "jsr:@std/encoding@^1.0.10", + "@std/fs": "jsr:@std/fs@^1.0.0", "@std/path": "jsr:@std/path@^1.1.1", "@types/blobshape": "npm:@types/blobshape@^1.0.3", "color-hash": "https://deno.land/x/color_hash@v2.0.1/mod.ts", diff --git a/packages/core/mermaid.ts b/packages/core/mermaid.ts new file mode 100644 index 0000000..5bd8b73 --- /dev/null +++ b/packages/core/mermaid.ts @@ -0,0 +1,338 @@ +/** + * Static mermaid renderer. + * + * Mermaid is too sensitive to runtime conditions (font load timing, + * hidden tab measurement, slot reassignment, CDN race with our own + * config script) to render reliably in the browser. We pre-render at + * build/dev time inside a long-running headless Chromium so every + * audience sees identical SVG. + * + * The pool is lazy: nothing launches until the first diagram. A SHA-256 + * keyed cache on disk skips Chrome entirely for unchanged diagrams, so + * typical incremental rebuilds touch no subprocess. + */ + +import { type Browser, launch, type Page } from "@astral/astral"; +import { encodeHex } from "@std/encoding/hex"; +import { ensureDir } from "@std/fs/ensure-dir"; +import { join } from "@std/path"; + +/** Pinned mermaid version. Bump when upgrading; invalidates the cache. */ +const MERMAID_VERSION = "11.9.0"; +/** + * Bump when the theme variables, mermaid.initialize() config, or any + * property in the renderer page that affects glyph metrics changes — + * anything that would alter SVG output for the same source. Folded + * into the cache key so stale cache entries don't survive an update. + */ +const THEME_VERSION = "v4"; + +/** + * The page we serve to Chromium. Loads Recursive + mermaid from the + * same CDNs the runtime used to use, configures mermaid identically to + * how the partial used to, waits for fonts to settle, then signals + * readiness by setting an attribute on . We poll for that + * attribute instead of evaluating a global so Astral's waitForSelector + * does the polling work. + */ +const RENDERER_HTML = ` + + + + + + + + + + +
+ + +`; + +let browser: Browser | undefined; +let page: Page | undefined; +let pageInit: Promise | undefined; + +/** + * Common install paths for a system Chrome/Chromium/Edge per platform. + * We prefer the user's installed browser over Astral's bundled snapshot + * because the snapshot is pinned to an old Chromium that often fails to + * start on machines missing the matching VC++ side-by-side runtime + * (Windows) or shared libs (Linux). The system browser is also kept + * patched. + */ +const SYSTEM_BROWSER_PATHS: Record = { + windows: [ + "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe", + "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", + "C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe", + "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe", + ], + darwin: [ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Chromium.app/Contents/MacOS/Chromium", + "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", + ], + linux: [ + "/usr/bin/google-chrome", + "/usr/bin/chromium", + "/usr/bin/chromium-browser", + "/snap/bin/chromium", + "/usr/bin/microsoft-edge", + ], +}; + +function detectSystemBrowser(): string | undefined { + const override = Deno.env.get("MORKDECK_CHROME_PATH"); + if (override) return override; + const candidates = SYSTEM_BROWSER_PATHS[Deno.build.os] ?? []; + for (const path of candidates) { + try { + const stat = Deno.statSync(path); + if (stat.isFile) return path; + } catch { + // not present, keep looking + } + } + return undefined; +} + +/** + * Lazy singleton: launch Chrome and open the renderer page on first + * call, reuse for every subsequent render. Concurrent first calls share + * the same in-flight `pageInit` promise so we don't race two launches. + */ +function ensurePage(): Promise { + if (page) return Promise.resolve(page); + if (pageInit) return pageInit; + pageInit = (async () => { + if (!browser) { + const path = detectSystemBrowser(); + browser = path ? await launch({ path }) : await launch(); + } + const dataUrl = `data:text/html;charset=utf-8,${ + encodeURIComponent(RENDERER_HTML) + }`; + const p = await browser.newPage(dataUrl); + await p.waitForSelector("body[data-mermaid-ready=true]"); + page = p; + return p; + })(); + return pageInit; +} + +/** + * Tear down the browser. Call from CLI commands that are about to exit + * (build, one-shot) so the Chrome subprocess doesn't linger. The dev + * server should leave it alive across rebuilds. + */ +export async function shutdownMermaidRenderer(): Promise { + if (page) { + try { + await page.close(); + } catch { + // ignore close-after-already-closed + } + page = undefined; + } + if (browser) { + try { + await browser.close(); + } catch { + // ignore + } + browser = undefined; + } + pageInit = undefined; +} + +/** Throw shape for the renderer caller to detect mermaid-specific errors. */ +export class MermaidRenderError extends Error { + override name = "MermaidRenderError"; + constructor(message: string, readonly source: string) { + super(message); + } +} + +/* ─── Cache ─────────────────────────────────────────────────────────── */ + +/** + * Per-machine cache directory. Honors platform conventions: + * - Windows: %LOCALAPPDATA%\morkdeck\mermaid + * - XDG: $XDG_CACHE_HOME/morkdeck/mermaid + * - Unix: ~/.cache/morkdeck/mermaid + * Falls back to a project-local dir if no home is discoverable. + */ +function cacheDir(): string { + const local = Deno.env.get("LOCALAPPDATA"); + if (local) return join(local, "morkdeck", "mermaid"); + const xdg = Deno.env.get("XDG_CACHE_HOME"); + if (xdg) return join(xdg, "morkdeck", "mermaid"); + const home = Deno.env.get("HOME") ?? Deno.env.get("USERPROFILE"); + if (home) return join(home, ".cache", "morkdeck", "mermaid"); + return join(Deno.cwd(), ".morkdeck-cache", "mermaid"); +} + +async function sha256(text: string): Promise { + const buf = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(text), + ); + return encodeHex(new Uint8Array(buf)); +} + +async function readCache(key: string): Promise { + try { + return await Deno.readTextFile(join(cacheDir(), `${key}.svg`)); + } catch { + return undefined; + } +} + +async function writeCache(key: string, svg: string): Promise { + const dir = cacheDir(); + await ensureDir(dir); + await Deno.writeTextFile(join(dir, `${key}.svg`), svg); +} + +/* ─── Public API ────────────────────────────────────────────────────── */ + +/** + * Render a mermaid source to an SVG string. Cached on disk by + * SHA-256(format | theme version | mermaid version | source); a cache + * hit returns without touching Chrome. + * + * Throws `MermaidRenderError` if mermaid itself rejects the source. + * Callers in dev mode catch this and render the source + error inline; + * callers in build mode let it propagate to fail the build. + * + * The `svg` discriminator on the cache key is forward-looking — when + * PNG / multi-resolution is added it just slots in as additional keys. + */ +export async function renderMermaid(source: string): Promise { + const key = await sha256( + `svg|${THEME_VERSION}|${MERMAID_VERSION}|${source}`, + ); + const cached = await readCache(key); + if (cached !== undefined) return cached; + + const p = await ensurePage(); + const result = await p.evaluate( + async (src: string) => { + try { + // Unique id per call so mermaid doesn't collide with its own + // previous run on the same page (it caches sub-trees by id). + const id = "morkdeck-" + Math.random().toString(36).slice(2); + // @ts-ignore: __mermaid is injected by the renderer page script. + const out = await window.__mermaid.render(id, src); + return { ok: true as const, svg: out.svg as string }; + } catch (e) { + const msg = e instanceof Error + ? (e.message || e.toString()) + : String(e); + return { ok: false as const, error: msg }; + } + }, + { args: [source] }, + ); + + if (!result.ok) { + throw new MermaidRenderError(result.error, source); + } + await writeCache(key, result.svg); + return result.svg; +} diff --git a/packages/core/mod.ts b/packages/core/mod.ts index ae16df1..68a3b00 100644 --- a/packages/core/mod.ts +++ b/packages/core/mod.ts @@ -1,2 +1,7 @@ export * from "./renderer.ts"; export * from "./types.ts"; +export { + MermaidRenderError, + renderMermaid, + shutdownMermaidRenderer, +} from "./mermaid.ts"; diff --git a/packages/core/renderer.ts b/packages/core/renderer.ts index eeb6627..3eddabe 100644 --- a/packages/core/renderer.ts +++ b/packages/core/renderer.ts @@ -9,6 +9,7 @@ import { Eta } from "@eta-dev/eta"; import { resolve } from "@std/path"; import { compileCss } from "@morkdeck/theme"; import { detectLayout, extractNotes, isElement } from "./layout.ts"; +import { MermaidRenderError, renderMermaid } from "./mermaid.ts"; import type { Includes, RenderOptions } from "./types.ts"; const eta = new Eta({ @@ -65,8 +66,6 @@ export async function renderPresentationHtml( const { content, notes } = extractNotes(rawChildren); const { layout, children } = detectLayout(content, i === 0); - if (layout === "diagram-focus") includes.add("mermaid"); - // Speaker notes are emitted as a