papilot 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 (99) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +126 -2
  3. package/README.zh-CN.md +127 -0
  4. package/bin/papilot.mjs +113 -0
  5. package/dist/apple-touch-icon.png +0 -0
  6. package/dist/assets/Inconsolatazi4-Regular-Dbtq7t_r.otf +0 -0
  7. package/dist/assets/TeXGyrePagellaX-Bold-CEtYdOXj.otf +0 -0
  8. package/dist/assets/TeXGyrePagellaX-Italic-CSuYIstn.otf +0 -0
  9. package/dist/assets/TeXGyrePagellaX-Regular-DQJjZE_L.otf +0 -0
  10. package/dist/assets/cursor-editorTextHighlight-kG3uUOwL.svg +8 -0
  11. package/dist/assets/index-BhQFCMTx.js +131 -0
  12. package/dist/assets/index-CAV3-87U.css +1 -0
  13. package/dist/assets/pdf-view-DJo0X38w.js +598 -0
  14. package/dist/assets/pdf.worker.min-CjEcRF4W.mjs +28 -0
  15. package/dist/favicon.png +0 -0
  16. package/dist/index.html +4 -0
  17. package/examples/papilot-paper/.latex-review.json +4 -0
  18. package/examples/papilot-paper/appendix/proofs.tex +60 -0
  19. package/examples/papilot-paper/appendix/reference.tex +54 -0
  20. package/examples/papilot-paper/main.tex +57 -0
  21. package/examples/papilot-paper/preamble.tex +53 -0
  22. package/examples/papilot-paper/references.bib +158 -0
  23. package/examples/papilot-paper/sections/01-introduction.tex +66 -0
  24. package/examples/papilot-paper/sections/02-using.tex +134 -0
  25. package/examples/papilot-paper/sections/03-background.tex +97 -0
  26. package/examples/papilot-paper/sections/04-model.tex +185 -0
  27. package/examples/papilot-paper/sections/05-conflicts.tex +99 -0
  28. package/examples/papilot-paper/sections/06-building.tex +70 -0
  29. package/examples/papilot-paper/sections/07-geometry.tex +193 -0
  30. package/examples/papilot-paper/sections/08-notes.tex +104 -0
  31. package/examples/papilot-paper/sections/09-agents.tex +106 -0
  32. package/examples/papilot-paper/sections/10-usage.tex +56 -0
  33. package/examples/papilot-paper/sections/11-git.tex +62 -0
  34. package/examples/papilot-paper/sections/12-security.tex +41 -0
  35. package/examples/papilot-paper/sections/13-evaluation.tex +106 -0
  36. package/examples/papilot-paper/sections/14-related.tex +38 -0
  37. package/examples/papilot-paper/sections/15-conclusion.tex +27 -0
  38. package/package.json +85 -4
  39. package/scripts/demo.ts +21 -0
  40. package/scripts/service.ts +35 -0
  41. package/src/notes.ts +40 -0
  42. package/src/server/agents.ts +343 -0
  43. package/src/server/codex.ts +63 -0
  44. package/src/server/frontend.ts +15 -0
  45. package/src/server/main.ts +202 -0
  46. package/src/server/manuscript.ts +337 -0
  47. package/src/server/paths.ts +10 -0
  48. package/src/server/pdf.ts +27 -0
  49. package/src/server/review.ts +53 -0
  50. package/src/server/security.ts +15 -0
  51. package/src/server/store.ts +60 -0
  52. package/src/server/workspace.ts +529 -0
  53. package/src/shared.ts +28 -0
  54. package/vendor/latex-review/LICENSE +21 -0
  55. package/vendor/latex-review/dist/web/assets/TeXGyrePagellaX-Bold-CEtYdOXj.otf +0 -0
  56. package/vendor/latex-review/dist/web/assets/TeXGyrePagellaX-Regular-DQJjZE_L.otf +0 -0
  57. package/vendor/latex-review/dist/web/assets/index-DyzYnIGk.css +1 -0
  58. package/vendor/latex-review/dist/web/assets/index-HxBK-dls.js +19 -0
  59. package/vendor/latex-review/dist/web/index.html +18 -0
  60. package/vendor/latex-review/package-lock.json +3114 -0
  61. package/vendor/latex-review/package.json +49 -0
  62. package/vendor/latex-review/src/cli.ts +494 -0
  63. package/vendor/latex-review/src/client.ts +198 -0
  64. package/vendor/latex-review/src/core/build.ts +296 -0
  65. package/vendor/latex-review/src/core/changes.ts +126 -0
  66. package/vendor/latex-review/src/core/config.ts +270 -0
  67. package/vendor/latex-review/src/core/diff.ts +54 -0
  68. package/vendor/latex-review/src/core/files.ts +96 -0
  69. package/vendor/latex-review/src/core/git.ts +317 -0
  70. package/vendor/latex-review/src/core/outline.ts +314 -0
  71. package/vendor/latex-review/src/core/process.ts +111 -0
  72. package/vendor/latex-review/src/core/publish.ts +339 -0
  73. package/vendor/latex-review/src/core/review.ts +734 -0
  74. package/vendor/latex-review/src/core/store.ts +66 -0
  75. package/vendor/latex-review/src/core/tasks.ts +237 -0
  76. package/vendor/latex-review/src/render/inline.ts +805 -0
  77. package/vendor/latex-review/src/render/locate.ts +481 -0
  78. package/vendor/latex-review/src/render/pdf.ts +281 -0
  79. package/vendor/latex-review/src/render/pdfdiff.ts +272 -0
  80. package/vendor/latex-review/src/render/round.ts +236 -0
  81. package/vendor/latex-review/src/render/worker.ts +10 -0
  82. package/vendor/latex-review/src/runtime.ts +23 -0
  83. package/vendor/latex-review/src/server.ts +401 -0
  84. package/vendor/latex-review/src/types.ts +283 -0
  85. package/vendor/latex-review/src/web/ChangeMap.tsx +212 -0
  86. package/vendor/latex-review/src/web/PdfStrip.tsx +77 -0
  87. package/vendor/latex-review/src/web/fonts/GUST-FONT-LICENSE.txt +29 -0
  88. package/vendor/latex-review/src/web/fonts/LPPL.txt +415 -0
  89. package/vendor/latex-review/src/web/fonts/MANIFEST-newpx.txt +20 -0
  90. package/vendor/latex-review/src/web/fonts/README-newpx.txt +473 -0
  91. package/vendor/latex-review/src/web/fonts/README.md +12 -0
  92. package/vendor/latex-review/src/web/fonts/TeXGyrePagellaX-Bold.otf +0 -0
  93. package/vendor/latex-review/src/web/fonts/TeXGyrePagellaX-Regular.otf +0 -0
  94. package/vendor/latex-review/src/web/index.html +17 -0
  95. package/vendor/latex-review/src/web/main.tsx +10 -0
  96. package/vendor/latex-review/src/web/model.ts +153 -0
  97. package/vendor/latex-review/src/web/overview.tsx +146 -0
  98. package/vendor/latex-review/src/web/review.tsx +991 -0
  99. package/vendor/latex-review/src/web/style.css +1249 -0
@@ -0,0 +1,317 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { run, which } from "./process.ts";
5
+ import { same } from "./files.ts";
6
+ const IDENTITY = {
7
+ GIT_AUTHOR_NAME: "latex-review",
8
+ GIT_AUTHOR_EMAIL: "latex-review@localhost",
9
+ GIT_COMMITTER_NAME: "latex-review",
10
+ GIT_COMMITTER_EMAIL: "latex-review@localhost",
11
+ };
12
+ export class GitError extends Error {}
13
+ let binary: string | undefined;
14
+ export function gitBinary() {
15
+ if (binary) return binary;
16
+ binary = which("git") ?? "git";
17
+ try {
18
+ const candidate = path.join(
19
+ run(binary, ["--exec-path"]).stdout.toString().trim(),
20
+ "git",
21
+ );
22
+ fs.accessSync(candidate, fs.constants.X_OK);
23
+ binary = candidate;
24
+ } catch {
25
+ /* The PATH executable remains usable. */
26
+ }
27
+ return binary;
28
+ }
29
+ export const within = (file: string, scopes: readonly string[]) =>
30
+ scopes.some(
31
+ (scope) =>
32
+ !scope ||
33
+ scope === "." ||
34
+ file === scope ||
35
+ file.startsWith(scope.replace(/\/+$/, "") + "/"),
36
+ );
37
+ export function cleanPath(value: string) {
38
+ const normalized = value.replaceAll("\\", "/");
39
+ const parts = normalized.split("/").filter((p) => p && p !== ".");
40
+ if (normalized.startsWith("/") || parts.includes("..") || parts[0] === ".git")
41
+ throw new Error(`Use a repository-relative path: ${value}`);
42
+ return parts.join("/");
43
+ }
44
+ function temporary<T>(prefix: string, work: (folder: string) => T): T {
45
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
46
+ try {
47
+ return work(dir);
48
+ } finally {
49
+ fs.rmSync(dir, { recursive: true, force: true });
50
+ }
51
+ }
52
+ export class Repo {
53
+ constructor(public root: string) {
54
+ this.root = path.resolve(root);
55
+ }
56
+ run(
57
+ args: string[],
58
+ options: {
59
+ input?: string | Buffer;
60
+ env?: NodeJS.ProcessEnv;
61
+ check?: boolean;
62
+ timeout?: number;
63
+ } = {},
64
+ ) {
65
+ try {
66
+ return run(
67
+ gitBinary(),
68
+ ["-c", "core.quotepath=off", "--literal-pathspecs", ...args],
69
+ { cwd: this.root, ...options },
70
+ ).stdout;
71
+ } catch (error) {
72
+ throw new GitError((error as Error).message);
73
+ }
74
+ }
75
+ text(args: string[], options: Parameters<Repo["run"]>[1] = {}) {
76
+ return this.run(args, options).toString().trim();
77
+ }
78
+ toplevel() {
79
+ return fs.realpathSync(this.text(["rev-parse", "--show-toplevel"]));
80
+ }
81
+ gitDir() {
82
+ return fs.realpathSync(this.text(["rev-parse", "--absolute-git-dir"]));
83
+ }
84
+ rev(name: string) {
85
+ return this.text(["rev-parse", "--verify", "--quiet", name + "^{commit}"]);
86
+ }
87
+ hasHead() {
88
+ return (
89
+ this.run(["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], {
90
+ check: false,
91
+ }).length > 0
92
+ );
93
+ }
94
+ blobs(rev: string, paths: string[]) {
95
+ const names = [...new Set(paths)];
96
+ const result: Record<string, Buffer | null> = {};
97
+ if (!names.length) return result;
98
+ const output = this.run(["cat-file", "--batch", "-z"], {
99
+ input: names.map((p) => `${rev}:${p}\0`).join(""),
100
+ });
101
+ let offset = 0;
102
+ for (const name of names) {
103
+ const end = output.indexOf(10, offset),
104
+ header = output.subarray(offset, end).toString().split(" ");
105
+ offset = end + 1;
106
+ const size = Number(header[2]);
107
+ result[name] =
108
+ header[1] === "blob" && Number.isFinite(size)
109
+ ? output.subarray(offset, offset + size)
110
+ : null;
111
+ if (Number.isFinite(size)) offset += size + 1;
112
+ }
113
+ return result;
114
+ }
115
+ blob(rev: string, file: string) {
116
+ return this.blobs(rev, [file])[file];
117
+ }
118
+ files(rev: string, folder = "") {
119
+ return this.run([
120
+ "ls-tree",
121
+ "-r",
122
+ "-z",
123
+ "--name-only",
124
+ rev,
125
+ ...(folder ? ["--", folder] : []),
126
+ ])
127
+ .toString()
128
+ .split("\0")
129
+ .filter(Boolean);
130
+ }
131
+ changedFiles(before: string, after: string) {
132
+ return this.run([
133
+ "diff",
134
+ "--name-only",
135
+ "--no-renames",
136
+ "-z",
137
+ before,
138
+ after,
139
+ ])
140
+ .toString()
141
+ .split("\0")
142
+ .filter(Boolean);
143
+ }
144
+ mode(rev: string, file: string) {
145
+ return this.text(["ls-tree", rev, "--", file]).split(/\s+/)[0] || null;
146
+ }
147
+ treeId(rev: string, folder = "") {
148
+ return this.text([
149
+ "rev-parse",
150
+ folder ? `${rev}:${folder}` : `${rev}^{tree}`,
151
+ ]);
152
+ }
153
+ hashObject(data: Buffer) {
154
+ return this.text(["hash-object", "-w", "--stdin"], { input: data });
155
+ }
156
+ commitTree(tree: string, parent: string | null, message: string) {
157
+ return this.text(
158
+ [
159
+ "commit-tree",
160
+ "--no-gpg-sign",
161
+ tree,
162
+ ...(parent ? ["-p", parent] : []),
163
+ "-m",
164
+ message,
165
+ ],
166
+ { env: { ...process.env, ...IDENTITY } },
167
+ );
168
+ }
169
+ updateRef(ref: string, value: string) {
170
+ this.run(["update-ref", ref, value]);
171
+ }
172
+ deleteRefs(prefix: string) {
173
+ const refs = this.text(["for-each-ref", "--format=%(refname)", prefix])
174
+ .split("\n")
175
+ .filter(Boolean);
176
+ if (refs.length)
177
+ this.run(["update-ref", "--stdin"], {
178
+ input: refs.map((r) => `delete ${r}\n`).join(""),
179
+ });
180
+ }
181
+ snapshot(include: string[] = [], exclude: readonly string[] = []) {
182
+ const head = this.rev("HEAD");
183
+ const staged = this.run([
184
+ "diff",
185
+ "--cached",
186
+ "--name-only",
187
+ "--diff-filter=A",
188
+ "-z",
189
+ ])
190
+ .toString()
191
+ .split("\0")
192
+ .filter(Boolean);
193
+ const tree = temporary("latex-review-index-", (folder) => {
194
+ const env = {
195
+ ...process.env,
196
+ GIT_INDEX_FILE: path.join(folder, "index"),
197
+ };
198
+ const capture = () => {
199
+ this.run(["read-tree", head], { env });
200
+ this.run(["add", "-u"], { env });
201
+ const untracked = this.run(
202
+ ["ls-files", "--others", "--exclude-standard", "-z"],
203
+ { env },
204
+ )
205
+ .toString()
206
+ .split("\0");
207
+ const chosen = new Set([
208
+ ...untracked.filter(
209
+ (p) =>
210
+ p &&
211
+ within(p, include) &&
212
+ !exclude.some((suffix) => p.endsWith(suffix)),
213
+ ),
214
+ ...staged.filter((p) => fs.existsSync(path.join(this.root, p))),
215
+ ]);
216
+ if (chosen.size)
217
+ this.run(["add", "--pathspec-from-file=-", "--pathspec-file-nul"], {
218
+ env,
219
+ input: [...chosen].sort().join("\0") + "\0",
220
+ });
221
+ return this.text(["write-tree"], { env });
222
+ };
223
+ const first = capture();
224
+ if (first !== capture() || head !== this.rev("HEAD"))
225
+ throw new GitError(
226
+ "Files changed while the snapshot was taken. Wait for saves to finish and retry.",
227
+ );
228
+ return first;
229
+ });
230
+ return this.commitTree(tree, head, "latex-review snapshot");
231
+ }
232
+ commitWith(
233
+ parent: string,
234
+ contents: Record<string, Buffer | null>,
235
+ message: string,
236
+ ) {
237
+ const tree = temporary("latex-review-index-", (folder) => {
238
+ const env = {
239
+ ...process.env,
240
+ GIT_INDEX_FILE: path.join(folder, "index"),
241
+ };
242
+ this.run(["read-tree", parent], { env });
243
+ for (const [file, data] of Object.entries(contents).sort()) {
244
+ if (data === null)
245
+ this.run(["update-index", "--force-remove", "--", file], { env });
246
+ else
247
+ this.run(
248
+ [
249
+ "update-index",
250
+ "--add",
251
+ "--cacheinfo",
252
+ `${this.mode(parent, file) || "100644"},${this.hashObject(data)},${file}`,
253
+ ],
254
+ { env },
255
+ );
256
+ }
257
+ return this.text(["write-tree"], { env });
258
+ });
259
+ return this.commitTree(tree, parent, message);
260
+ }
261
+ export(rev: string, folder: string, destination: string) {
262
+ fs.mkdirSync(destination, { recursive: true });
263
+ const files = this.files(rev, folder),
264
+ blobs = this.blobs(rev, files);
265
+ const links: [string, string][] = [];
266
+ for (const file of files) {
267
+ const relative = path.posix.relative(folder || ".", file);
268
+ cleanPath(relative);
269
+ const target = path.join(destination, relative),
270
+ data = blobs[file];
271
+ if (data === null) continue;
272
+ fs.mkdirSync(path.dirname(target), { recursive: true });
273
+ const mode = this.mode(rev, file);
274
+ if (mode === "120000") {
275
+ const link = data.toString(),
276
+ resolved = path.resolve(path.dirname(target), link);
277
+ if (
278
+ path.isAbsolute(link) ||
279
+ !resolved.startsWith(path.resolve(destination) + path.sep)
280
+ )
281
+ throw new GitError(
282
+ `A link in the snapshot points outside the document: ${file}`,
283
+ );
284
+ links.push([target, link]);
285
+ } else
286
+ fs.writeFileSync(target, data, {
287
+ mode: mode === "100755" ? 0o755 : 0o644,
288
+ });
289
+ }
290
+ for (const [target, link] of links) fs.symlinkSync(link, target);
291
+ }
292
+ }
293
+ export function merge3(
294
+ ours: Buffer | null,
295
+ base: Buffer | null,
296
+ theirs: Buffer | null,
297
+ ): [Buffer | null, boolean] {
298
+ if (same(ours, base) || same(ours, theirs)) return [theirs, false];
299
+ if (same(theirs, base)) return [ours, false];
300
+ if (
301
+ ours === null ||
302
+ base === null ||
303
+ theirs === null ||
304
+ [ours, base, theirs].some((b) => b.includes(0))
305
+ )
306
+ return [null, true];
307
+ return temporary("latex-review-merge-", (folder) => {
308
+ const files = ["ours", "base", "theirs"].map((name) =>
309
+ path.join(folder, name),
310
+ );
311
+ [ours, base, theirs].forEach((data, i) => fs.writeFileSync(files[i], data));
312
+ const result = run(gitBinary(), ["merge-file", "-p", ...files], {
313
+ check: false,
314
+ });
315
+ return [result.stdout, result.status !== 0];
316
+ });
317
+ }
@@ -0,0 +1,314 @@
1
+ import path from "node:path";
2
+ import { createHash } from "node:crypto";
3
+ import type { Heading, Ranges, SectionRef, Span } from "../types.ts";
4
+ const LEVELS: Record<string, number> = {
5
+ part: 0,
6
+ chapter: 1,
7
+ section: 2,
8
+ subsection: 3,
9
+ subsubsection: 4,
10
+ };
11
+ const COMMAND =
12
+ /^\s*\\(part|chapter|section|subsection|subsubsection|input|include|subfile|appendix|mainmatter|frontmatter|backmatter)\b(\*)?/;
13
+ export const stripComments = (text: string) =>
14
+ text.replace(/(?<!\\)%[^\n]*/g, "");
15
+ export function sourceLines(text: string) {
16
+ const lines = text.split("\n");
17
+ if (lines.at(-1) === "") lines.pop();
18
+ return lines;
19
+ }
20
+ export function argument(
21
+ text: string,
22
+ position: number,
23
+ ): [string | null, number] {
24
+ while (/\s/.test(text[position] ?? "") && position < text.length) position++;
25
+ if (text[position] === "[") {
26
+ const end = text.indexOf("]", position);
27
+ return end < 0 ? [null, position] : argument(text, end + 1);
28
+ }
29
+ if (text[position] !== "{") return [null, position];
30
+ let depth = 1;
31
+ const begin = ++position;
32
+ for (; position < text.length; position++) {
33
+ if (text[position] === "\\") {
34
+ position++;
35
+ continue;
36
+ }
37
+ if (text[position] === "{") depth++;
38
+ else if (text[position] === "}" && --depth === 0)
39
+ return [text.slice(begin, position), position + 1];
40
+ }
41
+ return [null, position];
42
+ }
43
+ export function plainTitle(value: string) {
44
+ return value
45
+ .replace(/\\(?:linebreak|newline)\b(?:\[[^\]]*\])?/g, " ")
46
+ .replace(/\\texorpdfstring\{([^{}]*)\}\{([^{}]*)\}/g, "$2")
47
+ .replace(/\\[A-Za-z@]+\*?(?:\[[^\]]*\])?\{([^{}]*)\}/g, "$1")
48
+ .replace(/[{}]/g, "")
49
+ .replaceAll("~", " ")
50
+ .replace(/\s+/g, " ")
51
+ .trim();
52
+ }
53
+ export function compactRanges(points: Record<string, Set<number>>): Ranges {
54
+ return Object.fromEntries(
55
+ Object.entries(points).map(([file, lines]) => {
56
+ const spans: Span[] = [];
57
+ for (const line of [...lines].sort((a, b) => a - b)) {
58
+ const last = spans.at(-1);
59
+ if (last && line <= last[1] + 1) last[1] = line;
60
+ else spans.push([line, line]);
61
+ }
62
+ return [file, spans];
63
+ }),
64
+ );
65
+ }
66
+ export function buildOutline(
67
+ read: (file: string) => string | null | undefined,
68
+ main: string,
69
+ ) {
70
+ type Draft = Omit<Heading, "number" | "ranges" | "files"> & {
71
+ points: Record<string, Set<number>>;
72
+ counters: number[] | null;
73
+ appendix: boolean;
74
+ };
75
+ const base = path.posix.dirname(main),
76
+ nodes: Draft[] = [],
77
+ stack: Draft[] = [],
78
+ warnings: string[] = [],
79
+ used = new Set<string>();
80
+ const counters = [0, 0, 0, 0, 0];
81
+ let appendix = false,
82
+ numbered = true;
83
+ const resolve = (value: string) => {
84
+ value = value.trim();
85
+ if (!value || /[\\#]/.test(value)) return null;
86
+ const file = path.posix.normalize(path.posix.join(base, value));
87
+ return file === ".." ||
88
+ file.startsWith("../") ||
89
+ path.posix.isAbsolute(value)
90
+ ? null
91
+ : path.posix.extname(file)
92
+ ? file
93
+ : file + ".tex";
94
+ };
95
+ const record = (file: string, line: number, root: boolean) => {
96
+ if (root && (!stack.length || stack.at(-1)!.file !== file)) return;
97
+ for (const node of stack) (node.points[file] ??= new Set()).add(line);
98
+ };
99
+ const walk = (file: string, active: string[] = []) => {
100
+ if (active.includes(file)) {
101
+ warnings.push("Circular input: " + file);
102
+ return;
103
+ }
104
+ const text = read(file);
105
+ if (text == null) {
106
+ warnings.push("Input not found: " + file);
107
+ return;
108
+ }
109
+ const root = file === main,
110
+ lines = sourceLines(stripComments(text)),
111
+ first = lines.findIndex((line) => line.trim()),
112
+ opening = first >= 0 ? lines[first].match(COMMAND) : null;
113
+ const prefix = opening && opening[1] in LEVELS ? first : 0;
114
+ if (prefix)
115
+ while (stack.length && stack.at(-1)!.level >= LEVELS[opening![1]])
116
+ stack.pop();
117
+ let inDocument = !root,
118
+ verbatim: string | null = null,
119
+ i = 0;
120
+ while (i < lines.length) {
121
+ const line = lines[i];
122
+ if (!inDocument) {
123
+ inDocument = line.includes("\\begin{document}");
124
+ i++;
125
+ continue;
126
+ }
127
+ if (root && line.includes("\\end{document}")) break;
128
+ const environment = line.match(
129
+ /\\begin\{(verbatim\*?|Verbatim\*?|lstlisting|minted|comment)\}/,
130
+ );
131
+ if (environment) verbatim = environment[1];
132
+ if (verbatim) {
133
+ record(file, i + 1, root);
134
+ if (line.includes("\\end{" + verbatim + "}")) verbatim = null;
135
+ i++;
136
+ continue;
137
+ }
138
+ const match = line.match(COMMAND);
139
+ if (!match) {
140
+ if (i >= prefix) record(file, i + 1, root);
141
+ i++;
142
+ continue;
143
+ }
144
+ const command = match[1];
145
+ if (
146
+ ["mainmatter", "frontmatter", "backmatter", "appendix"].includes(
147
+ command,
148
+ )
149
+ ) {
150
+ if (command === "appendix") {
151
+ appendix = true;
152
+ counters.fill(0, 1);
153
+ } else numbered = command === "mainmatter";
154
+ i++;
155
+ continue;
156
+ }
157
+ const joined = lines.slice(i).join("\n"),
158
+ [value, end] = argument(joined, match[0].length);
159
+ if (value === null) {
160
+ warnings.push(`Could not parse ${file}:${i + 1}`);
161
+ i++;
162
+ continue;
163
+ }
164
+ const consumed = joined.slice(0, end).split("\n").length;
165
+ if (["input", "include", "subfile"].includes(command)) {
166
+ if (!root) record(file, i + 1, root);
167
+ const child = resolve(value);
168
+ if (child) walk(child, [...active, file]);
169
+ else
170
+ warnings.push(`Dynamic input needs manual review: ${file}:${i + 1}`);
171
+ i += consumed;
172
+ continue;
173
+ }
174
+ const level = LEVELS[command];
175
+ while (stack.length && stack.at(-1)!.level >= level) stack.pop();
176
+ const rest = joined.slice(end).split("\n")[0],
177
+ labels = [...rest.matchAll(/\\label\{([^}]+)\}/g)].map((m) => m[1]);
178
+ for (const following of lines.slice(i + consumed, i + consumed + 8)) {
179
+ if (!following.trim()) continue;
180
+ const found = following.match(/^\s*\\label\{([^}]+)\}\s*$/);
181
+ if (!found) break;
182
+ labels.push(found[1]);
183
+ }
184
+ const label = labels.find((l) => !/^sec:\d/.test(l)) ?? labels[0];
185
+ let id = label
186
+ ? "label:" + label
187
+ : `${file}#${command}:${plainTitle(value)}`;
188
+ if (used.has(id))
189
+ id +=
190
+ ":" +
191
+ createHash("sha256").update(`${file}:${i}`).digest("hex").slice(0, 8);
192
+ used.add(id);
193
+ const isNumbered = !match[2] && (numbered || level === 0);
194
+ if (isNumbered) {
195
+ counters[level]++;
196
+ if (level) counters.fill(0, level + 1);
197
+ }
198
+ const node: Draft = {
199
+ id,
200
+ title: plainTitle(value),
201
+ kind: command,
202
+ level,
203
+ file,
204
+ line: i + 1,
205
+ parent: stack.at(-1)?.id ?? null,
206
+ labels,
207
+ points: {},
208
+ counters: isNumbered ? [...counters] : null,
209
+ appendix,
210
+ };
211
+ nodes.push(node);
212
+ stack.push(node);
213
+ if (i === prefix)
214
+ for (let k = 0; k < prefix; k++) record(file, k + 1, root);
215
+ for (let k = 0; k < consumed; k++) record(file, i + k + 1, root);
216
+ i += consumed;
217
+ }
218
+ };
219
+ walk(main);
220
+ const top = nodes.some((n) => n.kind === "chapter") ? 1 : 2;
221
+ const headings: Heading[] = nodes.map(
222
+ ({ counters: counts, appendix, points, ...node }) => {
223
+ let number = "";
224
+ if (counts && !(node.level > 0 && node.level < top)) {
225
+ const first =
226
+ appendix && counts[top] >= 1 && counts[top] <= 26
227
+ ? String.fromCharCode(64 + counts[top])
228
+ : String(counts[top]);
229
+ number =
230
+ node.level === 0
231
+ ? String(counts[0])
232
+ : [first, ...counts.slice(top + 1, node.level + 1)].join(".");
233
+ }
234
+ const ranges = compactRanges(points);
235
+ return { ...node, number, ranges, files: Object.keys(ranges).sort() };
236
+ },
237
+ );
238
+ return {
239
+ nodes: headings,
240
+ warnings,
241
+ signature: createHash("sha256")
242
+ .update(JSON.stringify(headings))
243
+ .digest("hex")
244
+ .slice(0, 20),
245
+ };
246
+ }
247
+ export function overlaps(a: Ranges, b: Ranges) {
248
+ return Object.keys(a).some(
249
+ (file) =>
250
+ b[file] &&
251
+ a[file].some(([x, y]) =>
252
+ b[file].some(([u, v]) => Math.max(x, u) <= Math.min(y, v)),
253
+ ),
254
+ );
255
+ }
256
+ export function selectedRanges(
257
+ nodes: Heading[],
258
+ ids: string[],
259
+ ): [Ranges, SectionRef[]] {
260
+ const selected = nodes.filter((n) => ids.includes(n.id));
261
+ if (selected.length !== new Set(ids).size)
262
+ throw new Error("The outline changed; select the sections again.");
263
+ const points: Record<string, Set<number>> = {};
264
+ for (const node of selected)
265
+ for (const [file, spans] of Object.entries(node.ranges))
266
+ for (const [a, b] of spans)
267
+ for (let i = a; i <= b; i++) (points[file] ??= new Set()).add(i);
268
+ return [
269
+ compactRanges(points),
270
+ selected.map(({ id, number, title }) => ({ id, number, title })),
271
+ ];
272
+ }
273
+ export function resolveSections(nodes: Heading[], selectors: string[]) {
274
+ return [
275
+ ...new Set(
276
+ selectors.map((selector) => {
277
+ const key = selector.trim(),
278
+ label = key.startsWith("label:") ? key : "label:" + key;
279
+ let matches = nodes.filter(
280
+ (n) => n.id === key || n.number === key || n.id === label,
281
+ );
282
+ if (!matches.length)
283
+ matches = nodes.filter(
284
+ (n) => n.title.toLocaleLowerCase() === key.toLocaleLowerCase(),
285
+ );
286
+ if (matches.length !== 1)
287
+ throw new Error(
288
+ `Section ${JSON.stringify(selector)} matches ${matches.length} headings. Use a section number, exact title, or label.`,
289
+ );
290
+ return matches[0].id;
291
+ }),
292
+ ),
293
+ ];
294
+ }
295
+ export function covers(spans: Span[], start: number, end: number) {
296
+ let reached = start - 1;
297
+ for (const [a, b] of [...spans].sort((a, b) => a[0] - b[0])) {
298
+ if (a > reached + 1) break;
299
+ reached = Math.max(reached, b);
300
+ }
301
+ return reached >= end;
302
+ }
303
+ export function deepestNode(
304
+ nodes: Heading[],
305
+ file: string,
306
+ start: number,
307
+ end: number,
308
+ ) {
309
+ return (
310
+ nodes
311
+ .filter((n) => covers(n.ranges[file] ?? [], start, end))
312
+ .sort((a, b) => b.level - a.level)[0] ?? null
313
+ );
314
+ }