visualproof 0.0.0-stage → 0.2.0
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/LICENSE +202 -0
- package/README.md +99 -2
- package/dist/assets/fonts/JuliaMono-Regular.ttf +0 -0
- package/dist/assets/fonts/JuliaMono-RegularItalic.ttf +0 -0
- package/dist/assets/fonts/JuliaMono-SemiBold.ttf +0 -0
- package/dist/assets/fonts/LICENSE-JuliaMono.txt +93 -0
- package/dist/assets/latex/visualproof.sty +308 -0
- package/dist/assets/webfonts/LICENSE-Jost.txt +93 -0
- package/dist/assets/webfonts/LICENSE-SourceSans3.txt +93 -0
- package/dist/assets/webfonts/LICENSE-SourceSerif4.txt +93 -0
- package/dist/assets/webfonts/jost-latin-ext-wght-normal.woff2 +0 -0
- package/dist/assets/webfonts/jost-latin-wght-normal.woff2 +0 -0
- package/dist/assets/webfonts/source-sans-3-greek-wght-italic.woff2 +0 -0
- package/dist/assets/webfonts/source-sans-3-greek-wght-normal.woff2 +0 -0
- package/dist/assets/webfonts/source-sans-3-latin-ext-wght-italic.woff2 +0 -0
- package/dist/assets/webfonts/source-sans-3-latin-ext-wght-normal.woff2 +0 -0
- package/dist/assets/webfonts/source-sans-3-latin-wght-italic.woff2 +0 -0
- package/dist/assets/webfonts/source-sans-3-latin-wght-normal.woff2 +0 -0
- package/dist/assets/webfonts/source-serif-4-greek-wght-italic.woff2 +0 -0
- package/dist/assets/webfonts/source-serif-4-greek-wght-normal.woff2 +0 -0
- package/dist/assets/webfonts/source-serif-4-latin-ext-wght-italic.woff2 +0 -0
- package/dist/assets/webfonts/source-serif-4-latin-ext-wght-normal.woff2 +0 -0
- package/dist/assets/webfonts/source-serif-4-latin-wght-italic.woff2 +0 -0
- package/dist/assets/webfonts/source-serif-4-latin-wght-normal.woff2 +0 -0
- package/dist/extractor/Extract.lean +1180 -0
- package/dist/extractor/Usage.lean +900 -0
- package/dist/mcp/visualproof-mcp.mjs +476 -0
- package/dist/server/access.js +95 -0
- package/dist/server/agent.js +3270 -0
- package/dist/server/agentdocs.js +35 -0
- package/dist/server/agentlibrary.js +202 -0
- package/dist/server/agentnotes.js +701 -0
- package/dist/server/agentviews.js +703 -0
- package/dist/server/askfollow.js +286 -0
- package/dist/server/boardspots.js +159 -0
- package/dist/server/dirs.js +175 -0
- package/dist/server/doctor.js +186 -0
- package/dist/server/events.js +198 -0
- package/dist/server/export.js +420 -0
- package/dist/server/extract.js +204 -0
- package/dist/server/fetchurl.js +321 -0
- package/dist/server/figures.js +158 -0
- package/dist/server/history.js +780 -0
- package/dist/server/historyapi.js +304 -0
- package/dist/server/hover.js +595 -0
- package/dist/server/identity.js +114 -0
- package/dist/server/invites.js +241 -0
- package/dist/server/layoutthread.js +51 -0
- package/dist/server/library.js +600 -0
- package/dist/server/libraryroute.js +211 -0
- package/dist/server/live.js +1101 -0
- package/dist/server/lostfound.js +199 -0
- package/dist/server/main.js +1406 -0
- package/dist/server/mcptools.js +496 -0
- package/dist/server/pastdoc.js +73 -0
- package/dist/server/pipeline.js +159 -0
- package/dist/server/poppler.js +251 -0
- package/dist/server/presence.js +252 -0
- package/dist/server/project.js +222 -0
- package/dist/server/prompt.js +53 -0
- package/dist/server/readonly.js +152 -0
- package/dist/server/readonlygate.js +123 -0
- package/dist/server/renames.js +268 -0
- package/dist/server/review.js +334 -0
- package/dist/server/reviewapi.js +188 -0
- package/dist/server/snapshot.js +50 -0
- package/dist/server/sourcestore.js +473 -0
- package/dist/server/status.js +360 -0
- package/dist/server/talk.js +476 -0
- package/dist/server/tokens.js +221 -0
- package/dist/server/usage.js +501 -0
- package/dist/server/versions.js +82 -0
- package/dist/server/view.js +164 -0
- package/dist/server/views.js +1167 -0
- package/dist/shared/askfollow.js +46 -0
- package/dist/shared/board.js +332 -0
- package/dist/shared/build.js +2 -0
- package/dist/shared/collab.js +241 -0
- package/dist/shared/comment.js +285 -0
- package/dist/shared/context.js +157 -0
- package/dist/shared/doc/authors.js +98 -0
- package/dist/shared/doc/build.js +358 -0
- package/dist/shared/doc/latex.js +455 -0
- package/dist/shared/doc/markdown.js +159 -0
- package/dist/shared/doc/model.js +37 -0
- package/dist/shared/doc/order.js +124 -0
- package/dist/shared/doc/outline.js +45 -0
- package/dist/shared/doc/proof.js +272 -0
- package/dist/shared/doc/sections.js +93 -0
- package/dist/shared/doc/settings.js +75 -0
- package/dist/shared/edgecodec.js +102 -0
- package/dist/shared/editor.js +64 -0
- package/dist/shared/excerpt.js +87 -0
- package/dist/shared/graph.js +16 -0
- package/dist/shared/items.js +92 -0
- package/dist/shared/keys.js +203 -0
- package/dist/shared/layout.js +926 -0
- package/dist/shared/lexer.js +130 -0
- package/dist/shared/library.js +711 -0
- package/dist/shared/lostfound.js +150 -0
- package/dist/shared/moves.js +244 -0
- package/dist/shared/notecard.js +295 -0
- package/dist/shared/noteplace.js +505 -0
- package/dist/shared/palette.js +46 -0
- package/dist/shared/pastgraph.js +103 -0
- package/dist/shared/places.js +80 -0
- package/dist/shared/projectnotes.js +244 -0
- package/dist/shared/query.js +818 -0
- package/dist/shared/review.js +642 -0
- package/dist/shared/reviewqueue.js +333 -0
- package/dist/shared/ring.js +366 -0
- package/dist/shared/scope.js +255 -0
- package/dist/shared/scopeedit.js +81 -0
- package/dist/shared/search.js +191 -0
- package/dist/shared/separate.js +178 -0
- package/dist/shared/sharesnap.js +281 -0
- package/dist/shared/sticky.js +501 -0
- package/dist/shared/timeline.js +73 -0
- package/dist/shared/titles.js +300 -0
- package/dist/shared/viewgraph.js +157 -0
- package/dist/shared/views.js +859 -0
- package/dist/web/bundle/app.js +52671 -0
- package/dist/web/bundle/edges.js +407 -0
- package/dist/web/bundle/layout.js +1010 -0
- package/dist/web/bundle/pdfworker.js +48322 -0
- package/dist/web/index.html +257 -0
- package/dist/web/src/motion.js +430 -0
- package/dist/web/src/past.js +224 -0
- package/dist/web/src/scene.js +804 -0
- package/dist/web/style.css +2548 -0
- package/docs/agent-api/README.md +91 -0
- package/docs/agent-api/advice.md +108 -0
- package/docs/agent-api/export.md +120 -0
- package/docs/agent-api/following.md +140 -0
- package/docs/agent-api/history.md +142 -0
- package/docs/agent-api/library.md +65 -0
- package/docs/agent-api/mcp.md +104 -0
- package/docs/agent-api/notes.md +93 -0
- package/docs/agent-api/reading.md +166 -0
- package/docs/agent-api/requests.md +94 -0
- package/docs/agent-api/talking.md +100 -0
- package/docs/agent-api/tcb.md +135 -0
- package/docs/agent-api/views.md +125 -0
- package/docs/agent-api/words.md +56 -0
- package/package.json +39 -4
- package/test/toolchains.json +5 -0
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
// The exported document, compiled (ExportSpec decision 6): a .tex made by shared/doc/latex.ts,
|
|
2
|
+
// typeset by XeLaTeX with the style file and the fonts this repository holds (assets/latex/,
|
|
3
|
+
// assets/fonts/). Typesetting takes seconds for a large document, so a request starts a job and
|
|
4
|
+
// is answered at once; the page asks how the job is doing and fetches the PDF once it is done.
|
|
5
|
+
//
|
|
6
|
+
// XeLaTeX runs in a temporary directory of its own under the project's cache, never in the project:
|
|
7
|
+
// with -no-shell-escape (no program is ever run), -halt-on-error, nothing read from the terminal,
|
|
8
|
+
// TeX's paranoid file access (openin_any=p, openout_any=p: nothing read or written by an absolute
|
|
9
|
+
// path or outside that directory, beyond TeX's own trees), and a time limit after which it is
|
|
10
|
+
// killed. The .tex itself holds nothing a reader wrote that LaTeX would read as a command (its
|
|
11
|
+
// escaping, shared/doc/latex.ts); these are a second wall. The directory goes once the run is over,
|
|
12
|
+
// whatever happened.
|
|
13
|
+
//
|
|
14
|
+
// A document is known by a hash of its .tex and the style file: the same request again is the same
|
|
15
|
+
// job, running or done, and a done one is kept in a cache of the last few under the project's cache
|
|
16
|
+
// (`export/cache/<hash>/`), so that a reader downloading the sources after the PDF, or a second
|
|
17
|
+
// reader asking for the same document, does not typeset it again. Its figures (the images and clips
|
|
18
|
+
// its notes carry, server/figures.ts) are written beside it in `figures/` before XeLaTeX runs, kept
|
|
19
|
+
// with it in the cache and in the sources' zip; their names say what they are drawn from, so the
|
|
20
|
+
// .tex names them too.
|
|
21
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
22
|
+
import { createHash } from "node:crypto";
|
|
23
|
+
import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
|
|
24
|
+
import { join, resolve } from "node:path";
|
|
25
|
+
import { deflateRawSync } from "node:zlib";
|
|
26
|
+
/** The repository's assets: the style file and the fonts it loads. */
|
|
27
|
+
const ASSETS = resolve(import.meta.dirname, "..", "assets");
|
|
28
|
+
export const STYLE_FILE = join(ASSETS, "latex", "visualproof.sty");
|
|
29
|
+
export const FONTS_DIR = join(ASSETS, "fonts");
|
|
30
|
+
/** How many runs of XeLaTeX one document may take: the second resolves page references and the
|
|
31
|
+
* bookmarks; a third only where the second moved a page break. */
|
|
32
|
+
const MAX_RUNS = 3;
|
|
33
|
+
/** How much of XeLaTeX's log a failed job keeps, in characters: its end, where the error is. */
|
|
34
|
+
const LOG_TAIL = 4000;
|
|
35
|
+
/** What stands in for a figure that could not be made: a white pixel, so that the rest still prints
|
|
36
|
+
* and the job's notes say which. */
|
|
37
|
+
const BLANK_PNG = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR42mP4//8/AAX+Av4zEpUUAAAAAElFTkSuQmCC", "base64");
|
|
38
|
+
/** A figure's name as the .tex gives it (shared/doc/latex.ts, figureAsset): nothing else is written. */
|
|
39
|
+
const FIGURE_NAME = /^(?:fig|clip)-[0-9a-z-]{1,200}\.(?:png|jpg)$/;
|
|
40
|
+
export class Exporter {
|
|
41
|
+
dir;
|
|
42
|
+
cacheDir;
|
|
43
|
+
timeoutMs;
|
|
44
|
+
xelatex;
|
|
45
|
+
maxCached;
|
|
46
|
+
concurrent;
|
|
47
|
+
jobs = new Map();
|
|
48
|
+
/** the jobs' ends, for whoever waits on one */
|
|
49
|
+
ends = new Map();
|
|
50
|
+
running = 0;
|
|
51
|
+
queue = [];
|
|
52
|
+
found = null;
|
|
53
|
+
constructor(cacheDir, o = {}) {
|
|
54
|
+
this.dir = join(cacheDir, "export");
|
|
55
|
+
this.cacheDir = join(this.dir, "cache");
|
|
56
|
+
this.timeoutMs = o.timeoutMs ?? 120_000;
|
|
57
|
+
this.xelatex = o.xelatex ?? "xelatex";
|
|
58
|
+
this.maxCached = o.maxCached ?? 20;
|
|
59
|
+
this.concurrent = o.concurrent ?? 2;
|
|
60
|
+
}
|
|
61
|
+
/** Whether XeLaTeX can be run here: asked once. */
|
|
62
|
+
available() {
|
|
63
|
+
if (this.found === null) {
|
|
64
|
+
const r = spawnSync(this.xelatex, ["--version"], { stdio: "ignore", timeout: 10_000 });
|
|
65
|
+
this.found = r.status === 0;
|
|
66
|
+
}
|
|
67
|
+
return this.found;
|
|
68
|
+
}
|
|
69
|
+
/** The style file as it is now: part of what names a document, so that a changed style typesets
|
|
70
|
+
* again. */
|
|
71
|
+
style() {
|
|
72
|
+
return readFileSync(STYLE_FILE, "utf8");
|
|
73
|
+
}
|
|
74
|
+
/** Typeset `tex`: the job that does it, at once — the one already running or done for the same
|
|
75
|
+
* document, else a new one. `notes`: what the job says besides the document (`ExportJob.notes`).
|
|
76
|
+
* `figures`: the files the .tex includes from `figures/`, made when it is typeset. */
|
|
77
|
+
compile(tex, notes = [], figures = []) {
|
|
78
|
+
const sty = this.style();
|
|
79
|
+
const id = createHash("sha256").update(tex).update("\u0000").update(sty).digest("hex").slice(0, 32);
|
|
80
|
+
const note = (j) => {
|
|
81
|
+
if (notes.length)
|
|
82
|
+
j.notes = [...notes];
|
|
83
|
+
else
|
|
84
|
+
delete j.notes;
|
|
85
|
+
return j;
|
|
86
|
+
};
|
|
87
|
+
const known = this.jobs.get(id);
|
|
88
|
+
if (known && known.state !== "failed")
|
|
89
|
+
return note(known);
|
|
90
|
+
const kept = this.fromCache(id);
|
|
91
|
+
if (kept) {
|
|
92
|
+
this.remember(note(kept));
|
|
93
|
+
this.ends.set(id, Promise.resolve(kept));
|
|
94
|
+
return kept;
|
|
95
|
+
}
|
|
96
|
+
const job = note({ id, state: "running", at: new Date().toISOString() });
|
|
97
|
+
this.remember(job);
|
|
98
|
+
this.ends.set(id, this.turn().then(async () => {
|
|
99
|
+
try {
|
|
100
|
+
await this.run(job, tex, sty, figures);
|
|
101
|
+
}
|
|
102
|
+
catch (e) {
|
|
103
|
+
job.state = "failed";
|
|
104
|
+
job.error = e instanceof Error ? e.message : String(e);
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
this.running--;
|
|
108
|
+
this.queue.shift()?.();
|
|
109
|
+
}
|
|
110
|
+
return job;
|
|
111
|
+
}));
|
|
112
|
+
return job;
|
|
113
|
+
}
|
|
114
|
+
/** A job by its id: one this server knows, or one the cache holds. */
|
|
115
|
+
job(id) {
|
|
116
|
+
if (!/^[0-9a-f]{32}$/.test(id))
|
|
117
|
+
return null;
|
|
118
|
+
const j = this.jobs.get(id) ?? this.fromCache(id);
|
|
119
|
+
if (j)
|
|
120
|
+
this.remember(j);
|
|
121
|
+
return j;
|
|
122
|
+
}
|
|
123
|
+
/** The job once it is over. */
|
|
124
|
+
wait(id) {
|
|
125
|
+
return this.ends.get(id) ?? Promise.resolve(this.job(id));
|
|
126
|
+
}
|
|
127
|
+
/** A file of a done job: the PDF, the .tex, the style file, or a zip of everything needed to
|
|
128
|
+
* typeset it again (the .tex, the style file, the fonts, a README). Null where the job is not
|
|
129
|
+
* done, or no longer in the cache. */
|
|
130
|
+
file(id, what) {
|
|
131
|
+
const j = this.job(id);
|
|
132
|
+
if (!j || j.state !== "done")
|
|
133
|
+
return null;
|
|
134
|
+
const at = join(this.cacheDir, id);
|
|
135
|
+
try {
|
|
136
|
+
switch (what) {
|
|
137
|
+
case "pdf":
|
|
138
|
+
return { body: readFileSync(join(at, "doc.pdf")), type: "application/pdf", name: "visualproof-export.pdf" };
|
|
139
|
+
case "tex":
|
|
140
|
+
return { body: readFileSync(join(at, "doc.tex")), type: "application/x-tex; charset=utf-8", name: "visualproof-export.tex" };
|
|
141
|
+
case "sty":
|
|
142
|
+
return { body: readFileSync(join(at, "visualproof.sty")), type: "application/x-tex; charset=utf-8", name: "visualproof.sty" };
|
|
143
|
+
case "zip": {
|
|
144
|
+
const file = join(at, "sources.zip");
|
|
145
|
+
if (!existsSync(file))
|
|
146
|
+
writeFileSync(file, this.sources(at));
|
|
147
|
+
return { body: readFileSync(file), type: "application/zip", name: "visualproof-export-sources.zip" };
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
// gone from the cache meanwhile
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/** A turn to typeset: at most `concurrent` at once, the rest in order. */
|
|
157
|
+
turn() {
|
|
158
|
+
if (this.running < this.concurrent) {
|
|
159
|
+
this.running++;
|
|
160
|
+
return Promise.resolve();
|
|
161
|
+
}
|
|
162
|
+
return new Promise((ok) => this.queue.push(() => {
|
|
163
|
+
this.running++;
|
|
164
|
+
ok();
|
|
165
|
+
}));
|
|
166
|
+
}
|
|
167
|
+
/** Keep a job among those this server knows, the last hundred. */
|
|
168
|
+
remember(j) {
|
|
169
|
+
this.jobs.delete(j.id);
|
|
170
|
+
this.jobs.set(j.id, j);
|
|
171
|
+
while (this.jobs.size > 100) {
|
|
172
|
+
const first = this.jobs.keys().next().value;
|
|
173
|
+
this.jobs.delete(first);
|
|
174
|
+
this.ends.delete(first);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/** A done job the cache holds, as a job. */
|
|
178
|
+
fromCache(id) {
|
|
179
|
+
try {
|
|
180
|
+
const meta = JSON.parse(readFileSync(join(this.cacheDir, id, "job.json"), "utf8"));
|
|
181
|
+
if (!existsSync(join(this.cacheDir, id, "doc.pdf")))
|
|
182
|
+
return null;
|
|
183
|
+
return { id, state: "done", at: meta.at, pages: meta.pages, ms: meta.ms, runs: meta.runs, cached: true, ...(meta.notes?.length ? { notes: meta.notes } : {}) };
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
/** Typeset in a directory of its own, as often as the log asks (at most MAX_RUNS), then keep
|
|
190
|
+
* the result in the cache. The directory goes whatever happens. */
|
|
191
|
+
async run(job, tex, sty, figures = []) {
|
|
192
|
+
mkdirSync(this.dir, { recursive: true });
|
|
193
|
+
const tmp = mkdtempSync(join(this.dir, "run-"));
|
|
194
|
+
const start = Date.now();
|
|
195
|
+
try {
|
|
196
|
+
writeFileSync(join(tmp, "doc.tex"), tex);
|
|
197
|
+
writeFileSync(join(tmp, "visualproof.sty"), sty);
|
|
198
|
+
// the fonts as the style file loads them, from fonts/ beside the document
|
|
199
|
+
symlinkSync(FONTS_DIR, join(tmp, "fonts"), "dir");
|
|
200
|
+
// the figures the .tex includes; one that cannot be made is a blank, and the job says which
|
|
201
|
+
if (figures.length)
|
|
202
|
+
mkdirSync(join(tmp, "figures"));
|
|
203
|
+
const failed = [];
|
|
204
|
+
for (const f of figures) {
|
|
205
|
+
if (!FIGURE_NAME.test(f.name))
|
|
206
|
+
continue;
|
|
207
|
+
let bytes;
|
|
208
|
+
try {
|
|
209
|
+
bytes = await f.make();
|
|
210
|
+
}
|
|
211
|
+
catch (e) {
|
|
212
|
+
failed.push(`${f.name} (${e instanceof Error ? e.message : String(e)})`);
|
|
213
|
+
bytes = BLANK_PNG;
|
|
214
|
+
}
|
|
215
|
+
writeFileSync(join(tmp, "figures", f.name), bytes);
|
|
216
|
+
}
|
|
217
|
+
if (failed.length)
|
|
218
|
+
job.notes = [...(job.notes ?? []), `${failed.length === 1 ? "A figure" : `${failed.length} figures`} could not be drawn and ${failed.length === 1 ? "is" : "are"} left blank: ${failed.join("; ")}.`];
|
|
219
|
+
let log = "";
|
|
220
|
+
let runs = 0;
|
|
221
|
+
for (;;) {
|
|
222
|
+
runs++;
|
|
223
|
+
const left = this.timeoutMs - (Date.now() - start);
|
|
224
|
+
const r = await this.xetex(tmp, left);
|
|
225
|
+
log = readLog(tmp);
|
|
226
|
+
if (r === "timeout")
|
|
227
|
+
throw Object.assign(new Error(`Typesetting took longer than ${Math.round(this.timeoutMs / 1000)} s and was stopped.`), { log });
|
|
228
|
+
if (r !== 0 || !existsSync(join(tmp, "doc.pdf")))
|
|
229
|
+
throw Object.assign(new Error(firstError(log) ?? `XeLaTeX failed (exit ${r}).`), { log });
|
|
230
|
+
if (runs >= MAX_RUNS || !rerun(log))
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
const at = join(this.cacheDir, job.id);
|
|
234
|
+
mkdirSync(at, { recursive: true });
|
|
235
|
+
for (const f of ["doc.pdf", "doc.tex", "visualproof.sty"])
|
|
236
|
+
writeFileSync(join(at, f), readFileSync(join(tmp, f)));
|
|
237
|
+
if (figures.length)
|
|
238
|
+
cpSync(join(tmp, "figures"), join(at, "figures"), { recursive: true });
|
|
239
|
+
job.state = "done";
|
|
240
|
+
job.pages = pagesOf(log) ?? undefined;
|
|
241
|
+
job.runs = runs;
|
|
242
|
+
job.ms = Date.now() - start;
|
|
243
|
+
writeFileSync(join(at, "job.json"), JSON.stringify({ at: job.at, pages: job.pages, ms: job.ms, runs, ...(job.notes ? { notes: job.notes } : {}) }));
|
|
244
|
+
this.prune();
|
|
245
|
+
}
|
|
246
|
+
catch (e) {
|
|
247
|
+
job.state = "failed";
|
|
248
|
+
job.error = e instanceof Error ? e.message : String(e);
|
|
249
|
+
const log = e.log;
|
|
250
|
+
if (log)
|
|
251
|
+
job.log = log.slice(-LOG_TAIL);
|
|
252
|
+
job.ms = Date.now() - start;
|
|
253
|
+
}
|
|
254
|
+
finally {
|
|
255
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
/** One run of XeLaTeX on doc.tex in `cwd`: its exit code, or "timeout" when it was killed. */
|
|
259
|
+
xetex(cwd, timeoutMs) {
|
|
260
|
+
return new Promise((ok) => {
|
|
261
|
+
if (timeoutMs <= 0)
|
|
262
|
+
return ok("timeout");
|
|
263
|
+
const p = spawn(this.xelatex, ["-no-shell-escape", "-halt-on-error", "-interaction=nonstopmode", "doc.tex"], {
|
|
264
|
+
cwd,
|
|
265
|
+
// TeX's paranoid file access; and a log whose lines are not wrapped, so that it reads back
|
|
266
|
+
env: { ...process.env, openin_any: "p", openout_any: "p", shell_escape: "f", max_print_line: "10000" },
|
|
267
|
+
stdio: ["ignore", "ignore", "ignore"],
|
|
268
|
+
});
|
|
269
|
+
let killed = false;
|
|
270
|
+
const timer = setTimeout(() => {
|
|
271
|
+
killed = true;
|
|
272
|
+
p.kill("SIGKILL");
|
|
273
|
+
}, timeoutMs);
|
|
274
|
+
p.on("error", () => {
|
|
275
|
+
clearTimeout(timer);
|
|
276
|
+
ok(127);
|
|
277
|
+
});
|
|
278
|
+
p.on("close", (code) => {
|
|
279
|
+
clearTimeout(timer);
|
|
280
|
+
ok(killed ? "timeout" : (code ?? 1));
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
/** Keep the newest `maxCached` documents of the cache; the rest go. */
|
|
285
|
+
prune() {
|
|
286
|
+
let entries;
|
|
287
|
+
try {
|
|
288
|
+
entries = readdirSync(this.cacheDir).map((d) => ({ dir: join(this.cacheDir, d), t: statSync(join(this.cacheDir, d)).mtimeMs }));
|
|
289
|
+
}
|
|
290
|
+
catch {
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
entries.sort((a, b) => b.t - a.t);
|
|
294
|
+
for (const e of entries.slice(this.maxCached))
|
|
295
|
+
rmSync(e.dir, { recursive: true, force: true });
|
|
296
|
+
}
|
|
297
|
+
/** The zip of a cached document's sources: what anyone needs to typeset it again. */
|
|
298
|
+
sources(at) {
|
|
299
|
+
const files = [
|
|
300
|
+
{ name: "doc.tex", data: readFileSync(join(at, "doc.tex")) },
|
|
301
|
+
{ name: "visualproof.sty", data: readFileSync(join(at, "visualproof.sty")) },
|
|
302
|
+
{ name: "README.txt", data: Buffer.from(README, "utf8") },
|
|
303
|
+
];
|
|
304
|
+
const figures = join(at, "figures");
|
|
305
|
+
if (existsSync(figures))
|
|
306
|
+
for (const f of readdirSync(figures).sort())
|
|
307
|
+
files.push({ name: `figures/${f}`, data: readFileSync(join(figures, f)) });
|
|
308
|
+
for (const f of readdirSync(FONTS_DIR).sort())
|
|
309
|
+
files.push({ name: `fonts/${f}`, data: readFileSync(join(FONTS_DIR, f)) });
|
|
310
|
+
return zip(files);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
const README = `This is a document VisualProof exported, as LaTeX.
|
|
314
|
+
|
|
315
|
+
Typeset it with XeLaTeX, twice (the second run resolves page references and bookmarks):
|
|
316
|
+
|
|
317
|
+
xelatex doc.tex
|
|
318
|
+
xelatex doc.tex
|
|
319
|
+
|
|
320
|
+
doc.tex holds the content only, as semantic macros (\\vpPart, \\begin{vpDecl}, \\vpFullName, \\begin{vpCode}, ...).
|
|
321
|
+
How it looks is visualproof.sty's: change it, replace it, or load a package of your own after it
|
|
322
|
+
and \\renewcommand what you want different. Its head lists every macro a document uses.
|
|
323
|
+
|
|
324
|
+
fonts/ holds JuliaMono (SIL Open Font License, fonts/LICENSE-JuliaMono.txt), the code's font, which
|
|
325
|
+
visualproof.sty loads from there. figures/, where there is one, holds the images and the clips of
|
|
326
|
+
papers the document's notes carry. The text is set in Libertinus (TeX Live), or Latin Modern where
|
|
327
|
+
Libertinus is not installed.
|
|
328
|
+
`;
|
|
329
|
+
/** XeLaTeX's log, as far as there is one. */
|
|
330
|
+
function readLog(dir) {
|
|
331
|
+
try {
|
|
332
|
+
return readFileSync(join(dir, "doc.log"), "latin1");
|
|
333
|
+
}
|
|
334
|
+
catch {
|
|
335
|
+
return "";
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
/** Whether the log asks for another run: references or labels not yet resolved. */
|
|
339
|
+
export function rerun(log) {
|
|
340
|
+
return /Rerun to get|Label\(s\) may have changed|There were undefined references|Rerun LaTeX|rerunfilecheck Warning: File .* has changed/.test(log);
|
|
341
|
+
}
|
|
342
|
+
/** How many pages the log says were written. */
|
|
343
|
+
export function pagesOf(log) {
|
|
344
|
+
const m = /Output written on .*?\((\d+) pages?/.exec(log.replace(/\n/g, ""));
|
|
345
|
+
return m ? Number(m[1]) : null;
|
|
346
|
+
}
|
|
347
|
+
/** The first error in a log, in a line: TeX's `! …`. */
|
|
348
|
+
function firstError(log) {
|
|
349
|
+
const m = /^! (.*)$/m.exec(log);
|
|
350
|
+
return m ? `XeLaTeX: ${m[1].slice(0, 300)}` : null;
|
|
351
|
+
}
|
|
352
|
+
// ---------------------------------------------------------------- a zip, without a dependency
|
|
353
|
+
const CRC_TABLE = (() => {
|
|
354
|
+
const t = new Uint32Array(256);
|
|
355
|
+
for (let n = 0; n < 256; n++) {
|
|
356
|
+
let c = n;
|
|
357
|
+
for (let k = 0; k < 8; k++)
|
|
358
|
+
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
359
|
+
t[n] = c >>> 0;
|
|
360
|
+
}
|
|
361
|
+
return t;
|
|
362
|
+
})();
|
|
363
|
+
/** CRC-32, as zip files check their entries with. */
|
|
364
|
+
export function crc32(b) {
|
|
365
|
+
let c = 0xffffffff;
|
|
366
|
+
for (let i = 0; i < b.length; i++)
|
|
367
|
+
c = CRC_TABLE[(c ^ b[i]) & 0xff] ^ (c >>> 8);
|
|
368
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
369
|
+
}
|
|
370
|
+
/** A zip archive of `files`, each deflated, its name in UTF-8; dated now, as DOS times go. */
|
|
371
|
+
export function zip(files) {
|
|
372
|
+
const d = new Date();
|
|
373
|
+
const time = (d.getHours() << 11) | (d.getMinutes() << 5) | (d.getSeconds() >> 1);
|
|
374
|
+
const date = ((d.getFullYear() - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate();
|
|
375
|
+
const locals = [];
|
|
376
|
+
const centrals = [];
|
|
377
|
+
let offset = 0;
|
|
378
|
+
for (const f of files) {
|
|
379
|
+
const name = Buffer.from(f.name, "utf8");
|
|
380
|
+
const packed = deflateRawSync(f.data, { level: 9 });
|
|
381
|
+
const crc = crc32(f.data);
|
|
382
|
+
const local = Buffer.alloc(30);
|
|
383
|
+
local.writeUInt32LE(0x04034b50, 0);
|
|
384
|
+
local.writeUInt16LE(20, 4);
|
|
385
|
+
local.writeUInt16LE(0x0800, 6); // the name is UTF-8
|
|
386
|
+
local.writeUInt16LE(8, 8); // deflate
|
|
387
|
+
local.writeUInt16LE(time, 10);
|
|
388
|
+
local.writeUInt16LE(date, 12);
|
|
389
|
+
local.writeUInt32LE(crc, 14);
|
|
390
|
+
local.writeUInt32LE(packed.length, 18);
|
|
391
|
+
local.writeUInt32LE(f.data.length, 22);
|
|
392
|
+
local.writeUInt16LE(name.length, 26);
|
|
393
|
+
local.writeUInt16LE(0, 28);
|
|
394
|
+
const central = Buffer.alloc(46);
|
|
395
|
+
central.writeUInt32LE(0x02014b50, 0);
|
|
396
|
+
central.writeUInt16LE(20, 4);
|
|
397
|
+
central.writeUInt16LE(20, 6);
|
|
398
|
+
central.writeUInt16LE(0x0800, 8);
|
|
399
|
+
central.writeUInt16LE(8, 10);
|
|
400
|
+
central.writeUInt16LE(time, 12);
|
|
401
|
+
central.writeUInt16LE(date, 14);
|
|
402
|
+
central.writeUInt32LE(crc, 16);
|
|
403
|
+
central.writeUInt32LE(packed.length, 20);
|
|
404
|
+
central.writeUInt32LE(f.data.length, 24);
|
|
405
|
+
central.writeUInt16LE(name.length, 28);
|
|
406
|
+
// no extra field, no comment, disk 0, no attributes
|
|
407
|
+
central.writeUInt32LE(offset, 42);
|
|
408
|
+
locals.push(local, name, packed);
|
|
409
|
+
centrals.push(central, name);
|
|
410
|
+
offset += local.length + name.length + packed.length;
|
|
411
|
+
}
|
|
412
|
+
const dir = Buffer.concat(centrals);
|
|
413
|
+
const end = Buffer.alloc(22);
|
|
414
|
+
end.writeUInt32LE(0x06054b50, 0);
|
|
415
|
+
end.writeUInt16LE(files.length, 8);
|
|
416
|
+
end.writeUInt16LE(files.length, 10);
|
|
417
|
+
end.writeUInt32LE(dir.length, 12);
|
|
418
|
+
end.writeUInt32LE(offset, 16);
|
|
419
|
+
return Buffer.concat([...locals, dir, end]);
|
|
420
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// Run the Lean extractor against a project, or reuse its last result when nothing it reads has
|
|
2
|
+
// changed.
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { dirname, join, relative } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
export const EXTRACTOR = join(dirname(fileURLToPath(import.meta.url)), "..", "extractor", "Extract.lean");
|
|
9
|
+
/** The usage pass's worker (server/usage.ts). */
|
|
10
|
+
export const USAGE = join(dirname(fileURLToPath(import.meta.url)), "..", "extractor", "Usage.lean");
|
|
11
|
+
/** Mixed into the fingerprint. Bumped when extractions filed before must not be reused, even under
|
|
12
|
+
* the current Extract.lean's fingerprint: a server started before an update of the extractor runs
|
|
13
|
+
* the binary it built at start, and files that binary's output under the new source's
|
|
14
|
+
* fingerprint. 2: axiom sets reach through theorems' proofs. 3: a declaration Lean generated
|
|
15
|
+
* from a command (`gen`) names the declaration that command writes. 4: units — a declaration
|
|
16
|
+
* Lean generated from a command folds into the one that command writes (`also`), with its
|
|
17
|
+
* references, axioms and hash; instances of classes whose instances are all equal are left out
|
|
18
|
+
* of TCBs as proofs are (TCBSpec.md, decisions 1, 3 and 5). 5: meta-level declarations are
|
|
19
|
+
* units, and syntax extension commands units of kind `notation` (decision 2). 6: grammar leaves
|
|
20
|
+
* the TCB (the grammar site), notation units are named after what they declare or interpret. 7:
|
|
21
|
+
* every unit has a name of its own — a declaration two modules list is taken once, local notation
|
|
22
|
+
* is qualified with its module's last part, and a name still shared is numbered. 8: each unit's
|
|
23
|
+
* shape and references, which versions are made from (server/versions.ts). */
|
|
24
|
+
const EXTRACTION_VERSION = 8;
|
|
25
|
+
/** A fingerprint of everything the extraction depends on: the extractor itself, the toolchain,
|
|
26
|
+
* the roots, and every .olean of the project (path, size, mtime). Dependencies are pinned by
|
|
27
|
+
* lake-manifest.json, so it stands in for their builds. */
|
|
28
|
+
export function fingerprint(p) {
|
|
29
|
+
const h = createHash("sha256");
|
|
30
|
+
h.update(`extraction ${EXTRACTION_VERSION}\0`);
|
|
31
|
+
h.update(readFileSync(EXTRACTOR));
|
|
32
|
+
h.update(p.toolchain + "\0" + p.roots.join(",") + "\0");
|
|
33
|
+
const manifest = join(p.root, "lake-manifest.json");
|
|
34
|
+
if (existsSync(manifest))
|
|
35
|
+
h.update(readFileSync(manifest));
|
|
36
|
+
const walk = (dir) => {
|
|
37
|
+
const ents = readdirSync(dir, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : 1));
|
|
38
|
+
for (const ent of ents) {
|
|
39
|
+
const f = join(dir, ent.name);
|
|
40
|
+
if (ent.isDirectory())
|
|
41
|
+
walk(f);
|
|
42
|
+
else if (ent.name.endsWith(".olean")) {
|
|
43
|
+
const st = statSync(f);
|
|
44
|
+
h.update(`${relative(p.libDir, f)}\0${st.size}\0${st.mtimeMs}\n`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
walk(p.libDir);
|
|
49
|
+
return h.digest("hex").slice(0, 16);
|
|
50
|
+
}
|
|
51
|
+
export const EXTRACT_PROGRAM = { dir: "extractor", source: EXTRACTOR, exe: "vp-extract", root: "Extract" };
|
|
52
|
+
export const USAGE_PROGRAM = { dir: "usage", source: USAGE, exe: "vp-usage", root: "Usage" };
|
|
53
|
+
const built = new Map();
|
|
54
|
+
export function leanProgram(prog, toolchain, leanRoot, log) {
|
|
55
|
+
const source = readFileSync(prog.source);
|
|
56
|
+
const hash = createHash("sha256").update(source).digest("hex").slice(0, 12);
|
|
57
|
+
const key = [prog.exe, toolchain, hash, leanRoot].join("\0");
|
|
58
|
+
let p = built.get(key);
|
|
59
|
+
if (p)
|
|
60
|
+
return p;
|
|
61
|
+
p = new Promise((resolveP) => {
|
|
62
|
+
const parent = join(leanRoot, prog.dir, toolchain.replace(/[^A-Za-z0-9.]+/g, "-"));
|
|
63
|
+
const dir = join(parent, hash);
|
|
64
|
+
const bin = join(dir, ".lake", "build", "bin", prog.exe);
|
|
65
|
+
if (existsSync(bin))
|
|
66
|
+
return resolveP(bin);
|
|
67
|
+
mkdirSync(parent, { recursive: true });
|
|
68
|
+
const work = mkdtempSync(join(parent, `${hash}.tmp-`));
|
|
69
|
+
writeFileSync(join(work, `${prog.root}.lean`), source);
|
|
70
|
+
writeFileSync(join(work, "lean-toolchain"), toolchain + "\n");
|
|
71
|
+
writeFileSync(join(work, "lakefile.toml"), `name = "${prog.exe.replace(/-/g, "")}"\n\n[[lean_exe]]\nname = "${prog.exe}"\nroot = "${prog.root}"\nsupportInterpreter = true\n`);
|
|
72
|
+
log(`building ${prog.exe} for ${toolchain}…`);
|
|
73
|
+
const child = spawn("lake", ["build", prog.exe], { cwd: work, stdio: ["ignore", "pipe", "pipe"] });
|
|
74
|
+
let tail = "";
|
|
75
|
+
child.stdout.on("data", (b) => (tail = (tail + b).slice(-2000)));
|
|
76
|
+
child.stderr.on("data", (b) => (tail = (tail + b).slice(-2000)));
|
|
77
|
+
let finished = false;
|
|
78
|
+
const done = (ok) => {
|
|
79
|
+
// a child that failed to start may report both `error` and `close`
|
|
80
|
+
if (finished)
|
|
81
|
+
return;
|
|
82
|
+
finished = true;
|
|
83
|
+
if (ok) {
|
|
84
|
+
try {
|
|
85
|
+
renameSync(work, dir);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
// another server moved its build of the same source into place first
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
else
|
|
92
|
+
log(`could not build ${prog.exe}:\n${tail}`);
|
|
93
|
+
rmSync(work, { recursive: true, force: true });
|
|
94
|
+
resolveP(existsSync(bin) ? bin : null);
|
|
95
|
+
};
|
|
96
|
+
child.on("error", () => done(false));
|
|
97
|
+
child.on("close", (code) => done(code === 0 && existsSync(join(work, ".lake", "build", "bin", prog.exe))));
|
|
98
|
+
});
|
|
99
|
+
built.set(key, p);
|
|
100
|
+
return p;
|
|
101
|
+
}
|
|
102
|
+
/** The extractor, compiled (`leanProgram`); null when it could not be, and it is interpreted. */
|
|
103
|
+
export function extractorBinary(toolchain, leanRoot, log) {
|
|
104
|
+
return leanProgram(EXTRACT_PROGRAM, toolchain, leanRoot, log);
|
|
105
|
+
}
|
|
106
|
+
/** `lake env` puts the project's own toolchain and package paths in place, so the extractor
|
|
107
|
+
* always matches the .olean format it reads. */
|
|
108
|
+
function runExtractor(p, out, opts, log) {
|
|
109
|
+
return new Promise((resolveP, reject) => {
|
|
110
|
+
const tail_ = ["--cache", opts.cache, "--salt", opts.salt];
|
|
111
|
+
const args = opts.bin
|
|
112
|
+
? ["env", opts.bin, out, p.libDir, ...p.roots, ...tail_]
|
|
113
|
+
: ["env", "lean", "--run", EXTRACTOR, out, p.libDir, ...p.roots, ...tail_];
|
|
114
|
+
const child = spawn("lake", args, { cwd: p.root, stdio: ["ignore", "pipe", "pipe"] });
|
|
115
|
+
let tail = "";
|
|
116
|
+
const onData = (buf) => {
|
|
117
|
+
const text = buf.toString();
|
|
118
|
+
tail = (tail + text).slice(-4000);
|
|
119
|
+
for (const line of text.split("\n"))
|
|
120
|
+
if (line.trim())
|
|
121
|
+
log(line);
|
|
122
|
+
};
|
|
123
|
+
child.stdout.on("data", onData);
|
|
124
|
+
child.stderr.on("data", onData);
|
|
125
|
+
child.on("error", (e) => reject(new Error(`could not start lake: ${e.message}`)));
|
|
126
|
+
child.on("close", (code) => {
|
|
127
|
+
if (code === 0)
|
|
128
|
+
return resolveP();
|
|
129
|
+
const missing = tail.match(/object file '[^']*' of module (\S+) does not exist/);
|
|
130
|
+
if (missing) {
|
|
131
|
+
return reject(new Error(`the build is incomplete: ${missing[1]} has no .olean. A \`lake build\` is running or ` +
|
|
132
|
+
"failed; VisualProof reads only what a finished build left behind. Retry when it is done."));
|
|
133
|
+
}
|
|
134
|
+
// modules a project never imports together (two versions of one file, a library of reference
|
|
135
|
+
// statements beside its proofs): Lean cannot import them into one environment
|
|
136
|
+
const clash = tail.match(/import (\S+) failed, environment already contains '([^']+)' from (\S+)/);
|
|
137
|
+
if (clash) {
|
|
138
|
+
return reject(new Error(`${clash[1]} and ${clash[3]} cannot be read together: both declare ${clash[2]}. Read fewer: ` +
|
|
139
|
+
"`--root` names the libraries or modules to read (repeatable)."));
|
|
140
|
+
}
|
|
141
|
+
reject(new Error(`extractor exited with ${code}:\n${tail}`));
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
/** Keep the newest `keep` files of a cache directory that match `pattern`; a live session
|
|
146
|
+
* writes one extraction per build and would otherwise fill the disk. */
|
|
147
|
+
export function prune(dir, pattern, keep) {
|
|
148
|
+
try {
|
|
149
|
+
const files = readdirSync(dir)
|
|
150
|
+
.filter((f) => pattern.test(f))
|
|
151
|
+
.map((f) => ({ f, t: statSync(join(dir, f)).mtimeMs }))
|
|
152
|
+
.sort((a, b) => b.t - a.t);
|
|
153
|
+
for (const { f } of files.slice(keep))
|
|
154
|
+
unlinkSync(join(dir, f));
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
// pruning is housekeeping; never a reason to fail
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
/** A saved extraction, served as it is: for a snapshot of a project that is not on this
|
|
161
|
+
* machine, or not built right now. */
|
|
162
|
+
export function loadSaved(file) {
|
|
163
|
+
const text = readFileSync(file, "utf8");
|
|
164
|
+
return {
|
|
165
|
+
graph: JSON.parse(text),
|
|
166
|
+
fingerprint: createHash("sha256").update(text).digest("hex").slice(0, 16),
|
|
167
|
+
at: statSync(file).mtime.toISOString(),
|
|
168
|
+
cached: true,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
/** An extraction the project's cache already holds, by fingerprint. */
|
|
172
|
+
export function cachedExtraction(cacheDir, fp) {
|
|
173
|
+
const file = join(cacheDir, "extract", `${fp}.json`);
|
|
174
|
+
if (!existsSync(file))
|
|
175
|
+
return null;
|
|
176
|
+
return { graph: JSON.parse(readFileSync(file, "utf8")), fingerprint: fp, at: statSync(file).mtime.toISOString(), cached: true };
|
|
177
|
+
}
|
|
178
|
+
export async function extract(p, dirs, log) {
|
|
179
|
+
const fp = fingerprint(p);
|
|
180
|
+
const dir = join(dirs.cache, "extract");
|
|
181
|
+
mkdirSync(dir, { recursive: true });
|
|
182
|
+
const file = join(dir, `${fp}.json`);
|
|
183
|
+
if (existsSync(file)) {
|
|
184
|
+
return {
|
|
185
|
+
graph: JSON.parse(readFileSync(file, "utf8")),
|
|
186
|
+
fingerprint: fp,
|
|
187
|
+
at: statSync(file).mtime.toISOString(),
|
|
188
|
+
cached: true,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
const tmp = `${file}.tmp`;
|
|
192
|
+
const bin = await extractorBinary(p.toolchain, dirs.lean, log);
|
|
193
|
+
// the per-module cache is only valid for one extractor, toolchain and set of dependencies
|
|
194
|
+
const salt = createHash("sha256")
|
|
195
|
+
.update(readFileSync(EXTRACTOR))
|
|
196
|
+
.update(p.toolchain)
|
|
197
|
+
.update(existsSync(join(p.root, "lake-manifest.json")) ? readFileSync(join(p.root, "lake-manifest.json")) : "")
|
|
198
|
+
.digest("hex")
|
|
199
|
+
.slice(0, 16);
|
|
200
|
+
await runExtractor(p, tmp, { bin, cache: join(dirs.cache, "modules"), salt }, log);
|
|
201
|
+
renameSync(tmp, file);
|
|
202
|
+
prune(dir, /^[0-9a-f]{16}\.json$/, 3);
|
|
203
|
+
return { graph: JSON.parse(readFileSync(file, "utf8")), fingerprint: fp, at: new Date().toISOString(), cached: false };
|
|
204
|
+
}
|