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.
- package/LICENSE +661 -0
- package/README.md +403 -0
- package/README.zh-CN.md +391 -0
- package/dist/client/assets/GitDialog-BHHxSBn3.js +3 -0
- package/dist/client/assets/HistoryDialog-Dlv57tYf.js +5 -0
- package/dist/client/assets/LatexEditor-B-0VRUPv.js +55 -0
- package/dist/client/assets/PdfPreview-617TWFJN.js +1 -0
- package/dist/client/assets/ProjectNavigationDialogs-Cpt2LVEt.js +1 -0
- package/dist/client/assets/SystemMetricsDialog-fkFHVVzJ.js +1 -0
- package/dist/client/assets/editor-JxT6uSYX.js +14 -0
- package/dist/client/assets/index-Bc5CmFMl.js +15 -0
- package/dist/client/assets/index-CMqYAbDx.css +1 -0
- package/dist/client/assets/pdf-DPnwNQHb.js +55 -0
- package/dist/client/assets/pdf.worker.min-CHFwMXne.mjs +29 -0
- package/dist/client/assets/plus-DzbLkp6l.js +1 -0
- package/dist/client/assets/react-CsO_xvbl.js +49 -0
- package/dist/client/assets/spellCheck-R6fUM9oA.js +49783 -0
- package/dist/client/index.html +20 -0
- package/dist/client/logo.svg +4 -0
- package/dist/server/anchors.js +95 -0
- package/dist/server/app.js +1624 -0
- package/dist/server/archive.js +26 -0
- package/dist/server/auth.js +42 -0
- package/dist/server/cli.js +318 -0
- package/dist/server/collaboration.js +635 -0
- package/dist/server/compileArtifacts.js +86 -0
- package/dist/server/compileDiagnostics.js +227 -0
- package/dist/server/compiler.js +514 -0
- package/dist/server/config.js +336 -0
- package/dist/server/db.js +280 -0
- package/dist/server/environment.js +57 -0
- package/dist/server/files.js +121 -0
- package/dist/server/git.js +414 -0
- package/dist/server/history.js +379 -0
- package/dist/server/http.js +7 -0
- package/dist/server/index.js +75 -0
- package/dist/server/latexCompletion.js +296 -0
- package/dist/server/metrics.js +34 -0
- package/dist/server/pm2.js +234 -0
- package/dist/server/projectOutline.js +101 -0
- package/dist/server/projectSearch.js +112 -0
- package/dist/server/projects.js +29 -0
- package/dist/server/routes/compile.js +343 -0
- package/dist/server/runtimePaths.js +42 -0
- package/dist/server/security.js +29 -0
- package/dist/server/spellCheck.js +195 -0
- package/dist/server/synctex.js +56 -0
- package/dist/server/zip.js +185 -0
- package/package.json +110 -0
- package/preview.png +0 -0
- 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
|
+
}
|