visualproof 0.2.0 → 0.3.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/README.md +40 -26
- package/dist/assets/webfonts/LICENSE-Noto.txt +95 -0
- package/dist/assets/webfonts/metrics.json +1 -0
- package/dist/assets/webfonts/ttf/jost-400-normal.ttf +0 -0
- package/dist/assets/webfonts/ttf/jost-500-normal.ttf +0 -0
- package/dist/assets/webfonts/ttf/noto-sans-math.ttf +0 -0
- package/dist/assets/webfonts/ttf/noto-sans.ttf +0 -0
- package/dist/assets/webfonts/ttf/source-sans-3-400-italic.ttf +0 -0
- package/dist/assets/webfonts/ttf/source-sans-3-400-normal.ttf +0 -0
- package/dist/assets/webfonts/ttf/source-sans-3-500-normal.ttf +0 -0
- package/dist/assets/webfonts/ttf/source-sans-3-600-normal.ttf +0 -0
- package/dist/assets/webfonts/ttf/source-sans-3-700-normal.ttf +0 -0
- package/dist/assets/webfonts/ttf/source-serif-4-400-italic.ttf +0 -0
- package/dist/assets/webfonts/ttf/source-serif-4-600-italic.ttf +0 -0
- package/dist/server/agent.js +84 -86
- package/dist/server/agentdocs.js +1 -1
- package/dist/server/artcmd.js +335 -0
- package/dist/server/live.js +6 -0
- package/dist/server/main.js +136 -8
- package/dist/server/mcptools.js +4 -1
- package/dist/server/pipeline.js +15 -9
- package/dist/server/readonlygate.js +1 -1
- package/dist/server/review.js +4 -1
- package/dist/server/reviewapi.js +35 -1
- package/dist/server/snapshot.js +10 -1
- package/dist/server/view.js +0 -7
- package/dist/server/views.js +24 -0
- package/dist/shared/artfonts.js +81 -0
- package/dist/shared/artrecipe.js +316 -0
- package/dist/shared/collab.js +7 -2
- package/dist/shared/declfind.js +95 -0
- package/dist/shared/lostfound.js +1 -0
- package/dist/shared/pastgraph.js +6 -6
- package/dist/shared/review.js +62 -5
- package/dist/web/bundle/app.js +713 -164
- package/dist/web/bundle/edges.js +1 -1
- package/dist/web/src/agentmark.js +14 -0
- package/dist/web/src/artwork.js +1000 -0
- package/dist/web/src/bundles.js +151 -0
- package/dist/web/src/canvas.js +3694 -0
- package/dist/web/src/drag.js +61 -0
- package/dist/web/src/edgequeue.js +204 -0
- package/dist/web/src/gl/gl.js +123 -0
- package/dist/web/src/gl/renderer.js +451 -0
- package/dist/web/src/halo.js +95 -0
- package/dist/web/src/highlight.js +108 -0
- package/dist/web/src/overview.js +393 -0
- package/dist/web/src/reviewqueue.js +250 -0
- package/dist/web/src/selection.js +272 -0
- package/dist/web/src/status.js +80 -0
- package/dist/web/src/visitors.js +318 -0
- package/dist/web/src/visits.js +174 -0
- package/dist/web/style.css +7 -0
- package/docs/agent-api/README.md +1 -1
- package/docs/agent-api/export.md +41 -2
- package/docs/agent-api/history.md +2 -1
- package/docs/agent-api/mcp.md +1 -1
- package/docs/agent-api/reading.md +2 -1
- package/package.json +3 -1
package/dist/server/main.js
CHANGED
|
@@ -2,12 +2,15 @@
|
|
|
2
2
|
// visualproof serve <path> — read a built Lean project and serve its declaration graph
|
|
3
3
|
// visualproof export <path> — the same page, frozen into one HTML file
|
|
4
4
|
import { historyAnswer } from "./historyapi.js";
|
|
5
|
+
import { gitSource } from "./history.js";
|
|
5
6
|
import { createHash } from "node:crypto";
|
|
6
|
-
import { existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
7
8
|
import { createServer } from "node:http";
|
|
8
|
-
import { extname, join, resolve } from "node:path";
|
|
9
|
+
import { basename, dirname, extname, join, resolve } from "node:path";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
9
11
|
import { gunzipSync, gzipSync } from "node:zlib";
|
|
10
12
|
import { WEB, WEBFONTS, bundleDir, bundleDirIn, ensureBundle, prepare, useBundleDir } from "./pipeline.js";
|
|
13
|
+
import { makeArtwork, pngOf, projectView } from "./artcmd.js";
|
|
11
14
|
import { SiteBusy, cacheHome, dataHome, lockSite, openSite, projectDirs } from "./dirs.js";
|
|
12
15
|
import { ProjectError, openProject, targetsInWords } from "./project.js";
|
|
13
16
|
import { Live } from "./live.js";
|
|
@@ -15,7 +18,7 @@ import { ReviewStore } from "./review.js";
|
|
|
15
18
|
import { isDeclName, LeanHover } from "./hover.js";
|
|
16
19
|
import { Identities, layoutAuthor } from "./identity.js";
|
|
17
20
|
import { LAYOUT_VERSION } from "../shared/graph.js";
|
|
18
|
-
import { MAX_MEMORY_BYTES, Views } from "./views.js";
|
|
21
|
+
import { MAX_MEMORY_BYTES, readProjectView, Views } from "./views.js";
|
|
19
22
|
import { cleanView, defaultAgentName, validSession, withoutArtwork } from "../shared/collab.js";
|
|
20
23
|
import { writeSnapshot } from "./snapshot.js";
|
|
21
24
|
import { lakeTextHash } from "./status.js";
|
|
@@ -48,6 +51,8 @@ function usage(code = 2) {
|
|
|
48
51
|
visualproof purge-file <lean-project> <sha256> [--site dir] [--cache dir]
|
|
49
52
|
visualproof export <lean-project> -o page.html [--label text] [--root Name]... [--graph saved.json]
|
|
50
53
|
[--jobs ${DEFAULT_JOBS}] [--site dir] [--cache dir]
|
|
54
|
+
visualproof artwork <lean-project> --recipe recipe.json [-o name] [--png] [--svg] [--root Name]...
|
|
55
|
+
[--site dir] [--cache dir] [--jobs ${DEFAULT_JOBS}] [--screen 1920x1200] [--graph saved.json]
|
|
51
56
|
visualproof doctor [<lean-project>] [--root Name]... [--site dir] [--cache dir]
|
|
52
57
|
visualproof --version
|
|
53
58
|
|
|
@@ -72,6 +77,17 @@ function usage(code = 2) {
|
|
|
72
77
|
the build; the project path still supplies the sources
|
|
73
78
|
export writes one self-contained HTML file: the page, its data and the sources, frozen
|
|
74
79
|
|
|
80
|
+
artwork makes the picture an artwork recipe says (the agents' API's, docs/agent-api/export.md:
|
|
81
|
+
the size, the look, names by a rule, the targets, the selection), as the page's
|
|
82
|
+
artwork mode makes it, without a browser: an SVG and a PNG (--png or --svg for one),
|
|
83
|
+
named by -o (a name, or a path without its extension), else the recipe's title, else
|
|
84
|
+
the project. With --site, the board as that site keeps it (its layout, pins and folds)
|
|
85
|
+
and its review's targets; without, the board as a new site would lay it out, and the
|
|
86
|
+
targets the recipe names. --screen: the screen the board is drawn on, as a reader's
|
|
87
|
+
(default 1920x1200, at least 640x480): how large names are in the picture. It reads
|
|
88
|
+
the site, never writes it; it writes the files and the cache's extraction. Anything
|
|
89
|
+
that fails ends it with status 1. Notes are not drawn yet.
|
|
90
|
+
|
|
75
91
|
--identity-header behind a proxy that authenticates (Tailscale, oauth2-proxy, …): take each
|
|
76
92
|
reader's name from this header instead of the name the page gives. Only use it when
|
|
77
93
|
the proxy is the only way to reach the server: the header is believed as it comes.
|
|
@@ -131,9 +147,9 @@ function parseArgs(argv) {
|
|
|
131
147
|
console.log(packageVersion());
|
|
132
148
|
process.exit(0);
|
|
133
149
|
}
|
|
134
|
-
if (cmd !== "serve" && cmd !== "export" && cmd !== "purge-file" && cmd !== "doctor")
|
|
150
|
+
if (cmd !== "serve" && cmd !== "export" && cmd !== "purge-file" && cmd !== "doctor" && cmd !== "artwork")
|
|
135
151
|
usage();
|
|
136
|
-
const o = { cmd, path: "", roots: [], port: 4173, host: "127.0.0.1", site: null, cache: null, graph: null, out: null, label: "VisualProof", identityHeader: null, editor: null, agents: "full", accessKeyFile: null, jobs: DEFAULT_JOBS, publicUrl: null, libraryLimit: DEFAULT_LIBRARY_LIMIT, sha: null, publicRead: false };
|
|
152
|
+
const o = { cmd, path: "", roots: [], port: 4173, host: "127.0.0.1", site: null, cache: null, graph: null, out: null, label: "VisualProof", identityHeader: null, editor: null, agents: "full", accessKeyFile: null, jobs: DEFAULT_JOBS, publicUrl: null, libraryLimit: DEFAULT_LIBRARY_LIMIT, sha: null, publicRead: false, recipe: null, png: false, svg: false, screen: { w: 1920, h: 1200 } };
|
|
137
153
|
for (let i = 0; i < rest.length; i++) {
|
|
138
154
|
const a = rest[i];
|
|
139
155
|
const val = () => rest[++i] ?? usage();
|
|
@@ -161,6 +177,18 @@ function parseArgs(argv) {
|
|
|
161
177
|
o.accessKeyFile = resolve(val());
|
|
162
178
|
else if (a === "--public")
|
|
163
179
|
o.publicRead = true;
|
|
180
|
+
else if (a === "--recipe" && cmd === "artwork")
|
|
181
|
+
o.recipe = resolve(val());
|
|
182
|
+
else if (a === "--png" && cmd === "artwork")
|
|
183
|
+
o.png = true;
|
|
184
|
+
else if (a === "--svg" && cmd === "artwork")
|
|
185
|
+
o.svg = true;
|
|
186
|
+
else if (a === "--screen" && cmd === "artwork") {
|
|
187
|
+
const m = /^(\d+)x(\d+)$/.exec(val());
|
|
188
|
+
if (!m || +m[1] < 640 || +m[2] < 480 || +m[1] > 10000 || +m[2] > 10000)
|
|
189
|
+
usage();
|
|
190
|
+
o.screen = { w: +m[1], h: +m[2] };
|
|
191
|
+
}
|
|
164
192
|
else if (a === "--public-url") {
|
|
165
193
|
const u = val();
|
|
166
194
|
if (!/^https?:\/\/[^\s/?#]+\/?$/.test(u)) {
|
|
@@ -196,7 +224,7 @@ function parseArgs(argv) {
|
|
|
196
224
|
else
|
|
197
225
|
usage();
|
|
198
226
|
}
|
|
199
|
-
if ((!o.path && cmd !== "doctor") || (cmd === "export" && !o.out))
|
|
227
|
+
if ((!o.path && cmd !== "doctor") || (cmd === "export" && !o.out) || (cmd === "artwork" && !o.recipe))
|
|
200
228
|
usage();
|
|
201
229
|
// read-only for whoever comes, beside those the key or a link lets in: without a key, everyone is in
|
|
202
230
|
// already; behind a sign-in, there is nobody else
|
|
@@ -221,6 +249,11 @@ export class App {
|
|
|
221
249
|
live;
|
|
222
250
|
hover;
|
|
223
251
|
ids;
|
|
252
|
+
/** how long a read of the project's git (GET /api/git) is kept, whoever asks: git is run at most
|
|
253
|
+
* once in that time, so what readers cost the server is bounded (CollabSpec decision 11); 0 in
|
|
254
|
+
* the tests */
|
|
255
|
+
gitFreshMs = 3000;
|
|
256
|
+
gitRead = null;
|
|
224
257
|
/** the agents' API (server/agent.ts), and what agents may change */
|
|
225
258
|
agents;
|
|
226
259
|
/** who may come in at all (server/access.ts); null: anyone who reaches the port */
|
|
@@ -976,6 +1009,13 @@ export class App {
|
|
|
976
1009
|
const a = this.agents.undo(me, agent, r.body?.id);
|
|
977
1010
|
return sendJson(res, a.body, a.status);
|
|
978
1011
|
}
|
|
1012
|
+
// the project's git source and commit as git says them now, not as the build was read (an
|
|
1013
|
+
// artwork's foot, ExportSpec decision 19; the owner's decision of 2026-10-15)
|
|
1014
|
+
if (path === "/api/git") {
|
|
1015
|
+
if (!this.gitRead || Date.now() - this.gitRead.at >= this.gitFreshMs)
|
|
1016
|
+
this.gitRead = { git: gitSource(live.project.root), at: Date.now() };
|
|
1017
|
+
return sendJson(res, { git: this.gitRead.git });
|
|
1018
|
+
}
|
|
979
1019
|
if (path === "/api/meta" || path === "/api/edges") {
|
|
980
1020
|
const g = live.graph;
|
|
981
1021
|
if (!g)
|
|
@@ -1294,8 +1334,10 @@ async function main() {
|
|
|
1294
1334
|
console.log(printReport(report, o.path || null));
|
|
1295
1335
|
process.exit(report.checks.some((c) => c.level === "fail") ? 1 : 0);
|
|
1296
1336
|
}
|
|
1297
|
-
// a live server outlives any one failed task: log it and keep serving
|
|
1298
|
-
|
|
1337
|
+
// a live server outlives any one failed task: log it and keep serving (a picture is no server:
|
|
1338
|
+
// whatever fails ends it, with status 1)
|
|
1339
|
+
if (o.cmd !== "artwork")
|
|
1340
|
+
process.on("unhandledRejection", (e) => console.error("unhandled:", e instanceof Error ? e.message : e));
|
|
1299
1341
|
let project;
|
|
1300
1342
|
try {
|
|
1301
1343
|
project = openProject(o.path, o.roots, o.graph === null);
|
|
@@ -1307,6 +1349,9 @@ async function main() {
|
|
|
1307
1349
|
}
|
|
1308
1350
|
throw e;
|
|
1309
1351
|
}
|
|
1352
|
+
// a picture: the site read, never made, locked or written (ExportSpec decision 21)
|
|
1353
|
+
if (o.cmd === "artwork")
|
|
1354
|
+
return artworkCommand(project, o);
|
|
1310
1355
|
const dirs = projectDirs(project.root, o.site, o.cache);
|
|
1311
1356
|
openSite(dirs.site);
|
|
1312
1357
|
mkdirSync(dirs.cache, { recursive: true });
|
|
@@ -1401,6 +1446,89 @@ async function main() {
|
|
|
1401
1446
|
});
|
|
1402
1447
|
live.start();
|
|
1403
1448
|
}
|
|
1449
|
+
/** The artwork command (ExportSpec decision 21): the build read as an export reads it, the picture a
|
|
1450
|
+
* recipe says made of it (server/artcmd.ts), its files written; what it named said as the agents'
|
|
1451
|
+
* API says it; problems said, and exit status 1. */
|
|
1452
|
+
async function artworkCommand(project, o) {
|
|
1453
|
+
const fail = (why) => {
|
|
1454
|
+
console.error(`visualproof artwork: ${why}`);
|
|
1455
|
+
process.exit(1);
|
|
1456
|
+
};
|
|
1457
|
+
try {
|
|
1458
|
+
await artwork(project, o, fail);
|
|
1459
|
+
}
|
|
1460
|
+
catch (e) {
|
|
1461
|
+
fail(e instanceof Error ? e.message : String(e));
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
async function artwork(project, o, fail) {
|
|
1465
|
+
const dirs = projectDirs(project.root, o.site, o.cache);
|
|
1466
|
+
const site = o.site ? dirs.site : null;
|
|
1467
|
+
if (site && !existsSync(site))
|
|
1468
|
+
fail(`there is no site at ${site}`);
|
|
1469
|
+
let recipe;
|
|
1470
|
+
try {
|
|
1471
|
+
recipe = JSON.parse(readFileSync(o.recipe, "utf8"));
|
|
1472
|
+
}
|
|
1473
|
+
catch (e) {
|
|
1474
|
+
fail(`the recipe ${o.recipe} cannot be read: ${e instanceof Error ? e.message : String(e)}`);
|
|
1475
|
+
}
|
|
1476
|
+
// -o: a name, or a path without its extension, in a folder that is there
|
|
1477
|
+
const outBase = o.out ? o.out.replace(/\.(png|svg)$/i, "") : null;
|
|
1478
|
+
const dir = outBase ? dirname(outBase) : process.cwd();
|
|
1479
|
+
if (!existsSync(dir))
|
|
1480
|
+
fail(`there is no folder ${dir} to write the files in`);
|
|
1481
|
+
mkdirSync(dirs.cache, { recursive: true });
|
|
1482
|
+
const log = (l) => console.log(l);
|
|
1483
|
+
let ex = o.graph ? undefined : await extract(project, dirs, log);
|
|
1484
|
+
if (ex && o.jobs > 0) {
|
|
1485
|
+
const pass = new UsagePass(project, dirs, o.jobs, log);
|
|
1486
|
+
const plan = pass.plan(ex.graph);
|
|
1487
|
+
if (plan.todo.length)
|
|
1488
|
+
log(`usage pass: elaborating ${plan.todo.length} of ${plan.modules.length} modules, ${o.jobs} at a time`);
|
|
1489
|
+
const results = await pass.run(plan);
|
|
1490
|
+
if (results) {
|
|
1491
|
+
ex = mergeUsage(ex, plan, results, pass.status.failed, pass.status.ms ?? 0);
|
|
1492
|
+
saveMerged(dirs.cache, ex);
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
// the site's layout memory read and never written, nor the views laid out written to the cache;
|
|
1496
|
+
// without a site, none read: the board as a new site lays it out
|
|
1497
|
+
const prep = await prepare(project, dirs, o.graph, log, { ...(ex ? { extraction: ex } : {}), readOnlySite: true, ...(site ? {} : { freshLayout: true }) });
|
|
1498
|
+
const kept = site ? readProjectView(site, LAYOUT_VERSION) : { view: null };
|
|
1499
|
+
if (kept.why)
|
|
1500
|
+
console.error(`visualproof artwork: the site's views cannot be read (${kept.why}): the Project view as a new site has it`);
|
|
1501
|
+
const review = site ? new ReviewStore(project.root, site).data : null;
|
|
1502
|
+
const a = makeArtwork(prep, review, projectView(kept.view), { recipe, screen: o.screen, name: outBase ? basename(outBase) : null, root: project.root });
|
|
1503
|
+
if (!("svg" in a)) {
|
|
1504
|
+
for (const p of a.problems)
|
|
1505
|
+
console.error(`visualproof artwork: ${p}`);
|
|
1506
|
+
process.exit(1);
|
|
1507
|
+
}
|
|
1508
|
+
const list = (xs) => (xs.length ? xs.join(", ") : "none");
|
|
1509
|
+
console.log(`names written: ${list(a.names.written)}`);
|
|
1510
|
+
if (a.names.ruledSkipped.length)
|
|
1511
|
+
console.log(`names a rule chose, left out where they would cover another: ${list(a.names.ruledSkipped)}`);
|
|
1512
|
+
if (a.names.left)
|
|
1513
|
+
console.log(`names or targets left out for room: ${a.names.left}`);
|
|
1514
|
+
console.log(`targets marked: ${list(a.targets)}`);
|
|
1515
|
+
console.log(`selection: ${a.selection ? `${a.selection.mode} of ${a.selection.name}` : "none"}`);
|
|
1516
|
+
if (a.notesLeftOut)
|
|
1517
|
+
console.log(`notes: ${a.notesLeftOut} on the board, not drawn (the command draws no notes yet)`);
|
|
1518
|
+
const both = !o.png && !o.svg;
|
|
1519
|
+
const mb = (n) => `${(n / 1e6).toFixed(1)} MB`;
|
|
1520
|
+
if (both || o.svg) {
|
|
1521
|
+
const file = join(dir, a.file("svg"));
|
|
1522
|
+
writeFileSync(file, a.svg);
|
|
1523
|
+
console.log(`wrote ${file} (${mb(Buffer.byteLength(a.svg))})`);
|
|
1524
|
+
}
|
|
1525
|
+
if (both || o.png) {
|
|
1526
|
+
const png = await pngOf(a.svg);
|
|
1527
|
+
const file = join(dir, a.file("png"));
|
|
1528
|
+
writeFileSync(file, png);
|
|
1529
|
+
console.log(`wrote ${file} (${mb(png.length)}, ${a.look.w} × ${a.look.h})`);
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1404
1532
|
// run as the command (`node src/server/main.ts`, the `visualproof` bin), not when a test imports App
|
|
1405
1533
|
if (import.meta.main)
|
|
1406
1534
|
void main();
|
package/dist/server/mcptools.js
CHANGED
|
@@ -373,12 +373,13 @@ export const TOOLS = [
|
|
|
373
373
|
},
|
|
374
374
|
{
|
|
375
375
|
name: "export",
|
|
376
|
-
about: "A view's document — every declaration of the project its board holds, in its sections and order: its model as JSON, or typeset as a PDF and saved on your machine.",
|
|
376
|
+
about: "A view's document — every declaration of the project its board holds, in its sections and order: its model as JSON, or typeset as a PDF and saved on your machine. Or a picture of the board: a link that opens it in the artwork mode as a recipe says.",
|
|
377
377
|
actions: {
|
|
378
378
|
model: { method: "GET", path: "/api/agent/export", says: "the document's model", args: ["view", "settings", "version"] },
|
|
379
379
|
typeset: { method: "POST", path: "/api/agent/export", says: "start typesetting: a job", args: ["view", "settings", "version"] },
|
|
380
380
|
job: { method: "GET", path: "/api/agent/export/job", says: "how the job is doing", args: ["id"] },
|
|
381
381
|
file: { method: "GET", path: "/api/agent/export/file", saves: true, says: "a file of it saved on your machine", args: ["id", "what", "saveTo"] },
|
|
382
|
+
artwork: { method: "POST", path: "/api/agent/artwork", says: "a link to a picture of the board, as a recipe says", args: ["recipe", "save"] },
|
|
382
383
|
},
|
|
383
384
|
args: {
|
|
384
385
|
view: str("the view's id: a target's Key view is open on key(<target>)"),
|
|
@@ -387,6 +388,8 @@ export const TOOLS = [
|
|
|
387
388
|
id: str("the job's id"),
|
|
388
389
|
what: str("pdf (default), tex, sty or zip", ["pdf", "tex", "sty", "zip"]),
|
|
389
390
|
saveTo: SAVE_TO,
|
|
391
|
+
recipe: { type: "object", description: "the picture: size, orientation, style, theme, names (e.g. {clusters: 1}), targets, selection {name, mode}, title, subtitle, edges… (docs: export)" },
|
|
392
|
+
save: str("png, svg or png,svg: saved by the page once ready"),
|
|
390
393
|
},
|
|
391
394
|
},
|
|
392
395
|
];
|
package/dist/server/pipeline.js
CHANGED
|
@@ -41,8 +41,9 @@ export function bundleDir() {
|
|
|
41
41
|
* views from an older server are rebuilt rather than served. 7: a notation unit's statement is
|
|
42
42
|
* its command's text. 8: notation units carry their Lean constant, display form, what they
|
|
43
43
|
* interpret and their rules. 9: a node's hash is its version, beside the kernel's
|
|
44
|
-
* (server/versions.ts). 10: the project's git source and commit (server/history.ts, gitSource).
|
|
45
|
-
|
|
44
|
+
* (server/versions.ts). 10: the project's git source and commit (server/history.ts, gitSource). 11:
|
|
45
|
+
* not them any more: they are asked when wanted (GET /api/git), not kept with the view. */
|
|
46
|
+
const VIEW_VERSION = 11;
|
|
46
47
|
function loadMemory(siteDir) {
|
|
47
48
|
try {
|
|
48
49
|
const mem = JSON.parse(readFileSync(join(siteDir, "layout-memory.json"), "utf8"));
|
|
@@ -63,7 +64,9 @@ export async function prepare(project, dirs, saved, log, opts = {}) {
|
|
|
63
64
|
// `reuse`: lay out an extraction already held, without reading the build (which may be
|
|
64
65
|
// half-written right now); `extraction`: one in hand (the usage pass's, merged); `provisional`:
|
|
65
66
|
// the graph waits for its usage pass; `completes`: the fingerprint of the build's extraction that
|
|
66
|
-
// this graph, the usage pass's merged, completes
|
|
67
|
+
// this graph, the usage pass's merged, completes; `readOnlySite`: the site's layout memory read,
|
|
68
|
+
// never written, and the view laid out not kept in the cache either, which a server would take for
|
|
69
|
+
// its site's (the artwork command, ExportSpec decision 21)
|
|
67
70
|
const held = opts.extraction ?? (opts.reuse ? cachedExtraction(dirs.cache, opts.reuse) : null);
|
|
68
71
|
if (opts.reuse && !held)
|
|
69
72
|
throw new Error("the graph on screen is no longer cached; it is laid out afresh at the next build");
|
|
@@ -72,7 +75,6 @@ export async function prepare(project, dirs, saved, log, opts = {}) {
|
|
|
72
75
|
log(ex.cached ? `extraction ${ex.fingerprint} reused` : `extraction ${ex.fingerprint} done`);
|
|
73
76
|
const usage = ex.graph.usage ?? null;
|
|
74
77
|
const viewDir = join(dirs.cache, "view");
|
|
75
|
-
mkdirSync(viewDir, { recursive: true });
|
|
76
78
|
const metaFile = join(viewDir, `${ex.fingerprint}-v${VIEW_VERSION}.meta.json.gz`);
|
|
77
79
|
const edgeFile = join(viewDir, `${ex.fingerprint}-v${VIEW_VERSION}.edges.bin.gz`);
|
|
78
80
|
const memory = opts.freshLayout ? null : loadMemory(dirs.site);
|
|
@@ -87,13 +89,17 @@ export async function prepare(project, dirs, saved, log, opts = {}) {
|
|
|
87
89
|
const completing = memory !== null && opts.completes !== undefined && memory.fingerprint === opts.completes && memory.before !== undefined;
|
|
88
90
|
const against = completing ? (memory.before ?? null) : (memory?.layouts ?? null);
|
|
89
91
|
const view = await buildViewOffThread(project, ex, log, against);
|
|
90
|
-
|
|
92
|
+
if (!opts.readOnlySite)
|
|
93
|
+
saveMemory(dirs.site, ex.fingerprint, view.meta, opts.provisional ? against : undefined);
|
|
91
94
|
const metaGz = gzipSync(JSON.stringify(view.meta));
|
|
92
95
|
const edgesGz = gzipSync(Buffer.from(view.edges.buffer, view.edges.byteOffset, view.edges.byteLength));
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
96
|
+
if (!opts.readOnlySite) {
|
|
97
|
+
mkdirSync(viewDir, { recursive: true });
|
|
98
|
+
writeFileSync(metaFile, metaGz);
|
|
99
|
+
writeFileSync(edgeFile, edgesGz);
|
|
100
|
+
prune(viewDir, /\.meta\.json\.gz$/, 3);
|
|
101
|
+
prune(viewDir, /\.edges\.bin\.gz$/, 3);
|
|
102
|
+
}
|
|
97
103
|
return { fingerprint: ex.fingerprint, cached: ex.cached, metaGz, edgesGz, usage };
|
|
98
104
|
}
|
|
99
105
|
function readdirRec(dir) {
|
|
@@ -12,7 +12,7 @@ export function readOnlyAllows(method, path) {
|
|
|
12
12
|
// the graph, its sources and Lean's answers, the review, what happened, the views, the history,
|
|
13
13
|
// the library, the printed document's state and files, who the reader is and the link they
|
|
14
14
|
// came by, and the event stream every change comes by
|
|
15
|
-
if (["/api/status", "/api/meta", "/api/edges", "/api/source", "/api/review", "/api/activity", "/api/identity", "/api/share"].includes(path))
|
|
15
|
+
if (["/api/status", "/api/meta", "/api/edges", "/api/git", "/api/source", "/api/review", "/api/activity", "/api/identity", "/api/share"].includes(path))
|
|
16
16
|
return true;
|
|
17
17
|
// a stream is opened by GET alone: a HEAD would take one of their places for nothing
|
|
18
18
|
if (path === "/api/events")
|
package/dist/server/review.js
CHANGED
|
@@ -263,7 +263,10 @@ export class ReviewStore {
|
|
|
263
263
|
this.refresh();
|
|
264
264
|
const file = logFileName(name, false, person);
|
|
265
265
|
const now = Object.hasOwn(this.data.people, person) ? this.data.people[person].name : null;
|
|
266
|
-
|
|
266
|
+
// one who has no log yet, but whom opinions were recorded for (ReviewSpec decision 8), is
|
|
267
|
+
// given one by the rename, so that those follow it
|
|
268
|
+
const spokenFor = Object.values(this.data.opinions).some((ops) => ops.some((o) => o.person === person && o.recordedBy));
|
|
269
|
+
if ((!this.files.has(file) && !spokenFor) || now === name.trim())
|
|
267
270
|
return { change: null, unchanged: true };
|
|
268
271
|
return this.write(file, () => nameEvent(person, name, this.nextTime()));
|
|
269
272
|
}
|
package/dist/server/reviewapi.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
// Every refusal is an answer: a status, and words that say what to do instead.
|
|
18
18
|
import { readFileSync } from "node:fs";
|
|
19
19
|
import { join } from "node:path";
|
|
20
|
+
import { MAX_NAME, sameName, validName } from "../shared/collab.js";
|
|
20
21
|
import { exactContext, judgedText } from "../shared/context.js";
|
|
21
22
|
import { MAX_TEXT, STANDARD_AXIOMS, validPrefix } from "../shared/review.js";
|
|
22
23
|
import { reviewSet } from "../shared/reviewqueue.js";
|
|
@@ -102,6 +103,9 @@ export class ReviewApi {
|
|
|
102
103
|
return answer(refuse(400, "The comment is longer than 10,000 characters.", "Say it shorter."));
|
|
103
104
|
if (b.verdict === "comment" && !comment)
|
|
104
105
|
return answer(refuse(400, "A comment needs text.", "Write it in the box, then Comment."));
|
|
106
|
+
const voice = this.voice(b.for, who);
|
|
107
|
+
if ("status" in voice)
|
|
108
|
+
return answer(voice);
|
|
105
109
|
const v = this.graph();
|
|
106
110
|
if ("status" in v)
|
|
107
111
|
return answer(v);
|
|
@@ -109,7 +113,7 @@ export class ReviewApi {
|
|
|
109
113
|
if ("status" in x)
|
|
110
114
|
return answer(x);
|
|
111
115
|
// an agent's opinion is advice, whatever its level (AgentSpec decision 2)
|
|
112
|
-
return this.record({ op: "opinion", name: x.name, by, verdict: b.verdict, ...(comment ? { comment } : {}), ...(x.hash ? { hash: x.hash } : {}), ...(x.text !== undefined ? { text: x.text } : x.statement !== undefined ? { statement: x.statement } : {}), ...agent }, who);
|
|
116
|
+
return this.record({ op: "opinion", name: x.name, by, verdict: b.verdict, ...(comment ? { comment } : {}), ...(x.hash ? { hash: x.hash } : {}), ...(x.text !== undefined ? { text: x.text } : x.statement !== undefined ? { statement: x.statement } : {}), ...voice, ...agent }, who);
|
|
113
117
|
}
|
|
114
118
|
case "target": {
|
|
115
119
|
if (typeof b.on !== "boolean")
|
|
@@ -173,6 +177,36 @@ export class ReviewApi {
|
|
|
173
177
|
return answer(refuse(400, "op is opinion, target, trust or key.", "Send {op, …}."));
|
|
174
178
|
}
|
|
175
179
|
}
|
|
180
|
+
/** Whose an opinion is to be (ReviewSpec decision 8): the asker's own where `asked` is absent or
|
|
181
|
+
* names them; else another person's, recorded for them — by their link's id where `asked` is an
|
|
182
|
+
* invited person's name, a guest's as the logs write it — and never an agent's, nor by an agent. */
|
|
183
|
+
voice(asked, who) {
|
|
184
|
+
if (asked === undefined)
|
|
185
|
+
return {};
|
|
186
|
+
if (who.agent)
|
|
187
|
+
return refuse(403, "An agent records no opinion for anyone else.", "Leave out for: your opinion is your own, and advice.");
|
|
188
|
+
if (typeof asked !== "string" || !validName(asked.trim()))
|
|
189
|
+
return refuse(400, `for is the name of the person the opinion is recorded for: 1–${MAX_NAME} characters, no line breaks.`, "Send {op: \"opinion\", name, verdict, for: \"<their name>\"}.");
|
|
190
|
+
const name = asked.trim();
|
|
191
|
+
const held = this.world.invited?.(name);
|
|
192
|
+
// one's own name, or one's own link: one's own opinion
|
|
193
|
+
if (held ? held.id === who.person : sameName(name, who.name.trim()))
|
|
194
|
+
return {};
|
|
195
|
+
// the writers the logs name, people's and agents', in any case
|
|
196
|
+
const d = this.world.review.data;
|
|
197
|
+
const writers = [
|
|
198
|
+
...Object.values(d.opinions).flatMap((ops) => ops.map((o) => ({ by: o.by, agent: !!o.agent, person: !!o.person }))),
|
|
199
|
+
...Object.values(d.targets).map((t) => ({ by: t.by, agent: !!t.agent, person: !!t.person })),
|
|
200
|
+
...Object.values(d.keys).flatMap((marks) => Object.values(marks).map((m) => ({ by: m.by, agent: !!m.agent, person: !!m.person }))),
|
|
201
|
+
];
|
|
202
|
+
if (writers.some((w) => w.agent && sameName(w.by, name)))
|
|
203
|
+
return refuse(400, `${name} is an agent: nobody records an opinion for an agent.`, "Record it for a person, or as your own.");
|
|
204
|
+
if (held)
|
|
205
|
+
return { for: held.name, forPerson: held.id };
|
|
206
|
+
// a guest already in the logs, as they write their name: "bob" is Bob
|
|
207
|
+
const guest = writers.find((w) => !w.agent && !w.person && sameName(w.by, name));
|
|
208
|
+
return { for: guest?.by ?? name };
|
|
209
|
+
}
|
|
176
210
|
/** Append a writer's event to their log; the answer carries the change, which the page applies
|
|
177
211
|
* at once, and the event as written. */
|
|
178
212
|
record(op, who) {
|
package/dist/server/snapshot.js
CHANGED
|
@@ -4,11 +4,19 @@ import { readFileSync, writeFileSync } from "node:fs";
|
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { gunzipSync, gzipSync } from "node:zlib";
|
|
6
6
|
import { WEB, WEBFONTS, bundleDir } from "./pipeline.js";
|
|
7
|
+
import { gitSource } from "./history.js";
|
|
7
8
|
import { lakeTextHash } from "./status.js";
|
|
8
9
|
/** Text that sits inside a <script> element must not close it. */
|
|
9
10
|
function scriptSafe(s) {
|
|
10
11
|
return s.replace(/<\/script/gi, "<\\/script");
|
|
11
12
|
}
|
|
13
|
+
/** A snapshot's graph: the view's, with the project's git source and commit as the snapshot is
|
|
14
|
+
* taken, since a snapshot has no server to ask (GET /api/git); as it was where the project is no
|
|
15
|
+
* git repository. */
|
|
16
|
+
export function withGit(root, meta, metaGz) {
|
|
17
|
+
const git = gitSource(root);
|
|
18
|
+
return git ? gzipSync(JSON.stringify({ ...meta, project: { ...meta.project, git } })) : metaGz;
|
|
19
|
+
}
|
|
12
20
|
export function writeSnapshot(project, prep, out, label, review, views) {
|
|
13
21
|
const meta = JSON.parse(gunzipSync(prep.metaGz).toString("utf8"));
|
|
14
22
|
// the sources a card can show: every project module's file, with the usage pass's contexts where
|
|
@@ -28,6 +36,7 @@ export function writeSnapshot(project, prep, out, label, review, views) {
|
|
|
28
36
|
}
|
|
29
37
|
}
|
|
30
38
|
const b64 = (buf) => buf.toString("base64");
|
|
39
|
+
const metaGz = withGit(project.root, meta, prep.metaGz);
|
|
31
40
|
// the artwork's fonts within the page, which has no server to ask for them
|
|
32
41
|
const css = readFileSync(join(WEB, "style.css"), "utf8").replace(/url\("\/fonts\/([a-z0-9-]+\.woff2)"\)/g, (_, f) => `url("data:font/woff2;base64,${b64(readFileSync(join(WEBFONTS, f)))}")`);
|
|
33
42
|
const js = readFileSync(join(bundleDir(), "app.js"), "utf8").replace(/\n\/\/# sourceMappingURL=.*$/m, "");
|
|
@@ -35,7 +44,7 @@ export function writeSnapshot(project, prep, out, label, review, views) {
|
|
|
35
44
|
const html = readFileSync(join(WEB, "index.html"), "utf8")
|
|
36
45
|
.replace(`<link rel="stylesheet" href="/style.css">`, () => `<style>\n${css}\n</style>`)
|
|
37
46
|
.replace(`<script type="module" src="/app.js"></script>`, () => `<script id="vp-static" type="application/json">${scriptSafe(JSON.stringify(info))}</script>\n` +
|
|
38
|
-
`<script id="vp-meta" type="application/octet-stream">${b64(
|
|
47
|
+
`<script id="vp-meta" type="application/octet-stream">${b64(metaGz)}</script>\n` +
|
|
39
48
|
`<script id="vp-edges" type="application/octet-stream">${b64(prep.edgesGz)}</script>\n` +
|
|
40
49
|
`<script id="vp-sources" type="application/octet-stream">${b64(gzipSync(JSON.stringify(sources)))}</script>\n` +
|
|
41
50
|
// the review as it stood: shown, not changed (ReviewSpec decision 7)
|
package/dist/server/view.js
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
import { readFileSync } from "node:fs";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { LAYOUT_VERSION } from "../shared/graph.js";
|
|
6
|
-
import { gitSource } from "./history.js";
|
|
7
6
|
import { layOutHere, layOutOffThread } from "./layoutthread.js";
|
|
8
7
|
import { compiledTextHash } from "./status.js";
|
|
9
8
|
import { versionBase, versions } from "./versions.js";
|
|
@@ -75,11 +74,6 @@ function viewParts(p, ex) {
|
|
|
75
74
|
});
|
|
76
75
|
return { nodes, edges };
|
|
77
76
|
}
|
|
78
|
-
/** The project's git source and commit as it is read, where it is a git repository. */
|
|
79
|
-
function gitOf(root) {
|
|
80
|
-
const git = gitSource(root);
|
|
81
|
-
return git ? { git } : {};
|
|
82
|
-
}
|
|
83
77
|
function assemble(p, ex, nodes, edges, layouts) {
|
|
84
78
|
const g = ex.graph;
|
|
85
79
|
const meta = {
|
|
@@ -93,7 +87,6 @@ function assemble(p, ex, nodes, edges, layouts) {
|
|
|
93
87
|
toolchain: p.toolchain,
|
|
94
88
|
extractedAt: ex.at,
|
|
95
89
|
extractMs: g.stats.ms,
|
|
96
|
-
...gitOf(p.root),
|
|
97
90
|
},
|
|
98
91
|
modules: g.modules.map((m) => {
|
|
99
92
|
const file = p.files.get(m.name) ?? null;
|
package/dist/server/views.js
CHANGED
|
@@ -161,6 +161,30 @@ function cleanMemory(x) {
|
|
|
161
161
|
return null;
|
|
162
162
|
return { set: m.set, mem: { nodes, clusters } };
|
|
163
163
|
}
|
|
164
|
+
/** The Project view as a site keeps it — its pins, folds, hidden levels and clustering — read and
|
|
165
|
+
* nothing written (the artwork command, ExportSpec decision 21; the Views class sets aside a file
|
|
166
|
+
* it cannot read): null where the site keeps none, or `why` where its views cannot be read. Pins
|
|
167
|
+
* dropped under another way of laying out mean nothing in this one, as Views reads them. */
|
|
168
|
+
export function readProjectView(siteDir, layoutVersion) {
|
|
169
|
+
const file = join(siteDir, "views.json");
|
|
170
|
+
if (!existsSync(file))
|
|
171
|
+
return { view: null };
|
|
172
|
+
try {
|
|
173
|
+
const raw = JSON.parse(readFileSync(file, "utf8"));
|
|
174
|
+
for (const r of Array.isArray(raw.views) ? raw.views : []) {
|
|
175
|
+
const v = readView(r);
|
|
176
|
+
if (typeof v === "string" || v.id !== PROJECT)
|
|
177
|
+
continue;
|
|
178
|
+
if (raw.layoutVersion !== layoutVersion)
|
|
179
|
+
v.pins = emptyLayouts();
|
|
180
|
+
return { view: v };
|
|
181
|
+
}
|
|
182
|
+
return { view: null };
|
|
183
|
+
}
|
|
184
|
+
catch (e) {
|
|
185
|
+
return { view: null, why: e instanceof Error ? e.message : String(e) };
|
|
186
|
+
}
|
|
187
|
+
}
|
|
164
188
|
export class Views {
|
|
165
189
|
book;
|
|
166
190
|
file;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Text measured as an artwork's fonts draw it, without a browser (ExportSpec decision 21): the
|
|
2
|
+
// advance widths of the faces the package ships (assets/webfonts/metrics.json, made by
|
|
3
|
+
// scripts/artfonts.py), for a CSS font as a canvas takes it. A face stands for its family at the
|
|
4
|
+
// nearest weight it is made at; a character it has not is measured in the faces that stand in for
|
|
5
|
+
// it (Noto Sans, Noto Sans Math), as the renderer draws it from them. No kerning: the renderer the
|
|
6
|
+
// command draws with is given none either.
|
|
7
|
+
/** the families an artwork's fonts name that the package has not, and the face drawn for them: the
|
|
8
|
+
* system's own for the board's names (web/src/canvas.ts FONT), and the generic ones */
|
|
9
|
+
const STANDS_FOR = {
|
|
10
|
+
"-apple-system": "Source Sans 3",
|
|
11
|
+
"blinkmacsystemfont": "Source Sans 3",
|
|
12
|
+
"sf pro text": "Source Sans 3",
|
|
13
|
+
"inter": "Source Sans 3",
|
|
14
|
+
"segoe ui": "Source Sans 3",
|
|
15
|
+
"sans-serif": "Source Sans 3",
|
|
16
|
+
"system-ui": "Source Sans 3",
|
|
17
|
+
"georgia": "Source Serif 4",
|
|
18
|
+
"times new roman": "Source Serif 4",
|
|
19
|
+
"serif": "Source Serif 4",
|
|
20
|
+
};
|
|
21
|
+
/** A CSS font as a 2D context takes it ("italic 600 12px \"Jost\", sans-serif"): its slant, weight,
|
|
22
|
+
* size in pixels, and families in order. */
|
|
23
|
+
export function parseFont(font) {
|
|
24
|
+
const m = /^\s*((?:(?:italic|oblique|normal|bold|bolder|lighter|\d{3})\s+)*)([\d.]+)px(?:\/[^\s]+)?\s+(.+)$/i.exec(font);
|
|
25
|
+
if (!m)
|
|
26
|
+
return { style: "normal", weight: 400, px: 10, families: ["sans-serif"] };
|
|
27
|
+
const words = m[1].trim().toLowerCase().split(/\s+/).filter(Boolean);
|
|
28
|
+
const style = words.includes("italic") || words.includes("oblique") ? "italic" : "normal";
|
|
29
|
+
const num = words.find((w) => /^\d{3}$/.test(w));
|
|
30
|
+
const weight = num ? Number(num) : words.includes("bold") ? 700 : 400;
|
|
31
|
+
const families = m[3].split(",").map((f) => f.trim().replace(/^["']|["']$/g, "")).filter(Boolean);
|
|
32
|
+
return { style, weight, px: Number(m[2]), families };
|
|
33
|
+
}
|
|
34
|
+
/** How wide `text` is in `font`, in pixels, as the package's faces draw it. */
|
|
35
|
+
export function textMeasurer(metrics) {
|
|
36
|
+
const families = new Map();
|
|
37
|
+
for (const f of metrics.faces)
|
|
38
|
+
families.set(f.family.toLowerCase(), [...(families.get(f.family.toLowerCase()) ?? []), f]);
|
|
39
|
+
const chosen = new Map();
|
|
40
|
+
/** the face a font is drawn in: its first family the package has (or stands one in for), at the
|
|
41
|
+
* slant asked where it is made in it, at the nearest weight */
|
|
42
|
+
const faceOf = (font) => {
|
|
43
|
+
const hit = chosen.get(font);
|
|
44
|
+
if (hit)
|
|
45
|
+
return hit;
|
|
46
|
+
const f = parseFont(font);
|
|
47
|
+
let set;
|
|
48
|
+
for (const fam of f.families) {
|
|
49
|
+
const k = fam.toLowerCase();
|
|
50
|
+
set = families.get(k) ?? families.get(STANDS_FOR[k]?.toLowerCase() ?? "");
|
|
51
|
+
if (set)
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
set ??= families.get("source sans 3") ?? metrics.faces;
|
|
55
|
+
const slanted = set.filter((x) => x.style === f.style);
|
|
56
|
+
const pool = slanted.length ? slanted : set;
|
|
57
|
+
const face = pool.reduce((a, b) => (Math.abs(b.weight - f.weight) < Math.abs(a.weight - f.weight) ? b : a));
|
|
58
|
+
const out = { face, px: f.px };
|
|
59
|
+
chosen.set(font, out);
|
|
60
|
+
return out;
|
|
61
|
+
};
|
|
62
|
+
return (text, font) => {
|
|
63
|
+
const { face, px } = faceOf(font);
|
|
64
|
+
let w = 0;
|
|
65
|
+
for (const ch of text) {
|
|
66
|
+
const cp = String(ch.codePointAt(0));
|
|
67
|
+
let units = face.widths[cp];
|
|
68
|
+
let upm = face.unitsPerEm;
|
|
69
|
+
if (units === undefined)
|
|
70
|
+
for (const fb of metrics.fallback)
|
|
71
|
+
if (fb.widths[cp] !== undefined) {
|
|
72
|
+
units = fb.widths[cp];
|
|
73
|
+
upm = fb.unitsPerEm;
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
// a character no face has: about an en, as a renderer's missing glyph is drawn
|
|
77
|
+
w += ((units ?? face.unitsPerEm * 0.55) / upm) * px;
|
|
78
|
+
}
|
|
79
|
+
return w;
|
|
80
|
+
};
|
|
81
|
+
}
|