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
|
@@ -1,16 +1,33 @@
|
|
|
1
|
+
import { literalEnvironmentNames } from "../shared/latexLiterals.js";
|
|
1
2
|
// This intentionally recognizes only the parts of LaTeX that are clearly not
|
|
2
3
|
// prose. It is a single-pass lexer, not a parser: malformed delimiters remain
|
|
3
4
|
// narrow, so they never hide the rest of a document or cause backtracking.
|
|
5
|
+
const literalEnvironments = new Set(literalEnvironmentNames.map((name) => name.toLowerCase()));
|
|
4
6
|
const nonProseEnvironments = new Set([
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
"
|
|
8
|
-
"
|
|
7
|
+
...literalEnvironments,
|
|
8
|
+
// Display and helper math environments commonly used by amsmath/mathtools.
|
|
9
|
+
"math", "displaymath", "equation", "equation*", "align", "align*", "alignat", "alignat*", "aligned", "alignedat", "gather", "gather*", "gathered", "multline", "multline*", "multlined", "flalign", "flalign*", "split", "cases", "dcases", "rcases", "drcases",
|
|
10
|
+
"array", "matrix", "pmatrix", "bmatrix", "vmatrix", "smallmatrix", "psmallmatrix", "bsmallmatrix", "vsmallmatrix",
|
|
11
|
+
// Diagram, table, and literal/code environments. Keep prose containers such
|
|
12
|
+
// as figure, table, theorem, and caption outside this list.
|
|
13
|
+
"tikzpicture", "tikzcd", "axis", "scope", "pgfonlayer", "pgfpicture",
|
|
14
|
+
"tabular", "tabularx", "tabulary", "longtable",
|
|
15
|
+
"verbatim", "verbatim*", "bverbatim", "bverbatim*", "lverbatim", "lverbatim*", "saveverbatim", "saveverbatim*", "verbatimout",
|
|
16
|
+
"lstlisting", "minted", "minted*", "filecontents", "filecontents*", "luacode", "luacodestar", "comment", "algorithmic"
|
|
9
17
|
]);
|
|
10
18
|
const opaqueArgumentCommands = new Set([
|
|
11
19
|
"documentclass", "usepackage", "requirepackage", "includegraphics", "tikzset", "pgfplotsset", "hypersetup", "lstset",
|
|
12
20
|
"label", "hypertarget", "ref", "pageref", "autoref", "nameref", "hyperref", "index", "gls", "glspl",
|
|
13
|
-
"bibliography", "addbibresource", "input", "include", "url", "path", "setlength", "setcounter", "color", "textcolor", "colorbox", "pagecolor"
|
|
21
|
+
"bibliography", "addbibresource", "input", "include", "url", "path", "setlength", "setcounter", "color", "textcolor", "colorbox", "pagecolor",
|
|
22
|
+
// These arguments are code, identifiers, or paths rather than prose.
|
|
23
|
+
"texttt", "detokenize", "lstinputlisting"
|
|
24
|
+
]);
|
|
25
|
+
/** Number of brace arguments after a macro/environment target. */
|
|
26
|
+
const macroDefinitionArgumentCounts = new Map([
|
|
27
|
+
["newcommand", 1], ["renewcommand", 1], ["providecommand", 1], ["declarerobustcommand", 1],
|
|
28
|
+
["newenvironment", 2], ["renewenvironment", 2],
|
|
29
|
+
["newdocumentcommand", 2], ["renewdocumentcommand", 2], ["providedocumentcommand", 2], ["declaredocumentcommand", 2],
|
|
30
|
+
["declaremathoperator", 1], ["declarepaireddelimiter", 2]
|
|
14
31
|
]);
|
|
15
32
|
function addRange(ranges, from, to) {
|
|
16
33
|
if (to > from)
|
|
@@ -26,7 +43,7 @@ function isCommandNameCharacter(character) {
|
|
|
26
43
|
return (code >= 65 && code <= 90) || (code >= 97 && code <= 122) || (code >= 48 && code <= 57)
|
|
27
44
|
|| character === "@" || character === ":" || character === "_";
|
|
28
45
|
}
|
|
29
|
-
function balancedArgument(source, start, open, close) {
|
|
46
|
+
function balancedArgument(source, start, open, close, literal = false) {
|
|
30
47
|
if (source[start] !== open)
|
|
31
48
|
return null;
|
|
32
49
|
let depth = 1;
|
|
@@ -35,6 +52,13 @@ function balancedArgument(source, start, open, close) {
|
|
|
35
52
|
index += 1;
|
|
36
53
|
continue;
|
|
37
54
|
}
|
|
55
|
+
if (!literal && source[index] === "%") {
|
|
56
|
+
const lineEnd = source.indexOf("\n", index);
|
|
57
|
+
if (lineEnd < 0)
|
|
58
|
+
return null;
|
|
59
|
+
index = lineEnd;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
38
62
|
if (source[index] === open)
|
|
39
63
|
depth += 1;
|
|
40
64
|
else if (source[index] === close && --depth === 0)
|
|
@@ -48,6 +72,17 @@ function skipWhitespace(source, start) {
|
|
|
48
72
|
index += 1;
|
|
49
73
|
return index;
|
|
50
74
|
}
|
|
75
|
+
/** Skip whitespace and TeX comments between arguments while masking comments. */
|
|
76
|
+
function skipTrivia(source, start, ranges) {
|
|
77
|
+
let index = skipWhitespace(source, start);
|
|
78
|
+
while (source[index] === "%") {
|
|
79
|
+
const lineEnd = source.indexOf("\n", index);
|
|
80
|
+
const end = lineEnd < 0 ? source.length : lineEnd;
|
|
81
|
+
addRange(ranges, index, end);
|
|
82
|
+
index = skipWhitespace(source, lineEnd < 0 ? source.length : lineEnd + 1);
|
|
83
|
+
}
|
|
84
|
+
return index;
|
|
85
|
+
}
|
|
51
86
|
function readCommand(source, start) {
|
|
52
87
|
let end = start + 1;
|
|
53
88
|
if (isCommandNameCharacter(source[end]))
|
|
@@ -64,23 +99,103 @@ function isCitationCommand(name) {
|
|
|
64
99
|
const normalized = name.toLocaleLowerCase("en-US");
|
|
65
100
|
return normalized === "cite" || normalized.startsWith("cite") || normalized.endsWith("cite");
|
|
66
101
|
}
|
|
67
|
-
function
|
|
102
|
+
function opaqueArgumentCount(name) {
|
|
68
103
|
const normalized = name.toLocaleLowerCase("en-US");
|
|
69
|
-
|
|
104
|
+
if (normalized === "definecolor" || normalized === "colorlet")
|
|
105
|
+
return 3;
|
|
106
|
+
if (normalized === "mint" || normalized === "inputminted")
|
|
107
|
+
return 2;
|
|
108
|
+
return opaqueArgumentCommands.has(normalized) || isCitationCommand(normalized) || normalized === "href" ? 1 : 0;
|
|
70
109
|
}
|
|
71
|
-
function
|
|
110
|
+
function maskBraceArguments(source, start, count, ranges) {
|
|
72
111
|
let cursor = start;
|
|
73
|
-
const normalized = name.toLocaleLowerCase("en-US");
|
|
74
|
-
const count = normalized === "definecolor" || normalized === "colorlet" ? 3 : 1;
|
|
75
112
|
for (let index = 0; index < count; index += 1) {
|
|
76
113
|
const argument = balancedArgument(source, cursor, "{", "}");
|
|
77
114
|
if (!argument)
|
|
78
115
|
break;
|
|
79
116
|
addRange(ranges, argument.from, argument.to);
|
|
80
|
-
cursor =
|
|
117
|
+
cursor = skipTrivia(source, argument.to, ranges);
|
|
81
118
|
}
|
|
82
119
|
return cursor;
|
|
83
120
|
}
|
|
121
|
+
function maskMacroTarget(source, start, ranges) {
|
|
122
|
+
const bracedTarget = balancedArgument(source, start, "{", "}");
|
|
123
|
+
if (bracedTarget) {
|
|
124
|
+
addRange(ranges, bracedTarget.from, bracedTarget.to);
|
|
125
|
+
return skipTrivia(source, bracedTarget.to, ranges);
|
|
126
|
+
}
|
|
127
|
+
if (source[start] === "\\") {
|
|
128
|
+
const target = readCommand(source, start);
|
|
129
|
+
addRange(ranges, start, target.end);
|
|
130
|
+
return skipTrivia(source, target.end, ranges);
|
|
131
|
+
}
|
|
132
|
+
return start;
|
|
133
|
+
}
|
|
134
|
+
function maskMacroDefinition(source, start, definitionArgumentCount, ranges) {
|
|
135
|
+
let cursor = maskMacroTarget(source, start, ranges);
|
|
136
|
+
if (cursor === start)
|
|
137
|
+
return cursor;
|
|
138
|
+
// newcommand-style macros may have both an argument-count option and a
|
|
139
|
+
// default-argument option. Mask every immediately following option before
|
|
140
|
+
// their definition bodies, while keeping malformed input narrow.
|
|
141
|
+
for (;;) {
|
|
142
|
+
const option = balancedArgument(source, cursor, "[", "]");
|
|
143
|
+
if (!option)
|
|
144
|
+
break;
|
|
145
|
+
addRange(ranges, option.from, option.to);
|
|
146
|
+
cursor = skipTrivia(source, option.to, ranges);
|
|
147
|
+
}
|
|
148
|
+
return maskBraceArguments(source, cursor, definitionArgumentCount, ranges);
|
|
149
|
+
}
|
|
150
|
+
function maskDelimitedCode(source, start, ranges) {
|
|
151
|
+
const delimiter = source[start];
|
|
152
|
+
if (!delimiter || isWhitespace(delimiter))
|
|
153
|
+
return start;
|
|
154
|
+
// TeX's delimiter-based inline literal commands cannot span a line. If a
|
|
155
|
+
// user is still typing the closing delimiter, hide only the incomplete
|
|
156
|
+
// current line rather than accidentally suppressing all later prose until a
|
|
157
|
+
// matching character happens to occur.
|
|
158
|
+
for (let cursor = start + 1; cursor < source.length; cursor += 1) {
|
|
159
|
+
const character = source[cursor];
|
|
160
|
+
if (character === delimiter) {
|
|
161
|
+
addRange(ranges, start, cursor + 1);
|
|
162
|
+
return skipTrivia(source, cursor + 1, ranges);
|
|
163
|
+
}
|
|
164
|
+
if (character === "\r" || character === "\n") {
|
|
165
|
+
addRange(ranges, start, cursor);
|
|
166
|
+
return cursor;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
addRange(ranges, start, source.length);
|
|
170
|
+
return source.length;
|
|
171
|
+
}
|
|
172
|
+
function maskDelimitedOrBracedCode(source, start, ranges) {
|
|
173
|
+
if (source[start] !== "{")
|
|
174
|
+
return maskDelimitedCode(source, start, ranges);
|
|
175
|
+
// The brace form is also inline literal code. Preserve nested braces and
|
|
176
|
+
// escaped characters, but never let an unfinished argument conceal a later
|
|
177
|
+
// source line from the writing checker.
|
|
178
|
+
let depth = 1;
|
|
179
|
+
for (let cursor = start + 1; cursor < source.length; cursor += 1) {
|
|
180
|
+
const character = source[cursor];
|
|
181
|
+
if (character === "\\" && source[cursor + 1] !== "\r" && source[cursor + 1] !== "\n") {
|
|
182
|
+
cursor += 1;
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
if (character === "\r" || character === "\n") {
|
|
186
|
+
addRange(ranges, start, cursor);
|
|
187
|
+
return cursor;
|
|
188
|
+
}
|
|
189
|
+
if (character === "{")
|
|
190
|
+
depth += 1;
|
|
191
|
+
else if (character === "}" && --depth === 0) {
|
|
192
|
+
addRange(ranges, start, cursor + 1);
|
|
193
|
+
return cursor + 1;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
addRange(ranges, start, source.length);
|
|
197
|
+
return source.length;
|
|
198
|
+
}
|
|
84
199
|
function rawUrlEnd(source, start) {
|
|
85
200
|
const first = source[start];
|
|
86
201
|
if (first !== "h" && first !== "H" && first !== "f" && first !== "F")
|
|
@@ -115,6 +230,33 @@ export function ignoredLatexRanges(source) {
|
|
|
115
230
|
// The same technique keeps unmatched $ / \[ delimiters cheap and local.
|
|
116
231
|
let pendingMath = null;
|
|
117
232
|
for (let index = 0; index < source.length;) {
|
|
233
|
+
// An open literal environment owns its full body. Do not parse
|
|
234
|
+
// dollars, comments, or nested commands within it: literal code often
|
|
235
|
+
// contains all of those. Only look for its matching \end marker.
|
|
236
|
+
const activeNonProse = nonProseStack.at(-1);
|
|
237
|
+
if (activeNonProse && literalEnvironments.has(activeNonProse.name)) {
|
|
238
|
+
if (source[index] === "\\") {
|
|
239
|
+
const command = readCommand(source, index);
|
|
240
|
+
if (command.name === "end") {
|
|
241
|
+
const cursor = skipTrivia(source, command.end, ranges);
|
|
242
|
+
const environment = balancedArgument(source, cursor, "{", "}");
|
|
243
|
+
const environmentName = environment
|
|
244
|
+
? source.slice(environment.from + 1, environment.to - 1).trim().toLocaleLowerCase("en-US")
|
|
245
|
+
: "";
|
|
246
|
+
if (environment && environmentName === activeNonProse.name) {
|
|
247
|
+
activeNonProse.range.to = skipTrivia(source, environment.to, ranges);
|
|
248
|
+
nonProseStack.pop();
|
|
249
|
+
index = activeNonProse.range.to;
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
index = Math.max(index + 1, command.end);
|
|
254
|
+
}
|
|
255
|
+
else {
|
|
256
|
+
index += 1;
|
|
257
|
+
}
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
118
260
|
if (pendingMath?.close.startsWith("\\") && source.startsWith(pendingMath.close, index)) {
|
|
119
261
|
pendingMath.range.to = index + pendingMath.close.length;
|
|
120
262
|
index += pendingMath.close.length;
|
|
@@ -171,44 +313,41 @@ export function ignoredLatexRanges(source) {
|
|
|
171
313
|
continue;
|
|
172
314
|
}
|
|
173
315
|
const command = readCommand(source, commandStart);
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
316
|
+
const commandName = command.name.toLocaleLowerCase("en-US");
|
|
317
|
+
// Reserve the command span before scanning trivia, which may append spans.
|
|
318
|
+
const commandRange = { from: commandStart, to: command.end };
|
|
319
|
+
ranges.push(commandRange);
|
|
320
|
+
let cursor = commandName === "verb" || commandName === "lstinline"
|
|
321
|
+
? command.end : skipTrivia(source, command.end, ranges);
|
|
322
|
+
if (commandName === "verb") {
|
|
323
|
+
const options = command.name === "Verb" ? balancedArgument(source, cursor, "[", "]") : null;
|
|
324
|
+
if (options) {
|
|
325
|
+
addRange(ranges, options.from, options.to);
|
|
326
|
+
cursor = skipWhitespace(source, options.to);
|
|
184
327
|
}
|
|
328
|
+
cursor = maskDelimitedOrBracedCode(source, cursor, ranges);
|
|
185
329
|
index = Math.max(command.end, cursor);
|
|
186
330
|
continue;
|
|
187
331
|
}
|
|
188
332
|
if (command.name === "begin" || command.name === "end") {
|
|
189
333
|
const environment = balancedArgument(source, cursor, "{", "}");
|
|
190
334
|
if (!environment) {
|
|
191
|
-
addRange(ranges, commandStart, command.end);
|
|
192
335
|
index = command.end;
|
|
193
336
|
continue;
|
|
194
337
|
}
|
|
195
338
|
const environmentName = source.slice(environment.from + 1, environment.to - 1).trim().toLocaleLowerCase("en-US");
|
|
196
|
-
|
|
339
|
+
addRange(ranges, environment.from, environment.to);
|
|
340
|
+
cursor = skipTrivia(source, environment.to, ranges);
|
|
197
341
|
let options = null;
|
|
198
342
|
if (command.name === "begin") {
|
|
199
343
|
options = balancedArgument(source, cursor, "[", "]");
|
|
200
|
-
if (options)
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
const range = { from: commandStart, to: cursor };
|
|
204
|
-
ranges.push(range);
|
|
205
|
-
nonProseStack.push({ name: environmentName, range });
|
|
344
|
+
if (options) {
|
|
345
|
+
addRange(ranges, options.from, options.to);
|
|
346
|
+
cursor = skipTrivia(source, options.to, ranges);
|
|
206
347
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
if (options)
|
|
211
|
-
addRange(ranges, options.from, options.to);
|
|
348
|
+
if (nonProseEnvironments.has(environmentName)) {
|
|
349
|
+
commandRange.to = cursor;
|
|
350
|
+
nonProseStack.push({ name: environmentName, range: commandRange });
|
|
212
351
|
}
|
|
213
352
|
}
|
|
214
353
|
else {
|
|
@@ -217,22 +356,36 @@ export function ignoredLatexRanges(source) {
|
|
|
217
356
|
nonProseStack.pop();
|
|
218
357
|
open.range.to = cursor;
|
|
219
358
|
}
|
|
220
|
-
else {
|
|
221
|
-
addRange(ranges, commandStart, command.end);
|
|
222
|
-
addRange(ranges, environment.from, environment.to);
|
|
223
|
-
}
|
|
224
359
|
}
|
|
225
360
|
index = Math.max(command.end, cursor);
|
|
226
361
|
continue;
|
|
227
362
|
}
|
|
228
|
-
addRange(ranges, commandStart, command.end);
|
|
229
363
|
const options = balancedArgument(source, cursor, "[", "]");
|
|
230
364
|
if (options) {
|
|
231
365
|
addRange(ranges, options.from, options.to);
|
|
232
|
-
cursor = skipWhitespace(source, options.to);
|
|
366
|
+
cursor = commandName === "lstinline" ? skipWhitespace(source, options.to) : skipTrivia(source, options.to, ranges);
|
|
367
|
+
}
|
|
368
|
+
if (commandName === "lstinline") {
|
|
369
|
+
cursor = maskDelimitedOrBracedCode(source, cursor, ranges);
|
|
370
|
+
}
|
|
371
|
+
else if (commandName === "mintinline") {
|
|
372
|
+
const language = balancedArgument(source, cursor, "{", "}");
|
|
373
|
+
if (language) {
|
|
374
|
+
addRange(ranges, language.from, language.to);
|
|
375
|
+
cursor = maskDelimitedOrBracedCode(source, skipWhitespace(source, language.to), ranges);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
else {
|
|
379
|
+
const macroDefinitionArgumentCount = macroDefinitionArgumentCounts.get(commandName);
|
|
380
|
+
if (macroDefinitionArgumentCount !== undefined) {
|
|
381
|
+
cursor = maskMacroDefinition(source, cursor, macroDefinitionArgumentCount, ranges);
|
|
382
|
+
}
|
|
383
|
+
else {
|
|
384
|
+
const opaqueCount = opaqueArgumentCount(commandName);
|
|
385
|
+
if (opaqueCount > 0)
|
|
386
|
+
cursor = maskBraceArguments(source, cursor, opaqueCount, ranges);
|
|
387
|
+
}
|
|
233
388
|
}
|
|
234
|
-
if (masksOpaqueArguments(command.name))
|
|
235
|
-
cursor = maskOpaqueArguments(source, cursor, command.name, ranges);
|
|
236
389
|
index = Math.max(command.end, cursor);
|
|
237
390
|
}
|
|
238
391
|
return mergeOrderedRanges(ranges);
|
package/dist/server/pm2.js
CHANGED
|
@@ -2,6 +2,7 @@ import crypto from "node:crypto";
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import http from "node:http";
|
|
4
4
|
import path from "node:path";
|
|
5
|
+
import { basePathHref, withBasePath } from "../shared/basePath.js";
|
|
5
6
|
import { managedLogPaths } from "./logRotation.js";
|
|
6
7
|
import { defaultConfigPath, packageRootDirectory, packageServerEntry } from "./runtimePaths.js";
|
|
7
8
|
let pm2Client = null;
|
|
@@ -197,7 +198,7 @@ export async function processStatus(config) {
|
|
|
197
198
|
uptimeSeconds: startedAt === null ? null : Math.max(0, Math.floor((Date.now() - startedAt) / 1000)),
|
|
198
199
|
restarts: env?.restart_time ?? 0,
|
|
199
200
|
version: packageVersion(),
|
|
200
|
-
address: `http://${config.host}:${config.port}`,
|
|
201
|
+
address: `http://${config.host}:${config.port}${basePathHref(config.basePath)}`,
|
|
201
202
|
dataDir: config.dataDir,
|
|
202
203
|
cwd: env?.pm_cwd ?? packageRootDirectory(),
|
|
203
204
|
outputLog: env?.pm_out_log_path ?? null,
|
|
@@ -255,7 +256,7 @@ function probeHost(host) {
|
|
|
255
256
|
function healthUrl(config) {
|
|
256
257
|
const host = probeHost(config.host);
|
|
257
258
|
const formattedHost = host.includes(":") ? `[${host}]` : host;
|
|
258
|
-
return `http://${formattedHost}:${config.port}/api/health`;
|
|
259
|
+
return `http://${formattedHost}:${config.port}${withBasePath(config.basePath, "/api/health")}`;
|
|
259
260
|
}
|
|
260
261
|
function probeService(config, timeoutMs = 1_500) {
|
|
261
262
|
return new Promise((resolve) => {
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { listProjectFilesAsync, resolveSourcePath } from "./files.js";
|
|
3
|
+
import { findLatexReferenceDefinitions, lineAndColumnAt } from "../shared/latexReferences.js";
|
|
4
|
+
import { documentSourceOrder, latexDocumentDirectives, resolveProjectReferencePath, uniqueProjectPaths } from "./latexDocumentGraph.js";
|
|
5
|
+
const definitionExtensions = /\.(?:tex|sty|cls)$/i;
|
|
6
|
+
const texExtension = /\.tex$/i;
|
|
7
|
+
const bibExtension = /\.bib$/i;
|
|
8
|
+
const MAX_REFERENCE_KEY_LENGTH = 512;
|
|
9
|
+
export function validReferenceKey(value) {
|
|
10
|
+
return typeof value === "string"
|
|
11
|
+
&& value.trim().length > 0
|
|
12
|
+
&& value.trim().length <= MAX_REFERENCE_KEY_LENGTH
|
|
13
|
+
&& !/[\u0000-\u001f\u007f]/.test(value);
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Resolve a source-level citation or label target from the project tree.
|
|
17
|
+
*
|
|
18
|
+
* A document's own input graph and declared bibliography files are checked
|
|
19
|
+
* before unrelated files in the project. This keeps a stale \bibitem in an
|
|
20
|
+
* archive or template from shadowing the bibliography used by the selected
|
|
21
|
+
* main document.
|
|
22
|
+
*/
|
|
23
|
+
export async function resolveProjectReference(config, projectId, kind, keyInput, options = {}) {
|
|
24
|
+
// Keep the former preferred-path argument working for internal callers
|
|
25
|
+
// during the transition to the richer main-document-aware lookup options.
|
|
26
|
+
const lookup = typeof options === "string" ? { preferredPath: options } : options;
|
|
27
|
+
const key = keyInput.trim();
|
|
28
|
+
const files = (await listProjectFilesAsync(config, projectId))
|
|
29
|
+
.filter((entry) => entry.type === "file" && (definitionExtensions.test(entry.path) || bibExtension.test(entry.path)));
|
|
30
|
+
const sourceEntries = files.filter((entry) => definitionExtensions.test(entry.path));
|
|
31
|
+
const bibEntries = files.filter((entry) => bibExtension.test(entry.path));
|
|
32
|
+
const sourceContents = await readProjectSources(config, projectId, sourceEntries.map((entry) => entry.path));
|
|
33
|
+
const sourcePaths = [...sourceContents.keys()].sort((left, right) => left.localeCompare(right));
|
|
34
|
+
const bibPaths = bibEntries.map((entry) => entry.path).sort((left, right) => left.localeCompare(right));
|
|
35
|
+
const sourceDirectives = new Map();
|
|
36
|
+
for (const [entryPath, source] of sourceContents)
|
|
37
|
+
sourceDirectives.set(entryPath, latexDocumentDirectives(source));
|
|
38
|
+
const preferredPath = sourceContents.has(lookup.preferredPath ?? "") ? lookup.preferredPath ?? "" : "";
|
|
39
|
+
const mainFile = sourceContents.has(lookup.mainFile ?? "") && texExtension.test(lookup.mainFile ?? "")
|
|
40
|
+
? lookup.mainFile ?? ""
|
|
41
|
+
: "";
|
|
42
|
+
const documentSources = documentSourceOrder(sourceDirectives, mainFile);
|
|
43
|
+
const prioritizedSources = uniqueProjectPaths([
|
|
44
|
+
...(preferredPath ? [preferredPath] : []),
|
|
45
|
+
...documentSources
|
|
46
|
+
]);
|
|
47
|
+
const orderedPaths = kind === "label"
|
|
48
|
+
? uniqueProjectPaths([...prioritizedSources, ...sourcePaths])
|
|
49
|
+
: citationLookupOrder(sourceDirectives, prioritizedSources, bibPaths, sourcePaths);
|
|
50
|
+
for (const entryPath of orderedPaths) {
|
|
51
|
+
const source = sourceContents.get(entryPath)
|
|
52
|
+
?? await readProjectFile(config, projectId, entryPath);
|
|
53
|
+
if (source === null)
|
|
54
|
+
continue;
|
|
55
|
+
const definition = findLatexReferenceDefinitions(source, kind, bibExtension.test(entryPath))
|
|
56
|
+
.find((candidate) => candidate.key === key);
|
|
57
|
+
if (!definition)
|
|
58
|
+
continue;
|
|
59
|
+
const location = lineAndColumnAt(source, definition.targetFrom);
|
|
60
|
+
return { path: entryPath, ...location, kind, source: definition.source };
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
async function readProjectSources(config, projectId, paths) {
|
|
65
|
+
const contents = new Map();
|
|
66
|
+
await Promise.all(paths.map(async (entryPath) => {
|
|
67
|
+
const source = await readProjectFile(config, projectId, entryPath);
|
|
68
|
+
if (source !== null)
|
|
69
|
+
contents.set(entryPath, source);
|
|
70
|
+
}));
|
|
71
|
+
return contents;
|
|
72
|
+
}
|
|
73
|
+
async function readProjectFile(config, projectId, entryPath) {
|
|
74
|
+
try {
|
|
75
|
+
return await fs.promises.readFile(resolveSourcePath(config, projectId, entryPath), "utf8");
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
// A concurrent file deletion is harmless for navigation. The project
|
|
79
|
+
// mutation barrier prevents partial writes; simply continue to the next
|
|
80
|
+
// possible definition when a file has disappeared.
|
|
81
|
+
if (error.code === "ENOENT")
|
|
82
|
+
return null;
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function citationLookupOrder(sourceDirectives, documentSources, bibPaths, sourcePaths) {
|
|
87
|
+
// Callers without a selected root retain the conservative legacy behavior:
|
|
88
|
+
// prefer in-source thebibliography entries before standalone .bib files.
|
|
89
|
+
if (documentSources.length === 0)
|
|
90
|
+
return uniqueProjectPaths([...sourcePaths, ...bibPaths]);
|
|
91
|
+
const bibPathSet = new Set(bibPaths);
|
|
92
|
+
const declaredBibliographies = [];
|
|
93
|
+
for (const sourcePath of documentSources) {
|
|
94
|
+
const directives = sourceDirectives.get(sourcePath);
|
|
95
|
+
if (!directives)
|
|
96
|
+
continue;
|
|
97
|
+
for (const bibliography of directives.bibliographies) {
|
|
98
|
+
const resolved = resolveProjectReferencePath(bibliography, sourcePath, ".bib", bibPathSet, true);
|
|
99
|
+
if (resolved)
|
|
100
|
+
declaredBibliographies.push(resolved);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return uniqueProjectPaths([
|
|
104
|
+
...documentSources,
|
|
105
|
+
...declaredBibliographies,
|
|
106
|
+
...bibPaths,
|
|
107
|
+
...sourcePaths
|
|
108
|
+
]);
|
|
109
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { publicUser, requireUser } from "../auth.js";
|
|
2
2
|
import { createSessionToken, digestToken, hashPassword, verifyPassword } from "../security.js";
|
|
3
3
|
import { apiError, ValidationError } from "../http.js";
|
|
4
|
+
import { basePathHref } from "../../shared/basePath.js";
|
|
4
5
|
const now = () => new Date().toISOString();
|
|
5
6
|
function text(value, max = 200) {
|
|
6
7
|
if (typeof value !== "string" || !value.trim() || value.length > max) {
|
|
@@ -35,8 +36,9 @@ export function registerAuthRoutes(app, context) {
|
|
|
35
36
|
db.prepare("INSERT INTO sessions (id, user_id, expires_at, created_at) VALUES (?, ?, ?, ?)")
|
|
36
37
|
.run(session.digest, user.id, expires.toISOString(), createdAt);
|
|
37
38
|
const isSecure = request.protocol === "https" || request.headers["x-forwarded-proto"] === "https";
|
|
39
|
+
const cookiePath = basePathHref(config.basePath);
|
|
38
40
|
reply.setCookie("texlite_session", session.token, {
|
|
39
|
-
path:
|
|
41
|
+
path: cookiePath,
|
|
40
42
|
httpOnly: true,
|
|
41
43
|
sameSite: "strict",
|
|
42
44
|
secure: isSecure,
|
|
@@ -48,7 +50,8 @@ export function registerAuthRoutes(app, context) {
|
|
|
48
50
|
const token = request.cookies.texlite_session;
|
|
49
51
|
if (token)
|
|
50
52
|
db.prepare("DELETE FROM sessions WHERE id = ?").run(digestToken(token));
|
|
51
|
-
|
|
53
|
+
const cookiePath = basePathHref(config.basePath);
|
|
54
|
+
reply.clearCookie("texlite_session", { path: cookiePath });
|
|
52
55
|
return { ok: true };
|
|
53
56
|
});
|
|
54
57
|
app.get("/api/me", async (request, reply) => {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
-
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
3
|
import { requireUser } from "../auth.js";
|
|
4
4
|
import { createSourceAnchor, offsetToLine } from "../anchors.js";
|
|
5
5
|
import { resolveSourcePath, safeRelativePath } from "../files.js";
|
|
@@ -106,6 +106,18 @@ export function registerCommentRoutes(app, context) {
|
|
|
106
106
|
if (!Number.isInteger(body.startOffset) || !Number.isInteger(body.endOffset)) {
|
|
107
107
|
return apiError(reply, 400, "COMMENT_RANGE_INVALID");
|
|
108
108
|
}
|
|
109
|
+
// The browser supplies the exact source revision the user selected.
|
|
110
|
+
// Keep this optional for an already-open client during an upgrade;
|
|
111
|
+
// current clients never bind an annotation to offsets from a different
|
|
112
|
+
// revision.
|
|
113
|
+
if (body.sourceHash !== undefined) {
|
|
114
|
+
if (typeof body.sourceHash !== "string" || !/^[a-f0-9]{64}$/.test(body.sourceHash)) {
|
|
115
|
+
return apiError(reply, 400, "REQUEST_INVALID");
|
|
116
|
+
}
|
|
117
|
+
const currentHash = createHash("sha256").update(source, "utf8").digest("hex");
|
|
118
|
+
if (currentHash !== body.sourceHash)
|
|
119
|
+
return apiError(reply, 409, "COMMENT_SOURCE_CHANGED");
|
|
120
|
+
}
|
|
109
121
|
const anchor = createSourceAnchor(source, Number(body.startOffset), Number(body.endOffset));
|
|
110
122
|
const comment = {
|
|
111
123
|
id: randomUUID(), filePath,
|
|
@@ -85,7 +85,7 @@ export function registerCompileRoutes(app, context) {
|
|
|
85
85
|
} : null
|
|
86
86
|
} : null,
|
|
87
87
|
hasPdf: Boolean(pdf),
|
|
88
|
-
pdfUrl: pdf ? compilePdfUrl(id, mainFile, pdfVersion ?? pdf.version) : null,
|
|
88
|
+
pdfUrl: pdf ? compilePdfUrl(config, id, mainFile, pdfVersion ?? pdf.version) : null,
|
|
89
89
|
pdfCompiledAt: publishedRun?.finished_at ?? latestSuccess?.finished_at ?? null,
|
|
90
90
|
...pdfMetadata
|
|
91
91
|
};
|
|
@@ -674,7 +674,7 @@ export function registerCompileRoutes(app, context) {
|
|
|
674
674
|
].filter(Boolean).join(", "));
|
|
675
675
|
return {
|
|
676
676
|
mainFile, runId: result.runId, ok: result.ok, cancelled: result.cancelled === true, skipped: result.skipped === true, log: result.log, diagnostics: result.diagnostics,
|
|
677
|
-
pdfUrl: result.ok ? compilePdfUrl(id, mainFile, result.runId) : null,
|
|
677
|
+
pdfUrl: result.ok ? compilePdfUrl(config, id, mainFile, result.runId) : null,
|
|
678
678
|
pdfCompiledAt: result.ok ? completed?.finished_at ?? null : null,
|
|
679
679
|
...pdfMetadata,
|
|
680
680
|
timings
|
|
@@ -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
|
}
|
|
@@ -155,7 +155,12 @@ export function registerProjectHistoryRoutes(app, context) {
|
|
|
155
155
|
if (!project || !canEdit(project))
|
|
156
156
|
return apiError(reply, 403, "PROJECT_EDIT_FORBIDDEN");
|
|
157
157
|
const body = request.body;
|
|
158
|
-
|
|
158
|
+
if (body?.snapshotHash !== undefined && (typeof body.snapshotHash !== "string" || !/^[a-f0-9]{64}$/.test(body.snapshotHash))) {
|
|
159
|
+
return apiError(reply, 400, "REQUEST_INVALID");
|
|
160
|
+
}
|
|
161
|
+
const snapshotHash = typeof body?.snapshotHash === "string" ? body.snapshotHash : undefined;
|
|
162
|
+
history.assertSnapshotHash(id, versionId, snapshotHash);
|
|
163
|
+
const label = body?.label === null || body?.label === "" ? null : text(body?.label, 80);
|
|
159
164
|
const version = history.setLabel(id, versionId, label);
|
|
160
165
|
if (!version)
|
|
161
166
|
return apiError(reply, 404, "HISTORY_VERSION_NOT_FOUND");
|
|
@@ -206,8 +211,15 @@ export function registerProjectHistoryRoutes(app, context) {
|
|
|
206
211
|
return apiError(reply, 403, "PROJECT_EDIT_FORBIDDEN");
|
|
207
212
|
const body = request.body;
|
|
208
213
|
const filePath = typeof body?.path === "string" ? safeRelativePath(body.path) : undefined;
|
|
214
|
+
// Current clients bind a restore to the snapshot they displayed. Accept
|
|
215
|
+
// an omitted fingerprint only for already-open clients during an upgrade.
|
|
216
|
+
if (body?.snapshotHash !== undefined && (typeof body.snapshotHash !== "string" || !/^[a-f0-9]{64}$/.test(body.snapshotHash))) {
|
|
217
|
+
return apiError(reply, 400, "REQUEST_INVALID");
|
|
218
|
+
}
|
|
219
|
+
const snapshotHash = typeof body?.snapshotHash === "string" ? body.snapshotHash : undefined;
|
|
209
220
|
return await projectMutations.runExclusive(id, "history restore", () => {
|
|
210
221
|
const currentProject = requireEditableProject(db, id, user);
|
|
222
|
+
history.assertSnapshotHash(id, versionId, snapshotHash);
|
|
211
223
|
recordHistory(id, user.id, "checkpoint");
|
|
212
224
|
const before = projectTextSnapshot(config, id);
|
|
213
225
|
const restored = history.restore(id, versionId, filePath);
|
|
@@ -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
|
+
}
|