texlite 0.2.0
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/LICENSE +661 -0
- package/README.md +403 -0
- package/README.zh-CN.md +391 -0
- package/dist/client/assets/GitDialog-BHHxSBn3.js +3 -0
- package/dist/client/assets/HistoryDialog-Dlv57tYf.js +5 -0
- package/dist/client/assets/LatexEditor-B-0VRUPv.js +55 -0
- package/dist/client/assets/PdfPreview-617TWFJN.js +1 -0
- package/dist/client/assets/ProjectNavigationDialogs-Cpt2LVEt.js +1 -0
- package/dist/client/assets/SystemMetricsDialog-fkFHVVzJ.js +1 -0
- package/dist/client/assets/editor-JxT6uSYX.js +14 -0
- package/dist/client/assets/index-Bc5CmFMl.js +15 -0
- package/dist/client/assets/index-CMqYAbDx.css +1 -0
- package/dist/client/assets/pdf-DPnwNQHb.js +55 -0
- package/dist/client/assets/pdf.worker.min-CHFwMXne.mjs +29 -0
- package/dist/client/assets/plus-DzbLkp6l.js +1 -0
- package/dist/client/assets/react-CsO_xvbl.js +49 -0
- package/dist/client/assets/spellCheck-R6fUM9oA.js +49783 -0
- package/dist/client/index.html +20 -0
- package/dist/client/logo.svg +4 -0
- package/dist/server/anchors.js +95 -0
- package/dist/server/app.js +1624 -0
- package/dist/server/archive.js +26 -0
- package/dist/server/auth.js +42 -0
- package/dist/server/cli.js +318 -0
- package/dist/server/collaboration.js +635 -0
- package/dist/server/compileArtifacts.js +86 -0
- package/dist/server/compileDiagnostics.js +227 -0
- package/dist/server/compiler.js +514 -0
- package/dist/server/config.js +336 -0
- package/dist/server/db.js +280 -0
- package/dist/server/environment.js +57 -0
- package/dist/server/files.js +121 -0
- package/dist/server/git.js +414 -0
- package/dist/server/history.js +379 -0
- package/dist/server/http.js +7 -0
- package/dist/server/index.js +75 -0
- package/dist/server/latexCompletion.js +296 -0
- package/dist/server/metrics.js +34 -0
- package/dist/server/pm2.js +234 -0
- package/dist/server/projectOutline.js +101 -0
- package/dist/server/projectSearch.js +112 -0
- package/dist/server/projects.js +29 -0
- package/dist/server/routes/compile.js +343 -0
- package/dist/server/runtimePaths.js +42 -0
- package/dist/server/security.js +29 -0
- package/dist/server/spellCheck.js +195 -0
- package/dist/server/synctex.js +56 -0
- package/dist/server/zip.js +185 -0
- package/package.json +110 -0
- package/preview.png +0 -0
- package/texlite.config.example.json +33 -0
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { listProjectFiles, listProjectFilesAsync, resolveSourcePath } from "./files.js";
|
|
3
|
+
const textExtensions = /\.(?:tex|sty|cls|def|cfg|bib|bst|fd|dtx|ins)$/i;
|
|
4
|
+
const definitionExtensions = /\.(?:tex|sty|cls|def|cfg|dtx|ins)$/i;
|
|
5
|
+
const maxIndexedFileBytes = 3 * 1024 * 1024;
|
|
6
|
+
const maxIndexedBytes = 24 * 1024 * 1024;
|
|
7
|
+
const maxIndexedFiles = 500;
|
|
8
|
+
// This is deliberately kept as metadata rather than hard-coded in the editor,
|
|
9
|
+
// so the same vocabulary is available to every client and can be extended later.
|
|
10
|
+
const standardCommands = [
|
|
11
|
+
["\\documentclass", "Document class", "\\documentclass[${options}]{${class}}"],
|
|
12
|
+
["\\usepackage", "Load package", "\\usepackage[${options}]{${package}}"],
|
|
13
|
+
["\\RequirePackage", "Load package", "\\RequirePackage{${package}}"],
|
|
14
|
+
["\\documentstyle", "Document style"], ["\\title", "Document title"], ["\\author", "Document author"], ["\\date", "Document date"],
|
|
15
|
+
["\\maketitle", "Render title"], ["\\tableofcontents", "Table of contents"], ["\\listoffigures", "List of figures"], ["\\listoftables", "List of tables"],
|
|
16
|
+
["\\part", "Part heading", "\\part{${title}}"], ["\\chapter", "Chapter heading", "\\chapter{${title}}"], ["\\section", "Section heading", "\\section{${title}}"],
|
|
17
|
+
["\\subsection", "Subsection heading", "\\subsection{${title}}"], ["\\subsubsection", "Subsubsection heading", "\\subsubsection{${title}}"],
|
|
18
|
+
["\\paragraph", "Paragraph heading", "\\paragraph{${title}}"], ["\\subparagraph", "Subparagraph heading", "\\subparagraph{${title}}"],
|
|
19
|
+
["\\label", "Create label", "\\label{${label}}"], ["\\ref", "Reference label", "\\ref{${label}}"], ["\\pageref", "Page reference", "\\pageref{${label}}"],
|
|
20
|
+
["\\autoref", "Automatic reference", "\\autoref{${label}}"], ["\\hyperref", "Hyperlink reference", "\\hyperref[${label}]{${text}}"],
|
|
21
|
+
["\\cite", "Citation", "\\cite{${key}}"], ["\\citep", "Parenthetical citation", "\\citep{${key}}"], ["\\citet", "Textual citation", "\\citet{${key}}"],
|
|
22
|
+
["\\parencite", "Parenthetical citation", "\\parencite{${key}}"], ["\\textcite", "Textual citation", "\\textcite{${key}}"],
|
|
23
|
+
["\\footnote", "Footnote", "\\footnote{${text}}"], ["\\marginpar", "Margin note", "\\marginpar{${text}}"],
|
|
24
|
+
["\\textbf", "Bold text", "\\textbf{${text}}"], ["\\textit", "Italic text", "\\textit{${text}}"], ["\\texttt", "Monospace text", "\\texttt{${text}}"],
|
|
25
|
+
["\\textrm", "Roman text", "\\textrm{${text}}"], ["\\textsf", "Sans-serif text", "\\textsf{${text}}"], ["\\textsc", "Small caps text", "\\textsc{${text}}"],
|
|
26
|
+
["\\emph", "Emphasized text", "\\emph{${text}}"], ["\\underline", "Underlined text", "\\underline{${text}}"], ["\\mbox", "Unbreakable text", "\\mbox{${text}}"],
|
|
27
|
+
["\\textwidth", "Text width"], ["\\linewidth", "Current line width"], ["\\columnwidth", "Column width"], ["\\paperwidth", "Paper width"],
|
|
28
|
+
["\\include", "Include LaTeX file", "\\include{${file}}"], ["\\input", "Input LaTeX file", "\\input{${file}}"], ["\\includeonly", "Limit included files", "\\includeonly{${file}}"],
|
|
29
|
+
["\\includegraphics", "Include image", "\\includegraphics[width=${0.8}\\textwidth]{${file}}"], ["\\graphicspath", "Image search path", "\\graphicspath{{${path}/}}"],
|
|
30
|
+
["\\caption", "Caption", "\\caption{${caption}}"], ["\\captionof", "Caption outside float", "\\captionof{${type}}{${caption}}"],
|
|
31
|
+
["\\centering", "Center contents"], ["\\raggedright", "Left align contents"], ["\\raggedleft", "Right align contents"], ["\\newline", "Line break"],
|
|
32
|
+
["\\linebreak", "Suggest line break"], ["\\pagebreak", "Suggest page break"], ["\\clearpage", "Flush floats and page break"], ["\\newpage", "Page break"], ["\\vspace", "Vertical space", "\\vspace{${length}}"],
|
|
33
|
+
["\\hspace", "Horizontal space", "\\hspace{${length}}"], ["\\hfill", "Horizontal fill"], ["\\vfill", "Vertical fill"], ["\\rule", "Rule", "\\rule{${width}}{${height}}"],
|
|
34
|
+
["\\ldots", "Ellipsis"], ["\\dots", "Ellipsis"], ["\\LaTeX", "LaTeX logo"], ["\\TeX", "TeX logo"], ["\\today", "Current date"],
|
|
35
|
+
["\\newcommand", "Define command", "\\newcommand{\\${name}}[${args}] {${definition}}"], ["\\renewcommand", "Redefine command"], ["\\providecommand", "Provide command"],
|
|
36
|
+
["\\newenvironment", "Define environment", "\\newenvironment{${name}}{${begin}}{${end}}"], ["\\renewenvironment", "Redefine environment"],
|
|
37
|
+
["\\newlength", "Define length", "\\newlength{\\${name}}"], ["\\setlength", "Set length", "\\setlength{\\${length}}{${value}}"], ["\\addtolength", "Adjust length"],
|
|
38
|
+
["\\newcounter", "Define counter", "\\newcounter{${name}}"], ["\\setcounter", "Set counter"], ["\\stepcounter", "Step counter"], ["\\value", "Counter value"],
|
|
39
|
+
["\\newtheorem", "Define theorem", "\\newtheorem{${name}}{${caption}}"], ["\\DeclareMathOperator", "Define math operator"], ["\\operatorname", "Math operator"],
|
|
40
|
+
["\\frac", "Fraction", "\\frac{${numerator}}{${denominator}}"], ["\\dfrac", "Display fraction", "\\dfrac{${numerator}}{${denominator}}"], ["\\tfrac", "Text fraction", "\\tfrac{${numerator}}{${denominator}}"],
|
|
41
|
+
["\\sqrt", "Square root", "\\sqrt[${index}]{${radicand}}"], ["\\sum", "Summation"], ["\\prod", "Product"], ["\\int", "Integral"], ["\\lim", "Limit"],
|
|
42
|
+
["\\mathbf", "Bold math", "\\mathbf{${symbol}}"], ["\\mathrm", "Roman math", "\\mathrm{${symbol}}"], ["\\mathit", "Italic math", "\\mathit{${symbol}}"],
|
|
43
|
+
["\\left", "Scalable left delimiter"], ["\\right", "Scalable right delimiter"], ["\\text", "Text in math", "\\text{${text}}"],
|
|
44
|
+
["\\begin", "Begin environment", "\\begin{${environment}}"], ["\\end", "End environment", "\\end{${environment}}"],
|
|
45
|
+
["\\item", "List item", "\\item ${text}"],
|
|
46
|
+
["\\bibliography", "Bibliography database", "\\bibliography{${file}}"], ["\\bibliographystyle", "Bibliography style", "\\bibliographystyle{${style}}"],
|
|
47
|
+
["\\printbibliography", "Print bibliography"], ["\\addbibresource", "Add bibliography resource", "\\addbibresource{${file}}"],
|
|
48
|
+
["\\definecolor", "Define color", "\\definecolor{${name}}{${model}}{${specification}}"], ["\\color", "Set text color", "\\color{${color}}"], ["\\textcolor", "Colored text", "\\textcolor{${color}}{${text}}"],
|
|
49
|
+
["\\url", "URL", "\\url{${url}}"], ["\\href", "Hyperlink", "\\href{${url}}{${text}}"], ["\\verb", "Verbatim text"], ["\\verb*", "Verbatim text"],
|
|
50
|
+
["\\begin{itemize}", "Unordered list", "\\begin{itemize}\n\t\\item ${item}\n\\end{itemize}"],
|
|
51
|
+
["\\begin{enumerate}", "Numbered list", "\\begin{enumerate}\n\t\\item ${item}\n\\end{enumerate}"],
|
|
52
|
+
["\\begin{figure}", "Figure environment", "\\begin{figure}[htbp]\n\t\\centering\n\t${content}\n\t\\caption{${caption}}\n\t\\label{fig:${label}}\n\\end{figure}"],
|
|
53
|
+
["\\begin{table}", "Table environment", "\\begin{table}[htbp]\n\t\\centering\n\t${content}\n\t\\caption{${caption}}\n\t\\label{tab:${label}}\n\\end{table}"],
|
|
54
|
+
["\\begin{equation}", "Equation environment", "\\begin{equation}\n\t${equation}\n\\end{equation}"],
|
|
55
|
+
["\\begin{align}", "Aligned equation", "\\begin{align}\n\t${equation}\n\\end{align}"],
|
|
56
|
+
["\\begin{tikzpicture}", "TikZ picture", "\\begin{tikzpicture}\n\t${content}\n\\end{tikzpicture}"],
|
|
57
|
+
["\\begin{document}", "Document body", "\\begin{document}\n${content}\n\\end{document}"]
|
|
58
|
+
];
|
|
59
|
+
const standardEnvironments = [
|
|
60
|
+
["document", "Document body", "\\begin{document}\n${content}\n\\end{document}"],
|
|
61
|
+
["itemize", "Unordered list", "\\begin{itemize}\n\t\\item ${item}\n\\end{itemize}"],
|
|
62
|
+
["enumerate", "Numbered list", "\\begin{enumerate}\n\t\\item ${item}\n\\end{enumerate}"],
|
|
63
|
+
["description", "Description list"], ["figure", "Figure float"], ["table", "Table float"], ["tabular", "Table contents"],
|
|
64
|
+
["equation", "Displayed equation"], ["equation*", "Unnumbered equation"], ["align", "Aligned equations"], ["align*", "Unnumbered aligned equations"],
|
|
65
|
+
["gather", "Gathered equations"], ["multline", "Multiline equation"], ["split", "Split equation"], ["cases", "Cases"],
|
|
66
|
+
["verbatim", "Verbatim block"], ["verbatim*", "Verbatim block"], ["quote", "Short quotation"], ["quotation", "Long quotation"], ["verse", "Verse"],
|
|
67
|
+
["center", "Centered block"], ["flushleft", "Left-aligned block"], ["flushright", "Right-aligned block"], ["minipage", "Mini page"],
|
|
68
|
+
["abstract", "Abstract"], ["appendix", "Appendix"], ["theorem", "Theorem"], ["lemma", "Lemma"], ["proof", "Proof"],
|
|
69
|
+
["tikzpicture", "TikZ picture"], ["frame", "Beamer frame"]
|
|
70
|
+
];
|
|
71
|
+
const standardPackages = [
|
|
72
|
+
"amsmath", "amssymb", "amsthm", "array", "babel", "biblatex", "booktabs", "caption", "cleveref", "enumitem", "float",
|
|
73
|
+
"geometry", "graphicx", "hyperref", "listings", "longtable", "mathtools", "microtype", "multirow", "natbib", "pgfplots",
|
|
74
|
+
"tcolorbox", "tikz", "url", "xcolor", "xurl"
|
|
75
|
+
];
|
|
76
|
+
const standardClasses = ["article", "report", "book", "letter", "memoir", "beamer", "standalone", "scrartcl", "scrreprt", "scrbook", "IEEEtran", "acmart"];
|
|
77
|
+
function item(label, detail, kind, source, apply) {
|
|
78
|
+
return { label, detail, kind, source, ...(apply ? { apply } : {}) };
|
|
79
|
+
}
|
|
80
|
+
function createIndex() {
|
|
81
|
+
return {
|
|
82
|
+
commands: new Map(), environments: new Map(), labels: new Map(), citations: new Map(), packages: new Map(), files: new Map()
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
function withoutComments(content) {
|
|
86
|
+
return content.split("\n").map((line) => line.replace(/(^|[^\\])%.*$/, "$1")).join("\n");
|
|
87
|
+
}
|
|
88
|
+
function commandItem(index, name, detail, source, apply) {
|
|
89
|
+
if (!name.startsWith("\\"))
|
|
90
|
+
name = `\\${name}`;
|
|
91
|
+
if (!/^\\[A-Za-z@][A-Za-z@0-9:_]*$/.test(name))
|
|
92
|
+
return;
|
|
93
|
+
const existing = index.commands.get(name);
|
|
94
|
+
// A project definition is more useful than the generic built-in entry: it
|
|
95
|
+
// carries the actual argument count and therefore the right snippet.
|
|
96
|
+
if (!existing || (existing.source === "LaTeX" && source !== "LaTeX"))
|
|
97
|
+
index.commands.set(name, item(name, detail, "function", source, apply));
|
|
98
|
+
}
|
|
99
|
+
function commandSnippet(name, argumentCount) {
|
|
100
|
+
if (argumentCount <= 0)
|
|
101
|
+
return undefined;
|
|
102
|
+
const placeholder = (index) => `\${${index}}`;
|
|
103
|
+
return name + Array.from({ length: argumentCount }, (_, index) => `{${placeholder(index + 1)}}`).join("");
|
|
104
|
+
}
|
|
105
|
+
function xparseArgumentCount(specification) {
|
|
106
|
+
// The common xparse argument types all consume one user supplied value.
|
|
107
|
+
// Modifiers such as `+` and argument delimiters are intentionally ignored.
|
|
108
|
+
return [...specification.matchAll(/[moOrRdDsStvb]/g)].length;
|
|
109
|
+
}
|
|
110
|
+
function extractSymbols(index, filePath, original) {
|
|
111
|
+
const content = withoutComments(original);
|
|
112
|
+
const source = filePath;
|
|
113
|
+
for (const match of content.matchAll(/\\(?:newcommand|renewcommand|providecommand|DeclareRobustCommand)\s*\*?\s*(?:\{\s*)?\\([A-Za-z@][A-Za-z@0-9:_]*)\s*(?:\})?\s*(?:\[(\d+)\])?/g)) {
|
|
114
|
+
const args = Number.parseInt(match[2] ?? "0", 10);
|
|
115
|
+
commandItem(index, `\\${match[1]}`, `Project command (${args} argument${args === 1 ? "" : "s"})`, source, commandSnippet(`\\${match[1]}`, args));
|
|
116
|
+
}
|
|
117
|
+
for (const match of content.matchAll(/\\(?:NewDocumentCommand|RenewDocumentCommand|ProvideDocumentCommand|DeclareDocumentCommand|DeclareExpandableDocumentCommand|RenewExpandableDocumentCommand|ProvideExpandableDocumentCommand)\s*\{\s*\\([A-Za-z@][A-Za-z@0-9:_]*)\s*\}\s*\{([^}]*)\}/g)) {
|
|
118
|
+
const args = xparseArgumentCount(match[2]);
|
|
119
|
+
commandItem(index, `\\${match[1]}`, `Project command (${args} argument${args === 1 ? "" : "s"})`, source, commandSnippet(`\\${match[1]}`, args));
|
|
120
|
+
}
|
|
121
|
+
for (const match of content.matchAll(/\\(?:def|gdef|edef|xdef)\s*\\([A-Za-z@][A-Za-z@0-9:_]*)((?:\s*#\d+)*)/g)) {
|
|
122
|
+
const args = [...(match[2] ?? "").matchAll(/#\d+/g)].length;
|
|
123
|
+
commandItem(index, `\\${match[1]}`, "Project macro", source, commandSnippet(`\\${match[1]}`, args));
|
|
124
|
+
}
|
|
125
|
+
for (const match of content.matchAll(/\\DeclareMathOperator\s*\*?\s*\{?\\([A-Za-z@][A-Za-z@0-9:_]*)\}?/g))
|
|
126
|
+
commandItem(index, `\\${match[1]}`, "Project math operator", source);
|
|
127
|
+
for (const match of content.matchAll(/\\DeclarePairedDelimiter\s*\{?\\([A-Za-z@][A-Za-z@0-9:_]*)\}?/g))
|
|
128
|
+
commandItem(index, `\\${match[1]}`, "Project math delimiter", source);
|
|
129
|
+
for (const match of content.matchAll(/\\cs_(?:new|set|gset|provide|generate)(?:_protected)?\:[A-Za-z]+\s+\\([A-Za-z@][A-Za-z@0-9:_]*)/g))
|
|
130
|
+
commandItem(index, `\\${match[1]}`, "Expl3 project command", source);
|
|
131
|
+
for (const match of content.matchAll(/\\(?:newenvironment|renewenvironment|NewDocumentEnvironment|RenewDocumentEnvironment|DeclareDocumentEnvironment)\s*\*?\s*\{([^}]+)\}/g)) {
|
|
132
|
+
const name = match[1].trim();
|
|
133
|
+
const existing = index.environments.get(name);
|
|
134
|
+
if (/^[A-Za-z][A-Za-z0-9*_-]*$/.test(name) && (!existing || (existing.source === "LaTeX" && source !== "LaTeX"))) {
|
|
135
|
+
index.environments.set(name, item(name, "Project environment", "keyword", source));
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
for (const match of content.matchAll(/\\(?:newtheorem|declaretheorem)\s*\*?\s*\{([^}]+)\}/g)) {
|
|
139
|
+
const name = match[1].trim();
|
|
140
|
+
const existing = index.environments.get(name);
|
|
141
|
+
if (/^[A-Za-z][A-Za-z0-9*_-]*$/.test(name) && (!existing || (existing.source === "LaTeX" && source !== "LaTeX"))) {
|
|
142
|
+
index.environments.set(name, item(name, "Theorem environment", "keyword", source));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
for (const match of content.matchAll(/\\(?:label|hypertarget)\s*\{([^}]+)\}/g)) {
|
|
146
|
+
const label = match[1].trim();
|
|
147
|
+
if (label && !index.labels.has(label))
|
|
148
|
+
index.labels.set(label, item(label, "Label", "constant", source));
|
|
149
|
+
}
|
|
150
|
+
for (const match of content.matchAll(/\\(?:input|include|subfile|import)\s*(?:\{([^}]+)\}|\s+([^\s%]+))/g)) {
|
|
151
|
+
const file = (match[1] ?? match[2] ?? "").trim();
|
|
152
|
+
if (file)
|
|
153
|
+
index.files.set(file, item(file, "Project file", "text", source));
|
|
154
|
+
}
|
|
155
|
+
for (const match of content.matchAll(/\\(?:usepackage|RequirePackage|documentclass)\s*(?:\[[^]]*\])?\s*\{([^}]+)\}/g)) {
|
|
156
|
+
for (const packageName of match[1].split(",").map((value) => value.trim()).filter(Boolean)) {
|
|
157
|
+
const target = /documentclass|documentstyle/.test(match[0]) ? index.files : index.packages;
|
|
158
|
+
if (!target.has(packageName))
|
|
159
|
+
target.set(packageName, item(packageName, target === index.files ? "Document class" : "Package", "text", source));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
for (const match of content.matchAll(/\\(?:cite|citep|citet|parencite|textcite|autocite|footcite)(?:\w*)?\s*(?:\[[^]]*\])?\s*\{([^}]+)\}/g)) {
|
|
163
|
+
for (const key of match[1].split(",").map((value) => value.trim()).filter(Boolean)) {
|
|
164
|
+
if (!index.citations.has(key))
|
|
165
|
+
index.citations.set(key, item(key, "Citation key", "constant", source));
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
for (const match of content.matchAll(/\\bibitem\s*(?:\[[^]]*\])?\s*\{([^}]+)\}/g)) {
|
|
169
|
+
const key = match[1].trim();
|
|
170
|
+
if (key && !index.citations.has(key))
|
|
171
|
+
index.citations.set(key, item(key, "Bibliography key", "constant", source));
|
|
172
|
+
}
|
|
173
|
+
for (const match of content.matchAll(/@(?:\w+)\s*\{\s*([^,\s]+)\s*,/g)) {
|
|
174
|
+
const key = match[1].trim();
|
|
175
|
+
if (key && !index.citations.has(key))
|
|
176
|
+
index.citations.set(key, item(key, "BibTeX key", "constant", source));
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
function sorted(map) {
|
|
180
|
+
return [...map.values()].sort((left, right) => left.label.localeCompare(right.label));
|
|
181
|
+
}
|
|
182
|
+
function standardIndex() {
|
|
183
|
+
const index = createIndex();
|
|
184
|
+
for (const [label, detail, apply] of standardCommands)
|
|
185
|
+
commandItem(index, label, detail, "LaTeX", apply);
|
|
186
|
+
for (const [label, detail, apply] of standardEnvironments)
|
|
187
|
+
index.environments.set(label, item(label, detail, "keyword", "LaTeX", apply));
|
|
188
|
+
for (const packageName of standardPackages)
|
|
189
|
+
index.packages.set(packageName.trim(), item(packageName.trim(), "Package", "text", "LaTeX"));
|
|
190
|
+
for (const className of standardClasses)
|
|
191
|
+
index.files.set(className, item(className, "Document class", "class", "LaTeX"));
|
|
192
|
+
return index;
|
|
193
|
+
}
|
|
194
|
+
export function buildLatexCompletionIndex(config, projectId) {
|
|
195
|
+
const index = standardIndex();
|
|
196
|
+
const allEntries = listProjectFiles(config, projectId).filter((entry) => entry.type === "file").slice(0, maxIndexedFiles);
|
|
197
|
+
for (const entry of allEntries)
|
|
198
|
+
index.files.set(entry.path, item(entry.path, "Project file", "text", "Project"));
|
|
199
|
+
const entries = allEntries.filter((entry) => textExtensions.test(entry.path));
|
|
200
|
+
let indexedBytes = 0;
|
|
201
|
+
for (const entry of entries) {
|
|
202
|
+
const absolute = resolveSourcePath(config, projectId, entry.path);
|
|
203
|
+
let content;
|
|
204
|
+
try {
|
|
205
|
+
const size = fs.statSync(absolute).size;
|
|
206
|
+
if (size > maxIndexedFileBytes || indexedBytes + size > maxIndexedBytes)
|
|
207
|
+
continue;
|
|
208
|
+
indexedBytes += size;
|
|
209
|
+
content = fs.readFileSync(absolute, "utf8");
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
if (definitionExtensions.test(entry.path) || /\.bib$/i.test(entry.path))
|
|
215
|
+
extractSymbols(index, entry.path, content);
|
|
216
|
+
}
|
|
217
|
+
return {
|
|
218
|
+
commands: sorted(index.commands), environments: sorted(index.environments), labels: sorted(index.labels),
|
|
219
|
+
citations: sorted(index.citations), packages: sorted(index.packages), files: sorted(index.files)
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
/** Reuses extracted symbols for files whose mtime and size did not change. */
|
|
223
|
+
export class LatexCompletionService {
|
|
224
|
+
config;
|
|
225
|
+
cache = new Map();
|
|
226
|
+
pending = new Map();
|
|
227
|
+
constructor(config) {
|
|
228
|
+
this.config = config;
|
|
229
|
+
}
|
|
230
|
+
build(projectId) {
|
|
231
|
+
const existing = this.pending.get(projectId);
|
|
232
|
+
if (existing)
|
|
233
|
+
return existing;
|
|
234
|
+
const request = this.buildIncremental(projectId).finally(() => {
|
|
235
|
+
if (this.pending.get(projectId) === request)
|
|
236
|
+
this.pending.delete(projectId);
|
|
237
|
+
});
|
|
238
|
+
this.pending.set(projectId, request);
|
|
239
|
+
return request;
|
|
240
|
+
}
|
|
241
|
+
invalidate(projectId) {
|
|
242
|
+
this.cache.delete(projectId);
|
|
243
|
+
}
|
|
244
|
+
async buildIncremental(projectId) {
|
|
245
|
+
const index = standardIndex();
|
|
246
|
+
const projectCache = this.cache.get(projectId) ?? new Map();
|
|
247
|
+
this.cache.set(projectId, projectCache);
|
|
248
|
+
const allEntries = (await listProjectFilesAsync(this.config, projectId)).filter((entry) => entry.type === "file").slice(0, maxIndexedFiles);
|
|
249
|
+
const livePaths = new Set(allEntries.map((entry) => entry.path));
|
|
250
|
+
for (const cachedPath of [...projectCache.keys()])
|
|
251
|
+
if (!livePaths.has(cachedPath))
|
|
252
|
+
projectCache.delete(cachedPath);
|
|
253
|
+
for (const entry of allEntries)
|
|
254
|
+
index.files.set(entry.path, item(entry.path, "Project file", "text", "Project"));
|
|
255
|
+
let indexedBytes = 0;
|
|
256
|
+
for (const entry of allEntries.filter((candidate) => textExtensions.test(candidate.path))) {
|
|
257
|
+
const size = entry.size ?? 0;
|
|
258
|
+
if (size > maxIndexedFileBytes || indexedBytes + size > maxIndexedBytes)
|
|
259
|
+
continue;
|
|
260
|
+
indexedBytes += size;
|
|
261
|
+
const signature = `${size}:${entry.mtimeMs ?? 0}`;
|
|
262
|
+
let cached = projectCache.get(entry.path);
|
|
263
|
+
if (!cached || cached.signature !== signature) {
|
|
264
|
+
let content;
|
|
265
|
+
try {
|
|
266
|
+
content = await fs.promises.readFile(resolveSourcePath(this.config, projectId, entry.path), "utf8");
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
projectCache.delete(entry.path);
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
const fileIndex = createIndex();
|
|
273
|
+
if (definitionExtensions.test(entry.path) || /\.bib$/i.test(entry.path))
|
|
274
|
+
extractSymbols(fileIndex, entry.path, content);
|
|
275
|
+
cached = { signature, index: fileIndex };
|
|
276
|
+
projectCache.set(entry.path, cached);
|
|
277
|
+
}
|
|
278
|
+
mergeIndex(index, cached.index);
|
|
279
|
+
}
|
|
280
|
+
return {
|
|
281
|
+
commands: sorted(index.commands), environments: sorted(index.environments), labels: sorted(index.labels),
|
|
282
|
+
citations: sorted(index.citations), packages: sorted(index.packages), files: sorted(index.files)
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
function mergeIndex(target, source) {
|
|
287
|
+
for (const value of source.commands.values())
|
|
288
|
+
commandItem(target, value.label, value.detail, value.source ?? "Project", value.apply);
|
|
289
|
+
for (const key of ["environments", "labels", "citations", "packages", "files"]) {
|
|
290
|
+
for (const [label, value] of source[key]) {
|
|
291
|
+
const existing = target[key].get(label);
|
|
292
|
+
if (!existing || (existing.source === "LaTeX" && value.source !== "LaTeX"))
|
|
293
|
+
target[key].set(label, value);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** A deliberately small in-memory window; metrics never contain source text. */
|
|
2
|
+
export class MetricRegistry {
|
|
3
|
+
capacity;
|
|
4
|
+
samples = new Map();
|
|
5
|
+
constructor(capacity = 200) {
|
|
6
|
+
this.capacity = capacity;
|
|
7
|
+
}
|
|
8
|
+
record(name, milliseconds) {
|
|
9
|
+
if (!Number.isFinite(milliseconds) || milliseconds < 0)
|
|
10
|
+
return;
|
|
11
|
+
const values = this.samples.get(name) ?? [];
|
|
12
|
+
values.push(Math.round(milliseconds * 10) / 10);
|
|
13
|
+
if (values.length > this.capacity)
|
|
14
|
+
values.splice(0, values.length - this.capacity);
|
|
15
|
+
this.samples.set(name, values);
|
|
16
|
+
}
|
|
17
|
+
summaries() {
|
|
18
|
+
return Object.fromEntries([...this.samples].map(([name, values]) => {
|
|
19
|
+
const sorted = [...values].sort((left, right) => left - right);
|
|
20
|
+
return [name, {
|
|
21
|
+
count: values.length,
|
|
22
|
+
last: values.at(-1) ?? 0,
|
|
23
|
+
p50: percentile(sorted, 0.5),
|
|
24
|
+
p95: percentile(sorted, 0.95),
|
|
25
|
+
max: sorted.at(-1) ?? 0
|
|
26
|
+
}];
|
|
27
|
+
}));
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function percentile(sorted, ratio) {
|
|
31
|
+
if (!sorted.length)
|
|
32
|
+
return 0;
|
|
33
|
+
return sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil(sorted.length * ratio) - 1))];
|
|
34
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { defaultConfigPath, packageRootDirectory, packageServerEntry } from "./runtimePaths.js";
|
|
5
|
+
let pm2Client = null;
|
|
6
|
+
async function loadPm2() {
|
|
7
|
+
if (pm2Client)
|
|
8
|
+
return pm2Client;
|
|
9
|
+
const module = await import("pm2");
|
|
10
|
+
pm2Client = (module.default ?? module);
|
|
11
|
+
return pm2Client;
|
|
12
|
+
}
|
|
13
|
+
function callback(operation) {
|
|
14
|
+
return new Promise((resolve, reject) => {
|
|
15
|
+
operation((error, value) => error ? reject(error) : resolve(value));
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
async function connect() {
|
|
19
|
+
const client = await loadPm2();
|
|
20
|
+
await callback((done) => client.connect((error) => done(error ?? null)));
|
|
21
|
+
}
|
|
22
|
+
function disconnect() {
|
|
23
|
+
try {
|
|
24
|
+
pm2Client?.disconnect();
|
|
25
|
+
}
|
|
26
|
+
catch { /* PM2 may already have disconnected after a daemon error. */ }
|
|
27
|
+
}
|
|
28
|
+
function list() {
|
|
29
|
+
if (!pm2Client)
|
|
30
|
+
return Promise.reject(new Error("PM2 is not connected"));
|
|
31
|
+
return callback((done) => pm2Client.list((error, processes) => done(error ?? null, processes)));
|
|
32
|
+
}
|
|
33
|
+
function processId(process) {
|
|
34
|
+
return process.pm_id ?? process.name ?? "";
|
|
35
|
+
}
|
|
36
|
+
function environmentOf(process) {
|
|
37
|
+
const value = process.pm2_env;
|
|
38
|
+
return value?.env && typeof value.env === "object" ? value.env : {};
|
|
39
|
+
}
|
|
40
|
+
export function processName(configPath) {
|
|
41
|
+
const absoluteConfigPath = path.resolve(configPath);
|
|
42
|
+
if (absoluteConfigPath === defaultConfigPath())
|
|
43
|
+
return "texlite";
|
|
44
|
+
const digest = crypto.createHash("sha256").update(absoluteConfigPath).digest("hex").slice(0, 8);
|
|
45
|
+
return `texlite-${digest}`;
|
|
46
|
+
}
|
|
47
|
+
export async function managedProcess(configPath) {
|
|
48
|
+
const absoluteConfigPath = path.resolve(configPath);
|
|
49
|
+
const name = processName(absoluteConfigPath);
|
|
50
|
+
await connect();
|
|
51
|
+
try {
|
|
52
|
+
const processes = await list();
|
|
53
|
+
assertNameAvailable(processes, name, absoluteConfigPath);
|
|
54
|
+
const description = processes.find((process) => process.name === name
|
|
55
|
+
&& environmentOf(process).TEXLITE_CONFIG === absoluteConfigPath) ?? null;
|
|
56
|
+
return { name, configPath: absoluteConfigPath, description };
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
disconnect();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function startOptions(config) {
|
|
63
|
+
const packageRoot = packageRootDirectory();
|
|
64
|
+
const script = packageServerEntry();
|
|
65
|
+
const environment = {
|
|
66
|
+
...Object.fromEntries(Object.entries(process.env).filter((entry) => typeof entry[1] === "string")),
|
|
67
|
+
NODE_ENV: "production",
|
|
68
|
+
TEXLITE_CONFIG: path.resolve(config.configPath),
|
|
69
|
+
TEXLITE_CLIENT_DIR: path.resolve(config.clientDir)
|
|
70
|
+
};
|
|
71
|
+
return {
|
|
72
|
+
name: processName(config.configPath),
|
|
73
|
+
script,
|
|
74
|
+
cwd: packageRoot,
|
|
75
|
+
exec_mode: "fork",
|
|
76
|
+
instances: 1,
|
|
77
|
+
autorestart: true,
|
|
78
|
+
watch: false,
|
|
79
|
+
max_memory_restart: "512M",
|
|
80
|
+
kill_timeout: 10_000,
|
|
81
|
+
env: environment
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
export async function startManaged(config) {
|
|
85
|
+
await connect();
|
|
86
|
+
try {
|
|
87
|
+
const name = processName(config.configPath);
|
|
88
|
+
const absoluteConfigPath = path.resolve(config.configPath);
|
|
89
|
+
const processes = await list();
|
|
90
|
+
assertNameAvailable(processes, name, absoluteConfigPath);
|
|
91
|
+
const existing = processes.find((process) => process.name === name
|
|
92
|
+
&& environmentOf(process).TEXLITE_CONFIG === absoluteConfigPath);
|
|
93
|
+
if (existing) {
|
|
94
|
+
const status = existing.pm2_env?.status;
|
|
95
|
+
if (status === "online" || status === "launching")
|
|
96
|
+
return { name, configPath: config.configPath, description: existing };
|
|
97
|
+
await callback((done) => pm2Client.restart(processId(existing), (error) => done(error ?? null)));
|
|
98
|
+
const refreshed = (await list()).find((process) => process.name === name) ?? existing;
|
|
99
|
+
return { name, configPath: config.configPath, description: refreshed };
|
|
100
|
+
}
|
|
101
|
+
const started = await callback((done) => pm2Client.start(startOptions(config), (error, process) => done(error ?? null, process)));
|
|
102
|
+
const description = Array.isArray(started) ? started[0] : started;
|
|
103
|
+
return { name, configPath: config.configPath, description };
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
disconnect();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
export async function stopManaged(configPath) {
|
|
110
|
+
await connect();
|
|
111
|
+
try {
|
|
112
|
+
const name = processName(configPath);
|
|
113
|
+
const absoluteConfigPath = path.resolve(configPath);
|
|
114
|
+
const processes = await list();
|
|
115
|
+
assertNameAvailable(processes, name, absoluteConfigPath);
|
|
116
|
+
const existing = processes.find((process) => process.name === name
|
|
117
|
+
&& environmentOf(process).TEXLITE_CONFIG === absoluteConfigPath);
|
|
118
|
+
if (existing && existing.pm2_env?.status !== "stopped") {
|
|
119
|
+
await callback((done) => pm2Client.stop(processId(existing), (error) => done(error ?? null)));
|
|
120
|
+
}
|
|
121
|
+
const description = (await list()).find((process) => process.name === name
|
|
122
|
+
&& environmentOf(process).TEXLITE_CONFIG === absoluteConfigPath) ?? existing ?? null;
|
|
123
|
+
return { name, configPath: absoluteConfigPath, description };
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
disconnect();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
export async function restartManaged(config) {
|
|
130
|
+
await connect();
|
|
131
|
+
try {
|
|
132
|
+
const name = processName(config.configPath);
|
|
133
|
+
const absoluteConfigPath = path.resolve(config.configPath);
|
|
134
|
+
const processes = await list();
|
|
135
|
+
assertNameAvailable(processes, name, absoluteConfigPath);
|
|
136
|
+
const existing = processes.find((process) => process.name === name
|
|
137
|
+
&& environmentOf(process).TEXLITE_CONFIG === absoluteConfigPath);
|
|
138
|
+
if (!existing) {
|
|
139
|
+
const started = await callback((done) => pm2Client.start(startOptions(config), (error, process) => done(error ?? null, process)));
|
|
140
|
+
return { name, configPath: config.configPath, description: Array.isArray(started) ? started[0] : started };
|
|
141
|
+
}
|
|
142
|
+
await callback((done) => pm2Client.restart(processId(existing), (error) => done(error ?? null)));
|
|
143
|
+
const description = (await list()).find((process) => process.name === name) ?? existing;
|
|
144
|
+
return { name, configPath: config.configPath, description };
|
|
145
|
+
}
|
|
146
|
+
finally {
|
|
147
|
+
disconnect();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
export async function processStatus(config) {
|
|
151
|
+
const managed = await managedProcess(config.configPath);
|
|
152
|
+
const description = managed.description;
|
|
153
|
+
const env = description ? description.pm2_env : undefined;
|
|
154
|
+
return {
|
|
155
|
+
name: managed.name,
|
|
156
|
+
configPath: managed.configPath,
|
|
157
|
+
status: env?.status ?? "missing",
|
|
158
|
+
pid: description?.pid ?? null,
|
|
159
|
+
uptime: env?.pm_uptime ?? null,
|
|
160
|
+
restarts: env?.restart_time ?? 0,
|
|
161
|
+
version: packageVersion(),
|
|
162
|
+
address: `http://${config.host}:${config.port}`,
|
|
163
|
+
dataDir: config.dataDir,
|
|
164
|
+
cwd: env?.pm_cwd ?? packageRootDirectory(),
|
|
165
|
+
outputLog: env?.pm_out_log_path ?? null,
|
|
166
|
+
errorLog: env?.pm_err_log_path ?? null
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
/** Wait until PM2 has actually launched the service, rather than reporting
|
|
170
|
+
* success while it is still in the `launching` state. */
|
|
171
|
+
export async function waitForOnline(config, timeoutMs = 15_000) {
|
|
172
|
+
const deadline = Date.now() + timeoutMs;
|
|
173
|
+
let latest = await managedProcess(config.configPath);
|
|
174
|
+
while (Date.now() < deadline) {
|
|
175
|
+
const status = latest.description?.pm2_env?.status;
|
|
176
|
+
if (status === "online")
|
|
177
|
+
return latest;
|
|
178
|
+
if (status === "errored" || status === "stopped") {
|
|
179
|
+
throw new Error(`PM2 could not start TexLite (current status: ${status}). Run texlite logs to inspect the logs.`);
|
|
180
|
+
}
|
|
181
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
182
|
+
latest = await managedProcess(config.configPath);
|
|
183
|
+
}
|
|
184
|
+
const status = latest.description?.pm2_env?.status ?? "missing";
|
|
185
|
+
throw new Error(`Timed out waiting for TexLite to start (current status: ${status}). Run texlite status or texlite logs to investigate.`);
|
|
186
|
+
}
|
|
187
|
+
export async function streamLogs(config) {
|
|
188
|
+
await connect();
|
|
189
|
+
const managed = (await list()).find((process) => process.name === processName(config.configPath)
|
|
190
|
+
&& environmentOf(process).TEXLITE_CONFIG === path.resolve(config.configPath));
|
|
191
|
+
if (!managed) {
|
|
192
|
+
disconnect();
|
|
193
|
+
throw new Error("TexLite is not currently managed by PM2. Run texlite start first.");
|
|
194
|
+
}
|
|
195
|
+
const bus = await callback((done) => {
|
|
196
|
+
pm2Client.launchBus((error, value) => done(error ?? null, value));
|
|
197
|
+
});
|
|
198
|
+
const print = (packet, error) => {
|
|
199
|
+
if (!packet || typeof packet !== "object")
|
|
200
|
+
return;
|
|
201
|
+
const value = packet;
|
|
202
|
+
if (value.process?.name !== managed.name)
|
|
203
|
+
return;
|
|
204
|
+
const data = typeof value.data === "string" ? value.data : JSON.stringify(value.data);
|
|
205
|
+
process.stdout.write(`${error ? "[error] " : ""}${data}\n`);
|
|
206
|
+
};
|
|
207
|
+
bus.on("log:out", (packet) => print(packet, false));
|
|
208
|
+
bus.on("log:err", (packet) => print(packet, true));
|
|
209
|
+
await new Promise((resolve) => {
|
|
210
|
+
const finish = () => { disconnect(); resolve(); };
|
|
211
|
+
process.once("SIGINT", finish);
|
|
212
|
+
process.once("SIGTERM", finish);
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
function packageVersion() {
|
|
216
|
+
try {
|
|
217
|
+
const packageJson = requirePackageJson();
|
|
218
|
+
return typeof packageJson.version === "string" ? packageJson.version : "unknown";
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
return "unknown";
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
function requirePackageJson() {
|
|
225
|
+
const packagePath = path.join(packageRootDirectory(), "package.json");
|
|
226
|
+
return JSON.parse(fs.readFileSync(packagePath, "utf8"));
|
|
227
|
+
}
|
|
228
|
+
function assertNameAvailable(processes, name, configPath) {
|
|
229
|
+
const collision = processes.find((process) => process.name === name
|
|
230
|
+
&& environmentOf(process).TEXLITE_CONFIG !== configPath);
|
|
231
|
+
if (collision) {
|
|
232
|
+
throw new Error(`The PM2 name ${name} is already used by another configuration. Remove the old process or use a different configuration path and try again.`);
|
|
233
|
+
}
|
|
234
|
+
}
|