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.
- package/DESIGN.md +36 -6
- package/OPERATIONS.md +70 -3
- package/README.md +11 -3
- package/README.zh-CN.md +11 -2
- package/THIRD_PARTY_NOTICES.md +29 -0
- package/dist/client/assets/AdminUsers-huwQK7Ly.js +1 -0
- package/dist/client/assets/CitationLibraryDialog-C5C3sPdw.js +1 -0
- package/dist/client/assets/Dashboard-DtHSc55D.js +2 -0
- package/dist/client/assets/{GitDialog-C9Loz4Al.js → GitDialog-BI7QIMsh.js} +1 -1
- package/dist/client/assets/{HistoryDialog-3UNN_jhU.js → HistoryDialog-DA7O9aF2.js} +1 -1
- package/dist/client/assets/LatexEditor-BYWWx5Km.js +138 -0
- package/dist/client/assets/{PdfPreview-BKhcS-2c.js → PdfPreview-mO9WS01q.js} +1 -1
- package/dist/client/assets/ProjectIconPickerDialog-B0q_r5dX.js +1 -0
- package/dist/client/assets/{ProjectNavigationDialogs-CZKTnxrl.js → ProjectNavigationDialogs-DwWYbB1A.js} +1 -1
- package/dist/client/assets/ProjectSettings-BpPFgdoj.js +1 -0
- package/dist/client/assets/ProjectWorkspace-CI2zKon5.js +32 -0
- package/dist/client/assets/{SelectionHistoryDialog-D3dO83lg.js → SelectionHistoryDialog-pWMI5izu.js} +2 -2
- package/dist/client/assets/SystemMetricsDialog-BmlO7oNg.js +1 -0
- package/dist/client/assets/TagManagementDialog-h4Ves4FR.js +1 -0
- package/dist/client/assets/{WordCountDialog-TwpyAP3m.js → WordCountDialog-DlBSP4y7.js} +1 -1
- package/dist/client/assets/check-BAdi_Lxq.js +1 -0
- package/dist/client/assets/editor-DZS7-wrd.js +14 -0
- package/dist/client/assets/{hard-drive-XHSiS_A7.js → hard-drive-DP2OHKMC.js} +1 -1
- package/dist/client/assets/index-CBrIrzgg.css +1 -0
- package/dist/client/assets/{index-Cd6-F29b.js → index-i5IY_PMw.js} +1 -1
- package/dist/client/assets/index-teah8iB2.js +12 -0
- package/dist/client/assets/katex-D9PXdmFF.css +1 -0
- package/dist/client/assets/{key-round-C-CXCWnz.js → key-round-D8sCqL2b.js} +1 -1
- package/dist/client/assets/{latexFormatterWorker-DYkYtcFy.js → latexFormatterWorker-BRmsns8a.js} +2 -2
- package/dist/client/assets/{minimize-2-BMDPVaF-.js → minimize-2-6tf519Ts.js} +1 -1
- package/dist/client/assets/{minus-BIa7-KuM.js → minus-CqvESp9M.js} +1 -1
- package/dist/client/assets/{pdf-BB1XvBIF.js → pdf-DUYcbPMK.js} +1 -1
- package/dist/client/assets/{plus-BKDWy4Ui.js → plus-DIrweesj.js} +1 -1
- package/dist/client/assets/{refresh-cw-Bo7-a7-I.js → refresh-cw-BDcwKu_d.js} +1 -1
- package/dist/client/assets/{search-Bz9Ob5mT.js → search-C9_Jyrnx.js} +1 -1
- package/dist/client/assets/{sigma-DxlHf9gv.js → sigma-p9-fBP0d.js} +1 -1
- package/dist/client/assets/spellCheck-Y0RlmXbl.js +1 -0
- package/dist/client/assets/{tags-IMZehjzE.js → tags-DjF3z21M.js} +1 -1
- package/dist/client/assets/{type-DTWpFQNQ.js → type-CNrDJnzR.js} +1 -1
- package/dist/client/assets/{user-plus--aVpd8K6.js → user-plus-C7exNawX.js} +1 -1
- package/dist/client/assets/x-BEVz0wdw.js +1 -0
- package/dist/client/index.html +6 -4
- package/dist/server/app.js +130 -83
- package/dist/server/cli.js +4 -2
- package/dist/server/collaboration.js +6 -19
- package/dist/server/compileArtifacts.js +3 -2
- package/dist/server/config.js +27 -0
- package/dist/server/db.js +91 -10
- package/dist/server/index.js +4 -2
- package/dist/server/instanceLock.js +4 -2
- package/dist/server/latexCompletion.js +126 -63
- package/dist/server/latexDocumentGraph.js +217 -0
- package/dist/server/latexSpellMask.js +3 -4
- package/dist/server/pm2.js +3 -2
- package/dist/server/projectOutline.js +103 -91
- package/dist/server/projectReferences.js +109 -0
- package/dist/server/routes/auth.js +5 -2
- package/dist/server/routes/comments.js +10 -4
- package/dist/server/routes/compile.js +2 -2
- package/dist/server/routes/projectFiles.js +12 -3
- package/dist/server/routes/projectReferences.js +36 -0
- package/dist/server/routes/projectShared.js +33 -12
- package/dist/server/routes/projects.js +5 -0
- package/dist/server/routes/system.js +1 -0
- package/dist/server/zip.js +2 -16
- package/dist/shared/basePath.js +44 -0
- package/dist/shared/bibtexReferences.js +135 -0
- package/dist/shared/collaborationProtocol.js +31 -0
- package/dist/shared/latexCommandSnippets.js +91 -0
- package/dist/shared/latexDependencies.js +131 -0
- package/dist/shared/latexLiterals.js +215 -0
- package/dist/shared/latexReferenceCommands.js +75 -0
- package/dist/shared/latexReferences.js +193 -0
- package/dist/shared/latexRoot.js +21 -0
- package/dist/shared/latexScanner.js +183 -0
- package/dist/shared/writingChecks.js +4 -0
- package/package.json +11 -4
- package/texlite.config.example.json +2 -4
- package/dist/client/assets/AdminUsers-Dazar9cj.js +0 -1
- package/dist/client/assets/CitationLibraryDialog-VSUiSPgG.js +0 -1
- package/dist/client/assets/Dashboard-CoE5ESfb.js +0 -2
- package/dist/client/assets/LatexEditor-Bq76v8sO.js +0 -65
- package/dist/client/assets/ProjectIconPickerDialog-BoWMjQVm.js +0 -1
- package/dist/client/assets/ProjectSettings-DiOQVJ40.js +0 -1
- package/dist/client/assets/ProjectWorkspace-DqLwkGpT.js +0 -27
- package/dist/client/assets/SystemMetricsDialog-0zCwLtH_.js +0 -1
- package/dist/client/assets/TagManagementDialog-C6KDwtvd.js +0 -1
- package/dist/client/assets/check-sZxOOKtS.js +0 -1
- package/dist/client/assets/chevron-left-32SrOwa5.js +0 -1
- package/dist/client/assets/editor-CiK7TK0p.js +0 -14
- package/dist/client/assets/index-BRr3Kuxh.css +0 -1
- package/dist/client/assets/index-BjzELSQ-.js +0 -12
- package/dist/client/assets/katex-CEK31ho9.css +0 -1
- package/dist/client/assets/spellCheck-BfZEnpwY.js +0 -1
- 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
|
|
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
|
|
139
|
-
commandItem(index, `\\${match[1]}`, `Project command (${
|
|
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
|
|
142
|
-
const
|
|
143
|
-
|
|
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
|
|
170
|
-
|
|
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*(?:\[[
|
|
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.
|
|
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.
|
|
182
|
+
target.set(packageName, item(packageName, target === index.classes ? "Document class" : "Package", target === index.classes ? "class" : "text", source));
|
|
184
183
|
}
|
|
185
184
|
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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.
|
|
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
|
|
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
|
|
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(
|
|
267
|
-
this.pending.delete(
|
|
272
|
+
if (this.pending.get(pendingKey) === request)
|
|
273
|
+
this.pending.delete(pendingKey);
|
|
268
274
|
});
|
|
269
|
-
this.pending.set(
|
|
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
|
|
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 = {
|
|
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
|
-
|
|
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.
|
|
331
|
-
this.
|
|
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
|
|
338
|
-
if (
|
|
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,
|
|
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
|
|
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",
|
package/dist/server/pm2.js
CHANGED
|
@@ -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) => {
|