papilot 0.8.5 → 0.8.7
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/dist/assets/index-BIc9Mnim.css +1 -0
- package/dist/assets/index-MvQvvv3I.js +267 -0
- package/dist/assets/{math-DHvVou9A.js → math-CBzEH7Y2.js} +1 -1
- package/dist/assets/{pdf-view-AYTS7kdA.js → pdf-view-ByftRBhD.js} +2 -2
- package/dist/index.html +2 -2
- package/engine/dist/web/assets/{FullPaper-DlgZ1MOz.js → FullPaper-DWwiT43p.js} +2 -2
- package/engine/dist/web/assets/{index-BCgVChtV.js → index-DBYJK8Uy.js} +4 -4
- package/engine/dist/web/assets/{index-D3_ZFdT8.css → index-DGkq8xQx.css} +1 -1
- package/engine/dist/web/index.html +2 -2
- package/engine/src/cli.ts +3 -0
- package/engine/src/client.ts +24 -7
- package/engine/src/core/build.ts +367 -92
- package/engine/src/core/changes.ts +39 -10
- package/engine/src/core/config.ts +2 -2
- package/engine/src/core/current-commit.ts +34 -34
- package/engine/src/core/delivery.ts +83 -30
- package/engine/src/core/files.ts +19 -0
- package/engine/src/core/git.ts +283 -92
- package/engine/src/core/process.ts +124 -11
- package/engine/src/core/publish.ts +59 -50
- package/engine/src/core/reconcile.ts +23 -21
- package/engine/src/core/review.ts +43 -24
- package/engine/src/core/tasks.ts +85 -28
- package/engine/src/core/view.ts +8 -8
- package/engine/src/server.ts +62 -6
- package/engine/src/types.ts +8 -0
- package/engine/src/web/FullPaper.tsx +122 -34
- package/engine/src/web/review.tsx +4 -1
- package/engine/src/web/style.css +24 -0
- package/package.json +1 -1
- package/src/server/agents.ts +3 -0
- package/src/server/annotated-pdf.ts +392 -0
- package/src/server/annotation-placement.ts +18 -3
- package/src/server/app.ts +68 -10
- package/src/server/changes.ts +12 -7
- package/src/server/cursor.ts +1 -0
- package/src/server/manuscript.ts +218 -56
- package/src/server/pdf.ts +176 -23
- package/src/server/references.ts +10 -2
- package/src/server/review.ts +6 -2
- package/src/server/start.ts +4 -1
- package/src/server/update-install.ts +5 -1
- package/src/server/updates.ts +24 -49
- package/src/server/workspace.ts +18 -0
- package/dist/assets/index-DSWizoaI.js +0 -267
- package/dist/assets/index-PFQDdu53.css +0 -1
package/engine/src/core/build.ts
CHANGED
|
@@ -2,12 +2,14 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { createHash, randomUUID } from "node:crypto";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
|
-
import {
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import { gunzip, gzip } from "node:zlib";
|
|
7
|
+
import type { ChildProcess } from "node:child_process";
|
|
6
8
|
import { Repo } from "./git.ts";
|
|
7
|
-
import {
|
|
9
|
+
import { readJson, writeJsonAsync, withLock, now } from "./files.ts";
|
|
8
10
|
import { stripComments } from "./outline.ts";
|
|
9
11
|
import { signature, type Project } from "./config.ts";
|
|
10
|
-
import {
|
|
12
|
+
import { capture, commandVersion, runAsync } from "./process.ts";
|
|
11
13
|
import { progressReader, writtenPages } from "./progress.ts";
|
|
12
14
|
import type { BuildInfo, BuildProgress } from "../types.ts";
|
|
13
15
|
export class BuildError extends Error {
|
|
@@ -33,24 +35,127 @@ const AUXILIARY = new Set([
|
|
|
33
35
|
// Output that TeX writes for this folder: SyncTeX data and the log name its
|
|
34
36
|
// absolute path.
|
|
35
37
|
const OUTPUT = /\.(?:pdf|xdv|dvi|log|synctex(?:\.gz)?)$/i;
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
fs.
|
|
38
|
+
const unzip = promisify(gunzip), zip = promisify(gzip);
|
|
39
|
+
async function clone(file: string, target: string) {
|
|
40
|
+
await fs.promises.mkdir(path.dirname(target), { recursive: true });
|
|
41
|
+
await fs.promises.copyFile(file, target, fs.constants.COPYFILE_FICLONE);
|
|
42
|
+
}
|
|
43
|
+
// Every file below a folder, read without blocking the event loop.
|
|
44
|
+
async function filesIn(root: string): Promise<string[]> {
|
|
45
|
+
let items: fs.Dirent[];
|
|
46
|
+
try { items = await fs.promises.readdir(root, { withFileTypes: true }); }
|
|
47
|
+
catch { return []; }
|
|
48
|
+
return (await Promise.all(items.map((e) => e.isDirectory() ? filesIn(path.join(root, e.name)) : [path.join(root, e.name)]))).flat();
|
|
39
49
|
}
|
|
40
50
|
// Output that latexmk kept from an earlier build folder still names that
|
|
41
51
|
// folder. Build folders differ only in their equal-length key, so the paths
|
|
42
52
|
// are replaced in place without moving anything else in the file.
|
|
43
|
-
export function relocate(file: string, from: string, to: string) {
|
|
44
|
-
if (!fs.existsSync(file)) return;
|
|
53
|
+
export async function relocate(file: string, from: string, to: string) {
|
|
45
54
|
const old = Buffer.from(from + path.sep), next = Buffer.from(to + path.sep);
|
|
46
|
-
if (old.length !== next.length) return;
|
|
47
|
-
|
|
48
|
-
|
|
55
|
+
if (old.length !== next.length || old.equals(next)) return;
|
|
56
|
+
let data: Buffer;
|
|
57
|
+
try { data = await fs.promises.readFile(file); }
|
|
58
|
+
catch { return; }
|
|
59
|
+
const zipped = file.endsWith(".gz");
|
|
60
|
+
if (zipped) data = await unzip(data);
|
|
49
61
|
let at = data.indexOf(old);
|
|
50
62
|
if (at < 0) return;
|
|
51
63
|
for (; at >= 0; at = data.indexOf(old, at + next.length)) next.copy(data, at);
|
|
52
|
-
fs.
|
|
64
|
+
await fs.promises.writeFile(file, zipped ? await zip(data, { level: 1 }) : data);
|
|
65
|
+
}
|
|
66
|
+
/** How much a build matters to the person waiting: the PDF on screen comes
|
|
67
|
+
* first, a speculative build last. */
|
|
68
|
+
export type BuildPriority = "foreground" | "normal" | "background";
|
|
69
|
+
const RANK: Record<BuildPriority, number> = { background: 0, normal: 1, foreground: 2 };
|
|
70
|
+
interface Active {
|
|
71
|
+
key: string;
|
|
72
|
+
priority: BuildPriority;
|
|
73
|
+
child?: ChildProcess;
|
|
74
|
+
paused: boolean;
|
|
75
|
+
yielding?: boolean;
|
|
76
|
+
// Who is shown each pass, and the latest pass that more work followed.
|
|
77
|
+
watchers: Set<(preview: PassPreview) => void>;
|
|
78
|
+
followed?: () => void;
|
|
79
|
+
}
|
|
80
|
+
const active = new Set<Active>();
|
|
81
|
+
// Everyone in this process who waits for each build key. A build runs at the
|
|
82
|
+
// highest priority among them and shows its passes to all of them, also to
|
|
83
|
+
// those who asked before it started compiling.
|
|
84
|
+
interface Waiter { priority: BuildPriority; pass?: (preview: PassPreview) => void }
|
|
85
|
+
const waiting = new Map<string, Set<Waiter>>();
|
|
86
|
+
const building = new Map<string, Promise<BuildInfo>>();
|
|
87
|
+
// A background build's compiler stops while any other build of this process
|
|
88
|
+
// compiles, and continues once none does, so speculation never slows down a
|
|
89
|
+
// PDF someone is waiting for. A build that someone waits for is promoted.
|
|
90
|
+
function schedule() {
|
|
91
|
+
const top = Math.max(-1, ...[...active].filter((a) => a.child).map((a) => RANK[a.priority]));
|
|
92
|
+
for (const build of active) {
|
|
93
|
+
const pause = build.priority === "background" && top > RANK.background;
|
|
94
|
+
if (pause === build.paused || !build.child?.pid || build.child.exitCode !== null) continue;
|
|
95
|
+
try {
|
|
96
|
+
process.kill(-build.child.pid, pause ? "SIGSTOP" : "SIGCONT");
|
|
97
|
+
build.paused = pause;
|
|
98
|
+
} catch { /* The compiler has exited. */ }
|
|
99
|
+
}
|
|
100
|
+
watchOthers();
|
|
101
|
+
}
|
|
102
|
+
function promote(key: string, priority: BuildPriority) {
|
|
103
|
+
let changed = false;
|
|
104
|
+
for (const build of active)
|
|
105
|
+
if (build.key === key && RANK[priority] > RANK[build.priority]) {
|
|
106
|
+
build.priority = priority;
|
|
107
|
+
if (build.yielding) void yieldTo(build, false);
|
|
108
|
+
changed = true;
|
|
109
|
+
}
|
|
110
|
+
if (changed) schedule();
|
|
53
111
|
}
|
|
112
|
+
// On macOS a background build also yields to TeX that runs elsewhere, such as
|
|
113
|
+
// the review engine compiling another task: while any does, its compiler runs
|
|
114
|
+
// at background priority (efficiency cores, throttled disk). Unlike a nice
|
|
115
|
+
// value this is undone as soon as nothing else compiles, or when someone
|
|
116
|
+
// starts waiting for the build, so an idle machine gives it full speed.
|
|
117
|
+
const TASKPOLICY = "/usr/sbin/taskpolicy";
|
|
118
|
+
const darwinBackground = process.platform === "darwin" && fs.existsSync(TASKPOLICY);
|
|
119
|
+
const OTHER_TEX = /(?:^|\/)(?:latexmk|(?:pdf|xe|lua|luahb)(?:la)?tex|xdvipdfmx|bibtex8?|biber)(?:\s|$)/;
|
|
120
|
+
let watching: NodeJS.Timeout | null = null;
|
|
121
|
+
function watchOthers() {
|
|
122
|
+
const waiting = darwinBackground && [...active].some((a) => a.priority === "background" && a.child);
|
|
123
|
+
if (!waiting) { if (watching) clearInterval(watching); watching = null; return; }
|
|
124
|
+
if (watching) return;
|
|
125
|
+
watching = setInterval(() => void (async () => {
|
|
126
|
+
const ours = new Set([...active].flatMap((a) => (a.child?.pid ? [String(a.child.pid)] : [])));
|
|
127
|
+
const lines = (await capture("ps", ["-A", "-o", "pgid=,command="], { check: false })).stdout.toString().split("\n");
|
|
128
|
+
const others = lines.some((line) => {
|
|
129
|
+
const [, group, command] = /^\s*(\d+)\s+(.*)$/.exec(line) ?? [];
|
|
130
|
+
return command !== undefined && !ours.has(group) && OTHER_TEX.test(command);
|
|
131
|
+
});
|
|
132
|
+
await Promise.all([...active].filter((a) => a.priority === "background" && a.child && Boolean(a.yielding) !== others).map((a) => yieldTo(a, others)));
|
|
133
|
+
})().catch(() => undefined), 2000);
|
|
134
|
+
watching.unref();
|
|
135
|
+
}
|
|
136
|
+
async function yieldTo(build: Active, others: boolean) {
|
|
137
|
+
const group = build.child?.pid;
|
|
138
|
+
if (!darwinBackground || !group) return;
|
|
139
|
+
build.yielding = others;
|
|
140
|
+
try {
|
|
141
|
+
const members = (await capture("ps", ["-o", "pid=", "-g", String(group)], { check: false })).stdout.toString().split(/\s+/).filter(Boolean);
|
|
142
|
+
await Promise.all(members.map((pid) => capture(TASKPOLICY, [others ? "-b" : "-B", "-p", pid], { check: false }).catch(() => undefined)));
|
|
143
|
+
} catch { /* The compiler has exited. */ }
|
|
144
|
+
}
|
|
145
|
+
// This file is part of every build key, so a change to how builds run
|
|
146
|
+
// starts new build folders.
|
|
147
|
+
const SELF = createHash("sha256").update(fs.readFileSync(fileURLToPath(import.meta.url))).digest("hex");
|
|
148
|
+
// latexmk (4.84 and later) runs this after every TeX pass: it copies the
|
|
149
|
+
// pass's output aside, so that the pages can be shown while later passes
|
|
150
|
+
// settle references, page numbers and the bibliography. Older versions
|
|
151
|
+
// have no hooks and skip it.
|
|
152
|
+
const PASS_HOOK = String.raw`if (defined &add_hook) { my $papilot_pass = 0; add_hook('after_xlatex', sub { my $dir = $ENV{PAPILOT_PASS_DIR}; return 0 unless $dir; $papilot_pass++; my $to = "$dir/$papilot_pass"; make_path($to); for my $file (split /\n/, $ENV{PAPILOT_PASS_FILES}) { next unless -f $file; my ($name) = $file =~ m{([^/]+)$}; copy($file, "$to/$name"); } print "Papilot: pass $papilot_pass output saved\n"; return 0; }); }`;
|
|
153
|
+
const SAVED = /^Papilot: pass (\d+) output saved/m, NEXT = /^Run number \d+ of rule '([^' ]+)/m;
|
|
154
|
+
/** A TeX pass's PDF, shown while the build goes on. */
|
|
155
|
+
export interface PassPreview { pass: number; pdf: string; root: string; cwd: string }
|
|
156
|
+
// Completed builds kept per set of targets, as candidates to continue from.
|
|
157
|
+
const RECENT = 16;
|
|
158
|
+
interface Recent { key: string; context: string; date: unknown; tree?: string }
|
|
54
159
|
const hash = (value: string | Buffer) =>
|
|
55
160
|
createHash("sha256").update(value).digest("hex");
|
|
56
161
|
export const includes = (text: string) =>
|
|
@@ -191,10 +296,27 @@ export class Builder {
|
|
|
191
296
|
]),
|
|
192
297
|
);
|
|
193
298
|
}
|
|
299
|
+
async documentTextsAsync(rev: string) {
|
|
300
|
+
const files = (await this.repo.runAsync(["ls-tree", "-r", "-z", "--name-only", rev, ...(this.project.doc_dir ? ["--", this.project.doc_dir] : [])]))
|
|
301
|
+
.toString().split("\0").filter((f) => f.endsWith(".tex")),
|
|
302
|
+
blobs = await this.repo.blobsAsync(rev, files);
|
|
303
|
+
return Object.fromEntries(
|
|
304
|
+
files.map((f) => [
|
|
305
|
+
path.posix.relative(this.project.doc_dir || ".", f),
|
|
306
|
+
blobs[f]?.toString() ?? "",
|
|
307
|
+
]),
|
|
308
|
+
);
|
|
309
|
+
}
|
|
194
310
|
plan(before: string, after: string, changed: string[]) {
|
|
311
|
+
return this.choose(this.documentTexts(before), this.documentTexts(after), changed);
|
|
312
|
+
}
|
|
313
|
+
/** `plan` for servers, which keep answering requests meanwhile. */
|
|
314
|
+
async planAsync(before: string, after: string, changed: string[]) {
|
|
315
|
+
const [old, next] = await Promise.all([this.documentTextsAsync(before), this.documentTextsAsync(after)]);
|
|
316
|
+
return this.choose(old, next, changed);
|
|
317
|
+
}
|
|
318
|
+
private choose(old: Record<string, string>, next: Record<string, string>, changed: string[]) {
|
|
195
319
|
const p = this.project,
|
|
196
|
-
old = this.documentTexts(before),
|
|
197
|
-
next = this.documentTexts(after),
|
|
198
320
|
main = path.posix.basename(p.main),
|
|
199
321
|
targets = includes(next[main] ?? "");
|
|
200
322
|
if (
|
|
@@ -220,23 +342,24 @@ export class Builder {
|
|
|
220
342
|
rev: string,
|
|
221
343
|
targets = ["full"],
|
|
222
344
|
seed: BuildInfo | null = null,
|
|
223
|
-
options: { force?: boolean; progress?: (progress: BuildProgress) => void } = {},
|
|
345
|
+
options: { force?: boolean; progress?: (progress: BuildProgress) => void; priority?: BuildPriority; pass?: (preview: PassPreview) => void } = {},
|
|
224
346
|
): Promise<BuildInfo> {
|
|
225
347
|
const p = this.project,
|
|
226
|
-
partial = JSON.stringify(targets) !== '["full"]'
|
|
348
|
+
partial = JSON.stringify(targets) !== '["full"]',
|
|
349
|
+
priority = options.priority ?? "normal";
|
|
227
350
|
if (partial && !seed)
|
|
228
351
|
throw new BuildError(
|
|
229
352
|
"A partial build needs a completed full build to start from.",
|
|
230
353
|
);
|
|
231
354
|
let version: string;
|
|
232
355
|
try {
|
|
233
|
-
version =
|
|
356
|
+
version = await commandVersion(p.engine);
|
|
234
357
|
} catch {
|
|
235
358
|
throw new BuildError(
|
|
236
359
|
`${p.engine} was not found. Install TeX Live (or MacTeX) with latexmk.`,
|
|
237
360
|
);
|
|
238
361
|
}
|
|
239
|
-
const tree = this.repo.
|
|
362
|
+
const tree = await this.repo.treeIdAsync(rev, p.source_dir),
|
|
240
363
|
date = buildDate(),
|
|
241
364
|
context = hash(JSON.stringify([signature(p), version])),
|
|
242
365
|
key = hash(
|
|
@@ -247,44 +370,139 @@ export class Builder {
|
|
|
247
370
|
signature(p),
|
|
248
371
|
version,
|
|
249
372
|
date,
|
|
250
|
-
|
|
373
|
+
SELF,
|
|
251
374
|
// Manual recompilation gets a fresh output directory. Other previews
|
|
252
375
|
// and review rounds may still be reading the previous cached PDF.
|
|
253
376
|
...(options.force ? [randomUUID()] : []),
|
|
254
377
|
]),
|
|
255
378
|
).slice(0, 32),
|
|
256
379
|
folder = path.join(this.cache, key);
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
380
|
+
// Someone now waits for a build of this key that may already be running,
|
|
381
|
+
// and sees its passes from now on, starting with the latest one.
|
|
382
|
+
const waiter: Waiter = { priority, pass: options.pass };
|
|
383
|
+
let waiters = waiting.get(key);
|
|
384
|
+
if (!waiters) waiting.set(key, (waiters = new Set()));
|
|
385
|
+
waiters.add(waiter);
|
|
386
|
+
try {
|
|
387
|
+
promote(key, priority);
|
|
388
|
+
if (options.pass)
|
|
389
|
+
for (const build of active)
|
|
390
|
+
if (build.key === key) { build.watchers.add(options.pass); build.followed?.(); }
|
|
391
|
+
// A build of this key under way in this process is awaited directly,
|
|
392
|
+
// rather than by polling its lock, so its result arrives the moment it
|
|
393
|
+
// is ready. The lock keeps other processes from building it twice.
|
|
394
|
+
const ongoing = building.get(key);
|
|
395
|
+
if (ongoing) {
|
|
396
|
+
const result = await ongoing.catch(() => null);
|
|
397
|
+
if (result) return result;
|
|
265
398
|
}
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
let result: BuildInfo;
|
|
399
|
+
const attempt = (async () =>
|
|
400
|
+
(await this.finished(folder)) ??
|
|
401
|
+
withLock(path.join(this.cache, key + ".lock"), async () =>
|
|
402
|
+
(await this.finished(folder)) ?? this.compileOnce(rev, tree, key, context, targets, seed, date, partial, options),
|
|
403
|
+
))();
|
|
404
|
+
building.set(key, attempt);
|
|
273
405
|
try {
|
|
274
|
-
|
|
406
|
+
return await attempt;
|
|
407
|
+
} finally {
|
|
408
|
+
if (building.get(key) === attempt) building.delete(key);
|
|
409
|
+
}
|
|
410
|
+
} finally {
|
|
411
|
+
waiters.delete(waiter);
|
|
412
|
+
if (!waiters.size && waiting.get(key) === waiters) waiting.delete(key);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
private async compileOnce(
|
|
416
|
+
rev: string,
|
|
417
|
+
tree: string,
|
|
418
|
+
key: string,
|
|
419
|
+
context: string,
|
|
420
|
+
targets: string[],
|
|
421
|
+
seed: BuildInfo | null,
|
|
422
|
+
date: ReturnType<typeof buildDate>,
|
|
423
|
+
partial: boolean,
|
|
424
|
+
options: { force?: boolean; progress?: (progress: BuildProgress) => void },
|
|
425
|
+
) {
|
|
426
|
+
// A build continues from the completed one with the same targets whose
|
|
427
|
+
// sources differ least from this one, or a full build from the author's
|
|
428
|
+
// own latexmk output, as latexmk does when a folder is compiled again:
|
|
429
|
+
// unchanged files are cloned and LaTeX keeps its state, so it reruns
|
|
430
|
+
// only as often as the edit requires. Manual recompilation starts
|
|
431
|
+
// clean, and so does a retry when leftover state broke a build.
|
|
432
|
+
const start = options.force ? null : (await this.closest(context, targets, tree)) ?? (partial ? null : "author");
|
|
433
|
+
const waiters = [...(waiting.get(key) ?? [])],
|
|
434
|
+
priority = waiters.reduce<BuildPriority>((top, w) => (RANK[w.priority] > RANK[top] ? w.priority : top), "background");
|
|
435
|
+
const run: Active = { key, priority, paused: false, watchers: new Set(waiters.flatMap((w) => (w.pass ? [w.pass] : []))) };
|
|
436
|
+
active.add(run);
|
|
437
|
+
let result: BuildInfo;
|
|
438
|
+
try {
|
|
439
|
+
try {
|
|
440
|
+
result = await this.compile(rev, tree, key, targets, seed, date, start, options.progress, run);
|
|
275
441
|
} catch (error) {
|
|
276
442
|
if (!start || (error instanceof BuildError && !error.details.some(line => /(?:^|\s|\()(?:\.\/)?build\//.test(line)))) throw error;
|
|
277
|
-
result = await this.compile(rev, tree, key, targets, seed, date, null, options.progress);
|
|
443
|
+
result = await this.compile(rev, tree, key, targets, seed, date, null, options.progress, run);
|
|
278
444
|
}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
445
|
+
} finally {
|
|
446
|
+
active.delete(run);
|
|
447
|
+
schedule();
|
|
448
|
+
}
|
|
449
|
+
await this.remember(targets, { key, context, date: date.SOURCE_DATE_EPOCH, tree });
|
|
450
|
+
return result;
|
|
451
|
+
}
|
|
452
|
+
// A completed build in this folder, whose PDF is still there.
|
|
453
|
+
private async finished(folder: string) {
|
|
454
|
+
try {
|
|
455
|
+
const cached = JSON.parse(await fs.promises.readFile(path.join(folder, "result.json"), "utf8")) as BuildInfo;
|
|
456
|
+
await fs.promises.access(cached.pdf);
|
|
457
|
+
const time = new Date();
|
|
458
|
+
await fs.promises.utimes(folder, time, time).catch(() => undefined);
|
|
459
|
+
return cached;
|
|
460
|
+
} catch {
|
|
461
|
+
return null;
|
|
462
|
+
}
|
|
282
463
|
}
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
const
|
|
287
|
-
return
|
|
464
|
+
// Review rounds build the same chapters again and again (\includeonly), so
|
|
465
|
+
// each set of targets continues from its own recent builds.
|
|
466
|
+
private latestFile(targets: string[]) {
|
|
467
|
+
const name = JSON.stringify(targets) === '["full"]' ? "latest.json" : `latest-${hash(JSON.stringify(targets)).slice(0, 16)}.json`;
|
|
468
|
+
return path.join(this.cache, name);
|
|
469
|
+
}
|
|
470
|
+
private recentFile(targets: string[]) {
|
|
471
|
+
return this.latestFile(targets).replace(/latest(-[0-9a-f]+)?\.json$/, "recent$1.json");
|
|
472
|
+
}
|
|
473
|
+
private recent(targets: string[]): Recent[] {
|
|
474
|
+
const list = readJson<unknown>(this.recentFile(targets), []),
|
|
475
|
+
latest = readJson<Partial<Recent> | null>(this.latestFile(targets), null),
|
|
476
|
+
valid = (r: unknown): r is Recent => typeof (r as Recent)?.key === "string" && /^[0-9a-f]{32}$/.test((r as Recent).key) && typeof (r as Recent).context === "string";
|
|
477
|
+
const entries = (Array.isArray(list) ? list : []).filter(valid);
|
|
478
|
+
// latest.json, also written by earlier versions, names the newest build.
|
|
479
|
+
if (valid(latest) && !entries.some((r) => r.key === latest.key)) entries.unshift(latest);
|
|
480
|
+
return entries;
|
|
481
|
+
}
|
|
482
|
+
private async remember(targets: string[], entry: Recent) {
|
|
483
|
+
const entries = [entry, ...this.recent(targets).filter((r) => r.key !== entry.key)].slice(0, RECENT);
|
|
484
|
+
await writeJsonAsync(this.recentFile(targets), entries);
|
|
485
|
+
await writeJsonAsync(this.latestFile(targets), { key: entry.key, context: entry.context, date: entry.date });
|
|
486
|
+
}
|
|
487
|
+
// Of the recent builds that can be continued, the one whose sources differ
|
|
488
|
+
// in the fewest files from `tree`, preferring the newer: after a review or a
|
|
489
|
+
// speculative build, the manuscript's own previous build may be further
|
|
490
|
+
// from the next version than either.
|
|
491
|
+
private async closest(context: string, targets: string[], tree: string): Promise<{ info: BuildInfo; date: unknown } | null> {
|
|
492
|
+
const candidates = (await Promise.all(this.recent(targets).filter((r) => r.context === context).map(async (r) => {
|
|
493
|
+
try {
|
|
494
|
+
const info = JSON.parse(await fs.promises.readFile(path.join(this.cache, r.key, "result.json"), "utf8")) as BuildInfo;
|
|
495
|
+
if (JSON.stringify(info.targets) !== JSON.stringify(targets)) return null;
|
|
496
|
+
await Promise.all([fs.promises.access(info.pdf), fs.promises.access(info.root)]);
|
|
497
|
+
const changed = info.tree === tree ? 0 : (await this.repo.runAsync(["diff-tree", "-r", "--name-only", "-z", info.tree, tree])).toString().split("\0").filter(Boolean).length;
|
|
498
|
+
return { info, date: r.date, changed };
|
|
499
|
+
} catch {
|
|
500
|
+
return null;
|
|
501
|
+
}
|
|
502
|
+
}))).filter((c) => c !== null);
|
|
503
|
+
let best: (typeof candidates)[number] | null = null;
|
|
504
|
+
for (const candidate of candidates) if (!best || candidate.changed < best.changed) best = candidate;
|
|
505
|
+
return best && { info: best.info, date: best.date };
|
|
288
506
|
}
|
|
289
507
|
private async compile(
|
|
290
508
|
rev: string,
|
|
@@ -295,11 +513,12 @@ export class Builder {
|
|
|
295
513
|
date: ReturnType<typeof buildDate>,
|
|
296
514
|
start: { info: BuildInfo; date: unknown } | "author" | null,
|
|
297
515
|
progress?: (progress: BuildProgress) => void,
|
|
516
|
+
scheduled?: Active,
|
|
298
517
|
): Promise<BuildInfo> {
|
|
299
518
|
const p = this.project,
|
|
300
519
|
partial = JSON.stringify(targets) !== '["full"]',
|
|
301
520
|
folder = path.join(this.cache, key);
|
|
302
|
-
fs.
|
|
521
|
+
await fs.promises.rm(folder, { recursive: true, force: true });
|
|
303
522
|
const source = path.join(folder, "src");
|
|
304
523
|
const base = start && start !== "author" ? start.info : null;
|
|
305
524
|
await this.repo.export(rev, p.source_dir, source, base ? { root: base.root, tree: base.tree } : undefined);
|
|
@@ -310,32 +529,32 @@ export class Builder {
|
|
|
310
529
|
main = path.posix.basename(p.main),
|
|
311
530
|
job = path.posix.basename(main, ".tex"),
|
|
312
531
|
out = path.join(cwd, "build");
|
|
313
|
-
fs.
|
|
314
|
-
const included = includes(fs.
|
|
315
|
-
|
|
316
|
-
fs.mkdirSync(path.join(out, path.dirname(target)), { recursive: true });
|
|
532
|
+
await fs.promises.mkdir(out, { recursive: true });
|
|
533
|
+
const included = includes(await fs.promises.readFile(path.join(cwd, main), "utf8"));
|
|
534
|
+
await Promise.all(included.map((target) => fs.promises.mkdir(path.join(out, path.dirname(target)), { recursive: true })));
|
|
317
535
|
// The PDF, SyncTeX data and log carry over only within a day, so that
|
|
318
536
|
// \today stays current; latexmk then skips TeX when no input changed.
|
|
319
537
|
const reused = base !== null && start !== "author" && start?.date === date.SOURCE_DATE_EPOCH;
|
|
320
538
|
if (base) {
|
|
321
539
|
const previous = path.dirname(base.pdf);
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
540
|
+
await Promise.all((await filesIn(previous))
|
|
541
|
+
.filter((file) => !/\.fls$/i.test(file) && (reused || !OUTPUT.test(file)))
|
|
542
|
+
.map((file) => clone(file, path.join(out, path.relative(previous, file)))));
|
|
325
543
|
} else if (start === "author") {
|
|
326
544
|
// Only this job's state: the author's folder may hold other editions.
|
|
327
545
|
const own = path.join(p.root, p.doc_dir, "build");
|
|
328
546
|
if (fs.existsSync(path.join(own, job + ".fdb_latexmk")))
|
|
329
|
-
|
|
330
|
-
...fs.
|
|
547
|
+
await Promise.all([
|
|
548
|
+
...(await fs.promises.readdir(own)).filter((name) => (name.startsWith(job + ".") && !OUTPUT.test(name) && !name.endsWith(".fls")) || name.startsWith("_minted")),
|
|
331
549
|
...included.map((target) => target + ".aux"),
|
|
332
|
-
])
|
|
550
|
+
].map(async (name) => {
|
|
333
551
|
try {
|
|
334
|
-
if (fs.
|
|
335
|
-
else clone(path.join(own, name), path.join(out, name));
|
|
552
|
+
if ((await fs.promises.stat(path.join(own, name))).isDirectory()) await fs.promises.cp(path.join(own, name), path.join(out, name), { recursive: true });
|
|
553
|
+
else await clone(path.join(own, name), path.join(out, name));
|
|
336
554
|
} catch {
|
|
337
555
|
/* A missing file is regenerated. */
|
|
338
556
|
}
|
|
557
|
+
}));
|
|
339
558
|
}
|
|
340
559
|
const args = [
|
|
341
560
|
"-norc",
|
|
@@ -353,13 +572,16 @@ export class Builder {
|
|
|
353
572
|
"-file-line-error",
|
|
354
573
|
];
|
|
355
574
|
if (partial) {
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
575
|
+
// Chapters outside \includeonly keep the seed's auxiliary files, also
|
|
576
|
+
// when this build continues from an earlier one built on another seed.
|
|
577
|
+
// latexmk compares contents, so an unchanged chapter causes no rerun.
|
|
578
|
+
const seedOut = path.dirname(seed!.pdf),
|
|
579
|
+
others = new Set(included.filter((target) => !targets.includes(target)).map((target) => path.join(...target.split("/")) + ".aux"));
|
|
580
|
+
await Promise.all((await filesIn(seedOut)).filter((file) => AUXILIARY.has(path.extname(file))).map(async (file) => {
|
|
581
|
+
const relative = path.relative(seedOut, file), target = path.join(out, relative);
|
|
582
|
+
await fs.promises.mkdir(path.dirname(target), { recursive: true });
|
|
583
|
+
if (others.has(relative) || !fs.existsSync(target)) await fs.promises.copyFile(file, target);
|
|
584
|
+
}));
|
|
363
585
|
const preload = Object.entries(p.preload)
|
|
364
586
|
.filter(
|
|
365
587
|
([name, file]) =>
|
|
@@ -372,9 +594,43 @@ export class Builder {
|
|
|
372
594
|
(preload ? `\\AtBeginDocument{${preload}}` : ""),
|
|
373
595
|
);
|
|
374
596
|
}
|
|
597
|
+
// Each pass's pages are shown once latexmk decides to run more than that
|
|
598
|
+
// pass: a bibliography or another pass. The last pass's are the result.
|
|
599
|
+
// A background build keeps its passes too, for whoever starts waiting
|
|
600
|
+
// for it before it ends.
|
|
601
|
+
const passes = path.join(folder, "passes"),
|
|
602
|
+
keeping = Boolean(scheduled && (scheduled.watchers.size || scheduled.priority === "background"));
|
|
603
|
+
let saved = 0, followed = 0, shown = 0, previewing = Promise.resolve();
|
|
604
|
+
const kept = new Set<number>();
|
|
605
|
+
const preview = () => {
|
|
606
|
+
if (!scheduled?.watchers.size || followed <= shown) return;
|
|
607
|
+
const number = (shown = followed);
|
|
608
|
+
kept.add(number);
|
|
609
|
+
previewing = previewing.then(async () => {
|
|
610
|
+
const dir = path.join(passes, String(number)), xdv = path.join(dir, job + ".xdv"), file = path.join(dir, job + ".pdf");
|
|
611
|
+
if (fs.existsSync(xdv)) {
|
|
612
|
+
await capture("xdvipdfmx", ["-q", "-E", "-o", file, xdv], { cwd, env: { ...process.env, ...date }, timeout: 300000 });
|
|
613
|
+
await fs.promises.rm(xdv, { force: true });
|
|
614
|
+
}
|
|
615
|
+
await fs.promises.access(file);
|
|
616
|
+
for (const watcher of scheduled.watchers) watcher({ pass: number, pdf: file, root: source, cwd });
|
|
617
|
+
}).catch(() => undefined);
|
|
618
|
+
};
|
|
619
|
+
if (scheduled) scheduled.followed = preview;
|
|
620
|
+
let pending = "";
|
|
621
|
+
const watch = (chunk: Buffer) => {
|
|
622
|
+
const lines = (pending + chunk.toString("latin1")).split("\n");
|
|
623
|
+
pending = lines.pop()!;
|
|
624
|
+
for (const line of lines) {
|
|
625
|
+
const done = SAVED.exec(line), next = NEXT.exec(line);
|
|
626
|
+
if (done) saved = Number(done[1]);
|
|
627
|
+
else if (next && saved > followed && !/^(?:xdvipdfmx|dvipdfmx?|dvips|ps2pdf)$/.test(next[1])) { followed = saved; preview(); }
|
|
628
|
+
}
|
|
629
|
+
};
|
|
630
|
+
if (keeping) args.push("-e", PASS_HOOK);
|
|
375
631
|
args.push(main);
|
|
376
632
|
const transcript = path.join(folder, "latexmk.log"),
|
|
377
|
-
|
|
633
|
+
log = await fs.promises.open(transcript, "w"),
|
|
378
634
|
started = now(),
|
|
379
635
|
// Pages are counted against the build this one continues from.
|
|
380
636
|
read = progress
|
|
@@ -387,39 +643,54 @@ export class Builder {
|
|
|
387
643
|
),
|
|
388
644
|
)
|
|
389
645
|
: null;
|
|
390
|
-
let failure: Error | null = null;
|
|
646
|
+
let failure: Error | null = null, writing = Promise.resolve();
|
|
391
647
|
try {
|
|
392
648
|
await runAsync("latexmk", args, {
|
|
393
649
|
cwd,
|
|
394
|
-
env: {
|
|
650
|
+
env: {
|
|
651
|
+
...process.env,
|
|
652
|
+
...date,
|
|
653
|
+
...(keeping ? { PAPILOT_PASS_DIR: passes, PAPILOT_PASS_FILES: ["xdv", "pdf", "synctex.gz"].map((ext) => `build/${job}.${ext}`).join("\n") } : {}),
|
|
654
|
+
},
|
|
395
655
|
timeout: 1800000,
|
|
656
|
+
started: (child) => {
|
|
657
|
+
if (!scheduled) return;
|
|
658
|
+
scheduled.child = child;
|
|
659
|
+
schedule();
|
|
660
|
+
},
|
|
396
661
|
output: (chunk) => {
|
|
397
|
-
|
|
662
|
+
writing = writing.then(() => log.write(chunk)).then(() => undefined, () => undefined);
|
|
398
663
|
read?.(chunk);
|
|
664
|
+
if (keeping) watch(chunk);
|
|
399
665
|
},
|
|
400
666
|
});
|
|
401
667
|
} catch (error) {
|
|
402
668
|
failure = error as Error;
|
|
403
669
|
} finally {
|
|
404
|
-
|
|
670
|
+
if (scheduled) { scheduled.child = undefined; scheduled.paused = false; schedule(); }
|
|
671
|
+
await writing;
|
|
672
|
+
await log.close();
|
|
673
|
+
await previewing;
|
|
405
674
|
}
|
|
675
|
+
// Passes that were not shown are not needed.
|
|
676
|
+
if (scheduled) scheduled.followed = undefined;
|
|
677
|
+
if (keeping) for (let number = 1; number <= saved; number++) if (!kept.has(number)) await fs.promises.rm(path.join(passes, String(number)), { recursive: true, force: true });
|
|
406
678
|
const pdf = path.join(out, job + ".pdf"),
|
|
407
|
-
|
|
679
|
+
texLog = path.join(out, job + ".log");
|
|
408
680
|
if (reused && !failure)
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
const logText = fs.existsSync(log) ? fs.readFileSync(log, "utf8") : "";
|
|
681
|
+
await Promise.all([path.join(out, job + ".synctex.gz"), texLog].map((file) => relocate(file, path.dirname(base!.root), folder)));
|
|
682
|
+
const logText = await fs.promises.readFile(texLog, "utf8").catch(() => "");
|
|
412
683
|
if (failure || !fs.existsSync(pdf)) {
|
|
413
|
-
const transcriptText = fs.
|
|
684
|
+
const transcriptText = await fs.promises.readFile(transcript, "utf8"),
|
|
414
685
|
details = compilerDiagnostics(logText, transcriptText),
|
|
415
686
|
kept = path.join(p.state, "logs", key + ".log");
|
|
416
|
-
fs.
|
|
417
|
-
fs.
|
|
687
|
+
await fs.promises.mkdir(path.dirname(kept), { recursive: true });
|
|
688
|
+
await fs.promises.writeFile(
|
|
418
689
|
kept,
|
|
419
690
|
transcriptText + (logText ? "\n\n--- TeX log ---\n" + logText : ""),
|
|
420
691
|
);
|
|
421
692
|
const tail = transcriptText.split("\n").slice(-25);
|
|
422
|
-
fs.
|
|
693
|
+
await fs.promises.rm(folder, { recursive: true, force: true });
|
|
423
694
|
throw new BuildError(
|
|
424
695
|
failure?.message.includes("ENOENT")
|
|
425
696
|
? "latexmk was not found. Install TeX Live or MacTeX."
|
|
@@ -438,25 +709,29 @@ export class Builder {
|
|
|
438
709
|
seconds: Math.round((now() - started) * 10) / 10,
|
|
439
710
|
warnings: warnings(logText),
|
|
440
711
|
};
|
|
441
|
-
|
|
712
|
+
await writeJsonAsync(path.join(folder, "result.json"), result);
|
|
442
713
|
return result;
|
|
443
714
|
}
|
|
715
|
+
// Removes builds unused for `maxAge` seconds, except those in `keep` and the
|
|
716
|
+
// two newest of each set of targets, which the next build continues from.
|
|
444
717
|
async prune(keep: Set<string>, maxAge = 3 * 86400) {
|
|
445
|
-
|
|
446
|
-
|
|
718
|
+
let entries: fs.Dirent[];
|
|
719
|
+
try { entries = await fs.promises.readdir(this.cache, { withFileTypes: true }); }
|
|
720
|
+
catch { return; }
|
|
721
|
+
const kept = new Set(keep);
|
|
722
|
+
for (const entry of entries)
|
|
723
|
+
if (entry.isFile() && /^(?:recent|latest)(?:-[0-9a-f]+)?\.json$/.test(entry.name)) {
|
|
724
|
+
const value = readJson<unknown>(path.join(this.cache, entry.name), null),
|
|
725
|
+
list = Array.isArray(value) ? value : [value];
|
|
726
|
+
for (const item of list.slice(0, 2)) if (typeof (item as Recent)?.key === "string") kept.add((item as Recent).key);
|
|
727
|
+
}
|
|
728
|
+
for (const entry of entries) {
|
|
447
729
|
const folder = path.join(this.cache, entry.name);
|
|
448
|
-
if (
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
)
|
|
454
|
-
await withLock(
|
|
455
|
-
path.join(this.cache, entry.name + ".lock"),
|
|
456
|
-
async () => {
|
|
457
|
-
fs.rmSync(folder, { recursive: true, force: true });
|
|
458
|
-
},
|
|
459
|
-
);
|
|
730
|
+
if (!entry.isDirectory() || entry.name.endsWith(".lock") || kept.has(entry.name)) continue;
|
|
731
|
+
const stat = await fs.promises.stat(folder).catch(() => null);
|
|
732
|
+
if (!stat || now() - stat.mtimeMs / 1000 <= maxAge) continue;
|
|
733
|
+
await withLock(path.join(this.cache, entry.name + ".lock"), () => fs.promises.rm(folder, { recursive: true, force: true }));
|
|
734
|
+
await fs.promises.rm(path.join(this.cache, entry.name + ".lock"), { force: true });
|
|
460
735
|
}
|
|
461
736
|
}
|
|
462
737
|
}
|