// 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 `` 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 `` 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 = {};
//  — 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