// 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 { FONT_PRESETS, type FontSlot, MEASURE_DEFAULT, MEASURE_MAX, MEASURE_MIN, READING_FONTS, READING_SIZES, type ScratchConfig } from "../config.ts";
import { type Pad, type SetRole, exportFileSlug, pathKey, 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 { UI_FONT_CSS_CDN, subsetsFor, uiFontCss } from "./fonts.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;
/** The pad's role when the view is a pad set (see resolvePadSet). */
set?: SetRole;
/** "::" → the registered file
* another pad of the view holds at that link's target. */
xrefs?: Record;
}
/** Links from each pad's markdown to files registered in OTHER pads of the view,
* resolved here with node:path so the client only looks them up. */
function collectCrossPadLinks(view: PadView[]): void {
if (view.length < 2) return;
const owner = new Map();
for (const p of view) for (const f of p.files) owner.set(pathKey(f.abs), { dir: p.dir, path: f.path });
for (const p of view) {
const xrefs: Record = {};
for (const { key, abs } of markdownLinks(p.files)) {
const hit = owner.get(pathKey(abs));
if (hit && hit.dir !== p.dir) xrefs[key] = hit;
}
if (Object.keys(xrefs).length) p.xrefs = xrefs;
}
}
/** Local file links (`[text](href)`, not images) in the files' markdown: `key` is
* "::", the lookup key the client uses. */
function* markdownLinks(files: FileView[]): Generator<{ key: string; abs: string }> {
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) yield { key: f.path + "::" + href, abs };
}
}
}
/** 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. Targets in `known` (files registered in any
* pad of the view) are skipped: the click handler finds those first. */
async function collectLinkedViews(
files: FileView[],
known: Set,
): Promise> {
const linkKeys: Record = {};
for (const { key, abs } of markdownLinks(files)) if (!known.has(pathKey(abs))) linkKeys[key] = toPosix(abs);
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 {
const view: PadView[] = await 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 } : {}),
...(p.set ? { set: p.set } : {}),
};
}),
);
collectCrossPadLinks(view);
if (opts.linked) {
const known = new Set(view.flatMap((p) => p.files.map((f) => pathKey(f.abs))));
await Promise.all(view.map(async (p) => Object.assign(p, await collectLinkedViews(p.files, known))));
}
return view;
}
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`;
// UI fonts as data: URIs, limited to the subsets the pad's text uses (latin is
// always in). Comments a reader adds later in another script fall back to the
// system font in the stack. Only the exporter's reading font is embedded; a
// reader who switches it gets the system sans/serif from the stack. The system
// preset embeds none, and a reader who switches to bundled gets the fallbacks.
if (fontPreset === "bundled") {
const fontCss = uiFontCss({ b64: b.UI_FONT_B64, only: subsetsFor(data), reading: readingFont });
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 \n`;
}
// In-place Excalidraw editor CDN roots — injected ONLY into the live viewer.
// Exports carry zero excalidraw references (code AND urls; the offline test
// asserts no CDN hostname appears), and the client gates the edit button on
// this island's presence, so its absence is what disables editing.
if (!opts.exportMode) {
vendor += `\n`;
}
// The Save-a-copy button ships in BOTH modes (Ctrl+S in the client script
// mirrors it). In an export, saving is what persists comments (no write-back
// channel); in a live viewer it exports a standalone copy of the page — the
// saved file gets data-export injected so it opens as a real export. The
// saveDot (unsaved-comments hint) only ever fires in export mode.
const saveTitle = opts.exportMode
? "Save a copy of this page — comments live in the saved file (Shift: this file only)"
: "Export a copy of this page to a file (Ctrl+S; Shift: this file only)";
const saveBtn = `
`;
return `
scratch · ${escapeHtml(titleName)}
${vendorCss}
${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(/
Settings
Mode
Theme
Background
Width
Fonts
Reading font
Text size
Auto reload
Contents (O)
Zoom
`;
}
/** 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)" },
{ keys: ["Shift", "P"], combo: true, label: "Jump to file" },
],
},
{
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 or image 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", "Shift", "S"], combo: true, label: "Save this file only (no sidebar)" },
{ 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 `
Keyboard shortcuts
${cells.join("\n ")}
`;
}
// 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 = `
Themes
`;
// 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