texlite 0.8.4 → 0.9.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 (69) hide show
  1. package/DESIGN.md +75 -8
  2. package/OPERATIONS.md +19 -2
  3. package/dist/client/assets/{AdminUsers-CqOmV9rq.js → AdminUsers-Dazar9cj.js} +1 -1
  4. package/dist/client/assets/{CitationLibraryDialog-BmWFXTW2.js → CitationLibraryDialog-VSUiSPgG.js} +1 -1
  5. package/dist/client/assets/Dashboard-CoE5ESfb.js +2 -0
  6. package/dist/client/assets/GitDialog-C9Loz4Al.js +3 -0
  7. package/dist/client/assets/HistoryDialog-3UNN_jhU.js +7 -0
  8. package/dist/client/assets/{LatexEditor-DSopAHbC.js → LatexEditor-Bq76v8sO.js} +2 -2
  9. package/dist/client/assets/{PdfPreview-BDkU20jP.js → PdfPreview-BKhcS-2c.js} +1 -1
  10. package/dist/client/assets/{ProjectIconPickerDialog-GAd8HGOx.js → ProjectIconPickerDialog-BoWMjQVm.js} +1 -1
  11. package/dist/client/assets/{ProjectNavigationDialogs-BTLcRY7i.js → ProjectNavigationDialogs-CZKTnxrl.js} +1 -1
  12. package/dist/client/assets/{ProjectSettings-BJWTkoZq.js → ProjectSettings-DiOQVJ40.js} +1 -1
  13. package/dist/client/assets/ProjectWorkspace-DqLwkGpT.js +27 -0
  14. package/dist/client/assets/SelectionHistoryDialog-D3dO83lg.js +3 -0
  15. package/dist/client/assets/{SystemMetricsDialog-C9P4TSQl.js → SystemMetricsDialog-0zCwLtH_.js} +1 -1
  16. package/dist/client/assets/{TagManagementDialog-LYbib36_.js → TagManagementDialog-C6KDwtvd.js} +1 -1
  17. package/dist/client/assets/{WordCountDialog-BFg0GeJy.js → WordCountDialog-TwpyAP3m.js} +1 -1
  18. package/dist/client/assets/check-sZxOOKtS.js +1 -0
  19. package/dist/client/assets/chevron-left-32SrOwa5.js +1 -0
  20. package/dist/client/assets/hard-drive-XHSiS_A7.js +1 -0
  21. package/dist/client/assets/index-BRr3Kuxh.css +1 -0
  22. package/dist/client/assets/index-BjzELSQ-.js +12 -0
  23. package/dist/client/assets/index-Cd6-F29b.js +5 -0
  24. package/dist/client/assets/{key-round-BaIj7Jch.js → key-round-C-CXCWnz.js} +1 -1
  25. package/dist/client/assets/minimize-2-BMDPVaF-.js +1 -0
  26. package/dist/client/assets/{minus-Beso_n-6.js → minus-BIa7-KuM.js} +1 -1
  27. package/dist/client/assets/{plus-BfEC7mzf.js → plus-BKDWy4Ui.js} +1 -1
  28. package/dist/client/assets/{refresh-cw-FZs9MtoA.js → refresh-cw-Bo7-a7-I.js} +1 -1
  29. package/dist/client/assets/{search-DL2UGvdj.js → search-Bz9Ob5mT.js} +1 -1
  30. package/dist/client/assets/{sigma-CSRwPPkk.js → sigma-DxlHf9gv.js} +1 -1
  31. package/dist/client/assets/spellCheck-BfZEnpwY.js +1 -0
  32. package/dist/client/assets/{tags-CjkWzXqX.js → tags-IMZehjzE.js} +1 -1
  33. package/dist/client/assets/{type-D1mmDdLM.js → type-DTWpFQNQ.js} +1 -1
  34. package/dist/client/assets/{user-plus-YJ3ftuL5.js → user-plus--aVpd8K6.js} +1 -1
  35. package/dist/client/assets/x-Tbo3aA0J.js +1 -0
  36. package/dist/client/index.html +2 -2
  37. package/dist/server/app.js +45 -4
  38. package/dist/server/cli.js +4 -0
  39. package/dist/server/collaboration.js +219 -7
  40. package/dist/server/commentMentions.js +139 -0
  41. package/dist/server/config.js +9 -2
  42. package/dist/server/db.js +59 -0
  43. package/dist/server/editHistory.js +411 -0
  44. package/dist/server/editHistoryRetry.js +90 -0
  45. package/dist/server/harper.js +205 -8
  46. package/dist/server/history.js +189 -23
  47. package/dist/server/historyRetention.js +37 -0
  48. package/dist/server/i18n.js +12 -0
  49. package/dist/server/index.js +6 -0
  50. package/dist/server/latexSpellMask.js +197 -43
  51. package/dist/server/logRotation.js +82 -0
  52. package/dist/server/pm2.js +7 -0
  53. package/dist/server/routes/comments.js +120 -17
  54. package/dist/server/routes/projectHistory.js +106 -8
  55. package/dist/server/routes/projectShared.js +2 -1
  56. package/dist/server/routes/projects.js +39 -5
  57. package/package.json +1 -1
  58. package/texlite.config.example.json +4 -1
  59. package/dist/client/assets/Dashboard-BfN2bCgZ.js +0 -2
  60. package/dist/client/assets/GitDialog-BCfSHuMB.js +0 -3
  61. package/dist/client/assets/HistoryDialog-BtQhyTr9.js +0 -5
  62. package/dist/client/assets/ProjectWorkspace-WLYA7BSv.js +0 -27
  63. package/dist/client/assets/check-bnimQPun.js +0 -1
  64. package/dist/client/assets/chevron-left-DNM7mhNi.js +0 -1
  65. package/dist/client/assets/hard-drive-CF3WDfkG.js +0 -1
  66. package/dist/client/assets/index-CzVDvC5h.js +0 -14
  67. package/dist/client/assets/index-RjUuWk8F.css +0 -1
  68. package/dist/client/assets/spellCheck-L9gD2d1M.js +0 -1
  69. package/dist/client/assets/x-BDSEosRE.js +0 -1
@@ -16,6 +16,7 @@ const enMessages = {
16
16
  COMMENT_FILE_NOT_FOUND: "The file for this comment does not exist.",
17
17
  COMMENT_NOT_FOUND: "The comment does not exist.",
18
18
  COMMENT_RANGE_INVALID: "The comment source range is invalid.",
19
+ COMMENT_SOURCE_CHANGED: "The source changed while this comment was being prepared. Reselect the passage and try again.",
19
20
  COMMENT_UPDATE_EMPTY: "There is no comment change to save.",
20
21
  COMPILE_ARTIFACT_NOT_FOUND: "The compiled artifact does not exist.",
21
22
  COMPILE_ARTIFACTS_NOT_FOUND: "No compiled artifacts are available yet.",
@@ -45,9 +46,12 @@ const enMessages = {
45
46
  GIT_UNAVAILABLE: "Git is not available on this server.",
46
47
  HARPER_UNAVAILABLE: "Harper spelling and grammar checking is temporarily unavailable.",
47
48
  HISTORY_FILE_NOT_FOUND: "The historical file does not exist.",
49
+ HISTORY_OBJECT_MISSING: "A stored file for this snapshot is missing. It can no longer be viewed or restored.",
48
50
  HISTORY_FILE_PREVIEW_UNSUPPORTED: "This historical file cannot be compared as text.",
49
51
  HISTORY_TARGET_CONFLICT: "The historical version cannot be restored to the requested path.",
50
52
  HISTORY_VERSION_NOT_FOUND: "The history version does not exist.",
53
+ HISTORY_VERSION_CHANGED: "The selected project snapshot changed while it was being reviewed. Refresh it before restoring.",
54
+ SELECTION_HISTORY_SOURCE_CHANGED: "The source changed while opening selection history. Wait for synchronization, then select the passage and open its history again.",
51
55
  INVALID_PATH: "The file path is invalid.",
52
56
  LAST_ADMIN: "At least one active administrator must remain.",
53
57
  LATEXMKRC_DISABLED: "Project latexmkrc files are disabled by the administrator.",
@@ -59,6 +63,7 @@ const enMessages = {
59
63
  MAIN_FILE_INVALID: "The main document must be an existing .tex file.",
60
64
  MAIN_FILE_NOT_FOUND: "The selected main document does not exist.",
61
65
  MEMBERS_MANAGE_FORBIDDEN: "Only the project owner can manage members.",
66
+ MENTION_NOT_FOUND: "The mention notification does not exist.",
62
67
  MOVE_INTO_SELF: "A folder cannot be moved into itself.",
63
68
  OWNER_MEMBER_FORBIDDEN: "The project owner cannot be added as a member.",
64
69
  PASSWORD_TOO_SHORT: "The password must contain at least {{minLength}} characters.",
@@ -87,6 +92,7 @@ const enMessages = {
87
92
  SOURCE_FLUSH_FAILED: "Some source files could not be saved. The project operation was cancelled.",
88
93
  SPELLCHECK_SOURCE_INVALID: "The source to check is invalid.",
89
94
  SPELLCHECK_SOURCE_TOO_LARGE: "The source to check is too large.",
95
+ SPELLCHECK_SUPERSEDED: "A newer writing check replaced this request.",
90
96
  SYNCTEX_EXTERNAL_PATH: "SyncTeX returned a source path outside the project.",
91
97
  SYNCTEX_NOT_AVAILABLE: "No SyncTeX data is available. Compile the project again.",
92
98
  SYMLINK_FORBIDDEN: "Symbolic links are not allowed in project sources.",
@@ -142,6 +148,7 @@ const zhMessages = {
142
148
  COMMENT_FILE_NOT_FOUND: "批注对应的文件不存在。",
143
149
  COMMENT_NOT_FOUND: "批注不存在。",
144
150
  COMMENT_RANGE_INVALID: "批注的源码区间无效。",
151
+ COMMENT_SOURCE_CHANGED: "准备批注时源码已发生变化。请重新选择文本后再试。",
145
152
  COMMENT_UPDATE_EMPTY: "没有可保存的批注修改。",
146
153
  COMPILE_ARTIFACT_NOT_FOUND: "编译产物不存在。",
147
154
  COMPILE_ARTIFACTS_NOT_FOUND: "尚无可用的编译产物。",
@@ -171,9 +178,12 @@ const zhMessages = {
171
178
  GIT_UNAVAILABLE: "服务器未安装 Git。",
172
179
  HARPER_UNAVAILABLE: "Harper 拼写与语法检查暂不可用。",
173
180
  HISTORY_FILE_NOT_FOUND: "历史文件不存在。",
181
+ HISTORY_OBJECT_MISSING: "该快照的已存储文件缺失,无法再查看或恢复。",
174
182
  HISTORY_FILE_PREVIEW_UNSUPPORTED: "该历史文件不能作为文本比较。",
175
183
  HISTORY_TARGET_CONFLICT: "无法将历史版本恢复到所请求的路径。",
176
184
  HISTORY_VERSION_NOT_FOUND: "历史版本不存在。",
185
+ HISTORY_VERSION_CHANGED: "查看期间该项目快照已发生变化。请刷新后再恢复。",
186
+ SELECTION_HISTORY_SOURCE_CHANGED: "打开选区历史时源码已发生变化。请等待同步完成,重新选择文本后再打开历史。",
177
187
  INVALID_PATH: "文件路径无效。",
178
188
  LAST_ADMIN: "系统必须保留至少一名可用管理员。",
179
189
  LATEXMKRC_DISABLED: "管理员已禁用项目级 latexmkrc。",
@@ -185,6 +195,7 @@ const zhMessages = {
185
195
  MAIN_FILE_INVALID: "主文档必须是存在的 .tex 文件。",
186
196
  MAIN_FILE_NOT_FOUND: "所选主文档不存在。",
187
197
  MEMBERS_MANAGE_FORBIDDEN: "只有项目所有者可以管理成员。",
198
+ MENTION_NOT_FOUND: "@ 提醒不存在。",
188
199
  MOVE_INTO_SELF: "不能把文件夹移动到自身内部。",
189
200
  OWNER_MEMBER_FORBIDDEN: "项目所有者不能作为成员添加。",
190
201
  PASSWORD_TOO_SHORT: "密码至少需要 {{minLength}} 个字符。",
@@ -213,6 +224,7 @@ const zhMessages = {
213
224
  SOURCE_FLUSH_FAILED: "部分源码未能保存至磁盘,项目操作已取消。",
214
225
  SPELLCHECK_SOURCE_INVALID: "待检查源码格式不正确。",
215
226
  SPELLCHECK_SOURCE_TOO_LARGE: "待检查源码过大。",
227
+ SPELLCHECK_SUPERSEDED: "更新的写作检查已替代此请求。",
216
228
  SYNCTEX_EXTERNAL_PATH: "SyncTeX 返回了项目外部的源码路径。",
217
229
  SYNCTEX_NOT_AVAILABLE: "尚无可用的 SyncTeX 数据,请重新编译。",
218
230
  SYMLINK_FORBIDDEN: "项目源码中不允许使用符号链接。",
@@ -5,12 +5,16 @@ import { activeAdminCount, openDatabase } from "./db.js";
5
5
  import { buildApp } from "./app.js";
6
6
  import { assertEnvironment } from "./environment.js";
7
7
  import { acquireDataDirectoryLock } from "./instanceLock.js";
8
+ import { LogRotation } from "./logRotation.js";
8
9
  export async function startServer(configPath) {
9
10
  const config = loadConfig(configPath);
10
11
  const lock = acquireDataDirectoryLock(config);
11
12
  let db = null;
12
13
  let app = null;
14
+ const logs = process.env.TEXLITE_MANAGED_LOGS === "1"
15
+ ? new LogRotation(config.dataDir, (error) => console.error("TexLite log rotation failed:", error)) : null;
13
16
  try {
17
+ await logs?.start();
14
18
  const environment = await assertEnvironment(config);
15
19
  db = openDatabase(config);
16
20
  if (activeAdminCount(db) === 0) {
@@ -34,6 +38,7 @@ export async function startServer(configPath) {
34
38
  db?.close();
35
39
  }
36
40
  finally {
41
+ await logs?.stop();
37
42
  lock.release();
38
43
  }
39
44
  }
@@ -50,6 +55,7 @@ export async function startServer(configPath) {
50
55
  db?.close();
51
56
  }
52
57
  finally {
58
+ await logs?.stop();
53
59
  lock.release();
54
60
  }
55
61
  }
@@ -1,16 +1,34 @@
1
1
  // This intentionally recognizes only the parts of LaTeX that are clearly not
2
2
  // prose. It is a single-pass lexer, not a parser: malformed delimiters remain
3
3
  // narrow, so they never hide the rest of a document or cause backtracking.
4
+ const literalEnvironments = new Set([
5
+ "verbatim", "verbatim*", "bverbatim", "bverbatim*", "lverbatim", "lverbatim*", "saveverbatim", "saveverbatim*", "verbatimout",
6
+ "lstlisting", "minted", "minted*", "filecontents", "filecontents*", "luacode", "luacodestar", "comment"
7
+ ]);
4
8
  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
+ // Display and helper math environments commonly used by amsmath/mathtools.
10
+ "math", "displaymath", "equation", "equation*", "align", "align*", "alignat", "alignat*", "aligned", "alignedat", "gather", "gather*", "gathered", "multline", "multline*", "multlined", "flalign", "flalign*", "split", "cases", "dcases", "rcases", "drcases",
11
+ "array", "matrix", "pmatrix", "bmatrix", "vmatrix", "smallmatrix", "psmallmatrix", "bsmallmatrix", "vsmallmatrix",
12
+ // Diagram, table, and literal/code environments. Keep prose containers such
13
+ // as figure, table, theorem, and caption outside this list.
14
+ "tikzpicture", "tikzcd", "axis", "scope", "pgfonlayer", "pgfpicture",
15
+ "tabular", "tabularx", "tabulary", "longtable",
16
+ "verbatim", "verbatim*", "bverbatim", "bverbatim*", "lverbatim", "lverbatim*", "saveverbatim", "saveverbatim*", "verbatimout",
17
+ "lstlisting", "minted", "minted*", "filecontents", "filecontents*", "luacode", "luacodestar", "comment", "algorithmic"
9
18
  ]);
10
19
  const opaqueArgumentCommands = new Set([
11
20
  "documentclass", "usepackage", "requirepackage", "includegraphics", "tikzset", "pgfplotsset", "hypersetup", "lstset",
12
21
  "label", "hypertarget", "ref", "pageref", "autoref", "nameref", "hyperref", "index", "gls", "glspl",
13
- "bibliography", "addbibresource", "input", "include", "url", "path", "setlength", "setcounter", "color", "textcolor", "colorbox", "pagecolor"
22
+ "bibliography", "addbibresource", "input", "include", "url", "path", "setlength", "setcounter", "color", "textcolor", "colorbox", "pagecolor",
23
+ // These arguments are code, identifiers, or paths rather than prose.
24
+ "texttt", "detokenize", "lstinputlisting"
25
+ ]);
26
+ /** Number of brace arguments after a macro/environment target. */
27
+ const macroDefinitionArgumentCounts = new Map([
28
+ ["newcommand", 1], ["renewcommand", 1], ["providecommand", 1], ["declarerobustcommand", 1],
29
+ ["newenvironment", 2], ["renewenvironment", 2],
30
+ ["newdocumentcommand", 2], ["renewdocumentcommand", 2], ["providedocumentcommand", 2], ["declaredocumentcommand", 2],
31
+ ["declaremathoperator", 1], ["declarepaireddelimiter", 2]
14
32
  ]);
15
33
  function addRange(ranges, from, to) {
16
34
  if (to > from)
@@ -26,7 +44,7 @@ function isCommandNameCharacter(character) {
26
44
  return (code >= 65 && code <= 90) || (code >= 97 && code <= 122) || (code >= 48 && code <= 57)
27
45
  || character === "@" || character === ":" || character === "_";
28
46
  }
29
- function balancedArgument(source, start, open, close) {
47
+ function balancedArgument(source, start, open, close, literal = false) {
30
48
  if (source[start] !== open)
31
49
  return null;
32
50
  let depth = 1;
@@ -35,6 +53,13 @@ function balancedArgument(source, start, open, close) {
35
53
  index += 1;
36
54
  continue;
37
55
  }
56
+ if (!literal && source[index] === "%") {
57
+ const lineEnd = source.indexOf("\n", index);
58
+ if (lineEnd < 0)
59
+ return null;
60
+ index = lineEnd;
61
+ continue;
62
+ }
38
63
  if (source[index] === open)
39
64
  depth += 1;
40
65
  else if (source[index] === close && --depth === 0)
@@ -48,6 +73,17 @@ function skipWhitespace(source, start) {
48
73
  index += 1;
49
74
  return index;
50
75
  }
76
+ /** Skip whitespace and TeX comments between arguments while masking comments. */
77
+ function skipTrivia(source, start, ranges) {
78
+ let index = skipWhitespace(source, start);
79
+ while (source[index] === "%") {
80
+ const lineEnd = source.indexOf("\n", index);
81
+ const end = lineEnd < 0 ? source.length : lineEnd;
82
+ addRange(ranges, index, end);
83
+ index = skipWhitespace(source, lineEnd < 0 ? source.length : lineEnd + 1);
84
+ }
85
+ return index;
86
+ }
51
87
  function readCommand(source, start) {
52
88
  let end = start + 1;
53
89
  if (isCommandNameCharacter(source[end]))
@@ -64,23 +100,103 @@ function isCitationCommand(name) {
64
100
  const normalized = name.toLocaleLowerCase("en-US");
65
101
  return normalized === "cite" || normalized.startsWith("cite") || normalized.endsWith("cite");
66
102
  }
67
- function masksOpaqueArguments(name) {
103
+ function opaqueArgumentCount(name) {
68
104
  const normalized = name.toLocaleLowerCase("en-US");
69
- return opaqueArgumentCommands.has(normalized) || isCitationCommand(normalized) || normalized === "definecolor" || normalized === "colorlet" || normalized === "href";
105
+ if (normalized === "definecolor" || normalized === "colorlet")
106
+ return 3;
107
+ if (normalized === "mint" || normalized === "inputminted")
108
+ return 2;
109
+ return opaqueArgumentCommands.has(normalized) || isCitationCommand(normalized) || normalized === "href" ? 1 : 0;
70
110
  }
71
- function maskOpaqueArguments(source, start, name, ranges) {
111
+ function maskBraceArguments(source, start, count, ranges) {
72
112
  let cursor = start;
73
- const normalized = name.toLocaleLowerCase("en-US");
74
- const count = normalized === "definecolor" || normalized === "colorlet" ? 3 : 1;
75
113
  for (let index = 0; index < count; index += 1) {
76
114
  const argument = balancedArgument(source, cursor, "{", "}");
77
115
  if (!argument)
78
116
  break;
79
117
  addRange(ranges, argument.from, argument.to);
80
- cursor = skipWhitespace(source, argument.to);
118
+ cursor = skipTrivia(source, argument.to, ranges);
81
119
  }
82
120
  return cursor;
83
121
  }
122
+ function maskMacroTarget(source, start, ranges) {
123
+ const bracedTarget = balancedArgument(source, start, "{", "}");
124
+ if (bracedTarget) {
125
+ addRange(ranges, bracedTarget.from, bracedTarget.to);
126
+ return skipTrivia(source, bracedTarget.to, ranges);
127
+ }
128
+ if (source[start] === "\\") {
129
+ const target = readCommand(source, start);
130
+ addRange(ranges, start, target.end);
131
+ return skipTrivia(source, target.end, ranges);
132
+ }
133
+ return start;
134
+ }
135
+ function maskMacroDefinition(source, start, definitionArgumentCount, ranges) {
136
+ let cursor = maskMacroTarget(source, start, ranges);
137
+ if (cursor === start)
138
+ return cursor;
139
+ // newcommand-style macros may have both an argument-count option and a
140
+ // default-argument option. Mask every immediately following option before
141
+ // their definition bodies, while keeping malformed input narrow.
142
+ for (;;) {
143
+ const option = balancedArgument(source, cursor, "[", "]");
144
+ if (!option)
145
+ break;
146
+ addRange(ranges, option.from, option.to);
147
+ cursor = skipTrivia(source, option.to, ranges);
148
+ }
149
+ return maskBraceArguments(source, cursor, definitionArgumentCount, ranges);
150
+ }
151
+ function maskDelimitedCode(source, start, ranges) {
152
+ const delimiter = source[start];
153
+ if (!delimiter || isWhitespace(delimiter))
154
+ return start;
155
+ // TeX's delimiter-based inline literal commands cannot span a line. If a
156
+ // user is still typing the closing delimiter, hide only the incomplete
157
+ // current line rather than accidentally suppressing all later prose until a
158
+ // matching character happens to occur.
159
+ for (let cursor = start + 1; cursor < source.length; cursor += 1) {
160
+ const character = source[cursor];
161
+ if (character === delimiter) {
162
+ addRange(ranges, start, cursor + 1);
163
+ return skipTrivia(source, cursor + 1, ranges);
164
+ }
165
+ if (character === "\r" || character === "\n") {
166
+ addRange(ranges, start, cursor);
167
+ return cursor;
168
+ }
169
+ }
170
+ addRange(ranges, start, source.length);
171
+ return source.length;
172
+ }
173
+ function maskDelimitedOrBracedCode(source, start, ranges) {
174
+ if (source[start] !== "{")
175
+ return maskDelimitedCode(source, start, ranges);
176
+ // The brace form is also inline literal code. Preserve nested braces and
177
+ // escaped characters, but never let an unfinished argument conceal a later
178
+ // source line from the writing checker.
179
+ let depth = 1;
180
+ for (let cursor = start + 1; cursor < source.length; cursor += 1) {
181
+ const character = source[cursor];
182
+ if (character === "\\" && source[cursor + 1] !== "\r" && source[cursor + 1] !== "\n") {
183
+ cursor += 1;
184
+ continue;
185
+ }
186
+ if (character === "\r" || character === "\n") {
187
+ addRange(ranges, start, cursor);
188
+ return cursor;
189
+ }
190
+ if (character === "{")
191
+ depth += 1;
192
+ else if (character === "}" && --depth === 0) {
193
+ addRange(ranges, start, cursor + 1);
194
+ return cursor + 1;
195
+ }
196
+ }
197
+ addRange(ranges, start, source.length);
198
+ return source.length;
199
+ }
84
200
  function rawUrlEnd(source, start) {
85
201
  const first = source[start];
86
202
  if (first !== "h" && first !== "H" && first !== "f" && first !== "F")
@@ -115,6 +231,33 @@ export function ignoredLatexRanges(source) {
115
231
  // The same technique keeps unmatched $ / \[ delimiters cheap and local.
116
232
  let pendingMath = null;
117
233
  for (let index = 0; index < source.length;) {
234
+ // An open literal environment owns its full body. Do not parse
235
+ // dollars, comments, or nested commands within it: literal code often
236
+ // contains all of those. Only look for its matching \end marker.
237
+ const activeNonProse = nonProseStack.at(-1);
238
+ if (activeNonProse && literalEnvironments.has(activeNonProse.name)) {
239
+ if (source[index] === "\\") {
240
+ const command = readCommand(source, index);
241
+ if (command.name === "end") {
242
+ const cursor = skipTrivia(source, command.end, ranges);
243
+ const environment = balancedArgument(source, cursor, "{", "}");
244
+ const environmentName = environment
245
+ ? source.slice(environment.from + 1, environment.to - 1).trim().toLocaleLowerCase("en-US")
246
+ : "";
247
+ if (environment && environmentName === activeNonProse.name) {
248
+ activeNonProse.range.to = skipTrivia(source, environment.to, ranges);
249
+ nonProseStack.pop();
250
+ index = activeNonProse.range.to;
251
+ continue;
252
+ }
253
+ }
254
+ index = Math.max(index + 1, command.end);
255
+ }
256
+ else {
257
+ index += 1;
258
+ }
259
+ continue;
260
+ }
118
261
  if (pendingMath?.close.startsWith("\\") && source.startsWith(pendingMath.close, index)) {
119
262
  pendingMath.range.to = index + pendingMath.close.length;
120
263
  index += pendingMath.close.length;
@@ -171,44 +314,41 @@ export function ignoredLatexRanges(source) {
171
314
  continue;
172
315
  }
173
316
  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
- }
317
+ const commandName = command.name.toLocaleLowerCase("en-US");
318
+ // Reserve the command span before scanning trivia, which may append spans.
319
+ const commandRange = { from: commandStart, to: command.end };
320
+ ranges.push(commandRange);
321
+ let cursor = commandName === "verb" || commandName === "lstinline"
322
+ ? command.end : skipTrivia(source, command.end, ranges);
323
+ if (commandName === "verb") {
324
+ const options = command.name === "Verb" ? balancedArgument(source, cursor, "[", "]") : null;
325
+ if (options) {
326
+ addRange(ranges, options.from, options.to);
327
+ cursor = skipWhitespace(source, options.to);
184
328
  }
329
+ cursor = maskDelimitedOrBracedCode(source, cursor, ranges);
185
330
  index = Math.max(command.end, cursor);
186
331
  continue;
187
332
  }
188
333
  if (command.name === "begin" || command.name === "end") {
189
334
  const environment = balancedArgument(source, cursor, "{", "}");
190
335
  if (!environment) {
191
- addRange(ranges, commandStart, command.end);
192
336
  index = command.end;
193
337
  continue;
194
338
  }
195
339
  const environmentName = source.slice(environment.from + 1, environment.to - 1).trim().toLocaleLowerCase("en-US");
196
- cursor = skipWhitespace(source, environment.to);
340
+ addRange(ranges, environment.from, environment.to);
341
+ cursor = skipTrivia(source, environment.to, ranges);
197
342
  let options = null;
198
343
  if (command.name === "begin") {
199
344
  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 });
345
+ if (options) {
346
+ addRange(ranges, options.from, options.to);
347
+ cursor = skipTrivia(source, options.to, ranges);
206
348
  }
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);
349
+ if (nonProseEnvironments.has(environmentName)) {
350
+ commandRange.to = cursor;
351
+ nonProseStack.push({ name: environmentName, range: commandRange });
212
352
  }
213
353
  }
214
354
  else {
@@ -217,22 +357,36 @@ export function ignoredLatexRanges(source) {
217
357
  nonProseStack.pop();
218
358
  open.range.to = cursor;
219
359
  }
220
- else {
221
- addRange(ranges, commandStart, command.end);
222
- addRange(ranges, environment.from, environment.to);
223
- }
224
360
  }
225
361
  index = Math.max(command.end, cursor);
226
362
  continue;
227
363
  }
228
- addRange(ranges, commandStart, command.end);
229
364
  const options = balancedArgument(source, cursor, "[", "]");
230
365
  if (options) {
231
366
  addRange(ranges, options.from, options.to);
232
- cursor = skipWhitespace(source, options.to);
367
+ cursor = commandName === "lstinline" ? skipWhitespace(source, options.to) : skipTrivia(source, options.to, ranges);
368
+ }
369
+ if (commandName === "lstinline") {
370
+ cursor = maskDelimitedOrBracedCode(source, cursor, ranges);
371
+ }
372
+ else if (commandName === "mintinline") {
373
+ const language = balancedArgument(source, cursor, "{", "}");
374
+ if (language) {
375
+ addRange(ranges, language.from, language.to);
376
+ cursor = maskDelimitedOrBracedCode(source, skipWhitespace(source, language.to), ranges);
377
+ }
378
+ }
379
+ else {
380
+ const macroDefinitionArgumentCount = macroDefinitionArgumentCounts.get(commandName);
381
+ if (macroDefinitionArgumentCount !== undefined) {
382
+ cursor = maskMacroDefinition(source, cursor, macroDefinitionArgumentCount, ranges);
383
+ }
384
+ else {
385
+ const opaqueCount = opaqueArgumentCount(commandName);
386
+ if (opaqueCount > 0)
387
+ cursor = maskBraceArguments(source, cursor, opaqueCount, ranges);
388
+ }
233
389
  }
234
- if (masksOpaqueArguments(command.name))
235
- cursor = maskOpaqueArguments(source, cursor, command.name, ranges);
236
390
  index = Math.max(command.end, cursor);
237
391
  }
238
392
  return mergeOrderedRanges(ranges);
@@ -0,0 +1,82 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ export const LOG_MAX_BYTES = 10 * 1024 * 1024;
4
+ export const LOG_CHECK_INTERVAL_MS = 60_000;
5
+ const LOG_BACKUPS = 3;
6
+ export function managedLogPaths(dataDir) {
7
+ const directory = path.resolve(dataDir, "logs");
8
+ return { stdout: path.join(directory, "stdout.log"), stderr: path.join(directory, "stderr.log") };
9
+ }
10
+ /** PM2 owns the open descriptors: copy/truncate keeps them valid across rotation. */
11
+ export class LogRotation {
12
+ dataDir;
13
+ onError;
14
+ maxBytes;
15
+ pending = null;
16
+ timer = null;
17
+ stopped = false;
18
+ constructor(dataDir, onError, maxBytes = LOG_MAX_BYTES) {
19
+ this.dataDir = dataDir;
20
+ this.onError = onError;
21
+ this.maxBytes = maxBytes;
22
+ }
23
+ async start() {
24
+ await this.check();
25
+ if (!this.stopped && !this.timer) {
26
+ this.timer = setInterval(() => { void this.check(); }, LOG_CHECK_INTERVAL_MS);
27
+ this.timer.unref();
28
+ }
29
+ }
30
+ check() {
31
+ if (this.stopped)
32
+ return Promise.resolve();
33
+ if (this.pending)
34
+ return this.pending;
35
+ this.pending = this.rotateLogs().finally(() => { this.pending = null; });
36
+ return this.pending;
37
+ }
38
+ async stop() {
39
+ this.stopped = true;
40
+ if (this.timer)
41
+ clearInterval(this.timer);
42
+ this.timer = null;
43
+ await this.pending;
44
+ }
45
+ async rotateLogs() {
46
+ for (const file of Object.values(managedLogPaths(this.dataDir))) {
47
+ try {
48
+ await this.rotate(file);
49
+ }
50
+ catch (error) {
51
+ this.onError(error);
52
+ }
53
+ }
54
+ }
55
+ async rotate(file) {
56
+ const stat = await fs.lstat(file).catch((error) => {
57
+ if (error.code === "ENOENT")
58
+ return null;
59
+ throw error;
60
+ });
61
+ if (!stat?.isFile() || stat.size < this.maxBytes)
62
+ return;
63
+ const temporary = `${file}.rotate.tmp`;
64
+ try {
65
+ // Do not truncate until a complete backup has been successfully published.
66
+ await fs.copyFile(file, temporary);
67
+ await fs.rm(`${file}.${LOG_BACKUPS}`, { force: true });
68
+ for (let index = LOG_BACKUPS - 1; index >= 1; index--) {
69
+ await fs.rename(`${file}.${index}`, `${file}.${index + 1}`).catch((error) => {
70
+ if (error.code !== "ENOENT")
71
+ throw error;
72
+ });
73
+ }
74
+ await fs.rename(temporary, `${file}.1`);
75
+ // Writes between the copy and truncate can be lost; these are diagnostic logs.
76
+ await fs.truncate(file, 0);
77
+ }
78
+ finally {
79
+ await fs.rm(temporary, { force: true });
80
+ }
81
+ }
82
+ }
@@ -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 { managedLogPaths } from "./logRotation.js";
5
6
  import { defaultConfigPath, packageRootDirectory, packageServerEntry } from "./runtimePaths.js";
6
7
  let pm2Client = null;
7
8
  async function loadPm2() {
@@ -64,12 +65,15 @@ export async function managedProcess(configPath) {
64
65
  }
65
66
  }
66
67
  function startOptions(config) {
68
+ const logs = managedLogPaths(config.dataDir);
69
+ fs.mkdirSync(path.dirname(logs.stdout), { recursive: true, mode: 0o700 });
67
70
  const packageRoot = packageRootDirectory();
68
71
  const script = packageServerEntry();
69
72
  const environment = {
70
73
  ...Object.fromEntries(Object.entries(process.env).filter((entry) => typeof entry[1] === "string")),
71
74
  NODE_ENV: "production",
72
75
  TEXLITE_CONFIG: path.resolve(config.configPath),
76
+ TEXLITE_MANAGED_LOGS: "1",
73
77
  TEXLITE_CLIENT_DIR: path.resolve(config.clientDir)
74
78
  };
75
79
  return {
@@ -78,6 +82,9 @@ function startOptions(config) {
78
82
  cwd: packageRoot,
79
83
  exec_mode: "fork",
80
84
  instances: 1,
85
+ output: logs.stdout,
86
+ error: logs.stderr,
87
+ merge_logs: true,
81
88
  autorestart: true,
82
89
  watch: false,
83
90
  wait_ready: true,