(null);
const id = useId();
const run = (action: () => void) => () => {
@@ -1192,7 +1266,7 @@ function MoreMenu({ open, setOpen, onCommands, onShortcuts, joinLines, onJoinLin
aria-controls={open ? id : undefined}
aria-label="More"
onClick={() => setOpen(!open)}
- title="Commands, shortcuts, line breaks and theme"
+ title="Commands, shortcuts and theme"
>
@@ -1209,17 +1283,15 @@ function MoreMenu({ open, setOpen, onCommands, onShortcuts, joinLines, onJoinLin
Keyboard shortcuts
?
-
+
+ Export
+ {(["pdf", "docx", "bundle"] as const).map((format) => (
+
+ ))}
+
{theme && onTheme && (
Theme
@@ -1331,6 +1403,8 @@ const SHORTCUTS: readonly [string, string][] = [
[`${MOD_LABEL} Shift K`, "Make the selection a link"],
[`${MOD_LABEL} ${ALT_LABEL} M`, "Comment on the selection"],
[`${MOD_LABEL} ${ALT_LABEL} S`, "Suggest an edit to the selection"],
+ [`${MOD_LABEL} ${ALT_LABEL} F`, "Add a footnote"],
+ ["/", "Insert a footnote, figure, table, equation, cross-reference or citation"],
[`${MOD_LABEL} S`, "Save all records now"],
[`${MOD_LABEL} Shift S`, "Export"],
["F8 / Shift F8", "Next / previous problem"],
diff --git a/apps/writer/src/ui/icons.tsx b/apps/writer/src/ui/icons.tsx
index 387c028..5583612 100644
--- a/apps/writer/src/ui/icons.tsx
+++ b/apps/writer/src/ui/icons.tsx
@@ -4,6 +4,7 @@ import type { Icon as PhosphorIcon } from "@phosphor-icons/react";
import { ArrowSquareOutIcon as PhArrowSquareOut } from "@phosphor-icons/react/ArrowSquareOut";
import { ArticleIcon as PhArticle } from "@phosphor-icons/react/Article";
import { CaretDownIcon as PhCaretDown } from "@phosphor-icons/react/CaretDown";
+import { CaretUpIcon as PhCaretUp } from "@phosphor-icons/react/CaretUp";
import { CaretLeftIcon as PhCaretLeft } from "@phosphor-icons/react/CaretLeft";
import { CaretRightIcon as PhCaretRight } from "@phosphor-icons/react/CaretRight";
import { CheckIcon as PhCheck } from "@phosphor-icons/react/Check";
@@ -21,6 +22,7 @@ import { MagnifyingGlassIcon as PhMagnifyingGlass } from "@phosphor-icons/react/
import { MinusIcon as PhMinus } from "@phosphor-icons/react/Minus";
import { PencilSimpleIcon as PhPencilSimple } from "@phosphor-icons/react/PencilSimple";
import { PlusIcon as PhPlus } from "@phosphor-icons/react/Plus";
+import { PlusSquareIcon as PhPlusSquare } from "@phosphor-icons/react/PlusSquare";
import { SidebarSimpleIcon as PhSidebarSimple } from "@phosphor-icons/react/SidebarSimple";
import { TextBIcon as PhTextB } from "@phosphor-icons/react/TextB";
import { TextItalicIcon as PhTextItalic } from "@phosphor-icons/react/TextItalic";
@@ -34,6 +36,7 @@ function icon(Glyph: PhosphorIcon) {
}
export const ChevronDown = icon(PhCaretDown);
+export const ChevronUp = icon(PhCaretUp);
export const ChevronLeft = icon(PhCaretLeft);
export const ChevronRight = icon(PhCaretRight);
export const SidebarIcon = icon(PhSidebarSimple);
@@ -46,6 +49,7 @@ export const AlertIcon = icon(PhWarningCircle);
export const CheckIcon = icon(PhCheck);
export const CloseIcon = icon(PhX);
export const PlusIcon = icon(PhPlus);
+export const InsertIcon = icon(PhPlusSquare);
export const MinusIcon = icon(PhMinus);
export const KeyboardIcon = icon(PhKeyboard);
export const MoreIcon = icon(PhDotsThree);
diff --git a/apps/writer/src/ui/layout.ts b/apps/writer/src/ui/layout.ts
index bf03bdd..69f07aa 100644
--- a/apps/writer/src/ui/layout.ts
+++ b/apps/writer/src/ui/layout.ts
@@ -14,10 +14,12 @@ export interface Layout {
readonly zoom: number | "fit";
/** Hard-wrapped paragraphs flow in the editor (their soft line breaks drawn as spaces). */
readonly joinLines: boolean;
+ /** The editor reads like the manuscript (citations, figures and tables drawn) rather than as Markdown source. */
+ readonly livePreview: boolean;
}
const KEY = "mdbase-writer:layout";
-export const DEFAULT_LAYOUT: Layout = { sidebar: true, tab: "outline", sidebarWidth: 272, view: "both", split: 0.5, zoom: "fit", joinLines: true };
+export const DEFAULT_LAYOUT: Layout = { sidebar: true, tab: "outline", sidebarWidth: 272, view: "both", split: 0.5, zoom: "fit", joinLines: true, livePreview: true };
export const ZOOM_STEPS = [0.5, 0.67, 0.75, 0.9, 1, 1.1, 1.25, 1.5, 1.75, 2] as const;
export const SIDEBAR_MIN = 200;
@@ -35,6 +37,7 @@ export function loadLayout(): Layout {
view: raw.view === "write" || raw.view === "preview" || raw.view === "both" ? raw.view : DEFAULT_LAYOUT.view,
split: typeof raw.split === "number" ? clampSplit(raw.split) : DEFAULT_LAYOUT.split,
joinLines: typeof raw.joinLines === "boolean" ? raw.joinLines : DEFAULT_LAYOUT.joinLines,
+ livePreview: typeof raw.livePreview === "boolean" ? raw.livePreview : DEFAULT_LAYOUT.livePreview,
zoom: raw.zoom === "fit" || (typeof raw.zoom === "number" && raw.zoom >= 0.25 && raw.zoom <= 4) ? raw.zoom : DEFAULT_LAYOUT.zoom,
};
} catch {
diff --git a/apps/writer/src/ui/problems.test.ts b/apps/writer/src/ui/problems.test.ts
new file mode 100644
index 0000000..a503aed
--- /dev/null
+++ b/apps/writer/src/ui/problems.test.ts
@@ -0,0 +1,19 @@
+import { describe, expect, it } from "vitest";
+
+import type { WriterDiagnostic } from "../compile/protocol.js";
+import { problemRows } from "./ProblemsStrip.js";
+
+const d = (record: string, from: number, extra: Partial = {}): WriterDiagnostic => ({ record, from, to: from, severity: "error", message: "m", origin: "writer", ...extra });
+
+describe("problem rows", () => {
+ const bodies: Record = { "main.md": "# Title\n\nOne\nTwo\n", "ch/one.md": "# One\n\nText" };
+ const body = (p: string) => bodies[p];
+ const title = (p: string) => (p === "main.md" ? "Main" : "Chapter one");
+ it("orders settings first, then records in reading order, with their lines", () => {
+ const rows = problemRows([d("ch/one.md", 8), d("main.md", 13), d("main.md", 0), d("main.md", 0, { field: "title" })], ["main.md", "ch/one.md"], body, title);
+ expect(rows.map((r) => r.where)).toEqual(["Settings · title", "Main · line 1", "Main · line 4", "Chapter one · line 3"]);
+ });
+ it("names only the line in a single-record manuscript", () => {
+ expect(problemRows([d("main.md", 9)], ["main.md"], body, title)[0]?.where).toBe("Line 3");
+ });
+});
diff --git a/apps/writer/src/ui/recent.test.ts b/apps/writer/src/ui/recent.test.ts
new file mode 100644
index 0000000..a05819c
--- /dev/null
+++ b/apps/writer/src/ui/recent.test.ts
@@ -0,0 +1,31 @@
+import { describe, expect, it } from "vitest";
+
+import { recentManuscripts, recordOpened, setWordGoal, wordGoal } from "./recent.js";
+
+function memory(): Storage {
+ const map = new Map();
+ return { getItem: (k) => map.get(k) ?? null, setItem: (k, v) => void map.set(k, v), removeItem: (k) => void map.delete(k), clear: () => map.clear(), key: (i) => [...map.keys()][i] ?? null, get length() { return map.size; } };
+}
+
+describe("what the home screen remembers", () => {
+ it("lists manuscripts most recently opened first, without repeats", () => {
+ const store = memory();
+ recordOpened("demo", "a.md", store, new Date("2026-10-01T00:00:00Z"));
+ recordOpened("demo", "b.md", store, new Date("2026-10-02T00:00:00Z"));
+ recordOpened("demo", "a.md", store, new Date("2026-10-03T00:00:00Z"));
+ expect(recentManuscripts("demo", store).map((e) => e.path)).toEqual(["a.md", "b.md"]);
+ expect(recentManuscripts("other", store)).toEqual([]);
+ });
+ it("keeps a word goal per manuscript and removes it when cleared", () => {
+ const store = memory();
+ setWordGoal("demo", "a.md", 8000, store);
+ expect(wordGoal("demo", "a.md", store)).toBe(8000);
+ setWordGoal("demo", "a.md", null, store);
+ expect(wordGoal("demo", "a.md", store)).toBeNull();
+ });
+ it("ignores unreadable stored values", () => {
+ const store = memory();
+ store.setItem("mdbase-writer:recent:demo", "{not json");
+ expect(recentManuscripts("demo", store)).toEqual([]);
+ });
+});
diff --git a/apps/writer/src/ui/recent.ts b/apps/writer/src/ui/recent.ts
new file mode 100644
index 0000000..994ebbb
--- /dev/null
+++ b/apps/writer/src/ui/recent.ts
@@ -0,0 +1,61 @@
+// What this browser remembers about a collection's manuscripts for the home
+// screen: which were opened last, and a writing goal for each. Both are
+// conveniences kept in local storage, isolated by collection; nothing here
+// is written to the collection.
+
+export interface RecentEntry {
+ readonly path: string;
+ /** When it was last opened (ISO 8601). */
+ readonly at: string;
+}
+
+const RECENT_KEPT = 8;
+
+function storage(): Storage | undefined {
+ try { return typeof localStorage === "undefined" ? undefined : localStorage; } catch { return undefined; }
+}
+const recentKey = (namespace: string) => `mdbase-writer:recent:${encodeURIComponent(namespace)}`;
+const goalKey = (namespace: string, path: string) => `mdbase-writer:goal:${encodeURIComponent(namespace)}:${encodeURIComponent(path)}`;
+
+export function recentManuscripts(namespace: string, store: Pick | undefined = storage()): RecentEntry[] {
+ try {
+ const value: unknown = JSON.parse(store?.getItem(recentKey(namespace)) ?? "[]");
+ if (!Array.isArray(value)) return [];
+ return value.filter((e): e is RecentEntry => Boolean(e) && typeof e === "object" && typeof (e as RecentEntry).path === "string" && typeof (e as RecentEntry).at === "string");
+ } catch {
+ return [];
+ }
+}
+
+/** Notes that `path` was opened now; it moves to the front of the list. */
+export function recordOpened(namespace: string, path: string, store: Pick | undefined = storage(), now = new Date()): void {
+ try {
+ const rest = recentManuscripts(namespace, store).filter((e) => e.path !== path);
+ store?.setItem(recentKey(namespace), JSON.stringify([{ path, at: now.toISOString() }, ...rest].slice(0, RECENT_KEPT)));
+ } catch {
+ // Storage may be unavailable; the home screen then has no "continue" card.
+ }
+}
+
+/** A manuscript's word goal in this browser, or null. */
+export function wordGoal(namespace: string, path: string, store: Pick | undefined = storage()): number | null {
+ try {
+ const n = Number(store?.getItem(goalKey(namespace, path)));
+ return Number.isFinite(n) && n > 0 ? Math.round(n) : null;
+ } catch {
+ return null;
+ }
+}
+
+export function setWordGoal(namespace: string, path: string, goal: number | null, store: Pick | undefined = storage()): void {
+ try {
+ if (goal && goal > 0) store?.setItem(goalKey(namespace, path), String(Math.round(goal)));
+ else store?.removeItem(goalKey(namespace, path));
+ } catch {
+ // Storage may be unavailable; the goal lasts for this page.
+ }
+}
+
+/** The namespace a backend's local state is kept under (shared with local drafts). */
+export const localNamespace = (backend: { readonly kind: string; readonly collectionName: string; readonly draftNamespace?: string | undefined }) =>
+ backend.draftNamespace ?? `${backend.kind}:${backend.collectionName}`;
diff --git a/apps/writer/src/workspace/drafts.test.ts b/apps/writer/src/workspace/drafts.test.ts
index 526750a..b978620 100644
--- a/apps/writer/src/workspace/drafts.test.ts
+++ b/apps/writer/src/workspace/drafts.test.ts
@@ -1,35 +1,19 @@
import { describe, expect, it } from "vitest";
-import { DraftStore, draftPatch, type LocalDraft } from "./drafts.js";
-const memory = () => {
- const values = new Map();
- return { getItem: (key: string) => values.get(key) ?? null, setItem: (key: string, value: string) => { values.set(key, value); }, removeItem: (key: string) => { values.delete(key); } };
-};
-const draft: LocalDraft = { version: 1, body: "local", baseBody: "base", revision: "1", frontmatter: { title: "Local", unrelated: "old" }, baseFrontmatter: { title: "Base", unrelated: "old" }, updated: "2026-01-01" };
+import { DraftStore, type LocalDraft } from "./drafts.js";
-describe("local draft recovery", () => {
- it("persists across store instances and isolates collections and paths", () => {
- const storage = memory();
- expect(new DraftStore("one", storage).write("main.md", draft)).toBe(true);
- expect(new DraftStore("one", storage).read("main.md")).toEqual(draft);
- expect(new DraftStore("two", storage).read("main.md")).toBeNull();
- expect(new DraftStore("one", storage).read("other.md")).toBeNull();
- new DraftStore("one", storage).remove("main.md");
- expect(new DraftStore("one", storage).read("main.md")).toBeNull();
- });
- it("only restores settings changed locally, preserving other remote changes", () => {
- expect(draftPatch(draft)).toEqual({ title: "Local" });
- });
- it("reports blocked storage without throwing away the in-memory draft", () => {
- const storage = { getItem: () => { throw new Error("blocked"); }, setItem: () => { throw new Error("quota"); }, removeItem: () => { throw new Error("blocked"); } };
- const store = new DraftStore("one", storage);
- expect(store.write("main.md", draft)).toBe(false);
- expect(store.read("main.md")).toBeNull();
- expect(() => store.remove("main.md")).not.toThrow();
- });
- it("rejects corrupt or unversioned drafts", () => {
- const storage = memory();
- storage.setItem("mdbase-writer:draft:one:main.md", '{"body":"bad"}');
- expect(new DraftStore("one", storage).read("main.md")).toBeNull();
+function memory(): Storage {
+ const map = new Map();
+ return { getItem: (k) => map.get(k) ?? null, setItem: (k, v) => void map.set(k, v), removeItem: (k) => void map.delete(k), clear: () => map.clear(), key: (i) => [...map.keys()][i] ?? null, get length() { return map.size; } };
+}
+const draft = (updated: string): LocalDraft => ({ version: 1, body: "b", frontmatter: {}, baseBody: "", baseFrontmatter: {}, revision: "r", updated });
+
+describe("listing local drafts", () => {
+ it("lists a namespace's drafts newest first, and no other namespace's", () => {
+ const store = memory();
+ new DraftStore("demo", store).write("a.md", draft("2026-10-01T00:00:00Z"));
+ new DraftStore("demo", store).write("ch/b.md", draft("2026-10-02T00:00:00Z"));
+ new DraftStore("other", store).write("c.md", draft("2026-10-03T00:00:00Z"));
+ expect(new DraftStore("demo", store).list().map((d) => d.path)).toEqual(["ch/b.md", "a.md"]);
});
});
diff --git a/apps/writer/src/workspace/drafts.ts b/apps/writer/src/workspace/drafts.ts
index 008dbd3..3ae9d58 100644
--- a/apps/writer/src/workspace/drafts.ts
+++ b/apps/writer/src/workspace/drafts.ts
@@ -36,6 +36,22 @@ export class DraftStore {
remove(path: string): void {
try { this.storage?.removeItem(this.key(path)); } catch { /* Keep recovery available if storage is locked. */ }
}
+ /** Every record with a draft in this namespace, newest first (for the home screen). */
+ list(): { path: string; updated: string }[] {
+ const out: { path: string; updated: string }[] = [];
+ try {
+ const store = this.storage as (Storage | undefined);
+ const prefix = `mdbase-writer:draft:${encodeURIComponent(this.namespace)}:`;
+ const keys = store && typeof store.length === "number" ? Array.from({ length: store.length }, (_, i) => store.key(i)) : [];
+ for (const key of keys) {
+ if (!key?.startsWith(prefix)) continue;
+ const path = decodeURIComponent(key.slice(prefix.length));
+ const draft = this.read(path);
+ if (draft) out.push({ path, updated: draft.updated });
+ }
+ } catch { /* Nothing listed when storage is locked. */ }
+ return out.sort((a, b) => b.updated.localeCompare(a.updated));
+ }
}
const isObject = (value: unknown): value is JsonObject => Boolean(value && typeof value === "object" && !Array.isArray(value));
diff --git a/apps/writer/src/workspace/workspace.ts b/apps/writer/src/workspace/workspace.ts
index 72e5105..e11d207 100644
--- a/apps/writer/src/workspace/workspace.ts
+++ b/apps/writer/src/workspace/workspace.ts
@@ -10,7 +10,7 @@ import { ManuscriptAssembler, translateRecord, type WriterRecord, type AssemblyI
import { applySuggestion, bodyHash, linkPath, targetFor, type CommentRecord, type CommentThread } from "@mdbase-writer/core/comments";
import { BUNDLE_README, materialize } from "@mdbase-writer/core/materialize";
import { isAnnotation } from "@mdbase-writer/core/annotations";
-import { PathIndex, pathIndex, resolveLinkTarget } from "@mdbase-writer/core/records";
+import { IMAGE_EXTENSION, PathIndex, pathIndex, resolveLinkTarget } from "@mdbase-writer/core/records";
import { LOCALES, STYLES } from "@mdbase-writer/core/styles";
import { NO_PEOPLE, type CommentChange, type People } from "../backend/comments.js";
@@ -89,6 +89,8 @@ export interface WorkspaceSnapshot {
/** Records the collection lists as Reader annotations (an embedded one is a quotation). */
readonly annotationPaths: ReadonlySet;
readonly annotationVersion: number;
+ /** Increases as images and other files load, so views drawing them redraw. */
+ readonly assetVersion: number;
readonly compiling: boolean;
readonly pendingSettings: boolean;
/** Every comment in the collection; the UI keeps those on this manuscript's records. */
@@ -118,6 +120,8 @@ export class ManuscriptWorkspace {
private readonly drafts: DraftStore;
private readonly stagedSettings = new Map();
private readonly assetBytes = new Map();
+ /** Object URLs of images shown in the editor, revoked when the manuscript closes. */
+ private readonly imageUrls = new Map();
private readonly assetJobs = new Map>();
private readonly assetAttempts = new Map();
private readonly retryTimers = new Set>();
@@ -151,7 +155,7 @@ export class ManuscriptWorkspace {
readonly main: string,
) {
this.drafts = new DraftStore(backend.draftNamespace ?? `${backend.kind}:${backend.collectionName}`);
- this.current = { main, phase: "loading", indexLoad: LOADING, libraryLoad: LOADING, annotationsLoad: LOADING, commentsLoad: LOADING, records: new Map(), library: [], recordPaths: [], filePaths: [], recordIndex: new PathIndex([]), fileIndex: new PathIndex([]), annotationPaths: new Set(), annotationVersion: 0, compiling: true, pendingSettings: false, comments: [], people: NO_PEOPLE, recoveredDrafts: new Map(), assetProblems: new Map(), recordProblems: new Map() };
+ this.current = { main, phase: "loading", indexLoad: LOADING, libraryLoad: LOADING, annotationsLoad: LOADING, commentsLoad: LOADING, records: new Map(), library: [], recordPaths: [], filePaths: [], recordIndex: new PathIndex([]), fileIndex: new PathIndex([]), annotationPaths: new Set(), annotationVersion: 0, assetVersion: 0, compiling: true, pendingSettings: false, comments: [], people: NO_PEOPLE, recoveredDrafts: new Map(), assetProblems: new Map(), recordProblems: new Map() };
this.followCompiler();
this.cleanups.push(backend.onExternalChange((paths) => this.onExternalChange(paths)));
if (backend.onCollectionChange) this.cleanups.push(backend.onCollectionChange((delta) => this.onCollectionChange(delta)));
@@ -585,10 +589,12 @@ export class ManuscriptWorkspace {
if (this.disposed) return;
this.assetAttempts.delete(path);
this.assetBytes.set(path, read.value);
+ const shown = this.imageUrls.get(path);
+ if (shown) { URL.revokeObjectURL(shown); this.imageUrls.delete(path); }
if (/\.(csl|typ)$/i.test(path)) this.texts.set(path, new TextDecoder().decode(read.value));
const assetProblems = new Map(this.current.assetProblems);
assetProblems.delete(path);
- this.update({ assetProblems });
+ this.update({ assetProblems, assetVersion: this.current.assetVersion + 1 });
this.sendAssets([[path, read.value]]);
} catch (error) {
if (this.disposed) return;
@@ -742,6 +748,53 @@ export class ManuscriptWorkspace {
return ok(path);
}
+ /** Whether the collection accepts files, so a figure can be stored from the editor. */
+ get canStoreFiles(): boolean {
+ return typeof this.backend.writeFile === "function";
+ }
+
+ /**
+ * Stores an image in the collection, next to the manuscript's other figures
+ * (its `figures` folder unless one is already used), and resolves to its
+ * path. The index learns the file at once, so the figure typesets without a
+ * fresh listing.
+ */
+ async addFigure(file: { name: string; bytes: Uint8Array; type?: string }): Promise> {
+ if (!this.backend.writeFile) return fail("This collection does not accept files from Writer.");
+ const extension = /\.[a-z0-9]{1,5}$/i.exec(file.name)?.[0]?.toLowerCase() ?? "";
+ if (!IMAGE_EXTENSION.test(extension)) return fail("Figures must be PNG, JPEG, GIF, SVG or WebP images.");
+ const stem = manuscriptSlug(file.name.slice(0, file.name.length - extension.length)) || "figure";
+ const used = [...this.current.fileIndex].find((p) => IMAGE_EXTENSION.test(p) && p.includes("/"));
+ const folder = used ? used.slice(0, used.lastIndexOf("/")) : "figures";
+ const stored = await this.backend.writeFile(`${folder}/${stem}${extension}`, file.bytes, file.type);
+ if (!stored.ok) return stored;
+ const path = stored.value;
+ this.assetBytes.set(path, file.bytes);
+ if (!this.current.fileIndex.has(path)) {
+ const filePaths = [...this.current.filePaths, path];
+ this.update({ filePaths });
+ this.compile.send({ type: "collection", recordPaths: this.current.recordPaths, filePaths });
+ }
+ return ok(path);
+ }
+
+ /** An object URL for an image the manuscript uses, once its bytes are loaded for the preview; null until then. */
+ imageUrl(target: string, from: string): string | null {
+ const path = resolveLinkTarget(target, from, this.current.fileIndex, "");
+ if (!path) return null;
+ const known = this.imageUrls.get(path);
+ if (known) return known;
+ const bytes = this.assetBytes.get(path);
+ if (!bytes) {
+ // Not needed by the compiler yet (a figure just written); load it for the card.
+ if (!this.requestedAssets.has(path)) void this.loadAssets([path]);
+ return null;
+ }
+ const url = URL.createObjectURL(new Blob([bytes as BlobPart], { type: imageType(path) }));
+ this.imageUrls.set(path, url);
+ return url;
+ }
+
/** Focused setting drafts are backed up immediately, without typesetting half-parsed fields. */
stageFrontmatter(path: string, patch: JsonObject): void {
if (this.current.recoveredDrafts.has(path)) return;
@@ -984,6 +1037,8 @@ export class ManuscriptWorkspace {
if (this.disposed) return;
this.commitSettings();
this.disposed = true;
+ for (const url of this.imageUrls.values()) URL.revokeObjectURL(url);
+ this.imageUrls.clear();
clearTimeout(this.refreshTimer);
clearTimeout(this.commentsTimer);
for (const timer of this.retryTimers) clearTimeout(timer);
@@ -998,3 +1053,8 @@ export class ManuscriptWorkspace {
this.leases.clear();
}
}
+
+const IMAGE_TYPES: Record = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", svg: "image/svg+xml", webp: "image/webp" };
+function imageType(path: string): string {
+ return IMAGE_TYPES[path.slice(path.lastIndexOf(".") + 1).toLowerCase()] ?? "application/octet-stream";
+}