diff --git a/.gitignore b/.gitignore index f4e2c6d..0feab4c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules/ dist/ *.tsbuildinfo +projects/ diff --git a/README.md b/README.md index c3cd0b3..a0a0b5b 100644 --- a/README.md +++ b/README.md @@ -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 in your browser. A `hello` project is auto-seeded on first run. + +### Environment variables + +| Var | Default | Description | +| --- | --- | --- | +| `PROJECTS_ROOT` | `/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 ` 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=…` diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0b1fd60..88f32ae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/typst-leaf-backend/package.json b/typst-leaf-backend/package.json index e55c213..80ac405 100644 --- a/typst-leaf-backend/package.json +++ b/typst-leaf-backend/package.json @@ -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" }, diff --git a/typst-leaf-backend/src/config.ts b/typst-leaf-backend/src/config.ts new file mode 100644 index 0000000..881f3cf --- /dev/null +++ b/typst-leaf-backend/src/config.ts @@ -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, +); diff --git a/typst-leaf-backend/src/index.ts b/typst-leaf-backend/src/index.ts index 09e6ae8..e5da939 100644 --- a/typst-leaf-backend/src/index.ts +++ b/typst-leaf-backend/src/index.ts @@ -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(); + } +}); + +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 { + await seedIfMissing(); + server.listen(PORT, () => { + console.log(`Backend running on http://localhost:${PORT}`); }); - ws.on("close", () => { - console.log("WebSocket client disconnected"); - }); -}); + // 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")); +} -server.listen(PORT, () => { - console.log(`Backend running on http://localhost:${PORT}`); +main().catch((err) => { + console.error("Fatal startup error:", err); + process.exit(1); }); diff --git a/typst-leaf-backend/src/lsp-proxy.ts b/typst-leaf-backend/src/lsp-proxy.ts new file mode 100644 index 0000000..229fd49 --- /dev/null +++ b/typst-leaf-backend/src/lsp-proxy.ts @@ -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); +} diff --git a/typst-leaf-backend/src/paths.ts b/typst-leaf-backend/src/paths.ts new file mode 100644 index 0000000..1418f59 --- /dev/null +++ b/typst-leaf-backend/src/paths.ts @@ -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("/")}` }; +} diff --git a/typst-leaf-backend/src/preview-proxy.ts b/typst-leaf-backend/src/preview-proxy.ts new file mode 100644 index 0000000..91a0ff5 --- /dev/null +++ b/typst-leaf-backend/src/preview-proxy.ts @@ -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 { + 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 { + 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 */ } + }); +} diff --git a/typst-leaf-backend/src/preview-sink.ts b/typst-leaf-backend/src/preview-sink.ts new file mode 100644 index 0000000..3c07baf --- /dev/null +++ b/typst-leaf-backend/src/preview-sink.ts @@ -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}`; +} diff --git a/typst-leaf-backend/src/preview-webview.ts b/typst-leaf-backend/src/preview-webview.ts new file mode 100644 index 0000000..11cb956 --- /dev/null +++ b/typst-leaf-backend/src/preview-webview.ts @@ -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 = ``; + +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("")) { + html = html.replace("", `${REWRITE_SCRIPT}`); + } else { + // no ; 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}`); + } +}); diff --git a/typst-leaf-backend/src/routes/files.ts b/typst-leaf-backend/src/routes/files.ts new file mode 100644 index 0000000..69619f0 --- /dev/null +++ b/typst-leaf-backend/src/routes/files.ts @@ -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 }); + } +}); diff --git a/typst-leaf-backend/src/routes/preview.ts b/typst-leaf-backend/src/routes/preview.ts new file mode 100644 index 0000000..e635f30 --- /dev/null +++ b/typst-leaf-backend/src/routes/preview.ts @@ -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 }); +}); diff --git a/typst-leaf-backend/src/routes/projects.ts b/typst-leaf-backend/src/routes/projects.ts new file mode 100644 index 0000000..9cb733b --- /dev/null +++ b/typst-leaf-backend/src/routes/projects.ts @@ -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 { + 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 }); +}); diff --git a/typst-leaf-backend/src/seed.ts b/typst-leaf-backend/src/seed.ts new file mode 100644 index 0000000..cf80169 --- /dev/null +++ b/typst-leaf-backend/src/seed.ts @@ -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 { + 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}`); + } +} diff --git a/typst-leaf-frontend/index.html b/typst-leaf-frontend/index.html index ff93803..77dee06 100644 --- a/typst-leaf-frontend/index.html +++ b/typst-leaf-frontend/index.html @@ -4,7 +4,7 @@ - Tauri + React + Typescript + typst-leaf diff --git a/typst-leaf-frontend/package.json b/typst-leaf-frontend/package.json index e3b856f..6540451 100644 --- a/typst-leaf-frontend/package.json +++ b/typst-leaf-frontend/package.json @@ -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": { diff --git a/typst-leaf-frontend/src-tauri/tauri.conf.json b/typst-leaf-frontend/src-tauri/tauri.conf.json index a5ea178..9d9a2be 100644 --- a/typst-leaf-frontend/src-tauri/tauri.conf.json +++ b/typst-leaf-frontend/src-tauri/tauri.conf.json @@ -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": { diff --git a/typst-leaf-frontend/src/App.tsx b/typst-leaf-frontend/src/App.tsx index a071892..da1ec21 100644 --- a/typst-leaf-frontend/src/App.tsx +++ b/typst-leaf-frontend/src/App.tsx @@ -1,5 +1,48 @@ -function App() { - return
; +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 ( +
+ +
+ + +
+
+ ); } -export default App; +export default function App() { + return ( + + + + ); +} diff --git a/typst-leaf-frontend/src/api.ts b/typst-leaf-frontend/src/api.ts new file mode 100644 index 0000000..8645782 --- /dev/null +++ b/typst-leaf-frontend/src/api.ts @@ -0,0 +1,53 @@ +import type { + FileResponse, + Project, + TreeResponse, +} from "./types"; + +const API = "/api"; + +async function jsonOrThrow(res: Response): Promise { + if (!res.ok) { + const text = await res.text(); + throw new Error(`${res.status} ${res.statusText}: ${text}`); + } + return res.json() as Promise; +} + +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(r), + ), + getProject: (name: string) => + fetch(`${API}/projects/${encodeURIComponent(name)}`).then((r) => + jsonOrThrow(r), + ), + getTree: (name: string) => + fetch(`${API}/projects/${encodeURIComponent(name)}/tree`).then((r) => + jsonOrThrow(r), + ), + getFile: (project: string, path: string) => + fetch( + `${API}/projects/${encodeURIComponent(project)}/file?path=${encodeURIComponent(path)}`, + ).then((r) => jsonOrThrow(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(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)), +}; diff --git a/typst-leaf-frontend/src/app-state.tsx b/typst-leaf-frontend/src/app-state.tsx new file mode 100644 index 0000000..f56f3e7 --- /dev/null +++ b/typst-leaf-frontend/src/app-state.tsx @@ -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; + 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({ + 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 ( + + {children} + + ); +} diff --git a/typst-leaf-frontend/src/components/Editor.tsx b/typst-leaf-frontend/src/components/Editor.tsx new file mode 100644 index 0000000..5b20218 --- /dev/null +++ b/typst-leaf-frontend/src/components/Editor.tsx @@ -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(null); + const [error, setError] = useState(null); + const editorRef = useRef(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 ( +
+ Select a project to begin. +
+ ); + } + if (!file) { + return ( +
+ Open a file from the sidebar. +
+ ); + } + if (error) { + return ( +
+ {error} +
+ ); + } + + return ( +
+
+ {file} + + + +
+
+ +
+
+ ); +} diff --git a/typst-leaf-frontend/src/components/FileTree.tsx b/typst-leaf-frontend/src/components/FileTree.tsx new file mode 100644 index 0000000..1c9d2f2 --- /dev/null +++ b/typst-leaf-frontend/src/components/FileTree.tsx @@ -0,0 +1,36 @@ +import { useApp } from "../app-state"; + +export function FileTree() { + const { state, dispatch } = useApp(); + + if (!state.currentProject) { + return ( +
select a project
+ ); + } + if (state.tree.length === 0) { + return
no .typ files
; + } + + return ( +
+
+ Files +
+ {state.tree.map((f) => ( + + ))} +
+ ); +} diff --git a/typst-leaf-frontend/src/components/Preview.tsx b/typst-leaf-frontend/src/components/Preview.tsx new file mode 100644 index 0000000..d51580b --- /dev/null +++ b/typst-leaf-frontend/src/components/Preview.tsx @@ -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 ( +
+
+ Preview + {status} +
+
+ {state.currentProject && lsp.status === "ready" && lsp.previewReady ? ( +