papilot 0.8.6 → 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-C7ENJr-a.js → math-CBzEH7Y2.js} +1 -1
- package/dist/assets/{pdf-view-B2xQuHmI.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 +357 -93
- 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/workspace.ts +18 -0
- package/dist/assets/index-Dm7AYM9e.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,51 +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;
|
|
398
|
+
}
|
|
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);
|
|
405
|
+
try {
|
|
406
|
+
return await attempt;
|
|
407
|
+
} finally {
|
|
408
|
+
if (building.get(key) === attempt) building.delete(key);
|
|
265
409
|
}
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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 {
|
|
274
439
|
try {
|
|
275
|
-
result = await this.compile(rev, tree, key, targets, seed, date, start, options.progress);
|
|
440
|
+
result = await this.compile(rev, tree, key, targets, seed, date, start, options.progress, run);
|
|
276
441
|
} catch (error) {
|
|
277
442
|
if (!start || (error instanceof BuildError && !error.details.some(line => /(?:^|\s|\()(?:\.\/)?build\//.test(line)))) throw error;
|
|
278
|
-
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);
|
|
279
444
|
}
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
+
}
|
|
283
463
|
}
|
|
284
464
|
// Review rounds build the same chapters again and again (\includeonly), so
|
|
285
|
-
// each set of targets continues from its own
|
|
465
|
+
// each set of targets continues from its own recent builds.
|
|
286
466
|
private latestFile(targets: string[]) {
|
|
287
467
|
const name = JSON.stringify(targets) === '["full"]' ? "latest.json" : `latest-${hash(JSON.stringify(targets)).slice(0, 16)}.json`;
|
|
288
468
|
return path.join(this.cache, name);
|
|
289
469
|
}
|
|
290
|
-
private
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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 };
|
|
295
506
|
}
|
|
296
507
|
private async compile(
|
|
297
508
|
rev: string,
|
|
@@ -302,11 +513,12 @@ export class Builder {
|
|
|
302
513
|
date: ReturnType<typeof buildDate>,
|
|
303
514
|
start: { info: BuildInfo; date: unknown } | "author" | null,
|
|
304
515
|
progress?: (progress: BuildProgress) => void,
|
|
516
|
+
scheduled?: Active,
|
|
305
517
|
): Promise<BuildInfo> {
|
|
306
518
|
const p = this.project,
|
|
307
519
|
partial = JSON.stringify(targets) !== '["full"]',
|
|
308
520
|
folder = path.join(this.cache, key);
|
|
309
|
-
fs.
|
|
521
|
+
await fs.promises.rm(folder, { recursive: true, force: true });
|
|
310
522
|
const source = path.join(folder, "src");
|
|
311
523
|
const base = start && start !== "author" ? start.info : null;
|
|
312
524
|
await this.repo.export(rev, p.source_dir, source, base ? { root: base.root, tree: base.tree } : undefined);
|
|
@@ -317,32 +529,32 @@ export class Builder {
|
|
|
317
529
|
main = path.posix.basename(p.main),
|
|
318
530
|
job = path.posix.basename(main, ".tex"),
|
|
319
531
|
out = path.join(cwd, "build");
|
|
320
|
-
fs.
|
|
321
|
-
const included = includes(fs.
|
|
322
|
-
|
|
323
|
-
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 })));
|
|
324
535
|
// The PDF, SyncTeX data and log carry over only within a day, so that
|
|
325
536
|
// \today stays current; latexmk then skips TeX when no input changed.
|
|
326
537
|
const reused = base !== null && start !== "author" && start?.date === date.SOURCE_DATE_EPOCH;
|
|
327
538
|
if (base) {
|
|
328
539
|
const previous = path.dirname(base.pdf);
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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)))));
|
|
332
543
|
} else if (start === "author") {
|
|
333
544
|
// Only this job's state: the author's folder may hold other editions.
|
|
334
545
|
const own = path.join(p.root, p.doc_dir, "build");
|
|
335
546
|
if (fs.existsSync(path.join(own, job + ".fdb_latexmk")))
|
|
336
|
-
|
|
337
|
-
...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")),
|
|
338
549
|
...included.map((target) => target + ".aux"),
|
|
339
|
-
])
|
|
550
|
+
].map(async (name) => {
|
|
340
551
|
try {
|
|
341
|
-
if (fs.
|
|
342
|
-
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));
|
|
343
554
|
} catch {
|
|
344
555
|
/* A missing file is regenerated. */
|
|
345
556
|
}
|
|
557
|
+
}));
|
|
346
558
|
}
|
|
347
559
|
const args = [
|
|
348
560
|
"-norc",
|
|
@@ -365,12 +577,11 @@ export class Builder {
|
|
|
365
577
|
// latexmk compares contents, so an unchanged chapter causes no rerun.
|
|
366
578
|
const seedOut = path.dirname(seed!.pdf),
|
|
367
579
|
others = new Set(included.filter((target) => !targets.includes(target)).map((target) => path.join(...target.split("/")) + ".aux"));
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
}
|
|
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
|
+
}));
|
|
374
585
|
const preload = Object.entries(p.preload)
|
|
375
586
|
.filter(
|
|
376
587
|
([name, file]) =>
|
|
@@ -383,9 +594,43 @@ export class Builder {
|
|
|
383
594
|
(preload ? `\\AtBeginDocument{${preload}}` : ""),
|
|
384
595
|
);
|
|
385
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);
|
|
386
631
|
args.push(main);
|
|
387
632
|
const transcript = path.join(folder, "latexmk.log"),
|
|
388
|
-
|
|
633
|
+
log = await fs.promises.open(transcript, "w"),
|
|
389
634
|
started = now(),
|
|
390
635
|
// Pages are counted against the build this one continues from.
|
|
391
636
|
read = progress
|
|
@@ -398,39 +643,54 @@ export class Builder {
|
|
|
398
643
|
),
|
|
399
644
|
)
|
|
400
645
|
: null;
|
|
401
|
-
let failure: Error | null = null;
|
|
646
|
+
let failure: Error | null = null, writing = Promise.resolve();
|
|
402
647
|
try {
|
|
403
648
|
await runAsync("latexmk", args, {
|
|
404
649
|
cwd,
|
|
405
|
-
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
|
+
},
|
|
406
655
|
timeout: 1800000,
|
|
656
|
+
started: (child) => {
|
|
657
|
+
if (!scheduled) return;
|
|
658
|
+
scheduled.child = child;
|
|
659
|
+
schedule();
|
|
660
|
+
},
|
|
407
661
|
output: (chunk) => {
|
|
408
|
-
|
|
662
|
+
writing = writing.then(() => log.write(chunk)).then(() => undefined, () => undefined);
|
|
409
663
|
read?.(chunk);
|
|
664
|
+
if (keeping) watch(chunk);
|
|
410
665
|
},
|
|
411
666
|
});
|
|
412
667
|
} catch (error) {
|
|
413
668
|
failure = error as Error;
|
|
414
669
|
} finally {
|
|
415
|
-
|
|
670
|
+
if (scheduled) { scheduled.child = undefined; scheduled.paused = false; schedule(); }
|
|
671
|
+
await writing;
|
|
672
|
+
await log.close();
|
|
673
|
+
await previewing;
|
|
416
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 });
|
|
417
678
|
const pdf = path.join(out, job + ".pdf"),
|
|
418
|
-
|
|
679
|
+
texLog = path.join(out, job + ".log");
|
|
419
680
|
if (reused && !failure)
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
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(() => "");
|
|
423
683
|
if (failure || !fs.existsSync(pdf)) {
|
|
424
|
-
const transcriptText = fs.
|
|
684
|
+
const transcriptText = await fs.promises.readFile(transcript, "utf8"),
|
|
425
685
|
details = compilerDiagnostics(logText, transcriptText),
|
|
426
686
|
kept = path.join(p.state, "logs", key + ".log");
|
|
427
|
-
fs.
|
|
428
|
-
fs.
|
|
687
|
+
await fs.promises.mkdir(path.dirname(kept), { recursive: true });
|
|
688
|
+
await fs.promises.writeFile(
|
|
429
689
|
kept,
|
|
430
690
|
transcriptText + (logText ? "\n\n--- TeX log ---\n" + logText : ""),
|
|
431
691
|
);
|
|
432
692
|
const tail = transcriptText.split("\n").slice(-25);
|
|
433
|
-
fs.
|
|
693
|
+
await fs.promises.rm(folder, { recursive: true, force: true });
|
|
434
694
|
throw new BuildError(
|
|
435
695
|
failure?.message.includes("ENOENT")
|
|
436
696
|
? "latexmk was not found. Install TeX Live or MacTeX."
|
|
@@ -449,25 +709,29 @@ export class Builder {
|
|
|
449
709
|
seconds: Math.round((now() - started) * 10) / 10,
|
|
450
710
|
warnings: warnings(logText),
|
|
451
711
|
};
|
|
452
|
-
|
|
712
|
+
await writeJsonAsync(path.join(folder, "result.json"), result);
|
|
453
713
|
return result;
|
|
454
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.
|
|
455
717
|
async prune(keep: Set<string>, maxAge = 3 * 86400) {
|
|
456
|
-
|
|
457
|
-
|
|
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) {
|
|
458
729
|
const folder = path.join(this.cache, entry.name);
|
|
459
|
-
if (
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
)
|
|
465
|
-
await withLock(
|
|
466
|
-
path.join(this.cache, entry.name + ".lock"),
|
|
467
|
-
async () => {
|
|
468
|
-
fs.rmSync(folder, { recursive: true, force: true });
|
|
469
|
-
},
|
|
470
|
-
);
|
|
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 });
|
|
471
735
|
}
|
|
472
736
|
}
|
|
473
737
|
}
|