texlite 0.9.0 → 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DESIGN.md +7 -0
- package/OPERATIONS.md +70 -3
- package/README.md +5 -0
- package/README.zh-CN.md +5 -0
- package/THIRD_PARTY_NOTICES.md +29 -0
- package/dist/client/assets/{AdminUsers-3CHb5012.js → AdminUsers-Buf5ZclR.js} +1 -1
- package/dist/client/assets/{CitationLibraryDialog-zPpsUhWJ.js → CitationLibraryDialog-DrKbIlD-.js} +1 -1
- package/dist/client/assets/Dashboard-DLk2j3oq.js +2 -0
- package/dist/client/assets/GitDialog-CTWAU8q6.js +3 -0
- package/dist/client/assets/HistoryDialog-Mo4p8HEg.js +7 -0
- package/dist/client/assets/LatexEditor-CwvakYva.js +135 -0
- package/dist/client/assets/{PdfPreview-BFVNczSk.js → PdfPreview-BUpELZd-.js} +1 -1
- package/dist/client/assets/{ProjectIconPickerDialog-Cf0zBf8u.js → ProjectIconPickerDialog-BiWSwDdt.js} +1 -1
- package/dist/client/assets/{ProjectNavigationDialogs-TPbe11iR.js → ProjectNavigationDialogs-CpjkXZTz.js} +1 -1
- package/dist/client/assets/{ProjectSettings-CDQvIlQ4.js → ProjectSettings-DoxitHbX.js} +1 -1
- package/dist/client/assets/ProjectWorkspace-iNqXZlsj.js +29 -0
- package/dist/client/assets/SelectionHistoryDialog-9FWlc3Rb.js +3 -0
- package/dist/client/assets/{SystemMetricsDialog-CuqHPUnn.js → SystemMetricsDialog-24kifZO6.js} +1 -1
- package/dist/client/assets/TagManagementDialog-BHIA0vEA.js +1 -0
- package/dist/client/assets/{WordCountDialog-B4PI0vEk.js → WordCountDialog-DFIWvLrP.js} +1 -1
- package/dist/client/assets/check-C3GZh6a1.js +1 -0
- package/dist/client/assets/chevron-left-l1oupvGO.js +1 -0
- package/dist/client/assets/editor-3rgn4IYh.js +14 -0
- package/dist/client/assets/{hard-drive-6uYFyezl.js → hard-drive-B-NSDVVA.js} +1 -1
- package/dist/client/assets/{index-BRT5O-Hg.js → index-CNTGCh7t.js} +1 -1
- package/dist/client/assets/index-CrlfruX_.css +1 -0
- package/dist/client/assets/index-DIM_KiG2.js +12 -0
- package/dist/client/assets/katex-D9PXdmFF.css +1 -0
- package/dist/client/assets/{key-round-DOAxDSfz.js → key-round-BjL9H8-H.js} +1 -1
- package/dist/client/assets/{latexFormatterWorker-DYkYtcFy.js → latexFormatterWorker-BRmsns8a.js} +2 -2
- package/dist/client/assets/minimize-2-DDCq8c49.js +1 -0
- package/dist/client/assets/{minus-9eVJnugf.js → minus-DgKZCT9F.js} +1 -1
- package/dist/client/assets/{pdf-BB1XvBIF.js → pdf-DUYcbPMK.js} +1 -1
- package/dist/client/assets/{plus-BGXipRoG.js → plus-CezUzSIZ.js} +1 -1
- package/dist/client/assets/{refresh-cw-Dvp3O90f.js → refresh-cw-CDFNL-qu.js} +1 -1
- package/dist/client/assets/{search-b1ORp1k8.js → search-CrDJjHoj.js} +1 -1
- package/dist/client/assets/{sigma-Bk0_MzWi.js → sigma-DOcJr7VG.js} +1 -1
- package/dist/client/assets/spellCheck-BgWyesvf.js +1 -0
- package/dist/client/assets/{tags-JAkbmWns.js → tags-BedQ75lh.js} +1 -1
- package/dist/client/assets/{type-CE2DYkPH.js → type-glZNdzZR.js} +1 -1
- package/dist/client/assets/{user-plus-CKalKSsw.js → user-plus-gQ6q5Nom.js} +1 -1
- package/dist/client/assets/{x-Bq29njHL.js → x-CSzFikn2.js} +1 -1
- package/dist/client/index.html +6 -4
- package/dist/server/app.js +130 -83
- package/dist/server/cli.js +4 -2
- package/dist/server/compileArtifacts.js +3 -2
- package/dist/server/config.js +27 -0
- package/dist/server/harper.js +205 -8
- package/dist/server/history.js +53 -3
- package/dist/server/i18n.js +8 -0
- 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 +106 -0
- package/dist/server/latexSpellMask.js +196 -43
- package/dist/server/pm2.js +3 -2
- package/dist/server/projectReferences.js +109 -0
- package/dist/server/routes/auth.js +5 -2
- package/dist/server/routes/comments.js +13 -1
- package/dist/server/routes/compile.js +2 -2
- package/dist/server/routes/projectFiles.js +12 -3
- package/dist/server/routes/projectHistory.js +13 -1
- package/dist/server/routes/projectReferences.js +36 -0
- package/dist/server/routes/projects.js +41 -4
- 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/latexCommandSnippets.js +91 -0
- package/dist/shared/latexDependencies.js +71 -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/Dashboard-CWfSLzD3.js +0 -2
- package/dist/client/assets/GitDialog-BUU2U_LV.js +0 -3
- package/dist/client/assets/HistoryDialog-B6aopsrw.js +0 -7
- package/dist/client/assets/LatexEditor-CM445Tk0.js +0 -65
- package/dist/client/assets/ProjectWorkspace-C6GrvKN6.js +0 -27
- package/dist/client/assets/SelectionHistoryDialog-CyE0_YDs.js +0 -3
- package/dist/client/assets/TagManagementDialog-gRLM3lLx.js +0 -1
- package/dist/client/assets/check-BXvzrdoz.js +0 -1
- package/dist/client/assets/chevron-left-BB5MYb7g.js +0 -1
- package/dist/client/assets/editor-CiK7TK0p.js +0 -14
- package/dist/client/assets/index-C0t11tlT.js +0 -14
- package/dist/client/assets/index-DXWp2bzM.css +0 -1
- package/dist/client/assets/katex-CEK31ho9.css +0 -1
- package/dist/client/assets/minimize-2-BWtbag2I.js +0 -1
- package/dist/client/assets/spellCheck-BJjWgnef.js +0 -1
package/dist/server/history.js
CHANGED
|
@@ -193,6 +193,23 @@ export class ProjectHistoryService {
|
|
|
193
193
|
.get(versionId, projectId);
|
|
194
194
|
return row ? parseManifest(row.manifest_json) : null;
|
|
195
195
|
}
|
|
196
|
+
/**
|
|
197
|
+
* A short autosave window deliberately coalesces updates into one row. A
|
|
198
|
+
* client that has been inspecting that row must not restore newer contents
|
|
199
|
+
* it has not reviewed, so verify the manifest fingerprint immediately
|
|
200
|
+
* before a restore mutates the project.
|
|
201
|
+
*/
|
|
202
|
+
assertSnapshotHash(projectId, versionId, expectedHash) {
|
|
203
|
+
if (!expectedHash)
|
|
204
|
+
return;
|
|
205
|
+
const row = this.db.prepare("SELECT manifest_json FROM project_history_versions WHERE id = ? AND project_id = ?")
|
|
206
|
+
.get(versionId, projectId);
|
|
207
|
+
if (!row)
|
|
208
|
+
throw httpError(404, "HISTORY_VERSION_NOT_FOUND");
|
|
209
|
+
if (historySnapshotHash(row.manifest_json) !== expectedHash) {
|
|
210
|
+
throw httpError(409, "HISTORY_VERSION_CHANGED");
|
|
211
|
+
}
|
|
212
|
+
}
|
|
196
213
|
/**
|
|
197
214
|
* Validate a single-file restore before the project enters maintenance.
|
|
198
215
|
* restore() repeats the target check while the exclusive operation is held
|
|
@@ -229,7 +246,7 @@ export class ProjectHistoryService {
|
|
|
229
246
|
const entry = this.manifest(projectId, versionId)?.files[filePath];
|
|
230
247
|
if (!entry || entry.size > MAX_TEXT_PREVIEW_BYTES)
|
|
231
248
|
return null;
|
|
232
|
-
return
|
|
249
|
+
return this.readStoredObject(projectId, entry.digest, filePath);
|
|
233
250
|
}
|
|
234
251
|
restore(projectId, versionId, filePathInput) {
|
|
235
252
|
const manifest = this.manifest(projectId, versionId);
|
|
@@ -245,7 +262,7 @@ export class ProjectHistoryService {
|
|
|
245
262
|
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
246
263
|
const temporary = `${target}.history-${randomUUID()}.tmp`;
|
|
247
264
|
try {
|
|
248
|
-
|
|
265
|
+
this.copyStoredObject(projectId, entry.digest, filePath, temporary);
|
|
249
266
|
fs.chmodSync(temporary, 0o600);
|
|
250
267
|
fs.renameSync(temporary, target);
|
|
251
268
|
}
|
|
@@ -340,6 +357,35 @@ export class ProjectHistoryService {
|
|
|
340
357
|
throw new Error("Invalid history object digest");
|
|
341
358
|
return path.join(outputRoot(this.config, projectId), ".texlite", "history", "objects", digest.slice(0, 2), digest);
|
|
342
359
|
}
|
|
360
|
+
/**
|
|
361
|
+
* A history manifest can outlive a damaged or manually removed object. Keep
|
|
362
|
+
* that operational failure distinct from an absent path in the snapshot so
|
|
363
|
+
* the UI can tell the user why viewing or restoring it is impossible.
|
|
364
|
+
*/
|
|
365
|
+
readStoredObject(projectId, digest, filePath) {
|
|
366
|
+
try {
|
|
367
|
+
return fs.readFileSync(this.objectPath(projectId, digest), "utf8");
|
|
368
|
+
}
|
|
369
|
+
catch (error) {
|
|
370
|
+
this.rethrowHistoryObjectError(error, filePath);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
copyStoredObject(projectId, digest, filePath, target) {
|
|
374
|
+
try {
|
|
375
|
+
fs.copyFileSync(this.objectPath(projectId, digest), target);
|
|
376
|
+
}
|
|
377
|
+
catch (error) {
|
|
378
|
+
this.rethrowHistoryObjectError(error, filePath);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
rethrowHistoryObjectError(error, filePath) {
|
|
382
|
+
const code = typeof error === "object" && error !== null && "code" in error && typeof error.code === "string"
|
|
383
|
+
? error.code : "";
|
|
384
|
+
if (code === "ENOENT" || code === "ENOTDIR" || code === "EISDIR") {
|
|
385
|
+
throw httpError(410, "HISTORY_OBJECT_MISSING", { path: filePath });
|
|
386
|
+
}
|
|
387
|
+
throw error;
|
|
388
|
+
}
|
|
343
389
|
storedObjectDigests(projectId) {
|
|
344
390
|
const objectsRoot = path.join(outputRoot(this.config, projectId), ".texlite", "history", "objects");
|
|
345
391
|
if (!fs.existsSync(objectsRoot))
|
|
@@ -493,7 +539,7 @@ export class ProjectHistoryService {
|
|
|
493
539
|
for (const [filePath, entry] of Object.entries(manifest.files)) {
|
|
494
540
|
const target = path.join(temporary, safeRelativePath(filePath));
|
|
495
541
|
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
496
|
-
|
|
542
|
+
this.copyStoredObject(projectId, entry.digest, filePath, target);
|
|
497
543
|
fs.chmodSync(target, 0o600);
|
|
498
544
|
}
|
|
499
545
|
fs.renameSync(live, backup);
|
|
@@ -581,6 +627,7 @@ function versionJson(row) {
|
|
|
581
627
|
const files = Object.values(manifest.files);
|
|
582
628
|
return {
|
|
583
629
|
id: row.id,
|
|
630
|
+
snapshotHash: historySnapshotHash(row.manifest_json),
|
|
584
631
|
reason: row.reason,
|
|
585
632
|
label: row.label,
|
|
586
633
|
createdAt: row.created_at,
|
|
@@ -594,3 +641,6 @@ function versionJson(row) {
|
|
|
594
641
|
totalSize: files.reduce((sum, file) => sum + file.size, 0)
|
|
595
642
|
};
|
|
596
643
|
}
|
|
644
|
+
function historySnapshotHash(manifestJson) {
|
|
645
|
+
return createHash("sha256").update(manifestJson, "utf8").digest("hex");
|
|
646
|
+
}
|
package/dist/server/i18n.js
CHANGED
|
@@ -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,11 @@ 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.",
|
|
51
54
|
SELECTION_HISTORY_SOURCE_CHANGED: "The source changed while opening selection history. Wait for synchronization, then select the passage and open its history again.",
|
|
52
55
|
INVALID_PATH: "The file path is invalid.",
|
|
53
56
|
LAST_ADMIN: "At least one active administrator must remain.",
|
|
@@ -89,6 +92,7 @@ const enMessages = {
|
|
|
89
92
|
SOURCE_FLUSH_FAILED: "Some source files could not be saved. The project operation was cancelled.",
|
|
90
93
|
SPELLCHECK_SOURCE_INVALID: "The source to check is invalid.",
|
|
91
94
|
SPELLCHECK_SOURCE_TOO_LARGE: "The source to check is too large.",
|
|
95
|
+
SPELLCHECK_SUPERSEDED: "A newer writing check replaced this request.",
|
|
92
96
|
SYNCTEX_EXTERNAL_PATH: "SyncTeX returned a source path outside the project.",
|
|
93
97
|
SYNCTEX_NOT_AVAILABLE: "No SyncTeX data is available. Compile the project again.",
|
|
94
98
|
SYMLINK_FORBIDDEN: "Symbolic links are not allowed in project sources.",
|
|
@@ -144,6 +148,7 @@ const zhMessages = {
|
|
|
144
148
|
COMMENT_FILE_NOT_FOUND: "批注对应的文件不存在。",
|
|
145
149
|
COMMENT_NOT_FOUND: "批注不存在。",
|
|
146
150
|
COMMENT_RANGE_INVALID: "批注的源码区间无效。",
|
|
151
|
+
COMMENT_SOURCE_CHANGED: "准备批注时源码已发生变化。请重新选择文本后再试。",
|
|
147
152
|
COMMENT_UPDATE_EMPTY: "没有可保存的批注修改。",
|
|
148
153
|
COMPILE_ARTIFACT_NOT_FOUND: "编译产物不存在。",
|
|
149
154
|
COMPILE_ARTIFACTS_NOT_FOUND: "尚无可用的编译产物。",
|
|
@@ -173,9 +178,11 @@ const zhMessages = {
|
|
|
173
178
|
GIT_UNAVAILABLE: "服务器未安装 Git。",
|
|
174
179
|
HARPER_UNAVAILABLE: "Harper 拼写与语法检查暂不可用。",
|
|
175
180
|
HISTORY_FILE_NOT_FOUND: "历史文件不存在。",
|
|
181
|
+
HISTORY_OBJECT_MISSING: "该快照的已存储文件缺失,无法再查看或恢复。",
|
|
176
182
|
HISTORY_FILE_PREVIEW_UNSUPPORTED: "该历史文件不能作为文本比较。",
|
|
177
183
|
HISTORY_TARGET_CONFLICT: "无法将历史版本恢复到所请求的路径。",
|
|
178
184
|
HISTORY_VERSION_NOT_FOUND: "历史版本不存在。",
|
|
185
|
+
HISTORY_VERSION_CHANGED: "查看期间该项目快照已发生变化。请刷新后再恢复。",
|
|
179
186
|
SELECTION_HISTORY_SOURCE_CHANGED: "打开选区历史时源码已发生变化。请等待同步完成,重新选择文本后再打开历史。",
|
|
180
187
|
INVALID_PATH: "文件路径无效。",
|
|
181
188
|
LAST_ADMIN: "系统必须保留至少一名可用管理员。",
|
|
@@ -217,6 +224,7 @@ const zhMessages = {
|
|
|
217
224
|
SOURCE_FLUSH_FAILED: "部分源码未能保存至磁盘,项目操作已取消。",
|
|
218
225
|
SPELLCHECK_SOURCE_INVALID: "待检查源码格式不正确。",
|
|
219
226
|
SPELLCHECK_SOURCE_TOO_LARGE: "待检查源码过大。",
|
|
227
|
+
SPELLCHECK_SUPERSEDED: "更新的写作检查已替代此请求。",
|
|
220
228
|
SYNCTEX_EXTERNAL_PATH: "SyncTeX 返回了项目外部的源码路径。",
|
|
221
229
|
SYNCTEX_NOT_AVAILABLE: "尚无可用的 SyncTeX 数据,请重新编译。",
|
|
222
230
|
SYMLINK_FORBIDDEN: "项目源码中不允许使用符号链接。",
|
package/dist/server/index.js
CHANGED
|
@@ -6,6 +6,7 @@ import { buildApp } from "./app.js";
|
|
|
6
6
|
import { assertEnvironment } from "./environment.js";
|
|
7
7
|
import { acquireDataDirectoryLock } from "./instanceLock.js";
|
|
8
8
|
import { LogRotation } from "./logRotation.js";
|
|
9
|
+
import { basePathHref } from "../shared/basePath.js";
|
|
9
10
|
export async function startServer(configPath) {
|
|
10
11
|
const config = loadConfig(configPath);
|
|
11
12
|
const lock = acquireDataDirectoryLock(config);
|
|
@@ -23,11 +24,12 @@ export async function startServer(configPath) {
|
|
|
23
24
|
app = await buildApp(config, db);
|
|
24
25
|
await app.listen({ host: config.host, port: config.port });
|
|
25
26
|
app.log.info(`Environment ready: ${environment.map((item) => `${item.name} ${item.version}`).join(", ")}`);
|
|
26
|
-
|
|
27
|
+
const address = `http://${config.host}:${config.port}${basePathHref(config.basePath)}`;
|
|
28
|
+
app.log.info(`${config.siteName} running at ${address}`);
|
|
27
29
|
if (typeof process.send === "function")
|
|
28
30
|
process.send("ready");
|
|
29
31
|
return {
|
|
30
|
-
address
|
|
32
|
+
address,
|
|
31
33
|
close: async () => {
|
|
32
34
|
try {
|
|
33
35
|
if (app)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { basePathHref } from "../shared/basePath.js";
|
|
4
5
|
export const LOCK_FILE_NAME = ".texlite.lock";
|
|
5
6
|
export function isProcessAlive(pid) {
|
|
6
7
|
if (!Number.isInteger(pid) || pid <= 0)
|
|
@@ -45,7 +46,8 @@ export function acquireDataDirectoryLock(config) {
|
|
|
45
46
|
token: randomUUID(),
|
|
46
47
|
configPath: config.configPath,
|
|
47
48
|
host: config.host,
|
|
48
|
-
port: config.port
|
|
49
|
+
port: config.port,
|
|
50
|
+
basePath: config.basePath
|
|
49
51
|
};
|
|
50
52
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
51
53
|
try {
|
|
@@ -103,7 +105,7 @@ export function acquireDataDirectoryLock(config) {
|
|
|
103
105
|
throw new Error(`The data directory is already locked by another TexLite instance:\n` +
|
|
104
106
|
` - PID: ${existing.pid}\n` +
|
|
105
107
|
` - Config: ${existing.configPath}\n` +
|
|
106
|
-
` - Address: http://${existing.host}:${existing.port}\n` +
|
|
108
|
+
` - Address: http://${existing.host}:${existing.port}${basePathHref(existing.basePath ?? "/")}\n` +
|
|
107
109
|
` - Started: ${existing.startedAt}\n` +
|
|
108
110
|
` - Data directory: ${config.dataDir}\n` +
|
|
109
111
|
`Do not run multiple TexLite instances with the same data directory.`);
|
|
@@ -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,106 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { findLatexBibliographyFiles, findLatexSourceIncludes } from "../shared/latexReferences.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((reference) => reference.path),
|
|
11
|
+
bibliographies: findLatexBibliographyFiles(source).map((reference) => reference.path)
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
/** Return the selected root followed by its reachable \input-style sources. */
|
|
15
|
+
export function documentSourceOrder(sources, mainFile) {
|
|
16
|
+
if (!mainFile || !sources.has(mainFile))
|
|
17
|
+
return [];
|
|
18
|
+
const result = [];
|
|
19
|
+
const queued = [mainFile];
|
|
20
|
+
const seen = new Set();
|
|
21
|
+
while (queued.length) {
|
|
22
|
+
const current = queued.shift();
|
|
23
|
+
if (!current || seen.has(current))
|
|
24
|
+
continue;
|
|
25
|
+
const directives = sources.get(current);
|
|
26
|
+
if (!directives)
|
|
27
|
+
continue;
|
|
28
|
+
seen.add(current);
|
|
29
|
+
result.push(current);
|
|
30
|
+
for (const include of directives.includes) {
|
|
31
|
+
const resolved = resolveProjectReferencePath(include, current, ".tex", sources, false);
|
|
32
|
+
if (resolved && !seen.has(resolved))
|
|
33
|
+
queued.push(resolved);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return result;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Return exactly the BibTeX resources declared by a selected root and its
|
|
40
|
+
* input graph. `null` means the selected root was unavailable to the graph
|
|
41
|
+
* scanner, so callers can retain a backwards-compatible fallback.
|
|
42
|
+
*/
|
|
43
|
+
export function declaredBibliographyPaths(sources, mainFile, bibPaths) {
|
|
44
|
+
if (!mainFile || !sources.has(mainFile))
|
|
45
|
+
return null;
|
|
46
|
+
const availableBibliographies = new Set(bibPaths);
|
|
47
|
+
const declared = [];
|
|
48
|
+
for (const sourcePath of documentSourceOrder(sources, mainFile)) {
|
|
49
|
+
const directives = sources.get(sourcePath);
|
|
50
|
+
if (!directives)
|
|
51
|
+
continue;
|
|
52
|
+
for (const bibliography of directives.bibliographies) {
|
|
53
|
+
const resolved = resolveProjectReferencePath(bibliography, sourcePath, ".bib", availableBibliographies, true);
|
|
54
|
+
if (resolved)
|
|
55
|
+
declared.push(resolved);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return uniqueProjectPaths(declared);
|
|
59
|
+
}
|
|
60
|
+
export function resolveProjectReferencePath(rawPath, currentPath, extension, available, rootFirst) {
|
|
61
|
+
const raw = normalizeReferenceInput(rawPath);
|
|
62
|
+
if (!raw)
|
|
63
|
+
return null;
|
|
64
|
+
const parent = path.posix.dirname(currentPath);
|
|
65
|
+
// TeX resolves relative files against the file that declared them. A raw
|
|
66
|
+
// ../ reference is valid when this final candidate remains inside the
|
|
67
|
+
// project tree (for example chapters/intro.tex -> ../refs.bib).
|
|
68
|
+
const relative = normalizeProjectPath(parent === "." ? raw : path.posix.join(parent, raw));
|
|
69
|
+
const root = normalizeProjectPath(raw);
|
|
70
|
+
const roots = rootFirst ? [root, relative] : [relative, root];
|
|
71
|
+
const candidates = roots.flatMap((candidate) => {
|
|
72
|
+
if (!candidate)
|
|
73
|
+
return [];
|
|
74
|
+
return candidate.toLowerCase().endsWith(extension)
|
|
75
|
+
? [candidate]
|
|
76
|
+
: [candidate, candidate + extension];
|
|
77
|
+
});
|
|
78
|
+
for (const candidate of uniqueProjectPaths(candidates)) {
|
|
79
|
+
if (available.has(candidate))
|
|
80
|
+
return candidate;
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
export function uniqueProjectPaths(paths) {
|
|
85
|
+
const seen = new Set();
|
|
86
|
+
return paths.filter((entryPath) => {
|
|
87
|
+
if (!entryPath || seen.has(entryPath))
|
|
88
|
+
return false;
|
|
89
|
+
seen.add(entryPath);
|
|
90
|
+
return true;
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
function normalizeReferenceInput(value) {
|
|
94
|
+
const trimmed = value.trim().replaceAll("\\", "/");
|
|
95
|
+
if (!trimmed || trimmed.startsWith("/") || /^[A-Za-z]:\//.test(trimmed) || trimmed.includes("\0"))
|
|
96
|
+
return null;
|
|
97
|
+
return trimmed;
|
|
98
|
+
}
|
|
99
|
+
function normalizeProjectPath(value) {
|
|
100
|
+
const normalized = path.posix.normalize(value);
|
|
101
|
+
if (normalized === "." || normalized === ".." || normalized.startsWith("../"))
|
|
102
|
+
return null;
|
|
103
|
+
if (normalized.split("/").some((segment) => segment.toLowerCase() === ".git"))
|
|
104
|
+
return null;
|
|
105
|
+
return normalized;
|
|
106
|
+
}
|