From 26cae27857fc3a226d83b1e971f4c52389447aa2 Mon Sep 17 00:00:00 2001 From: Frederic Cabassut Date: Thu, 5 Mar 2026 21:24:29 +0100 Subject: [PATCH] docs: deduplicate README, add OpenAPI spec with auto-generated API docs - Slim README to essentials, link to docs/ for details - Add docs/openapi.yaml as single source of truth for the API - Add scripts/generate-api-docs.ts to produce docs/API.md from the spec - Add pnpm docs:api / make docs-api target Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 1 + Makefile | 5 +- README.md | 144 +++-------- docs/API.md | 484 ++++++++++++++++++++++++++++------- docs/openapi.yaml | 420 ++++++++++++++++++++++++++++++ package.json | 31 ++- pnpm-lock.yaml | 101 +++++--- scripts/generate-api-docs.ts | 166 ++++++++++++ 8 files changed, 1103 insertions(+), 249 deletions(-) create mode 100644 docs/openapi.yaml create mode 100644 scripts/generate-api-docs.ts diff --git a/CLAUDE.md b/CLAUDE.md index 1b93c3d..f60833b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,6 +60,7 @@ A `Makefile` wraps all common tasks for tool-agnostic usage. Run `make help` to | `make db-generate` | `pnpm db:generate` | Generate migrations | | `make db-migrate` | `pnpm db:migrate` | Run migrations | | `make db-studio` | `pnpm db:studio` | Open Drizzle Studio | +| `make docs-api` | `pnpm docs:api` | Regenerate API docs from OpenAPI spec | | `make install` | `pnpm install` | Install dependencies | | `make docker-build` | — | Build Docker image | | `make docker-up` | — | Start containers | diff --git a/Makefile b/Makefile index 2d2f64d..625ace8 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ .PHONY: help dev build preview check test test-unit test-e2e lint \ - db-push db-generate db-migrate db-studio \ + db-push db-generate db-migrate db-studio docs-api \ install docker-build docker-up docker-down docker-logs clean help: ## List all targets @@ -41,6 +41,9 @@ db-migrate: ## Run migrations db-studio: ## Open Drizzle Studio pnpm db:studio +docs-api: ## Regenerate API docs from OpenAPI spec + pnpm docs:api + install: ## Install dependencies pnpm install diff --git a/README.md b/README.md index 3f1d17e..44a18e7 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,6 @@ -# Crumbs by Bretzel +# 🥨 Crumbs by Bretzel -A self-hostable, offline-first note-taking app inspired by Google Keep. Part of the [Bretzel](https://bretzel.app) app universe. Built with SvelteKit, SQLite, and Tailwind CSS. - -## Features - -- **Notes CRUD** - Create, edit, and delete notes with rich Markdown support -- **Markdown** - Bold, italic, headings, lists, code blocks, tables, links -- **Checklists** - Interactive task lists with checkboxes -- **Image Attachments** - Upload and display images in notes -- **Tags** - Organize notes with inline #hashtags -- **Colors** - 12 color-coded note backgrounds (matching Google Keep) -- **Pin** - Pin important notes to the top -- **Archive** - Archive notes to declutter your main view -- **Trash** - Soft delete with restore capability -- **Search** - Full-text search across titles, content, and tags -- **Dark Mode** - System-aware with manual toggle -- **Single-User Auth** - Password-protected access -- **PWA** - Installable as a standalone app with offline support -- **Local-First** - Notes stored in IndexedDB, synced to server via LWW CRDTs -- **Docker** - Single command deployment with `docker compose up` +A self-hostable, offline-first note-taking app inspired by Google Keep. Part of the [Bretzel](https://bretzel.app) app universe. ## Quick Start @@ -33,118 +15,60 @@ Open http://localhost:3000 and set your password on first visit. ### Development ```bash -# Install dependencies pnpm install - -# Start dev server pnpm dev - -# Run unit tests -pnpm test:unit - -# Run E2E tests (requires Playwright browsers) -npx playwright install chromium -pnpm test:e2e - -# Type check -pnpm check - -# Build for production -pnpm build ``` +Run `make help` to see all available commands, or use pnpm directly: + +```bash +pnpm test # Unit + E2E tests +pnpm check # Type checking +pnpm build # Production build +``` + +## Features + +- Rich notes with Markdown, checklists, image attachments, and 12 color themes +- Organize with #tags, pinning, archive, and trash +- Full-text search across titles, content, and tags +- Dark mode with system detection +- PWA — installable, works offline via IndexedDB + LWW CRDT sync +- Single-user password auth (Argon2) +- Docker deployment with a single command + +See [docs/FEATURES.md](docs/FEATURES.md) for detailed feature documentation. + ## Tech Stack | Layer | Technology | |-------|-----------| -| Framework | SvelteKit 2 | +| Framework | SvelteKit 2 (Svelte 5 runes) | | Language | TypeScript (strict) | | UI | Tailwind CSS 4 | -| Database | SQLite (better-sqlite3) | -| ORM | Drizzle ORM | +| Database | SQLite (better-sqlite3) + Drizzle ORM | | Client DB | IndexedDB (idb) | | Sync | LWW CRDTs | | Auth | Argon2 + session cookies | -| Markdown | markdown-it | -| PWA | @vite-pwa/sveltekit | | Testing | Vitest + Playwright | | Container | Docker (multi-stage) | | CI/CD | GitHub Actions | -## Project Structure +## Documentation -``` -src/ -├── lib/ -│ ├── components/ # Svelte UI components -│ ├── server/db/ # Drizzle schema & connection -│ ├── server/auth.ts # Authentication logic -│ ├── sync/ # CRDT sync engine (client + server) -│ ├── stores/ # Svelte reactive stores -│ ├── types/ # TypeScript type definitions -│ └── utils/ # Markdown, tags, colors, debounce -├── routes/ -│ ├── api/ # REST API endpoints -│ ├── login/ # Login page -│ ├── setup/ # First-run setup page -│ └── +page.svelte # Main notes view -└── hooks.server.ts # Auth middleware -tests/ -├── unit/ # Vitest unit tests -└── e2e/ # Playwright E2E tests -``` - -## API Endpoints - -| Method | Path | Description | -|--------|------|-------------| -| GET | `/api/notes?filter=all\|archived\|trashed` | List notes | -| POST | `/api/notes` | Create a note | -| GET | `/api/notes/:id` | Get a note | -| PATCH | `/api/notes/:id` | Update a note | -| DELETE | `/api/notes/:id` | Delete a note permanently | -| GET | `/api/search?q=query` | Full-text search | -| GET | `/api/tags` | List all tags | -| POST | `/api/sync` | Push sync changes | -| GET | `/api/sync?since=timestamp` | Pull changes since timestamp | -| POST | `/api/auth/setup` | First-time password setup | -| POST | `/api/auth/login` | Login | -| POST | `/api/auth/logout` | Logout | -| GET/POST/DELETE | `/api/notes/:id/attachments` | Manage image attachments | - -## Environment Variables - -| Variable | Default | Description | -|----------|---------|-------------| -| `DATABASE_URL` | `./data/crumbs.db` | SQLite database file path | -| `DATA_DIR` | `./data` | Data directory for attachments | -| `ORIGIN` | `http://localhost:3000` | Server origin (CSRF protection) | -| `PORT` | `3000` | Server port | -| `NODE_ENV` | `development` | Node environment | +| Document | Description | +|----------|-------------| +| [Features](docs/FEATURES.md) | Detailed feature list and behavior | +| [Architecture](docs/ARCHITECTURE.md) | Local-first sync design, tech rationale, DB schema | +| [Deployment](docs/DEPLOYMENT.md) | Docker, Node.js, reverse proxy, backups, env vars | +| [API](docs/API.md) | REST API reference (auto-generated) | ## CI/CD -GitHub Actions workflows are included: +- **CI** — lint, type check, unit tests, E2E tests, Docker build on every push/PR +- **Release** — builds and pushes Docker image on `v*` tags -- **CI** (`ci.yml`): Runs on every push/PR - lint, type check, unit tests, build, E2E tests, Docker build -- **Release** (`release.yml`): Runs on version tags (`v*`) - builds and pushes Docker image to a configurable registry - -Configure your registry via GitHub Secrets: -- `REGISTRY_URL` - Container registry URL -- `REGISTRY_USER` - Registry username -- `REGISTRY_TOKEN` - Registry access token - -## Sync Architecture - -Crumbs uses a local-first architecture with Last-Write-Wins (LWW) conflict resolution: - -1. All reads come from IndexedDB (instant, works offline) -2. Writes go to IndexedDB + a sync queue -3. Background sync pushes queued changes to server every 30s -4. Server resolves conflicts using timestamps (newer wins) -5. Client pulls server changes and merges with local state - -This ensures the app works fully offline and syncs automatically when connectivity is restored. +Configure registry via GitHub Secrets: `REGISTRY_URL`, `REGISTRY_USER`, `REGISTRY_TOKEN`. ## License diff --git a/docs/API.md b/docs/API.md index a51bc8b..fc16285 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1,166 +1,476 @@ -# API Reference + + -All API endpoints require authentication (session cookie) except `/api/auth/setup` and `/api/auth/login`. +# Crumbs API -## Authentication +REST API for Crumbs by Bretzel — a self-hostable, offline-first notes app. -### POST /api/auth/setup -First-time setup. Creates the single user account. +Base URL: `http://localhost:3000` + +All endpoints require a `session` cookie (set by login) except where noted. + +## Auth + +### `POST /api/auth/setup` + +First-time password setup + +Creates the single user account. Only works once — returns 400 if already set up. + +> No authentication required. **Request:** + ```json -{ "password": "min8characters" } +{ + "password": "password" +} ``` -**Response:** `201 Created` +**Response:** `201` + ```json -{ "success": true } +{ + "success": true +} ``` -Sets `session` cookie. + +**Errors:** `400` Setup already completed or invalid password + +--- + +### `POST /api/auth/login` + +Log in + +> No authentication required. -### POST /api/auth/login **Request:** + ```json -{ "password": "yourpassword" } +{ + "password": "password" +} ``` -**Response:** `200 OK` -```json -{ "success": true } -``` -Sets `session` cookie. +**Response:** `200` -### POST /api/auth/logout -Clears session. - -**Response:** `200 OK` ```json -{ "success": true } +{ + "success": true +} ``` +**Errors:** `401` Invalid password + +--- + +### `POST /api/auth/logout` + +Log out + +**Response:** `200` + +```json +{ + "success": true +} +``` + +--- + ## Notes -### GET /api/notes -List notes by filter. +### `GET /api/notes` -**Query Parameters:** -- `filter` - `all` (default), `archived`, `trashed` +List notes + +**Parameters:** + +| Name | In | Type | Required | Description | +|------|----|------|----------|-------------| +| `filter` | query | string | no | (all, archived, trashed) — default: `all` | + +**Response:** `200` -**Response:** `200 OK` ```json [ { "id": "uuid", - "title": "My Note", - "content": "**Markdown** content", - "color": "coral", + "title": "title", + "content": "content", + "color": "default", "pinned": true, - "archived": false, - "trashed": false, - "checklistMode": false, - "sortOrder": 0, - "createdAt": "2024-01-01T00:00:00.000Z", - "updatedAt": "2024-01-01T00:00:00.000Z", + "archived": true, + "trashed": true, + "checklistMode": true, + "sortOrder": 1, + "createdAt": "2025-01-01T00:00:00.000Z", + "updatedAt": "2025-01-01T00:00:00.000Z", "version": 1, - "tags": ["work", "important"] + "tags": [ + "item" + ] } ] ``` -### POST /api/notes -Create a new note. +--- + +### `POST /api/notes` + +Create a note **Request:** + ```json { - "title": "My Note", - "content": "Note content with #tags", + "title": "title", + "content": "content", "color": "default", - "pinned": false, - "checklistMode": false + "pinned": true, + "archived": true, + "trashed": true, + "checklistMode": true, + "sortOrder": 1 } ``` -**Response:** `201 Created` - Returns the created note. +**Response:** `201` -### GET /api/notes/:id -Get a single note. - -### PATCH /api/notes/:id -Update a note. - -**Request:** (partial update - only include changed fields) ```json { - "title": "Updated Title", - "color": "coral", - "pinned": true + "id": "uuid", + "title": "title", + "content": "content", + "color": "default", + "pinned": true, + "archived": true, + "trashed": true, + "checklistMode": true, + "sortOrder": 1, + "createdAt": "2025-01-01T00:00:00.000Z", + "updatedAt": "2025-01-01T00:00:00.000Z", + "version": 1, + "tags": [ + "item" + ] } ``` -**Response:** `200 OK` - Returns the updated note. +--- -### DELETE /api/notes/:id -Permanently delete a note. +### `GET /api/notes/{id}` -## Search +Get a note -### GET /api/search -Full-text search across notes. +**Parameters:** -**Query Parameters:** -- `q` - Search query +| Name | In | Type | Required | Description | +|------|----|------|----------|-------------| +| `id` | path | string | yes | | -**Response:** `200 OK` - Array of matching notes (excluding trashed). +**Response:** `200` -## Tags - -### GET /api/tags -List all tags. - -**Response:** `200 OK` ```json -[ - { "id": 1, "name": "work" }, - { "id": 2, "name": "personal" } -] +{ + "id": "uuid", + "title": "title", + "content": "content", + "color": "default", + "pinned": true, + "archived": true, + "trashed": true, + "checklistMode": true, + "sortOrder": 1, + "createdAt": "2025-01-01T00:00:00.000Z", + "updatedAt": "2025-01-01T00:00:00.000Z", + "version": 1, + "tags": [ + "item" + ] +} ``` +**Errors:** `404` Note not found + +--- + +### `PATCH /api/notes/{id}` + +Update a note + +Partial update — only include changed fields. + +**Parameters:** + +| Name | In | Type | Required | Description | +|------|----|------|----------|-------------| +| `id` | path | string | yes | | + +**Request:** + +```json +{ + "title": "title", + "content": "content", + "color": "default", + "pinned": true, + "archived": true, + "trashed": true, + "checklistMode": true, + "sortOrder": 1 +} +``` + +**Response:** `200` + +```json +{ + "id": "uuid", + "title": "title", + "content": "content", + "color": "default", + "pinned": true, + "archived": true, + "trashed": true, + "checklistMode": true, + "sortOrder": 1, + "createdAt": "2025-01-01T00:00:00.000Z", + "updatedAt": "2025-01-01T00:00:00.000Z", + "version": 1, + "tags": [ + "item" + ] +} +``` + +**Errors:** `404` Note not found + +--- + +### `DELETE /api/notes/{id}` + +Permanently delete a note + +**Parameters:** + +| Name | In | Type | Required | Description | +|------|----|------|----------|-------------| +| `id` | path | string | yes | | + +**Response:** `200` + +```json +{ + "success": true +} +``` + +**Errors:** `404` Note not found + +--- + ## Attachments -### GET /api/notes/:id/attachments -List attachments for a note. +### `GET /api/notes/{id}/attachments` -### POST /api/notes/:id/attachments -Upload an image attachment. +List or download attachments + +Without `attachmentId`, lists all attachments. With `attachmentId`, returns the file. + +**Parameters:** + +| Name | In | Type | Required | Description | +|------|----|------|----------|-------------| +| `id` | path | string | yes | | +| `attachmentId` | query | string | no | | + +**Response:** `200` + +--- + +### `POST /api/notes/{id}/attachments` + +Upload an image attachment + +**Parameters:** + +| Name | In | Type | Required | Description | +|------|----|------|----------|-------------| +| `id` | path | string | yes | | **Request:** `multipart/form-data` with `file` field. -**Response:** `201 Created` - Returns attachment metadata. +**Response:** `201` -### DELETE /api/notes/:id/attachments?attachmentId=xxx -Delete an attachment. +```json +{ + "id": "id", + "noteId": "noteId", + "filename": "filename", + "mimeType": "mimeType", + "size": 1, + "createdAt": "2025-01-01T00:00:00.000Z" +} +``` + +**Errors:** `400` Invalid file + +--- + +### `DELETE /api/notes/{id}/attachments` + +Delete an attachment + +**Parameters:** + +| Name | In | Type | Required | Description | +|------|----|------|----------|-------------| +| `id` | path | string | yes | | +| `attachmentId` | query | string | yes | | + +**Response:** `200` + +```json +{ + "success": true +} +``` + +--- + +## Search + +### `GET /api/search` + +Full-text search + +Searches across note titles, content, and tags. Excludes trashed notes. + +**Parameters:** + +| Name | In | Type | Required | Description | +|------|----|------|----------|-------------| +| `q` | query | string | yes | | + +**Response:** `200` + +```json +[ + { + "id": "uuid", + "title": "title", + "content": "content", + "color": "default", + "pinned": true, + "archived": true, + "trashed": true, + "checklistMode": true, + "sortOrder": 1, + "createdAt": "2025-01-01T00:00:00.000Z", + "updatedAt": "2025-01-01T00:00:00.000Z", + "version": 1, + "tags": [ + "item" + ] + } +] +``` + +--- + +## Tags + +### `GET /api/tags` + +List all tags + +**Response:** `200` + +```json +[ + { + "id": 1, + "name": "name" + } +] +``` + +--- ## Sync -### POST /api/sync -Push local changes to server. +### `GET /api/sync` + +Pull changes since timestamp + +**Parameters:** + +| Name | In | Type | Required | Description | +|------|----|------|----------|-------------| +| `since` | query | integer | yes | Unix timestamp in milliseconds | + +**Response:** `200` + +```json +[ + { + "id": "uuid", + "title": "title", + "content": "content", + "color": "default", + "pinned": true, + "archived": true, + "trashed": true, + "checklistMode": true, + "sortOrder": 1, + "createdAt": "2025-01-01T00:00:00.000Z", + "updatedAt": "2025-01-01T00:00:00.000Z", + "version": 1, + "tags": [ + "item" + ] + } +] +``` + +--- + +### `POST /api/sync` + +Push local changes **Request:** + ```json { "changes": [ { "noteId": "uuid", - "operation": "create|update|delete", - "timestamp": 1704067200000, - "data": { "title": "...", "content": "..." } + "operation": "create", + "timestamp": 1, + "data": { + "title": "title", + "content": "content", + "color": "default", + "pinned": true, + "archived": true, + "trashed": true, + "checklistMode": true, + "sortOrder": 1 + } } ] } ``` -### GET /api/sync?since=timestamp -Pull changes since a timestamp (milliseconds). +**Response:** `200` -**Response:** `200 OK` - Array of notes updated since the given timestamp. +```json +{ + "success": true +} +``` + +--- diff --git a/docs/openapi.yaml b/docs/openapi.yaml new file mode 100644 index 0000000..87a5613 --- /dev/null +++ b/docs/openapi.yaml @@ -0,0 +1,420 @@ +openapi: 3.1.0 +info: + title: Crumbs API + description: REST API for Crumbs by Bretzel — a self-hostable, offline-first notes app. + version: 0.1.0 + contact: + name: Bretzel + url: https://bretzel.app + +servers: + - url: http://localhost:3000 + description: Local development + +security: + - sessionCookie: [] + +paths: + /api/auth/setup: + post: + summary: First-time password setup + description: Creates the single user account. Only works once — returns 400 if already set up. + tags: [Auth] + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [password] + properties: + password: + type: string + minLength: 8 + responses: + '201': + description: Account created, session cookie set + content: + application/json: + schema: + $ref: '#/components/schemas/Success' + '400': + description: Setup already completed or invalid password + + /api/auth/login: + post: + summary: Log in + tags: [Auth] + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [password] + properties: + password: + type: string + responses: + '200': + description: Logged in, session cookie set + content: + application/json: + schema: + $ref: '#/components/schemas/Success' + '401': + description: Invalid password + + /api/auth/logout: + post: + summary: Log out + tags: [Auth] + responses: + '200': + description: Session cleared + content: + application/json: + schema: + $ref: '#/components/schemas/Success' + + /api/notes: + get: + summary: List notes + tags: [Notes] + parameters: + - name: filter + in: query + schema: + type: string + enum: [all, archived, trashed] + default: all + responses: + '200': + description: Array of notes + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Note' + post: + summary: Create a note + tags: [Notes] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/NoteInput' + responses: + '201': + description: Created note + content: + application/json: + schema: + $ref: '#/components/schemas/Note' + + /api/notes/{id}: + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + get: + summary: Get a note + tags: [Notes] + responses: + '200': + description: Note with tags + content: + application/json: + schema: + $ref: '#/components/schemas/Note' + '404': + description: Note not found + patch: + summary: Update a note + description: Partial update — only include changed fields. + tags: [Notes] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/NoteInput' + responses: + '200': + description: Updated note + content: + application/json: + schema: + $ref: '#/components/schemas/Note' + '404': + description: Note not found + delete: + summary: Permanently delete a note + tags: [Notes] + responses: + '200': + description: Deleted + content: + application/json: + schema: + $ref: '#/components/schemas/Success' + '404': + description: Note not found + + /api/notes/{id}/attachments: + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + get: + summary: List or download attachments + description: Without `attachmentId`, lists all attachments. With `attachmentId`, returns the file. + tags: [Attachments] + parameters: + - name: attachmentId + in: query + schema: + type: string + responses: + '200': + description: Attachment list or file content + post: + summary: Upload an image attachment + tags: [Attachments] + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: + file: + type: string + format: binary + description: Image file (max 10MB) + responses: + '201': + description: Attachment metadata + content: + application/json: + schema: + $ref: '#/components/schemas/Attachment' + '400': + description: Invalid file + delete: + summary: Delete an attachment + tags: [Attachments] + parameters: + - name: attachmentId + in: query + required: true + schema: + type: string + responses: + '200': + description: Deleted + content: + application/json: + schema: + $ref: '#/components/schemas/Success' + + /api/search: + get: + summary: Full-text search + description: Searches across note titles, content, and tags. Excludes trashed notes. + tags: [Search] + parameters: + - name: q + in: query + required: true + schema: + type: string + responses: + '200': + description: Matching notes + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Note' + + /api/tags: + get: + summary: List all tags + tags: [Tags] + responses: + '200': + description: Array of tags + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Tag' + + /api/sync: + get: + summary: Pull changes since timestamp + tags: [Sync] + parameters: + - name: since + in: query + required: true + schema: + type: integer + description: Unix timestamp in milliseconds + responses: + '200': + description: Notes updated since the given timestamp + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Note' + post: + summary: Push local changes + tags: [Sync] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [changes] + properties: + changes: + type: array + items: + $ref: '#/components/schemas/SyncChange' + responses: + '200': + description: Changes applied + content: + application/json: + schema: + $ref: '#/components/schemas/Success' + +components: + securitySchemes: + sessionCookie: + type: apiKey + in: cookie + name: session + + schemas: + Success: + type: object + properties: + success: + type: boolean + + Note: + type: object + properties: + id: + type: string + format: uuid + title: + type: string + content: + type: string + color: + type: string + enum: [default, coral, peach, sand, mint, sage, fog, storm, dusk, blossom, clay, chalk] + pinned: + type: boolean + archived: + type: boolean + trashed: + type: boolean + checklistMode: + type: boolean + sortOrder: + type: integer + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + version: + type: integer + tags: + type: array + items: + type: string + + NoteInput: + type: object + properties: + title: + type: string + content: + type: string + color: + type: string + enum: [default, coral, peach, sand, mint, sage, fog, storm, dusk, blossom, clay, chalk] + pinned: + type: boolean + archived: + type: boolean + trashed: + type: boolean + checklistMode: + type: boolean + sortOrder: + type: integer + + Tag: + type: object + properties: + id: + type: integer + name: + type: string + + Attachment: + type: object + properties: + id: + type: string + noteId: + type: string + filename: + type: string + mimeType: + type: string + size: + type: integer + createdAt: + type: string + format: date-time + + SyncChange: + type: object + required: [noteId, operation, timestamp] + properties: + noteId: + type: string + format: uuid + operation: + type: string + enum: [create, update, delete] + timestamp: + type: integer + description: Unix timestamp in milliseconds + data: + $ref: '#/components/schemas/NoteInput' diff --git a/package.json b/package.json index 9439f82..079646e 100644 --- a/package.json +++ b/package.json @@ -18,33 +18,36 @@ "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:studio": "drizzle-kit studio", - "lint": "eslint ." + "lint": "eslint .", + "docs:api": "tsx scripts/generate-api-docs.ts" }, "dependencies": { "@sveltejs/adapter-node": "^5.0.0", + "argon2": "^0.43.0", "better-sqlite3": "^11.0.0", "drizzle-orm": "^0.44.0", - "argon2": "^0.43.0", "idb": "^8.0.0", - "uuid": "^11.0.0", - "markdown-it": "^14.0.0" + "markdown-it": "^14.0.0", + "uuid": "^11.0.0" }, "devDependencies": { + "@playwright/test": "^1.50.0", "@sveltejs/kit": "^2.50.2", "@sveltejs/vite-plugin-svelte": "^6.2.4", - "svelte": "^5.51.0", - "svelte-check": "^4.4.2", - "typescript": "^5.9.3", - "vite": "^7.3.1", "@tailwindcss/vite": "^4.0.0", - "tailwindcss": "^4.0.0", - "@vite-pwa/sveltekit": "^1.0.0", - "drizzle-kit": "^0.31.0", - "vitest": "^3.0.0", - "@playwright/test": "^1.50.0", "@types/better-sqlite3": "^7.6.0", "@types/markdown-it": "^14.0.0", - "@types/uuid": "^10.0.0" + "@types/uuid": "^10.0.0", + "@vite-pwa/sveltekit": "^1.0.0", + "drizzle-kit": "^0.31.0", + "svelte": "^5.51.0", + "svelte-check": "^4.4.2", + "tailwindcss": "^4.0.0", + "tsx": "^4.21.0", + "typescript": "^5.9.3", + "vite": "^7.3.1", + "vitest": "^3.0.0", + "yaml": "^2.8.2" }, "pnpm": { "onlyBuiltDependencies": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1e6c2c7..951362c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,7 +10,7 @@ importers: dependencies: '@sveltejs/adapter-node': specifier: ^5.0.0 - version: 5.5.4(@sveltejs/kit@2.53.4(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0))) + version: 5.5.4(@sveltejs/kit@2.53.4(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) argon2: specifier: ^0.43.0 version: 0.43.1 @@ -35,13 +35,13 @@ importers: version: 1.58.2 '@sveltejs/kit': specifier: ^2.50.2 - version: 2.53.4(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)) + version: 2.53.4(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) '@sveltejs/vite-plugin-svelte': specifier: ^6.2.4 - version: 6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)) + version: 6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) '@tailwindcss/vite': specifier: ^4.0.0 - version: 4.2.1(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)) + version: 4.2.1(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) '@types/better-sqlite3': specifier: ^7.6.0 version: 7.6.13 @@ -53,7 +53,7 @@ importers: version: 10.0.0 '@vite-pwa/sveltekit': specifier: ^1.0.0 - version: 1.1.0(@sveltejs/kit@2.53.4(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)))(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0))(workbox-build@7.4.0)(workbox-window@7.4.0) + version: 1.1.0(@sveltejs/kit@2.53.4(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(workbox-build@7.4.0)(workbox-window@7.4.0) drizzle-kit: specifier: ^0.31.0 version: 0.31.9 @@ -66,15 +66,21 @@ importers: tailwindcss: specifier: ^4.0.0 version: 4.2.1 + tsx: + specifier: ^4.21.0 + version: 4.21.0 typescript: specifier: ^5.9.3 version: 5.9.3 vite: specifier: ^7.3.1 - version: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0) + version: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) vitest: specifier: ^3.0.0 - version: 3.2.4(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0) + version: 3.2.4(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + yaml: + specifier: ^2.8.2 + version: 2.8.2 packages: @@ -2798,6 +2804,11 @@ packages: tr46@1.0.1: resolution: {integrity: sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==} + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} @@ -3057,6 +3068,11 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yaml@2.8.2: + resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==} + engines: {node: '>= 14.6'} + hasBin: true + zimmerframe@1.1.4: resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==} @@ -4163,19 +4179,19 @@ snapshots: dependencies: acorn: 8.16.0 - '@sveltejs/adapter-node@5.5.4(@sveltejs/kit@2.53.4(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)))': + '@sveltejs/adapter-node@5.5.4(@sveltejs/kit@2.53.4(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))': dependencies: '@rollup/plugin-commonjs': 29.0.1(rollup@4.59.0) '@rollup/plugin-json': 6.1.0(rollup@4.59.0) '@rollup/plugin-node-resolve': 16.0.3(rollup@4.59.0) - '@sveltejs/kit': 2.53.4(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)) + '@sveltejs/kit': 2.53.4(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) rollup: 4.59.0 - '@sveltejs/kit@2.53.4(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0))': + '@sveltejs/kit@2.53.4(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@standard-schema/spec': 1.1.0 '@sveltejs/acorn-typescript': 1.0.9(acorn@8.16.0) - '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)) + '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) '@types/cookie': 0.6.0 acorn: 8.16.0 cookie: 0.6.0 @@ -4187,26 +4203,26 @@ snapshots: set-cookie-parser: 3.0.1 sirv: 3.0.2 svelte: 5.53.7 - vite: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0) + vite: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) optionalDependencies: typescript: 5.9.3 - '@sveltejs/vite-plugin-svelte-inspector@5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)))(svelte@5.53.7)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0))': + '@sveltejs/vite-plugin-svelte-inspector@5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: - '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)) + '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) obug: 2.1.1 svelte: 5.53.7 - vite: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0) + vite: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) - '@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0))': + '@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: - '@sveltejs/vite-plugin-svelte-inspector': 5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)))(svelte@5.53.7)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)) + '@sveltejs/vite-plugin-svelte-inspector': 5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) deepmerge: 4.3.1 magic-string: 0.30.21 obug: 2.1.1 svelte: 5.53.7 - vite: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0) - vitefu: 1.1.2(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)) + vite: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vitefu: 1.1.2(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) '@tailwindcss/node@4.2.1': dependencies: @@ -4269,12 +4285,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.2.1 '@tailwindcss/oxide-win32-x64-msvc': 4.2.1 - '@tailwindcss/vite@4.2.1(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0))': + '@tailwindcss/vite@4.2.1(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@tailwindcss/node': 4.2.1 '@tailwindcss/oxide': 4.2.1 tailwindcss: 4.2.1 - vite: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0) + vite: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) '@types/better-sqlite3@7.6.13': dependencies: @@ -4312,12 +4328,12 @@ snapshots: '@types/uuid@10.0.0': {} - '@vite-pwa/sveltekit@1.1.0(@sveltejs/kit@2.53.4(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)))(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0))(workbox-build@7.4.0)(workbox-window@7.4.0)': + '@vite-pwa/sveltekit@1.1.0(@sveltejs/kit@2.53.4(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(workbox-build@7.4.0)(workbox-window@7.4.0)': dependencies: - '@sveltejs/kit': 2.53.4(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)) + '@sveltejs/kit': 2.53.4(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) kolorist: 1.8.0 tinyglobby: 0.2.15 - vite-plugin-pwa: 1.2.0(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0))(workbox-build@7.4.0)(workbox-window@7.4.0) + vite-plugin-pwa: 1.2.0(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(workbox-build@7.4.0)(workbox-window@7.4.0) transitivePeerDependencies: - supports-color - vite @@ -4332,13 +4348,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0))': + '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0) + vite: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) '@vitest/pretty-format@3.2.4': dependencies: @@ -5725,6 +5741,13 @@ snapshots: dependencies: punycode: 2.3.1 + tsx@4.21.0: + dependencies: + esbuild: 0.27.3 + get-tsconfig: 4.13.6 + optionalDependencies: + fsevents: 2.3.3 + tunnel-agent@0.6.0: dependencies: safe-buffer: 5.2.1 @@ -5806,13 +5829,13 @@ snapshots: uuid@11.1.0: {} - vite-node@3.2.4(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0): + vite-node@3.2.4(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0) + vite: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - '@types/node' - jiti @@ -5827,18 +5850,18 @@ snapshots: - tsx - yaml - vite-plugin-pwa@1.2.0(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0))(workbox-build@7.4.0)(workbox-window@7.4.0): + vite-plugin-pwa@1.2.0(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(workbox-build@7.4.0)(workbox-window@7.4.0): dependencies: debug: 4.4.3 pretty-bytes: 6.1.1 tinyglobby: 0.2.15 - vite: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0) + vite: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) workbox-build: 7.4.0 workbox-window: 7.4.0 transitivePeerDependencies: - supports-color - vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0): + vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: esbuild: 0.27.3 fdir: 6.5.0(picomatch@4.0.3) @@ -5852,16 +5875,18 @@ snapshots: jiti: 2.6.1 lightningcss: 1.31.1 terser: 5.46.0 + tsx: 4.21.0 + yaml: 2.8.2 - vitefu@1.1.2(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)): + vitefu@1.1.2(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)): optionalDependencies: - vite: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0) + vite: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) - vitest@3.2.4(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0): + vitest@3.2.4(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)) + '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -5879,8 +5904,8 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0) - vite-node: 3.2.4(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0) + vite: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vite-node: 3.2.4(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.3.3 @@ -6073,4 +6098,6 @@ snapshots: yallist@3.1.1: {} + yaml@2.8.2: {} + zimmerframe@1.1.4: {} diff --git a/scripts/generate-api-docs.ts b/scripts/generate-api-docs.ts new file mode 100644 index 0000000..de780be --- /dev/null +++ b/scripts/generate-api-docs.ts @@ -0,0 +1,166 @@ +/** + * Generates docs/API.md from docs/openapi.yaml. + * + * Usage: npx tsx scripts/generate-api-docs.ts + */ + +import { readFileSync, writeFileSync } from 'fs'; +import { parse } from 'yaml'; + +const spec = parse(readFileSync('docs/openapi.yaml', 'utf-8')); + +const lines: string[] = [ + '', + '', + '', + `# ${spec.info.title}`, + '', + spec.info.description, + '', + `Base URL: \`${spec.servers[0].url}\``, + '', + 'All endpoints require a `session` cookie (set by login) except where noted.', + '' +]; + +// Group paths by tag +const groups = new Map }[]>(); + +for (const [path, methods] of Object.entries(spec.paths) as [string, Record][]) { + const pathParams = (methods.parameters as unknown[]) || []; + for (const [method, op] of Object.entries(methods)) { + if (method === 'parameters') continue; + const operation = op as Record; + const tag = ((operation.tags as string[]) || ['Other'])[0]; + if (!groups.has(tag)) groups.set(tag, []); + groups.get(tag)!.push({ method: method.toUpperCase(), path, op: { ...operation, _pathParams: pathParams } }); + } +} + +for (const [tag, endpoints] of groups) { + lines.push(`## ${tag}`, ''); + for (const { method, path, op } of endpoints) { + lines.push(`### \`${method} ${path}\``, ''); + lines.push(String(op.summary || ''), ''); + if (op.description) lines.push(String(op.description), ''); + + if ((op.security as unknown[])?.length === 0) { + lines.push('> No authentication required.', ''); + } + + // Parameters + const allParams = [...((op._pathParams as unknown[]) || []), ...((op.parameters as unknown[]) || [])]; + if (allParams.length > 0) { + lines.push('**Parameters:**', ''); + lines.push('| Name | In | Type | Required | Description |'); + lines.push('|------|----|------|----------|-------------|'); + for (const p of allParams as Record[]) { + const schema = p.schema as Record | undefined; + const type = schema?.type || ''; + const enumVals = schema?.enum as string[] | undefined; + let desc = String(p.description || schema?.description || ''); + if (enumVals) desc += ` (${enumVals.join(', ')})`; + if (schema?.default !== undefined) desc += ` — default: \`${schema.default}\``; + lines.push(`| \`${p.name}\` | ${p.in} | ${type} | ${p.required ? 'yes' : 'no'} | ${desc} |`); + } + lines.push(''); + } + + // Request body + const body = op.requestBody as Record | undefined; + if (body) { + const content = body.content as Record>; + const contentType = Object.keys(content)[0]; + const schema = content[contentType]?.schema as Record | undefined; + + if (contentType === 'multipart/form-data') { + lines.push('**Request:** `multipart/form-data` with `file` field.', ''); + } else if (schema) { + const example = buildExample(schema, spec.components?.schemas as Record); + if (example) { + lines.push('**Request:**', '', '```json', JSON.stringify(example, null, 2), '```', ''); + } + } + } + + // Responses + const responses = op.responses as Record> | undefined; + if (responses) { + const codes = Object.keys(responses); + const successCode = codes.find(c => c.startsWith('2')) || codes[0]; + const successResp = responses[successCode]; + lines.push(`**Response:** \`${successCode}\``, ''); + const respContent = successResp?.content as Record> | undefined; + if (respContent) { + const schema = Object.values(respContent)[0]?.schema as Record; + if (schema) { + const example = buildExample(schema, spec.components?.schemas as Record); + if (example) { + lines.push('```json', JSON.stringify(example, null, 2), '```', ''); + } + } + } + + // Error codes + const errors = codes.filter(c => !c.startsWith('2')); + if (errors.length > 0) { + lines.push(`**Errors:** ${errors.map(c => `\`${c}\` ${responses[c].description || ''}`).join(', ')}`, ''); + } + } + + lines.push('---', ''); + } +} + +writeFileSync('docs/API.md', lines.join('\n')); +console.log('Generated docs/API.md'); + +function buildExample(schema: Record, schemas?: Record): unknown { + if (schema.$ref) { + const refName = String(schema.$ref).split('/').pop()!; + return schemas ? buildExample(schemas[refName] as Record, schemas) : {}; + } + + if (schema.type === 'array') { + const items = schema.items as Record; + if (!items) return []; + if (items.$ref || items.properties || items.type === 'object') return [buildExample(items, schemas)]; + return [exampleValue('item', items, schemas)]; + } + + if (schema.type === 'object' || schema.properties) { + const props = schema.properties as Record> | undefined; + if (!props) return {}; + const obj: Record = {}; + for (const [key, prop] of Object.entries(props)) { + obj[key] = exampleValue(key, prop, schemas); + } + return obj; + } + + return null; +} + +function exampleValue(key: string, prop: Record, schemas?: Record): unknown { + if (prop.$ref) return buildExample(prop, schemas); + if (prop.type === 'array') return buildExample(prop, schemas); + if (prop.type === 'object' || prop.properties) return buildExample(prop, schemas); + + const enumVals = prop.enum as string[] | undefined; + if (enumVals) return enumVals[0]; + + switch (prop.type) { + case 'string': + if (prop.format === 'uuid') return 'uuid'; + if (prop.format === 'date-time') return '2025-01-01T00:00:00.000Z'; + if (prop.format === 'binary') return '(binary)'; + return key; + case 'integer': + case 'number': + return prop.default !== undefined ? prop.default : 1; + case 'boolean': + return prop.default !== undefined ? prop.default : true; + default: + return null; + } +}