feat: barebones MVP — editor, live LSP, and preview

Backend (typst-leaf-backend)
- REST API for multi-project file management under PROJECTS_ROOT
  (list/create projects, get file tree, read/write files; zod-validated
  sandbox prevents path traversal).
- LSP thin proxy: spawns a single tinymist lsp process, pipes one active
  WebSocket straight to its stdio via vscode-ws-jsonrpc. New connections
  trigger a takeover (old session disposed, fresh tinymist spawned).
- Live preview pipeline: the frontend sends tinymist.startDefaultPreview
  over the LSP wire, reads the returned data-plane port, and relays it to
  the backend. The backend proxies /preview (WS, with retry and an Origin
  header for typst-preview) and /preview-app (HTTP, injecting a script that
  rewrites the webview's WS path to /preview and forces light mode).
- Graceful SIGTERM/SIGINT shutdown.
- Auto-seeds a hello project on first boot.
- Removed execa dependency (no longer needed).

Frontend (typst-leaf-frontend)
- Monaco editor with typst syntax highlighting (Monarch tokenizer) and
  live LSP diagnostics (markers), completion, hover, definitions, and
  formatting — all driven by a raw JSON-RPC WebSocket client (no
  monaco-languageclient or vscode-api dependency).
- Project picker and file tree sidebar.
- Live preview pane: iframes the typst-preview webview through the
  backend's /preview-app proxy, so the pipedepeline works remotely.
- Monaco bundled locally (no CDN) with a Vite worker setup.
- Vite dev proxy so relative /api, /lsp, /preview, and /preVIEW-app URLs
  reach the backend on port 4000.
- Light-mode UI (editor 'vs' theme, white chrome).
- Prevent esbuild from tree-shaking monaco.editor services
  (optimizeDeps.esbuildOptions.treeShaking: false).

Infrastructure
- Added vscode-ws-jsonrpc to backend dependencies.
- Removed monaco-languageclient and vscode-ws-jsonrpc from frontend
  dependencies (replaced by the raw JSON-RPC client).
- Added monaco-editor as a direct frontend dependency.
- Updated README (correct frontend port 1420, tinymist install command,
  environment variables, architecture).
- Added projects/ to .gitignore.
- Renamed Tauri productName and page title to typst-leaf.
This commit is contained in:
2026-07-01 20:53:42 +05:30
parent b00fe3ec9e
commit 061f95ddee
29 changed files with 1904 additions and 667 deletions
+1
View File
@@ -1,3 +1,4 @@
node_modules/
dist/
*.tsbuildinfo
projects/
+37 -28
View File
@@ -4,55 +4,64 @@ A completely self-hostable, high-performance editor for [Typst](https://typst.ap
**Features:**
* **Instant Preview:** Sub-50ms incremental compilation using `tinymist`.
* **Vim Mode:** First-class support via Monaco.
* **Git Native:** No hidden databases. Your projects are just folders with `.git`.
* **Hybrid:** Run it in Docker on your server, or as a native desktop app via Tauri.
* **Live diagnostics & autocomplete** from the Typst language server.
* **Git Native:** No hidden databases. Your projects are just folders.
* **Hybrid:** Run it in a browser, or as a native desktop app via Tauri.
## 📂 Repository Structure
```text
typst-leaf/
├── typst-leaf-backend/ # Node.js/TS server (manages tinymist & git)
├── typst-leaf-backend/ # Node.js/TS server (spawns tinymist, REST + WS)
├── typst-leaf-frontend/ # React + Monaco frontend (includes Tauri)
├── VISION.md # Project philosophy and architecture
└── AGENTS.md # Guidelines for AI coding assistants
```
## Prerequisites
* **Node.js 20+**
* **pnpm** (10+)
* **Rust** (only required for the Tauri desktop wrapper)
* **`tinymist`** on your `PATH` — the Typst language server & preview engine:
```bash
cargo install --git https://github.com/Myriad-Dreamin/tinymist --bin tinymist --locked tinymist-cli
```
## 🚀 Quick Start (Development)
**Prerequisites:**
* Node.js 20+
* pnpm
* Rust (for Tauri)
* `tinymist` binary installed in your PATH.
```bash
# 1. Install dependencies
# 1. Install workspace dependencies
pnpm install
# 2. Start the backend (Port 4000)
cd typst-leaf-backend
pnpm dev
# 2. Start the backend (port 4000)
pnpm --filter typst-leaf-backend dev
# 3. Start the web frontend (Port 3000)
# (In a new terminal)
cd typst-leaf-frontend
pnpm dev
# 4. Start Tauri Desktop App (Optional)
# (In a new terminal)
cd typst-leaf-frontend
pnpm tauri dev
# 3. Start the web frontend (port 1420 — mandated by Tauri)
pnpm --filter typst-leaf-frontend dev
# 4. (Optional) Launch the Tauri desktop wrapper
pnpm --filter typst-leaf-frontend tauri dev
```
Then open <http://localhost:1420> in your browser. A `hello` project is auto-seeded on first run.
### Environment variables
| Var | Default | Description |
| --- | --- | --- |
| `PROJECTS_ROOT` | `<repo>/projects` | Directory under which project folders live. The backend refuses to read or write outside this root. |
| `TINYMIST_PREVIEW_HOST` | `127.0.0.1` | Host the tinymist preview server binds to. |
| `TINYMIST_PREVIEW_PORT` | `23635` | Port the tinymist preview server binds to. |
| `PORT` (backend) | `4000` | Backend HTTP/WS port. |
| `FRONTEND_ORIGIN` (backend) | `http://localhost:1420` | CORS origin allowed to call the REST API. |
## 🏗️ Architecture
* **Editor:** Monaco Editor
* **LSP:** `tinymist` (via WebSocket proxy)
* **Communication:**
* `ws://localhost:4000/lsp` -> Tinymist Language Server
* `ws://localhost:4000/preview` -> Tinymist Preview Service
* **LSP:** `tinymist` over a WebSocket thin proxy
* **Preview:** Backend spawns `tinymist preview <file>` on demand; the frontend iframes its webview for live SVG updates.
* **Data plane:**
* `ws://localhost:4000/lsp` → `tinymist lsp` (single active connection, takeover on reconnect)
* `GET /api/projects`, `GET/PUT /api/projects/:p/file?path=…`, `POST /api/projects/:p/focus?path=…`
+6 -615
View File
@@ -11,15 +11,15 @@ importers:
cors:
specifier: ^2.8.5
version: 2.8.6
execa:
specifier: ^9.6.1
version: 9.6.1
express:
specifier: ^4.22.2
version: 4.22.2
simple-git:
specifier: ^3.27.0
version: 3.36.0
vscode-ws-jsonrpc:
specifier: ^3.5.0
version: 3.5.0
ws:
specifier: ^8.18.2
version: 8.21.0
@@ -57,9 +57,9 @@ importers:
'@tauri-apps/plugin-opener':
specifier: ^2
version: 2.5.4
monaco-languageclient:
specifier: ^10.7.0
version: 10.7.0
monaco-editor:
specifier: ^0.55.1
version: 0.55.1
monaco-vim:
specifier: ^0.4.4
version: 0.4.4(monaco-editor@0.55.1)
@@ -69,9 +69,6 @@ importers:
react-dom:
specifier: ^19.1.0
version: 19.2.7(react@19.2.7)
vscode-ws-jsonrpc:
specifier: ^3.5.0
version: 3.5.0
zod:
specifier: ^3.24.5
version: 3.25.76
@@ -186,132 +183,6 @@ packages:
resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==}
engines: {node: '>=6.9.0'}
'@codingame/monaco-vscode-api@25.1.2':
resolution: {integrity: sha512-K04QcQA+Zb0KXucBAK/BGCT5dldiwIqdUbBQq7yuLvBLbof3cP1WSUuxasMHGYwM0MWyzIAsDtyAYMS7is8ZuA==}
'@codingame/monaco-vscode-base-service-override@25.1.2':
resolution: {integrity: sha512-OwYs6h1ATUAeMmX+Q1c8esTG7GLMqniBs+fLEr1/9b/ciY485ArKo5UvrUxVPDtRNy/7F06vRW9IUCq9iKP14w==}
'@codingame/monaco-vscode-bulk-edit-service-override@25.1.2':
resolution: {integrity: sha512-+EfSzjiFakCf0IIJKPZrHVGioq5N8GBsp51bXuKBR5J/B58cUaJY0Dc12PNTSpgAusAGOppUIOSBqUk4F/7IaQ==}
'@codingame/monaco-vscode-configuration-service-override@25.1.2':
resolution: {integrity: sha512-oeoZ3WtM42zHA1IWHrx9UGEfE+TixE+G8Bl9M9bjgFj1EROnkB5yOfELwRYPo4WOEtcK1C5nvIvWIj/hL9MaLg==}
'@codingame/monaco-vscode-editor-api@25.1.2':
resolution: {integrity: sha512-dVXoBLRN8vyFHsLY6iYISaNetZ3ispXLut0qL+jvN0e0CEFkUv1F/3EAE7myptrJSS/N1AptrRIxATT3lwFP+Q==}
'@codingame/monaco-vscode-editor-service-override@25.1.2':
resolution: {integrity: sha512-EadvDCyWdgxOPmaIvbcVVDNjTUYuKdjYWwKbPbbcTs9t4z1/DjdE7mV3ZdT6aGh5m6zkEEUOi143l27Y5eRt+Q==}
'@codingame/monaco-vscode-environment-service-override@25.1.2':
resolution: {integrity: sha512-8GoD3lk0CN0dIMZOrZNS/i8RCaF1YSQ6nmrf+rqneOSHG9S382EnsZZD69d4+i7JnoeyttO7Kr9KH8WOhRV6OA==}
'@codingame/monaco-vscode-extension-api@25.1.2':
resolution: {integrity: sha512-SJW/YOhjo+9MXEyzMwQMUWdJVR3Llc6pTq5JQqs6Y30v73gTrpLqtzbd9FNdCuQR8S6bUk5ScH8GL4QrVuL5FA==}
'@codingame/monaco-vscode-extensions-service-override@25.1.2':
resolution: {integrity: sha512-rTTZW2biPxcg+JumhVf2L+38C5ptvNNxiJlwz39VfXFEh6qOHtAsIMy7vIXa0uGg5/y8DNp0SnOQJP/RKhLYZA==}
'@codingame/monaco-vscode-files-service-override@25.1.2':
resolution: {integrity: sha512-TenLLAFIwY7keZFF8e3beUn7OVfnNINR5Noi4PVrjeeTcy6FuNH6Jghdul2JwpRAkvyJLdFMvomE2jlT6F03jQ==}
'@codingame/monaco-vscode-host-service-override@25.1.2':
resolution: {integrity: sha512-lgaalpA9CUQW7i0bBwgBOK0DQNDvOo3QO3p6Rz6yVsHpgA4iMqq2d11dBDUKvuQSwIHPRu8CMHCqhQk/BQN/YA==}
'@codingame/monaco-vscode-keybindings-service-override@25.1.2':
resolution: {integrity: sha512-cp/gGyTvCTAzCYnQm0HJykXJRB0Huz8Lvq60lj5LutgWcb8S3w6dOB2Houm8dHoeUm/jOko8SQNIP8hzWN92Zw==}
'@codingame/monaco-vscode-language-pack-cs@25.1.2':
resolution: {integrity: sha512-v0cB2uAOCwj135aGIf0arGV+DNW32lbWh04bv8ctTxcWRt1Pr2kTQ1pjfE8ynKgxabPfAk8E25/CerKSYOmZ+A==}
'@codingame/monaco-vscode-language-pack-de@25.1.2':
resolution: {integrity: sha512-xA3WOt1w5jlAOnyx4PBwx+qV3vx8C8/zie29qjYbgJMxGKDkb0HfpuKUwywDA2uUMI2wJZS+PnNG00zPDoLIrw==}
'@codingame/monaco-vscode-language-pack-es@25.1.2':
resolution: {integrity: sha512-1/upuO9lRJilZ3sRr0QLTpz55KYRaBWDe8wtPvghOFYOHyWgW8A4VhUQxa6L9SJgY1JkypUAm0U8WcMX2G4LnQ==}
'@codingame/monaco-vscode-language-pack-fr@25.1.2':
resolution: {integrity: sha512-iq+xx+tv1QIMmFD0eBhFRMF4xMAsVf/HyA1WogqBofteCWeAvRE9HUjZ5JzHz7jXBPe3dLP1LOM0r0GrJZs4fQ==}
'@codingame/monaco-vscode-language-pack-it@25.1.2':
resolution: {integrity: sha512-FajWCML9OR8ppLnJ0mcg+sFHEhYJl8zhb3/DHnd+pNysw8dLfetXoSWjaPnwPPpwiQgkNN1UsToZHOU9czVifQ==}
'@codingame/monaco-vscode-language-pack-ja@25.1.2':
resolution: {integrity: sha512-NwKh0BnPgUrJkxsm0X6vY4ftnd9DjxkcnQqK+bohta6UOzm09J1EjZ6QD42fjWngxrp/xiegtrYQ9NA2q6VpoA==}
'@codingame/monaco-vscode-language-pack-ko@25.1.2':
resolution: {integrity: sha512-fvaisgfcg8YaAwnyPcGmQDLwkwqzamLQUyx9HmnwDpXw0YANzd058Kwn6bz+Vfn9MjwuMNT0nllD0qQMnpdyew==}
'@codingame/monaco-vscode-language-pack-pl@25.1.2':
resolution: {integrity: sha512-9hDRyzFJkDia5rO9QE262JgxwP/cnalFisLFo7FQcw57ZhqzqXIdQIuwcKaHuAgzeQ6W2+A3KOLfTr3m7VZrXw==}
'@codingame/monaco-vscode-language-pack-pt-br@25.1.2':
resolution: {integrity: sha512-7fFnqOTAJGb5RuJ4uwh9sh0JmXALuHPGOl7iL9rZkcgIuVP5y6wVDUDXq5qjiRTNSFDs7Bzh463Ir5m5D6mJbA==}
'@codingame/monaco-vscode-language-pack-qps-ploc@25.1.2':
resolution: {integrity: sha512-IFjoqrSuPtIFWb+KlPT6PFWKszzNX+TCD9drgCV6AigvBO/xfGL3QwHB68l/DLbmDbohOz4Xdkutv20wuENAeA==}
'@codingame/monaco-vscode-language-pack-ru@25.1.2':
resolution: {integrity: sha512-0uDAeXO+GllKUPhJzP893rlDhlFV1IwCu/515rBdcyegt48iGm/xAgj26V90hNz8hmB6EuM/7d8MFeklbiIpYA==}
'@codingame/monaco-vscode-language-pack-tr@25.1.2':
resolution: {integrity: sha512-MJhHxDyJEiuVLQ9+jb8MnnN9lsbJOjJjMswVCeJ7v/Q/msAhq25QYUfn0DbOIzESJE1f7crffRb5e38XP8sYWA==}
'@codingame/monaco-vscode-language-pack-zh-hans@25.1.2':
resolution: {integrity: sha512-c7MMrhnSLb59NxpAa8nVy9aIbxy4gVYrCpDMq8W380LOaXTYb7nueTrw8QJ5QbJBNi2P2KZoGkn2BlONuBtJJg==}
'@codingame/monaco-vscode-language-pack-zh-hant@25.1.2':
resolution: {integrity: sha512-ARedFTM6JCluoPLJqkBcTJaQFdJNcN86OX6B8/NMApIPrnSIAfanMndpyilt8XjzUG6IH22cypR+DAlEjf48cA==}
'@codingame/monaco-vscode-languages-service-override@25.1.2':
resolution: {integrity: sha512-ipuS1V3NgXDkNrj0vBcgMBFnqo+19HVsZjjFGfPFH3x0uptP9aiWWK42wtDK3Qbu4teSjHL7WnSLrmw94rplWw==}
'@codingame/monaco-vscode-layout-service-override@25.1.2':
resolution: {integrity: sha512-SxBGcMK3RgkGtUn7ZDl7dCoyNW0CWFQ/bfSRYUY06A0IA4JNS5jq1lhof57d0WXewm+5l8w1Spr/vMsfx1c9ig==}
'@codingame/monaco-vscode-localization-service-override@25.1.2':
resolution: {integrity: sha512-QLj62A8XDOIQW3KjsZlNxs+sfsNNHYxWMjQMwZu/y2Vw3IIHGly2Lpn4t4SFbeaBHJQJy4i5s7NpzlbF9MbEzQ==}
'@codingame/monaco-vscode-log-service-override@25.1.2':
resolution: {integrity: sha512-OoileAUtPAJ0j3RW31DFSxtOipy0EcFq+iIXEdGvoRlsQPZJ3o9ayjf1JvCXpxUjJ3QkmvQVhXsWNUFREjEFLg==}
'@codingame/monaco-vscode-model-service-override@25.1.2':
resolution: {integrity: sha512-MGz/eV1CxibLvnl6WzK6idUHJCXJOVepJvKM6Trkv5050vRe+f/o1TjCiG8PaznAypYqZvnwkTG0B7/OTizCpQ==}
'@codingame/monaco-vscode-monarch-service-override@25.1.2':
resolution: {integrity: sha512-akyNHOJQRS7YHyk6kf0Encnkt+shlR+bIB84UJRUHFgSeF8s5gkDkQuFJph0YeUDWJWat+yBLUSZx2nHomdbHQ==}
'@codingame/monaco-vscode-quickaccess-service-override@25.1.2':
resolution: {integrity: sha512-7IIrXnwHiF3w9d9p9kspEUz/LCibMLUztmRpGdZQfFtWBJw043q7rk8V1O42KdXr1hVg9IR5vfffwjy9nbiiUg==}
'@codingame/monaco-vscode-textmate-service-override@25.1.2':
resolution: {integrity: sha512-AL0FtSQBW+1vtoXYQvUqB2hfWojpK73Kq/n6KuNXxjLF/XBJ5FpeeZDfrBfwhWPPoHuBTsaFUCQy4L8xQgbVlA==}
'@codingame/monaco-vscode-theme-defaults-default-extension@25.1.2':
resolution: {integrity: sha512-0vTMFiC89YSDSmjFckuQBUKwRuFNtsILNO3k0PBiSLN/MW+VDItjJpiVLXC42+rUWlGgY2lYxOneGVa5slCV1w==}
'@codingame/monaco-vscode-theme-service-override@25.1.2':
resolution: {integrity: sha512-hsTwl6YYTiheFuQMmCmiEGLIdIdgYaf8Z85XWyxe6YgPtDaYGnp0fGSOXKA9/bf0JtuynzoLKtUUfDupK/A7Tw==}
'@codingame/monaco-vscode-view-banner-service-override@25.1.2':
resolution: {integrity: sha512-zhujHd1PQ6rRXsC2OQGrx/282G2v3lpPFl9heDFGKzpdj5119SgcW+B9p/MwJ1qF3LJpuRRgefNiQtqC/KT1eA==}
'@codingame/monaco-vscode-view-common-service-override@25.1.2':
resolution: {integrity: sha512-4Po/YaHUvVf4VmhVCZmM2lc/flOptiWSM140bIRNpMcfH0VwihYg15CcDeu1Oc+6DaauzsG3u59GtEvlMmJ9Zw==}
'@codingame/monaco-vscode-view-status-bar-service-override@25.1.2':
resolution: {integrity: sha512-Jp9ytLaWZ6evabTPtG3Mu3dFx+7WTIPz69BsGpl9PnU0kiSWUqQhPSob0Jz7E2qmMj0ZcNv2Wqvm6bMBu5OyrA==}
'@codingame/monaco-vscode-view-title-bar-service-override@25.1.2':
resolution: {integrity: sha512-NVYtTAFR35NV/Fx7tSlbASicvpAjK5A14fmxF7/LJJN8ZmzhA/P3Y+UzhqOQl6/VcPV4pAMU0Z7Sicgwbn37dw==}
'@codingame/monaco-vscode-views-service-override@25.1.2':
resolution: {integrity: sha512-LfzlztsvobdP5L5EvJ/rqSEgy5fEVmrkMqRteuhEtNGd4hnmdBoX8W7BNMBPff6d4NfCK74pGHJF57RyT4Iixg==}
'@codingame/monaco-vscode-workbench-service-override@25.1.2':
resolution: {integrity: sha512-2LMHr+na03FhOAaXpIGmamq9hf7e4wt2kULn8NqNZRd3i+0v1tx/TSSjGhsA5EkrNrFD7CMSoXayBq8tgpCq/A==}
'@esbuild/aix-ppc64@0.28.1':
resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==}
engines: {node: '>=18'}
@@ -641,19 +512,12 @@ packages:
cpu: [x64]
os: [win32]
'@sec-ant/readable-stream@0.4.1':
resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
'@simple-git/args-pathspec@1.0.3':
resolution: {integrity: sha512-ngJMaHlsWDTfjyq9F3VIQ8b7NXbBLq5j9i5bJ6XLYtD6qlDXT7fdKY2KscWWUF8t18xx052Y/PUO1K1TRc9yKA==}
'@simple-git/argv-parser@1.1.1':
resolution: {integrity: sha512-Q9lBcfQ+VQCpQqGJFHe5yooOS5hGdLFFbJ5R+R5aDsnkPCahtn1hSkMcORX65J2Z5lxSkD0lQorMsncuBQxYUw==}
'@sindresorhus/merge-streams@4.0.0':
resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==}
engines: {node: '>=18'}
'@tailwindcss/node@4.3.2':
resolution: {integrity: sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==}
@@ -904,9 +768,6 @@ packages:
peerDependencies:
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
'@vscode/iconv-lite-umd@0.7.1':
resolution: {integrity: sha512-tK6k0DXFHW7q5+GGuGZO+phpAqpxO4WXl+BLc/8/uOk3RsM2ssAL3CQUQDb1TGfwltjsauhN6S4ghYZzs4sPFw==}
accepts@1.3.8:
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
engines: {node: '>= 0.6'}
@@ -914,9 +775,6 @@ packages:
array-flatten@1.1.1:
resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==}
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
baseline-browser-mapping@2.10.40:
resolution: {integrity: sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==}
engines: {node: '>=6.0.0'}
@@ -926,9 +784,6 @@ packages:
resolution: {integrity: sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==}
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
brace-expansion@2.1.1:
resolution: {integrity: sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==}
browserslist@4.28.4:
resolution: {integrity: sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
@@ -971,10 +826,6 @@ packages:
resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==}
engines: {node: '>= 0.10'}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
@@ -1010,9 +861,6 @@ packages:
dompurify@3.2.7:
resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==}
dompurify@3.3.1:
resolution: {integrity: sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==}
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
@@ -1059,10 +907,6 @@ packages:
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
engines: {node: '>= 0.6'}
execa@9.6.1:
resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==}
engines: {node: ^18.19.0 || >=20.5.0}
express@4.22.2:
resolution: {integrity: sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==}
engines: {node: '>= 0.10.0'}
@@ -1076,10 +920,6 @@ packages:
picomatch:
optional: true
figures@6.1.0:
resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==}
engines: {node: '>=18'}
finalhandler@1.3.2:
resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==}
engines: {node: '>= 0.8'}
@@ -1112,10 +952,6 @@ packages:
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
engines: {node: '>= 0.4'}
get-stream@9.0.1:
resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==}
engines: {node: '>=18'}
gopd@1.2.0:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'}
@@ -1135,10 +971,6 @@ packages:
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
engines: {node: '>= 0.8'}
human-signals@8.0.1:
resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==}
engines: {node: '>=18.18.0'}
iconv-lite@0.4.24:
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
engines: {node: '>=0.10.0'}
@@ -1150,21 +982,6 @@ packages:
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
engines: {node: '>= 0.10'}
is-plain-obj@4.1.0:
resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==}
engines: {node: '>=12'}
is-stream@4.0.1:
resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==}
engines: {node: '>=18'}
is-unicode-supported@2.1.0:
resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==}
engines: {node: '>=18'}
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
jiti@2.7.0:
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true
@@ -1172,10 +989,6 @@ packages:
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
jschardet@3.1.4:
resolution: {integrity: sha512-/kmVISmrwVwtyYU40iQUOp3SUPk2dhNCMsZBQX0R1/jZ8maaXJ/oZIzUOiyOqcgtLnETFKYChbJ5iDC/eWmFHg==}
engines: {node: '>=0.1.90'}
jsesc@3.1.0:
resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
engines: {node: '>=6'}
@@ -1299,17 +1112,9 @@ packages:
engines: {node: '>=4'}
hasBin: true
minimatch@5.1.9:
resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==}
engines: {node: '>=10'}
monaco-editor@0.55.1:
resolution: {integrity: sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==}
monaco-languageclient@10.7.0:
resolution: {integrity: sha512-oA5cOFixkF4bspVL2zMSn48LvlNR/Cu3vJ8MCVam3PdjobSULGgHtOASuZIi3FgWK42X1z8/6hrG0LCjvNu1Hw==}
engines: {node: '>=20.10.0', npm: '>=10.2.3'}
monaco-vim@0.4.4:
resolution: {integrity: sha512-LNChAb//WEm/W+eyeHG/0+pdVEHotk2hLTN+M3sQZx5E8cAlSWSgqcxpcRuQnxDybSln7pfHF9i63HmbIQvrWw==}
peerDependencies:
@@ -1334,10 +1139,6 @@ packages:
resolution: {integrity: sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==}
engines: {node: '>=18'}
npm-run-path@6.0.0:
resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==}
engines: {node: '>=18'}
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
@@ -1350,22 +1151,10 @@ packages:
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
engines: {node: '>= 0.8'}
parse-ms@4.0.0:
resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==}
engines: {node: '>=18'}
parseurl@1.3.3:
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
engines: {node: '>= 0.8'}
path-key@3.1.1:
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
engines: {node: '>=8'}
path-key@4.0.0:
resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==}
engines: {node: '>=12'}
path-to-regexp@0.1.13:
resolution: {integrity: sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==}
@@ -1380,10 +1169,6 @@ packages:
resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==}
engines: {node: ^10 || ^12 || >=14}
pretty-ms@9.3.0:
resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==}
engines: {node: '>=18'}
proxy-addr@2.0.7:
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
engines: {node: '>= 0.10'}
@@ -1431,11 +1216,6 @@ packages:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
semver@7.8.5:
resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
engines: {node: '>=10'}
hasBin: true
send@0.19.2:
resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==}
engines: {node: '>= 0.8.0'}
@@ -1447,14 +1227,6 @@ packages:
setprototypeof@1.2.0:
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
shebang-regex@3.0.0:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'}
side-channel-list@1.0.1:
resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==}
engines: {node: '>= 0.4'}
@@ -1471,10 +1243,6 @@ packages:
resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==}
engines: {node: '>= 0.4'}
signal-exit@4.1.0:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'}
simple-git@3.36.0:
resolution: {integrity: sha512-cGQjLjK8bxJw4QuYT7gxHw3/IouVESbhahSsHrX97MzCL1gu2u7oy38W6L2ZIGECEfIBG4BabsWDPjBxJENv9Q==}
@@ -1489,10 +1257,6 @@ packages:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'}
strip-final-newline@4.0.0:
resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==}
engines: {node: '>=18'}
tailwindcss@4.3.2:
resolution: {integrity: sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==}
@@ -1525,10 +1289,6 @@ packages:
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
unicorn-magic@0.3.0:
resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==}
engines: {node: '>=18'}
unpipe@1.0.0:
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
engines: {node: '>= 0.8'}
@@ -1587,33 +1347,14 @@ packages:
yaml:
optional: true
vscode-jsonrpc@8.2.0:
resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==}
engines: {node: '>=14.0.0'}
vscode-jsonrpc@8.2.1:
resolution: {integrity: sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ==}
engines: {node: '>=14.0.0'}
vscode-languageclient@9.0.1:
resolution: {integrity: sha512-JZiimVdvimEuHh5olxhxkht09m3JzUGwggb5eRUkzzJhZ2KjCN0nh55VfiED9oez9DyF8/fz1g1iBV3h+0Z2EA==}
engines: {vscode: ^1.82.0}
vscode-languageserver-protocol@3.17.5:
resolution: {integrity: sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==}
vscode-languageserver-types@3.17.5:
resolution: {integrity: sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==}
vscode-ws-jsonrpc@3.5.0:
resolution: {integrity: sha512-13ZDy7Od4AfEPK2HIfY3DtyRi4FVsvFql1yobVJrpIoHOKGGJpIjVvIJpMxkrHzCZzWlYlg+WEu2hrYkCTvM0Q==}
engines: {node: '>=20.10.0', npm: '>=10.2.3'}
which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
hasBin: true
ws@8.21.0:
resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==}
engines: {node: '>=10.0.0'}
@@ -1629,10 +1370,6 @@ packages:
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
yoctocolors@2.1.2:
resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==}
engines: {node: '>=18'}
zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
@@ -1750,203 +1487,6 @@ snapshots:
'@babel/helper-string-parser': 7.29.7
'@babel/helper-validator-identifier': 7.29.7
'@codingame/monaco-vscode-api@25.1.2':
dependencies:
'@codingame/monaco-vscode-base-service-override': 25.1.2
'@codingame/monaco-vscode-environment-service-override': 25.1.2
'@codingame/monaco-vscode-extensions-service-override': 25.1.2
'@codingame/monaco-vscode-files-service-override': 25.1.2
'@codingame/monaco-vscode-host-service-override': 25.1.2
'@codingame/monaco-vscode-layout-service-override': 25.1.2
'@codingame/monaco-vscode-quickaccess-service-override': 25.1.2
'@vscode/iconv-lite-umd': 0.7.1
dompurify: 3.3.1
jschardet: 3.1.4
marked: 14.0.0
'@codingame/monaco-vscode-base-service-override@25.1.2':
dependencies:
'@codingame/monaco-vscode-api': 25.1.2
'@codingame/monaco-vscode-bulk-edit-service-override@25.1.2':
dependencies:
'@codingame/monaco-vscode-api': 25.1.2
'@codingame/monaco-vscode-configuration-service-override@25.1.2':
dependencies:
'@codingame/monaco-vscode-api': 25.1.2
'@codingame/monaco-vscode-files-service-override': 25.1.2
'@codingame/monaco-vscode-editor-api@25.1.2':
dependencies:
'@codingame/monaco-vscode-api': 25.1.2
'@codingame/monaco-vscode-editor-service-override@25.1.2':
dependencies:
'@codingame/monaco-vscode-api': 25.1.2
'@codingame/monaco-vscode-environment-service-override@25.1.2':
dependencies:
'@codingame/monaco-vscode-api': 25.1.2
'@codingame/monaco-vscode-extension-api@25.1.2':
dependencies:
'@codingame/monaco-vscode-api': 25.1.2
'@codingame/monaco-vscode-extensions-service-override': 25.1.2
'@codingame/monaco-vscode-extensions-service-override@25.1.2':
dependencies:
'@codingame/monaco-vscode-api': 25.1.2
'@codingame/monaco-vscode-files-service-override': 25.1.2
'@codingame/monaco-vscode-files-service-override@25.1.2':
dependencies:
'@codingame/monaco-vscode-api': 25.1.2
'@codingame/monaco-vscode-host-service-override@25.1.2':
dependencies:
'@codingame/monaco-vscode-api': 25.1.2
'@codingame/monaco-vscode-keybindings-service-override@25.1.2':
dependencies:
'@codingame/monaco-vscode-api': 25.1.2
'@codingame/monaco-vscode-files-service-override': 25.1.2
'@codingame/monaco-vscode-language-pack-cs@25.1.2':
dependencies:
'@codingame/monaco-vscode-api': 25.1.2
'@codingame/monaco-vscode-language-pack-de@25.1.2':
dependencies:
'@codingame/monaco-vscode-api': 25.1.2
'@codingame/monaco-vscode-language-pack-es@25.1.2':
dependencies:
'@codingame/monaco-vscode-api': 25.1.2
'@codingame/monaco-vscode-language-pack-fr@25.1.2':
dependencies:
'@codingame/monaco-vscode-api': 25.1.2
'@codingame/monaco-vscode-language-pack-it@25.1.2':
dependencies:
'@codingame/monaco-vscode-api': 25.1.2
'@codingame/monaco-vscode-language-pack-ja@25.1.2':
dependencies:
'@codingame/monaco-vscode-api': 25.1.2
'@codingame/monaco-vscode-language-pack-ko@25.1.2':
dependencies:
'@codingame/monaco-vscode-api': 25.1.2
'@codingame/monaco-vscode-language-pack-pl@25.1.2':
dependencies:
'@codingame/monaco-vscode-api': 25.1.2
'@codingame/monaco-vscode-language-pack-pt-br@25.1.2':
dependencies:
'@codingame/monaco-vscode-api': 25.1.2
'@codingame/monaco-vscode-language-pack-qps-ploc@25.1.2':
dependencies:
'@codingame/monaco-vscode-api': 25.1.2
'@codingame/monaco-vscode-language-pack-ru@25.1.2':
dependencies:
'@codingame/monaco-vscode-api': 25.1.2
'@codingame/monaco-vscode-language-pack-tr@25.1.2':
dependencies:
'@codingame/monaco-vscode-api': 25.1.2
'@codingame/monaco-vscode-language-pack-zh-hans@25.1.2':
dependencies:
'@codingame/monaco-vscode-api': 25.1.2
'@codingame/monaco-vscode-language-pack-zh-hant@25.1.2':
dependencies:
'@codingame/monaco-vscode-api': 25.1.2
'@codingame/monaco-vscode-languages-service-override@25.1.2':
dependencies:
'@codingame/monaco-vscode-api': 25.1.2
'@codingame/monaco-vscode-files-service-override': 25.1.2
'@codingame/monaco-vscode-layout-service-override@25.1.2':
dependencies:
'@codingame/monaco-vscode-api': 25.1.2
'@codingame/monaco-vscode-localization-service-override@25.1.2':
dependencies:
'@codingame/monaco-vscode-api': 25.1.2
'@codingame/monaco-vscode-log-service-override@25.1.2':
dependencies:
'@codingame/monaco-vscode-api': 25.1.2
'@codingame/monaco-vscode-environment-service-override': 25.1.2
'@codingame/monaco-vscode-model-service-override@25.1.2':
dependencies:
'@codingame/monaco-vscode-api': 25.1.2
'@codingame/monaco-vscode-monarch-service-override@25.1.2':
dependencies:
'@codingame/monaco-vscode-api': 25.1.2
'@codingame/monaco-vscode-quickaccess-service-override@25.1.2':
dependencies:
'@codingame/monaco-vscode-api': 25.1.2
'@codingame/monaco-vscode-textmate-service-override@25.1.2':
dependencies:
'@codingame/monaco-vscode-api': 25.1.2
'@codingame/monaco-vscode-files-service-override': 25.1.2
'@codingame/monaco-vscode-theme-defaults-default-extension@25.1.2':
dependencies:
'@codingame/monaco-vscode-api': 25.1.2
'@codingame/monaco-vscode-theme-service-override@25.1.2':
dependencies:
'@codingame/monaco-vscode-api': 25.1.2
'@codingame/monaco-vscode-files-service-override': 25.1.2
'@codingame/monaco-vscode-view-banner-service-override@25.1.2':
dependencies:
'@codingame/monaco-vscode-api': 25.1.2
'@codingame/monaco-vscode-view-common-service-override@25.1.2':
dependencies:
'@codingame/monaco-vscode-api': 25.1.2
'@codingame/monaco-vscode-bulk-edit-service-override': 25.1.2
'@codingame/monaco-vscode-view-status-bar-service-override@25.1.2':
dependencies:
'@codingame/monaco-vscode-api': 25.1.2
'@codingame/monaco-vscode-view-title-bar-service-override@25.1.2':
dependencies:
'@codingame/monaco-vscode-api': 25.1.2
'@codingame/monaco-vscode-views-service-override@25.1.2':
dependencies:
'@codingame/monaco-vscode-api': 25.1.2
'@codingame/monaco-vscode-keybindings-service-override': 25.1.2
'@codingame/monaco-vscode-layout-service-override': 25.1.2
'@codingame/monaco-vscode-quickaccess-service-override': 25.1.2
'@codingame/monaco-vscode-view-common-service-override': 25.1.2
'@codingame/monaco-vscode-workbench-service-override@25.1.2':
dependencies:
'@codingame/monaco-vscode-api': 25.1.2
'@codingame/monaco-vscode-keybindings-service-override': 25.1.2
'@codingame/monaco-vscode-quickaccess-service-override': 25.1.2
'@codingame/monaco-vscode-view-banner-service-override': 25.1.2
'@codingame/monaco-vscode-view-common-service-override': 25.1.2
'@codingame/monaco-vscode-view-status-bar-service-override': 25.1.2
'@codingame/monaco-vscode-view-title-bar-service-override': 25.1.2
'@esbuild/aix-ppc64@0.28.1':
optional: true
@@ -2140,16 +1680,12 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.62.2':
optional: true
'@sec-ant/readable-stream@0.4.1': {}
'@simple-git/args-pathspec@1.0.3': {}
'@simple-git/argv-parser@1.1.1':
dependencies:
'@simple-git/args-pathspec': 1.0.3
'@sindresorhus/merge-streams@4.0.0': {}
'@tailwindcss/node@4.3.2':
dependencies:
'@jridgewell/remapping': 2.3.5
@@ -2375,8 +1911,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@vscode/iconv-lite-umd@0.7.1': {}
accepts@1.3.8:
dependencies:
mime-types: 2.1.35
@@ -2384,8 +1918,6 @@ snapshots:
array-flatten@1.1.1: {}
balanced-match@1.0.2: {}
baseline-browser-mapping@2.10.40: {}
body-parser@1.20.5:
@@ -2405,10 +1937,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
brace-expansion@2.1.1:
dependencies:
balanced-match: 1.0.2
browserslist@4.28.4:
dependencies:
baseline-browser-mapping: 2.10.40
@@ -2448,12 +1976,6 @@ snapshots:
object-assign: 4.1.1
vary: 1.1.2
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
shebang-command: 2.0.0
which: 2.0.2
csstype@3.2.3: {}
debug@2.6.9:
@@ -2474,10 +1996,6 @@ snapshots:
optionalDependencies:
'@types/trusted-types': 2.0.7
dompurify@3.3.1:
optionalDependencies:
'@types/trusted-types': 2.0.7
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.2
@@ -2538,21 +2056,6 @@ snapshots:
etag@1.8.1: {}
execa@9.6.1:
dependencies:
'@sindresorhus/merge-streams': 4.0.0
cross-spawn: 7.0.6
figures: 6.1.0
get-stream: 9.0.1
human-signals: 8.0.1
is-plain-obj: 4.1.0
is-stream: 4.0.1
npm-run-path: 6.0.0
pretty-ms: 9.3.0
signal-exit: 4.1.0
strip-final-newline: 4.0.0
yoctocolors: 2.1.2
express@4.22.2:
dependencies:
accepts: 1.3.8
@@ -2593,10 +2096,6 @@ snapshots:
optionalDependencies:
picomatch: 4.0.4
figures@6.1.0:
dependencies:
is-unicode-supported: 2.1.0
finalhandler@1.3.2:
dependencies:
debug: 2.6.9
@@ -2638,11 +2137,6 @@ snapshots:
dunder-proto: 1.0.1
es-object-atoms: 1.1.2
get-stream@9.0.1:
dependencies:
'@sec-ant/readable-stream': 0.4.1
is-stream: 4.0.1
gopd@1.2.0: {}
graceful-fs@4.2.11: {}
@@ -2661,8 +2155,6 @@ snapshots:
statuses: 2.0.2
toidentifier: 1.0.1
human-signals@8.0.1: {}
iconv-lite@0.4.24:
dependencies:
safer-buffer: 2.1.2
@@ -2671,20 +2163,10 @@ snapshots:
ipaddr.js@1.9.1: {}
is-plain-obj@4.1.0: {}
is-stream@4.0.1: {}
is-unicode-supported@2.1.0: {}
isexe@2.0.0: {}
jiti@2.7.0: {}
js-tokens@4.0.0: {}
jschardet@3.1.4: {}
jsesc@3.1.0: {}
json5@2.2.3: {}
@@ -2764,52 +2246,11 @@ snapshots:
mime@1.6.0: {}
minimatch@5.1.9:
dependencies:
brace-expansion: 2.1.1
monaco-editor@0.55.1:
dependencies:
dompurify: 3.2.7
marked: 14.0.0
monaco-languageclient@10.7.0:
dependencies:
'@codingame/monaco-vscode-api': 25.1.2
'@codingame/monaco-vscode-configuration-service-override': 25.1.2
'@codingame/monaco-vscode-editor-api': 25.1.2
'@codingame/monaco-vscode-editor-service-override': 25.1.2
'@codingame/monaco-vscode-extension-api': 25.1.2
'@codingame/monaco-vscode-extensions-service-override': 25.1.2
'@codingame/monaco-vscode-language-pack-cs': 25.1.2
'@codingame/monaco-vscode-language-pack-de': 25.1.2
'@codingame/monaco-vscode-language-pack-es': 25.1.2
'@codingame/monaco-vscode-language-pack-fr': 25.1.2
'@codingame/monaco-vscode-language-pack-it': 25.1.2
'@codingame/monaco-vscode-language-pack-ja': 25.1.2
'@codingame/monaco-vscode-language-pack-ko': 25.1.2
'@codingame/monaco-vscode-language-pack-pl': 25.1.2
'@codingame/monaco-vscode-language-pack-pt-br': 25.1.2
'@codingame/monaco-vscode-language-pack-qps-ploc': 25.1.2
'@codingame/monaco-vscode-language-pack-ru': 25.1.2
'@codingame/monaco-vscode-language-pack-tr': 25.1.2
'@codingame/monaco-vscode-language-pack-zh-hans': 25.1.2
'@codingame/monaco-vscode-language-pack-zh-hant': 25.1.2
'@codingame/monaco-vscode-languages-service-override': 25.1.2
'@codingame/monaco-vscode-localization-service-override': 25.1.2
'@codingame/monaco-vscode-log-service-override': 25.1.2
'@codingame/monaco-vscode-model-service-override': 25.1.2
'@codingame/monaco-vscode-monarch-service-override': 25.1.2
'@codingame/monaco-vscode-textmate-service-override': 25.1.2
'@codingame/monaco-vscode-theme-defaults-default-extension': 25.1.2
'@codingame/monaco-vscode-theme-service-override': 25.1.2
'@codingame/monaco-vscode-views-service-override': 25.1.2
'@codingame/monaco-vscode-workbench-service-override': 25.1.2
vscode: '@codingame/monaco-vscode-extension-api@25.1.2'
vscode-languageclient: 9.0.1
vscode-languageserver-protocol: 3.17.5
vscode-ws-jsonrpc: 3.5.0
monaco-vim@0.4.4(monaco-editor@0.55.1):
dependencies:
monaco-editor: 0.55.1
@@ -2824,11 +2265,6 @@ snapshots:
node-releases@2.0.50: {}
npm-run-path@6.0.0:
dependencies:
path-key: 4.0.0
unicorn-magic: 0.3.0
object-assign@4.1.1: {}
object-inspect@1.13.4: {}
@@ -2837,14 +2273,8 @@ snapshots:
dependencies:
ee-first: 1.1.1
parse-ms@4.0.0: {}
parseurl@1.3.3: {}
path-key@3.1.1: {}
path-key@4.0.0: {}
path-to-regexp@0.1.13: {}
picocolors@1.1.1: {}
@@ -2857,10 +2287,6 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
pretty-ms@9.3.0:
dependencies:
parse-ms: 4.0.0
proxy-addr@2.0.7:
dependencies:
forwarded: 0.2.0
@@ -2928,8 +2354,6 @@ snapshots:
semver@6.3.1: {}
semver@7.8.5: {}
send@0.19.2:
dependencies:
debug: 2.6.9
@@ -2959,12 +2383,6 @@ snapshots:
setprototypeof@1.2.0: {}
shebang-command@2.0.0:
dependencies:
shebang-regex: 3.0.0
shebang-regex@3.0.0: {}
side-channel-list@1.0.1:
dependencies:
es-errors: 1.3.0
@@ -2993,8 +2411,6 @@ snapshots:
side-channel-map: 1.0.1
side-channel-weakmap: 1.0.2
signal-exit@4.1.0: {}
simple-git@3.36.0:
dependencies:
'@kwsites/file-exists': 1.1.1
@@ -3011,8 +2427,6 @@ snapshots:
statuses@2.0.2: {}
strip-final-newline@4.0.0: {}
tailwindcss@4.3.2: {}
tapable@2.3.3: {}
@@ -3039,8 +2453,6 @@ snapshots:
undici-types@6.21.0: {}
unicorn-magic@0.3.0: {}
unpipe@1.0.0: {}
update-browserslist-db@1.2.3(browserslist@4.28.4):
@@ -3068,35 +2480,14 @@ snapshots:
lightningcss: 1.32.0
tsx: 4.22.4
vscode-jsonrpc@8.2.0: {}
vscode-jsonrpc@8.2.1: {}
vscode-languageclient@9.0.1:
dependencies:
minimatch: 5.1.9
semver: 7.8.5
vscode-languageserver-protocol: 3.17.5
vscode-languageserver-protocol@3.17.5:
dependencies:
vscode-jsonrpc: 8.2.0
vscode-languageserver-types: 3.17.5
vscode-languageserver-types@3.17.5: {}
vscode-ws-jsonrpc@3.5.0:
dependencies:
vscode-jsonrpc: 8.2.1
which@2.0.2:
dependencies:
isexe: 2.0.0
ws@8.21.0: {}
yallist@3.1.1: {}
yoctocolors@2.1.2: {}
zod@3.25.76: {}
+1 -1
View File
@@ -12,9 +12,9 @@
},
"dependencies": {
"cors": "^2.8.5",
"execa": "^9.6.1",
"express": "^4.22.2",
"simple-git": "^3.27.0",
"vscode-ws-jsonrpc": "^3.5.0",
"ws": "^8.18.2",
"zod": "^3.24.5"
},
+7
View File
@@ -0,0 +1,7 @@
import path from "node:path";
const DEFAULT_ROOT = path.resolve(process.cwd(), "..", "projects");
export const PROJECTS_ROOT = path.resolve(
process.env.PROJECTS_ROOT ?? DEFAULT_ROOT,
);
+68 -12
View File
@@ -1,27 +1,83 @@
import { createServer } from "http";
import cors from "cors";
import express from "express";
import { createServer } from "node:http";
import { WebSocketServer } from "ws";
import { attachLsp } from "./lsp-proxy.js";
import { attachPreview } from "./preview-proxy.js";
import { previewWebviewRouter } from "./preview-webview.js";
import { seedIfMissing } from "./seed.js";
import { filesRouter } from "./routes/files.js";
import { previewRouter } from "./routes/preview.js";
import { projectsRouter } from "./routes/projects.js";
const PORT = Number(process.env.PORT) || 4000;
// Allow a single origin or a comma-separated list (self-hosted behind a
// reverse proxy / multiple frontends).
const FRONTEND_ORIGINS = (process.env.FRONTEND_ORIGIN ?? "http://localhost:1420")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
const server = createServer((_req, res) => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "ok", service: "typst-leaf-backend" }));
const app = express();
app.use(cors({ origin: FRONTEND_ORIGINS }));
app.use(express.json({ limit: "4mb" }));
app.get("/api/health", (_req, res) => {
res.json({ status: "ok", service: "typst-leaf-backend" });
});
const wss = new WebSocketServer({ server });
app.use("/api/projects", projectsRouter);
app.use("/api/projects", filesRouter);
app.use("/api/preview", previewRouter);
app.use("/preview-app", previewWebviewRouter);
wss.on("connection", (ws) => {
console.log("WebSocket client connected");
const server = createServer(app);
ws.on("message", (data) => {
console.log("Received:", data.toString());
const wss = new WebSocketServer({ noServer: true });
server.on("upgrade", (req, socket, head) => {
const { url } = req;
if (url === "/lsp" || url === "/preview") {
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit("connection", ws, req);
});
} else {
socket.destroy();
}
});
ws.on("close", () => {
console.log("WebSocket client disconnected");
});
wss.on("connection", (ws, req) => {
const path = new URL(req.url ?? "/", "http://x").pathname;
console.log(`[ws] connected: ${path}`);
ws.on("close", () => console.log(`[ws] disconnected: ${path}`));
ws.on("error", (err) => console.error(`[ws] error (${path}):`, err.message));
if (path === "/lsp") {
attachLsp(ws);
} else if (path === "/preview") {
attachPreview(ws);
}
});
async function main(): Promise<void> {
await seedIfMissing();
server.listen(PORT, () => {
console.log(`Backend running on http://localhost:${PORT}`);
});
// Graceful shutdown: close the HTTP/WS server so tinymist (spawned by the
// LSP proxy) is reaped and ports release. Without this, `tsx watch` restarts
// can orphan child processes.
const shutdown = (sig: string) => () => {
console.log(`[shutdown] received ${sig}`);
server.close(() => process.exit(0));
// Force-exit after a short grace if something hangs.
setTimeout(() => process.exit(0), 1500).unref();
};
process.on("SIGTERM", shutdown("SIGTERM"));
process.on("SIGINT", shutdown("SIGINT"));
}
main().catch((err) => {
console.error("Fatal startup error:", err);
process.exit(1);
});
+84
View File
@@ -0,0 +1,84 @@
import type { WebSocket } from "ws";
import {
createConnection,
forward,
type IConnection,
} from "vscode-ws-jsonrpc/server";
import {
WebSocketMessageReader,
WebSocketMessageWriter,
type IWebSocket,
} from "vscode-ws-jsonrpc/socket";
import { createServerProcess } from "vscode-ws-jsonrpc/server";
interface ActiveSession {
clientConn: IConnection;
}
let active: ActiveSession | null = null;
function adaptWs(ws: WebSocket): IWebSocket {
const messageListeners: Array<(data: unknown) => void> = [];
const errorListeners: Array<(reason: unknown) => void> = [];
const closeListeners: Array<(code: number, reason: string) => void> = [];
ws.on("message", (data) => {
const text = typeof data === "string" ? data : data.toString("utf8");
for (const cb of messageListeners) cb(text);
});
ws.on("error", (err) => {
for (const cb of errorListeners) cb(err);
});
ws.on("close", (code, reason) => {
for (const cb of closeListeners) cb(code, reason.toString());
});
return {
send(content) {
ws.send(content);
},
onMessage(cb) {
messageListeners.push(cb);
},
onError(cb) {
errorListeners.push(cb);
},
onClose(cb) {
closeListeners.push(cb);
},
dispose() {
try {
ws.close();
} catch {
/* ignore */
}
},
};
}
export function attachLsp(ws: WebSocket): void {
if (active) {
console.log("[lsp] takeover: closing previous session");
active.clientConn.dispose();
active = null;
}
const serverConn = createServerProcess("tinymist", "tinymist", ["lsp"]);
if (!serverConn) {
console.error("[lsp] failed to spawn tinymist");
ws.close(1011, "tinymist spawn failed");
return;
}
const iws = adaptWs(ws);
const wsReader = new WebSocketMessageReader(iws);
const wsWriter = new WebSocketMessageWriter(iws);
const clientConn = createConnection(wsReader, wsWriter, () => iws.dispose());
forward(clientConn, serverConn);
active = { clientConn };
const cleanup = () => {
if (active?.clientConn === clientConn) active = null;
};
clientConn.onClose(cleanup);
serverConn.onClose(cleanup);
}
+43
View File
@@ -0,0 +1,43 @@
import path from "node:path";
import { z } from "zod";
import { PROJECTS_ROOT } from "./config.js";
export const projectNameSchema = z
.string()
.min(1)
.max(64)
.regex(/^[A-Za-z0-9._-]+$/, "invalid project name")
.refine((n) => n !== "." && n !== "..", "project name not allowed");
export const relPathSchema = z
.string()
.min(1)
.max(512)
.refine((p) => !path.isAbsolute(p), "absolute paths not allowed")
.refine((p) => !p.includes("\0"), "NUL bytes not allowed")
.refine((p) => {
const parts = p.split(/[\\/]+/);
return !parts.includes("..");
}, "parent traversal not allowed");
export function projectDir(name: string): string {
const dir = path.resolve(PROJECTS_ROOT, name);
const rootReal = path.resolve(PROJECTS_ROOT) + path.sep;
if (!(dir + path.sep).startsWith(rootReal) && dir !== path.resolve(PROJECTS_ROOT)) {
throw new Error("project name escapes projects root");
}
return dir;
}
export function resolveFile(
project: string,
relPath: string,
): { abs: string; virtual: string } {
const projectAbs = projectDir(project);
const abs = path.resolve(projectAbs, relPath);
const projectReal = projectAbs + path.sep;
if (!(abs + path.sep).startsWith(projectReal) && abs !== projectAbs) {
throw new Error("path escapes project root");
}
return { abs, virtual: `${project}/${relPath.split(/[\\/]+/).join("/")}` };
}
+82
View File
@@ -0,0 +1,82 @@
import type { WebSocket, WebSocket as WSClient } from "ws";
import WebSocketImpl from "ws";
import { getPreviewHost, getPreviewPort } from "./preview-sink.js";
const RETRY_DELAY_MS = 250;
const RETRY_TIMEOUT_MS = 10000;
function dialPreview(port: number): Promise<WSClient> {
return new Promise((resolve, reject) => {
const url = `ws://${getPreviewHost()}:${port}`;
// typst-preview warns/rejects connections without an Origin header; the
// browser always sends one, so our backend dial must add it too.
const upstream = new WebSocketImpl(url, {
headers: { Origin: `http://${getPreviewHost()}:${port}` },
});
upstream.once("open", () => {
upstream.off("error", onError);
resolve(upstream);
});
const onError = (err: Error) => {
upstream.off("open", onOpen);
reject(err);
};
const onOpen = () => {};
upstream.once("open", onOpen);
upstream.once("error", onError);
});
}
async function connectWithRetry(port: number): Promise<WSClient> {
const start = Date.now();
for (;;) {
try {
return await dialPreview(port);
} catch {
if (Date.now() - start > RETRY_TIMEOUT_MS) {
throw new Error("preview server not reachable");
}
await new Promise((r) => setTimeout(r, RETRY_DELAY_MS));
}
}
}
function bridge(client: WebSocket, upstream: WSClient): void {
client.on("message", (data, isBinary) => {
if (upstream.readyState === upstream.OPEN) {
upstream.send(data, { binary: isBinary });
}
});
upstream.on("message", (data, isBinary) => {
if (client.readyState === client.OPEN) {
client.send(data, { binary: isBinary });
}
});
const close = () => {
try { client.close(); } catch { /* ignore */ }
try { upstream.close(); } catch { /* ignore */ }
};
client.on("close", close);
upstream.on("close", close);
client.on("error", close);
upstream.on("error", close);
}
export function attachPreview(ws: WebSocket): void {
const port = getPreviewPort();
if (port === null) {
console.error("[preview] no preview port registered yet");
try { ws.close(1011, "no preview server"); } catch { /* ignore */ }
return;
}
console.log(`[preview] client connected, dialing ws://${getPreviewHost()}:${port}`);
connectWithRetry(port)
.then((upstream) => {
console.log("[preview] upstream connected");
bridge(ws, upstream);
})
.catch((err) => {
console.error(`[preview] upstream unavailable: ${(err as Error).message}`);
try { ws.close(1011, "preview server unavailable"); } catch { /* ignore */ }
});
}
+27
View File
@@ -0,0 +1,27 @@
// Holds the preview-server port discovered by the client (returned by the
// `tinymist.startDefaultPreview` LSP command). The client learns the port
// over the LSP wire and relays it here via REST; the backend then proxies
// `/preview` (WS) and `/preview-app` (HTTP) to that port. This keeps the LSP
// proxy a pure byte-pipe while still letting the browser reach the preview
// through our origin (works for remote self-hosting).
const DEFAULT_HOST = process.env.TINYMIST_PREVIEW_HOST ?? "127.0.0.1";
let currentPort: number | null = null;
export function getPreviewHost(): string {
return DEFAULT_HOST;
}
export function getPreviewPort(): number | null {
return currentPort;
}
export function setPreviewPort(port: number): void {
currentPort = port;
}
export function previewBaseUrl(): string | null {
if (currentPort === null) return null;
return `http://${DEFAULT_HOST}:${currentPort}`;
}
+75
View File
@@ -0,0 +1,75 @@
import { Router, type Router as ExpressRouter } from "express";
import { previewBaseUrl } from "./preview-sink.js";
export const previewWebviewRouter: ExpressRouter = Router();
// Small script injected into the typst-preview webview HTML. The webview opens
// its data-plane WebSocket against its own origin (`ws://host/`); when we
// serve it from our origin we must repoint that socket at `/preview`, which
// our backend proxies to the (random) port tinymist assigned. Patching the
// global WebSocket constructor is reliable regardless of how the webview's
// minified RxJS builds the URL.
//
// We also override `matchMedia` so `(prefers-color-scheme: dark)` is always
// `false`, forcing the preview to render in light mode. tinymist's CLI
// `--invert-colors` flag sometimes doesn't propagate to the webview client;
// this is a belt-and-suspenders fix at the HTML level.
const REWRITE_SCRIPT = `<script>(function(){
// ---- WS path rewrite ----
var Orig = window.WebSocket;
function Patched(url, protocols){
try {
var u = new URL(url, location.origin);
if (u.pathname === "/" || u.pathname === "") u.pathname = "/preview";
url = u.toString();
} catch (e) {}
return protocols === undefined ? new Orig(url) : new Orig(url, protocols);
}
Patched.prototype = Orig.prototype;
Patched.CONNECTING = Orig.CONNECTING;
Patched.OPEN = Orig.OPEN;
Patched.CLOSING = Orig.CLOSING;
Patched.CLOSED = Orig.CLOSED;
window.WebSocket = Patched;
// ---- Force light mode ----
var _mm = window.matchMedia.bind(window);
window.matchMedia = function(q) {
var m = _mm(q);
if (/(prefers-color-scheme|color-scheme)/i.test(q)) {
return Object.defineProperties({}, {
matches: { get: function(){ return false; } },
media: { get: function(){ return q; } },
onchange: { get: function(){ return null; }, set: function(){} },
addEventListener: { value: function(){} },
removeEventListener: { value: function(){} },
});
}
return m;
};
})();</script>`;
previewWebviewRouter.get("/", async (_req, res) => {
const base = previewBaseUrl();
if (!base) {
res.status(503).send("preview server not started");
return;
}
try {
const upstream = await fetch(base + "/");
if (!upstream.ok) {
res.status(502).send(`preview server returned ${upstream.status}`);
return;
}
let html = await upstream.text();
if (html.includes("</head>")) {
html = html.replace("</head>", `${REWRITE_SCRIPT}</head>`);
} else {
// no <head>; prepend so the patch is installed before any app script runs
html = REWRITE_SCRIPT + html;
}
res.type("html").send(html);
} catch (err) {
res.status(502).send(`preview server unreachable: ${(err as Error).message}`);
}
});
+55
View File
@@ -0,0 +1,55 @@
import { Router, type Router as ExpressRouter } from "express";
import fs from "node:fs/promises";
import path from "node:path";
import { z } from "zod";
import { projectNameSchema, relPathSchema, resolveFile } from "../paths.js";
export const filesRouter: ExpressRouter = Router();
const projectParam = z.object({ project: projectNameSchema });
const fileQuery = z.object({ path: relPathSchema });
const writeBody = z.object({ content: z.string() });
filesRouter.get("/:project/file", async (req, res) => {
const p = projectParam.safeParse(req.params);
const q = fileQuery.safeParse(req.query);
if (!p.success || !q.success) {
res.status(400).json({
error: [...(p.error?.issues ?? []), ...(q.error?.issues ?? [])],
});
return;
}
try {
const { abs } = resolveFile(p.data.project, q.data.path);
const content = await fs.readFile(abs, "utf8");
const stat = await fs.stat(abs);
res.json({ path: q.data.path, content, mtime: stat.mtimeMs });
} catch (err) {
res.status(404).json({ error: (err as Error).message });
}
});
filesRouter.put("/:project/file", async (req, res) => {
const p = projectParam.safeParse(req.params);
const q = fileQuery.safeParse(req.query);
const b = writeBody.safeParse(req.body);
if (!p.success || !q.success || !b.success) {
res.status(400).json({
error: [
...(p.error?.issues ?? []),
...(q.error?.issues ?? []),
...(b.error?.issues ?? []),
],
});
return;
}
try {
const { abs } = resolveFile(p.data.project, q.data.path);
await fs.mkdir(path.dirname(abs), { recursive: true });
await fs.writeFile(abs, b.data.content, "utf8");
const stat = await fs.stat(abs);
res.json({ path: q.data.path, mtime: stat.mtimeMs });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
+22
View File
@@ -0,0 +1,22 @@
import { Router, type Router as ExpressRouter } from "express";
import { z } from "zod";
import { setPreviewPort } from "../preview-sink.js";
export const previewRouter: ExpressRouter = Router();
const portSchema = z.object({
port: z.number().int().min(1).max(65535),
});
// Called by the frontend after the LSP `tinymist.startDefaultPreview` command
// returns its (random) data-plane port. The backend stores it so that the
// `/preview` WS and `/preview-app` HTTP proxies know where to dial.
previewRouter.post("/sink", (req, res) => {
const parsed = portSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: parsed.error.issues });
return;
}
setPreviewPort(parsed.data.port);
res.json({ ok: true, port: parsed.data.port });
});
+107
View File
@@ -0,0 +1,107 @@
import { Router, type Router as ExpressRouter } from "express";
import fs from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { PROJECTS_ROOT } from "../config.js";
import { projectDir, projectNameSchema } from "../paths.js";
export const projectsRouter: ExpressRouter = Router();
const MAX_TREE_DEPTH = 6;
const MAX_TREE_ENTRIES = 2000;
projectsRouter.get("/", async (_req, res) => {
try {
await fs.mkdir(PROJECTS_ROOT, { recursive: true });
const entries = await fs.readdir(PROJECTS_ROOT, { withFileTypes: true });
const projects = entries
.filter((e) => e.isDirectory() && !e.name.startsWith("."))
.map((e) => e.name)
.sort();
res.json({ projects });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
projectsRouter.post("/:project", async (req, res) => {
const parsed = projectNameSchema.safeParse(req.params.project);
if (!parsed.success) {
res.status(400).json({ error: parsed.error.issues });
return;
}
const name = parsed.data;
const dir = path.join(PROJECTS_ROOT, name);
try {
await fs.mkdir(dir, { recursive: true });
const main = path.join(dir, "main.typ");
try {
await fs.access(main);
} catch {
await fs.writeFile(
main,
// The Typst heading `=` must be followed by a space, then the title.
`= ${name}\n\nHello from a fresh project.\n`,
"utf8",
);
}
res.status(201).json({ project: name });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
projectsRouter.get("/:project", async (req, res) => {
const parsed = projectNameSchema.safeParse(req.params.project);
if (!parsed.success) {
res.status(400).json({ error: parsed.error.issues });
return;
}
const dir = projectDir(parsed.data);
try {
await fs.access(dir);
} catch {
res.status(404).json({ error: "project not found" });
return;
}
res.json({ project: parsed.data, uri: pathToFileURL(dir).href });
});
projectsRouter.get("/:project/tree", async (req, res) => {
const parsed = projectNameSchema.safeParse(req.params.project);
if (!parsed.success) {
res.status(400).json({ error: parsed.error.issues });
return;
}
const dir = projectDir(parsed.data);
try {
await fs.access(dir);
} catch {
res.status(404).json({ error: "project not found" });
return;
}
const out: { path: string; mtime: number }[] = [];
const budget = { left: MAX_TREE_ENTRIES };
async function walk(rel: string, depth: number): Promise<void> {
if (depth > MAX_TREE_DEPTH || budget.left <= 0) return;
const abs = path.join(dir, rel);
const entries = await fs.readdir(abs, { withFileTypes: true });
for (const entry of entries) {
if (budget.left <= 0) return;
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
const childRel = rel ? `${rel}/${entry.name}` : entry.name;
const childAbs = path.join(abs, entry.name);
const stat = await fs.stat(childAbs);
if (entry.isDirectory()) {
await walk(childRel, depth + 1);
} else if (entry.isFile() && entry.name.endsWith(".typ")) {
out.push({ path: childRel, mtime: stat.mtimeMs });
budget.left -= 1;
}
}
}
await walk("", 0);
res.json({ project: parsed.data, files: out });
});
+44
View File
@@ -0,0 +1,44 @@
import fs from "node:fs/promises";
import path from "node:path";
import { PROJECTS_ROOT } from "./config.js";
const SEED_PROJECT = "hello";
const SEED_FILE = "main.typ";
const SEED_CONTENT = `= Hello, typst-leaf!
Welcome to your first project. Edit this file on the left;
the preview on the right updates as you type.
== Features
- Instant preview powered by *tinymist*
- Live diagnostics and autocomplete
- A real editor in your browser
== Math
The quadratic formula:
$ x = (-b +- sqrt(b^2 - 4 a c)) / (2 a) $
== Code listing
\`\`\`typ
#let greet(name) = [Hello, *#name*!]
#greet("world")
\`\`\`
`;
export async function seedIfMissing(): Promise<void> {
await fs.mkdir(PROJECTS_ROOT, { recursive: true });
const projectDir = path.join(PROJECTS_ROOT, SEED_PROJECT);
const file = path.join(projectDir, SEED_FILE);
try {
await fs.access(file);
} catch {
await fs.mkdir(projectDir, { recursive: true });
await fs.writeFile(file, SEED_CONTENT, "utf8");
console.log(`[seed] created ${SEED_PROJECT}/${SEED_FILE}`);
}
}
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tauri + React + Typescript</title>
<title>typst-leaf</title>
</head>
<body>
+1 -2
View File
@@ -14,11 +14,10 @@
"@monaco-editor/react": "^4.7.0",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2",
"monaco-languageclient": "^10.7.0",
"monaco-editor": "^0.55.1",
"monaco-vim": "^0.4.4",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"vscode-ws-jsonrpc": "^3.5.0",
"zod": "^3.24.5"
},
"devDependencies": {
@@ -1,6 +1,6 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "typst-leaf-frontend",
"productName": "typst-leaf",
"version": "0.1.0",
"identifier": "com.nav.typst-leaf-frontend",
"build": {
+46 -3
View File
@@ -1,5 +1,48 @@
function App() {
return <div className="h-screen w-screen bg-zinc-900 text-zinc-100" />;
import { AppProvider, useApp } from "./app-state";
import { EditorView } from "./components/Editor";
import { FileTree } from "./components/FileTree";
import { Preview } from "./components/Preview";
import { ProjectPicker } from "./components/ProjectPicker";
function Shell() {
const { state, lsp } = useApp();
return (
<div className="h-screen w-screen bg-white text-zinc-800 flex">
<aside className="w-60 border-r border-zinc-200 flex flex-col bg-zinc-50">
<div className="px-3 py-2 border-b border-zinc-200 text-sm font-semibold flex items-center gap-2">
<span>typst-leaf</span>
<span className="ml-auto text-xs font-normal text-zinc-400">
{lsp.status === "ready"
? "●"
: lsp.status === "connecting"
? "◌"
: lsp.status === "error"
? "✕"
: "○"}
</span>
</div>
<ProjectPicker />
<div className="flex-1 overflow-auto">
<FileTree />
</div>
{state.error && (
<div className="p-2 text-xs text-red-500 border-t border-zinc-200">
{state.error}
</div>
)}
</aside>
<main className="flex-1 flex min-w-0">
<EditorView />
<Preview />
</main>
</div>
);
}
export default App;
export default function App() {
return (
<AppProvider>
<Shell />
</AppProvider>
);
}
+53
View File
@@ -0,0 +1,53 @@
import type {
FileResponse,
Project,
TreeResponse,
} from "./types";
const API = "/api";
async function jsonOrThrow<T>(res: Response): Promise<T> {
if (!res.ok) {
const text = await res.text();
throw new Error(`${res.status} ${res.statusText}: ${text}`);
}
return res.json() as Promise<T>;
}
export const api = {
listProjects: () =>
fetch(`${API}/projects`).then((r) =>
jsonOrThrow<{ projects: string[] }>(r).then((d) => d.projects),
),
createProject: (name: string) =>
fetch(`${API}/projects/${encodeURIComponent(name)}`, { method: "POST" }).then(
(r) => jsonOrThrow<Project>(r),
),
getProject: (name: string) =>
fetch(`${API}/projects/${encodeURIComponent(name)}`).then((r) =>
jsonOrThrow<Project>(r),
),
getTree: (name: string) =>
fetch(`${API}/projects/${encodeURIComponent(name)}/tree`).then((r) =>
jsonOrThrow<TreeResponse>(r),
),
getFile: (project: string, path: string) =>
fetch(
`${API}/projects/${encodeURIComponent(project)}/file?path=${encodeURIComponent(path)}`,
).then((r) => jsonOrThrow<FileResponse>(r)),
putFile: (project: string, path: string, content: string) =>
fetch(
`${API}/projects/${encodeURIComponent(project)}/file?path=${encodeURIComponent(path)}`,
{
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ content }),
},
).then((r) => jsonOrThrow<FileResponse>(r)),
sinkPreviewPort: (port: number) =>
fetch(`${API}/preview/sink`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ port }),
}).then((r) => jsonOrThrow<{ ok: boolean; port: number }>(r)),
};
+177
View File
@@ -0,0 +1,177 @@
import {
createContext,
useContext,
useEffect,
useReducer,
useRef,
useState,
type ReactNode,
} from "react";
import { api } from "./api";
import { loadMonaco } from "./monaco-setup";
import { startLsp, startPreview, type RawLspClient } from "./lsp";
import type { Monaco } from "@monaco-editor/react";
import type { FileEntry } from "./types";
interface State {
projects: string[];
currentProject: string | null;
currentUri: string | null;
tree: FileEntry[];
openFile: string | null;
loading: boolean;
error: string | null;
}
type Action =
| { type: "setProjects"; projects: string[] }
| { type: "selectProject"; project: string; uri: string }
| { type: "setTree"; tree: FileEntry[] }
| { type: "openFile"; path: string | null }
| { type: "loading"; loading: boolean }
| { type: "error"; error: string | null };
const initial: State = {
projects: [],
currentProject: null,
currentUri: null,
tree: [],
openFile: null,
loading: false,
error: null,
};
function reducer(state: State, action: Action): State {
switch (action.type) {
case "setProjects":
return { ...state, projects: action.projects };
case "selectProject":
return {
...state,
currentProject: action.project,
currentUri: action.uri,
tree: [],
openFile: null,
};
case "setTree":
return { ...state, tree: action.tree };
case "openFile":
return { ...state, openFile: action.path };
case "loading":
return { ...state, loading: action.loading };
case "error":
return { ...state, error: action.error };
}
}
interface LspState {
monaco: Monaco | null;
client: RawLspClient | null;
status: "idle" | "connecting" | "ready" | "error";
previewReady: boolean;
error: string | null;
}
const AppContext = createContext<{
state: State;
dispatch: React.Dispatch<Action>;
lsp: LspState;
} | null>(null);
export function useApp() {
const ctx = useContext(AppContext);
if (!ctx) throw new Error("useApp outside provider");
return ctx;
}
export function AppProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(reducer, initial);
const [lsp, setLsp] = useState<LspState>({
monaco: null,
client: null,
status: "idle",
previewReady: false,
error: null,
});
const sessionRef = useRef<{ dispose: () => void } | null>(null);
useEffect(() => {
loadMonaco()
.then((monaco) => setLsp((s) => ({ ...s, monaco })))
.catch((e) =>
setLsp((s) => ({ ...s, status: "error", error: String(e) })),
);
}, []);
useEffect(() => {
api
.listProjects()
.then((projects) => dispatch({ type: "setProjects", projects }))
.catch((e) => dispatch({ type: "error", error: String(e) }));
}, []);
useEffect(() => {
if (!state.currentProject || !lsp.monaco) return;
const sessionRefLocal = sessionRef;
let cancelled = false;
const project = state.currentProject;
const uri = state.currentUri!;
setLsp((s) => ({ ...s, status: "connecting", previewReady: false }));
startLsp(lsp.monaco, project, uri, async (client) => {
// First file opened: ask tinymist for a live preview and relay the
// (random) data-plane port to the backend so /preview-app can proxy.
try {
const port = await startPreview(client);
await api.sinkPreviewPort(port);
setLsp((s) => ({ ...s, previewReady: true }));
} catch (e) {
console.error("preview start failed:", String(e));
}
})
.then((session) => {
if (cancelled) {
session.dispose();
return;
}
sessionRefLocal.current = session;
setLsp({
monaco: lsp.monaco,
client: session.client,
status: "ready",
previewReady: false,
error: null,
});
})
.catch((e) => {
console.error("[lsp] startLsp failed:", e);
if (!cancelled) {
setLsp({
monaco: lsp.monaco,
client: null,
status: "error",
previewReady: false,
error: e instanceof Error ? `${e.name}: ${e.message}` : String(e),
});
}
});
return () => {
cancelled = true;
sessionRefLocal.current?.dispose();
sessionRefLocal.current = null;
};
}, [state.currentProject, state.currentUri, lsp.monaco]);
useEffect(() => {
if (!state.currentProject) return;
api
.getTree(state.currentProject)
.then((t) => dispatch({ type: "setTree", tree: t.files }))
.catch((e) => dispatch({ type: "error", error: String(e) }));
}, [state.currentProject]);
return (
<AppContext.Provider value={{ state, dispatch, lsp }}>
{children}
</AppContext.Provider>
);
}
@@ -0,0 +1,138 @@
import Editor, { type Monaco, type OnMount } from "@monaco-editor/react";
import { useEffect, useRef, useState } from "react";
import { api } from "../api";
import { useApp } from "../app-state";
type ITextModel = Monaco["editor"]["ITextModel"];
type IStandaloneCodeEditor = Monaco["editor"]["IStandaloneCodeEditor"];
export function EditorView() {
const { state, lsp } = useApp();
const monaco = lsp.monaco;
const [model, setModel] = useState<ITextModel | null>(null);
const [error, setError] = useState<string | null>(null);
const editorRef = useRef<IStandaloneCodeEditor | null>(null);
const project = state.currentProject;
const uri = state.currentUri;
const file = state.openFile;
const savingRef = useRef(false);
// Create/replace the Monaco model for the open file. Monaco comes from the
// app-level loader (no per-component polling); the effect re-runs when the
// file, project, or monaco instance changes.
useEffect(() => {
if (!monaco || !project || !file || !uri) {
setModel((m: ITextModel | null) => {
m?.dispose();
return null;
});
return;
}
let cancelled = false;
setError(null);
api
.getFile(project, file)
.then((f) => {
if (cancelled) return;
const base = uri.endsWith("/") ? uri : uri + "/";
const docUri = base + file;
// Create the model OUTSIDE the setState updater: React StrictMode
// double-invokes updaters with the same `prev`, and createModel is a
// side effect (the second call would crash on "model already exists").
// Disposing any stale model with this URI also keeps remounts idempotent.
const uriObj = monaco.Uri.parse(docUri);
monaco.editor.getModel(uriObj)?.dispose();
const next = monaco.editor.createModel(f.content, "typst", uriObj);
setModel((prev: ITextModel | null) => {
prev?.dispose();
return next;
});
// Creating the model triggers the LSP `didOpen` (via bindModels in
// app-state), which in turn starts the live preview. No disk write
// needed — the preview is driven by the LSP document state.
})
.catch((e) => !cancelled && setError(String(e)));
return () => {
cancelled = true;
};
}, [monaco, project, file, uri]);
useEffect(() => {
if (editorRef.current && model) {
editorRef.current.setModel(model);
}
}, [model]);
async function save() {
if (!project || !file || !model || savingRef.current) return;
savingRef.current = true;
try {
await api.putFile(project, file, model.getValue());
} catch (e) {
setError(String(e));
} finally {
savingRef.current = false;
}
}
const handleMount: OnMount = (editor, m) => {
editorRef.current = editor;
if (model) editor.setModel(model);
editor.addCommand(m.KeyMod.CtrlCmd | m.KeyCode.KeyS, () => {
void save();
});
};
if (!project) {
return (
<div className="flex-1 flex items-center justify-center text-zinc-400 text-sm">
Select a project to begin.
</div>
);
}
if (!file) {
return (
<div className="flex-1 flex items-center justify-center text-zinc-400 text-sm">
Open a file from the sidebar.
</div>
);
}
if (error) {
return (
<div className="flex-1 flex items-center justify-center text-red-400 text-sm p-4">
{error}
</div>
);
}
return (
<div className="flex-1 flex flex-col min-h-0">
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-zinc-200 text-xs text-zinc-500 font-mono">
<span>{file}</span>
<span className="ml-auto">
<button
type="button"
onClick={() => void save()}
className="bg-white hover:bg-zinc-100 text-zinc-700 border border-zinc-300 px-2 py-0.5 rounded"
>
Save (Ctrl+S)
</button>
</span>
</div>
<div className="flex-1 min-h-0">
<Editor
defaultLanguage="typst"
theme="vs"
onMount={handleMount}
options={{
fontSize: 14,
minimap: { enabled: false },
wordWrap: "on",
automaticLayout: true,
tabSize: 2,
}}
/>
</div>
</div>
);
}
@@ -0,0 +1,36 @@
import { useApp } from "../app-state";
export function FileTree() {
const { state, dispatch } = useApp();
if (!state.currentProject) {
return (
<div className="p-2 text-xs text-zinc-400">select a project</div>
);
}
if (state.tree.length === 0) {
return <div className="p-2 text-xs text-zinc-400">no .typ files</div>;
}
return (
<div className="flex flex-col gap-0.5 p-2">
<div className="text-xs uppercase tracking-wide text-zinc-400 mb-1">
Files
</div>
{state.tree.map((f) => (
<button
key={f.path}
type="button"
onClick={() => dispatch({ type: "openFile", path: f.path })}
className={`text-left text-sm px-2 py-1 rounded font-mono ${
f.path === state.openFile
? "bg-emerald-100 text-emerald-800 font-medium"
: "hover:bg-zinc-100 text-zinc-600"
}`}
>
{f.path}
</button>
))}
</div>
);
}
@@ -0,0 +1,39 @@
import { useApp } from "../app-state";
export function Preview() {
const { state, lsp } = useApp();
let status = "idle";
if (!state.currentProject) status = "no project";
else if (lsp.status !== "ready") status = `LSP ${lsp.status}`;
else if (!lsp.previewReady) status = "starting preview…";
else status = "live";
return (
<div className="w-1/2 border-l border-zinc-200 flex flex-col bg-white">
<div className="px-3 py-1.5 border-b border-zinc-200 text-xs text-zinc-500 font-mono flex items-center gap-2">
<span>Preview</span>
<span className="ml-auto text-zinc-400">{status}</span>
</div>
<div className="flex-1 bg-zinc-50 relative">
{state.currentProject && lsp.status === "ready" && lsp.previewReady ? (
<iframe
key={state.currentProject}
src="/preview-app"
title="Typst preview"
className="absolute inset-0 w-full h-full bg-white"
/>
) : (
<div className="absolute inset-0 flex flex-col items-center justify-center text-zinc-400 text-sm p-6 gap-2">
<span>{status}</span>
{lsp.status === "error" && lsp.error && (
<pre className="mt-2 max-w-full overflow-auto text-xs text-red-500 bg-red-50 p-3 rounded whitespace-pre-wrap break-all">
{lsp.error}
</pre>
)}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,71 @@
import { useState } from "react";
import { api } from "../api";
import { useApp } from "../app-state";
export function ProjectPicker() {
const { state, dispatch } = useApp();
const [name, setName] = useState("");
async function select(p: string) {
if (p === state.currentProject) return;
const info = await api.getProject(p);
dispatch({ type: "selectProject", project: info.project, uri: info.uri });
}
async function create() {
const n = name.trim();
if (!n) return;
await api.createProject(n);
setName("");
const projects = await api.listProjects();
dispatch({ type: "setProjects", projects });
const info = await api.getProject(n);
dispatch({ type: "selectProject", project: info.project, uri: info.uri });
}
return (
<div className="flex flex-col gap-2 p-2 border-b border-zinc-200">
<div className="text-xs uppercase tracking-wide text-zinc-400">
Projects
</div>
<ul className="flex flex-col gap-0.5">
{state.projects.map((p) => (
<li key={p}>
<button
type="button"
onClick={() => select(p)}
className={`w-full text-left text-sm px-2 py-1 rounded ${
p === state.currentProject
? "bg-emerald-100 text-emerald-800 font-medium"
: "hover:bg-zinc-100 text-zinc-600"
}`}
>
{p}
</button>
</li>
))}
{state.projects.length === 0 && (
<li className="text-xs text-zinc-400 px-2">no projects yet</li>
)}
</ul>
<div className="flex gap-1">
<input
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") create();
}}
placeholder="new-project"
className="flex-1 bg-white text-zinc-800 text-sm px-2 py-1 rounded border border-zinc-300 focus:outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 placeholder:text-zinc-400"
/>
<button
type="button"
onClick={create}
className="text-sm bg-emerald-600 hover:bg-emerald-500 text-white px-2 py-1 rounded"
>
New
</button>
</div>
</div>
);
}
+555
View File
@@ -0,0 +1,555 @@
import type { Monaco } from "@monaco-editor/react";
// ============================================================================
// Raw LSP client over WebSocket.
//
// We deliberately avoid monaco-languageclient / vscode-languageclient here.
// monaco-languageclient v10's low-level client requires bootstrapping the
// entire @codingame/monaco-vscode-api service stack (the wrapper does this),
// which is heavy and fragile. Driving LSP directly is ~300 lines, has no
// magic, and the model<->LSP binding + Monaco provider registration are done
// explicitly. The wire protocol is just JSON-RPC messages, one per WebSocket
// text frame (raw JSON, no Content-Length framing — that's only for stdio).
// ============================================================================
interface LspSession {
client: RawLspClient;
dispose: () => void;
}
type NotifHandler = (params: unknown) => void;
type ReqHandler = (params: unknown) => Promise<unknown> | unknown;
export class RawLspClient {
private ws: WebSocket;
private nextId = 1;
private pending = new Map<
number,
{ resolve: (v: unknown) => void; reject: (e: Error) => void }
>();
private notifHandlers = new Map<string, NotifHandler[]>();
private reqHandlers = new Map<string, ReqHandler>();
constructor(ws: WebSocket) {
this.ws = ws;
ws.addEventListener("message", (e) => this.onMessage(e));
}
private onMessage(e: MessageEvent): void {
let msg: Record<string, unknown>;
try {
msg = JSON.parse(
typeof e.data === "string" ? e.data : e.data.toString(),
);
} catch {
return;
}
const hasMethod = typeof msg.method === "string";
const id = msg.id;
const hasId = id !== undefined && id !== null;
if (hasMethod && hasId) {
// request from server -> handle and reply
const handler = this.reqHandlers.get(msg.method as string);
Promise.resolve(handler ? handler(msg.params) : undefined).then(
(result) =>
this.send({ jsonrpc: "2.0", id, result: result ?? null }),
(err) =>
this.send({
jsonrpc: "2.0",
id,
error: {
code: -32603,
message: err instanceof Error ? err.message : String(err),
},
}),
);
} else if (hasMethod) {
// notification from server
const handlers = this.notifHandlers.get(msg.method as string);
if (handlers) for (const h of handlers) h(msg.params);
} else if (hasId) {
// response to a request we sent
const p = this.pending.get(id as number);
if (p) {
this.pending.delete(id as number);
if (msg.error) {
p.reject(
new Error(
(msg.error as { message: string }).message ?? "LSP error",
),
);
} else {
p.resolve(msg.result);
}
}
}
}
private send(msg: unknown): void {
if (this.ws.readyState === this.ws.OPEN) {
this.ws.send(JSON.stringify(msg));
}
}
notify(method: string, params?: unknown): void {
this.send({ jsonrpc: "2.0", method, params });
}
sendRequest(method: string, params?: unknown): Promise<unknown> {
const id = this.nextId++;
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject });
this.send({ jsonrpc: "2.0", id, method, params });
});
}
onNotification(method: string, handler: NotifHandler): void {
const arr = this.notifHandlers.get(method) ?? [];
arr.push(handler);
this.notifHandlers.set(method, arr);
}
onRequest(method: string, handler: ReqHandler): void {
this.reqHandlers.set(method, handler);
}
dispose(): void {
try {
this.ws.close();
} catch {
/* ignore */
}
for (const [, p] of this.pending) p.reject(new Error("disposed"));
this.pending.clear();
this.notifHandlers.clear();
this.reqHandlers.clear();
}
}
// ---------- LSP <-> Monaco type helpers ----------
interface LspPosition {
line: number;
character: number;
}
interface LspRange {
start: LspPosition;
end: LspPosition;
}
interface LspDiagnostic {
message: string;
severity?: number;
source?: string;
range: LspRange;
}
function toMonacoRange(
monaco: Monaco,
r: LspRange | undefined,
): Monaco["ranges"]["IRange"] {
if (!r) return new monaco.Range(1, 1, 1, 1);
return new monaco.Range(
r.start.line + 1,
r.start.character + 1,
r.end.line + 1,
r.end.character + 1,
);
}
function toMarker(d: LspDiagnostic): Monaco["editor"]["IMarkerData"] {
// LSP severity: 1=Error 2=Warning 3=Info 4=Hint
// MarkerSeverity: Error=8 Warning=4 Info=2 Hint=1
const sev =
d.severity === 1 ? 8 : d.severity === 2 ? 4 : d.severity === 3 ? 2 : 1;
return {
message: d.message,
severity: sev as Monaco["MarkerSeverity"],
source: d.source ?? "typst",
startLineNumber: d.range.start.line + 1,
startColumn: d.range.start.character + 1,
endLineNumber: d.range.end.line + 1,
endColumn: d.range.end.character + 1,
};
}
// ---------- Model <-> LSP binding ----------
interface ModelBinding {
dispose(): void;
}
function bindModels(
client: RawLspClient,
monaco: Monaco,
onFirstOpen?: () => void,
): ModelBinding {
const versions = new Map<string, number>();
const opened = new Set<string>();
const disposers: Array<() => void> = [];
const perModel = new Map<string, () => void>();
let firstOpenFired = false;
const openModel = (model: Monaco["editor"]["ITextModel"]) => {
const u = model.uri.toString();
// The @monaco-editor/react <Editor> creates default models with
// inmemory:// URIs. We only sync models that have file:// URIs
// (the ones we explicitly create), otherwise tinymist panics trying
// to resolve the URI.
if (model.uri.scheme !== "file") return;
if (opened.has(u) || model.getLanguageId() !== "typst") return;
opened.add(u);
versions.set(u, 1);
client.notify("textDocument/didOpen", {
textDocument: {
uri: u,
languageId: "typst",
version: 1,
text: model.getValue(),
},
});
const onChange = model.onDidChangeContent(() => {
const v = (versions.get(u) ?? 1) + 1;
versions.set(u, v);
client.notify("textDocument/didChange", {
textDocument: { uri: u, version: v },
contentChanges: [{ text: model.getValue() }],
});
});
perModel.set(u, () => onChange.dispose());
if (!firstOpenFired) {
firstOpenFired = true;
onFirstOpen?.();
}
};
const closeModel = (uri: string) => {
if (!opened.has(uri)) return;
opened.delete(uri);
versions.delete(uri);
perModel.get(uri)?.();
perModel.delete(uri);
client.notify("textDocument/didClose", { textDocument: { uri } });
};
for (const m of monaco.editor.getModels()) openModel(m);
disposers.push(
monaco.editor.onDidCreateModel((m: Monaco["editor"]["ITextModel"]) =>
openModel(m),
),
);
disposers.push(
monaco.editor.onWillDisposeModel((m: Monaco["editor"]["ITextModel"]) => {
if (m.uri.scheme === "file") closeModel(m.uri.toString());
}),
);
return {
dispose() {
for (const d of disposers) d();
for (const [, d] of perModel) d();
},
};
}
// ---------- Monaco provider registration (LSP-backed) ----------
function registerProviders(
client: RawLspClient,
monaco: Monaco,
capabilities: Record<string, { [k: string]: unknown } | undefined>,
): Monaco["IDisposable"][] {
const disposables: Monaco["IDisposable"][] = [];
const doc = (model: Monaco["editor"]["ITextModel"]) => ({
textDocument: { uri: model.uri.toString() },
});
const pos = (p: Monaco["Position"]) => ({
line: p.lineNumber - 1,
character: p.column - 1,
});
type ITextModel = Monaco["editor"]["ITextModel"];
type IPosition = Monaco["Position"];
if (capabilities.completionProvider) {
const triggers =
(capabilities.completionProvider.triggerCharacters as string[] | undefined) ??
[];
disposables.push(
monaco.languages.registerCompletionItemProvider("typst", {
triggerCharacters: triggers,
provideCompletionItems: async (model: ITextModel, position: IPosition) => {
const r = await client.sendRequest("textDocument/completion", {
...doc(model),
position: pos(position),
});
const items = Array.isArray(r)
? r
: ((r as { items?: unknown[] })?.items ?? []);
const word = model.getWordUntilPosition(position);
const range = new monaco.Range(
position.lineNumber,
word.startColumn,
position.lineNumber,
word.endColumn,
);
return {
suggestions: (items as Record<string, unknown>[]).map((it) => ({
label: it.label as string,
kind: (it.kind as number) ?? 1,
detail: it.detail as string,
documentation: (
typeof it.documentation === "string"
? it.documentation
: (it.documentation as { value?: string })?.value
) as string,
insertText: (it.insertText as string) ?? (it.label as string),
insertTextRules:
it.insertTextFormat === 2
? monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet
: undefined,
range,
})),
};
},
}),
);
}
if (capabilities.hoverProvider) {
disposables.push(
monaco.languages.registerHoverProvider("typst", {
provideHover: async (model: ITextModel, position: IPosition) => {
const r = (await client.sendRequest("textDocument/hover", {
...doc(model),
position: pos(position),
})) as { contents?: unknown[]; range?: LspRange } | null;
if (!r) return undefined;
const contents = (r.contents ?? []).map((c) =>
typeof c === "string"
? c
: (c as { value?: string })?.value ?? "",
);
return {
range: toMonacoRange(monaco, r.range),
contents: contents.filter(Boolean),
};
},
}),
);
}
const defProv = async (
model: Monaco["editor"]["ITextModel"],
position: Monaco["Position"],
) => {
const r = await client.sendRequest("textDocument/definition", {
...doc(model),
position: pos(position),
});
const locs = (Array.isArray(r) ? r : r ? [r] : []) as {
uri: string;
range: LspRange;
}[];
return locs.map((l) => ({
uri: monaco.Uri.parse(l.uri),
range: toMonacoRange(monaco, l.range),
}));
};
if (capabilities.definitionProvider) {
disposables.push(
monaco.languages.registerDefinitionProvider("typst", {
provideDefinition: defProv,
}),
);
}
if (capabilities.referencesProvider) {
disposables.push(
monaco.languages.registerReferenceProvider("typst", {
provideReferences: defProv,
}),
);
}
if (capabilities.documentSymbolProvider) {
disposables.push(
monaco.languages.registerDocumentSymbolProvider("typst", {
provideDocumentSymbols: async (model: ITextModel) => {
const r = await client.sendRequest(
"textDocument/documentSymbol",
doc(model),
);
return ((r as Record<string, unknown>[]) ?? []).map((s) => ({
name: s.name as string,
detail: (s.detail as string) ?? "",
kind: (s.kind as number) ?? 1,
range: toMonacoRange(monaco, s.range as LspRange),
selectionRange: toMonacoRange(monaco, s.selectionRange as LspRange),
}));
},
}),
);
}
if (capabilities.documentFormattingProvider) {
disposables.push(
monaco.languages.registerDocumentFormattingEditProvider("typst", {
provideDocumentFormattingEdits: async (model: ITextModel) => {
const r = await client.sendRequest("textDocument/formatting", {
...doc(model),
options: { tabSize: 2, insertSpaces: true },
});
return ((r as { range: LspRange; newText: string }[]) ?? []).map(
(e) => ({
range: toMonacoRange(monaco, e.range),
text: e.newText,
}),
);
},
}),
);
}
return disposables;
}
// ---------- Session bootstrap ----------
export async function startLsp(
monaco: Monaco,
projectName: string,
projectUri: string,
onFirstOpen?: (client: RawLspClient) => void,
): Promise<LspSession> {
const wsUrl = `${location.protocol === "https:" ? "wss:" : "ws:"}//${location.host}/lsp`;
const ws = new WebSocket(wsUrl);
await new Promise<void>((resolve, reject) => {
const onOpen = () => {
ws.removeEventListener("open", onOpen);
ws.removeEventListener("error", onError);
resolve();
};
const onError = (e: Event) => {
ws.removeEventListener("open", onOpen);
ws.removeEventListener("error", onError);
reject(
new Error(
`WebSocket to ${wsUrl} failed: ${(e as ErrorEvent).message ?? "unknown"}`,
),
);
};
ws.addEventListener("open", onOpen);
ws.addEventListener("error", onError);
});
const client = new RawLspClient(ws);
// Diagnostics → Monaco markers (using the lowlevel `setModelMarkers`
// because esbuild's treeshaking strips `createDiagnosticCollection`
// from standaloneLanguages when bundling monacoeditor for the browser).
const diagOwner = "typst-leaf";
client.onNotification("textDocument/publishDiagnostics", (params) => {
const p = params as { uri: string; diagnostics: LspDiagnostic[] };
const model = monaco.editor.getModel(monaco.Uri.parse(p.uri));
if (model) {
monaco.editor.setModelMarkers(
model,
diagOwner,
p.diagnostics.map(toMarker),
);
}
});
// Server -> client requests tinymist issues during init
client.onRequest("workspace/configuration", (params) => {
const items = (params as { items?: unknown[] }).items ?? [];
return items.map(() => ({}));
});
client.onRequest("workspace/workspaceFolders", () => [
{ uri: projectUri, name: projectName },
]);
client.onRequest("client/registerCapability", () => undefined);
client.onRequest("window/workDoneProgress/create", () => undefined);
// LSP handshake
// Force typst-preview into light mode by passing `dark` theme as
// `--invert-colors=never` during initialization. The preview
// `x-preview` input carries a `theme` (string) and a `version` (int);
// tinymist passes them through to the Typst document as `sys.inputs`
// so templates can read them. We set a light theme here so the
// rendering stays light from the very first compile.
const initResult = (await client.sendRequest("initialize", {
processId: null,
rootUri: projectUri,
capabilities: {
workspace: { configuration: true, workspaceFolders: true },
textDocument: {
synchronization: { openClose: true, change: 1 },
completion: {
completionItem: {
snippet: true,
documentationFormat: ["markdown", "plaintext"],
},
},
hover: { contentFormat: ["markdown", "plaintext"] },
signatureHelp: {},
definition: {},
references: {},
documentSymbol: {},
formatting: {},
publishDiagnostics: { relatedInformation: true },
},
},
trace: "off",
workspaceFolders: [{ uri: projectUri, name: projectName }],
initializationOptions: {
"tinymist.preview.browsing.args": [
"--data-plane-host=127.0.0.1:0",
"--invert-colors=never",
],
"tinymist.preview.invert_colors": "never",
},
})) as { capabilities?: Record<string, { [k: string]: unknown } | undefined> };
client.notify("initialized", {});
const capabilities = initResult.capabilities ?? {};
const providers = registerProviders(client, monaco, capabilities);
const binding = bindModels(client, monaco, () => onFirstOpen?.(client));
return {
client,
dispose: () => {
try {
binding.dispose();
} catch {
/* ignore */
}
for (const p of providers) {
try {
p.dispose();
} catch {
/* ignore */
}
}
try {
client.dispose();
} catch {
/* ignore */
}
},
};
}
// Ask tinymist to start a live (LSP-driven) preview and return the random
// data-plane port it assigned. The caller relays this port to the backend
// (POST /api/preview/sink) so the backend can proxy the preview webview.
export async function startPreview(
client: RawLspClient,
): Promise<number> {
const result = (await client.sendRequest("workspace/executeCommand", {
command: "tinymist.startDefaultPreview",
arguments: [{}],
})) as { dataPlanePort?: number } | null;
const port = result?.dataPlanePort;
if (typeof port !== "number") {
throw new Error("tinymist did not return a preview port");
}
return port;
}
+76
View File
@@ -0,0 +1,76 @@
import { loader, type Monaco } from "@monaco-editor/react";
import * as monaco from "monaco-editor";
import editorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker";
let initialized = false;
let monacoPromise: Promise<Monaco> | null = null;
// A Monarch tokenizer for Typst covering the common surface (headings,
// script keywords, math, comments, emphasis, strings, raw blocks). This is
// syntax-only; the LSP additionally provides diagnostics and (optionally)
// semantic tokens.
const typstLanguageDefinition: monaco.languages.IMonarchLanguage = {
defaultToken: "",
tokenPostfix: ".typst",
brackets: [
{ open: "(", close: ")", token: "delimiter.parenthesis" },
{ open: "[", close: "]", token: "delimiter.square" },
{ open: "{", close: "}", token: "delimiter.curly" },
],
tokenizer: {
root: [
[/^=+\s.*$/, "keyword.heading"],
[/\*[^*]+\*/, "string.emphasis"],
[/_[^_]+_/, "string.emphasis"],
[/\/\/.*$/, "comment"],
[/\/\*/, "comment", "@comment"],
[/#\w+/, "keyword"], // #set, #let, #show, #if, ...
[/\$/, { token: "string", next: "@math" }],
[/"/, "string", "@string"],
[/[{}()\[\]]/, "@brackets"],
],
comment: [
[/\*\//, "comment", "@pop"],
[/[^*]+/, "comment"],
[/\*/, "comment"],
],
math: [
[/\$/, { token: "string", next: "@pop" }],
[/\\[a-zA-Z]+/, "keyword"],
[/[a-zA-Z]+/, "variable"],
[/[^\$]/, "string"],
],
string: [
[/[^"]/, "string"],
[/"/, "string", "@pop"],
],
},
};
function initEnvironment(): void {
if (initialized) return;
// Vite bundles the editor worker via `?worker`. Monaco only needs the base
// editor worker for plaintext/typst (no language-specific workers required).
self.MonacoEnvironment = {
getWorker: () => new editorWorker(),
};
loader.config({ monaco });
initialized = true;
}
export function loadMonaco(): Promise<Monaco> {
if (!monacoPromise) {
initEnvironment();
// loader.init() resolves to the same `monaco` instance we configured above.
monacoPromise = loader.init().then((m) => {
m.languages.register({ id: "typst" });
m.languages.setMonarchTokensProvider("typst", typstLanguageDefinition);
return m;
});
}
return monacoPromise;
}
export function getMonaco(): Monaco {
return monaco as unknown as Monaco;
}
+24
View File
@@ -0,0 +1,24 @@
export interface Project {
project: string;
uri: string;
}
export interface ProjectSummary {
name: string;
}
export interface FileEntry {
path: string;
mtime: number;
}
export interface TreeResponse {
project: string;
files: FileEntry[];
}
export interface FileResponse {
path: string;
content: string;
mtime: number;
}
+23
View File
@@ -8,6 +8,15 @@ const host = process.env.TAURI_DEV_HOST;
// https://vite.dev/config/
export default defineConfig(async () => ({
plugins: [tailwindcss(), react()],
// Force esbuild to tree-shake as little as possible from monaco-editor.
// By default, Vite's pre-bundling applies tree-shaking which discards
// `monaco.languages.createDiagnosticCollection` and other standalone-editor
// services that we need at runtime.
optimizeDeps: {
esbuildOptions: {
treeShaking: false,
},
},
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
//
@@ -29,5 +38,19 @@ export default defineConfig(async () => ({
// 3. tell Vite to ignore watching `src-tauri`
ignored: ["**/src-tauri/**"],
},
// Dev proxy: the SPA talks to the backend via relative URLs so that the
// same code works in dev (Vite :1420 → backend :4000) and in self-hosted
// prod (behind a reverse proxy). The preview webview and its WS are also
// proxied so the iframe is same-origin with the app.
proxy: {
"/api": "http://localhost:4000",
"/preview-app": {
target: "http://localhost:4000",
// the webview is HTML; rewrite so the path is preserved for upstream
ws: false,
},
"/lsp": { target: "ws://localhost:4000", ws: true },
"/preview": { target: "ws://localhost:4000", ws: true },
},
},
}));