Add short URL sharing via paste service to fix URL truncation (#188)

* Add short URL sharing via paste service to fix URL truncation on Slack/messaging apps

Share URLs for large plans can be 10-40KB+ because the entire plan + annotations
are compressed into the URL hash. Services like Slack, WhatsApp, and Twitter
truncate these URLs, making shared plans unviewable (related: #142).

This adds an optional paste-service backend that stores compressed payloads and
returns short ~60-char URLs (e.g. share.plannotator.ai/p/aBcDeFgH).

Changes:
- Add Cloudflare Worker paste service (apps/paste-worker/) with KV storage
  and 90-day TTL for stored plans
- Add createShortShareUrl() and loadFromPasteId() to sharing utils
- Update useSharing hook to auto-generate short URLs with 1s debounce
- Update ExportModal to show short URL as primary copy target with
  full hash URL as backup
- Portal automatically supports /p/<id> paths via useSharing hook
- Fully backward compatible: hash-based URLs continue to work unchanged
- Graceful degradation: falls back to hash URLs if paste service is unavailable

* Fix critical bugs found during code review of short URL feature

- Worker: return only { id } so client constructs URL with its own
  shareBaseUrl (fixes self-hosted deployments)
- importFromShareUrl: handle /p/<id> short URLs in addition to hash
  URLs (fixes teammate import via short links)
- Anchor /p/<id> regex with ^ to prevent false matches on nested paths
- Pass shareBaseUrl to loadFromPasteId (was always defaulting)
- replaceState preserves base path instead of hardcoding /
- Clear stale shortShareUrl immediately on debounce to prevent showing
  outdated link during the 1s delay
- Add shareBaseUrl to dependency arrays for loadFromHash and
  importFromShareUrl callbacks
- Remove unused url field fallback from paste API response parsing

* Fix pasteApiUrl/shareBaseUrl confusion and add remote session share URLs

Bug fix (addresses @backnotprop's code review):
- useSharing hook now accepts separate `pasteApiUrl` parameter instead of
  incorrectly passing `shareBaseUrl` to `loadFromPasteId`. These are
  different domains (share portal vs paste backend). When omitted, the
  default `https://paste.plannotator.ai` is used correctly.
- Fixed in all three call sites: loadFromHash, importFromShareUrl, and
  generateShortUrl (via createShortShareUrl options).

Remote session share URLs (implements #192):
- When running on a remote instance (SSH, devcontainer), Plannotator now
  generates a share.plannotator.ai URL and prints it to stderr so the
  user can open the plan review in their local browser.
- Works for all three modes: plan review, code review, and annotate.
- Uses the same deflate-raw + base64url encoding as the client.
- Fails silently if URL generation fails — port forwarding still works.
- New server utility: packages/server/share-url.ts

* Address code review findings: fix spread limit, CORS, deps array

- share-url.ts: Replace btoa(String.fromCharCode(...compressed)) with
  a loop to avoid RangeError on plans >65K compressed bytes
- share-url.ts: Add cross-reference comment noting the server-side
  SharePayload is an intentional subset of the canonical UI type
- paste-worker: Only return CORS headers for allowed origins; disallowed
  origins get no CORS headers instead of misleading partial headers
- useSharing.ts: Remove unused shareBaseUrl from loadFromHash dependency
  array (loadFromHash only uses pasteApiUrl, not shareBaseUrl)

* Restructure paste service with pluggable storage, fix consent flow and security issues

Replaces monolithic paste-worker with multi-target paste-service architecture:
- Core logic (handler, storage interface, CORS) separated from deployment targets
- Filesystem store (self-hosted), KV store (Cloudflare), S3 stub (future)
- Bun binary target + Cloudflare Worker target
- Fix auto-upload removed: short URLs only created on explicit user click
- Clear stale short URL state when content changes
- Fix require('fs') in ESM module, add path-traversal guard in FsPasteStore
- Return 400 (not 500) for malformed JSON in both targets
- Fix compress() spread limit for large plans
- Updated docs: self-hosting guide, sharing guide, env vars, API endpoints

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Simplify paste service: extract shared router, remove TOCTOU patterns

- Extract duplicated HTTP routing from bun.ts and cloudflare.ts into
  handleRequest() in core/handler.ts — targets are now thin wrappers
- Move ID_PATTERN regex to core (was duplicated in both targets)
- Remove existsSync guard before mkdirSync({recursive:true}) in fs.ts
- Remove file.exists() check before file.json() in fs.ts get() — the
  try/catch already handles missing files
- Extract DEFAULT_SHARE_BASE constant in sharing.ts (was hardcoded twice)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Deduplicate compression, image parsing, and remote share URL logic

- Create packages/shared/ with compress/decompress (single source of truth)
  — both @plannotator/server and @plannotator/ui import from here
- Export parseShareableImages from sharing.ts, remove duplicate
  parseGlobalAttachments from useSharing.ts
- Extract writeRemoteShareLink() helper in share-url.ts, replace 3
  near-identical blocks in apps/hook/server/index.ts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fix modulo bias in paste ID generation and remove nonexistent doc artifacts

- Use rejection sampling in generateId() to eliminate modulo bias
  (bytes >= 248 discarded, uniform distribution over 62 chars)
- Remove references to install-paste.sh and Docker image that don't
  exist yet; point self-hosting docs to GitHub Releases binaries

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Michael Ramos <mdramos8@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Gunit Bindal
2026-03-01 12:25:55 +05:30
committed by GitHub
parent af18db7aab
commit c470dcebf1
28 changed files with 1222 additions and 133 deletions
+16
View File
@@ -62,6 +62,22 @@ jobs:
bun build apps/hook/server/index.ts --compile --target=bun-windows-x64 --outfile plannotator-win32-x64.exe
sha256sum plannotator-win32-x64.exe > plannotator-win32-x64.exe.sha256
# Paste service binaries
bun build apps/paste-service/targets/bun.ts --compile --target=bun-darwin-arm64 --outfile plannotator-paste-darwin-arm64
sha256sum plannotator-paste-darwin-arm64 > plannotator-paste-darwin-arm64.sha256
bun build apps/paste-service/targets/bun.ts --compile --target=bun-darwin-x64 --outfile plannotator-paste-darwin-x64
sha256sum plannotator-paste-darwin-x64 > plannotator-paste-darwin-x64.sha256
bun build apps/paste-service/targets/bun.ts --compile --target=bun-linux-x64 --outfile plannotator-paste-linux-x64
sha256sum plannotator-paste-linux-x64 > plannotator-paste-linux-x64.sha256
bun build apps/paste-service/targets/bun.ts --compile --target=bun-linux-arm64 --outfile plannotator-paste-linux-arm64
sha256sum plannotator-paste-linux-arm64 > plannotator-paste-linux-arm64.sha256
bun build apps/paste-service/targets/bun.ts --compile --target=bun-windows-x64 --outfile plannotator-paste-win32-x64.exe
sha256sum plannotator-paste-win32-x64.exe > plannotator-paste-win32-x64.exe.sha256
- name: Upload artifacts
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
+16 -1
View File
@@ -20,6 +20,10 @@ plannotator/
│ │ └── review-editor.html # Built code review app
│ ├── marketing/ # Marketing site, docs, and blog (plannotator.ai)
│ │ └── astro.config.mjs # Astro 5 static site with content collections
│ ├── paste-service/ # Paste service for short URL sharing
│ │ ├── core/ # Platform-agnostic logic (handler, storage interface, cors)
│ │ ├── stores/ # Storage backends (fs, kv, s3)
│ │ └── targets/ # Deployment entries (bun.ts, cloudflare.ts)
│ └── review/ # Standalone review server (for development)
│ ├── index.html
│ ├── index.tsx
@@ -30,6 +34,7 @@ plannotator/
│ │ ├── review.ts # startReviewServer(), handleReviewServerReady()
│ │ ├── annotate.ts # startAnnotateServer(), handleAnnotateServerReady()
│ │ ├── storage.ts # Plan saving to disk (getPlanDir, savePlan, etc.)
│ │ ├── share-url.ts # Server-side share URL generation for remote sessions
│ │ ├── remote.ts # isRemoteSession(), getServerPort()
│ │ ├── browser.ts # openBrowser()
│ │ ├── integrations.ts # Obsidian, Bear integrations
@@ -74,6 +79,7 @@ claude --plugin-dir ./apps/hook
| `PLANNOTATOR_PORT` | Fixed port to use. Default: random locally, `19432` for remote sessions. |
| `PLANNOTATOR_BROWSER` | Custom browser to open plans in. macOS: app name or path. Linux/Windows: executable path. |
| `PLANNOTATOR_SHARE_URL` | Custom base URL for share links (self-hosted portal). Default: `https://share.plannotator.ai`. |
| `PLANNOTATOR_PASTE_URL` | Base URL of the paste service API for short URL sharing. Default: `https://paste.plannotator.ai`. |
**Legacy:** `SSH_TTY` and `SSH_CONNECTION` are still detected. Prefer `PLANNOTATOR_REMOTE=1` for explicit control.
@@ -173,6 +179,15 @@ Send Annotations → feedback sent to agent session
All servers use random ports locally or fixed port (`19432`) in remote mode.
### Paste Service (`apps/paste-service/`)
| Endpoint | Method | Purpose |
| --------------------- | ------ | ------------------------------------------ |
| `/api/paste` | POST | Store compressed plan data, returns `{ id }` |
| `/api/paste/:id` | GET | Retrieve stored compressed data |
Runs as a separate service on port `19433` (self-hosted) or as a Cloudflare Worker (hosted).
## Plan Version History
Every plan is automatically saved to `~/.plannotator/history/{project}/{slug}/` on arrival, before the user sees the UI. Versions are numbered sequentially (`001.md`, `002.md`, etc.). The slug is derived from the plan's first `# Heading` + today's date via `generateSlug()`, scoped by project name (git repo or cwd). Same heading on the same day = same slug = same plan being iterated on. Identical resubmissions are deduplicated (no new file if content matches the latest version).
@@ -265,7 +280,7 @@ Text highlighting uses `web-highlighter` library. Code blocks use manual `<mark>
**Location:** `packages/ui/utils/sharing.ts`, `packages/ui/hooks/useSharing.ts`
Shares full plan + annotations via URL hash using deflate compression.
Shares full plan + annotations via URL hash using deflate compression. For large plans, short URLs are created via the paste service (user must explicitly confirm).
**Payload format:**
+24 -3
View File
@@ -36,6 +36,7 @@ import {
handleAnnotateServerReady,
} from "@plannotator/server/annotate";
import { getGitContext, runGitDiff } from "@plannotator/server/git";
import { writeRemoteShareLink } from "@plannotator/server/share-url";
// Embed the built HTML at compile time
// @ts-ignore - Bun import attribute for text
@@ -55,6 +56,9 @@ const sharingEnabled = process.env.PLANNOTATOR_SHARE !== "disabled";
// Custom share portal URL for self-hosting
const shareBaseUrl = process.env.PLANNOTATOR_SHARE_URL || undefined;
// Paste service URL for short URL sharing
const pasteApiUrl = process.env.PLANNOTATOR_PASTE_URL || undefined;
if (args[0] === "review") {
// ============================================
// CODE REVIEW MODE
@@ -79,7 +83,13 @@ if (args[0] === "review") {
sharingEnabled,
shareBaseUrl,
htmlContent: reviewHtmlContent,
onReady: handleReviewServerReady,
onReady: async (url, isRemote, port) => {
handleReviewServerReady(url, isRemote, port);
if (isRemote && sharingEnabled && rawPatch) {
await writeRemoteShareLink(rawPatch, shareBaseUrl, "review changes", "diff only").catch(() => {});
}
},
});
// Wait for user feedback
@@ -126,7 +136,13 @@ if (args[0] === "review") {
sharingEnabled,
shareBaseUrl,
htmlContent: planHtmlContent,
onReady: handleAnnotateServerReady,
onReady: async (url, isRemote, port) => {
handleAnnotateServerReady(url, isRemote, port);
if (isRemote && sharingEnabled) {
await writeRemoteShareLink(markdown, shareBaseUrl, "annotate", "document only").catch(() => {});
}
},
});
// Wait for user feedback
@@ -173,9 +189,14 @@ if (args[0] === "review") {
permissionMode,
sharingEnabled,
shareBaseUrl,
pasteApiUrl,
htmlContent: planHtmlContent,
onReady: (url, isRemote, port) => {
onReady: async (url, isRemote, port) => {
handleServerReady(url, isRemote, port);
if (isRemote && sharingEnabled) {
await writeRemoteShareLink(planContent, shareBaseUrl, "review the plan", "plan only").catch(() => {});
}
},
});
@@ -1,14 +1,40 @@
---
title: "Self-Hosting"
description: "Self-host the Plannotator share portal for private plan sharing."
description: "Deploy and self-host the full Plannotator system — hook, share portal, and paste service."
sidebar:
order: 23
section: "Guides"
---
The share portal is a static single-page application. It has no backend, no database, and makes no network requests. All plan data is encoded in the URL hash.
Plannotator has three components. Only the hook is required.
## Build
## Components
| Component | Required | What it does |
|-----------|----------|--------------|
| Hook | Yes | Local binary that intercepts `ExitPlanMode`, runs the review UI |
| Share Portal | Optional | Static site that renders shared plans. When you open a share link, this is what loads in your browser. |
| Paste Service | Optional | Storage backend for the share portal. When a plan is too large for a URL, the paste service holds the compressed data and the portal fetches it on load. |
### How sharing works
Small plans are encoded entirely in the URL hash — the share portal reads the hash and renders the plan. No backend involved. The data remains private — it never leaves the URL.
Large plans don't fit in a URL. **When a user explicitly confirms** short link creation, the compressed plan is sent to the paste service, which stores it and returns a short ID. A compressed plan goes in, a link to retrieve it comes out. The share URL becomes `share.plannotator.ai/p/aBcDeFgH` (or `your-portal.example.com/p/aBcDeFgH` if self-hosting). When someone opens that link, the portal fetches the compressed data from the paste service, decompresses it, and renders the plan.
**Without paste service:** Sharing still works for plans that fit in a URL. Those plans stay completely private — the data lives only in the URL hash and never touches a server. Large plans show a warning that the URL may be truncated by messaging apps.
**With paste service:** Large plans get short, reliable URLs that work everywhere. Plannotator temporarily stores the compressed plan data — it auto-deletes after the configured TTL.
## 1. Install the Hook
See [Installation](/docs/getting-started/installation/) for hook setup instructions.
## 2. Deploy the Share Portal
The share portal is a static single-page application. It has no backend, no database, and makes no network requests beyond fetching paste data for short URLs.
### Build
```bash
bun install
@@ -17,11 +43,11 @@ bun run build:portal
Output: `apps/portal/dist/`
## Deploy
### Deploy
Upload the `dist/` folder to any static hosting provider.
### Nginx
#### Nginx
```nginx
server {
@@ -32,7 +58,7 @@ server {
}
```
### AWS S3 + CloudFront
#### AWS S3 + CloudFront
```bash
aws s3 sync apps/portal/dist/ s3://your-bucket/ --delete
@@ -40,27 +66,50 @@ aws s3 sync apps/portal/dist/ s3://your-bucket/ --delete
Configure the CloudFront distribution to return `/index.html` for 404s (SPA routing).
### Vercel / Netlify / Cloudflare Pages
#### Vercel / Netlify / Cloudflare Pages
Point to the repository root:
- **Build command**: `bun run build:portal`
- **Output directory**: `apps/portal/dist`
## Configure Plannotator
## 3. Deploy the Paste Service
Set the `PLANNOTATOR_SHARE_URL` environment variable to your portal's URL:
The paste service accepts compressed plan data and returns a short ID. A compressed plan goes in, a link to retrieve it comes out. Pastes auto-delete after the configured TTL. No database required.
The paste service is fully open source — the same codebase you're looking at.
### Run the binary
Download the paste service binary for your platform from [GitHub Releases](https://github.com/backnotprop/plannotator/releases). Binaries are available for macOS (ARM64, x64), Linux (x64, ARM64), and Windows (x64).
```bash
export PLANNOTATOR_SHARE_URL=https://plannotator.internal.example.com
chmod +x plannotator-paste-*
./plannotator-paste-darwin-arm64 # or whichever matches your platform
```
All share links generated by Plannotator will now point to your self-hosted portal. The import dialog placeholder updates automatically.
Pastes stored to `~/.plannotator/pastes/` by default.
When `PLANNOTATOR_SHARE_URL` is not set, the default `https://share.plannotator.ai` is used.
### Configuration
## Verify
| Variable | Default | Description |
|----------|---------|-------------|
| `PASTE_PORT` | `19433` | Server port |
| `PASTE_DATA_DIR` | `~/.plannotator/pastes` | Storage directory |
| `PASTE_TTL_DAYS` | `7` | Auto-delete after N days |
| `PASTE_MAX_SIZE` | `524288` | Max payload size (512KB) |
| `PASTE_ALLOWED_ORIGINS` | (see defaults) | CORS allowed origins |
## 4. Connect the Components
```bash
export PLANNOTATOR_SHARE_URL=https://your-portal.example.com
export PLANNOTATOR_PASTE_URL=https://your-paste.example.com
```
## 5. Verify
1. Start a plan review in Claude Code or OpenCode
2. Add an annotation, click Export → Share
2. Add annotations, click **Export** → **Share**
3. Confirm the share URL starts with your configured domain
4. Open the link — the plan and annotations should render correctly
4. If the plan is large, click **Create short link** when prompted
5. Open the short URL — the plan should render correctly
@@ -1,12 +1,12 @@
---
title: "Sharing & Collaboration"
description: "Share plans and annotations via URL — no backend required."
description: "Share plans and annotations via URL with optional short links for large plans."
sidebar:
order: 21
section: "Guides"
---
Plannotator lets you share plans and annotations with teammates via URL. All data is encoded in the URL hash — no backend, no accounts, no server stores anything.
Plannotator lets you share plans and annotations with teammates via URL. Small plans are encoded entirely in the URL hash — no backend, no accounts, no server stores anything. Large plans can optionally use short URLs via the [paste service](/docs/guides/self-hosting/#3-deploy-the-paste-service).
## How sharing works
@@ -57,13 +57,34 @@ When sharing is disabled:
- The "Copy Share Link" quick action is removed
- The Import Review option is hidden
## Short URLs for large plans
When a plan is too large for a URL (~2KB+ compressed), messaging apps like Slack and WhatsApp may truncate it. Plannotator can create a short link by temporarily storing the compressed plan in a paste service.
### How it works
1. Click **Export** → **Share**
2. If the URL is large, you'll see a notice: "This plan is too large for a URL"
3. Click **Create short link** to confirm
4. The compressed plan is temporarily stored, then automatically deleted after the configured TTL
5. A short URL like `share.plannotator.ai/p/aBcDeFgH` is generated
6. Both the short URL and the full hash URL are shown — the short URL is safe for messaging apps
### Privacy
- Plans are only uploaded when you explicitly click "Create short link" — no data leaves your machine until you confirm
- Pastes auto-expire and are permanently deleted (hosted: a few days, self-hosted: configurable via `PASTE_TTL_DAYS`)
- The paste service is fully open source — you can audit exactly what it does
- Self-hosters can run their own paste service for complete control — see the [self-hosting guide](/docs/guides/self-hosting/)
- If the paste service is unavailable, the full hash URL is always available as fallback
## Self-hosting the share portal
By default, share URLs point to `https://share.plannotator.ai`. You can self-host the portal and point Plannotator at your instance. See the [self-hosting guide](/docs/guides/self-hosting/) for details.
## Privacy model
- Plans and annotations are never sent to any server
- The share portal is a static page — it only reads the URL hash client-side
- Plans and annotations are never sent to any server — the data lives entirely in the URL hash
- The share portal is a static page — it only reads the hash and renders client-side
- No analytics, no tracking, no cookies on the share portal
- If you self-host, you have complete control over the infrastructure
- Short URLs are opt-in — data is only uploaded when you explicitly click "Create short link" (see [Short URLs for large plans](#short-urls-for-large-plans) for details)
@@ -133,3 +133,42 @@ Body:
"annotations": []
}
```
## Paste service
Stores compressed plan data for short URL sharing. Runs as a separate service from the plan/review/annotate servers.
Default: `https://paste.plannotator.ai` (or self-hosted)
| Endpoint | Method | Purpose |
|----------|--------|---------|
| `/api/paste` | POST | Store compressed plan data, returns `{ id }` |
| `/api/paste/:id` | GET | Retrieve stored compressed data |
### POST `/api/paste`
Body:
```json
{
"data": "<compressed base64 string>"
}
```
Returns: `{ "id": "aBcDeFgH" }` (201 Created)
Limits: 512KB max payload. Auto-deleted after configured TTL (default: 7 days).
### GET `/api/paste/:id`
Returns:
```json
{
"data": "<compressed base64 string>"
}
```
Or: `{ "error": "Paste not found or expired" }` (404)
Cached for 1 hour (`Cache-Control: public, max-age=3600`).
@@ -19,6 +19,24 @@ All Plannotator environment variables and their defaults.
| `PLANNOTATOR_SHARE` | enabled | Set to `disabled` to turn off sharing. Hides share UI and import options. |
| `PLANNOTATOR_SHARE_URL` | `https://share.plannotator.ai` | Base URL for share links. Set this when self-hosting the share portal. |
## Paste service variables
| Variable | Default | Description |
|----------|---------|-------------|
| `PLANNOTATOR_PASTE_URL` | `https://paste.plannotator.ai` | Base URL of the paste service API. Set this when self-hosting the paste service. |
### Self-hosted paste service
When running your own paste service binary, these variables configure it:
| Variable | Default | Description |
|----------|---------|-------------|
| `PASTE_PORT` | `19433` | Server port |
| `PASTE_DATA_DIR` | `~/.plannotator/pastes` | Filesystem storage directory |
| `PASTE_TTL_DAYS` | `7` | Paste expiration in days |
| `PASTE_MAX_SIZE` | `524288` | Max payload size in bytes (512KB) |
| `PASTE_ALLOWED_ORIGINS` | `https://share.plannotator.ai,http://localhost:3001` | CORS allowed origins (comma-separated) |
## Install script variables
| Variable | Default | Description |
+25
View File
@@ -0,0 +1,25 @@
const BASE_CORS_HEADERS = {
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
"Access-Control-Max-Age": "86400",
};
export function getAllowedOrigins(envValue?: string): string[] {
if (envValue) {
return envValue.split(",").map((o) => o.trim());
}
return ["https://share.plannotator.ai", "http://localhost:3001"];
}
export function corsHeaders(
requestOrigin: string,
allowedOrigins: string[]
): Record<string, string> {
if (allowedOrigins.includes(requestOrigin) || allowedOrigins.includes("*")) {
return {
...BASE_CORS_HEADERS,
"Access-Control-Allow-Origin": requestOrigin,
};
}
return {};
}
+144
View File
@@ -0,0 +1,144 @@
import type { PasteStore } from "./storage";
import { corsHeaders } from "./cors";
export interface PasteOptions {
maxSize: number;
ttlSeconds: number;
}
const DEFAULT_OPTIONS: PasteOptions = {
maxSize: 524_288, // 512 KB
ttlSeconds: 7 * 24 * 60 * 60, // 7 days
};
const ID_PATTERN = /^\/api\/paste\/([A-Za-z0-9]{6,16})$/;
/**
* Generate a short URL-safe ID (8 chars, ~47.6 bits of entropy).
* Uses Web Crypto with rejection sampling to avoid modulo bias.
*/
function generateId(): string {
const chars =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
const limit = 256 - (256 % chars.length); // 248 — largest multiple of 62 that fits in a byte
const id: string[] = [];
while (id.length < 8) {
const bytes = new Uint8Array(16); // oversample to minimize rounds
crypto.getRandomValues(bytes);
for (const b of bytes) {
if (b < limit) {
id.push(chars[b % chars.length]);
if (id.length === 8) break;
}
}
}
return id.join("");
}
export async function createPaste(
data: string,
store: PasteStore,
options: Partial<PasteOptions> = {}
): Promise<{ id: string }> {
const opts = { ...DEFAULT_OPTIONS, ...options };
if (!data || typeof data !== "string") {
throw new PasteError('Missing or invalid "data" field', 400);
}
if (data.length > opts.maxSize) {
throw new PasteError(
`Payload too large (max ${Math.round(opts.maxSize / 1024)} KB compressed)`,
413
);
}
const id = generateId();
await store.put(id, data, opts.ttlSeconds);
return { id };
}
export async function getPaste(
id: string,
store: PasteStore
): Promise<string | null> {
return store.get(id);
}
export class PasteError extends Error {
constructor(
message: string,
public status: number
) {
super(message);
}
}
/**
* Shared HTTP request handler for the paste service.
* Both Bun and Cloudflare targets delegate to this after wiring up their store.
*/
export async function handleRequest(
request: Request,
store: PasteStore,
cors: Record<string, string>,
options?: Partial<PasteOptions>
): Promise<Response> {
const url = new URL(request.url);
if (request.method === "OPTIONS") {
return new Response(null, { status: 204, headers: cors });
}
if (url.pathname === "/api/paste" && request.method === "POST") {
let body: { data?: unknown };
try {
body = (await request.json()) as { data?: unknown };
} catch {
return Response.json(
{ error: "Invalid JSON body" },
{ status: 400, headers: cors }
);
}
try {
const result = await createPaste(body.data as string, store, options);
return Response.json(result, { status: 201, headers: cors });
} catch (e) {
if (e instanceof PasteError) {
return Response.json(
{ error: e.message },
{ status: e.status, headers: cors }
);
}
return Response.json(
{ error: "Failed to store paste" },
{ status: 500, headers: cors }
);
}
}
const match = url.pathname.match(ID_PATTERN);
if (match && request.method === "GET") {
const data = await getPaste(match[1], store);
if (!data) {
return Response.json(
{ error: "Paste not found or expired" },
{ status: 404, headers: cors }
);
}
return Response.json(
{ data },
{
headers: {
...cors,
"Cache-Control": "public, max-age=3600",
},
}
);
}
return Response.json(
{ error: "Not found. Valid paths: POST /api/paste, GET /api/paste/:id" },
{ status: 404, headers: cors }
);
}
+9
View File
@@ -0,0 +1,9 @@
/**
* PasteStore interface — pluggable storage backend for paste data.
*
* Implementations: FsPasteStore (filesystem), KvPasteStore (CF KV)
*/
export interface PasteStore {
put(id: string, data: string, ttlSeconds: number): Promise<void>;
get(id: string): Promise<string | null>;
}
+14
View File
@@ -0,0 +1,14 @@
{
"name": "@plannotator/paste-service",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "bun run targets/bun.ts",
"dev:cf": "wrangler dev",
"deploy:cf": "wrangler deploy"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20241218.0",
"wrangler": "^3.99.0"
}
}
+70
View File
@@ -0,0 +1,70 @@
import { mkdirSync, readdirSync, readFileSync, unlinkSync } from "fs";
import { join, resolve } from "path";
import type { PasteStore } from "../core/storage";
interface PasteFile {
data: string;
expiresAt: number;
}
export class FsPasteStore implements PasteStore {
private resolvedDir: string;
constructor(private dataDir: string) {
mkdirSync(dataDir, { recursive: true });
this.resolvedDir = resolve(dataDir);
this.sweep();
}
private safePath(id: string): string {
const filePath = resolve(join(this.dataDir, `${id}.json`));
if (!filePath.startsWith(this.resolvedDir)) {
throw new Error("Invalid paste ID");
}
return filePath;
}
async put(id: string, data: string, ttlSeconds: number): Promise<void> {
const entry: PasteFile = {
data,
expiresAt: Date.now() + ttlSeconds * 1000,
};
await Bun.write(this.safePath(id), JSON.stringify(entry));
}
async get(id: string): Promise<string | null> {
const path = this.safePath(id);
try {
const entry: PasteFile = await Bun.file(path).json();
if (Date.now() > entry.expiresAt) {
unlinkSync(path);
return null;
}
return entry.data;
} catch {
return null;
}
}
/** Delete expired pastes on startup */
private sweep(): void {
try {
const files = readdirSync(this.dataDir).filter((f) => f.endsWith(".json"));
const now = Date.now();
for (const file of files) {
const path = join(this.dataDir, file);
try {
const raw = readFileSync(path, "utf-8");
const entry: PasteFile = JSON.parse(raw);
if (now > entry.expiresAt) {
unlinkSync(path);
}
} catch {
// skip malformed files
}
}
} catch {
// dataDir might not exist yet
}
}
}
+17
View File
@@ -0,0 +1,17 @@
import type { PasteStore } from "../core/storage";
/**
* Cloudflare KV-backed paste store.
* Uses KV's native expirationTtl for automatic cleanup.
*/
export class KvPasteStore implements PasteStore {
constructor(private kv: KVNamespace) {}
async put(id: string, data: string, ttlSeconds: number): Promise<void> {
await this.kv.put(`paste:${id}`, data, { expirationTtl: ttlSeconds });
}
async get(id: string): Promise<string | null> {
return this.kv.get(`paste:${id}`);
}
}
+17
View File
@@ -0,0 +1,17 @@
import type { PasteStore } from "../core/storage";
/**
* S3-compatible paste store (future implementation).
*
* TTL handled via S3 lifecycle rules configured on the bucket.
* Implement when needed for AWS Lambda or other cloud deployments.
*/
export class S3PasteStore implements PasteStore {
async put(_id: string, _data: string, _ttlSeconds: number): Promise<void> {
throw new Error("S3PasteStore not yet implemented");
}
async get(_id: string): Promise<string | null> {
throw new Error("S3PasteStore not yet implemented");
}
}
+28
View File
@@ -0,0 +1,28 @@
import { homedir } from "os";
import { join } from "path";
import { handleRequest } from "../core/handler";
import { corsHeaders, getAllowedOrigins } from "../core/cors";
import { FsPasteStore } from "../stores/fs";
const port = parseInt(process.env.PASTE_PORT || "19433", 10);
const dataDir =
process.env.PASTE_DATA_DIR || join(homedir(), ".plannotator", "pastes");
const ttlDays = parseInt(process.env.PASTE_TTL_DAYS || "7", 10);
const ttlSeconds = ttlDays * 24 * 60 * 60;
const maxSize = parseInt(process.env.PASTE_MAX_SIZE || "524288", 10);
const allowedOrigins = getAllowedOrigins(process.env.PASTE_ALLOWED_ORIGINS);
const store = new FsPasteStore(dataDir);
Bun.serve({
port,
async fetch(request) {
const origin = request.headers.get("Origin") ?? "";
const cors = corsHeaders(origin, allowedOrigins);
return handleRequest(request, store, cors, { maxSize, ttlSeconds });
},
});
console.log(`Plannotator paste service running on http://localhost:${port}`);
console.log(`Storage: ${dataDir}`);
console.log(`TTL: ${ttlDays} days`);
+18
View File
@@ -0,0 +1,18 @@
import { handleRequest } from "../core/handler";
import { corsHeaders, getAllowedOrigins } from "../core/cors";
import { KvPasteStore } from "../stores/kv";
interface Env {
PASTE_KV: KVNamespace;
ALLOWED_ORIGINS?: string;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const origin = request.headers.get("Origin") ?? "";
const allowed = getAllowedOrigins(env.ALLOWED_ORIGINS);
const cors = corsHeaders(origin, allowed);
const store = new KvPasteStore(env.PASTE_KV);
return handleRequest(request, store, cors);
},
};
+12
View File
@@ -0,0 +1,12 @@
name = "plannotator-paste"
main = "targets/cloudflare.ts"
compatibility_date = "2024-12-01"
[[kv_namespaces]]
binding = "PASTE_KV"
# Run `wrangler kv:namespace create PASTE_KV` to get your IDs and fill them in.
id = "REPLACE_WITH_KV_NAMESPACE_ID"
preview_id = "REPLACE_WITH_PREVIEW_KV_NAMESPACE_ID"
[vars]
ALLOWED_ORIGINS = "https://share.plannotator.ai,http://localhost:3001"
+209 -4
View File
@@ -1,6 +1,5 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "plannotator",
@@ -65,6 +64,14 @@
"bun": ">=1.0.0",
},
},
"apps/paste-service": {
"name": "@plannotator/paste-service",
"version": "0.1.0",
"devDependencies": {
"@cloudflare/workers-types": "^4.20241218.0",
"wrangler": "^3.99.0",
},
},
"apps/pi-extension": {
"name": "@plannotator/pi-extension",
"version": "0.9.2",
@@ -135,14 +142,22 @@
"packages/server": {
"name": "@plannotator/server",
"version": "0.9.2",
"dependencies": {
"@plannotator/shared": "workspace:*",
},
"peerDependencies": {
"bun": ">=1.0.0",
},
},
"packages/shared": {
"name": "@plannotator/shared",
"version": "0.0.1",
},
"packages/ui": {
"name": "@plannotator/ui",
"version": "0.0.1",
"dependencies": {
"@plannotator/shared": "workspace:*",
"@plannotator/web-highlighter": "^0.8.1",
"diff": "^8.0.3",
"highlight.js": "^11.11.1",
@@ -301,8 +316,30 @@
"@chevrotain/utils": ["@chevrotain/utils@11.0.3", "", {}, "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ=="],
"@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.3.4", "", { "dependencies": { "mime": "^3.0.0" } }, "sha512-YLPHc8yASwjNkmcDMQMY35yiWjoKAKnhUbPRszBRS0YgH+IXtsMp61j+yTcnCE3oO2DgP0U3iejLC8FTtKDC8Q=="],
"@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.0.2", "", { "peerDependencies": { "unenv": "2.0.0-rc.14", "workerd": "^1.20250124.0" }, "optionalPeers": ["workerd"] }, "sha512-nyzYnlZjjV5xT3LizahG1Iu6mnrCaxglJ04rZLpDwlDVDZ7v46lNsfxhV3A/xtfgQuSHmLnc6SVI+KwBpc3Lwg=="],
"@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20250718.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-FHf4t7zbVN8yyXgQ/r/GqLPaYZSGUVzeR7RnL28Mwj2djyw2ZergvytVc7fdGcczl6PQh+VKGfZCfUqpJlbi9g=="],
"@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20250718.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-fUiyUJYyqqp4NqJ0YgGtp4WJh/II/YZsUnEb6vVy5Oeas8lUOxnN+ZOJ8N/6/5LQCVAtYCChRiIrBbfhTn5Z8Q=="],
"@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20250718.0", "", { "os": "linux", "cpu": "x64" }, "sha512-5+eb3rtJMiEwp08Kryqzzu8d1rUcK+gdE442auo5eniMpT170Dz0QxBrqkg2Z48SFUPYbj+6uknuA5tzdRSUSg=="],
"@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20250718.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Aa2M/DVBEBQDdATMbn217zCSFKE+ud/teS+fFS+OQqKABLn0azO2qq6ANAHYOIE6Q3Sq4CxDIQr8lGdaJHwUog=="],
"@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20250718.0", "", { "os": "win32", "cpu": "x64" }, "sha512-dY16RXKffmugnc67LTbyjdDHZn5NoTF1yHEf2fN4+OaOnoGSp3N1x77QubTDwqZ9zECWxgQfDLjddcH8dWeFhg=="],
"@cloudflare/workers-types": ["@cloudflare/workers-types@4.20260301.1", "", {}, "sha512-klKnECMb5A4GtVF0P5NH6rCjtyjqIEKJaz6kEtx9YPHhfFO2HUEarO+MI4F8WPchgeZqpGlEpDhRapzrOTw51Q=="],
"@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="],
"@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="],
"@esbuild-plugins/node-globals-polyfill": ["@esbuild-plugins/node-globals-polyfill@0.2.3", "", { "peerDependencies": { "esbuild": "*" } }, "sha512-r3MIryXDeXDOZh7ih1l/yE9ZLORCd5e8vWg02azWRGj5SPTuoh69A2AIyn0Z31V/kHBfZ4HgWJ+OK3GTTwLmnw=="],
"@esbuild-plugins/node-modules-polyfill": ["@esbuild-plugins/node-modules-polyfill@0.2.2", "", { "dependencies": { "escape-string-regexp": "^4.0.0", "rollup-plugin-node-polyfills": "^0.2.1" }, "peerDependencies": { "esbuild": "*" } }, "sha512-LXV7QsWJxRuMYvKbiznh+U1ilIop3g2TeKRzUxOG5X3YITc8JyyTa90BmLwqqv0YnX4v32CSlG+vsziZp9dMvA=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
@@ -355,6 +392,8 @@
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
"@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="],
"@google/genai": ["@google/genai@1.42.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "p-retry": "^4.6.2", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-+3nlMTcrQufbQ8IumGkOphxD5Pd5kKyJOzLcnY0/1IuE8upJk5aLmoexZ2BJhBp1zAjRJMEB4a2CJwKI9e2EYw=="],
"@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="],
@@ -501,6 +540,8 @@
"@plannotator/opencode": ["@plannotator/opencode@workspace:apps/opencode-plugin"],
"@plannotator/paste-service": ["@plannotator/paste-service@workspace:apps/paste-service"],
"@plannotator/pi-extension": ["@plannotator/pi-extension@workspace:apps/pi-extension"],
"@plannotator/portal": ["@plannotator/portal@workspace:apps/portal"],
@@ -511,6 +552,8 @@
"@plannotator/server": ["@plannotator/server@workspace:packages/server"],
"@plannotator/shared": ["@plannotator/shared@workspace:packages/shared"],
"@plannotator/ui": ["@plannotator/ui@workspace:packages/ui"],
"@plannotator/web-highlighter": ["@plannotator/web-highlighter@0.8.1", "", {}, "sha512-FlteNOwRj9iNSY/AhFMtqOnVS4FvsACvTw6IiOM1y8iDyhiU/WeZOgjURENvIY+wuUaiS9DDFmg0PrHMyuMR1Q=="],
@@ -845,6 +888,8 @@
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
"acorn-walk": ["acorn-walk@8.3.2", "", {}, "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A=="],
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
@@ -869,6 +914,8 @@
"array-iterate": ["array-iterate@2.0.1", "", {}, "sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg=="],
"as-table": ["as-table@1.0.55", "", { "dependencies": { "printable-characters": "^1.0.42" } }, "sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ=="],
"ast-types": ["ast-types@0.13.4", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w=="],
"astring": ["astring@1.9.0", "", { "bin": { "astring": "bin/astring" } }, "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg=="],
@@ -891,6 +938,8 @@
"bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="],
"blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="],
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
"bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="],
@@ -941,10 +990,14 @@
"collapse-white-space": ["collapse-white-space@2.1.0", "", {}, "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw=="],
"color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="],
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
"commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
@@ -1049,7 +1102,7 @@
"dagre-d3-es": ["dagre-d3-es@7.0.13", "", { "dependencies": { "d3": "^7.9.0", "lodash-es": "^4.17.21" } }, "sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q=="],
"data-uri-to-buffer": ["data-uri-to-buffer@6.0.2", "", {}, "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw=="],
"data-uri-to-buffer": ["data-uri-to-buffer@2.0.2", "", {}, "sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA=="],
"dayjs": ["dayjs@1.11.19", "", {}, "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw=="],
@@ -1113,7 +1166,7 @@
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
"escodegen": ["escodegen@2.1.0", "", { "dependencies": { "esprima": "^4.0.1", "estraverse": "^5.2.0", "esutils": "^2.0.2" }, "optionalDependencies": { "source-map": "~0.6.1" }, "bin": { "esgenerate": "bin/esgenerate.js", "escodegen": "bin/escodegen.js" } }, "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w=="],
@@ -1139,6 +1192,10 @@
"eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="],
"exit-hook": ["exit-hook@2.2.1", "", {}, "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw=="],
"exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="],
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
@@ -1177,12 +1234,16 @@
"get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="],
"get-source": ["get-source@2.0.12", "", { "dependencies": { "data-uri-to-buffer": "^2.0.0", "source-map": "^0.6.1" } }, "sha512-X5+4+iD+HoSeEED+uwrQ07BOQr0kEDFMVqqpBuI+RaZBpBpHCuXxo70bjar6f0b0u/DQJsJ7ssurpP0V60Az+w=="],
"get-uri": ["get-uri@6.0.5", "", { "dependencies": { "basic-ftp": "^5.0.2", "data-uri-to-buffer": "^6.0.2", "debug": "^4.3.4" } }, "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg=="],
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
"glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="],
"glob-to-regexp": ["glob-to-regexp@0.4.1", "", {}, "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="],
"google-auth-library": ["google-auth-library@10.5.0", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.0.0", "gcp-metadata": "^8.0.0", "google-logging-utils": "^1.0.0", "gtoken": "^8.0.0", "jws": "^4.0.0" } }, "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w=="],
"google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="],
@@ -1257,6 +1318,8 @@
"is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="],
"is-arrayish": ["is-arrayish@0.3.4", "", {}, "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA=="],
"is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="],
"is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],
@@ -1463,10 +1526,14 @@
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
"mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="],
"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=="],
"miniflare": ["miniflare@3.20250718.3", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "acorn": "8.14.0", "acorn-walk": "8.3.2", "exit-hook": "2.2.1", "glob-to-regexp": "0.4.1", "stoppable": "1.1.0", "undici": "^5.28.5", "workerd": "1.20250718.0", "ws": "8.18.0", "youch": "3.3.4", "zod": "3.22.3" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-JuPrDJhwLrNLEJiNLWO7ZzJrv/Vv9kZuwMYCfv0LskQDM6Eonw4OvywO3CH/wCGjgHzha/qyjUh8JQ068TjDgQ=="],
"minimatch": ["minimatch@10.2.2", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw=="],
"minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
@@ -1477,6 +1544,8 @@
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"mustache": ["mustache@4.2.0", "", { "bin": { "mustache": "bin/mustache" } }, "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ=="],
"mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
@@ -1545,6 +1614,8 @@
"path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="],
"path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="],
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"perfect-freehand": ["perfect-freehand@1.2.2", "", {}, "sha512-eh31l019WICQ03pkF3FSzHxB8n07ItqIQ++G5UV8JX0zVOXzgTGCqnRR0jJ2h9U8/2uW4W4mtGJELt9kEV0CFQ=="],
@@ -1563,6 +1634,8 @@
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"printable-characters": ["printable-characters@1.0.42", "", {}, "sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ=="],
"prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="],
"prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="],
@@ -1643,6 +1716,12 @@
"rollup": ["rollup@4.54.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.54.0", "@rollup/rollup-android-arm64": "4.54.0", "@rollup/rollup-darwin-arm64": "4.54.0", "@rollup/rollup-darwin-x64": "4.54.0", "@rollup/rollup-freebsd-arm64": "4.54.0", "@rollup/rollup-freebsd-x64": "4.54.0", "@rollup/rollup-linux-arm-gnueabihf": "4.54.0", "@rollup/rollup-linux-arm-musleabihf": "4.54.0", "@rollup/rollup-linux-arm64-gnu": "4.54.0", "@rollup/rollup-linux-arm64-musl": "4.54.0", "@rollup/rollup-linux-loong64-gnu": "4.54.0", "@rollup/rollup-linux-ppc64-gnu": "4.54.0", "@rollup/rollup-linux-riscv64-gnu": "4.54.0", "@rollup/rollup-linux-riscv64-musl": "4.54.0", "@rollup/rollup-linux-s390x-gnu": "4.54.0", "@rollup/rollup-linux-x64-gnu": "4.54.0", "@rollup/rollup-linux-x64-musl": "4.54.0", "@rollup/rollup-openharmony-arm64": "4.54.0", "@rollup/rollup-win32-arm64-msvc": "4.54.0", "@rollup/rollup-win32-ia32-msvc": "4.54.0", "@rollup/rollup-win32-x64-gnu": "4.54.0", "@rollup/rollup-win32-x64-msvc": "4.54.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw=="],
"rollup-plugin-inject": ["rollup-plugin-inject@3.0.2", "", { "dependencies": { "estree-walker": "^0.6.1", "magic-string": "^0.25.3", "rollup-pluginutils": "^2.8.1" } }, "sha512-ptg9PQwzs3orn4jkgXJ74bfs5vYz1NCZlSQMBUA0wKcGp5i5pA1AO3fOUEte8enhGUC+iapTCzEWw2jEFFUO/w=="],
"rollup-plugin-node-polyfills": ["rollup-plugin-node-polyfills@0.2.1", "", { "dependencies": { "rollup-plugin-inject": "^3.0.0" } }, "sha512-4kCrKPTJ6sK4/gLL/U5QzVT8cxJcofO0OU74tnB19F40cmuAKSzH5/siithxlofFEjwvw1YAhPmbvGNA6jEroA=="],
"rollup-pluginutils": ["rollup-pluginutils@2.8.2", "", { "dependencies": { "estree-walker": "^0.6.1" } }, "sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ=="],
"roughjs": ["roughjs@4.6.6", "", { "dependencies": { "hachure-fill": "^0.5.2", "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ=="],
"rw": ["rw@1.3.3", "", {}, "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="],
@@ -1667,6 +1746,8 @@
"signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
"simple-swizzle": ["simple-swizzle@0.2.4", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw=="],
"sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
"sitemap": ["sitemap@8.0.2", "", { "dependencies": { "@types/node": "^17.0.5", "@types/sax": "^1.2.1", "arg": "^5.0.0", "sax": "^1.4.1" }, "bin": { "sitemap": "dist/cli.js" } }, "sha512-LwktpJcyZDoa0IL6KT++lQ53pbSrx2c9ge41/SeLTyqy2XUNA6uR4+P9u5IVo5lPeL2arAcOKn1aZAxoYbCKlQ=="],
@@ -1683,10 +1764,16 @@
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"sourcemap-codec": ["sourcemap-codec@1.4.8", "", {}, "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA=="],
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
"stacktracey": ["stacktracey@2.1.8", "", { "dependencies": { "as-table": "^1.0.36", "get-source": "^2.0.12" } }, "sha512-Kpij9riA+UNg7TnphqjH7/CzctQ/owJGNbFkfEeve4Z4uxT5+JapVLFXcsurIfN34gnTWZNJ/f7NMG0E8JDzTw=="],
"std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
"stoppable": ["stoppable@1.1.0", "", {}, "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw=="],
"stream-replace-string": ["stream-replace-string@2.0.0", "", {}, "sha512-TlnjJ1C0QrmxRNrON00JvaFFlNh5TTG00APw23j74ET7gkQpTASi6/L2fuiav8pzK715HXtUeClpBTw2NPSn6w=="],
"string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
@@ -1755,10 +1842,12 @@
"uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="],
"undici": ["undici@7.22.0", "", {}, "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg=="],
"undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"unenv": ["unenv@2.0.0-rc.14", "", { "dependencies": { "defu": "^6.1.4", "exsolve": "^1.0.1", "ohash": "^2.0.10", "pathe": "^2.0.3", "ufo": "^1.5.4" } }, "sha512-od496pShMen7nOy5VmVJCnq8rptd45vh6Nx/r2iPbrba6pa6p+tS2ywuIHRZ/OBvSbQZB0kWvpO9XBNVFXHD3Q=="],
"unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
"unifont": ["unifont@0.7.3", "", { "dependencies": { "css-tree": "^3.1.0", "ofetch": "^1.5.1", "ohash": "^2.0.11" } }, "sha512-b0GtQzKCyuSHGsfj5vyN8st7muZ6VCI4XD4vFlr7Uy1rlWVYxC3npnfk8MyreHxJYrz1ooLDqDzFe9XqQTlAhA=="],
@@ -1827,6 +1916,10 @@
"widest-line": ["widest-line@5.0.0", "", { "dependencies": { "string-width": "^7.0.0" } }, "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA=="],
"workerd": ["workerd@1.20250718.0", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20250718.0", "@cloudflare/workerd-darwin-arm64": "1.20250718.0", "@cloudflare/workerd-linux-64": "1.20250718.0", "@cloudflare/workerd-linux-arm64": "1.20250718.0", "@cloudflare/workerd-windows-64": "1.20250718.0" }, "bin": { "workerd": "bin/workerd" } }, "sha512-kqkIJP/eOfDlUyBzU7joBg+tl8aB25gEAGqDap+nFWb+WHhnooxjGHgxPBy3ipw2hnShPFNOQt5lFRxbwALirg=="],
"wrangler": ["wrangler@3.114.17", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.3.4", "@cloudflare/unenv-preset": "2.0.2", "@esbuild-plugins/node-globals-polyfill": "0.2.3", "@esbuild-plugins/node-modules-polyfill": "0.2.2", "blake3-wasm": "2.1.5", "esbuild": "0.17.19", "miniflare": "3.20250718.3", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.14", "workerd": "1.20250718.0" }, "optionalDependencies": { "fsevents": "~2.3.2", "sharp": "^0.33.5" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20250408.0" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-tAvf7ly+tB+zwwrmjsCyJ2pJnnc7SZhbnNwXbH+OIdVas3zTSmjcZOjmLKcGGptssAA3RyTKhcF9BvKZzMUycA=="],
"wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
"wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
@@ -1851,6 +1944,8 @@
"yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="],
"youch": ["youch@3.3.4", "", { "dependencies": { "cookie": "^0.7.1", "mustache": "^4.2.0", "stacktracey": "^2.1.8" } }, "sha512-UeVBXie8cA35DS6+nBkls68xaBBXCye0CNznrhszZjTbRVnJKQuNsyLKBTTL4ln1o1rh2PKtv35twV7irj5SEg=="],
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="],
@@ -1889,10 +1984,14 @@
"@chevrotain/gast/lodash-es": ["lodash-es@4.17.21", "", {}, "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="],
"@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="],
"@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
"@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
"@mariozechner/pi-ai/undici": ["undici@7.22.0", "", {}, "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg=="],
"@opencode-ai/plugin/zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="],
"@pierre/diffs/diff": ["diff@8.0.2", "", {}, "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg=="],
@@ -1943,6 +2042,10 @@
"foreground-child/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"get-source/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"get-uri/data-uri-to-buffer": ["data-uri-to-buffer@6.0.2", "", {}, "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw=="],
"hast-util-from-html/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
"hast-util-raw/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
@@ -1953,10 +2056,18 @@
"magicast/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
"mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
"mermaid/marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="],
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"miniflare/acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="],
"miniflare/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="],
"miniflare/zod": ["zod@3.22.3", "", {}, "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug=="],
"node-fetch/data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
"p-retry/retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="],
@@ -1969,6 +2080,12 @@
"rimraf/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
"rollup-plugin-inject/estree-walker": ["estree-walker@0.6.1", "", {}, "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w=="],
"rollup-plugin-inject/magic-string": ["magic-string@0.25.9", "", { "dependencies": { "sourcemap-codec": "^1.4.8" } }, "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ=="],
"rollup-pluginutils/estree-walker": ["estree-walker@0.6.1", "", {}, "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w=="],
"sitemap/@types/node": ["@types/node@17.0.45", "", {}, "sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw=="],
"string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
@@ -1977,6 +2094,10 @@
"strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"wrangler/esbuild": ["esbuild@0.17.19", "", { "optionalDependencies": { "@esbuild/android-arm": "0.17.19", "@esbuild/android-arm64": "0.17.19", "@esbuild/android-x64": "0.17.19", "@esbuild/darwin-arm64": "0.17.19", "@esbuild/darwin-x64": "0.17.19", "@esbuild/freebsd-arm64": "0.17.19", "@esbuild/freebsd-x64": "0.17.19", "@esbuild/linux-arm": "0.17.19", "@esbuild/linux-arm64": "0.17.19", "@esbuild/linux-ia32": "0.17.19", "@esbuild/linux-loong64": "0.17.19", "@esbuild/linux-mips64el": "0.17.19", "@esbuild/linux-ppc64": "0.17.19", "@esbuild/linux-riscv64": "0.17.19", "@esbuild/linux-s390x": "0.17.19", "@esbuild/linux-x64": "0.17.19", "@esbuild/netbsd-x64": "0.17.19", "@esbuild/openbsd-x64": "0.17.19", "@esbuild/sunos-x64": "0.17.19", "@esbuild/win32-arm64": "0.17.19", "@esbuild/win32-ia32": "0.17.19", "@esbuild/win32-x64": "0.17.19" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-XQ0jAPFkK/u3LcVRcvVHQcTIqD6E2H1fvZMA5dQPSOWb3suUbWbfbRf94pjc0bNzRYLfIrDRQXr7X+LHIm5oHw=="],
"wrangler/sharp": ["sharp@0.33.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", "semver": "^7.6.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.33.5", "@img/sharp-darwin-x64": "0.33.5", "@img/sharp-libvips-darwin-arm64": "1.0.4", "@img/sharp-libvips-darwin-x64": "1.0.4", "@img/sharp-libvips-linux-arm": "1.0.5", "@img/sharp-libvips-linux-arm64": "1.0.4", "@img/sharp-libvips-linux-s390x": "1.0.4", "@img/sharp-libvips-linux-x64": "1.0.4", "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", "@img/sharp-libvips-linuxmusl-x64": "1.0.4", "@img/sharp-linux-arm": "0.33.5", "@img/sharp-linux-arm64": "0.33.5", "@img/sharp-linux-s390x": "0.33.5", "@img/sharp-linux-x64": "0.33.5", "@img/sharp-linuxmusl-arm64": "0.33.5", "@img/sharp-linuxmusl-x64": "0.33.5", "@img/sharp-wasm32": "0.33.5", "@img/sharp-win32-ia32": "0.33.5", "@img/sharp-win32-x64": "0.33.5" } }, "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw=="],
"wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
@@ -1987,6 +2108,8 @@
"yargs/yargs-parser": ["yargs-parser@20.2.9", "", {}, "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w=="],
"youch/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"@astrojs/react/@vitejs/plugin-react/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
"@astrojs/react/@vitejs/plugin-react/react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
@@ -2087,6 +2210,88 @@
"string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"wrangler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.17.19", "", { "os": "android", "cpu": "arm" }, "sha512-rIKddzqhmav7MSmoFCmDIb6e2W57geRsM94gV2l38fzhXMwq7hZoClug9USI2pFRGL06f4IOPHHpFNOkWieR8A=="],
"wrangler/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.17.19", "", { "os": "android", "cpu": "arm64" }, "sha512-KBMWvEZooR7+kzY0BtbTQn0OAYY7CsiydT63pVEaPtVYF0hXbUaOyZog37DKxK7NF3XacBJOpYT4adIJh+avxA=="],
"wrangler/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.17.19", "", { "os": "android", "cpu": "x64" }, "sha512-uUTTc4xGNDT7YSArp/zbtmbhO0uEEK9/ETW29Wk1thYUJBz3IVnvgEiEwEa9IeLyvnpKrWK64Utw2bgUmDveww=="],
"wrangler/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.17.19", "", { "os": "darwin", "cpu": "arm64" }, "sha512-80wEoCfF/hFKM6WE1FyBHc9SfUblloAWx6FJkFWTWiCoht9Mc0ARGEM47e67W9rI09YoUxJL68WHfDRYEAvOhg=="],
"wrangler/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.17.19", "", { "os": "darwin", "cpu": "x64" }, "sha512-IJM4JJsLhRYr9xdtLytPLSH9k/oxR3boaUIYiHkAawtwNOXKE8KoU8tMvryogdcT8AU+Bflmh81Xn6Q0vTZbQw=="],
"wrangler/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.17.19", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-pBwbc7DufluUeGdjSU5Si+P3SoMF5DQ/F/UmTSb8HXO80ZEAJmrykPyzo1IfNbAoaqw48YRpv8shwd1NoI0jcQ=="],
"wrangler/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.17.19", "", { "os": "freebsd", "cpu": "x64" }, "sha512-4lu+n8Wk0XlajEhbEffdy2xy53dpR06SlzvhGByyg36qJw6Kpfk7cp45DR/62aPH9mtJRmIyrXAS5UWBrJT6TQ=="],
"wrangler/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.17.19", "", { "os": "linux", "cpu": "arm" }, "sha512-cdmT3KxjlOQ/gZ2cjfrQOtmhG4HJs6hhvm3mWSRDPtZ/lP5oe8FWceS10JaSJC13GBd4eH/haHnqf7hhGNLerA=="],
"wrangler/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.17.19", "", { "os": "linux", "cpu": "arm64" }, "sha512-ct1Tg3WGwd3P+oZYqic+YZF4snNl2bsnMKRkb3ozHmnM0dGWuxcPTTntAF6bOP0Sp4x0PjSF+4uHQ1xvxfRKqg=="],
"wrangler/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.17.19", "", { "os": "linux", "cpu": "ia32" }, "sha512-w4IRhSy1VbsNxHRQpeGCHEmibqdTUx61Vc38APcsRbuVgK0OPEnQ0YD39Brymn96mOx48Y2laBQGqgZ0j9w6SQ=="],
"wrangler/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.17.19", "", { "os": "linux", "cpu": "none" }, "sha512-2iAngUbBPMq439a+z//gE+9WBldoMp1s5GWsUSgqHLzLJ9WoZLZhpwWuym0u0u/4XmZ3gpHmzV84PonE+9IIdQ=="],
"wrangler/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.17.19", "", { "os": "linux", "cpu": "none" }, "sha512-LKJltc4LVdMKHsrFe4MGNPp0hqDFA1Wpt3jE1gEyM3nKUvOiO//9PheZZHfYRfYl6AwdTH4aTcXSqBerX0ml4A=="],
"wrangler/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.17.19", "", { "os": "linux", "cpu": "ppc64" }, "sha512-/c/DGybs95WXNS8y3Ti/ytqETiW7EU44MEKuCAcpPto3YjQbyK3IQVKfF6nbghD7EcLUGl0NbiL5Rt5DMhn5tg=="],
"wrangler/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.17.19", "", { "os": "linux", "cpu": "none" }, "sha512-FC3nUAWhvFoutlhAkgHf8f5HwFWUL6bYdvLc/TTuxKlvLi3+pPzdZiFKSWz/PF30TB1K19SuCxDTI5KcqASJqA=="],
"wrangler/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.17.19", "", { "os": "linux", "cpu": "s390x" }, "sha512-IbFsFbxMWLuKEbH+7sTkKzL6NJmG2vRyy6K7JJo55w+8xDk7RElYn6xvXtDW8HCfoKBFK69f3pgBJSUSQPr+4Q=="],
"wrangler/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.17.19", "", { "os": "linux", "cpu": "x64" }, "sha512-68ngA9lg2H6zkZcyp22tsVt38mlhWde8l3eJLWkyLrp4HwMUr3c1s/M2t7+kHIhvMjglIBrFpncX1SzMckomGw=="],
"wrangler/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.17.19", "", { "os": "none", "cpu": "x64" }, "sha512-CwFq42rXCR8TYIjIfpXCbRX0rp1jo6cPIUPSaWwzbVI4aOfX96OXY8M6KNmtPcg7QjYeDmN+DD0Wp3LaBOLf4Q=="],
"wrangler/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.17.19", "", { "os": "openbsd", "cpu": "x64" }, "sha512-cnq5brJYrSZ2CF6c35eCmviIN3k3RczmHz8eYaVlNasVqsNY+JKohZU5MKmaOI+KkllCdzOKKdPs762VCPC20g=="],
"wrangler/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.17.19", "", { "os": "sunos", "cpu": "x64" }, "sha512-vCRT7yP3zX+bKWFeP/zdS6SqdWB8OIpaRq/mbXQxTGHnIxspRtigpkUcDMlSCOejlHowLqII7K2JKevwyRP2rg=="],
"wrangler/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.17.19", "", { "os": "win32", "cpu": "arm64" }, "sha512-yYx+8jwowUstVdorcMdNlzklLYhPxjniHWFKgRqH7IFlUEa0Umu3KuYplf1HUZZ422e3NU9F4LGb+4O0Kdcaag=="],
"wrangler/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.17.19", "", { "os": "win32", "cpu": "ia32" }, "sha512-eggDKanJszUtCdlVs0RB+h35wNlb5v4TWEkq4vZcmVt5u/HiDZrTXe2bWFQUez3RgNHwx/x4sk5++4NSSicKkw=="],
"wrangler/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.17.19", "", { "os": "win32", "cpu": "x64" }, "sha512-lAhycmKnVOuRYNtRtatQR1LPQf2oYCkRGkSFnseDAKPl8lu5SOsK/e1sXe5a0Pc5kHIHe6P2I/ilntNv2xf3cA=="],
"wrangler/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="],
"wrangler/sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.0.4" }, "os": "darwin", "cpu": "x64" }, "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q=="],
"wrangler/sharp/@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg=="],
"wrangler/sharp/@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ=="],
"wrangler/sharp/@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.0.5", "", { "os": "linux", "cpu": "arm" }, "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g=="],
"wrangler/sharp/@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA=="],
"wrangler/sharp/@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.0.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA=="],
"wrangler/sharp/@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw=="],
"wrangler/sharp/@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA=="],
"wrangler/sharp/@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw=="],
"wrangler/sharp/@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.0.5" }, "os": "linux", "cpu": "arm" }, "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ=="],
"wrangler/sharp/@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA=="],
"wrangler/sharp/@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.0.4" }, "os": "linux", "cpu": "s390x" }, "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q=="],
"wrangler/sharp/@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA=="],
"wrangler/sharp/@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g=="],
"wrangler/sharp/@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw=="],
"wrangler/sharp/@img/sharp-wasm32": ["@img/sharp-wasm32@0.33.5", "", { "dependencies": { "@emnapi/runtime": "^1.2.0" }, "cpu": "none" }, "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg=="],
"wrangler/sharp/@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.33.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ=="],
"wrangler/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="],
"wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
+15 -2
View File
@@ -372,6 +372,7 @@ const App: React.FC = () => {
const [permissionMode, setPermissionMode] = useState<PermissionMode>('bypassPermissions');
const [sharingEnabled, setSharingEnabled] = useState(true);
const [shareBaseUrl, setShareBaseUrl] = useState<string | undefined>(undefined);
const [pasteApiUrl, setPasteApiUrl] = useState<string | undefined>(undefined);
const [repoInfo, setRepoInfo] = useState<{ display: string; branch?: string } | null>(null);
const [showExportDropdown, setShowExportDropdown] = useState(false);
const [initialExportTab, setInitialExportTab] = useState<'share' | 'annotations' | 'notes'>();
@@ -425,9 +426,13 @@ const App: React.FC = () => {
isLoadingShared,
shareUrl,
shareUrlSize,
shortShareUrl,
isGeneratingShortUrl,
shortUrlError,
pendingSharedAnnotations,
sharedGlobalAttachments,
clearPendingSharedAnnotations,
generateShortUrl,
importFromShareUrl,
} = useSharing(
markdown,
@@ -440,7 +445,8 @@ const App: React.FC = () => {
// When loaded from share, mark as loaded
setIsLoading(false);
},
shareBaseUrl
shareBaseUrl,
pasteApiUrl
);
// Fetch available agents for OpenCode (for validation on approve)
@@ -481,7 +487,7 @@ const App: React.FC = () => {
if (!res.ok) throw new Error('Not in API mode');
return res.json();
})
.then((data: { plan: string; origin?: 'claude-code' | 'opencode' | 'pi'; mode?: 'annotate'; sharingEnabled?: boolean; shareBaseUrl?: string; repoInfo?: { display: string; branch?: string }; previousPlan?: string | null; versionInfo?: { version: number; totalVersions: number; project: string } }) => {
.then((data: { plan: string; origin?: 'claude-code' | 'opencode' | 'pi'; mode?: 'annotate'; sharingEnabled?: boolean; shareBaseUrl?: string; pasteApiUrl?: string; repoInfo?: { display: string; branch?: string }; previousPlan?: string | null; versionInfo?: { version: number; totalVersions: number; project: string } }) => {
setMarkdown(data.plan);
setIsApiMode(true);
if (data.mode === 'annotate') {
@@ -493,6 +499,9 @@ const App: React.FC = () => {
if (data.shareBaseUrl) {
setShareBaseUrl(data.shareBaseUrl);
}
if (data.pasteApiUrl) {
setPasteApiUrl(data.pasteApiUrl);
}
if (data.repoInfo) {
setRepoInfo(data.repoInfo);
}
@@ -1274,6 +1283,10 @@ const App: React.FC = () => {
onClose={() => { setShowExport(false); setInitialExportTab(undefined); }}
shareUrl={shareUrl}
shareUrlSize={shareUrlSize}
shortShareUrl={shortShareUrl}
isGeneratingShortUrl={isGeneratingShortUrl}
shortUrlError={shortUrlError}
onGenerateShortUrl={generateShortUrl}
annotationsOutput={annotationsOutput}
annotationCount={annotations.length}
taterSprite={taterMode ? <TaterSpritePullup /> : undefined}
+4 -2
View File
@@ -59,6 +59,8 @@ export interface ServerOptions {
sharingEnabled?: boolean;
/** Custom base URL for share links (default: https://share.plannotator.ai) */
shareBaseUrl?: string;
/** Base URL of the paste service API for short URL sharing */
pasteApiUrl?: string;
/** Called when server starts with the URL, remote status, and port */
onReady?: (url: string, isRemote: boolean, port: number) => void;
/** OpenCode client for querying available agents (OpenCode only) */
@@ -105,7 +107,7 @@ const RETRY_DELAY_MS = 500;
export async function startPlannotatorServer(
options: ServerOptions
): Promise<ServerResult> {
const { plan, origin, htmlContent, permissionMode, sharingEnabled = true, shareBaseUrl, onReady } = options;
const { plan, origin, htmlContent, permissionMode, sharingEnabled = true, shareBaseUrl, pasteApiUrl, onReady } = options;
const isRemote = isRemoteSession();
const configuredPort = getServerPort();
@@ -196,7 +198,7 @@ export async function startPlannotatorServer(
// API: Get plan content
if (url.pathname === "/api/plan") {
return Response.json({ plan, origin, permissionMode, sharingEnabled, shareBaseUrl, repoInfo, previousPlan, versionInfo });
return Response.json({ plan, origin, permissionMode, sharingEnabled, shareBaseUrl, pasteApiUrl, repoInfo, previousPlan, versionInfo });
}
// API: Serve a linked markdown document
+5 -1
View File
@@ -13,11 +13,15 @@
"./browser": "./browser.ts",
"./storage": "./storage.ts",
"./git": "./git.ts",
"./repo": "./repo.ts"
"./repo": "./repo.ts",
"./share-url": "./share-url.ts"
},
"files": [
"*.ts"
],
"dependencies": {
"@plannotator/shared": "workspace:*"
},
"peerDependencies": {
"bun": ">=1.0.0"
}
+53
View File
@@ -0,0 +1,53 @@
/**
* Server-side share URL generation for remote sessions
*
* Generates a share.plannotator.ai URL from plan content so remote users
* can open the review in their local browser without port forwarding.
*/
import { compress } from "@plannotator/shared/compress";
const DEFAULT_SHARE_BASE = "https://share.plannotator.ai";
/**
* Generate a share URL from plan markdown content.
*
* Returns the full hash-based URL. For remote sessions, this lets the
* user open the plan in their local browser without any backend needed.
*/
export async function generateRemoteShareUrl(
plan: string,
shareBaseUrl?: string
): Promise<string> {
const base = shareBaseUrl || DEFAULT_SHARE_BASE;
const hash = await compress({ p: plan, a: [] });
return `${base}/#${hash}`;
}
/**
* Format byte size as human-readable string
*/
export function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
const kb = bytes / 1024;
return kb < 100 ? `${kb.toFixed(1)} KB` : `${Math.round(kb)} KB`;
}
/**
* Generate a remote share URL and write it to stderr for the user.
* Silently does nothing on failure.
*/
export async function writeRemoteShareLink(
content: string,
shareBaseUrl: string | undefined,
verb: string,
noun: string
): Promise<void> {
const shareUrl = await generateRemoteShareUrl(content, shareBaseUrl);
const size = formatSize(new TextEncoder().encode(shareUrl).length);
process.stderr.write(
`\n Open this link on your local machine to ${verb}:\n` +
` ${shareUrl}\n\n` +
` (${size} — ${noun}, annotations added in browser)\n\n`
);
}
+51
View File
@@ -0,0 +1,51 @@
/**
* Portable deflate-raw + base64url compression.
*
* Uses only Web APIs (CompressionStream, TextEncoder, btoa) so it works
* in browsers, Bun, and edge runtimes. Both @plannotator/server and
* @plannotator/ui import from here — single source of truth.
*/
export async function compress(data: unknown): Promise<string> {
const json = JSON.stringify(data);
const byteArray = new TextEncoder().encode(json);
const stream = new CompressionStream('deflate-raw');
const writer = stream.writable.getWriter();
writer.write(byteArray);
writer.close();
const buffer = await new Response(stream.readable).arrayBuffer();
const compressed = new Uint8Array(buffer);
// Loop instead of spread to avoid RangeError on large payloads
// (String.fromCharCode(...arr) has a ~65K argument limit)
let binary = '';
for (let i = 0; i < compressed.length; i++) {
binary += String.fromCharCode(compressed[i]);
}
const base64 = btoa(binary);
return base64
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
export async function decompress(b64: string): Promise<unknown> {
const base64 = b64
.replace(/-/g, '+')
.replace(/_/g, '/');
const binary = atob(base64);
const byteArray = Uint8Array.from(binary, c => c.charCodeAt(0));
const stream = new DecompressionStream('deflate-raw');
const writer = stream.writable.getWriter();
writer.write(byteArray);
writer.close();
const buffer = await new Response(stream.readable).arrayBuffer();
const json = new TextDecoder().decode(buffer);
return JSON.parse(json);
}
+8
View File
@@ -0,0 +1,8 @@
{
"name": "@plannotator/shared",
"version": "0.0.1",
"private": true,
"exports": {
"./compress": "./compress.ts"
}
}
+94 -17
View File
@@ -15,6 +15,14 @@ interface ExportModalProps {
onClose: () => void;
shareUrl: string;
shareUrlSize: string;
/** Short share URL from the paste service (empty string when unavailable) */
shortShareUrl?: string;
/** Whether the short URL is currently being generated */
isGeneratingShortUrl?: boolean;
/** Error from the last short URL generation attempt (empty string = no error) */
shortUrlError?: string;
/** Generate a short URL on demand (user clicks "Create short link") */
onGenerateShortUrl?: () => void;
annotationsOutput: string;
annotationCount: number;
taterSprite?: React.ReactNode;
@@ -34,6 +42,10 @@ export const ExportModal: React.FC<ExportModalProps> = ({
onClose,
shareUrl,
shareUrlSize,
shortShareUrl = '',
isGeneratingShortUrl = false,
shortUrlError = '',
onGenerateShortUrl,
annotationsOutput,
annotationCount,
taterSprite,
@@ -44,7 +56,7 @@ export const ExportModal: React.FC<ExportModalProps> = ({
}) => {
const defaultTab = initialTab || (sharingEnabled ? 'share' : 'annotations');
const [activeTab, setActiveTab] = useState<Tab>(defaultTab);
const [copied, setCopied] = useState(false);
const [copied, setCopied] = useState<'short' | 'full' | 'annotations' | false>(false);
const [saveStatus, setSaveStatus] = useState<Record<SaveTarget, SaveStatus>>({ obsidian: 'idle', bear: 'idle' });
const [saveErrors, setSaveErrors] = useState<Record<string, string>>({});
@@ -72,10 +84,10 @@ export const ExportModal: React.FC<ExportModalProps> = ({
const isObsidianReady = obsidianSettings.enabled && effectiveVaultPath.trim().length > 0;
const isBearReady = bearSettings.enabled;
const handleCopyUrl = async () => {
const handleCopy = async (text: string, which: 'short' | 'full' | 'annotations') => {
try {
await navigator.clipboard.writeText(shareUrl);
setCopied(true);
await navigator.clipboard.writeText(text);
setCopied(which);
setTimeout(() => setCopied(false), 2000);
} catch (e) {
console.error('Failed to copy:', e);
@@ -83,15 +95,12 @@ export const ExportModal: React.FC<ExportModalProps> = ({
};
const handleCopyAnnotations = async () => {
try {
await navigator.clipboard.writeText(annotationsOutput);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (e) {
console.error('Failed to copy:', e);
}
await handleCopy(annotationsOutput, 'annotations');
};
// Whether the hash URL is large enough to warrant a short URL option
const urlIsLarge = shareUrl.length > 2048;
const handleDownloadAnnotations = () => {
const blob = new Blob([annotationsOutput], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
@@ -225,22 +234,85 @@ export const ExportModal: React.FC<ExportModalProps> = ({
{/* Tab content */}
{activeTab === 'share' && sharingEnabled ? (
<div className="space-y-4">
{/* Short URL — primary copy target when available */}
{shortShareUrl ? (
<div>
<label className="block text-xs font-medium text-muted-foreground mb-2">
Share Link
</label>
<div className="relative group">
<input
readOnly
value={shortShareUrl}
className="w-full bg-muted rounded-lg p-3 pr-20 text-xs font-mono focus:outline-none focus:ring-2 focus:ring-accent/50"
onClick={e => (e.target as HTMLInputElement).select()}
/>
<button
onClick={() => handleCopy(shortShareUrl, 'short')}
className="absolute top-1.5 right-2 px-2 py-1 rounded text-xs font-medium bg-background/80 hover:bg-background border border-border/50 transition-colors flex items-center gap-1"
>
{copied === 'short' ? (
<>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
Copied
</>
) : (
<>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
Copy
</>
)}
</button>
</div>
<p className="text-[10px] text-muted-foreground mt-1">
Short link — safe for Slack, email, and messaging apps.
</p>
</div>
) : isGeneratingShortUrl ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground p-3 bg-muted rounded-lg">
<svg className="w-3 h-3 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path d="M12 2v4m0 12v4m-7.07-3.93l2.83-2.83m8.48-8.48l2.83-2.83M2 12h4m12 0h4m-3.93 7.07l-2.83-2.83M7.76 7.76L4.93 4.93" />
</svg>
Generating short link...
</div>
) : urlIsLarge && onGenerateShortUrl ? (
<div className="p-3 bg-amber-500/10 border border-amber-500/20 rounded-lg">
<p className="text-xs text-amber-600 dark:text-amber-400 mb-2">
This URL may be too long for some messaging apps.
</p>
<button
onClick={onGenerateShortUrl}
className="px-3 py-1.5 rounded-md text-xs font-medium bg-primary text-primary-foreground hover:opacity-90 transition-opacity"
>
Create short link
</button>
{shortUrlError && (
<p className="text-[10px] text-amber-500 mt-1">({shortUrlError})</p>
)}
</div>
) : null}
{/* Full hash URL — always available */}
<div>
<label className="block text-xs font-medium text-muted-foreground mb-2">
Shareable URL
{shortShareUrl ? 'Full URL (backup)' : 'Shareable URL'}
</label>
<div className="relative group">
<textarea
readOnly
value={shareUrl}
className="w-full h-32 bg-muted rounded-lg p-3 pr-20 text-xs font-mono resize-none focus:outline-none focus:ring-2 focus:ring-accent/50"
className="w-full h-24 bg-muted rounded-lg p-3 pr-20 text-xs font-mono resize-none focus:outline-none focus:ring-2 focus:ring-accent/50"
onClick={e => (e.target as HTMLTextAreaElement).select()}
/>
<button
onClick={handleCopyUrl}
onClick={() => handleCopy(shareUrl, 'full')}
className="absolute top-2 right-2 px-2 py-1 rounded text-xs font-medium bg-background/80 hover:bg-background border border-border/50 transition-colors flex items-center gap-1"
>
{copied ? (
{copied === 'full' ? (
<>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
@@ -260,10 +332,15 @@ export const ExportModal: React.FC<ExportModalProps> = ({
{shareUrlSize}
</div>
</div>
{!shortShareUrl && !isGeneratingShortUrl && !urlIsLarge && (
<p className="text-[10px] text-muted-foreground mt-1">
This URL contains the full plan and annotations.
</p>
)}
</div>
<p className="text-xs text-muted-foreground">
This URL contains the full plan and all annotations. Anyone with this link can view and add to your annotations.
Anyone with this link can view and add to your annotations.
</p>
</div>
) : activeTab === 'notes' && showNotesTab ? (
@@ -384,7 +461,7 @@ export const ExportModal: React.FC<ExportModalProps> = ({
onClick={handleCopyAnnotations}
className="px-3 py-1.5 rounded-md text-xs font-medium bg-muted hover:bg-muted/80 transition-colors"
>
{copied ? 'Copied!' : 'Copy'}
{copied === 'annotations' ? 'Copied!' : 'Copy'}
</button>
<button
onClick={handleDownloadAnnotations}
+128 -35
View File
@@ -3,18 +3,23 @@
*
* Handles:
* - Loading shared state from URL hash on mount
* - Generating shareable URLs
* - Loading from paste-service short URLs (/p/<id>)
* - Generating shareable URLs (hash-based and short)
* - Tracking whether current session is from a shared link
*/
import { useState, useEffect, useCallback } from 'react';
import { Annotation, type ImageAttachment } from '../types';
import {
type SharePayload,
parseShareHash,
generateShareUrl,
decompress,
fromShareable,
parseShareableImages,
formatUrlSize,
createShortShareUrl,
loadFromPasteId,
} from '../utils/sharing';
export interface ImportResult {
@@ -37,6 +42,15 @@ interface UseSharingResult {
/** Human-readable size of the share URL */
shareUrlSize: string;
/** Short share URL backed by the paste service (empty string when unavailable) */
shortShareUrl: string;
/** Whether the short URL is currently being generated */
isGeneratingShortUrl: boolean;
/** Error message from the last short URL generation attempt, or empty string */
shortUrlError: string;
/** Annotations loaded from share that need to be applied to DOM */
pendingSharedAnnotations: Annotation[] | null;
@@ -49,26 +63,13 @@ interface UseSharingResult {
/** Manually trigger share URL generation */
refreshShareUrl: () => Promise<void>;
/** Generate a short URL via the paste service (user must explicitly trigger this) */
generateShortUrl: () => Promise<void>;
/** Import annotations from a teammate's share URL */
importFromShareUrl: (url: string) => Promise<ImportResult>;
}
/**
* Parse SharePayload.g (which can be old string[] or new [path,name][] format) into ImageAttachment[]
*/
function parseGlobalAttachments(g: unknown[] | undefined): ImageAttachment[] {
if (!g?.length) return [];
return g.map((item, idx) => {
if (typeof item === 'string') {
const name = item.split('/').pop()?.replace(/\.[^.]+$/, '') || `image-${idx + 1}`;
return { path: item, name };
}
if (Array.isArray(item) && item.length >= 2) {
return { path: item[0] as string, name: item[1] as string };
}
return { path: String(item), name: `image-${idx + 1}` };
});
}
export function useSharing(
markdown: string,
@@ -78,12 +79,16 @@ export function useSharing(
setAnnotations: (a: Annotation[]) => void,
setGlobalAttachments: (g: ImageAttachment[]) => void,
onSharedLoad?: () => void,
shareBaseUrl?: string
shareBaseUrl?: string,
pasteApiUrl?: string
): UseSharingResult {
const [isSharedSession, setIsSharedSession] = useState(false);
const [isLoadingShared, setIsLoadingShared] = useState(true);
const [shareUrl, setShareUrl] = useState('');
const [shareUrlSize, setShareUrlSize] = useState('');
const [shortShareUrl, setShortShareUrl] = useState('');
const [isGeneratingShortUrl, setIsGeneratingShortUrl] = useState(false);
const [shortUrlError, setShortUrlError] = useState('');
const [pendingSharedAnnotations, setPendingSharedAnnotations] = useState<Annotation[] | null>(null);
const [sharedGlobalAttachments, setSharedGlobalAttachments] = useState<ImageAttachment[] | null>(null);
@@ -92,9 +97,40 @@ export function useSharing(
setSharedGlobalAttachments(null);
}, []);
// Load shared state from URL hash
// Load shared state from URL hash (or paste-service short URL)
const loadFromHash = useCallback(async () => {
try {
// Check for short URL path pattern: /p/<id>
const pathMatch = window.location.pathname.match(/^\/p\/([A-Za-z0-9]{6,16})$/);
if (pathMatch) {
const pasteId = pathMatch[1];
const payload = await loadFromPasteId(pasteId, pasteApiUrl);
if (payload) {
setMarkdown(payload.p);
const restoredAnnotations = fromShareable(payload.a);
setAnnotations(restoredAnnotations);
if (payload.g?.length) {
const parsed = parseShareableImages(payload.g) ?? [];
setGlobalAttachments(parsed);
setSharedGlobalAttachments(parsed);
}
setPendingSharedAnnotations(restoredAnnotations);
setIsSharedSession(true);
onSharedLoad?.();
// Remove the /p/<id> path from browser history so a refresh doesn't
// attempt a network fetch. The plan is now held in memory.
const basePath = window.location.pathname.replace(/\/p\/[A-Za-z0-9]+$/, '') || '/';
window.history.replaceState({}, '', basePath);
return true;
}
// Paste fetch failed — fall through to try the hash fragment instead.
}
const payload = await parseShareHash();
if (payload) {
@@ -107,7 +143,7 @@ export function useSharing(
// Restore global attachments if present
if (payload.g?.length) {
const parsed = parseGlobalAttachments(payload.g);
const parsed = parseShareableImages(payload.g) ?? [];
setGlobalAttachments(parsed);
setSharedGlobalAttachments(parsed);
}
@@ -135,7 +171,7 @@ export function useSharing(
console.error('Failed to load from share hash:', e);
return false;
}
}, [setMarkdown, setAnnotations, setGlobalAttachments, onSharedLoad]);
}, [setMarkdown, setAnnotations, setGlobalAttachments, onSharedLoad, pasteApiUrl]);
// Load from hash on mount
useEffect(() => {
@@ -172,21 +208,74 @@ export function useSharing(
refreshShareUrl();
}, [refreshShareUrl]);
// Import annotations from a teammate's share URL
// Clear stale short URL when content changes (does NOT auto-regenerate —
// the user must explicitly click "Create short link" again)
useEffect(() => {
setShortShareUrl('');
setShortUrlError('');
}, [markdown, annotations]);
/**
* Generate a short URL via the paste service.
* Only called when the user explicitly clicks "Create short link".
* Clears the short URL if the service is unavailable — the full
* hash-based URL remains usable as a fallback.
*/
const generateShortUrl = useCallback(async () => {
if (!markdown) return;
setIsGeneratingShortUrl(true);
setShortUrlError('');
try {
const result = await createShortShareUrl(
markdown,
annotations,
globalAttachments,
{ pasteApiUrl, shareBaseUrl }
);
if (result) {
setShortShareUrl(result.shortUrl);
} else {
setShortShareUrl('');
setShortUrlError('Short URL service unavailable');
}
} catch {
setShortShareUrl('');
setShortUrlError('Failed to generate short URL');
} finally {
setIsGeneratingShortUrl(false);
}
}, [markdown, annotations, globalAttachments, shareBaseUrl, pasteApiUrl]);
// Import annotations from a teammate's share URL (supports both hash-based and short /p/<id> URLs)
const importFromShareUrl = useCallback(async (url: string): Promise<ImportResult> => {
try {
// Extract hash from URL
const hashIndex = url.indexOf('#');
if (hashIndex === -1) {
return { success: false, count: 0, planTitle: '', error: 'Invalid share URL: no hash fragment found' };
}
const hash = url.slice(hashIndex + 1);
if (!hash) {
return { success: false, count: 0, planTitle: '', error: 'Invalid share URL: empty hash' };
}
let payload: SharePayload | undefined;
// Decompress payload
const payload = await decompress(hash);
// Check for short URL pattern: /p/<id>
const shortMatch = url.match(/\/p\/([A-Za-z0-9]{6,16})(?:\?|$)/);
if (shortMatch) {
const pasteId = shortMatch[1];
const loaded = await loadFromPasteId(pasteId, pasteApiUrl);
if (!loaded) {
return { success: false, count: 0, planTitle: '', error: 'Failed to load from short URL — paste may have expired' };
}
payload = loaded;
} else {
// Fall back to hash-based URL
const hashIndex = url.indexOf('#');
if (hashIndex === -1) {
return { success: false, count: 0, planTitle: '', error: 'Invalid share URL: no hash fragment or short link found' };
}
const hash = url.slice(hashIndex + 1);
if (!hash) {
return { success: false, count: 0, planTitle: '', error: 'Invalid share URL: empty hash' };
}
payload = await decompress(hash);
}
// Extract plan title from embedded plan text
const lines = (payload.p || '').trim().split('\n');
@@ -218,7 +307,7 @@ export function useSharing(
// Handle global attachments (deduplicate by path)
if (payload.g?.length) {
const parsed = parseGlobalAttachments(payload.g);
const parsed = parseShareableImages(payload.g) ?? [];
const existingPaths = new Set(globalAttachments.map(g => g.path));
const newAttachments = parsed.filter(p => !existingPaths.has(p.path));
if (newAttachments.length > 0) {
@@ -233,17 +322,21 @@ export function useSharing(
const errorMessage = e instanceof Error ? e.message : 'Failed to decompress share URL';
return { success: false, count: 0, planTitle: '', error: errorMessage };
}
}, [annotations, globalAttachments, setAnnotations, setGlobalAttachments]);
}, [annotations, globalAttachments, setAnnotations, setGlobalAttachments, pasteApiUrl]);
return {
isSharedSession,
isLoadingShared,
shareUrl,
shareUrlSize,
shortShareUrl,
isGeneratingShortUrl,
shortUrlError,
pendingSharedAnnotations,
sharedGlobalAttachments,
clearPendingSharedAnnotations,
refreshShareUrl,
generateShortUrl,
importFromShareUrl,
};
}
+1
View File
@@ -9,6 +9,7 @@
"./types": "./types.ts"
},
"dependencies": {
"@plannotator/shared": "workspace:*",
"@plannotator/web-highlighter": "^0.8.1",
"diff": "^8.0.3",
"highlight.js": "^11.11.1",
+97 -48
View File
@@ -9,6 +9,7 @@
*/
import { Annotation, AnnotationType, type ImageAttachment } from '../types';
import { compress, decompress } from '@plannotator/shared/compress';
// Image in shareable format: plain string (old) or [path, name] tuple (new)
type ShareableImage = string | [string, string];
@@ -30,7 +31,7 @@ export interface SharePayload {
/**
* Convert ShareableImage[] to ImageAttachment[] (handles old plain-string format)
*/
function parseShareableImages(raw: ShareableImage[] | undefined): ImageAttachment[] | undefined {
export function parseShareableImages(raw: ShareableImage[] | undefined): ImageAttachment[] | undefined {
if (!raw?.length) return undefined;
return raw.map(img => {
if (typeof img === 'string') {
@@ -45,56 +46,13 @@ function parseShareableImages(raw: ShareableImage[] | undefined): ImageAttachmen
/**
* Convert ImageAttachment[] to ShareableImage[] for compact serialization
*/
function toShareableImages(images: ImageAttachment[] | undefined): ShareableImage[] | undefined {
export function toShareableImages(images: ImageAttachment[] | undefined): ShareableImage[] | undefined {
if (!images?.length) return undefined;
return images.map(img => [img.path, img.name]);
}
/**
* Compress a SharePayload to a base64url string
*/
export async function compress(payload: SharePayload): Promise<string> {
const json = JSON.stringify(payload);
const byteArray = new TextEncoder().encode(json);
const stream = new CompressionStream('deflate-raw');
const writer = stream.writable.getWriter();
writer.write(byteArray);
writer.close();
const buffer = await new Response(stream.readable).arrayBuffer();
const compressed = new Uint8Array(buffer);
// Convert to base64url (URL-safe base64)
const base64 = btoa(String.fromCharCode(...compressed));
return base64
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
/**
* Decompress a base64url string back to SharePayload
*/
export async function decompress(b64: string): Promise<SharePayload> {
// Restore standard base64
const base64 = b64
.replace(/-/g, '+')
.replace(/_/g, '/');
const binary = atob(base64);
const byteArray = Uint8Array.from(binary, c => c.charCodeAt(0));
const stream = new DecompressionStream('deflate-raw');
const writer = stream.writable.getWriter();
writer.write(byteArray);
writer.close();
const buffer = await new Response(stream.readable).arrayBuffer();
const json = new TextDecoder().decode(buffer);
return JSON.parse(json) as SharePayload;
}
// Re-export compress/decompress from shared package (single source of truth)
export { compress, decompress };
/**
* Convert full Annotation objects to minimal shareable format
@@ -187,7 +145,7 @@ export async function generateShareUrl(
markdown: string,
annotations: Annotation[],
globalAttachments?: ImageAttachment[],
baseUrl: string = 'https://share.plannotator.ai'
baseUrl: string = DEFAULT_SHARE_BASE
): Promise<string> {
const payload: SharePayload = {
p: markdown,
@@ -228,3 +186,94 @@ export function formatUrlSize(url: string): string {
}
return `${(bytes / 1024).toFixed(1)} KB`;
}
// ---------------------------------------------------------------------------
// Short URL support (paste-service backed)
// ---------------------------------------------------------------------------
const DEFAULT_PASTE_API = 'https://paste.plannotator.ai';
const DEFAULT_SHARE_BASE = 'https://share.plannotator.ai';
/**
* Create a short share URL by posting compressed plan data to the paste service.
*
* Returns `{ shortUrl, id }` on success, or `null` when the paste service is
* unavailable (e.g. self-hosted environments without a paste backend). Callers
* should fall back to the hash-based URL in that case.
*
* The request has a 5-second timeout so UI responsiveness is not affected.
*/
export async function createShortShareUrl(
markdown: string,
annotations: Annotation[],
globalAttachments?: ImageAttachment[],
options?: {
/** Override the paste API base URL (default: https://paste.plannotator.ai) */
pasteApiUrl?: string;
/** Override the share site base URL used in the returned short link */
shareBaseUrl?: string;
}
): Promise<{ shortUrl: string; id: string } | null> {
const pasteApi = options?.pasteApiUrl ?? DEFAULT_PASTE_API;
const shareBase = options?.shareBaseUrl ?? DEFAULT_SHARE_BASE;
try {
const payload: SharePayload = {
p: markdown,
a: toShareable(annotations),
g: globalAttachments?.length ? toShareableImages(globalAttachments) : undefined,
};
const compressed = await compress(payload);
const response = await fetch(`${pasteApi}/api/paste`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data: compressed }),
signal: AbortSignal.timeout(5_000),
});
if (!response.ok) {
console.warn(`[sharing] Paste service returned ${response.status}`);
return null;
}
const result = (await response.json()) as { id: string };
const shortUrl = `${shareBase}/p/${result.id}`;
return { shortUrl, id: result.id };
} catch (e) {
// Service unavailable — expected for self-hosted setups without a paste backend.
// The caller is responsible for falling back to hash-based sharing silently.
console.debug('[sharing] Short URL service unavailable, using hash-based sharing:', e);
return null;
}
}
/**
* Load plan data from a paste service using the paste ID embedded in a short URL.
*
* Fetches the compressed payload from `<pasteApiUrl>/api/paste/<pasteId>` and
* decompresses it into a `SharePayload`. Returns `null` on any failure.
*/
export async function loadFromPasteId(
pasteId: string,
pasteApiUrl: string = DEFAULT_PASTE_API
): Promise<SharePayload | null> {
try {
const response = await fetch(`${pasteApiUrl}/api/paste/${pasteId}`, {
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) {
console.warn(`[sharing] Paste fetch returned ${response.status} for id ${pasteId}`);
return null;
}
const result = (await response.json()) as { data: string };
return await decompress(result.data);
} catch (e) {
console.warn('[sharing] Failed to load from paste ID:', e);
return null;
}
}