Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e27e5e4c41 | |||
| fcb2c499cf | |||
| 887d3776b8 | |||
| 95d27c5142 | |||
| b60b921ff8 |
@@ -3,7 +3,7 @@ name: Publish GHCR Image
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'release-*'
|
||||
- "release-*"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -34,6 +34,4 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
||||
|
||||
PLAN.md
|
||||
|
||||
excali-box-data/
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
dist
|
||||
data
|
||||
excali-box-data
|
||||
@@ -5,3 +5,7 @@
|
||||
- Keep commit messages terse.
|
||||
|
||||
Example: `feat: dark mode by default`
|
||||
|
||||
# Decisions on architecture and design
|
||||
|
||||
Read [DECISIONS.md](DECISIONS.md)
|
||||
|
||||
@@ -6,25 +6,25 @@
|
||||
:80 {
|
||||
encode zstd gzip
|
||||
|
||||
@mcp path /mcp
|
||||
handle @mcp {
|
||||
header {
|
||||
X-Frame-Options "DENY"
|
||||
X-Content-Type-Options "nosniff"
|
||||
Referrer-Policy "strict-origin-when-cross-origin"
|
||||
}
|
||||
|
||||
handle /mcp {
|
||||
reverse_proxy 127.0.0.1:3001
|
||||
}
|
||||
|
||||
@api path /api/*
|
||||
handle @api {
|
||||
reverse_proxy 127.0.0.1:3000
|
||||
}
|
||||
|
||||
@root path /
|
||||
handle @root {
|
||||
@app path / /api/*
|
||||
handle @app {
|
||||
reverse_proxy 127.0.0.1:3000
|
||||
}
|
||||
|
||||
handle {
|
||||
root * /srv/public
|
||||
|
||||
@html path /index.html /d/*
|
||||
@html path /index.html /d/* /p/*
|
||||
header @html Cache-Control "no-cache"
|
||||
|
||||
@assets path_regexp assets \.(?:css|js|mjs|svg|png|jpg|jpeg|gif|webp|ico|woff2?|ttf|otf)$
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# a list of architectural decisions
|
||||
|
||||
( to remind me when I over-engineer and be fickle minded )
|
||||
|
||||
## Image is NOT meant to be exposed directly to the internet
|
||||
|
||||
- the defaults MUST take care of this
|
||||
- and hence AUTH won't be a part of the image, whatever the user is using for reverse proxy should handle auth.
|
||||
+5
-1
@@ -2,6 +2,7 @@ FROM oven/bun:1.3.11-alpine AS build
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json bun.lock tsconfig.json ./
|
||||
COPY patches ./patches
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
COPY src ./src
|
||||
@@ -19,14 +20,17 @@ ENV MCP_HOST=127.0.0.1
|
||||
ENV MCP_PORT=3001
|
||||
ENV DATABASE_PATH=/data/excalidraw.sqlite
|
||||
|
||||
RUN mkdir -p /config /data
|
||||
|
||||
COPY --from=caddy /usr/bin/caddy /usr/bin/caddy
|
||||
COPY --from=build /app/dist/public /srv/public
|
||||
COPY --from=build /app/dist/server /app/dist/server
|
||||
COPY --from=build /app/dist/mcp /app/dist/mcp
|
||||
COPY Caddyfile /etc/caddy/Caddyfile
|
||||
COPY STYLES_GUIDE.md /config/styles-guide.md
|
||||
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
RUN mkdir -p /data && chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
VOLUME ["/data"]
|
||||
|
||||
|
||||
@@ -5,8 +5,6 @@ Private self-hosted Excalidraw with Bun, Caddy, and SQLite.
|
||||
This is bare wrapper around the excalidraw packages that adds autosave and disk-persistence.
|
||||
No auth to keep things simple - you either run in a private network or put it behind Caddy auth or similar solutions.
|
||||
|
||||
> GPT 5.5 generated
|
||||
|
||||
## Tl;dr
|
||||
|
||||
When building the image yourself
|
||||
@@ -16,7 +14,6 @@ docker run --rm \
|
||||
-p 127.0.0.1:3000:80 \
|
||||
-e PUBLIC_BASE_URL=http://localhost:3000 \
|
||||
-v "$PWD/excali-box-data:/data" \
|
||||
-v "$PWD/STYLES_GUIDE.md:/config/styles-guide.md:ro" \
|
||||
excali
|
||||
```
|
||||
|
||||
@@ -27,7 +24,6 @@ docker run --rm \
|
||||
-p 127.0.0.1:3000:80 \
|
||||
-e PUBLIC_BASE_URL=http://localhost:3000 \
|
||||
-v "$PWD/excali-box-data:/data" \
|
||||
-v "$PWD/STYLES_GUIDE.md:/config/styles-guide.md:ro" \
|
||||
ghcr.io/ruinivist/excalidraw-box:latest
|
||||
```
|
||||
|
||||
@@ -54,6 +50,7 @@ Open `http://localhost:3000`.
|
||||
## Test
|
||||
|
||||
```bash
|
||||
bun run format
|
||||
bun run test
|
||||
bun run typecheck
|
||||
```
|
||||
@@ -70,7 +67,8 @@ This will make the data persistent in the `excali-box-data` folder in the curren
|
||||
## Optional MCP styles guide resource
|
||||
|
||||
The MCP server can optionally expose one extra read-only resource at `excali://styles-guide`. This file is then
|
||||
discoverable as an MCP resource.
|
||||
discoverable as an MCP resource. Container images already include the repo `STYLES_GUIDE.md` at
|
||||
`/config/styles-guide.md`, so the default guide is exposed automatically in Docker/GHCR.
|
||||
|
||||
```bash
|
||||
docker run --rm \
|
||||
@@ -81,4 +79,5 @@ docker run --rm \
|
||||
ghcr.io/ruinivist/excalidraw-box:latest
|
||||
```
|
||||
|
||||
Repo-root `STYLES_GUIDE.md` which is what I wrote for myself is not used at all unless you explicitly mount it to `/config/styles-guide.md`.
|
||||
Bind-mounting a different file to `/config/styles-guide.md` overrides the bundled default for that container.
|
||||
Local non-container runs are unchanged; if you want a styles guide there, you still need a file at `/config/styles-guide.md`.
|
||||
|
||||
@@ -17,10 +17,16 @@
|
||||
"@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",
|
||||
},
|
||||
},
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"@excalidraw/excalidraw@0.18.1": "patches/@excalidraw%2Fexcalidraw@0.18.1.patch",
|
||||
},
|
||||
"packages": {
|
||||
"@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="],
|
||||
|
||||
@@ -66,6 +72,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 +434,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 +502,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 +540,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 +556,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 +618,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 +628,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 +684,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 +730,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 +756,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,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,12 @@
|
||||
"@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"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"@excalidraw/excalidraw@0.18.1": "patches/@excalidraw%2Fexcalidraw@0.18.1.patch"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
+46
-1
@@ -1,10 +1,27 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { DrawingSidebar, DrawingsToggle } from "./components/DrawingSidebar";
|
||||
import { EditorCanvas } from "./components/EditorCanvas";
|
||||
import { PublicViewer } from "./components/PublicViewer";
|
||||
import { usePublicDrawing } from "./hooks/usePublicDrawing";
|
||||
import { useDrawingSession } from "./hooks/useDrawingSession";
|
||||
import { useThemeTokenSync } from "./hooks/useThemeTokenSync";
|
||||
|
||||
export function App() {
|
||||
type AppRoute = { type: "private" } | { type: "public"; slug: string };
|
||||
|
||||
function routeFromPath(pathname = window.location.pathname): AppRoute {
|
||||
if (!pathname.startsWith("/p/")) {
|
||||
return { type: "private" };
|
||||
}
|
||||
|
||||
const slug = pathname.slice(3);
|
||||
if (slug.length === 0 || slug.includes("/")) {
|
||||
return { type: "private" };
|
||||
}
|
||||
|
||||
return { type: "public", slug: decodeURIComponent(slug) };
|
||||
}
|
||||
|
||||
function PrivateApp() {
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||
const appShellRef = useRef<HTMLDivElement | null>(null);
|
||||
const scheduleThemeTokenSync = useThemeTokenSync(appShellRef);
|
||||
@@ -17,12 +34,18 @@ export function App() {
|
||||
error,
|
||||
toastMessage,
|
||||
editorReloadNonce,
|
||||
publication,
|
||||
publicationSlug,
|
||||
publicationBusy,
|
||||
setActiveTitle,
|
||||
submitTitle,
|
||||
handleSceneChange,
|
||||
selectDrawing,
|
||||
createDrawing,
|
||||
deleteDrawing,
|
||||
setPublicationSlug,
|
||||
publishPublication,
|
||||
disablePublication,
|
||||
} = useDrawingSession();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -98,13 +121,35 @@ export function App() {
|
||||
drawings={drawings}
|
||||
activeId={activeId}
|
||||
activeTitle={activeTitle}
|
||||
publication={publication}
|
||||
publicationSlug={publicationSlug}
|
||||
publicationBusy={publicationBusy}
|
||||
onClose={closeSidebar}
|
||||
onCreate={handleCreateDrawing}
|
||||
onSelect={handleSelectDrawing}
|
||||
onDelete={(drawingId) => void deleteDrawing(drawingId)}
|
||||
onTitleChange={setActiveTitle}
|
||||
onTitleSubmit={() => void submitTitle()}
|
||||
onPublicationSlugChange={setPublicationSlug}
|
||||
onPublish={() => void publishPublication()}
|
||||
onDisablePublication={() => void disablePublication()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PublicApp({ slug }: { slug: string }) {
|
||||
const { drawing, loading, error } = usePublicDrawing(slug);
|
||||
|
||||
return <PublicViewer drawing={drawing} loading={loading} error={error} />;
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const route = routeFromPath();
|
||||
|
||||
if (route.type === "public") {
|
||||
return <PublicApp slug={route.slug} />;
|
||||
}
|
||||
|
||||
return <PrivateApp />;
|
||||
}
|
||||
|
||||
@@ -1,24 +1,51 @@
|
||||
import { type DrawingMeta } from "../../core/shared";
|
||||
import { type DrawingMeta, type DrawingPublication } from "../../core/shared";
|
||||
|
||||
type DrawingSidebarProps = {
|
||||
open: boolean;
|
||||
drawings: DrawingMeta[];
|
||||
activeId: string | null;
|
||||
activeTitle: string;
|
||||
publication: DrawingPublication;
|
||||
publicationSlug: string;
|
||||
publicationBusy: boolean;
|
||||
onClose: () => void;
|
||||
onCreate: () => void;
|
||||
onSelect: (drawingId: string) => void;
|
||||
onDelete: (drawingId: string) => void;
|
||||
onTitleChange: (title: string) => void;
|
||||
onTitleSubmit: () => void;
|
||||
onPublicationSlugChange: (slug: string) => void;
|
||||
onPublish: () => void;
|
||||
onDisablePublication: () => void;
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -42,28 +69,49 @@ export function DrawingSidebar({
|
||||
drawings,
|
||||
activeId,
|
||||
activeTitle,
|
||||
publication,
|
||||
publicationSlug,
|
||||
publicationBusy,
|
||||
onClose,
|
||||
onCreate,
|
||||
onSelect,
|
||||
onDelete,
|
||||
onTitleChange,
|
||||
onTitleSubmit,
|
||||
onPublicationSlugChange,
|
||||
onPublish,
|
||||
onDisablePublication,
|
||||
}: DrawingSidebarProps) {
|
||||
const publicPath = publication.enabled ? `/p/${publication.slug}` : null;
|
||||
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>
|
||||
@@ -72,35 +120,106 @@ 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">
|
||||
<input
|
||||
className="title-input"
|
||||
value={activeTitle}
|
||||
onChange={(event) => onTitleChange(event.target.value)}
|
||||
onBlur={onTitleSubmit}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.currentTarget.blur();
|
||||
<div className="drawing-link drawing-link-active">
|
||||
<div className="drawing-item-header">
|
||||
<input
|
||||
className="title-input"
|
||||
value={activeTitle}
|
||||
onChange={(event) => onTitleChange(event.target.value)}
|
||||
onBlur={onTitleSubmit}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button drawing-delete-button"
|
||||
onClick={() => onDelete(drawing.id)}
|
||||
aria-label={`Delete ${drawing.title}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="publication-panel">
|
||||
<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)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
spellCheck={false}
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
/>
|
||||
<div className="publication-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
onClick={onPublish}
|
||||
disabled={publicationBusy}
|
||||
>
|
||||
{publication.enabled ? "Update link" : "Publish"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
onClick={onDisablePublication}
|
||||
disabled={!publication.enabled || publicationBusy}
|
||||
>
|
||||
Unpublish
|
||||
</button>
|
||||
</div>
|
||||
<div className="publication-status">
|
||||
{publication.enabled ? (
|
||||
<a
|
||||
href={publicPath ?? "#"}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="publication-link"
|
||||
>
|
||||
{publicationStatus}
|
||||
</a>
|
||||
) : (
|
||||
<span>{publicationStatus}</span>
|
||||
)}
|
||||
</div>
|
||||
</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>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
onClick={() => onDelete(drawing.id)}
|
||||
aria-label={`Delete ${drawing.title}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
{drawing.id !== activeId ? (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
onClick={() => onDelete(drawing.id)}
|
||||
aria-label={`Delete ${drawing.title}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import "../../../node_modules/@excalidraw/excalidraw/dist/prod/index.css";
|
||||
import { memo } from "react";
|
||||
import { Excalidraw } from "@excalidraw/excalidraw";
|
||||
import type {
|
||||
AppState,
|
||||
BinaryFiles,
|
||||
ExcalidrawInitialDataState,
|
||||
} from "@excalidraw/excalidraw/types";
|
||||
import "../../../node_modules/@excalidraw/excalidraw/dist/prod/index.css";
|
||||
import { type ScenePayload } from "../../core/shared";
|
||||
|
||||
type EditorCanvasProps = {
|
||||
@@ -27,13 +28,14 @@ function sceneFromEditor(
|
||||
files: BinaryFiles,
|
||||
): ScenePayload {
|
||||
return {
|
||||
elements: [...elements],
|
||||
// Excalidraw passes immutable arrays, avoid copying it on every onChange event
|
||||
elements: elements as unknown[],
|
||||
appState: appState as unknown as Record<string, unknown>,
|
||||
files: files as unknown as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
export function EditorCanvas({
|
||||
export const EditorCanvas = memo(function EditorCanvas({
|
||||
activeId,
|
||||
scene,
|
||||
loading,
|
||||
@@ -58,4 +60,4 @@ export function EditorCanvas({
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { memo } from "react";
|
||||
import { Excalidraw } from "@excalidraw/excalidraw";
|
||||
import type { ExcalidrawInitialDataState } from "@excalidraw/excalidraw/types";
|
||||
import "../../../node_modules/@excalidraw/excalidraw/dist/prod/index.css";
|
||||
import { type PublicDrawing } from "../../core/shared";
|
||||
|
||||
type PublicViewerProps = {
|
||||
drawing: PublicDrawing | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
function drawingToInitialData(
|
||||
drawing: PublicDrawing,
|
||||
): ExcalidrawInitialDataState {
|
||||
return drawing as ExcalidrawInitialDataState;
|
||||
}
|
||||
|
||||
const viewerUiOptions = {
|
||||
canvasActions: {
|
||||
changeViewBackgroundColor: false,
|
||||
clearCanvas: false,
|
||||
export: false,
|
||||
loadScene: false,
|
||||
saveAsImage: false,
|
||||
saveToActiveFile: false,
|
||||
toggleTheme: false,
|
||||
},
|
||||
tools: {
|
||||
image: false,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const PublicViewer = memo(function PublicViewer({
|
||||
drawing,
|
||||
loading,
|
||||
error,
|
||||
}: PublicViewerProps) {
|
||||
if (loading || !drawing) {
|
||||
return <div className="editor-loading">{error ?? "Loading..."}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="public-viewer-shell">
|
||||
<Excalidraw
|
||||
initialData={drawingToInitialData(drawing)}
|
||||
viewModeEnabled={true}
|
||||
zenModeEnabled={true}
|
||||
UIOptions={viewerUiOptions}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1,7 +1,13 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { DEFAULT_TITLE, type DrawingMeta, type ScenePayload } from "../../core/shared";
|
||||
import {
|
||||
DEFAULT_TITLE,
|
||||
type DrawingMeta,
|
||||
type DrawingPublication,
|
||||
type ScenePayload,
|
||||
} from "../../core/shared";
|
||||
|
||||
type DrawingResponse = DrawingMeta & ScenePayload;
|
||||
type PublicationResponse = DrawingPublication;
|
||||
type ApiErrorBody = { ok: false; error?: string; drawing?: DrawingMeta };
|
||||
type UpdateResponse = { ok: true; drawing: DrawingMeta };
|
||||
|
||||
@@ -23,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]);
|
||||
}
|
||||
@@ -49,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 {
|
||||
@@ -70,10 +85,16 @@ 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 [publicationSlug, setPublicationSlugState] = useState("");
|
||||
const [publicationBusy, setPublicationBusy] = useState(false);
|
||||
|
||||
const currentIdRef = useRef<string | null>(activeId);
|
||||
const currentTitleRef = useRef(activeTitle);
|
||||
const latestSceneRef = useRef<ScenePayload | null>(null);
|
||||
const publicationEnabledRef = useRef(false);
|
||||
const ignoreChangeRef = useRef(false);
|
||||
const saveTimeoutRef = useRef<number | null>(null);
|
||||
const toastTimeoutRef = useRef<number | null>(null);
|
||||
@@ -88,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();
|
||||
@@ -106,6 +133,21 @@ export function useDrawingSession() {
|
||||
return list;
|
||||
}, []);
|
||||
|
||||
const applyPublication = useCallback(
|
||||
(nextPublication: DrawingPublication) => {
|
||||
publicationEnabledRef.current = nextPublication.enabled;
|
||||
setPublication(nextPublication);
|
||||
|
||||
if (nextPublication.enabled) {
|
||||
setPublicationSlugState(nextPublication.slug);
|
||||
return;
|
||||
}
|
||||
|
||||
setPublicationSlugState("");
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const loadDrawing = useCallback(
|
||||
async (drawingId: string) => {
|
||||
const version = ++loadVersionRef.current;
|
||||
@@ -113,9 +155,15 @@ export function useDrawingSession() {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const [list, drawing] = await Promise.all([
|
||||
const [list, drawing, nextPublication] = await Promise.all([
|
||||
requestJson<DrawingMeta[]>("/api/drawings", { method: "GET" }),
|
||||
requestJson<DrawingResponse>(`/api/drawings/${drawingId}`, { method: "GET" }),
|
||||
requestJson<DrawingResponse>(`/api/drawings/${drawingId}`, {
|
||||
method: "GET",
|
||||
}),
|
||||
requestJson<PublicationResponse>(
|
||||
`/api/drawings/${drawingId}/publication`,
|
||||
{ method: "GET" },
|
||||
),
|
||||
]);
|
||||
|
||||
if (version !== loadVersionRef.current) {
|
||||
@@ -134,20 +182,25 @@ export function useDrawingSession() {
|
||||
setActiveId(drawing.id);
|
||||
setActiveTitleState(drawing.title);
|
||||
setScene(nextScene);
|
||||
applyPublication(nextPublication);
|
||||
setEditorReloadNonce((current) => current + 1);
|
||||
} catch (loadError) {
|
||||
if (version !== loadVersionRef.current) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
},
|
||||
[],
|
||||
[applyPublication],
|
||||
);
|
||||
|
||||
const saveScene = useCallback(
|
||||
@@ -164,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) {
|
||||
@@ -181,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);
|
||||
}
|
||||
@@ -238,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;
|
||||
|
||||
@@ -266,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]);
|
||||
|
||||
@@ -280,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();
|
||||
}
|
||||
@@ -305,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;
|
||||
@@ -320,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]);
|
||||
|
||||
@@ -350,6 +428,82 @@ export function useDrawingSession() {
|
||||
[navigateToDrawing],
|
||||
);
|
||||
|
||||
const setPublicationSlug = useCallback((slug: string) => {
|
||||
setPublicationSlugState(slug);
|
||||
}, []);
|
||||
|
||||
const publishPublication = useCallback(async () => {
|
||||
const drawingId = currentIdRef.current;
|
||||
if (!drawingId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPublicationBusy(true);
|
||||
setError(null);
|
||||
showToast("Publishing...");
|
||||
|
||||
try {
|
||||
await flushPendingSave();
|
||||
|
||||
const currentDrawingId = currentIdRef.current;
|
||||
if (!currentDrawingId) {
|
||||
return;
|
||||
}
|
||||
|
||||
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",
|
||||
);
|
||||
showToast("Publish failed", 2000);
|
||||
} finally {
|
||||
setPublicationBusy(false);
|
||||
}
|
||||
}, [applyPublication, flushPendingSave, publicationSlug, showToast]);
|
||||
|
||||
const disablePublication = useCallback(async () => {
|
||||
const drawingId = currentIdRef.current;
|
||||
if (!drawingId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPublicationBusy(true);
|
||||
setError(null);
|
||||
showToast("Disabling...");
|
||||
|
||||
try {
|
||||
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",
|
||||
);
|
||||
showToast("Disable failed", 2000);
|
||||
} finally {
|
||||
setPublicationBusy(false);
|
||||
}
|
||||
}, [applyPublication, showToast]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "s") {
|
||||
@@ -391,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);
|
||||
}
|
||||
@@ -441,11 +601,17 @@ export function useDrawingSession() {
|
||||
error,
|
||||
toastMessage,
|
||||
editorReloadNonce,
|
||||
publication,
|
||||
publicationSlug,
|
||||
publicationBusy,
|
||||
setActiveTitle,
|
||||
submitTitle,
|
||||
handleSceneChange,
|
||||
selectDrawing,
|
||||
createDrawing,
|
||||
deleteDrawing,
|
||||
setPublicationSlug,
|
||||
publishPublication,
|
||||
disablePublication,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { type PublicDrawing } from "../../core/shared";
|
||||
|
||||
type PublicDrawingState = {
|
||||
drawing: PublicDrawing | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
class RequestError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function requestPublicDrawing(slug: string): Promise<PublicDrawing> {
|
||||
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}`,
|
||||
);
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
export function usePublicDrawing(slug: string): PublicDrawingState {
|
||||
const [drawing, setDrawing] = useState<PublicDrawing | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
void requestPublicDrawing(slug)
|
||||
.then((nextDrawing) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDrawing(nextDrawing);
|
||||
document.title = nextDrawing.title;
|
||||
})
|
||||
.catch((loadError: unknown) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDrawing(null);
|
||||
setError(
|
||||
loadError instanceof Error
|
||||
? loadError.message
|
||||
: "Failed to load drawing",
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [slug]);
|
||||
|
||||
return {
|
||||
drawing,
|
||||
loading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
@@ -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(() => {
|
||||
@@ -31,10 +33,20 @@ export function useThemeTokenSync(appShellRef: RefObject<HTMLDivElement | null>)
|
||||
return;
|
||||
}
|
||||
|
||||
// Read all tokens first to avoid layout thrashing
|
||||
const computedStyles = window.getComputedStyle(excalidrawRoot);
|
||||
const updates: Array<[string, string]> = [];
|
||||
|
||||
for (const [sourceToken, targetToken] of EXCALIDRAW_THEME_TOKEN_MAP) {
|
||||
const value = computedStyles.getPropertyValue(sourceToken).trim();
|
||||
if (value) {
|
||||
updates.push([targetToken, value]);
|
||||
}
|
||||
}
|
||||
|
||||
// Write in a separate pass, skipping unchanged values
|
||||
for (const [targetToken, value] of updates) {
|
||||
if (appShell.style.getPropertyValue(targetToken) !== value) {
|
||||
appShell.style.setProperty(targetToken, value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,6 +134,22 @@ input {
|
||||
font-size: var(--app-sidebar-font-size);
|
||||
}
|
||||
|
||||
.drawing-link-active {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.drawing-item-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.drawing-delete-button {
|
||||
flex: 0 0 32px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.drawing-title,
|
||||
.title-input {
|
||||
display: block;
|
||||
@@ -152,6 +168,38 @@ input {
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.publication-panel {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 8px 0 4px;
|
||||
}
|
||||
|
||||
.publication-label,
|
||||
.publication-status {
|
||||
color: color-mix(in srgb, var(--app-text-color) 75%, white);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.publication-input {
|
||||
font-family:
|
||||
ui-monospace, SFMono-Regular, SFMono-Regular, Menlo, Monaco, Consolas,
|
||||
"Liberation Mono", monospace;
|
||||
}
|
||||
|
||||
.publication-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.publication-link {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.publication-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.editor-shell {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
@@ -270,6 +318,7 @@ input {
|
||||
}
|
||||
|
||||
.editor-frame,
|
||||
.public-viewer-shell,
|
||||
.editor-loading {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { HttpError } from "./scene";
|
||||
|
||||
const RESERVED_SLUGS = new Set(["api", "d", "p", "mcp"]);
|
||||
|
||||
export function normalizePublicationSlug(input: unknown): string {
|
||||
if (typeof input !== "string") {
|
||||
throw new HttpError(400, "slug must be a string");
|
||||
}
|
||||
|
||||
const normalized = input
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
|
||||
if (normalized.length === 0) {
|
||||
throw new HttpError(400, "slug is required");
|
||||
}
|
||||
|
||||
if (RESERVED_SLUGS.has(normalized)) {
|
||||
throw new HttpError(400, "slug is reserved");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -4,6 +4,19 @@ export type ScenePayload = {
|
||||
files: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type DrawingPublication =
|
||||
| {
|
||||
enabled: false;
|
||||
}
|
||||
| {
|
||||
enabled: true;
|
||||
slug: string;
|
||||
};
|
||||
|
||||
export type PublicDrawing = {
|
||||
title: string;
|
||||
} & ScenePayload;
|
||||
|
||||
export type DrawingMeta = {
|
||||
id: string;
|
||||
title: string;
|
||||
|
||||
+22
-81
@@ -1,26 +1,17 @@
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { join } from "node:path";
|
||||
import { type ScenePayload } from "../core/shared";
|
||||
import { createDrawingStore, type DrawingStore } from "../server/db";
|
||||
import {
|
||||
DEFAULT_STYLES_GUIDE_PATH,
|
||||
createExcaliMcpHandler,
|
||||
createMcpToolHandlers,
|
||||
resolvePublicBaseUrl,
|
||||
resolveStylesGuidePath,
|
||||
} from "./server";
|
||||
import { createMcpToolHandlers, resolvePublicBaseUrl } from "./server";
|
||||
|
||||
const cleanup: Array<{ dir: string; store?: DrawingStore; client?: Client }> = [];
|
||||
const cleanup: Array<{ dir: string; store?: DrawingStore }> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
while (cleanup.length > 0) {
|
||||
const item = cleanup.pop();
|
||||
if (item) {
|
||||
await item.client?.close().catch(() => undefined);
|
||||
item.store?.close();
|
||||
rmSync(item.dir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -37,35 +28,6 @@ function createTempTools() {
|
||||
};
|
||||
}
|
||||
|
||||
function createTempStylesGuidePath(content?: string) {
|
||||
const dir = mkdtempSync(join(tmpdir(), "excali-mcp-"));
|
||||
const path = join(dir, DEFAULT_STYLES_GUIDE_PATH.slice(1));
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
if (content !== undefined) {
|
||||
writeFileSync(path, content);
|
||||
}
|
||||
cleanup.push({ dir });
|
||||
return path;
|
||||
}
|
||||
|
||||
async function createTempClient(stylesGuidePath?: string) {
|
||||
const dir = mkdtempSync(join(tmpdir(), "excali-mcp-"));
|
||||
const store = createDrawingStore(join(dir, "test.sqlite"));
|
||||
const handler = createExcaliMcpHandler(
|
||||
store,
|
||||
"http://example.test",
|
||||
stylesGuidePath ? resolveStylesGuidePath(stylesGuidePath) : undefined,
|
||||
);
|
||||
const transport = new StreamableHTTPClientTransport(new URL("http://localhost/mcp"), {
|
||||
fetch: async (input, init) => handler(new Request(input, init)),
|
||||
});
|
||||
const client = new Client({ name: "excali-test", version: "1.0.0" });
|
||||
|
||||
await client.connect(transport);
|
||||
cleanup.push({ dir, store, client });
|
||||
return { client, store };
|
||||
}
|
||||
|
||||
function testScene(): ScenePayload {
|
||||
return {
|
||||
elements: [
|
||||
@@ -79,41 +41,9 @@ function testScene(): ScenePayload {
|
||||
|
||||
describe("mcp tool handlers", () => {
|
||||
test("PUBLIC_BASE_URL is required", () => {
|
||||
expect(() => resolvePublicBaseUrl("")).toThrow("PUBLIC_BASE_URL is required");
|
||||
});
|
||||
|
||||
test("missing fixed styles-guide path exposes no styles-guide resource", async () => {
|
||||
const stylesGuidePath = createTempStylesGuidePath();
|
||||
expect(resolveStylesGuidePath(stylesGuidePath)).toBeUndefined();
|
||||
|
||||
const { client } = await createTempClient(stylesGuidePath);
|
||||
|
||||
expect(client.getServerCapabilities()?.resources).toBeUndefined();
|
||||
});
|
||||
|
||||
test("valid fixed styles-guide path registers the styles-guide resource", async () => {
|
||||
const contents = "# Scene style\n\nKeep labels terse.\n";
|
||||
const stylesGuidePath = createTempStylesGuidePath(contents);
|
||||
const resolved = resolveStylesGuidePath(stylesGuidePath);
|
||||
const { client } = await createTempClient(stylesGuidePath);
|
||||
const resources = await client.listResources();
|
||||
const readResult = await client.readResource({ uri: "excali://styles-guide" });
|
||||
|
||||
expect(resolved).toEqual({ path: stylesGuidePath });
|
||||
expect(resources.resources).toContainEqual({
|
||||
name: "styles-guide",
|
||||
uri: "excali://styles-guide",
|
||||
title: "Styles Guide",
|
||||
description: "User-provided drawing styles guide for scene generation",
|
||||
mimeType: "text/markdown",
|
||||
});
|
||||
expect(readResult.contents).toEqual([
|
||||
{
|
||||
uri: "excali://styles-guide",
|
||||
mimeType: "text/markdown",
|
||||
text: contents,
|
||||
},
|
||||
]);
|
||||
expect(() => resolvePublicBaseUrl("")).toThrow(
|
||||
"PUBLIC_BASE_URL is required",
|
||||
);
|
||||
});
|
||||
|
||||
test("create_drawing writes an explicit scene to a temp SQLite DB", () => {
|
||||
@@ -151,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);
|
||||
@@ -193,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
@@ -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);
|
||||
|
||||
+227
-4
@@ -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,10 +201,231 @@ 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();
|
||||
});
|
||||
|
||||
test("returns disabled and enabled publication shapes", async () => {
|
||||
const { api, cleanup } = withApi();
|
||||
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 } },
|
||||
),
|
||||
);
|
||||
expect(disabled.status).toBe(200);
|
||||
expect(await disabled.json()).toEqual({ enabled: false });
|
||||
|
||||
const publish = await api.updateDrawingPublication(
|
||||
Object.assign(
|
||||
new Request(`http://local/api/drawings/${created.id}/publication`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
enabled: true,
|
||||
slug: "Flow Chart",
|
||||
}),
|
||||
}),
|
||||
{ params: { id: created.id } },
|
||||
),
|
||||
);
|
||||
|
||||
expect(publish.status).toBe(200);
|
||||
expect(await publish.json()).toEqual({
|
||||
enabled: true,
|
||||
slug: "flow-chart",
|
||||
});
|
||||
|
||||
const enabled = await api.getDrawingPublication(
|
||||
Object.assign(
|
||||
new Request(`http://local/api/drawings/${created.id}/publication`),
|
||||
{ params: { id: created.id } },
|
||||
),
|
||||
);
|
||||
expect(await enabled.json()).toEqual({
|
||||
enabled: true,
|
||||
slug: "flow-chart",
|
||||
});
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("publishes, renames, and disables publications", async () => {
|
||||
const { api, cleanup } = withApi();
|
||||
const created = await api.createDrawing().json();
|
||||
|
||||
await api.updateDrawingPublication(
|
||||
Object.assign(
|
||||
new Request(`http://local/api/drawings/${created.id}/publication`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
enabled: true,
|
||||
slug: "first",
|
||||
}),
|
||||
}),
|
||||
{ params: { id: created.id } },
|
||||
),
|
||||
);
|
||||
|
||||
const renamed = await api.updateDrawingPublication(
|
||||
Object.assign(
|
||||
new Request(`http://local/api/drawings/${created.id}/publication`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
enabled: true,
|
||||
slug: "second",
|
||||
}),
|
||||
}),
|
||||
{ params: { id: created.id } },
|
||||
),
|
||||
);
|
||||
|
||||
expect(renamed.status).toBe(200);
|
||||
expect(await renamed.json()).toEqual({
|
||||
enabled: true,
|
||||
slug: "second",
|
||||
});
|
||||
|
||||
const disable = await api.updateDrawingPublication(
|
||||
Object.assign(
|
||||
new Request(`http://local/api/drawings/${created.id}/publication`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ enabled: false }),
|
||||
}),
|
||||
{ params: { id: created.id } },
|
||||
),
|
||||
);
|
||||
|
||||
expect(disable.status).toBe(200);
|
||||
expect(await disable.json()).toEqual({ enabled: false });
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("returns 400 for invalid publication slugs", async () => {
|
||||
const { api, cleanup } = withApi();
|
||||
const created = await api.createDrawing().json();
|
||||
|
||||
const response = await api.updateDrawingPublication(
|
||||
Object.assign(
|
||||
new Request(`http://local/api/drawings/${created.id}/publication`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
enabled: true,
|
||||
slug: "API",
|
||||
}),
|
||||
}),
|
||||
{ params: { id: created.id } },
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.json()).toEqual({
|
||||
ok: false,
|
||||
error: "slug is reserved",
|
||||
});
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("returns 409 for publication slug collisions", async () => {
|
||||
const { api, cleanup } = withApi();
|
||||
const first = await api.createDrawing().json();
|
||||
const second = await api.createDrawing().json();
|
||||
|
||||
await api.updateDrawingPublication(
|
||||
Object.assign(
|
||||
new Request(`http://local/api/drawings/${first.id}/publication`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
enabled: true,
|
||||
slug: "shared",
|
||||
}),
|
||||
}),
|
||||
{ params: { id: first.id } },
|
||||
),
|
||||
);
|
||||
|
||||
const response = await api.updateDrawingPublication(
|
||||
Object.assign(
|
||||
new Request(`http://local/api/drawings/${second.id}/publication`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
enabled: true,
|
||||
slug: "shared",
|
||||
}),
|
||||
}),
|
||||
{ params: { id: second.id } },
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(409);
|
||||
expect(await response.json()).toEqual({
|
||||
ok: false,
|
||||
error: "Published slug already exists",
|
||||
});
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("returns public scene data for enabled slugs and 404 otherwise", async () => {
|
||||
const { api, cleanup } = withApi();
|
||||
const created = await api.createDrawing().json();
|
||||
|
||||
await api.updateDrawing(
|
||||
Object.assign(
|
||||
new Request(`http://local/api/drawings/${created.id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
title: "Public Flow",
|
||||
elements: [{ id: "shape" }],
|
||||
appState: { theme: "dark" },
|
||||
files: {},
|
||||
expectedRevision: 0,
|
||||
}),
|
||||
}),
|
||||
{ params: { id: created.id } },
|
||||
),
|
||||
);
|
||||
|
||||
await api.updateDrawingPublication(
|
||||
Object.assign(
|
||||
new Request(`http://local/api/drawings/${created.id}/publication`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
enabled: true,
|
||||
slug: "public-flow",
|
||||
}),
|
||||
}),
|
||||
{ params: { id: created.id } },
|
||||
),
|
||||
);
|
||||
|
||||
const enabled = await api.getPublicDrawing(
|
||||
Object.assign(
|
||||
new Request("http://local/api/public/drawings/public-flow"),
|
||||
{ params: { slug: "public-flow" } },
|
||||
),
|
||||
);
|
||||
|
||||
expect(enabled.status).toBe(200);
|
||||
expect(await enabled.json()).toEqual({
|
||||
title: "Public Flow",
|
||||
elements: [{ id: "shape" }],
|
||||
appState: { theme: "dark" },
|
||||
files: {},
|
||||
});
|
||||
|
||||
const missing = await api.getPublicDrawing(
|
||||
Object.assign(new Request("http://local/api/public/drawings/missing"), {
|
||||
params: { slug: "missing" },
|
||||
}),
|
||||
);
|
||||
expect(missing.status).toBe(404);
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
+127
-6
@@ -1,6 +1,12 @@
|
||||
import { type DrawingStore } from "./db";
|
||||
import { type DrawingMeta } from "../core/shared";
|
||||
import { HttpError, normalizeTitle, parseJsonBody, parseSceneText } from "../core/scene";
|
||||
import { normalizePublicationSlug } from "../core/publication";
|
||||
import { type DrawingMeta, type DrawingPublication } from "../core/shared";
|
||||
import {
|
||||
HttpError,
|
||||
normalizeTitle,
|
||||
parseJsonBody,
|
||||
parseSceneText,
|
||||
} from "../core/scene";
|
||||
|
||||
type RouteRequest = Request & {
|
||||
params?: Record<string, string>;
|
||||
@@ -33,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;
|
||||
}
|
||||
@@ -46,6 +54,10 @@ function parseExpectedRevision(raw: Record<string, unknown>): number | undefined
|
||||
return value;
|
||||
}
|
||||
|
||||
function jsonPublication(publication: DrawingPublication): Response {
|
||||
return json(publication);
|
||||
}
|
||||
|
||||
export function createApi(store: DrawingStore) {
|
||||
return {
|
||||
health() {
|
||||
@@ -73,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({
|
||||
@@ -85,6 +100,27 @@ export function createApi(store: DrawingStore) {
|
||||
}
|
||||
},
|
||||
|
||||
getPublicDrawing(request: RouteRequest) {
|
||||
try {
|
||||
const slug = request.params?.slug;
|
||||
if (!slug) {
|
||||
throw new HttpError(400, "Missing published slug");
|
||||
}
|
||||
|
||||
const drawing = store.getPublicDrawing(slug);
|
||||
if (!drawing) {
|
||||
return json(
|
||||
{ ok: false, error: "Drawing not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
return json(drawing);
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
},
|
||||
|
||||
async updateDrawing(request: RouteRequest) {
|
||||
try {
|
||||
const id = request.params?.id;
|
||||
@@ -113,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") {
|
||||
@@ -140,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);
|
||||
@@ -150,5 +192,84 @@ export function createApi(store: DrawingStore) {
|
||||
|
||||
return json({ ok: true, nextId: next?.id ?? null });
|
||||
},
|
||||
|
||||
getDrawingPublication(request: RouteRequest) {
|
||||
try {
|
||||
const id = request.params?.id;
|
||||
if (!id) {
|
||||
throw new HttpError(400, "Missing drawing id");
|
||||
}
|
||||
|
||||
const publication = store.getDrawingPublication(id);
|
||||
if (!publication) {
|
||||
return json(
|
||||
{ ok: false, error: "Drawing not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
return jsonPublication(publication);
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
},
|
||||
|
||||
async updateDrawingPublication(request: RouteRequest) {
|
||||
try {
|
||||
const id = request.params?.id;
|
||||
if (!id) {
|
||||
throw new HttpError(400, "Missing drawing id");
|
||||
}
|
||||
|
||||
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 jsonPublication(result.publication);
|
||||
}
|
||||
|
||||
if (raw.enabled !== true) {
|
||||
throw new HttpError(400, "enabled must be a boolean");
|
||||
}
|
||||
|
||||
const drawing = store.getDrawing(id);
|
||||
if (!drawing) {
|
||||
return json(
|
||||
{ ok: false, error: "Drawing not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
const result = store.publishDrawing(id, {
|
||||
slug: normalizePublicationSlug(raw.slug),
|
||||
});
|
||||
|
||||
if (!result.ok && result.reason === "not_found") {
|
||||
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 jsonPublication(result.publication);
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
+117
-6
@@ -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,12 +137,113 @@ 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);
|
||||
});
|
||||
|
||||
test("persists publication fields and exposes public drawings by slug", () => {
|
||||
const store = createTempStore();
|
||||
const created = store.createDrawing("Flow");
|
||||
store.updateDrawing(created.id, {
|
||||
scene: {
|
||||
elements: [{ id: "one" }],
|
||||
appState: { theme: "dark" },
|
||||
files: {},
|
||||
},
|
||||
});
|
||||
|
||||
const published = store.publishDrawing(created.id, { slug: "flow" });
|
||||
|
||||
expect(published.ok).toBe(true);
|
||||
expect(store.getDrawingPublication(created.id)).toEqual({
|
||||
enabled: true,
|
||||
slug: "flow",
|
||||
});
|
||||
expect(store.getPublicDrawing("flow")).toEqual({
|
||||
title: "Flow",
|
||||
elements: [{ id: "one" }],
|
||||
appState: { theme: "dark" },
|
||||
files: {},
|
||||
});
|
||||
});
|
||||
|
||||
test("enforces unique active publication slugs", () => {
|
||||
const store = createTempStore();
|
||||
const first = store.createDrawing("First");
|
||||
const second = store.createDrawing("Second");
|
||||
|
||||
expect(
|
||||
store.publishDrawing(first.id, {
|
||||
slug: "shared",
|
||||
}),
|
||||
).toEqual({
|
||||
ok: true,
|
||||
publication: {
|
||||
enabled: true,
|
||||
slug: "shared",
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
store.publishDrawing(second.id, {
|
||||
slug: "shared",
|
||||
}),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
reason: "slug_conflict",
|
||||
});
|
||||
|
||||
expect(store.disableDrawingPublication(first.id)).toEqual({
|
||||
ok: true,
|
||||
publication: { enabled: false },
|
||||
});
|
||||
|
||||
expect(
|
||||
store.publishDrawing(second.id, {
|
||||
slug: "shared",
|
||||
}),
|
||||
).toEqual({
|
||||
ok: true,
|
||||
publication: {
|
||||
enabled: true,
|
||||
slug: "shared",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("clears public fields on disable", () => {
|
||||
const store = createTempStore();
|
||||
const created = store.createDrawing();
|
||||
|
||||
expect(
|
||||
store.publishDrawing(created.id, {
|
||||
slug: "note",
|
||||
}),
|
||||
).toEqual({
|
||||
ok: true,
|
||||
publication: {
|
||||
enabled: true,
|
||||
slug: "note",
|
||||
},
|
||||
});
|
||||
|
||||
expect(store.disableDrawingPublication(created.id)).toEqual({
|
||||
ok: true,
|
||||
publication: { enabled: false },
|
||||
});
|
||||
expect(store.getDrawingPublication(created.id)).toEqual({ enabled: false });
|
||||
expect(store.getPublicDrawing("note")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
+216
-10
@@ -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 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 = {
|
||||
@@ -16,11 +22,26 @@ type DrawingRow = {
|
||||
|
||||
type DrawingMetaRow = Omit<DrawingRow, "data">;
|
||||
|
||||
type DrawingPublicationRow = {
|
||||
publicEnabled: number;
|
||||
publicSlug: string | null;
|
||||
};
|
||||
|
||||
type PublicDrawingRow = {
|
||||
title: string;
|
||||
data: string;
|
||||
};
|
||||
|
||||
export type DrawingUpdateResult =
|
||||
| { ok: true; drawing: DrawingRecord }
|
||||
| { ok: false; reason: "not_found" }
|
||||
| { ok: false; reason: "conflict"; drawing: DrawingRecord };
|
||||
|
||||
export type DrawingPublicationUpdateResult =
|
||||
| { ok: true; publication: DrawingPublication }
|
||||
| { ok: false; reason: "not_found" }
|
||||
| { ok: false; reason: "slug_conflict" };
|
||||
|
||||
export type DrawingStore = ReturnType<typeof createDrawingStore>;
|
||||
|
||||
function createId(): string {
|
||||
@@ -54,6 +75,43 @@ function readMeta(row: DrawingMetaRow | null | undefined): DrawingMeta | null {
|
||||
};
|
||||
}
|
||||
|
||||
function readPublication(
|
||||
row: DrawingPublicationRow | null | undefined,
|
||||
): DrawingPublication | null {
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (row.publicEnabled !== 1 || !row.publicSlug) {
|
||||
return { enabled: false };
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
slug: row.publicSlug,
|
||||
};
|
||||
}
|
||||
|
||||
function readPublicDrawing(row: PublicDrawingRow | null | undefined) {
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let scene: ScenePayload;
|
||||
try {
|
||||
scene = normalizeScene(JSON.parse(row.data));
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Unknown scene parsing error";
|
||||
throw new Error(`Invalid stored public scene: ${message}`);
|
||||
}
|
||||
|
||||
return {
|
||||
title: row.title,
|
||||
...scene,
|
||||
};
|
||||
}
|
||||
|
||||
function readRow(row: DrawingRow | null | undefined): DrawingRecord | null {
|
||||
if (!row) {
|
||||
return null;
|
||||
@@ -63,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}`);
|
||||
}
|
||||
|
||||
@@ -73,6 +132,30 @@ 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 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",
|
||||
);
|
||||
}
|
||||
|
||||
if (!names.has("public_slug")) {
|
||||
db.exec("ALTER TABLE drawings ADD COLUMN public_slug TEXT");
|
||||
}
|
||||
}
|
||||
|
||||
function isSlugConflictError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
error.message.includes("UNIQUE constraint failed: drawings.public_slug")
|
||||
);
|
||||
}
|
||||
|
||||
export function createDrawingStore(databasePath = resolveDatabasePath()) {
|
||||
mkdirSync(dirname(databasePath), { recursive: true });
|
||||
|
||||
@@ -86,13 +169,22 @@ export function createDrawingStore(databasePath = resolveDatabasePath()) {
|
||||
data TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
revision INTEGER NOT NULL DEFAULT 0
|
||||
revision INTEGER NOT NULL DEFAULT 0,
|
||||
public_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
public_slug TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS drawings_updated_at_idx
|
||||
ON drawings(updated_at DESC, created_at DESC);
|
||||
`);
|
||||
|
||||
ensurePublicationColumns(db);
|
||||
db.exec(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS drawings_public_slug_active_idx
|
||||
ON drawings(public_slug)
|
||||
WHERE public_enabled = 1 AND public_slug IS NOT NULL;
|
||||
`);
|
||||
|
||||
const listQuery = db.query(`
|
||||
SELECT
|
||||
id,
|
||||
@@ -162,6 +254,38 @@ export function createDrawingStore(databasePath = resolveDatabasePath()) {
|
||||
WHERE id = ?1
|
||||
`);
|
||||
|
||||
const getPublicationQuery = db.query(`
|
||||
SELECT
|
||||
public_enabled AS publicEnabled,
|
||||
public_slug AS publicSlug
|
||||
FROM drawings
|
||||
WHERE id = ?1
|
||||
`);
|
||||
|
||||
const publishQuery = db.query(`
|
||||
UPDATE drawings
|
||||
SET
|
||||
public_enabled = 1,
|
||||
public_slug = ?2
|
||||
WHERE id = ?1
|
||||
`);
|
||||
|
||||
const disablePublicationQuery = db.query(`
|
||||
UPDATE drawings
|
||||
SET
|
||||
public_enabled = 0,
|
||||
public_slug = NULL
|
||||
WHERE id = ?1
|
||||
`);
|
||||
|
||||
const getPublicDrawingQuery = db.query(`
|
||||
SELECT
|
||||
title,
|
||||
data
|
||||
FROM drawings
|
||||
WHERE public_enabled = 1 AND public_slug = ?1
|
||||
`);
|
||||
|
||||
function listDrawings(): DrawingMeta[] {
|
||||
return (listQuery.all() as DrawingMetaRow[]).map((row) => readMeta(row)!);
|
||||
}
|
||||
@@ -201,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;
|
||||
|
||||
@@ -210,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 };
|
||||
}
|
||||
|
||||
@@ -219,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);
|
||||
@@ -244,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 };
|
||||
@@ -256,6 +409,55 @@ export function createDrawingStore(databasePath = resolveDatabasePath()) {
|
||||
};
|
||||
}
|
||||
|
||||
function getDrawingPublication(id: string): DrawingPublication | null {
|
||||
return readPublication(
|
||||
getPublicationQuery.get(id) as DrawingPublicationRow | null | undefined,
|
||||
);
|
||||
}
|
||||
|
||||
function publishDrawing(
|
||||
id: string,
|
||||
publication: { slug: string },
|
||||
): DrawingPublicationUpdateResult {
|
||||
try {
|
||||
const changes = Number(publishQuery.run(id, publication.slug).changes);
|
||||
if (changes === 0) {
|
||||
return { ok: false, reason: "not_found" };
|
||||
}
|
||||
} catch (error) {
|
||||
if (isSlugConflictError(error)) {
|
||||
return { ok: false, reason: "slug_conflict" };
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
publication: getDrawingPublication(id)!,
|
||||
};
|
||||
}
|
||||
|
||||
function disableDrawingPublication(
|
||||
id: string,
|
||||
): DrawingPublicationUpdateResult {
|
||||
const changes = Number(disablePublicationQuery.run(id).changes);
|
||||
if (changes === 0) {
|
||||
return { ok: false, reason: "not_found" };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
publication: { enabled: false },
|
||||
};
|
||||
}
|
||||
|
||||
function getPublicDrawing(slug: string) {
|
||||
return readPublicDrawing(
|
||||
getPublicDrawingQuery.get(slug) as PublicDrawingRow | null | undefined,
|
||||
);
|
||||
}
|
||||
|
||||
function close() {
|
||||
db.close(false);
|
||||
}
|
||||
@@ -270,6 +472,10 @@ export function createDrawingStore(databasePath = resolveDatabasePath()) {
|
||||
ensureInitialDrawing,
|
||||
updateDrawing,
|
||||
deleteDrawing,
|
||||
getDrawingPublication,
|
||||
publishDrawing,
|
||||
disableDrawingPublication,
|
||||
getPublicDrawing,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createDevServer } from "./dev-server";
|
||||
|
||||
describe("createDevServer", () => {
|
||||
test("serves the spa for private and public drawing routes", () => {
|
||||
const server = createDevServer();
|
||||
|
||||
expect(Object.hasOwn(server.routes, "/d/:id")).toBe(true);
|
||||
expect(Object.hasOwn(server.routes, "/p/:slug")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ export function createDevServer() {
|
||||
routes: {
|
||||
...server.routes,
|
||||
"/d/:id": index,
|
||||
"/p/:slug": index,
|
||||
},
|
||||
} satisfies Parameters<typeof Bun.serve>[0];
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
@@ -58,11 +62,49 @@ describe("createServer", () => {
|
||||
const { server } = createServerFixture();
|
||||
|
||||
expect(Object.hasOwn(server.routes, "/d/:id")).toBe(false);
|
||||
expect(Object.hasOwn(server.routes, "/p/:slug")).toBe(false);
|
||||
});
|
||||
|
||||
test("serves public drawing data through the public api", async () => {
|
||||
const { server, store } = createServerFixture();
|
||||
const drawing = store.createDrawing("Published");
|
||||
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" },
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(response?.status).toBe(200);
|
||||
expect(await response?.json()).toEqual({
|
||||
title: "Published",
|
||||
elements: [],
|
||||
appState: { theme: "dark" },
|
||||
files: {},
|
||||
});
|
||||
});
|
||||
|
||||
test("returns 404 for unknown or unpublished public slugs", async () => {
|
||||
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" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response?.status).toBe(404);
|
||||
});
|
||||
|
||||
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" });
|
||||
|
||||
@@ -25,12 +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),
|
||||
},
|
||||
"/api/drawings/:id/publication": {
|
||||
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];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user