commit 65993a713586363acceab129af67161a5fbe71cc
parent 24ce9e9ef60be3233adb8eaa03f5a936c1ef4acd
Author: Pablo Murad <pblmrd@gmail.com>
Date: Fri, 5 Jun 2026 22:23:29 -0300
updates
Diffstat:
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, '&')
- .replace(/</g, '<')
- .replace(/>/g, '>')
- .replace(/"/g, '"');
-}
-
-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';
+ }
},
},
},