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

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

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

Infrastructure
- Added vscode-ws-jsonrpc to backend dependencies.
- Removed monaco-languageclient and vscode-ws-jsonrpc from frontend
  dependencies (replaced by the raw JSON-RPC client).
- Added monaco-editor as a direct frontend dependency.
- Updated README (correct frontend port 1420, tinymist install command,
  environment variables, architecture).
- Added projects/ to .gitignore.
- Renamed Tauri productName and page title to typst-leaf.
This commit is contained in:
2026-07-01 20:53:42 +05:30
parent b00fe3ec9e
commit 061f95ddee
29 changed files with 1904 additions and 667 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tauri + React + Typescript</title>
<title>typst-leaf</title>
</head>
<body>
+1 -2
View File
@@ -14,11 +14,10 @@
"@monaco-editor/react": "^4.7.0",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2",
"monaco-languageclient": "^10.7.0",
"monaco-editor": "^0.55.1",
"monaco-vim": "^0.4.4",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"vscode-ws-jsonrpc": "^3.5.0",
"zod": "^3.24.5"
},
"devDependencies": {
@@ -1,6 +1,6 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "typst-leaf-frontend",
"productName": "typst-leaf",
"version": "0.1.0",
"identifier": "com.nav.typst-leaf-frontend",
"build": {
+46 -3
View File
@@ -1,5 +1,48 @@
function App() {
return <div className="h-screen w-screen bg-zinc-900 text-zinc-100" />;
import { AppProvider, useApp } from "./app-state";
import { EditorView } from "./components/Editor";
import { FileTree } from "./components/FileTree";
import { Preview } from "./components/Preview";
import { ProjectPicker } from "./components/ProjectPicker";
function Shell() {
const { state, lsp } = useApp();
return (
<div className="h-screen w-screen bg-white text-zinc-800 flex">
<aside className="w-60 border-r border-zinc-200 flex flex-col bg-zinc-50">
<div className="px-3 py-2 border-b border-zinc-200 text-sm font-semibold flex items-center gap-2">
<span>typst-leaf</span>
<span className="ml-auto text-xs font-normal text-zinc-400">
{lsp.status === "ready"
? "●"
: lsp.status === "connecting"
? "◌"
: lsp.status === "error"
? "✕"
: "○"}
</span>
</div>
<ProjectPicker />
<div className="flex-1 overflow-auto">
<FileTree />
</div>
{state.error && (
<div className="p-2 text-xs text-red-500 border-t border-zinc-200">
{state.error}
</div>
)}
</aside>
<main className="flex-1 flex min-w-0">
<EditorView />
<Preview />
</main>
</div>
);
}
export default App;
export default function App() {
return (
<AppProvider>
<Shell />
</AppProvider>
);
}
+53
View File
@@ -0,0 +1,53 @@
import type {
FileResponse,
Project,
TreeResponse,
} from "./types";
const API = "/api";
async function jsonOrThrow<T>(res: Response): Promise<T> {
if (!res.ok) {
const text = await res.text();
throw new Error(`${res.status} ${res.statusText}: ${text}`);
}
return res.json() as Promise<T>;
}
export const api = {
listProjects: () =>
fetch(`${API}/projects`).then((r) =>
jsonOrThrow<{ projects: string[] }>(r).then((d) => d.projects),
),
createProject: (name: string) =>
fetch(`${API}/projects/${encodeURIComponent(name)}`, { method: "POST" }).then(
(r) => jsonOrThrow<Project>(r),
),
getProject: (name: string) =>
fetch(`${API}/projects/${encodeURIComponent(name)}`).then((r) =>
jsonOrThrow<Project>(r),
),
getTree: (name: string) =>
fetch(`${API}/projects/${encodeURIComponent(name)}/tree`).then((r) =>
jsonOrThrow<TreeResponse>(r),
),
getFile: (project: string, path: string) =>
fetch(
`${API}/projects/${encodeURIComponent(project)}/file?path=${encodeURIComponent(path)}`,
).then((r) => jsonOrThrow<FileResponse>(r)),
putFile: (project: string, path: string, content: string) =>
fetch(
`${API}/projects/${encodeURIComponent(project)}/file?path=${encodeURIComponent(path)}`,
{
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ content }),
},
).then((r) => jsonOrThrow<FileResponse>(r)),
sinkPreviewPort: (port: number) =>
fetch(`${API}/preview/sink`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ port }),
}).then((r) => jsonOrThrow<{ ok: boolean; port: number }>(r)),
};
+177
View File
@@ -0,0 +1,177 @@
import {
createContext,
useContext,
useEffect,
useReducer,
useRef,
useState,
type ReactNode,
} from "react";
import { api } from "./api";
import { loadMonaco } from "./monaco-setup";
import { startLsp, startPreview, type RawLspClient } from "./lsp";
import type { Monaco } from "@monaco-editor/react";
import type { FileEntry } from "./types";
interface State {
projects: string[];
currentProject: string | null;
currentUri: string | null;
tree: FileEntry[];
openFile: string | null;
loading: boolean;
error: string | null;
}
type Action =
| { type: "setProjects"; projects: string[] }
| { type: "selectProject"; project: string; uri: string }
| { type: "setTree"; tree: FileEntry[] }
| { type: "openFile"; path: string | null }
| { type: "loading"; loading: boolean }
| { type: "error"; error: string | null };
const initial: State = {
projects: [],
currentProject: null,
currentUri: null,
tree: [],
openFile: null,
loading: false,
error: null,
};
function reducer(state: State, action: Action): State {
switch (action.type) {
case "setProjects":
return { ...state, projects: action.projects };
case "selectProject":
return {
...state,
currentProject: action.project,
currentUri: action.uri,
tree: [],
openFile: null,
};
case "setTree":
return { ...state, tree: action.tree };
case "openFile":
return { ...state, openFile: action.path };
case "loading":
return { ...state, loading: action.loading };
case "error":
return { ...state, error: action.error };
}
}
interface LspState {
monaco: Monaco | null;
client: RawLspClient | null;
status: "idle" | "connecting" | "ready" | "error";
previewReady: boolean;
error: string | null;
}
const AppContext = createContext<{
state: State;
dispatch: React.Dispatch<Action>;
lsp: LspState;
} | null>(null);
export function useApp() {
const ctx = useContext(AppContext);
if (!ctx) throw new Error("useApp outside provider");
return ctx;
}
export function AppProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(reducer, initial);
const [lsp, setLsp] = useState<LspState>({
monaco: null,
client: null,
status: "idle",
previewReady: false,
error: null,
});
const sessionRef = useRef<{ dispose: () => void } | null>(null);
useEffect(() => {
loadMonaco()
.then((monaco) => setLsp((s) => ({ ...s, monaco })))
.catch((e) =>
setLsp((s) => ({ ...s, status: "error", error: String(e) })),
);
}, []);
useEffect(() => {
api
.listProjects()
.then((projects) => dispatch({ type: "setProjects", projects }))
.catch((e) => dispatch({ type: "error", error: String(e) }));
}, []);
useEffect(() => {
if (!state.currentProject || !lsp.monaco) return;
const sessionRefLocal = sessionRef;
let cancelled = false;
const project = state.currentProject;
const uri = state.currentUri!;
setLsp((s) => ({ ...s, status: "connecting", previewReady: false }));
startLsp(lsp.monaco, project, uri, async (client) => {
// First file opened: ask tinymist for a live preview and relay the
// (random) data-plane port to the backend so /preview-app can proxy.
try {
const port = await startPreview(client);
await api.sinkPreviewPort(port);
setLsp((s) => ({ ...s, previewReady: true }));
} catch (e) {
console.error("preview start failed:", String(e));
}
})
.then((session) => {
if (cancelled) {
session.dispose();
return;
}
sessionRefLocal.current = session;
setLsp({
monaco: lsp.monaco,
client: session.client,
status: "ready",
previewReady: false,
error: null,
});
})
.catch((e) => {
console.error("[lsp] startLsp failed:", e);
if (!cancelled) {
setLsp({
monaco: lsp.monaco,
client: null,
status: "error",
previewReady: false,
error: e instanceof Error ? `${e.name}: ${e.message}` : String(e),
});
}
});
return () => {
cancelled = true;
sessionRefLocal.current?.dispose();
sessionRefLocal.current = null;
};
}, [state.currentProject, state.currentUri, lsp.monaco]);
useEffect(() => {
if (!state.currentProject) return;
api
.getTree(state.currentProject)
.then((t) => dispatch({ type: "setTree", tree: t.files }))
.catch((e) => dispatch({ type: "error", error: String(e) }));
}, [state.currentProject]);
return (
<AppContext.Provider value={{ state, dispatch, lsp }}>
{children}
</AppContext.Provider>
);
}
@@ -0,0 +1,138 @@
import Editor, { type Monaco, type OnMount } from "@monaco-editor/react";
import { useEffect, useRef, useState } from "react";
import { api } from "../api";
import { useApp } from "../app-state";
type ITextModel = Monaco["editor"]["ITextModel"];
type IStandaloneCodeEditor = Monaco["editor"]["IStandaloneCodeEditor"];
export function EditorView() {
const { state, lsp } = useApp();
const monaco = lsp.monaco;
const [model, setModel] = useState<ITextModel | null>(null);
const [error, setError] = useState<string | null>(null);
const editorRef = useRef<IStandaloneCodeEditor | null>(null);
const project = state.currentProject;
const uri = state.currentUri;
const file = state.openFile;
const savingRef = useRef(false);
// Create/replace the Monaco model for the open file. Monaco comes from the
// app-level loader (no per-component polling); the effect re-runs when the
// file, project, or monaco instance changes.
useEffect(() => {
if (!monaco || !project || !file || !uri) {
setModel((m: ITextModel | null) => {
m?.dispose();
return null;
});
return;
}
let cancelled = false;
setError(null);
api
.getFile(project, file)
.then((f) => {
if (cancelled) return;
const base = uri.endsWith("/") ? uri : uri + "/";
const docUri = base + file;
// Create the model OUTSIDE the setState updater: React StrictMode
// double-invokes updaters with the same `prev`, and createModel is a
// side effect (the second call would crash on "model already exists").
// Disposing any stale model with this URI also keeps remounts idempotent.
const uriObj = monaco.Uri.parse(docUri);
monaco.editor.getModel(uriObj)?.dispose();
const next = monaco.editor.createModel(f.content, "typst", uriObj);
setModel((prev: ITextModel | null) => {
prev?.dispose();
return next;
});
// Creating the model triggers the LSP `didOpen` (via bindModels in
// app-state), which in turn starts the live preview. No disk write
// needed — the preview is driven by the LSP document state.
})
.catch((e) => !cancelled && setError(String(e)));
return () => {
cancelled = true;
};
}, [monaco, project, file, uri]);
useEffect(() => {
if (editorRef.current && model) {
editorRef.current.setModel(model);
}
}, [model]);
async function save() {
if (!project || !file || !model || savingRef.current) return;
savingRef.current = true;
try {
await api.putFile(project, file, model.getValue());
} catch (e) {
setError(String(e));
} finally {
savingRef.current = false;
}
}
const handleMount: OnMount = (editor, m) => {
editorRef.current = editor;
if (model) editor.setModel(model);
editor.addCommand(m.KeyMod.CtrlCmd | m.KeyCode.KeyS, () => {
void save();
});
};
if (!project) {
return (
<div className="flex-1 flex items-center justify-center text-zinc-400 text-sm">
Select a project to begin.
</div>
);
}
if (!file) {
return (
<div className="flex-1 flex items-center justify-center text-zinc-400 text-sm">
Open a file from the sidebar.
</div>
);
}
if (error) {
return (
<div className="flex-1 flex items-center justify-center text-red-400 text-sm p-4">
{error}
</div>
);
}
return (
<div className="flex-1 flex flex-col min-h-0">
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-zinc-200 text-xs text-zinc-500 font-mono">
<span>{file}</span>
<span className="ml-auto">
<button
type="button"
onClick={() => void save()}
className="bg-white hover:bg-zinc-100 text-zinc-700 border border-zinc-300 px-2 py-0.5 rounded"
>
Save (Ctrl+S)
</button>
</span>
</div>
<div className="flex-1 min-h-0">
<Editor
defaultLanguage="typst"
theme="vs"
onMount={handleMount}
options={{
fontSize: 14,
minimap: { enabled: false },
wordWrap: "on",
automaticLayout: true,
tabSize: 2,
}}
/>
</div>
</div>
);
}
@@ -0,0 +1,36 @@
import { useApp } from "../app-state";
export function FileTree() {
const { state, dispatch } = useApp();
if (!state.currentProject) {
return (
<div className="p-2 text-xs text-zinc-400">select a project</div>
);
}
if (state.tree.length === 0) {
return <div className="p-2 text-xs text-zinc-400">no .typ files</div>;
}
return (
<div className="flex flex-col gap-0.5 p-2">
<div className="text-xs uppercase tracking-wide text-zinc-400 mb-1">
Files
</div>
{state.tree.map((f) => (
<button
key={f.path}
type="button"
onClick={() => dispatch({ type: "openFile", path: f.path })}
className={`text-left text-sm px-2 py-1 rounded font-mono ${
f.path === state.openFile
? "bg-emerald-100 text-emerald-800 font-medium"
: "hover:bg-zinc-100 text-zinc-600"
}`}
>
{f.path}
</button>
))}
</div>
);
}
@@ -0,0 +1,39 @@
import { useApp } from "../app-state";
export function Preview() {
const { state, lsp } = useApp();
let status = "idle";
if (!state.currentProject) status = "no project";
else if (lsp.status !== "ready") status = `LSP ${lsp.status}`;
else if (!lsp.previewReady) status = "starting preview…";
else status = "live";
return (
<div className="w-1/2 border-l border-zinc-200 flex flex-col bg-white">
<div className="px-3 py-1.5 border-b border-zinc-200 text-xs text-zinc-500 font-mono flex items-center gap-2">
<span>Preview</span>
<span className="ml-auto text-zinc-400">{status}</span>
</div>
<div className="flex-1 bg-zinc-50 relative">
{state.currentProject && lsp.status === "ready" && lsp.previewReady ? (
<iframe
key={state.currentProject}
src="/preview-app"
title="Typst preview"
className="absolute inset-0 w-full h-full bg-white"
/>
) : (
<div className="absolute inset-0 flex flex-col items-center justify-center text-zinc-400 text-sm p-6 gap-2">
<span>{status}</span>
{lsp.status === "error" && lsp.error && (
<pre className="mt-2 max-w-full overflow-auto text-xs text-red-500 bg-red-50 p-3 rounded whitespace-pre-wrap break-all">
{lsp.error}
</pre>
)}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,71 @@
import { useState } from "react";
import { api } from "../api";
import { useApp } from "../app-state";
export function ProjectPicker() {
const { state, dispatch } = useApp();
const [name, setName] = useState("");
async function select(p: string) {
if (p === state.currentProject) return;
const info = await api.getProject(p);
dispatch({ type: "selectProject", project: info.project, uri: info.uri });
}
async function create() {
const n = name.trim();
if (!n) return;
await api.createProject(n);
setName("");
const projects = await api.listProjects();
dispatch({ type: "setProjects", projects });
const info = await api.getProject(n);
dispatch({ type: "selectProject", project: info.project, uri: info.uri });
}
return (
<div className="flex flex-col gap-2 p-2 border-b border-zinc-200">
<div className="text-xs uppercase tracking-wide text-zinc-400">
Projects
</div>
<ul className="flex flex-col gap-0.5">
{state.projects.map((p) => (
<li key={p}>
<button
type="button"
onClick={() => select(p)}
className={`w-full text-left text-sm px-2 py-1 rounded ${
p === state.currentProject
? "bg-emerald-100 text-emerald-800 font-medium"
: "hover:bg-zinc-100 text-zinc-600"
}`}
>
{p}
</button>
</li>
))}
{state.projects.length === 0 && (
<li className="text-xs text-zinc-400 px-2">no projects yet</li>
)}
</ul>
<div className="flex gap-1">
<input
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") create();
}}
placeholder="new-project"
className="flex-1 bg-white text-zinc-800 text-sm px-2 py-1 rounded border border-zinc-300 focus:outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 placeholder:text-zinc-400"
/>
<button
type="button"
onClick={create}
className="text-sm bg-emerald-600 hover:bg-emerald-500 text-white px-2 py-1 rounded"
>
New
</button>
</div>
</div>
);
}
+555
View File
@@ -0,0 +1,555 @@
import type { Monaco } from "@monaco-editor/react";
// ============================================================================
// Raw LSP client over WebSocket.
//
// We deliberately avoid monaco-languageclient / vscode-languageclient here.
// monaco-languageclient v10's low-level client requires bootstrapping the
// entire @codingame/monaco-vscode-api service stack (the wrapper does this),
// which is heavy and fragile. Driving LSP directly is ~300 lines, has no
// magic, and the model<->LSP binding + Monaco provider registration are done
// explicitly. The wire protocol is just JSON-RPC messages, one per WebSocket
// text frame (raw JSON, no Content-Length framing — that's only for stdio).
// ============================================================================
interface LspSession {
client: RawLspClient;
dispose: () => void;
}
type NotifHandler = (params: unknown) => void;
type ReqHandler = (params: unknown) => Promise<unknown> | unknown;
export class RawLspClient {
private ws: WebSocket;
private nextId = 1;
private pending = new Map<
number,
{ resolve: (v: unknown) => void; reject: (e: Error) => void }
>();
private notifHandlers = new Map<string, NotifHandler[]>();
private reqHandlers = new Map<string, ReqHandler>();
constructor(ws: WebSocket) {
this.ws = ws;
ws.addEventListener("message", (e) => this.onMessage(e));
}
private onMessage(e: MessageEvent): void {
let msg: Record<string, unknown>;
try {
msg = JSON.parse(
typeof e.data === "string" ? e.data : e.data.toString(),
);
} catch {
return;
}
const hasMethod = typeof msg.method === "string";
const id = msg.id;
const hasId = id !== undefined && id !== null;
if (hasMethod && hasId) {
// request from server -> handle and reply
const handler = this.reqHandlers.get(msg.method as string);
Promise.resolve(handler ? handler(msg.params) : undefined).then(
(result) =>
this.send({ jsonrpc: "2.0", id, result: result ?? null }),
(err) =>
this.send({
jsonrpc: "2.0",
id,
error: {
code: -32603,
message: err instanceof Error ? err.message : String(err),
},
}),
);
} else if (hasMethod) {
// notification from server
const handlers = this.notifHandlers.get(msg.method as string);
if (handlers) for (const h of handlers) h(msg.params);
} else if (hasId) {
// response to a request we sent
const p = this.pending.get(id as number);
if (p) {
this.pending.delete(id as number);
if (msg.error) {
p.reject(
new Error(
(msg.error as { message: string }).message ?? "LSP error",
),
);
} else {
p.resolve(msg.result);
}
}
}
}
private send(msg: unknown): void {
if (this.ws.readyState === this.ws.OPEN) {
this.ws.send(JSON.stringify(msg));
}
}
notify(method: string, params?: unknown): void {
this.send({ jsonrpc: "2.0", method, params });
}
sendRequest(method: string, params?: unknown): Promise<unknown> {
const id = this.nextId++;
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject });
this.send({ jsonrpc: "2.0", id, method, params });
});
}
onNotification(method: string, handler: NotifHandler): void {
const arr = this.notifHandlers.get(method) ?? [];
arr.push(handler);
this.notifHandlers.set(method, arr);
}
onRequest(method: string, handler: ReqHandler): void {
this.reqHandlers.set(method, handler);
}
dispose(): void {
try {
this.ws.close();
} catch {
/* ignore */
}
for (const [, p] of this.pending) p.reject(new Error("disposed"));
this.pending.clear();
this.notifHandlers.clear();
this.reqHandlers.clear();
}
}
// ---------- LSP <-> Monaco type helpers ----------
interface LspPosition {
line: number;
character: number;
}
interface LspRange {
start: LspPosition;
end: LspPosition;
}
interface LspDiagnostic {
message: string;
severity?: number;
source?: string;
range: LspRange;
}
function toMonacoRange(
monaco: Monaco,
r: LspRange | undefined,
): Monaco["ranges"]["IRange"] {
if (!r) return new monaco.Range(1, 1, 1, 1);
return new monaco.Range(
r.start.line + 1,
r.start.character + 1,
r.end.line + 1,
r.end.character + 1,
);
}
function toMarker(d: LspDiagnostic): Monaco["editor"]["IMarkerData"] {
// LSP severity: 1=Error 2=Warning 3=Info 4=Hint
// MarkerSeverity: Error=8 Warning=4 Info=2 Hint=1
const sev =
d.severity === 1 ? 8 : d.severity === 2 ? 4 : d.severity === 3 ? 2 : 1;
return {
message: d.message,
severity: sev as Monaco["MarkerSeverity"],
source: d.source ?? "typst",
startLineNumber: d.range.start.line + 1,
startColumn: d.range.start.character + 1,
endLineNumber: d.range.end.line + 1,
endColumn: d.range.end.character + 1,
};
}
// ---------- Model <-> LSP binding ----------
interface ModelBinding {
dispose(): void;
}
function bindModels(
client: RawLspClient,
monaco: Monaco,
onFirstOpen?: () => void,
): ModelBinding {
const versions = new Map<string, number>();
const opened = new Set<string>();
const disposers: Array<() => void> = [];
const perModel = new Map<string, () => void>();
let firstOpenFired = false;
const openModel = (model: Monaco["editor"]["ITextModel"]) => {
const u = model.uri.toString();
// The @monaco-editor/react <Editor> creates default models with
// inmemory:// URIs. We only sync models that have file:// URIs
// (the ones we explicitly create), otherwise tinymist panics trying
// to resolve the URI.
if (model.uri.scheme !== "file") return;
if (opened.has(u) || model.getLanguageId() !== "typst") return;
opened.add(u);
versions.set(u, 1);
client.notify("textDocument/didOpen", {
textDocument: {
uri: u,
languageId: "typst",
version: 1,
text: model.getValue(),
},
});
const onChange = model.onDidChangeContent(() => {
const v = (versions.get(u) ?? 1) + 1;
versions.set(u, v);
client.notify("textDocument/didChange", {
textDocument: { uri: u, version: v },
contentChanges: [{ text: model.getValue() }],
});
});
perModel.set(u, () => onChange.dispose());
if (!firstOpenFired) {
firstOpenFired = true;
onFirstOpen?.();
}
};
const closeModel = (uri: string) => {
if (!opened.has(uri)) return;
opened.delete(uri);
versions.delete(uri);
perModel.get(uri)?.();
perModel.delete(uri);
client.notify("textDocument/didClose", { textDocument: { uri } });
};
for (const m of monaco.editor.getModels()) openModel(m);
disposers.push(
monaco.editor.onDidCreateModel((m: Monaco["editor"]["ITextModel"]) =>
openModel(m),
),
);
disposers.push(
monaco.editor.onWillDisposeModel((m: Monaco["editor"]["ITextModel"]) => {
if (m.uri.scheme === "file") closeModel(m.uri.toString());
}),
);
return {
dispose() {
for (const d of disposers) d();
for (const [, d] of perModel) d();
},
};
}
// ---------- Monaco provider registration (LSP-backed) ----------
function registerProviders(
client: RawLspClient,
monaco: Monaco,
capabilities: Record<string, { [k: string]: unknown } | undefined>,
): Monaco["IDisposable"][] {
const disposables: Monaco["IDisposable"][] = [];
const doc = (model: Monaco["editor"]["ITextModel"]) => ({
textDocument: { uri: model.uri.toString() },
});
const pos = (p: Monaco["Position"]) => ({
line: p.lineNumber - 1,
character: p.column - 1,
});
type ITextModel = Monaco["editor"]["ITextModel"];
type IPosition = Monaco["Position"];
if (capabilities.completionProvider) {
const triggers =
(capabilities.completionProvider.triggerCharacters as string[] | undefined) ??
[];
disposables.push(
monaco.languages.registerCompletionItemProvider("typst", {
triggerCharacters: triggers,
provideCompletionItems: async (model: ITextModel, position: IPosition) => {
const r = await client.sendRequest("textDocument/completion", {
...doc(model),
position: pos(position),
});
const items = Array.isArray(r)
? r
: ((r as { items?: unknown[] })?.items ?? []);
const word = model.getWordUntilPosition(position);
const range = new monaco.Range(
position.lineNumber,
word.startColumn,
position.lineNumber,
word.endColumn,
);
return {
suggestions: (items as Record<string, unknown>[]).map((it) => ({
label: it.label as string,
kind: (it.kind as number) ?? 1,
detail: it.detail as string,
documentation: (
typeof it.documentation === "string"
? it.documentation
: (it.documentation as { value?: string })?.value
) as string,
insertText: (it.insertText as string) ?? (it.label as string),
insertTextRules:
it.insertTextFormat === 2
? monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet
: undefined,
range,
})),
};
},
}),
);
}
if (capabilities.hoverProvider) {
disposables.push(
monaco.languages.registerHoverProvider("typst", {
provideHover: async (model: ITextModel, position: IPosition) => {
const r = (await client.sendRequest("textDocument/hover", {
...doc(model),
position: pos(position),
})) as { contents?: unknown[]; range?: LspRange } | null;
if (!r) return undefined;
const contents = (r.contents ?? []).map((c) =>
typeof c === "string"
? c
: (c as { value?: string })?.value ?? "",
);
return {
range: toMonacoRange(monaco, r.range),
contents: contents.filter(Boolean),
};
},
}),
);
}
const defProv = async (
model: Monaco["editor"]["ITextModel"],
position: Monaco["Position"],
) => {
const r = await client.sendRequest("textDocument/definition", {
...doc(model),
position: pos(position),
});
const locs = (Array.isArray(r) ? r : r ? [r] : []) as {
uri: string;
range: LspRange;
}[];
return locs.map((l) => ({
uri: monaco.Uri.parse(l.uri),
range: toMonacoRange(monaco, l.range),
}));
};
if (capabilities.definitionProvider) {
disposables.push(
monaco.languages.registerDefinitionProvider("typst", {
provideDefinition: defProv,
}),
);
}
if (capabilities.referencesProvider) {
disposables.push(
monaco.languages.registerReferenceProvider("typst", {
provideReferences: defProv,
}),
);
}
if (capabilities.documentSymbolProvider) {
disposables.push(
monaco.languages.registerDocumentSymbolProvider("typst", {
provideDocumentSymbols: async (model: ITextModel) => {
const r = await client.sendRequest(
"textDocument/documentSymbol",
doc(model),
);
return ((r as Record<string, unknown>[]) ?? []).map((s) => ({
name: s.name as string,
detail: (s.detail as string) ?? "",
kind: (s.kind as number) ?? 1,
range: toMonacoRange(monaco, s.range as LspRange),
selectionRange: toMonacoRange(monaco, s.selectionRange as LspRange),
}));
},
}),
);
}
if (capabilities.documentFormattingProvider) {
disposables.push(
monaco.languages.registerDocumentFormattingEditProvider("typst", {
provideDocumentFormattingEdits: async (model: ITextModel) => {
const r = await client.sendRequest("textDocument/formatting", {
...doc(model),
options: { tabSize: 2, insertSpaces: true },
});
return ((r as { range: LspRange; newText: string }[]) ?? []).map(
(e) => ({
range: toMonacoRange(monaco, e.range),
text: e.newText,
}),
);
},
}),
);
}
return disposables;
}
// ---------- Session bootstrap ----------
export async function startLsp(
monaco: Monaco,
projectName: string,
projectUri: string,
onFirstOpen?: (client: RawLspClient) => void,
): Promise<LspSession> {
const wsUrl = `${location.protocol === "https:" ? "wss:" : "ws:"}//${location.host}/lsp`;
const ws = new WebSocket(wsUrl);
await new Promise<void>((resolve, reject) => {
const onOpen = () => {
ws.removeEventListener("open", onOpen);
ws.removeEventListener("error", onError);
resolve();
};
const onError = (e: Event) => {
ws.removeEventListener("open", onOpen);
ws.removeEventListener("error", onError);
reject(
new Error(
`WebSocket to ${wsUrl} failed: ${(e as ErrorEvent).message ?? "unknown"}`,
),
);
};
ws.addEventListener("open", onOpen);
ws.addEventListener("error", onError);
});
const client = new RawLspClient(ws);
// Diagnostics → Monaco markers (using the lowlevel `setModelMarkers`
// because esbuild's treeshaking strips `createDiagnosticCollection`
// from standaloneLanguages when bundling monacoeditor for the browser).
const diagOwner = "typst-leaf";
client.onNotification("textDocument/publishDiagnostics", (params) => {
const p = params as { uri: string; diagnostics: LspDiagnostic[] };
const model = monaco.editor.getModel(monaco.Uri.parse(p.uri));
if (model) {
monaco.editor.setModelMarkers(
model,
diagOwner,
p.diagnostics.map(toMarker),
);
}
});
// Server -> client requests tinymist issues during init
client.onRequest("workspace/configuration", (params) => {
const items = (params as { items?: unknown[] }).items ?? [];
return items.map(() => ({}));
});
client.onRequest("workspace/workspaceFolders", () => [
{ uri: projectUri, name: projectName },
]);
client.onRequest("client/registerCapability", () => undefined);
client.onRequest("window/workDoneProgress/create", () => undefined);
// LSP handshake
// Force typst-preview into light mode by passing `dark` theme as
// `--invert-colors=never` during initialization. The preview
// `x-preview` input carries a `theme` (string) and a `version` (int);
// tinymist passes them through to the Typst document as `sys.inputs`
// so templates can read them. We set a light theme here so the
// rendering stays light from the very first compile.
const initResult = (await client.sendRequest("initialize", {
processId: null,
rootUri: projectUri,
capabilities: {
workspace: { configuration: true, workspaceFolders: true },
textDocument: {
synchronization: { openClose: true, change: 1 },
completion: {
completionItem: {
snippet: true,
documentationFormat: ["markdown", "plaintext"],
},
},
hover: { contentFormat: ["markdown", "plaintext"] },
signatureHelp: {},
definition: {},
references: {},
documentSymbol: {},
formatting: {},
publishDiagnostics: { relatedInformation: true },
},
},
trace: "off",
workspaceFolders: [{ uri: projectUri, name: projectName }],
initializationOptions: {
"tinymist.preview.browsing.args": [
"--data-plane-host=127.0.0.1:0",
"--invert-colors=never",
],
"tinymist.preview.invert_colors": "never",
},
})) as { capabilities?: Record<string, { [k: string]: unknown } | undefined> };
client.notify("initialized", {});
const capabilities = initResult.capabilities ?? {};
const providers = registerProviders(client, monaco, capabilities);
const binding = bindModels(client, monaco, () => onFirstOpen?.(client));
return {
client,
dispose: () => {
try {
binding.dispose();
} catch {
/* ignore */
}
for (const p of providers) {
try {
p.dispose();
} catch {
/* ignore */
}
}
try {
client.dispose();
} catch {
/* ignore */
}
},
};
}
// Ask tinymist to start a live (LSP-driven) preview and return the random
// data-plane port it assigned. The caller relays this port to the backend
// (POST /api/preview/sink) so the backend can proxy the preview webview.
export async function startPreview(
client: RawLspClient,
): Promise<number> {
const result = (await client.sendRequest("workspace/executeCommand", {
command: "tinymist.startDefaultPreview",
arguments: [{}],
})) as { dataPlanePort?: number } | null;
const port = result?.dataPlanePort;
if (typeof port !== "number") {
throw new Error("tinymist did not return a preview port");
}
return port;
}
+76
View File
@@ -0,0 +1,76 @@
import { loader, type Monaco } from "@monaco-editor/react";
import * as monaco from "monaco-editor";
import editorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker";
let initialized = false;
let monacoPromise: Promise<Monaco> | null = null;
// A Monarch tokenizer for Typst covering the common surface (headings,
// script keywords, math, comments, emphasis, strings, raw blocks). This is
// syntax-only; the LSP additionally provides diagnostics and (optionally)
// semantic tokens.
const typstLanguageDefinition: monaco.languages.IMonarchLanguage = {
defaultToken: "",
tokenPostfix: ".typst",
brackets: [
{ open: "(", close: ")", token: "delimiter.parenthesis" },
{ open: "[", close: "]", token: "delimiter.square" },
{ open: "{", close: "}", token: "delimiter.curly" },
],
tokenizer: {
root: [
[/^=+\s.*$/, "keyword.heading"],
[/\*[^*]+\*/, "string.emphasis"],
[/_[^_]+_/, "string.emphasis"],
[/\/\/.*$/, "comment"],
[/\/\*/, "comment", "@comment"],
[/#\w+/, "keyword"], // #set, #let, #show, #if, ...
[/\$/, { token: "string", next: "@math" }],
[/"/, "string", "@string"],
[/[{}()\[\]]/, "@brackets"],
],
comment: [
[/\*\//, "comment", "@pop"],
[/[^*]+/, "comment"],
[/\*/, "comment"],
],
math: [
[/\$/, { token: "string", next: "@pop" }],
[/\\[a-zA-Z]+/, "keyword"],
[/[a-zA-Z]+/, "variable"],
[/[^\$]/, "string"],
],
string: [
[/[^"]/, "string"],
[/"/, "string", "@pop"],
],
},
};
function initEnvironment(): void {
if (initialized) return;
// Vite bundles the editor worker via `?worker`. Monaco only needs the base
// editor worker for plaintext/typst (no language-specific workers required).
self.MonacoEnvironment = {
getWorker: () => new editorWorker(),
};
loader.config({ monaco });
initialized = true;
}
export function loadMonaco(): Promise<Monaco> {
if (!monacoPromise) {
initEnvironment();
// loader.init() resolves to the same `monaco` instance we configured above.
monacoPromise = loader.init().then((m) => {
m.languages.register({ id: "typst" });
m.languages.setMonarchTokensProvider("typst", typstLanguageDefinition);
return m;
});
}
return monacoPromise;
}
export function getMonaco(): Monaco {
return monaco as unknown as Monaco;
}
+24
View File
@@ -0,0 +1,24 @@
export interface Project {
project: string;
uri: string;
}
export interface ProjectSummary {
name: string;
}
export interface FileEntry {
path: string;
mtime: number;
}
export interface TreeResponse {
project: string;
files: FileEntry[];
}
export interface FileResponse {
path: string;
content: string;
mtime: number;
}
+23
View File
@@ -8,6 +8,15 @@ const host = process.env.TAURI_DEV_HOST;
// https://vite.dev/config/
export default defineConfig(async () => ({
plugins: [tailwindcss(), react()],
// Force esbuild to tree-shake as little as possible from monaco-editor.
// By default, Vite's pre-bundling applies tree-shaking which discards
// `monaco.languages.createDiagnosticCollection` and other standalone-editor
// services that we need at runtime.
optimizeDeps: {
esbuildOptions: {
treeShaking: false,
},
},
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
//
@@ -29,5 +38,19 @@ export default defineConfig(async () => ({
// 3. tell Vite to ignore watching `src-tauri`
ignored: ["**/src-tauri/**"],
},
// Dev proxy: the SPA talks to the backend via relative URLs so that the
// same code works in dev (Vite :1420 → backend :4000) and in self-hosted
// prod (behind a reverse proxy). The preview webview and its WS are also
// proxied so the iframe is same-origin with the app.
proxy: {
"/api": "http://localhost:4000",
"/preview-app": {
target: "http://localhost:4000",
// the webview is HTML; rewrite so the path is preserved for upstream
ws: false,
},
"/lsp": { target: "ws://localhost:4000", ws: true },
"/preview": { target: "ws://localhost:4000", ws: true },
},
},
}));