chore: add formatting

This commit is contained in:
2026-06-01 21:34:05 +00:00
parent f64d5a73d2
commit 6cc028305a
21 changed files with 542 additions and 177 deletions
+1 -3
View File
@@ -6,9 +6,7 @@ import { usePublicDrawing } from "./hooks/usePublicDrawing";
import { useDrawingSession } from "./hooks/useDrawingSession";
import { useThemeTokenSync } from "./hooks/useThemeTokenSync";
type AppRoute =
| { type: "private" }
| { type: "public"; slug: string };
type AppRoute = { type: "private" } | { type: "public"; slug: string };
function routeFromPath(pathname = window.location.pathname): AppRoute {
if (!pathname.startsWith("/p/")) {
+69 -13
View File
@@ -22,9 +22,30 @@ type DrawingSidebarProps = {
function DrawerIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<rect x="3.5" y="5" width="17" height="14" rx="2.5" fill="none" stroke="currentColor" strokeWidth="1.8" />
<path d="M9 5v14" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
<path d="M5.75 9h1.5M5.75 12h1.5M5.75 15h1.5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
<rect
x="3.5"
y="5"
width="17"
height="14"
rx="2.5"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
/>
<path
d="M9 5v14"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
/>
<path
d="M5.75 9h1.5M5.75 12h1.5M5.75 15h1.5"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
/>
</svg>
);
}
@@ -62,23 +83,35 @@ export function DrawingSidebar({
onDisablePublication,
}: DrawingSidebarProps) {
const publicPath = publication.enabled ? `/p/${publication.slug}` : null;
const publicationStatus = publication.enabled ? `Published at ${publicPath}` : "Not published";
const publicationStatus = publication.enabled
? `Published at ${publicPath}`
: "Not published";
return (
<>
<div
className={open ? "sidebar-backdrop sidebar-backdrop-open" : "sidebar-backdrop"}
className={
open ? "sidebar-backdrop sidebar-backdrop-open" : "sidebar-backdrop"
}
onClick={onClose}
aria-hidden={!open}
/>
<aside className={open ? "sidebar sidebar-open" : "sidebar"} aria-hidden={!open}>
<aside
className={open ? "sidebar sidebar-open" : "sidebar"}
aria-hidden={!open}
>
<div className="sidebar-header">
<h1>excali-box</h1>
<div className="sidebar-actions">
<button type="button" className="primary-button" onClick={onCreate}>
New
</button>
<button type="button" className="icon-button" onClick={onClose} aria-label="Close drawings">
<button
type="button"
className="icon-button"
onClick={onClose}
aria-label="Close drawings"
>
×
</button>
</div>
@@ -87,7 +120,11 @@ export function DrawingSidebar({
{drawings.map((drawing) => (
<div
key={drawing.id}
className={drawing.id === activeId ? "drawing-item drawing-item-active" : "drawing-item"}
className={
drawing.id === activeId
? "drawing-item drawing-item-active"
: "drawing-item"
}
>
{drawing.id === activeId ? (
<div className="drawing-link drawing-link-active">
@@ -113,20 +150,30 @@ export function DrawingSidebar({
</button>
</div>
<div className="publication-panel">
<label className="publication-label" htmlFor="publication-slug">
<label
className="publication-label"
htmlFor="publication-slug"
>
Public name
</label>
<input
id="publication-slug"
className="title-input publication-input"
value={publicationSlug}
onChange={(event) => onPublicationSlugChange(event.target.value)}
onChange={(event) =>
onPublicationSlugChange(event.target.value)
}
spellCheck={false}
autoCapitalize="none"
autoCorrect="off"
/>
<div className="publication-actions">
<button type="button" className="secondary-button" onClick={onPublish} disabled={publicationBusy}>
<button
type="button"
className="secondary-button"
onClick={onPublish}
disabled={publicationBusy}
>
{publication.enabled ? "Update link" : "Publish"}
</button>
<button
@@ -140,7 +187,12 @@ export function DrawingSidebar({
</div>
<div className="publication-status">
{publication.enabled ? (
<a href={publicPath ?? "#"} target="_blank" rel="noreferrer" className="publication-link">
<a
href={publicPath ?? "#"}
target="_blank"
rel="noreferrer"
className="publication-link"
>
{publicationStatus}
</a>
) : (
@@ -150,7 +202,11 @@ export function DrawingSidebar({
</div>
</div>
) : (
<button type="button" className="drawing-link" onClick={() => onSelect(drawing.id)}>
<button
type="button"
className="drawing-link"
onClick={() => onSelect(drawing.id)}
>
<span className="drawing-title">{drawing.title}</span>
</button>
)}
+138 -63
View File
@@ -1,5 +1,10 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { DEFAULT_TITLE, type DrawingMeta, type DrawingPublication, type ScenePayload } from "../../core/shared";
import {
DEFAULT_TITLE,
type DrawingMeta,
type DrawingPublication,
type ScenePayload,
} from "../../core/shared";
type DrawingResponse = DrawingMeta & ScenePayload;
type PublicationResponse = DrawingPublication;
@@ -24,7 +29,10 @@ function sortDrawings(drawings: DrawingMeta[]): DrawingMeta[] {
return [...drawings].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
}
function replaceMeta(drawings: DrawingMeta[], drawing: DrawingMeta): DrawingMeta[] {
function replaceMeta(
drawings: DrawingMeta[],
drawing: DrawingMeta,
): DrawingMeta[] {
const filtered = drawings.filter((item) => item.id !== drawing.id);
return sortDrawings([drawing, ...filtered]);
}
@@ -50,8 +58,14 @@ function fetchDrawingList() {
return requestJson<DrawingMeta[]>("/api/drawings", { method: "GET" });
}
function isConflictError(error: unknown): error is RequestError & { body: ApiErrorBody & { drawing: DrawingMeta } } {
return error instanceof RequestError && error.status === 409 && error.body.drawing !== undefined;
function isConflictError(
error: unknown,
): error is RequestError & { body: ApiErrorBody & { drawing: DrawingMeta } } {
return (
error instanceof RequestError &&
error.status === 409 &&
error.body.drawing !== undefined
);
}
function sceneFromDrawing(drawing: DrawingResponse): ScenePayload {
@@ -71,7 +85,9 @@ export function useDrawingSession() {
const [error, setError] = useState<string | null>(null);
const [toastMessage, setToastMessage] = useState<string | null>(null);
const [editorReloadNonce, setEditorReloadNonce] = useState(0);
const [publication, setPublication] = useState<DrawingPublication>({ enabled: false });
const [publication, setPublication] = useState<DrawingPublication>({
enabled: false,
});
const [publicationSlug, setPublicationSlugState] = useState("");
const [publicationBusy, setPublicationBusy] = useState(false);
@@ -93,17 +109,23 @@ export function useDrawingSession() {
}
}, []);
const showToast = useCallback((message: string | null, timeoutMs?: number) => {
if (toastTimeoutRef.current !== null) {
clearTimeout(toastTimeoutRef.current);
toastTimeoutRef.current = null;
}
const showToast = useCallback(
(message: string | null, timeoutMs?: number) => {
if (toastTimeoutRef.current !== null) {
clearTimeout(toastTimeoutRef.current);
toastTimeoutRef.current = null;
}
setToastMessage(message);
if (message !== null && timeoutMs !== undefined) {
toastTimeoutRef.current = window.setTimeout(() => setToastMessage(null), timeoutMs);
}
}, []);
setToastMessage(message);
if (message !== null && timeoutMs !== undefined) {
toastTimeoutRef.current = window.setTimeout(
() => setToastMessage(null),
timeoutMs,
);
}
},
[],
);
const refreshList = useCallback(async () => {
const list = await fetchDrawingList();
@@ -111,17 +133,20 @@ export function useDrawingSession() {
return list;
}, []);
const applyPublication = useCallback((nextPublication: DrawingPublication) => {
publicationEnabledRef.current = nextPublication.enabled;
setPublication(nextPublication);
const applyPublication = useCallback(
(nextPublication: DrawingPublication) => {
publicationEnabledRef.current = nextPublication.enabled;
setPublication(nextPublication);
if (nextPublication.enabled) {
setPublicationSlugState(nextPublication.slug);
return;
}
if (nextPublication.enabled) {
setPublicationSlugState(nextPublication.slug);
return;
}
setPublicationSlugState("");
}, []);
setPublicationSlugState("");
},
[],
);
const loadDrawing = useCallback(
async (drawingId: string) => {
@@ -132,8 +157,13 @@ export function useDrawingSession() {
try {
const [list, drawing, nextPublication] = await Promise.all([
requestJson<DrawingMeta[]>("/api/drawings", { method: "GET" }),
requestJson<DrawingResponse>(`/api/drawings/${drawingId}`, { method: "GET" }),
requestJson<PublicationResponse>(`/api/drawings/${drawingId}/publication`, { method: "GET" }),
requestJson<DrawingResponse>(`/api/drawings/${drawingId}`, {
method: "GET",
}),
requestJson<PublicationResponse>(
`/api/drawings/${drawingId}/publication`,
{ method: "GET" },
),
]);
if (version !== loadVersionRef.current) {
@@ -159,7 +189,11 @@ export function useDrawingSession() {
return;
}
setError(loadError instanceof Error ? loadError.message : "Failed to load drawing");
setError(
loadError instanceof Error
? loadError.message
: "Failed to load drawing",
);
} finally {
if (version === loadVersionRef.current) {
setLoading(false);
@@ -183,10 +217,13 @@ export function useDrawingSession() {
showToast("Saving...");
}
const promise = requestJson<UpdateResponse>(`/api/drawings/${drawingId}`, {
method: "PUT",
body: JSON.stringify({ ...nextScene, expectedRevision }),
})
const promise = requestJson<UpdateResponse>(
`/api/drawings/${drawingId}`,
{
method: "PUT",
body: JSON.stringify({ ...nextScene, expectedRevision }),
},
)
.then((body) => {
setDrawings((current) => replaceMeta(current, body.drawing));
if (currentIdRef.current === drawingId) {
@@ -200,14 +237,18 @@ export function useDrawingSession() {
if (isConflictError(saveError)) {
clearPendingSaveTimeout();
setError(null);
setDrawings((current) => replaceMeta(current, saveError.body.drawing));
setDrawings((current) =>
replaceMeta(current, saveError.body.drawing),
);
if (isManual) {
showToast("Reloading latest...");
}
return loadDrawing(drawingId);
}
setError(saveError instanceof Error ? saveError.message : "Save failed");
setError(
saveError instanceof Error ? saveError.message : "Save failed",
);
if (isManual) {
showToast("Save failed", 2000);
}
@@ -257,7 +298,10 @@ export function useDrawingSession() {
}, [saveScene]);
const navigateToDrawing = useCallback(
async (drawingId: string, options?: { replace?: boolean; flush?: boolean }) => {
async (
drawingId: string,
options?: { replace?: boolean; flush?: boolean },
) => {
const replace = options?.replace ?? false;
const flush = options?.flush ?? true;
@@ -285,7 +329,9 @@ export function useDrawingSession() {
const createDrawing = useCallback(async () => {
await flushPendingSave();
const created = await requestJson<DrawingResponse>("/api/drawings", { method: "POST" });
const created = await requestJson<DrawingResponse>("/api/drawings", {
method: "POST",
});
await navigateToDrawing(created.id, { flush: false });
}, [flushPendingSave, navigateToDrawing]);
@@ -299,12 +345,18 @@ export function useDrawingSession() {
await flushPendingSave();
}
const response = await requestJson<{ ok: true; nextId: string | null }>(`/api/drawings/${drawingId}`, {
method: "DELETE",
});
const response = await requestJson<{ ok: true; nextId: string | null }>(
`/api/drawings/${drawingId}`,
{
method: "DELETE",
},
);
if (drawingId === currentIdRef.current && response.nextId) {
await navigateToDrawing(response.nextId, { replace: true, flush: false });
await navigateToDrawing(response.nextId, {
replace: true,
flush: false,
});
} else {
await refreshList();
}
@@ -324,13 +376,16 @@ export function useDrawingSession() {
}
try {
const body = await requestJson<UpdateResponse>(`/api/drawings/${drawingId}`, {
method: "PUT",
body: JSON.stringify({
title: currentTitleRef.current,
expectedRevision: loadedRevisionRef.current,
}),
});
const body = await requestJson<UpdateResponse>(
`/api/drawings/${drawingId}`,
{
method: "PUT",
body: JSON.stringify({
title: currentTitleRef.current,
expectedRevision: loadedRevisionRef.current,
}),
},
);
currentTitleRef.current = body.drawing.title;
loadedRevisionRef.current = body.drawing.revision;
@@ -339,12 +394,16 @@ export function useDrawingSession() {
} catch (renameError) {
if (isConflictError(renameError)) {
clearPendingSaveTimeout();
setDrawings((current) => replaceMeta(current, renameError.body.drawing));
setDrawings((current) =>
replaceMeta(current, renameError.body.drawing),
);
await loadDrawing(drawingId);
return;
}
setError(renameError instanceof Error ? renameError.message : "Rename failed");
setError(
renameError instanceof Error ? renameError.message : "Rename failed",
);
}
}, [clearPendingSaveTimeout, loadDrawing]);
@@ -391,18 +450,23 @@ export function useDrawingSession() {
return;
}
const body = await requestJson<PublicationResponse>(`/api/drawings/${currentDrawingId}/publication`, {
method: "PUT",
body: JSON.stringify({
enabled: true,
slug: publicationSlug,
}),
});
const body = await requestJson<PublicationResponse>(
`/api/drawings/${currentDrawingId}/publication`,
{
method: "PUT",
body: JSON.stringify({
enabled: true,
slug: publicationSlug,
}),
},
);
applyPublication(body);
showToast("Published", 2000);
} catch (publishError) {
setError(publishError instanceof Error ? publishError.message : "Publish failed");
setError(
publishError instanceof Error ? publishError.message : "Publish failed",
);
showToast("Publish failed", 2000);
} finally {
setPublicationBusy(false);
@@ -420,15 +484,20 @@ export function useDrawingSession() {
showToast("Disabling...");
try {
const body = await requestJson<PublicationResponse>(`/api/drawings/${drawingId}/publication`, {
method: "PUT",
body: JSON.stringify({ enabled: false }),
});
const body = await requestJson<PublicationResponse>(
`/api/drawings/${drawingId}/publication`,
{
method: "PUT",
body: JSON.stringify({ enabled: false }),
},
);
applyPublication(body);
showToast("Publication disabled", 2000);
} catch (disableError) {
setError(disableError instanceof Error ? disableError.message : "Disable failed");
setError(
disableError instanceof Error ? disableError.message : "Disable failed",
);
showToast("Disable failed", 2000);
} finally {
setPublicationBusy(false);
@@ -476,9 +545,15 @@ export function useDrawingSession() {
const list = await refreshList();
const drawingId = currentIdRef.current;
const loadedRevision = loadedRevisionRef.current;
const serverDrawing = drawingId ? list.find((drawing) => drawing.id === drawingId) : null;
const serverDrawing = drawingId
? list.find((drawing) => drawing.id === drawingId)
: null;
if (serverDrawing && loadedRevision !== null && serverDrawing.revision > loadedRevision) {
if (
serverDrawing &&
loadedRevision !== null &&
serverDrawing.revision > loadedRevision
) {
clearPendingSaveTimeout();
await loadDrawing(serverDrawing.id);
}
+12 -3
View File
@@ -17,11 +17,16 @@ class RequestError extends Error {
}
async function requestPublicDrawing(slug: string): Promise<PublicDrawing> {
const response = await fetch(`/api/public/drawings/${encodeURIComponent(slug)}`);
const response = await fetch(
`/api/public/drawings/${encodeURIComponent(slug)}`,
);
const body = (await response.json()) as PublicDrawing & { error?: string };
if (!response.ok) {
throw new RequestError(response.status, body.error ?? `Request failed: ${response.status}`);
throw new RequestError(
response.status,
body.error ?? `Request failed: ${response.status}`,
);
}
return body;
@@ -52,7 +57,11 @@ export function usePublicDrawing(slug: string): PublicDrawingState {
}
setDrawing(null);
setError(loadError instanceof Error ? loadError.message : "Failed to load drawing");
setError(
loadError instanceof Error
? loadError.message
: "Failed to load drawing",
);
})
.finally(() => {
if (!cancelled) {
+3 -1
View File
@@ -21,7 +21,9 @@ const EXCALIDRAW_THEME_TOKEN_MAP = [
["--overlay-bg-color", "--app-overlay-bg"],
] as const;
export function useThemeTokenSync(appShellRef: RefObject<HTMLDivElement | null>) {
export function useThemeTokenSync(
appShellRef: RefObject<HTMLDivElement | null>,
) {
const themeSyncFrameRef = useRef<number | null>(null);
const syncThemeTokens = useCallback(() => {
+2 -8
View File
@@ -182,14 +182,8 @@ input {
.publication-input {
font-family:
ui-monospace,
SFMono-Regular,
SFMono-Regular,
Menlo,
Monaco,
Consolas,
"Liberation Mono",
monospace;
ui-monospace, SFMono-Regular, SFMono-Regular, Menlo, Monaco, Consolas,
"Liberation Mono", monospace;
}
.publication-actions {
+7 -1
View File
@@ -1,5 +1,11 @@
import { describe, expect, test } from "bun:test";
import { HttpError, MAX_SCENE_BYTES, emptyScene, normalizeScene, parseJsonBody } from "./scene";
import {
HttpError,
MAX_SCENE_BYTES,
emptyScene,
normalizeScene,
parseJsonBody,
} from "./scene";
describe("scene helpers", () => {
test("creates empty scenes with dark theme by default", () => {
+3 -1
View File
@@ -62,7 +62,9 @@ export function normalizeScene(input: unknown): ScenePayload {
}
const appState = Object.fromEntries(
Object.entries(input.appState).filter(([key]) => !VOLATILE_APP_STATE_KEYS.has(key)),
Object.entries(input.appState).filter(
([key]) => !VOLATILE_APP_STATE_KEYS.has(key),
),
);
return {
+18 -5
View File
@@ -41,7 +41,9 @@ function testScene(): ScenePayload {
describe("mcp tool handlers", () => {
test("PUBLIC_BASE_URL is required", () => {
expect(() => resolvePublicBaseUrl("")).toThrow("PUBLIC_BASE_URL is required");
expect(() => resolvePublicBaseUrl("")).toThrow(
"PUBLIC_BASE_URL is required",
);
});
test("create_drawing writes an explicit scene to a temp SQLite DB", () => {
@@ -79,14 +81,21 @@ describe("mcp tool handlers", () => {
test("replace_drawing updates the existing drawing", () => {
const { store, tools } = createTempTools();
const result = tools.create_drawing({ title: "replace", scene: testScene() });
const result = tools.create_drawing({
title: "replace",
scene: testScene(),
});
const nextScene: ScenePayload = {
elements: [{ id: "manual", type: "rectangle" }],
appState: { theme: "dark" },
files: {},
};
const replaced = tools.replace_drawing({ id: result.id, title: "replaced", scene: nextScene });
const replaced = tools.replace_drawing({
id: result.id,
title: "replaced",
scene: nextScene,
});
expect(store.listDrawings()).toHaveLength(1);
expect(replaced.revision).toBe(2);
@@ -121,13 +130,17 @@ describe("mcp tool handlers", () => {
expect(patched.revision).toBe(2);
expect(store.getDrawing(result.id)?.revision).toBe(2);
expect(store.getDrawing(result.id)?.scene.elements).toEqual([{ id: "one", type: "rectangle", x: 42, y: 20 }]);
expect(store.getDrawing(result.id)?.scene.elements).toEqual([
{ id: "one", type: "rectangle", x: 42, y: 20 },
]);
});
test("missing drawing IDs return tool errors", () => {
const { tools } = createTempTools();
expect(() => tools.get_drawing({ id: "missing" })).toThrow("Drawing not found: missing");
expect(() => tools.get_drawing({ id: "missing" })).toThrow(
"Drawing not found: missing",
);
expect(() =>
tools.replace_drawing({
id: "missing",
+50 -16
View File
@@ -47,7 +47,9 @@ function trimTrailingSlash(url: string): string {
return url.replace(/\/+$/, "");
}
export function resolvePublicBaseUrl(raw = process.env.PUBLIC_BASE_URL): string {
export function resolvePublicBaseUrl(
raw = process.env.PUBLIC_BASE_URL,
): string {
const value = raw?.trim();
if (!value) {
throw new Error("PUBLIC_BASE_URL is required for the MCP server");
@@ -58,13 +60,14 @@ export function resolvePublicBaseUrl(raw = process.env.PUBLIC_BASE_URL): string
if (url.protocol === "http:" || url.protocol === "https:") {
return trimTrailingSlash(url.toString());
}
} catch {
}
} catch {}
throw new Error("PUBLIC_BASE_URL must be an absolute http(s) URL");
}
export function resolveStylesGuidePath(raw = DEFAULT_STYLES_GUIDE_PATH): StylesGuideResource | undefined {
export function resolveStylesGuidePath(
raw = DEFAULT_STYLES_GUIDE_PATH,
): StylesGuideResource | undefined {
const value = raw.trim();
if (!isAbsolute(value)) {
@@ -75,7 +78,12 @@ export function resolveStylesGuidePath(raw = DEFAULT_STYLES_GUIDE_PATH): StylesG
try {
stats = statSync(value);
} catch (error) {
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
if (
error &&
typeof error === "object" &&
"code" in error &&
error.code === "ENOENT"
) {
return undefined;
}
@@ -88,7 +96,9 @@ export function resolveStylesGuidePath(raw = DEFAULT_STYLES_GUIDE_PATH): StylesG
const extension = extname(value).toLowerCase();
if (extension !== ".md" && extension !== ".markdown") {
throw new Error("Styles guide path must point to a Markdown file (.md or .markdown)");
throw new Error(
"Styles guide path must point to a Markdown file (.md or .markdown)",
);
}
if (!stats.isFile()) {
@@ -127,7 +137,10 @@ function notFound(id: string): never {
throw new Error(`Drawing not found: ${id}`);
}
function mutationResult(publicBaseUrl: string, drawing: { id: string; title: string; revision: number }) {
function mutationResult(
publicBaseUrl: string,
drawing: { id: string; title: string; revision: number },
) {
return {
id: drawing.id,
title: drawing.title,
@@ -149,16 +162,26 @@ function updateExistingDrawing(
return result.drawing;
}
function applyScenePatch(scene: ScenePayload, patch: Operation[]): ScenePayload {
function applyScenePatch(
scene: ScenePayload,
patch: Operation[],
): ScenePayload {
try {
const result = applyPatch(structuredClone(scene), patch, true, true, true);
return normalizeScene(result.newDocument);
} catch (error) {
throw new Error(error instanceof Error ? `Invalid JSON Patch: ${error.message}` : "Invalid JSON Patch");
throw new Error(
error instanceof Error
? `Invalid JSON Patch: ${error.message}`
: "Invalid JSON Patch",
);
}
}
export function createMcpToolHandlers(store: DrawingStore, publicBaseUrl: string) {
export function createMcpToolHandlers(
store: DrawingStore,
publicBaseUrl: string,
) {
return {
list_drawings() {
return store.listDrawings();
@@ -224,7 +247,8 @@ export function createExcaliMcpHandler(
"excali://styles-guide",
{
title: "Styles Guide",
description: "User-provided drawing styles guide for scene generation",
description:
"User-provided drawing styles guide for scene generation",
mimeType: "text/markdown",
},
async (uri) => ({
@@ -252,7 +276,8 @@ export function createExcaliMcpHandler(
"get_drawing",
{
title: "Get Drawing",
description: "Returns drawing metadata, browser URL, and raw ScenePayload.",
description:
"Returns drawing metadata, browser URL, and raw ScenePayload.",
inputSchema: idInput.shape,
},
async (input) => jsonText(tools.get_drawing(input)),
@@ -262,7 +287,8 @@ export function createExcaliMcpHandler(
"create_drawing",
{
title: "Create Drawing",
description: "Creates a drawing from an explicit Excalidraw ScenePayload.",
description:
"Creates a drawing from an explicit Excalidraw ScenePayload.",
inputSchema: createDrawingInput.shape,
},
async (input) => jsonText(tools.create_drawing(input)),
@@ -282,7 +308,8 @@ export function createExcaliMcpHandler(
"patch_drawing",
{
title: "Patch Drawing",
description: "Applies RFC 6902 JSON Patch operations to a drawing scene and optionally updates its title.",
description:
"Applies RFC 6902 JSON Patch operations to a drawing scene and optionally updates its title.",
inputSchema: patchDrawingInput.shape,
},
async (input) => jsonText(tools.patch_drawing(input)),
@@ -304,7 +331,11 @@ export function createExcaliMcpHandler(
export function createMcpServer() {
const store = createDrawingStore(process.env.DATABASE_PATH);
const handler = createExcaliMcpHandler(store, resolvePublicBaseUrl(), resolveStylesGuidePath());
const handler = createExcaliMcpHandler(
store,
resolvePublicBaseUrl(),
resolveStylesGuidePath(),
);
return {
port: Number(process.env.MCP_PORT ?? "3001"),
@@ -312,7 +343,10 @@ export function createMcpServer() {
fetch(request: Request) {
const url = new URL(request.url);
if (url.pathname !== "/mcp") {
return Response.json({ ok: false, error: "Not found" }, { status: 404 });
return Response.json(
{ ok: false, error: "Not found" },
{ status: 404 },
);
}
return handler(request);
+25 -8
View File
@@ -52,7 +52,9 @@ describe("api", () => {
const { api, cleanup } = withApi();
const response = api.getDrawing(
Object.assign(new Request("http://local/api/drawings/missing"), { params: { id: "missing" } }),
Object.assign(new Request("http://local/api/drawings/missing"), {
params: { id: "missing" },
}),
);
expect(response.status).toBe(404);
@@ -199,9 +201,13 @@ describe("api", () => {
},
});
const stored = await api.getDrawing(
Object.assign(new Request(`http://local/api/drawings/${created.id}`), { params: { id: created.id } }),
).json();
const stored = await api
.getDrawing(
Object.assign(new Request(`http://local/api/drawings/${created.id}`), {
params: { id: created.id },
}),
)
.json();
expect(stored.elements).toEqual([{ id: "server" }]);
cleanup();
});
@@ -211,7 +217,10 @@ describe("api", () => {
const created = await api.createDrawing().json();
const disabled = await api.getDrawingPublication(
Object.assign(new Request(`http://local/api/drawings/${created.id}/publication`), { params: { id: created.id } }),
Object.assign(
new Request(`http://local/api/drawings/${created.id}/publication`),
{ params: { id: created.id } },
),
);
expect(disabled.status).toBe(200);
expect(await disabled.json()).toEqual({ enabled: false });
@@ -236,7 +245,10 @@ describe("api", () => {
});
const enabled = await api.getDrawingPublication(
Object.assign(new Request(`http://local/api/drawings/${created.id}/publication`), { params: { id: created.id } }),
Object.assign(
new Request(`http://local/api/drawings/${created.id}/publication`),
{ params: { id: created.id } },
),
);
expect(await enabled.json()).toEqual({
enabled: true,
@@ -394,7 +406,10 @@ describe("api", () => {
);
const enabled = await api.getPublicDrawing(
Object.assign(new Request("http://local/api/public/drawings/public-flow"), { params: { slug: "public-flow" } }),
Object.assign(
new Request("http://local/api/public/drawings/public-flow"),
{ params: { slug: "public-flow" } },
),
);
expect(enabled.status).toBe(200);
@@ -406,7 +421,9 @@ describe("api", () => {
});
const missing = await api.getPublicDrawing(
Object.assign(new Request("http://local/api/public/drawings/missing"), { params: { slug: "missing" } }),
Object.assign(new Request("http://local/api/public/drawings/missing"), {
params: { slug: "missing" },
}),
);
expect(missing.status).toBe(404);
cleanup();
+48 -12
View File
@@ -1,7 +1,12 @@
import { type DrawingStore } from "./db";
import { normalizePublicationSlug } from "../core/publication";
import { type DrawingMeta, type DrawingPublication } from "../core/shared";
import { HttpError, normalizeTitle, parseJsonBody, parseSceneText } from "../core/scene";
import {
HttpError,
normalizeTitle,
parseJsonBody,
parseSceneText,
} from "../core/scene";
type RouteRequest = Request & {
params?: Record<string, string>;
@@ -34,7 +39,9 @@ function toMeta(drawing: DrawingMeta): DrawingMeta {
};
}
function parseExpectedRevision(raw: Record<string, unknown>): number | undefined {
function parseExpectedRevision(
raw: Record<string, unknown>,
): number | undefined {
if (!Object.hasOwn(raw, "expectedRevision")) {
return undefined;
}
@@ -78,7 +85,10 @@ export function createApi(store: DrawingStore) {
const drawing = id ? store.getDrawing(id) : null;
if (!drawing) {
return json({ ok: false, error: "Drawing not found" }, { status: 404 });
return json(
{ ok: false, error: "Drawing not found" },
{ status: 404 },
);
}
return json({
@@ -99,7 +109,10 @@ export function createApi(store: DrawingStore) {
const drawing = store.getPublicDrawing(slug);
if (!drawing) {
return json({ ok: false, error: "Drawing not found" }, { status: 404 });
return json(
{ ok: false, error: "Drawing not found" },
{ status: 404 },
);
}
return json(drawing);
@@ -136,7 +149,10 @@ export function createApi(store: DrawingStore) {
});
if (!result.ok && result.reason === "not_found") {
return json({ ok: false, error: "Drawing not found" }, { status: 404 });
return json(
{ ok: false, error: "Drawing not found" },
{ status: 404 },
);
}
if (!result.ok && result.reason === "conflict") {
@@ -163,7 +179,10 @@ export function createApi(store: DrawingStore) {
deleteDrawing(request: RouteRequest) {
const id = request.params?.id;
if (!id) {
return json({ ok: false, error: "Missing drawing id" }, { status: 400 });
return json(
{ ok: false, error: "Missing drawing id" },
{ status: 400 },
);
}
const { deleted, next } = store.deleteDrawing(id);
@@ -183,7 +202,10 @@ export function createApi(store: DrawingStore) {
const publication = store.getDrawingPublication(id);
if (!publication) {
return json({ ok: false, error: "Drawing not found" }, { status: 404 });
return json(
{ ok: false, error: "Drawing not found" },
{ status: 404 },
);
}
return jsonPublication(publication);
@@ -199,11 +221,16 @@ export function createApi(store: DrawingStore) {
throw new HttpError(400, "Missing drawing id");
}
const raw = parseJsonBody<Record<string, unknown>>(await request.text());
const raw = parseJsonBody<Record<string, unknown>>(
await request.text(),
);
if (raw.enabled === false) {
const result = store.disableDrawingPublication(id);
if (!result.ok) {
return json({ ok: false, error: "Drawing not found" }, { status: 404 });
return json(
{ ok: false, error: "Drawing not found" },
{ status: 404 },
);
}
return jsonPublication(result.publication);
@@ -215,7 +242,10 @@ export function createApi(store: DrawingStore) {
const drawing = store.getDrawing(id);
if (!drawing) {
return json({ ok: false, error: "Drawing not found" }, { status: 404 });
return json(
{ ok: false, error: "Drawing not found" },
{ status: 404 },
);
}
const result = store.publishDrawing(id, {
@@ -223,11 +253,17 @@ export function createApi(store: DrawingStore) {
});
if (!result.ok && result.reason === "not_found") {
return json({ ok: false, error: "Drawing not found" }, { status: 404 });
return json(
{ ok: false, error: "Drawing not found" },
{ status: 404 },
);
}
if (!result.ok && result.reason === "slug_conflict") {
return json({ ok: false, error: "Published slug already exists" }, { status: 409 });
return json(
{ ok: false, error: "Published slug already exists" },
{ status: 409 },
);
}
return jsonPublication(result.publication);
+23 -6
View File
@@ -79,7 +79,9 @@ describe("drawing store", () => {
expect(stale.ok).toBe(false);
expect(stale.ok ? null : stale.reason).toBe("conflict");
expect(store.getDrawing(created.id)?.revision).toBe(1);
expect(store.getDrawing(created.id)?.scene.elements).toEqual([{ id: "first" }]);
expect(store.getDrawing(created.id)?.scene.elements).toEqual([
{ id: "first" },
]);
});
test("keeps the revision unchanged when the scene is already stored", () => {
@@ -109,13 +111,21 @@ describe("drawing store", () => {
files: {},
};
const first = store.updateDrawing(created.id, { expectedRevision: 0, scene });
const first = store.updateDrawing(created.id, {
expectedRevision: 0,
scene,
});
expect(first.ok ? first.drawing.revision : null).toBe(1);
const repeated = store.updateDrawing(created.id, { expectedRevision: 0, scene });
const repeated = store.updateDrawing(created.id, {
expectedRevision: 0,
scene,
});
expect(repeated.ok).toBe(true);
expect(repeated.ok ? repeated.drawing.revision : null).toBe(1);
expect(store.getDrawing(created.id)?.scene.elements).toEqual([{ id: "same" }]);
expect(store.getDrawing(created.id)?.scene.elements).toEqual([
{ id: "same" },
]);
});
test("increments the revision when the title changes but the scene does not", () => {
@@ -127,10 +137,17 @@ describe("drawing store", () => {
files: {},
};
const first = store.updateDrawing(created.id, { expectedRevision: 0, scene });
const first = store.updateDrawing(created.id, {
expectedRevision: 0,
scene,
});
expect(first.ok ? first.drawing.revision : null).toBe(1);
const renamed = store.updateDrawing(created.id, { expectedRevision: 1, title: "Renamed", scene });
const renamed = store.updateDrawing(created.id, {
expectedRevision: 1,
title: "Renamed",
scene,
});
expect(renamed.ok).toBe(true);
expect(renamed.ok ? renamed.drawing.title : null).toBe("Renamed");
expect(renamed.ok ? renamed.drawing.revision : null).toBe(2);
+73 -20
View File
@@ -2,7 +2,13 @@ import { Database } from "bun:sqlite";
import { randomUUID } from "node:crypto";
import { mkdirSync } from "node:fs";
import { dirname } from "node:path";
import { DEFAULT_TITLE, type DrawingMeta, type DrawingPublication, type DrawingRecord, type ScenePayload } from "../core/shared";
import {
DEFAULT_TITLE,
type DrawingMeta,
type DrawingPublication,
type DrawingRecord,
type ScenePayload,
} from "../core/shared";
import { emptyScene, normalizeScene, normalizeTitle } from "../core/scene";
type DrawingRow = {
@@ -69,7 +75,9 @@ function readMeta(row: DrawingMetaRow | null | undefined): DrawingMeta | null {
};
}
function readPublication(row: DrawingPublicationRow | null | undefined): DrawingPublication | null {
function readPublication(
row: DrawingPublicationRow | null | undefined,
): DrawingPublication | null {
if (!row) {
return null;
}
@@ -93,7 +101,8 @@ function readPublicDrawing(row: PublicDrawingRow | null | undefined) {
try {
scene = normalizeScene(JSON.parse(row.data));
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown scene parsing error";
const message =
error instanceof Error ? error.message : "Unknown scene parsing error";
throw new Error(`Invalid stored public scene: ${message}`);
}
@@ -112,7 +121,8 @@ function readRow(row: DrawingRow | null | undefined): DrawingRecord | null {
try {
scene = normalizeScene(JSON.parse(row.data));
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown scene parsing error";
const message =
error instanceof Error ? error.message : "Unknown scene parsing error";
throw new Error(`Invalid stored scene for drawing ${row.id}: ${message}`);
}
@@ -123,13 +133,15 @@ function readRow(row: DrawingRow | null | undefined): DrawingRecord | null {
}
function ensurePublicationColumns(db: Database) {
const columns = db
.query("PRAGMA table_info(drawings)")
.all() as Array<{ name: string }>;
const columns = db.query("PRAGMA table_info(drawings)").all() as Array<{
name: string;
}>;
const names = new Set(columns.map((column) => column.name));
if (!names.has("public_enabled")) {
db.exec("ALTER TABLE drawings ADD COLUMN public_enabled INTEGER NOT NULL DEFAULT 0");
db.exec(
"ALTER TABLE drawings ADD COLUMN public_enabled INTEGER NOT NULL DEFAULT 0",
);
}
if (!names.has("public_slug")) {
@@ -138,7 +150,10 @@ function ensurePublicationColumns(db: Database) {
}
function isSlugConflictError(error: unknown): boolean {
return error instanceof Error && error.message.includes("UNIQUE constraint failed: drawings.public_slug");
return (
error instanceof Error &&
error.message.includes("UNIQUE constraint failed: drawings.public_slug")
);
}
export function createDrawingStore(databasePath = resolveDatabasePath()) {
@@ -310,8 +325,12 @@ export function createDrawingStore(databasePath = resolveDatabasePath()) {
return { ok: true, drawing: existing };
}
const nextTitle = hasTitle ? normalizeTitle(update.title) : existingRow.title;
const nextScene = hasScene ? JSON.stringify(update.scene) : existingRow.data;
const nextTitle = hasTitle
? normalizeTitle(update.title)
: existingRow.title;
const nextScene = hasScene
? JSON.stringify(update.scene)
: existingRow.data;
const titleChanged = nextTitle !== existingRow.title;
const sceneChanged = nextScene !== existingRow.data;
@@ -319,7 +338,10 @@ export function createDrawingStore(databasePath = resolveDatabasePath()) {
return { ok: true, drawing: existing };
}
if (update.expectedRevision !== undefined && update.expectedRevision !== existingRow.revision) {
if (
update.expectedRevision !== undefined &&
update.expectedRevision !== existingRow.revision
) {
return { ok: false, reason: "conflict", drawing: existing };
}
@@ -328,17 +350,36 @@ export function createDrawingStore(databasePath = resolveDatabasePath()) {
changes =
update.expectedRevision === undefined
? Number(updateBothQuery.run(id, nextTitle, nextScene).changes)
: Number(updateBothExpectedQuery.run(id, nextTitle, nextScene, update.expectedRevision).changes);
: Number(
updateBothExpectedQuery.run(
id,
nextTitle,
nextScene,
update.expectedRevision,
).changes,
);
} else if (sceneChanged) {
changes =
update.expectedRevision === undefined
? Number(updateSceneQuery.run(id, nextScene).changes)
: Number(updateSceneExpectedQuery.run(id, nextScene, update.expectedRevision).changes);
: Number(
updateSceneExpectedQuery.run(
id,
nextScene,
update.expectedRevision,
).changes,
);
} else {
changes =
update.expectedRevision === undefined
? Number(updateTitleQuery.run(id, nextTitle).changes)
: Number(updateTitleExpectedQuery.run(id, nextTitle, update.expectedRevision).changes);
: Number(
updateTitleExpectedQuery.run(
id,
nextTitle,
update.expectedRevision,
).changes,
);
}
const drawing = getDrawing(id);
@@ -353,7 +394,10 @@ export function createDrawingStore(databasePath = resolveDatabasePath()) {
return { ok: false, reason: "conflict", drawing };
}
function deleteDrawing(id: string): { deleted: boolean; next: DrawingMeta | null } {
function deleteDrawing(id: string): {
deleted: boolean;
next: DrawingMeta | null;
} {
const changes = Number(deleteQuery.run(id).changes);
if (changes === 0) {
return { deleted: false, next: null };
@@ -366,10 +410,15 @@ export function createDrawingStore(databasePath = resolveDatabasePath()) {
}
function getDrawingPublication(id: string): DrawingPublication | null {
return readPublication(getPublicationQuery.get(id) as DrawingPublicationRow | null | undefined);
return readPublication(
getPublicationQuery.get(id) as DrawingPublicationRow | null | undefined,
);
}
function publishDrawing(id: string, publication: { slug: string }): DrawingPublicationUpdateResult {
function publishDrawing(
id: string,
publication: { slug: string },
): DrawingPublicationUpdateResult {
try {
const changes = Number(publishQuery.run(id, publication.slug).changes);
if (changes === 0) {
@@ -389,7 +438,9 @@ export function createDrawingStore(databasePath = resolveDatabasePath()) {
};
}
function disableDrawingPublication(id: string): DrawingPublicationUpdateResult {
function disableDrawingPublication(
id: string,
): DrawingPublicationUpdateResult {
const changes = Number(disablePublicationQuery.run(id).changes);
if (changes === 0) {
return { ok: false, reason: "not_found" };
@@ -402,7 +453,9 @@ export function createDrawingStore(databasePath = resolveDatabasePath()) {
}
function getPublicDrawing(slug: string) {
return readPublicDrawing(getPublicDrawingQuery.get(slug) as PublicDrawingRow | null | undefined);
return readPublicDrawing(
getPublicDrawingQuery.get(slug) as PublicDrawingRow | null | undefined,
);
}
function close() {
+18 -7
View File
@@ -38,12 +38,16 @@ describe("createServer", () => {
test("redirects / to the latest drawing", () => {
const { server, store } = createServerFixture();
const response = server.routes["/"]?.(new Request("http://local/")) as Response;
const response = server.routes["/"]?.(
new Request("http://local/"),
) as Response;
const created = store.listDrawings();
expect(created).toHaveLength(1);
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe(`http://local/d/${created[0]?.id}`);
expect(response.headers.get("location")).toBe(
`http://local/d/${created[0]?.id}`,
);
});
test("keeps Bun responsible for API routes", async () => {
@@ -67,9 +71,12 @@ describe("createServer", () => {
store.publishDrawing(drawing.id, { slug: "published-note" });
const response = await server.routes["/api/public/drawings/:slug"]?.GET?.(
Object.assign(new Request("http://local/api/public/drawings/published-note"), {
params: { slug: "published-note" },
}),
Object.assign(
new Request("http://local/api/public/drawings/published-note"),
{
params: { slug: "published-note" },
},
),
);
expect(response?.status).toBe(200);
@@ -85,7 +92,9 @@ describe("createServer", () => {
const { server } = createServerFixture();
const response = await server.routes["/api/public/drawings/:slug"]?.GET?.(
Object.assign(new Request("http://local/api/public/drawings/missing"), { params: { slug: "missing" } }),
Object.assign(new Request("http://local/api/public/drawings/missing"), {
params: { slug: "missing" },
}),
);
expect(response?.status).toBe(404);
@@ -93,7 +102,9 @@ describe("createServer", () => {
test("returns JSON 404 for unmatched requests", async () => {
const { server } = createServerFixture();
const response = await server.fetch(new Request("http://local/index-abc123.js"));
const response = await server.fetch(
new Request("http://local/index-abc123.js"),
);
expect(response.status).toBe(404);
expect(await response.json()).toEqual({ ok: false, error: "Not found" });
+14 -7
View File
@@ -25,19 +25,26 @@ export function createServer(options: CreateServerOptions = {}) {
POST: () => api.createDrawing(),
},
"/api/drawings/:id": {
GET: (request: Request & { params: Record<string, string> }) => api.getDrawing(request),
PUT: (request: Request & { params: Record<string, string> }) => api.updateDrawing(request),
DELETE: (request: Request & { params: Record<string, string> }) => api.deleteDrawing(request),
GET: (request: Request & { params: Record<string, string> }) =>
api.getDrawing(request),
PUT: (request: Request & { params: Record<string, string> }) =>
api.updateDrawing(request),
DELETE: (request: Request & { params: Record<string, string> }) =>
api.deleteDrawing(request),
},
"/api/public/drawings/:slug": {
GET: (request: Request & { params: Record<string, string> }) => api.getPublicDrawing(request),
GET: (request: Request & { params: Record<string, string> }) =>
api.getPublicDrawing(request),
},
"/api/drawings/:id/publication": {
GET: (request: Request & { params: Record<string, string> }) => api.getDrawingPublication(request),
PUT: (request: Request & { params: Record<string, string> }) => api.updateDrawingPublication(request),
GET: (request: Request & { params: Record<string, string> }) =>
api.getDrawingPublication(request),
PUT: (request: Request & { params: Record<string, string> }) =>
api.updateDrawingPublication(request),
},
},
fetch: (_request: Request) => Response.json({ ok: false, error: "Not found" }, { status: 404 }),
fetch: (_request: Request) =>
Response.json({ ok: false, error: "Not found" }, { status: 404 }),
} satisfies Parameters<typeof Bun.serve>[0];
}