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.
Files changed (44) 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-C7ENJr-a.js → math-CBzEH7Y2.js} +1 -1
  4. package/dist/assets/{pdf-view-B2xQuHmI.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 +357 -93
  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/workspace.ts +18 -0
  43. package/dist/assets/index-Dm7AYM9e.js +0 -267
  44. 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,51 +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;
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
- // A build continues from the latest completed one with the same
267
- // targets, or a full build from the author's own latexmk output, as
268
- // latexmk does when a folder is compiled again: unchanged files are
269
- // cloned and LaTeX keeps its state, so it reruns only as often as the
270
- // edit requires. Manual recompilation starts clean, and so does a retry
271
- // when leftover state broke a build.
272
- const start = options.force ? null : this.latest(context, targets) ?? (partial ? null : "author");
273
- let result: BuildInfo;
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
- writeJson(this.latestFile(targets), { key, context, date: date.SOURCE_DATE_EPOCH });
281
- return result;
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 latest build.
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 latest(context: string, targets: string[]): { info: BuildInfo; date: unknown } | null {
291
- const latest = readJson<{ key?: unknown; context?: unknown; date?: unknown } | null>(this.latestFile(targets), null);
292
- if (typeof latest?.key !== "string" || !/^[0-9a-f]{32}$/.test(latest.key) || latest.context !== context) return null;
293
- const info = readJson<BuildInfo | null>(path.join(this.cache, latest.key, "result.json"), null);
294
- return info && JSON.stringify(info.targets) === JSON.stringify(targets) && fs.existsSync(info.pdf) && fs.existsSync(info.root) ? { info, date: latest.date } : null;
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.rmSync(folder, { recursive: true, force: true });
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.mkdirSync(out, { recursive: true });
321
- const included = includes(fs.readFileSync(path.join(cwd, main), "utf8"));
322
- for (const target of included)
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
- for (const file of listFiles(previous))
330
- if (!/\.fls$/i.test(file) && (reused || !OUTPUT.test(file)))
331
- 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)))));
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
- for (const name of [
337
- ...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")),
338
549
  ...included.map((target) => target + ".aux"),
339
- ])
550
+ ].map(async (name) => {
340
551
  try {
341
- if (fs.statSync(path.join(own, name)).isDirectory()) fs.cpSync(path.join(own, name), path.join(out, name), { recursive: true });
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
- for (const file of listFiles(seedOut))
369
- if (AUXILIARY.has(path.extname(file))) {
370
- const relative = path.relative(seedOut, file), target = path.join(out, relative);
371
- fs.mkdirSync(path.dirname(target), { recursive: true });
372
- if (!fs.existsSync(target) || others.has(relative)) fs.copyFileSync(file, target);
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
- fd = fs.openSync(transcript, "w"),
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: { ...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
+ },
406
655
  timeout: 1800000,
656
+ started: (child) => {
657
+ if (!scheduled) return;
658
+ scheduled.child = child;
659
+ schedule();
660
+ },
407
661
  output: (chunk) => {
408
- fs.writeSync(fd, chunk);
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
- fs.closeSync(fd);
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
- log = path.join(out, job + ".log");
679
+ texLog = path.join(out, job + ".log");
419
680
  if (reused && !failure)
420
- for (const file of [path.join(out, job + ".synctex.gz"), log])
421
- relocate(file, path.dirname(base!.root), folder);
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.readFileSync(transcript, "utf8"),
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.mkdirSync(path.dirname(kept), { recursive: true });
428
- fs.writeFileSync(
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.rmSync(folder, { recursive: true, force: true });
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
- writeJson(path.join(folder, "result.json"), result);
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
- if (!fs.existsSync(this.cache)) return;
457
- 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) {
458
729
  const folder = path.join(this.cache, entry.name);
459
- if (
460
- entry.isDirectory() &&
461
- !entry.name.endsWith(".lock") &&
462
- !keep.has(entry.name) &&
463
- now() - fs.statSync(folder).mtimeMs / 1000 > maxAge
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
  }