visualproof 0.2.0 → 0.4.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 +88 -33
- package/dist/assets/tips/phone-fingers.light.mp4 +0 -0
- package/dist/assets/tips/phone-fingers.light.webp +0 -0
- package/dist/assets/tips/tour-agent.webp +0 -0
- package/dist/assets/tips/tour-arrange.webp +0 -0
- package/dist/assets/tips/tour-artwork.webp +0 -0
- package/dist/assets/tips/tour-checks.webp +0 -0
- package/dist/assets/tips/tour-export.webp +0 -0
- package/dist/assets/tips/tour-keys.webp +0 -0
- package/dist/assets/tips/tour-library.webp +0 -0
- package/dist/assets/tips/tour-project.webp +0 -0
- package/dist/assets/tips/tour-review.webp +0 -0
- package/dist/assets/tips/tour-share.webp +0 -0
- package/dist/assets/tips/tour-source.webp +0 -0
- package/dist/assets/tips/tour-target.webp +0 -0
- package/dist/assets/tips/tour-view.webp +0 -0
- package/dist/assets/tips/tour.json +289 -0
- package/dist/assets/tips/tour.webp +0 -0
- 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/mcp/visualproof-mcp.mjs +3 -1
- package/dist/server/agent.js +94 -91
- package/dist/server/agentdocs.js +1 -1
- package/dist/server/artcmd.js +335 -0
- package/dist/server/children.js +85 -0
- package/dist/server/doctor.js +105 -42
- package/dist/server/events.js +10 -0
- package/dist/server/export.js +11 -4
- package/dist/server/extract.js +3 -2
- package/dist/server/figures.js +3 -2
- package/dist/server/film.js +380 -0
- package/dist/server/history.js +11 -1
- package/dist/server/hover.js +8 -5
- package/dist/server/live.js +72 -9
- package/dist/server/main.js +352 -23
- package/dist/server/mcptools.js +4 -1
- package/dist/server/media.js +114 -0
- package/dist/server/pipeline.js +21 -10
- package/dist/server/poppler.js +3 -2
- package/dist/server/readonlygate.js +4 -4
- package/dist/server/review.js +4 -1
- package/dist/server/reviewapi.js +35 -1
- package/dist/server/snapshot.js +10 -1
- package/dist/server/sourcestore.js +3 -2
- package/dist/server/status.js +3 -2
- package/dist/server/supervise.js +219 -0
- package/dist/server/updates.js +951 -0
- package/dist/server/usage.js +3 -1
- package/dist/server/version.js +183 -0
- 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/shared/timeline.js +5 -0
- package/dist/web/bundle/app.js +3006 -413
- package/dist/web/bundle/edges.js +1 -1
- package/dist/web/index.html +13 -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 +3716 -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 +196 -1
- package/docs/agent-api/README.md +1 -1
- package/docs/agent-api/export.md +41 -2
- package/docs/agent-api/history.md +5 -3
- package/docs/agent-api/mcp.md +1 -1
- package/docs/agent-api/reading.md +2 -1
- package/package.json +5 -1
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// The programs the server runs and waits for — the extractor and the Lean programs' build, the
|
|
2
|
+
// usage pass's `lake env`, Lean's language server, XeLaTeX, ImageMagick, Poppler, git, ps — kept
|
|
3
|
+
// here while they run, so that a server stopping stops them (UpdateSpec decision 9) rather than
|
|
4
|
+
// leave them working for nobody. Those that start programs of their own (Lake starts Lean, Lean's
|
|
5
|
+
// server its workers, XeLaTeX kpathsea's, ImageMagick its delegates) run in a process group of
|
|
6
|
+
// their own (`grouped`, spawn's `detached`), and are stopped with all they started; what is left
|
|
7
|
+
// at the server's exit is ended at once. The usage pass's workers have a stop of their own
|
|
8
|
+
// (server/usage.ts).
|
|
9
|
+
const running = new Set();
|
|
10
|
+
/** the children that lead a process group of their own */
|
|
11
|
+
const groups = new WeakSet();
|
|
12
|
+
/** spawn's `detached` for a program run as a group (`grouped`): its own process group, where the
|
|
13
|
+
* platform has them */
|
|
14
|
+
export const DETACH = process.platform !== "win32";
|
|
15
|
+
/** Keep `child` as `tracked` does, as the leader of a process group of its own: spawned with
|
|
16
|
+
* `detached: DETACH`, and stopped with every program it started. */
|
|
17
|
+
export function grouped(child) {
|
|
18
|
+
if (DETACH)
|
|
19
|
+
groups.add(child);
|
|
20
|
+
return tracked(child);
|
|
21
|
+
}
|
|
22
|
+
/** End `child` with `signal`: where it leads a process group, the whole group. */
|
|
23
|
+
export function end(child, signal = "SIGTERM") {
|
|
24
|
+
if (groups.has(child) && child.pid && child.exitCode === null) {
|
|
25
|
+
try {
|
|
26
|
+
process.kill(-child.pid, signal);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// the group is gone: the child alone, if it is not
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
child.kill(signal);
|
|
34
|
+
}
|
|
35
|
+
// what still runs as the server's process ends goes with it
|
|
36
|
+
process.once("exit", () => {
|
|
37
|
+
for (const c of running)
|
|
38
|
+
end(c, "SIGKILL");
|
|
39
|
+
});
|
|
40
|
+
/** Keep `child` until it exits; the same child, for `spawn(…)` to be wrapped in place. */
|
|
41
|
+
export function tracked(child) {
|
|
42
|
+
running.add(child);
|
|
43
|
+
const gone = () => running.delete(child);
|
|
44
|
+
child.once("exit", gone);
|
|
45
|
+
child.once("error", gone);
|
|
46
|
+
return child;
|
|
47
|
+
}
|
|
48
|
+
/** The environment a program the server runs is given: the server's own, without what is the
|
|
49
|
+
* server's alone (`VISUALPROOF_*`: the supervisor's word to it, its test hooks; UpdateSpec decision
|
|
50
|
+
* 9), with `extra` over it. */
|
|
51
|
+
export function childEnv(extra = {}) {
|
|
52
|
+
const env = {};
|
|
53
|
+
for (const [k, v] of Object.entries(process.env))
|
|
54
|
+
if (!k.startsWith("VISUALPROOF_"))
|
|
55
|
+
env[k] = v;
|
|
56
|
+
return { ...env, ...extra };
|
|
57
|
+
}
|
|
58
|
+
/** How many tracked programs run now. */
|
|
59
|
+
export function childCount() {
|
|
60
|
+
return running.size;
|
|
61
|
+
}
|
|
62
|
+
/** Ask every tracked program to end (SIGTERM), and end those still there after `graceMs`
|
|
63
|
+
* (SIGKILL). Resolves once none runs, or at the end of the grace; the process waits for it. */
|
|
64
|
+
export function stopChildren(graceMs = 2000) {
|
|
65
|
+
if (!running.size)
|
|
66
|
+
return Promise.resolve();
|
|
67
|
+
for (const c of running)
|
|
68
|
+
end(c, "SIGTERM");
|
|
69
|
+
return new Promise((resolve) => {
|
|
70
|
+
const t0 = Date.now();
|
|
71
|
+
const tick = setInterval(() => {
|
|
72
|
+
if (!running.size) {
|
|
73
|
+
clearInterval(tick);
|
|
74
|
+
return resolve();
|
|
75
|
+
}
|
|
76
|
+
if (Date.now() - t0 >= graceMs) {
|
|
77
|
+
for (const c of running)
|
|
78
|
+
end(c, "SIGKILL");
|
|
79
|
+
running.clear();
|
|
80
|
+
clearInterval(tick);
|
|
81
|
+
resolve();
|
|
82
|
+
}
|
|
83
|
+
}, 50);
|
|
84
|
+
});
|
|
85
|
+
}
|
package/dist/server/doctor.js
CHANGED
|
@@ -1,52 +1,25 @@
|
|
|
1
1
|
// `visualproof doctor [<lean-project>]`: what this machine, and a project, need for VisualProof to
|
|
2
|
-
// run, each checked, with what to do where it falls short
|
|
3
|
-
//
|
|
2
|
+
// run, each checked, with what to do where it falls short; and whether a newer VisualProof is out,
|
|
3
|
+
// asked of the npm registry through npm (UpdateSpec decision 1). It changes nothing: it runs no Lake
|
|
4
|
+
// (in a project's folder that would install its toolchain, and Lake may build), and makes no folder.
|
|
4
5
|
import { spawnSync } from "node:child_process";
|
|
5
|
-
import { accessSync, constants, existsSync, readFileSync } from "node:fs";
|
|
6
|
+
import { accessSync, constants, existsSync, readFileSync, statSync } from "node:fs";
|
|
7
|
+
import { filmPin } from "./film.js";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
6
9
|
import { dirname, join, relative, resolve } from "node:path";
|
|
7
|
-
import { projectDirs, siteHolder } from "./dirs.js";
|
|
10
|
+
import { cacheHome, projectDirs, siteHolder } from "./dirs.js";
|
|
11
|
+
import { cmp, packageVersion, parseVersion, satisfies } from "./version.js";
|
|
12
|
+
import { choose, FROM_PACKAGE, installCommand, newerThan, newest, notesOf, npmCommand, PACKAGE_ROOT, readInfo, sameMinor, Versions } from "./updates.js";
|
|
13
|
+
export { packageVersion, satisfies };
|
|
8
14
|
import { ProjectError, openProject, targetsInWords } from "./project.js";
|
|
9
|
-
export const run = (cmd, args) => {
|
|
10
|
-
const r = spawnSync(cmd, args, { encoding: "utf8", timeout: 30_000, stdio: ["ignore", "pipe", "pipe"] });
|
|
15
|
+
export const run = (cmd, args, cwd) => {
|
|
16
|
+
const r = spawnSync(cmd, args, { encoding: "utf8", timeout: 30_000, stdio: ["ignore", "pipe", "pipe"], ...(cwd ? { cwd } : {}) });
|
|
11
17
|
return r.error || r.status !== 0 ? null : `${r.stdout}${r.stderr}`;
|
|
12
18
|
};
|
|
13
19
|
/** The package's own file, read where both a checkout and the installed package have it. */
|
|
14
20
|
function packageFile(rel) {
|
|
15
21
|
return join(import.meta.dirname, "..", "..", rel);
|
|
16
22
|
}
|
|
17
|
-
export function packageVersion() {
|
|
18
|
-
return JSON.parse(readFileSync(packageFile("package.json"), "utf8")).version;
|
|
19
|
-
}
|
|
20
|
-
function parseVersion(v) {
|
|
21
|
-
const m = /(\d+)\.(\d+)\.(\d+)/.exec(v);
|
|
22
|
-
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
|
|
23
|
-
}
|
|
24
|
-
function cmp(a, b) {
|
|
25
|
-
for (let i = 0; i < 3; i++)
|
|
26
|
-
if (a[i] !== b[i])
|
|
27
|
-
return a[i] - b[i];
|
|
28
|
-
return 0;
|
|
29
|
-
}
|
|
30
|
-
/** Whether a version meets a range of the forms package.json's `engines` uses: alternatives
|
|
31
|
-
* joined by `||`, each of `^x.y.z`, `>=x.y.z`, `<x.y.z`, joined by spaces. */
|
|
32
|
-
export function satisfies(version, range) {
|
|
33
|
-
const v = parseVersion(version);
|
|
34
|
-
if (!v)
|
|
35
|
-
return false;
|
|
36
|
-
return range.split("||").some((alt) => alt.trim().split(/\s+/).every((part) => {
|
|
37
|
-
const m = /^(\^|>=|<|)(\d+\.\d+\.\d+)$/.exec(part);
|
|
38
|
-
if (!m)
|
|
39
|
-
return false;
|
|
40
|
-
const w = parseVersion(m[2]);
|
|
41
|
-
if (m[1] === "^")
|
|
42
|
-
return v[0] === w[0] && cmp(v, w) >= 0;
|
|
43
|
-
if (m[1] === ">=")
|
|
44
|
-
return cmp(v, w) >= 0;
|
|
45
|
-
if (m[1] === "<")
|
|
46
|
-
return cmp(v, w) < 0;
|
|
47
|
-
return cmp(v, w) === 0;
|
|
48
|
-
}));
|
|
49
|
-
}
|
|
50
23
|
/** A toolchain as elan names it: `leanprover/lean4:v4.33.1`, whether the project writes it so, or
|
|
51
24
|
* as a bare version, or elan lists it as its folder's name (`leanprover--lean4---v4.33.1`). */
|
|
52
25
|
export function toolchainName(t) {
|
|
@@ -92,6 +65,93 @@ function install(what, platform) {
|
|
|
92
65
|
return platform === "darwin" ? "brew install poppler" : "install poppler-utils (Debian, Ubuntu: sudo apt install poppler-utils)";
|
|
93
66
|
return platform === "darwin" ? "install MacTeX, or brew install --cask mactex-no-gui" : "install TeX Live's XeLaTeX (Debian, Ubuntu: sudo apt install texlive-xetex texlive-latex-extra)";
|
|
94
67
|
}
|
|
68
|
+
/** What the registry says of the releases beyond this one, in a line (UpdateSpec decisions 1–4, 7):
|
|
69
|
+
* asked through npm under this Node, unless updates are off (VISUALPROOF_UPDATES=off); with what
|
|
70
|
+
* this machine's servers set aside. */
|
|
71
|
+
function updatesLine(exec, node, fromPackage, cacheRoot, mode, site) {
|
|
72
|
+
const running = packageVersion();
|
|
73
|
+
if (!fromPackage)
|
|
74
|
+
return "a checkout: its updates are git's, not npm's";
|
|
75
|
+
if (mode === "off")
|
|
76
|
+
return "not asked (VISUALPROOF_UPDATES=off)";
|
|
77
|
+
// what is set aside now (for the project's site, where one is checked), and where its patches are paused
|
|
78
|
+
const kept = new Versions(cacheRoot);
|
|
79
|
+
const held = kept.reasons(Date.now(), site);
|
|
80
|
+
const paused = kept.paused(site);
|
|
81
|
+
const bad = new Map(held.map((h) => [h.version, h]));
|
|
82
|
+
const aside = (held.length ? `; set aside here: ${held.map((h) => `${h.version} (${h.why})`).join(", ")}` : "") +
|
|
83
|
+
(paused ? `; this site's patches up to ${paused.upTo} are not taken (${paused.why})` : "");
|
|
84
|
+
const { cmd, args } = npmCommand();
|
|
85
|
+
// run where no project's .npmrc applies: the caches' root, else the temporary folder
|
|
86
|
+
const cwd = existsSync(cacheRoot) ? cacheRoot : tmpdir();
|
|
87
|
+
const ask = (more) => exec(cmd, [...args, ...more, "--fetch-timeout=15000", "--fetch-retries=1"], cwd);
|
|
88
|
+
// npm's own warnings come after its answer: the answer alone is read
|
|
89
|
+
const json = (out, re) => {
|
|
90
|
+
const m = out === null ? null : re.exec(out)?.[0];
|
|
91
|
+
try {
|
|
92
|
+
return m ? JSON.parse(m) : undefined;
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
const v = json(ask(["view", "visualproof", "versions", "--json"]), /\[[^\]]*\]|"[^"\n]*"/);
|
|
99
|
+
if (v === undefined)
|
|
100
|
+
return `the npm registry could not be asked (offline, or npm not found): not checked${aside}`;
|
|
101
|
+
const versions = (Array.isArray(v) ? v : [v]).filter((x) => typeof x === "string");
|
|
102
|
+
// the engines of the newest beyond this one: those the doctor would name
|
|
103
|
+
const engines = new Map();
|
|
104
|
+
const deprecated = new Set();
|
|
105
|
+
const first = choose(running, versions, { bad: new Set(bad.keys()) });
|
|
106
|
+
for (const c of [first.patch, first.release]) {
|
|
107
|
+
if (!c || engines.has(c))
|
|
108
|
+
continue;
|
|
109
|
+
const out = ask(["view", `visualproof@${c}`, "engines", "deprecated", "--json"]);
|
|
110
|
+
const info = out === null ? null : readInfo(/^[\s\S]*?(\{[\s\S]*\}|"[^"\n]*")/.exec(out)?.[1] ?? "");
|
|
111
|
+
if (!info)
|
|
112
|
+
continue;
|
|
113
|
+
engines.set(c, info.engines);
|
|
114
|
+
if (info.deprecated)
|
|
115
|
+
deprecated.add(c);
|
|
116
|
+
}
|
|
117
|
+
// only versions npm said the engines and deprecation of are named: one it could not say of is not
|
|
118
|
+
const { patch, release } = choose(running, versions.filter((x) => engines.has(x)), { node, bad: new Set(bad.keys()), engines: (x) => engines.get(x), deprecated });
|
|
119
|
+
const skipped = newest(versions.filter((x) => newerThan(x, running)));
|
|
120
|
+
const unsaid = [first.patch, first.release].find((c) => c && !engines.has(c));
|
|
121
|
+
if (release && !sameMinor(release, running))
|
|
122
|
+
return `${release} is out: ${installCommand(PACKAGE_ROOT, release)} (${notesOf(release)}); a server restarts into it by itself${aside}`;
|
|
123
|
+
// the pause is said in `aside`: no server here takes this one
|
|
124
|
+
if (patch && paused && !newerThan(patch, paused.upTo))
|
|
125
|
+
return `${patch} is out, not taken${aside}, for a day or until a newer patch`;
|
|
126
|
+
if (patch)
|
|
127
|
+
return `${patch} is out: a server takes it by itself (--updates auto), or ${installCommand(PACKAGE_ROOT, patch)}${aside}`;
|
|
128
|
+
if (unsaid)
|
|
129
|
+
return `${unsaid} is out, but npm could not say what it needs: not checked${aside}`;
|
|
130
|
+
if (skipped && deprecated.has(skipped))
|
|
131
|
+
return `${skipped} is out, but deprecated by its publisher${aside}`;
|
|
132
|
+
if (skipped && engines.has(skipped))
|
|
133
|
+
return `${skipped} is out, but needs Node ${engines.get(skipped)}; this is ${node}${aside}`;
|
|
134
|
+
return `the latest release${aside}`;
|
|
135
|
+
}
|
|
136
|
+
/** The tips' tour film, in a line: kept in the cache, or fetched on its first play, or never. */
|
|
137
|
+
function filmLine(cacheRoot, mode) {
|
|
138
|
+
if (process.env.VISUALPROOF_TOUR_FILM)
|
|
139
|
+
return `the local file ${process.env.VISUALPROOF_TOUR_FILM} (VISUALPROOF_TOUR_FILM)`;
|
|
140
|
+
const pin = filmPin();
|
|
141
|
+
if (!pin)
|
|
142
|
+
return "none pinned in this package";
|
|
143
|
+
const kept = join(cacheRoot, "media", `${pin.sha256}.mp4`);
|
|
144
|
+
try {
|
|
145
|
+
if (statSync(kept).size === pin.size)
|
|
146
|
+
return `kept (${kept})`;
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
// not kept yet
|
|
150
|
+
}
|
|
151
|
+
if (mode === "off")
|
|
152
|
+
return "never fetched (VISUALPROOF_UPDATES=off): the tour shows its stills and captions";
|
|
153
|
+
return `fetched from the project's GitHub release on its first play (${(pin.size / 1e6).toFixed(0)} MB)`;
|
|
154
|
+
}
|
|
95
155
|
export function doctor(o) {
|
|
96
156
|
const exec = o.run ?? run;
|
|
97
157
|
const node = o.node ?? process.versions.node;
|
|
@@ -121,8 +181,10 @@ export function doctor(o) {
|
|
|
121
181
|
if (exec("git", ["--version"]) === null) {
|
|
122
182
|
checks.push({ level: "info", what: "git not found: builds are kept without their commit, and past sources only as the builds kept them" });
|
|
123
183
|
}
|
|
184
|
+
const film = filmLine(o.cache ? resolve(o.cache) : cacheHome(), o.updates ?? process.env.VISUALPROOF_UPDATES);
|
|
185
|
+
const updates = updatesLine(exec, node, o.fromPackage ?? FROM_PACKAGE, o.cache ? resolve(o.cache) : cacheHome(), o.updates ?? process.env.VISUALPROOF_UPDATES, o.path === null ? null : projectDirs(resolve(o.path), o.site, o.cache).site);
|
|
124
186
|
if (o.path === null)
|
|
125
|
-
return { checks, ready: null };
|
|
187
|
+
return { checks, updates, film, ready: null };
|
|
126
188
|
let project;
|
|
127
189
|
try {
|
|
128
190
|
project = openProject(o.path, o.roots, false);
|
|
@@ -131,7 +193,7 @@ export function doctor(o) {
|
|
|
131
193
|
if (!(e instanceof ProjectError))
|
|
132
194
|
throw e;
|
|
133
195
|
checks.push({ level: "fail", what: e.message });
|
|
134
|
-
return { checks, ready: null };
|
|
196
|
+
return { checks, updates, film, ready: null };
|
|
135
197
|
}
|
|
136
198
|
const { root, toolchain, roots, libDir } = project;
|
|
137
199
|
const name = toolchainName(toolchain);
|
|
@@ -168,7 +230,7 @@ export function doctor(o) {
|
|
|
168
230
|
: { level: "info", what: `site ${dirs.site}: a server (process ${holder}) is serving it; another needs a --site of its own` });
|
|
169
231
|
const failed = checks.some((c) => c.level === "fail");
|
|
170
232
|
const args = [o.path, ...o.roots.flatMap((r) => ["--root", r]), ...(o.site ? ["--site", o.site] : []), ...(o.cache ? ["--cache", o.cache] : [])];
|
|
171
|
-
return { checks, ready: failed ? null : `visualproof serve ${args.map((a) => (/^[\w./:@%+=-]+$/.test(a) ? a : JSON.stringify(a))).join(" ")}` };
|
|
233
|
+
return { checks, updates, film, ready: failed ? null : `visualproof serve ${args.map((a) => (/^[\w./:@%+=-]+$/.test(a) ? a : JSON.stringify(a))).join(" ")}` };
|
|
172
234
|
}
|
|
173
235
|
const MARK = { ok: "✓", fail: "✗", warn: "!", info: "·" };
|
|
174
236
|
export function printReport(r, project) {
|
|
@@ -178,6 +240,7 @@ export function printReport(r, project) {
|
|
|
178
240
|
if (c.fix && c.level !== "ok")
|
|
179
241
|
lines.push(` ${c.fix}`);
|
|
180
242
|
}
|
|
243
|
+
lines.push(` updates: ${r.updates}`, ` tour film: ${r.film}`);
|
|
181
244
|
if (r.ready)
|
|
182
245
|
lines.push("", `Ready: ${r.ready}`);
|
|
183
246
|
else if (project === null && !r.checks.some((c) => c.level === "fail"))
|
package/dist/server/events.js
CHANGED
|
@@ -77,6 +77,16 @@ export class Timeline {
|
|
|
77
77
|
renameSync(tmp, this.file);
|
|
78
78
|
}
|
|
79
79
|
/** Record an event, and tell every page. */
|
|
80
|
+
/** The VisualProof version the timeline last recorded as serving the site (UpdateSpec decision
|
|
81
|
+
* 10), or null where none is kept. */
|
|
82
|
+
lastVersion() {
|
|
83
|
+
for (let i = this.events.length - 1; i >= 0; i--) {
|
|
84
|
+
const e = this.events[i];
|
|
85
|
+
if (e.kind === "tool")
|
|
86
|
+
return e.to;
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
80
90
|
add(e) {
|
|
81
91
|
const full = { ...e, id: (this.events[this.events.length - 1]?.id ?? 0) + 1, at: new Date().toISOString() };
|
|
82
92
|
this.events.push(full);
|