Initial scaffold: pnpm workspace monorepo with backend + frontend

- Backend: Express + WebSocket proxy (tsx watch, strict TS, ESM)
- Frontend: React 19 + Vite + Monaco + Tailwind v4 + Tauri v2
- pnpm workspace at root for single-install workflow
- AGENTS.md with architectural constraints and commands
- Removed Tauri greet demo, added minimal App shell
- No external database, Git-as-Database philosophy
This commit is contained in:
2026-07-01 13:23:24 +05:30
commit b00fe3ec9e
45 changed files with 8771 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
node_modules/
dist/
*.tsbuildinfo
+50
View File
@@ -0,0 +1,50 @@
# AGENTS.md
Guidelines for AI coding assistants working on typst-leaf.
## Project Context
Self-hosted web IDE for the [Typst](https://typst.app/) typesetting language — a self-hosted "Overleaf" alternative. Built on "Git as the Database": projects are plain folders with `.git`, no external database. Architecture and philosophy are detailed in `VISION.md`.
## Commands
This is a **pnpm workspace** (see `pnpm-workspace.yaml`). Install once from the repo root:
```bash
pnpm install # installs both backend and frontend
pnpm -r typecheck # typecheck ALL workspaces (run before finishing a task)
pnpm --filter typst-leaf-backend dev # backend dev server on http://localhost:4000
pnpm --filter typst-leaf-frontend dev # frontend dev server (Vite)
pnpm --filter typst-leaf-frontend tauri dev # Tauri desktop wrapper (runs Vite first)
```
**No test framework is configured** — the `test` scripts are placeholders. Do not assume one exists.
## Architecture & Boundaries
```
typst-leaf-backend/ Node.js/TS — stateless orchestration: spawns tinymist, proxies WebSockets, REST for files + git
typst-leaf-frontend/ React (Vite) + Monaco — the IDE SPA; also contains src-tauri/ for the desktop build
```
* **Backend** (`typst-leaf-backend/`): ESM (`"type": "module"`), strict TS with `noUncheckedIndexedAccess`. Entry: `src/index.ts`. Dev via `tsx watch`; build via `tsc``dist/`. Uses `express` + `ws` + `simple-git` + `execa` + `zod`.
* **Frontend** (`typst-leaf-frontend/`): React 19 + Vite. Monaco Editor wired to LSP via `monaco-languageclient` + `vscode-ws-jsonrpc`. Styling is **Tailwind v4** via the `@tailwindcss/vite` plugin (import with `@import "tailwindcss"`; there is no `tailwind.config.js`).
* **Tauri v2** lives at `typst-leaf-frontend/src-tauri/`. It shells out to `pnpm dev`/`pnpm build` and serves `../dist`.
* **Data plane:** `ws://localhost:4000/lsp` (LSP), `ws://localhost:4000/preview` (SVG stream).
## Critical Constraints (do NOT violate)
1. **Never re-implement Typst/LSP logic in TypeScript.** Spawn `tinymist` as a child process and pipe WebSocket messages straight to its stdio. The backend is a thin proxy.
2. **Never use PDF.js.** The preview renders **SVG** streamed from tinymist (selectable text, instant diffs, no binary PDFs).
3. **Path duality:** the frontend sees *virtual paths*; the backend sees *absolute OS paths* under the projects root. Keep the mapping explicit — this is a frequent source of bugs.
4. **No polling** for file or preview changes. Drive updates from LSP/WebSocket events only.
5. **Monaco LSP lifecycle is fragile.** Always dispose the languageclient + WebSocket on component unmount to avoid leaking duplicate socket connections.
6. **Browser-first.** The app must run in a generic browser. Guard any Tauri-only API behind a `window.__TAURI__` check; do not rely on it exclusively.
7. **No databases.** State = filesystem; history = `.git`. Collaboration is Git (commit/push/pull in the UI), not OT/CRDTs.
## Port Gotcha
The frontend dev server is **port 1420** (`strictPort: true`, mandated by Tauri) — **not** 3000 as `README.md` states. Backend is port 4000. `README.md` is stale on this point; trust `vite.config.ts`.
## Code Style
* TypeScript strict mode in both packages. **No `any`.** Validate at API boundaries with `zod`.
* Prefer `async/await` over raw Promises.
* Comment complex logic around WebSocket message passing and process spawning.
+58
View File
@@ -0,0 +1,58 @@
# typst-leaf 🍂
A completely self-hostable, high-performance editor for [Typst](https://typst.app/), built on the philosophy of "Git as the Database."
**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.
## 📂 Repository Structure
```text
typst-leaf/
├── typst-leaf-backend/ # Node.js/TS server (manages tinymist & git)
├── typst-leaf-frontend/ # React + Monaco frontend (includes Tauri)
├── VISION.md # Project philosophy and architecture
└── AGENTS.md # Guidelines for AI coding assistants
```
## 🚀 Quick Start (Development)
**Prerequisites:**
* Node.js 20+
* pnpm
* Rust (for Tauri)
* `tinymist` binary installed in your PATH.
```bash
# 1. Install dependencies
pnpm install
# 2. Start the backend (Port 4000)
cd typst-leaf-backend
pnpm 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
```
## 🏗️ Architecture
* **Editor:** Monaco Editor
* **LSP:** `tinymist` (via WebSocket proxy)
* **Communication:**
* `ws://localhost:4000/lsp` -> Tinymist Language Server
* `ws://localhost:4000/preview` -> Tinymist Preview Service
+62
View File
@@ -0,0 +1,62 @@
# VISION: typst-leaf
## 1. Product Philosophy
**typst-leaf** is a high-performance, self-hosted LaTeX alternative built for the modern era. It rejects the bloated, slow architecture of traditional LaTeX editors in favor of the **Typst** ecosystem.
It is designed for:
1. **Speed:** Sub-50ms compile times.
2. **Sovereignty:** Your data lives in a standard Git repository on your filesystem, not a proprietary database.
3. **Developer Experience:** First-class Vim support, fast LSP feedback, and CLI-based workflows.
## 2. Architecture Overview
The system follows a strict **Client-Server** model, even when running locally.
### The Stack
* **Monorepo Structure:** Flat directory structure (`typst-leaf-backend`, `typst-leaf-frontend`).
* **Frontend:** React (Vite) + Monaco Editor.
* **Desktop Wrapper:** Tauri v2 (integrated into `typst-leaf-frontend`).
* **Backend:** TypeScript (Node.js/Bun) + Express/Fastify.
* **Core Engine:** `tinymist` (Binary) for LSP and compilation.
### Component Breakdown
#### A. Backend (`typst-leaf-backend`)
The backend is a stateless orchestration layer that sits on top of the file system.
* **Role:**
* Spawns and manages the `tinymist` process.
* Proxies WebSocket connections for LSP (Language Server Protocol).
* Proxies WebSocket connections for the Preview (SVG stream).
* Exposes a REST API for file management (CRUD) and Git operations (`git commit`, `git push`).
* **Data Source:** Standard OS file system. Each "Project" is a directory.
#### B. Frontend (`typst-leaf-frontend`)
A Single Page Application (SPA) that acts as the IDE interface.
* **Editor:** Monaco Editor configured with `monaco-languageclient` to talk to the backend LSP.
* **Preview:** A high-performance SVG renderer that receives delta updates from Tinymist.
* **Vim Mode:** `monaco-vim` enabled by default or toggleable.
* **Tauri Integration:** The frontend folder contains `src-tauri`, allowing it to build as a native desktop application.
## 3. Critical Technical Decisions
### 3.1 The "No-Database" Database
We do not use MongoDB, Postgres, or SQL.
* **State:** The state is the file system.
* **History:** The history is the `.git` folder.
* **Collaboration:** We do not implement Operational Transforms (OT) or CRDTs for simultaneous editing. Collaboration is handled via Git (Commit/Push/Pull) logic exposed in the UI.
### 3.2 The Rendering Pipeline
We **do not** render PDFs in the browser for preview.
1. User types in Monaco.
2. Frontend sends `textDocument/didChange` (LSP) to Backend.
3. Backend pipes to `tinymist`.
4. `tinymist` computes layout and pushes an **SVG** diff to the preview socket.
5. Frontend renders the SVG.
*Rationale:* SVGs are selectable, zoomable without blur, and significantly lighter than streaming PDF binaries.
### 3.3 The LSP Connection
We use `monaco-languageclient` to connect Monaco to the backend via WebSocket. The backend simply pipes this socket to the stdin/stdout of the running `tinymist` binary. We do not reimplement LSP logic in TypeScript.
## 4. User Stories
* **As a User,** I want to type code and see the result instantly (<100ms lag).
* **As a User,** I want to double-click an element in the preview and have my cursor jump to the code (SyncTeX/Source Mapping).
* **As a User,** I want to hit `Cmd+S` to save to disk and `Cmd+Shift+S` to create a Git commit.
+3102
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
packages:
- 'typst-leaf-backend'
- 'typst-leaf-frontend'
allowBuilds:
esbuild: true
+30
View File
@@ -0,0 +1,30 @@
{
"name": "typst-leaf-backend",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"typecheck": "tsc --noEmit",
"test": "echo \"Error: no test specified\""
},
"dependencies": {
"cors": "^2.8.5",
"execa": "^9.6.1",
"express": "^4.22.2",
"simple-git": "^3.27.0",
"ws": "^8.18.2",
"zod": "^3.24.5"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^4.17.25",
"@types/node": "^22.15.17",
"@types/ws": "^8.18.1",
"tsx": "^4.19.4",
"typescript": "~5.8.3"
},
"packageManager": "pnpm@10.23.0"
}
+27
View File
@@ -0,0 +1,27 @@
import { createServer } from "http";
import { WebSocketServer } from "ws";
const PORT = Number(process.env.PORT) || 4000;
const server = createServer((_req, res) => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "ok", service: "typst-leaf-backend" }));
});
const wss = new WebSocketServer({ server });
wss.on("connection", (ws) => {
console.log("WebSocket client connected");
ws.on("message", (data) => {
console.log("Received:", data.toString());
});
ws.on("close", () => {
console.log("WebSocket client disconnected");
});
});
server.listen(PORT, () => {
console.log(`Backend running on http://localhost:${PORT}`);
});
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"noUncheckedIndexedAccess": true,
"noEmit": false
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"]
}
+7
View File
@@ -0,0 +1,7 @@
# Tauri + React + Typescript
This template should help get you started developing with Tauri, React and Typescript in Vite.
## Recommended IDE Setup
- [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<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>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+35
View File
@@ -0,0 +1,35 @@
{
"name": "typst-leaf-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "tauri",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@monaco-editor/react": "^4.7.0",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2",
"monaco-languageclient": "^10.7.0",
"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": {
"@tailwindcss/vite": "^4.1.6",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0",
"tailwindcss": "^4.1.6",
"typescript": "~5.8.3",
"vite": "^7.0.4",
"@tauri-apps/cli": "^2"
},
"packageManager": "pnpm@10.23.0"
}
+7
View File
@@ -0,0 +1,7 @@
# Generated by Cargo
# will have compiled files and executables
/target/
# Generated by Tauri
# will have schema files for capabilities auto-completion
/gen/schemas
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "typst-leaf-frontend"
version = "0.1.0"
description = "A Tauri App"
authors = ["you"]
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
# The `_lib` suffix may seem redundant but it is necessary
# to make the lib name unique and wouldn't conflict with the bin name.
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
name = "typst_leaf_frontend_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
@@ -0,0 +1,10 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"opener:default"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 974 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 903 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+14
View File
@@ -0,0 +1,14 @@
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}! You've been greeted from Rust!", name)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![greet])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
@@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
typst_leaf_frontend_lib::run()
}
@@ -0,0 +1,35 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "typst-leaf-frontend",
"version": "0.1.0",
"identifier": "com.nav.typst-leaf-frontend",
"build": {
"beforeDevCommand": "pnpm dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "pnpm build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"title": "typst-leaf-frontend",
"width": 800,
"height": 600
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}
+5
View File
@@ -0,0 +1,5 @@
function App() {
return <div className="h-screen w-screen bg-zinc-900 text-zinc-100" />;
}
export default App;
+1
View File
@@ -0,0 +1 @@
@import "tailwindcss";
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "./index.css";
createRoot(document.getElementById("root") as HTMLElement).render(
<StrictMode>
<App />
</StrictMode>,
);
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+33
View File
@@ -0,0 +1,33 @@
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
// @ts-expect-error process is a nodejs global
const host = process.env.TAURI_DEV_HOST;
// https://vite.dev/config/
export default defineConfig(async () => ({
plugins: [tailwindcss(), react()],
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
//
// 1. prevent Vite from obscuring rust errors
clearScreen: false,
// 2. tauri expects a fixed port, fail if that port is not available
server: {
port: 1420,
strictPort: true,
host: host || false,
hmr: host
? {
protocol: "ws",
host,
port: 1421,
}
: undefined,
watch: {
// 3. tell Vite to ignore watching `src-tauri`
ignored: ["**/src-tauri/**"],
},
},
}));