visualproof 0.0.0-stage → 0.1.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.
Files changed (128) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +99 -2
  3. package/dist/assets/fonts/JuliaMono-Regular.ttf +0 -0
  4. package/dist/assets/fonts/JuliaMono-RegularItalic.ttf +0 -0
  5. package/dist/assets/fonts/JuliaMono-SemiBold.ttf +0 -0
  6. package/dist/assets/fonts/LICENSE-JuliaMono.txt +93 -0
  7. package/dist/assets/latex/visualproof.sty +308 -0
  8. package/dist/extractor/Extract.lean +1180 -0
  9. package/dist/extractor/Usage.lean +900 -0
  10. package/dist/mcp/visualproof-mcp.mjs +476 -0
  11. package/dist/server/access.js +95 -0
  12. package/dist/server/agent.js +3235 -0
  13. package/dist/server/agentdocs.js +35 -0
  14. package/dist/server/agentlibrary.js +202 -0
  15. package/dist/server/agentnotes.js +701 -0
  16. package/dist/server/agentviews.js +703 -0
  17. package/dist/server/askfollow.js +286 -0
  18. package/dist/server/boardspots.js +159 -0
  19. package/dist/server/dirs.js +175 -0
  20. package/dist/server/doctor.js +186 -0
  21. package/dist/server/events.js +198 -0
  22. package/dist/server/export.js +420 -0
  23. package/dist/server/extract.js +204 -0
  24. package/dist/server/fetchurl.js +321 -0
  25. package/dist/server/figures.js +158 -0
  26. package/dist/server/history.js +741 -0
  27. package/dist/server/historyapi.js +304 -0
  28. package/dist/server/hover.js +595 -0
  29. package/dist/server/identity.js +114 -0
  30. package/dist/server/invites.js +241 -0
  31. package/dist/server/layoutthread.js +51 -0
  32. package/dist/server/library.js +600 -0
  33. package/dist/server/libraryroute.js +211 -0
  34. package/dist/server/live.js +1101 -0
  35. package/dist/server/lostfound.js +199 -0
  36. package/dist/server/main.js +1394 -0
  37. package/dist/server/mcptools.js +497 -0
  38. package/dist/server/pastdoc.js +73 -0
  39. package/dist/server/pipeline.js +156 -0
  40. package/dist/server/poppler.js +251 -0
  41. package/dist/server/presence.js +252 -0
  42. package/dist/server/project.js +222 -0
  43. package/dist/server/prompt.js +53 -0
  44. package/dist/server/readonly.js +152 -0
  45. package/dist/server/readonlygate.js +123 -0
  46. package/dist/server/renames.js +268 -0
  47. package/dist/server/review.js +334 -0
  48. package/dist/server/reviewapi.js +188 -0
  49. package/dist/server/snapshot.js +49 -0
  50. package/dist/server/sourcestore.js +473 -0
  51. package/dist/server/status.js +360 -0
  52. package/dist/server/talk.js +476 -0
  53. package/dist/server/tokens.js +221 -0
  54. package/dist/server/usage.js +501 -0
  55. package/dist/server/versions.js +82 -0
  56. package/dist/server/view.js +157 -0
  57. package/dist/server/views.js +1167 -0
  58. package/dist/shared/askfollow.js +46 -0
  59. package/dist/shared/board.js +332 -0
  60. package/dist/shared/build.js +2 -0
  61. package/dist/shared/collab.js +148 -0
  62. package/dist/shared/comment.js +285 -0
  63. package/dist/shared/context.js +157 -0
  64. package/dist/shared/doc/authors.js +98 -0
  65. package/dist/shared/doc/build.js +358 -0
  66. package/dist/shared/doc/latex.js +455 -0
  67. package/dist/shared/doc/markdown.js +159 -0
  68. package/dist/shared/doc/model.js +37 -0
  69. package/dist/shared/doc/order.js +107 -0
  70. package/dist/shared/doc/proof.js +272 -0
  71. package/dist/shared/doc/sections.js +93 -0
  72. package/dist/shared/doc/settings.js +75 -0
  73. package/dist/shared/edgecodec.js +102 -0
  74. package/dist/shared/editor.js +64 -0
  75. package/dist/shared/excerpt.js +87 -0
  76. package/dist/shared/graph.js +16 -0
  77. package/dist/shared/items.js +92 -0
  78. package/dist/shared/keys.js +203 -0
  79. package/dist/shared/layout.js +926 -0
  80. package/dist/shared/lexer.js +130 -0
  81. package/dist/shared/library.js +711 -0
  82. package/dist/shared/lostfound.js +150 -0
  83. package/dist/shared/moves.js +243 -0
  84. package/dist/shared/notecard.js +295 -0
  85. package/dist/shared/noteplace.js +505 -0
  86. package/dist/shared/palette.js +46 -0
  87. package/dist/shared/pastgraph.js +95 -0
  88. package/dist/shared/places.js +80 -0
  89. package/dist/shared/projectnotes.js +244 -0
  90. package/dist/shared/query.js +810 -0
  91. package/dist/shared/review.js +642 -0
  92. package/dist/shared/reviewqueue.js +333 -0
  93. package/dist/shared/ring.js +366 -0
  94. package/dist/shared/scope.js +255 -0
  95. package/dist/shared/scopeedit.js +81 -0
  96. package/dist/shared/search.js +191 -0
  97. package/dist/shared/separate.js +178 -0
  98. package/dist/shared/sharesnap.js +280 -0
  99. package/dist/shared/sticky.js +501 -0
  100. package/dist/shared/timeline.js +73 -0
  101. package/dist/shared/titles.js +298 -0
  102. package/dist/shared/viewgraph.js +139 -0
  103. package/dist/shared/views.js +859 -0
  104. package/dist/web/bundle/app.js +48733 -0
  105. package/dist/web/bundle/edges.js +409 -0
  106. package/dist/web/bundle/layout.js +1010 -0
  107. package/dist/web/bundle/pdfworker.js +48322 -0
  108. package/dist/web/index.html +239 -0
  109. package/dist/web/src/motion.js +430 -0
  110. package/dist/web/src/past.js +224 -0
  111. package/dist/web/src/scene.js +787 -0
  112. package/dist/web/style.css +2312 -0
  113. package/docs/agent-api/README.md +91 -0
  114. package/docs/agent-api/advice.md +108 -0
  115. package/docs/agent-api/export.md +120 -0
  116. package/docs/agent-api/following.md +140 -0
  117. package/docs/agent-api/history.md +142 -0
  118. package/docs/agent-api/library.md +65 -0
  119. package/docs/agent-api/mcp.md +104 -0
  120. package/docs/agent-api/notes.md +93 -0
  121. package/docs/agent-api/reading.md +166 -0
  122. package/docs/agent-api/requests.md +94 -0
  123. package/docs/agent-api/talking.md +100 -0
  124. package/docs/agent-api/tcb.md +135 -0
  125. package/docs/agent-api/views.md +124 -0
  126. package/docs/agent-api/words.md +56 -0
  127. package/package.json +39 -4
  128. package/test/toolchains.json +5 -0
@@ -0,0 +1,455 @@
1
+ // The exported document as LaTeX (ExportSpec decisions 6–8 and 13): the document model
2
+ // (shared/doc/model.ts) written as semantic macros and environments — what each thing is, a section,
3
+ // a declaration, its docstring, its code, a note and its figures — which the style file,
4
+ // assets/latex/visualproof.sty, typesets; a user replaces or extends that file to make the document
5
+ // their own. The model's order is the board's (the owner's decisions of 2026-10-12), so a section's
6
+ // own declarations and notes may follow one of its sections: they are headed again by the section's
7
+ // title, "continued", so that nothing reads as the section before's. Only the
8
+ // settings that are the document's own (paper, margins, sizes, whether the contents are printed)
9
+ // and VisualProof's palette (shared/palette.ts: the code's tokens, so that the document's code
10
+ // reads as the page's) are written here. The renderer numbers the sections (three levels; the
11
+ // paragraph-level headings below are not numbered), and writes the table of contents from them.
12
+ //
13
+ // Nothing that reaches the .tex is trusted: names, docstrings, comments, code and the title are
14
+ // what people, agents and source files said. Every one of them goes through `escapeLatex` (prose)
15
+ // or `escapeVerbatim` (code), which leave no character LaTeX would read as anything but text:
16
+ // `\input{/etc/passwd}` prints as written. The server compiles with -no-shell-escape and paranoid
17
+ // file access besides (server/export.ts), but the escaping is what keeps a document from doing
18
+ // anything at all.
19
+ //
20
+ // The .tex holds no time of its making (a past build's says when that build finished, which does not
21
+ // change): the same document asked for again is the same text, which the server's cache knows by its
22
+ // hash.
23
+ import { TOKEN_COLORS } from "../palette.js";
24
+ /** Code points the text font (Libertinus) may lack and JuliaMono has: superscripts and subscripts,
25
+ * letterlike symbols (ℕ ℝ), arrows, mathematical operators, technical symbols, enclosed
26
+ * alphanumerics, box drawing, blocks, geometric shapes, miscellaneous symbols and dingbats, the
27
+ * mathematical brackets (⟪ ⟫ ⦃ ⦄) and arrows, supplemental operators, and the mathematical
28
+ * alphanumerics (𝔽 𝒪). They are set in JuliaMono (`\vpSym`) wherever prose holds them. */
29
+ const SYMBOL_RANGES = [
30
+ [0x2070, 0x209f],
31
+ [0x2100, 0x214f],
32
+ [0x2190, 0x23ff],
33
+ [0x2460, 0x27bf],
34
+ [0x27c0, 0x2bff],
35
+ [0x1d400, 0x1d7ff],
36
+ ];
37
+ const isSymbol = (cp) => SYMBOL_RANGES.some(([a, b]) => cp >= a && cp <= b);
38
+ /** Characters that are never text: C0 and C1 controls, and the line and paragraph separators. */
39
+ const CONTROL = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u2028\u2029]/g;
40
+ /** Prose, a name or a line of the head card, as LaTeX that prints exactly that text: the ten
41
+ * characters LaTeX gives a meaning to are spelt out, control characters dropped, line breaks made
42
+ * spaces, the ligatures Libertinus would make of `--` and backquotes broken, and symbols the text
43
+ * font lacks set in JuliaMono. */
44
+ export function escapeLatex(s) {
45
+ let out = "";
46
+ let sym = "";
47
+ const flush = () => {
48
+ if (sym)
49
+ out += `\\vpSym{${sym}}`;
50
+ sym = "";
51
+ };
52
+ for (const ch of String(s).replace(CONTROL, "").replace(/[\r\n\t]+/g, " ")) {
53
+ const cp = ch.codePointAt(0);
54
+ if (isSymbol(cp)) {
55
+ sym += ch;
56
+ continue;
57
+ }
58
+ flush();
59
+ switch (ch) {
60
+ case "\\":
61
+ out += "\\textbackslash{}";
62
+ break;
63
+ case "{":
64
+ out += "\\{";
65
+ break;
66
+ case "}":
67
+ out += "\\}";
68
+ break;
69
+ case "$":
70
+ out += "\\$";
71
+ break;
72
+ case "&":
73
+ out += "\\&";
74
+ break;
75
+ case "#":
76
+ out += "\\#";
77
+ break;
78
+ case "%":
79
+ out += "\\%";
80
+ break;
81
+ case "_":
82
+ out += "\\_";
83
+ break;
84
+ case "^":
85
+ out += "\\textasciicircum{}";
86
+ break;
87
+ case "~":
88
+ out += "\\textasciitilde{}";
89
+ break;
90
+ case "`":
91
+ out += "\\textasciigrave{}";
92
+ break;
93
+ case "-":
94
+ // `--` would be a dash: the text keeps its hyphens
95
+ out += out.endsWith("-") ? "{}-" : "-";
96
+ break;
97
+ default:
98
+ out += ch;
99
+ }
100
+ }
101
+ flush();
102
+ return out;
103
+ }
104
+ /** A piece of code as the text of a line of `vpCode` (fvextra's Verbatim, its command characters
105
+ * `\ { }`): those three spelt out as macros, tabs made two spaces, control characters dropped.
106
+ * Everything else is printed as it is, in JuliaMono. */
107
+ export function escapeVerbatim(s) {
108
+ return String(s)
109
+ .replace(CONTROL, "")
110
+ .replace(/[\r\n]/g, "")
111
+ .replace(/\t/g, " ")
112
+ .replace(/[\\{}]/g, (c) => (c === "\\" ? "\\vpBS{}" : c === "{" ? "\\vpLB{}" : "\\vpRB{}"));
113
+ }
114
+ /** A full Lean name, breakable after each dot. */
115
+ export function escapeName(s) {
116
+ return String(s)
117
+ .split(".")
118
+ .map(escapeLatex)
119
+ .join(".\\vpBreak{}");
120
+ }
121
+ /** A file's path in the project, breakable after each slash. */
122
+ export function escapePath(s) {
123
+ return String(s)
124
+ .split("/")
125
+ .map(escapeLatex)
126
+ .join("/\\vpBreak{}");
127
+ }
128
+ /** An anchor as LaTeX labels and PDF destinations take it: letters, digits and `-` only, every
129
+ * other character spelt as its code, under a prefix of VisualProof's own: `vp:…`. A section's id
130
+ * starts `section:`, which no declaration's name does, so the two never meet. */
131
+ export function anchor(id) {
132
+ return "vp:" + [...String(id)].map((c) => (/[A-Za-z0-9-]/.test(c) ? c : `.${c.codePointAt(0).toString(16)}.`)).join("");
133
+ }
134
+ /** Text for a PDF bookmark: what a PDF outline shows, never markup. The characters LaTeX gives a
135
+ * meaning to are left out; the rest is kept, Unicode included. */
136
+ export function bookmarkText(s) {
137
+ return String(s).replace(CONTROL, "").replace(/[\\{}$&#^_%~`\r\n\t]/g, " ").replace(/\s+/g, " ").trim() || "?";
138
+ }
139
+ /** A count as the macros take it: a whole number, never negative. */
140
+ const int = (x) => (typeof x === "number" && Number.isFinite(x) && x > 0 ? Math.floor(x) : 0);
141
+ /** A colour of the palette as xcolor's HTML model takes it; grey for anything else. */
142
+ const hex = (c) => (/^#[0-9a-fA-F]{6}$/.test(c) ? c.slice(1).toUpperCase() : "AEAEB2");
143
+ /** The code's palette, as colour definitions after the style file's own. */
144
+ function palette() {
145
+ return Object.entries(TOKEN_COLORS).map(([k, c]) => `\\definecolor{vpTk${k}}{HTML}{${hex(c)}}`);
146
+ }
147
+ /** The macro each kind of token is printed with; plain text has none. */
148
+ const TOKEN_MACRO = {
149
+ keyword: "vpTkKeyword",
150
+ type: "vpTkType",
151
+ comment: "vpTkComment",
152
+ doc: "vpTkDoc",
153
+ string: "vpTkString",
154
+ number: "vpTkNumber",
155
+ attr: "vpTkAttr",
156
+ op: "vpTkOp",
157
+ sorry: "vpTkSorry",
158
+ elided: "vpTkElided",
159
+ };
160
+ /** One token of a line of code. A token's spaces stay outside its macro, so that a long comment
161
+ * or string can still be broken at them (fvextra breaks lines only between macros). */
162
+ function token([kind, text]) {
163
+ const macro = Object.hasOwn(TOKEN_MACRO, kind) ? TOKEN_MACRO[kind] : null;
164
+ if (!macro)
165
+ return escapeVerbatim(text);
166
+ return String(text)
167
+ .split(/( +)/)
168
+ .map((part) => (!part ? "" : /^ +$/.test(part) ? part : `\\${macro}{${escapeVerbatim(part)}}`))
169
+ .join("");
170
+ }
171
+ /** A block of code, in `vpCode`, without line numbers. How the proofs in it were found is not said:
172
+ * the export's notes say it (shared/doc/build.ts, `docNotes`). */
173
+ function code(c) {
174
+ if (!c.lines.length)
175
+ return "";
176
+ // a Verbatim environment reads up to its \end at the start of a line: nothing escaped can be one
177
+ return `\\begin{vpCode}\n${c.lines.map((l) => l.map(token).join("")).join("\n")}\n\\end{vpCode}`;
178
+ }
179
+ function inline(xs) {
180
+ return xs
181
+ .map((x) => {
182
+ // a declaration a note names, linked to where the document prints it, with its page
183
+ if (x.t === "decl")
184
+ return `\\vpMention{${anchor(x.id)}}{${escapeName(x.s)}}`;
185
+ if (x.t === "br")
186
+ return "\\newline ";
187
+ const t = escapeLatex(x.s);
188
+ return x.t === "code" ? `\\vpInlineCode{${t}}` : x.t === "em" ? `\\emph{${t}}` : x.t === "strong" ? `\\textbf{${t}}` : t;
189
+ })
190
+ .join("");
191
+ }
192
+ /** Prose (a docstring), block by block. */
193
+ function blocks(bs) {
194
+ return bs
195
+ .map((b) => {
196
+ if (b.t === "p")
197
+ return inline(b.inl);
198
+ if (b.t === "code")
199
+ return code(b.code);
200
+ return `\\begin{itemize}[nosep, leftmargin=1.2em]\n${b.items.map((it) => `\\item ${inline(it)}`).join("\n")}\n\\end{itemize}`;
201
+ })
202
+ .join("\n\n");
203
+ }
204
+ /** What is said under the code of a declaration of a past build whose text of then is not kept
205
+ * (ExportSpec decision 16): its statement as the build recorded it is printed instead. */
206
+ export const NOT_KEPT_NOTE = "Its source text at this build is not kept: printed is its statement, as the build recorded it.";
207
+ /** A declaration: its head, its full name and place, what it is, its code. `level`: its PDF
208
+ * bookmark's, one below its section's. */
209
+ function decl(d, level, refs = new Map()) {
210
+ const id = anchor(d.id);
211
+ const out = [];
212
+ out.push(`\\vpBookmark{${id}}{${int(level)}}{${escapeLatex(bookmarkText(d.short))}}`);
213
+ out.push(`\\begin{vpDecl}{${id}}{${escapeLatex(d.short)}}{${escapeLatex(d.kind)}}`);
214
+ out.push(`\\vpFullName{${escapeName(d.name)}}`);
215
+ out.push(`\\vpPlace{${escapeName(d.module)}}{${d.file ? escapePath(d.file) : ""}}{${d.line ? int(d.line) : ""}}`);
216
+ if (d.doc)
217
+ out.push(`\\begin{vpDocstring}`, blocks(d.doc), `\\end{vpDocstring}`);
218
+ out.push(code(d.code));
219
+ if (d.code.notKept)
220
+ out.push(`\\vpDeclNote{${escapeLatex(NOT_KEPT_NOTE)}}`);
221
+ if (d.papers)
222
+ out.push(papers(d.papers, refs));
223
+ // the notes attached to it, as its explanation
224
+ for (const e of d.notes ?? [])
225
+ out.push(note(e, refs));
226
+ out.push(`\\end{vpDecl}`);
227
+ return out.join("\n");
228
+ }
229
+ /** A note (the owner's decisions of 2026-10-12): its text as prose, then the images and clips it
230
+ * carries as figures, then the whole PDFs it carries as references. Who wrote it is not said: the
231
+ * author line names them already. */
232
+ function note(e, refs) {
233
+ const out = [`\\begin{vpNote}{${anchor(e.id)}}`];
234
+ if (e.text?.length)
235
+ out.push(blocks(e.text));
236
+ for (const f of e.figures ?? [])
237
+ out.push(figure(f, refs));
238
+ if (e.papers)
239
+ out.push(papers(e.papers, refs));
240
+ out.push(`\\end{vpNote}`);
241
+ return out.join("\n");
242
+ }
243
+ /** The resolution figures are drawn at for print, in dots per inch. */
244
+ export const PRINT_DPI = 300;
245
+ /** The widest a clip is asked to be drawn, in pixels: the renderer's widest. */
246
+ const CLIP_PX = 2000;
247
+ /** Figure `f`'s asset, the file it shows being of type `type`. */
248
+ export function figureAsset(f, type) {
249
+ const sha = f.file.replace(/[^0-9a-f]/g, "").slice(0, 64);
250
+ if (!f.region || !f.page)
251
+ return { name: `fig-${sha}.${type === "jpeg" ? "jpg" : "png"}`, file: f.file, type };
252
+ const px = f.size ? Math.min(CLIP_PX, Math.max(1, Math.ceil((f.size[0] / 72) * PRINT_DPI))) : CLIP_PX;
253
+ const r = f.region.map((v) => Math.round(Math.min(1, Math.max(0, v)) * 1e4));
254
+ return { name: `clip-${sha}-p${int(f.page)}-${r.join("-")}-w${px}.png`, file: f.file, type, page: int(f.page), region: f.region, px };
255
+ }
256
+ /** Every figure a document prints, each once, as what it is drawn from. */
257
+ export function figureAssets(doc) {
258
+ const types = new Map((doc.references ?? []).map((r) => [r.n, r.type]));
259
+ const out = new Map();
260
+ const walk = (xs) => {
261
+ for (const x of xs) {
262
+ const notes = x.t === "note" ? [x.note] : x.t === "decl" ? x.decl.notes ?? [] : [];
263
+ for (const e of notes)
264
+ for (const f of e.figures ?? []) {
265
+ const a = figureAsset(f, types.get(f.ref) ?? "png");
266
+ out.set(a.name, a);
267
+ }
268
+ if (x.t === "section")
269
+ walk(x.section.items);
270
+ }
271
+ };
272
+ walk(doc.items);
273
+ return [...out.values()];
274
+ }
275
+ /** A figure: an image at its own size, a clip at the size the paper prints it (`size`), neither wider
276
+ * than the text; its caption the file's title and a clip's page, numbered as the references are. */
277
+ function figure(f, refs) {
278
+ const r = refs.get(f.ref);
279
+ const a = figureAsset(f, r?.type ?? "png");
280
+ const width = f.size && Number.isFinite(f.size[0]) && f.size[0] > 0 ? `${Math.round(f.size[0] * 10) / 10}pt` : "";
281
+ return `\\vpFigure{figures/${a.name}}{${width}}{${escapeLatex(r?.title ?? "")}}{${f.page ? int(f.page) : ""}}{${int(f.ref)}}`;
282
+ }
283
+ /** The places in references something is linked to (ExportSpec decision 17), one line each:
284
+ * "Reference: Theorem 3 [title, p. 12]", the title the cited file's, numbered as the list at the
285
+ * end. */
286
+ function papers(ps, refs) {
287
+ return ps
288
+ .map((p) => {
289
+ const r = refs.get(p.ref);
290
+ return `\\vpPaper{${escapeLatex(p.label)}}{${escapeLatex(r?.title ?? "")}}{${p.page ? int(p.page) : ""}}{${int(p.ref)}}`;
291
+ })
292
+ .join("\n");
293
+ }
294
+ /** The files the document cites, at its end: each one's title, where it came from — the address it
295
+ * was fetched from, else the name it was uploaded under — and its hash, which names it for good. */
296
+ function references(rs) {
297
+ const from = (r) => (r.url ? `\\vpUrl{${escapePath(r.url)}}` : r.name ? `uploaded as ${escapeLatex(r.name)}` : "uploaded");
298
+ const what = (r) => (r.type === "pdf" ? (r.pages ? `PDF, ${int(r.pages)} page${int(r.pages) === 1 ? "" : "s"}` : "PDF") : `${escapeLatex(r.type.toUpperCase())} image`);
299
+ return [`\\begin{vpReferences}`, ...rs.map((r) => `\\vpReference{${int(r.n)}}{${escapeLatex(r.title)}}{${from(r)}}{${what(r)}}{${escapeLatex(r.sha.slice(0, 16))}}`), `\\end{vpReferences}`].join("\n");
300
+ }
301
+ /** Each section's number, by id: `1`, `1.2`, `1.2.3` for the first three levels; a paragraph-level
302
+ * heading has none. */
303
+ function numbers(doc) {
304
+ const out = new Map();
305
+ const walk = (xs, prefix) => sectionsOf(xs).forEach((s, k) => {
306
+ const n = prefix ? `${prefix}.${k + 1}` : `${k + 1}`;
307
+ if (s.level < 4)
308
+ out.set(s.id, n);
309
+ walk(s.items, s.level < 3 ? n : "");
310
+ });
311
+ walk(doc.items, "");
312
+ return out;
313
+ }
314
+ /** The sections among `xs`, in order. */
315
+ const sectionsOf = (xs) => xs.flatMap((x) => (x.t === "section" ? [x.section] : []));
316
+ /** A section, and what it holds: its heading (the cluster's key beneath it), its declarations, its
317
+ * sections. `depth` counts from 1 at the top, for the PDF's bookmarks, which nest as the sections
318
+ * do below the paragraph level too. */
319
+ function section(s, depth, n, refs = new Map()) {
320
+ const id = anchor(s.id);
321
+ const level = Math.min(4, Math.max(1, int(s.level)));
322
+ const out = [`\\vpSection{${id}}{${level}}{${n.get(s.id) ?? ""}}{${escapeLatex(s.title)}}{${escapeName(s.key.replace("⟨external⟩", "External"))}}{${int(depth - 1)}}{${escapeLatex(bookmarkText(s.title))}}`];
323
+ // the cluster's papers, under its heading
324
+ if (s.papers)
325
+ out.push(`\\begin{vpSectionPapers}`, papers(s.papers, refs), `\\end{vpSectionPapers}`);
326
+ // what it holds, in the board's order: its own declarations and notes after one of its sections
327
+ // are headed by its title again, so that they do not read as that section's
328
+ let after = false;
329
+ for (const x of s.items) {
330
+ if (x.t === "section") {
331
+ out.push(section(x.section, depth + 1, n, refs));
332
+ after = true;
333
+ continue;
334
+ }
335
+ if (after)
336
+ out.push(`\\vpSectionResume{${Math.min(4, level + 1)}}{${escapeLatex(s.title)}}`);
337
+ after = false;
338
+ out.push(x.t === "decl" ? decl(x.decl, depth, refs) : note(x.note, refs));
339
+ }
340
+ return out.join("\n");
341
+ }
342
+ /** The most declarations a document has for its contents to list them beside the sections. */
343
+ export const CONTENTS_DECLS = 60;
344
+ /** The table of contents (ExportSpec decision 8), where the settings print it: the sections, each
345
+ * with its number and page — every level where the document is short (at most CONTENTS_DECLS
346
+ * declarations), and then its declarations too, under their sections; else the first three
347
+ * levels. The style sets it in two columns, the body following on the same page. */
348
+ function contents(doc, n) {
349
+ const short = doc.count.declarations <= CONTENTS_DECLS;
350
+ const out = ["\\begin{vpContents}"];
351
+ const entry = (d, level) => out.push(`\\vpContentsDecl{${anchor(d.id)}}{${level}}{${escapeLatex(d.short)}}`);
352
+ const decls = (xs) => xs.flatMap((x) => (x.t === "decl" ? [x.decl] : []));
353
+ if (short)
354
+ for (const d of decls(doc.items))
355
+ entry(d, 1);
356
+ const walk = (xs) => {
357
+ for (const s of sectionsOf(xs)) {
358
+ if (!short && s.level > 3)
359
+ continue;
360
+ out.push(`\\vpContentsSection{${anchor(s.id)}}{${Math.min(4, Math.max(1, int(s.level)))}}{${n.get(s.id) ?? ""}}{${escapeLatex(s.title)}}`);
361
+ if (short)
362
+ for (const d of decls(s.items))
363
+ entry(d, Math.min(4, s.level) + 1);
364
+ walk(s.items);
365
+ }
366
+ };
367
+ walk(doc.items);
368
+ // the files the references cite, at the end, under "References" (ExportSpec decision 17)
369
+ if (doc.references?.length)
370
+ out.push(`\\vpContentsSection{vp-references}{1}{}{References}`);
371
+ out.push("\\end{vpContents}");
372
+ return out.join("\n");
373
+ }
374
+ /** What the view holds from outside the project, in words: `300 declarations from outside the
375
+ * project (Mathlib 203, Init 97)`, the six largest libraries named. */
376
+ function outsideLine(o) {
377
+ const n = int(o.count);
378
+ const named = o.by.slice(0, 6).map((b) => `${escapeLatex(b.name)} ${int(b.count)}`);
379
+ const rest = o.by.slice(6).reduce((a, b) => a + int(b.count), 0);
380
+ if (rest)
381
+ named.push(`others ${rest}`);
382
+ return `${n} declaration${n === 1 ? "" : "s"} from outside the project${named.length ? ` (${named.join(", ")})` : ""}, not printed`;
383
+ }
384
+ /** `n` declarations, in words. */
385
+ const decls = (n) => `${int(n)} declaration${int(n) === 1 ? "" : "s"}`;
386
+ /** What the head card says of a past build's texts (ExportSpec decision 16): that they are the ones
387
+ * that build compiled, how many were matched to it by its places alone, and how many are not kept,
388
+ * their statements printed instead. */
389
+ function textLine(p) {
390
+ const out = ["as this build compiled it"];
391
+ if (int(p.places))
392
+ out.push(`for ${decls(p.places)} matched to the build by the places it recorded, its hash not being kept`);
393
+ if (int(p.notKept))
394
+ out.push(`not kept for ${decls(p.notKept)}, whose statement${int(p.notKept) === 1 ? " is" : "s are"} printed instead`);
395
+ return out.join("; ");
396
+ }
397
+ /** The head card (ExportSpec decision 8): the project, the build and its commit where known, the
398
+ * toolchain, the trusted boundary, and what the view holds from outside the project. Of a past
399
+ * build (decision 16): when it finished, its commit by its short hash (and whether its files were
400
+ * edited from it), and how its texts were had. */
401
+ function head(doc) {
402
+ const h = doc.head;
403
+ const row = (label, value) => `\\vpCardRow{${label}}{${value}}`;
404
+ const mono = (s) => `\\vpMono{${escapeLatex(s)}}`;
405
+ const p = h.past;
406
+ const c = h.commit;
407
+ const commit = !c
408
+ ? []
409
+ : p
410
+ ? [row("Commit", `${c.guessed ? "probably " : ""}${mono(c.hash.slice(0, 7))}${c.branch ? ` on ${mono(c.branch)}` : ""}${c.dirty ? ", edited" : ""}`)]
411
+ : [row("Commit", `${mono(c.hash)}${c.branch ? ` on ${mono(c.branch)}` : ""}`)];
412
+ const rows = [
413
+ row("Project", `${escapeLatex(h.project)}${h.roots.length ? ` (${h.roots.map(mono).join(", ")})` : ""}`),
414
+ row("Build", p ? `${p.approx ? "about " : ""}${escapeLatex(p.when)}` : h.fingerprint ? mono(h.fingerprint) : "not known"),
415
+ ...commit,
416
+ ...(p ? [row("Source text", escapeLatex(textLine(p)))] : []),
417
+ row("Toolchain", mono(h.toolchain)),
418
+ row("Trusted without review", h.trusted.prefixes.length ? h.trusted.prefixes.map((p) => (p === "@external" ? "everything outside the project" : mono(p))).join(", ") : "nothing by prefix"),
419
+ row("Standard axioms", h.trusted.standardAxioms.map(mono).join(", ")),
420
+ ...(h.outside.count ? [row("Also on this view", outsideLine(h.outside))] : []),
421
+ ];
422
+ return `\\begin{vpCard}\n${rows.join("\n")}\n\\end{vpCard}`;
423
+ }
424
+ /** The whole .tex. */
425
+ export function renderLatex(doc) {
426
+ const s = doc.settings;
427
+ const title = doc.head.title || doc.view.title;
428
+ const n = numbers(doc);
429
+ const out = [
430
+ "% Made by VisualProof (ExportSpec.md). Typeset with XeLaTeX, twice:",
431
+ "% xelatex doc.tex && xelatex doc.tex",
432
+ "% The look is visualproof.sty's: replace it, or extend it with a package of your own loaded",
433
+ "% after it. Everything below is the document's content, escaped.",
434
+ `\\documentclass[${s.fontSize === 10 || s.fontSize === 12 ? s.fontSize : 11}pt]{article}`,
435
+ "\\usepackage{visualproof}",
436
+ `\\vpSetup{paper=${s.paper === "letter" ? "letter" : "a4"}, margin=${int(s.margin) || 22}mm, codesize=${Number.isFinite(s.codeSize) ? Math.min(12, Math.max(6, s.codeSize)) : 8.5}pt}`,
437
+ ...palette(),
438
+ `\\vpSetTitle{${escapeLatex(title)}}`,
439
+ `\\hypersetup{pdftitle={${escapeLatex(bookmarkText(title))}}${doc.head.author ? `, pdfauthor={${escapeLatex(bookmarkText(doc.head.author))}}` : ""}, pdfcreator={VisualProof}}`,
440
+ "\\begin{document}",
441
+ `\\vpTitle{${escapeLatex(title)}}{${escapeLatex(doc.head.author)}}`,
442
+ head(doc),
443
+ ...(s.contents ? [contents(doc, n)] : []),
444
+ ];
445
+ // the start — what sits in no cluster, the notes outside every cluster — under no heading, then
446
+ // the sections, all in the board's order
447
+ const refs = new Map((doc.references ?? []).map((r) => [r.n, r]));
448
+ for (const x of doc.items)
449
+ out.push(x.t === "decl" ? decl(x.decl, 0, refs) : x.t === "note" ? note(x.note, refs) : section(x.section, 1, n, refs));
450
+ // the files the references cite, at the end (ExportSpec decision 17)
451
+ if (doc.references?.length)
452
+ out.push(references(doc.references));
453
+ out.push("\\end{document}", "");
454
+ return out.join("\n");
455
+ }
@@ -0,0 +1,159 @@
1
+ // Prose for the exported document (shared/doc/model.ts): docstrings are Markdown, of which the
2
+ // document keeps what the page's panel renders (web/src/highlight.ts, renderDoc):
3
+ // paragraphs, code spans and fenced code blocks (highlighted as Lean), strong and emphasis — and
4
+ // lists, which docstrings use. Everything else stays text, as written: a `$…$` is not mathematics
5
+ // here, since no renderer may read what a writer typed as markup of its own. A note's text
6
+ // (`noteBlocks`) keeps what its card shows: its line breaks, and the declarations it names.
7
+ import { tokenizeLean, tokenLines } from "../lexer.js";
8
+ /** Lean source as a block of highlighted lines (shared/lexer.ts). */
9
+ export function codeOf(text, from = "source") {
10
+ const lines = tokenLines(tokenizeLean(text)).map((l) => l.map((t) => [t.kind, t.text]));
11
+ return { lines, proofElided: false, from };
12
+ }
13
+ /** A paragraph's text as inline pieces: `code`, **strong**, *emphasis* or _emphasis_. Markers that
14
+ * do not close are text. */
15
+ export function inlines(text) {
16
+ const out = [];
17
+ const push = (t, s) => {
18
+ if (!s)
19
+ return;
20
+ const last = out[out.length - 1];
21
+ if (t === "text" && last?.t === "text")
22
+ last.s += s;
23
+ else
24
+ out.push({ t, s });
25
+ };
26
+ let i = 0;
27
+ while (i < text.length) {
28
+ const c = text[i];
29
+ if (c === "`") {
30
+ // a code span: as many backquotes close it as opened it
31
+ let n = 1;
32
+ while (text[i + n] === "`")
33
+ n++;
34
+ const fence = "`".repeat(n);
35
+ const end = text.indexOf(fence, i + n);
36
+ if (end > i) {
37
+ push("code", text.slice(i + n, end).replace(/^ (.*) $/s, "$1"));
38
+ i = end + n;
39
+ continue;
40
+ }
41
+ push("text", fence);
42
+ i += n;
43
+ continue;
44
+ }
45
+ if (c === "*" && text[i + 1] === "*") {
46
+ const end = text.indexOf("**", i + 2);
47
+ if (end > i + 2) {
48
+ push("strong", text.slice(i + 2, end));
49
+ i = end + 2;
50
+ continue;
51
+ }
52
+ }
53
+ // emphasis opens before a letter and closes after one: `a * b` and `snake_case` stay text
54
+ if ((c === "*" || c === "_") && /\S/.test(text[i + 1] ?? "") && (c === "*" || !/[\p{L}\p{N}]/u.test(text[i - 1] ?? ""))) {
55
+ const end = text.indexOf(c, i + 1);
56
+ if (end > i + 1 && /\S/.test(text[end - 1]) && (c === "*" || !/[\p{L}\p{N}]/u.test(text[end + 1] ?? ""))) {
57
+ push("em", text.slice(i + 1, end));
58
+ i = end + 1;
59
+ continue;
60
+ }
61
+ }
62
+ push("text", c);
63
+ i++;
64
+ }
65
+ return out;
66
+ }
67
+ /** Markdown as blocks: fenced code (as Lean), lists of `-`, `*` or `+` items, paragraphs. Lines of a
68
+ * paragraph are joined with a space. */
69
+ export function parseMarkdown(md) {
70
+ const lines = md.replace(/\r\n?/g, "\n").split("\n");
71
+ const out = [];
72
+ let para = [];
73
+ let items = null;
74
+ const flush = () => {
75
+ if (para.length)
76
+ out.push({ t: "p", inl: inlines(para.join(" ").trim()) });
77
+ para = [];
78
+ if (items?.length)
79
+ out.push({ t: "list", items: items.map((x) => inlines(x.trim())) });
80
+ items = null;
81
+ };
82
+ for (let k = 0; k < lines.length; k++) {
83
+ const line = lines[k];
84
+ const fence = /^\s*(```+|~~~+)/.exec(line);
85
+ if (fence) {
86
+ flush();
87
+ const body = [];
88
+ for (k++; k < lines.length && !lines[k].trim().startsWith(fence[1]); k++)
89
+ body.push(lines[k]);
90
+ out.push({ t: "code", code: codeOf(body.join("\n")) });
91
+ continue;
92
+ }
93
+ if (!line.trim()) {
94
+ flush();
95
+ continue;
96
+ }
97
+ const item = /^\s*[-*+]\s+(.*)$/.exec(line);
98
+ if (item) {
99
+ if (para.length)
100
+ flush();
101
+ (items ??= []).push(item[1]);
102
+ continue;
103
+ }
104
+ // a line under a list item, indented, goes on with it
105
+ if (items && /^\s+\S/.test(line)) {
106
+ items[items.length - 1] += " " + line.trim();
107
+ continue;
108
+ }
109
+ if (items)
110
+ flush();
111
+ para.push(line.trim());
112
+ }
113
+ flush();
114
+ return out;
115
+ }
116
+ /** A note's text as blocks (the owner's decisions of 2026-10-12): its little Markdown carried over as
117
+ * the page shows it — fenced code, `code`, **bold**, *italics* — paragraphs parted by a blank line
118
+ * and the line breaks within one kept, as the note's card keeps them (it lists nothing: a line that
119
+ * starts with `-` is that line). A full declaration's name in backticks that `mention` knows (the
120
+ * id of the entry the document prints it as) is a `decl`, which the renderer links to where it is
121
+ * printed; any other is code. */
122
+ export function noteBlocks(text, mention) {
123
+ const out = [];
124
+ const para = (lines) => {
125
+ const inl = [];
126
+ lines.forEach((line, k) => {
127
+ if (k)
128
+ inl.push({ t: "br", s: "" });
129
+ for (const x of inlines(line)) {
130
+ const id = x.t === "code" ? mention(x.s.trim()) : null;
131
+ inl.push(id ? { t: "decl", s: x.s.trim(), id } : x);
132
+ }
133
+ });
134
+ if (inl.length)
135
+ out.push({ t: "p", inl });
136
+ };
137
+ const lines = text.replace(/\r\n?/g, "\n").split("\n");
138
+ let run = [];
139
+ for (let k = 0; k < lines.length; k++) {
140
+ const fence = /^\s*(```+|~~~+)/.exec(lines[k]);
141
+ if (fence) {
142
+ para(run);
143
+ run = [];
144
+ const body = [];
145
+ for (k++; k < lines.length && !lines[k].trim().startsWith(fence[1]); k++)
146
+ body.push(lines[k]);
147
+ out.push({ t: "code", code: codeOf(body.join("\n")) });
148
+ continue;
149
+ }
150
+ if (!lines[k].trim()) {
151
+ para(run);
152
+ run = [];
153
+ continue;
154
+ }
155
+ run.push(lines[k].trim());
156
+ }
157
+ para(run);
158
+ return out;
159
+ }
@@ -0,0 +1,37 @@
1
+ // The exported document's model (ExportSpec decision 6): what the document holds, independent of
2
+ // how it is rendered. The builder (shared/doc/build.ts) makes it from the graph, a view and the
3
+ // sources; a renderer turns it into something to read — the LaTeX renderer (shared/doc/latex.ts)
4
+ // today, an HTML one or another engine later, without touching the builder. It is plain JSON,
5
+ // versioned: the agents' API serves it as it is (GET /api/agent/export), and a renderer reads
6
+ // nothing else.
7
+ //
8
+ // It holds nothing of the review (ExportSpec decision 8): no status, no opinion, no progress. A
9
+ // document is about what the formalization says, and a reviewer's word on it is the page's. What
10
+ // explains it does belong (decision 17, AttachmentSpec decision 10): the places in papers a
11
+ // declaration or a cluster is linked to, and the files they cite; and, since the owner's decisions
12
+ // of 2026-10-12, the notes the view shows, as the document's prose, with the images and clips they
13
+ // carry as figures.
14
+ //
15
+ // Its order is the board's (the owner's decisions of 2026-10-12): what a section holds — its
16
+ // declarations, its notes and its sections — comes in one list, in the order the board draws them
17
+ // from the bottom up, whatever depends on what.
18
+ //
19
+ // Nothing in it is trusted: names, docstrings, comments and code are what people, agents and
20
+ // source files said, and a renderer escapes every one of them.
21
+ /** Bumped when the model changes shape: a renderer reads only the versions it knows. 2: proofs
22
+ * inside definitions left out. 3: nothing of the review, no context and no line numbers, the head
23
+ * card without times. 4: no figure and no legend; a definition's value always printed, the proofs
24
+ * in it found by reading its text where the usage pass's places cannot be used (`textCut`). 5: a
25
+ * declaration's `doc` its docstring alone; nothing of what it rests on. 6 (2026-10-09): the document
26
+ * of a view (`view`), in sections from its cluster structure, the outside world counted on the head
27
+ * card (`head.outside`); no targets' parts, preset, content, appendix or key marks. 7 (2026-10-09):
28
+ * the title the view's and the author line its people's (`head.title`, `head.author`), no longer
29
+ * settings. 8 (2026-10-10): the document of a view at a past build (`head.past`), its texts that
30
+ * build's, a declaration whose text of then is not kept printed as its statement (`code.notKept`),
31
+ * a text matched to its build by its places marked (`code.places`). 9 (2026-10-11): the papers a
32
+ * declaration and a section are linked to (`papers`), and the files they cite (`references`). 10
33
+ * (2026-10-12): the board's order, a section's declarations, notes and sections in one list
34
+ * (`items`), the document's first ones too; the notes the view shows (`NoteEntry`), a declaration's
35
+ * own under it (`DeclEntry.notes`), their images and clips as figures; a declaration a note names
36
+ * linked where the document prints it (an inline `decl`). */
37
+ export const DOC_VERSION = 10;