texlite 0.9.1 → 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-Dazar9cj.js → AdminUsers-Buf5ZclR.js} +1 -1
- package/dist/client/assets/{CitationLibraryDialog-VSUiSPgG.js → CitationLibraryDialog-DrKbIlD-.js} +1 -1
- package/dist/client/assets/Dashboard-DLk2j3oq.js +2 -0
- package/dist/client/assets/{GitDialog-C9Loz4Al.js → GitDialog-CTWAU8q6.js} +1 -1
- package/dist/client/assets/{HistoryDialog-3UNN_jhU.js → HistoryDialog-Mo4p8HEg.js} +1 -1
- package/dist/client/assets/LatexEditor-CwvakYva.js +135 -0
- package/dist/client/assets/{PdfPreview-BKhcS-2c.js → PdfPreview-BUpELZd-.js} +1 -1
- package/dist/client/assets/{ProjectIconPickerDialog-BoWMjQVm.js → ProjectIconPickerDialog-BiWSwDdt.js} +1 -1
- package/dist/client/assets/{ProjectNavigationDialogs-CZKTnxrl.js → ProjectNavigationDialogs-CpjkXZTz.js} +1 -1
- package/dist/client/assets/{ProjectSettings-DiOQVJ40.js → ProjectSettings-DoxitHbX.js} +1 -1
- package/dist/client/assets/ProjectWorkspace-iNqXZlsj.js +29 -0
- package/dist/client/assets/{SelectionHistoryDialog-D3dO83lg.js → SelectionHistoryDialog-9FWlc3Rb.js} +1 -1
- package/dist/client/assets/{SystemMetricsDialog-0zCwLtH_.js → SystemMetricsDialog-24kifZO6.js} +1 -1
- package/dist/client/assets/TagManagementDialog-BHIA0vEA.js +1 -0
- package/dist/client/assets/{WordCountDialog-TwpyAP3m.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-XHSiS_A7.js → hard-drive-B-NSDVVA.js} +1 -1
- package/dist/client/assets/{index-Cd6-F29b.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-C-CXCWnz.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-BMDPVaF-.js → minimize-2-DDCq8c49.js} +1 -1
- package/dist/client/assets/{minus-BIa7-KuM.js → minus-DgKZCT9F.js} +1 -1
- package/dist/client/assets/{pdf-BB1XvBIF.js → pdf-DUYcbPMK.js} +1 -1
- package/dist/client/assets/{plus-BKDWy4Ui.js → plus-CezUzSIZ.js} +1 -1
- package/dist/client/assets/{refresh-cw-Bo7-a7-I.js → refresh-cw-CDFNL-qu.js} +1 -1
- package/dist/client/assets/{search-Bz9Ob5mT.js → search-CrDJjHoj.js} +1 -1
- package/dist/client/assets/{sigma-DxlHf9gv.js → sigma-DOcJr7VG.js} +1 -1
- package/dist/client/assets/spellCheck-BgWyesvf.js +1 -0
- package/dist/client/assets/{tags-IMZehjzE.js → tags-BedQ75lh.js} +1 -1
- package/dist/client/assets/{type-DTWpFQNQ.js → type-glZNdzZR.js} +1 -1
- package/dist/client/assets/{user-plus--aVpd8K6.js → user-plus-gQ6q5Nom.js} +1 -1
- package/dist/client/assets/{x-Tbo3aA0J.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/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 +3 -4
- 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/compile.js +2 -2
- package/dist/server/routes/projectFiles.js +12 -3
- package/dist/server/routes/projectReferences.js +36 -0
- 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/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-CoE5ESfb.js +0 -2
- package/dist/client/assets/LatexEditor-Bq76v8sO.js +0 -65
- package/dist/client/assets/ProjectWorkspace-DqLwkGpT.js +0 -27
- 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
|
@@ -149,14 +149,23 @@ export function registerProjectFileRoutes(app, context) {
|
|
|
149
149
|
if (!user)
|
|
150
150
|
return;
|
|
151
151
|
const { id } = request.params;
|
|
152
|
-
|
|
152
|
+
const project = accessibleProject(db, id, user);
|
|
153
|
+
if (!project)
|
|
153
154
|
return apiError(reply, 404, "PROJECT_NOT_FOUND");
|
|
155
|
+
const query = request.query;
|
|
156
|
+
const mainFile = compileMainFile(config, id, project.main_file, query.mainFile);
|
|
157
|
+
if (!mainFile)
|
|
158
|
+
return apiError(reply, 400, "MAIN_DOCUMENT_INVALID");
|
|
154
159
|
const startedAt = performance.now();
|
|
155
160
|
try {
|
|
156
|
-
return await projectMutations.runConsistentRead(id, async () => ({ index: await latexCompletions.build(id) }), {
|
|
161
|
+
return await projectMutations.runConsistentRead(id, async () => ({ index: await latexCompletions.build(id, mainFile) }), {
|
|
157
162
|
preflight: () => {
|
|
158
|
-
|
|
163
|
+
const current = accessibleProject(db, id, user);
|
|
164
|
+
if (!current)
|
|
159
165
|
throw httpError(404, "PROJECT_NOT_FOUND");
|
|
166
|
+
const selected = compileMainFile(config, id, current.main_file, query.mainFile);
|
|
167
|
+
if (!selected || selected !== mainFile)
|
|
168
|
+
throw httpError(400, "MAIN_DOCUMENT_INVALID");
|
|
160
169
|
}
|
|
161
170
|
});
|
|
162
171
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { requireUser } from "../auth.js";
|
|
2
|
+
import { safeRelativePath } from "../files.js";
|
|
3
|
+
import { apiError, httpError } from "../http.js";
|
|
4
|
+
import { resolveProjectReference, validReferenceKey } from "../projectReferences.js";
|
|
5
|
+
import { accessibleProject } from "../projects.js";
|
|
6
|
+
/** Register Ctrl/Cmd-click source-reference navigation routes. */
|
|
7
|
+
export function registerProjectReferenceRoutes(app, context) {
|
|
8
|
+
const { config, db, projectMutations } = context;
|
|
9
|
+
app.get("/api/projects/:id/references/resolve", async (request, reply) => {
|
|
10
|
+
const user = requireUser(request, reply, db);
|
|
11
|
+
if (!user)
|
|
12
|
+
return;
|
|
13
|
+
const { id } = request.params;
|
|
14
|
+
const project = accessibleProject(db, id, user);
|
|
15
|
+
if (!project)
|
|
16
|
+
return apiError(reply, 404, "PROJECT_NOT_FOUND");
|
|
17
|
+
const query = request.query;
|
|
18
|
+
const kind = query.kind === "citation" || query.kind === "label" ? query.kind : null;
|
|
19
|
+
if (!kind || !validReferenceKey(query.key))
|
|
20
|
+
return apiError(reply, 400, "REQUEST_INVALID");
|
|
21
|
+
const key = query.key;
|
|
22
|
+
const preferredPath = typeof query.path === "string" && query.path ? safeRelativePath(query.path) : "";
|
|
23
|
+
const requestedMainFile = typeof query.mainFile === "string" && query.mainFile
|
|
24
|
+
? safeRelativePath(query.mainFile)
|
|
25
|
+
: "";
|
|
26
|
+
const mainFile = /\.tex$/i.test(requestedMainFile) ? requestedMainFile : project.main_file;
|
|
27
|
+
return await projectMutations.runConsistentRead(id, async () => ({
|
|
28
|
+
target: await resolveProjectReference(config, id, kind, key, { preferredPath, mainFile })
|
|
29
|
+
}), {
|
|
30
|
+
preflight: () => {
|
|
31
|
+
if (!accessibleProject(db, id, user))
|
|
32
|
+
throw httpError(404, "PROJECT_NOT_FOUND");
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
}
|
|
@@ -12,6 +12,7 @@ import { writeProjectArchive } from "../archive.js";
|
|
|
12
12
|
import { extractProjectZip, ZipValidationError } from "../zip.js";
|
|
13
13
|
import { HarperLintSupersededError, HarperUnavailableError } from "../harper.js";
|
|
14
14
|
import { digestToken } from "../security.js";
|
|
15
|
+
import { supportsWritingChecks } from "../../shared/writingChecks.js";
|
|
15
16
|
import { unreadMentionCountsForProjects } from "../commentMentions.js";
|
|
16
17
|
import { commentsSummaryForProject, commentsSummaryForProjects, dictionaryWord, escapeLikePattern, now, projectJson, requireProjectOwnerPermission, tagColors, tagsForProject, tagsForProjects, text } from "./projectShared.js";
|
|
17
18
|
const clientIdPattern = /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i;
|
|
@@ -390,6 +391,10 @@ export function registerProjectCatalogRoutes(app, context) {
|
|
|
390
391
|
catch {
|
|
391
392
|
return apiError(reply, 400, "SPELLCHECK_SOURCE_INVALID");
|
|
392
393
|
}
|
|
394
|
+
// Bibliography files are structured reference data rather than prose.
|
|
395
|
+
// Keep this server-side guard for legacy clients and direct API callers.
|
|
396
|
+
if (!supportsWritingChecks(filePath))
|
|
397
|
+
return { lints: [] };
|
|
393
398
|
// A login session is shared by browser tabs. Pair the authenticated user
|
|
394
399
|
// with the page-local client ID so each new editor can replace only its
|
|
395
400
|
// own obsolete waiting work. Legacy clients retain their former session
|
|
@@ -8,6 +8,7 @@ export function registerSystemRoutes(app, context) {
|
|
|
8
8
|
app.get("/api/config", async () => ({
|
|
9
9
|
siteName: config.siteName,
|
|
10
10
|
adminEmail: config.adminEmail,
|
|
11
|
+
basePath: config.basePath,
|
|
11
12
|
minPasswordLength: MIN_PASSWORD_LENGTH,
|
|
12
13
|
maxCitationBibtexBytes: MAX_CITATION_BIBTEX_BYTES,
|
|
13
14
|
maxUploadSizeMB: Math.floor(config.maxUploadBytes / 1024 / 1024),
|
package/dist/server/zip.js
CHANGED
|
@@ -3,6 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import { Transform } from "node:stream";
|
|
4
4
|
import { pipeline } from "node:stream/promises";
|
|
5
5
|
import yauzl from "yauzl";
|
|
6
|
+
import { hasLatexDocumentClass } from "../shared/latexRoot.js";
|
|
6
7
|
import { assertNoSymbolicLinks, safeRelativePath } from "./files.js";
|
|
7
8
|
const MAX_ENTRIES = 1_000;
|
|
8
9
|
const MAX_TOTAL_BYTES = 200 * 1024 * 1024;
|
|
@@ -180,22 +181,7 @@ function discoverMainFile(root, files) {
|
|
|
180
181
|
}) ?? "";
|
|
181
182
|
}
|
|
182
183
|
export function hasDocumentClass(source) {
|
|
183
|
-
|
|
184
|
-
for (let index = 0; index < line.length; index += 1) {
|
|
185
|
-
if (line[index] !== "%")
|
|
186
|
-
continue;
|
|
187
|
-
let slashes = 0;
|
|
188
|
-
for (let cursor = index - 1; cursor >= 0 && line[cursor] === "\\"; cursor -= 1)
|
|
189
|
-
slashes += 1;
|
|
190
|
-
if (slashes % 2 === 0)
|
|
191
|
-
return `${line.slice(0, index)}${line.endsWith("\n") ? "\n" : ""}`;
|
|
192
|
-
}
|
|
193
|
-
return line;
|
|
194
|
-
}).join("");
|
|
195
|
-
const withoutVerbatim = withoutComments
|
|
196
|
-
.replace(/\\verb\*?([^\s]).*?\1/g, "")
|
|
197
|
-
.replace(/\\begin\{(?:verbatim\*?|Verbatim|lstlisting|minted)\}(?:\[[^\]]*\])?[\s\S]*?\\end\{(?:verbatim\*?|Verbatim|lstlisting|minted)\}/g, "");
|
|
198
|
-
return /\\documentclass\s*(?:\[[^\]]*\]\s*)?\{/.test(withoutVerbatim);
|
|
184
|
+
return hasLatexDocumentClass(source);
|
|
199
185
|
}
|
|
200
186
|
function formatMB(bytes) {
|
|
201
187
|
return Math.floor(bytes / 1024 / 1024);
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export const ROOT_BASE_PATH = "/";
|
|
2
|
+
/**
|
|
3
|
+
* Normalize a configured URL path prefix. Root is represented as `/`; every
|
|
4
|
+
* other value starts with one slash and has no trailing slash.
|
|
5
|
+
*/
|
|
6
|
+
export function normalizeBasePath(value) {
|
|
7
|
+
const trimmed = value.trim();
|
|
8
|
+
if (trimmed === ROOT_BASE_PATH)
|
|
9
|
+
return ROOT_BASE_PATH;
|
|
10
|
+
const normalized = trimmed.replace(/\/+$/, "");
|
|
11
|
+
if (!normalized.startsWith("/") || normalized.includes("//"))
|
|
12
|
+
return null;
|
|
13
|
+
const segments = normalized.slice(1).split("/");
|
|
14
|
+
if (!segments.length || segments.some((segment) => segment === "." || segment === ".." || !/^[A-Za-z0-9._~-]+$/.test(segment)))
|
|
15
|
+
return null;
|
|
16
|
+
return normalized;
|
|
17
|
+
}
|
|
18
|
+
/** Return the prefix used when registering or composing a path. */
|
|
19
|
+
export function basePathPrefix(basePath) {
|
|
20
|
+
return basePath === ROOT_BASE_PATH ? "" : basePath;
|
|
21
|
+
}
|
|
22
|
+
/** Prefix an origin-local absolute path with the configured application path. */
|
|
23
|
+
export function withBasePath(basePath, pathname) {
|
|
24
|
+
if (!pathname.startsWith("/"))
|
|
25
|
+
throw new Error(`Application path must start with /: ${pathname}`);
|
|
26
|
+
const prefix = basePathPrefix(basePath);
|
|
27
|
+
if (!prefix)
|
|
28
|
+
return pathname;
|
|
29
|
+
return pathname === "/" ? `${prefix}/` : `${prefix}${pathname}`;
|
|
30
|
+
}
|
|
31
|
+
/** Remove the configured prefix, or return null when a path belongs elsewhere. */
|
|
32
|
+
export function withoutBasePath(basePath, pathname) {
|
|
33
|
+
const prefix = basePathPrefix(basePath);
|
|
34
|
+
if (!prefix)
|
|
35
|
+
return pathname;
|
|
36
|
+
if (pathname === prefix || pathname === `${prefix}/`)
|
|
37
|
+
return "/";
|
|
38
|
+
if (!pathname.startsWith(`${prefix}/`))
|
|
39
|
+
return null;
|
|
40
|
+
return pathname.slice(prefix.length) || "/";
|
|
41
|
+
}
|
|
42
|
+
export function basePathHref(basePath) {
|
|
43
|
+
return basePath === ROOT_BASE_PATH ? ROOT_BASE_PATH : `${basePath}/`;
|
|
44
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight BibTeX entry-key scanner for server-side completion and source
|
|
3
|
+
* navigation. The browser editor uses the full Lezer grammar; this companion
|
|
4
|
+
* deliberately only finds complete entry boundaries and keys, so server code
|
|
5
|
+
* does not need to load a browser-oriented CodeMirror parser.
|
|
6
|
+
*/
|
|
7
|
+
import { isLatexCommentStart, skipLatexComment, skipLatexTrivia } from "./latexScanner.js";
|
|
8
|
+
function readBibtexBlock(source, start) {
|
|
9
|
+
const opening = source[start];
|
|
10
|
+
const closing = opening === "{" ? "}" : ")";
|
|
11
|
+
if (opening !== "{" && opening !== "(")
|
|
12
|
+
return null;
|
|
13
|
+
// Braced and parenthesized BibTeX entries have different nesting rules. A
|
|
14
|
+
// quote is ordinary content inside a braced entry, while a parenthesized
|
|
15
|
+
// entry must protect its closing paren from braced and quoted values.
|
|
16
|
+
let depth = 0;
|
|
17
|
+
let braceDepth = 0;
|
|
18
|
+
let quoted = false;
|
|
19
|
+
for (let index = start; index < source.length; index += 1) {
|
|
20
|
+
const character = source[index];
|
|
21
|
+
if (character === "\\" && index + 1 < source.length) {
|
|
22
|
+
index += 1;
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (opening === "{") {
|
|
26
|
+
if (character === "{") {
|
|
27
|
+
depth += 1;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (character !== "}")
|
|
31
|
+
continue;
|
|
32
|
+
depth -= 1;
|
|
33
|
+
if (depth === 0) {
|
|
34
|
+
return { from: start, to: index + 1, contentFrom: start + 1, contentTo: index };
|
|
35
|
+
}
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (quoted) {
|
|
39
|
+
if (character === "\"")
|
|
40
|
+
quoted = false;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (braceDepth > 0) {
|
|
44
|
+
if (character === "{")
|
|
45
|
+
braceDepth += 1;
|
|
46
|
+
else if (character === "}")
|
|
47
|
+
braceDepth -= 1;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (character === "{") {
|
|
51
|
+
braceDepth = 1;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (character === "\"") {
|
|
55
|
+
quoted = true;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (character === "(") {
|
|
59
|
+
depth += 1;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (character !== closing)
|
|
63
|
+
continue;
|
|
64
|
+
depth -= 1;
|
|
65
|
+
if (depth === 0) {
|
|
66
|
+
return { from: start, to: index + 1, contentFrom: start + 1, contentTo: index };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
function bibtexKeyRange(source, entry) {
|
|
72
|
+
let from = entry.contentFrom;
|
|
73
|
+
while (from < entry.contentTo && /\s/.test(source[from]))
|
|
74
|
+
from += 1;
|
|
75
|
+
if (from >= entry.contentTo)
|
|
76
|
+
return null;
|
|
77
|
+
let to = from;
|
|
78
|
+
while (to < entry.contentTo && source[to] !== "," && !/\s/.test(source[to]))
|
|
79
|
+
to += 1;
|
|
80
|
+
let trimmedTo = to;
|
|
81
|
+
while (trimmedTo > from && /\s/.test(source[trimmedTo - 1]))
|
|
82
|
+
trimmedTo -= 1;
|
|
83
|
+
if (trimmedTo <= from)
|
|
84
|
+
return null;
|
|
85
|
+
const separator = skipLatexTrivia(source, to);
|
|
86
|
+
if (separator < entry.contentTo && source[separator] !== ",")
|
|
87
|
+
return null;
|
|
88
|
+
return { key: source.slice(from, trimmedTo), from, to: trimmedTo };
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Find ordinary BibTeX/BibLaTeX entry keys. `@string`, `@preamble`, and
|
|
92
|
+
* `@comment` are intentionally skipped because their leading token is not a
|
|
93
|
+
* citeable entry key.
|
|
94
|
+
*/
|
|
95
|
+
export function findBibtexEntryKeys(source) {
|
|
96
|
+
const entries = [];
|
|
97
|
+
let index = 0;
|
|
98
|
+
while (index < source.length) {
|
|
99
|
+
// Percent signs are comments at top level. Once inside an entry a percent
|
|
100
|
+
// may be part of a braced URL (for example `%20`), so the block reader
|
|
101
|
+
// must receive the original source unchanged.
|
|
102
|
+
if (isLatexCommentStart(source, index)) {
|
|
103
|
+
index = skipLatexComment(source, index);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (source[index] !== "@") {
|
|
107
|
+
index += 1;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
let typeEnd = index + 1;
|
|
111
|
+
while (typeEnd < source.length && /[A-Za-z]/.test(source[typeEnd]))
|
|
112
|
+
typeEnd += 1;
|
|
113
|
+
if (typeEnd === index + 1) {
|
|
114
|
+
index += 1;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
let opening = typeEnd;
|
|
118
|
+
while (opening < source.length && /\s/.test(source[opening]))
|
|
119
|
+
opening += 1;
|
|
120
|
+
const block = readBibtexBlock(source, opening);
|
|
121
|
+
if (!block) {
|
|
122
|
+
index = typeEnd;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
const type = source.slice(index + 1, typeEnd).toLowerCase();
|
|
126
|
+
if (type !== "string" && type !== "preamble" && type !== "comment") {
|
|
127
|
+
const key = bibtexKeyRange(source, block);
|
|
128
|
+
if (key)
|
|
129
|
+
entries.push({ ...key, entryFrom: index, entryTo: block.to });
|
|
130
|
+
}
|
|
131
|
+
// Do not scan @-looking text inside an already complete entry.
|
|
132
|
+
index = block.to;
|
|
133
|
+
}
|
|
134
|
+
return entries;
|
|
135
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
function skipWhitespace(source, position) {
|
|
2
|
+
let cursor = position;
|
|
3
|
+
while (/[ \t\r\n]/.test(source[cursor] ?? ""))
|
|
4
|
+
cursor += 1;
|
|
5
|
+
return cursor;
|
|
6
|
+
}
|
|
7
|
+
export function readBalancedLatexArgument(source, start, open = "{", close = "}") {
|
|
8
|
+
const from = skipWhitespace(source, start);
|
|
9
|
+
if (source[from] !== open)
|
|
10
|
+
return null;
|
|
11
|
+
let depth = 1;
|
|
12
|
+
for (let cursor = from + 1; cursor < source.length; cursor += 1) {
|
|
13
|
+
if (source[cursor] === "\\") {
|
|
14
|
+
cursor += 1;
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
if (source[cursor] === open)
|
|
18
|
+
depth += 1;
|
|
19
|
+
else if (source[cursor] === close && --depth === 0) {
|
|
20
|
+
return { from, to: cursor + 1, content: source.slice(from + 1, cursor) };
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
export function commandSnippet(name, arguments_) {
|
|
26
|
+
if (arguments_.length === 0)
|
|
27
|
+
return undefined;
|
|
28
|
+
return name + arguments_.map((argument, index) => {
|
|
29
|
+
const placeholder = `\${${index + 1}}`;
|
|
30
|
+
return argument.optional ? `[${placeholder}]` : `{${placeholder}}`;
|
|
31
|
+
}).join("");
|
|
32
|
+
}
|
|
33
|
+
export function newCommandArguments(argumentCount, hasDefault) {
|
|
34
|
+
if (!Number.isFinite(argumentCount) || argumentCount <= 0)
|
|
35
|
+
return [];
|
|
36
|
+
if (!hasDefault)
|
|
37
|
+
return Array.from({ length: argumentCount }, () => ({ optional: false }));
|
|
38
|
+
return [{ optional: true }, ...Array.from({ length: Math.max(0, argumentCount - 1) }, () => ({ optional: false }))];
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Interpret only xparse forms that have a safe ordinary LaTeX insertion. For
|
|
42
|
+
* delimiter, verbatim, star, and token arguments, omit a snippet rather than
|
|
43
|
+
* generate invalid syntax.
|
|
44
|
+
*/
|
|
45
|
+
export function xparseCommandArguments(specification) {
|
|
46
|
+
const result = [];
|
|
47
|
+
for (let cursor = 0; cursor < specification.length;) {
|
|
48
|
+
const character = specification[cursor];
|
|
49
|
+
if (/[ \t\r\n+\-!]/.test(character)) {
|
|
50
|
+
cursor += 1;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (character === "m") {
|
|
54
|
+
result.push({ optional: false });
|
|
55
|
+
cursor += 1;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (character === "o") {
|
|
59
|
+
result.push({ optional: true });
|
|
60
|
+
cursor += 1;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (character === "O") {
|
|
64
|
+
const defaultValue = readBalancedLatexArgument(specification, cursor + 1);
|
|
65
|
+
if (!defaultValue)
|
|
66
|
+
return null;
|
|
67
|
+
result.push({ optional: true });
|
|
68
|
+
cursor = defaultValue.to;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
return result;
|
|
74
|
+
}
|
|
75
|
+
const xparseDeclaration = /\\(?:NewDocumentCommand|RenewDocumentCommand|ProvideDocumentCommand|DeclareDocumentCommand|DeclareExpandableDocumentCommand|RenewExpandableDocumentCommand|ProvideExpandableDocumentCommand)\b/g;
|
|
76
|
+
export function xparseCommandDefinitions(source) {
|
|
77
|
+
const definitions = [];
|
|
78
|
+
for (const match of source.matchAll(xparseDeclaration)) {
|
|
79
|
+
const target = readBalancedLatexArgument(source, match.index + match[0].length);
|
|
80
|
+
if (!target)
|
|
81
|
+
continue;
|
|
82
|
+
const name = /^\s*\\([A-Za-z@][A-Za-z@0-9:_]*)\s*$/.exec(target.content)?.[1];
|
|
83
|
+
if (!name)
|
|
84
|
+
continue;
|
|
85
|
+
const specification = readBalancedLatexArgument(source, target.to);
|
|
86
|
+
if (!specification)
|
|
87
|
+
continue;
|
|
88
|
+
definitions.push({ name, specification: specification.content });
|
|
89
|
+
}
|
|
90
|
+
return definitions;
|
|
91
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/** Lightweight extraction of source and bibliography dependencies from TeX. */
|
|
2
|
+
import { forEachLatexCommand, readLatexMandatoryArguments } from "./latexScanner.js";
|
|
3
|
+
function pathRanges(source, argument, splitOnComma) {
|
|
4
|
+
const paths = [];
|
|
5
|
+
let segmentStart = argument.contentFrom;
|
|
6
|
+
for (let index = argument.contentFrom; index <= argument.contentTo; index += 1) {
|
|
7
|
+
if (index !== argument.contentTo && (!splitOnComma || source[index] !== ","))
|
|
8
|
+
continue;
|
|
9
|
+
let from = segmentStart;
|
|
10
|
+
let to = index;
|
|
11
|
+
while (from < to && /\s/.test(source[from]))
|
|
12
|
+
from += 1;
|
|
13
|
+
while (to > from && /\s/.test(source[to - 1]))
|
|
14
|
+
to -= 1;
|
|
15
|
+
if (to > from)
|
|
16
|
+
paths.push({ path: source.slice(from, to), command: "", from, to });
|
|
17
|
+
segmentStart = index + 1;
|
|
18
|
+
}
|
|
19
|
+
return paths;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Find source files pulled into a LaTeX document. Paths intentionally remain
|
|
23
|
+
* raw: project-level code is responsible for resolving them safely.
|
|
24
|
+
*/
|
|
25
|
+
export function findLatexSourceIncludes(source) {
|
|
26
|
+
const includes = [];
|
|
27
|
+
forEachLatexCommand(source, (command) => {
|
|
28
|
+
const name = command.name.toLowerCase();
|
|
29
|
+
if (name === "input" || name === "include" || name === "subfile") {
|
|
30
|
+
const argument = readLatexMandatoryArguments(source, command.to, 1)[0];
|
|
31
|
+
if (!argument)
|
|
32
|
+
return;
|
|
33
|
+
for (const include of pathRanges(source, argument, false))
|
|
34
|
+
includes.push({ ...include, command: command.name });
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (name !== "import" && name !== "subimport" && name !== "includefrom" && name !== "inputfrom")
|
|
38
|
+
return;
|
|
39
|
+
const argumentsFound = readLatexMandatoryArguments(source, command.to, 2);
|
|
40
|
+
if (argumentsFound.length !== 2)
|
|
41
|
+
return;
|
|
42
|
+
const directory = source.slice(argumentsFound[0].contentFrom, argumentsFound[0].contentTo).trim();
|
|
43
|
+
const file = source.slice(argumentsFound[1].contentFrom, argumentsFound[1].contentTo).trim();
|
|
44
|
+
if (!directory || !file)
|
|
45
|
+
return;
|
|
46
|
+
const separator = directory.endsWith("/") ? "" : "/";
|
|
47
|
+
includes.push({
|
|
48
|
+
path: directory + separator + file,
|
|
49
|
+
command: command.name,
|
|
50
|
+
from: argumentsFound[1].contentFrom,
|
|
51
|
+
to: argumentsFound[1].contentTo
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
return includes;
|
|
55
|
+
}
|
|
56
|
+
/** Find bibliography resources declared by a LaTeX document. */
|
|
57
|
+
export function findLatexBibliographyFiles(source) {
|
|
58
|
+
const files = [];
|
|
59
|
+
forEachLatexCommand(source, (command) => {
|
|
60
|
+
const name = command.name.toLowerCase();
|
|
61
|
+
if (name !== "bibliography" && name !== "addbibresource" && name !== "addglobalbib" && name !== "addsectionbib")
|
|
62
|
+
return;
|
|
63
|
+
const argument = readLatexMandatoryArguments(source, command.to, 1)[0];
|
|
64
|
+
if (!argument)
|
|
65
|
+
return;
|
|
66
|
+
const splitOnComma = name === "bibliography";
|
|
67
|
+
for (const file of pathRanges(source, argument, splitOnComma))
|
|
68
|
+
files.push({ ...file, command: command.name });
|
|
69
|
+
});
|
|
70
|
+
return files;
|
|
71
|
+
}
|