Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c88ac7972 | |||
| 0f984d4dea | |||
| 0a1d70b605 | |||
| 40bf063490 | |||
| 01300c7061 | |||
| 7d36b6c9ea | |||
| e5eaa4f8d7 | |||
| d8d917d315 | |||
| 5887baf324 | |||
| 8db56d9ab7 | |||
| 6cc028305a | |||
| f64d5a73d2 | |||
| 22f13820ab | |||
| 459993a78d | |||
| 95d27c5142 | |||
| b60b921ff8 |
@@ -3,7 +3,7 @@ name: Publish GHCR Image
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'release-*'
|
||||
- "release-*"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -34,6 +34,4 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
||||
|
||||
PLAN.md
|
||||
|
||||
excali-box-data/
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
dist
|
||||
data
|
||||
excali-box-data
|
||||
@@ -5,3 +5,7 @@
|
||||
- Keep commit messages terse.
|
||||
|
||||
Example: `feat: dark mode by default`
|
||||
|
||||
# Decisions on architecture and design
|
||||
|
||||
Read [DECISIONS.md](DECISIONS.md)
|
||||
|
||||
@@ -6,25 +6,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)$
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# a list of architectural decisions
|
||||
|
||||
( to remind me when I over-engineer and be fickle minded )
|
||||
|
||||
## Image is NOT meant to be exposed directly to the internet
|
||||
|
||||
- the defaults MUST take care of this
|
||||
- and hence AUTH won't be a part of the image, whatever the user is using for reverse proxy should handle auth.
|
||||
+5
-1
@@ -2,6 +2,7 @@ FROM oven/bun:1.3.11-alpine AS build
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json bun.lock tsconfig.json ./
|
||||
COPY patches ./patches
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
COPY src ./src
|
||||
@@ -19,14 +20,17 @@ ENV MCP_HOST=127.0.0.1
|
||||
ENV MCP_PORT=3001
|
||||
ENV DATABASE_PATH=/data/excalidraw.sqlite
|
||||
|
||||
RUN mkdir -p /config /data
|
||||
|
||||
COPY --from=caddy /usr/bin/caddy /usr/bin/caddy
|
||||
COPY --from=build /app/dist/public /srv/public
|
||||
COPY --from=build /app/dist/server /app/dist/server
|
||||
COPY --from=build /app/dist/mcp /app/dist/mcp
|
||||
COPY Caddyfile /etc/caddy/Caddyfile
|
||||
COPY STYLES_GUIDE.md /config/styles-guide.md
|
||||
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
RUN mkdir -p /data && chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
VOLUME ["/data"]
|
||||
|
||||
|
||||
@@ -5,8 +5,6 @@ Private self-hosted Excalidraw with Bun, Caddy, and SQLite.
|
||||
This is bare wrapper around the excalidraw packages that adds autosave and disk-persistence.
|
||||
No auth to keep things simple - you either run in a private network or put it behind Caddy auth or similar solutions.
|
||||
|
||||
> GPT 5.5 generated
|
||||
|
||||
## Tl;dr
|
||||
|
||||
When building the image yourself
|
||||
@@ -16,7 +14,6 @@ docker run --rm \
|
||||
-p 127.0.0.1:3000:80 \
|
||||
-e PUBLIC_BASE_URL=http://localhost:3000 \
|
||||
-v "$PWD/excali-box-data:/data" \
|
||||
-v "$PWD/STYLES_GUIDE.md:/config/styles-guide.md:ro" \
|
||||
excali
|
||||
```
|
||||
|
||||
@@ -27,7 +24,6 @@ docker run --rm \
|
||||
-p 127.0.0.1:3000:80 \
|
||||
-e PUBLIC_BASE_URL=http://localhost:3000 \
|
||||
-v "$PWD/excali-box-data:/data" \
|
||||
-v "$PWD/STYLES_GUIDE.md:/config/styles-guide.md:ro" \
|
||||
ghcr.io/ruinivist/excalidraw-box:latest
|
||||
```
|
||||
|
||||
@@ -54,6 +50,7 @@ Open `http://localhost:3000`.
|
||||
## Test
|
||||
|
||||
```bash
|
||||
bun run format
|
||||
bun run test
|
||||
bun run typecheck
|
||||
```
|
||||
@@ -70,7 +67,8 @@ This will make the data persistent in the `excali-box-data` folder in the curren
|
||||
## Optional MCP styles guide resource
|
||||
|
||||
The MCP server can optionally expose one extra read-only resource at `excali://styles-guide`. This file is then
|
||||
discoverable as an MCP resource.
|
||||
discoverable as an MCP resource. Container images already include the repo `STYLES_GUIDE.md` at
|
||||
`/config/styles-guide.md`, so the default guide is exposed automatically in Docker/GHCR.
|
||||
|
||||
```bash
|
||||
docker run --rm \
|
||||
@@ -81,4 +79,5 @@ docker run --rm \
|
||||
ghcr.io/ruinivist/excalidraw-box:latest
|
||||
```
|
||||
|
||||
Repo-root `STYLES_GUIDE.md` which is what I wrote for myself is not used at all unless you explicitly mount it to `/config/styles-guide.md`.
|
||||
Bind-mounting a different file to `/config/styles-guide.md` overrides the bundled default for that container.
|
||||
Local non-container runs are unchanged; if you want a styles guide there, you still need a file at `/config/styles-guide.md`.
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
@@ -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
+284
-2
@@ -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,102 @@ 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,
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="app-shell" ref={appShellRef}>
|
||||
{toastMessage && (
|
||||
@@ -78,6 +314,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 +330,20 @@ export function App() {
|
||||
error={error}
|
||||
editorReloadNonce={editorReloadNonce}
|
||||
onSceneChange={handleSceneChange}
|
||||
onSelectionStateChange={handleCodeBlockSelectionChange}
|
||||
onEditorActivity={scheduleThemeTokenSync}
|
||||
onExcalidrawAPI={(api) => {
|
||||
excalidrawApiRef.current = api;
|
||||
}}
|
||||
renderEmbeddable={renderCodeBlockEmbeddable}
|
||||
/>
|
||||
<CodeBlockSidebar
|
||||
open={codeBlockSelection.isPanelOpen}
|
||||
draft={codeBlockDraft}
|
||||
onCodeChange={handleCodeBlockCodeChange}
|
||||
onLanguageChange={handleCodeBlockLanguageChange}
|
||||
onShowBackgroundChange={handleCodeBlockShowBackgroundChange}
|
||||
onCommit={flushCodeBlockDraft}
|
||||
/>
|
||||
</main>
|
||||
<DrawingSidebar
|
||||
@@ -98,13 +351,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 />;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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} />;
|
||||
};
|
||||
@@ -0,0 +1,184 @@
|
||||
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;
|
||||
void renderHighlightedCodeBlockHtml(draft.code, draft.language)
|
||||
.then((nextHtml) => {
|
||||
if (!cancelled) {
|
||||
setHtml(nextHtml);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setHtml(null);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [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>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +1,51 @@
|
||||
import { type DrawingMeta } from "../../core/shared";
|
||||
import { type DrawingMeta, type DrawingPublication } from "../../core/shared";
|
||||
|
||||
type DrawingSidebarProps = {
|
||||
open: boolean;
|
||||
drawings: DrawingMeta[];
|
||||
activeId: string | null;
|
||||
activeTitle: string;
|
||||
publication: DrawingPublication;
|
||||
publicationSlug: string;
|
||||
publicationBusy: boolean;
|
||||
onClose: () => void;
|
||||
onCreate: () => void;
|
||||
onSelect: (drawingId: string) => void;
|
||||
onDelete: (drawingId: string) => void;
|
||||
onTitleChange: (title: string) => void;
|
||||
onTitleSubmit: () => void;
|
||||
onPublicationSlugChange: (slug: string) => void;
|
||||
onPublish: () => void;
|
||||
onDisablePublication: () => void;
|
||||
};
|
||||
|
||||
function DrawerIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<rect x="3.5" y="5" width="17" height="14" rx="2.5" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
||||
<path d="M9 5v14" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
<path d="M5.75 9h1.5M5.75 12h1.5M5.75 15h1.5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
<rect
|
||||
x="3.5"
|
||||
y="5"
|
||||
width="17"
|
||||
height="14"
|
||||
rx="2.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
/>
|
||||
<path
|
||||
d="M9 5v14"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M5.75 9h1.5M5.75 12h1.5M5.75 15h1.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -42,28 +69,49 @@ export function DrawingSidebar({
|
||||
drawings,
|
||||
activeId,
|
||||
activeTitle,
|
||||
publication,
|
||||
publicationSlug,
|
||||
publicationBusy,
|
||||
onClose,
|
||||
onCreate,
|
||||
onSelect,
|
||||
onDelete,
|
||||
onTitleChange,
|
||||
onTitleSubmit,
|
||||
onPublicationSlugChange,
|
||||
onPublish,
|
||||
onDisablePublication,
|
||||
}: DrawingSidebarProps) {
|
||||
const publicPath = publication.enabled ? `/p/${publication.slug}` : null;
|
||||
const publicationStatus = publication.enabled
|
||||
? `Published at ${publicPath}`
|
||||
: "Not published";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={open ? "sidebar-backdrop sidebar-backdrop-open" : "sidebar-backdrop"}
|
||||
className={
|
||||
open ? "sidebar-backdrop sidebar-backdrop-open" : "sidebar-backdrop"
|
||||
}
|
||||
onClick={onClose}
|
||||
aria-hidden={!open}
|
||||
/>
|
||||
<aside className={open ? "sidebar sidebar-open" : "sidebar"} aria-hidden={!open}>
|
||||
<aside
|
||||
className={open ? "sidebar sidebar-open" : "sidebar"}
|
||||
aria-hidden={!open}
|
||||
>
|
||||
<div className="sidebar-header">
|
||||
<h1>excali-box</h1>
|
||||
<div className="sidebar-actions">
|
||||
<button type="button" className="primary-button" onClick={onCreate}>
|
||||
New
|
||||
</button>
|
||||
<button type="button" className="icon-button" onClick={onClose} aria-label="Close drawings">
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
onClick={onClose}
|
||||
aria-label="Close drawings"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
@@ -72,35 +120,106 @@ export function DrawingSidebar({
|
||||
{drawings.map((drawing) => (
|
||||
<div
|
||||
key={drawing.id}
|
||||
className={drawing.id === activeId ? "drawing-item drawing-item-active" : "drawing-item"}
|
||||
className={
|
||||
drawing.id === activeId
|
||||
? "drawing-item drawing-item-active"
|
||||
: "drawing-item"
|
||||
}
|
||||
>
|
||||
{drawing.id === activeId ? (
|
||||
<div className="drawing-link">
|
||||
<input
|
||||
className="title-input"
|
||||
value={activeTitle}
|
||||
onChange={(event) => onTitleChange(event.target.value)}
|
||||
onBlur={onTitleSubmit}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.currentTarget.blur();
|
||||
<div className="drawing-link drawing-link-active">
|
||||
<div className="drawing-item-header">
|
||||
<input
|
||||
className="title-input"
|
||||
value={activeTitle}
|
||||
onChange={(event) => onTitleChange(event.target.value)}
|
||||
onBlur={onTitleSubmit}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button drawing-delete-button"
|
||||
onClick={() => onDelete(drawing.id)}
|
||||
aria-label={`Delete ${drawing.title}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="publication-panel">
|
||||
<label
|
||||
className="publication-label"
|
||||
htmlFor="publication-slug"
|
||||
>
|
||||
Public name
|
||||
</label>
|
||||
<input
|
||||
id="publication-slug"
|
||||
className="title-input publication-input"
|
||||
value={publicationSlug}
|
||||
onChange={(event) =>
|
||||
onPublicationSlugChange(event.target.value)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
spellCheck={false}
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
/>
|
||||
<div className="publication-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
onClick={onPublish}
|
||||
disabled={publicationBusy}
|
||||
>
|
||||
{publication.enabled ? "Update link" : "Publish"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
onClick={onDisablePublication}
|
||||
disabled={!publication.enabled || publicationBusy}
|
||||
>
|
||||
Unpublish
|
||||
</button>
|
||||
</div>
|
||||
<div className="publication-status">
|
||||
{publication.enabled ? (
|
||||
<a
|
||||
href={publicPath ?? "#"}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="publication-link"
|
||||
>
|
||||
{publicationStatus}
|
||||
</a>
|
||||
) : (
|
||||
<span>{publicationStatus}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button type="button" className="drawing-link" onClick={() => onSelect(drawing.id)}>
|
||||
<button
|
||||
type="button"
|
||||
className="drawing-link"
|
||||
onClick={() => onSelect(drawing.id)}
|
||||
>
|
||||
<span className="drawing-title">{drawing.title}</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
onClick={() => onDelete(drawing.id)}
|
||||
aria-label={`Delete ${drawing.title}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
{drawing.id !== activeId ? (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
onClick={() => onDelete(drawing.id)}
|
||||
aria-label={`Delete ${drawing.title}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,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>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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) {
|
||||
@@ -175,22 +231,30 @@ export function useDrawingSession() {
|
||||
}
|
||||
if (isManual) {
|
||||
showToast("Saved", 2000);
|
||||
} else {
|
||||
showToast(null);
|
||||
}
|
||||
})
|
||||
.catch((saveError: unknown) => {
|
||||
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);
|
||||
} else {
|
||||
showToast("Autosave failed");
|
||||
}
|
||||
throw saveError;
|
||||
})
|
||||
@@ -238,7 +302,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 +333,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 +349,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 +380,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 +398,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 +432,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 +549,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 +605,17 @@ export function useDrawingSession() {
|
||||
error,
|
||||
toastMessage,
|
||||
editorReloadNonce,
|
||||
publication,
|
||||
publicationSlug,
|
||||
publicationBusy,
|
||||
setActiveTitle,
|
||||
submitTitle,
|
||||
handleSceneChange,
|
||||
selectDrawing,
|
||||
createDrawing,
|
||||
deleteDrawing,
|
||||
setPublicationSlug,
|
||||
publishPublication,
|
||||
disablePublication,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { type PublicDrawing } from "../../core/shared";
|
||||
|
||||
type PublicDrawingState = {
|
||||
drawing: PublicDrawing | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
class RequestError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function requestPublicDrawing(slug: string): Promise<PublicDrawing> {
|
||||
const response = await fetch(
|
||||
`/api/public/drawings/${encodeURIComponent(slug)}`,
|
||||
);
|
||||
const body = (await response.json()) as PublicDrawing & { error?: string };
|
||||
|
||||
if (!response.ok) {
|
||||
throw new RequestError(
|
||||
response.status,
|
||||
body.error ?? `Request failed: ${response.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
export function usePublicDrawing(slug: string): PublicDrawingState {
|
||||
const [drawing, setDrawing] = useState<PublicDrawing | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
void requestPublicDrawing(slug)
|
||||
.then((nextDrawing) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDrawing(nextDrawing);
|
||||
document.title = nextDrawing.title;
|
||||
})
|
||||
.catch((loadError: unknown) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDrawing(null);
|
||||
setError(
|
||||
loadError instanceof Error
|
||||
? loadError.message
|
||||
: "Failed to load drawing",
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [slug]);
|
||||
|
||||
return {
|
||||
drawing,
|
||||
loading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
@@ -21,7 +21,9 @@ const EXCALIDRAW_THEME_TOKEN_MAP = [
|
||||
["--overlay-bg-color", "--app-overlay-bg"],
|
||||
] as const;
|
||||
|
||||
export function useThemeTokenSync(appShellRef: RefObject<HTMLDivElement | null>) {
|
||||
export function useThemeTokenSync(
|
||||
appShellRef: RefObject<HTMLDivElement | null>,
|
||||
) {
|
||||
const themeSyncFrameRef = useRef<number | null>(null);
|
||||
|
||||
const syncThemeTokens = useCallback(() => {
|
||||
@@ -31,10 +33,20 @@ export function useThemeTokenSync(appShellRef: RefObject<HTMLDivElement | null>)
|
||||
return;
|
||||
}
|
||||
|
||||
// Read all tokens first to avoid layout thrashing
|
||||
const computedStyles = window.getComputedStyle(excalidrawRoot);
|
||||
const updates: Array<[string, string]> = [];
|
||||
|
||||
for (const [sourceToken, targetToken] of EXCALIDRAW_THEME_TOKEN_MAP) {
|
||||
const value = computedStyles.getPropertyValue(sourceToken).trim();
|
||||
if (value) {
|
||||
updates.push([targetToken, value]);
|
||||
}
|
||||
}
|
||||
|
||||
// Write in a separate pass, skipping unchanged values
|
||||
for (const [targetToken, value] of updates) {
|
||||
if (appShell.style.getPropertyValue(targetToken) !== value) {
|
||||
appShell.style.setProperty(targetToken, value);
|
||||
}
|
||||
}
|
||||
|
||||
+257
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { HttpError } from "./scene";
|
||||
|
||||
const RESERVED_SLUGS = new Set(["api", "d", "p", "mcp"]);
|
||||
|
||||
export function normalizePublicationSlug(input: unknown): string {
|
||||
if (typeof input !== "string") {
|
||||
throw new HttpError(400, "slug must be a string");
|
||||
}
|
||||
|
||||
const normalized = input
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
|
||||
if (normalized.length === 0) {
|
||||
throw new HttpError(400, "slug is required");
|
||||
}
|
||||
|
||||
if (RESERVED_SLUGS.has(normalized)) {
|
||||
throw new HttpError(400, "slug is reserved");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { HttpError, MAX_SCENE_BYTES, emptyScene, normalizeScene, parseJsonBody } from "./scene";
|
||||
import {
|
||||
HttpError,
|
||||
MAX_SCENE_BYTES,
|
||||
emptyScene,
|
||||
normalizeScene,
|
||||
parseJsonBody,
|
||||
} from "./scene";
|
||||
|
||||
describe("scene helpers", () => {
|
||||
test("creates empty scenes with dark theme by default", () => {
|
||||
|
||||
+3
-1
@@ -62,7 +62,9 @@ export function normalizeScene(input: unknown): ScenePayload {
|
||||
}
|
||||
|
||||
const appState = Object.fromEntries(
|
||||
Object.entries(input.appState).filter(([key]) => !VOLATILE_APP_STATE_KEYS.has(key)),
|
||||
Object.entries(input.appState).filter(
|
||||
([key]) => !VOLATILE_APP_STATE_KEYS.has(key),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -4,6 +4,19 @@ export type ScenePayload = {
|
||||
files: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type DrawingPublication =
|
||||
| {
|
||||
enabled: false;
|
||||
}
|
||||
| {
|
||||
enabled: true;
|
||||
slug: string;
|
||||
};
|
||||
|
||||
export type PublicDrawing = {
|
||||
title: string;
|
||||
} & ScenePayload;
|
||||
|
||||
export type DrawingMeta = {
|
||||
id: string;
|
||||
title: string;
|
||||
|
||||
+22
-81
@@ -1,26 +1,17 @@
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { join } from "node:path";
|
||||
import { type ScenePayload } from "../core/shared";
|
||||
import { createDrawingStore, type DrawingStore } from "../server/db";
|
||||
import {
|
||||
DEFAULT_STYLES_GUIDE_PATH,
|
||||
createExcaliMcpHandler,
|
||||
createMcpToolHandlers,
|
||||
resolvePublicBaseUrl,
|
||||
resolveStylesGuidePath,
|
||||
} from "./server";
|
||||
import { createMcpToolHandlers, resolvePublicBaseUrl } from "./server";
|
||||
|
||||
const cleanup: Array<{ dir: string; store?: DrawingStore; client?: Client }> = [];
|
||||
const cleanup: Array<{ dir: string; store?: DrawingStore }> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
while (cleanup.length > 0) {
|
||||
const item = cleanup.pop();
|
||||
if (item) {
|
||||
await item.client?.close().catch(() => undefined);
|
||||
item.store?.close();
|
||||
rmSync(item.dir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -37,35 +28,6 @@ function createTempTools() {
|
||||
};
|
||||
}
|
||||
|
||||
function createTempStylesGuidePath(content?: string) {
|
||||
const dir = mkdtempSync(join(tmpdir(), "excali-mcp-"));
|
||||
const path = join(dir, DEFAULT_STYLES_GUIDE_PATH.slice(1));
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
if (content !== undefined) {
|
||||
writeFileSync(path, content);
|
||||
}
|
||||
cleanup.push({ dir });
|
||||
return path;
|
||||
}
|
||||
|
||||
async function createTempClient(stylesGuidePath?: string) {
|
||||
const dir = mkdtempSync(join(tmpdir(), "excali-mcp-"));
|
||||
const store = createDrawingStore(join(dir, "test.sqlite"));
|
||||
const handler = createExcaliMcpHandler(
|
||||
store,
|
||||
"http://example.test",
|
||||
stylesGuidePath ? resolveStylesGuidePath(stylesGuidePath) : undefined,
|
||||
);
|
||||
const transport = new StreamableHTTPClientTransport(new URL("http://localhost/mcp"), {
|
||||
fetch: async (input, init) => handler(new Request(input, init)),
|
||||
});
|
||||
const client = new Client({ name: "excali-test", version: "1.0.0" });
|
||||
|
||||
await client.connect(transport);
|
||||
cleanup.push({ dir, store, client });
|
||||
return { client, store };
|
||||
}
|
||||
|
||||
function testScene(): ScenePayload {
|
||||
return {
|
||||
elements: [
|
||||
@@ -79,41 +41,9 @@ function testScene(): ScenePayload {
|
||||
|
||||
describe("mcp tool handlers", () => {
|
||||
test("PUBLIC_BASE_URL is required", () => {
|
||||
expect(() => resolvePublicBaseUrl("")).toThrow("PUBLIC_BASE_URL is required");
|
||||
});
|
||||
|
||||
test("missing fixed styles-guide path exposes no styles-guide resource", async () => {
|
||||
const stylesGuidePath = createTempStylesGuidePath();
|
||||
expect(resolveStylesGuidePath(stylesGuidePath)).toBeUndefined();
|
||||
|
||||
const { client } = await createTempClient(stylesGuidePath);
|
||||
|
||||
expect(client.getServerCapabilities()?.resources).toBeUndefined();
|
||||
});
|
||||
|
||||
test("valid fixed styles-guide path registers the styles-guide resource", async () => {
|
||||
const contents = "# Scene style\n\nKeep labels terse.\n";
|
||||
const stylesGuidePath = createTempStylesGuidePath(contents);
|
||||
const resolved = resolveStylesGuidePath(stylesGuidePath);
|
||||
const { client } = await createTempClient(stylesGuidePath);
|
||||
const resources = await client.listResources();
|
||||
const readResult = await client.readResource({ uri: "excali://styles-guide" });
|
||||
|
||||
expect(resolved).toEqual({ path: stylesGuidePath });
|
||||
expect(resources.resources).toContainEqual({
|
||||
name: "styles-guide",
|
||||
uri: "excali://styles-guide",
|
||||
title: "Styles Guide",
|
||||
description: "User-provided drawing styles guide for scene generation",
|
||||
mimeType: "text/markdown",
|
||||
});
|
||||
expect(readResult.contents).toEqual([
|
||||
{
|
||||
uri: "excali://styles-guide",
|
||||
mimeType: "text/markdown",
|
||||
text: contents,
|
||||
},
|
||||
]);
|
||||
expect(() => resolvePublicBaseUrl("")).toThrow(
|
||||
"PUBLIC_BASE_URL is required",
|
||||
);
|
||||
});
|
||||
|
||||
test("create_drawing writes an explicit scene to a temp SQLite DB", () => {
|
||||
@@ -151,14 +81,21 @@ describe("mcp tool handlers", () => {
|
||||
|
||||
test("replace_drawing updates the existing drawing", () => {
|
||||
const { store, tools } = createTempTools();
|
||||
const result = tools.create_drawing({ title: "replace", scene: testScene() });
|
||||
const result = tools.create_drawing({
|
||||
title: "replace",
|
||||
scene: testScene(),
|
||||
});
|
||||
const nextScene: ScenePayload = {
|
||||
elements: [{ id: "manual", type: "rectangle" }],
|
||||
appState: { theme: "dark" },
|
||||
files: {},
|
||||
};
|
||||
|
||||
const replaced = tools.replace_drawing({ id: result.id, title: "replaced", scene: nextScene });
|
||||
const replaced = tools.replace_drawing({
|
||||
id: result.id,
|
||||
title: "replaced",
|
||||
scene: nextScene,
|
||||
});
|
||||
|
||||
expect(store.listDrawings()).toHaveLength(1);
|
||||
expect(replaced.revision).toBe(2);
|
||||
@@ -193,13 +130,17 @@ describe("mcp tool handlers", () => {
|
||||
|
||||
expect(patched.revision).toBe(2);
|
||||
expect(store.getDrawing(result.id)?.revision).toBe(2);
|
||||
expect(store.getDrawing(result.id)?.scene.elements).toEqual([{ id: "one", type: "rectangle", x: 42, y: 20 }]);
|
||||
expect(store.getDrawing(result.id)?.scene.elements).toEqual([
|
||||
{ id: "one", type: "rectangle", x: 42, y: 20 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("missing drawing IDs return tool errors", () => {
|
||||
const { tools } = createTempTools();
|
||||
|
||||
expect(() => tools.get_drawing({ id: "missing" })).toThrow("Drawing not found: missing");
|
||||
expect(() => tools.get_drawing({ id: "missing" })).toThrow(
|
||||
"Drawing not found: missing",
|
||||
);
|
||||
expect(() =>
|
||||
tools.replace_drawing({
|
||||
id: "missing",
|
||||
|
||||
+50
-16
@@ -47,7 +47,9 @@ function trimTrailingSlash(url: string): string {
|
||||
return url.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
export function resolvePublicBaseUrl(raw = process.env.PUBLIC_BASE_URL): string {
|
||||
export function resolvePublicBaseUrl(
|
||||
raw = process.env.PUBLIC_BASE_URL,
|
||||
): string {
|
||||
const value = raw?.trim();
|
||||
if (!value) {
|
||||
throw new Error("PUBLIC_BASE_URL is required for the MCP server");
|
||||
@@ -58,13 +60,14 @@ export function resolvePublicBaseUrl(raw = process.env.PUBLIC_BASE_URL): string
|
||||
if (url.protocol === "http:" || url.protocol === "https:") {
|
||||
return trimTrailingSlash(url.toString());
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
} catch {}
|
||||
|
||||
throw new Error("PUBLIC_BASE_URL must be an absolute http(s) URL");
|
||||
}
|
||||
|
||||
export function resolveStylesGuidePath(raw = DEFAULT_STYLES_GUIDE_PATH): StylesGuideResource | undefined {
|
||||
export function resolveStylesGuidePath(
|
||||
raw = DEFAULT_STYLES_GUIDE_PATH,
|
||||
): StylesGuideResource | undefined {
|
||||
const value = raw.trim();
|
||||
|
||||
if (!isAbsolute(value)) {
|
||||
@@ -75,7 +78,12 @@ export function resolveStylesGuidePath(raw = DEFAULT_STYLES_GUIDE_PATH): StylesG
|
||||
try {
|
||||
stats = statSync(value);
|
||||
} catch (error) {
|
||||
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
|
||||
if (
|
||||
error &&
|
||||
typeof error === "object" &&
|
||||
"code" in error &&
|
||||
error.code === "ENOENT"
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -88,7 +96,9 @@ export function resolveStylesGuidePath(raw = DEFAULT_STYLES_GUIDE_PATH): StylesG
|
||||
|
||||
const extension = extname(value).toLowerCase();
|
||||
if (extension !== ".md" && extension !== ".markdown") {
|
||||
throw new Error("Styles guide path must point to a Markdown file (.md or .markdown)");
|
||||
throw new Error(
|
||||
"Styles guide path must point to a Markdown file (.md or .markdown)",
|
||||
);
|
||||
}
|
||||
|
||||
if (!stats.isFile()) {
|
||||
@@ -127,7 +137,10 @@ function notFound(id: string): never {
|
||||
throw new Error(`Drawing not found: ${id}`);
|
||||
}
|
||||
|
||||
function mutationResult(publicBaseUrl: string, drawing: { id: string; title: string; revision: number }) {
|
||||
function mutationResult(
|
||||
publicBaseUrl: string,
|
||||
drawing: { id: string; title: string; revision: number },
|
||||
) {
|
||||
return {
|
||||
id: drawing.id,
|
||||
title: drawing.title,
|
||||
@@ -149,16 +162,26 @@ function updateExistingDrawing(
|
||||
return result.drawing;
|
||||
}
|
||||
|
||||
function applyScenePatch(scene: ScenePayload, patch: Operation[]): ScenePayload {
|
||||
function applyScenePatch(
|
||||
scene: ScenePayload,
|
||||
patch: Operation[],
|
||||
): ScenePayload {
|
||||
try {
|
||||
const result = applyPatch(structuredClone(scene), patch, true, true, true);
|
||||
return normalizeScene(result.newDocument);
|
||||
} catch (error) {
|
||||
throw new Error(error instanceof Error ? `Invalid JSON Patch: ${error.message}` : "Invalid JSON Patch");
|
||||
throw new Error(
|
||||
error instanceof Error
|
||||
? `Invalid JSON Patch: ${error.message}`
|
||||
: "Invalid JSON Patch",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function createMcpToolHandlers(store: DrawingStore, publicBaseUrl: string) {
|
||||
export function createMcpToolHandlers(
|
||||
store: DrawingStore,
|
||||
publicBaseUrl: string,
|
||||
) {
|
||||
return {
|
||||
list_drawings() {
|
||||
return store.listDrawings();
|
||||
@@ -224,7 +247,8 @@ export function createExcaliMcpHandler(
|
||||
"excali://styles-guide",
|
||||
{
|
||||
title: "Styles Guide",
|
||||
description: "User-provided drawing styles guide for scene generation",
|
||||
description:
|
||||
"User-provided drawing styles guide for scene generation",
|
||||
mimeType: "text/markdown",
|
||||
},
|
||||
async (uri) => ({
|
||||
@@ -252,7 +276,8 @@ export function createExcaliMcpHandler(
|
||||
"get_drawing",
|
||||
{
|
||||
title: "Get Drawing",
|
||||
description: "Returns drawing metadata, browser URL, and raw ScenePayload.",
|
||||
description:
|
||||
"Returns drawing metadata, browser URL, and raw ScenePayload.",
|
||||
inputSchema: idInput.shape,
|
||||
},
|
||||
async (input) => jsonText(tools.get_drawing(input)),
|
||||
@@ -262,7 +287,8 @@ export function createExcaliMcpHandler(
|
||||
"create_drawing",
|
||||
{
|
||||
title: "Create Drawing",
|
||||
description: "Creates a drawing from an explicit Excalidraw ScenePayload.",
|
||||
description:
|
||||
"Creates a drawing from an explicit Excalidraw ScenePayload.",
|
||||
inputSchema: createDrawingInput.shape,
|
||||
},
|
||||
async (input) => jsonText(tools.create_drawing(input)),
|
||||
@@ -282,7 +308,8 @@ export function createExcaliMcpHandler(
|
||||
"patch_drawing",
|
||||
{
|
||||
title: "Patch Drawing",
|
||||
description: "Applies RFC 6902 JSON Patch operations to a drawing scene and optionally updates its title.",
|
||||
description:
|
||||
"Applies RFC 6902 JSON Patch operations to a drawing scene and optionally updates its title.",
|
||||
inputSchema: patchDrawingInput.shape,
|
||||
},
|
||||
async (input) => jsonText(tools.patch_drawing(input)),
|
||||
@@ -304,7 +331,11 @@ export function createExcaliMcpHandler(
|
||||
|
||||
export function createMcpServer() {
|
||||
const store = createDrawingStore(process.env.DATABASE_PATH);
|
||||
const handler = createExcaliMcpHandler(store, resolvePublicBaseUrl(), resolveStylesGuidePath());
|
||||
const handler = createExcaliMcpHandler(
|
||||
store,
|
||||
resolvePublicBaseUrl(),
|
||||
resolveStylesGuidePath(),
|
||||
);
|
||||
|
||||
return {
|
||||
port: Number(process.env.MCP_PORT ?? "3001"),
|
||||
@@ -312,7 +343,10 @@ export function createMcpServer() {
|
||||
fetch(request: Request) {
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname !== "/mcp") {
|
||||
return Response.json({ ok: false, error: "Not found" }, { status: 404 });
|
||||
return Response.json(
|
||||
{ ok: false, error: "Not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
return handler(request);
|
||||
|
||||
+227
-4
@@ -52,7 +52,9 @@ describe("api", () => {
|
||||
const { api, cleanup } = withApi();
|
||||
|
||||
const response = api.getDrawing(
|
||||
Object.assign(new Request("http://local/api/drawings/missing"), { params: { id: "missing" } }),
|
||||
Object.assign(new Request("http://local/api/drawings/missing"), {
|
||||
params: { id: "missing" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
@@ -199,10 +201,231 @@ describe("api", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const stored = await api.getDrawing(
|
||||
Object.assign(new Request(`http://local/api/drawings/${created.id}`), { params: { id: created.id } }),
|
||||
).json();
|
||||
const stored = await api
|
||||
.getDrawing(
|
||||
Object.assign(new Request(`http://local/api/drawings/${created.id}`), {
|
||||
params: { id: created.id },
|
||||
}),
|
||||
)
|
||||
.json();
|
||||
expect(stored.elements).toEqual([{ id: "server" }]);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("returns disabled and enabled publication shapes", async () => {
|
||||
const { api, cleanup } = withApi();
|
||||
const created = await api.createDrawing().json();
|
||||
|
||||
const disabled = await api.getDrawingPublication(
|
||||
Object.assign(
|
||||
new Request(`http://local/api/drawings/${created.id}/publication`),
|
||||
{ params: { id: created.id } },
|
||||
),
|
||||
);
|
||||
expect(disabled.status).toBe(200);
|
||||
expect(await disabled.json()).toEqual({ enabled: false });
|
||||
|
||||
const publish = await api.updateDrawingPublication(
|
||||
Object.assign(
|
||||
new Request(`http://local/api/drawings/${created.id}/publication`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
enabled: true,
|
||||
slug: "Flow Chart",
|
||||
}),
|
||||
}),
|
||||
{ params: { id: created.id } },
|
||||
),
|
||||
);
|
||||
|
||||
expect(publish.status).toBe(200);
|
||||
expect(await publish.json()).toEqual({
|
||||
enabled: true,
|
||||
slug: "flow-chart",
|
||||
});
|
||||
|
||||
const enabled = await api.getDrawingPublication(
|
||||
Object.assign(
|
||||
new Request(`http://local/api/drawings/${created.id}/publication`),
|
||||
{ params: { id: created.id } },
|
||||
),
|
||||
);
|
||||
expect(await enabled.json()).toEqual({
|
||||
enabled: true,
|
||||
slug: "flow-chart",
|
||||
});
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("publishes, renames, and disables publications", async () => {
|
||||
const { api, cleanup } = withApi();
|
||||
const created = await api.createDrawing().json();
|
||||
|
||||
await api.updateDrawingPublication(
|
||||
Object.assign(
|
||||
new Request(`http://local/api/drawings/${created.id}/publication`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
enabled: true,
|
||||
slug: "first",
|
||||
}),
|
||||
}),
|
||||
{ params: { id: created.id } },
|
||||
),
|
||||
);
|
||||
|
||||
const renamed = await api.updateDrawingPublication(
|
||||
Object.assign(
|
||||
new Request(`http://local/api/drawings/${created.id}/publication`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
enabled: true,
|
||||
slug: "second",
|
||||
}),
|
||||
}),
|
||||
{ params: { id: created.id } },
|
||||
),
|
||||
);
|
||||
|
||||
expect(renamed.status).toBe(200);
|
||||
expect(await renamed.json()).toEqual({
|
||||
enabled: true,
|
||||
slug: "second",
|
||||
});
|
||||
|
||||
const disable = await api.updateDrawingPublication(
|
||||
Object.assign(
|
||||
new Request(`http://local/api/drawings/${created.id}/publication`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ enabled: false }),
|
||||
}),
|
||||
{ params: { id: created.id } },
|
||||
),
|
||||
);
|
||||
|
||||
expect(disable.status).toBe(200);
|
||||
expect(await disable.json()).toEqual({ enabled: false });
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("returns 400 for invalid publication slugs", async () => {
|
||||
const { api, cleanup } = withApi();
|
||||
const created = await api.createDrawing().json();
|
||||
|
||||
const response = await api.updateDrawingPublication(
|
||||
Object.assign(
|
||||
new Request(`http://local/api/drawings/${created.id}/publication`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
enabled: true,
|
||||
slug: "API",
|
||||
}),
|
||||
}),
|
||||
{ params: { id: created.id } },
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.json()).toEqual({
|
||||
ok: false,
|
||||
error: "slug is reserved",
|
||||
});
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("returns 409 for publication slug collisions", async () => {
|
||||
const { api, cleanup } = withApi();
|
||||
const first = await api.createDrawing().json();
|
||||
const second = await api.createDrawing().json();
|
||||
|
||||
await api.updateDrawingPublication(
|
||||
Object.assign(
|
||||
new Request(`http://local/api/drawings/${first.id}/publication`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
enabled: true,
|
||||
slug: "shared",
|
||||
}),
|
||||
}),
|
||||
{ params: { id: first.id } },
|
||||
),
|
||||
);
|
||||
|
||||
const response = await api.updateDrawingPublication(
|
||||
Object.assign(
|
||||
new Request(`http://local/api/drawings/${second.id}/publication`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
enabled: true,
|
||||
slug: "shared",
|
||||
}),
|
||||
}),
|
||||
{ params: { id: second.id } },
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(409);
|
||||
expect(await response.json()).toEqual({
|
||||
ok: false,
|
||||
error: "Published slug already exists",
|
||||
});
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("returns public scene data for enabled slugs and 404 otherwise", async () => {
|
||||
const { api, cleanup } = withApi();
|
||||
const created = await api.createDrawing().json();
|
||||
|
||||
await api.updateDrawing(
|
||||
Object.assign(
|
||||
new Request(`http://local/api/drawings/${created.id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
title: "Public Flow",
|
||||
elements: [{ id: "shape" }],
|
||||
appState: { theme: "dark" },
|
||||
files: {},
|
||||
expectedRevision: 0,
|
||||
}),
|
||||
}),
|
||||
{ params: { id: created.id } },
|
||||
),
|
||||
);
|
||||
|
||||
await api.updateDrawingPublication(
|
||||
Object.assign(
|
||||
new Request(`http://local/api/drawings/${created.id}/publication`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
enabled: true,
|
||||
slug: "public-flow",
|
||||
}),
|
||||
}),
|
||||
{ params: { id: created.id } },
|
||||
),
|
||||
);
|
||||
|
||||
const enabled = await api.getPublicDrawing(
|
||||
Object.assign(
|
||||
new Request("http://local/api/public/drawings/public-flow"),
|
||||
{ params: { slug: "public-flow" } },
|
||||
),
|
||||
);
|
||||
|
||||
expect(enabled.status).toBe(200);
|
||||
expect(await enabled.json()).toEqual({
|
||||
title: "Public Flow",
|
||||
elements: [{ id: "shape" }],
|
||||
appState: { theme: "dark" },
|
||||
files: {},
|
||||
});
|
||||
|
||||
const missing = await api.getPublicDrawing(
|
||||
Object.assign(new Request("http://local/api/public/drawings/missing"), {
|
||||
params: { slug: "missing" },
|
||||
}),
|
||||
);
|
||||
expect(missing.status).toBe(404);
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
+127
-6
@@ -1,6 +1,12 @@
|
||||
import { type DrawingStore } from "./db";
|
||||
import { type DrawingMeta } from "../core/shared";
|
||||
import { HttpError, normalizeTitle, parseJsonBody, parseSceneText } from "../core/scene";
|
||||
import { normalizePublicationSlug } from "../core/publication";
|
||||
import { type DrawingMeta, type DrawingPublication } from "../core/shared";
|
||||
import {
|
||||
HttpError,
|
||||
normalizeTitle,
|
||||
parseJsonBody,
|
||||
parseSceneText,
|
||||
} from "../core/scene";
|
||||
|
||||
type RouteRequest = Request & {
|
||||
params?: Record<string, string>;
|
||||
@@ -33,7 +39,9 @@ function toMeta(drawing: DrawingMeta): DrawingMeta {
|
||||
};
|
||||
}
|
||||
|
||||
function parseExpectedRevision(raw: Record<string, unknown>): number | undefined {
|
||||
function parseExpectedRevision(
|
||||
raw: Record<string, unknown>,
|
||||
): number | undefined {
|
||||
if (!Object.hasOwn(raw, "expectedRevision")) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -46,6 +54,10 @@ function parseExpectedRevision(raw: Record<string, unknown>): number | undefined
|
||||
return value;
|
||||
}
|
||||
|
||||
function jsonPublication(publication: DrawingPublication): Response {
|
||||
return json(publication);
|
||||
}
|
||||
|
||||
export function createApi(store: DrawingStore) {
|
||||
return {
|
||||
health() {
|
||||
@@ -73,7 +85,10 @@ export function createApi(store: DrawingStore) {
|
||||
const drawing = id ? store.getDrawing(id) : null;
|
||||
|
||||
if (!drawing) {
|
||||
return json({ ok: false, error: "Drawing not found" }, { status: 404 });
|
||||
return json(
|
||||
{ ok: false, error: "Drawing not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
return json({
|
||||
@@ -85,6 +100,27 @@ export function createApi(store: DrawingStore) {
|
||||
}
|
||||
},
|
||||
|
||||
getPublicDrawing(request: RouteRequest) {
|
||||
try {
|
||||
const slug = request.params?.slug;
|
||||
if (!slug) {
|
||||
throw new HttpError(400, "Missing published slug");
|
||||
}
|
||||
|
||||
const drawing = store.getPublicDrawing(slug);
|
||||
if (!drawing) {
|
||||
return json(
|
||||
{ ok: false, error: "Drawing not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
return json(drawing);
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
},
|
||||
|
||||
async updateDrawing(request: RouteRequest) {
|
||||
try {
|
||||
const id = request.params?.id;
|
||||
@@ -113,7 +149,10 @@ export function createApi(store: DrawingStore) {
|
||||
});
|
||||
|
||||
if (!result.ok && result.reason === "not_found") {
|
||||
return json({ ok: false, error: "Drawing not found" }, { status: 404 });
|
||||
return json(
|
||||
{ ok: false, error: "Drawing not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
if (!result.ok && result.reason === "conflict") {
|
||||
@@ -140,7 +179,10 @@ export function createApi(store: DrawingStore) {
|
||||
deleteDrawing(request: RouteRequest) {
|
||||
const id = request.params?.id;
|
||||
if (!id) {
|
||||
return json({ ok: false, error: "Missing drawing id" }, { status: 400 });
|
||||
return json(
|
||||
{ ok: false, error: "Missing drawing id" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const { deleted, next } = store.deleteDrawing(id);
|
||||
@@ -150,5 +192,84 @@ export function createApi(store: DrawingStore) {
|
||||
|
||||
return json({ ok: true, nextId: next?.id ?? null });
|
||||
},
|
||||
|
||||
getDrawingPublication(request: RouteRequest) {
|
||||
try {
|
||||
const id = request.params?.id;
|
||||
if (!id) {
|
||||
throw new HttpError(400, "Missing drawing id");
|
||||
}
|
||||
|
||||
const publication = store.getDrawingPublication(id);
|
||||
if (!publication) {
|
||||
return json(
|
||||
{ ok: false, error: "Drawing not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
return jsonPublication(publication);
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
},
|
||||
|
||||
async updateDrawingPublication(request: RouteRequest) {
|
||||
try {
|
||||
const id = request.params?.id;
|
||||
if (!id) {
|
||||
throw new HttpError(400, "Missing drawing id");
|
||||
}
|
||||
|
||||
const raw = parseJsonBody<Record<string, unknown>>(
|
||||
await request.text(),
|
||||
);
|
||||
if (raw.enabled === false) {
|
||||
const result = store.disableDrawingPublication(id);
|
||||
if (!result.ok) {
|
||||
return json(
|
||||
{ ok: false, error: "Drawing not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
return jsonPublication(result.publication);
|
||||
}
|
||||
|
||||
if (raw.enabled !== true) {
|
||||
throw new HttpError(400, "enabled must be a boolean");
|
||||
}
|
||||
|
||||
const drawing = store.getDrawing(id);
|
||||
if (!drawing) {
|
||||
return json(
|
||||
{ ok: false, error: "Drawing not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
const result = store.publishDrawing(id, {
|
||||
slug: normalizePublicationSlug(raw.slug),
|
||||
});
|
||||
|
||||
if (!result.ok && result.reason === "not_found") {
|
||||
return json(
|
||||
{ ok: false, error: "Drawing not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
if (!result.ok && result.reason === "slug_conflict") {
|
||||
return json(
|
||||
{ ok: false, error: "Published slug already exists" },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
return jsonPublication(result.publication);
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
+117
-6
@@ -79,7 +79,9 @@ describe("drawing store", () => {
|
||||
expect(stale.ok).toBe(false);
|
||||
expect(stale.ok ? null : stale.reason).toBe("conflict");
|
||||
expect(store.getDrawing(created.id)?.revision).toBe(1);
|
||||
expect(store.getDrawing(created.id)?.scene.elements).toEqual([{ id: "first" }]);
|
||||
expect(store.getDrawing(created.id)?.scene.elements).toEqual([
|
||||
{ id: "first" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("keeps the revision unchanged when the scene is already stored", () => {
|
||||
@@ -109,13 +111,21 @@ describe("drawing store", () => {
|
||||
files: {},
|
||||
};
|
||||
|
||||
const first = store.updateDrawing(created.id, { expectedRevision: 0, scene });
|
||||
const first = store.updateDrawing(created.id, {
|
||||
expectedRevision: 0,
|
||||
scene,
|
||||
});
|
||||
expect(first.ok ? first.drawing.revision : null).toBe(1);
|
||||
|
||||
const repeated = store.updateDrawing(created.id, { expectedRevision: 0, scene });
|
||||
const repeated = store.updateDrawing(created.id, {
|
||||
expectedRevision: 0,
|
||||
scene,
|
||||
});
|
||||
expect(repeated.ok).toBe(true);
|
||||
expect(repeated.ok ? repeated.drawing.revision : null).toBe(1);
|
||||
expect(store.getDrawing(created.id)?.scene.elements).toEqual([{ id: "same" }]);
|
||||
expect(store.getDrawing(created.id)?.scene.elements).toEqual([
|
||||
{ id: "same" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("increments the revision when the title changes but the scene does not", () => {
|
||||
@@ -127,12 +137,113 @@ describe("drawing store", () => {
|
||||
files: {},
|
||||
};
|
||||
|
||||
const first = store.updateDrawing(created.id, { expectedRevision: 0, scene });
|
||||
const first = store.updateDrawing(created.id, {
|
||||
expectedRevision: 0,
|
||||
scene,
|
||||
});
|
||||
expect(first.ok ? first.drawing.revision : null).toBe(1);
|
||||
|
||||
const renamed = store.updateDrawing(created.id, { expectedRevision: 1, title: "Renamed", scene });
|
||||
const renamed = store.updateDrawing(created.id, {
|
||||
expectedRevision: 1,
|
||||
title: "Renamed",
|
||||
scene,
|
||||
});
|
||||
expect(renamed.ok).toBe(true);
|
||||
expect(renamed.ok ? renamed.drawing.title : null).toBe("Renamed");
|
||||
expect(renamed.ok ? renamed.drawing.revision : null).toBe(2);
|
||||
});
|
||||
|
||||
test("persists publication fields and exposes public drawings by slug", () => {
|
||||
const store = createTempStore();
|
||||
const created = store.createDrawing("Flow");
|
||||
store.updateDrawing(created.id, {
|
||||
scene: {
|
||||
elements: [{ id: "one" }],
|
||||
appState: { theme: "dark" },
|
||||
files: {},
|
||||
},
|
||||
});
|
||||
|
||||
const published = store.publishDrawing(created.id, { slug: "flow" });
|
||||
|
||||
expect(published.ok).toBe(true);
|
||||
expect(store.getDrawingPublication(created.id)).toEqual({
|
||||
enabled: true,
|
||||
slug: "flow",
|
||||
});
|
||||
expect(store.getPublicDrawing("flow")).toEqual({
|
||||
title: "Flow",
|
||||
elements: [{ id: "one" }],
|
||||
appState: { theme: "dark" },
|
||||
files: {},
|
||||
});
|
||||
});
|
||||
|
||||
test("enforces unique active publication slugs", () => {
|
||||
const store = createTempStore();
|
||||
const first = store.createDrawing("First");
|
||||
const second = store.createDrawing("Second");
|
||||
|
||||
expect(
|
||||
store.publishDrawing(first.id, {
|
||||
slug: "shared",
|
||||
}),
|
||||
).toEqual({
|
||||
ok: true,
|
||||
publication: {
|
||||
enabled: true,
|
||||
slug: "shared",
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
store.publishDrawing(second.id, {
|
||||
slug: "shared",
|
||||
}),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
reason: "slug_conflict",
|
||||
});
|
||||
|
||||
expect(store.disableDrawingPublication(first.id)).toEqual({
|
||||
ok: true,
|
||||
publication: { enabled: false },
|
||||
});
|
||||
|
||||
expect(
|
||||
store.publishDrawing(second.id, {
|
||||
slug: "shared",
|
||||
}),
|
||||
).toEqual({
|
||||
ok: true,
|
||||
publication: {
|
||||
enabled: true,
|
||||
slug: "shared",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("clears public fields on disable", () => {
|
||||
const store = createTempStore();
|
||||
const created = store.createDrawing();
|
||||
|
||||
expect(
|
||||
store.publishDrawing(created.id, {
|
||||
slug: "note",
|
||||
}),
|
||||
).toEqual({
|
||||
ok: true,
|
||||
publication: {
|
||||
enabled: true,
|
||||
slug: "note",
|
||||
},
|
||||
});
|
||||
|
||||
expect(store.disableDrawingPublication(created.id)).toEqual({
|
||||
ok: true,
|
||||
publication: { enabled: false },
|
||||
});
|
||||
expect(store.getDrawingPublication(created.id)).toEqual({ enabled: false });
|
||||
expect(store.getPublicDrawing("note")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
+216
-10
@@ -2,7 +2,13 @@ import { Database } from "bun:sqlite";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import { DEFAULT_TITLE, type DrawingMeta, type DrawingRecord, type ScenePayload } from "../core/shared";
|
||||
import {
|
||||
DEFAULT_TITLE,
|
||||
type DrawingMeta,
|
||||
type DrawingPublication,
|
||||
type DrawingRecord,
|
||||
type ScenePayload,
|
||||
} from "../core/shared";
|
||||
import { emptyScene, normalizeScene, normalizeTitle } from "../core/scene";
|
||||
|
||||
type DrawingRow = {
|
||||
@@ -16,11 +22,26 @@ type DrawingRow = {
|
||||
|
||||
type DrawingMetaRow = Omit<DrawingRow, "data">;
|
||||
|
||||
type DrawingPublicationRow = {
|
||||
publicEnabled: number;
|
||||
publicSlug: string | null;
|
||||
};
|
||||
|
||||
type PublicDrawingRow = {
|
||||
title: string;
|
||||
data: string;
|
||||
};
|
||||
|
||||
export type DrawingUpdateResult =
|
||||
| { ok: true; drawing: DrawingRecord }
|
||||
| { ok: false; reason: "not_found" }
|
||||
| { ok: false; reason: "conflict"; drawing: DrawingRecord };
|
||||
|
||||
export type DrawingPublicationUpdateResult =
|
||||
| { ok: true; publication: DrawingPublication }
|
||||
| { ok: false; reason: "not_found" }
|
||||
| { ok: false; reason: "slug_conflict" };
|
||||
|
||||
export type DrawingStore = ReturnType<typeof createDrawingStore>;
|
||||
|
||||
function createId(): string {
|
||||
@@ -54,6 +75,43 @@ function readMeta(row: DrawingMetaRow | null | undefined): DrawingMeta | null {
|
||||
};
|
||||
}
|
||||
|
||||
function readPublication(
|
||||
row: DrawingPublicationRow | null | undefined,
|
||||
): DrawingPublication | null {
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (row.publicEnabled !== 1 || !row.publicSlug) {
|
||||
return { enabled: false };
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
slug: row.publicSlug,
|
||||
};
|
||||
}
|
||||
|
||||
function readPublicDrawing(row: PublicDrawingRow | null | undefined) {
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let scene: ScenePayload;
|
||||
try {
|
||||
scene = normalizeScene(JSON.parse(row.data));
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Unknown scene parsing error";
|
||||
throw new Error(`Invalid stored public scene: ${message}`);
|
||||
}
|
||||
|
||||
return {
|
||||
title: row.title,
|
||||
...scene,
|
||||
};
|
||||
}
|
||||
|
||||
function readRow(row: DrawingRow | null | undefined): DrawingRecord | null {
|
||||
if (!row) {
|
||||
return null;
|
||||
@@ -63,7 +121,8 @@ function readRow(row: DrawingRow | null | undefined): DrawingRecord | null {
|
||||
try {
|
||||
scene = normalizeScene(JSON.parse(row.data));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown scene parsing error";
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Unknown scene parsing error";
|
||||
throw new Error(`Invalid stored scene for drawing ${row.id}: ${message}`);
|
||||
}
|
||||
|
||||
@@ -73,6 +132,30 @@ function readRow(row: DrawingRow | null | undefined): DrawingRecord | null {
|
||||
};
|
||||
}
|
||||
|
||||
function ensurePublicationColumns(db: Database) {
|
||||
const columns = db.query("PRAGMA table_info(drawings)").all() as Array<{
|
||||
name: string;
|
||||
}>;
|
||||
const names = new Set(columns.map((column) => column.name));
|
||||
|
||||
if (!names.has("public_enabled")) {
|
||||
db.exec(
|
||||
"ALTER TABLE drawings ADD COLUMN public_enabled INTEGER NOT NULL DEFAULT 0",
|
||||
);
|
||||
}
|
||||
|
||||
if (!names.has("public_slug")) {
|
||||
db.exec("ALTER TABLE drawings ADD COLUMN public_slug TEXT");
|
||||
}
|
||||
}
|
||||
|
||||
function isSlugConflictError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
error.message.includes("UNIQUE constraint failed: drawings.public_slug")
|
||||
);
|
||||
}
|
||||
|
||||
export function createDrawingStore(databasePath = resolveDatabasePath()) {
|
||||
mkdirSync(dirname(databasePath), { recursive: true });
|
||||
|
||||
@@ -86,13 +169,22 @@ export function createDrawingStore(databasePath = resolveDatabasePath()) {
|
||||
data TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
revision INTEGER NOT NULL DEFAULT 0
|
||||
revision INTEGER NOT NULL DEFAULT 0,
|
||||
public_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
public_slug TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS drawings_updated_at_idx
|
||||
ON drawings(updated_at DESC, created_at DESC);
|
||||
`);
|
||||
|
||||
ensurePublicationColumns(db);
|
||||
db.exec(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS drawings_public_slug_active_idx
|
||||
ON drawings(public_slug)
|
||||
WHERE public_enabled = 1 AND public_slug IS NOT NULL;
|
||||
`);
|
||||
|
||||
const listQuery = db.query(`
|
||||
SELECT
|
||||
id,
|
||||
@@ -162,6 +254,38 @@ export function createDrawingStore(databasePath = resolveDatabasePath()) {
|
||||
WHERE id = ?1
|
||||
`);
|
||||
|
||||
const getPublicationQuery = db.query(`
|
||||
SELECT
|
||||
public_enabled AS publicEnabled,
|
||||
public_slug AS publicSlug
|
||||
FROM drawings
|
||||
WHERE id = ?1
|
||||
`);
|
||||
|
||||
const publishQuery = db.query(`
|
||||
UPDATE drawings
|
||||
SET
|
||||
public_enabled = 1,
|
||||
public_slug = ?2
|
||||
WHERE id = ?1
|
||||
`);
|
||||
|
||||
const disablePublicationQuery = db.query(`
|
||||
UPDATE drawings
|
||||
SET
|
||||
public_enabled = 0,
|
||||
public_slug = NULL
|
||||
WHERE id = ?1
|
||||
`);
|
||||
|
||||
const getPublicDrawingQuery = db.query(`
|
||||
SELECT
|
||||
title,
|
||||
data
|
||||
FROM drawings
|
||||
WHERE public_enabled = 1 AND public_slug = ?1
|
||||
`);
|
||||
|
||||
function listDrawings(): DrawingMeta[] {
|
||||
return (listQuery.all() as DrawingMetaRow[]).map((row) => readMeta(row)!);
|
||||
}
|
||||
@@ -201,8 +325,12 @@ export function createDrawingStore(databasePath = resolveDatabasePath()) {
|
||||
return { ok: true, drawing: existing };
|
||||
}
|
||||
|
||||
const nextTitle = hasTitle ? normalizeTitle(update.title) : existingRow.title;
|
||||
const nextScene = hasScene ? JSON.stringify(update.scene) : existingRow.data;
|
||||
const nextTitle = hasTitle
|
||||
? normalizeTitle(update.title)
|
||||
: existingRow.title;
|
||||
const nextScene = hasScene
|
||||
? JSON.stringify(update.scene)
|
||||
: existingRow.data;
|
||||
const titleChanged = nextTitle !== existingRow.title;
|
||||
const sceneChanged = nextScene !== existingRow.data;
|
||||
|
||||
@@ -210,7 +338,10 @@ export function createDrawingStore(databasePath = resolveDatabasePath()) {
|
||||
return { ok: true, drawing: existing };
|
||||
}
|
||||
|
||||
if (update.expectedRevision !== undefined && update.expectedRevision !== existingRow.revision) {
|
||||
if (
|
||||
update.expectedRevision !== undefined &&
|
||||
update.expectedRevision !== existingRow.revision
|
||||
) {
|
||||
return { ok: false, reason: "conflict", drawing: existing };
|
||||
}
|
||||
|
||||
@@ -219,17 +350,36 @@ export function createDrawingStore(databasePath = resolveDatabasePath()) {
|
||||
changes =
|
||||
update.expectedRevision === undefined
|
||||
? Number(updateBothQuery.run(id, nextTitle, nextScene).changes)
|
||||
: Number(updateBothExpectedQuery.run(id, nextTitle, nextScene, update.expectedRevision).changes);
|
||||
: Number(
|
||||
updateBothExpectedQuery.run(
|
||||
id,
|
||||
nextTitle,
|
||||
nextScene,
|
||||
update.expectedRevision,
|
||||
).changes,
|
||||
);
|
||||
} else if (sceneChanged) {
|
||||
changes =
|
||||
update.expectedRevision === undefined
|
||||
? Number(updateSceneQuery.run(id, nextScene).changes)
|
||||
: Number(updateSceneExpectedQuery.run(id, nextScene, update.expectedRevision).changes);
|
||||
: Number(
|
||||
updateSceneExpectedQuery.run(
|
||||
id,
|
||||
nextScene,
|
||||
update.expectedRevision,
|
||||
).changes,
|
||||
);
|
||||
} else {
|
||||
changes =
|
||||
update.expectedRevision === undefined
|
||||
? Number(updateTitleQuery.run(id, nextTitle).changes)
|
||||
: Number(updateTitleExpectedQuery.run(id, nextTitle, update.expectedRevision).changes);
|
||||
: Number(
|
||||
updateTitleExpectedQuery.run(
|
||||
id,
|
||||
nextTitle,
|
||||
update.expectedRevision,
|
||||
).changes,
|
||||
);
|
||||
}
|
||||
|
||||
const drawing = getDrawing(id);
|
||||
@@ -244,7 +394,10 @@ export function createDrawingStore(databasePath = resolveDatabasePath()) {
|
||||
return { ok: false, reason: "conflict", drawing };
|
||||
}
|
||||
|
||||
function deleteDrawing(id: string): { deleted: boolean; next: DrawingMeta | null } {
|
||||
function deleteDrawing(id: string): {
|
||||
deleted: boolean;
|
||||
next: DrawingMeta | null;
|
||||
} {
|
||||
const changes = Number(deleteQuery.run(id).changes);
|
||||
if (changes === 0) {
|
||||
return { deleted: false, next: null };
|
||||
@@ -256,6 +409,55 @@ export function createDrawingStore(databasePath = resolveDatabasePath()) {
|
||||
};
|
||||
}
|
||||
|
||||
function getDrawingPublication(id: string): DrawingPublication | null {
|
||||
return readPublication(
|
||||
getPublicationQuery.get(id) as DrawingPublicationRow | null | undefined,
|
||||
);
|
||||
}
|
||||
|
||||
function publishDrawing(
|
||||
id: string,
|
||||
publication: { slug: string },
|
||||
): DrawingPublicationUpdateResult {
|
||||
try {
|
||||
const changes = Number(publishQuery.run(id, publication.slug).changes);
|
||||
if (changes === 0) {
|
||||
return { ok: false, reason: "not_found" };
|
||||
}
|
||||
} catch (error) {
|
||||
if (isSlugConflictError(error)) {
|
||||
return { ok: false, reason: "slug_conflict" };
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
publication: getDrawingPublication(id)!,
|
||||
};
|
||||
}
|
||||
|
||||
function disableDrawingPublication(
|
||||
id: string,
|
||||
): DrawingPublicationUpdateResult {
|
||||
const changes = Number(disablePublicationQuery.run(id).changes);
|
||||
if (changes === 0) {
|
||||
return { ok: false, reason: "not_found" };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
publication: { enabled: false },
|
||||
};
|
||||
}
|
||||
|
||||
function getPublicDrawing(slug: string) {
|
||||
return readPublicDrawing(
|
||||
getPublicDrawingQuery.get(slug) as PublicDrawingRow | null | undefined,
|
||||
);
|
||||
}
|
||||
|
||||
function close() {
|
||||
db.close(false);
|
||||
}
|
||||
@@ -270,6 +472,10 @@ export function createDrawingStore(databasePath = resolveDatabasePath()) {
|
||||
ensureInitialDrawing,
|
||||
updateDrawing,
|
||||
deleteDrawing,
|
||||
getDrawingPublication,
|
||||
publishDrawing,
|
||||
disableDrawingPublication,
|
||||
getPublicDrawing,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createDevServer } from "./dev-server";
|
||||
|
||||
describe("createDevServer", () => {
|
||||
test("serves the spa for private and public drawing routes", () => {
|
||||
const server = createDevServer();
|
||||
|
||||
expect(Object.hasOwn(server.routes, "/d/:id")).toBe(true);
|
||||
expect(Object.hasOwn(server.routes, "/p/:slug")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ export function createDevServer() {
|
||||
routes: {
|
||||
...server.routes,
|
||||
"/d/:id": index,
|
||||
"/p/:slug": index,
|
||||
},
|
||||
} satisfies Parameters<typeof Bun.serve>[0];
|
||||
}
|
||||
|
||||
@@ -38,12 +38,16 @@ describe("createServer", () => {
|
||||
test("redirects / to the latest drawing", () => {
|
||||
const { server, store } = createServerFixture();
|
||||
|
||||
const response = server.routes["/"]?.(new Request("http://local/")) as Response;
|
||||
const response = server.routes["/"]?.(
|
||||
new Request("http://local/"),
|
||||
) as Response;
|
||||
const created = store.listDrawings();
|
||||
|
||||
expect(created).toHaveLength(1);
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get("location")).toBe(`http://local/d/${created[0]?.id}`);
|
||||
expect(response.headers.get("location")).toBe(
|
||||
`http://local/d/${created[0]?.id}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps Bun responsible for API routes", async () => {
|
||||
@@ -58,11 +62,49 @@ describe("createServer", () => {
|
||||
const { server } = createServerFixture();
|
||||
|
||||
expect(Object.hasOwn(server.routes, "/d/:id")).toBe(false);
|
||||
expect(Object.hasOwn(server.routes, "/p/:slug")).toBe(false);
|
||||
});
|
||||
|
||||
test("serves public drawing data through the public api", async () => {
|
||||
const { server, store } = createServerFixture();
|
||||
const drawing = store.createDrawing("Published");
|
||||
store.publishDrawing(drawing.id, { slug: "published-note" });
|
||||
|
||||
const response = await server.routes["/api/public/drawings/:slug"]?.GET?.(
|
||||
Object.assign(
|
||||
new Request("http://local/api/public/drawings/published-note"),
|
||||
{
|
||||
params: { slug: "published-note" },
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(response?.status).toBe(200);
|
||||
expect(await response?.json()).toEqual({
|
||||
title: "Published",
|
||||
elements: [],
|
||||
appState: { theme: "dark" },
|
||||
files: {},
|
||||
});
|
||||
});
|
||||
|
||||
test("returns 404 for unknown or unpublished public slugs", async () => {
|
||||
const { server } = createServerFixture();
|
||||
|
||||
const response = await server.routes["/api/public/drawings/:slug"]?.GET?.(
|
||||
Object.assign(new Request("http://local/api/public/drawings/missing"), {
|
||||
params: { slug: "missing" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response?.status).toBe(404);
|
||||
});
|
||||
|
||||
test("returns JSON 404 for unmatched requests", async () => {
|
||||
const { server } = createServerFixture();
|
||||
const response = await server.fetch(new Request("http://local/index-abc123.js"));
|
||||
const response = await server.fetch(
|
||||
new Request("http://local/index-abc123.js"),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(await response.json()).toEqual({ ok: false, error: "Not found" });
|
||||
|
||||
@@ -12,6 +12,7 @@ export function createServer(options: CreateServerOptions = {}) {
|
||||
return {
|
||||
port: Number(process.env.PORT ?? "3000"),
|
||||
hostname: process.env.HOST ?? "localhost",
|
||||
idleTimeout: 120,
|
||||
routes: {
|
||||
"/": (request: Request) => {
|
||||
const drawing = drawingStore.ensureInitialDrawing();
|
||||
@@ -25,12 +26,26 @@ export function createServer(options: CreateServerOptions = {}) {
|
||||
POST: () => api.createDrawing(),
|
||||
},
|
||||
"/api/drawings/:id": {
|
||||
GET: (request: Request & { params: Record<string, string> }) => api.getDrawing(request),
|
||||
PUT: (request: Request & { params: Record<string, string> }) => api.updateDrawing(request),
|
||||
DELETE: (request: Request & { params: Record<string, string> }) => api.deleteDrawing(request),
|
||||
GET: (request: Request & { params: Record<string, string> }) =>
|
||||
api.getDrawing(request),
|
||||
PUT: (request: Request & { params: Record<string, string> }) =>
|
||||
api.updateDrawing(request),
|
||||
DELETE: (request: Request & { params: Record<string, string> }) =>
|
||||
api.deleteDrawing(request),
|
||||
},
|
||||
"/api/public/drawings/:slug": {
|
||||
GET: (request: Request & { params: Record<string, string> }) =>
|
||||
api.getPublicDrawing(request),
|
||||
},
|
||||
"/api/drawings/:id/publication": {
|
||||
GET: (request: Request & { params: Record<string, string> }) =>
|
||||
api.getDrawingPublication(request),
|
||||
PUT: (request: Request & { params: Record<string, string> }) =>
|
||||
api.updateDrawingPublication(request),
|
||||
},
|
||||
},
|
||||
fetch: (_request: Request) => Response.json({ ok: false, error: "Not found" }, { status: 404 }),
|
||||
fetch: (_request: Request) =>
|
||||
Response.json({ ok: false, error: "Not found" }, { status: 404 }),
|
||||
} satisfies Parameters<typeof Bun.serve>[0];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user