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.
Files changed (46) hide show
  1. package/dist/assets/index-BIc9Mnim.css +1 -0
  2. package/dist/assets/index-MvQvvv3I.js +267 -0
  3. package/dist/assets/{math-DHvVou9A.js → math-CBzEH7Y2.js} +1 -1
  4. package/dist/assets/{pdf-view-AYTS7kdA.js → pdf-view-ByftRBhD.js} +2 -2
  5. package/dist/index.html +2 -2
  6. package/engine/dist/web/assets/{FullPaper-DlgZ1MOz.js → FullPaper-DWwiT43p.js} +2 -2
  7. package/engine/dist/web/assets/{index-BCgVChtV.js → index-DBYJK8Uy.js} +4 -4
  8. package/engine/dist/web/assets/{index-D3_ZFdT8.css → index-DGkq8xQx.css} +1 -1
  9. package/engine/dist/web/index.html +2 -2
  10. package/engine/src/cli.ts +3 -0
  11. package/engine/src/client.ts +24 -7
  12. package/engine/src/core/build.ts +367 -92
  13. package/engine/src/core/changes.ts +39 -10
  14. package/engine/src/core/config.ts +2 -2
  15. package/engine/src/core/current-commit.ts +34 -34
  16. package/engine/src/core/delivery.ts +83 -30
  17. package/engine/src/core/files.ts +19 -0
  18. package/engine/src/core/git.ts +283 -92
  19. package/engine/src/core/process.ts +124 -11
  20. package/engine/src/core/publish.ts +59 -50
  21. package/engine/src/core/reconcile.ts +23 -21
  22. package/engine/src/core/review.ts +43 -24
  23. package/engine/src/core/tasks.ts +85 -28
  24. package/engine/src/core/view.ts +8 -8
  25. package/engine/src/server.ts +62 -6
  26. package/engine/src/types.ts +8 -0
  27. package/engine/src/web/FullPaper.tsx +122 -34
  28. package/engine/src/web/review.tsx +4 -1
  29. package/engine/src/web/style.css +24 -0
  30. package/package.json +1 -1
  31. package/src/server/agents.ts +3 -0
  32. package/src/server/annotated-pdf.ts +392 -0
  33. package/src/server/annotation-placement.ts +18 -3
  34. package/src/server/app.ts +68 -10
  35. package/src/server/changes.ts +12 -7
  36. package/src/server/cursor.ts +1 -0
  37. package/src/server/manuscript.ts +218 -56
  38. package/src/server/pdf.ts +176 -23
  39. package/src/server/references.ts +10 -2
  40. package/src/server/review.ts +6 -2
  41. package/src/server/start.ts +4 -1
  42. package/src/server/update-install.ts +5 -1
  43. package/src/server/updates.ts +24 -49
  44. package/src/server/workspace.ts +18 -0
  45. package/dist/assets/index-DSWizoaI.js +0 -267
  46. package/dist/assets/index-PFQDdu53.css +0 -1
@@ -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 { gunzipSync, gzipSync } from "node:zlib";
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 { listFiles, readJson, writeJson, withLock, now } from "./files.ts";
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 { run, runAsync } from "./process.ts";
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
- function clone(file: string, target: string) {
37
- fs.mkdirSync(path.dirname(target), { recursive: true });
38
- fs.copyFileSync(file, target, fs.constants.COPYFILE_FICLONE);
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
- const zipped = file.endsWith(".gz"),
48
- data = zipped ? gunzipSync(fs.readFileSync(file)) : fs.readFileSync(file);
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.writeFileSync(file, zipped ? gzipSync(data) : data);
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 = run(p.engine, ["--version"]).stdout.toString().split("\n")[0];
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.treeId(rev, p.source_dir),
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
- hash(fs.readFileSync(fileURLToPath(import.meta.url))),
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
- return withLock(path.join(this.cache, key + ".lock"), async () => {
258
- const cached = readJson<BuildInfo | null>(
259
- path.join(folder, "result.json"),
260
- null,
261
- );
262
- if (cached && fs.existsSync(cached.pdf)) {
263
- fs.utimesSync(folder, new Date(), new Date());
264
- return cached;
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
- // A full build continues from the latest completed one, or from the
267
- // author's own latexmk output, as latexmk does when a folder is
268
- // compiled again: unchanged files are cloned and LaTeX keeps its state,
269
- // so it reruns only as often as the edit requires. Manual recompilation
270
- // starts clean, and so does a retry when leftover state broke a build.
271
- const start = partial || options.force ? null : this.latest(context) ?? "author";
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
- result = await this.compile(rev, tree, key, targets, seed, date, start, options.progress);
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
- if (!partial) writeJson(path.join(this.cache, "latest.json"), { key, context, date: date.SOURCE_DATE_EPOCH });
280
- return result;
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
- private latest(context: string): { info: BuildInfo; date: unknown } | null {
284
- const latest = readJson<{ key?: unknown; context?: unknown; date?: unknown } | null>(path.join(this.cache, "latest.json"), null);
285
- if (typeof latest?.key !== "string" || !/^[0-9a-f]{32}$/.test(latest.key) || latest.context !== context) return null;
286
- const info = readJson<BuildInfo | null>(path.join(this.cache, latest.key, "result.json"), null);
287
- return info && JSON.stringify(info.targets) === '["full"]' && fs.existsSync(info.pdf) && fs.existsSync(info.root) ? { info, date: latest.date } : null;
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.rmSync(folder, { recursive: true, force: true });
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.mkdirSync(out, { recursive: true });
314
- const included = includes(fs.readFileSync(path.join(cwd, main), "utf8"));
315
- for (const target of included)
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
- for (const file of listFiles(previous))
323
- if (!/\.fls$/i.test(file) && (reused || !OUTPUT.test(file)))
324
- clone(file, path.join(out, path.relative(previous, file)));
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
- for (const name of [
330
- ...fs.readdirSync(own).filter((name) => (name.startsWith(job + ".") && !OUTPUT.test(name) && !name.endsWith(".fls")) || name.startsWith("_minted")),
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.statSync(path.join(own, name)).isDirectory()) fs.cpSync(path.join(own, name), path.join(out, name), { recursive: true });
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
- const seedOut = path.dirname(seed!.pdf);
357
- for (const file of listFiles(seedOut))
358
- if (AUXILIARY.has(path.extname(file))) {
359
- const target = path.join(out, path.relative(seedOut, file));
360
- fs.mkdirSync(path.dirname(target), { recursive: true });
361
- if (!fs.existsSync(target)) fs.copyFileSync(file, target);
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
- fd = fs.openSync(transcript, "w"),
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: { ...process.env, ...date },
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
- fs.writeSync(fd, chunk);
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
- fs.closeSync(fd);
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
- log = path.join(out, job + ".log");
679
+ texLog = path.join(out, job + ".log");
408
680
  if (reused && !failure)
409
- for (const file of [path.join(out, job + ".synctex.gz"), log])
410
- relocate(file, path.dirname(base!.root), folder);
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.readFileSync(transcript, "utf8"),
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.mkdirSync(path.dirname(kept), { recursive: true });
417
- fs.writeFileSync(
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.rmSync(folder, { recursive: true, force: true });
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
- writeJson(path.join(folder, "result.json"), result);
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
- if (!fs.existsSync(this.cache)) return;
446
- for (const entry of fs.readdirSync(this.cache, { withFileTypes: true })) {
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
- entry.isDirectory() &&
450
- !entry.name.endsWith(".lock") &&
451
- !keep.has(entry.name) &&
452
- now() - fs.statSync(folder).mtimeMs / 1000 > maxAge
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
  }