texlite 0.9.1 → 0.9.3

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 (95) hide show
  1. package/DESIGN.md +36 -6
  2. package/OPERATIONS.md +70 -3
  3. package/README.md +11 -3
  4. package/README.zh-CN.md +11 -2
  5. package/THIRD_PARTY_NOTICES.md +29 -0
  6. package/dist/client/assets/AdminUsers-huwQK7Ly.js +1 -0
  7. package/dist/client/assets/CitationLibraryDialog-C5C3sPdw.js +1 -0
  8. package/dist/client/assets/Dashboard-DtHSc55D.js +2 -0
  9. package/dist/client/assets/{GitDialog-C9Loz4Al.js → GitDialog-BI7QIMsh.js} +1 -1
  10. package/dist/client/assets/{HistoryDialog-3UNN_jhU.js → HistoryDialog-DA7O9aF2.js} +1 -1
  11. package/dist/client/assets/LatexEditor-BYWWx5Km.js +138 -0
  12. package/dist/client/assets/{PdfPreview-BKhcS-2c.js → PdfPreview-mO9WS01q.js} +1 -1
  13. package/dist/client/assets/ProjectIconPickerDialog-B0q_r5dX.js +1 -0
  14. package/dist/client/assets/{ProjectNavigationDialogs-CZKTnxrl.js → ProjectNavigationDialogs-DwWYbB1A.js} +1 -1
  15. package/dist/client/assets/ProjectSettings-BpPFgdoj.js +1 -0
  16. package/dist/client/assets/ProjectWorkspace-CI2zKon5.js +32 -0
  17. package/dist/client/assets/{SelectionHistoryDialog-D3dO83lg.js → SelectionHistoryDialog-pWMI5izu.js} +2 -2
  18. package/dist/client/assets/SystemMetricsDialog-BmlO7oNg.js +1 -0
  19. package/dist/client/assets/TagManagementDialog-h4Ves4FR.js +1 -0
  20. package/dist/client/assets/{WordCountDialog-TwpyAP3m.js → WordCountDialog-DlBSP4y7.js} +1 -1
  21. package/dist/client/assets/check-BAdi_Lxq.js +1 -0
  22. package/dist/client/assets/editor-DZS7-wrd.js +14 -0
  23. package/dist/client/assets/{hard-drive-XHSiS_A7.js → hard-drive-DP2OHKMC.js} +1 -1
  24. package/dist/client/assets/index-CBrIrzgg.css +1 -0
  25. package/dist/client/assets/{index-Cd6-F29b.js → index-i5IY_PMw.js} +1 -1
  26. package/dist/client/assets/index-teah8iB2.js +12 -0
  27. package/dist/client/assets/katex-D9PXdmFF.css +1 -0
  28. package/dist/client/assets/{key-round-C-CXCWnz.js → key-round-D8sCqL2b.js} +1 -1
  29. package/dist/client/assets/{latexFormatterWorker-DYkYtcFy.js → latexFormatterWorker-BRmsns8a.js} +2 -2
  30. package/dist/client/assets/{minimize-2-BMDPVaF-.js → minimize-2-6tf519Ts.js} +1 -1
  31. package/dist/client/assets/{minus-BIa7-KuM.js → minus-CqvESp9M.js} +1 -1
  32. package/dist/client/assets/{pdf-BB1XvBIF.js → pdf-DUYcbPMK.js} +1 -1
  33. package/dist/client/assets/{plus-BKDWy4Ui.js → plus-DIrweesj.js} +1 -1
  34. package/dist/client/assets/{refresh-cw-Bo7-a7-I.js → refresh-cw-BDcwKu_d.js} +1 -1
  35. package/dist/client/assets/{search-Bz9Ob5mT.js → search-C9_Jyrnx.js} +1 -1
  36. package/dist/client/assets/{sigma-DxlHf9gv.js → sigma-p9-fBP0d.js} +1 -1
  37. package/dist/client/assets/spellCheck-Y0RlmXbl.js +1 -0
  38. package/dist/client/assets/{tags-IMZehjzE.js → tags-DjF3z21M.js} +1 -1
  39. package/dist/client/assets/{type-DTWpFQNQ.js → type-CNrDJnzR.js} +1 -1
  40. package/dist/client/assets/{user-plus--aVpd8K6.js → user-plus-C7exNawX.js} +1 -1
  41. package/dist/client/assets/x-BEVz0wdw.js +1 -0
  42. package/dist/client/index.html +6 -4
  43. package/dist/server/app.js +130 -83
  44. package/dist/server/cli.js +4 -2
  45. package/dist/server/collaboration.js +6 -19
  46. package/dist/server/compileArtifacts.js +3 -2
  47. package/dist/server/config.js +27 -0
  48. package/dist/server/db.js +91 -10
  49. package/dist/server/index.js +4 -2
  50. package/dist/server/instanceLock.js +4 -2
  51. package/dist/server/latexCompletion.js +126 -63
  52. package/dist/server/latexDocumentGraph.js +217 -0
  53. package/dist/server/latexSpellMask.js +3 -4
  54. package/dist/server/pm2.js +3 -2
  55. package/dist/server/projectOutline.js +103 -91
  56. package/dist/server/projectReferences.js +109 -0
  57. package/dist/server/routes/auth.js +5 -2
  58. package/dist/server/routes/comments.js +10 -4
  59. package/dist/server/routes/compile.js +2 -2
  60. package/dist/server/routes/projectFiles.js +12 -3
  61. package/dist/server/routes/projectReferences.js +36 -0
  62. package/dist/server/routes/projectShared.js +33 -12
  63. package/dist/server/routes/projects.js +5 -0
  64. package/dist/server/routes/system.js +1 -0
  65. package/dist/server/zip.js +2 -16
  66. package/dist/shared/basePath.js +44 -0
  67. package/dist/shared/bibtexReferences.js +135 -0
  68. package/dist/shared/collaborationProtocol.js +31 -0
  69. package/dist/shared/latexCommandSnippets.js +91 -0
  70. package/dist/shared/latexDependencies.js +131 -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/AdminUsers-Dazar9cj.js +0 -1
  80. package/dist/client/assets/CitationLibraryDialog-VSUiSPgG.js +0 -1
  81. package/dist/client/assets/Dashboard-CoE5ESfb.js +0 -2
  82. package/dist/client/assets/LatexEditor-Bq76v8sO.js +0 -65
  83. package/dist/client/assets/ProjectIconPickerDialog-BoWMjQVm.js +0 -1
  84. package/dist/client/assets/ProjectSettings-DiOQVJ40.js +0 -1
  85. package/dist/client/assets/ProjectWorkspace-DqLwkGpT.js +0 -27
  86. package/dist/client/assets/SystemMetricsDialog-0zCwLtH_.js +0 -1
  87. package/dist/client/assets/TagManagementDialog-C6KDwtvd.js +0 -1
  88. package/dist/client/assets/check-sZxOOKtS.js +0 -1
  89. package/dist/client/assets/chevron-left-32SrOwa5.js +0 -1
  90. package/dist/client/assets/editor-CiK7TK0p.js +0 -14
  91. package/dist/client/assets/index-BRr3Kuxh.css +0 -1
  92. package/dist/client/assets/index-BjzELSQ-.js +0 -12
  93. package/dist/client/assets/katex-CEK31ho9.css +0 -1
  94. package/dist/client/assets/spellCheck-BfZEnpwY.js +0 -1
  95. package/dist/client/assets/x-Tbo3aA0J.js +0 -1
@@ -1,11 +1,16 @@
1
1
  import fs from "node:fs";
2
2
  import { listProjectFiles, listProjectFilesAsync, resolveSourcePath } from "./files.js";
3
+ import { commandSnippet, newCommandArguments, xparseCommandArguments, xparseCommandDefinitions } from "../shared/latexCommandSnippets.js";
4
+ import { maskLatexComments, maskLatexLiteralContent } from "../shared/latexLiterals.js";
5
+ import { findLatexReferenceDefinitions } from "../shared/latexReferences.js";
6
+ import { declaredBibliographyPaths, latexDocumentDirectives } from "./latexDocumentGraph.js";
3
7
  const textExtensions = /\.(?:tex|sty|cls|def|cfg|bib|bst|fd|dtx|ins)$/i;
4
8
  const definitionExtensions = /\.(?:tex|sty|cls|def|cfg|dtx|ins)$/i;
5
9
  const maxIndexedFileBytes = 3 * 1024 * 1024;
6
10
  const maxIndexedBytes = 24 * 1024 * 1024;
7
11
  const maxIndexedFiles = 500;
8
12
  const maxCachedProjects = 64;
13
+ const maxCachedRootScopes = 8;
9
14
  // Core LaTeX commands are kept in the completion index even when the project
10
15
  // has not loaded a package-specific definition yet. Keep the catalogue
11
16
  // grouped by purpose so missing primitives are easy to spot and extend.
@@ -103,11 +108,11 @@ function item(label, detail, kind, source, apply) {
103
108
  }
104
109
  function createIndex() {
105
110
  return {
106
- commands: new Map(), environments: new Map(), labels: new Map(), citations: new Map(), packages: new Map(), files: new Map()
111
+ commands: new Map(), environments: new Map(), labels: new Map(), citations: new Map(), packages: new Map(), classes: new Map(), files: new Map()
107
112
  };
108
113
  }
109
114
  function withoutComments(content) {
110
- return content.split("\n").map((line) => line.replace(/(^|[^\\])%.*$/, "$1")).join("\n");
115
+ return maskLatexComments(maskLatexLiteralContent(content));
111
116
  }
112
117
  function commandItem(index, name, detail, source, apply) {
113
118
  if (!name.startsWith("\\"))
@@ -120,31 +125,23 @@ function commandItem(index, name, detail, source, apply) {
120
125
  if (!existing || (existing.source === "LaTeX" && source !== "LaTeX"))
121
126
  index.commands.set(name, item(name, detail, "function", source, apply));
122
127
  }
123
- function commandSnippet(name, argumentCount) {
124
- if (argumentCount <= 0)
125
- return undefined;
126
- const placeholder = (index) => `\${${index}}`;
127
- return name + Array.from({ length: argumentCount }, (_, index) => `{${placeholder(index + 1)}}`).join("");
128
- }
129
- function xparseArgumentCount(specification) {
130
- // The common xparse argument types all consume one user supplied value.
131
- // Modifiers such as `+` and argument delimiters are intentionally ignored.
132
- return [...specification.matchAll(/[moOrRdDsStvb]/g)].length;
133
- }
134
128
  function extractSymbols(index, filePath, original) {
135
129
  const content = withoutComments(original);
136
130
  const source = filePath;
137
- for (const match of content.matchAll(/\\(?:newcommand|renewcommand|providecommand|DeclareRobustCommand)\s*\*?\s*(?:\{\s*)?\\([A-Za-z@][A-Za-z@0-9:_]*)\s*(?:\})?\s*(?:\[(\d+)\])?/g)) {
138
- const args = Number.parseInt(match[2] ?? "0", 10);
139
- commandItem(index, `\\${match[1]}`, `Project command (${args} argument${args === 1 ? "" : "s"})`, source, commandSnippet(`\\${match[1]}`, args));
131
+ for (const match of content.matchAll(/\\(?:newcommand|renewcommand|providecommand|DeclareRobustCommand)\s*\*?\s*(?:\{\s*)?\\([A-Za-z@][A-Za-z@0-9:_]*)\s*(?:\})?\s*(?:\[(\d+)\])?(?:\s*(\[[^\]]*\]))?/g)) {
132
+ const arguments_ = newCommandArguments(Number.parseInt(match[2] ?? "0", 10), Boolean(match[3]));
133
+ commandItem(index, `\\${match[1]}`, `Project command (${arguments_.length} argument${arguments_.length === 1 ? "" : "s"})`, source, commandSnippet(`\\${match[1]}`, arguments_));
140
134
  }
141
- for (const match of content.matchAll(/\\(?:NewDocumentCommand|RenewDocumentCommand|ProvideDocumentCommand|DeclareDocumentCommand|DeclareExpandableDocumentCommand|RenewExpandableDocumentCommand|ProvideExpandableDocumentCommand)\s*\{\s*\\([A-Za-z@][A-Za-z@0-9:_]*)\s*\}\s*\{([^}]*)\}/g)) {
142
- const args = xparseArgumentCount(match[2]);
143
- commandItem(index, `\\${match[1]}`, `Project command (${args} argument${args === 1 ? "" : "s"})`, source, commandSnippet(`\\${match[1]}`, args));
135
+ for (const definition of xparseCommandDefinitions(content)) {
136
+ const arguments_ = xparseCommandArguments(definition.specification);
137
+ const detail = arguments_
138
+ ? `Project command (${arguments_.length} argument${arguments_.length === 1 ? "" : "s"})`
139
+ : "Project command";
140
+ commandItem(index, `\\${definition.name}`, detail, source, arguments_ ? commandSnippet(`\\${definition.name}`, arguments_) : undefined);
144
141
  }
145
142
  for (const match of content.matchAll(/\\(?:def|gdef|edef|xdef)\s*\\([A-Za-z@][A-Za-z@0-9:_]*)((?:\s*#\d+)*)/g)) {
146
143
  const args = [...(match[2] ?? "").matchAll(/#\d+/g)].length;
147
- commandItem(index, `\\${match[1]}`, "Project macro", source, commandSnippet(`\\${match[1]}`, args));
144
+ commandItem(index, `\\${match[1]}`, "Project macro", source, commandSnippet(`\\${match[1]}`, newCommandArguments(args, false)));
148
145
  }
149
146
  for (const match of content.matchAll(/\\DeclareMathOperator\s*\*?\s*\{?\\([A-Za-z@][A-Za-z@0-9:_]*)\}?/g))
150
147
  commandItem(index, `\\${match[1]}`, "Project math operator", source);
@@ -166,8 +163,10 @@ function extractSymbols(index, filePath, original) {
166
163
  index.environments.set(name, item(name, "Theorem environment", "keyword", source));
167
164
  }
168
165
  }
169
- for (const match of content.matchAll(/\\(?:label|hypertarget)\s*\{([^}]+)\}/g)) {
170
- const label = match[1].trim();
166
+ for (const definition of findLatexReferenceDefinitions(content, "label")) {
167
+ if (definition.source !== "label")
168
+ continue;
169
+ const label = definition.key;
171
170
  if (label && !index.labels.has(label))
172
171
  index.labels.set(label, item(label, "Label", "constant", source));
173
172
  }
@@ -176,29 +175,24 @@ function extractSymbols(index, filePath, original) {
176
175
  if (file)
177
176
  index.files.set(file, item(file, "Project file", "text", source));
178
177
  }
179
- for (const match of content.matchAll(/\\(?:usepackage|RequirePackage|documentclass)\s*(?:\[[^]]*\])?\s*\{([^}]+)\}/g)) {
178
+ for (const match of content.matchAll(/\\(?:usepackage|RequirePackage|documentclass)\s*(?:\[[^\]]*\])?\s*\{([^}]+)\}/g)) {
180
179
  for (const packageName of match[1].split(",").map((value) => value.trim()).filter(Boolean)) {
181
- const target = /documentclass|documentstyle/.test(match[0]) ? index.files : index.packages;
180
+ const target = /documentclass|documentstyle/.test(match[0]) ? index.classes : index.packages;
182
181
  if (!target.has(packageName))
183
- target.set(packageName, item(packageName, target === index.files ? "Document class" : "Package", "text", source));
182
+ target.set(packageName, item(packageName, target === index.classes ? "Document class" : "Package", target === index.classes ? "class" : "text", source));
184
183
  }
185
184
  }
186
- for (const match of content.matchAll(/\\(?:cite|citep|citet|parencite|textcite|autocite|footcite)(?:\w*)?\s*(?:\[[^]]*\])?\s*\{([^}]+)\}/g)) {
187
- for (const key of match[1].split(",").map((value) => value.trim()).filter(Boolean)) {
188
- if (!index.citations.has(key))
189
- index.citations.set(key, item(key, "Citation key", "constant", source));
185
+ // `\\bibitem` suggestions are scoped by the client to the file currently
186
+ // open in the editor. Keep the server index reserved for standalone
187
+ // project BibTeX databases, so old cite uses and other source files do
188
+ // not leak into a citation menu.
189
+ if (/\.bib$/i.test(filePath)) {
190
+ for (const definition of findLatexReferenceDefinitions(original, "citation", true)) {
191
+ const key = definition.key;
192
+ if (key && !index.citations.has(key))
193
+ index.citations.set(key, item(key, "BibTeX key", "constant", source));
190
194
  }
191
195
  }
192
- for (const match of content.matchAll(/\\bibitem\s*(?:\[[^]]*\])?\s*\{([^}]+)\}/g)) {
193
- const key = match[1].trim();
194
- if (key && !index.citations.has(key))
195
- index.citations.set(key, item(key, "Bibliography key", "constant", source));
196
- }
197
- for (const match of content.matchAll(/@(?:\w+)\s*\{\s*([^,\s]+)\s*,/g)) {
198
- const key = match[1].trim();
199
- if (key && !index.citations.has(key))
200
- index.citations.set(key, item(key, "BibTeX key", "constant", source));
201
- }
202
196
  }
203
197
  function sorted(map) {
204
198
  return [...map.values()].sort((left, right) => left.label.localeCompare(right.label));
@@ -212,14 +206,21 @@ function standardIndex() {
212
206
  for (const packageName of standardPackages)
213
207
  index.packages.set(packageName.trim(), item(packageName.trim(), "Package", "text", "LaTeX"));
214
208
  for (const className of standardClasses)
215
- index.files.set(className, item(className, "Document class", "class", "LaTeX"));
209
+ index.classes.set(className, item(className, "Document class", "class", "LaTeX"));
216
210
  return index;
217
211
  }
212
+ function addProjectFile(index, filePath) {
213
+ index.files.set(filePath, item(filePath, "Project file", "text", "Project"));
214
+ if (/\.cls$/i.test(filePath)) {
215
+ const name = filePath.replace(/\.cls$/i, "");
216
+ index.classes.set(name, item(name, "Document class", "class", filePath));
217
+ }
218
+ }
218
219
  export function buildLatexCompletionIndex(config, projectId) {
219
220
  const index = standardIndex();
220
221
  const allEntries = listProjectFiles(config, projectId).filter((entry) => entry.type === "file").slice(0, maxIndexedFiles);
221
222
  for (const entry of allEntries)
222
- index.files.set(entry.path, item(entry.path, "Project file", "text", "Project"));
223
+ addProjectFile(index, entry.path);
223
224
  const entries = allEntries.filter((entry) => textExtensions.test(entry.path));
224
225
  let indexedBytes = 0;
225
226
  for (const entry of entries) {
@@ -240,33 +241,38 @@ export function buildLatexCompletionIndex(config, projectId) {
240
241
  }
241
242
  return {
242
243
  commands: sorted(index.commands), environments: sorted(index.environments), labels: sorted(index.labels),
243
- citations: sorted(index.citations), packages: sorted(index.packages), files: sorted(index.files)
244
+ citations: sorted(index.citations), packages: sorted(index.packages), classes: sorted(index.classes), files: sorted(index.files)
244
245
  };
245
246
  }
246
247
  /** Reuses extracted symbols for files whose mtime and size did not change. */
247
248
  export class LatexCompletionService {
248
249
  config;
249
250
  cache = new Map();
251
+ // One project can have several selectable root documents. Keep a small
252
+ // result cache per project so switching roots does not accidentally reuse
253
+ // citation keys collected for another manuscript.
250
254
  resultCache = new Map();
251
255
  pending = new Map();
252
256
  constructor(config) {
253
257
  this.config = config;
254
258
  }
255
- build(projectId) {
256
- const existing = this.pending.get(projectId);
259
+ build(projectId, mainFile) {
260
+ const scope = completionScope(mainFile);
261
+ const pendingKey = `${projectId}\0${scope}`;
262
+ const existing = this.pending.get(pendingKey);
257
263
  if (existing)
258
264
  return existing;
259
- const request = this.buildIncremental(projectId)
265
+ const request = this.buildIncremental(projectId, mainFile)
260
266
  // Do not retain a partial per-file cache when a project scan fails.
261
267
  .catch((error) => {
262
268
  this.invalidate(projectId);
263
269
  throw error;
264
270
  })
265
271
  .finally(() => {
266
- if (this.pending.get(projectId) === request)
267
- this.pending.delete(projectId);
272
+ if (this.pending.get(pendingKey) === request)
273
+ this.pending.delete(pendingKey);
268
274
  });
269
- this.pending.set(projectId, request);
275
+ this.pending.set(pendingKey, request);
270
276
  return request;
271
277
  }
272
278
  invalidate(projectId) {
@@ -281,13 +287,14 @@ export class LatexCompletionService {
281
287
  cachedFiles: [...this.cache.values()].reduce((total, files) => total + files.size, 0)
282
288
  };
283
289
  }
284
- async buildIncremental(projectId) {
290
+ async buildIncremental(projectId, mainFile) {
291
+ const scope = completionScope(mainFile);
285
292
  const index = standardIndex();
286
293
  const allEntries = (await listProjectFilesAsync(this.config, projectId)).filter((entry) => entry.type === "file").slice(0, maxIndexedFiles);
287
294
  const signature = allEntries.map((entry) => `${entry.path}:${entry.size ?? 0}:${entry.mtimeMs ?? 0}`).sort().join("\n");
288
- const cachedResult = this.resultCache.get(projectId);
295
+ const cachedResult = this.resultCache.get(projectId)?.get(scope);
289
296
  if (cachedResult?.signature === signature) {
290
- this.touch(projectId);
297
+ this.touch(projectId, scope);
291
298
  return cachedResult.index;
292
299
  }
293
300
  const projectCache = this.cache.get(projectId) ?? new Map();
@@ -297,7 +304,9 @@ export class LatexCompletionService {
297
304
  if (!livePaths.has(cachedPath))
298
305
  projectCache.delete(cachedPath);
299
306
  for (const entry of allEntries)
300
- index.files.set(entry.path, item(entry.path, "Project file", "text", "Project"));
307
+ addProjectFile(index, entry.path);
308
+ const indexedFiles = [];
309
+ const sourceDirectives = new Map();
301
310
  let indexedBytes = 0;
302
311
  for (const entry of allEntries.filter((candidate) => textExtensions.test(candidate.path))) {
303
312
  const size = entry.size ?? 0;
@@ -318,26 +327,68 @@ export class LatexCompletionService {
318
327
  const fileIndex = createIndex();
319
328
  if (definitionExtensions.test(entry.path) || /\.bib$/i.test(entry.path))
320
329
  extractSymbols(fileIndex, entry.path, content);
321
- cached = { signature, index: fileIndex };
330
+ cached = {
331
+ signature,
332
+ index: fileIndex,
333
+ ...(definitionExtensions.test(entry.path) ? { directives: latexDocumentDirectives(content) } : {})
334
+ };
322
335
  projectCache.set(entry.path, cached);
323
336
  }
324
- mergeIndex(index, cached.index);
337
+ if (cached.directives)
338
+ sourceDirectives.set(entry.path, cached.directives);
339
+ indexedFiles.push({ path: entry.path, symbols: cached });
340
+ mergeIndex(index, cached.index, false);
341
+ }
342
+ // A project can hold templates, archives, and unrelated bibliography
343
+ // databases. Citation completion follows the selected root's \input graph
344
+ // and only includes BibTeX resources it explicitly declares. If a caller
345
+ // does not provide a root (the backwards-compatible service API), keep the
346
+ // former project-wide bibliography behavior.
347
+ const bibPaths = allEntries.filter((entry) => /\.bib$/i.test(entry.path)).map((entry) => entry.path);
348
+ const selectedBibliographies = mainFile
349
+ ? declaredBibliographyPaths(sourceDirectives, mainFile, bibPaths)
350
+ : null;
351
+ const allowedBibliographies = new Set(selectedBibliographies ?? bibPaths);
352
+ for (const { path: filePath, symbols } of indexedFiles) {
353
+ if (!allowedBibliographies.has(filePath))
354
+ continue;
355
+ mergeCitations(index, symbols.index);
325
356
  }
326
357
  const result = {
327
358
  commands: sorted(index.commands), environments: sorted(index.environments), labels: sorted(index.labels),
328
- citations: sorted(index.citations), packages: sorted(index.packages), files: sorted(index.files)
359
+ citations: sorted(index.citations), packages: sorted(index.packages), classes: sorted(index.classes), files: sorted(index.files)
329
360
  };
330
- this.resultCache.set(projectId, { signature, index: result });
331
- this.touch(projectId);
361
+ const projectResults = this.resultCache.get(projectId) ?? new Map();
362
+ this.resultCache.set(projectId, projectResults);
363
+ // A root document is user-selectable, so do not let a project with many
364
+ // standalone examples retain an unbounded number of full completion
365
+ // result arrays. The per-file symbol cache remains shared by all roots.
366
+ if (projectResults.has(scope))
367
+ projectResults.delete(scope);
368
+ projectResults.set(scope, { signature, index: result });
369
+ while (projectResults.size > maxCachedRootScopes) {
370
+ const oldestScope = projectResults.keys().next().value;
371
+ if (oldestScope === undefined)
372
+ break;
373
+ projectResults.delete(oldestScope);
374
+ }
375
+ this.touch(projectId, scope);
332
376
  this.evictLeastRecentlyUsedProjects();
333
377
  return result;
334
378
  }
335
379
  /** Keep result and per-file symbol caches on the same project-level LRU. */
336
- touch(projectId) {
337
- const result = this.resultCache.get(projectId);
338
- if (result) {
380
+ touch(projectId, scope) {
381
+ const results = this.resultCache.get(projectId);
382
+ if (results) {
383
+ if (scope !== undefined) {
384
+ const result = results.get(scope);
385
+ if (result) {
386
+ results.delete(scope);
387
+ results.set(scope, result);
388
+ }
389
+ }
339
390
  this.resultCache.delete(projectId);
340
- this.resultCache.set(projectId, result);
391
+ this.resultCache.set(projectId, results);
341
392
  }
342
393
  const symbols = this.cache.get(projectId);
343
394
  if (symbols) {
@@ -355,10 +406,15 @@ export class LatexCompletionService {
355
406
  }
356
407
  }
357
408
  }
358
- function mergeIndex(target, source) {
409
+ function completionScope(mainFile) {
410
+ return mainFile ?? "";
411
+ }
412
+ function mergeIndex(target, source, includeCitations) {
359
413
  for (const value of source.commands.values())
360
414
  commandItem(target, value.label, value.detail, value.source ?? "Project", value.apply);
361
- for (const key of ["environments", "labels", "citations", "packages", "files"]) {
415
+ for (const key of ["environments", "labels", "citations", "packages", "classes", "files"]) {
416
+ if (key === "citations" && !includeCitations)
417
+ continue;
362
418
  for (const [label, value] of source[key]) {
363
419
  const existing = target[key].get(label);
364
420
  if (!existing || (existing.source === "LaTeX" && value.source !== "LaTeX"))
@@ -366,3 +422,10 @@ function mergeIndex(target, source) {
366
422
  }
367
423
  }
368
424
  }
425
+ function mergeCitations(target, source) {
426
+ for (const [label, value] of source.citations) {
427
+ const existing = target.citations.get(label);
428
+ if (!existing || (existing.source === "LaTeX" && value.source !== "LaTeX"))
429
+ target.citations.set(label, value);
430
+ }
431
+ }
@@ -0,0 +1,217 @@
1
+ import path from "node:path";
2
+ import { findLatexBibliographyFiles, findLatexSourceIncludes } from "../shared/latexDependencies.js";
3
+ /**
4
+ * Extract only the small amount of source information needed for project-level
5
+ * document graph lookups. Keeping this separate from completion symbols means
6
+ * cached completion data does not need to retain whole source files.
7
+ */
8
+ export function latexDocumentDirectives(source) {
9
+ return {
10
+ includes: findLatexSourceIncludes(source).map(toLatexIncludeDirective),
11
+ bibliographies: findLatexBibliographyFiles(source).map((reference) => reference.path)
12
+ };
13
+ }
14
+ export function toLatexIncludeDirective(reference) {
15
+ return {
16
+ path: reference.path,
17
+ command: reference.command,
18
+ ...(reference.directory === undefined ? {} : { directory: reference.directory })
19
+ };
20
+ }
21
+ /** Return the selected root followed by its reachable \input-style sources. */
22
+ export function documentSourceOrder(sources, mainFile) {
23
+ if (!mainFile || !sources.has(mainFile))
24
+ return [];
25
+ const result = [];
26
+ const queued = [{ path: mainFile, importBase: "" }];
27
+ // A source can technically be reached under more than one import context.
28
+ // Keeping its first discovered context mirrors the ordinary project graph
29
+ // traversal while bounding recursive import-package structures.
30
+ const seen = new Set();
31
+ while (queued.length) {
32
+ const current = queued.shift();
33
+ if (!current || seen.has(current.path))
34
+ continue;
35
+ const directives = sources.get(current.path);
36
+ if (!directives)
37
+ continue;
38
+ seen.add(current.path);
39
+ result.push(current.path);
40
+ for (const include of directives.includes) {
41
+ const resolved = resolveLatexInclude(include, current.importBase, sources);
42
+ if (resolved && !seen.has(resolved.path))
43
+ queued.push(resolved);
44
+ }
45
+ }
46
+ return result;
47
+ }
48
+ /**
49
+ * Return exactly the BibTeX resources declared by a selected root and its
50
+ * input graph. `null` means the selected root was unavailable to the graph
51
+ * scanner, so callers can retain a backwards-compatible fallback.
52
+ */
53
+ export function declaredBibliographyPaths(sources, mainFile, bibPaths) {
54
+ if (!mainFile || !sources.has(mainFile))
55
+ return null;
56
+ const availableBibliographies = new Set(bibPaths);
57
+ const declared = [];
58
+ for (const sourcePath of documentSourceOrder(sources, mainFile)) {
59
+ const directives = sources.get(sourcePath);
60
+ if (!directives)
61
+ continue;
62
+ for (const bibliography of directives.bibliographies) {
63
+ const resolved = resolveProjectReferencePath(bibliography, sourcePath, ".bib", availableBibliographies, "project-root-then-source-file");
64
+ if (resolved)
65
+ declared.push(resolved);
66
+ }
67
+ }
68
+ return uniqueProjectPaths(declared);
69
+ }
70
+ export function resolveProjectReferencePath(rawPath, currentPath, extension, available, resolution) {
71
+ const raw = normalizeReferenceInput(rawPath);
72
+ if (!raw)
73
+ return null;
74
+ const parent = path.posix.dirname(currentPath);
75
+ const relative = normalizeProjectPath(parent === "." ? raw : path.posix.join(parent, raw));
76
+ const root = normalizeProjectPath(raw);
77
+ const roots = resolution === "project-root"
78
+ ? [root]
79
+ : resolution === "source-file"
80
+ ? [relative]
81
+ : [root, relative];
82
+ return resolveCandidatePaths(roots, extension, available);
83
+ }
84
+ /**
85
+ * Resolve import.sty's dynamic path handling. `\\import`, `\\inputfrom`,
86
+ * and `\\includefrom` reset the import path from the compiler root. Their
87
+ * `sub*` counterparts extend the active path. Plain input-style commands use
88
+ * the active path first, then the compiler root, matching import.sty's
89
+ * `\\input@path` fallback behavior.
90
+ */
91
+ export function resolveLatexInclude(include, importBase, available) {
92
+ switch (include.command.toLowerCase()) {
93
+ case "input":
94
+ case "include": {
95
+ const resolved = resolveOrdinaryIncludePath(include.path, importBase, available);
96
+ return resolved ? { path: resolved, importBase } : null;
97
+ }
98
+ case "subfile":
99
+ return resolveSubfilePath(include.path, importBase, available);
100
+ case "import":
101
+ case "includefrom":
102
+ case "inputfrom": {
103
+ const nextImportBase = normalizeImportBase(include.directory ?? "");
104
+ if (nextImportBase === null)
105
+ return null;
106
+ const resolved = resolveProjectPathAtBase(include.path, "", ".tex", available);
107
+ return resolved ? { path: resolved, importBase: nextImportBase } : null;
108
+ }
109
+ case "subimport":
110
+ case "subinputfrom":
111
+ case "subincludefrom": {
112
+ const nextImportBase = extendImportBase(importBase, include.directory ?? "");
113
+ if (nextImportBase === null)
114
+ return null;
115
+ const resolved = resolveProjectPathAtBase(include.path, importBase, ".tex", available);
116
+ return resolved ? { path: resolved, importBase: nextImportBase } : null;
117
+ }
118
+ default:
119
+ return null;
120
+ }
121
+ }
122
+ /** `subfiles` implements \subfile through import.sty's \subimport. */
123
+ function resolveSubfilePath(rawPath, importBase, available) {
124
+ const raw = normalizeReferenceInput(rawPath);
125
+ if (!raw)
126
+ return null;
127
+ const directory = path.posix.dirname(raw);
128
+ const file = path.posix.basename(raw);
129
+ if (!file || file === "." || file === "/")
130
+ return null;
131
+ const nextImportBase = extendImportBase(importBase, directory === "." ? "" : directory);
132
+ if (nextImportBase === null)
133
+ return null;
134
+ const resolved = resolveProjectPathAtBase(file, nextImportBase, ".tex", available);
135
+ return resolved ? { path: resolved, importBase: nextImportBase } : null;
136
+ }
137
+ function resolveOrdinaryIncludePath(rawPath, importBase, available) {
138
+ if (importBase) {
139
+ const fromImportPath = resolveProjectPathAtBase(rawPath, importBase, ".tex", available);
140
+ if (fromImportPath)
141
+ return fromImportPath;
142
+ }
143
+ return resolveProjectPathAtBase(rawPath, "", ".tex", available);
144
+ }
145
+ function resolveProjectPathAtBase(rawPath, base, extension, available) {
146
+ const raw = normalizeReferenceInput(rawPath);
147
+ const normalizedBase = normalizeImportBase(base);
148
+ if (!raw || normalizedBase === null)
149
+ return null;
150
+ const candidate = normalizeProjectPath(normalizedBase ? path.posix.join(normalizedBase, raw) : raw);
151
+ return resolveCandidatePaths(candidate ? [candidate] : [], extension, available);
152
+ }
153
+ function extendImportBase(importBase, extension) {
154
+ const normalizedBase = normalizeImportBase(importBase);
155
+ const relativeExtension = normalizeRelativeImportPath(extension);
156
+ if (normalizedBase === null || relativeExtension === null)
157
+ return null;
158
+ return normalizeImportBase(normalizedBase
159
+ ? path.posix.join(normalizedBase, relativeExtension)
160
+ : relativeExtension);
161
+ }
162
+ function resolveCandidatePaths(paths, extension, available) {
163
+ const candidates = paths.flatMap((candidate) => {
164
+ if (!candidate)
165
+ return [];
166
+ return candidate.toLowerCase().endsWith(extension)
167
+ ? [candidate]
168
+ : [candidate, candidate + extension];
169
+ });
170
+ for (const candidate of uniqueProjectPaths(candidates)) {
171
+ if (available.has(candidate))
172
+ return candidate;
173
+ }
174
+ return null;
175
+ }
176
+ export function uniqueProjectPaths(paths) {
177
+ const seen = new Set();
178
+ return paths.filter((entryPath) => {
179
+ if (!entryPath || seen.has(entryPath))
180
+ return false;
181
+ seen.add(entryPath);
182
+ return true;
183
+ });
184
+ }
185
+ function normalizeReferenceInput(value) {
186
+ const trimmed = value.trim().replaceAll("\\", "/");
187
+ if (!trimmed || trimmed.startsWith("/") || /^[A-Za-z]:\//.test(trimmed) || trimmed.includes("\0"))
188
+ return null;
189
+ return trimmed;
190
+ }
191
+ function normalizeRelativeImportPath(value) {
192
+ const trimmed = value.trim().replaceAll("\\", "/");
193
+ if (trimmed.startsWith("/") || /^[A-Za-z]:\//.test(trimmed) || trimmed.includes("\0"))
194
+ return null;
195
+ return trimmed || ".";
196
+ }
197
+ function normalizeImportBase(value) {
198
+ const relative = normalizeRelativeImportPath(value);
199
+ if (relative === null)
200
+ return null;
201
+ const normalized = path.posix.normalize(relative);
202
+ if (normalized === ".")
203
+ return "";
204
+ if (normalized === ".." || normalized.startsWith("../"))
205
+ return null;
206
+ if (normalized.split("/").some((segment) => segment.toLowerCase() === ".git"))
207
+ return null;
208
+ return normalized;
209
+ }
210
+ function normalizeProjectPath(value) {
211
+ const normalized = path.posix.normalize(value);
212
+ if (normalized === "." || normalized === ".." || normalized.startsWith("../"))
213
+ return null;
214
+ if (normalized.split("/").some((segment) => segment.toLowerCase() === ".git"))
215
+ return null;
216
+ return normalized;
217
+ }
@@ -1,11 +1,10 @@
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.
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
- ]);
5
+ const literalEnvironments = new Set(literalEnvironmentNames.map((name) => name.toLowerCase()));
8
6
  const nonProseEnvironments = new Set([
7
+ ...literalEnvironments,
9
8
  // Display and helper math environments commonly used by amsmath/mathtools.
10
9
  "math", "displaymath", "equation", "equation*", "align", "align*", "alignat", "alignat*", "aligned", "alignedat", "gather", "gather*", "gathered", "multline", "multline*", "multlined", "flalign", "flalign*", "split", "cases", "dcases", "rcases", "drcases",
11
10
  "array", "matrix", "pmatrix", "bmatrix", "vmatrix", "smallmatrix", "psmallmatrix", "bsmallmatrix", "vsmallmatrix",
@@ -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) => {