snow-editor

small markdown and org-mode editor
Log | Files | Refs | README

commit 65993a713586363acceab129af67161a5fbe71cc
parent 24ce9e9ef60be3233adb8eaa03f5a936c1ef4acd
Author: Pablo Murad <pblmrd@gmail.com>
Date:   Fri,  5 Jun 2026 22:23:29 -0300

updates

Diffstat:
M.env.example | 3+++
M.gitignore | 1+
MREADME.md | 247+++++++++++++++++++++++--------------------------------------------------------
Mbackend/Dockerfile | 1+
Mbackend/package.json | 3++-
Abackend/src/app.js | 78++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbackend/src/db.js | 9+++++++++
Mbackend/src/messages.js | 6++++++
Abackend/src/originGuard.js | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbackend/src/routes/documents.js | 184++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
Mbackend/src/server.js | 64++--------------------------------------------------------------
Abackend/src/versionUtils.js | 26++++++++++++++++++++++++++
Abackend/test/api.test.js | 229+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdocker-compose.yml | 10+++++++++-
Mpackage-lock.json | 1356++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mpackage.json | 16++++++++++++++--
Ascripts/backup-db.ps1 | 22++++++++++++++++++++++
Ascripts/backup-db.sh | 24++++++++++++++++++++++++
Ascripts/restore-db.ps1 | 32++++++++++++++++++++++++++++++++
Ascripts/restore-db.sh | 36++++++++++++++++++++++++++++++++++++
Msrc/components/EditorLayout.jsx | 102+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Asrc/components/OrgEditor.jsx | 112+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/components/OrgOutline.jsx | 39+++++++++++++++++++++++++++++++++++++++
Asrc/components/VersionHistory.jsx | 126+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/lib/api.js | 18++++++++++++++++++
Msrc/lib/editorConstants.js | 25+++++++++++++++++--------
Asrc/lib/org/checklistPlugin.js | 42++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/org/constants.js | 4++++
Asrc/lib/org/editorUtils.js | 47+++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/org/editorUtils.test.js | 20++++++++++++++++++++
Asrc/lib/org/enhanceHtml.js | 19+++++++++++++++++++
Asrc/lib/org/fixtures/checklist.org | 3+++
Asrc/lib/org/fixtures/export-html.org | 4++++
Asrc/lib/org/fixtures/headings-todo.org | 4++++
Asrc/lib/org/fixtures/nested-lists.org | 6++++++
Asrc/lib/org/fixtures/table.org | 4++++
Asrc/lib/org/keymap.js | 81+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/org/normalize.js | 4++++
Asrc/lib/org/orgTheme.js | 36++++++++++++++++++++++++++++++++++++
Asrc/lib/org/parseDocument.js | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/org/parseDocument.test.js | 21+++++++++++++++++++++
Asrc/lib/org/pipeline.js | 34++++++++++++++++++++++++++++++++++
Asrc/lib/org/pipeline.test.js | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/org/sanitize.js | 10++++++++++
Dsrc/lib/parseOrgMode.js | 216-------------------------------------------------------------------------------
Msrc/lib/previewHtml.js | 23+++++++++++++++++++----
Msrc/lib/strings.js | 20++++++++++++++++++++
Msrc/pages/SharedEditPage.jsx | 36+++++++++++++++++++++++++++++++++++-
Msrc/styles.css | 167+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mvite.config.js | 13+++++++++++++
50 files changed, 3259 insertions(+), 543 deletions(-)

diff --git a/.env.example b/.env.example @@ -6,6 +6,9 @@ ALLOWED_HOSTS=snow.pablomurad.com,localhost,127.0.0.1 PORT=41738 DATABASE_PATH=./data/snow.db +# Origins allowed to create shared documents (POST /api/documents) +SHARE_ALLOWED_ORIGINS=https://snow.pablomurad.com,http://localhost:41737,http://127.0.0.1:41737 + # Optional CORS for local dev when API is called directly (default: use Vite proxy) # CORS_ORIGIN=http://localhost:41737 diff --git a/.gitignore b/.gitignore @@ -41,3 +41,4 @@ docker-compose.override.yml # SQLite data (local / Docker volume) data/ !data/.gitkeep +data/backups/ diff --git a/README.md b/README.md @@ -1,234 +1,127 @@ -# Snow Editor — Markdown Cozy +# Snow Editor -**Version:** 0.0.1 -**Creator:** Pablo Murad — [pablomurad@pm.me](mailto:pablomurad@pm.me) +Markdown and Org-mode editor with live preview. Local drafts in `localStorage`. Shared docs via link + SQLite backend. -A cozy online editor inspired by snow and calm reading. Write in **Markdown** or experimental **Org-mode** with live preview. Use it **locally** in the browser (localStorage) or **share** documents by link with a small backend API. +Pablo Murad — pablomurad@pm.me -## Features +## Requirements -- Real-time editor with side-by-side preview -- **Markdown** (full) and **Org-mode** (experimental, basic parser) -- **Local mode** — drafts in `localStorage`, import/export `.md` / `.org` -- **Shared links** — view-only and edit links with optional expiry -- **Edit lock** — only one editor at a time per document (no realtime collaboration) -- **Server autosave** on shared edit links (debounced 1s) -- Mode switch saved in `localStorage` (local editor only) -- Production Docker: nginx (frontend) + Node API (backend) + SQLite volume -- Preview HTML sanitized with [DOMPurify](https://github.com/cure53/DOMPurify) +- Node.js 22.5+ +- Docker + Compose (optional) -## Stack - -- **Frontend:** React + Vite + react-router-dom -- **Backend:** Node.js (≥22.5) + Express + SQLite (`node:sqlite`) -- [marked](https://marked.js.org/) — Markdown to HTML -- Custom Org parser — [`src/lib/parseOrgMode.js`](src/lib/parseOrgMode.js) -- Plain CSS (cozy / snow theme) - -## Prerequisites - -- **Local:** Node.js **22.5+** (frontend + backend) -- **Docker:** Docker and Docker Compose - -## Environment +## Quick start ```bash cp .env.example .env -``` - -| Variable | Description | -|----------|-------------| -| `ALLOWED_HOSTS` | Hostnames for Vite dev/preview | -| `PORT` | Backend port (default `41738`) | -| `DATABASE_PATH` | SQLite path (default `./data/snow.db`) | -| `CORS_ORIGIN` | Optional; dev only if not using Vite proxy | -| `VITE_API_BASE` | Optional API prefix (empty = `/api` on same origin) | -| `VITE_PUBLIC_ORIGIN` | Public site URL for share links (e.g. `https://snow.pablomurad.com`). If empty, uses the browser origin (`localhost` in local dev) | -| `VITE_ALLOW_SEARCH_INDEXING` | `true` to allow search engines; `false` adds `noindex` meta and `Disallow: /` in `robots.txt`. **Rebuild required** after changing | - -### Search engine indexing - -Set in `.env` before `npm run build` or Docker build: - -- `VITE_ALLOW_SEARCH_INDEXING=false` (default) — site and shared links are not indexed; `robots.txt` blocks crawlers -- `VITE_ALLOW_SEARCH_INDEXING=true` — public indexing allowed - -The API (`/api/*`) always sends `X-Robots-Tag: noindex` via nginx, regardless of this setting. - -## Run with Docker - -```bash docker compose up -d --build ``` -| Service | URL | -|---------|-----| -| Frontend | http://localhost:41737 | -| Backend API | http://localhost:41738/api/health | - -SQLite data is stored in `./data/snow.db` (volume `./data:/app/data` on the backend service). - -The frontend nginx proxies `/api/` to the backend container. +- Frontend: http://localhost:41737 +- API: http://localhost:41738/api/health +- DB file: `./data/snow.db` -## Run locally with npm +## Local dev -**Terminal 1 — frontend:** +Terminal 1: ```bash npm install -cp .env.example .env npm run dev ``` -Open: **http://localhost:41737** - -**Terminal 2 — backend:** +Terminal 2: ```bash -cd backend -npm install -npm run dev +cd backend && npm install && npm run dev ``` -API: **http://localhost:41738/api/health** - -Vite proxies `/api` → `http://localhost:41738` in dev and preview. +Vite proxies `/api` to port 41738. ```bash npm run build -npm run preview # frontend only; backend must still be running for sharing +npm run preview ``` -## Share a document +## Tests -1. Open the **local** editor at `/`. -2. Click **Share**. -3. Set title and link expiry (1h, 24h, 7d, 30d, or never). -4. Copy the **view link** (`/v/...`) or **edit link** (`/e/...`). URLs use `VITE_PUBLIC_ORIGIN` when set; otherwise they use whatever host you opened in the browser. +```bash +cd backend && npm test +npm test +``` -### View link (`/v/:token`) +## Config -- Read-only preview -- Download `.md` or `.org` -- No server saves +See `.env.example`. Main vars: -### Edit link (`/e/:token`) +- `SHARE_ALLOWED_ORIGINS` — who may `POST /api/documents` (default includes localhost:41737) +- `VITE_PUBLIC_ORIGIN` — base URL for share links +- `VITE_ALLOW_SEARCH_INDEXING` — `true`/`false` (rebuild after change) +- `DATABASE_PATH` — SQLite path -- Tries to acquire an **edit lock** for your browser (`snow_client_id` in localStorage) -- If lock granted: live preview, **Save to server**, autosave after 1s idle -- If another person holds the lock: read-only + friendly message -- **Release edit lock** releases the lock for others +## Routes -### Edit lock rules +- `/` — local editor +- `/v/:token` — read-only shared view +- `/e/:token` — shared edit (lock required to save) -- One active editor per document -- Lock TTL: **2 minutes**; renewed every **30 seconds** while the tab is open -- Lock release on tab close is best-effort (`fetch` keepalive) +Share from `/`: pick title and expiry, get view + edit URLs. -### Link expiry +`POST /api/documents` needs a browser `Origin` on the allowlist. No origin → 403. -- Optional expiry when creating the share -- Expired links return **410** with a friendly page: “This link has expired.” +Edit lock: one editor per doc, 2 min TTL, refreshed every 30s while tab is open. -## Local editor (unchanged) +## API -- Routes: `/` only for local editing -- Badge **Online** -- Autosave to `localStorage` (500ms debounce) -- **Save .md** / **Save .org**, **Import**, **Clear**, mode switch +``` +GET /api/health +POST /api/documents +GET /api/documents/view/:token +GET /api/documents/edit/:token +POST /api/documents/edit/:token/lock +POST /api/documents/edit/:token/lock/refresh +DELETE /api/documents/edit/:token/lock +PUT /api/documents/edit/:token +GET /api/documents/edit/:token/versions?clientId=&lockToken= +POST /api/documents/edit/:token/versions/:versionId/restore +``` -### Browser storage keys +Limits: 60 req/min per IP on `/api`, 10 req/min on `POST /api/documents`, 1 MB max body. -| Key | Purpose | -|-----|---------| -| `editor_mode` | Last selected mode | -| `snow_editor_markdown_content` | Markdown draft | -| `snow_editor_org_content` | Org-mode draft | -| `snow_client_id` | Browser id for shared edit locks | +Health returns `{ ok, db, uptime, version }`. DB down → 503. -## API endpoints +## Stack -| Method | Path | -|--------|------| -| GET | `/api/health` | -| POST | `/api/documents` | -| GET | `/api/documents/view/:token` | -| GET | `/api/documents/edit/:token` | -| POST | `/api/documents/edit/:token/lock` | -| POST | `/api/documents/edit/:token/lock/refresh` | -| DELETE | `/api/documents/edit/:token/lock` | -| PUT | `/api/documents/edit/:token` | +React, Vite, Express, SQLite (`node:sqlite`), marked, Orga, CodeMirror 6, DOMPurify. -Rate limit: 60 requests/minute per IP on `/api`. Max document size: **1 MB**. +## Org-mode -## Current limitations +Parser: Orga. Editor: CodeMirror (highlight, fold, checklist toggle, outline on wide screens). -- No realtime collaboration (no WebSocket, Yjs, CRDT, remote cursors) -- No version history UI (`document_versions` is stored server-side only) -- No login or user accounts -- No email or PDF export -- Anyone with a valid **edit link** can edit when the document is not locked -- `POST /api/documents` is open (mitigated by rate limit and long random tokens) +Works: headings, lists, tables, TODO keywords, SRC/QUOTE blocks, basic inline markup, `#+TITLE`. -## Security +Does not work: babel, agenda, LaTeX, full Emacs export. Not a replacement for Emacs. -- Preview uses DOMPurify on the frontend -- Tokens: `crypto.randomBytes(32)`; IDs: `crypto.randomUUID()` -- Org parser escapes text before applying markup +## Backup -## Project structure +Manual only. Stop backend first if copying live. +```bash +./scripts/backup-db.sh # or backup-db.ps1 +docker compose stop backend +./scripts/restore-db.sh <file> # or restore-db.ps1 -Backup <file> +docker compose start backend ``` -├── backend/ -│ ├── Dockerfile -│ ├── package.json -│ └── src/ -│ ├── server.js -│ ├── db.js -│ ├── utils.js -│ └── routes/documents.js -├── data/ # SQLite (gitignored, .gitkeep only) -├── docker-compose.yml -├── Dockerfile # frontend static build -├── nginx.conf -├── src/ -│ ├── App.jsx # routes -│ ├── pages/ -│ ├── components/ -│ ├── hooks/ -│ └── lib/ -└── public/ -``` - -## Maintenance and optimization - -Repeat before each release: - -1. `npm run build` — confirm chunk sizes; no errors -2. `rg` for stray Portuguese in `src/` and `backend/src/` — should be empty -3. `VITE_ALLOW_SEARCH_INDEXING=false` — view-source has `noindex`; `/robots.txt` contains `Disallow: /` -4. `VITE_ALLOW_SEARCH_INDEXING=true` — no blocking robots meta; `/robots.txt` allows crawlers -5. Shared routes (`/v/`, `/e/`) load via lazy chunks (smaller initial bundle on `/`) -6. Rebuild Docker images after any `VITE_*` change -**Kept light by design:** inline SVG icons (no icon font), lazy `marked`, manual vendor chunks, minimal backend deps (`express` + `node:sqlite`). +Or copy `data/snow.db` yourself. -## Manual test checklist +## Notes -1. `docker compose up -d --build` works -2. Frontend at http://localhost:41737 -3. `GET /api/health` returns `{ "ok": true }` -4. Local editor at `/` works (Markdown + Org, import, clear, localStorage) -5. **Share** creates view + edit links -6. `/v/:token` is read-only -7. `/e/:token` edits with autosave when lock is free -8. Second browser/profile on same edit link → read-only (423) -9. Lock expires after ~2 minutes without refresh -10. Expired share shows friendly message -11. Content over 1 MB rejected with 413 -12. Download `.md` / `.org` still works on shared pages -13. `/robots.txt` and HTML robots meta match `VITE_ALLOW_SEARCH_INDEXING` +- No accounts. Edit links are capability tokens — anyone with the link can edit when unlocked. +- No realtime collab. +- Preview HTML is sanitized. +- Monitor production with `GET /api/health`. +- Rebuild Docker after changing `VITE_*` or `SHARE_ALLOWED_ORIGINS`. ## License -Free to use for personal projects and learning. +Use freely for personal projects and learning. diff --git a/backend/Dockerfile b/backend/Dockerfile @@ -1,4 +1,5 @@ FROM node:22-alpine +RUN apk add --no-cache wget WORKDIR /app COPY package.json package-lock.json* ./ RUN npm install --omit=dev diff --git a/backend/package.json b/backend/package.json @@ -8,7 +8,8 @@ }, "scripts": { "dev": "node --watch src/server.js", - "start": "node src/server.js" + "start": "node src/server.js", + "test": "node --test test/**/*.test.js" }, "dependencies": { "express": "^4.21.2", diff --git a/backend/src/app.js b/backend/src/app.js @@ -0,0 +1,78 @@ +import express from 'express'; +import rateLimit from 'express-rate-limit'; +import { MSG } from './messages.js'; +import { + getAllowedOrigins, + parseAllowedOrigins, + setAllowedOrigins, +} from './originGuard.js'; +import documentsRouter from './routes/documents.js'; + +export function createApp(options = {}) { + const corsOrigin = options.corsOrigin ?? process.env.CORS_ORIGIN?.trim() ?? ''; + const shareAllowedOrigins = parseAllowedOrigins( + options.shareAllowedOrigins ?? process.env.SHARE_ALLOWED_ORIGINS ?? '', + ); + setAllowedOrigins(shareAllowedOrigins); + + const app = express(); + app.set('trust proxy', 1); + + if (corsOrigin) { + app.use((req, res, next) => { + const origin = req.headers.origin; + if (origin === corsOrigin) { + res.setHeader('Access-Control-Allow-Origin', origin); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + } + if (req.method === 'OPTIONS') { + return res.sendStatus(204); + } + next(); + }); + } + + app.use(express.json({ limit: '1.1mb' })); + + const apiLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 60, + standardHeaders: true, + legacyHeaders: false, + message: { + error: 'RATE_LIMIT', + message: MSG.RATE_LIMIT, + }, + }); + + app.use('/api', apiLimiter, documentsRouter); + + app.use((err, _req, res, next) => { + if (err instanceof SyntaxError && 'body' in err) { + return res.status(400).json({ + error: 'INVALID_JSON', + message: MSG.INVALID_JSON, + }); + } + next(err); + }); + + app.use((err, _req, res, _next) => { + if (err?.type === 'entity.too.large') { + return res.status(413).json({ + error: 'CONTENT_TOO_LARGE', + message: MSG.CONTENT_TOO_LARGE, + }); + } + console.error(err); + res.status(500).json({ + error: 'INTERNAL_ERROR', + message: MSG.INTERNAL_ERROR, + }); + }); + + return app; +} + +export { getAllowedOrigins }; diff --git a/backend/src/db.js b/backend/src/db.js @@ -68,3 +68,12 @@ export function purgeExpiredLocks(database) { const now = new Date().toISOString(); database.prepare('DELETE FROM edit_locks WHERE expires_at <= ?').run(now); } + +export function checkDbHealth(database) { + try { + database.prepare('SELECT 1 AS ok').get(); + return true; + } catch { + return false; + } +} diff --git a/backend/src/messages.js b/backend/src/messages.js @@ -13,6 +13,12 @@ export const MSG = { RATE_LIMIT: 'Too many requests. Try again in a minute.', INVALID_JSON: 'Invalid JSON in request body.', INTERNAL_ERROR: 'Internal server error.', + ORIGIN_NOT_ALLOWED: + 'Document creation is only allowed from the Snow Editor website.', + VERSION_NOT_FOUND: 'Version not found.', }; +export const APP_VERSION = '0.0.1'; +export const MAX_VERSIONS_PER_DOCUMENT = 50; + export const DEFAULT_DOCUMENT_TITLE = 'Untitled document'; diff --git a/backend/src/originGuard.js b/backend/src/originGuard.js @@ -0,0 +1,63 @@ +import { MSG } from './messages.js'; +import { sendError } from './utils.js'; + +const DEFAULT_ORIGINS = [ + 'http://localhost:41737', + 'http://127.0.0.1:41737', +]; + +let allowedOrigins = [...DEFAULT_ORIGINS]; + +export function parseAllowedOrigins(value) { + if (!value || value.trim() === '') { + return [...DEFAULT_ORIGINS]; + } + return value + .split(',') + .map((origin) => origin.trim()) + .filter(Boolean); +} + +export function setAllowedOrigins(origins) { + allowedOrigins = origins.length > 0 ? origins : [...DEFAULT_ORIGINS]; +} + +export function getAllowedOrigins() { + return allowedOrigins; +} + +function normalizeOrigin(urlString) { + try { + const url = new URL(urlString); + return `${url.protocol}//${url.host}`; + } catch { + return null; + } +} + +function resolveRequestOrigin(req) { + const origin = req.headers.origin; + if (origin) { + return normalizeOrigin(origin); + } + + const referer = req.headers.referer; + if (referer) { + return normalizeOrigin(referer); + } + + return null; +} + +export function requireAllowedOrigin(req, res, next) { + const requestOrigin = resolveRequestOrigin(req); + + if (!requestOrigin || !allowedOrigins.includes(requestOrigin)) { + console.warn( + `[origin-guard] blocked POST /documents origin=${requestOrigin ?? 'missing'}`, + ); + return sendError(res, 403, 'ORIGIN_NOT_ALLOWED', MSG.ORIGIN_NOT_ALLOWED); + } + + return next(); +} diff --git a/backend/src/routes/documents.js b/backend/src/routes/documents.js @@ -1,6 +1,13 @@ import { Router } from 'express'; -import { getDb, purgeExpiredLocks } from '../db.js'; -import { DEFAULT_DOCUMENT_TITLE, MSG } from '../messages.js'; +import rateLimit from 'express-rate-limit'; +import { checkDbHealth, getDb, purgeExpiredLocks } from '../db.js'; +import { + APP_VERSION, + DEFAULT_DOCUMENT_TITLE, + MSG, +} from '../messages.js'; +import { requireAllowedOrigin } from '../originGuard.js'; +import { saveDocumentVersion } from '../versionUtils.js'; import { assertContentSize, assertMode, @@ -14,6 +21,17 @@ import { const router = Router(); +const createDocLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 10, + standardHeaders: true, + legacyHeaders: false, + message: { + error: 'RATE_LIMIT', + message: MSG.RATE_LIMIT, + }, +}); + function getDocumentByViewToken(token) { return getDb() .prepare('SELECT * FROM documents WHERE view_token = ?') @@ -60,11 +78,38 @@ function getActiveLock(documentId) { .get(documentId, now); } +function validateLock(doc, clientId, lockToken) { + purgeExpiredLocks(getDb()); + const lock = getDb() + .prepare( + 'SELECT * FROM edit_locks WHERE document_id = ? AND lock_token = ? AND client_id = ?', + ) + .get(doc.id, lockToken, clientId); + + if (!lock || isExpired(lock.expires_at)) { + return null; + } + return lock; +} + router.get('/health', (_req, res) => { - res.json({ ok: true }); + const dbOk = checkDbHealth(getDb()); + const payload = { + ok: dbOk, + db: dbOk ? 'ok' : 'error', + uptime: Math.floor(process.uptime()), + version: APP_VERSION, + }; + + if (!dbOk) { + console.error('[health] database check failed'); + return res.status(503).json(payload); + } + + res.json(payload); }); -router.post('/documents', (req, res) => { +router.post('/documents', createDocLimiter, requireAllowedOrigin, (req, res) => { const { title, mode, content, expiresIn } = req.body ?? {}; if (!assertMode(mode)) { @@ -81,12 +126,7 @@ router.post('/documents', (req, res) => { const expiresAt = parseExpiresIn(expiresIn); if (expiresAt === undefined) { - return sendError( - res, - 400, - 'INVALID_EXPIRES_IN', - MSG.INVALID_EXPIRES_IN, - ); + return sendError(res, 400, 'INVALID_EXPIRES_IN', MSG.INVALID_EXPIRES_IN); } const id = newId(); @@ -127,6 +167,87 @@ router.get('/documents/edit/:token', (req, res) => { res.json(documentToPublic(doc)); }); +router.get('/documents/edit/:token/versions', (req, res) => { + const { clientId, lockToken } = req.query; + + if (!clientId || !lockToken) { + return sendError(res, 403, 'LOCK_REQUIRED', MSG.LOCK_REQUIRED); + } + + const doc = getDocumentByEditToken(req.params.token); + if (!checkDocumentAccess(doc, res)) return; + + const lock = validateLock(doc, String(clientId), String(lockToken)); + if (!lock) { + return sendError(res, 403, 'LOCK_REQUIRED', MSG.LOCK_REQUIRED); + } + + const versions = getDb() + .prepare( + `SELECT id, created_at FROM document_versions + WHERE document_id = ? + ORDER BY created_at DESC + LIMIT 20`, + ) + .all(doc.id) + .map((row) => ({ + id: row.id, + createdAt: row.created_at, + })); + + res.json({ versions }); +}); + +router.post('/documents/edit/:token/versions/:versionId/restore', (req, res) => { + const { clientId, lockToken } = req.body ?? {}; + + if (!clientId || !lockToken) { + return sendError(res, 403, 'LOCK_REQUIRED', MSG.LOCK_REQUIRED); + } + + const doc = getDocumentByEditToken(req.params.token); + if (!checkDocumentAccess(doc, res)) return; + + const lock = validateLock(doc, clientId, lockToken); + if (!lock) { + return sendError(res, 403, 'LOCK_REQUIRED', MSG.LOCK_REQUIRED); + } + + const version = getDb() + .prepare( + 'SELECT * FROM document_versions WHERE id = ? AND document_id = ?', + ) + .get(req.params.versionId, doc.id); + + if (!version) { + return sendError(res, 404, 'VERSION_NOT_FOUND', MSG.VERSION_NOT_FOUND); + } + + const now = new Date().toISOString(); + const db = getDb(); + + saveDocumentVersion(db, doc, now); + + db.prepare( + `UPDATE documents SET title = ?, mode = ?, content = ?, updated_at = ? WHERE id = ?`, + ).run(version.title, version.mode, version.content, now, doc.id); + + const lockExpires = lockExpiresAtFromNow(); + db.prepare('UPDATE edit_locks SET expires_at = ?, updated_at = ? WHERE id = ?').run( + lockExpires, + now, + lock.id, + ); + + res.json({ + success: true, + title: version.title, + mode: version.mode, + content: version.content, + updated_at: now, + }); +}); + router.post('/documents/edit/:token/lock', (req, res) => { const { clientId } = req.body ?? {}; if (!clientId || typeof clientId !== 'string') { @@ -196,12 +317,7 @@ router.post('/documents/edit/:token/lock/refresh', (req, res) => { .get(doc.id, lockToken, clientId); if (!lock || isExpired(lock.expires_at)) { - return sendError( - res, - 403, - 'LOCK_INVALID', - MSG.LOCK_INVALID, - ); + return sendError(res, 403, 'LOCK_INVALID', MSG.LOCK_INVALID); } const expiresAt = lockExpiresAtFromNow(); @@ -237,30 +353,11 @@ router.delete('/documents/edit/:token/lock', (req, res) => { res.json({ success: true }); }); -function validateLock(doc, clientId, lockToken) { - purgeExpiredLocks(getDb()); - const lock = getDb() - .prepare( - 'SELECT * FROM edit_locks WHERE document_id = ? AND lock_token = ? AND client_id = ?', - ) - .get(doc.id, lockToken, clientId); - - if (!lock || isExpired(lock.expires_at)) { - return null; - } - return lock; -} - router.put('/documents/edit/:token', (req, res) => { const { clientId, lockToken, title, mode, content } = req.body ?? {}; if (!clientId || !lockToken) { - return sendError( - res, - 403, - 'LOCK_REQUIRED', - MSG.LOCK_REQUIRED, - ); + return sendError(res, 403, 'LOCK_REQUIRED', MSG.LOCK_REQUIRED); } const doc = getDocumentByEditToken(req.params.token); @@ -268,12 +365,7 @@ router.put('/documents/edit/:token', (req, res) => { const lock = validateLock(doc, clientId, lockToken); if (!lock) { - return sendError( - res, - 403, - 'LOCK_REQUIRED', - MSG.LOCK_REQUIRED, - ); + return sendError(res, 403, 'LOCK_REQUIRED', MSG.LOCK_REQUIRED); } if (!assertMode(mode)) { @@ -291,13 +383,9 @@ router.put('/documents/edit/:token', (req, res) => { const docTitle = typeof title === 'string' && title.trim() ? title.trim() : doc.title; const now = new Date().toISOString(); - const db = getDb(); - const versionId = newId(); - db.prepare( - `INSERT INTO document_versions (id, document_id, title, mode, content, created_at) - VALUES (?, ?, ?, ?, ?, ?)`, - ).run(versionId, doc.id, doc.title, doc.mode, doc.content, now); + + saveDocumentVersion(db, doc, now); db.prepare( `UPDATE documents SET title = ?, mode = ?, content = ?, updated_at = ? WHERE id = ?`, diff --git a/backend/src/server.js b/backend/src/server.js @@ -1,74 +1,14 @@ -import express from 'express'; -import rateLimit from 'express-rate-limit'; import { initDb } from './db.js'; -import { MSG } from './messages.js'; -import documentsRouter from './routes/documents.js'; +import { createApp } from './app.js'; const PORT = Number(process.env.PORT) || 41738; const DATABASE_PATH = process.env.DATABASE_PATH || (process.env.NODE_ENV === 'production' ? '/app/data/snow.db' : './data/snow.db'); -const CORS_ORIGIN = process.env.CORS_ORIGIN?.trim() || ''; initDb(DATABASE_PATH); -const app = express(); - -app.set('trust proxy', 1); - -if (CORS_ORIGIN) { - app.use((req, res, next) => { - const origin = req.headers.origin; - if (origin === CORS_ORIGIN) { - res.setHeader('Access-Control-Allow-Origin', origin); - res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); - res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); - } - if (req.method === 'OPTIONS') { - return res.sendStatus(204); - } - next(); - }); -} - -app.use(express.json({ limit: '1.1mb' })); - -const apiLimiter = rateLimit({ - windowMs: 60 * 1000, - max: 60, - standardHeaders: true, - legacyHeaders: false, - message: { - error: 'RATE_LIMIT', - message: MSG.RATE_LIMIT, - }, -}); - -app.use('/api', apiLimiter, documentsRouter); - -app.use((err, _req, res, next) => { - if (err instanceof SyntaxError && 'body' in err) { - return res.status(400).json({ - error: 'INVALID_JSON', - message: MSG.INVALID_JSON, - }); - } - next(err); -}); - -app.use((err, _req, res, _next) => { - if (err?.type === 'entity.too.large') { - return res.status(413).json({ - error: 'CONTENT_TOO_LARGE', - message: MSG.CONTENT_TOO_LARGE, - }); - } - console.error(err); - res.status(500).json({ - error: 'INTERNAL_ERROR', - message: MSG.INTERNAL_ERROR, - }); -}); +const app = createApp(); app.listen(PORT, '0.0.0.0', () => { console.log(`Snow Editor API listening on http://0.0.0.0:${PORT}`); diff --git a/backend/src/versionUtils.js b/backend/src/versionUtils.js @@ -0,0 +1,26 @@ +import { MAX_VERSIONS_PER_DOCUMENT } from './messages.js'; +import { newId } from './utils.js'; + +export function saveDocumentVersion(db, doc, createdAt) { + const versionId = newId(); + db.prepare( + `INSERT INTO document_versions (id, document_id, title, mode, content, created_at) + VALUES (?, ?, ?, ?, ?, ?)`, + ).run(versionId, doc.id, doc.title, doc.mode, doc.content, createdAt); + + const excess = db + .prepare( + `SELECT id FROM document_versions + WHERE document_id = ? + ORDER BY created_at DESC + LIMIT -1 OFFSET ?`, + ) + .all(doc.id, MAX_VERSIONS_PER_DOCUMENT); + + if (excess.length > 0) { + const placeholders = excess.map(() => '?').join(','); + db.prepare(`DELETE FROM document_versions WHERE id IN (${placeholders})`).run( + ...excess.map((row) => row.id), + ); + } +} diff --git a/backend/test/api.test.js b/backend/test/api.test.js @@ -0,0 +1,229 @@ +import assert from 'node:assert'; +import { after, before, describe, test } from 'node:test'; +import { createApp } from '../src/app.js'; +import { getDb, initDb } from '../src/db.js'; +const ALLOWED_ORIGIN = 'http://localhost:41737'; + +let server; +let baseUrl; + +function api(path, { method = 'GET', headers = {}, body } = {}) { + return fetch(`${baseUrl}${path}`, { + method, + headers: { + 'Content-Type': 'application/json', + ...headers, + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); +} + +async function readJson(res) { + const text = await res.text(); + return text ? JSON.parse(text) : null; +} + +async function createDocument(overrides = {}) { + const res = await api('/api/documents', { + method: 'POST', + headers: { Origin: ALLOWED_ORIGIN }, + body: { + title: 'Test doc', + mode: 'markdown', + content: '# Hello', + expiresIn: '7d', + ...overrides, + }, + }); + assert.equal(res.status, 201); + return readJson(res); +} + +before(() => { + initDb(':memory:'); + const app = createApp({ + shareAllowedOrigins: ALLOWED_ORIGIN, + }); + server = app.listen(0); + const { port } = server.address(); + baseUrl = `http://127.0.0.1:${port}`; +}); + +after(() => { + server.close(); +}); + +describe('Snow Editor API', () => { + test('GET /api/health returns ok with db check', async () => { + const res = await api('/api/health'); + const data = await readJson(res); + + assert.equal(res.status, 200); + assert.equal(data.ok, true); + assert.equal(data.db, 'ok'); + assert.equal(typeof data.uptime, 'number'); + assert.equal(data.version, '0.0.1'); + }); + + test('POST /documents without Origin is rejected', async () => { + const res = await api('/api/documents', { + method: 'POST', + body: { + title: 'Blocked', + mode: 'markdown', + content: 'nope', + expiresIn: '7d', + }, + }); + const data = await readJson(res); + + assert.equal(res.status, 403); + assert.equal(data.error, 'ORIGIN_NOT_ALLOWED'); + }); + + test('POST /documents with allowed Origin succeeds', async () => { + const data = await createDocument({ title: 'Allowed' }); + assert.ok(data.viewToken); + assert.ok(data.editToken); + }); + + test('GET /view/:token returns document', async () => { + const doc = await createDocument({ content: '# View me' }); + const res = await api(`/api/documents/view/${doc.viewToken}`); + const data = await readJson(res); + + assert.equal(res.status, 200); + assert.equal(data.content, '# View me'); + }); + + test('POST lock acquires edit lock', async () => { + const doc = await createDocument(); + const res = await api(`/api/documents/edit/${doc.editToken}/lock`, { + method: 'POST', + body: { clientId: 'client-a' }, + }); + const data = await readJson(res); + + assert.equal(res.status, 200); + assert.equal(data.locked, true); + assert.ok(data.lockToken); + }); + + test('second clientId on lock returns 423', async () => { + const doc = await createDocument(); + await api(`/api/documents/edit/${doc.editToken}/lock`, { + method: 'POST', + body: { clientId: 'client-a' }, + }); + + const res = await api(`/api/documents/edit/${doc.editToken}/lock`, { + method: 'POST', + body: { clientId: 'client-b' }, + }); + const data = await readJson(res); + + assert.equal(res.status, 423); + assert.equal(data.error, 'DOCUMENT_LOCKED'); + }); + + test('PUT without lock returns 403', async () => { + const doc = await createDocument(); + const res = await api(`/api/documents/edit/${doc.editToken}`, { + method: 'PUT', + body: { + clientId: 'ghost', + lockToken: 'missing', + title: 'Nope', + mode: 'markdown', + content: 'fail', + }, + }); + const data = await readJson(res); + + assert.equal(res.status, 403); + assert.equal(data.error, 'LOCK_REQUIRED'); + }); + + test('expired document returns 410', async () => { + const doc = await createDocument(); + const past = new Date(Date.now() - 60_000).toISOString(); + getDb() + .prepare('UPDATE documents SET expires_at = ? WHERE edit_token = ?') + .run(past, doc.editToken); + + const res = await api(`/api/documents/edit/${doc.editToken}`); + const data = await readJson(res); + + assert.equal(res.status, 410); + assert.equal(data.error, 'EXPIRED'); + }); + + test('body larger than 1 MB returns 413', async () => { + const doc = await createDocument(); + const lockRes = await api(`/api/documents/edit/${doc.editToken}/lock`, { + method: 'POST', + body: { clientId: 'big-body' }, + }); + const lock = await readJson(lockRes); + + const huge = 'x'.repeat(1024 * 1024 + 1); + const res = await api(`/api/documents/edit/${doc.editToken}`, { + method: 'PUT', + body: { + clientId: 'big-body', + lockToken: lock.lockToken, + title: 'Huge', + mode: 'markdown', + content: huge, + }, + }); + const data = await readJson(res); + + assert.equal(res.status, 413); + assert.equal(data.error, 'CONTENT_TOO_LARGE'); + }); + + test('version list and restore require active lock', async () => { + const doc = await createDocument({ content: 'v1' }); + const lockRes = await api(`/api/documents/edit/${doc.editToken}/lock`, { + method: 'POST', + body: { clientId: 'version-client' }, + }); + const lock = await readJson(lockRes); + + await api(`/api/documents/edit/${doc.editToken}`, { + method: 'PUT', + body: { + clientId: 'version-client', + lockToken: lock.lockToken, + title: doc.title, + mode: 'markdown', + content: 'v2', + }, + }); + + const versionsRes = await api( + `/api/documents/edit/${doc.editToken}/versions?clientId=version-client&lockToken=${lock.lockToken}`, + ); + const versionsData = await readJson(versionsRes); + + assert.equal(versionsRes.status, 200); + assert.ok(versionsData.versions.length >= 1); + + const versionId = versionsData.versions[0].id; + const restoreRes = await api( + `/api/documents/edit/${doc.editToken}/versions/${versionId}/restore`, + { + method: 'POST', + body: { + clientId: 'version-client', + lockToken: lock.lockToken, + }, + }, + ); + const restored = await readJson(restoreRes); + + assert.equal(restoreRes.status, 200); + assert.equal(restored.content, 'v1'); + }); +}); diff --git a/docker-compose.yml b/docker-compose.yml @@ -10,6 +10,13 @@ services: - NODE_ENV=production - PORT=41738 - DATABASE_PATH=/app/data/snow.db + - SHARE_ALLOWED_ORIGINS=https://snow.pablomurad.com,http://localhost:41737,http://127.0.0.1:41737 + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:41738/api/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s editor: build: @@ -20,5 +27,6 @@ services: ports: - "41737:41737" depends_on: - - backend + backend: + condition: service_healthy restart: unless-stopped diff --git a/package-lock.json b/package-lock.json @@ -8,17 +8,79 @@ "name": "snow-editor", "version": "0.0.1", "dependencies": { + "@codemirror/commands": "^6.10.3", + "@codemirror/language": "^6.12.3", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.43.0", + "@orgajs/cm-lang": "^1.3.0", + "@orgajs/reorg-parse": "^4.4.1", + "@orgajs/reorg-rehype": "^4.3.11", "dompurify": "^3.2.4", "marked": "^15.0.7", + "orga": "^4.7.1", "react": "^19.0.0", "react-dom": "^19.0.0", - "react-router-dom": "^7.4.0" + "react-router-dom": "^7.4.0", + "rehype-stringify": "^10.0.1", + "unified": "^11.0.5" }, "devDependencies": { "@vitejs/plugin-react": "^4.3.4", + "jsdom": "^29.1.1", "vite": "^6.2.0" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -253,6 +315,15 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", @@ -301,6 +372,206 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.10.3", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz", + "integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.12.3", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz", + "integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/state": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz", + "integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.43.0", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.0.tgz", + "integrity": "sha512-V7ZCLQO3Jus9hzh2jVCCPW3mO4IBMr43O37PqSUYautJSnnJF41YlgLw21x0fLJTYvJ+Vkm6Gp+qKGH9pltgXA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.6.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.1.tgz", + "integrity": "sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.5.tgz", + "integrity": "sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -743,6 +1014,24 @@ "node": ">=18" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -793,6 +1082,76 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lezer/common": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz", + "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", + "license": "MIT" + }, + "node_modules/@lezer/highlight": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz", + "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", + "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", + "license": "MIT" + }, + "node_modules/@orgajs/cm-lang": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@orgajs/cm-lang/-/cm-lang-1.3.0.tgz", + "integrity": "sha512-Zgqyf17+sEJyyJALKAajyaKD+r9DqTrceApgad++/pdPTDBaJbzoAjJDOPz8v1B7G75fOBfG4zcSdUcF2gfavg==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.10.8", + "@orgajs/lezer": "^1.4.0" + } + }, + "node_modules/@orgajs/lezer": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@orgajs/lezer/-/lezer-1.4.1.tgz", + "integrity": "sha512-HG41lWyJRQ4zbZ5ovpMHDaBqGw9W1NJMcW3ZmhSFZW7eecIVH/NV+1BPBlwr4N5hWRSZSS2zka/ZbjcI5RoRwQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.1.1", + "@lezer/highlight": "^1.2.1", + "orga": "^4.7.1", + "unist-util-visit": "^5.0.0" + } + }, + "node_modules/@orgajs/reorg-parse": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/@orgajs/reorg-parse/-/reorg-parse-4.4.1.tgz", + "integrity": "sha512-zasLsdTm6XAt1Uk76xnYNNHDwOktj1kf/QoUkE81lvlZKlhaq6D1HOtSSdel777Y4mT2bCSpg4YmeLQo6SX1AQ==", + "license": "MIT", + "dependencies": { + "orga": "^4.7.1" + } + }, + "node_modules/@orgajs/reorg-rehype": { + "version": "4.3.11", + "resolved": "https://registry.npmjs.org/@orgajs/reorg-rehype/-/reorg-rehype-4.3.11.tgz", + "integrity": "sha512-YO1GyjRq3YqOj1W7wKy9crrVRYYZX+h1hLo43KAjGUcPmxJzXTg/zpucANUXzk0oc/TilRY8WHul5yJ5Oz4YoQ==", + "license": "MIT", + "dependencies": { + "oast-to-hast": "4.5.3" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -1241,6 +1600,24 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -1248,6 +1625,18 @@ "license": "MIT", "optional": true }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", + "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", + "license": "ISC" + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -1269,6 +1658,16 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.33", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz", @@ -1282,6 +1681,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/browserslist": { "version": "4.28.2", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", @@ -1337,6 +1746,46 @@ ], "license": "CC-BY-4.0" }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1357,6 +1806,65 @@ "url": "https://opencollective.com/express" } }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "license": "MIT" + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/date-fns": { + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", + "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.21.0" + }, + "engines": { + "node": ">=0.11" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/date-fns" + } + }, + "node_modules/date-fns-tz": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-2.0.1.tgz", + "integrity": "sha512-fJCG3Pwx8HUoLhkepdsP7Z5RsucUi+ZBOxyM5d0ZZ6c4SdYustq0VMmOu6Wf7bli+yS/Jwp91TOCqn9jMcVrUA==", + "license": "MIT", + "peerDependencies": { + "date-fns": "2.x" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1375,13 +1883,42 @@ } } }, - "node_modules/dompurify": { - "version": "3.4.7", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.7.tgz", - "integrity": "sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA==", - "license": "(MPL-2.0 OR Apache-2.0)", - "optionalDependencies": { - "@types/trusted-types": "^2.0.7" + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dompurify": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.7.tgz", + "integrity": "sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" } }, "node_modules/electron-to-chromium": { @@ -1391,6 +1928,18 @@ "dev": true, "license": "ISC" }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -1443,6 +1992,12 @@ "node": ">=6" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1486,6 +2041,152 @@ "node": ">=6.9.0" } }, + "node_modules/hast-util-from-html": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz", + "integrity": "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.1.0", + "hast-util-from-parse5": "^8.0.0", + "parse5": "^7.0.0", + "vfile": "^6.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-parse5": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", + "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "hastscript": "^9.0.0", + "property-information": "^7.0.0", + "vfile": "^6.0.0", + "vfile-location": "^5.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -1493,6 +2194,83 @@ "dev": true, "license": "MIT" }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsdom/node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -1541,6 +2319,135 @@ "node": ">= 18" } }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1577,6 +2484,42 @@ "node": ">=18" } }, + "node_modules/oast-to-hast": { + "version": "4.5.3", + "resolved": "https://registry.npmjs.org/oast-to-hast/-/oast-to-hast-4.5.3.tgz", + "integrity": "sha512-bYftNVW6xSO/1JP2kHrlyJrxadQgkJXJVTt5NtYNECO2LANjfmthlyP7W75GoNq17L7ssSC/YlCnSO44KjhgMw==", + "license": "MIT", + "dependencies": { + "hast-util-from-html": "^2.0.3", + "mime": "^3.0.0", + "orga": "^4.7.1", + "parse5": "^7.1.2", + "unist-util-position": "^5.0.0" + } + }, + "node_modules/orga": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/orga/-/orga-4.7.1.tgz", + "integrity": "sha512-7f163963srwEs6WbPzGEYyWgp0NQiWL2XFjNMOdIrIH2mF2qclzHqY3C3rZVwiRHDmgt1pLuHKbTC8P6j6QXcQ==", + "license": "MIT", + "dependencies": { + "date-fns": "^2.30.0", + "date-fns-tz": "^2.0.0", + "text-kit": "^4.5.1" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1626,6 +2569,26 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/react": { "version": "19.2.7", "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", @@ -1695,6 +2658,31 @@ "react-dom": ">=18" } }, + "node_modules/rehype-stringify": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/rehype-stringify/-/rehype-stringify-10.0.1.tgz", + "integrity": "sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-to-html": "^9.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rollup": { "version": "4.61.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.0.tgz", @@ -1740,6 +2728,19 @@ "fsevents": "~2.3.2" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -1772,6 +2773,49 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "license": "MIT" + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/text-kit": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/text-kit/-/text-kit-4.5.1.tgz", + "integrity": "sha512-f6Q0eXek4NuPh+PX1TnOpXXaFKmQRbvTFPBJsQUIfEoU2O15JajAU7XVNu55yDdSg/uPP89IRNZYA/3XPaPioQ==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -1789,6 +2833,169 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tldts": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.2.tgz", + "integrity": "sha512-kCwffuaH8ntKtygnWe1b4BJKWiCUH30n5KfoTr6IchcXOwR7chAOFJxFrH3vjANafUYrIA4a7SDL+nn7SiR4Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.2" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.2.tgz", + "integrity": "sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/undici": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.27.1.tgz", + "integrity": "sha512-UDdpiex+mzigiyrXrGbiUaF4HzTNhKbh2vRNFaTMzcqmLIPrZxaCtwo/1TMSuWoM1Xz3WiTo9KdgI3kRqYzJGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -1820,6 +3027,48 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-location": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz", + "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vite": { "version": "6.4.3", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", @@ -1895,12 +3144,103 @@ } } }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/web-namespaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", + "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, "license": "ISC" + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/package.json b/package.json @@ -6,17 +6,29 @@ "scripts": { "dev": "vite --host 0.0.0.0 --port 41737", "build": "vite build", - "preview": "vite preview --config vite.preview.config.js --host 0.0.0.0 --port 41737" + "preview": "vite preview --config vite.preview.config.js --host 0.0.0.0 --port 41737", + "test": "node --test src/lib/org/**/*.test.js" }, "dependencies": { + "@codemirror/commands": "^6.10.3", + "@codemirror/language": "^6.12.3", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.43.0", + "@orgajs/cm-lang": "^1.3.0", + "@orgajs/reorg-parse": "^4.4.1", + "@orgajs/reorg-rehype": "^4.3.11", "dompurify": "^3.2.4", "marked": "^15.0.7", + "orga": "^4.7.1", "react": "^19.0.0", "react-dom": "^19.0.0", - "react-router-dom": "^7.4.0" + "react-router-dom": "^7.4.0", + "rehype-stringify": "^10.0.1", + "unified": "^11.0.5" }, "devDependencies": { "@vitejs/plugin-react": "^4.3.4", + "jsdom": "^29.1.1", "vite": "^6.2.0" } } diff --git a/scripts/backup-db.ps1 b/scripts/backup-db.ps1 @@ -0,0 +1,22 @@ +$Root = Split-Path -Parent $PSScriptRoot +$Db = Join-Path $Root "data\snow.db" +$BackupDir = Join-Path $Root "data\backups" + +if (-not (Test-Path $Db)) { + Write-Error "Database not found: $Db" + exit 1 +} + +New-Item -ItemType Directory -Force -Path $BackupDir | Out-Null +$Stamp = Get-Date -Format "yyyy-MM-dd-HHmmss" +$Dest = Join-Path $BackupDir "snow-$Stamp.db" + +$sqlite3 = Get-Command sqlite3 -ErrorAction SilentlyContinue +if ($sqlite3) { + & sqlite3 $Db ".backup '$Dest'" +} else { + Write-Host "sqlite3 not found — copying file (stop the backend first for a safe copy)." + Copy-Item $Db $Dest +} + +Write-Host "Backup saved: $Dest" diff --git a/scripts/backup-db.sh b/scripts/backup-db.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +DB="${ROOT}/data/snow.db" +BACKUP_DIR="${ROOT}/data/backups" + +if [[ ! -f "$DB" ]]; then + echo "Database not found: $DB" >&2 + exit 1 +fi + +mkdir -p "$BACKUP_DIR" +STAMP="$(date +%Y-%m-%d-%H%M%S)" +DEST="${BACKUP_DIR}/snow-${STAMP}.db" + +if command -v sqlite3 >/dev/null 2>&1; then + sqlite3 "$DB" ".backup '${DEST}'" +else + echo "sqlite3 not found — copying file (stop the backend first for a safe copy)." + cp "$DB" "$DEST" +fi + +echo "Backup saved: $DEST" diff --git a/scripts/restore-db.ps1 b/scripts/restore-db.ps1 @@ -0,0 +1,32 @@ +param( + [Parameter(Mandatory = $true)] + [string]$Backup +) + +$Root = Split-Path -Parent $PSScriptRoot +$Db = Join-Path $Root "data\snow.db" +$BackupDir = Join-Path $Root "data\backups" + +$Source = $Backup +if (-not (Test-Path $Source)) { + $Source = Join-Path $BackupDir $Backup +} + +if (-not (Test-Path $Source)) { + Write-Error "Backup not found: $Source" + exit 1 +} + +Write-Host "This will overwrite $Db with:" +Write-Host " $Source" +Write-Host "Stop the backend first: docker compose stop backend" +$Confirm = Read-Host "Type RESTORE to continue" + +if ($Confirm -ne "RESTORE") { + Write-Host "Aborted." + exit 0 +} + +New-Item -ItemType Directory -Force -Path (Split-Path $Db) | Out-Null +Copy-Item $Source $Db -Force +Write-Host "Database restored from $Source" diff --git a/scripts/restore-db.sh b/scripts/restore-db.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +DB="${ROOT}/data/snow.db" +BACKUP_DIR="${ROOT}/data/backups" + +if [[ $# -lt 1 ]]; then + echo "Usage: $0 <backup-filename-or-path>" >&2 + echo "Example: $0 snow-2026-06-03-120000.db" >&2 + exit 1 +fi + +SOURCE="$1" +if [[ ! -f "$SOURCE" ]]; then + SOURCE="${BACKUP_DIR}/$1" +fi + +if [[ ! -f "$SOURCE" ]]; then + echo "Backup not found: $SOURCE" >&2 + exit 1 +fi + +echo "This will overwrite ${DB} with:" +echo " ${SOURCE}" +echo "Stop the backend first: docker compose stop backend" +read -r -p "Type RESTORE to continue: " CONFIRM + +if [[ "$CONFIRM" != "RESTORE" ]]; then + echo "Aborted." + exit 1 +fi + +mkdir -p "$(dirname "$DB")" +cp "$SOURCE" "$DB" +echo "Database restored from $SOURCE" diff --git a/src/components/EditorLayout.jsx b/src/components/EditorLayout.jsx @@ -1,6 +1,10 @@ -import { useDeferredValue, useEffect, useMemo, useState } from 'react'; +import { lazy, Suspense, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'; import { MODES } from '../lib/editorConstants.js'; -import { buildPreviewHtml, ensureMarkedLoaded } from '../lib/previewHtml.js'; +import { parseOrgDocument } from '../lib/org/parseDocument.js'; +import { buildPreviewHtml, ensureMarkedLoaded, ensureOrgLoaded } from '../lib/previewHtml.js'; +import OrgOutline from './OrgOutline.jsx'; + +const OrgEditor = lazy(() => import('./OrgEditor.jsx')); function useDividerOrientation() { const [orientation, setOrientation] = useState('vertical'); @@ -16,6 +20,22 @@ function useDividerOrientation() { return orientation; } +function useWideLayout() { + const [wide, setWide] = useState(() => + typeof window !== 'undefined' ? window.matchMedia('(min-width: 900px)').matches : true, + ); + + useEffect(() => { + const media = window.matchMedia('(min-width: 900px)'); + const update = () => setWide(media.matches); + update(); + media.addEventListener('change', update); + return () => media.removeEventListener('change', update); + }, []); + + return wide; +} + export function countWords(text) { const trimmed = text.trim(); if (!trimmed) return 0; @@ -32,12 +52,15 @@ export default function EditorLayout({ previewOnly = false, }) { const [markedReady, setMarkedReady] = useState(false); + const [orgReady, setOrgReady] = useState(false); + const scrollToLineRef = useRef(null); const dividerOrientation = useDividerOrientation(); + const wideLayout = useWideLayout(); const deferredContent = useDeferredValue(content); const previewIsStale = content !== deferredContent; useEffect(() => { - if (mode !== MODES.MARKDOWN) return; + if (mode !== MODES.MARKDOWN) return undefined; let cancelled = false; ensureMarkedLoaded().then(() => { if (!cancelled) setMarkedReady(true); @@ -47,9 +70,33 @@ export default function EditorLayout({ }; }, [mode]); + useEffect(() => { + if (mode !== MODES.ORG) return undefined; + let cancelled = false; + ensureOrgLoaded().then(() => { + if (!cancelled) setOrgReady(true); + }); + return () => { + cancelled = true; + }; + }, [mode]); + + const orgMeta = useMemo(() => { + if (mode !== MODES.ORG) return null; + return parseOrgDocument(deferredContent); + }, [mode, deferredContent]); + const html = useMemo(() => { return buildPreviewHtml(mode, deferredContent); - }, [mode, deferredContent, markedReady]); + }, [mode, deferredContent, markedReady, orgReady]); + + const handleRegisterScroll = useCallback((scrollFn) => { + scrollToLineRef.current = scrollFn; + }, []); + + const handleOutlineSelect = useCallback((line) => { + scrollToLineRef.current?.(line); + }, []); const wordCount = useMemo(() => countWords(content), [content]); const charCount = content.length; @@ -57,9 +104,18 @@ export default function EditorLayout({ const editorAriaLabel = mode === MODES.ORG ? 'Org-mode editing area' : 'Markdown editing area'; + const showOutline = + mode === MODES.ORG && showEditor && !previewOnly && wideLayout && orgMeta?.headings?.length > 0; + return ( <> - <main className={`app-layout${previewOnly ? ' app-layout--preview-only' : ''}`}> + <main + className={`app-layout${previewOnly ? ' app-layout--preview-only' : ''}${showOutline ? ' app-layout--with-outline' : ''}`} + > + {showOutline && ( + <OrgOutline content={content} onSelectHeading={handleOutlineSelect} /> + )} + {showEditor && !previewOnly && ( <> <section @@ -67,16 +123,29 @@ export default function EditorLayout({ aria-label={mode === MODES.ORG ? 'Org-mode editor' : 'Markdown editor'} > <div className="panel-label">Write</div> - <textarea - ref={editorRef} - className="editor" - value={content} - onChange={onContentChange ? (e) => onContentChange(e.target.value) : undefined} - readOnly={readOnly} - spellCheck="true" - aria-label={editorAriaLabel} - placeholder="Start writing..." - /> + {mode === MODES.ORG ? ( + <Suspense fallback={<div className="editor editor--loading">Loading Org editor…</div>}> + <OrgEditor + value={content} + onChange={onContentChange} + readOnly={readOnly} + editorRef={editorRef} + ariaLabel={editorAriaLabel} + onRegisterScroll={handleRegisterScroll} + /> + </Suspense> + ) : ( + <textarea + ref={editorRef} + className="editor" + value={content} + onChange={onContentChange ? (e) => onContentChange(e.target.value) : undefined} + readOnly={readOnly} + spellCheck="true" + aria-label={editorAriaLabel} + placeholder="Start writing..." + /> + )} </section> <div @@ -92,6 +161,9 @@ export default function EditorLayout({ aria-label="Document preview" > <div className="panel-label">Read</div> + {orgMeta?.title && ( + <p className="org-doc-title">{orgMeta.title}</p> + )} <div className={`preview-paper${previewIsStale ? ' preview-updating' : ''}`} dangerouslySetInnerHTML={{ __html: html }} diff --git a/src/components/OrgEditor.jsx b/src/components/OrgEditor.jsx @@ -0,0 +1,112 @@ +import { Compartment, EditorState } from '@codemirror/state'; +import { + drawSelection, + EditorView, + highlightActiveLine, + keymap, + lineNumbers, +} from '@codemirror/view'; +import { defaultKeymap, indentWithTab } from '@codemirror/commands'; +import { org } from '@orgajs/cm-lang'; +import { useEffect, useRef } from 'react'; +import { checklistPlugin } from '../lib/org/checklistPlugin.js'; +import { orgKeymap } from '../lib/org/keymap.js'; +import { orgTheme } from '../lib/org/orgTheme.js'; + +export default function OrgEditor({ + value, + onChange, + readOnly = false, + editorRef, + ariaLabel, + onRegisterScroll, +}) { + const containerRef = useRef(null); + const viewRef = useRef(null); + const readOnlyRef = useRef(readOnly); + const onChangeRef = useRef(onChange); + const editableCompartment = useRef(new Compartment()); + + readOnlyRef.current = readOnly; + onChangeRef.current = onChange; + + useEffect(() => { + if (!containerRef.current) return undefined; + + const updateListener = EditorView.updateListener.of((update) => { + if (update.docChanged && onChangeRef.current) { + onChangeRef.current(update.state.doc.toString()); + } + }); + + const state = EditorState.create({ + doc: value, + extensions: [ + org(), + orgTheme, + lineNumbers(), + highlightActiveLine(), + drawSelection(), + editableCompartment.current.of(EditorView.editable.of(!readOnlyRef.current)), + EditorState.readOnly.of(readOnlyRef.current), + EditorView.contentAttributes.of({ 'aria-label': ariaLabel }), + updateListener, + checklistPlugin(() => readOnlyRef.current), + orgKeymap, + keymap.of([...defaultKeymap, indentWithTab]), + ], + }); + + const view = new EditorView({ state, parent: containerRef.current }); + viewRef.current = view; + + if (editorRef) { + editorRef.current = { + focus: () => view.focus(), + getView: () => view, + }; + } + + onRegisterScroll?.((lineNumber) => { + const line = view.state.doc.line( + Math.min(Math.max(1, lineNumber), view.state.doc.lines), + ); + view.dispatch({ + effects: EditorView.scrollIntoView(line.from, { y: 'center' }), + selection: { anchor: line.from }, + }); + view.focus(); + }); + + return () => { + view.destroy(); + viewRef.current = null; + if (editorRef) editorRef.current = null; + }; + }, [ariaLabel, editorRef, onRegisterScroll]); + + useEffect(() => { + const view = viewRef.current; + if (!view) return; + + const current = view.state.doc.toString(); + if (current !== value) { + view.dispatch({ + changes: { from: 0, to: current.length, insert: value }, + }); + } + }, [value]); + + useEffect(() => { + const view = viewRef.current; + if (!view) return; + + view.dispatch({ + effects: editableCompartment.current.reconfigure( + EditorView.editable.of(!readOnly), + ), + }); + }, [readOnly]); + + return <div ref={containerRef} className="org-editor cm-host" />; +} diff --git a/src/components/OrgOutline.jsx b/src/components/OrgOutline.jsx @@ -0,0 +1,39 @@ +import { useMemo } from 'react'; +import { parseOrgDocument } from '../lib/org/parseDocument.js'; +import { STR } from '../lib/strings.js'; + +export default function OrgOutline({ content, onSelectHeading, collapsed = false }) { + const { headings } = useMemo(() => parseOrgDocument(content), [content]); + + if (collapsed || headings.length === 0) { + return null; + } + + return ( + <aside className="org-outline" aria-label={STR.ORG_OUTLINE}> + <div className="org-outline__title">{STR.ORG_OUTLINE}</div> + <ul className="org-outline__list"> + {headings.map((heading) => ( + <li + key={`${heading.line}-${heading.title}`} + className="org-outline__item" + style={{ paddingLeft: `${(heading.level - 1) * 0.65}rem` }} + > + <button + type="button" + className="org-outline__link" + onClick={() => onSelectHeading?.(heading.line)} + > + {heading.todo && ( + <span className={`org-badge org-${heading.todo.toLowerCase()}`}> + {heading.todo} + </span> + )} + {heading.title} + </button> + </li> + ))} + </ul> + </aside> + ); +} diff --git a/src/components/VersionHistory.jsx b/src/components/VersionHistory.jsx @@ -0,0 +1,126 @@ +import { useCallback, useEffect, useState } from 'react'; +import { + fetchDocumentVersions, + friendlyErrorMessage, + restoreDocumentVersion, +} from '../lib/api.js'; +import { STR, formatVersionDate } from '../lib/strings.js'; + +export default function VersionHistory({ + editToken, + clientId, + lockToken, + onRestored, +}) { + const [open, setOpen] = useState(false); + const [versions, setVersions] = useState([]); + const [loading, setLoading] = useState(false); + const [restoringId, setRestoringId] = useState(''); + const [error, setError] = useState(''); + + const loadVersions = useCallback(async () => { + setLoading(true); + setError(''); + try { + const data = await fetchDocumentVersions(editToken, { clientId, lockToken }); + setVersions(data.versions ?? []); + } catch (err) { + setError(friendlyErrorMessage(err)); + } finally { + setLoading(false); + } + }, [editToken, clientId, lockToken]); + + useEffect(() => { + if (!open) return; + loadVersions(); + }, [open, loadVersions]); + + const handleRestore = async (versionId) => { + const confirmed = window.confirm(STR.VERSION_RESTORE_CONFIRM); + if (!confirmed) return; + + setRestoringId(versionId); + setError(''); + try { + const data = await restoreDocumentVersion(editToken, versionId, { + clientId, + lockToken, + }); + onRestored(data); + await loadVersions(); + } catch (err) { + setError(friendlyErrorMessage(err)); + } finally { + setRestoringId(''); + } + }; + + return ( + <> + <button + type="button" + className="btn btn-ghost" + onClick={() => setOpen(true)} + > + {STR.VERSION_HISTORY} + </button> + + {open && ( + <div className="modal-backdrop" role="presentation" onClick={() => setOpen(false)}> + <div + className="modal share-panel version-panel" + role="dialog" + aria-labelledby="version-history-title" + aria-modal="true" + onClick={(e) => e.stopPropagation()} + > + <h2 id="version-history-title" className="modal__title"> + {STR.VERSION_HISTORY} + </h2> + + {loading && <p className="version-panel__status">{STR.VERSION_LOADING}</p>} + + {!loading && versions.length === 0 && ( + <p className="version-panel__status">{STR.VERSION_EMPTY}</p> + )} + + {!loading && versions.length > 0 && ( + <ul className="version-list"> + {versions.map((version) => ( + <li key={version.id} className="version-list__item"> + <span className="version-list__date"> + {formatVersionDate(version.createdAt)} + </span> + <button + type="button" + className="btn btn-ghost version-list__restore" + disabled={restoringId === version.id} + onClick={() => handleRestore(version.id)} + > + {restoringId === version.id + ? STR.VERSION_RESTORING + : STR.VERSION_RESTORE} + </button> + </li> + ))} + </ul> + )} + + {error && ( + <p className="share-error" role="alert"> + {error} + </p> + )} + + <div className="modal__actions"> + <button type="button" className="btn" onClick={() => setOpen(false)}> + {STR.CLOSE} + </button> + </div> + </div> + </div> + )} + </> + ); +} diff --git a/src/lib/api.js b/src/lib/api.js @@ -27,6 +27,7 @@ const ERROR_MESSAGES = { LOCK_REQUIRED: STR.LOCK_REQUIRED, CONTENT_TOO_LARGE: STR.CONTENT_TOO_LARGE, RATE_LIMIT: STR.RATE_LIMIT, + ORIGIN_NOT_ALLOWED: STR.ORIGIN_NOT_ALLOWED, NETWORK: STR.NETWORK, }; @@ -120,6 +121,23 @@ export function updateDocument(token, body) { }); } +export function fetchDocumentVersions(token, { clientId, lockToken }) { + const params = new URLSearchParams({ clientId, lockToken }); + return request( + `/api/documents/edit/${encodeURIComponent(token)}/versions?${params}`, + ); +} + +export function restoreDocumentVersion(token, versionId, body) { + return request( + `/api/documents/edit/${encodeURIComponent(token)}/versions/${encodeURIComponent(versionId)}/restore`, + { + method: 'POST', + body: JSON.stringify(body), + }, + ); +} + export function toAbsoluteUrl(path) { if (path.startsWith('http')) return path; return `${getPublicOrigin()}${path}`; diff --git a/src/lib/editorConstants.js b/src/lib/editorConstants.js @@ -28,16 +28,25 @@ console.log("writing in peace"); \`\`\` `; -const DEFAULT_ORG = `* Snow Journal +const DEFAULT_ORG = `#+TITLE: Snow Journal +#+AUTHOR: Snow Editor -Welcome to your quiet Org-mode writing nook. +* Welcome :intro: +A quiet Org-mode writing nook with /live preview/. -** TODO Ideas -- Write notes -- Create documents -- Organize thoughts +** TODO Ideas :notes: +- [ ] Write notes +- [X] Try checklist click in the editor +- Nested list + - Inner item + - Another inner item + +** DONE First table -** DONE First draft +| Feature | Status | +|-----------+--------| +| Tables | yes | +| Outline | yes | #+BEGIN_QUOTE A clean space to think calmly. @@ -47,7 +56,7 @@ A clean space to think calmly. console.log("writing in peace"); #+END_SRC -** Example link +** Links [[https://orgmode.org][Official Org-mode site]] `; diff --git a/src/lib/org/checklistPlugin.js b/src/lib/org/checklistPlugin.js @@ -0,0 +1,42 @@ +import { ViewPlugin } from '@codemirror/view'; +import { toggleChecklistLine, isChecklistLine } from './editorUtils.js'; + +export function checklistPlugin(getReadOnly) { + return ViewPlugin.fromClass( + class { + constructor(view) { + this.view = view; + this.getReadOnly = getReadOnly; + this.onMouseDown = this.onMouseDown.bind(this); + view.dom.addEventListener('mousedown', this.onMouseDown); + } + + onMouseDown(event) { + if (this.getReadOnly()) return; + + const pos = this.view.posAtCoords({ x: event.clientX, y: event.clientY }); + if (pos == null) return; + + const line = this.view.state.doc.lineAt(pos); + if (!isChecklistLine(line.text)) return; + + const bracketStart = line.text.indexOf('['); + const bracketEnd = line.text.indexOf(']', bracketStart); + if (bracketStart < 0 || bracketEnd < 0) return; + + const clickStart = pos - line.from; + if (clickStart < bracketStart || clickStart > bracketEnd + 1) return; + + event.preventDefault(); + const nextLine = toggleChecklistLine(line.text); + this.view.dispatch({ + changes: { from: line.from, to: line.to, insert: nextLine }, + }); + } + + destroy() { + this.view.dom.removeEventListener('mousedown', this.onMouseDown); + } + }, + ); +} diff --git a/src/lib/org/constants.js b/src/lib/org/constants.js @@ -0,0 +1,4 @@ +export const ORG_SANITIZE_OPTIONS = { + ADD_ATTR: ['class', 'rel', 'href', 'title', 'id', 'colspan', 'rowspan'], + ADD_TAGS: ['table', 'thead', 'tbody', 'tr', 'th', 'td', 'del', 'sub', 'sup', 'fn'], +}; diff --git a/src/lib/org/editorUtils.js b/src/lib/org/editorUtils.js @@ -0,0 +1,47 @@ +const CHECKLIST_RE = /^(\s*)([-+*])\s+\[([ Xx\-])\](.*)$/; +const HEADING_RE = /^(\*+)\s+(.*)$/; + +export function isChecklistLine(line) { + return CHECKLIST_RE.test(line); +} + +export function toggleChecklistLine(line) { + const match = line.match(CHECKLIST_RE); + if (!match) return line; + + const [, indent, bullet, state, rest] = match; + const nextState = state === 'X' || state === 'x' ? ' ' : 'X'; + return `${indent}${bullet} [${nextState}]${rest}`; +} + +export function adjustHeading(line, delta) { + const match = line.match(HEADING_RE); + if (!match) return line; + + const nextLevel = match[1].length + delta; + if (nextLevel < 1 || nextLevel > 8) return line; + return `${'*'.repeat(nextLevel)} ${match[2]}`; +} + +export function isHeadingLine(line) { + return HEADING_RE.test(line); +} + +export function insertNewListItem(line) { + const checklist = line.match(/^(\s*)([-+*])\s+\[[ Xx\-]\](.*)$/); + if (checklist) { + return `${checklist[1]}${checklist[2]} [ ]`; + } + + const list = line.match(/^(\s*)([-+*]|\d+\.)\s+(.*)$/); + if (list) { + const bullet = list[2].endsWith('.') ? list[2] : list[2]; + return `${list[1]}${bullet} `; + } + + if (isHeadingLine(line)) { + return '- '; + } + + return '- '; +} diff --git a/src/lib/org/editorUtils.test.js b/src/lib/org/editorUtils.test.js @@ -0,0 +1,20 @@ +import assert from 'node:assert'; +import { describe, test } from 'node:test'; +import { + adjustHeading, + isChecklistLine, + toggleChecklistLine, +} from './editorUtils.js'; + +describe('Org editor utils', () => { + test('toggleChecklistLine switches states', () => { + assert.equal(toggleChecklistLine('- [ ] task'), '- [X] task'); + assert.equal(toggleChecklistLine('- [X] task'), '- [ ] task'); + assert.ok(isChecklistLine('- [ ] task')); + }); + + test('adjustHeading changes star count', () => { + assert.equal(adjustHeading('* Title', 1), '** Title'); + assert.equal(adjustHeading('** Title', -1), '* Title'); + }); +}); diff --git a/src/lib/org/enhanceHtml.js b/src/lib/org/enhanceHtml.js @@ -0,0 +1,19 @@ +export function enhanceOrgHtml(html) { + if (!html) return ''; + + let result = html; + + result = result.replace(/<table(?![^>]*class=)/g, '<table class="org-table"'); + result = result.replace(/<h([1-6])(?![^>]*class=)/g, '<h$1 class="org-heading org-heading--$1"'); + result = result.replace( + /<blockquote(?![^>]*class=)/g, + '<blockquote class="org-quote"', + ); + result = result.replace(/<pre(?![^>]*class=)/g, '<pre class="org-src"'); + result = result.replace( + /<div class="section"/g, + '<div class="org-section"', + ); + + return result; +} diff --git a/src/lib/org/fixtures/checklist.org b/src/lib/org/fixtures/checklist.org @@ -0,0 +1,3 @@ +* Tasks +- [ ] open task +- [X] done task diff --git a/src/lib/org/fixtures/export-html.org b/src/lib/org/fixtures/export-html.org @@ -0,0 +1,4 @@ +#+BEGIN_EXPORT html +<p>safe</p> +<script>alert('x')</script> +#+END_EXPORT diff --git a/src/lib/org/fixtures/headings-todo.org b/src/lib/org/fixtures/headings-todo.org @@ -0,0 +1,4 @@ +#+TITLE: Heading Sample +* Top level :work: +** TODO Nested task +*** DONE Deep section diff --git a/src/lib/org/fixtures/nested-lists.org b/src/lib/org/fixtures/nested-lists.org @@ -0,0 +1,6 @@ +* Lists +- parent + - child one + - child two +1. ordered +2. second diff --git a/src/lib/org/fixtures/table.org b/src/lib/org/fixtures/table.org @@ -0,0 +1,4 @@ +* Data +| Name | Value | +|------+-------| +| Snow | 42 | diff --git a/src/lib/org/keymap.js b/src/lib/org/keymap.js @@ -0,0 +1,81 @@ +import { keymap } from '@codemirror/view'; +import { insertNewlineAndIndent } from '@codemirror/commands'; +import { + adjustHeading, + insertNewListItem, + isChecklistLine, + isHeadingLine, +} from './editorUtils.js'; + +function promoteHeading(view) { + const line = view.state.doc.lineAt(view.state.selection.main.head); + if (!isHeadingLine(line.text)) return false; + const next = adjustHeading(line.text, -1); + if (next === line.text) return false; + view.dispatch({ changes: { from: line.from, to: line.to, insert: next } }); + return true; +} + +function demoteHeading(view) { + const line = view.state.doc.lineAt(view.state.selection.main.head); + if (!isHeadingLine(line.text)) return false; + const next = adjustHeading(line.text, 1); + if (next === line.text) return false; + view.dispatch({ changes: { from: line.from, to: line.to, insert: next } }); + return true; +} + +function continueListItem(view) { + const line = view.state.doc.lineAt(view.state.selection.main.head); + const prefix = insertNewListItem(line.text); + view.dispatch({ + changes: { from: line.to, insert: `\n${prefix}` }, + selection: { anchor: line.to + 1 + prefix.length }, + }); + return true; +} + +function indentListLine(view) { + const line = view.state.doc.lineAt(view.state.selection.main.head); + if (!/^(\s*)([-+*]|\d+\.)\s+/.test(line.text) && !isChecklistLine(line.text)) { + return false; + } + view.dispatch({ + changes: { from: line.from, to: line.from, insert: ' ' }, + selection: { anchor: view.state.selection.main.head + 2 }, + }); + return true; +} + +function handleTab(view) { + const line = view.state.doc.lineAt(view.state.selection.main.head); + if (isHeadingLine(line.text)) { + return demoteHeading(view); + } + if (indentListLine(view)) return true; + return insertNewlineAndIndent(view); +} + +function handleShiftTab(view) { + const line = view.state.doc.lineAt(view.state.selection.main.head); + if (isHeadingLine(line.text)) { + return promoteHeading(view); + } + if (isChecklistLine(line.text) || /^(\s*)([-+*]|\d+\.)\s+/.test(line.text)) { + const match = line.text.match(/^(\s*)/); + const indent = match?.[1] ?? ''; + if (indent.length >= 2) { + view.dispatch({ + changes: { from: line.from, to: line.to, insert: line.text.slice(2) }, + }); + return true; + } + } + return false; +} + +export const orgKeymap = keymap.of([ + { key: 'Tab', run: handleTab }, + { key: 'Shift-Tab', run: handleShiftTab }, + { key: 'Mod-Enter', run: continueListItem }, +]); diff --git a/src/lib/org/normalize.js b/src/lib/org/normalize.js @@ -0,0 +1,4 @@ +export function normalizeOrgContent(content) { + if (!content) return ''; + return content.replace(/\r\n/g, '\n'); +} diff --git a/src/lib/org/orgTheme.js b/src/lib/org/orgTheme.js @@ -0,0 +1,36 @@ +import { EditorView } from '@codemirror/view'; + +export const orgTheme = EditorView.theme({ + '&': { + height: '100%', + fontSize: '0.9rem', + fontFamily: 'var(--font-mono)', + backgroundColor: 'var(--paper)', + }, + '.cm-scroller': { + overflow: 'auto', + fontFamily: 'inherit', + }, + '.cm-content': { + caretColor: 'var(--accent-hover)', + padding: '0.75rem 0.5rem', + minHeight: '100%', + }, + '.cm-gutters': { + backgroundColor: 'var(--snow)', + borderRight: '1px solid var(--border-soft)', + color: 'var(--text-muted)', + }, + '.cm-activeLine': { + backgroundColor: 'rgba(122, 155, 184, 0.08)', + }, + '.cm-cursor': { + borderLeftColor: 'var(--accent-hover)', + }, + '&.cm-focused .cm-selectionBackground, .cm-selectionBackground': { + backgroundColor: 'rgba(122, 155, 184, 0.2) !important', + }, + '.cm-line': { + lineHeight: '1.55', + }, +}); diff --git a/src/lib/org/parseDocument.js b/src/lib/org/parseDocument.js @@ -0,0 +1,91 @@ +import { getSettings, parse } from 'orga'; +import { normalizeOrgContent } from './normalize.js'; + +function headlineTitle(headline) { + const parts = []; + for (const child of headline.children ?? []) { + if (child.type === 'text' && child.value) { + parts.push(child.value); + } + } + return parts.join('').trim() || 'Untitled section'; +} + +function headlineLevel(headline) { + const stars = headline.children?.find((child) => child.type === 'stars'); + if (stars?.level) return stars.level; + if (stars?.value) return stars.value.length; + return 1; +} + +function headlineTodo(headline) { + const todoNode = headline.children?.find((child) => child.type === 'todo'); + return todoNode?.keyword ?? null; +} + +function headlineTags(headline) { + const tagsNode = headline.children?.find((child) => child.type === 'tags'); + if (!tagsNode?.tags?.length) return []; + return tagsNode.tags; +} + +function walkSection(section, headings) { + if (!section?.children) return; + + for (const child of section.children) { + if (child.type === 'headline') { + headings.push({ + level: headlineLevel(child), + title: headlineTitle(child), + todo: headlineTodo(child), + tags: headlineTags(child), + line: child.position?.start?.line ?? 1, + }); + } + if (child.type === 'section') { + walkSection(child, headings); + } + } +} + +export function parseOrgDocument(content) { + if (!content?.trim()) { + return { + keywords: {}, + headings: [], + title: null, + author: null, + todoKeywords: ['TODO', 'DONE'], + }; + } + + const normalized = normalizeOrgContent(content); + const settings = getSettings(normalized); + const doc = parse(normalized); + const headings = []; + + for (const child of doc.children ?? []) { + if (child.type === 'section') { + walkSection(child, headings); + } + } + + const titleSetting = settings.title; + const authorSetting = settings.author; + const todoSetting = settings.todo; + + let todoKeywords = ['TODO', 'DONE']; + if (typeof todoSetting === 'string') { + todoKeywords = todoSetting.split(/\s+/).filter(Boolean); + } else if (Array.isArray(todoSetting)) { + todoKeywords = todoSetting.flatMap((entry) => entry.split(/\s+/)).filter(Boolean); + } + + return { + keywords: settings, + headings, + title: typeof titleSetting === 'string' ? titleSetting : null, + author: typeof authorSetting === 'string' ? authorSetting : null, + todoKeywords, + }; +} diff --git a/src/lib/org/parseDocument.test.js b/src/lib/org/parseDocument.test.js @@ -0,0 +1,21 @@ +import assert from 'node:assert'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, test } from 'node:test'; +import { parseOrgDocument } from './parseDocument.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +describe('parseOrgDocument', () => { + test('extracts title keyword and headings', () => { + const content = readFileSync(join(__dirname, 'fixtures/headings-todo.org'), 'utf8'); + const doc = parseOrgDocument(content); + + assert.equal(doc.title, 'Heading Sample'); + assert.ok(doc.headings.length >= 3); + assert.equal(doc.headings[0].title, 'Top level'); + assert.equal(doc.headings[1].level, 2); + assert.equal(doc.headings[1].todo, 'TODO'); + }); +}); diff --git a/src/lib/org/pipeline.js b/src/lib/org/pipeline.js @@ -0,0 +1,34 @@ +import { unified } from 'unified'; +import reorgParse from '@orgajs/reorg-parse'; +import reorgRehype from '@orgajs/reorg-rehype'; +import rehypeStringify from 'rehype-stringify'; +import { enhanceOrgHtml } from './enhanceHtml.js'; +import { normalizeOrgContent } from './normalize.js'; +import { sanitizeOrgHtml } from './sanitize.js'; + +let processor = null; + +function getProcessor() { + if (!processor) { + processor = unified() + .use(reorgParse) + .use(reorgRehype) + .use(rehypeStringify); + } + return processor; +} + +function processOrg(content) { + return getProcessor().processSync(normalizeOrgContent(content)).toString(); +} + +export function orgToHtmlSync(content) { + if (!content || !content.trim()) return ''; + const raw = enhanceOrgHtml(processOrg(content)); + return sanitizeOrgHtml(raw); +} + +export function orgToRawHtmlSync(content) { + if (!content || !content.trim()) return ''; + return processOrg(content); +} diff --git a/src/lib/org/pipeline.test.js b/src/lib/org/pipeline.test.js @@ -0,0 +1,65 @@ +import assert from 'node:assert'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, test } from 'node:test'; +import { JSDOM } from 'jsdom'; +import DOMPurify from 'dompurify'; +import { enhanceOrgHtml } from './enhanceHtml.js'; +import { orgToHtmlSync, orgToRawHtmlSync } from './pipeline.js'; +import { ORG_SANITIZE_OPTIONS } from './constants.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const fixturesDir = join(__dirname, 'fixtures'); + +function loadFixture(name) { + return readFileSync(join(fixturesDir, name), 'utf8'); +} + +function sanitizeInNode(html) { + const window = new JSDOM('').window; + const purify = DOMPurify(window); + return purify.sanitize(html, ORG_SANITIZE_OPTIONS); +} + +describe('Org pipeline', () => { + test('renders headings and nested sections', () => { + const html = orgToRawHtmlSync(loadFixture('headings-todo.org')); + assert.match(html, /<h1[^>]*>.*Top level/i); + assert.match(html, /<h2[^>]*>.*Nested task/i); + assert.match(html, /<h3[^>]*>.*Deep section/i); + }); + + test('renders nested lists', () => { + const html = orgToRawHtmlSync(loadFixture('nested-lists.org')); + assert.match(html, /<ul>/); + assert.match(html, /<li>.*child one/i); + }); + + test('renders tables with cozy class', () => { + const raw = enhanceOrgHtml(orgToRawHtmlSync(loadFixture('table.org'))); + assert.match(raw, /class="org-table"/); + assert.match(raw, /<table/); + assert.match(raw, /Snow/); + }); + + test('renders checklist items', () => { + const html = orgToRawHtmlSync(loadFixture('checklist.org')); + assert.match(html, /open task/); + assert.match(html, /done task/); + }); + + test('strips script tags from export html blocks', () => { + const raw = enhanceOrgHtml(orgToRawHtmlSync(loadFixture('export-html.org'))); + const clean = sanitizeInNode(raw); + assert.match(clean, /safe/); + assert.doesNotMatch(clean, /<script/i); + }); + + test('sanitize removes scripts in node environment', () => { + const dirty = '<p>ok</p><script>alert(1)</script>'; + const clean = sanitizeInNode(dirty); + assert.match(clean, /ok/); + assert.doesNotMatch(clean, /<script/i); + }); +}); diff --git a/src/lib/org/sanitize.js b/src/lib/org/sanitize.js @@ -0,0 +1,10 @@ +import DOMPurify from 'dompurify'; +import { ORG_SANITIZE_OPTIONS } from './constants.js'; + +export function sanitizeOrgHtml(html) { + if (!html) return ''; + if (typeof window === 'undefined' || !window.document) { + return html; + } + return DOMPurify.sanitize(html, ORG_SANITIZE_OPTIONS); +} diff --git a/src/lib/parseOrgMode.js b/src/lib/parseOrgMode.js @@ -1,216 +0,0 @@ -function escapeHtml(text) { - return text - .replace(/&/g, '&amp;') - .replace(/</g, '&lt;') - .replace(/>/g, '&gt;') - .replace(/"/g, '&quot;'); -} - -function sanitizeHref(url) { - const trimmed = url.trim(); - if (/^https?:\/\//i.test(trimmed)) { - return escapeHtml(trimmed); - } - return null; -} - -function sanitizeLanguage(language) { - const trimmed = language.trim(); - if (!trimmed) return 'text'; - if (/^[a-zA-Z0-9#+.-]+$/.test(trimmed)) { - return escapeHtml(trimmed); - } - return 'text'; -} - -function parseOrgInline(text) { - const placeholders = []; - let working = escapeHtml(text); - - const stash = (html) => { - const key = `\x00ORG${placeholders.length}PH\x00`; - placeholders.push(html); - return key; - }; - - working = working.replace( - /\[\[([^\]]+)\]\[([^\]]+)\]\]/g, - (_, url, label) => { - const href = sanitizeHref(url); - if (!href) return escapeHtml(`[[${url}][${label}]]`); - return stash( - `<a href="${href}" rel="noopener noreferrer">${escapeHtml(label)}</a>`, - ); - }, - ); - - working = working.replace(/\[\[([^\]]+)\]\]/g, (_, url) => { - const href = sanitizeHref(url); - if (!href) return escapeHtml(`[[${url}]]`); - return stash(`<a href="${href}" rel="noopener noreferrer">${href}</a>`); - }); - - working = working.replace(/~([^~]+)~/g, (_, code) => `<code>${code}</code>`); - working = working.replace(/=([^=]+)=/g, (_, code) => `<code>${code}</code>`); - working = working.replace(/\*([^*]+)\*/g, (_, bold) => `<strong>${bold}</strong>`); - working = working.replace(/\/([^/]+)\//g, (_, italic) => `<em>${italic}</em>`); - - placeholders.forEach((html, index) => { - working = working.replace(`\x00ORG${index}PH\x00`, html); - }); - - return working; -} - -function parseHeading(line) { - const match = line.match(/^(\*{1,4})\s+(?:(TODO|DONE)\s+)?(.+)$/); - if (!match) return null; - - const level = Math.min(match[1].length, 4); - const keyword = match[2]; - const title = match[3].trim(); - const tag = `h${level}`; - - let badge = ''; - if (keyword === 'TODO') { - badge = '<span class="org-badge org-todo">TODO</span> '; - } else if (keyword === 'DONE') { - badge = '<span class="org-badge org-done">DONE</span> '; - } - - return `<${tag}>${badge}${parseOrgInline(title)}</${tag}>`; -} - -function parseListBlock(lines) { - const items = lines - .filter((line) => /^[-+]\s+/.test(line)) - .map((line) => `<li>${parseOrgInline(line.replace(/^[-+]\s+/, ''))}</li>`); - - if (items.length === 0) return ''; - return `<ul>${items.join('')}</ul>`; -} - -function parseParagraphBlock(lines) { - const html = lines.map((line) => parseOrgInline(line)).join('<br>'); - return `<p>${html}</p>`; -} - -function extractSpecialBlocks(input) { - const lines = input.replace(/\r\n/g, '\n').split('\n'); - const blocks = []; - let i = 0; - - while (i < lines.length) { - const srcMatch = lines[i].match(/^#\+BEGIN_SRC\s*(\S*)?\s*$/i); - const quoteMatch = lines[i].match(/^#\+BEGIN_QUOTE\s*$/i); - - if (srcMatch) { - const language = sanitizeLanguage((srcMatch[1] || '').trim()); - const codeLines = []; - i += 1; - while (i < lines.length && !/^#\+END_SRC\s*$/i.test(lines[i])) { - codeLines.push(lines[i]); - i += 1; - } - if (i < lines.length) i += 1; - const langClass = ` class="language-${language}"`; - blocks.push({ - type: 'html', - html: `<pre><code${langClass}>${escapeHtml(codeLines.join('\n'))}</code></pre>`, - }); - continue; - } - - if (quoteMatch) { - const quoteLines = []; - i += 1; - while (i < lines.length && !/^#\+END_QUOTE\s*$/i.test(lines[i])) { - quoteLines.push(lines[i]); - i += 1; - } - if (i < lines.length) i += 1; - const inner = - quoteLines.length === 0 - ? '' - : quoteLines.map((l) => parseOrgInline(l)).join('<br>'); - blocks.push({ - type: 'html', - html: `<blockquote>${inner}</blockquote>`, - }); - continue; - } - - const textLines = []; - while ( - i < lines.length && - !/^#\+BEGIN_SRC/i.test(lines[i]) && - !/^#\+BEGIN_QUOTE/i.test(lines[i]) - ) { - textLines.push(lines[i]); - i += 1; - } - - if (textLines.length > 0) { - blocks.push({ type: 'text', lines: textLines }); - } - } - - return blocks; -} - -function parseTextBlock(lines) { - const chunks = []; - let current = []; - - const flush = () => { - if (current.length === 0) return; - chunks.push([...current]); - current = []; - }; - - for (const line of lines) { - if (line.trim() === '') { - flush(); - } else { - current.push(line); - } - } - flush(); - - return chunks - .map((chunk) => { - if (chunk.every((line) => /^[-+]\s+/.test(line))) { - return parseListBlock(chunk); - } - if (chunk.length === 1) { - const heading = parseHeading(chunk[0]); - if (heading) return heading; - if (/^[-+]\s+/.test(chunk[0])) return parseListBlock(chunk); - } - const heading = parseHeading(chunk[0]); - if (heading) { - return heading + (chunk.length > 1 ? parseParagraphBlock(chunk.slice(1)) : ''); - } - return parseParagraphBlock(chunk); - }) - .join(''); -} - -export function parseOrgMode(input) { - if (!input || !input.trim()) { - return ''; - } - - const blocks = extractSpecialBlocks(input); - const htmlParts = []; - - for (const block of blocks) { - if (block.type === 'html') { - htmlParts.push(block.html); - } else { - htmlParts.push(parseTextBlock(block.lines)); - } - } - - return htmlParts.join(''); -} diff --git a/src/lib/previewHtml.js b/src/lib/previewHtml.js @@ -1,11 +1,12 @@ import DOMPurify from 'dompurify'; import { MODES } from './editorConstants.js'; -import { parseOrgMode } from './parseOrgMode.js'; -const SANITIZE_OPTIONS = { ADD_ATTR: ['class', 'rel'] }; +const MARKDOWN_SANITIZE_OPTIONS = { ADD_ATTR: ['class', 'rel'] }; let markedParser = null; let markedLoadPromise = null; +let orgToHtmlSync = null; +let orgLoadPromise = null; export function ensureMarkedLoaded() { if (markedParser) { @@ -20,13 +21,27 @@ export function ensureMarkedLoaded() { return markedLoadPromise; } +export function ensureOrgLoaded() { + if (orgToHtmlSync) { + return Promise.resolve(orgToHtmlSync); + } + if (!orgLoadPromise) { + orgLoadPromise = import('./org/pipeline.js').then((module) => { + orgToHtmlSync = module.orgToHtmlSync; + return orgToHtmlSync; + }); + } + return orgLoadPromise; +} + export function buildPreviewHtml(mode, content) { if (!content || !content.trim()) { return ''; } if (mode === MODES.ORG) { - return DOMPurify.sanitize(parseOrgMode(content), SANITIZE_OPTIONS); + if (!orgToHtmlSync) return ''; + return orgToHtmlSync(content); } if (!markedParser) { @@ -34,5 +49,5 @@ export function buildPreviewHtml(mode, content) { } const rawHtml = markedParser.parse(content, { gfm: true, breaks: true }); - return DOMPurify.sanitize(rawHtml, SANITIZE_OPTIONS); + return DOMPurify.sanitize(rawHtml, MARKDOWN_SANITIZE_OPTIONS); } diff --git a/src/lib/strings.js b/src/lib/strings.js @@ -62,9 +62,21 @@ export const STR = { LOCK_REQUIRED: 'You need an active edit lock to save.', CONTENT_TOO_LARGE: 'Document is larger than 1 MB.', RATE_LIMIT: 'Too many requests. Try again in a minute.', + ORIGIN_NOT_ALLOWED: + 'Sharing is only available from the Snow Editor website. Open snow.pablomurad.com and try again.', NETWORK: 'Could not connect to the server. Check your connection.', GENERIC_ERROR: 'Something went wrong.', UNEXPECTED_ERROR: 'An unexpected error occurred.', + + VERSION_HISTORY: 'Version history', + VERSION_LOADING: 'Loading versions…', + VERSION_EMPTY: 'No saved versions yet.', + VERSION_RESTORE: 'Restore', + VERSION_RESTORING: 'Restoring…', + VERSION_RESTORE_CONFIRM: + 'Restore this version? Your current content will be saved as a new version first.', + + ORG_OUTLINE: 'Outline', }; export const EXPIRY_OPTIONS = [ @@ -95,3 +107,11 @@ export function formatLockExpiry(iso) { return null; } } + +export function formatVersionDate(iso) { + try { + return new Date(iso).toLocaleString(DATE_LOCALE, DATE_OPTIONS); + } catch { + return iso; + } +} diff --git a/src/pages/SharedEditPage.jsx b/src/pages/SharedEditPage.jsx @@ -4,6 +4,7 @@ import EditorLayout from '../components/EditorLayout.jsx'; import ReadOnlyBanner from '../components/ReadOnlyBanner.jsx'; import SaveStatus from '../components/SaveStatus.jsx'; import StatusBadge from '../components/StatusBadge.jsx'; +import VersionHistory from '../components/VersionHistory.jsx'; import { useEditLock } from '../hooks/useEditLock.js'; import { useServerAutosave } from '../hooks/useServerAutosave.js'; import { @@ -12,6 +13,7 @@ import { friendlyErrorMessage, } from '../lib/api.js'; import { downloadDocument } from '../lib/download.js'; +import { parseOrgDocument } from '../lib/org/parseDocument.js'; import { STR } from '../lib/strings.js'; import LinkErrorPage from './LinkErrorPage.jsx'; @@ -25,6 +27,7 @@ export default function SharedEditPage() { const [loading, setLoading] = useState(true); const [lockLost, setLockLost] = useState(false); const editorRef = useRef(null); + const titleEditedRef = useRef(false); const { lockState, acquire, release, hasLock, lockToken, clientId } = useEditLock(token, !!doc && !loadError); @@ -42,11 +45,23 @@ export default function SharedEditPage() { }); useEffect(() => { + if (mode !== 'org' || titleEditedRef.current) return; + const { title: orgTitle } = parseOrgDocument(content); + if ( + orgTitle && + (title === STR.UNTITLED_DOCUMENT || title.trim() === '') + ) { + setTitle(orgTitle); + } + }, [content, mode, title]); + + useEffect(() => { let cancelled = false; (async () => { setLoading(true); setLoadError(null); + titleEditedRef.current = false; try { const data = await fetchEditDocument(token); if (cancelled) return; @@ -101,6 +116,16 @@ export default function SharedEditPage() { downloadDocument(content, mode, title); }, [content, mode, title]); + const handleVersionRestored = useCallback( + (data) => { + setTitle(data.title); + setMode(data.mode); + setContent(data.content); + saveNow(); + }, + [saveNow], + ); + if (loading) { return ( <div className="app"> @@ -149,7 +174,10 @@ export default function SharedEditPage() { <input className="doc-title-input" value={title} - onChange={(e) => setTitle(e.target.value)} + onChange={(e) => { + titleEditedRef.current = true; + setTitle(e.target.value); + }} readOnly={readOnly} aria-label="Document title" /> @@ -170,6 +198,12 @@ export default function SharedEditPage() { <button type="button" className="btn" onClick={handleSaveServer}> {STR.SAVE_TO_SERVER} </button> + <VersionHistory + editToken={token} + clientId={clientId} + lockToken={lockToken} + onRestored={handleVersionRestored} + /> <button type="button" className="btn btn-ghost" onClick={handleRelease}> {STR.RELEASE_EDIT_LOCK} </button> diff --git a/src/styles.css b/src/styles.css @@ -484,6 +484,121 @@ body { color: #3d5c4a; } +.app-layout--with-outline { + grid-template-columns: 10.5rem 1fr auto 1fr; +} + +.org-outline { + display: flex; + flex-direction: column; + min-height: 0; + padding: 0.35rem 0.5rem 0.75rem; + border-right: 1px solid var(--border-soft); + background: rgba(255, 255, 255, 0.35); + overflow-y: auto; +} + +.org-outline__title { + font-family: var(--font-mono); + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--text-muted); + padding: 0.5rem 0.35rem 0.4rem; +} + +.org-outline__list { + margin: 0; + padding: 0; + list-style: none; +} + +.org-outline__item { + margin: 0.15rem 0; +} + +.org-outline__link { + width: 100%; + padding: 0.2rem 0.35rem; + border: none; + border-radius: 4px; + background: transparent; + font-family: var(--font-mono); + font-size: 0.72rem; + line-height: 1.35; + text-align: left; + color: var(--text-soft); + cursor: pointer; +} + +.org-outline__link:hover { + background: rgba(122, 155, 184, 0.12); + color: var(--text-heading); +} + +.org-doc-title { + margin: 0; + padding: 0 0.75rem 0.35rem; + font-family: var(--font-serif); + font-size: 1.05rem; + font-weight: 600; + color: var(--text-heading); +} + +.panel-editor .org-editor, +.panel-editor .cm-host { + flex: 1; + min-height: 280px; + overflow: hidden; +} + +.panel-editor .editor--loading { + display: flex; + align-items: center; + justify-content: center; + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 0.8rem; +} + +.preview-paper .org-table { + width: 100%; + border-collapse: collapse; + margin: 1rem 0; + font-size: 0.95rem; +} + +.preview-paper .org-table td, +.preview-paper .org-table th { + border: 1px solid var(--border-soft); + padding: 0.4rem 0.55rem; +} + +.preview-paper .org-table tbody tr:nth-child(even) { + background: rgba(122, 155, 184, 0.06); +} + +.preview-paper .org-heading--1 { + margin-top: 0; +} + +.preview-paper .org-quote { + margin: 1rem 0; + padding: 0.65rem 0.9rem; + border-left: 3px solid rgba(122, 155, 184, 0.35); + background: rgba(232, 240, 247, 0.45); +} + +.preview-paper .org-src { + margin: 1rem 0; + padding: 0.75rem; + border-radius: var(--radius-btn); + background: rgba(42, 51, 64, 0.04); + overflow-x: auto; + font-family: var(--font-mono); + font-size: 0.82rem; +} + .app-footer { display: flex; flex-direction: column; @@ -707,6 +822,58 @@ body { color: var(--accent-hover); } +.version-panel { + width: min(100%, 32rem); +} + +.version-panel__status { + margin: 0 0 0.75rem; + font-size: 0.85rem; + color: var(--text-muted); +} + +.version-list { + margin: 0; + padding: 0; + list-style: none; +} + +.version-list__item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.5rem 0; + border-bottom: 1px solid var(--border-soft); +} + +.version-list__item:last-child { + border-bottom: none; +} + +.version-list__date { + font-size: 0.82rem; + color: var(--text-soft); +} + +.version-list__restore { + flex-shrink: 0; + font-size: 0.78rem; +} + +.mode-switch__experimental { + margin-left: 0.35rem; + padding: 0.1rem 0.35rem; + border-radius: 4px; + font-size: 0.62rem; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; + vertical-align: middle; + background: rgba(122, 155, 184, 0.15); + color: var(--accent-hover); +} + .doc-title-input { flex: 1; min-width: 8rem; diff --git a/vite.config.js b/vite.config.js @@ -24,6 +24,19 @@ export default defineConfig(({ mode }) => { if (id.includes('node_modules/marked') || id.includes('node_modules/dompurify')) { return 'vendor-markdown'; } + if ( + id.includes('node_modules/orga') || + id.includes('node_modules/@orgajs') || + id.includes('node_modules/@codemirror') || + id.includes('node_modules/@lezer') || + id.includes('node_modules/unified') || + id.includes('node_modules/rehype') || + id.includes('node_modules/hast') || + id.includes('node_modules/vfile') || + id.includes('node_modules/oast-to-hast') + ) { + return 'vendor-org'; + } }, }, },