texlite 0.9.0 → 0.9.2

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 (93) hide show
  1. package/DESIGN.md +7 -0
  2. package/OPERATIONS.md +70 -3
  3. package/README.md +5 -0
  4. package/README.zh-CN.md +5 -0
  5. package/THIRD_PARTY_NOTICES.md +29 -0
  6. package/dist/client/assets/{AdminUsers-3CHb5012.js → AdminUsers-Buf5ZclR.js} +1 -1
  7. package/dist/client/assets/{CitationLibraryDialog-zPpsUhWJ.js → CitationLibraryDialog-DrKbIlD-.js} +1 -1
  8. package/dist/client/assets/Dashboard-DLk2j3oq.js +2 -0
  9. package/dist/client/assets/GitDialog-CTWAU8q6.js +3 -0
  10. package/dist/client/assets/HistoryDialog-Mo4p8HEg.js +7 -0
  11. package/dist/client/assets/LatexEditor-CwvakYva.js +135 -0
  12. package/dist/client/assets/{PdfPreview-BFVNczSk.js → PdfPreview-BUpELZd-.js} +1 -1
  13. package/dist/client/assets/{ProjectIconPickerDialog-Cf0zBf8u.js → ProjectIconPickerDialog-BiWSwDdt.js} +1 -1
  14. package/dist/client/assets/{ProjectNavigationDialogs-TPbe11iR.js → ProjectNavigationDialogs-CpjkXZTz.js} +1 -1
  15. package/dist/client/assets/{ProjectSettings-CDQvIlQ4.js → ProjectSettings-DoxitHbX.js} +1 -1
  16. package/dist/client/assets/ProjectWorkspace-iNqXZlsj.js +29 -0
  17. package/dist/client/assets/SelectionHistoryDialog-9FWlc3Rb.js +3 -0
  18. package/dist/client/assets/{SystemMetricsDialog-CuqHPUnn.js → SystemMetricsDialog-24kifZO6.js} +1 -1
  19. package/dist/client/assets/TagManagementDialog-BHIA0vEA.js +1 -0
  20. package/dist/client/assets/{WordCountDialog-B4PI0vEk.js → WordCountDialog-DFIWvLrP.js} +1 -1
  21. package/dist/client/assets/check-C3GZh6a1.js +1 -0
  22. package/dist/client/assets/chevron-left-l1oupvGO.js +1 -0
  23. package/dist/client/assets/editor-3rgn4IYh.js +14 -0
  24. package/dist/client/assets/{hard-drive-6uYFyezl.js → hard-drive-B-NSDVVA.js} +1 -1
  25. package/dist/client/assets/{index-BRT5O-Hg.js → index-CNTGCh7t.js} +1 -1
  26. package/dist/client/assets/index-CrlfruX_.css +1 -0
  27. package/dist/client/assets/index-DIM_KiG2.js +12 -0
  28. package/dist/client/assets/katex-D9PXdmFF.css +1 -0
  29. package/dist/client/assets/{key-round-DOAxDSfz.js → key-round-BjL9H8-H.js} +1 -1
  30. package/dist/client/assets/{latexFormatterWorker-DYkYtcFy.js → latexFormatterWorker-BRmsns8a.js} +2 -2
  31. package/dist/client/assets/minimize-2-DDCq8c49.js +1 -0
  32. package/dist/client/assets/{minus-9eVJnugf.js → minus-DgKZCT9F.js} +1 -1
  33. package/dist/client/assets/{pdf-BB1XvBIF.js → pdf-DUYcbPMK.js} +1 -1
  34. package/dist/client/assets/{plus-BGXipRoG.js → plus-CezUzSIZ.js} +1 -1
  35. package/dist/client/assets/{refresh-cw-Dvp3O90f.js → refresh-cw-CDFNL-qu.js} +1 -1
  36. package/dist/client/assets/{search-b1ORp1k8.js → search-CrDJjHoj.js} +1 -1
  37. package/dist/client/assets/{sigma-Bk0_MzWi.js → sigma-DOcJr7VG.js} +1 -1
  38. package/dist/client/assets/spellCheck-BgWyesvf.js +1 -0
  39. package/dist/client/assets/{tags-JAkbmWns.js → tags-BedQ75lh.js} +1 -1
  40. package/dist/client/assets/{type-CE2DYkPH.js → type-glZNdzZR.js} +1 -1
  41. package/dist/client/assets/{user-plus-CKalKSsw.js → user-plus-gQ6q5Nom.js} +1 -1
  42. package/dist/client/assets/{x-Bq29njHL.js → x-CSzFikn2.js} +1 -1
  43. package/dist/client/index.html +6 -4
  44. package/dist/server/app.js +130 -83
  45. package/dist/server/cli.js +4 -2
  46. package/dist/server/compileArtifacts.js +3 -2
  47. package/dist/server/config.js +27 -0
  48. package/dist/server/harper.js +205 -8
  49. package/dist/server/history.js +53 -3
  50. package/dist/server/i18n.js +8 -0
  51. package/dist/server/index.js +4 -2
  52. package/dist/server/instanceLock.js +4 -2
  53. package/dist/server/latexCompletion.js +126 -63
  54. package/dist/server/latexDocumentGraph.js +106 -0
  55. package/dist/server/latexSpellMask.js +196 -43
  56. package/dist/server/pm2.js +3 -2
  57. package/dist/server/projectReferences.js +109 -0
  58. package/dist/server/routes/auth.js +5 -2
  59. package/dist/server/routes/comments.js +13 -1
  60. package/dist/server/routes/compile.js +2 -2
  61. package/dist/server/routes/projectFiles.js +12 -3
  62. package/dist/server/routes/projectHistory.js +13 -1
  63. package/dist/server/routes/projectReferences.js +36 -0
  64. package/dist/server/routes/projects.js +41 -4
  65. package/dist/server/routes/system.js +1 -0
  66. package/dist/server/zip.js +2 -16
  67. package/dist/shared/basePath.js +44 -0
  68. package/dist/shared/bibtexReferences.js +135 -0
  69. package/dist/shared/latexCommandSnippets.js +91 -0
  70. package/dist/shared/latexDependencies.js +71 -0
  71. package/dist/shared/latexLiterals.js +215 -0
  72. package/dist/shared/latexReferenceCommands.js +75 -0
  73. package/dist/shared/latexReferences.js +193 -0
  74. package/dist/shared/latexRoot.js +21 -0
  75. package/dist/shared/latexScanner.js +183 -0
  76. package/dist/shared/writingChecks.js +4 -0
  77. package/package.json +11 -4
  78. package/texlite.config.example.json +2 -4
  79. package/dist/client/assets/Dashboard-CWfSLzD3.js +0 -2
  80. package/dist/client/assets/GitDialog-BUU2U_LV.js +0 -3
  81. package/dist/client/assets/HistoryDialog-B6aopsrw.js +0 -7
  82. package/dist/client/assets/LatexEditor-CM445Tk0.js +0 -65
  83. package/dist/client/assets/ProjectWorkspace-C6GrvKN6.js +0 -27
  84. package/dist/client/assets/SelectionHistoryDialog-CyE0_YDs.js +0 -3
  85. package/dist/client/assets/TagManagementDialog-gRLM3lLx.js +0 -1
  86. package/dist/client/assets/check-BXvzrdoz.js +0 -1
  87. package/dist/client/assets/chevron-left-BB5MYb7g.js +0 -1
  88. package/dist/client/assets/editor-CiK7TK0p.js +0 -14
  89. package/dist/client/assets/index-C0t11tlT.js +0 -14
  90. package/dist/client/assets/index-DXWp2bzM.css +0 -1
  91. package/dist/client/assets/katex-CEK31ho9.css +0 -1
  92. package/dist/client/assets/minimize-2-BWtbag2I.js +0 -1
  93. package/dist/client/assets/spellCheck-BJjWgnef.js +0 -1
@@ -1,16 +1,33 @@
1
+ import { literalEnvironmentNames } from "../shared/latexLiterals.js";
1
2
  // This intentionally recognizes only the parts of LaTeX that are clearly not
2
3
  // prose. It is a single-pass lexer, not a parser: malformed delimiters remain
3
4
  // narrow, so they never hide the rest of a document or cause backtracking.
5
+ const literalEnvironments = new Set(literalEnvironmentNames.map((name) => name.toLowerCase()));
4
6
  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"
7
+ ...literalEnvironments,
8
+ // Display and helper math environments commonly used by amsmath/mathtools.
9
+ "math", "displaymath", "equation", "equation*", "align", "align*", "alignat", "alignat*", "aligned", "alignedat", "gather", "gather*", "gathered", "multline", "multline*", "multlined", "flalign", "flalign*", "split", "cases", "dcases", "rcases", "drcases",
10
+ "array", "matrix", "pmatrix", "bmatrix", "vmatrix", "smallmatrix", "psmallmatrix", "bsmallmatrix", "vsmallmatrix",
11
+ // Diagram, table, and literal/code environments. Keep prose containers such
12
+ // as figure, table, theorem, and caption outside this list.
13
+ "tikzpicture", "tikzcd", "axis", "scope", "pgfonlayer", "pgfpicture",
14
+ "tabular", "tabularx", "tabulary", "longtable",
15
+ "verbatim", "verbatim*", "bverbatim", "bverbatim*", "lverbatim", "lverbatim*", "saveverbatim", "saveverbatim*", "verbatimout",
16
+ "lstlisting", "minted", "minted*", "filecontents", "filecontents*", "luacode", "luacodestar", "comment", "algorithmic"
9
17
  ]);
10
18
  const opaqueArgumentCommands = new Set([
11
19
  "documentclass", "usepackage", "requirepackage", "includegraphics", "tikzset", "pgfplotsset", "hypersetup", "lstset",
12
20
  "label", "hypertarget", "ref", "pageref", "autoref", "nameref", "hyperref", "index", "gls", "glspl",
13
- "bibliography", "addbibresource", "input", "include", "url", "path", "setlength", "setcounter", "color", "textcolor", "colorbox", "pagecolor"
21
+ "bibliography", "addbibresource", "input", "include", "url", "path", "setlength", "setcounter", "color", "textcolor", "colorbox", "pagecolor",
22
+ // These arguments are code, identifiers, or paths rather than prose.
23
+ "texttt", "detokenize", "lstinputlisting"
24
+ ]);
25
+ /** Number of brace arguments after a macro/environment target. */
26
+ const macroDefinitionArgumentCounts = new Map([
27
+ ["newcommand", 1], ["renewcommand", 1], ["providecommand", 1], ["declarerobustcommand", 1],
28
+ ["newenvironment", 2], ["renewenvironment", 2],
29
+ ["newdocumentcommand", 2], ["renewdocumentcommand", 2], ["providedocumentcommand", 2], ["declaredocumentcommand", 2],
30
+ ["declaremathoperator", 1], ["declarepaireddelimiter", 2]
14
31
  ]);
15
32
  function addRange(ranges, from, to) {
16
33
  if (to > from)
@@ -26,7 +43,7 @@ function isCommandNameCharacter(character) {
26
43
  return (code >= 65 && code <= 90) || (code >= 97 && code <= 122) || (code >= 48 && code <= 57)
27
44
  || character === "@" || character === ":" || character === "_";
28
45
  }
29
- function balancedArgument(source, start, open, close) {
46
+ function balancedArgument(source, start, open, close, literal = false) {
30
47
  if (source[start] !== open)
31
48
  return null;
32
49
  let depth = 1;
@@ -35,6 +52,13 @@ function balancedArgument(source, start, open, close) {
35
52
  index += 1;
36
53
  continue;
37
54
  }
55
+ if (!literal && source[index] === "%") {
56
+ const lineEnd = source.indexOf("\n", index);
57
+ if (lineEnd < 0)
58
+ return null;
59
+ index = lineEnd;
60
+ continue;
61
+ }
38
62
  if (source[index] === open)
39
63
  depth += 1;
40
64
  else if (source[index] === close && --depth === 0)
@@ -48,6 +72,17 @@ function skipWhitespace(source, start) {
48
72
  index += 1;
49
73
  return index;
50
74
  }
75
+ /** Skip whitespace and TeX comments between arguments while masking comments. */
76
+ function skipTrivia(source, start, ranges) {
77
+ let index = skipWhitespace(source, start);
78
+ while (source[index] === "%") {
79
+ const lineEnd = source.indexOf("\n", index);
80
+ const end = lineEnd < 0 ? source.length : lineEnd;
81
+ addRange(ranges, index, end);
82
+ index = skipWhitespace(source, lineEnd < 0 ? source.length : lineEnd + 1);
83
+ }
84
+ return index;
85
+ }
51
86
  function readCommand(source, start) {
52
87
  let end = start + 1;
53
88
  if (isCommandNameCharacter(source[end]))
@@ -64,23 +99,103 @@ function isCitationCommand(name) {
64
99
  const normalized = name.toLocaleLowerCase("en-US");
65
100
  return normalized === "cite" || normalized.startsWith("cite") || normalized.endsWith("cite");
66
101
  }
67
- function masksOpaqueArguments(name) {
102
+ function opaqueArgumentCount(name) {
68
103
  const normalized = name.toLocaleLowerCase("en-US");
69
- return opaqueArgumentCommands.has(normalized) || isCitationCommand(normalized) || normalized === "definecolor" || normalized === "colorlet" || normalized === "href";
104
+ if (normalized === "definecolor" || normalized === "colorlet")
105
+ return 3;
106
+ if (normalized === "mint" || normalized === "inputminted")
107
+ return 2;
108
+ return opaqueArgumentCommands.has(normalized) || isCitationCommand(normalized) || normalized === "href" ? 1 : 0;
70
109
  }
71
- function maskOpaqueArguments(source, start, name, ranges) {
110
+ function maskBraceArguments(source, start, count, ranges) {
72
111
  let cursor = start;
73
- const normalized = name.toLocaleLowerCase("en-US");
74
- const count = normalized === "definecolor" || normalized === "colorlet" ? 3 : 1;
75
112
  for (let index = 0; index < count; index += 1) {
76
113
  const argument = balancedArgument(source, cursor, "{", "}");
77
114
  if (!argument)
78
115
  break;
79
116
  addRange(ranges, argument.from, argument.to);
80
- cursor = skipWhitespace(source, argument.to);
117
+ cursor = skipTrivia(source, argument.to, ranges);
81
118
  }
82
119
  return cursor;
83
120
  }
121
+ function maskMacroTarget(source, start, ranges) {
122
+ const bracedTarget = balancedArgument(source, start, "{", "}");
123
+ if (bracedTarget) {
124
+ addRange(ranges, bracedTarget.from, bracedTarget.to);
125
+ return skipTrivia(source, bracedTarget.to, ranges);
126
+ }
127
+ if (source[start] === "\\") {
128
+ const target = readCommand(source, start);
129
+ addRange(ranges, start, target.end);
130
+ return skipTrivia(source, target.end, ranges);
131
+ }
132
+ return start;
133
+ }
134
+ function maskMacroDefinition(source, start, definitionArgumentCount, ranges) {
135
+ let cursor = maskMacroTarget(source, start, ranges);
136
+ if (cursor === start)
137
+ return cursor;
138
+ // newcommand-style macros may have both an argument-count option and a
139
+ // default-argument option. Mask every immediately following option before
140
+ // their definition bodies, while keeping malformed input narrow.
141
+ for (;;) {
142
+ const option = balancedArgument(source, cursor, "[", "]");
143
+ if (!option)
144
+ break;
145
+ addRange(ranges, option.from, option.to);
146
+ cursor = skipTrivia(source, option.to, ranges);
147
+ }
148
+ return maskBraceArguments(source, cursor, definitionArgumentCount, ranges);
149
+ }
150
+ function maskDelimitedCode(source, start, ranges) {
151
+ const delimiter = source[start];
152
+ if (!delimiter || isWhitespace(delimiter))
153
+ return start;
154
+ // TeX's delimiter-based inline literal commands cannot span a line. If a
155
+ // user is still typing the closing delimiter, hide only the incomplete
156
+ // current line rather than accidentally suppressing all later prose until a
157
+ // matching character happens to occur.
158
+ for (let cursor = start + 1; cursor < source.length; cursor += 1) {
159
+ const character = source[cursor];
160
+ if (character === delimiter) {
161
+ addRange(ranges, start, cursor + 1);
162
+ return skipTrivia(source, cursor + 1, ranges);
163
+ }
164
+ if (character === "\r" || character === "\n") {
165
+ addRange(ranges, start, cursor);
166
+ return cursor;
167
+ }
168
+ }
169
+ addRange(ranges, start, source.length);
170
+ return source.length;
171
+ }
172
+ function maskDelimitedOrBracedCode(source, start, ranges) {
173
+ if (source[start] !== "{")
174
+ return maskDelimitedCode(source, start, ranges);
175
+ // The brace form is also inline literal code. Preserve nested braces and
176
+ // escaped characters, but never let an unfinished argument conceal a later
177
+ // source line from the writing checker.
178
+ let depth = 1;
179
+ for (let cursor = start + 1; cursor < source.length; cursor += 1) {
180
+ const character = source[cursor];
181
+ if (character === "\\" && source[cursor + 1] !== "\r" && source[cursor + 1] !== "\n") {
182
+ cursor += 1;
183
+ continue;
184
+ }
185
+ if (character === "\r" || character === "\n") {
186
+ addRange(ranges, start, cursor);
187
+ return cursor;
188
+ }
189
+ if (character === "{")
190
+ depth += 1;
191
+ else if (character === "}" && --depth === 0) {
192
+ addRange(ranges, start, cursor + 1);
193
+ return cursor + 1;
194
+ }
195
+ }
196
+ addRange(ranges, start, source.length);
197
+ return source.length;
198
+ }
84
199
  function rawUrlEnd(source, start) {
85
200
  const first = source[start];
86
201
  if (first !== "h" && first !== "H" && first !== "f" && first !== "F")
@@ -115,6 +230,33 @@ export function ignoredLatexRanges(source) {
115
230
  // The same technique keeps unmatched $ / \[ delimiters cheap and local.
116
231
  let pendingMath = null;
117
232
  for (let index = 0; index < source.length;) {
233
+ // An open literal environment owns its full body. Do not parse
234
+ // dollars, comments, or nested commands within it: literal code often
235
+ // contains all of those. Only look for its matching \end marker.
236
+ const activeNonProse = nonProseStack.at(-1);
237
+ if (activeNonProse && literalEnvironments.has(activeNonProse.name)) {
238
+ if (source[index] === "\\") {
239
+ const command = readCommand(source, index);
240
+ if (command.name === "end") {
241
+ const cursor = skipTrivia(source, command.end, ranges);
242
+ const environment = balancedArgument(source, cursor, "{", "}");
243
+ const environmentName = environment
244
+ ? source.slice(environment.from + 1, environment.to - 1).trim().toLocaleLowerCase("en-US")
245
+ : "";
246
+ if (environment && environmentName === activeNonProse.name) {
247
+ activeNonProse.range.to = skipTrivia(source, environment.to, ranges);
248
+ nonProseStack.pop();
249
+ index = activeNonProse.range.to;
250
+ continue;
251
+ }
252
+ }
253
+ index = Math.max(index + 1, command.end);
254
+ }
255
+ else {
256
+ index += 1;
257
+ }
258
+ continue;
259
+ }
118
260
  if (pendingMath?.close.startsWith("\\") && source.startsWith(pendingMath.close, index)) {
119
261
  pendingMath.range.to = index + pendingMath.close.length;
120
262
  index += pendingMath.close.length;
@@ -171,44 +313,41 @@ export function ignoredLatexRanges(source) {
171
313
  continue;
172
314
  }
173
315
  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
- }
316
+ const commandName = command.name.toLocaleLowerCase("en-US");
317
+ // Reserve the command span before scanning trivia, which may append spans.
318
+ const commandRange = { from: commandStart, to: command.end };
319
+ ranges.push(commandRange);
320
+ let cursor = commandName === "verb" || commandName === "lstinline"
321
+ ? command.end : skipTrivia(source, command.end, ranges);
322
+ if (commandName === "verb") {
323
+ const options = command.name === "Verb" ? balancedArgument(source, cursor, "[", "]") : null;
324
+ if (options) {
325
+ addRange(ranges, options.from, options.to);
326
+ cursor = skipWhitespace(source, options.to);
184
327
  }
328
+ cursor = maskDelimitedOrBracedCode(source, cursor, ranges);
185
329
  index = Math.max(command.end, cursor);
186
330
  continue;
187
331
  }
188
332
  if (command.name === "begin" || command.name === "end") {
189
333
  const environment = balancedArgument(source, cursor, "{", "}");
190
334
  if (!environment) {
191
- addRange(ranges, commandStart, command.end);
192
335
  index = command.end;
193
336
  continue;
194
337
  }
195
338
  const environmentName = source.slice(environment.from + 1, environment.to - 1).trim().toLocaleLowerCase("en-US");
196
- cursor = skipWhitespace(source, environment.to);
339
+ addRange(ranges, environment.from, environment.to);
340
+ cursor = skipTrivia(source, environment.to, ranges);
197
341
  let options = null;
198
342
  if (command.name === "begin") {
199
343
  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 });
344
+ if (options) {
345
+ addRange(ranges, options.from, options.to);
346
+ cursor = skipTrivia(source, options.to, ranges);
206
347
  }
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);
348
+ if (nonProseEnvironments.has(environmentName)) {
349
+ commandRange.to = cursor;
350
+ nonProseStack.push({ name: environmentName, range: commandRange });
212
351
  }
213
352
  }
214
353
  else {
@@ -217,22 +356,36 @@ export function ignoredLatexRanges(source) {
217
356
  nonProseStack.pop();
218
357
  open.range.to = cursor;
219
358
  }
220
- else {
221
- addRange(ranges, commandStart, command.end);
222
- addRange(ranges, environment.from, environment.to);
223
- }
224
359
  }
225
360
  index = Math.max(command.end, cursor);
226
361
  continue;
227
362
  }
228
- addRange(ranges, commandStart, command.end);
229
363
  const options = balancedArgument(source, cursor, "[", "]");
230
364
  if (options) {
231
365
  addRange(ranges, options.from, options.to);
232
- cursor = skipWhitespace(source, options.to);
366
+ cursor = commandName === "lstinline" ? skipWhitespace(source, options.to) : skipTrivia(source, options.to, ranges);
367
+ }
368
+ if (commandName === "lstinline") {
369
+ cursor = maskDelimitedOrBracedCode(source, cursor, ranges);
370
+ }
371
+ else if (commandName === "mintinline") {
372
+ const language = balancedArgument(source, cursor, "{", "}");
373
+ if (language) {
374
+ addRange(ranges, language.from, language.to);
375
+ cursor = maskDelimitedOrBracedCode(source, skipWhitespace(source, language.to), ranges);
376
+ }
377
+ }
378
+ else {
379
+ const macroDefinitionArgumentCount = macroDefinitionArgumentCounts.get(commandName);
380
+ if (macroDefinitionArgumentCount !== undefined) {
381
+ cursor = maskMacroDefinition(source, cursor, macroDefinitionArgumentCount, ranges);
382
+ }
383
+ else {
384
+ const opaqueCount = opaqueArgumentCount(commandName);
385
+ if (opaqueCount > 0)
386
+ cursor = maskBraceArguments(source, cursor, opaqueCount, ranges);
387
+ }
233
388
  }
234
- if (masksOpaqueArguments(command.name))
235
- cursor = maskOpaqueArguments(source, cursor, command.name, ranges);
236
389
  index = Math.max(command.end, cursor);
237
390
  }
238
391
  return mergeOrderedRanges(ranges);
@@ -2,6 +2,7 @@ import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import http from "node:http";
4
4
  import path from "node:path";
5
+ import { basePathHref, withBasePath } from "../shared/basePath.js";
5
6
  import { managedLogPaths } from "./logRotation.js";
6
7
  import { defaultConfigPath, packageRootDirectory, packageServerEntry } from "./runtimePaths.js";
7
8
  let pm2Client = null;
@@ -197,7 +198,7 @@ export async function processStatus(config) {
197
198
  uptimeSeconds: startedAt === null ? null : Math.max(0, Math.floor((Date.now() - startedAt) / 1000)),
198
199
  restarts: env?.restart_time ?? 0,
199
200
  version: packageVersion(),
200
- address: `http://${config.host}:${config.port}`,
201
+ address: `http://${config.host}:${config.port}${basePathHref(config.basePath)}`,
201
202
  dataDir: config.dataDir,
202
203
  cwd: env?.pm_cwd ?? packageRootDirectory(),
203
204
  outputLog: env?.pm_out_log_path ?? null,
@@ -255,7 +256,7 @@ function probeHost(host) {
255
256
  function healthUrl(config) {
256
257
  const host = probeHost(config.host);
257
258
  const formattedHost = host.includes(":") ? `[${host}]` : host;
258
- return `http://${formattedHost}:${config.port}/api/health`;
259
+ return `http://${formattedHost}:${config.port}${withBasePath(config.basePath, "/api/health")}`;
259
260
  }
260
261
  function probeService(config, timeoutMs = 1_500) {
261
262
  return new Promise((resolve) => {
@@ -0,0 +1,109 @@
1
+ import fs from "node:fs";
2
+ import { listProjectFilesAsync, resolveSourcePath } from "./files.js";
3
+ import { findLatexReferenceDefinitions, lineAndColumnAt } from "../shared/latexReferences.js";
4
+ import { documentSourceOrder, latexDocumentDirectives, resolveProjectReferencePath, uniqueProjectPaths } from "./latexDocumentGraph.js";
5
+ const definitionExtensions = /\.(?:tex|sty|cls)$/i;
6
+ const texExtension = /\.tex$/i;
7
+ const bibExtension = /\.bib$/i;
8
+ const MAX_REFERENCE_KEY_LENGTH = 512;
9
+ export function validReferenceKey(value) {
10
+ return typeof value === "string"
11
+ && value.trim().length > 0
12
+ && value.trim().length <= MAX_REFERENCE_KEY_LENGTH
13
+ && !/[\u0000-\u001f\u007f]/.test(value);
14
+ }
15
+ /**
16
+ * Resolve a source-level citation or label target from the project tree.
17
+ *
18
+ * A document's own input graph and declared bibliography files are checked
19
+ * before unrelated files in the project. This keeps a stale \bibitem in an
20
+ * archive or template from shadowing the bibliography used by the selected
21
+ * main document.
22
+ */
23
+ export async function resolveProjectReference(config, projectId, kind, keyInput, options = {}) {
24
+ // Keep the former preferred-path argument working for internal callers
25
+ // during the transition to the richer main-document-aware lookup options.
26
+ const lookup = typeof options === "string" ? { preferredPath: options } : options;
27
+ const key = keyInput.trim();
28
+ const files = (await listProjectFilesAsync(config, projectId))
29
+ .filter((entry) => entry.type === "file" && (definitionExtensions.test(entry.path) || bibExtension.test(entry.path)));
30
+ const sourceEntries = files.filter((entry) => definitionExtensions.test(entry.path));
31
+ const bibEntries = files.filter((entry) => bibExtension.test(entry.path));
32
+ const sourceContents = await readProjectSources(config, projectId, sourceEntries.map((entry) => entry.path));
33
+ const sourcePaths = [...sourceContents.keys()].sort((left, right) => left.localeCompare(right));
34
+ const bibPaths = bibEntries.map((entry) => entry.path).sort((left, right) => left.localeCompare(right));
35
+ const sourceDirectives = new Map();
36
+ for (const [entryPath, source] of sourceContents)
37
+ sourceDirectives.set(entryPath, latexDocumentDirectives(source));
38
+ const preferredPath = sourceContents.has(lookup.preferredPath ?? "") ? lookup.preferredPath ?? "" : "";
39
+ const mainFile = sourceContents.has(lookup.mainFile ?? "") && texExtension.test(lookup.mainFile ?? "")
40
+ ? lookup.mainFile ?? ""
41
+ : "";
42
+ const documentSources = documentSourceOrder(sourceDirectives, mainFile);
43
+ const prioritizedSources = uniqueProjectPaths([
44
+ ...(preferredPath ? [preferredPath] : []),
45
+ ...documentSources
46
+ ]);
47
+ const orderedPaths = kind === "label"
48
+ ? uniqueProjectPaths([...prioritizedSources, ...sourcePaths])
49
+ : citationLookupOrder(sourceDirectives, prioritizedSources, bibPaths, sourcePaths);
50
+ for (const entryPath of orderedPaths) {
51
+ const source = sourceContents.get(entryPath)
52
+ ?? await readProjectFile(config, projectId, entryPath);
53
+ if (source === null)
54
+ continue;
55
+ const definition = findLatexReferenceDefinitions(source, kind, bibExtension.test(entryPath))
56
+ .find((candidate) => candidate.key === key);
57
+ if (!definition)
58
+ continue;
59
+ const location = lineAndColumnAt(source, definition.targetFrom);
60
+ return { path: entryPath, ...location, kind, source: definition.source };
61
+ }
62
+ return null;
63
+ }
64
+ async function readProjectSources(config, projectId, paths) {
65
+ const contents = new Map();
66
+ await Promise.all(paths.map(async (entryPath) => {
67
+ const source = await readProjectFile(config, projectId, entryPath);
68
+ if (source !== null)
69
+ contents.set(entryPath, source);
70
+ }));
71
+ return contents;
72
+ }
73
+ async function readProjectFile(config, projectId, entryPath) {
74
+ try {
75
+ return await fs.promises.readFile(resolveSourcePath(config, projectId, entryPath), "utf8");
76
+ }
77
+ catch (error) {
78
+ // A concurrent file deletion is harmless for navigation. The project
79
+ // mutation barrier prevents partial writes; simply continue to the next
80
+ // possible definition when a file has disappeared.
81
+ if (error.code === "ENOENT")
82
+ return null;
83
+ throw error;
84
+ }
85
+ }
86
+ function citationLookupOrder(sourceDirectives, documentSources, bibPaths, sourcePaths) {
87
+ // Callers without a selected root retain the conservative legacy behavior:
88
+ // prefer in-source thebibliography entries before standalone .bib files.
89
+ if (documentSources.length === 0)
90
+ return uniqueProjectPaths([...sourcePaths, ...bibPaths]);
91
+ const bibPathSet = new Set(bibPaths);
92
+ const declaredBibliographies = [];
93
+ for (const sourcePath of documentSources) {
94
+ const directives = sourceDirectives.get(sourcePath);
95
+ if (!directives)
96
+ continue;
97
+ for (const bibliography of directives.bibliographies) {
98
+ const resolved = resolveProjectReferencePath(bibliography, sourcePath, ".bib", bibPathSet, true);
99
+ if (resolved)
100
+ declaredBibliographies.push(resolved);
101
+ }
102
+ }
103
+ return uniqueProjectPaths([
104
+ ...documentSources,
105
+ ...declaredBibliographies,
106
+ ...bibPaths,
107
+ ...sourcePaths
108
+ ]);
109
+ }
@@ -1,6 +1,7 @@
1
1
  import { publicUser, requireUser } from "../auth.js";
2
2
  import { createSessionToken, digestToken, hashPassword, verifyPassword } from "../security.js";
3
3
  import { apiError, ValidationError } from "../http.js";
4
+ import { basePathHref } from "../../shared/basePath.js";
4
5
  const now = () => new Date().toISOString();
5
6
  function text(value, max = 200) {
6
7
  if (typeof value !== "string" || !value.trim() || value.length > max) {
@@ -35,8 +36,9 @@ export function registerAuthRoutes(app, context) {
35
36
  db.prepare("INSERT INTO sessions (id, user_id, expires_at, created_at) VALUES (?, ?, ?, ?)")
36
37
  .run(session.digest, user.id, expires.toISOString(), createdAt);
37
38
  const isSecure = request.protocol === "https" || request.headers["x-forwarded-proto"] === "https";
39
+ const cookiePath = basePathHref(config.basePath);
38
40
  reply.setCookie("texlite_session", session.token, {
39
- path: "/",
41
+ path: cookiePath,
40
42
  httpOnly: true,
41
43
  sameSite: "strict",
42
44
  secure: isSecure,
@@ -48,7 +50,8 @@ export function registerAuthRoutes(app, context) {
48
50
  const token = request.cookies.texlite_session;
49
51
  if (token)
50
52
  db.prepare("DELETE FROM sessions WHERE id = ?").run(digestToken(token));
51
- reply.clearCookie("texlite_session", { path: "/" });
53
+ const cookiePath = basePathHref(config.basePath);
54
+ reply.clearCookie("texlite_session", { path: cookiePath });
52
55
  return { ok: true };
53
56
  });
54
57
  app.get("/api/me", async (request, reply) => {
@@ -1,5 +1,5 @@
1
1
  import fs from "node:fs";
2
- import { randomUUID } from "node:crypto";
2
+ import { createHash, randomUUID } from "node:crypto";
3
3
  import { requireUser } from "../auth.js";
4
4
  import { createSourceAnchor, offsetToLine } from "../anchors.js";
5
5
  import { resolveSourcePath, safeRelativePath } from "../files.js";
@@ -106,6 +106,18 @@ export function registerCommentRoutes(app, context) {
106
106
  if (!Number.isInteger(body.startOffset) || !Number.isInteger(body.endOffset)) {
107
107
  return apiError(reply, 400, "COMMENT_RANGE_INVALID");
108
108
  }
109
+ // The browser supplies the exact source revision the user selected.
110
+ // Keep this optional for an already-open client during an upgrade;
111
+ // current clients never bind an annotation to offsets from a different
112
+ // revision.
113
+ if (body.sourceHash !== undefined) {
114
+ if (typeof body.sourceHash !== "string" || !/^[a-f0-9]{64}$/.test(body.sourceHash)) {
115
+ return apiError(reply, 400, "REQUEST_INVALID");
116
+ }
117
+ const currentHash = createHash("sha256").update(source, "utf8").digest("hex");
118
+ if (currentHash !== body.sourceHash)
119
+ return apiError(reply, 409, "COMMENT_SOURCE_CHANGED");
120
+ }
109
121
  const anchor = createSourceAnchor(source, Number(body.startOffset), Number(body.endOffset));
110
122
  const comment = {
111
123
  id: randomUUID(), filePath,
@@ -85,7 +85,7 @@ export function registerCompileRoutes(app, context) {
85
85
  } : null
86
86
  } : null,
87
87
  hasPdf: Boolean(pdf),
88
- pdfUrl: pdf ? compilePdfUrl(id, mainFile, pdfVersion ?? pdf.version) : null,
88
+ pdfUrl: pdf ? compilePdfUrl(config, id, mainFile, pdfVersion ?? pdf.version) : null,
89
89
  pdfCompiledAt: publishedRun?.finished_at ?? latestSuccess?.finished_at ?? null,
90
90
  ...pdfMetadata
91
91
  };
@@ -674,7 +674,7 @@ export function registerCompileRoutes(app, context) {
674
674
  ].filter(Boolean).join(", "));
675
675
  return {
676
676
  mainFile, runId: result.runId, ok: result.ok, cancelled: result.cancelled === true, skipped: result.skipped === true, log: result.log, diagnostics: result.diagnostics,
677
- pdfUrl: result.ok ? compilePdfUrl(id, mainFile, result.runId) : null,
677
+ pdfUrl: result.ok ? compilePdfUrl(config, id, mainFile, result.runId) : null,
678
678
  pdfCompiledAt: result.ok ? completed?.finished_at ?? null : null,
679
679
  ...pdfMetadata,
680
680
  timings
@@ -149,14 +149,23 @@ export function registerProjectFileRoutes(app, context) {
149
149
  if (!user)
150
150
  return;
151
151
  const { id } = request.params;
152
- if (!accessibleProject(db, id, user))
152
+ const project = accessibleProject(db, id, user);
153
+ if (!project)
153
154
  return apiError(reply, 404, "PROJECT_NOT_FOUND");
155
+ const query = request.query;
156
+ const mainFile = compileMainFile(config, id, project.main_file, query.mainFile);
157
+ if (!mainFile)
158
+ return apiError(reply, 400, "MAIN_DOCUMENT_INVALID");
154
159
  const startedAt = performance.now();
155
160
  try {
156
- return await projectMutations.runConsistentRead(id, async () => ({ index: await latexCompletions.build(id) }), {
161
+ return await projectMutations.runConsistentRead(id, async () => ({ index: await latexCompletions.build(id, mainFile) }), {
157
162
  preflight: () => {
158
- if (!accessibleProject(db, id, user))
163
+ const current = accessibleProject(db, id, user);
164
+ if (!current)
159
165
  throw httpError(404, "PROJECT_NOT_FOUND");
166
+ const selected = compileMainFile(config, id, current.main_file, query.mainFile);
167
+ if (!selected || selected !== mainFile)
168
+ throw httpError(400, "MAIN_DOCUMENT_INVALID");
160
169
  }
161
170
  });
162
171
  }
@@ -155,7 +155,12 @@ export function registerProjectHistoryRoutes(app, context) {
155
155
  if (!project || !canEdit(project))
156
156
  return apiError(reply, 403, "PROJECT_EDIT_FORBIDDEN");
157
157
  const body = request.body;
158
- const label = body.label === null || body.label === "" ? null : text(body.label, 80);
158
+ if (body?.snapshotHash !== undefined && (typeof body.snapshotHash !== "string" || !/^[a-f0-9]{64}$/.test(body.snapshotHash))) {
159
+ return apiError(reply, 400, "REQUEST_INVALID");
160
+ }
161
+ const snapshotHash = typeof body?.snapshotHash === "string" ? body.snapshotHash : undefined;
162
+ history.assertSnapshotHash(id, versionId, snapshotHash);
163
+ const label = body?.label === null || body?.label === "" ? null : text(body?.label, 80);
159
164
  const version = history.setLabel(id, versionId, label);
160
165
  if (!version)
161
166
  return apiError(reply, 404, "HISTORY_VERSION_NOT_FOUND");
@@ -206,8 +211,15 @@ export function registerProjectHistoryRoutes(app, context) {
206
211
  return apiError(reply, 403, "PROJECT_EDIT_FORBIDDEN");
207
212
  const body = request.body;
208
213
  const filePath = typeof body?.path === "string" ? safeRelativePath(body.path) : undefined;
214
+ // Current clients bind a restore to the snapshot they displayed. Accept
215
+ // an omitted fingerprint only for already-open clients during an upgrade.
216
+ if (body?.snapshotHash !== undefined && (typeof body.snapshotHash !== "string" || !/^[a-f0-9]{64}$/.test(body.snapshotHash))) {
217
+ return apiError(reply, 400, "REQUEST_INVALID");
218
+ }
219
+ const snapshotHash = typeof body?.snapshotHash === "string" ? body.snapshotHash : undefined;
209
220
  return await projectMutations.runExclusive(id, "history restore", () => {
210
221
  const currentProject = requireEditableProject(db, id, user);
222
+ history.assertSnapshotHash(id, versionId, snapshotHash);
211
223
  recordHistory(id, user.id, "checkpoint");
212
224
  const before = projectTextSnapshot(config, id);
213
225
  const restored = history.restore(id, versionId, filePath);
@@ -0,0 +1,36 @@
1
+ import { requireUser } from "../auth.js";
2
+ import { safeRelativePath } from "../files.js";
3
+ import { apiError, httpError } from "../http.js";
4
+ import { resolveProjectReference, validReferenceKey } from "../projectReferences.js";
5
+ import { accessibleProject } from "../projects.js";
6
+ /** Register Ctrl/Cmd-click source-reference navigation routes. */
7
+ export function registerProjectReferenceRoutes(app, context) {
8
+ const { config, db, projectMutations } = context;
9
+ app.get("/api/projects/:id/references/resolve", async (request, reply) => {
10
+ const user = requireUser(request, reply, db);
11
+ if (!user)
12
+ return;
13
+ const { id } = request.params;
14
+ const project = accessibleProject(db, id, user);
15
+ if (!project)
16
+ return apiError(reply, 404, "PROJECT_NOT_FOUND");
17
+ const query = request.query;
18
+ const kind = query.kind === "citation" || query.kind === "label" ? query.kind : null;
19
+ if (!kind || !validReferenceKey(query.key))
20
+ return apiError(reply, 400, "REQUEST_INVALID");
21
+ const key = query.key;
22
+ const preferredPath = typeof query.path === "string" && query.path ? safeRelativePath(query.path) : "";
23
+ const requestedMainFile = typeof query.mainFile === "string" && query.mainFile
24
+ ? safeRelativePath(query.mainFile)
25
+ : "";
26
+ const mainFile = /\.tex$/i.test(requestedMainFile) ? requestedMainFile : project.main_file;
27
+ return await projectMutations.runConsistentRead(id, async () => ({
28
+ target: await resolveProjectReference(config, id, kind, key, { preferredPath, mainFile })
29
+ }), {
30
+ preflight: () => {
31
+ if (!accessibleProject(db, id, user))
32
+ throw httpError(404, "PROJECT_NOT_FOUND");
33
+ }
34
+ });
35
+ });
36
+ }