visualproof 0.1.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-Jost.txt +93 -0
- package/dist/assets/webfonts/LICENSE-Noto.txt +95 -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/metrics.json +1 -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/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 +125 -92
- package/dist/server/agentdocs.js +1 -1
- package/dist/server/artcmd.js +335 -0
- package/dist/server/history.js +39 -0
- package/dist/server/historyapi.js +2 -2
- package/dist/server/hover.js +3 -3
- package/dist/server/layoutthread.js +1 -1
- package/dist/server/live.js +6 -0
- package/dist/server/main.js +151 -11
- package/dist/server/mcptools.js +6 -4
- package/dist/server/pastdoc.js +1 -1
- package/dist/server/pipeline.js +18 -9
- package/dist/server/readonlygate.js +2 -2
- package/dist/server/review.js +4 -1
- package/dist/server/reviewapi.js +35 -1
- package/dist/server/snapshot.js +13 -3
- package/dist/server/usage.js +2 -2
- package/dist/server/views.js +25 -1
- package/dist/shared/artfonts.js +81 -0
- package/dist/shared/artrecipe.js +316 -0
- package/dist/shared/collab.js +99 -1
- package/dist/shared/declfind.js +95 -0
- package/dist/shared/doc/order.js +17 -0
- package/dist/shared/doc/outline.js +45 -0
- package/dist/shared/keys.js +1 -1
- package/dist/shared/lostfound.js +1 -0
- package/dist/shared/moves.js +2 -1
- package/dist/shared/pastgraph.js +9 -1
- package/dist/shared/query.js +21 -13
- package/dist/shared/review.js +62 -5
- package/dist/shared/sharesnap.js +2 -1
- package/dist/shared/titles.js +2 -0
- package/dist/shared/viewgraph.js +18 -0
- package/dist/web/bundle/app.js +7709 -3222
- package/dist/web/bundle/edges.js +4 -6
- package/dist/web/index.html +24 -6
- 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/scene.js +17 -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 +255 -12
- package/docs/agent-api/README.md +1 -1
- package/docs/agent-api/export.md +41 -2
- package/docs/agent-api/following.md +2 -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/docs/agent-api/views.md +3 -2
- package/package.json +3 -1
- package/test/toolchains.json +2 -2
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
// The artwork command (ExportSpec decision 21): `visualproof artwork <lean-project> --recipe <file>`
|
|
2
|
+
// makes the picture a recipe says (decision 20) without a browser — the same picture as the page's,
|
|
3
|
+
// by the page's own code. The build is read as `visualproof export` reads it; the board is the
|
|
4
|
+
// Project's, laid out as the site keeps it (or as a new site would); the recipe is resolved as the
|
|
5
|
+
// page resolves it; the board is drawn by the page's canvas without a graphics card (its renderer
|
|
6
|
+
// kept to its data, the names measured from the fonts the package ships) on a screen of the given
|
|
7
|
+
// size, the frame placed and fitted as the artwork mode places and fits it; the SVG is written as
|
|
8
|
+
// the page's Save SVG writes it, and the PNG is that SVG drawn by resvg with the same fonts.
|
|
9
|
+
// Nothing is written but the files: the site is read, never written (pipeline `readOnlySite`), and
|
|
10
|
+
// without --site none is read.
|
|
11
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
12
|
+
import { createRequire } from "node:module";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { createDeflate, crc32, gunzipSync } from "node:zlib";
|
|
15
|
+
import { readRecipe, resolveRecipe } from "../shared/artrecipe.js";
|
|
16
|
+
import { textMeasurer } from "../shared/artfonts.js";
|
|
17
|
+
import { boardSet, lightUp, wholeLayout } from "../shared/board.js";
|
|
18
|
+
import { declAt, guessNames } from "../shared/declfind.js";
|
|
19
|
+
import { litKeyEdges } from "../shared/keys.js";
|
|
20
|
+
import { emptyReview } from "../shared/review.js";
|
|
21
|
+
import { SearchIndex } from "../shared/search.js";
|
|
22
|
+
import { Graph } from "../shared/viewgraph.js";
|
|
23
|
+
import { PROJECT } from "../shared/views.js";
|
|
24
|
+
import { artName, artSvg, borderInset, cleanSettings, cssTheme, drawBackdrop, frameRect, sourceLine, SvgContext, themeBackground, titleOf, titleRoom, writeOver } from "../web/src/artwork.js";
|
|
25
|
+
import { GraphCanvas, themeOf } from "../web/src/canvas.js";
|
|
26
|
+
import { ART_SANS } from "../web/src/halo.js";
|
|
27
|
+
import { ReviewFlow } from "../web/src/reviewqueue.js";
|
|
28
|
+
import { declPick, edgeMode, selectionQuery } from "../web/src/selection.js";
|
|
29
|
+
import { statusCodes } from "../web/src/status.js";
|
|
30
|
+
import { gitSource } from "./history.js";
|
|
31
|
+
import { WEB, WEBFONTS } from "./pipeline.js";
|
|
32
|
+
/** the faces of a picture's names, from the package's own TrueType files (scripts/artfonts.py) */
|
|
33
|
+
const TTF = join(WEBFONTS, "ttf");
|
|
34
|
+
let metrics = null;
|
|
35
|
+
/** How wide text is in a font, as the package's faces draw it. */
|
|
36
|
+
function measurer() {
|
|
37
|
+
metrics ??= JSON.parse(readFileSync(join(WEBFONTS, "metrics.json"), "utf8"));
|
|
38
|
+
return textMeasurer(metrics);
|
|
39
|
+
}
|
|
40
|
+
/** The page's font faces, each its WOFF2 written in, as the page's Save SVG writes them (web/src/
|
|
41
|
+
* artframe.ts, faces): from the style sheet's rules, the files beside it. */
|
|
42
|
+
export function facesOf(css) {
|
|
43
|
+
const out = [];
|
|
44
|
+
for (const m of css.matchAll(/@font-face\s*\{([^}]*)\}/g)) {
|
|
45
|
+
const body = m[1];
|
|
46
|
+
const get = (k) => new RegExp(`${k}\\s*:\\s*([^;]+)`).exec(body)?.[1].trim();
|
|
47
|
+
const family = get("font-family")?.replace(/["']/g, "").trim() ?? "";
|
|
48
|
+
if (family !== "Source Sans 3" && family !== "Source Serif 4" && family !== "Jost")
|
|
49
|
+
continue;
|
|
50
|
+
const file = /url\(["']?\/fonts\/([a-z0-9-]+\.woff2)["']?\)/.exec(body)?.[1];
|
|
51
|
+
if (!file)
|
|
52
|
+
continue;
|
|
53
|
+
const b64 = readFileSync(join(WEBFONTS, file)).toString("base64");
|
|
54
|
+
out.push(`@font-face{font-family:"${family}";font-style:${get("font-style") || "normal"};font-weight:${get("font-weight") || "400"};src:url(data:font/woff2;base64,${b64}) format("woff2");unicode-range:${get("unicode-range") || "U+0-10FFFF"}}`);
|
|
55
|
+
}
|
|
56
|
+
return out.join("");
|
|
57
|
+
}
|
|
58
|
+
/** The Project view as a site keeps it (its pins, folds, hidden levels and clustering: server/views.ts,
|
|
59
|
+
* readProjectView), or as a new site has it. */
|
|
60
|
+
export function projectView(kept) {
|
|
61
|
+
const empty = () => ({ namespace: [], module: [] });
|
|
62
|
+
return (kept ?? {
|
|
63
|
+
id: PROJECT,
|
|
64
|
+
name: null,
|
|
65
|
+
scope: { op: "all" },
|
|
66
|
+
clustering: "namespace",
|
|
67
|
+
pins: { namespace: { clusters: {}, nodes: {} }, module: { clusters: {}, nodes: {} } },
|
|
68
|
+
folds: empty(),
|
|
69
|
+
hidden: empty(),
|
|
70
|
+
by: null,
|
|
71
|
+
at: new Date(0).toISOString(),
|
|
72
|
+
kept: true,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
/** The picture a recipe says, of the build `prep` — its graph and layouts — with the review and the
|
|
76
|
+
* Project view a site keeps (or none). */
|
|
77
|
+
export function makeArtwork(prep, review, project, ask) {
|
|
78
|
+
const read = readRecipe(ask.recipe);
|
|
79
|
+
if ("error" in read)
|
|
80
|
+
return { problems: [`${read.error} ${read.hint}`] };
|
|
81
|
+
const r = read.recipe;
|
|
82
|
+
if (r.view && r.view !== PROJECT)
|
|
83
|
+
return { problems: [`the command draws the Project view: the recipe names the view ${r.view}`] };
|
|
84
|
+
const meta = JSON.parse(gunzipSync(prep.metaGz).toString("utf8"));
|
|
85
|
+
const raw = gunzipSync(prep.edgesGz);
|
|
86
|
+
const g = new Graph(meta, new Int32Array(raw.buffer, raw.byteOffset, raw.byteLength / 4));
|
|
87
|
+
const data = review ?? emptyReview();
|
|
88
|
+
// the board: the Project's, on the server's layout under the levels it hides
|
|
89
|
+
const mode = project.clustering;
|
|
90
|
+
const bs = boardSet(g, data, project.scope);
|
|
91
|
+
const L = wholeLayout(meta.layouts[mode], g.nodes, mode, project.hidden[mode]);
|
|
92
|
+
// the targets: the review's with a site, those the recipe names without one
|
|
93
|
+
const onGraph = (n) => g.byName.has(n);
|
|
94
|
+
const targets = review ? Object.keys(data.targets) : Array.isArray(r.targets) ? r.targets.filter(onGraph) : [];
|
|
95
|
+
const problems = [];
|
|
96
|
+
let sel = null;
|
|
97
|
+
if (r.selection) {
|
|
98
|
+
// as the agents' API finds a declaration (shared/declfind.ts): a name it folds is the declaration
|
|
99
|
+
// it is folded into; none, what was likely meant
|
|
100
|
+
const i = declAt(g, r.selection.name);
|
|
101
|
+
if (i !== undefined)
|
|
102
|
+
sel = { name: g.nodes[i].name, mode: r.selection.mode ?? "tcb" };
|
|
103
|
+
else {
|
|
104
|
+
const meant = guessNames(g, new SearchIndex(meta.nodes, meta.projectCount), r.selection.name.trim());
|
|
105
|
+
problems.push(`no declaration ${r.selection.name} in the graph${meant.length ? ` (did you mean ${meant[0]}?)` : ""}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const res = resolveRecipe(r, {
|
|
109
|
+
clusters: L.clusters,
|
|
110
|
+
clusterOf: (name) => {
|
|
111
|
+
const i = g.byName.get(name);
|
|
112
|
+
return i === undefined ? undefined : L.cluster[i];
|
|
113
|
+
},
|
|
114
|
+
targets,
|
|
115
|
+
projectCount: g.projectCount,
|
|
116
|
+
});
|
|
117
|
+
problems.push(...res.problems);
|
|
118
|
+
if (problems.length)
|
|
119
|
+
return { problems };
|
|
120
|
+
const s = cleanSettings(res.look, { scheme: "kind", theme: "light" });
|
|
121
|
+
// the theme as the mode sets it (web/src/main.ts, drawLook): the board's style in the artwork's,
|
|
122
|
+
// the cells and the starfield in the dark one
|
|
123
|
+
const css = readFileSync(join(WEB, "style.css"), "utf8");
|
|
124
|
+
const v = cssTheme(css, s.style === "board" ? s.theme === "dark" : true);
|
|
125
|
+
const measure = measurer();
|
|
126
|
+
// the board, drawn without a browser on a screen of the size asked
|
|
127
|
+
const scratch = new SvgContext(measure, ART_SANS);
|
|
128
|
+
const none = () => { };
|
|
129
|
+
const C = new GraphCanvas(null, null, null, g, { nodeClick: none, clusterClick: none, backgroundClick: none, hover: none, pinsChanged: none, foldChanged: none }, { w: ask.screen.w, h: ask.screen.h, theme: themeOf(v), ctx: scratch });
|
|
130
|
+
C.setGraph(g);
|
|
131
|
+
C.setLayout(L, project.pins[mode], project.folds[mode], mode);
|
|
132
|
+
C.setShowExternals(bs.whole ? bs.outside : true);
|
|
133
|
+
// the review on it, as the page's syncStatus puts it (no build's file states: none is known here)
|
|
134
|
+
const idx = targets.map((n) => g.byName.get(n)).filter((j) => j !== undefined);
|
|
135
|
+
C.setStatus(statusCodes(data, g, new Map()));
|
|
136
|
+
C.setTargets(idx);
|
|
137
|
+
const flow = new ReviewFlow({ graph: () => g, review: () => data, reader: () => null });
|
|
138
|
+
const keys = new Map();
|
|
139
|
+
for (const t of idx)
|
|
140
|
+
for (const k of flow.keys(t).keys)
|
|
141
|
+
if (k !== t)
|
|
142
|
+
keys.set(k, [...(keys.get(k) ?? []), t]);
|
|
143
|
+
C.setKeys([...keys.keys()], [...keys.values()]);
|
|
144
|
+
// the artwork's look, as drawLook gives it
|
|
145
|
+
C.setScheme(s.scheme);
|
|
146
|
+
C.setEdges(s.edges !== "none");
|
|
147
|
+
C.setArt({ labels: s.labels, namesFrom: s.namesFrom, hidden: new Set(s.hiddenNames), shown: new Set(s.shownNames), ruled: new Set(s.ruledNames), edges: s.edges, targets: s.targets, shownTargets: new Set(s.shownTargets), keys: s.keys, style: s.style });
|
|
148
|
+
// the selection, as the page's apply makes it
|
|
149
|
+
const picks = sel ? [declPick(sel.mode, sel.name)] : [];
|
|
150
|
+
const q = selectionQuery(picks);
|
|
151
|
+
const lit = q && lightUp(g, data, bs, q);
|
|
152
|
+
const keyEdges = sel?.mode === "keys" && lit && lit.count ? litKeyEdges(idx.map((t) => flow.keys(t)), (i) => lit.set[i] === 1).flat() : null;
|
|
153
|
+
if (s.targets === "highlight") {
|
|
154
|
+
const alone = new Uint8Array(g.n);
|
|
155
|
+
for (const i of idx)
|
|
156
|
+
alone[i] = 1;
|
|
157
|
+
C.setActivation(edgeMode(picks), [], alone, [], null);
|
|
158
|
+
}
|
|
159
|
+
else if (!s.selection)
|
|
160
|
+
C.setActivation(edgeMode(picks), [], null, [], null);
|
|
161
|
+
else
|
|
162
|
+
C.setActivation(edgeMode(picks), sel ? [g.byName.get(sel.name)] : [], lit?.set ?? null, [], keyEdges);
|
|
163
|
+
// the frame, placed and fitted as the mode places and fits it (web/src/artframe.ts, place, open)
|
|
164
|
+
const rect = frameRect(ask.screen, s);
|
|
165
|
+
// the title's line counts the targets marked: the review's, or the recipe's where there is no site
|
|
166
|
+
const own = titleOf(meta.project.name, meta.projectCount, review ? Object.keys(data.targets).length : targets.length);
|
|
167
|
+
const below = titleRoom(rect.h, s, own);
|
|
168
|
+
const cam = C.fitCam({ x: rect.x, y: rect.y + below, w: rect.w, h: rect.h - below });
|
|
169
|
+
if (cam)
|
|
170
|
+
C.jumpTo(cam);
|
|
171
|
+
C.settle();
|
|
172
|
+
// what the names are: those written, and those a rule chose that would have covered another
|
|
173
|
+
const written = C.writtenNames();
|
|
174
|
+
const name = (id) => id.slice(2).replace("⟨external⟩", "External");
|
|
175
|
+
const git = s.source ? gitSource(ask.root) : null;
|
|
176
|
+
const source = git ? sourceLine(git) : null;
|
|
177
|
+
// the SVG, as the page's Save SVG writes it (artframe saveFile); notes are not drawn here
|
|
178
|
+
const ground = s.background !== "transparent";
|
|
179
|
+
const bg = ground && s.style === "board" ? themeBackground(v) : null;
|
|
180
|
+
const svg = artSvg(rect, s, bg, ART_SANS, measure, (pen) => {
|
|
181
|
+
const ctx = pen;
|
|
182
|
+
if (ground)
|
|
183
|
+
drawBackdrop(ctx, s.style, rect);
|
|
184
|
+
if (s.border) {
|
|
185
|
+
const i = borderInset(rect);
|
|
186
|
+
pen.clipRect("inside", rect.x + i, rect.y + i, rect.w - 2 * i, rect.h - 2 * i);
|
|
187
|
+
pen.raw(`<g clip-path="url(#inside)">`);
|
|
188
|
+
}
|
|
189
|
+
for (const sh of C.artShapes(rect))
|
|
190
|
+
pen.shape(sh);
|
|
191
|
+
C.drawMarks(ctx);
|
|
192
|
+
if (s.border)
|
|
193
|
+
pen.raw(`</g>`);
|
|
194
|
+
writeOver(ctx, s, rect, own, source, v);
|
|
195
|
+
}, facesOf(css));
|
|
196
|
+
const title = ask.name ?? (s.title || meta.project.name);
|
|
197
|
+
return {
|
|
198
|
+
problems: [],
|
|
199
|
+
look: s,
|
|
200
|
+
names: {
|
|
201
|
+
written: [...written].map(name),
|
|
202
|
+
ruledSkipped: s.ruledNames.filter((id) => !written.has(id)).map(name),
|
|
203
|
+
left: res.left,
|
|
204
|
+
},
|
|
205
|
+
targets: s.targets === "chosen" ? s.shownTargets : s.targets === "plain" ? [] : idx.map((j) => g.nodes[j].name),
|
|
206
|
+
selection: sel,
|
|
207
|
+
notesLeftOut: s.notes ? (project.notes ?? []).length : 0,
|
|
208
|
+
svg,
|
|
209
|
+
file: (kind) => artName(title, kind),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
let wasmReady = null;
|
|
213
|
+
let ttfs = null;
|
|
214
|
+
/** resvg's WebAssembly build, ready, with the package's TrueType faces and none of the system's
|
|
215
|
+
* (decision 21: the PNG agrees with the SVG, and is the same on every machine) */
|
|
216
|
+
async function renderer() {
|
|
217
|
+
const { initWasm, Resvg } = await import("@resvg/resvg-wasm");
|
|
218
|
+
wasmReady ??= initWasm(readFileSync(createRequire(import.meta.url).resolve("@resvg/resvg-wasm/index_bg.wasm")));
|
|
219
|
+
await wasmReady;
|
|
220
|
+
ttfs ??= readdirSync(TTF)
|
|
221
|
+
.filter((f) => f.endsWith(".ttf"))
|
|
222
|
+
.sort()
|
|
223
|
+
.map((f) => new Uint8Array(readFileSync(join(TTF, f))));
|
|
224
|
+
const opts = { font: { fontBuffers: ttfs, loadSystemFonts: false, defaultFontFamily: "Source Sans 3" } };
|
|
225
|
+
return (svg) => new Resvg(svg, opts);
|
|
226
|
+
}
|
|
227
|
+
/** The most pixels a PNG is drawn in at once. resvg holds a picture four times over and more (its
|
|
228
|
+
* clipped groups' layers and their masks: 17 bytes a pixel) in WebAssembly's memory, which is at
|
|
229
|
+
* most 4 GiB: a picture of 80 million pixels takes 1.9 GB, the largest (16384 × 16384) more than
|
|
230
|
+
* there is. A larger one is drawn in bands of BAND_PIXELS (the largest: 2.0 GB). */
|
|
231
|
+
export const PNG_PIXELS = 80_000_000;
|
|
232
|
+
/** A band's pixels: resvg's layers reach up to twice the band's height beyond it, so that a band
|
|
233
|
+
* costs 35 bytes a pixel, 0.7 GB, and the picture is drawn about three times as long. */
|
|
234
|
+
const BAND_PIXELS = 20_000_000;
|
|
235
|
+
/** An artwork's SVG (artSvg's: its root's viewBox the frame, its width and height the picture's) as
|
|
236
|
+
* it draws rows `y0` to `y0 + rows` of the picture: its viewBox moved down, the background's
|
|
237
|
+
* rectangle (its `100%`, which a moved viewBox would not move) in the frame's units, and the frame's
|
|
238
|
+
* clip left out, as the band is within the frame. The border's clip is kept, and given a rectangle
|
|
239
|
+
* as large as the frame that draws nothing: resvg 2.6 fails (WebAssembly's `unreachable`) on a
|
|
240
|
+
* clipped group whose contents lie more than twice the band's height away from it. */
|
|
241
|
+
export function bandSvg(svg, y0, rows) {
|
|
242
|
+
const root = /^<svg ([^>]*?)viewBox="0 0 ([\d.]+) ([\d.]+)" width="(\d+)" height="(\d+)"/.exec(svg);
|
|
243
|
+
const board = /<g clip-path="url\(#frame\)"><g transform="translate\(([-\d.]+) ([-\d.]+)\)">/.exec(svg);
|
|
244
|
+
if (!root || !board)
|
|
245
|
+
throw new Error("not an artwork's SVG");
|
|
246
|
+
const [W, H, PH] = [Number(root[2]), Number(root[3]), Number(root[5])];
|
|
247
|
+
const f = H / PH;
|
|
248
|
+
return svg
|
|
249
|
+
.replace(root[0], `<svg ${root[1]}viewBox="0 ${y0 * f} ${W} ${rows * f}" width="${root[4]}" height="${rows}"`)
|
|
250
|
+
.replace(`<rect width="100%" height="100%"`, `<rect width="${W}" height="${H}"`)
|
|
251
|
+
.replace(board[0], `<g><g transform="translate(${board[1]} ${board[2]})">`)
|
|
252
|
+
.replace(`<g clip-path="url(#inside)">`, `<g clip-path="url(#inside)"><rect x="${-Number(board[1])}" y="${-Number(board[2])}" width="${W}" height="${H}" fill="#000" fill-opacity="0"/>`);
|
|
253
|
+
}
|
|
254
|
+
/** An SVG drawn as a PNG at the size its root says, by resvg. A picture of more than `most` pixels
|
|
255
|
+
* is drawn in bands of about `band` pixels (bandSvg) and written as one PNG, row by row: the same
|
|
256
|
+
* picture, its pixels a few levels apart where translucent shapes overlap (resvg's layers round
|
|
257
|
+
* otherwise than one drawing). */
|
|
258
|
+
export async function pngOf(svg, most = PNG_PIXELS, band = BAND_PIXELS) {
|
|
259
|
+
const render = await renderer();
|
|
260
|
+
const m = /^<svg [^>]*?width="(\d+)" height="(\d+)"/.exec(svg);
|
|
261
|
+
const [w, h] = m ? [Number(m[1]), Number(m[2])] : [0, 0];
|
|
262
|
+
const failed = (e) => new Error(`the PNG${m ? `, ${w} × ${h},` : ""} could not be drawn (${e instanceof Error ? e.message : String(e)})`);
|
|
263
|
+
if (!m || w * h <= most) {
|
|
264
|
+
const r = render(svg);
|
|
265
|
+
let png;
|
|
266
|
+
try {
|
|
267
|
+
const image = r.render();
|
|
268
|
+
png = image.asPng();
|
|
269
|
+
image.free();
|
|
270
|
+
}
|
|
271
|
+
catch (e) {
|
|
272
|
+
// resvg is not called again once it has failed: the command ends
|
|
273
|
+
throw failed(e);
|
|
274
|
+
}
|
|
275
|
+
r.free();
|
|
276
|
+
return png;
|
|
277
|
+
}
|
|
278
|
+
// the rows, each its filter byte (none) and its pixels, demultiplied as resvg's own PNG has them
|
|
279
|
+
// (tiny-skia's `demultiply`)
|
|
280
|
+
const deflate = createDeflate({ level: 6 });
|
|
281
|
+
const out = [];
|
|
282
|
+
deflate.on("data", (b) => out.push(b));
|
|
283
|
+
const done = new Promise((ok, fail) => {
|
|
284
|
+
deflate.on("end", ok);
|
|
285
|
+
deflate.on("error", fail);
|
|
286
|
+
});
|
|
287
|
+
// bands of even heights, so that none is a sliver
|
|
288
|
+
const count = Math.ceil(h / Math.max(1, Math.floor(band / w)));
|
|
289
|
+
const rows = Math.ceil(h / count);
|
|
290
|
+
for (let y = 0; y < h; y += rows) {
|
|
291
|
+
const n = Math.min(rows, h - y);
|
|
292
|
+
const r = render(bandSvg(svg, y, n));
|
|
293
|
+
let px;
|
|
294
|
+
try {
|
|
295
|
+
const image = r.render();
|
|
296
|
+
px = image.pixels;
|
|
297
|
+
image.free();
|
|
298
|
+
}
|
|
299
|
+
catch (e) {
|
|
300
|
+
throw failed(e);
|
|
301
|
+
}
|
|
302
|
+
r.free();
|
|
303
|
+
const lines = Buffer.alloc(n * (w * 4 + 1));
|
|
304
|
+
for (let row = 0; row < n; row++) {
|
|
305
|
+
const o = row * (w * 4 + 1) + 1, from = row * w * 4;
|
|
306
|
+
for (let x = 0; x < w * 4; x += 4) {
|
|
307
|
+
const a = px[from + x + 3];
|
|
308
|
+
lines[o + x + 3] = a;
|
|
309
|
+
if (a === 255)
|
|
310
|
+
for (let c = 0; c < 3; c++)
|
|
311
|
+
lines[o + x + c] = px[from + x + c];
|
|
312
|
+
else if (a > 0)
|
|
313
|
+
for (let c = 0; c < 3; c++)
|
|
314
|
+
lines[o + x + c] = Math.min(255, Math.floor(px[from + x + c] / (a / 255) + 0.5));
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
if (!deflate.write(lines))
|
|
318
|
+
await new Promise((ok) => deflate.once("drain", ok));
|
|
319
|
+
}
|
|
320
|
+
deflate.end();
|
|
321
|
+
await done;
|
|
322
|
+
const chunk = (type, data) => {
|
|
323
|
+
const head = Buffer.alloc(8);
|
|
324
|
+
head.writeUInt32BE(data.length, 0);
|
|
325
|
+
head.write(type, 4, "latin1");
|
|
326
|
+
const crc = Buffer.alloc(4);
|
|
327
|
+
crc.writeUInt32BE(crc32(Buffer.concat([head.subarray(4), data])) >>> 0, 0);
|
|
328
|
+
return Buffer.concat([head, data, crc]);
|
|
329
|
+
};
|
|
330
|
+
const ihdr = Buffer.alloc(13);
|
|
331
|
+
ihdr.writeUInt32BE(w, 0);
|
|
332
|
+
ihdr.writeUInt32BE(h, 4);
|
|
333
|
+
ihdr.set([8, 6, 0, 0, 0], 8);
|
|
334
|
+
return new Uint8Array(Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), chunk("IHDR", ihdr), chunk("IDAT", Buffer.concat(out)), chunk("IEND", Buffer.alloc(0))]));
|
|
335
|
+
}
|
package/dist/server/history.js
CHANGED
|
@@ -70,6 +70,45 @@ export function commitNow(root) {
|
|
|
70
70
|
const status = git(root, "status", "--porcelain", "--untracked-files=no", "--", "*.lean");
|
|
71
71
|
return { hash, branch: branch && branch !== "HEAD" ? branch : null, ...(status ? { dirty: true } : {}) };
|
|
72
72
|
}
|
|
73
|
+
/** The project's git source and the commit checked out, as a page may show them (an artwork's foot,
|
|
74
|
+
* ExportSpec decision 19): the remote's address (`origin`, else the first) with no user name or
|
|
75
|
+
* password (sourceAddress); null where the project is not a git repository. Read-only, as `git`. */
|
|
76
|
+
export function gitSource(root) {
|
|
77
|
+
const c = commitNow(root);
|
|
78
|
+
if (!c)
|
|
79
|
+
return null;
|
|
80
|
+
const remotes = (git(root, "remote") ?? "").split("\n").filter(Boolean);
|
|
81
|
+
const name = remotes.includes("origin") ? "origin" : remotes[0];
|
|
82
|
+
const url = name ? git(root, "remote", "get-url", name) : null;
|
|
83
|
+
return { source: url ? sourceAddress(url) : null, commit: c.hash, branch: c.branch, ...(c.dirty ? { dirty: true } : {}) };
|
|
84
|
+
}
|
|
85
|
+
/** A remote's address as a page shows it: its host and path, without the scheme, a user name, a
|
|
86
|
+
* password, a port or `.git` — `git@github.com:A/B.git` and `https://user:token@github.com/A/B`
|
|
87
|
+
* both read `github.com/A/B`. Null for a path on this machine, or what is no address. */
|
|
88
|
+
export function sourceAddress(url) {
|
|
89
|
+
const u = url.trim();
|
|
90
|
+
let host, path;
|
|
91
|
+
// scp-like, [user@]host:path, but not a Windows drive
|
|
92
|
+
const scp = /^(?:[^@/\s]+@)?([A-Za-z0-9.-]+):(?!\/\/)(\S+)$/.exec(u);
|
|
93
|
+
if (scp && !/^[A-Za-z]:[\\/]/.test(u))
|
|
94
|
+
[host, path] = [scp[1], scp[2]];
|
|
95
|
+
else {
|
|
96
|
+
let p;
|
|
97
|
+
try {
|
|
98
|
+
p = new URL(u);
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
if (!["https:", "http:", "ssh:", "git:"].includes(p.protocol) || !p.hostname)
|
|
104
|
+
return null;
|
|
105
|
+
[host, path] = [p.hostname, decodeURIComponent(p.pathname)];
|
|
106
|
+
}
|
|
107
|
+
path = path.replace(/^\/+|\/+$/g, "").replace(/\.git$/, "");
|
|
108
|
+
if (!/^[A-Za-z0-9.-]+$/.test(host) || !path || !/^[\w.~/-]+$/.test(path))
|
|
109
|
+
return null;
|
|
110
|
+
return `${host}/${path}`;
|
|
111
|
+
}
|
|
73
112
|
/** The last commit made before `at`: what an imported extraction was probably built from. */
|
|
74
113
|
function commitBefore(root, at) {
|
|
75
114
|
const hash = git(root, "log", "-1", "--format=%H", `--before=${at}`);
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// or an agent at level full.
|
|
9
9
|
//
|
|
10
10
|
// The Time Machine steps from build to build, and a snapshot's graph is large (on a large project,
|
|
11
|
-
// megabytes of JSON and a noticeable time to make and compress:
|
|
11
|
+
// megabytes of JSON and a noticeable time to make and compress: Sphere-Packing-Lean's, 7,851 declarations, is 13.7 MB, made
|
|
12
12
|
// in 0.6 s): the page takes it in two parts, its declarations as JSON and its
|
|
13
13
|
// edges packed as the graph on show's are (shared/edgecodec.ts), each compressed once and kept for
|
|
14
14
|
// the last few snapshots asked, under the renames in force, and tagged so that a page that has one
|
|
@@ -21,7 +21,7 @@ const no = (status, error) => ({ status, body: { error } });
|
|
|
21
21
|
const ok = (body) => ({ status: 200, body });
|
|
22
22
|
/** the parts of the last snapshots asked, per history, by snapshot, build read and renames */
|
|
23
23
|
const kept = new WeakMap();
|
|
24
|
-
/** how many snapshots' parts are kept: about a megabyte each, compressed, on a large project (
|
|
24
|
+
/** how many snapshots' parts are kept: about a megabyte each, compressed, on a large project (Sphere-Packing-Lean's:
|
|
25
25
|
* 0.7 MB of declarations, 0.5 MB of edges) */
|
|
26
26
|
const KEEP_PARTS = 8;
|
|
27
27
|
/** what changed between two snapshots, as answered last (a step on the Time Machine's timeline asks
|
package/dist/server/hover.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
// (a diagnostic), not as a hover.
|
|
16
16
|
//
|
|
17
17
|
// Lean runs each open file in a worker process of its own, which can die (a worker on a large
|
|
18
|
-
// project holds gigabytes: 4.1 GB for
|
|
18
|
+
// project holds gigabytes: 4.1 GB for Sphere-Packing-Lean's main theorem's module; and the kernel's OOM killer picks the
|
|
19
19
|
// largest). Lean then says the file's progress
|
|
20
20
|
// is a fatal error, and starts the worker again only when the document changes: until then every
|
|
21
21
|
// question about it would wait forever. So a crashed document is closed and opened again here, as a
|
|
@@ -38,8 +38,8 @@ const IDLE_MS = 10 * 60 * 1000;
|
|
|
38
38
|
const REQUEST_MS = 5 * 60 * 1000;
|
|
39
39
|
/** a document still being elaborated that Lean has said nothing about for this long has stopped,
|
|
40
40
|
* whatever Lean says: it is opened again, as a crashed one (loading a large project's imports can
|
|
41
|
-
* take minutes on a machine short of memory, in which Lean says nothing; on an idle one
|
|
42
|
-
*
|
|
41
|
+
* take minutes on a machine short of memory, in which Lean says nothing; on an idle one
|
|
42
|
+
* Sphere-Packing-Lean's main theorem's module answers in 2 s) */
|
|
43
43
|
const STALL_MS = 10 * 60 * 1000;
|
|
44
44
|
/** crashes in a row (the document never elaborated to its end between them) after which a document
|
|
45
45
|
* is not opened again until GIVE_UP_MS have passed */
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// The graph's layouts, off the server's thread: computeLayout then stabilize, for each clustering in
|
|
2
|
-
// a worker of its own, both at once. Laid out on the server's thread,
|
|
2
|
+
// a worker of its own, both at once. Laid out on the server's thread, Sphere-Packing-Lean's graph held
|
|
3
3
|
// every request for seconds (2.5 s a clustering, more against a memory far off); a worker holds
|
|
4
4
|
// none. A worker is given what a layout reads of each declaration, the edges and the memory, as
|
|
5
5
|
// the page's own (web/src/layoutworker.ts), and gives the layout back. This module is its own
|
package/dist/server/live.js
CHANGED
|
@@ -913,6 +913,12 @@ export class Live {
|
|
|
913
913
|
}
|
|
914
914
|
return made;
|
|
915
915
|
}
|
|
916
|
+
/** The holder of a personal link in force who goes by `name` (CollabSpec decision 9): whom an
|
|
917
|
+
* opinion recorded for that name is for (ReviewSpec decision 8). */
|
|
918
|
+
invited(name) {
|
|
919
|
+
const r = this.invites.holder(name);
|
|
920
|
+
return r ? { id: r.id, name: r.name } : undefined;
|
|
921
|
+
}
|
|
916
922
|
/** The usage pass's contexts of a module (shared/context.ts), when the graph on show has them. */
|
|
917
923
|
contextsOf(module) {
|
|
918
924
|
return this.graph?.prep.usage?.contexts[module] ?? null;
|