Compare commits

...

13 Commits

38 changed files with 3034 additions and 231 deletions
+1 -1
View File
@@ -3,7 +3,7 @@ name: Publish GHCR Image
on:
push:
tags:
- 'release-*'
- "release-*"
permissions:
contents: read
-2
View File
@@ -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/
+3
View File
@@ -0,0 +1,3 @@
dist
data
excali-box-data
+4
View File
@@ -5,3 +5,7 @@
- Keep commit messages terse.
Example: `feat: dark mode by default`
# Decisions on architecture and design
Read [DECISIONS.md](DECISIONS.md)
+4 -10
View File
@@ -6,25 +6,19 @@
:80 {
encode zstd gzip
@mcp path /mcp
handle @mcp {
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)$
+8
View File
@@ -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
View File
@@ -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 -6
View File
@@ -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`.
+119 -2
View File
@@ -11,16 +11,23 @@
"mcp-handler": "^1.1.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"shiki": "^4.1.0",
"zod": "^4.4.3",
},
"devDependencies": {
"@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 +73,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=="],
@@ -128,6 +137,22 @@
"@redis/time-series": ["@redis/time-series@1.1.0", "", { "peerDependencies": { "@redis/client": "^1.0.0" } }, "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g=="],
"@shikijs/core": ["@shikijs/core@4.1.0", "", { "dependencies": { "@shikijs/primitive": "4.1.0", "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-jLJtSJeuFffqX6/inRE1zqU5aFv2hrszvYgq3OjbAgFRZiWv7abKMDdQzYxuSDfmUPQozZvI/kuy6VMTvnvqTQ=="],
"@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.1.0", "", { "dependencies": { "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" } }, "sha512-YquhawCUgaBfhsS72e2Y/dI59gCBNPHu3fEO/tvLaXrTssxZrY5ddjtNLTwndrMgPo8b3IscE+xoICDzpTmlFQ=="],
"@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.1.0", "", { "dependencies": { "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-axLpjVs45YBvvINa+dJF+NPW+KtFkNXsFr4SDw2BMj9GdeMnGxVB9PQb2xXlJYovslt/nz6giedAyOANkfc7hg=="],
"@shikijs/langs": ["@shikijs/langs@4.1.0", "", { "dependencies": { "@shikijs/types": "4.1.0" } }, "sha512-nwOMruEkbgdZfQ/b8CgpNBVOpvG1k0N5tbmgiFeqsan401+x3ILqlzZJowSla4Agmq4hG2Uf2wh5jLTEhR8VSg=="],
"@shikijs/primitive": ["@shikijs/primitive@4.1.0", "", { "dependencies": { "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-zx2/2Uwj2q9X3KSyYREEhXO23xBw5WUhP4orK2lE4r+t9JGITmEe0JH+wPmJhqHpOT2bRRs6lAL945+LDvOAGw=="],
"@shikijs/themes": ["@shikijs/themes@4.1.0", "", { "dependencies": { "@shikijs/types": "4.1.0" } }, "sha512-emCcTnUM7yO2wltYbaxm+yLvcCI4+h8XBKc4KmJ7EZUXoSGjcCHifkI//R4OFit9ewpg7H2/9tjOuXrT2v/Knw=="],
"@shikijs/types": ["@shikijs/types@4.1.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3EQWX54fMpniOrDblzAhiwiJwpiTMW6+B9DWyUd9ska483tbayFYuw47UxwuPknI31bKnySfVQ/QW+jFL4rFdA=="],
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
"@types/d3": ["@types/d3@7.4.3", "", { "dependencies": { "@types/d3-array": "*", "@types/d3-axis": "*", "@types/d3-brush": "*", "@types/d3-chord": "*", "@types/d3-color": "*", "@types/d3-contour": "*", "@types/d3-delaunay": "*", "@types/d3-dispatch": "*", "@types/d3-drag": "*", "@types/d3-dsv": "*", "@types/d3-ease": "*", "@types/d3-fetch": "*", "@types/d3-force": "*", "@types/d3-format": "*", "@types/d3-geo": "*", "@types/d3-hierarchy": "*", "@types/d3-interpolate": "*", "@types/d3-path": "*", "@types/d3-polygon": "*", "@types/d3-quadtree": "*", "@types/d3-random": "*", "@types/d3-scale": "*", "@types/d3-scale-chromatic": "*", "@types/d3-selection": "*", "@types/d3-shape": "*", "@types/d3-time": "*", "@types/d3-time-format": "*", "@types/d3-timer": "*", "@types/d3-transition": "*", "@types/d3-zoom": "*" } }, "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww=="],
@@ -194,6 +219,10 @@
"@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="],
"@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
"@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="],
"@types/react": ["@types/react@19.2.15", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q=="],
@@ -202,6 +231,10 @@
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.1", "", {}, "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ=="],
"@upsetjs/venn.js": ["@upsetjs/venn.js@2.0.0", "", { "optionalDependencies": { "d3-selection": "^3.0.0", "d3-transition": "^3.0.1" } }, "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw=="],
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
@@ -232,8 +265,14 @@
"canvas-roundrect-polyfill": ["canvas-roundrect-polyfill@0.0.1", "", {}, "sha512-yWq+R3U3jE+coOeEb3a3GgE2j/0MMiDKM/QpLb6h9ihf5fGY9UXtvK9o4vNqjWXoZz7/3EaSVU3IX53TvFFUOw=="],
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
"character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
"chevrotain": ["chevrotain@11.0.3", "", { "dependencies": { "@chevrotain/cst-dts-gen": "11.0.3", "@chevrotain/gast": "11.0.3", "@chevrotain/regexp-to-ast": "11.0.3", "@chevrotain/types": "11.0.3", "@chevrotain/utils": "11.0.3", "lodash-es": "4.17.21" } }, "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw=="],
"chevrotain-allstar": ["chevrotain-allstar@0.3.1", "", { "dependencies": { "lodash-es": "^4.17.21" }, "peerDependencies": { "chevrotain": "^11.0.0" } }, "sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw=="],
@@ -244,6 +283,8 @@
"cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="],
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
"commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
"content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="],
@@ -346,8 +387,12 @@
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
"dompurify": ["dompurify@3.4.5", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-OrwIBKsdNSVEeubdJ1HBv/wNENRM9ytAVCv7YXt//A3vPdVMNuACRqK9mXCGCBW2ln7BT/A4X0jXHo2Gu89miA=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
@@ -420,12 +465,20 @@
"hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
"hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="],
"hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
"hono": ["hono@4.12.23", "", {}, "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA=="],
"html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
"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=="],
@@ -482,16 +535,30 @@
"mcp-handler": ["mcp-handler@1.1.0", "", { "dependencies": { "chalk": "^5.3.0", "commander": "^11.1.0", "redis": "^4.6.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "1.26.0", "next": ">=13.0.0" }, "optionalPeers": ["next"], "bin": { "mcp-adapter": "dist/cli/index.js", "mcp-handler": "dist/cli/index.js", "create-mcp-route": "dist/cli/index.js" } }, "sha512-MVCES7g18gcoZy+R/3v5nadkUMzMAWdos8jRl6DyljOKvd2/ZKDmwlCjL6zp4vo+7FeCXOYL1uWinHWlkKAAUg=="],
"mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="],
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
"merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
"mermaid": ["mermaid@11.15.0", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.1", "@iconify/utils": "^3.0.2", "@mermaid-js/parser": "^1.1.1", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", "cytoscape": "^3.33.1", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.14", "dayjs": "^1.11.19", "dompurify": "^3.3.1", "es-toolkit": "^1.45.1", "katex": "^0.16.25", "khroma": "^2.1.0", "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" } }, "sha512-pTMbcf3rWdtLiYGpmoTjHEpeY8seiy6sR+9nD7LOs8KfUbHE4lOUAprTRqRAcWSQ6MQpdX+YEsxShtGsINtPtw=="],
"micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
"micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
"micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="],
"micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
"micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
"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=="],
@@ -510,6 +577,10 @@
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"oniguruma-parser": ["oniguruma-parser@0.12.2", "", {}, "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw=="],
"oniguruma-to-es": ["oniguruma-to-es@4.3.6", "", { "dependencies": { "oniguruma-parser": "^0.12.2", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA=="],
"open-color": ["open-color@1.9.1", "", {}, "sha512-vCseG/EQ6/RcvxhUcGJiHViOgrtz4x0XbZepXvKik66TMGkvbmjeJrKFyBEx6daG5rNyyd14zYXhz0hZVwQFOw=="],
"package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="],
@@ -528,7 +599,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 +615,12 @@
"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=="],
"property-information": ["property-information@7.2.0", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="],
"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=="],
@@ -566,6 +645,12 @@
"redis": ["redis@4.7.1", "", { "dependencies": { "@redis/bloom": "1.2.0", "@redis/client": "1.6.1", "@redis/graph": "1.1.1", "@redis/json": "1.0.7", "@redis/search": "1.2.0", "@redis/time-series": "1.1.0" } }, "sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ=="],
"regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="],
"regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="],
"regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"robust-predicates": ["robust-predicates@3.0.3", "", {}, "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA=="],
@@ -592,6 +677,8 @@
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"shiki": ["shiki@4.1.0", "", { "dependencies": { "@shikijs/core": "4.1.0", "@shikijs/engine-javascript": "4.1.0", "@shikijs/engine-oniguruma": "4.1.0", "@shikijs/langs": "4.1.0", "@shikijs/themes": "4.1.0", "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-l/ABZPUR5v70jI10EzqfMS/I96vjSGv2y0ihUV+WYFzv0EfvW4s54m0Lg8wCrrL+2IkwBzFTuxkZjPf8b2NX9Q=="],
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
"side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="],
@@ -600,20 +687,28 @@
"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=="],
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
"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=="],
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
"ts-dedent": ["ts-dedent@2.2.0", "", {}, "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
@@ -626,6 +721,16 @@
"undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
"unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
"unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],
"unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
"unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="],
"unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
@@ -638,6 +743,10 @@
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
"vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="],
"vscode-languageserver": ["vscode-languageserver@9.0.1", "", { "dependencies": { "vscode-languageserver-protocol": "3.17.5" }, "bin": { "installServerIntoExtension": "bin/installServerIntoExtension" } }, "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g=="],
@@ -664,6 +773,10 @@
"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=="],
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
"@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 +821,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 +847,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=="],
+12
View File
@@ -8,10 +8,15 @@
"build:client": "bun build --target=browser --production --outdir ./dist/public ./src/client/index.html",
"build:server": "mkdir -p ./dist/server && bun build --target=bun --production --outfile ./dist/server/server.js ./src/server/http-server.tsx",
"build:mcp": "mkdir -p ./dist/mcp && bun build --target=bun --production --outfile ./dist/mcp/mcp-server.js ./src/mcp/server.ts",
"format": "prettier --write .",
"postinstall": "simple-git-hooks",
"start": "DATABASE_PATH=./data/excalidraw.sqlite bun ./dist/server/server.js",
"test": "bun test",
"typecheck": "tsc --noEmit"
},
"simple-git-hooks": {
"pre-commit": "bunx pretty-quick --staged"
},
"dependencies": {
"@excalidraw/excalidraw": "^0.18.1",
"@modelcontextprotocol/sdk": "^1.29.0",
@@ -19,12 +24,19 @@
"mcp-handler": "^1.1.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"shiki": "^4.1.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@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
+286 -2
View File
@@ -1,12 +1,58 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
useCallback,
useEffect,
useEffectEvent,
useRef,
useState,
} from "react";
import { CaptureUpdateAction } from "@excalidraw/excalidraw";
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
import { DrawingSidebar, DrawingsToggle } from "./components/DrawingSidebar";
import {
CodeBlockSidebar,
InsertCodeBlockButton,
} from "./components/CodeBlockSidebar";
import { renderCodeBlockEmbeddable } from "./components/CodeBlockEmbeddable";
import { EditorCanvas } from "./components/EditorCanvas";
import { PublicViewer } from "./components/PublicViewer";
import {
createCodeBlockElement,
DEFAULT_CODE_BLOCK_HEIGHT,
DEFAULT_CODE_BLOCK_WIDTH,
EMPTY_CODE_BLOCK_SELECTION_STATE,
updateCodeBlockElements,
type CodeBlockDraftState,
type CodeBlockLanguage,
type CodeBlockSelectionState,
} from "./codeblock";
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 [codeBlockSelection, setCodeBlockSelection] =
useState<CodeBlockSelectionState>(EMPTY_CODE_BLOCK_SELECTION_STATE);
const [codeBlockDraft, setCodeBlockDraft] =
useState<CodeBlockDraftState | null>(null);
const appShellRef = useRef<HTMLDivElement | null>(null);
const excalidrawApiRef = useRef<ExcalidrawImperativeAPI | null>(null);
const codeBlockDraftRef = useRef<CodeBlockDraftState | null>(null);
const scheduleThemeTokenSync = useThemeTokenSync(appShellRef);
const {
drawings,
@@ -17,14 +63,52 @@ export function App() {
error,
toastMessage,
editorReloadNonce,
publication,
publicationSlug,
publicationBusy,
setActiveTitle,
submitTitle,
handleSceneChange,
selectDrawing,
createDrawing,
deleteDrawing,
setPublicationSlug,
publishPublication,
disablePublication,
} = useDrawingSession();
const flushCodeBlockDraft = useEffectEvent(() => {
const api = excalidrawApiRef.current;
const draft = codeBlockDraftRef.current;
if (!api || !draft) {
return;
}
const currentElements = api.getSceneElementsIncludingDeleted();
const nextElements = updateCodeBlockElements(
currentElements,
draft.elementId,
{
code: draft.code,
language: draft.language,
showBackground: draft.showBackground,
},
);
if (nextElements === currentElements) {
return;
}
api.updateScene({
elements: nextElements,
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
});
useEffect(() => {
codeBlockDraftRef.current = codeBlockDraft;
}, [codeBlockDraft]);
useEffect(() => {
if (!scene || loading) {
return;
@@ -50,6 +134,62 @@ export function App() {
};
}, [isSidebarOpen]);
useEffect(() => {
return () => {
flushCodeBlockDraft();
};
}, []);
useEffect(() => {
const selectedCodeBlock = codeBlockSelection.selectedCodeBlock;
setCodeBlockDraft((current) => {
const nextDraft = selectedCodeBlock
? {
elementId: selectedCodeBlock.id,
code: selectedCodeBlock.customData.code,
language: selectedCodeBlock.customData.language,
showBackground: selectedCodeBlock.customData.showBackground,
}
: null;
if (
current?.elementId === nextDraft?.elementId &&
current?.code === nextDraft?.code &&
current?.language === nextDraft?.language &&
current?.showBackground === nextDraft?.showBackground
) {
return current;
}
return nextDraft;
});
return () => {
flushCodeBlockDraft();
};
}, [codeBlockSelection.selectedCodeBlockId]);
useEffect(() => {
if (!codeBlockDraft) {
return;
}
const timeoutId = window.setTimeout(() => {
flushCodeBlockDraft();
}, 250);
return () => {
clearTimeout(timeoutId);
};
}, [codeBlockDraft]);
useEffect(() => {
if (loading) {
setCodeBlockSelection(EMPTY_CODE_BLOCK_SELECTION_STATE);
setCodeBlockDraft(null);
}
}, [loading]);
const openSidebar = useCallback(() => {
setIsSidebarOpen(true);
}, []);
@@ -71,6 +211,106 @@ export function App() {
void createDrawing();
}, [createDrawing]);
const handleInsertCodeBlock = useCallback(() => {
const api = excalidrawApiRef.current;
if (!api) {
return;
}
const appState = api.getAppState();
const viewportWidth =
typeof appState.width === "number" ? appState.width : window.innerWidth;
const viewportHeight =
typeof appState.height === "number"
? appState.height
: window.innerHeight;
const zoom = appState.zoom.value;
const x =
-appState.scrollX +
viewportWidth / (2 * zoom) -
DEFAULT_CODE_BLOCK_WIDTH / 2;
const y =
-appState.scrollY +
viewportHeight / (2 * zoom) -
DEFAULT_CODE_BLOCK_HEIGHT / 2;
const codeBlock = createCodeBlockElement({ x, y });
api.updateScene({
elements: [...api.getSceneElementsIncludingDeleted(), codeBlock],
appState: {
selectedElementIds: {
[codeBlock.id]: true,
},
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
}, []);
const handleCodeBlockSelectionChange = useCallback(
(selection: CodeBlockSelectionState) => {
setCodeBlockSelection((current) => {
if (
current.isPanelOpen === selection.isPanelOpen &&
current.selectedCodeBlockId === selection.selectedCodeBlockId &&
current.selectedCodeBlock?.customData.code ===
selection.selectedCodeBlock?.customData.code &&
current.selectedCodeBlock?.customData.language ===
selection.selectedCodeBlock?.customData.language &&
current.selectedCodeBlock?.customData.showBackground ===
selection.selectedCodeBlock?.customData.showBackground
) {
return current;
}
return selection;
});
},
[],
);
const handleCodeBlockCodeChange = useCallback((code: string) => {
setCodeBlockDraft((current) =>
current
? {
...current,
code,
}
: current,
);
}, []);
const handleCodeBlockLanguageChange = useCallback(
(language: CodeBlockLanguage) => {
setCodeBlockDraft((current) =>
current
? {
...current,
language,
}
: current,
);
},
[],
);
const handleCodeBlockShowBackgroundChange = useCallback(
(showBackground: boolean) => {
setCodeBlockDraft((current) =>
current
? {
...current,
showBackground,
}
: current,
);
},
[],
);
const handleExcalidrawAPI = useCallback((api: ExcalidrawImperativeAPI) => {
excalidrawApiRef.current = api;
}, []);
return (
<div className="app-shell" ref={appShellRef}>
{toastMessage && (
@@ -78,6 +318,10 @@ export function App() {
{toastMessage}
</div>
)}
<InsertCodeBlockButton
onClick={handleInsertCodeBlock}
disabled={loading || scene === null}
/>
<main className="editor-shell">
<div className="app-actions">
<DrawingsToggle onClick={openSidebar} />
@@ -90,7 +334,18 @@ export function App() {
error={error}
editorReloadNonce={editorReloadNonce}
onSceneChange={handleSceneChange}
onSelectionStateChange={handleCodeBlockSelectionChange}
onEditorActivity={scheduleThemeTokenSync}
onExcalidrawAPI={handleExcalidrawAPI}
renderEmbeddable={renderCodeBlockEmbeddable}
/>
<CodeBlockSidebar
open={codeBlockSelection.isPanelOpen}
draft={codeBlockDraft}
onCodeChange={handleCodeBlockCodeChange}
onLanguageChange={handleCodeBlockLanguageChange}
onShowBackgroundChange={handleCodeBlockShowBackgroundChange}
onCommit={flushCodeBlockDraft}
/>
</main>
<DrawingSidebar
@@ -98,13 +353,42 @@ 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}
renderEmbeddable={renderCodeBlockEmbeddable}
/>
);
}
export function App() {
const route = routeFromPath();
if (route.type === "public") {
return <PublicApp slug={route.slug} />;
}
return <PrivateApp />;
}
+202
View File
@@ -0,0 +1,202 @@
import { describe, expect, test } from "bun:test";
import {
DEFAULT_CODE_BLOCK_SHOW_BACKGROUND,
EMPTY_CODE_BLOCK_SELECTION_STATE,
createCodeBlockCustomData,
getCodeBlockSelectionState,
getCodeBlockCustomData,
isCodeBlockEmbeddable,
sanitizeCodeBlockLanguage,
updateCodeBlockElements,
} from "./codeblock";
const baseEmbeddable = {
id: "codeblock-1",
type: "embeddable" as const,
x: 10,
y: 20,
strokeColor: "transparent",
backgroundColor: "transparent",
fillStyle: "solid" as const,
strokeWidth: 1,
strokeStyle: "solid" as const,
roundness: null,
roughness: 0,
opacity: 100,
width: 420,
height: 260,
angle: 0,
seed: 1,
version: 1,
versionNonce: 2,
index: null,
isDeleted: false,
groupIds: [],
frameId: null,
boundElements: null,
updated: 1,
link: null,
locked: false,
};
describe("codeblock helpers", () => {
test("type guard accepts only repo-owned codeblock embeddables", () => {
expect(
isCodeBlockEmbeddable({
...baseEmbeddable,
customData: createCodeBlockCustomData(),
}),
).toBe(true);
expect(
isCodeBlockEmbeddable({
...baseEmbeddable,
customData: {
excaliType: "other",
version: 2,
code: "",
language: "tsx",
showBackground: true,
},
}),
).toBe(false);
expect(
isCodeBlockEmbeddable({
...baseEmbeddable,
link: "https://example.com",
customData: createCodeBlockCustomData(),
}),
).toBe(false);
expect(
isCodeBlockEmbeddable({
...baseEmbeddable,
type: "iframe",
customData: createCodeBlockCustomData(),
}),
).toBe(false);
expect(
isCodeBlockEmbeddable({
...baseEmbeddable,
customData: {
excaliType: "codeblock",
version: 1,
code: "",
language: "tsx",
showBackground: true,
},
}),
).toBe(false);
});
test("create helper defaults background to enabled", () => {
expect(createCodeBlockCustomData().showBackground).toBe(
DEFAULT_CODE_BLOCK_SHOW_BACKGROUND,
);
});
test("update helper preserves unrelated fields and non-codeblock embeddables", () => {
const codeBlock = {
...baseEmbeddable,
customData: createCodeBlockCustomData({ code: "const a = 1;" }),
};
const foreignEmbeddable = {
...baseEmbeddable,
id: "embed-2",
customData: { source: "foreign" },
};
const elements = [codeBlock, foreignEmbeddable];
const nextElements = updateCodeBlockElements(elements, "codeblock-1", {
code: "const a = 2;",
language: "ts",
showBackground: false,
});
expect(nextElements).not.toBe(elements);
expect(nextElements[0]).not.toBe(codeBlock);
expect(nextElements[0]).toMatchObject({
id: "codeblock-1",
x: 10,
y: 20,
width: 420,
customData: {
excaliType: "codeblock",
version: 2,
code: "const a = 2;",
language: "typescript",
showBackground: false,
},
});
expect(nextElements[1]).toBe(foreignEmbeddable);
expect(
updateCodeBlockElements(elements, "embed-2", { code: "ignored" }),
).toBe(elements);
});
test("selection helper opens only for a single selected codeblock", () => {
const codeBlock = {
...baseEmbeddable,
customData: createCodeBlockCustomData(),
};
const other = {
...baseEmbeddable,
id: "shape-2",
type: "rectangle" as const,
};
expect(
getCodeBlockSelectionState([codeBlock, other], {
selectedElementIds: { "codeblock-1": true },
}),
).toMatchObject({
isPanelOpen: true,
selectedCodeBlockId: "codeblock-1",
selectedCodeBlock: {
customData: {
showBackground: true,
},
},
});
expect(
getCodeBlockSelectionState([codeBlock, other], {
selectedElementIds: { "codeblock-1": true, "shape-2": true },
}),
).toEqual(EMPTY_CODE_BLOCK_SELECTION_STATE);
expect(
getCodeBlockSelectionState([codeBlock, other], {
selectedElementIds: { "shape-2": true },
}),
).toEqual(EMPTY_CODE_BLOCK_SELECTION_STATE);
});
test("language sanitization accepts shiki aliases and normalizes them", () => {
expect(sanitizeCodeBlockLanguage("ts")).toBe("typescript");
expect(sanitizeCodeBlockLanguage("js")).toBe("javascript");
expect(sanitizeCodeBlockLanguage("md")).toBe("markdown");
expect(sanitizeCodeBlockLanguage("bash")).toBe("shellscript");
expect(sanitizeCodeBlockLanguage("plaintext")).toBe("plaintext");
});
test("custom data reads normalize persisted shiki aliases", () => {
const codeBlock = {
...baseEmbeddable,
customData: {
excaliType: "codeblock" as const,
version: 2 as const,
code: "echo hi",
language: "bash" as const,
showBackground: true,
},
};
expect(getCodeBlockCustomData(codeBlock)).toMatchObject({
code: "echo hi",
language: "shellscript",
});
});
});
+327
View File
@@ -0,0 +1,327 @@
import type { AppState } from "@excalidraw/excalidraw/types";
import type {
ExcalidrawEmbeddableElement,
ExcalidrawElement,
NonDeleted,
} from "@excalidraw/excalidraw/element/types";
import {
bundledLanguages,
bundledLanguagesInfo,
type BundledLanguage,
} from "shiki";
export type CodeBlockLanguage = "plaintext" | BundledLanguage;
export type CodeBlockCustomData = {
excaliType: "codeblock";
version: 2;
code: string;
language: CodeBlockLanguage;
showBackground: boolean;
};
export type CodeBlockElement = NonDeleted<ExcalidrawEmbeddableElement> & {
customData: CodeBlockCustomData;
};
export type CodeBlockSelectionState = {
isPanelOpen: boolean;
selectedCodeBlock: CodeBlockElement | null;
selectedCodeBlockId: string | null;
};
export type CodeBlockDraftState = {
elementId: string;
code: string;
language: CodeBlockLanguage;
showBackground: boolean;
};
const SHIKI_LANGUAGE_SET = new Set<string>(Object.keys(bundledLanguages));
const SHIKI_LANGUAGE_ALIAS_TO_ID = new Map<string, BundledLanguage>(
bundledLanguagesInfo.flatMap(({ id, aliases }) =>
(aliases ?? []).map((alias) => [alias, id as BundledLanguage] as const),
),
);
export const CODE_BLOCK_LANGUAGES = Object.freeze([
"plaintext",
...bundledLanguagesInfo
.map(({ id }) => id as BundledLanguage)
.sort((left, right) => left.localeCompare(right)),
]) as readonly CodeBlockLanguage[];
export const DEFAULT_CODE_BLOCK_LANGUAGE: CodeBlockLanguage = "tsx";
export const DEFAULT_CODE_BLOCK_CODE = [
"export function Example() {",
' return <button type="button">Click me</button>;',
"}",
].join("\n");
export const DEFAULT_CODE_BLOCK_WIDTH = 420;
export const DEFAULT_CODE_BLOCK_HEIGHT = 260;
export const CODE_BLOCK_SHIKI_THEME = "poimandres";
export const DEFAULT_CODE_BLOCK_SHOW_BACKGROUND = true;
export const EMPTY_CODE_BLOCK_SELECTION_STATE: CodeBlockSelectionState = {
isPanelOpen: false,
selectedCodeBlock: null,
selectedCodeBlockId: null,
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function randomInt(): number {
return (
globalThis.crypto?.getRandomValues(new Uint32Array(1))[0] ??
Math.floor(Math.random() * 2 ** 31)
);
}
function createElementId(): string {
if (typeof globalThis.crypto?.randomUUID === "function") {
// randomUUID is only available in https context
return globalThis.crypto.randomUUID();
}
const bytes = new Uint8Array(16);
globalThis.crypto?.getRandomValues(bytes);
// Mark the identifier as RFC 4122 version 4 when Web Crypto UUID support is
// unavailable, such as Firefox over plain HTTP.
bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x40;
bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80;
const segments = [
bytes.subarray(0, 4),
bytes.subarray(4, 6),
bytes.subarray(6, 8),
bytes.subarray(8, 10),
bytes.subarray(10, 16),
];
return segments
.map((segment) =>
Array.from(segment, (byte) => byte.toString(16).padStart(2, "0")).join(
"",
),
)
.join("-");
}
function getCanonicalCodeBlockLanguage(
language: unknown,
): CodeBlockLanguage | null {
if (language === "plaintext") {
return language;
}
if (typeof language !== "string" || !SHIKI_LANGUAGE_SET.has(language)) {
return null;
}
return (
SHIKI_LANGUAGE_ALIAS_TO_ID.get(language) ?? (language as BundledLanguage)
);
}
export function sanitizeCodeBlockLanguage(
language: unknown,
): CodeBlockLanguage {
return getCanonicalCodeBlockLanguage(language) ?? DEFAULT_CODE_BLOCK_LANGUAGE;
}
export function createCodeBlockCustomData(
overrides?: Partial<
Pick<CodeBlockCustomData, "code" | "language" | "showBackground">
>,
): CodeBlockCustomData {
return {
excaliType: "codeblock",
version: 2,
code: overrides?.code ?? DEFAULT_CODE_BLOCK_CODE,
language: sanitizeCodeBlockLanguage(overrides?.language),
showBackground:
overrides?.showBackground ?? DEFAULT_CODE_BLOCK_SHOW_BACKGROUND,
};
}
export function isCodeBlockCustomData(
value: unknown,
): value is CodeBlockCustomData {
return (
isRecord(value) &&
value.excaliType === "codeblock" &&
value.version === 2 &&
typeof value.code === "string" &&
getCanonicalCodeBlockLanguage(value.language) !== null &&
typeof value.showBackground === "boolean"
);
}
export function isCodeBlockEmbeddable(
element: unknown,
): element is CodeBlockElement {
return (
isRecord(element) &&
element.type === "embeddable" &&
element.link === null &&
isCodeBlockCustomData(element.customData)
);
}
export function getCodeBlockCustomData(
element: unknown,
): CodeBlockCustomData | null {
if (!isCodeBlockEmbeddable(element)) {
return null;
}
const language = sanitizeCodeBlockLanguage(element.customData.language);
if (language === element.customData.language) {
return element.customData;
}
return {
...element.customData,
language,
};
}
export function createCodeBlockElement({
x,
y,
width = DEFAULT_CODE_BLOCK_WIDTH,
height = DEFAULT_CODE_BLOCK_HEIGHT,
}: {
x: number;
y: number;
width?: number;
height?: number;
}): CodeBlockElement {
const now = Date.now();
return {
id: createElementId(),
type: "embeddable",
x,
y,
strokeColor: "transparent",
backgroundColor: "transparent",
fillStyle: "solid",
strokeWidth: 1,
strokeStyle: "solid",
roundness: null,
roughness: 0,
opacity: 100,
width,
height,
angle: 0,
seed: randomInt(),
version: 1,
versionNonce: randomInt(),
index: null,
isDeleted: false,
groupIds: [],
frameId: null,
boundElements: null,
updated: now,
link: null,
locked: false,
customData: createCodeBlockCustomData(),
};
}
export function updateCodeBlockElement<TElement extends ExcalidrawElement>(
element: TElement,
patch: Partial<
Pick<CodeBlockCustomData, "code" | "language" | "showBackground">
>,
): TElement {
if (!isCodeBlockEmbeddable(element)) {
return element;
}
const nextCustomData = {
...element.customData,
...(patch.code !== undefined ? { code: patch.code } : {}),
...(patch.language !== undefined
? { language: sanitizeCodeBlockLanguage(patch.language) }
: {}),
...(patch.showBackground !== undefined
? { showBackground: patch.showBackground }
: {}),
} satisfies CodeBlockCustomData;
if (
nextCustomData.code === element.customData.code &&
nextCustomData.language === element.customData.language &&
nextCustomData.showBackground === element.customData.showBackground
) {
return element;
}
return {
...element,
customData: nextCustomData,
};
}
export function updateCodeBlockElements<TElement extends ExcalidrawElement>(
elements: readonly TElement[],
elementId: string,
patch: Partial<
Pick<CodeBlockCustomData, "code" | "language" | "showBackground">
>,
): readonly TElement[] {
let changed = false;
const nextElements = elements.map((element) => {
if (element.id !== elementId) {
return element;
}
const nextElement = updateCodeBlockElement(element, patch);
changed ||= nextElement !== element;
return nextElement;
});
return changed ? nextElements : elements;
}
export function getCodeBlockSelectionState(
elements: readonly ExcalidrawElement[],
appState: Pick<AppState, "selectedElementIds">,
): CodeBlockSelectionState {
const selectedIds = Object.entries(appState.selectedElementIds ?? {})
.filter(([, selected]) => selected)
.map(([elementId]) => elementId);
if (selectedIds.length !== 1) {
return EMPTY_CODE_BLOCK_SELECTION_STATE;
}
const selectedCodeBlock = elements.find(
(element) =>
element.id === selectedIds[0] && isCodeBlockEmbeddable(element),
);
if (!selectedCodeBlock || !isCodeBlockEmbeddable(selectedCodeBlock)) {
return EMPTY_CODE_BLOCK_SELECTION_STATE;
}
const customData = getCodeBlockCustomData(selectedCodeBlock);
if (!customData) {
return EMPTY_CODE_BLOCK_SELECTION_STATE;
}
return {
isPanelOpen: true,
selectedCodeBlock: {
...selectedCodeBlock,
customData,
},
selectedCodeBlockId: selectedCodeBlock.id,
};
}
+37
View File
@@ -0,0 +1,37 @@
import { getSingletonHighlighter, type Highlighter } from "shiki";
import { CODE_BLOCK_SHIKI_THEME, type CodeBlockLanguage } from "./codeblock";
let highlighterPromise: Promise<Highlighter> | null = null;
export function getCodeBlockHighlighter() {
if (!highlighterPromise) {
highlighterPromise = getSingletonHighlighter({
themes: [CODE_BLOCK_SHIKI_THEME],
});
}
return highlighterPromise;
}
export function getHighlightableCodeBlockLanguage(
language: CodeBlockLanguage,
): Exclude<CodeBlockLanguage, "plaintext"> | null {
return language === "plaintext" ? null : language;
}
export async function renderHighlightedCodeBlockHtml(
code: string,
language: CodeBlockLanguage,
): Promise<string | null> {
const shikiLanguage = getHighlightableCodeBlockLanguage(language);
if (!shikiLanguage) {
return null;
}
const highlighter = await getCodeBlockHighlighter();
await highlighter.loadLanguage(shikiLanguage);
return highlighter.codeToHtml(code, {
lang: shikiLanguage,
theme: CODE_BLOCK_SHIKI_THEME,
});
}
@@ -0,0 +1,79 @@
import { memo, useEffect, useState } from "react";
import type { ExcalidrawProps } from "@excalidraw/excalidraw/types";
import type {
ExcalidrawEmbeddableElement,
NonDeleted,
} from "@excalidraw/excalidraw/element/types";
import { getCodeBlockCustomData, isCodeBlockEmbeddable } from "../codeblock";
import { renderHighlightedCodeBlockHtml } from "../codeblockHighlight";
type CodeBlockEmbeddableProps = {
element: NonDeleted<ExcalidrawEmbeddableElement>;
};
const CodeBlockEmbeddable = memo(function CodeBlockEmbeddable({
element,
}: CodeBlockEmbeddableProps) {
const customData = getCodeBlockCustomData(element);
const [html, setHtml] = useState<string | null>(null);
useEffect(() => {
if (!customData) {
setHtml(null);
return;
}
let cancelled = false;
void renderHighlightedCodeBlockHtml(customData.code, customData.language)
.then((nextHtml) => {
if (!cancelled) {
setHtml(nextHtml);
}
})
.catch(() => {
if (!cancelled) {
setHtml(null);
}
});
return () => {
cancelled = true;
};
}, [customData]);
if (!customData) {
return null;
}
return (
<div
className={[
"codeblock-embeddable",
customData.showBackground
? "codeblock-surface--background"
: "codeblock-surface--transparent",
].join(" ")}
>
{html ? (
<div
className="codeblock-embeddable-html"
dangerouslySetInnerHTML={{ __html: html }}
/>
) : (
<pre className="codeblock-embeddable-fallback">
<code>{customData.code}</code>
</pre>
)}
</div>
);
});
export const renderCodeBlockEmbeddable: NonNullable<
ExcalidrawProps["renderEmbeddable"]
> = (element) => {
if (!isCodeBlockEmbeddable(element)) {
return null;
}
return <CodeBlockEmbeddable element={element} />;
};
+188
View File
@@ -0,0 +1,188 @@
import { useEffect, useRef, useState } from "react";
import {
CODE_BLOCK_LANGUAGES,
type CodeBlockDraftState,
type CodeBlockLanguage,
} from "../codeblock";
import { renderHighlightedCodeBlockHtml } from "../codeblockHighlight";
type CodeBlockSidebarProps = {
open: boolean;
draft: CodeBlockDraftState | null;
onCodeChange: (code: string) => void;
onLanguageChange: (language: CodeBlockLanguage) => void;
onShowBackgroundChange: (showBackground: boolean) => void;
onCommit: () => void;
};
function CodeBlockEditor({
draft,
onCodeChange,
onCommit,
}: {
draft: CodeBlockDraftState;
onCodeChange: (code: string) => void;
onCommit: () => void;
}) {
const [html, setHtml] = useState<string | null>(null);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const highlightRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
let cancelled = false;
const timeoutId = setTimeout(() => {
void renderHighlightedCodeBlockHtml(draft.code, draft.language)
.then((nextHtml) => {
if (!cancelled) {
setHtml(nextHtml);
}
})
.catch(() => {
if (!cancelled) {
setHtml(null);
}
});
}, 100);
return () => {
cancelled = true;
clearTimeout(timeoutId);
};
}, [draft.code, draft.language]);
const syncScroll = () => {
const textarea = textareaRef.current;
const highlight = highlightRef.current;
if (!textarea || !highlight) {
return;
}
highlight.scrollTop = textarea.scrollTop;
highlight.scrollLeft = textarea.scrollLeft;
};
return (
<div
className={[
"codeblock-editor-surface",
draft.showBackground
? "codeblock-surface--background"
: "codeblock-surface--transparent",
].join(" ")}
>
<div
ref={highlightRef}
className="codeblock-editor-highlight"
aria-hidden="true"
>
{html ? (
<div
className="codeblock-editor-highlight-html"
dangerouslySetInnerHTML={{ __html: html }}
/>
) : (
<pre className="codeblock-editor-highlight-fallback">
<code>{draft.code || " "}</code>
</pre>
)}
</div>
<textarea
ref={textareaRef}
className="codeblock-editor-textarea"
value={draft.code}
onChange={(event) => onCodeChange(event.target.value)}
onBlur={onCommit}
onScroll={syncScroll}
spellCheck={false}
autoCapitalize="none"
autoCorrect="off"
aria-label="Code block contents"
/>
</div>
);
}
function CodeBlockIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path
d="M8 8 4.5 12 8 16M16 8l3.5 4-3.5 4M13 6l-2 12"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export function InsertCodeBlockButton({
onClick,
disabled,
}: {
onClick: () => void;
disabled?: boolean;
}) {
return (
<button
type="button"
className="secondary-button app-actions-toggle codeblock-toolbar-button"
onClick={onClick}
disabled={disabled}
aria-label="Insert code block"
>
<CodeBlockIcon />
<span className="sr-only">Insert code block</span>
</button>
);
}
export function CodeBlockSidebar({
open,
draft,
onCodeChange,
onLanguageChange,
onShowBackgroundChange,
onCommit,
}: CodeBlockSidebarProps) {
if (!open || !draft) {
return null;
}
return (
<aside className="codeblock-sidebar">
<select
className="codeblock-language-select"
value={draft.language}
onChange={(event) =>
onLanguageChange(event.target.value as CodeBlockLanguage)
}
onBlur={onCommit}
aria-label="Code block language"
>
{CODE_BLOCK_LANGUAGES.map((language) => (
<option key={language} value={language}>
{language}
</option>
))}
</select>
<CodeBlockEditor
key={draft.elementId}
draft={draft}
onCodeChange={onCodeChange}
onCommit={onCommit}
/>
<label className="codeblock-background-toggle">
<input
type="checkbox"
checked={draft.showBackground}
onChange={(event) => onShowBackgroundChange(event.target.checked)}
onBlur={onCommit}
/>
<span>Background</span>
</label>
</aside>
);
}
+147 -28
View File
@@ -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>
+23 -4
View File
@@ -1,11 +1,18 @@
import "../../../node_modules/@excalidraw/excalidraw/dist/prod/index.css";
import { memo } from "react";
import { Excalidraw } from "@excalidraw/excalidraw";
import type {
AppState,
BinaryFiles,
ExcalidrawImperativeAPI,
ExcalidrawInitialDataState,
ExcalidrawProps,
} from "@excalidraw/excalidraw/types";
import "../../../node_modules/@excalidraw/excalidraw/dist/prod/index.css";
import { type ScenePayload } from "../../core/shared";
import {
getCodeBlockSelectionState,
type CodeBlockSelectionState,
} from "../codeblock";
type EditorCanvasProps = {
activeId: string | null;
@@ -14,7 +21,10 @@ type EditorCanvasProps = {
error: string | null;
editorReloadNonce: number;
onSceneChange: (scene: ScenePayload) => void;
onSelectionStateChange: (selection: CodeBlockSelectionState) => void;
onEditorActivity: () => void;
onExcalidrawAPI: (api: ExcalidrawImperativeAPI) => void;
renderEmbeddable?: ExcalidrawProps["renderEmbeddable"];
};
function sceneToInitialData(scene: ScenePayload): ExcalidrawInitialDataState {
@@ -27,20 +37,24 @@ 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,
error,
editorReloadNonce,
onSceneChange,
onSelectionStateChange,
onEditorActivity,
onExcalidrawAPI,
renderEmbeddable,
}: EditorCanvasProps) {
if (loading || !scene) {
return <div className="editor-loading">{error ?? "Loading..."}</div>;
@@ -51,11 +65,16 @@ export function EditorCanvas({
<Excalidraw
key={`${activeId}:${editorReloadNonce}`}
initialData={sceneToInitialData(scene)}
excalidrawAPI={onExcalidrawAPI}
renderEmbeddable={renderEmbeddable}
onChange={(elements, appState, files) => {
onEditorActivity();
onSceneChange(sceneFromEditor(elements, appState, files));
onSelectionStateChange(
getCodeBlockSelectionState(elements, appState),
);
}}
/>
</div>
);
}
});
+59
View File
@@ -0,0 +1,59 @@
import { memo } from "react";
import { Excalidraw } from "@excalidraw/excalidraw";
import type {
ExcalidrawInitialDataState,
ExcalidrawProps,
} 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;
renderEmbeddable?: ExcalidrawProps["renderEmbeddable"];
};
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,
renderEmbeddable,
}: 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}
renderEmbeddable={renderEmbeddable}
/>
</div>
);
});
+207 -41
View File
@@ -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,
};
}
+82
View File
@@ -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,
};
}
+13 -1
View File
@@ -21,7 +21,9 @@ const EXCALIDRAW_THEME_TOKEN_MAP = [
["--overlay-bg-color", "--app-overlay-bg"],
] as const;
export function useThemeTokenSync(appShellRef: RefObject<HTMLDivElement | null>) {
export function useThemeTokenSync(
appShellRef: RefObject<HTMLDivElement | null>,
) {
const themeSyncFrameRef = useRef<number | null>(null);
const syncThemeTokens = useCallback(() => {
@@ -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);
}
}
+257 -1
View File
@@ -27,7 +27,9 @@ body {
}
button,
input {
input,
select,
textarea {
font: inherit;
}
@@ -134,6 +136,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 +170,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%;
@@ -163,6 +213,7 @@ input {
bottom: calc(max(16px, env(safe-area-inset-bottom)) + 48px);
z-index: 20;
display: flex;
align-items: flex-end;
}
.app-actions-toggle {
@@ -179,6 +230,13 @@ input {
color: var(--app-text-color);
}
.codeblock-toolbar-button {
position: fixed;
top: max(16px, calc(env(safe-area-inset-top) + 16px));
left: calc(50% + 314px);
z-index: 20;
}
.app-actions-toggle svg {
width: 1rem;
height: 1rem;
@@ -270,10 +328,195 @@ input {
}
.editor-frame,
.public-viewer-shell,
.editor-loading {
height: 100%;
}
.codeblock-sidebar {
position: fixed;
top: 88px;
right: max(16px, calc(env(safe-area-inset-right) + 16px));
bottom: 16px;
z-index: 25;
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
gap: 10px;
width: min(360px, calc(100vw - 32px));
padding: 24px 12px 12px;
border: 1px solid var(--app-sidebar-border);
border-radius: 16px;
background: color-mix(in srgb, var(--app-sidebar-bg) 92%, white);
box-shadow: var(--app-sidebar-shadow);
backdrop-filter: blur(14px);
}
.codeblock-language-select,
.codeblock-editor-surface {
border: 1px solid var(--app-input-border);
color: var(--app-input-color);
}
.codeblock-background-toggle {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--app-text-color);
font-size: var(--app-sidebar-font-size);
}
.codeblock-background-toggle input {
margin: 0;
}
.codeblock-language-select {
border-radius: 10px;
padding: 8px 10px;
background: var(--app-input-bg);
}
.codeblock-editor-surface {
position: relative;
min-height: 0;
overflow: hidden;
border-radius: 10px;
background: transparent;
}
.codeblock-editor-highlight,
.codeblock-editor-highlight-html,
.codeblock-editor-highlight-html :where(pre.shiki),
.codeblock-editor-highlight-fallback,
.codeblock-editor-textarea {
width: 100%;
height: 100%;
}
.codeblock-editor-highlight,
.codeblock-editor-textarea,
.codeblock-editor-highlight-html :where(pre.shiki),
.codeblock-editor-highlight-fallback {
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
monospace;
line-height: 1.5;
}
.codeblock-editor-highlight {
position: absolute;
inset: 0;
overflow: auto;
pointer-events: none;
}
.codeblock-editor-highlight-html :where(pre.shiki),
.codeblock-editor-highlight-fallback {
margin: 0;
padding: 12px;
overflow: hidden;
}
.codeblock-editor-highlight-html :where(code),
.codeblock-editor-highlight-fallback code {
display: block;
min-height: 100%;
white-space: pre;
}
.codeblock-editor-highlight-fallback,
.codeblock-embeddable-fallback {
color: #a6accd;
}
.codeblock-editor-textarea {
position: relative;
min-height: 0;
width: 100%;
height: 100%;
resize: none;
padding: 12px;
border: 0;
background: transparent;
color: transparent;
caret-color: #e4f0fb;
}
.codeblock-editor-textarea::selection {
background: rgba(113, 124, 180, 0.45);
}
.codeblock-editor-textarea:focus {
outline: none;
}
.codeblock-embeddable,
.codeblock-embeddable-html,
.codeblock-embeddable-html :where(pre.shiki),
.codeblock-embeddable-fallback {
width: 100%;
height: 100%;
}
.codeblock-embeddable {
overflow: auto;
border-radius: inherit;
background: transparent;
}
.codeblock-embeddable-html :where(pre.shiki),
.codeblock-embeddable-fallback {
margin: 0;
padding: 16px;
overflow: auto;
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
monospace;
line-height: 1.5;
}
.codeblock-embeddable-html :where(pre.shiki) {
background: transparent !important;
}
.codeblock-surface--background {
background: #1b1e28;
}
.codeblock-surface--background .codeblock-embeddable-html :where(pre.shiki),
.codeblock-surface--background
.codeblock-editor-highlight-html
:where(pre.shiki) {
background: #1b1e28 !important;
}
.codeblock-surface--background .codeblock-embeddable-fallback,
.codeblock-surface--background .codeblock-editor-highlight-fallback {
background: #1b1e28;
}
.codeblock-surface--transparent {
background: transparent;
}
.codeblock-surface--transparent .codeblock-embeddable-html :where(pre.shiki),
.codeblock-surface--transparent
.codeblock-editor-highlight-html
:where(pre.shiki) {
background: transparent !important;
}
.codeblock-surface--transparent .codeblock-embeddable-fallback,
.codeblock-surface--transparent .codeblock-editor-highlight-fallback {
background: transparent;
}
.codeblock-embeddable-html :where(code),
.codeblock-embeddable-fallback code {
display: block;
min-height: 100%;
white-space: pre;
}
.editor-loading {
display: grid;
place-items: center;
@@ -310,4 +553,17 @@ input {
right: max(16px, calc(env(safe-area-inset-right) + 16px));
bottom: calc(max(16px, env(safe-area-inset-bottom)) + 56px);
}
.codeblock-toolbar-button {
left: calc(50% + 254px);
}
.codeblock-sidebar {
top: auto;
left: 16px;
right: 16px;
bottom: calc(max(16px, env(safe-area-inset-bottom)) + 72px);
width: auto;
height: min(44vh, 360px);
}
}
+25
View File
@@ -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;
}
+7 -1
View File
@@ -1,5 +1,11 @@
import { describe, expect, test } from "bun:test";
import { HttpError, MAX_SCENE_BYTES, emptyScene, normalizeScene, parseJsonBody } from "./scene";
import {
HttpError,
MAX_SCENE_BYTES,
emptyScene,
normalizeScene,
parseJsonBody,
} from "./scene";
describe("scene helpers", () => {
test("creates empty scenes with dark theme by default", () => {
+3 -1
View File
@@ -62,7 +62,9 @@ export function normalizeScene(input: unknown): ScenePayload {
}
const appState = Object.fromEntries(
Object.entries(input.appState).filter(([key]) => !VOLATILE_APP_STATE_KEYS.has(key)),
Object.entries(input.appState).filter(
([key]) => !VOLATILE_APP_STATE_KEYS.has(key),
),
);
return {
+13
View File
@@ -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
View File
@@ -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
View File
@@ -47,7 +47,9 @@ function trimTrailingSlash(url: string): string {
return url.replace(/\/+$/, "");
}
export function resolvePublicBaseUrl(raw = process.env.PUBLIC_BASE_URL): string {
export function resolvePublicBaseUrl(
raw = process.env.PUBLIC_BASE_URL,
): string {
const value = raw?.trim();
if (!value) {
throw new Error("PUBLIC_BASE_URL is required for the MCP server");
@@ -58,13 +60,14 @@ export function resolvePublicBaseUrl(raw = process.env.PUBLIC_BASE_URL): string
if (url.protocol === "http:" || url.protocol === "https:") {
return trimTrailingSlash(url.toString());
}
} catch {
}
} catch {}
throw new Error("PUBLIC_BASE_URL must be an absolute http(s) URL");
}
export function resolveStylesGuidePath(raw = DEFAULT_STYLES_GUIDE_PATH): StylesGuideResource | undefined {
export function resolveStylesGuidePath(
raw = DEFAULT_STYLES_GUIDE_PATH,
): StylesGuideResource | undefined {
const value = raw.trim();
if (!isAbsolute(value)) {
@@ -75,7 +78,12 @@ export function resolveStylesGuidePath(raw = DEFAULT_STYLES_GUIDE_PATH): StylesG
try {
stats = statSync(value);
} catch (error) {
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
if (
error &&
typeof error === "object" &&
"code" in error &&
error.code === "ENOENT"
) {
return undefined;
}
@@ -88,7 +96,9 @@ export function resolveStylesGuidePath(raw = DEFAULT_STYLES_GUIDE_PATH): StylesG
const extension = extname(value).toLowerCase();
if (extension !== ".md" && extension !== ".markdown") {
throw new Error("Styles guide path must point to a Markdown file (.md or .markdown)");
throw new Error(
"Styles guide path must point to a Markdown file (.md or .markdown)",
);
}
if (!stats.isFile()) {
@@ -127,7 +137,10 @@ function notFound(id: string): never {
throw new Error(`Drawing not found: ${id}`);
}
function mutationResult(publicBaseUrl: string, drawing: { id: string; title: string; revision: number }) {
function mutationResult(
publicBaseUrl: string,
drawing: { id: string; title: string; revision: number },
) {
return {
id: drawing.id,
title: drawing.title,
@@ -149,16 +162,26 @@ function updateExistingDrawing(
return result.drawing;
}
function applyScenePatch(scene: ScenePayload, patch: Operation[]): ScenePayload {
function applyScenePatch(
scene: ScenePayload,
patch: Operation[],
): ScenePayload {
try {
const result = applyPatch(structuredClone(scene), patch, true, true, true);
return normalizeScene(result.newDocument);
} catch (error) {
throw new Error(error instanceof Error ? `Invalid JSON Patch: ${error.message}` : "Invalid JSON Patch");
throw new Error(
error instanceof Error
? `Invalid JSON Patch: ${error.message}`
: "Invalid JSON Patch",
);
}
}
export function createMcpToolHandlers(store: DrawingStore, publicBaseUrl: string) {
export function createMcpToolHandlers(
store: DrawingStore,
publicBaseUrl: string,
) {
return {
list_drawings() {
return store.listDrawings();
@@ -224,7 +247,8 @@ export function createExcaliMcpHandler(
"excali://styles-guide",
{
title: "Styles Guide",
description: "User-provided drawing styles guide for scene generation",
description:
"User-provided drawing styles guide for scene generation",
mimeType: "text/markdown",
},
async (uri) => ({
@@ -252,7 +276,8 @@ export function createExcaliMcpHandler(
"get_drawing",
{
title: "Get Drawing",
description: "Returns drawing metadata, browser URL, and raw ScenePayload.",
description:
"Returns drawing metadata, browser URL, and raw ScenePayload.",
inputSchema: idInput.shape,
},
async (input) => jsonText(tools.get_drawing(input)),
@@ -262,7 +287,8 @@ export function createExcaliMcpHandler(
"create_drawing",
{
title: "Create Drawing",
description: "Creates a drawing from an explicit Excalidraw ScenePayload.",
description:
"Creates a drawing from an explicit Excalidraw ScenePayload.",
inputSchema: createDrawingInput.shape,
},
async (input) => jsonText(tools.create_drawing(input)),
@@ -282,7 +308,8 @@ export function createExcaliMcpHandler(
"patch_drawing",
{
title: "Patch Drawing",
description: "Applies RFC 6902 JSON Patch operations to a drawing scene and optionally updates its title.",
description:
"Applies RFC 6902 JSON Patch operations to a drawing scene and optionally updates its title.",
inputSchema: patchDrawingInput.shape,
},
async (input) => jsonText(tools.patch_drawing(input)),
@@ -304,7 +331,11 @@ export function createExcaliMcpHandler(
export function createMcpServer() {
const store = createDrawingStore(process.env.DATABASE_PATH);
const handler = createExcaliMcpHandler(store, resolvePublicBaseUrl(), resolveStylesGuidePath());
const handler = createExcaliMcpHandler(
store,
resolvePublicBaseUrl(),
resolveStylesGuidePath(),
);
return {
port: Number(process.env.MCP_PORT ?? "3001"),
@@ -312,7 +343,10 @@ export function createMcpServer() {
fetch(request: Request) {
const url = new URL(request.url);
if (url.pathname !== "/mcp") {
return Response.json({ ok: false, error: "Not found" }, { status: 404 });
return Response.json(
{ ok: false, error: "Not found" },
{ status: 404 },
);
}
return handler(request);
+227 -4
View File
@@ -52,7 +52,9 @@ describe("api", () => {
const { api, cleanup } = withApi();
const response = api.getDrawing(
Object.assign(new Request("http://local/api/drawings/missing"), { params: { id: "missing" } }),
Object.assign(new Request("http://local/api/drawings/missing"), {
params: { id: "missing" },
}),
);
expect(response.status).toBe(404);
@@ -199,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
View File
@@ -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
View File
@@ -79,7 +79,9 @@ describe("drawing store", () => {
expect(stale.ok).toBe(false);
expect(stale.ok ? null : stale.reason).toBe("conflict");
expect(store.getDrawing(created.id)?.revision).toBe(1);
expect(store.getDrawing(created.id)?.scene.elements).toEqual([{ id: "first" }]);
expect(store.getDrawing(created.id)?.scene.elements).toEqual([
{ id: "first" },
]);
});
test("keeps the revision unchanged when the scene is already stored", () => {
@@ -109,13 +111,21 @@ describe("drawing store", () => {
files: {},
};
const first = store.updateDrawing(created.id, { expectedRevision: 0, scene });
const first = store.updateDrawing(created.id, {
expectedRevision: 0,
scene,
});
expect(first.ok ? first.drawing.revision : null).toBe(1);
const repeated = store.updateDrawing(created.id, { expectedRevision: 0, scene });
const repeated = store.updateDrawing(created.id, {
expectedRevision: 0,
scene,
});
expect(repeated.ok).toBe(true);
expect(repeated.ok ? repeated.drawing.revision : null).toBe(1);
expect(store.getDrawing(created.id)?.scene.elements).toEqual([{ id: "same" }]);
expect(store.getDrawing(created.id)?.scene.elements).toEqual([
{ id: "same" },
]);
});
test("increments the revision when the title changes but the scene does not", () => {
@@ -127,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
View File
@@ -2,7 +2,13 @@ import { Database } from "bun:sqlite";
import { randomUUID } from "node:crypto";
import { mkdirSync } from "node:fs";
import { dirname } from "node:path";
import { DEFAULT_TITLE, type DrawingMeta, type 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,
};
}
+11
View File
@@ -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);
});
});
+1
View File
@@ -9,6 +9,7 @@ export function createDevServer() {
routes: {
...server.routes,
"/d/:id": index,
"/p/:slug": index,
},
} satisfies Parameters<typeof Bun.serve>[0];
}
+45 -3
View File
@@ -38,12 +38,16 @@ describe("createServer", () => {
test("redirects / to the latest drawing", () => {
const { server, store } = createServerFixture();
const response = server.routes["/"]?.(new Request("http://local/")) as Response;
const response = server.routes["/"]?.(
new Request("http://local/"),
) as Response;
const created = store.listDrawings();
expect(created).toHaveLength(1);
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe(`http://local/d/${created[0]?.id}`);
expect(response.headers.get("location")).toBe(
`http://local/d/${created[0]?.id}`,
);
});
test("keeps Bun responsible for API routes", async () => {
@@ -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" });
+18 -4
View File
@@ -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];
}