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 -1
View File
@@ -3,7 +3,7 @@ name: Publish GHCR Image
on:
push:
tags:
- 'release-*'
- "release-*"
permissions:
contents: read
+3
View File
@@ -0,0 +1,3 @@
dist
data
excali-box-data
+1
View File
@@ -50,6 +50,7 @@ Open `http://localhost:3000`.
## Test
```bash
bun run format
bun run test
bun run typecheck
```
+25 -2
View File
@@ -17,6 +17,9 @@
"@types/bun": "^1.3.14",
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
"prettier": "^3.8.3",
"pretty-quick": "^4.2.2",
"simple-git-hooks": "^2.13.1",
"typescript": "^5.9.3",
},
},
@@ -66,6 +69,8 @@
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
"@pkgr/core": ["@pkgr/core@0.2.10", "", {}, "sha512-x6fFWCeak8aCGfqZfe6CXYt5xVjxe9Os1cIPmVRcToInKLjhJkRVXvJ/L3/1KxFkjDQdbZV/YsuLKqa8t/xKpA=="],
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.1", "", {}, "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA=="],
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.2", "", { "dependencies": { "@radix-ui/react-primitive": "2.0.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-G+KcpzXHq24iH0uGG/pF8LyzpFJYGD4RfLjCIBfGdSLXvjLHST31RUiRVrupIBMvIppMgSzQ6l66iAxl03tdlg=="],
@@ -426,6 +431,8 @@
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
"ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
"image-blob-reduce": ["image-blob-reduce@3.0.1", "", { "dependencies": { "pica": "^7.1.0" } }, "sha512-/VmmWgIryG/wcn4TVrV7cC4mlfUC/oyiKIfSg5eVM3Ten/c1c34RJhMYKCWTnoSMHSqXLt3tsrBR4Q2HInvN+Q=="],
"immutable": ["immutable@4.3.8", "", {}, "sha512-d/Ld9aLbKpNwyl0KiM2CT1WYvkitQ1TSvmRtkcV8FKStiDoA7Slzgjmb/1G2yhKM1p0XeNOieaTbFZmU1d3Xuw=="],
@@ -492,6 +499,8 @@
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"multimath": ["multimath@2.0.0", "", { "dependencies": { "glur": "^1.1.2", "object-assign": "^4.1.1" } }, "sha512-toRx66cAMJ+Ccz7pMIg38xSIrtnbozk0dchXezwQDMgQmbGpfxjtv68H+L00iFL8hxDaVjrmwAFSb3I6bg8Q2g=="],
@@ -528,7 +537,9 @@
"pica": ["pica@7.1.1", "", { "dependencies": { "glur": "^1.1.2", "inherits": "^2.0.3", "multimath": "^2.0.0", "object-assign": "^4.1.1", "webworkify": "^1.5.0" } }, "sha512-WY73tMvNzXWEld2LicT9Y260L43isrZ85tPuqRyvtkljSDLmnNFQmZICt4xUJMVulmcc6L9O7jbBrtx3DOz/YQ=="],
"picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
@@ -542,6 +553,10 @@
"points-on-path": ["points-on-path@0.2.1", "", { "dependencies": { "path-data-parser": "0.1.0", "points-on-curve": "0.2.0" } }, "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g=="],
"prettier": ["prettier@3.8.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="],
"pretty-quick": ["pretty-quick@4.2.2", "", { "dependencies": { "@pkgr/core": "^0.2.7", "ignore": "^7.0.5", "mri": "^1.2.0", "picocolors": "^1.1.1", "picomatch": "^4.0.2", "tinyexec": "^0.3.2", "tslib": "^2.8.1" }, "peerDependencies": { "prettier": "^3.0.0" }, "bin": { "pretty-quick": "lib/cli.mjs" } }, "sha512-uAh96tBW1SsD34VhhDmWuEmqbpfYc/B3j++5MC/6b3Cb8Ow7NJsvKFhg0eoGu2xXX+o9RkahkTK6sUdd8E7g5w=="],
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
"pwacompat": ["pwacompat@2.0.17", "", {}, "sha512-6Du7IZdIy7cHiv7AhtDy4X2QRM8IAD5DII69mt5qWibC2d15ZU8DmBG1WdZKekG11cChSu4zkSUGPF9sweOl6w=="],
@@ -600,6 +615,8 @@
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
"simple-git-hooks": ["simple-git-hooks@2.13.1", "", { "bin": { "simple-git-hooks": "cli.js" } }, "sha512-WszCLXwT4h2k1ufIXAgsbiTOazqqevFCIncOuUBZJ91DdvWcC5+OFkluWRQPrcuSYd8fjq+o2y1QfWqYMoAToQ=="],
"sliced": ["sliced@1.0.1", "", {}, "sha512-VZBmZP8WU3sMOZm1bdgTadsQbcscK0UM8oKxKVBs4XAhUo2Xxzm/OFMGBkPusxw9xL3Uy8LrzEqGqJhclsr0yA=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
@@ -608,7 +625,7 @@
"stylis": ["stylis@4.4.0", "", {}, "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA=="],
"tinyexec": ["tinyexec@1.2.2", "", {}, "sha512-M/Q0B2cp4K7kynaT/vnED1j8TlLY+Pp7C6Wl2bl/7u/F0mUVwdyOpwomQb8JpYLitHUssAJRmLZdMCGsrx7i+g=="],
"tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
@@ -664,6 +681,8 @@
"zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="],
"@antfu/install-pkg/tinyexec": ["tinyexec@1.2.2", "", {}, "sha512-M/Q0B2cp4K7kynaT/vnED1j8TlLY+Pp7C6Wl2bl/7u/F0mUVwdyOpwomQb8JpYLitHUssAJRmLZdMCGsrx7i+g=="],
"@chevrotain/cst-dts-gen/@chevrotain/types": ["@chevrotain/types@11.0.3", "", {}, "sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ=="],
"@chevrotain/cst-dts-gen/lodash-es": ["lodash-es@4.17.21", "", {}, "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="],
@@ -708,6 +727,8 @@
"@radix-ui/react-tabs/@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.0.0", "", { "dependencies": { "@babel/runtime": "^7.13.10", "@radix-ui/react-use-callback-ref": "1.0.0" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0" } }, "sha512-FohDoZvk3mEXh9AWAVyRTYR4Sq7/gavuofglmiXB2g1aKyboUD4YtgWxKj8O5n+Uak52gXQ4wKz5IFST4vtJHg=="],
"anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"chevrotain/@chevrotain/types": ["@chevrotain/types@11.0.3", "", {}, "sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ=="],
"chevrotain/lodash-es": ["lodash-es@4.17.21", "", {}, "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="],
@@ -732,6 +753,8 @@
"points-on-path/points-on-curve": ["points-on-curve@0.2.0", "", {}, "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A=="],
"readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"roughjs/points-on-curve": ["points-on-curve@0.2.0", "", {}, "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A=="],
"type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
+8
View File
@@ -8,10 +8,15 @@
"build:client": "bun build --target=browser --production --outdir ./dist/public ./src/client/index.html",
"build:server": "mkdir -p ./dist/server && bun build --target=bun --production --outfile ./dist/server/server.js ./src/server/http-server.tsx",
"build:mcp": "mkdir -p ./dist/mcp && bun build --target=bun --production --outfile ./dist/mcp/mcp-server.js ./src/mcp/server.ts",
"format": "prettier --write .",
"postinstall": "simple-git-hooks",
"start": "DATABASE_PATH=./data/excalidraw.sqlite bun ./dist/server/server.js",
"test": "bun test",
"typecheck": "tsc --noEmit"
},
"simple-git-hooks": {
"pre-commit": "bunx pretty-quick --staged"
},
"dependencies": {
"@excalidraw/excalidraw": "^0.18.1",
"@modelcontextprotocol/sdk": "^1.29.0",
@@ -25,6 +30,9 @@
"@types/bun": "^1.3.14",
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
"prettier": "^3.8.3",
"pretty-quick": "^4.2.2",
"simple-git-hooks": "^2.13.1",
"typescript": "^5.9.3"
}
}
+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>
)}
+109 -34
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,7 +109,8 @@ export function useDrawingSession() {
}
}, []);
const showToast = useCallback((message: string | null, timeoutMs?: number) => {
const showToast = useCallback(
(message: string | null, timeoutMs?: number) => {
if (toastTimeoutRef.current !== null) {
clearTimeout(toastTimeoutRef.current);
toastTimeoutRef.current = null;
@@ -101,9 +118,14 @@ export function useDrawingSession() {
setToastMessage(message);
if (message !== null && timeoutMs !== undefined) {
toastTimeoutRef.current = window.setTimeout(() => setToastMessage(null), timeoutMs);
toastTimeoutRef.current = window.setTimeout(
() => setToastMessage(null),
timeoutMs,
);
}
}, []);
},
[],
);
const refreshList = useCallback(async () => {
const list = await fetchDrawingList();
@@ -111,7 +133,8 @@ export function useDrawingSession() {
return list;
}, []);
const applyPublication = useCallback((nextPublication: DrawingPublication) => {
const applyPublication = useCallback(
(nextPublication: DrawingPublication) => {
publicationEnabledRef.current = nextPublication.enabled;
setPublication(nextPublication);
@@ -121,7 +144,9 @@ export function useDrawingSession() {
}
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}`, {
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}`, {
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}`, {
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`, {
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`, {
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() {
+17 -6
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"), {
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];
}