papilot 0.8.1 → 0.8.3

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 (112) hide show
  1. package/README.md +4 -2
  2. package/bin/doctor.mjs +1 -1
  3. package/dist/assets/{_virtual_papilot-licenses-CMfCtEYQ.js → _virtual_papilot-licenses-YJbh9c1G.js} +1 -1
  4. package/dist/assets/index-0siHHtfz.js +267 -0
  5. package/dist/assets/index-CxdYm81a.css +1 -0
  6. package/dist/assets/{math-Bu3xEyrC.js → math-ibBpTslk.js} +1 -1
  7. package/dist/assets/{pdf-view-PbkCuTZ-.js → pdf-view-CD1kNKYH.js} +1 -1
  8. package/dist/index.html +2 -2
  9. package/engine/dist/web/assets/FullPaper-DUgLb01O.js +54 -0
  10. package/engine/dist/web/assets/index-D3_ZFdT8.css +1 -0
  11. package/engine/dist/web/assets/index-DJywe0wU.js +19 -0
  12. package/engine/dist/web/assets/pdf.worker.min-CjEcRF4W.mjs +28 -0
  13. package/{vendor/latex-review → engine}/dist/web/index.html +2 -2
  14. package/engine/package-lock.json +3390 -0
  15. package/engine/package.json +50 -0
  16. package/{vendor/latex-review → engine}/src/core/build.ts +198 -99
  17. package/{vendor/latex-review → engine}/src/core/git.ts +133 -28
  18. package/{vendor/latex-review → engine}/src/core/outline.ts +0 -9
  19. package/engine/src/core/progress.ts +67 -0
  20. package/{vendor/latex-review → engine}/src/core/review.ts +32 -0
  21. package/engine/src/locales.ts +47 -0
  22. package/{vendor/latex-review → engine}/src/render/inline.ts +1 -0
  23. package/{vendor/latex-review → engine}/src/render/locate.ts +26 -16
  24. package/engine/src/render/positions.ts +34 -0
  25. package/engine/src/render/synctex.ts +188 -0
  26. package/engine/src/render/synctex.wasm +0 -0
  27. package/engine/src/render/synctex.wasm.LICENSE +50 -0
  28. package/{vendor/latex-review → engine}/src/server.ts +5 -3
  29. package/{vendor/latex-review → engine}/src/types.ts +13 -0
  30. package/engine/src/web/FullPaper.tsx +318 -0
  31. package/engine/src/web/i18n.ts +36 -0
  32. package/engine/src/web/locales/de.json +253 -0
  33. package/engine/src/web/locales/es.json +253 -0
  34. package/engine/src/web/locales/fr.json +253 -0
  35. package/engine/src/web/locales/index.ts +16 -0
  36. package/engine/src/web/locales/it.json +253 -0
  37. package/engine/src/web/locales/ja.json +253 -0
  38. package/engine/src/web/locales/ko.json +253 -0
  39. package/engine/src/web/locales/pt.json +253 -0
  40. package/engine/src/web/locales/sv.json +253 -0
  41. package/engine/src/web/locales/zh-Hant.json +253 -0
  42. package/engine/src/web/paper.ts +13 -0
  43. package/{vendor/latex-review → engine}/src/web/references.tsx +1 -1
  44. package/{vendor/latex-review → engine}/src/web/review.tsx +99 -2
  45. package/{vendor/latex-review → engine}/src/web/style.css +71 -11
  46. package/engine/src/web/view-preferences.ts +13 -0
  47. package/{vendor/latex-review → engine}/src/web/zh-CN.ts +3 -0
  48. package/package.json +18 -9
  49. package/src/conversation.ts +1 -1
  50. package/src/server/acp.ts +9 -5
  51. package/src/server/agents.ts +4 -2
  52. package/src/server/app.ts +23 -0
  53. package/src/server/changes.ts +12 -3
  54. package/src/server/imports.ts +69 -25
  55. package/src/server/main.ts +3 -2
  56. package/src/server/manuscript.ts +31 -12
  57. package/src/server/paths.ts +1 -1
  58. package/src/server/start.ts +3 -3
  59. package/src/server/store.ts +0 -8
  60. package/src/server/update-install.ts +6 -3
  61. package/src/server/updates.ts +55 -23
  62. package/src/server/workspace.ts +24 -2
  63. package/src/shared.ts +7 -1
  64. package/src/skill-metadata.ts +1 -1
  65. package/vendor/latex-review/package-lock.json +4 -3106
  66. package/vendor/latex-review/package.json +3 -46
  67. package/README.zh-CN.md +0 -100
  68. package/dist/assets/index-BlLyIqT9.js +0 -265
  69. package/dist/assets/index-DVZtyZvu.css +0 -1
  70. package/vendor/latex-review/dist/web/assets/index-CXdnUjct.js +0 -19
  71. package/vendor/latex-review/dist/web/assets/index-DEz-LsGl.css +0 -1
  72. package/vendor/latex-review/src/web/i18n.ts +0 -48
  73. /package/{vendor/latex-review → engine}/LICENSE +0 -0
  74. /package/{vendor/latex-review → engine}/dist/web/assets/TeXGyrePagellaX-Bold-CEtYdOXj.otf +0 -0
  75. /package/{vendor/latex-review → engine}/dist/web/assets/TeXGyrePagellaX-Regular-DQJjZE_L.otf +0 -0
  76. /package/{vendor/latex-review → engine}/src/cli.ts +0 -0
  77. /package/{vendor/latex-review → engine}/src/client.ts +0 -0
  78. /package/{vendor/latex-review → engine}/src/core/bib.ts +0 -0
  79. /package/{vendor/latex-review → engine}/src/core/changes.ts +0 -0
  80. /package/{vendor/latex-review → engine}/src/core/config.ts +0 -0
  81. /package/{vendor/latex-review → engine}/src/core/current-commit.ts +0 -0
  82. /package/{vendor/latex-review → engine}/src/core/delivery.ts +0 -0
  83. /package/{vendor/latex-review → engine}/src/core/diff.ts +0 -0
  84. /package/{vendor/latex-review → engine}/src/core/files.ts +0 -0
  85. /package/{vendor/latex-review → engine}/src/core/process.ts +0 -0
  86. /package/{vendor/latex-review → engine}/src/core/publish.ts +0 -0
  87. /package/{vendor/latex-review → engine}/src/core/reconcile.ts +0 -0
  88. /package/{vendor/latex-review → engine}/src/core/store.ts +0 -0
  89. /package/{vendor/latex-review → engine}/src/core/tasks.ts +0 -0
  90. /package/{vendor/latex-review → engine}/src/core/verify.ts +0 -0
  91. /package/{vendor/latex-review → engine}/src/core/view.ts +0 -0
  92. /package/{vendor/latex-review → engine}/src/render/effects.ts +0 -0
  93. /package/{vendor/latex-review → engine}/src/render/pdf.ts +0 -0
  94. /package/{vendor/latex-review → engine}/src/render/pdfdiff.ts +0 -0
  95. /package/{vendor/latex-review → engine}/src/render/round.ts +0 -0
  96. /package/{vendor/latex-review → engine}/src/render/worker.ts +0 -0
  97. /package/{vendor/latex-review → engine}/src/runtime.ts +0 -0
  98. /package/{vendor/latex-review → engine}/src/web/ChangeMap.tsx +0 -0
  99. /package/{vendor/latex-review → engine}/src/web/PdfStrip.tsx +0 -0
  100. /package/{vendor/latex-review → engine}/src/web/effect-preferences.ts +0 -0
  101. /package/{vendor/latex-review → engine}/src/web/fonts/GUST-FONT-LICENSE.txt +0 -0
  102. /package/{vendor/latex-review → engine}/src/web/fonts/LPPL.txt +0 -0
  103. /package/{vendor/latex-review → engine}/src/web/fonts/MANIFEST-newpx.txt +0 -0
  104. /package/{vendor/latex-review → engine}/src/web/fonts/README-newpx.txt +0 -0
  105. /package/{vendor/latex-review → engine}/src/web/fonts/README.md +0 -0
  106. /package/{vendor/latex-review → engine}/src/web/fonts/TeXGyrePagellaX-Bold.otf +0 -0
  107. /package/{vendor/latex-review → engine}/src/web/fonts/TeXGyrePagellaX-Regular.otf +0 -0
  108. /package/{vendor/latex-review → engine}/src/web/index.html +0 -0
  109. /package/{vendor/latex-review → engine}/src/web/main.tsx +0 -0
  110. /package/{vendor/latex-review → engine}/src/web/model.ts +0 -0
  111. /package/{vendor/latex-review → engine}/src/web/overview.tsx +0 -0
  112. /package/{vendor/latex-review → engine}/src/web/shortcuts.ts +0 -0
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "latex-review-skill",
3
+ "version": "0.2.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=22.22.0"
8
+ },
9
+ "bin": {
10
+ "latex-review": "scripts/review.mjs"
11
+ },
12
+ "scripts": {
13
+ "review": "tsx src/cli.ts",
14
+ "dev": "vite --host 127.0.0.1",
15
+ "build": "tsc --noEmit && vite build",
16
+ "typecheck": "tsc --noEmit",
17
+ "test": "vitest run",
18
+ "check": "npm run typecheck && npm test && npm run build",
19
+ "demo": "tsx examples/demo.ts",
20
+ "test:browser": "playwright test",
21
+ "test:corpus": "tsx scripts/validate-corpus.ts",
22
+ "fixture:prepare": "tsx scripts/prepare-fixture.ts",
23
+ "format": "prettier --write src tests scripts examples/demo.ts *.ts *.json"
24
+ },
25
+ "dependencies": {
26
+ "@fastify/compress": "^9.2.2",
27
+ "@fastify/static": "^10.1.7",
28
+ "commander": "^15.0.0",
29
+ "diff": "^9.0.0",
30
+ "fastify": "^5.12.7",
31
+ "mupdf": "^1.28.1",
32
+ "proper-lockfile": "^4.1.2",
33
+ "react": "^19.3.1",
34
+ "react-dom": "^19.3.1",
35
+ "tsx": "^4.23.15"
36
+ },
37
+ "devDependencies": {
38
+ "@playwright/test": "^1.64.1",
39
+ "@types/node": "^22.20.5",
40
+ "@types/proper-lockfile": "^4.1.4",
41
+ "@types/react": "^19.3.0",
42
+ "@types/react-dom": "^19.3.0",
43
+ "@vitejs/plugin-react": "^6.1.2",
44
+ "pdfjs-dist": "^6.4.299",
45
+ "prettier": "^3.9.10",
46
+ "typescript": "^7.0.2",
47
+ "vite": "^8.3.5",
48
+ "vitest": "^5.0.5"
49
+ }
50
+ }
@@ -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
6
  import { Repo } from "./git.ts";
6
7
  import { listFiles, readJson, writeJson, withLock, now } from "./files.ts";
7
8
  import { stripComments } from "./outline.ts";
8
9
  import { signature, type Project } from "./config.ts";
9
10
  import { run, runAsync } from "./process.ts";
10
- import type { BuildInfo } from "../types.ts";
11
+ import { progressReader, writtenPages } from "./progress.ts";
12
+ import type { BuildInfo, BuildProgress } from "../types.ts";
11
13
  export class BuildError extends Error {
12
14
  constructor(
13
15
  message: string,
@@ -28,6 +30,27 @@ const AUXILIARY = new Set([
28
30
  ".out",
29
31
  ".thm",
30
32
  ]);
33
+ // Output that TeX writes for this folder: SyncTeX data and the log name its
34
+ // absolute path.
35
+ 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);
39
+ }
40
+ // Output that latexmk kept from an earlier build folder still names that
41
+ // folder. Build folders differ only in their equal-length key, so the paths
42
+ // 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;
45
+ 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);
49
+ let at = data.indexOf(old);
50
+ if (at < 0) return;
51
+ for (; at >= 0; at = data.indexOf(old, at + next.length)) next.copy(data, at);
52
+ fs.writeFileSync(file, zipped ? gzipSync(data) : data);
53
+ }
31
54
  const hash = (value: string | Buffer) =>
32
55
  createHash("sha256").update(value).digest("hex");
33
56
  export const includes = (text: string) =>
@@ -197,7 +220,7 @@ export class Builder {
197
220
  rev: string,
198
221
  targets = ["full"],
199
222
  seed: BuildInfo | null = null,
200
- options: { force?: boolean } = {},
223
+ options: { force?: boolean; progress?: (progress: BuildProgress) => void } = {},
201
224
  ): Promise<BuildInfo> {
202
225
  const p = this.project,
203
226
  partial = JSON.stringify(targets) !== '["full"]';
@@ -215,6 +238,7 @@ export class Builder {
215
238
  }
216
239
  const tree = this.repo.treeId(rev, p.source_dir),
217
240
  date = buildDate(),
241
+ context = hash(JSON.stringify([signature(p), version])),
218
242
  key = hash(
219
243
  JSON.stringify([
220
244
  tree,
@@ -239,109 +263,184 @@ export class Builder {
239
263
  fs.utimesSync(folder, new Date(), new Date());
240
264
  return cached;
241
265
  }
242
- fs.rmSync(folder, { recursive: true, force: true });
243
- const source = path.join(folder, "src");
244
- this.repo.export(rev, p.source_dir, source);
245
- const cwd = path.join(
246
- source,
247
- path.posix.relative(p.source_dir || ".", p.doc_dir || "."),
248
- ),
249
- main = path.posix.basename(p.main),
250
- job = path.posix.basename(main, ".tex"),
251
- out = path.join(cwd, "build");
252
- fs.mkdirSync(out, { recursive: true });
253
- for (const target of includes(
254
- fs.readFileSync(path.join(cwd, main), "utf8"),
255
- ))
256
- fs.mkdirSync(path.join(out, path.dirname(target)), { recursive: true });
257
- const args = [
258
- "-norc",
259
- ...(p.latexmkrc
260
- ? ["-r", path.posix.relative(p.doc_dir || ".", p.latexmkrc)]
261
- : []),
262
- { pdflatex: "-pdf", xelatex: "-xelatex", lualatex: "-lualatex" }[
263
- p.engine
264
- ],
265
- "-outdir=build",
266
- `-jobname=${job}`,
267
- "-synctex=1",
268
- "-interaction=nonstopmode",
269
- "-halt-on-error",
270
- "-file-line-error",
271
- ];
272
- if (partial) {
273
- const seedOut = path.dirname(seed!.pdf);
274
- for (const file of listFiles(seedOut))
275
- if (AUXILIARY.has(path.extname(file))) {
276
- const target = path.join(out, path.relative(seedOut, file));
277
- fs.mkdirSync(path.dirname(target), { recursive: true });
278
- if (!fs.existsSync(target)) fs.copyFileSync(file, target);
279
- }
280
- const preload = Object.entries(p.preload)
281
- .filter(
282
- ([name, file]) =>
283
- !targets.includes(name) && fs.existsSync(path.join(cwd, file)),
284
- )
285
- .map(([, file]) => `\\input{${file}}`)
286
- .join("");
287
- args.push(
288
- `-usepretex=\\includeonly{${targets.join(",")}}` +
289
- (preload ? `\\AtBeginDocument{${preload}}` : ""),
290
- );
291
- }
292
- args.push(main);
293
- const transcript = path.join(folder, "latexmk.log"),
294
- fd = fs.openSync(transcript, "w"),
295
- started = now();
296
- let failure: Error | null = null;
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;
297
273
  try {
298
- await runAsync("latexmk", args, {
299
- cwd,
300
- env: { ...process.env, ...date },
301
- timeout: 1800000,
302
- output: (chunk) => fs.writeSync(fd, chunk),
303
- });
274
+ result = await this.compile(rev, tree, key, targets, seed, date, start, options.progress);
304
275
  } catch (error) {
305
- failure = error as Error;
306
- } finally {
307
- fs.closeSync(fd);
308
- }
309
- const pdf = path.join(out, job + ".pdf"),
310
- log = path.join(out, job + ".log"),
311
- logText = fs.existsSync(log) ? fs.readFileSync(log, "utf8") : "";
312
- if (failure || !fs.existsSync(pdf)) {
313
- const transcriptText = fs.readFileSync(transcript, "utf8"),
314
- details = compilerDiagnostics(logText, transcriptText),
315
- kept = path.join(p.state, "logs", key + ".log");
316
- fs.mkdirSync(path.dirname(kept), { recursive: true });
317
- fs.writeFileSync(
318
- kept,
319
- transcriptText + (logText ? "\n\n--- TeX log ---\n" + logText : ""),
320
- );
321
- const tail = transcriptText.split("\n").slice(-25);
322
- fs.rmSync(folder, { recursive: true, force: true });
323
- throw new BuildError(
324
- failure?.message.includes("ENOENT")
325
- ? "latexmk was not found. Install TeX Live or MacTeX."
326
- : "LaTeX failed to compile.",
327
- details.length ? details : tail,
328
- kept,
329
- );
276
+ 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);
330
278
  }
331
- const result: BuildInfo = {
332
- key,
333
- tree,
334
- targets,
335
- root: source,
336
- cwd,
337
- pdf,
338
- seconds: Math.round((now() - started) * 10) / 10,
339
- warnings: warnings(logText),
340
- };
341
- writeJson(path.join(folder, "result.json"), result);
279
+ if (!partial) writeJson(path.join(this.cache, "latest.json"), { key, context, date: date.SOURCE_DATE_EPOCH });
342
280
  return result;
343
281
  });
344
282
  }
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;
288
+ }
289
+ private async compile(
290
+ rev: string,
291
+ tree: string,
292
+ key: string,
293
+ targets: string[],
294
+ seed: BuildInfo | null,
295
+ date: ReturnType<typeof buildDate>,
296
+ start: { info: BuildInfo; date: unknown } | "author" | null,
297
+ progress?: (progress: BuildProgress) => void,
298
+ ): Promise<BuildInfo> {
299
+ const p = this.project,
300
+ partial = JSON.stringify(targets) !== '["full"]',
301
+ folder = path.join(this.cache, key);
302
+ fs.rmSync(folder, { recursive: true, force: true });
303
+ const source = path.join(folder, "src");
304
+ const base = start && start !== "author" ? start.info : null;
305
+ await this.repo.export(rev, p.source_dir, source, base ? { root: base.root, tree: base.tree } : undefined);
306
+ const cwd = path.join(
307
+ source,
308
+ path.posix.relative(p.source_dir || ".", p.doc_dir || "."),
309
+ ),
310
+ main = path.posix.basename(p.main),
311
+ job = path.posix.basename(main, ".tex"),
312
+ 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 });
317
+ // The PDF, SyncTeX data and log carry over only within a day, so that
318
+ // \today stays current; latexmk then skips TeX when no input changed.
319
+ const reused = base !== null && start !== "author" && start?.date === date.SOURCE_DATE_EPOCH;
320
+ if (base) {
321
+ 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)));
325
+ } else if (start === "author") {
326
+ // Only this job's state: the author's folder may hold other editions.
327
+ const own = path.join(p.root, p.doc_dir, "build");
328
+ 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")),
331
+ ...included.map((target) => target + ".aux"),
332
+ ])
333
+ 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));
336
+ } catch {
337
+ /* A missing file is regenerated. */
338
+ }
339
+ }
340
+ const args = [
341
+ "-norc",
342
+ ...(p.latexmkrc
343
+ ? ["-r", path.posix.relative(p.doc_dir || ".", p.latexmkrc)]
344
+ : []),
345
+ { pdflatex: "-pdf", xelatex: "-xelatex", lualatex: "-lualatex" }[
346
+ p.engine
347
+ ],
348
+ "-outdir=build",
349
+ `-jobname=${job}`,
350
+ "-synctex=1",
351
+ "-interaction=nonstopmode",
352
+ "-halt-on-error",
353
+ "-file-line-error",
354
+ ];
355
+ 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
+ }
363
+ const preload = Object.entries(p.preload)
364
+ .filter(
365
+ ([name, file]) =>
366
+ !targets.includes(name) && fs.existsSync(path.join(cwd, file)),
367
+ )
368
+ .map(([, file]) => `\\input{${file}}`)
369
+ .join("");
370
+ args.push(
371
+ `-usepretex=\\includeonly{${targets.join(",")}}` +
372
+ (preload ? `\\AtBeginDocument{${preload}}` : ""),
373
+ );
374
+ }
375
+ args.push(main);
376
+ const transcript = path.join(folder, "latexmk.log"),
377
+ fd = fs.openSync(transcript, "w"),
378
+ started = now(),
379
+ // Pages are counted against the build this one continues from.
380
+ read = progress
381
+ ? progressReader(
382
+ progress,
383
+ writtenPages(
384
+ base
385
+ ? path.join(path.dirname(base.pdf), job + ".log")
386
+ : path.join(p.root, p.doc_dir, "build", job + ".log"),
387
+ ),
388
+ )
389
+ : null;
390
+ let failure: Error | null = null;
391
+ try {
392
+ await runAsync("latexmk", args, {
393
+ cwd,
394
+ env: { ...process.env, ...date },
395
+ timeout: 1800000,
396
+ output: (chunk) => {
397
+ fs.writeSync(fd, chunk);
398
+ read?.(chunk);
399
+ },
400
+ });
401
+ } catch (error) {
402
+ failure = error as Error;
403
+ } finally {
404
+ fs.closeSync(fd);
405
+ }
406
+ const pdf = path.join(out, job + ".pdf"),
407
+ log = path.join(out, job + ".log");
408
+ 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") : "";
412
+ if (failure || !fs.existsSync(pdf)) {
413
+ const transcriptText = fs.readFileSync(transcript, "utf8"),
414
+ details = compilerDiagnostics(logText, transcriptText),
415
+ kept = path.join(p.state, "logs", key + ".log");
416
+ fs.mkdirSync(path.dirname(kept), { recursive: true });
417
+ fs.writeFileSync(
418
+ kept,
419
+ transcriptText + (logText ? "\n\n--- TeX log ---\n" + logText : ""),
420
+ );
421
+ const tail = transcriptText.split("\n").slice(-25);
422
+ fs.rmSync(folder, { recursive: true, force: true });
423
+ throw new BuildError(
424
+ failure?.message.includes("ENOENT")
425
+ ? "latexmk was not found. Install TeX Live or MacTeX."
426
+ : "LaTeX failed to compile.",
427
+ details.length ? details : tail,
428
+ kept,
429
+ );
430
+ }
431
+ const result: BuildInfo = {
432
+ key,
433
+ tree,
434
+ targets,
435
+ root: source,
436
+ cwd,
437
+ pdf,
438
+ seconds: Math.round((now() - started) * 10) / 10,
439
+ warnings: warnings(logText),
440
+ };
441
+ writeJson(path.join(folder, "result.json"), result);
442
+ return result;
443
+ }
345
444
  async prune(keep: Set<string>, maxAge = 3 * 86400) {
346
445
  if (!fs.existsSync(this.cache)) return;
347
446
  for (const entry of fs.readdirSync(this.cache, { withFileTypes: true })) {
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
+ import { spawn } from "node:child_process";
4
5
  import { run, which } from "./process.ts";
5
6
  import { same } from "./files.ts";
6
7
  const IDENTITY = {
@@ -128,6 +129,14 @@ export class Repo {
128
129
  .split("\0")
129
130
  .filter(Boolean);
130
131
  }
132
+ entries(rev: string, folder = "") {
133
+ return this.run([
134
+ "ls-tree", "-r", "-l", "-z", rev, ...(folder ? ["--", folder] : []),
135
+ ]).toString().split("\0").filter(Boolean).flatMap(entry => {
136
+ const tab = entry.indexOf("\t"), [mode, type, oid, size] = entry.slice(0, tab).split(/ +/);
137
+ return type === "blob" ? [{ mode, oid, size: Number(size), file: entry.slice(tab + 1) }] : [];
138
+ });
139
+ }
131
140
  changedFiles(before: string, after: string) {
132
141
  return this.run([
133
142
  "diff",
@@ -261,36 +270,132 @@ export class Repo {
261
270
  return this.text(["write-tree"], { env });
262
271
  });
263
272
  }
264
- export(rev: string, folder: string, destination: string) {
265
- fs.mkdirSync(destination, { recursive: true });
266
- const files = this.files(rev, folder),
267
- blobs = this.blobs(rev, files);
268
- const links: [string, string][] = [];
269
- for (const file of files) {
270
- const relative = path.posix.relative(folder || ".", file);
273
+ // Write a revision's files under destination. Given an earlier export of
274
+ // another tree, files with the same Git object are cloned from it
275
+ // (copy-on-write where the file system supports it), so only changed files
276
+ // are read from Git.
277
+ async export(rev: string, folder: string, destination: string, base?: { root: string; tree: string }) {
278
+ const previous = new Map<string, string>();
279
+ if (base) for (const entry of this.entries(base.tree)) previous.set(entry.file, entry.mode + " " + entry.oid);
280
+ const wanted = this.entries(rev, folder).map(entry => {
281
+ const relative = path.posix.relative(folder || ".", entry.file);
271
282
  cleanPath(relative);
272
- const target = path.join(destination, relative),
273
- data = blobs[file];
274
- if (data === null) continue;
275
- fs.mkdirSync(path.dirname(target), { recursive: true });
276
- const mode = this.mode(rev, file);
277
- if (mode === "120000") {
278
- const link = data.toString(),
279
- resolved = path.resolve(path.dirname(target), link);
280
- if (
281
- path.isAbsolute(link) ||
282
- !resolved.startsWith(path.resolve(destination) + path.sep)
283
- )
284
- throw new GitError(
285
- `A link in the snapshot points outside the document: ${file}`,
286
- );
287
- links.push([target, link]);
288
- } else
289
- fs.writeFileSync(target, data, {
290
- mode: mode === "100755" ? 0o755 : 0o644,
291
- });
283
+ return { ...entry, relative, target: path.join(destination, relative) };
284
+ });
285
+ // On APFS, cp clones a whole folder far faster than creating each file
286
+ // separately; files that are not in this tree are then removed again.
287
+ let cloned = false;
288
+ if (base && process.platform === "darwin" && !fs.existsSync(destination))
289
+ try {
290
+ fs.mkdirSync(path.dirname(destination), { recursive: true });
291
+ cloned = run("/bin/cp", ["-c", "-R", base.root, destination], { check: false }).status === 0;
292
+ }
293
+ catch { /* Copy file by file below. */ }
294
+ if (cloned) {
295
+ const keep = new Set(wanted.map(entry => entry.relative));
296
+ (function prune(directory: string, prefix: string) {
297
+ for (const item of fs.readdirSync(directory, { withFileTypes: true })) {
298
+ const relative = prefix + item.name, file = path.join(directory, item.name);
299
+ if (item.isDirectory()) prune(file, relative + "/");
300
+ else if (!keep.has(relative)) fs.rmSync(file, { force: true });
301
+ }
302
+ })(destination, "");
303
+ } else {
304
+ fs.rmSync(destination, { recursive: true, force: true });
305
+ fs.mkdirSync(destination, { recursive: true });
306
+ }
307
+ const folders = new Set<string>(), entries: typeof wanted = [];
308
+ for (const entry of wanted) {
309
+ const { relative, target } = entry;
310
+ if (!folders.has(path.dirname(target))) { fs.mkdirSync(path.dirname(target), { recursive: true }); folders.add(path.dirname(target)); }
311
+ if (entry.mode !== "120000" && previous.get(relative) === entry.mode + " " + entry.oid) {
312
+ const source = cloned ? target : path.join(base!.root, relative);
313
+ try {
314
+ // A compiler may have rewritten a file in the earlier export.
315
+ const stat = fs.lstatSync(source);
316
+ if (stat.isFile() && stat.size === entry.size) {
317
+ if (!cloned) fs.copyFileSync(source, target, fs.constants.COPYFILE_FICLONE);
318
+ if ((stat.mode & 0o777) !== (entry.mode === "100755" ? 0o755 : 0o644)) fs.chmodSync(target, entry.mode === "100755" ? 0o755 : 0o644);
319
+ continue;
320
+ }
321
+ } catch { /* Read this file from Git. */ }
322
+ }
323
+ // A cloned link or rewritten file is replaced, never written through.
324
+ if (cloned) fs.rmSync(target, { force: true });
325
+ entries.push(entry);
326
+ }
327
+ if (!entries.length) return;
328
+ // Keep one Git process and stream each blob to disk. Collecting the entire
329
+ // snapshot with blobs() both duplicates memory and hits spawnSync's buffer
330
+ // limit for ordinary repositories containing PDFs and figures.
331
+ const child = spawn(gitBinary(), ["cat-file", "--batch"], {
332
+ cwd: this.root, stdio: ["pipe", "pipe", "pipe"],
333
+ });
334
+ let stderr = "", timedOut = false;
335
+ child.stderr.on("data", (data: Buffer) => { stderr = (stderr + data.toString()).slice(-65536); });
336
+ child.stdin.on("error", () => { /* Report process failures through finished. */ });
337
+ const finished = new Promise<Error | null>(resolve => {
338
+ child.once("error", resolve);
339
+ child.once("close", code => resolve(code === 0 && !timedOut ? null : new GitError(
340
+ timedOut ? "Git snapshot export timed out." : stderr.trim() || `Git snapshot export failed (${code}).`,
341
+ )));
342
+ });
343
+ const timer = setTimeout(() => { timedOut = true; child.kill("SIGKILL"); }, 300000);
344
+ const output = child.stdout[Symbol.asyncIterator]();
345
+ let chunk: Buffer = Buffer.alloc(0), offset = 0;
346
+ const available = async () => {
347
+ if (offset < chunk.length) return;
348
+ const next = await output.next();
349
+ if (next.done) throw await finished || new GitError("Git snapshot export ended before a file was complete.");
350
+ chunk = next.value; offset = 0;
351
+ };
352
+ const header = async () => {
353
+ const parts: Buffer[] = [];
354
+ while (true) {
355
+ await available();
356
+ const end = chunk.indexOf(10, offset);
357
+ parts.push(chunk.subarray(offset, end < 0 ? chunk.length : end));
358
+ offset = end < 0 ? chunk.length : end + 1;
359
+ if (end >= 0) return Buffer.concat(parts).toString();
360
+ }
361
+ };
362
+ const links: [string, string][] = [];
363
+ try {
364
+ for (const { file, mode, oid, target } of entries) {
365
+ child.stdin.write(oid + "\n");
366
+ const [object, type, size] = (await header()).split(" ");
367
+ let remaining = Number(size);
368
+ if (object !== oid || type !== "blob" || !Number.isSafeInteger(remaining) || remaining < 0)
369
+ throw new GitError(`Git snapshot export could not read: ${file}`);
370
+ const linkParts: Buffer[] = [], fd = mode === "120000" ? null : fs.openSync(target, "w", mode === "100755" ? 0o755 : 0o644);
371
+ try {
372
+ while (remaining) {
373
+ await available();
374
+ const length = Math.min(remaining, chunk.length - offset), data = chunk.subarray(offset, offset + length);
375
+ if (fd === null) linkParts.push(Buffer.from(data));
376
+ else fs.writeFileSync(fd, data);
377
+ remaining -= length; offset += length;
378
+ }
379
+ } finally { if (fd !== null) fs.closeSync(fd); }
380
+ await available();
381
+ if (chunk[offset++] !== 10) throw new GitError(`Git snapshot export returned an invalid file boundary: ${file}`);
382
+ if (mode === "120000") {
383
+ const link = Buffer.concat(linkParts).toString(), resolved = path.resolve(path.dirname(target), link);
384
+ if (path.isAbsolute(link) || !resolved.startsWith(path.resolve(destination) + path.sep))
385
+ throw new GitError(`A link in the snapshot points outside the document: ${file}`);
386
+ links.push([target, link]);
387
+ }
388
+ }
389
+ child.stdin.end();
390
+ if (!(await output.next()).done) throw new GitError("Git snapshot export returned unexpected output.");
391
+ const failure = await finished;
392
+ if (failure) throw failure;
393
+ for (const [target, link] of links) fs.symlinkSync(link, target);
394
+ } finally {
395
+ clearTimeout(timer);
396
+ if (child.exitCode === null) child.kill("SIGKILL");
397
+ await finished;
292
398
  }
293
- for (const [target, link] of links) fs.symlinkSync(link, target);
294
399
  }
295
400
  }
296
401
  export function merge3(
@@ -247,15 +247,6 @@ export function buildOutline(
247
247
  .slice(0, 20),
248
248
  };
249
249
  }
250
- export function overlaps(a: Ranges, b: Ranges) {
251
- return Object.keys(a).some(
252
- (file) =>
253
- b[file] &&
254
- a[file].some(([x, y]) =>
255
- b[file].some(([u, v]) => Math.max(x, u) <= Math.min(y, v)),
256
- ),
257
- );
258
- }
259
250
  export function selectedRanges(
260
251
  nodes: Heading[],
261
252
  ids: string[],
@@ -0,0 +1,67 @@
1
+ import fs from "node:fs";
2
+ import type { BuildProgress } from "../types.ts";
3
+
4
+ // What latexmk is doing, read from its output as it streams: the rule it
5
+ // runs, the TeX pass, and the pages TeX has shipped out in that pass. TeX
6
+ // prints "[n" for every page and flushes the terminal right after, so the
7
+ // count moves while a long document typesets.
8
+ const RUN = /^Run number (\d+) of rule '([^']+)'/;
9
+ const WRITTEN = /^Output written on .*?\((\d+) pages?\b/;
10
+ // A page marker is "[" with \count0 (and any nonzero \count1–9), closed by
11
+ // "]", a space, or the font map and image notes TeX prints inside it.
12
+ const PAGE = /\[-?\d+(?:\.-?\d+)*(?=[\]\s{<(])/g;
13
+
14
+ export function progressReader(
15
+ report: (progress: BuildProgress) => void,
16
+ total: number | null,
17
+ ) {
18
+ const state: BuildProgress = { step: "tex", pass: 0, pages: 0, total };
19
+ let line = "",
20
+ scanned = 0;
21
+ const pages = (text: string, from: number) => {
22
+ if (state.step !== "tex" || state.pass === 0) return text.length;
23
+ PAGE.lastIndex = from;
24
+ let end = from;
25
+ while (PAGE.exec(text)) {
26
+ state.pages++;
27
+ end = PAGE.lastIndex;
28
+ }
29
+ // An unfinished marker at the end counts once it is complete.
30
+ const open = text.lastIndexOf("[");
31
+ return open >= end && /^\[-?[\d.]*$/.test(text.slice(open)) ? open : text.length;
32
+ };
33
+ report({ ...state });
34
+ return (chunk: Buffer) => {
35
+ const before = JSON.stringify(state),
36
+ parts = (line + chunk.toString("latin1")).split("\n");
37
+ line = parts.pop()!;
38
+ for (const complete of parts) {
39
+ const run = RUN.exec(complete),
40
+ written = WRITTEN.exec(complete);
41
+ if (run) {
42
+ const rule = run[2].split(" ")[0];
43
+ if (/^(?:pdf|xe|lua)?latex$/.test(rule))
44
+ Object.assign(state, { step: "tex", pass: Number(run[1]), pages: 0 });
45
+ else
46
+ state.step = /^(?:xdvipdfmx|dvipdfmx?|dvips|ps2pdf)$/.test(rule)
47
+ ? "pdf"
48
+ : "bibliography";
49
+ } else if (written) state.total = state.pages = Number(written[1]);
50
+ else pages(complete, scanned);
51
+ scanned = 0;
52
+ }
53
+ scanned = pages(line, scanned);
54
+ if (JSON.stringify(state) !== before) report({ ...state });
55
+ };
56
+ }
57
+
58
+ // The pages of the build a compilation continues from, as TeX reported them.
59
+ export function writtenPages(log: string): number | null {
60
+ try {
61
+ const text = fs.readFileSync(log, "latin1"),
62
+ found = [...text.matchAll(/Output written on .*?\((\d+) pages?\b/g)].at(-1);
63
+ return found ? Number(found[1]) : null;
64
+ } catch {
65
+ return null;
66
+ }
67
+ }