texlite 0.7.8 → 0.8.1

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 (34) hide show
  1. package/DESIGN.md +463 -0
  2. package/NPM_TESTING.md +136 -0
  3. package/OPERATIONS.md +261 -0
  4. package/README.md +82 -305
  5. package/README.zh-CN.md +64 -329
  6. package/dist/client/assets/{GitDialog-CqGyUxfP.js → GitDialog-CZRQPg3O.js} +1 -1
  7. package/dist/client/assets/{HistoryDialog-BMl2zxBa.js → HistoryDialog-BQtr58Co.js} +1 -1
  8. package/dist/client/assets/{LatexEditor-C4bFVwJZ.js → LatexEditor-XLwZxO6_.js} +1 -1
  9. package/dist/client/assets/{PdfPreview-BpP2LnX7.js → PdfPreview-D5CC-vqV.js} +1 -1
  10. package/dist/client/assets/{ProjectNavigationDialogs-B7mxG8Fz.js → ProjectNavigationDialogs-BdtTldSa.js} +1 -1
  11. package/dist/client/assets/{SystemMetricsDialog-BK_K3ah8.js → SystemMetricsDialog-CEbYJdj7.js} +1 -1
  12. package/dist/client/assets/index-BAFR6u6_.js +64 -0
  13. package/dist/client/assets/index-g5CaToD6.css +1 -0
  14. package/dist/client/assets/{minus-BwzSZenU.js → minus-KzdrD7TK.js} +1 -1
  15. package/dist/client/assets/spellCheck-CDZ9ZXo6.js +1 -0
  16. package/dist/client/index.html +2 -2
  17. package/dist/server/app.js +8 -3
  18. package/dist/server/cli.js +170 -32
  19. package/dist/server/environment.js +168 -38
  20. package/dist/server/harper.js +210 -119
  21. package/dist/server/i18n.js +8 -0
  22. package/dist/server/latexSpellMask.js +257 -0
  23. package/dist/server/projects.js +0 -12
  24. package/dist/server/routes/projectShared.js +1 -1
  25. package/dist/server/routes/projects.js +20 -19
  26. package/dist/server/routes/wordCount.js +83 -0
  27. package/dist/server/texcount.js +244 -0
  28. package/package.json +6 -3
  29. package/preview-1.png +0 -0
  30. package/preview-2.png +0 -0
  31. package/dist/client/assets/index-B9n8hfV0.js +0 -64
  32. package/dist/client/assets/index-BhyTKMoO.css +0 -1
  33. package/dist/client/assets/spellCheck-CvOAlLWp.js +0 -4
  34. package/preview.png +0 -0
@@ -0,0 +1,257 @@
1
+ // This intentionally recognizes only the parts of LaTeX that are clearly not
2
+ // prose. It is a single-pass lexer, not a parser: malformed delimiters remain
3
+ // narrow, so they never hide the rest of a document or cause backtracking.
4
+ const nonProseEnvironments = new Set([
5
+ "math", "displaymath", "equation", "equation*", "align", "align*", "alignat", "alignat*", "gather", "gather*", "multline", "multline*", "flalign", "flalign*",
6
+ "tikzpicture", "axis", "scope", "pgfonlayer", "pgfpicture",
7
+ "tabular", "tabularx", "tabulary", "longtable", "array", "matrix", "pmatrix", "bmatrix", "vmatrix",
8
+ "verbatim", "lstlisting", "minted", "comment", "algorithmic"
9
+ ]);
10
+ const opaqueArgumentCommands = new Set([
11
+ "documentclass", "usepackage", "requirepackage", "includegraphics", "tikzset", "pgfplotsset", "hypersetup", "lstset",
12
+ "label", "hypertarget", "ref", "pageref", "autoref", "nameref", "hyperref", "index", "gls", "glspl",
13
+ "bibliography", "addbibresource", "input", "include", "url", "path", "setlength", "setcounter", "color", "textcolor", "colorbox", "pagecolor"
14
+ ]);
15
+ function addRange(ranges, from, to) {
16
+ if (to > from)
17
+ ranges.push({ from, to });
18
+ }
19
+ function isWhitespace(character) {
20
+ return character === " " || character === "\t" || character === "\r" || character === "\n";
21
+ }
22
+ function isCommandNameCharacter(character) {
23
+ if (!character)
24
+ return false;
25
+ const code = character.charCodeAt(0);
26
+ return (code >= 65 && code <= 90) || (code >= 97 && code <= 122) || (code >= 48 && code <= 57)
27
+ || character === "@" || character === ":" || character === "_";
28
+ }
29
+ function balancedArgument(source, start, open, close) {
30
+ if (source[start] !== open)
31
+ return null;
32
+ let depth = 1;
33
+ for (let index = start + 1; index < source.length; index += 1) {
34
+ if (source[index] === "\\") {
35
+ index += 1;
36
+ continue;
37
+ }
38
+ if (source[index] === open)
39
+ depth += 1;
40
+ else if (source[index] === close && --depth === 0)
41
+ return { from: start, to: index + 1 };
42
+ }
43
+ return null;
44
+ }
45
+ function skipWhitespace(source, start) {
46
+ let index = start;
47
+ while (index < source.length && isWhitespace(source[index]))
48
+ index += 1;
49
+ return index;
50
+ }
51
+ function readCommand(source, start) {
52
+ let end = start + 1;
53
+ if (isCommandNameCharacter(source[end]))
54
+ while (isCommandNameCharacter(source[end]))
55
+ end += 1;
56
+ else if (end < source.length)
57
+ end += 1;
58
+ const name = source.slice(start + 1, end);
59
+ if (source[end] === "*")
60
+ end += 1;
61
+ return { name, end };
62
+ }
63
+ function isCitationCommand(name) {
64
+ const normalized = name.toLocaleLowerCase("en-US");
65
+ return normalized === "cite" || normalized.startsWith("cite") || normalized.endsWith("cite");
66
+ }
67
+ function masksOpaqueArguments(name) {
68
+ const normalized = name.toLocaleLowerCase("en-US");
69
+ return opaqueArgumentCommands.has(normalized) || isCitationCommand(normalized) || normalized === "definecolor" || normalized === "colorlet" || normalized === "href";
70
+ }
71
+ function maskOpaqueArguments(source, start, name, ranges) {
72
+ let cursor = start;
73
+ const normalized = name.toLocaleLowerCase("en-US");
74
+ const count = normalized === "definecolor" || normalized === "colorlet" ? 3 : 1;
75
+ for (let index = 0; index < count; index += 1) {
76
+ const argument = balancedArgument(source, cursor, "{", "}");
77
+ if (!argument)
78
+ break;
79
+ addRange(ranges, argument.from, argument.to);
80
+ cursor = skipWhitespace(source, argument.to);
81
+ }
82
+ return cursor;
83
+ }
84
+ function rawUrlEnd(source, start) {
85
+ const first = source[start];
86
+ if (first !== "h" && first !== "H" && first !== "f" && first !== "F")
87
+ return null;
88
+ for (const prefix of ["http://", "https://", "ftp://"]) {
89
+ if (source.slice(start, start + prefix.length).toLowerCase() !== prefix)
90
+ continue;
91
+ let end = start + prefix.length;
92
+ while (end < source.length && !isWhitespace(source[end]))
93
+ end += 1;
94
+ return end;
95
+ }
96
+ return null;
97
+ }
98
+ function mergeOrderedRanges(ranges) {
99
+ const merged = [];
100
+ for (const range of ranges) {
101
+ const previous = merged.at(-1);
102
+ if (previous && range.from <= previous.to)
103
+ previous.to = Math.max(previous.to, range.to);
104
+ else
105
+ merged.push({ ...range });
106
+ }
107
+ return merged;
108
+ }
109
+ /** Return ordered source spans that should not be sent to a prose checker. */
110
+ export function ignoredLatexRanges(source) {
111
+ const ranges = [];
112
+ // Each non-prose environment installs its span at \begin and extends it at
113
+ // \end. This preserves source order, so final merging stays linear.
114
+ const nonProseStack = [];
115
+ // The same technique keeps unmatched $ / \[ delimiters cheap and local.
116
+ let pendingMath = null;
117
+ for (let index = 0; index < source.length;) {
118
+ if (pendingMath?.close.startsWith("\\") && source.startsWith(pendingMath.close, index)) {
119
+ pendingMath.range.to = index + pendingMath.close.length;
120
+ index += pendingMath.close.length;
121
+ pendingMath = null;
122
+ continue;
123
+ }
124
+ if (source[index] === "%") {
125
+ const end = source.indexOf("\n", index);
126
+ addRange(ranges, index, end < 0 ? source.length : end);
127
+ index = end < 0 ? source.length : end + 1;
128
+ continue;
129
+ }
130
+ if (source[index] === "$") {
131
+ if (pendingMath) {
132
+ if (pendingMath.close === "$" || (pendingMath.close === "$$" && source.startsWith("$$", index))) {
133
+ pendingMath.range.to = index + pendingMath.close.length;
134
+ index += pendingMath.close.length;
135
+ pendingMath = null;
136
+ }
137
+ else {
138
+ index += 1;
139
+ }
140
+ }
141
+ else {
142
+ const delimiter = source[index + 1] === "$" ? "$$" : "$";
143
+ const range = { from: index, to: index + delimiter.length };
144
+ ranges.push(range);
145
+ pendingMath = { close: delimiter, range };
146
+ index += delimiter.length;
147
+ }
148
+ continue;
149
+ }
150
+ const urlEnd = rawUrlEnd(source, index);
151
+ if (urlEnd !== null) {
152
+ addRange(ranges, index, urlEnd);
153
+ index = urlEnd;
154
+ continue;
155
+ }
156
+ if (source[index] !== "\\") {
157
+ index += 1;
158
+ continue;
159
+ }
160
+ const commandStart = index;
161
+ if (source[index + 1] === "(" || source[index + 1] === "[") {
162
+ if (pendingMath) {
163
+ addRange(ranges, commandStart, index + 2);
164
+ }
165
+ else {
166
+ const range = { from: commandStart, to: index + 2 };
167
+ ranges.push(range);
168
+ pendingMath = { close: source[index + 1] === "(" ? "\\)" : "\\]", range };
169
+ }
170
+ index += 2;
171
+ continue;
172
+ }
173
+ const command = readCommand(source, commandStart);
174
+ let cursor = skipWhitespace(source, command.end);
175
+ if (command.name === "verb") {
176
+ addRange(ranges, commandStart, command.end);
177
+ const delimiter = source[cursor];
178
+ if (delimiter && !isWhitespace(delimiter)) {
179
+ const end = source.indexOf(delimiter, cursor + 1);
180
+ if (end >= 0) {
181
+ addRange(ranges, cursor, end + 1);
182
+ cursor = end + 1;
183
+ }
184
+ }
185
+ index = Math.max(command.end, cursor);
186
+ continue;
187
+ }
188
+ if (command.name === "begin" || command.name === "end") {
189
+ const environment = balancedArgument(source, cursor, "{", "}");
190
+ if (!environment) {
191
+ addRange(ranges, commandStart, command.end);
192
+ index = command.end;
193
+ continue;
194
+ }
195
+ const environmentName = source.slice(environment.from + 1, environment.to - 1).trim().toLocaleLowerCase("en-US");
196
+ cursor = skipWhitespace(source, environment.to);
197
+ let options = null;
198
+ if (command.name === "begin") {
199
+ options = balancedArgument(source, cursor, "[", "]");
200
+ if (options)
201
+ cursor = skipWhitespace(source, options.to);
202
+ if (nonProseEnvironments.has(environmentName)) {
203
+ const range = { from: commandStart, to: cursor };
204
+ ranges.push(range);
205
+ nonProseStack.push({ name: environmentName, range });
206
+ }
207
+ else {
208
+ addRange(ranges, commandStart, command.end);
209
+ addRange(ranges, environment.from, environment.to);
210
+ if (options)
211
+ addRange(ranges, options.from, options.to);
212
+ }
213
+ }
214
+ else {
215
+ const open = nonProseStack.at(-1);
216
+ if (open?.name === environmentName) {
217
+ nonProseStack.pop();
218
+ open.range.to = cursor;
219
+ }
220
+ else {
221
+ addRange(ranges, commandStart, command.end);
222
+ addRange(ranges, environment.from, environment.to);
223
+ }
224
+ }
225
+ index = Math.max(command.end, cursor);
226
+ continue;
227
+ }
228
+ addRange(ranges, commandStart, command.end);
229
+ const options = balancedArgument(source, cursor, "[", "]");
230
+ if (options) {
231
+ addRange(ranges, options.from, options.to);
232
+ cursor = skipWhitespace(source, options.to);
233
+ }
234
+ if (masksOpaqueArguments(command.name))
235
+ cursor = maskOpaqueArguments(source, cursor, command.name, ranges);
236
+ index = Math.max(command.end, cursor);
237
+ }
238
+ return mergeOrderedRanges(ranges);
239
+ }
240
+ /** Replace syntax with spaces while retaining Unicode-scalar offsets and line breaks. */
241
+ export function maskLatexSource(source) {
242
+ const ranges = ignoredLatexRanges(source);
243
+ const chars = [];
244
+ let offset = 0;
245
+ let rangeIndex = 0;
246
+ for (const character of source) {
247
+ while (rangeIndex < ranges.length && ranges[rangeIndex].to <= offset)
248
+ rangeIndex += 1;
249
+ const range = ranges[rangeIndex];
250
+ const masked = range && range.from <= offset && offset < range.to && character !== "\n" && character !== "\r";
251
+ // `harper-cli` reports Unicode scalar positions. One space for every
252
+ // source scalar keeps diagnostics aligned after masked astral characters.
253
+ chars.push(masked ? " " : character);
254
+ offset += character.length;
255
+ }
256
+ return chars.join("");
257
+ }
@@ -1,16 +1,4 @@
1
1
  export function accessibleProject(db, projectId, user) {
2
- if (user.role === "admin") {
3
- const row = db.prepare(`
4
- SELECT p.*, 'owner' AS permission, owner.username AS owner_username,
5
- owner.display_name AS owner_display_name,
6
- modifier.username AS last_modified_username, modifier.display_name AS last_modified_display_name
7
- FROM projects p
8
- JOIN users owner ON owner.id = p.owner_id
9
- LEFT JOIN users modifier ON modifier.id = p.last_modified_by
10
- WHERE p.id = ?
11
- `).get(projectId);
12
- return row ?? null;
13
- }
14
2
  const row = db.prepare(`
15
3
  SELECT p.*,
16
4
  CASE WHEN p.owner_id = ? THEN 'owner' ELSE pm.permission END AS permission,
@@ -129,7 +129,7 @@ export function requireEditableProject(db, projectId, user) {
129
129
  throw httpError(403, "PROJECT_EDIT_FORBIDDEN");
130
130
  return project;
131
131
  }
132
- /** Owner permission includes an administrator's effective owner access. */
132
+ /** Owner permission is granted only to the stored project owner. */
133
133
  export function requireProjectOwnerPermission(db, projectId, user) {
134
134
  requireActiveUser(db, user);
135
135
  const project = accessibleProject(db, projectId, user);
@@ -9,6 +9,7 @@ import { isMainDocumentCandidate } from "../latexRoot.js";
9
9
  import { accessibleProject, canEdit } from "../projects.js";
10
10
  import { writeProjectArchive } from "../archive.js";
11
11
  import { extractProjectZip, ZipValidationError } from "../zip.js";
12
+ import { HarperUnavailableError } from "../harper.js";
12
13
  import { commentsSummaryForProject, commentsSummaryForProjects, dictionaryWord, escapeLikePattern, now, projectJson, requireProjectOwnerPermission, tagColors, tagsForProject, tagsForProjects, text } from "./projectShared.js";
13
14
  /** Register project catalog, metadata, archive, dictionary, tag, export, and deletion routes. */
14
15
  export function registerProjectCatalogRoutes(app, context) {
@@ -50,14 +51,11 @@ export function registerProjectCatalogRoutes(app, context) {
50
51
  const archiveCondition = archivedOnly
51
52
  ? "EXISTS (SELECT 1 FROM user_project_archives archive WHERE archive.project_id = p.id AND archive.user_id = :userId)"
52
53
  : "NOT EXISTS (SELECT 1 FROM user_project_archives archive WHERE archive.project_id = p.id AND archive.user_id = :userId)";
53
- const from = user.role === "admin"
54
- ? `FROM projects p JOIN users owner ON owner.id = p.owner_id
55
- LEFT JOIN users modifier ON modifier.id = p.last_modified_by`
56
- : `FROM projects p JOIN users owner ON owner.id = p.owner_id
57
- LEFT JOIN users modifier ON modifier.id = p.last_modified_by
58
- LEFT JOIN project_members pm ON pm.project_id = p.id AND pm.user_id = :userId`;
54
+ const from = `FROM projects p JOIN users owner ON owner.id = p.owner_id
55
+ LEFT JOIN users modifier ON modifier.id = p.last_modified_by
56
+ LEFT JOIN project_members pm ON pm.project_id = p.id AND pm.user_id = :userId`;
59
57
  const conditions = [
60
- ...(user.role === "admin" ? [] : ["(p.owner_id = :userId OR pm.user_id = :userId)"]),
58
+ "(p.owner_id = :userId OR pm.user_id = :userId)",
61
59
  archiveCondition
62
60
  ];
63
61
  const params = { userId: user.id };
@@ -79,14 +77,10 @@ export function registerProjectCatalogRoutes(app, context) {
79
77
  const totalPages = Math.ceil(total / pageSize);
80
78
  const currentPage = totalPages === 0 ? 1 : Math.min(page, totalPages);
81
79
  const rowsParams = { ...params, limit: pageSize, offset: (currentPage - 1) * pageSize };
82
- const select = user.role === "admin"
83
- ? `SELECT p.*, 'owner' AS permission, owner.username AS owner_username,
84
- owner.display_name AS owner_display_name,
85
- modifier.username AS last_modified_username, modifier.display_name AS last_modified_display_name`
86
- : `SELECT DISTINCT p.*,
87
- CASE WHEN p.owner_id = :userId THEN 'owner' ELSE pm.permission END AS permission,
88
- owner.username AS owner_username, owner.display_name AS owner_display_name,
89
- modifier.username AS last_modified_username, modifier.display_name AS last_modified_display_name`;
80
+ const select = `SELECT DISTINCT p.*,
81
+ CASE WHEN p.owner_id = :userId THEN 'owner' ELSE pm.permission END AS permission,
82
+ owner.username AS owner_username, owner.display_name AS owner_display_name,
83
+ modifier.username AS last_modified_username, modifier.display_name AS last_modified_display_name`;
90
84
  const rows = db.prepare(`${select} ${from} ${where}
91
85
  ORDER BY ${sortColumn} DESC, p.name COLLATE NOCASE ASC
92
86
  LIMIT :limit OFFSET :offset`).all(rowsParams);
@@ -259,16 +253,21 @@ export function registerProjectCatalogRoutes(app, context) {
259
253
  if (!accessibleProject(db, id, user))
260
254
  return apiError(reply, 404, "PROJECT_NOT_FOUND");
261
255
  const body = request.body;
262
- if (typeof body?.source !== "string")
256
+ if (typeof body?.source !== "string" || typeof body.path !== "string")
263
257
  return apiError(reply, 400, "SPELLCHECK_SOURCE_INVALID");
264
258
  if (Buffer.byteLength(body.source, "utf8") > maxCollaborativeFileBytes(config)) {
265
259
  return apiError(reply, 413, "SPELLCHECK_SOURCE_TOO_LARGE");
266
260
  }
267
261
  try {
268
- return { lints: await harper.lint(body.source) };
262
+ return { lints: await harper.lint(body.source, body.path) };
269
263
  }
270
264
  catch (error) {
271
- request.log.error({ err: error, projectId: id }, "Harper writing check failed");
265
+ // A missing optional command is an expected fallback condition. Keep it
266
+ // out of normal logs while preserving diagnostics for an actual failure.
267
+ if (error instanceof HarperUnavailableError)
268
+ request.log.debug({ projectId: id }, "Host Harper CLI unavailable; using browser fallback");
269
+ else
270
+ request.log.error({ err: error, projectId: id }, "Harper writing check failed");
272
271
  return apiError(reply, 503, "HARPER_UNAVAILABLE");
273
272
  }
274
273
  });
@@ -459,7 +458,9 @@ export function registerProjectCatalogRoutes(app, context) {
459
458
  return;
460
459
  const { id } = request.params;
461
460
  const project = accessibleProject(db, id, user);
462
- if (!project || project.permission !== "owner")
461
+ if (!project)
462
+ return apiError(reply, 404, "PROJECT_NOT_FOUND");
463
+ if (project.permission !== "owner")
463
464
  return apiError(reply, 403, "PROJECT_DELETE_FORBIDDEN");
464
465
  return await projectMutations.runExclusive(id, "project deletion", () => {
465
466
  collaboration.resetProject(id);
@@ -0,0 +1,83 @@
1
+ import { requireUser } from "../auth.js";
2
+ import { maxCollaborativeFileBytes } from "../collaboration.js";
3
+ import { compileMainFile } from "../compileArtifacts.js";
4
+ import { assertNoSourceSymlinks, safeRelativePath, sourceRoot } from "../files.js";
5
+ import { apiError, httpError } from "../http.js";
6
+ import { accessibleProject } from "../projects.js";
7
+ import { TexcountExecutionError, TexcountUnavailableError } from "../texcount.js";
8
+ /** Register host TeXcount-backed full-document and editor-selection counts. */
9
+ export function registerWordCountRoutes(app, context) {
10
+ const { config, db, projectMutations, texcount } = context;
11
+ app.post("/api/projects/:id/word-count", async (request, reply) => {
12
+ const user = requireUser(request, reply, db);
13
+ if (!user)
14
+ return;
15
+ const { id } = request.params;
16
+ const project = accessibleProject(db, id, user);
17
+ if (!project)
18
+ return apiError(reply, 404, "PROJECT_NOT_FOUND");
19
+ const body = (request.body ?? {});
20
+ const mode = body.mode;
21
+ if (mode !== "full" && mode !== "selection")
22
+ return apiError(reply, 400, "WORD_COUNT_SOURCE_INVALID");
23
+ try {
24
+ if (mode === "selection") {
25
+ return await countSelection(config, id, body, texcount);
26
+ }
27
+ return await countDocument(config, db, projectMutations, texcount, id, user, body.mainFile);
28
+ }
29
+ catch (error) {
30
+ if (error instanceof TexcountUnavailableError)
31
+ return apiError(reply, 503, "WORD_COUNT_UNAVAILABLE");
32
+ if (error instanceof TexcountExecutionError)
33
+ return apiError(reply, 422, "WORD_COUNT_FAILED");
34
+ throw error;
35
+ }
36
+ });
37
+ }
38
+ async function countSelection(config, projectId, body, texcount) {
39
+ if (typeof body.path !== "string" || typeof body.source !== "string") {
40
+ throw httpError(400, "WORD_COUNT_SOURCE_INVALID");
41
+ }
42
+ let filePath;
43
+ try {
44
+ filePath = safeRelativePath(body.path);
45
+ }
46
+ catch {
47
+ throw httpError(400, "WORD_COUNT_SOURCE_INVALID");
48
+ }
49
+ // TeXcount's stdin parser is intended for TeX-like source. Keep BibTeX and
50
+ // binary resources out of this endpoint while allowing .sty/.cls snippets.
51
+ if (!/\.(?:tex|sty|cls)$/i.test(filePath) || !body.source.trim()) {
52
+ throw httpError(400, "WORD_COUNT_SOURCE_INVALID");
53
+ }
54
+ if (Buffer.byteLength(body.source, "utf8") > maxCollaborativeFileBytes(config)) {
55
+ throw httpError(413, "WORD_COUNT_SOURCE_TOO_LARGE");
56
+ }
57
+ const counts = await texcount.countSource(sourceRoot(config, projectId), body.source);
58
+ return { mode: "selection", path: filePath, ...counts };
59
+ }
60
+ async function countDocument(config, db, projectMutations, texcount, projectId, user, requestedMainFile) {
61
+ const initialProject = accessibleProject(db, projectId, user);
62
+ if (!initialProject)
63
+ throw httpError(404, "PROJECT_NOT_FOUND");
64
+ const mainFile = compileMainFile(config, projectId, initialProject.main_file, requestedMainFile);
65
+ if (!mainFile)
66
+ throw httpError(400, "MAIN_DOCUMENT_INVALID");
67
+ return await projectMutations.runConsistentRead(projectId, async () => {
68
+ // TeXcount follows \input/\include paths. Reject links before handing it
69
+ // the source root so a checked-out link cannot expose files outside it.
70
+ assertNoSourceSymlinks(config, projectId);
71
+ const counts = await texcount.countFile(sourceRoot(config, projectId), mainFile);
72
+ return { mode: "full", path: mainFile, ...counts };
73
+ }, {
74
+ preflight: () => {
75
+ const current = accessibleProject(db, projectId, user);
76
+ if (!current)
77
+ throw httpError(404, "PROJECT_NOT_FOUND");
78
+ const selected = compileMainFile(config, projectId, current.main_file, requestedMainFile);
79
+ if (!selected || selected !== mainFile)
80
+ throw httpError(400, "MAIN_DOCUMENT_INVALID");
81
+ }
82
+ });
83
+ }