typst-mcp 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -13,42 +13,44 @@
13
13
 
14
14
  ---
15
15
 
16
- ## Quick Start
16
+ ## Installation
17
17
 
18
- ### Prerequisites
19
-
20
- - [**Node.js**](https://nodejs.org/) `>= 18` (for `npx` install) or [**Bun**](https://bun.sh) `>= 1.2` (for local development)
21
- - [**Typst CLI**](https://github.com/typst/typst) (`typst` on `PATH` for `typst_get_diagnostics` and `typst_export_pdf`)
22
-
23
- ### Installation
18
+ Add `typst-mcp` to your MCP client configuration (`.pi/agent/mcp-adapter.json`, `claude_desktop_config.json`, or `.cursor/mcp.json`).
24
19
 
25
- **Recommended — no clone, no global install:**
20
+ ### Prerequisites
26
21
 
27
- ```bash
28
- npx -y typst-mcp
29
- ```
22
+ - [**Node.js**](https://nodejs.org/) `>= 18`
23
+ - [**Typst CLI**](https://github.com/typst/typst) (optional: `typst` on `PATH` for local CLI compilation speed; if not installed, `typst_get_diagnostics` automatically uses Typst Web cloud compiler diagnostics)
30
24
 
31
- Runs the published [`typst-mcp`](https://www.npmjs.com/package/typst-mcp) package (Node `>= 18`). Use the same command in MCP config below. `-y` skips the npx install prompt.
25
+ ### Option 1: Normal (Standard)
32
26
 
33
- **From source (development):**
27
+ Runs the published [`typst-mcp`](https://www.npmjs.com/package/typst-mcp) package (`npx -y typst-mcp`):
34
28
 
35
- ```bash
36
- git clone https://github.com/caphefalumi/typst-web-mcp.git
37
- cd typst-web-mcp
38
- bun install
39
- bun run dev
29
+ ```json
30
+ {
31
+ "mcpServers": {
32
+ "typst-mcp": {
33
+ "command": "npx",
34
+ "args": ["-y", "typst-mcp"],
35
+ "env": {
36
+ "TYPST_EMAIL": "your-email@example.com",
37
+ "TYPST_PASSWORD": "your-password"
38
+ }
39
+ }
40
+ }
41
+ }
40
42
  ```
41
43
 
42
- ### MCP Client Configuration
44
+ ### Option 2: Always Latest
43
45
 
44
- Add `typst-mcp` to your MCP client configuration (`.pi/agent/mcp-adapter.json`, `claude_desktop_config.json`, or `.cursor/mcp.json`):
46
+ Bypasses local `npx` cache to automatically pull and run the newest release on launch (`npx -y typst-mcp@latest`):
45
47
 
46
48
  ```json
47
49
  {
48
50
  "mcpServers": {
49
51
  "typst-mcp": {
50
52
  "command": "npx",
51
- "args": ["-y", "typst-mcp"],
53
+ "args": ["-y", "typst-mcp@latest"],
52
54
  "env": {
53
55
  "TYPST_EMAIL": "your-email@example.com",
54
56
  "TYPST_PASSWORD": "your-password"
@@ -58,8 +60,6 @@ Add `typst-mcp` to your MCP client configuration (`.pi/agent/mcp-adapter.json`,
58
60
  }
59
61
  ```
60
62
 
61
- > **Session Persistence:** Once authenticated via `typst_auth_login` (or environment variables), the `__Http-typst-session` cookie is cached locally at `~/.pi/agent/typst-mcp-session.json` so subsequent tool calls authenticate automatically without re-signing in.
62
-
63
63
  ---
64
64
 
65
65
  ## Overview
@@ -74,21 +74,6 @@ Add `typst-mcp` to your MCP client configuration (`.pi/agent/mcp-adapter.json`,
74
74
 
75
75
  ---
76
76
 
77
- ## Architecture
78
-
79
- ```mermaid
80
- flowchart LR
81
- Agent["AI Agent\n(Pi / Claude / Cursor)"] <-->|"JSON-RPC (stdio)"| MCP["typst-mcp\n(Node / TypeScript)"]
82
- MCP <-->|"GraphQL (/v1/graphql)\n+ X-Typst-Preflight"| API["https://api.typst.app"]
83
- MCP <-->|"REST Yjs & Blobs\n(/v1/files/:pid/:fid/yjs)"| API
84
- MCP <-->|"Binary lib0 WebSocket\n(wss://api.typst.app/editing/v3)"| WS["typst.app Real-Time Sync"]
85
- MCP -->|"Workspace Sync + Compile"| TypstCLI["Typst Compiler\n(Diagnostics & PDF)"]
86
- ```
87
-
88
- See [`docs/PROTOCOL.md`](./docs/PROTOCOL.md) for the complete reverse-engineered protocol specification.
89
-
90
- ---
91
-
92
77
  ## MCP Tools (18 Tools)
93
78
 
94
79
  | Category | Tool Name | Description |
@@ -100,22 +85,110 @@ See [`docs/PROTOCOL.md`](./docs/PROTOCOL.md) for the complete reverse-engineered
100
85
  | **Projects** | `typst_create_project` | Create a new `typst.app` cloud project with initial `.typ`/`.bib` files and pinned compiler version. |
101
86
  | **Projects** | `typst_delete_project` | Permanently delete a `typst.app` project by ID (`DeleteProjectMutation`). |
102
87
  | **Files & CRDT** | `typst_list_files` | List all multiplayer Yjs text files and binary asset blobs in a project. |
103
- | **Files & CRDT** | `typst_read_file` | Read any project file with offset/limit pagination, optional line numbering, truncation limits (2,000 lines/50KB), and multimodal MCP image content blocks (`PNG`, `JPG`, `SVG`, `WebP`). |
88
+ | **Files & CRDT** | `typst_read_file` | Read bounded source pages (200 lines/16 KiB by default), with compact metadata, whole-file content hashes, separate continuation fields, and native image blocks. |
104
89
  | **Files & CRDT** | `typst_write_file` | Create or overwrite a `.typ`/`.bib`/`.toml` file, protected by per-file mutation queue serialization, syncing Yjs state diffs over `/editing/v3` WebSocket and REST. |
105
- | **Files & CRDT** | `typst_edit_file` | Apply surgical `oldText -> newText` replacements directly to the `Y.Text("text")` CRDT with CRLF/BOM resilience, fuzzy quote/dash matching, unified diff & patch output, and per-file mutation serialization. |
90
+ | **Files & CRDT** | `typst_edit_file` | Apply validated, unique edits to one snapshot with optional stale-content protection, exact matching by default, compact receipts, opt-in fuzzy fallback, and bounded diff previews. |
106
91
  | **Search & Replace** | `typst_grep_project` | Search for text or regular expressions across project files, returning file paths, line numbers, and optional surrounding context lines. |
107
- | **Search & Replace** | `typst_find_and_replace` | Find and replace text or regex across a single file or project-wide with regex capture groups (`$1`), dry-run preview diffs, and per-file mutation serialization. |
92
+ | **Search & Replace** | `typst_find_and_replace` | Replace text or regex in one file or project-wide. Batch ordered replacement rules in one call with one save per changed file, compact summaries, optional diffs, and dry-run previews. |
108
93
  | **Assets & Tree** | `typst_upload_asset` | Upload or replace binary assets (`PNG`, `JPG`, `SVG`, `PDF`, fonts) from a local path or base64 payload. |
109
94
  | **Assets & Tree** | `typst_rename_file` | Rename or move a file inside a `typst.app` project (`UpdatesFileMutation`). |
110
95
  | **Assets & Tree** | `typst_delete_file` | Delete a file or binary asset from a project (`DeleteFilesMutation`). |
111
- | **Compiler** | `typst_get_diagnostics` | Sync all cloud files and run `typst compile` to return structured errors, warnings, line/column spans, and hints. |
96
+ | **Compiler** | `typst_get_diagnostics` | Retrieve compiler diagnostics (errors, warnings, line/column numbers, and hints). Supports both local Typst CLI (`mode="cli"`) and Typst Web cloud compiler (`mode="web"`), with automatic fallback (`mode="auto"`). |
112
97
  | **Compiler** | `typst_export_pdf` | Sync all cloud text & binary assets, compile the project to PDF, and save to `outputPath`. |
113
98
  | **Preview** | `typst_capture_screenshot` | Capture high-DPI PNG preview screenshots (`mode="pages"` renders compiled PNG pages; `mode="browser"` screenshots the live `typst.app` web UI). |
114
99
 
115
100
  ---
116
101
 
102
+ ## Read and edit efficiently
103
+
104
+ 1. Use `typst_grep_project` to locate relevant text, then read just that range with `typst_read_file` (`offset` is 1-based, `limit` defaults to 200 lines, `maxBytes` defaults to 16 KiB and can be raised to 1 MiB).
105
+ 2. Keep the returned `contentHash`. It is SHA-256 of the **entire raw text file**, including BOM and line endings, even when reading a range or displaying line numbers.
106
+ 3. Send all disjoint edits in one `typst_edit_file` call, with `expectedHash` set to that hash. Prefer a short unique `oldText` anchor over repeating a whole file.
107
+
108
+ ```json
109
+ {
110
+ "projectId": "your-project-id",
111
+ "filePath": "main.typ",
112
+ "expectedHash": "<contentHash from the read response>",
113
+ "edits": [
114
+ { "oldText": "September 2025", "newText": "September 2026" }
115
+ ]
116
+ }
117
+ ```
118
+
119
+ Text read responses include source only in `content`, with `totalLines`, `startLine`, `endLine`, `truncated`, and `nextOffset` when more source remains. They never repeat source in truncation metadata or append instructions to it. Complete lines and original line endings are retained, so unnumbered pages can be concatenated without losing bytes. A trailing newline does not create a phantom extra line. An empty file has `totalLines: 0`, `startLine: 1`, `endLine: 0`.
120
+
121
+ Read metadata defaults to `{id, name}`. Use `output: "full"` for full file metadata; this does not increase the source range. `lineNumbers: true` adds display prefixes: remove those prefixes when constructing `oldText`. Byte budgets include those prefixes. If one line cannot fit, the tool returns an actionable error rather than an empty page that cannot advance.
122
+
123
+ Edits match against the same original snapshot, not the result of previous edits. All entries must be valid, and each anchor must be unique, including overlapping occurrences. Invalid batches, overlapping ranges, and stale hashes fail before saving. `matchMode: "exact"` is the default and accepts LF/CRLF differences. Use `matchMode: "fuzzy"` to opt into Unicode quote/dash/space and NFKC fallback when no exact match exists. Fallback matching preserves characters outside the matched span. Edits retain the BOM and untouched line endings, including mixed LF/CRLF files.
124
+
125
+ Successful edits return a compact receipt with file ID/name, `appliedCount`, the resulting `contentHash`, and `firstChangedLine`. This avoids returning the same changes as both a diff and a patch. Optional controls:
126
+
127
+ - `output: "diff"` adds a display diff; `output: "full"` also adds a patch and full metadata.
128
+ - Each diff/patch field is limited to 100 lines and `maxDiffBytes` bytes (default 8192, maximum 1 MiB). `diffTruncated` / `patchTruncated` explicitly flag omissions; read the affected range for complete source.
129
+ - `dryRun: true` validates and previews without saving and defaults to diff output. Its `appliedCount` describes proposed edits; its `contentHash` still identifies the unchanged cloud text.
130
+
131
+ `expectedHash` is checked on fresh text inside the shared file mutation queue, which serializes local edits, replacements, and whole-file writes to existing files. It detects changes made since the read; it is not a cloud compare-and-swap transaction and cannot prevent a collaborator from editing during the subsequent save. Yjs still merges concurrent cloud updates.
132
+
133
+ **Changed defaults:** reads previously allowed 2,000 lines/50 KiB and embedded continuation hints in `content`. Read callers should follow `nextOffset` now. MCP edits previously returned full diffs/patches and used fuzzy fallback automatically; request `output: "full"` or `matchMode: "fuzzy"` explicitly when needed. The low-level `editYjsText` helper retains its legacy fuzzy default; the client/MCP edit path defaults to exact matching.
134
+
135
+ ## Batch replacements for agents
136
+
137
+ Use one `typst_find_and_replace` call for related replacements. For example, expand abbreviated month names throughout all `.typ` files:
138
+
139
+ ```json
140
+ {
141
+ "projectId": "your-project-id",
142
+ "glob": "*.typ",
143
+ "replacements": [
144
+ { "pattern": "\\bSep\\b", "replacement": "September", "regex": true },
145
+ { "pattern": "\\bOct\\b", "replacement": "October", "regex": true }
146
+ ]
147
+ }
148
+ ```
149
+
150
+ Each file is downloaded once, all rules run in order on the previous rule's result, and the file is saved once if its final text changed. All rules are validated before any files are written. File mutations remain serialized with other edits; a project-wide operation is **not atomic across files** if a network or save error occurs.
151
+
152
+ Batch calls default to `output: "summary"`:
153
+
154
+ ```json
155
+ {
156
+ "filesModified": [
157
+ {
158
+ "file": "main.typ",
159
+ "replacementsCount": 3,
160
+ "replacementCounts": [2, 1],
161
+ "firstChangedLine": 4
162
+ }
163
+ ],
164
+ "totalReplacements": 3,
165
+ "dryRun": false
166
+ }
167
+ ```
168
+
169
+ `replacementCounts` follows the input rule order and counts matches applied by each rule in that changed file. Unchanged files, including batches whose rules cancel each other out, are omitted and do not contribute to totals.
170
+
171
+ - Set `dryRun: true` to preview without saving. Add `output: "diff"` to include a single combined display diff per file.
172
+ - Set `output: "full"` to include both the display diff and unified patch.
173
+ - Set `regex`, `ignoreCase`, and `limit` on each batch rule. `limit` is a non-negative integer per rule per file; `0` disables that rule.
174
+ - Use either `replacements` or the existing `pattern`/`replacement` fields. Existing single-replacement calls still default to `output: "full"`; they can request `"summary"` too.
175
+ - Use `filePath` to target one file by path or ID. A missing explicit file returns an error.
176
+
117
177
  ## Development & Verification
118
178
 
179
+ ### From Source (Development)
180
+
181
+ Requires [**Bun**](https://bun.sh) `>= 1.2`:
182
+
183
+ ```bash
184
+ git clone https://github.com/caphefalumi/typst-web-mcp.git
185
+ cd typst-web-mcp
186
+ bun install
187
+ bun run dev
188
+ ```
189
+
190
+ ### Build & Test
191
+
119
192
  ```bash
120
193
  # Compile publishable output to dist/
121
194
  bun run build
@@ -143,36 +216,6 @@ Same flow as [Canvas-CLI](https://github.com/caphefalumi/Canvas-CLI):
143
216
 
144
217
  **Trusted publisher (maintainers):** package [`typst-mcp`](https://www.npmjs.com/package/typst-mcp) → Settings → Trusted publishing → GitHub Actions — user/org `caphefalumi`, repo `typst-web-mcp`, workflow file `npm-publish.yml`, environment `npm-publish`, allow **npm publish**.
145
218
 
146
- ### Project Structure
147
-
148
- ```text
149
- typst-web-mcp/
150
- ├── docs/
151
- │ └── PROTOCOL.md # Reverse-engineered typst.app REST, GraphQL, Yjs & WS v3 spec
152
- ├── src/
153
- │ ├── protocol/
154
- │ │ ├── edit-diff.ts # Fuzzy matching, CRLF/BOM resilience, unified diff & patch generator
155
- │ │ ├── ws-codec.ts # Binary lib0 /editing/v3 WebSocket encoder & decoder
156
- │ │ ├── yjs-doc.ts # Yjs CRDT text decoding, replacement & surgical patching
157
- │ │ └── graphql.ts # GraphQL queries, mutations, fragments & types
158
- │ ├── client/
159
- │ │ ├── auth.ts # Direct /v1/auth/signin login, cookie jar & Playwright fallback
160
- │ │ ├── compiler.ts # Typst binary resolver, diagnostic parser, PDF & PNG preview compiler
161
- │ │ ├── errors.ts # Structured TypstLoadError & error formatting helpers
162
- │ │ └── typst-client.ts # TypstCloudClient (REST, GraphQL, WebSocket v3, Compiler facade)
163
- │ ├── tools/
164
- │ │ └── definitions.ts # MCP JSON Schema definitions for all 16 tools
165
- │ ├── utils/
166
- │ │ ├── file-mutation-queue.ts # Serialized mutation mutex queue per file
167
- │ │ ├── mime.ts # Image MIME type detection
168
- │ │ └── truncate.ts # Dual-limit (line & byte) head/tail truncation utilities
169
- │ └── index.ts # MCP stdio server entrypoint (dev); npm bin runs dist/index.js
170
- ├── test/
171
- │ └── typst-mcp.test.ts # 7 test suites (57 assertions) covering codecs, CRDTs & E2E
172
- ├── LICENSE # MIT License
173
- └── package.json
174
- ```
175
-
176
219
  ---
177
220
 
178
221
  ## License
@@ -13,9 +13,31 @@ function extractRawSetCookies(headers) {
13
13
  }
14
14
  return [];
15
15
  }
16
+ export function getDefaultSessionFilePath() {
17
+ if (process.env.TYPST_SESSION_FILE) {
18
+ return process.env.TYPST_SESSION_FILE;
19
+ }
20
+ const platform = process.platform;
21
+ if (platform === "win32") {
22
+ const localAppData = process.env.LOCALAPPDATA ||
23
+ (process.env.APPDATA
24
+ ? join(process.env.APPDATA, "..", "Local")
25
+ : join(homedir(), "AppData", "Local"));
26
+ return join(localAppData, "typst-mcp", "session.json");
27
+ }
28
+ if (platform === "darwin") {
29
+ if (process.env.XDG_CACHE_HOME) {
30
+ return join(process.env.XDG_CACHE_HOME, "typst-mcp", "session.json");
31
+ }
32
+ return join(homedir(), "Library", "Caches", "typst-mcp", "session.json");
33
+ }
34
+ // Linux and other POSIX systems (XDG Base Directory specification)
35
+ const cacheDir = process.env.XDG_CACHE_HOME || join(homedir(), ".cache");
36
+ return join(cacheDir, "typst-mcp", "session.json");
37
+ }
16
38
  function resolveSessionFilePath(configuredPath) {
17
39
  if (configuredPath === undefined) {
18
- return join(homedir(), ".pi", "agent", "typst-mcp-session.json");
40
+ return getDefaultSessionFilePath();
19
41
  }
20
42
  return configuredPath;
21
43
  }
@@ -92,11 +114,22 @@ export class TypstAuthManager {
92
114
  }
93
115
  }
94
116
  loadStoredSession() {
95
- if (!this.sessionFilePath || !existsSync(this.sessionFilePath)) {
117
+ if (!this.sessionFilePath) {
96
118
  return;
97
119
  }
120
+ let targetPath = this.sessionFilePath;
121
+ if (!existsSync(targetPath)) {
122
+ // Legacy fallback check for seamless migration from ~/.pi/agent/typst-mcp-session.json
123
+ const legacyPath = join(homedir(), ".pi", "agent", "typst-mcp-session.json");
124
+ if (existsSync(legacyPath)) {
125
+ targetPath = legacyPath;
126
+ }
127
+ else {
128
+ return;
129
+ }
130
+ }
98
131
  try {
99
- const raw = JSON.parse(readFileSync(this.sessionFilePath, "utf8"));
132
+ const raw = JSON.parse(readFileSync(targetPath, "utf8"));
100
133
  if (raw && typeof raw.cookies === "object" && raw.cookies) {
101
134
  this.cookies = { ...raw.cookies };
102
135
  this.authMethod = raw.authMethod ?? "cookie";
@@ -29,6 +29,52 @@ export function resolveTypstBinary(explicitBin) {
29
29
  }
30
30
  return "typst";
31
31
  }
32
+ export function hasTypstBinary(explicitBin) {
33
+ try {
34
+ const bin = resolveTypstBinary(explicitBin);
35
+ if (bin !== "typst" && existsSync(bin)) {
36
+ return true;
37
+ }
38
+ const proc = spawnSync(bin, ["--version"], {
39
+ stdio: "ignore",
40
+ encoding: "utf8",
41
+ });
42
+ return proc.status === 0 && !proc.error;
43
+ }
44
+ catch {
45
+ return false;
46
+ }
47
+ }
48
+ export function resolvePythonBinary() {
49
+ if (process.env.PYTHON_BIN) {
50
+ return process.env.PYTHON_BIN;
51
+ }
52
+ for (const cmd of ["python", "python3", "py"]) {
53
+ try {
54
+ const res = spawnSync(cmd, ["--version"], { stdio: "ignore" });
55
+ if (res.status === 0) {
56
+ return cmd;
57
+ }
58
+ }
59
+ catch { }
60
+ }
61
+ return "python";
62
+ }
63
+ export function offsetToPosition(text, offset) {
64
+ let line = 1;
65
+ let column = 1;
66
+ const target = Math.min(Math.max(0, offset), text.length);
67
+ for (let i = 0; i < target; i++) {
68
+ if (text[i] === "\n") {
69
+ line++;
70
+ column = 1;
71
+ }
72
+ else {
73
+ column++;
74
+ }
75
+ }
76
+ return { line, column };
77
+ }
32
78
  function hasValidPngSignature(bytes) {
33
79
  return (bytes.byteLength >= 8 &&
34
80
  bytes[0] === 0x89 &&
@@ -95,6 +141,9 @@ export function compileWorkspaceDiagnostics(params) {
95
141
  cwd: params.workspaceDir,
96
142
  encoding: "utf8",
97
143
  });
144
+ if (proc.error) {
145
+ throw new TypstLoadError(["TYPST_EXECUTION_FAILED"], `Failed to execute Typst compiler binary '${params.typstBin}': ${proc.error.message}`);
146
+ }
98
147
  const combinedOutput = `${proc.stderr ?? ""}\n${proc.stdout ?? ""}`;
99
148
  const diagnostics = parseShortDiagnostics(combinedOutput).map((d) => ({
100
149
  ...d,
@@ -130,6 +179,9 @@ export function compileWorkspacePdf(params) {
130
179
  cwd: params.workspaceDir,
131
180
  encoding: "utf8",
132
181
  });
182
+ if (proc.error) {
183
+ throw new TypstLoadError(["TYPST_EXECUTION_FAILED"], `Failed to execute Typst compiler binary '${params.typstBin}': ${proc.error.message}`);
184
+ }
133
185
  const combinedOutput = `${proc.stderr ?? ""}\n${proc.stdout ?? ""}`;
134
186
  const diagnostics = parseShortDiagnostics(combinedOutput);
135
187
  if (proc.status !== 0) {
@@ -148,12 +200,169 @@ export function compileWorkspacePdf(params) {
148
200
  diagnostics,
149
201
  };
150
202
  }
203
+ export function captureWebDiagnostics(params) {
204
+ const pythonBin = resolvePythonBinary();
205
+ const timeoutSec = ((params.timeoutMs ?? 25000) / 1000).toFixed(1);
206
+ const pyScript = `
207
+ import os, sys, json, time
208
+ from playwright.sync_api import sync_playwright
209
+
210
+ app_url = os.environ.get("TYPST_APP_URL", "https://typst.app")
211
+ proj_id = os.environ["TYPST_PROJ_ID"]
212
+ raw_cookie = os.environ.get("TYPST_COOKIE", "")
213
+ timeout_sec = float(os.environ.get("TYPST_TIMEOUT_SEC", "25.0"))
214
+
215
+ cookies = []
216
+ for part in raw_cookie.split(";"):
217
+ part = part.strip()
218
+ if "=" in part:
219
+ k, v = part.split("=", 1)
220
+ name = k.strip()
221
+ val = v.strip()
222
+ is_session = "__Http" in name or "session" in name
223
+ cookies.append({
224
+ "name": name,
225
+ "value": val,
226
+ "domain": ".typst.app",
227
+ "path": "/",
228
+ "httpOnly": is_session,
229
+ "secure": True,
230
+ "sameSite": "Strict" if is_session else "Lax",
231
+ })
232
+
233
+ with sync_playwright() as p:
234
+ browser = None
235
+ for channel in ["msedge", "chrome", None]:
236
+ try:
237
+ if channel:
238
+ browser = p.chromium.launch(channel=channel, headless=True)
239
+ else:
240
+ browser = p.chromium.launch(headless=True)
241
+ break
242
+ except Exception:
243
+ continue
244
+
245
+ if browser is None:
246
+ sys.stderr.write("NO_BROWSER_FOUND")
247
+ sys.exit(2)
248
+
249
+ ctx = browser.new_context(viewport={"width": 1920, "height": 1080})
250
+ if cookies:
251
+ ctx.add_cookies(cookies)
252
+
253
+ ctx.add_init_script("""
254
+ window.__typst_diagnostics = null;
255
+ window.__typst_status = 'starting';
256
+ const OrigWorker = window.Worker;
257
+ window.Worker = function(url, opts) {
258
+ const w = new OrigWorker(url, opts);
259
+ w.addEventListener('message', (ev) => {
260
+ try {
261
+ if (ev.data && ev.data.tag === 'notification') {
262
+ if (ev.data.name === 'diagnostics') {
263
+ window.__typst_diagnostics = ev.data.notification?.diagnostics || [];
264
+ } else if (ev.data.name === 'status') {
265
+ window.__typst_status = ev.data.notification?.status || 'idle';
266
+ }
267
+ }
268
+ } catch(e) {}
269
+ });
270
+ return w;
271
+ };
272
+ """)
273
+
274
+ page = ctx.new_page()
275
+ page.goto(f"{app_url}/project/{proj_id}", wait_until="commit", timeout=45000)
276
+
277
+ start_time = time.time()
278
+ diags = None
279
+ while time.time() - start_time < timeout_sec:
280
+ data = page.evaluate("({ diag: window.__typst_diagnostics, status: window.__typst_status, url: window.location.href })")
281
+ if "signin" in data.get("url", ""):
282
+ browser.close()
283
+ sys.stderr.write(f"AUTH_REQUIRED: Redirected to {data.get('url')}")
284
+ sys.exit(3)
285
+ if data.get("diag") is not None:
286
+ diags = data.get("diag")
287
+ if data.get("status") == "idle":
288
+ break
289
+ page.wait_for_timeout(300)
290
+
291
+ browser.close()
292
+ print(json.dumps(diags or []))
293
+ `;
294
+ const proc = spawnSync(pythonBin, ["-c", pyScript], {
295
+ env: {
296
+ ...process.env,
297
+ TYPST_APP_URL: params.appUrl,
298
+ TYPST_PROJ_ID: params.projectId,
299
+ TYPST_COOKIE: params.cookieHeader,
300
+ TYPST_TIMEOUT_SEC: timeoutSec,
301
+ },
302
+ encoding: "utf8",
303
+ timeout: (params.timeoutMs ?? 25000) + 15000,
304
+ });
305
+ if (proc.error || proc.status !== 0) {
306
+ const stderr = (proc.stderr || proc.error?.message || "").trim();
307
+ if (stderr.includes("NO_BROWSER_FOUND")) {
308
+ throw new TypstLoadError(["NO_BROWSER_FOUND"], "No compatible browser (Edge, Chrome, or Chromium) found for Playwright web diagnostics.");
309
+ }
310
+ if (stderr.includes("AUTH_REQUIRED")) {
311
+ throw new TypstLoadError(["UNAUTHENTICATED"], `Typst Web cloud session expired or invalid: ${stderr}`);
312
+ }
313
+ throw new TypstLoadError(["WEB_DIAGNOSTICS_FAILED"], `Failed to capture Typst Web cloud diagnostics: ${stderr || "Ensure python with playwright is installed ('pip install playwright && playwright install')"}`);
314
+ }
315
+ const rawJson = (proc.stdout || "").trim();
316
+ let rawList = [];
317
+ try {
318
+ rawList = JSON.parse(rawJson);
319
+ }
320
+ catch (err) {
321
+ throw new TypstLoadError(["WEB_DIAGNOSTICS_PARSE_FAILED"], `Failed to parse Typst Web diagnostics output: ${err.message}`);
322
+ }
323
+ const mainFileName = params.mainFile ?? "main.typ";
324
+ const diagnostics = rawList.map((raw) => {
325
+ let line = 1;
326
+ let column = 1;
327
+ const filePath = raw.path || mainFileName;
328
+ const fileText = params.fileContents?.[filePath];
329
+ if (fileText && raw.range?.start !== undefined) {
330
+ const pos = offsetToPosition(fileText, raw.range.start);
331
+ line = pos.line;
332
+ column = pos.column;
333
+ }
334
+ return {
335
+ path: filePath,
336
+ line,
337
+ column,
338
+ severity: raw.severity === "error" ? "error" : "warning",
339
+ message: raw.message,
340
+ hints: Array.isArray(raw.hints) ? raw.hints : [],
341
+ package: raw.package ?? null,
342
+ range: raw.range
343
+ ? { start: raw.range.start, end: raw.range.end }
344
+ : undefined,
345
+ };
346
+ });
347
+ const errorCount = diagnostics.filter((d) => d.severity === "error").length;
348
+ const warningCount = diagnostics.filter((d) => d.severity === "warning").length;
349
+ return {
350
+ projectId: params.projectId,
351
+ mainFile: mainFileName,
352
+ mode: "web",
353
+ success: errorCount === 0,
354
+ errorCount,
355
+ warningCount,
356
+ diagnostics,
357
+ };
358
+ }
151
359
  export function captureBrowserPreviewScreenshot(params) {
152
360
  const outputDir = resolveOutputDirectory(params.outputDir, `typst-preview-ui-${params.projectId}-`);
153
361
  mkdirSync(outputDir, { recursive: true });
154
362
  const screenshotPath = join(outputDir, `typst-app-${params.projectId}.png`);
363
+ const pythonBin = resolvePythonBinary();
155
364
  const pyScript = `
156
- import os
365
+ import os, sys
157
366
  from playwright.sync_api import sync_playwright
158
367
 
159
368
  app_url = os.environ["TYPST_PREVIEW_APP_URL"]
@@ -166,16 +375,35 @@ for part in raw_cookie.split(";"):
166
375
  part = part.strip()
167
376
  if "=" in part:
168
377
  k, v = part.split("=", 1)
378
+ name = k.strip()
379
+ val = v.strip()
380
+ is_session = "__Http" in name or "session" in name
169
381
  cookies.append({
170
- "name": k.strip(),
171
- "value": v.strip(),
172
- "domain": "typst.app",
382
+ "name": name,
383
+ "value": val,
384
+ "domain": ".typst.app",
173
385
  "path": "/",
386
+ "httpOnly": is_session,
174
387
  "secure": True,
388
+ "sameSite": "Strict" if is_session else "Lax",
175
389
  })
176
390
 
177
391
  with sync_playwright() as p:
178
- browser = p.chromium.launch(channel="msedge", headless=True)
392
+ browser = None
393
+ for channel in ["msedge", "chrome", None]:
394
+ try:
395
+ if channel:
396
+ browser = p.chromium.launch(channel=channel, headless=True)
397
+ else:
398
+ browser = p.chromium.launch(headless=True)
399
+ break
400
+ except Exception:
401
+ continue
402
+
403
+ if browser is None:
404
+ sys.stderr.write("NO_BROWSER_FOUND")
405
+ sys.exit(2)
406
+
179
407
  ctx = browser.new_context(viewport={"width": 1920, "height": 1080}, device_scale_factor=2)
180
408
  if cookies:
181
409
  ctx.add_cookies(cookies)
@@ -185,7 +413,7 @@ with sync_playwright() as p:
185
413
  page.screenshot(path=out_path, full_page=False)
186
414
  browser.close()
187
415
  `;
188
- const proc = spawnSync("python", ["-c", pyScript], {
416
+ const proc = spawnSync(pythonBin, ["-c", pyScript], {
189
417
  env: {
190
418
  ...process.env,
191
419
  TYPST_PREVIEW_APP_URL: params.appUrl,