texlite 0.2.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.
Files changed (51) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +403 -0
  3. package/README.zh-CN.md +391 -0
  4. package/dist/client/assets/GitDialog-BHHxSBn3.js +3 -0
  5. package/dist/client/assets/HistoryDialog-Dlv57tYf.js +5 -0
  6. package/dist/client/assets/LatexEditor-B-0VRUPv.js +55 -0
  7. package/dist/client/assets/PdfPreview-617TWFJN.js +1 -0
  8. package/dist/client/assets/ProjectNavigationDialogs-Cpt2LVEt.js +1 -0
  9. package/dist/client/assets/SystemMetricsDialog-fkFHVVzJ.js +1 -0
  10. package/dist/client/assets/editor-JxT6uSYX.js +14 -0
  11. package/dist/client/assets/index-Bc5CmFMl.js +15 -0
  12. package/dist/client/assets/index-CMqYAbDx.css +1 -0
  13. package/dist/client/assets/pdf-DPnwNQHb.js +55 -0
  14. package/dist/client/assets/pdf.worker.min-CHFwMXne.mjs +29 -0
  15. package/dist/client/assets/plus-DzbLkp6l.js +1 -0
  16. package/dist/client/assets/react-CsO_xvbl.js +49 -0
  17. package/dist/client/assets/spellCheck-R6fUM9oA.js +49783 -0
  18. package/dist/client/index.html +20 -0
  19. package/dist/client/logo.svg +4 -0
  20. package/dist/server/anchors.js +95 -0
  21. package/dist/server/app.js +1624 -0
  22. package/dist/server/archive.js +26 -0
  23. package/dist/server/auth.js +42 -0
  24. package/dist/server/cli.js +318 -0
  25. package/dist/server/collaboration.js +635 -0
  26. package/dist/server/compileArtifacts.js +86 -0
  27. package/dist/server/compileDiagnostics.js +227 -0
  28. package/dist/server/compiler.js +514 -0
  29. package/dist/server/config.js +336 -0
  30. package/dist/server/db.js +280 -0
  31. package/dist/server/environment.js +57 -0
  32. package/dist/server/files.js +121 -0
  33. package/dist/server/git.js +414 -0
  34. package/dist/server/history.js +379 -0
  35. package/dist/server/http.js +7 -0
  36. package/dist/server/index.js +75 -0
  37. package/dist/server/latexCompletion.js +296 -0
  38. package/dist/server/metrics.js +34 -0
  39. package/dist/server/pm2.js +234 -0
  40. package/dist/server/projectOutline.js +101 -0
  41. package/dist/server/projectSearch.js +112 -0
  42. package/dist/server/projects.js +29 -0
  43. package/dist/server/routes/compile.js +343 -0
  44. package/dist/server/runtimePaths.js +42 -0
  45. package/dist/server/security.js +29 -0
  46. package/dist/server/spellCheck.js +195 -0
  47. package/dist/server/synctex.js +56 -0
  48. package/dist/server/zip.js +185 -0
  49. package/package.json +110 -0
  50. package/preview.png +0 -0
  51. package/texlite.config.example.json +33 -0
@@ -0,0 +1,514 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ import { spawn } from "node:child_process";
5
+ import { performance } from "node:perf_hooks";
6
+ import { pipeline } from "node:stream/promises";
7
+ import { Transform } from "node:stream";
8
+ import { gunzipSync, gzipSync } from "node:zlib";
9
+ import { listProjectFilesAsync, outputRoot, safeRelativePath, sourceRoot } from "./files.js";
10
+ import { parseCompileDiagnostics } from "./compileDiagnostics.js";
11
+ export class CompileQueue {
12
+ concurrency;
13
+ running = 0;
14
+ pending = [];
15
+ constructor(concurrency) {
16
+ this.concurrency = concurrency;
17
+ }
18
+ add(job) {
19
+ return new Promise((resolve, reject) => {
20
+ this.pending.push(async () => {
21
+ try {
22
+ resolve(await job());
23
+ }
24
+ catch (error) {
25
+ reject(error);
26
+ }
27
+ });
28
+ this.drain();
29
+ });
30
+ }
31
+ stats() {
32
+ return { concurrency: this.concurrency, running: this.running, pending: this.pending.length };
33
+ }
34
+ drain() {
35
+ while (this.running < this.concurrency && this.pending.length > 0) {
36
+ const task = this.pending.shift();
37
+ if (!task)
38
+ return;
39
+ this.running += 1;
40
+ void task().finally(() => {
41
+ this.running -= 1;
42
+ this.drain();
43
+ });
44
+ }
45
+ }
46
+ }
47
+ /** Serializes each project while still respecting the server-wide LaTeX process limit. */
48
+ export class ProjectCompileCoordinator {
49
+ queue;
50
+ targets = new Map();
51
+ constructor(queue) {
52
+ this.queue = queue;
53
+ }
54
+ request(input) {
55
+ const targetKey = `${input.projectId}\0${input.target ?? ""}`;
56
+ const state = this.targets.get(targetKey) ?? { active: null, pending: null };
57
+ this.targets.set(targetKey, state);
58
+ if (state.active?.input.revision === input.revision) {
59
+ if (state.pending) {
60
+ this.redirect(state.pending, state.active);
61
+ state.pending.input.onDiscarded("superseded");
62
+ state.pending = null;
63
+ }
64
+ input.onDiscarded("duplicate");
65
+ state.active.input.onSelected();
66
+ return state.active.promise;
67
+ }
68
+ if (state.pending?.input.revision === input.revision) {
69
+ input.onDiscarded("duplicate");
70
+ return state.pending.promise;
71
+ }
72
+ const managed = deferredJob(input);
73
+ if (state.active) {
74
+ if (state.pending) {
75
+ this.redirect(state.pending, managed);
76
+ state.pending.input.onDiscarded("superseded");
77
+ }
78
+ state.pending = managed;
79
+ }
80
+ else {
81
+ state.active = managed;
82
+ input.onQueued();
83
+ this.start(targetKey, state, managed);
84
+ }
85
+ return managed.promise;
86
+ }
87
+ start(targetKey, state, job) {
88
+ void this.queue.add(job.input.execute).then(job.resolve, job.reject).finally(() => {
89
+ if (state.active !== job)
90
+ return;
91
+ state.active = null;
92
+ const next = state.pending;
93
+ state.pending = null;
94
+ if (next) {
95
+ state.active = next;
96
+ next.input.onQueued();
97
+ this.start(targetKey, state, next);
98
+ }
99
+ else {
100
+ this.targets.delete(targetKey);
101
+ }
102
+ });
103
+ }
104
+ redirect(from, to) {
105
+ void to.promise.then(from.resolve, from.reject);
106
+ }
107
+ }
108
+ function deferredJob(input) {
109
+ let resolve;
110
+ let reject;
111
+ const promise = new Promise((resolvePromise, rejectPromise) => {
112
+ resolve = resolvePromise;
113
+ reject = rejectPromise;
114
+ });
115
+ return { input, promise, resolve, reject };
116
+ }
117
+ export async function captureCompileSnapshot(config, projectId, runId, settings) {
118
+ const root = compileRunRoot(config, projectId, runId);
119
+ const snapshotSource = path.join(root, "source");
120
+ const snapshotOutput = path.join(root, "output");
121
+ await fs.promises.mkdir(snapshotSource, { recursive: true, mode: 0o700 });
122
+ await fs.promises.mkdir(snapshotOutput, { recursive: true, mode: 0o700 });
123
+ const hash = createHash("sha256");
124
+ const files = [];
125
+ const directories = [];
126
+ hash.update(JSON.stringify(settings));
127
+ try {
128
+ const entries = (await listProjectFilesAsync(config, projectId)).sort((left, right) => left.path.localeCompare(right.path));
129
+ const fileEntries = entries.filter((entry) => entry.type === "file");
130
+ for (const entry of entries) {
131
+ const destination = path.join(snapshotSource, entry.path);
132
+ hash.update(`\0${entry.type}\0${entry.path}\0`);
133
+ if (entry.type === "directory") {
134
+ await fs.promises.mkdir(destination, { recursive: true, mode: 0o700 });
135
+ directories.push(entry.path);
136
+ }
137
+ }
138
+ // Four concurrent streams improve snapshot latency for typical papers with
139
+ // many figures without creating unbounded disk pressure on a shared host.
140
+ for (let offset = 0; offset < fileEntries.length; offset += 4) {
141
+ const batch = fileEntries.slice(offset, offset + 4);
142
+ const settled = await Promise.allSettled(batch.map(async (entry) => {
143
+ const destination = path.join(snapshotSource, entry.path);
144
+ const liveFile = path.join(sourceRoot(config, projectId), entry.path);
145
+ const stat = await fs.promises.stat(liveFile);
146
+ await fs.promises.mkdir(path.dirname(destination), { recursive: true, mode: 0o700 });
147
+ const digest = await copyAndDigest(liveFile, destination);
148
+ await fs.promises.utimes(destination, stat.atime, stat.mtime);
149
+ return { path: entry.path, digest, size: stat.size, mtimeMs: stat.mtimeMs };
150
+ }));
151
+ const failed = settled.find((result) => result.status === "rejected");
152
+ if (failed)
153
+ throw failed.reason;
154
+ files.push(...settled.map((result) => result.value));
155
+ }
156
+ for (const file of files)
157
+ hash.update(file.digest);
158
+ return {
159
+ projectId, runId, mainFile: safeRelativePath(settings.mainFile), revision: hash.digest("hex"), root,
160
+ sourceDir: snapshotSource, outputDir: snapshotOutput, files, directories
161
+ };
162
+ }
163
+ catch (error) {
164
+ await fs.promises.rm(root, { recursive: true, force: true });
165
+ throw error;
166
+ }
167
+ }
168
+ async function copyAndDigest(source, destination) {
169
+ const digest = createHash("sha256");
170
+ const hasher = new Transform({
171
+ transform(chunk, _encoding, callback) {
172
+ digest.update(chunk);
173
+ callback(null, chunk);
174
+ }
175
+ });
176
+ await pipeline(fs.createReadStream(source), hasher, fs.createWriteStream(destination, { mode: 0o600 }));
177
+ return digest.digest("hex");
178
+ }
179
+ export function discardCompileSnapshot(snapshot) {
180
+ fs.rmSync(snapshot.root, { recursive: true, force: true });
181
+ }
182
+ export function publishCompileArtifacts(config, projectId, snapshot, result) {
183
+ if (!result.ok || !result.pdfPath)
184
+ throw new Error("无法发布失败的编译结果");
185
+ const manifestDirectory = compileTargetRoot(config, projectId, snapshot.mainFile);
186
+ const previous = publishedCompileArtifacts(config, projectId, snapshot.mainFile);
187
+ const manifest = {
188
+ version: 2,
189
+ mainFile: snapshot.mainFile,
190
+ runId: snapshot.runId,
191
+ revision: snapshot.revision,
192
+ pdf: path.basename(result.pdfPath),
193
+ synctex: result.synctexPath ? path.basename(result.synctexPath) : null
194
+ };
195
+ fs.mkdirSync(manifestDirectory, { recursive: true, mode: 0o700 });
196
+ const target = path.join(manifestDirectory, "latest.json");
197
+ const temporary = path.join(manifestDirectory, `latest-${snapshot.runId}.tmp`);
198
+ fs.writeFileSync(temporary, JSON.stringify(manifest), { encoding: "utf8", mode: 0o600 });
199
+ fs.renameSync(temporary, target);
200
+ if (previous && previous.runId !== snapshot.runId) {
201
+ // Keep the previous immutable bundle briefly so an already-open PDF or SyncTeX
202
+ // request can finish after the manifest switches to the new bundle.
203
+ const cleanup = setTimeout(() => {
204
+ fs.rmSync(compileRunRoot(config, projectId, previous.runId), { recursive: true, force: true });
205
+ }, 60_000);
206
+ cleanup.unref();
207
+ }
208
+ return publishedCompileArtifacts(config, projectId, snapshot.mainFile);
209
+ }
210
+ export function publishedCompileArtifacts(config, projectId, mainFile, allowLegacy = false) {
211
+ if (mainFile !== undefined) {
212
+ const normalized = safeRelativePath(mainFile);
213
+ const targeted = readPublishedManifest(config, projectId, path.join(compileTargetRoot(config, projectId, normalized), "latest.json"), normalized);
214
+ if (targeted)
215
+ return targeted;
216
+ return allowLegacy ? readPublishedManifest(config, projectId, path.join(compileDataRoot(config, projectId), "latest.json"), normalized, true) : null;
217
+ }
218
+ const legacy = readPublishedManifest(config, projectId, path.join(compileDataRoot(config, projectId), "latest.json"), "", true);
219
+ if (legacy)
220
+ return legacy;
221
+ return listPublishedCompileArtifacts(config, projectId)
222
+ .sort((left, right) => fs.statSync(right.pdf).mtimeMs - fs.statSync(left.pdf).mtimeMs)[0] ?? null;
223
+ }
224
+ export function listPublishedCompileArtifacts(config, projectId) {
225
+ const result = [];
226
+ const targetsRoot = path.join(compileDataRoot(config, projectId), "targets");
227
+ if (fs.existsSync(targetsRoot)) {
228
+ for (const entry of fs.readdirSync(targetsRoot, { withFileTypes: true })) {
229
+ if (!entry.isDirectory())
230
+ continue;
231
+ const artifact = readPublishedManifest(config, projectId, path.join(targetsRoot, entry.name, "latest.json"));
232
+ if (artifact)
233
+ result.push(artifact);
234
+ }
235
+ }
236
+ const legacy = readPublishedManifest(config, projectId, path.join(compileDataRoot(config, projectId), "latest.json"), "", true);
237
+ if (legacy && !result.some((artifact) => artifact.runId === legacy.runId))
238
+ result.push(legacy);
239
+ return result;
240
+ }
241
+ function readPublishedManifest(config, projectId, manifestPath, expectedMainFile, legacy = false) {
242
+ if (!fs.existsSync(manifestPath))
243
+ return null;
244
+ try {
245
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
246
+ const mainFile = manifest.version === 2 && typeof manifest.mainFile === "string"
247
+ ? safeRelativePath(manifest.mainFile)
248
+ : legacy && manifest.version === 1 ? expectedMainFile ?? "" : null;
249
+ if (mainFile === null || (expectedMainFile !== undefined && mainFile !== expectedMainFile)
250
+ || typeof manifest.runId !== "string" || !/^[a-f0-9-]{36}$/i.test(manifest.runId)
251
+ || typeof manifest.revision !== "string" || typeof manifest.pdf !== "string"
252
+ || path.basename(manifest.pdf) !== manifest.pdf
253
+ || (manifest.synctex !== null && (typeof manifest.synctex !== "string" || path.basename(manifest.synctex) !== manifest.synctex))) {
254
+ return null;
255
+ }
256
+ const root = compileRunRoot(config, projectId, manifest.runId);
257
+ const source = path.join(root, "source");
258
+ const output = path.join(root, "output");
259
+ const pdf = path.join(output, manifest.pdf);
260
+ const synctex = manifest.synctex ? path.join(output, manifest.synctex) : null;
261
+ if (!fs.existsSync(source) || !fs.existsSync(output) || !fs.existsSync(pdf) || (synctex && !fs.existsSync(synctex)))
262
+ return null;
263
+ return { mainFile, runId: manifest.runId, revision: manifest.revision, source, output, pdf, synctex };
264
+ }
265
+ catch {
266
+ return null;
267
+ }
268
+ }
269
+ /** Remove run bundles left by an interrupted process, preserving the published PDF. */
270
+ export function pruneOrphanedCompileRuns(config, projectId) {
271
+ const runsRoot = path.join(compileDataRoot(config, projectId), "runs");
272
+ const publishedRunIds = new Set(listPublishedCompileArtifacts(config, projectId).map((artifact) => artifact.runId));
273
+ if (!fs.existsSync(runsRoot))
274
+ return;
275
+ for (const entry of fs.readdirSync(runsRoot, { withFileTypes: true })) {
276
+ if (!entry.isDirectory() || publishedRunIds.has(entry.name))
277
+ continue;
278
+ fs.rmSync(path.join(runsRoot, entry.name), { recursive: true, force: true });
279
+ }
280
+ }
281
+ function compileDataRoot(config, projectId) {
282
+ return path.join(outputRoot(config, projectId), ".texlite");
283
+ }
284
+ function compileTargetRoot(config, projectId, mainFile) {
285
+ return path.join(compileDataRoot(config, projectId), "targets", compileTargetKey(mainFile));
286
+ }
287
+ function compileTargetKey(mainFile) {
288
+ return createHash("sha256").update(safeRelativePath(mainFile)).digest("hex").slice(0, 24);
289
+ }
290
+ function compileRunRoot(config, projectId, runId) {
291
+ return path.join(compileDataRoot(config, projectId), "runs", runId);
292
+ }
293
+ function compileCacheKey(config, snapshot, mainFile, engine, latexmkrc) {
294
+ const hash = createHash("sha256");
295
+ hash.update(JSON.stringify({
296
+ version: 1,
297
+ latexmk: config.latexmk,
298
+ engine,
299
+ mainFile,
300
+ latexmkrc,
301
+ extraArgs: config.extraArgs
302
+ }));
303
+ if (latexmkrc)
304
+ hash.update(fs.readFileSync(path.join(snapshot.sourceDir, latexmkrc)));
305
+ return hash.digest("hex").slice(0, 24);
306
+ }
307
+ function prepareCompileCache(config, snapshot, mainFile, engine, latexmkrc) {
308
+ const cacheDirectory = path.join(compileDataRoot(config, snapshot.projectId), "cache", compileTargetKey(mainFile));
309
+ const key = compileCacheKey(config, snapshot, mainFile, engine, latexmkrc);
310
+ const root = path.join(cacheDirectory, key);
311
+ const cacheSource = path.join(root, "source");
312
+ const cacheOutput = path.join(root, "output");
313
+ const statePath = path.join(root, "state.json");
314
+ fs.mkdirSync(cacheSource, { recursive: true, mode: 0o700 });
315
+ fs.mkdirSync(cacheOutput, { recursive: true, mode: 0o700 });
316
+ // Jobs for this root are serialized, so stale compiler-setting variants can
317
+ // be removed safely. Sibling root caches live in other directories and may
318
+ // be in use by another session.
319
+ for (const entry of fs.readdirSync(cacheDirectory, { withFileTypes: true })) {
320
+ if (entry.isDirectory() && entry.name !== key) {
321
+ fs.rmSync(path.join(cacheDirectory, entry.name), { recursive: true, force: true });
322
+ }
323
+ }
324
+ const previous = readCompileCacheState(statePath);
325
+ const validFiles = new Set(snapshot.files.map((file) => file.path));
326
+ const validDirectories = new Set(snapshot.directories);
327
+ for (const file of snapshot.files) {
328
+ for (let parent = path.posix.dirname(file.path); parent !== "."; parent = path.posix.dirname(parent)) {
329
+ validDirectories.add(parent);
330
+ }
331
+ }
332
+ pruneCompileSource(cacheSource, "", validFiles, validDirectories);
333
+ const nextFiles = {};
334
+ for (const directory of [...validDirectories].sort()) {
335
+ fs.mkdirSync(path.join(cacheSource, directory), { recursive: true, mode: 0o700 });
336
+ }
337
+ for (const file of snapshot.files) {
338
+ const source = path.join(snapshot.sourceDir, file.path);
339
+ const destination = path.join(cacheSource, file.path);
340
+ const previousFile = previous?.files[file.path];
341
+ const destinationStat = regularFileStat(destination);
342
+ if (!previousFile || previousFile.digest !== file.digest || previousFile.size !== file.size
343
+ || !destinationStat || destinationStat.size !== file.size) {
344
+ fs.mkdirSync(path.dirname(destination), { recursive: true, mode: 0o700 });
345
+ const temporary = `${destination}.texlite-${process.pid}-${Date.now()}`;
346
+ try {
347
+ fs.copyFileSync(source, temporary);
348
+ fs.chmodSync(temporary, 0o600);
349
+ // A changed file must be newer than both its cached predecessor and the
350
+ // last generated output. Unchanged files are never rewritten, so their
351
+ // stable mtimes let latexmk reuse its dependency database.
352
+ const mtimeMs = Math.max(file.mtimeMs, destinationStat ? destinationStat.mtimeMs + 1 : 0, Date.now());
353
+ fs.utimesSync(temporary, new Date(mtimeMs), new Date(mtimeMs));
354
+ fs.renameSync(temporary, destination);
355
+ }
356
+ finally {
357
+ if (fs.existsSync(temporary))
358
+ fs.rmSync(temporary, { force: true });
359
+ }
360
+ }
361
+ const cachedStat = fs.statSync(destination);
362
+ nextFiles[file.path] = { digest: file.digest, size: file.size, mtimeMs: cachedStat.mtimeMs };
363
+ }
364
+ writeCompileCacheState(statePath, { version: 1, files: nextFiles });
365
+ return { sourceDir: cacheSource, outputDir: cacheOutput };
366
+ }
367
+ function readCompileCacheState(statePath) {
368
+ if (!fs.existsSync(statePath))
369
+ return null;
370
+ try {
371
+ const state = JSON.parse(fs.readFileSync(statePath, "utf8"));
372
+ if (state.version !== 1 || typeof state.files !== "object" || state.files === null)
373
+ return null;
374
+ return state;
375
+ }
376
+ catch {
377
+ return null;
378
+ }
379
+ }
380
+ function writeCompileCacheState(statePath, state) {
381
+ const temporary = `${statePath}.${process.pid}.tmp`;
382
+ fs.writeFileSync(temporary, JSON.stringify(state), { encoding: "utf8", mode: 0o600 });
383
+ fs.renameSync(temporary, statePath);
384
+ }
385
+ function regularFileStat(file) {
386
+ try {
387
+ const stat = fs.lstatSync(file);
388
+ return stat.isFile() ? stat : null;
389
+ }
390
+ catch {
391
+ return null;
392
+ }
393
+ }
394
+ function pruneCompileSource(root, prefix, validFiles, validDirectories) {
395
+ for (const entry of fs.readdirSync(path.join(root, prefix), { withFileTypes: true })) {
396
+ const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
397
+ const absolute = path.join(root, relative);
398
+ if (entry.isDirectory()) {
399
+ if (!validDirectories.has(relative))
400
+ fs.rmSync(absolute, { recursive: true, force: true });
401
+ else
402
+ pruneCompileSource(root, relative, validFiles, validDirectories);
403
+ }
404
+ else if (!entry.isFile() || !validFiles.has(relative)) {
405
+ fs.rmSync(absolute, { force: true });
406
+ }
407
+ }
408
+ }
409
+ function materializeCompileArtifacts(cache, snapshot, basename) {
410
+ fs.rmSync(snapshot.outputDir, { recursive: true, force: true });
411
+ fs.mkdirSync(snapshot.outputDir, { recursive: true, mode: 0o700 });
412
+ copyCompileArtifacts(cache.outputDir, snapshot.outputDir);
413
+ const pdfPath = path.join(snapshot.outputDir, `${basename}.pdf`);
414
+ const synctexPath = path.join(snapshot.outputDir, `${basename}.synctex.gz`);
415
+ if (fs.existsSync(synctexPath))
416
+ rewriteSynctexSource(synctexPath, cache.sourceDir, snapshot.sourceDir);
417
+ return { pdfPath, synctexPath: fs.existsSync(synctexPath) ? synctexPath : null };
418
+ }
419
+ function copyCompileArtifacts(source, destination) {
420
+ for (const entry of fs.readdirSync(source, { withFileTypes: true })) {
421
+ const from = path.join(source, entry.name);
422
+ const to = path.join(destination, entry.name);
423
+ if (entry.isDirectory()) {
424
+ fs.mkdirSync(to, { recursive: true, mode: 0o700 });
425
+ copyCompileArtifacts(from, to);
426
+ }
427
+ else if (entry.isFile()) {
428
+ fs.copyFileSync(from, to);
429
+ fs.chmodSync(to, 0o600);
430
+ }
431
+ }
432
+ }
433
+ function rewriteSynctexSource(synctexPath, cacheSource, snapshotSource) {
434
+ const original = gunzipSync(fs.readFileSync(synctexPath)).toString("utf8");
435
+ const rewritten = original.replaceAll(path.resolve(cacheSource), path.resolve(snapshotSource));
436
+ if (rewritten !== original)
437
+ fs.writeFileSync(synctexPath, gzipSync(Buffer.from(rewritten)), { mode: 0o600 });
438
+ }
439
+ export async function compileProject(config, snapshot, mainFileInput, engine, latexmkrcInput) {
440
+ const mainFile = safeRelativePath(mainFileInput);
441
+ if (!mainFile.endsWith(".tex"))
442
+ throw new Error("主文件必须是 .tex 文件");
443
+ const startedAt = performance.now();
444
+ let latexmkrc = null;
445
+ if (config.allowProjectLatexmkrc && latexmkrcInput) {
446
+ latexmkrc = safeRelativePath(latexmkrcInput);
447
+ const absoluteRc = path.join(snapshot.sourceDir, latexmkrc);
448
+ if (!fs.existsSync(absoluteRc) || !fs.statSync(absoluteRc).isFile()) {
449
+ throw new Error(`项目 latexmkrc 不存在:${latexmkrc}`);
450
+ }
451
+ }
452
+ const cacheStartedAt = performance.now();
453
+ const cache = prepareCompileCache(config, snapshot, mainFile, engine, latexmkrc);
454
+ const cacheSyncMs = performance.now() - cacheStartedAt;
455
+ const cwd = cache.sourceDir;
456
+ const outDir = cache.outputDir;
457
+ const engineFlag = engine === "xelatex" ? "-xelatex" : engine === "lualatex" ? "-lualatex" : "-pdf";
458
+ const args = [
459
+ engineFlag,
460
+ "-interaction=nonstopmode",
461
+ "-file-line-error",
462
+ "-halt-on-error",
463
+ "-synctex=1",
464
+ "-no-shell-escape",
465
+ `-outdir=${outDir}`
466
+ ];
467
+ if (latexmkrc)
468
+ args.push("-r", latexmkrc);
469
+ args.push(...config.extraArgs, mainFile);
470
+ const latexmkStartedAt = performance.now();
471
+ const processResult = await new Promise((resolve, reject) => {
472
+ const child = spawn(config.latexmk, args, {
473
+ cwd,
474
+ shell: false,
475
+ env: { ...process.env, max_print_line: "1000" },
476
+ stdio: ["ignore", "pipe", "pipe"]
477
+ });
478
+ let log = "";
479
+ const append = (chunk) => {
480
+ log += chunk.toString("utf8");
481
+ if (log.length > 2_000_000)
482
+ log = log.slice(-2_000_000);
483
+ };
484
+ child.stdout.on("data", append);
485
+ child.stderr.on("data", append);
486
+ child.on("error", reject);
487
+ const timeout = setTimeout(() => {
488
+ log += `\n编译超过 ${config.compileTimeoutMs / 1000} 秒,已终止。\n`;
489
+ child.kill("SIGKILL");
490
+ }, config.compileTimeoutMs);
491
+ child.on("close", (code) => {
492
+ clearTimeout(timeout);
493
+ resolve({ code, log });
494
+ });
495
+ });
496
+ const latexmkMs = performance.now() - latexmkStartedAt;
497
+ const basename = path.basename(mainFile, ".tex");
498
+ const cachedPdf = path.join(outDir, `${basename}.pdf`);
499
+ const ok = processResult.code === 0 && fs.existsSync(cachedPdf);
500
+ const diagnostics = parseCompileDiagnostics(processResult.log, ok ? "succeeded" : "failed");
501
+ if (!ok) {
502
+ return {
503
+ ok: false, log: processResult.log, diagnostics, pdfPath: null, synctexPath: null,
504
+ timings: { cacheSyncMs, latexmkMs, artifactCopyMs: 0, totalMs: performance.now() - startedAt }
505
+ };
506
+ }
507
+ const artifactStartedAt = performance.now();
508
+ const artifacts = materializeCompileArtifacts(cache, snapshot, basename);
509
+ const artifactCopyMs = performance.now() - artifactStartedAt;
510
+ return {
511
+ ok: true, log: processResult.log, diagnostics, ...artifacts,
512
+ timings: { cacheSyncMs, latexmkMs, artifactCopyMs, totalMs: performance.now() - startedAt }
513
+ };
514
+ }