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.
- package/README.md +4 -2
- package/bin/doctor.mjs +1 -1
- package/dist/assets/{_virtual_papilot-licenses-CMfCtEYQ.js → _virtual_papilot-licenses-YJbh9c1G.js} +1 -1
- package/dist/assets/index-0siHHtfz.js +267 -0
- package/dist/assets/index-CxdYm81a.css +1 -0
- package/dist/assets/{math-Bu3xEyrC.js → math-ibBpTslk.js} +1 -1
- package/dist/assets/{pdf-view-PbkCuTZ-.js → pdf-view-CD1kNKYH.js} +1 -1
- package/dist/index.html +2 -2
- package/engine/dist/web/assets/FullPaper-DUgLb01O.js +54 -0
- package/engine/dist/web/assets/index-D3_ZFdT8.css +1 -0
- package/engine/dist/web/assets/index-DJywe0wU.js +19 -0
- package/engine/dist/web/assets/pdf.worker.min-CjEcRF4W.mjs +28 -0
- package/{vendor/latex-review → engine}/dist/web/index.html +2 -2
- package/engine/package-lock.json +3390 -0
- package/engine/package.json +50 -0
- package/{vendor/latex-review → engine}/src/core/build.ts +198 -99
- package/{vendor/latex-review → engine}/src/core/git.ts +133 -28
- package/{vendor/latex-review → engine}/src/core/outline.ts +0 -9
- package/engine/src/core/progress.ts +67 -0
- package/{vendor/latex-review → engine}/src/core/review.ts +32 -0
- package/engine/src/locales.ts +47 -0
- package/{vendor/latex-review → engine}/src/render/inline.ts +1 -0
- package/{vendor/latex-review → engine}/src/render/locate.ts +26 -16
- package/engine/src/render/positions.ts +34 -0
- package/engine/src/render/synctex.ts +188 -0
- package/engine/src/render/synctex.wasm +0 -0
- package/engine/src/render/synctex.wasm.LICENSE +50 -0
- package/{vendor/latex-review → engine}/src/server.ts +5 -3
- package/{vendor/latex-review → engine}/src/types.ts +13 -0
- package/engine/src/web/FullPaper.tsx +318 -0
- package/engine/src/web/i18n.ts +36 -0
- package/engine/src/web/locales/de.json +253 -0
- package/engine/src/web/locales/es.json +253 -0
- package/engine/src/web/locales/fr.json +253 -0
- package/engine/src/web/locales/index.ts +16 -0
- package/engine/src/web/locales/it.json +253 -0
- package/engine/src/web/locales/ja.json +253 -0
- package/engine/src/web/locales/ko.json +253 -0
- package/engine/src/web/locales/pt.json +253 -0
- package/engine/src/web/locales/sv.json +253 -0
- package/engine/src/web/locales/zh-Hant.json +253 -0
- package/engine/src/web/paper.ts +13 -0
- package/{vendor/latex-review → engine}/src/web/references.tsx +1 -1
- package/{vendor/latex-review → engine}/src/web/review.tsx +99 -2
- package/{vendor/latex-review → engine}/src/web/style.css +71 -11
- package/engine/src/web/view-preferences.ts +13 -0
- package/{vendor/latex-review → engine}/src/web/zh-CN.ts +3 -0
- package/package.json +18 -9
- package/src/conversation.ts +1 -1
- package/src/server/acp.ts +9 -5
- package/src/server/agents.ts +4 -2
- package/src/server/app.ts +23 -0
- package/src/server/changes.ts +12 -3
- package/src/server/imports.ts +69 -25
- package/src/server/main.ts +3 -2
- package/src/server/manuscript.ts +31 -12
- package/src/server/paths.ts +1 -1
- package/src/server/start.ts +3 -3
- package/src/server/store.ts +0 -8
- package/src/server/update-install.ts +6 -3
- package/src/server/updates.ts +55 -23
- package/src/server/workspace.ts +24 -2
- package/src/shared.ts +7 -1
- package/src/skill-metadata.ts +1 -1
- package/vendor/latex-review/package-lock.json +4 -3106
- package/vendor/latex-review/package.json +3 -46
- package/README.zh-CN.md +0 -100
- package/dist/assets/index-BlLyIqT9.js +0 -265
- package/dist/assets/index-DVZtyZvu.css +0 -1
- package/vendor/latex-review/dist/web/assets/index-CXdnUjct.js +0 -19
- package/vendor/latex-review/dist/web/assets/index-DEz-LsGl.css +0 -1
- package/vendor/latex-review/src/web/i18n.ts +0 -48
- /package/{vendor/latex-review → engine}/LICENSE +0 -0
- /package/{vendor/latex-review → engine}/dist/web/assets/TeXGyrePagellaX-Bold-CEtYdOXj.otf +0 -0
- /package/{vendor/latex-review → engine}/dist/web/assets/TeXGyrePagellaX-Regular-DQJjZE_L.otf +0 -0
- /package/{vendor/latex-review → engine}/src/cli.ts +0 -0
- /package/{vendor/latex-review → engine}/src/client.ts +0 -0
- /package/{vendor/latex-review → engine}/src/core/bib.ts +0 -0
- /package/{vendor/latex-review → engine}/src/core/changes.ts +0 -0
- /package/{vendor/latex-review → engine}/src/core/config.ts +0 -0
- /package/{vendor/latex-review → engine}/src/core/current-commit.ts +0 -0
- /package/{vendor/latex-review → engine}/src/core/delivery.ts +0 -0
- /package/{vendor/latex-review → engine}/src/core/diff.ts +0 -0
- /package/{vendor/latex-review → engine}/src/core/files.ts +0 -0
- /package/{vendor/latex-review → engine}/src/core/process.ts +0 -0
- /package/{vendor/latex-review → engine}/src/core/publish.ts +0 -0
- /package/{vendor/latex-review → engine}/src/core/reconcile.ts +0 -0
- /package/{vendor/latex-review → engine}/src/core/store.ts +0 -0
- /package/{vendor/latex-review → engine}/src/core/tasks.ts +0 -0
- /package/{vendor/latex-review → engine}/src/core/verify.ts +0 -0
- /package/{vendor/latex-review → engine}/src/core/view.ts +0 -0
- /package/{vendor/latex-review → engine}/src/render/effects.ts +0 -0
- /package/{vendor/latex-review → engine}/src/render/pdf.ts +0 -0
- /package/{vendor/latex-review → engine}/src/render/pdfdiff.ts +0 -0
- /package/{vendor/latex-review → engine}/src/render/round.ts +0 -0
- /package/{vendor/latex-review → engine}/src/render/worker.ts +0 -0
- /package/{vendor/latex-review → engine}/src/runtime.ts +0 -0
- /package/{vendor/latex-review → engine}/src/web/ChangeMap.tsx +0 -0
- /package/{vendor/latex-review → engine}/src/web/PdfStrip.tsx +0 -0
- /package/{vendor/latex-review → engine}/src/web/effect-preferences.ts +0 -0
- /package/{vendor/latex-review → engine}/src/web/fonts/GUST-FONT-LICENSE.txt +0 -0
- /package/{vendor/latex-review → engine}/src/web/fonts/LPPL.txt +0 -0
- /package/{vendor/latex-review → engine}/src/web/fonts/MANIFEST-newpx.txt +0 -0
- /package/{vendor/latex-review → engine}/src/web/fonts/README-newpx.txt +0 -0
- /package/{vendor/latex-review → engine}/src/web/fonts/README.md +0 -0
- /package/{vendor/latex-review → engine}/src/web/fonts/TeXGyrePagellaX-Bold.otf +0 -0
- /package/{vendor/latex-review → engine}/src/web/fonts/TeXGyrePagellaX-Regular.otf +0 -0
- /package/{vendor/latex-review → engine}/src/web/index.html +0 -0
- /package/{vendor/latex-review → engine}/src/web/main.tsx +0 -0
- /package/{vendor/latex-review → engine}/src/web/model.ts +0 -0
- /package/{vendor/latex-review → engine}/src/web/overview.tsx +0 -0
- /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
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
|
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
|
-
|
|
306
|
-
|
|
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
|
-
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
+
}
|