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 +115 -72
- package/dist/client/auth.js +36 -3
- package/dist/client/compiler.js +234 -6
- package/dist/client/typst-client.js +135 -112
- package/dist/index.js +84 -41
- package/dist/protocol/edit-diff.js +74 -23
- package/dist/protocol/read-content.js +61 -0
- package/dist/protocol/search-replace.js +104 -27
- package/dist/protocol/yjs-doc.js +47 -22
- package/dist/tools/definitions.js +75 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -13,42 +13,44 @@
|
|
|
13
13
|
|
|
14
14
|
---
|
|
15
15
|
|
|
16
|
-
##
|
|
16
|
+
## Installation
|
|
17
17
|
|
|
18
|
-
|
|
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
|
-
|
|
20
|
+
### Prerequisites
|
|
26
21
|
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
25
|
+
### Option 1: Normal (Standard)
|
|
32
26
|
|
|
33
|
-
|
|
27
|
+
Runs the published [`typst-mcp`](https://www.npmjs.com/package/typst-mcp) package (`npx -y typst-mcp`):
|
|
34
28
|
|
|
35
|
-
```
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
###
|
|
44
|
+
### Option 2: Always Latest
|
|
43
45
|
|
|
44
|
-
|
|
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
|
|
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
|
|
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` |
|
|
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` |
|
|
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
|
package/dist/client/auth.js
CHANGED
|
@@ -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
|
|
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
|
|
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(
|
|
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";
|
package/dist/client/compiler.js
CHANGED
|
@@ -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":
|
|
171
|
-
"value":
|
|
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 =
|
|
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(
|
|
416
|
+
const proc = spawnSync(pythonBin, ["-c", pyScript], {
|
|
189
417
|
env: {
|
|
190
418
|
...process.env,
|
|
191
419
|
TYPST_PREVIEW_APP_URL: params.appUrl,
|