commit da44ba535c2043b2be1afcd3798276c63ca7e677
Author: Pablo Murad <pblmrd@gmail.com>
Date: Wed, 3 Jun 2026 09:56:46 -0300
first commit
Diffstat:
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, '&')
+ .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
@@ -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,
+};