texlite 0.7.7 → 0.8.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 (30) hide show
  1. package/DESIGN.md +460 -0
  2. package/NPM_TESTING.md +130 -0
  3. package/OPERATIONS.md +246 -0
  4. package/README.md +78 -306
  5. package/README.zh-CN.md +61 -330
  6. package/dist/client/assets/{GitDialog-Bm5yMpQK.js → GitDialog-DdLrbz_H.js} +1 -1
  7. package/dist/client/assets/{HistoryDialog-Po4KHT_2.js → HistoryDialog-AlF9iozX.js} +1 -1
  8. package/dist/client/assets/{LatexEditor-CnKCXaHr.js → LatexEditor-DH9v1wgS.js} +22 -22
  9. package/dist/client/assets/{PdfPreview-Cd20VIjT.js → PdfPreview-CanYnyfJ.js} +1 -1
  10. package/dist/client/assets/ProjectNavigationDialogs-DiXtkTj1.js +1 -0
  11. package/dist/client/assets/{SystemMetricsDialog-DdOlcqqK.js → SystemMetricsDialog-DimBI7er.js} +1 -1
  12. package/dist/client/assets/index-CdiutUgf.css +1 -0
  13. package/dist/client/assets/index-vpWlpUOr.js +64 -0
  14. package/dist/client/assets/{minus-O6pK-6mA.js → minus-DR-ttrx3.js} +1 -1
  15. package/dist/client/assets/spellCheck-Wn7_eF9j.js +1 -0
  16. package/dist/client/index.html +2 -2
  17. package/dist/server/app.js +3 -3
  18. package/dist/server/compiler.js +139 -10
  19. package/dist/server/harper.js +210 -119
  20. package/dist/server/latexSpellMask.js +257 -0
  21. package/dist/server/projects.js +0 -12
  22. package/dist/server/routes/compile.js +54 -41
  23. package/dist/server/routes/projectFiles.js +11 -0
  24. package/dist/server/routes/projectShared.js +1 -1
  25. package/dist/server/routes/projects.js +20 -19
  26. package/package.json +4 -2
  27. package/dist/client/assets/ProjectNavigationDialogs-BDOtIoTy.js +0 -1
  28. package/dist/client/assets/index-MDwr9W9R.css +0 -1
  29. package/dist/client/assets/index-zIjCeZFK.js +0 -64
  30. package/dist/client/assets/spellCheck-Ce3o_xO-.js +0 -4
@@ -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,
@@ -5,7 +5,7 @@ import { performance } from "node:perf_hooks";
5
5
  import { requireUser } from "../auth.js";
6
6
  import { accessibleProject, canEdit } from "../projects.js";
7
7
  import { resolveSourcePath, safeRelativePath, texFileStem } from "../files.js";
8
- import { captureCompileSnapshot, cleanCompileArtifacts, cleanCompileCache, compileProject, discardCompileSnapshot, hasCompileCache, publishCompileArtifacts, publishedCompileArtifacts } from "../compiler.js";
8
+ import { captureCompileSnapshot, cleanCompileArtifacts, cleanCompileCache, CompileCancelledError, compileProject, discardCompileSnapshot, hasCompileCache, publishCompileArtifacts, publishedCompileArtifacts } from "../compiler.js";
9
9
  import { parseCompileDiagnostics } from "../compileDiagnostics.js";
10
10
  import { pdfToSource, sourceToPdf } from "../synctex.js";
11
11
  import { availablePdf, compileRunPdf, compileMainFile, compilePdfUrl, isTextCompileArtifact, listCompileArtifacts, pdfLoadingMode, syncArtifacts } from "../compileArtifacts.js";
@@ -32,6 +32,10 @@ function pdfResponseMetadata(config, pdfPath) {
32
32
  function mainDocumentChangedError() {
33
33
  return httpError(409, "MAIN_DOCUMENT_CHANGED");
34
34
  }
35
+ function throwIfCompileCancelled(signal) {
36
+ if (signal.aborted)
37
+ throw new CompileCancelledError();
38
+ }
35
39
  class CompileSnapshotBusyError extends Error {
36
40
  code = "COMPILE_SNAPSHOT_BUSY";
37
41
  constructor() {
@@ -292,6 +296,21 @@ export function registerCompileRoutes(app, context) {
292
296
  throw httpError(404, "PROJECT_NOT_FOUND");
293
297
  } });
294
298
  });
299
+ app.post("/api/projects/:id/compile/cancel", async (request, reply) => {
300
+ const user = requireUser(request, reply, db);
301
+ if (!user)
302
+ return;
303
+ const { id } = request.params;
304
+ const project = accessibleProject(db, id, user);
305
+ if (!project || !canEdit(project))
306
+ return apiError(reply, 403, "COMPILE_FORBIDDEN");
307
+ const body = (request.body ?? {});
308
+ const mainFile = compileMainFile(config, id, project.main_file, body.mainFile);
309
+ if (!mainFile)
310
+ return apiError(reply, 400, "MAIN_DOCUMENT_INVALID");
311
+ const cancelled = compileCoordinator.cancel(id, mainFile);
312
+ return { cancelled: Boolean(cancelled), mainFile, runId: cancelled?.runId ?? null, status: cancelled?.status ?? null };
313
+ });
295
314
  app.post("/api/projects/:id/compile", async (request, reply) => {
296
315
  const requestStartedAt = performance.now();
297
316
  const user = requireUser(request, reply, db);
@@ -363,8 +382,8 @@ export function registerCompileRoutes(app, context) {
363
382
  if (currentGeneration !== snapshotGeneration)
364
383
  snapshotStale = true;
365
384
  };
366
- const broadcast = () => collaboration.signalCompileState(id, {
367
- mainFile, runId, status: phase, requestedBy, updatedAt: now(),
385
+ const broadcast = (stateRunId = runId) => collaboration.signalCompileState(id, {
386
+ mainFile, runId: stateRunId, status: phase, requestedBy, updatedAt: now(),
368
387
  ...(phase === "succeeded" && snapshotStale ? { stale: true } : {})
369
388
  });
370
389
  const result = await compileCoordinator.request({
@@ -380,19 +399,28 @@ export function registerCompileRoutes(app, context) {
380
399
  onDiscarded: () => {
381
400
  db.prepare("DELETE FROM compile_runs WHERE id = ? AND status = 'queued'").run(runId);
382
401
  },
402
+ onCancelled: () => {
403
+ phase = "failed";
404
+ db.prepare("UPDATE compile_runs SET status = 'failed', log = ?, finished_at = ? WHERE id = ? AND status IN ('queued', 'running')")
405
+ .run(new CompileCancelledError().message, now(), runId);
406
+ broadcast();
407
+ },
383
408
  // The compile reservation protects the shared compiler cache and
384
409
  // prevents Git/cleanup/deletion from replacing the project while
385
410
  // latexmk is running, but it deliberately does not occupy the ordinary
386
411
  // project queue for the duration of the subprocess.
387
- execute: async () => projectMutations.runCompile(id, async () => {
412
+ execute: async (signal) => projectMutations.runCompile(id, async () => {
388
413
  let snapshot = null;
389
414
  let snapshotMs = 0;
390
415
  const snapshotStartedAt = performance.now();
391
416
  try {
392
- // Refresh the cheap generation after waiting for any queued source
393
- // operation. This makes the no-op check safe when a Git/HTTP write
394
- // completed after admission but before this compile got a worker.
395
- const refreshed = await projectMutations.runSerialized(id, () => {
417
+ throwIfCompileCancelled(signal);
418
+ // Revalidate settings after queued source operations. Do not use the
419
+ // in-memory collaboration revision to skip before a snapshot: it is
420
+ // not a durable content version and can collide after a room restart
421
+ // or reconnect.
422
+ await projectMutations.runSerialized(id, () => {
423
+ throwIfCompileCancelled(signal);
396
424
  const currentProject = accessibleProject(db, id, user);
397
425
  if (!currentProject || !canEdit(currentProject)) {
398
426
  throw new Error("Project access changed while waiting for a source snapshot");
@@ -404,38 +432,9 @@ export function registerCompileRoutes(app, context) {
404
432
  throw mainDocumentChangedError();
405
433
  project = currentProject;
406
434
  mainFile = currentMainFile;
407
- return {
408
- generation: compileRequestGeneration(currentProject, collaboration.currentRevision(id), collaboration.currentGeneration(id), config.extraArgs)
409
- };
410
435
  });
411
- const currentGeneration = refreshed.generation;
412
- const publishedBeforeSnapshot = publishedCompileArtifacts(config, id, mainFile, mainFile === project.main_file);
413
- const publishedRunBeforeSnapshot = publishedBeforeSnapshot ? db.prepare(`SELECT id, status, log, finished_at
414
- FROM compile_runs WHERE id = ? AND project_id = ?`).get(publishedBeforeSnapshot.runId, id) : undefined;
415
- const latestRunBeforeSnapshot = db.prepare(`SELECT id, status FROM compile_runs
416
- WHERE project_id = ? AND main_file = ? AND id <> ? ORDER BY created_at DESC LIMIT 1`).get(id, mainFile, runId);
417
- const activeRunBeforeSnapshot = db.prepare(`SELECT 1 AS active FROM compile_runs
418
- WHERE project_id = ? AND main_file = ? AND id <> ? AND status IN ('queued', 'running') LIMIT 1`).get(id, mainFile, runId);
419
- if (!activeRunBeforeSnapshot && publishedBeforeSnapshot?.generation === currentGeneration
420
- && publishedRunBeforeSnapshot?.status === "succeeded"
421
- && latestRunBeforeSnapshot?.id === publishedRunBeforeSnapshot.id
422
- && hasCompileCache(config, id, mainFile)) {
423
- db.prepare("DELETE FROM compile_runs WHERE id = ? AND status = 'queued'").run(runId);
424
- phase = "succeeded";
425
- broadcast();
426
- return {
427
- runId: publishedBeforeSnapshot.runId,
428
- ok: true,
429
- skipped: true,
430
- log: publishedRunBeforeSnapshot.log ?? "",
431
- diagnostics: parseCompileDiagnostics(publishedRunBeforeSnapshot.log ?? "", "succeeded"),
432
- pdfPath: null,
433
- synctexPath: publishedBeforeSnapshot.synctex,
434
- revision: publishedBeforeSnapshot.revision,
435
- snapshotMs: 0
436
- };
437
- }
438
436
  const captured = await projectMutations.runSerialized(id, async () => {
437
+ throwIfCompileCancelled(signal);
439
438
  const currentProject = accessibleProject(db, id, user);
440
439
  if (!currentProject || !canEdit(currentProject)) {
441
440
  throw new Error("Project access changed while waiting for a source snapshot");
@@ -469,6 +468,7 @@ export function registerCompileRoutes(app, context) {
469
468
  extraArgs: config.extraArgs,
470
469
  generation: generationKey
471
470
  });
471
+ throwIfCompileCancelled(signal);
472
472
  }
473
473
  catch (error) {
474
474
  captureError = error;
@@ -508,6 +508,7 @@ export function registerCompileRoutes(app, context) {
508
508
  throw new CompileSnapshotBusyError();
509
509
  }, { flush: false });
510
510
  snapshot = captured;
511
+ throwIfCompileCancelled(signal);
511
512
  snapshotMs = performance.now() - snapshotStartedAt;
512
513
  metrics.record("compile.snapshot", snapshotMs);
513
514
  const published = publishedCompileArtifacts(config, id, mainFile, mainFile === project.main_file);
@@ -526,7 +527,9 @@ export function registerCompileRoutes(app, context) {
526
527
  snapshot = null;
527
528
  db.prepare("DELETE FROM compile_runs WHERE id = ? AND status = 'queued'").run(runId);
528
529
  phase = "succeeded";
529
- broadcast();
530
+ // The probe row was deleted, so broadcast the retained run that
531
+ // clients can actually resolve from SQLite and the manifest.
532
+ broadcast(published.runId);
530
533
  return {
531
534
  runId: published.runId,
532
535
  ok: true,
@@ -545,15 +548,17 @@ export function registerCompileRoutes(app, context) {
545
548
  broadcast();
546
549
  let compiled;
547
550
  try {
548
- compiled = await compileProject(config, snapshot, mainFile, project.engine, project.latexmkrc);
551
+ compiled = await compileProject(config, snapshot, mainFile, project.engine, project.latexmkrc, { signal });
549
552
  refreshSnapshotStale();
550
553
  if (compiled.ok && compiled.pdfPath) {
554
+ throwIfCompileCancelled(signal);
551
555
  const publishStartedAt = performance.now();
552
556
  // Publishing is short and atomic, but still goes through the
553
557
  // ordinary project queue so it cannot overlap a source-tree
554
558
  // archive/read or another output mutation. The long latexmk
555
559
  // process above remains outside that queue.
556
560
  await projectMutations.runSerialized(id, () => {
561
+ throwIfCompileCancelled(signal);
557
562
  if (!db.prepare("SELECT 1 FROM projects WHERE id = ?").get(id)) {
558
563
  throw new Error("Project was deleted before compile artifacts could be published.");
559
564
  }
@@ -576,12 +581,19 @@ export function registerCompileRoutes(app, context) {
576
581
  snapshot = null;
577
582
  compiled = {
578
583
  ok: false,
584
+ ...(error instanceof CompileCancelledError ? { cancelled: true } : {}),
579
585
  log: error instanceof Error ? error.message : String(error),
580
586
  diagnostics: parseCompileDiagnostics(error instanceof Error ? error.message : String(error), "failed"),
581
587
  pdfPath: null,
582
588
  synctexPath: null
583
589
  };
584
590
  }
591
+ // A killed latexmk can leave an incomplete dependency database in
592
+ // the incremental cache. Keep the last published PDF, but make the
593
+ // next requested compile a clean rebuild instead of trusting that
594
+ // partial state.
595
+ if (compiled.cancelled)
596
+ cleanCompileCache(config, id, mainFile);
585
597
  phase = compiled.ok ? "succeeded" : "failed";
586
598
  db.prepare("UPDATE compile_runs SET status = ?, log = ?, finished_at = ? WHERE id = ?")
587
599
  .run(phase, compiled.log, now(), runId);
@@ -617,6 +629,7 @@ export function registerCompileRoutes(app, context) {
617
629
  broadcast();
618
630
  return {
619
631
  ok: false,
632
+ ...(error instanceof CompileCancelledError ? { cancelled: true } : {}),
620
633
  log,
621
634
  diagnostics: parseCompileDiagnostics(log, "failed"),
622
635
  pdfPath: null,
@@ -660,7 +673,7 @@ export function registerCompileRoutes(app, context) {
660
673
  `total;dur=${timingDuration(requestMs)}`
661
674
  ].filter(Boolean).join(", "));
662
675
  return {
663
- mainFile, runId: result.runId, ok: result.ok, skipped: result.skipped === true, log: result.log, diagnostics: result.diagnostics,
676
+ mainFile, runId: result.runId, ok: result.ok, cancelled: result.cancelled === true, skipped: result.skipped === true, log: result.log, diagnostics: result.diagnostics,
664
677
  pdfUrl: result.ok ? compilePdfUrl(id, mainFile, result.runId) : null,
665
678
  pdfCompiledAt: result.ok ? completed?.finished_at ?? null : null,
666
679
  ...pdfMetadata,
@@ -212,6 +212,14 @@ export function registerProjectFileRoutes(app, context) {
212
212
  return apiError(reply, 404, "PATH_NOT_FOUND", { path: source });
213
213
  if (destination === source)
214
214
  return { ok: true, path: source };
215
+ // Moving the configured root to (for example) main.tex.bak used to
216
+ // silently store that non-TeX path as main_file. The next compilation
217
+ // then failed long after the rename had appeared to succeed. Keep the
218
+ // current root a TeX file; users can first select another root document
219
+ // in project settings when they genuinely want to retire this one.
220
+ if (source === project.main_file && !/\.tex$/i.test(destination)) {
221
+ return apiError(reply, 400, "MAIN_DOCUMENT_INVALID");
222
+ }
215
223
  const initialSourceStat = fs.statSync(sourceAbsolute);
216
224
  if (initialSourceStat.isDirectory() && (destinationDirectory === source || destinationDirectory.startsWith(`${source}/`))) {
217
225
  return apiError(reply, 400, "MOVE_INTO_SELF", { path: source });
@@ -229,6 +237,9 @@ export function registerProjectFileRoutes(app, context) {
229
237
  const validateMove = () => {
230
238
  currentProject = requireEditableProject(db, id, user);
231
239
  assertNoSourceSymlinks(config, id);
240
+ if (source === currentProject.main_file && !/\.tex$/i.test(destination)) {
241
+ throw httpError(400, "MAIN_DOCUMENT_INVALID");
242
+ }
232
243
  if (!fs.existsSync(sourceAbsolute)) {
233
244
  throw httpError(404, "PATH_NOT_FOUND", { path: source });
234
245
  }
@@ -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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "texlite",
3
- "version": "0.7.7",
3
+ "version": "0.8.0",
4
4
  "description": "A lightweight local-first collaborative LaTeX workspace",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": {
@@ -24,6 +24,9 @@
24
24
  "dist",
25
25
  "README.md",
26
26
  "README.zh-CN.md",
27
+ "OPERATIONS.md",
28
+ "DESIGN.md",
29
+ "NPM_TESTING.md",
27
30
  "LICENSE",
28
31
  "preview.png",
29
32
  "texlite.config.example.json"
@@ -72,7 +75,6 @@
72
75
  "bibtex-tidy": "1.14.0",
73
76
  "diff-match-patch": "^1.0.5",
74
77
  "fastify": "^5.4.0",
75
- "harper.js": "^2.7.0",
76
78
  "i18next": "^25.3.2",
77
79
  "i18next-browser-languagedetector": "^8.2.0",
78
80
  "lib0": "^0.2.117",
@@ -1 +0,0 @@
1
- import{u as A,r,j as e}from"./react-BoEjKzFZ.js";import{j as B,a as R,M as F,o as $,L as I,p as J,n as K,F as V,q as G}from"./index-zIjCeZFK.js";const L=B("Replace",[["path",{d:"M14 4a2 2 0 0 1 2-2",key:"1w2hp7"}],["path",{d:"M16 10a2 2 0 0 1-2-2",key:"shjach"}],["path",{d:"M20 2a2 2 0 0 1 2 2",key:"188mtx"}],["path",{d:"M22 8a2 2 0 0 1-2 2",key:"ddf4tu"}],["path",{d:"m3 7 3 3 3-3",key:"x25e72"}],["path",{d:"M6 10V5a3 3 0 0 1 3-3h1",key:"3y3t5z"}],["rect",{x:"2",y:"14",width:"8",height:"8",rx:"2",key:"4rksxw"}]]);function Z({open:i,files:u,onOpenChange:l,onOpenFile:p}){const{t:a}=A(),[n,m]=r.useState(""),[d,o]=r.useState(0),f=r.useRef(null),h=r.useMemo(()=>u.filter(t=>t.type==="file").map(t=>({entry:t,score:H(t.path,n)})).filter(t=>t.score>=0).sort((t,c)=>c.score-t.score||t.entry.path.localeCompare(c.entry.path)).slice(0,100),[u,n]);r.useEffect(()=>{i&&(m(""),o(0),window.setTimeout(()=>f.current?.focus(),0))},[i]),r.useEffect(()=>o(t=>Math.min(t,Math.max(0,h.length-1))),[h.length]);const x=t=>{p(t),l(!1)};return e.jsx(F,{open:i,wide:!0,title:a("navigation.quickOpen"),description:a("navigation.quickOpenDescription"),onOpenChange:l,children:e.jsxs("div",{className:"quick-open-dialog",children:[e.jsxs("label",{children:[e.jsx($,{size:16}),e.jsx("input",{ref:f,value:n,placeholder:a("navigation.filePlaceholder"),onChange:t=>{m(t.target.value),o(0)},onKeyDown:t=>{t.key==="ArrowDown"&&(t.preventDefault(),o(c=>Math.min(h.length-1,c+1))),t.key==="ArrowUp"&&(t.preventDefault(),o(c=>Math.max(0,c-1))),t.key==="Enter"&&h[d]&&(t.preventDefault(),x(h[d].entry.path))}})]}),e.jsxs("div",{className:"quick-open-results",children:[h.map(({entry:t},c)=>e.jsxs("button",{className:c===d?"active":"",onMouseEnter:()=>o(c),onClick:()=>x(t.path),children:[e.jsx(V,{size:14}),e.jsx("span",{children:t.path}),c===d&&e.jsx(G,{size:13})]},t.path)),h.length===0&&e.jsx("p",{className:"muted padded",children:a("navigation.noFiles")})]})]})})}function _({open:i,project:u,onOpenChange:l,onJump:p}){const{t:a}=A(),[n,m]=r.useState(""),[d,o]=r.useState(""),[f,h]=r.useState(!1),[x,t]=r.useState(!1),[c,w]=r.useState([]),[v,C]=r.useState(0),[P,D]=r.useState(!1),[b,S]=r.useState(!1),[W,N]=r.useState(!1),[q,M]=r.useState(""),[z,k]=r.useState(""),[O,Q]=r.useState(0),y=r.useRef(null);r.useEffect(()=>{if(!i){y.current?.abort();return}if(M(""),k(""),!n.trim()){w([]),C(0),D(!1);return}const s=window.setTimeout(()=>{y.current?.abort();const j=new AbortController;y.current=j,S(!0);const E=new URLSearchParams({q:n});f&&E.set("caseSensitive","1"),x&&E.set("wholeWord","1"),R(`/api/projects/${u.id}/search?${E}`,{signal:j.signal}).then(g=>{w(g.matches),C(g.total),D(g.truncated)}).catch(g=>{g instanceof DOMException&&g.name==="AbortError"||k(T(g))}).finally(()=>{y.current===j&&(y.current=null,S(!1))})},250);return()=>window.clearTimeout(s)},[i,u.id,n,f,x,O]);const U=async()=>{N(!1),S(!0),k(""),M("");try{const s=await R(`/api/projects/${u.id}/search/replace`,{method:"POST",body:JSON.stringify({query:n,replacement:d,caseSensitive:f,wholeWord:x})});M(a("navigation.replaced",{count:s.replacements,files:s.files.length})),w([]),C(0),Q(j=>j+1)}catch(s){k(T(s))}finally{S(!1)}};return e.jsxs(e.Fragment,{children:[e.jsx(F,{open:i,extraWide:!0,title:a("navigation.projectSearch"),description:a("navigation.projectSearchDescription"),onOpenChange:l,footer:e.jsx("button",{onClick:()=>l(!1),children:a("common.close")}),children:e.jsxs("div",{className:"project-search-dialog",children:[z&&e.jsx("p",{className:"error",children:z}),q&&e.jsx("p",{className:"success",children:q}),e.jsxs("div",{className:"project-search-form",children:[e.jsxs("label",{className:"project-search-query",children:[e.jsx($,{size:16}),e.jsx("input",{autoFocus:!0,value:n,placeholder:a("navigation.searchPlaceholder"),onChange:s=>m(s.target.value)})]}),u.permission!=="read"&&e.jsxs("label",{className:"project-search-query",children:[e.jsx(L,{size:16}),e.jsx("input",{value:d,placeholder:a("navigation.replacePlaceholder"),onChange:s=>o(s.target.value)})]}),e.jsxs("div",{className:"project-search-options",children:[e.jsxs("label",{children:[e.jsx("input",{type:"checkbox",checked:f,onChange:s=>h(s.target.checked)}),a("editor.search.matchCase")]}),e.jsxs("label",{children:[e.jsx("input",{type:"checkbox",checked:x,onChange:s=>t(s.target.checked)}),a("editor.search.wholeWord")]}),u.permission!=="read"&&e.jsxs("button",{className:"danger-text",disabled:!n.trim()||v===0||b,onClick:()=>N(!0),children:[e.jsx(L,{size:13}),a("navigation.replaceAll",{count:v})]})]})]}),e.jsxs("div",{className:"project-search-summary",children:[e.jsx("span",{children:b?e.jsxs(e.Fragment,{children:[e.jsx(I,{className:"spin",size:13}),a("common.loading")]}):a("navigation.matchSummary",{count:v})}),P&&e.jsx("small",{children:a("navigation.truncated",{count:c.length})})]}),e.jsxs("div",{className:"project-search-results",children:[c.map((s,j)=>e.jsxs("button",{onClick:()=>{p(s.path,s.line,s.column),l(!1)},children:[e.jsx(J,{size:14}),e.jsxs("span",{children:[e.jsxs("strong",{children:[s.path,e.jsxs("small",{children:[":",s.line,":",s.column]})]}),e.jsxs("code",{children:[s.preview.slice(0,s.matchStart),e.jsx("mark",{children:s.preview.slice(s.matchStart,s.matchEnd)}),s.preview.slice(s.matchEnd)]})]})]},`${s.path}-${s.line}-${s.column}-${j}`)),!b&&n.trim()&&c.length===0&&e.jsx("p",{className:"muted padded",children:a("editor.search.noMatches")})]})]})}),e.jsx(K,{open:W,title:a("navigation.replaceConfirmTitle"),description:a("navigation.replaceConfirmDescription",{count:v}),confirmLabel:a("navigation.replaceAll",{count:v}),danger:!0,onCancel:()=>N(!1),onConfirm:()=>{U()}})]})}function H(i,u){const l=i.toLocaleLowerCase(),p=u.trim().toLocaleLowerCase();if(!p)return 1;const a=l.indexOf(p);if(a>=0)return 1e4-a-l.length;let n=0,m=0;for(const d of p){const o=l.indexOf(d,n);if(o<0)return-1;m+=o===n?5:1,n=o+1}return m-l.length/100}function T(i){return i instanceof Error?i.message:String(i)}export{_ as ProjectSearchDialog,Z as QuickOpenDialog};