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 <noreply@anthropic.com>
This commit is contained in:
Frederic Cabassut
2026-03-05 21:24:29 +01:00
co-authored by Claude Opus 4.6
parent 66c91df983
commit 26cae27857
8 changed files with 1103 additions and 249 deletions
+1
View File
@@ -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 |
+4 -1
View File
@@ -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
+34 -110
View File
@@ -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
+397 -87
View File
@@ -1,166 +1,476 @@
# API Reference
<!-- AUTO-GENERATED from docs/openapi.yaml — do not edit manually -->
<!-- Run: pnpm docs:api -->
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
}
```
---
+420
View File
@@ -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'
+17 -14
View File
@@ -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": [
+64 -37
View File
@@ -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: {}
+166
View File
@@ -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[] = [
'<!-- AUTO-GENERATED from docs/openapi.yaml — do not edit manually -->',
'<!-- Run: pnpm docs:api -->',
'',
`# ${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<string, { method: string; path: string; op: Record<string, unknown> }[]>();
for (const [path, methods] of Object.entries(spec.paths) as [string, Record<string, unknown>][]) {
const pathParams = (methods.parameters as unknown[]) || [];
for (const [method, op] of Object.entries(methods)) {
if (method === 'parameters') continue;
const operation = op as Record<string, unknown>;
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<string, unknown>[]) {
const schema = p.schema as Record<string, unknown> | 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<string, unknown> | undefined;
if (body) {
const content = body.content as Record<string, Record<string, unknown>>;
const contentType = Object.keys(content)[0];
const schema = content[contentType]?.schema as Record<string, unknown> | 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<string, unknown>);
if (example) {
lines.push('**Request:**', '', '```json', JSON.stringify(example, null, 2), '```', '');
}
}
}
// Responses
const responses = op.responses as Record<string, Record<string, unknown>> | 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<string, Record<string, unknown>> | undefined;
if (respContent) {
const schema = Object.values(respContent)[0]?.schema as Record<string, unknown>;
if (schema) {
const example = buildExample(schema, spec.components?.schemas as Record<string, unknown>);
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<string, unknown>, schemas?: Record<string, unknown>): unknown {
if (schema.$ref) {
const refName = String(schema.$ref).split('/').pop()!;
return schemas ? buildExample(schemas[refName] as Record<string, unknown>, schemas) : {};
}
if (schema.type === 'array') {
const items = schema.items as Record<string, unknown>;
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<string, Record<string, unknown>> | undefined;
if (!props) return {};
const obj: Record<string, unknown> = {};
for (const [key, prop] of Object.entries(props)) {
obj[key] = exampleValue(key, prop, schemas);
}
return obj;
}
return null;
}
function exampleValue(key: string, prop: Record<string, unknown>, schemas?: Record<string, unknown>): 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;
}
}