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
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,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire-level constants and payload shapes shared by the browser and server.
|
|
3
|
+
*
|
|
4
|
+
* These values are part of the persisted collaboration contract. Do not
|
|
5
|
+
* renumber a message or change a payload shape in place: make an intentional
|
|
6
|
+
* protocol-version migration instead.
|
|
7
|
+
*/
|
|
8
|
+
export const CollaborationMessageType = {
|
|
9
|
+
Sync: 0,
|
|
10
|
+
Awareness: 1,
|
|
11
|
+
QueryAwareness: 3,
|
|
12
|
+
Flush: 4,
|
|
13
|
+
Protocol: 5,
|
|
14
|
+
Maintenance: 6,
|
|
15
|
+
Permission: 7,
|
|
16
|
+
// A small, non-Yjs handshake message for ephemeral compile metadata.
|
|
17
|
+
// Source text still uses the normal Yjs sync protocol.
|
|
18
|
+
CompileStates: 8,
|
|
19
|
+
// Ephemeral per-file formatter leases, intentionally outside Yjs state.
|
|
20
|
+
FormatLease: 9
|
|
21
|
+
};
|
|
22
|
+
/** Increment only for an incompatible collaboration wire change. */
|
|
23
|
+
export const COLLABORATION_PROTOCOL_VERSION = 3;
|
|
24
|
+
const compileStatuses = ["queued", "running", "succeeded", "failed", "cleaned"];
|
|
25
|
+
const cleanModes = ["cache", "artifacts"];
|
|
26
|
+
export function isCollaborationCompileStatus(value) {
|
|
27
|
+
return typeof value === "string" && compileStatuses.includes(value);
|
|
28
|
+
}
|
|
29
|
+
export function isCollaborationCleanMode(value) {
|
|
30
|
+
return typeof value === "string" && cleanModes.includes(value);
|
|
31
|
+
}
|
|
@@ -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,131 @@
|
|
|
1
|
+
/** Lightweight extraction of source and bibliography dependencies from TeX. */
|
|
2
|
+
import { forEachLatexCommand, isLatexCommentStart, readLatexMandatoryArguments, skipLatexComment, skipLatexTrivia } from "./latexScanner.js";
|
|
3
|
+
function pathRanges(source, argument, splitOnComma) {
|
|
4
|
+
const paths = [];
|
|
5
|
+
let segmentStart = argument.contentFrom;
|
|
6
|
+
let index = argument.contentFrom;
|
|
7
|
+
while (index <= argument.contentTo) {
|
|
8
|
+
if (index < argument.contentTo && isLatexCommentStart(source, index)) {
|
|
9
|
+
index = skipLatexComment(source, index);
|
|
10
|
+
if (source[index] === "\n")
|
|
11
|
+
index += 1;
|
|
12
|
+
continue;
|
|
13
|
+
}
|
|
14
|
+
if (index !== argument.contentTo && (!splitOnComma || source[index] !== ",")) {
|
|
15
|
+
index += 1;
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
const reference = pathReferenceFromRange(source, segmentStart, index);
|
|
19
|
+
if (reference)
|
|
20
|
+
paths.push(reference);
|
|
21
|
+
segmentStart = index + 1;
|
|
22
|
+
index += 1;
|
|
23
|
+
}
|
|
24
|
+
return paths;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Turn a raw argument segment into a project path while preserving the source
|
|
28
|
+
* range that produced it. Comments disappear in TeX before filename handling,
|
|
29
|
+
* so a comment between path fragments must not become part of the path.
|
|
30
|
+
*/
|
|
31
|
+
function pathReferenceFromRange(source, start, end) {
|
|
32
|
+
const characters = [];
|
|
33
|
+
let index = start;
|
|
34
|
+
while (index < end) {
|
|
35
|
+
if (isLatexCommentStart(source, index)) {
|
|
36
|
+
index = skipLatexComment(source, index);
|
|
37
|
+
if (source[index] === "\n")
|
|
38
|
+
index += 1;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
characters.push({ character: source[index], index });
|
|
42
|
+
index += 1;
|
|
43
|
+
}
|
|
44
|
+
let first = 0;
|
|
45
|
+
let last = characters.length;
|
|
46
|
+
while (first < last && /\s/.test(characters[first].character))
|
|
47
|
+
first += 1;
|
|
48
|
+
while (last > first && /\s/.test(characters[last - 1].character))
|
|
49
|
+
last -= 1;
|
|
50
|
+
if (first === last)
|
|
51
|
+
return null;
|
|
52
|
+
return {
|
|
53
|
+
path: characters.slice(first, last).map((entry) => entry.character).join(""),
|
|
54
|
+
command: "",
|
|
55
|
+
from: characters[first].index,
|
|
56
|
+
to: characters[last - 1].index + 1
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/** Read TeX's common `\\input filename` form without guessing macro expansion. */
|
|
60
|
+
function readUnbracedInputPath(source, start) {
|
|
61
|
+
const from = skipLatexTrivia(source, start);
|
|
62
|
+
if (from >= source.length || /[\\{}\[\]#~]/.test(source[from]))
|
|
63
|
+
return null;
|
|
64
|
+
let to = from;
|
|
65
|
+
while (to < source.length && !/\s/.test(source[to]) && !isLatexCommentStart(source, to)) {
|
|
66
|
+
if (/[\\{}\[\]#~]/.test(source[to]))
|
|
67
|
+
break;
|
|
68
|
+
to += 1;
|
|
69
|
+
}
|
|
70
|
+
if (to === from)
|
|
71
|
+
return null;
|
|
72
|
+
return { path: source.slice(from, to), command: "", from, to };
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Find source files pulled into a LaTeX document. Paths intentionally remain
|
|
76
|
+
* raw: project-level code is responsible for resolving them safely.
|
|
77
|
+
*/
|
|
78
|
+
export function findLatexSourceIncludes(source) {
|
|
79
|
+
const includes = [];
|
|
80
|
+
forEachLatexCommand(source, (command) => {
|
|
81
|
+
const name = command.name.toLowerCase();
|
|
82
|
+
if (name === "input" || name === "include" || name === "subfile") {
|
|
83
|
+
const argument = readLatexMandatoryArguments(source, command.to, 1)[0];
|
|
84
|
+
if (argument) {
|
|
85
|
+
for (const include of pathRanges(source, argument, false))
|
|
86
|
+
includes.push({ ...include, command: command.name });
|
|
87
|
+
}
|
|
88
|
+
else if (name === "input") {
|
|
89
|
+
const include = readUnbracedInputPath(source, command.to);
|
|
90
|
+
if (include)
|
|
91
|
+
includes.push({ ...include, command: command.name });
|
|
92
|
+
}
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (name !== "import" && name !== "subimport" && name !== "includefrom" && name !== "inputfrom"
|
|
96
|
+
&& name !== "subinputfrom" && name !== "subincludefrom")
|
|
97
|
+
return;
|
|
98
|
+
const argumentsFound = readLatexMandatoryArguments(source, command.to, 2);
|
|
99
|
+
if (argumentsFound.length !== 2)
|
|
100
|
+
return;
|
|
101
|
+
const directory = pathRanges(source, argumentsFound[0], false)[0]?.path ?? "";
|
|
102
|
+
const file = pathRanges(source, argumentsFound[1], false)[0];
|
|
103
|
+
if (!file)
|
|
104
|
+
return;
|
|
105
|
+
const separator = directory && !directory.endsWith("/") ? "/" : "";
|
|
106
|
+
includes.push({
|
|
107
|
+
path: directory + separator + file.path,
|
|
108
|
+
command: command.name,
|
|
109
|
+
from: file.from,
|
|
110
|
+
to: file.to,
|
|
111
|
+
directory
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
return includes;
|
|
115
|
+
}
|
|
116
|
+
/** Find bibliography resources declared by a LaTeX document. */
|
|
117
|
+
export function findLatexBibliographyFiles(source) {
|
|
118
|
+
const files = [];
|
|
119
|
+
forEachLatexCommand(source, (command) => {
|
|
120
|
+
const name = command.name.toLowerCase();
|
|
121
|
+
if (name !== "bibliography" && name !== "addbibresource" && name !== "addglobalbib" && name !== "addsectionbib")
|
|
122
|
+
return;
|
|
123
|
+
const argument = readLatexMandatoryArguments(source, command.to, 1)[0];
|
|
124
|
+
if (!argument)
|
|
125
|
+
return;
|
|
126
|
+
const splitOnComma = name === "bibliography";
|
|
127
|
+
for (const file of pathRanges(source, argument, splitOnComma))
|
|
128
|
+
files.push({ ...file, command: command.name });
|
|
129
|
+
});
|
|
130
|
+
return files;
|
|
131
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// Literal TeX syntax used as opaque editor regions. `alltt` does permit a
|
|
2
|
+
// subset of TeX commands, but treating its raw body as opaque prevents its
|
|
3
|
+
// delimiters from leaking into ordinary source highlighting and tooling.
|
|
4
|
+
export const literalEnvironmentNames = [
|
|
5
|
+
"verbatim", "verbatim*", "Verbatim", "BVerbatim", "LVerbatim", "SaveVerbatim", "VerbatimOut",
|
|
6
|
+
"bverbatim", "bverbatim*", "lverbatim", "lverbatim*", "saveverbatim", "saveverbatim*", "verbatimout",
|
|
7
|
+
"verbatimwrite", "lstlisting", "lstlisting*", "minted", "minted*", "tcblisting", "tcblisting*", "pygmented", "alltt",
|
|
8
|
+
"filecontents", "filecontents*", "luacode", "luacodestar", "comment"
|
|
9
|
+
];
|
|
10
|
+
const literalEnvironments = new Set(literalEnvironmentNames);
|
|
11
|
+
export function isLatexLiteralEnvironment(name) {
|
|
12
|
+
return literalEnvironments.has(name);
|
|
13
|
+
}
|
|
14
|
+
function skipOptionalArgument(source, position, end) {
|
|
15
|
+
if (source[position] !== "[")
|
|
16
|
+
return position;
|
|
17
|
+
let depth = 1;
|
|
18
|
+
for (let index = position + 1; index < end; index += 1) {
|
|
19
|
+
if (source[index] === "\\") {
|
|
20
|
+
index += 1;
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (source[index] === "[")
|
|
24
|
+
depth += 1;
|
|
25
|
+
if (source[index] === "]") {
|
|
26
|
+
depth -= 1;
|
|
27
|
+
if (depth === 0)
|
|
28
|
+
return index + 1;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
function skipRequiredArgument(source, position, end) {
|
|
34
|
+
if (source[position] !== "{")
|
|
35
|
+
return null;
|
|
36
|
+
let depth = 1;
|
|
37
|
+
for (let index = position + 1; index < end; index += 1) {
|
|
38
|
+
if (source[index] === "\\") {
|
|
39
|
+
index += 1;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (source[index] === "{")
|
|
43
|
+
depth += 1;
|
|
44
|
+
if (source[index] === "}") {
|
|
45
|
+
depth -= 1;
|
|
46
|
+
if (depth === 0)
|
|
47
|
+
return index + 1;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
function inlineLatexLiteralSpan(source, start, end = source.length) {
|
|
53
|
+
const newline = source.indexOf("\n", start);
|
|
54
|
+
if (newline >= 0)
|
|
55
|
+
end = Math.min(end, newline);
|
|
56
|
+
const command = /^\\(verb\*?|Verb\*?|lstinline\*?|mintinline\*?)(?![A-Za-z@])/.exec(source.slice(start, end));
|
|
57
|
+
if (!command)
|
|
58
|
+
return null;
|
|
59
|
+
const name = command[1].replace(/\*$/, "");
|
|
60
|
+
let position = start + command[0].length;
|
|
61
|
+
if (name !== "verb") {
|
|
62
|
+
const optionalEnd = skipOptionalArgument(source, position, end);
|
|
63
|
+
if (optionalEnd === null)
|
|
64
|
+
return null;
|
|
65
|
+
position = optionalEnd;
|
|
66
|
+
}
|
|
67
|
+
if (name === "mintinline") {
|
|
68
|
+
const languageEnd = skipRequiredArgument(source, position, end);
|
|
69
|
+
if (languageEnd === null)
|
|
70
|
+
return null;
|
|
71
|
+
position = languageEnd;
|
|
72
|
+
}
|
|
73
|
+
const delimiter = source[position];
|
|
74
|
+
if (!delimiter || /\s/.test(delimiter))
|
|
75
|
+
return null;
|
|
76
|
+
// For verb, '{' is still a symmetric delimiter, not an argument opener.
|
|
77
|
+
if (delimiter === "{" && name !== "verb") {
|
|
78
|
+
const requiredEnd = skipRequiredArgument(source, position, end);
|
|
79
|
+
return { end: requiredEnd ?? end, closed: requiredEnd !== null };
|
|
80
|
+
}
|
|
81
|
+
const closing = source.indexOf(delimiter, position + 1);
|
|
82
|
+
if (closing < 0 || closing >= end)
|
|
83
|
+
return { end, closed: false };
|
|
84
|
+
return { end: closing + 1, closed: true };
|
|
85
|
+
}
|
|
86
|
+
export function inlineLatexLiteralEnd(source, start, end = source.length) {
|
|
87
|
+
return inlineLatexLiteralSpan(source, start, end)?.end ?? null;
|
|
88
|
+
}
|
|
89
|
+
export function literalEnvironmentEnd(source, start, name) {
|
|
90
|
+
const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
91
|
+
const pattern = new RegExp(String.raw `\\end\s*\{\s*${escapedName}\s*\}`, "g");
|
|
92
|
+
pattern.lastIndex = start;
|
|
93
|
+
const match = pattern.exec(source);
|
|
94
|
+
return match ? { from: match.index, to: match.index + match[0].length } : null;
|
|
95
|
+
}
|
|
96
|
+
function isEscaped(source, index) {
|
|
97
|
+
let backslashes = 0;
|
|
98
|
+
for (let cursor = index - 1; cursor >= 0 && source[cursor] === "\\"; cursor -= 1)
|
|
99
|
+
backslashes += 1;
|
|
100
|
+
return backslashes % 2 === 1;
|
|
101
|
+
}
|
|
102
|
+
function commandEnd(source, start) {
|
|
103
|
+
let cursor = start + 1;
|
|
104
|
+
if (/[A-Za-z@]/.test(source[cursor] ?? "")) {
|
|
105
|
+
while (/[A-Za-z@]/.test(source[cursor] ?? ""))
|
|
106
|
+
cursor += 1;
|
|
107
|
+
if (source[cursor] === "*")
|
|
108
|
+
cursor += 1;
|
|
109
|
+
return cursor;
|
|
110
|
+
}
|
|
111
|
+
return Math.min(source.length, cursor + 1);
|
|
112
|
+
}
|
|
113
|
+
function literalEnvironmentBegin(source, start) {
|
|
114
|
+
const match = /^\\begin\s*\{\s*([A-Za-z0-9@:_*.\-]+)\s*\}/.exec(source.slice(start));
|
|
115
|
+
if (!match || !isLatexLiteralEnvironment(match[1]))
|
|
116
|
+
return null;
|
|
117
|
+
return { name: match[1], to: start + match[0].length };
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Return whether a position is inside a TeX comment or one of the literal
|
|
121
|
+
* code forms shared by editor tooling. Positions after a completed literal are
|
|
122
|
+
* deliberately treated as normal source, including when its delimiter is '%'.
|
|
123
|
+
*/
|
|
124
|
+
export function latexOpaqueContextAt(source, position) {
|
|
125
|
+
const target = Math.max(0, Math.min(source.length, position));
|
|
126
|
+
for (let cursor = 0; cursor < target;) {
|
|
127
|
+
if (source[cursor] === "%" && !isEscaped(source, cursor)) {
|
|
128
|
+
const newline = source.indexOf("\n", cursor);
|
|
129
|
+
const end = newline < 0 ? source.length : newline;
|
|
130
|
+
if (target <= end)
|
|
131
|
+
return "comment";
|
|
132
|
+
cursor = end + 1;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (source[cursor] !== "\\" || isEscaped(source, cursor)) {
|
|
136
|
+
cursor += 1;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
const inline = inlineLatexLiteralSpan(source, cursor);
|
|
140
|
+
if (inline !== null) {
|
|
141
|
+
if (target < inline.end || (!inline.closed && target <= inline.end))
|
|
142
|
+
return "literal";
|
|
143
|
+
cursor = inline.end;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
const environment = literalEnvironmentBegin(source, cursor);
|
|
147
|
+
if (environment) {
|
|
148
|
+
const close = literalEnvironmentEnd(source, environment.to, environment.name);
|
|
149
|
+
const end = close?.to ?? source.length;
|
|
150
|
+
if (target < end || (!close && target <= end))
|
|
151
|
+
return "literal";
|
|
152
|
+
cursor = end;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
cursor = commandEnd(source, cursor);
|
|
156
|
+
}
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
function maskRange(source, from, to) {
|
|
160
|
+
return source.slice(from, to).replace(/[^\r\n]/g, " ");
|
|
161
|
+
}
|
|
162
|
+
/** Mask ordinary TeX comments while preserving every source offset. */
|
|
163
|
+
export function maskLatexComments(source) {
|
|
164
|
+
const characters = source.split("");
|
|
165
|
+
for (let cursor = 0; cursor < source.length; cursor += 1) {
|
|
166
|
+
if (source[cursor] !== "%" || isEscaped(source, cursor))
|
|
167
|
+
continue;
|
|
168
|
+
const newline = source.indexOf("\n", cursor);
|
|
169
|
+
const end = newline < 0 ? source.length : newline;
|
|
170
|
+
for (let index = cursor; index < end; index += 1)
|
|
171
|
+
characters[index] = " ";
|
|
172
|
+
cursor = end;
|
|
173
|
+
}
|
|
174
|
+
return characters.join("");
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Mask literal source while preserving offsets and line structure. Callers can
|
|
178
|
+
* then use lightweight regular expressions without learning project commands
|
|
179
|
+
* declared inside listings or verbatim examples.
|
|
180
|
+
*/
|
|
181
|
+
export function maskLatexLiteralContent(source) {
|
|
182
|
+
let result = "";
|
|
183
|
+
let cursor = 0;
|
|
184
|
+
while (cursor < source.length) {
|
|
185
|
+
if (source[cursor] === "%" && !isEscaped(source, cursor)) {
|
|
186
|
+
const newline = source.indexOf("\n", cursor);
|
|
187
|
+
const end = newline < 0 ? source.length : newline;
|
|
188
|
+
result += source.slice(cursor, end);
|
|
189
|
+
cursor = end;
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (source[cursor] !== "\\" || isEscaped(source, cursor)) {
|
|
193
|
+
result += source[cursor];
|
|
194
|
+
cursor += 1;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
const inlineEnd = inlineLatexLiteralEnd(source, cursor);
|
|
198
|
+
if (inlineEnd !== null) {
|
|
199
|
+
result += maskRange(source, cursor, inlineEnd);
|
|
200
|
+
cursor = inlineEnd;
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
const environment = literalEnvironmentBegin(source, cursor);
|
|
204
|
+
if (environment) {
|
|
205
|
+
const close = literalEnvironmentEnd(source, environment.to, environment.name);
|
|
206
|
+
const end = close?.to ?? source.length;
|
|
207
|
+
result += maskRange(source, cursor, end);
|
|
208
|
+
cursor = end;
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
result += source[cursor];
|
|
212
|
+
cursor += 1;
|
|
213
|
+
}
|
|
214
|
+
return result;
|
|
215
|
+
}
|