snow-editor

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

commit da44ba535c2043b2be1afcd3798276c63ca7e677
Author: Pablo Murad <pblmrd@gmail.com>
Date:   Wed,  3 Jun 2026 09:56:46 -0300

first commit

Diffstat:
A.dockerignore | 9+++++++++
A.env.example | 3+++
A.gitignore | 39+++++++++++++++++++++++++++++++++++++++
ADockerfile | 13+++++++++++++
AREADME.md | 173+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adocker-compose.yml | 6++++++
Aindex.html | 24++++++++++++++++++++++++
Anginx.conf | 20++++++++++++++++++++
Apackage-lock.json | 1848+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackage.json | 21+++++++++++++++++++++
Apublic/favicon.png | 0
Apublic/favicon.svg | 4++++
Asrc/App.jsx | 297+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/editorConstants.js | 126+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/parseOrgMode.js | 216+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/previewHtml.js | 38++++++++++++++++++++++++++++++++++++++
Asrc/main.jsx | 10++++++++++
Asrc/styles.css | 522+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Avite.config.js | 34++++++++++++++++++++++++++++++++++
Avite.preview.config.js | 9+++++++++
Avite.shared.js | 24++++++++++++++++++++++++
21 files changed, 3436 insertions(+), 0 deletions(-)

diff --git a/.dockerignore b/.dockerignore @@ -0,0 +1,9 @@ +node_modules +dist +.git +.gitignore +*.md +.cursor +.env +.env.* +npm-debug.log* diff --git a/.env.example b/.env.example @@ -0,0 +1,3 @@ +# Comma-separated hostnames allowed by Vite (dev + preview). +# Use * to allow any host (not recommended in production). +ALLOWED_HOSTS=snow.pablomurad.com,localhost,127.0.0.1 diff --git a/.gitignore b/.gitignore @@ -0,0 +1,39 @@ +# Dependencies +node_modules/ + +# Build +dist/ + +# Logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# Environment +.env +.env.* +!.env.example + +# Editor / OS +.DS_Store +Thumbs.db +*.swp +*.swo +*~ + +# IDE +.idea/ +.vscode/* +!.vscode/extensions.json +!.vscode/settings.json +*.code-workspace + +# Vite +*.local + +# Test / coverage +coverage/ + +# Docker (optional local overrides) +docker-compose.override.yml diff --git a/Dockerfile b/Dockerfile @@ -0,0 +1,13 @@ +# Build +FROM node:20-alpine AS build +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci +COPY . . +RUN npm run build + +# Production (static) +FROM nginx:1.27-alpine +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist /usr/share/nginx/html +EXPOSE 41737 diff --git a/README.md b/README.md @@ -0,0 +1,173 @@ +# Snow Editor — Markdown Cozy + +**Version:** 0.0.1 +**Creator:** Pablo Murad — [pablomurad@pm.me](mailto:pablomurad@pm.me) + +A cozy online editor inspired by snow and calm reading. Write in **Markdown** or experimental **Org-mode** in one panel and see the live preview in the other. No backend, no login — everything runs in the browser. + +## Features + +- Real-time editor with side-by-side preview +- **Markdown** (full) and **Org-mode** (experimental, basic parser) +- Mode switch saved in `localStorage` +- Separate drafts per mode (autosave, debounced) +- **Clear** — start a blank document (instant when still on the default starter text) +- Save as `.md` or `.org` +- Import `.md`, `.markdown`, `.org`, `.txt` +- Long-document friendly: deferred preview, debounced storage, lazy `marked` load +- Production Docker image: nginx serving static build (compact) +- Word and character counters in both modes +- Preview HTML sanitized with [DOMPurify](https://github.com/cure53/DOMPurify) + +## Editing modes + +Use the **Markdown** / **Org-mode** switch below the subtitle. + +| Mode | Preview | Save | +|------|---------|------| +| Markdown | `marked` | `document.md` | +| Org-mode | Custom parser (`src/lib/parseOrgMode.js`) | `document.org` | + +### Browser storage keys + +| Key | Purpose | +|-----|---------| +| `editor_mode` | Last selected mode | +| `snow_editor_markdown_content` | Markdown draft | +| `snow_editor_org_content` | Org-mode draft | + +Legacy key `cozy-markdown-editor-content` is migrated once into `snow_editor_markdown_content`. + +### Org-mode (partial support) + +Supported in the experimental parser: + +- Headings `*` … `****` with optional **TODO** / **DONE** badges +- Simple lists (`-`, `+`) +- Bold `*text*`, italic `/text/`, inline code `~code~` and `=code=` +- Links `[[url][label]]` and `[[url]]` +- Blocks `#+BEGIN_SRC` / `#+END_SRC` and `#+BEGIN_QUOTE` / `#+END_QUOTE` + +Not supported yet: agenda, tables, properties, drawers, advanced blocks, PDF export. + +## Stack + +- React + Vite +- [marked](https://marked.js.org/) — Markdown to HTML +- Custom Org parser — lightweight JavaScript +- [DOMPurify](https://github.com/cure53/DOMPurify) — HTML sanitization +- Plain CSS (cozy / snow theme) + +## Prerequisites + +- **Local:** Node.js 18 or newer +- **Docker (optional):** Docker and Docker Compose + +## Environment + +```bash +cp .env.example .env +``` + +| Variable | Description | +|----------|-------------| +| `ALLOWED_HOSTS` | Hostnames for Vite dev/preview (e.g. `snow.pablomurad.com,localhost`) | + +## Local installation + +```bash +npm install +cp .env.example .env +``` + +## Run without Docker + +```bash +npm run dev +``` + +Open: **http://localhost:41737** + +```bash +npm run build +npm run preview +``` + +## Pre-deploy checklist (production) + +1. Commit all sources under `src/` (including `src/lib/`). +2. Local smoke test: `npm run build` then `npm run preview`. +3. For **local dev** behind a custom host: `cp .env.example .env` and set `ALLOWED_HOSTS`. +4. Deploy: `docker compose up -d --build` (nginx serves static `dist/` on port **41737**). +5. Verify **http://localhost:41737** (or your public URL) — editor, both modes, save/import, Clear. + +Production Docker uses **nginx:alpine** (lightweight). It does not need `ALLOWED_HOSTS` — any hostname works for static files. + +## Run with Docker + +```bash +docker compose up -d --build +``` + +Open: **http://localhost:41737** + +Port **41737** (`41737:41737` in `docker-compose.yml`). + +## Editor actions + +### Clear + +1. Click **Clear** +2. If the editor still shows the default starter for the current mode, it clears immediately +3. Otherwise confirm, then you get a **blank** document (persisted on reload) + +Default starter text appears only on first visit per mode (or after you delete storage). To get the sample again, clear browser storage for the site or paste the sample manually. + +### Save + +- **Markdown:** **Save .md** → `document.md` +- **Org-mode:** **Save .org** → `document.org` + +### Import + +| Extension | Mode after import | +|-----------|-------------------| +| `.md`, `.markdown` | Markdown | +| `.org` | Org-mode | +| `.txt` | Keeps current mode | + +## Long documents + +- Editor and preview scroll independently +- Preview uses `useDeferredValue` +- Autosave debounced (500ms) +- If storage quota is exceeded, a footer warning appears — use **Save .md** / **Save .org** + +## Security + +Content is converted to HTML (`marked` or `parseOrgMode`), then `DOMPurify.sanitize()` before preview. User text is escaped in the Org parser before tags are applied. + +## Project structure + +``` +├── Dockerfile +├── nginx.conf +├── vite.config.js +├── vite.preview.config.js +├── vite.shared.js +├── public/ +│ ├── favicon.png +│ └── favicon.svg +└── src/ + ├── main.jsx + ├── App.jsx + ├── styles.css + └── lib/ + ├── editorConstants.js + ├── parseOrgMode.js + └── previewHtml.js +``` + +## License + +Free to use for personal projects and learning. diff --git a/docker-compose.yml b/docker-compose.yml @@ -0,0 +1,6 @@ +services: + editor: + build: . + ports: + - "41737:41737" + restart: unless-stopped diff --git a/index.html b/index.html @@ -0,0 +1,24 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <meta name="description" content="Cozy Markdown and Org-mode editor with live preview" /> + <meta name="author" content="Pablo Murad" /> + <meta name="theme-color" content="#fefefe" /> + <title>Snow Editor</title> + <link rel="icon" type="image/png" href="/favicon.png" sizes="72x72" /> + <link rel="icon" type="image/svg+xml" href="/favicon.svg" /> + <link rel="apple-touch-icon" href="/favicon.png" /> + <link rel="preconnect" href="https://fonts.googleapis.com" /> + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> + <link + href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,600;1,400&family=JetBrains+Mono:wght@400;500&display=swap" + rel="stylesheet" + /> + </head> + <body> + <div id="root"></div> + <script type="module" src="/src/main.jsx"></script> + </body> +</html> diff --git a/nginx.conf b/nginx.conf @@ -0,0 +1,20 @@ +server { + listen 41737; + server_name _; + root /usr/share/nginx/html; + index index.html; + + gzip on; + gzip_types text/plain text/css application/javascript application/json image/svg+xml; + gzip_min_length 256; + + location /assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + try_files $uri =404; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/package-lock.json b/package-lock.json @@ -0,0 +1,1848 @@ +{ + "name": "editor-markdown-cozy", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "editor-markdown-cozy", + "version": "1.0.0", + "dependencies": { + "dompurify": "^3.2.4", + "marked": "^15.0.7", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.4", + "vite": "^6.2.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.0.tgz", + "integrity": "sha512-dnxczajOqt0gesZlN5pGQ1s1imQVrsmCw5G2Ci4oM+0WvNz3pyRnlWrT7McoZIb8VlFwCawdmbWRmxRn7HI+VQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.0.tgz", + "integrity": "sha512-Bp3JpGP00Vu3f238ivRrjf7z3xSzVPXqCmaJYA9t2c+c8vKYvOzmXF7LkkeUalTEGd6cZcSWe+PFIP3Vy48fRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.0.tgz", + "integrity": "sha512-zaYIpr670mUmmZ1tVzUFplbQbG7h3Gugx3L5FoqhsC2m/YnLlR1a7zVLmXNPy+iY1tFPEbNG+HHBXZGyId0G5w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.0.tgz", + "integrity": "sha512-+P49fvkv2dSoeevUW+lgZ/I2JHSsJCK1Lyjj7Cu6E4UHG4tS9XIefzIjo5qhgELjAclnen1rLzK2PMKJdo+Dyg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.0.tgz", + "integrity": "sha512-l3FAAOyKJXH2ea6KNFN+MMgC/rnE94YGLXs2ehYqDcCoHt1DpvgWX75BhUJxN38XojP7Ul+4H8PRn7EdyqSDrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.0.tgz", + "integrity": "sha512-VokPN3TSctKj65cyCNPaUh4vMFA8awxOot/0sp+4J7ZlNRKQEhXhawqPwajoi8H5ZFt61i0ugZJuTKXBjGJ17Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.0.tgz", + "integrity": "sha512-DxH0P3wxm+Yzs/p3zrk9dw1rURu8p0Nv5+MRK/L7OtnLNg5rLZraSBFZ8iUXOd9f2BlhJyEpIZUH/emjq4UJ4g==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.0.tgz", + "integrity": "sha512-T6ZvMNe84kAz6TBWHC7hGAoEtzP1LWYw/AqayGWEF6uISt3Abk/st06LqRD9THd7Xz3NxzurUpzAuEAUbZf+nw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.0.tgz", + "integrity": "sha512-q/4hzvQkDs8b4jIBab1pnLiiM0ayTZsN2amBFPDzuyZxjEd4wDwx0UJFYM3cOZzSf5Kw8fnWSprJzIBMkcR44Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.0.tgz", + "integrity": "sha512-vvYWX3akdEAY6km+9wAqFDnk6pQsbJKVnj7xawcvs/+fdlYBGp+U+Qq/lLfpIxYIZvZLHMAKD9HLdacSx/r3dw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.0.tgz", + "integrity": "sha512-DePa5cqOxDP/Zp0VOXpeWaGew5iIv5DXp9NYbzkX5PFQyWVX9184WCTh3hvr/7lhXo8ZVlbFLkz8+o/q1dU6gA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.0.tgz", + "integrity": "sha512-LV8aWMB8UChglMCEzs7RkN0GsH29RJaLLqwm9fCIjlqwxQTiWAqNcc7wjBkH31hV0PU/yVxGYvrYsgfea2qw6g==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.0.tgz", + "integrity": "sha512-QoNSnwQtaeNu5grdBbsL0tt1uyl5EnS8DA8Mr3nluMXbhdQNyhN+G4tBax7VCdxLKj8YJ0/4OO9Ho84jMnJtKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.0.tgz", + "integrity": "sha512-/zZp5MKapIIApE8trN8qLGNSiRN9TUoaUZ1cmVu4XnVdd5LQLOXTtyi+vtfUbNnT3iyjzpPqYeKXmvJ+gJGYWw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.0.tgz", + "integrity": "sha512-RbrzcD3aJ1k3UbtMRRBNwojdVVyXjuVAFTfn/xPa6EEl6GE9Sm/akPgFTb9aAC9pMKGJ6CtWxaGrqWcabH+ySg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.0.tgz", + "integrity": "sha512-ZF+onDsBso8PJf1XaG9lB+O9RnBpKGnY6OrzC4CSHrtC1jb6jWLTKK4bRqdoCXHd22gyr2hiYmEAm8Wns/BOCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.0.tgz", + "integrity": "sha512-Atk0aSIk5Zx2Wuh9dgRQgLP0Koc8hOeYpbWryMXyk8G8/HmPkwPPkMqIIDhrXHHYqfUzSJA/I7IWSBv8xSmRBA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.0.tgz", + "integrity": "sha512-0uMOcf3eZ5K+K4cYHkdxShFMPlPXCOdfDFEFn9dNYAEEd2cVvmOfH7zFgRVoDgmtQ1m9k5q7qfrHzyMAubKYUA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.0.tgz", + "integrity": "sha512-mvFtE4A/t/7hRJ7X8Ozmu8FsIkAUat2nzl12pgU337BRmq87AQUJztwHz2Zv5/tjo9/C95E66CK03SI/ToEDJw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.0.tgz", + "integrity": "sha512-z9b9+aTxvt8n2rNltMPvyaUfB8NJ+CVyOrGK/MdIKHx7B+lXmZpm/XbRsU7Rpf3fRqJ2uS6mBJiJveCtq8LHDg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.0.tgz", + "integrity": "sha512-jXaXFqKMehsOc+g8R6oo33RRC6w07G9jDBxAE5eAKX7mOcCbZloYIPNhfG9Wl+P9O9IWHFO4OJgPi1Ml2qkt7w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.0.tgz", + "integrity": "sha512-OXNWVFocS2IA4+QplhTZZ2a+8hPZR7T8KuozsNmJKK8y7cp83StHvGksfHzPG3wczWTczyWHVQuqeiTUbjiyBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.0.tgz", + "integrity": "sha512-AlAbNtBO637LxSldqV43z0FfXoGfl2TW1DgAg/bs7aQswFbDewz2SJm3BUhiGfbOVtW571xbc9p+REdxhyN/Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.0.tgz", + "integrity": "sha512-QRSrQXyJ1M4tjNXdR0/G/IgV6lzfQQJYBjlWIEYkY2Xs86DRl/iEpQ4blMDjJxSl7n19eDKKXMg0AmuBVYy8pQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.0.tgz", + "integrity": "sha512-tkuFxhvKO/HlGd0VsINF6vHSYH8AF8W0TcNxKDK6JZmrehngFj78pToc8iemtnvwilDjs2G/qSzYFhe9U8q+fw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.33", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz", + "integrity": "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "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": { + "version": "1.5.366", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.366.tgz", + "integrity": "sha512-OlRuhb688YTCzzU3gXPLn6nGyd+F+53INE1qaKKlu6kETErE8FYsyDh0XqXEU+uBRn0MpCzz2vfNwORhkap8qg==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/marked": { + "version": "15.0.12", + "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", + "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.47", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", + "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "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", + "integrity": "sha512-T9mWdbWfQtp0B5lv/HX+wrhYsmXRlcWnXXmJbXqKJhlRaoS6KMhq0gpyzW4UJfclcxrEdLnTgjT2NjruLONu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.61.0", + "@rollup/rollup-android-arm64": "4.61.0", + "@rollup/rollup-darwin-arm64": "4.61.0", + "@rollup/rollup-darwin-x64": "4.61.0", + "@rollup/rollup-freebsd-arm64": "4.61.0", + "@rollup/rollup-freebsd-x64": "4.61.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.61.0", + "@rollup/rollup-linux-arm-musleabihf": "4.61.0", + "@rollup/rollup-linux-arm64-gnu": "4.61.0", + "@rollup/rollup-linux-arm64-musl": "4.61.0", + "@rollup/rollup-linux-loong64-gnu": "4.61.0", + "@rollup/rollup-linux-loong64-musl": "4.61.0", + "@rollup/rollup-linux-ppc64-gnu": "4.61.0", + "@rollup/rollup-linux-ppc64-musl": "4.61.0", + "@rollup/rollup-linux-riscv64-gnu": "4.61.0", + "@rollup/rollup-linux-riscv64-musl": "4.61.0", + "@rollup/rollup-linux-s390x-gnu": "4.61.0", + "@rollup/rollup-linux-x64-gnu": "4.61.0", + "@rollup/rollup-linux-x64-musl": "4.61.0", + "@rollup/rollup-openbsd-x64": "4.61.0", + "@rollup/rollup-openharmony-arm64": "4.61.0", + "@rollup/rollup-win32-arm64-msvc": "4.61.0", + "@rollup/rollup-win32-ia32-msvc": "4.61.0", + "@rollup/rollup-win32-x64-gnu": "4.61.0", + "@rollup/rollup-win32-x64-msvc": "4.61.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "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" + } + } +} diff --git a/package.json b/package.json @@ -0,0 +1,21 @@ +{ + "name": "snow-editor", + "private": true, + "version": "0.0.1", + "type": "module", + "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" + }, + "dependencies": { + "dompurify": "^3.2.4", + "marked": "^15.0.7", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.4", + "vite": "^6.2.0" + } +} diff --git a/public/favicon.png b/public/favicon.png Binary files differ. diff --git a/public/favicon.svg b/public/favicon.svg @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="UTF-8"?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 72 72"> + <image width="72" height="72" xlink:href="/favicon.png" /> +</svg> diff --git a/src/App.jsx b/src/App.jsx @@ -0,0 +1,297 @@ +import { + useCallback, + useDeferredValue, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { + MODES, + isDefaultContent, + loadContent, + loadMode, + persistContent, + persistMode, +} from './lib/editorConstants.js'; +import { buildPreviewHtml, ensureMarkedLoaded } from './lib/previewHtml.js'; + +const STORAGE_DEBOUNCE_MS = 500; +const APP_VERSION = '0.0.1'; +const CREATOR_NAME = 'Pablo Murad'; +const CREATOR_EMAIL = 'pablomurad@pm.me'; + +function countWords(text) { + const trimmed = text.trim(); + if (!trimmed) return 0; + return trimmed.split(/\s+/).filter(Boolean).length; +} + +function getFileExtension(filename) { + const parts = filename.toLowerCase().split('.'); + if (parts.length < 2) return 'txt'; + return parts[parts.length - 1]; +} + +function useDividerOrientation() { + const [orientation, setOrientation] = useState('vertical'); + + useEffect(() => { + const media = window.matchMedia('(max-width: 768px)'); + const update = () => setOrientation(media.matches ? 'horizontal' : 'vertical'); + update(); + media.addEventListener('change', update); + return () => media.removeEventListener('change', update); + }, []); + + return orientation; +} + +function App() { + const initialMode = loadMode(); + const [mode, setMode] = useState(initialMode); + const [content, setContent] = useState(() => loadContent(initialMode)); + const [storageWarning, setStorageWarning] = useState(false); + const [markedReady, setMarkedReady] = useState(false); + const fileInputRef = useRef(null); + const editorRef = useRef(null); + const dividerOrientation = useDividerOrientation(); + + const deferredContent = useDeferredValue(content); + const previewIsStale = content !== deferredContent; + + useEffect(() => { + if (mode !== MODES.MARKDOWN) return; + let cancelled = false; + ensureMarkedLoaded().then(() => { + if (!cancelled) setMarkedReady(true); + }); + return () => { + cancelled = true; + }; + }, [mode]); + + useEffect(() => { + const timer = window.setTimeout(() => { + const saved = persistContent(mode, content); + setStorageWarning(!saved); + }, STORAGE_DEBOUNCE_MS); + + return () => window.clearTimeout(timer); + }, [content, mode]); + + const html = useMemo(() => { + return buildPreviewHtml(mode, deferredContent); + }, [mode, deferredContent, markedReady]); + + const wordCount = useMemo(() => countWords(content), [content]); + const charCount = content.length; + + const saveLabel = mode === MODES.ORG ? 'Save .org' : 'Save .md'; + + const prefetchMarkdown = useCallback(() => { + ensureMarkedLoaded().then(() => setMarkedReady(true)); + }, []); + + const handleModeChange = useCallback( + (nextMode) => { + if (nextMode === mode) return; + + persistContent(mode, content); + persistMode(nextMode); + setMode(nextMode); + setContent(loadContent(nextMode)); + setStorageWarning(false); + if (nextMode === MODES.MARKDOWN) { + prefetchMarkdown(); + } + editorRef.current?.focus(); + }, + [mode, content, prefetchMarkdown], + ); + + const handleSave = useCallback(() => { + const isOrg = mode === MODES.ORG; + const blob = new Blob([content], { + type: isOrg ? 'text/plain;charset=utf-8' : 'text/markdown;charset=utf-8', + }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = isOrg ? 'document.org' : 'document.md'; + link.click(); + URL.revokeObjectURL(url); + }, [content, mode]); + + const handleImport = useCallback( + (event) => { + const file = event.target.files?.[0]; + if (!file) return; + + const ext = getFileExtension(file.name); + let targetMode = mode; + if (ext === 'org') targetMode = MODES.ORG; + else if (ext === 'md' || ext === 'markdown') targetMode = MODES.MARKDOWN; + + const reader = new FileReader(); + reader.onload = () => { + const result = reader.result; + if (typeof result !== 'string') return; + + if (targetMode !== mode) { + persistContent(mode, content); + persistMode(targetMode); + setMode(targetMode); + if (targetMode === MODES.MARKDOWN) { + prefetchMarkdown(); + } + } + + setContent(result); + persistContent(targetMode, result); + setStorageWarning(false); + }; + reader.readAsText(file); + event.target.value = ''; + }, + [mode, content, prefetchMarkdown], + ); + + const handleClear = useCallback(() => { + if (!isDefaultContent(mode, content)) { + const confirmed = window.confirm( + 'Clear the editor and start a blank document? Current content will be replaced.', + ); + if (!confirmed) return; + } + + setContent(''); + persistContent(mode, ''); + setStorageWarning(false); + editorRef.current?.focus(); + }, [mode, content]); + + const editorAriaLabel = + mode === MODES.ORG ? 'Org-mode editing area' : 'Markdown editing area'; + + return ( + <div className="app"> + <header className="app-header"> + <div className="app-header-text"> + <h1 className="app-title">Snow Editor</h1> + <p className="app-subtitle">Write calmly. See the result live.</p> + <div className="mode-switch" role="group" aria-label="Editor mode"> + <button + type="button" + className={`mode-switch__btn${mode === MODES.MARKDOWN ? ' is-active' : ''}`} + aria-pressed={mode === MODES.MARKDOWN} + onClick={() => handleModeChange(MODES.MARKDOWN)} + onMouseEnter={prefetchMarkdown} + onFocus={prefetchMarkdown} + > + Markdown + </button> + <button + type="button" + className={`mode-switch__btn${mode === MODES.ORG ? ' is-active' : ''}`} + aria-pressed={mode === MODES.ORG} + onClick={() => handleModeChange(MODES.ORG)} + > + Org-mode + </button> + </div> + </div> + <div className="toolbar" role="toolbar" aria-label="Editor actions"> + <button + type="button" + className="btn" + onClick={handleSave} + aria-label={mode === MODES.ORG ? 'Save as Org-mode file' : 'Save as Markdown file'} + > + {saveLabel} + </button> + <button + type="button" + className="btn" + onClick={() => fileInputRef.current?.click()} + aria-label="Import file" + > + Import + </button> + <input + ref={fileInputRef} + id="file-import" + type="file" + accept=".md,.markdown,.org,.txt,text/markdown,text/plain" + className="file-input-hidden" + onChange={handleImport} + aria-label="Choose file to import" + /> + <button + type="button" + className="btn btn-ghost" + onClick={handleClear} + aria-label="Clear editor and start blank document" + > + Clear + </button> + </div> + </header> + + <main className="app-layout"> + <section + className="panel panel-editor" + aria-label={mode === MODES.ORG ? 'Org-mode editor' : 'Markdown editor'} + > + <div className="panel-label">Write</div> + <textarea + ref={editorRef} + className="editor" + value={content} + onChange={(e) => setContent(e.target.value)} + spellCheck="true" + aria-label={editorAriaLabel} + placeholder="Start writing..." + /> + </section> + + <div + className="layout-divider" + role="separator" + aria-orientation={dividerOrientation} + /> + + <section className="panel panel-preview" aria-label="Document preview"> + <div className="panel-label">Read</div> + <div + className={`preview-paper${previewIsStale ? ' preview-updating' : ''}`} + dangerouslySetInnerHTML={{ __html: html }} + /> + </section> + </main> + + <footer className="app-footer"> + <div className="app-footer-stats"> + <span> + {wordCount} {wordCount === 1 ? 'word' : 'words'} + </span> + <span className="footer-separator">·</span> + <span> + {charCount} {charCount === 1 ? 'character' : 'characters'} + </span> + </div> + {storageWarning && ( + <p className="app-storage-warning" role="status"> + Draft too large to save in browser storage; export with {saveLabel}. + </p> + )} + <p className="app-meta"> + Snow Editor v{APP_VERSION} · {CREATOR_NAME} ·{' '} + <a href={`mailto:${CREATOR_EMAIL}`}>{CREATOR_EMAIL}</a> + </p> + </footer> + </div> + ); +} + +export default App; diff --git a/src/lib/editorConstants.js b/src/lib/editorConstants.js @@ -0,0 +1,126 @@ +export const STORAGE_MODE = 'editor_mode'; +export const STORAGE_MARKDOWN = 'snow_editor_markdown_content'; +export const STORAGE_ORG = 'snow_editor_org_content'; +export const STORAGE_LEGACY_MARKDOWN = 'cozy-markdown-editor-content'; + +export const MODES = { + MARKDOWN: 'markdown', + ORG: 'org', +}; + +const DEFAULT_MARKDOWN = `# Snow Journal + +Welcome to your quiet writing nook. + +Here you can write in **Markdown** and watch the result appear gently beside you. + +## Ideas + +- Write notes +- Create documents +- Draft texts +- Organize thoughts + +> A clean space to think calmly. + +\`\`\`js +console.log("writing in peace"); +\`\`\` +`; + +const DEFAULT_ORG = `* Diário de Neve + +Bem-vindo ao seu pequeno canto de escrita em Org-mode. + +** TODO Ideias +- Escrever notas +- Criar documentos +- Organizar pensamentos + +** DONE Primeiro rascunho + +#+BEGIN_QUOTE +Um espaço limpo para pensar com calma. +#+END_QUOTE + +#+BEGIN_SRC js +console.log("escrevendo em paz"); +#+END_SRC + +** Link de exemplo + +[[https://orgmode.org][Site oficial do Org-mode]] +`; + +function getStorageKey(mode) { + return mode === MODES.ORG ? STORAGE_ORG : STORAGE_MARKDOWN; +} + +function getDefaultContent(mode) { + return mode === MODES.ORG ? DEFAULT_ORG : DEFAULT_MARKDOWN; +} + +function migrateLegacyStorage() { + try { + const legacy = localStorage.getItem(STORAGE_LEGACY_MARKDOWN); + if (legacy !== null && localStorage.getItem(STORAGE_MARKDOWN) === null) { + localStorage.setItem(STORAGE_MARKDOWN, legacy); + } + } catch { + /* ignore */ + } +} + +export function initStorage() { + migrateLegacyStorage(); +} + +export function loadMode() { + initStorage(); + try { + const saved = localStorage.getItem(STORAGE_MODE); + if (saved === MODES.ORG || saved === MODES.MARKDOWN) { + return saved; + } + } catch { + /* ignore */ + } + return MODES.MARKDOWN; +} + +export function loadContent(mode) { + try { + const saved = localStorage.getItem(getStorageKey(mode)); + if (saved === null) { + return getDefaultContent(mode); + } + return saved; + } catch { + /* ignore */ + } + return getDefaultContent(mode); +} + +export function persistContent(mode, content) { + try { + localStorage.setItem(getStorageKey(mode), content); + return true; + } catch (error) { + if (error instanceof DOMException && error.name === 'QuotaExceededError') { + return false; + } + return false; + } +} + +export function persistMode(mode) { + try { + localStorage.setItem(STORAGE_MODE, mode); + } catch { + /* ignore */ + } +} + +export function isDefaultContent(mode, text) { + return text === getDefaultContent(mode); +} diff --git a/src/lib/parseOrgMode.js b/src/lib/parseOrgMode.js @@ -0,0 +1,216 @@ +function escapeHtml(text) { + return text + .replace(/&/g, '&amp;') + .replace(/</g, '&lt;') + .replace(/>/g, '&gt;') + .replace(/"/g, '&quot;'); +} + +function sanitizeHref(url) { + const trimmed = url.trim(); + if (/^https?:\/\//i.test(trimmed)) { + return escapeHtml(trimmed); + } + return null; +} + +function sanitizeLanguage(language) { + const trimmed = language.trim(); + if (!trimmed) return 'text'; + if (/^[a-zA-Z0-9#+.-]+$/.test(trimmed)) { + return escapeHtml(trimmed); + } + return 'text'; +} + +function parseOrgInline(text) { + const placeholders = []; + let working = escapeHtml(text); + + const stash = (html) => { + const key = `\x00ORG${placeholders.length}PH\x00`; + placeholders.push(html); + return key; + }; + + working = working.replace( + /\[\[([^\]]+)\]\[([^\]]+)\]\]/g, + (_, url, label) => { + const href = sanitizeHref(url); + if (!href) return escapeHtml(`[[${url}][${label}]]`); + return stash( + `<a href="${href}" rel="noopener noreferrer">${escapeHtml(label)}</a>`, + ); + }, + ); + + working = working.replace(/\[\[([^\]]+)\]\]/g, (_, url) => { + const href = sanitizeHref(url); + if (!href) return escapeHtml(`[[${url}]]`); + return stash(`<a href="${href}" rel="noopener noreferrer">${href}</a>`); + }); + + working = working.replace(/~([^~]+)~/g, (_, code) => `<code>${code}</code>`); + working = working.replace(/=([^=]+)=/g, (_, code) => `<code>${code}</code>`); + working = working.replace(/\*([^*]+)\*/g, (_, bold) => `<strong>${bold}</strong>`); + working = working.replace(/\/([^/]+)\//g, (_, italic) => `<em>${italic}</em>`); + + placeholders.forEach((html, index) => { + working = working.replace(`\x00ORG${index}PH\x00`, html); + }); + + return working; +} + +function parseHeading(line) { + const match = line.match(/^(\*{1,4})\s+(?:(TODO|DONE)\s+)?(.+)$/); + if (!match) return null; + + const level = Math.min(match[1].length, 4); + const keyword = match[2]; + const title = match[3].trim(); + const tag = `h${level}`; + + let badge = ''; + if (keyword === 'TODO') { + badge = '<span class="org-badge org-todo">TODO</span> '; + } else if (keyword === 'DONE') { + badge = '<span class="org-badge org-done">DONE</span> '; + } + + return `<${tag}>${badge}${parseOrgInline(title)}</${tag}>`; +} + +function parseListBlock(lines) { + const items = lines + .filter((line) => /^[-+]\s+/.test(line)) + .map((line) => `<li>${parseOrgInline(line.replace(/^[-+]\s+/, ''))}</li>`); + + if (items.length === 0) return ''; + return `<ul>${items.join('')}</ul>`; +} + +function parseParagraphBlock(lines) { + const html = lines.map((line) => parseOrgInline(line)).join('<br>'); + return `<p>${html}</p>`; +} + +function extractSpecialBlocks(input) { + const lines = input.replace(/\r\n/g, '\n').split('\n'); + const blocks = []; + let i = 0; + + while (i < lines.length) { + const srcMatch = lines[i].match(/^#\+BEGIN_SRC\s*(\S*)?\s*$/i); + const quoteMatch = lines[i].match(/^#\+BEGIN_QUOTE\s*$/i); + + if (srcMatch) { + const language = sanitizeLanguage((srcMatch[1] || '').trim()); + const codeLines = []; + i += 1; + while (i < lines.length && !/^#\+END_SRC\s*$/i.test(lines[i])) { + codeLines.push(lines[i]); + i += 1; + } + if (i < lines.length) i += 1; + const langClass = ` class="language-${language}"`; + blocks.push({ + type: 'html', + html: `<pre><code${langClass}>${escapeHtml(codeLines.join('\n'))}</code></pre>`, + }); + continue; + } + + if (quoteMatch) { + const quoteLines = []; + i += 1; + while (i < lines.length && !/^#\+END_QUOTE\s*$/i.test(lines[i])) { + quoteLines.push(lines[i]); + i += 1; + } + if (i < lines.length) i += 1; + const inner = + quoteLines.length === 0 + ? '' + : quoteLines.map((l) => parseOrgInline(l)).join('<br>'); + blocks.push({ + type: 'html', + html: `<blockquote>${inner}</blockquote>`, + }); + continue; + } + + const textLines = []; + while ( + i < lines.length && + !/^#\+BEGIN_SRC/i.test(lines[i]) && + !/^#\+BEGIN_QUOTE/i.test(lines[i]) + ) { + textLines.push(lines[i]); + i += 1; + } + + if (textLines.length > 0) { + blocks.push({ type: 'text', lines: textLines }); + } + } + + return blocks; +} + +function parseTextBlock(lines) { + const chunks = []; + let current = []; + + const flush = () => { + if (current.length === 0) return; + chunks.push([...current]); + current = []; + }; + + for (const line of lines) { + if (line.trim() === '') { + flush(); + } else { + current.push(line); + } + } + flush(); + + return chunks + .map((chunk) => { + if (chunk.every((line) => /^[-+]\s+/.test(line))) { + return parseListBlock(chunk); + } + if (chunk.length === 1) { + const heading = parseHeading(chunk[0]); + if (heading) return heading; + if (/^[-+]\s+/.test(chunk[0])) return parseListBlock(chunk); + } + const heading = parseHeading(chunk[0]); + if (heading) { + return heading + (chunk.length > 1 ? parseParagraphBlock(chunk.slice(1)) : ''); + } + return parseParagraphBlock(chunk); + }) + .join(''); +} + +export function parseOrgMode(input) { + if (!input || !input.trim()) { + return ''; + } + + const blocks = extractSpecialBlocks(input); + const htmlParts = []; + + for (const block of blocks) { + if (block.type === 'html') { + htmlParts.push(block.html); + } else { + htmlParts.push(parseTextBlock(block.lines)); + } + } + + return htmlParts.join(''); +} diff --git a/src/lib/previewHtml.js b/src/lib/previewHtml.js @@ -0,0 +1,38 @@ +import DOMPurify from 'dompurify'; +import { MODES } from './editorConstants.js'; +import { parseOrgMode } from './parseOrgMode.js'; + +const SANITIZE_OPTIONS = { ADD_ATTR: ['class', 'rel'] }; + +let markedParser = null; +let markedLoadPromise = null; + +export function ensureMarkedLoaded() { + if (markedParser) { + return Promise.resolve(markedParser); + } + if (!markedLoadPromise) { + markedLoadPromise = import('marked').then((module) => { + markedParser = module.marked; + return markedParser; + }); + } + return markedLoadPromise; +} + +export function buildPreviewHtml(mode, content) { + if (!content || !content.trim()) { + return ''; + } + + if (mode === MODES.ORG) { + return DOMPurify.sanitize(parseOrgMode(content), SANITIZE_OPTIONS); + } + + if (!markedParser) { + return ''; + } + + const rawHtml = markedParser.parse(content, { gfm: true, breaks: true }); + return DOMPurify.sanitize(rawHtml, SANITIZE_OPTIONS); +} diff --git a/src/main.jsx b/src/main.jsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import App from './App.jsx'; +import './styles.css'; + +createRoot(document.getElementById('root')).render( + <StrictMode> + <App /> + </StrictMode>, +); diff --git a/src/styles.css b/src/styles.css @@ -0,0 +1,522 @@ +:root { + --snow: #fefefe; + --ice-blue: #f9fbfd; + --warm-beige: #fcfcfb; + --canvas: #fefefe; + --paper: #faf9f7; + --text-soft: #3a424c; + --text-muted: #6b7580; + --text-heading: #2a3340; + --accent: #7a9bb8; + --accent-hover: #5f82a0; + --border-soft: rgba(122, 155, 184, 0.2); + --shadow-soft: 0 4px 24px rgba(42, 51, 64, 0.06); + --radius-btn: 10px; + --surface-glass: rgba(255, 255, 255, 0.65); + --font-mono: 'JetBrains Mono', Menlo, Consolas, monospace; + --font-serif: 'Cormorant Garamond', Georgia, 'Times New Roman', serif; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html, +body { + margin: 0; + height: 100%; +} + +body { + font-family: var(--font-mono); + font-size: 15px; + color: var(--text-soft); + background: linear-gradient( + 180deg, + var(--canvas) 0%, + var(--snow) 40%, + var(--ice-blue) 78%, + var(--warm-beige) 100% + ); + background-attachment: fixed; + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +#root { + min-height: 100%; +} + +.app { + display: flex; + flex-direction: column; + min-height: 100vh; + max-width: none; + width: 100%; + margin: 0 auto; + padding: 1.25rem 1.5rem 1rem; +} + +.app-header { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1.25rem; +} + +.app-title { + margin: 0; + font-family: var(--font-serif); + font-size: 2rem; + font-weight: 600; + color: var(--text-heading); + letter-spacing: 0.02em; +} + +.app-subtitle { + margin: 0.25rem 0 0; + font-family: var(--font-mono); + font-size: 0.85rem; + color: var(--text-muted); + font-weight: 400; +} + +.mode-switch { + display: inline-flex; + margin-top: 0.75rem; + padding: 0.2rem; + background: var(--surface-glass); + border: 1px solid var(--border-soft); + border-radius: 999px; + gap: 0.15rem; + backdrop-filter: blur(6px); +} + +.mode-switch__btn { + font-family: var(--font-mono); + font-size: 0.72rem; + padding: 0.4rem 0.85rem; + border: none; + border-radius: 999px; + background: transparent; + color: var(--text-muted); + cursor: pointer; + transition: + background 0.2s ease, + color 0.2s ease, + box-shadow 0.2s ease; +} + +.mode-switch__btn:hover { + color: var(--text-soft); +} + +.mode-switch__btn.is-active { + background: rgba(255, 255, 255, 0.92); + color: var(--text-heading); + box-shadow: 0 1px 4px rgba(42, 51, 64, 0.06); +} + +.toolbar { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.btn { + font-family: var(--font-mono); + font-size: 0.8rem; + padding: 0.5rem 1rem; + border: 1px solid var(--border-soft); + border-radius: var(--radius-btn); + background: var(--surface-glass); + color: var(--text-soft); + cursor: pointer; + transition: + background 0.2s ease, + border-color 0.2s ease, + box-shadow 0.2s ease, + transform 0.15s ease; + box-shadow: 0 1px 2px rgba(42, 51, 64, 0.03); + backdrop-filter: blur(6px); +} + +.btn:hover { + background: rgba(255, 255, 255, 0.88); + border-color: rgba(122, 155, 184, 0.35); + box-shadow: var(--shadow-soft); + transform: translateY(-1px); +} + +.btn:active { + transform: translateY(0); +} + +.btn-ghost:hover { + background: rgba(245, 240, 232, 0.9); +} + +.file-input-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.app-layout { + display: grid; + grid-template-columns: 1fr auto 1fr; + gap: 0; + flex: 1; + min-height: 0; +} + +.layout-divider { + position: relative; + align-self: stretch; + width: 2rem; + flex-shrink: 0; + pointer-events: none; +} + +.layout-divider::before { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: 50%; + width: 1px; + transform: translateX(-50%); + background: linear-gradient( + to bottom, + transparent 0%, + rgba(122, 155, 184, 0.12) 12%, + rgba(122, 155, 184, 0.32) 50%, + rgba(122, 155, 184, 0.12) 88%, + transparent 100% + ); +} + +.layout-divider::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 6px; + height: 6px; + transform: translate(-50%, -50%); + border-radius: 50%; + background: radial-gradient( + circle at 35% 35%, + #ffffff 0%, + rgba(232, 240, 247, 0.95) 45%, + rgba(122, 155, 184, 0.25) 100% + ); + box-shadow: + 0 0 0 1px rgba(122, 155, 184, 0.15), + 0 0 14px rgba(232, 240, 247, 0.9); +} + +@media (max-width: 768px) { + .app-layout { + grid-template-columns: 1fr; + grid-template-rows: minmax(240px, 1fr) auto minmax(240px, 1fr); + } + + .layout-divider { + width: 100%; + height: 2rem; + margin: 0.5rem 0; + } + + .layout-divider::before { + top: 50%; + bottom: auto; + left: 0; + right: 0; + width: auto; + height: 1px; + transform: translateY(-50%); + background: linear-gradient( + to right, + transparent 0%, + rgba(122, 155, 184, 0.12) 12%, + rgba(122, 155, 184, 0.32) 50%, + rgba(122, 155, 184, 0.12) 88%, + transparent 100% + ); + } + + .app { + padding: 1rem; + } + + .app-title { + font-size: 1.6rem; + } +} + +.panel { + display: flex; + flex-direction: column; + background: transparent; + overflow: hidden; + min-height: 320px; +} + +.panel-editor, +.panel-preview { + min-height: 0; +} + +@media (min-width: 769px) { + .panel { + min-height: calc(100vh - 11rem); + } + + .panel-editor .editor { + min-height: 0; + } +} + +.panel-label { + font-family: var(--font-mono); + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--text-muted); + opacity: 0.75; + padding: 0.5rem 0.75rem 0.35rem; +} + +.panel-editor .editor { + flex: 1; + width: 100%; + min-height: 280px; + margin: 0; + padding: 0.5rem 0.75rem 1.25rem; + border: none; + background: transparent; + font-family: var(--font-mono); + font-size: 0.9rem; + line-height: 1.65; + color: var(--text-soft); + caret-color: var(--accent); + resize: none; + outline: none; + overflow-y: auto; +} + +.panel-editor .editor:focus { + outline: none; + box-shadow: 0 0 0 1px rgba(122, 155, 184, 0.2); +} + +.panel-editor .editor::placeholder { + color: var(--text-muted); + opacity: 0.6; +} + +.preview-paper { + flex: 1; + overflow-y: auto; + min-height: 0; + padding: 0.5rem 0.75rem 1.5rem; + font-family: var(--font-serif); + font-size: 1.15rem; + line-height: 1.75; + color: var(--text-soft); + transition: opacity 0.2s ease; +} + +.preview-paper.preview-updating { + opacity: 0.72; +} + +.preview-paper h1, +.preview-paper h2, +.preview-paper h3, +.preview-paper h4 { + font-family: var(--font-serif); + color: var(--text-heading); + font-weight: 600; + margin-top: 1.25em; + margin-bottom: 0.5em; + line-height: 1.3; +} + +.preview-paper h1 { + font-size: 1.85rem; + margin-top: 0; +} + +.preview-paper h2 { + font-size: 1.45rem; +} + +.preview-paper h3 { + font-size: 1.2rem; +} + +.preview-paper p { + margin: 0.75em 0; +} + +.preview-paper ul, +.preview-paper ol { + margin: 0.75em 0; + padding-left: 1.5em; +} + +.preview-paper li { + margin: 0.35em 0; +} + +.preview-paper blockquote { + margin: 1em 0; + padding: 0.5em 1em; + border-left: 3px solid var(--accent); + background: rgba(232, 240, 247, 0.75); + color: var(--text-muted); + font-style: italic; +} + +.preview-paper code { + font-family: var(--font-mono); + font-size: 0.85em; + background: var(--ice-blue); + padding: 0.15em 0.4em; + border-radius: 4px; +} + +.preview-paper pre { + margin: 1em 0; + padding: 1em 1.25em; + background: rgba(238, 242, 246, 0.9); + border-radius: 8px; + overflow-x: auto; + border: 1px solid var(--border-soft); +} + +.preview-paper pre code { + background: none; + padding: 0; + font-size: 0.8rem; +} + +.preview-paper a { + color: var(--accent-hover); + text-decoration: none; + border-bottom: 1px solid rgba(95, 130, 160, 0.3); +} + +.preview-paper a:hover { + border-bottom-color: var(--accent-hover); +} + +.preview-paper hr { + border: none; + border-top: 1px solid var(--border-soft); + margin: 1.5em 0; +} + +.preview-paper strong { + font-weight: 600; + color: var(--text-heading); +} + +.org-badge { + display: inline-flex; + align-items: center; + padding: 0.15rem 0.45rem; + border-radius: 999px; + font-family: var(--font-mono); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.04em; + margin-right: 0.45rem; + vertical-align: middle; +} + +.org-todo { + background: #f3e8eb; + color: #7a4a52; +} + +.org-done { + background: #e5efe8; + color: #3d5c4a; +} + +.app-footer { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.5rem; + margin-top: 1rem; + padding: 0.75rem; + font-family: var(--font-mono); + font-size: 0.75rem; + color: var(--text-muted); +} + +.app-footer-stats { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.footer-separator { + opacity: 0.5; +} + +.app-meta { + margin: 0; + font-size: 0.7rem; + color: var(--text-muted); + opacity: 0.85; +} + +.app-meta a { + color: var(--accent-hover); + text-decoration: none; + border-bottom: 1px solid rgba(95, 130, 160, 0.25); +} + +.app-meta a:hover { + border-bottom-color: var(--accent-hover); +} + +.app-storage-warning { + margin: 0; + font-size: 0.7rem; + color: #8b5a4a; + text-align: center; + max-width: 28rem; +} + +@media (prefers-reduced-motion: reduce) { + .btn, + .mode-switch__btn, + .preview-paper { + transition: none; + } + + .btn:hover { + transform: none; + } + + .preview-paper.preview-updating { + opacity: 1; + } +} diff --git a/vite.config.js b/vite.config.js @@ -0,0 +1,34 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import { previewServerOptions, resolveAllowedHosts } from './vite.shared.js'; + +export default defineConfig(({ mode }) => { + const allowedHosts = resolveAllowedHosts(mode); + + return { + plugins: [react()], + build: { + target: 'es2020', + rollupOptions: { + output: { + manualChunks(id) { + if (id.includes('node_modules/react-dom') || id.includes('node_modules/react/')) { + return 'vendor-react'; + } + if (id.includes('node_modules/marked') || id.includes('node_modules/dompurify')) { + return 'vendor-markdown'; + } + }, + }, + }, + }, + server: { + ...previewServerOptions, + allowedHosts, + }, + preview: { + ...previewServerOptions, + allowedHosts, + }, + }; +}); diff --git a/vite.preview.config.js b/vite.preview.config.js @@ -0,0 +1,9 @@ +import { defineConfig } from 'vite'; +import { previewServerOptions, resolveAllowedHosts } from './vite.shared.js'; + +export default defineConfig(({ mode }) => ({ + preview: { + ...previewServerOptions, + allowedHosts: resolveAllowedHosts(mode), + }, +})); diff --git a/vite.shared.js b/vite.shared.js @@ -0,0 +1,24 @@ +import { loadEnv } from 'vite'; + +export function parseAllowedHosts(value) { + if (!value || value.trim() === '') { + return ['localhost', '127.0.0.1']; + } + if (value.trim() === '*') { + return true; + } + return value + .split(',') + .map((host) => host.trim()) + .filter(Boolean); +} + +export function resolveAllowedHosts(mode) { + const fileEnv = loadEnv(mode, process.cwd(), ''); + return parseAllowedHosts(process.env.ALLOWED_HOSTS ?? fileEnv.ALLOWED_HOSTS); +} + +export const previewServerOptions = { + host: '0.0.0.0', + port: 41737, +};