// Build the viewer HTML page. The CLI does all file I/O here and embeds the pad // data + file contents into one HTML string, so glimpse and the browser fallback // render identically with no round-trips. highlight.js and mermaid load from a // pinned CDN, added CONDITIONALLY — hljs only when a pad has code, mermaid only // when a ```mermaid block is present. import { stat } from "node:fs/promises"; import { basename, dirname, extname, isAbsolute, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import pkg from "../../package.json" with { type: "json" }; import type { ScratchConfig } from "../config.ts"; import { type Pad, exportFileSlug, resolveEntryPath, toPosix } from "../discovery.ts"; import { type Comment, DEFAULT_TYPE, type FileEntry, type Layout, MANIFEST_NAME } from "../manifest.ts"; import { type CommentItem, toCommentItems } from "../comments.ts"; import { KIT_CSS, KIT_SVG_DEFS } from "./kit.ts"; import { COLOR_THEMES, DEFAULT_COLOR_THEME, THEME_CSS } from "./theme.ts"; // Pinned CDN builds (version + SRI) live in vendor-manifest.ts — the single source // of truth shared with scripts/fetch-vendor.ts (offline cache). The script-global // builds set window.hljs / window.mermaid / window.katex; if they fail to load // (online page, offline) the client degrades gracefully (plain code + raw source). import { EXCA_EDITOR_CDN, HLJS_CDN, HLJS_THEME_DARK, HLJS_THEME_LIGHT, KATEX_CDN, KATEX_CSS, MERMAID_CDN, } from "./vendor-manifest.ts"; // Static import is safe: the module itself is light — the excalidraw dependency // only loads inside renderExcalidrawSvg, so non-drawing pads never pay for it. import { EXCALIDRAW_EXT, parseScene, renderExcalidrawSvg } from "../excalidraw.ts"; const MAX_EMBED_BYTES = 5 * 1024 * 1024; // skip embedding text/code content above this // Images get a far larger budget than text — a single screenshot routinely // exceeds 512KB, and embedding it is the only way it survives an export over // file://. Base64 inflates bytes ~33%, so this is the on-disk source ceiling. const MAX_IMAGE_BYTES = 10 * 1024 * 1024; const IMAGE_EXT = new Set([".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".bmp", ".ico"]); const MD_EXT = new Set([".md", ".markdown", ".mdx"]); const TEXT_EXT = new Set([ ".txt", ".log", ".csv", ".tsv", ".env", ".ini", ".cfg", ".conf", ".gitignore", ]); const CODE_EXT = new Set([ ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".json", ".jsonc", ".py", ".rb", ".go", ".rs", ".java", ".kt", ".c", ".h", ".cpp", ".hpp", ".cs", ".php", ".swift", ".sh", ".bash", ".zsh", ".ps1", ".sql", ".yaml", ".yml", ".toml", ".xml", ".css", ".scss", ".less", ".vue", ".svelte", ".lua", ".r", ".scala", ".dart", ]); // Rendered in a sandboxed iframe (scripts disabled) rather than as source. const HTML_EXT = new Set([".html", ".htm"]); // Scene JSON is the stored source of truth; the page embeds only the SVG // rendered server-side (src/excalidraw.ts), so exports carry no excalidraw code. // Rendered SVGs are memoized per file (keyed by mtime+size) — buildView runs on // every watcher event / reload, and a full excalidraw render (rasterizer + font // subsetting) per drawing per rebuild would make touching an unrelated note // re-render every scene in the session. const EXCA_SVG_CACHE = new Map(); // Text-vs-binary sniffing for unknown extensions: how much of the head to read, // and one shared strict decoder (constructing one per file adds up over a scan). const SNIFF_BYTES = 8192; const UTF8_STRICT = new TextDecoder("utf-8", { fatal: true }); const MIME: Record = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".svg": "image/svg+xml", ".webp": "image/webp", ".bmp": "image/bmp", ".ico": "image/x-icon", }; type Kind = "markdown" | "code" | "image" | "text" | "html" | "binary" | "toolarge"; export interface FileView { path: string; /** Absolute on-disk path (resolves manifest `src`); used for copy-full-path. */ abs: string; registered: boolean; /** Linked from outside the pad — content read from the manifest `src`. */ external?: boolean; title?: string; description?: string; tags?: string[]; type?: string; /** Visual group header the file sits under (absent = ungrouped). */ group?: string; kind: Kind; /** language hint for code files (extension without dot). */ lang?: string; /** text content for markdown/code/text; data URI for image; null otherwise. */ content: string | null; /** For markdown: raw inline-image src → embedded data URI, so `![](rel)` refs * survive an export over file://. Absent when the doc has no local images. */ assets?: Record; /** ISO timestamps from the file on disk (manifest has only pad-level dates). */ created?: string; updated?: string; /** Raw .excalidraw scene JSON (content holds the rendered SVG data URI). The * live viewer's in-place editor opens from this; exports just carry it inert. */ source?: string; /** .excalidraw scene with no elements — the viewer shows a placeholder notice * instead of the invisible blank SVG (content stays null). */ emptyScene?: boolean; /** Inline comments from the manifest (quote-anchored; see manifest.ts). */ comments?: Comment[]; /** Comments resolved against the source (file:line, heading, context) at render * time — the CLI's `comments --json` shape, embedded so the viewer's Ctrl+Alt+C * can copy it synchronously (no host round-trip = clipboard activation survives). */ commentsExport?: CommentItem[]; /** Manifest-hidden entry included only via a session reveal (see Reloader.reveal). */ hidden?: boolean; } export interface PadView { name: string; id?: string; dir: string; files: FileView[]; /** Optional group ordering/collapse hint from the manifest (see manifest.ts). */ layout?: Layout; /** Paths of manifest-hidden entries NOT in `files` (paths only — no content, so * nothing leaks into exports). Lets the client recognize a link to a hidden file * and ask the host to reveal it for the session. */ hiddenPaths?: string[]; /** Unregistered files the pad's markdown links to, keyed by absolute posix path * (see collectLinkedViews). Export-only: a live page asks the host on click. */ linked?: Record; /** "::" → key into `linked`, so the * client never resolves paths itself (the server did, once, with node:path). */ linkKeys?: Record; } /** Every FileView the page may render — sidebar files plus link-embedded ones. */ function allViews(view: PadView[]): FileView[] { return view.flatMap((p) => [...p.files, ...Object.values(p.linked ?? {})]); } /** Absolute path a markdown link points at, resolved from the linking doc's * on-disk location; null for non-file schemes. The one place this rule lives — * the live peek (resolvePeek) and the export embed (collectLinkedViews) must * agree or the same link would open in one and toast in the other. */ export function hrefToAbs(fromAbs: string, href: string): string | null { let h = href.split("#")[0]!.trim().replace(/^<|>$/g, ""); if (!h || /^(https?:|mailto:|data:|\/\/)/i.test(h)) return null; if (/^file:\/\//i.test(h)) { try { h = fileURLToPath(h); } catch { return null; } } try { h = decodeURI(h); } catch { // malformed escape — a stray % is still a legitimate filename char } return isAbsolute(h) ? h : resolve(dirname(fromAbs), h); } /** Base64 data URI for an embedded image's bytes (shared by the registered-file * and inline-markdown embed paths). */ function imageDataUri(buf: Buffer, ext: string): string { return `data:${MIME[ext] ?? "application/octet-stream"};base64,${buf.toString("base64")}`; } /** Extract the bare src token from an ![](...) destination — drops an optional * "title" and surrounding <...>. Must mirror the client's extraction so the * server-built asset key matches the client lookup. */ function imageSrcToken(raw: string): string { let s = raw.trim(); const sp = s.search(/\s/); if (sp >= 0) s = s.slice(0, sp); if (s.startsWith("<") && s.endsWith(">")) s = s.slice(1, -1); return s; } /** Embed each local file referenced by a markdown `![alt](src)` so the page stays * self-contained, keyed by raw src. Images become a data URI; a local `.html` ref * becomes its raw markup (rendered live in a sandboxed iframe client-side — md stays * prose, the diagram is its own loose file, NOT a manifest entry). Remote/scheme refs * and other types are left for the browser; missing/oversized files are skipped. * Resolves relative to the doc's dir. */ async function embedInlineAssets(markdown: string, baseDir: string): Promise> { const assets: Record = {}; // ![alt](src) — src is everything up to the first whitespace ("title" follows) // or the closing paren. Local regex (not module-level) so the /g lastIndex is // never shared across the concurrent scanPadFiles map. for (const m of markdown.matchAll(/!\[[^\]]*\]\(([^)]+)\)/g)) { const src = imageSrcToken(m[1]!); if (!src || src in assets) continue; // assets dedups by key if (/^(https?:|data:|file:|\/\/)/i.test(src)) continue; const ext = extname(src).toLowerCase(); const isImage = IMAGE_EXT.has(ext), isHtml = HTML_EXT.has(ext); if (!isImage && !isHtml) continue; let rel = src; try { rel = decodeURIComponent(src); // paths may be percent-encoded (e.g. %20) } catch { // malformed escape — fall back to the raw token } const file = Bun.file(isAbsolute(rel) ? rel : resolve(baseDir, rel)); if (file.size > (isImage ? MAX_IMAGE_BYTES : MAX_EMBED_BYTES)) continue; // 0 for a missing file → falls through to the read try { assets[src] = isImage ? imageDataUri(Buffer.from(await file.arrayBuffer()), ext) : await file.text(); } catch { // missing / unreadable (raced delete, perms) — leave the ref untouched } } return assets; } function classifyExt(ext: string): Kind | null { if (IMAGE_EXT.has(ext)) return "image"; if (HTML_EXT.has(ext)) return "html"; if (MD_EXT.has(ext)) return "markdown"; if (CODE_EXT.has(ext)) return "code"; if (TEXT_EXT.has(ext)) return "text"; return null; } /** Classify by filename, not just the last extension. extname() misses both * dotfiles (extname(".env") is "") and stacked suffixes (".env.sample"), so walk * the dotted segments right to left — a real extension still wins ("app.env.json" * is code). null = unknown, which the caller settles by sniffing the bytes. */ function classify(name: string): Kind | null { const parts = basename(name).toLowerCase().split("."); for (let i = parts.length - 1; i >= 1; i--) { const hit = classifyExt("." + parts[i]); if (hit) return hit; } return null; } /** A file is text if it decodes as UTF-8 and carries no NUL byte — the cheap * heuristic git uses. Lets any unknown extension still preview. */ function looksTextual(head: Buffer): boolean { if (head.includes(0)) return false; try { UTF8_STRICT.decode(head); return true; } catch { return false; } } /** Session-only reveal state owned by the Reloader: which manifest-hidden files * to include anyway. `revealed` keys come from revealKey below. */ export interface RevealState { revealed: Set; revealAll: boolean; } /** The one place the reveal Set key format is defined (NUL cannot appear in a * path, so keys never collide) - reload.ts imports this rather than re-encoding. */ export function revealKey(padDir: string, path: string): string { return padDir + "\u0000" + path; } function isRevealed(reveal: RevealState | undefined, padDir: string, path: string): boolean { return !!reveal && (reveal.revealAll || reveal.revealed.has(revealKey(padDir, path))); } /** Read the given manifest entries (buildView decides which — hidden filtering * happens there), merged with metadata. Unregistered on-disk files are * intentionally not shown. */ async function scanPadFiles(padDir: string, metas: FileEntry[]): Promise { // Files are independent, so read them concurrently; Promise.all keeps the // result in manifest.files[] order — the author's deliberate reading order. const views = metas.map(async (meta): Promise => { const path = meta.path; // Linked entries carry a label in `path`; classify by the real source filename // (its extension) so external files preview by kind, not as "binary/missing". const name = meta.src ?? path; const ext = extname(name).toLowerCase(); let kind: Kind = classify(name) ?? "binary"; let content: string | null = null; let source: string | undefined; let emptyScene = false; // Linked entries read from `src` (outside the pad); the rest from path under the pad dir. const abs = resolveEntryPath(padDir, meta); const file = Bun.file(abs); let created: string | undefined; let updated: string | undefined; if (await file.exists()) { try { const st = await stat(abs); updated = st.mtime.toISOString(); // birthtime is 0/epoch (or trails mtime) on filesystems that don't track // creation — only surface it when it's a real date. if (st.birthtimeMs > 0 && st.birthtimeMs <= st.mtimeMs) { created = st.birthtime.toISOString(); } } catch { // stat raced a delete/rename — dates just stay absent } const size = file.size; const cap = kind === "image" ? MAX_IMAGE_BYTES : MAX_EMBED_BYTES; if (size > cap) { kind = "toolarge"; } else if (ext === EXCALIDRAW_EXT) { const text = await file.text(); source = text; try { const scene = parseScene(text); kind = "image"; // scenes render as images; the catch below overrides if (scene.elements.length === 0) { // Nothing to render — a blank 40×40 SVG stretched full-width is just // invisible whitespace. kind stays "image" (with source) so the live // viewer's ✏️ edit entry point still appears over the placeholder. emptyScene = true; } else { const cacheKey = updated ? `${updated}:${size}` : null; const cached = EXCA_SVG_CACHE.get(abs); if (cacheKey && cached?.key === cacheKey) { content = cached.uri; } else { // 2× so a hand-sized sketch doesn't sit tiny in the card; SVG is // vector so nothing blurs, and oversize clamps to the card width. const svg = await renderExcalidrawSvg(scene, { scale: 2 }); content = imageDataUri(Buffer.from(svg), ".svg"); if (cacheKey) EXCA_SVG_CACHE.set(abs, { key: cacheKey, uri: content }); } } } catch { // unrenderable scene — show the raw JSON source instead of nothing kind = "code"; content = text; } } else if (kind === "image") { content = imageDataUri(Buffer.from(await file.arrayBuffer()), ext); } else if (kind === "binary") { // Unknown extension — let the bytes decide, so a dotfile or an // unfamiliar suffix still previews when it really is text. Only the // sniff window is read until it passes. const head = Buffer.from(await file.slice(0, SNIFF_BYTES).arrayBuffer()); if (looksTextual(head)) { kind = "text"; content = await file.text(); } } else { content = await file.text(); } } else { kind = "binary"; content = null; } // Markdown may reference local images / html diagrams by relative path; embed // them so the page stays self-contained (esp. an export, where the file isn't // on disk). let assets: Record | undefined; if (kind === "markdown" && content) { const embedded = await embedInlineAssets(content, dirname(abs)); if (Object.keys(embedded).length) assets = embedded; } return { path, abs, registered: true, external: !!meta.src, title: meta.title, description: meta.description, tags: meta.tags, type: meta.type ?? DEFAULT_TYPE, group: meta.group, kind, lang: kind === "code" ? (ext === EXCALIDRAW_EXT ? "json" : ext.slice(1)) : undefined, content, source, ...(emptyScene ? { emptyScene: true } : {}), assets, created, updated, comments: meta.comments, // Resolve against the source now (only text content can be located; image // data-URIs / binary / oversized are skipped) so the copy shortcut is sync. commentsExport: meta.comments?.length && content != null && kind !== "image" ? toCommentItems(path, content, meta.comments) : undefined, ...(meta.hidden ? { hidden: true } : {}), }; }); return Promise.all(views); } /** FileView for a file the viewer follows a link to but that no manifest lists — * inside the pad or anywhere else on disk. Read through the same pipeline as a * linked (`src`) entry, so kinds, size caps and inline-asset embedding match. * Null unless `abs` is a regular file. */ export async function buildLinkedView(abs: string): Promise { try { if (!(await stat(abs)).isFile()) return null; } catch { return null; } const name = basename(abs); const [v] = await scanPadFiles(dirname(abs), [{ path: name, src: abs, title: name }]); return { ...v!, registered: false }; } /** Files that the pad's markdown links to (`[text](href)`, local, not an image) * but that no manifest lists — in the pad dir or anywhere else on disk. One * level deep: a linked doc's own links are not followed, or an export could pull * in an unbounded slice of the disk. Registered targets are skipped (the click * handler finds those in `files` first). */ async function collectLinkedViews( files: FileView[], ): Promise> { const known = new Set(files.map((f) => toPosix(f.abs))); const linkKeys: Record = {}; for (const f of files) { if (f.kind !== "markdown" || !f.content) continue; for (const m of f.content.matchAll(/(^|[^!])\[[^\]]*\]\(([^)]+)\)/g)) { const href = imageSrcToken(m[2]!).split("#")[0]!; const abs = hrefToAbs(f.abs, href); if (!abs) continue; const key = toPosix(abs); if (!known.has(key)) linkKeys[f.path + "::" + href] = key; } } const linked: Record = {}; await Promise.all( [...new Set(Object.values(linkKeys))].map(async (key) => { const v = await buildLinkedView(key); if (v) linked[key] = v; }), ); for (const [k, key] of Object.entries(linkKeys)) if (!linked[key]) delete linkKeys[k]; return Object.keys(linked).length ? { linked, linkKeys } : {}; } /** `linked`: also embed unregistered files the pad's markdown links to (see * collectLinkedViews). Exports need it — no host is there to read them on click; * the live viewer asks the host instead (resolvePeek) and keeps the page small. */ export async function buildView( pads: Pad[], reveal?: RevealState, opts: { linked?: boolean } = {}, ): Promise { return Promise.all( pads.map(async (p) => { // One partition decides visibility: `hidden` entries stay registered in the // manifest and never reach the viewer, unless a session reveal (RevealState) // asked for them — then they're scanned like any other file, just flagged. const shown: FileEntry[] = []; const hiddenPaths: string[] = []; for (const m of p.manifest.files) { if (!m.hidden || isRevealed(reveal, p.dir, m.path)) shown.push(m); else hiddenPaths.push(m.path); } const files = await scanPadFiles(p.dir, shown); return { name: p.manifest.name, id: p.manifest.id, dir: p.dir, files, ...(p.manifest.layout ? { layout: p.manifest.layout } : {}), ...(hiddenPaths.length ? { hiddenPaths } : {}), ...(opts.linked ? await collectLinkedViews(files) : {}), }; }), ); } const MERMAID_RE = /```[ \t]*mermaid\b/; // TeX math: $$display$$ (one line or multi-line) OR inline $…$. The inline arm // requires non-space adjacency to the delimiters and a non-word/non-$ char // outside them, so prose currency ("$5 and $10") doesn't trip it. Kept in sync // with the client extractor in mdInline/renderMarkdown; a drift only over- or // under-loads the bundle (the client still degrades to raw source). const MATH_RE = /\$\$[\s\S]+?\$\$|(?'; /** The embedded data island, escaped for inline \n`; vendor += `\n`; } if (needs.hljs) { vendorCss += `\n`; vendorCss += `\n`; } if (needs.math) vendorCss += `\n`; } else { // CDN tags are blocking (no defer) so window.hljs/window.mermaid are ready // before the client script runs. SRI + crossorigin guard integrity; on load // failure the client degrades gracefully. const cdnTag = (c: { url: string; sri: string }) => `\n`; if (needs.hljs) vendor += cdnTag(HLJS_CDN); if (needs.mermaid) vendor += cdnTag(MERMAID_CDN); if (needs.math) vendor += cdnTag(KATEX_CDN); // hljs theme stylesheets, placed BEFORE our
scratch.
${saveBtn}
${HELP_MODAL_HTML} ${SETTINGS_MODAL_HTML} ${GALLERY_MODAL_HTML}
${DATA_ISLAND_OPEN}${data} ${vendor} `; } function escapeHtml(s: string): string { return s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!, ); } // Theme registry slimmed for the page: id/label + the 4 swatch-dot colors per // mode. Cards (settings strip AND gallery) are rendered client-side from this // island — the starred strip changes as stars toggle, so static server markup // can't carry it. Static registry → build once at module load. const THEMES_JSON = JSON.stringify( COLOR_THEMES.map((t) => ({ id: t.id, label: t.label, dark: [t.dark.field, t.dark.surface, t.dark.ember, t.dark.ink1], light: [t.light.field, t.light.surface, t.light.ember, t.light.ink1], })), ).replace(/ `; } /** One help-modal shortcut row: keycaps + label. `combo` joins the keys with a * plus (a chord) instead of listing them as alternatives; `live` marks rows * that only work against a live host (hidden in file:// exports via .sc-live); * `native` marks rows bound only inside the WebView2 host — in a browser those * keys ARE the browser's own accelerators and we leave them alone (.sc-native). */ type ShortcutRow = { keys: string[]; label: string; combo?: boolean; live?: boolean; native?: boolean }; type ShortcutGroup = { title: string; rows: ShortcutRow[] }; // The help modal's content, in reading order. Each entry pairs a left and a // right group onto the same grid rows, so their headings sit level and the // shorter group of a pair just leaves empty rows. const SHORTCUT_PAIRS: [ShortcutGroup, ShortcutGroup][] = [ [ { title: "Navigate", rows: [ { keys: ["↑", "↓"], label: "Next / previous file" }, { keys: ["←", "→"], label: "Collapse / expand group" }, { keys: ["Ctrl", "Tab"], combo: true, native: true, label: "Next file, wrapping (Shift: previous)" }, ], }, { title: "Zoom", rows: [ { keys: ["Ctrl", "+"], combo: true, label: "Zoom in" }, { keys: ["Ctrl", "−"], combo: true, label: "Zoom out" }, { keys: ["Ctrl", "0"], combo: true, label: "Reset zoom" }, ], }, ], [ { title: "Scroll", rows: [ { keys: ["j", "k"], label: "Down / up" }, { keys: ["d", "u"], label: "Half page down / up" }, { keys: ["g", "G"], label: "Top / bottom" }, ], }, { title: "Copy", rows: [ { keys: ["Ctrl", "Alt", "C"], combo: true, label: "This page's comments (JSON)" }, { keys: ["Ctrl", "Shift", "Alt", "C"], combo: true, label: "All comments (JSON)" }, { keys: ["Shift", "C"], combo: true, live: true, label: "Active file path" }, { keys: ["Ctrl", "Alt", "P"], combo: true, live: true, label: "Manifest path" }, ], }, ], [ { title: "View", rows: [ { keys: ["v"], label: "Toggle raw / rendered markdown" }, { keys: ["f"], label: "Expand embed under cursor" }, { keys: ["+", "−", "0"], label: "Scale that embed in / out / fit" }, { keys: ["o"], label: "Toggle table of contents" }, { keys: ["c"], label: "Toggle comments" }, { keys: ["t"], label: "Toggle theme" }, { keys: ["["], label: "Toggle sidebar" }, { keys: ["]"], label: "Toggle top bar" }, ], }, { title: "General", rows: [ { keys: ["Ctrl", "S"], combo: true, label: "Save / export a copy" }, { keys: ["Ctrl", "Alt", "H"], combo: true, live: true, label: "Hide file / unhide a revealed one" }, { keys: ["h"], live: true, label: "Reveal / re-hide hidden files" }, { keys: ["r"], live: true, label: "Reload from disk" }, { keys: ["s"], label: "Settings" }, { keys: ["?"], label: "Show this help" }, { keys: ["q"], live: true, label: "Quit (close window)" }, { keys: ["Esc"], label: "Close dialogs" }, ], }, ], ]; // Interleaves each pair's rows left-then-right so CSS grid auto-placement lands // them on shared row tracks (see .shortcuts in theme.ts). function helpModalHtml(): string { const kbd = (k: string) => `${k}`; const keysHtml = (r: ShortcutRow) => r.keys.map(kbd).join(r.combo ? '+' : ""); const cells: string[] = []; SHORTCUT_PAIRS.forEach(([left, right], pair) => { const first = pair === 0 ? " sc-first" : ""; cells.push( `
${left.title}
${right.title}
`, ); for (let i = 0; i < Math.max(left.rows.length, right.rows.length); i++) { for (const [group, side] of [ [left, "sc-l"], [right, "sc-r"], ] as const) { const row = group.rows[i]; if (!row) continue; const cls = side + (row.live ? " sc-live" : "") + (row.native ? " sc-native" : ""); cells.push(`
${keysHtml(row)}
${row.label}
`); } } }); return ``; } // Both depend only on static data, so build them once at module load instead // of on every render. const HELP_MODAL_HTML = helpModalHtml(); const SETTINGS_MODAL_HTML = settingsModalHtml(); // Theme gallery: every theme, each card with a star toggle (max 3 starred — // those are the cards the settings panel shows). Grid filled client-side from // the #themes island; scrim sits above the settings scrim so settings stays open. const GALLERY_MODAL_HTML = ``; // Offline (--offline) vendor bootstrap. The JS libs ship gzip+base64 in the // #vendor-gz island (mermaid 3.3MB→~1.2MB on disk); this decompresses each with // DecompressionStream and injects it as a Blob-URL