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,195 @@
|
|
|
1
|
+
import nspell from "nspell";
|
|
2
|
+
import dictionary from "dictionary-en";
|
|
3
|
+
const checker = nspell(dictionary);
|
|
4
|
+
const wordPattern = /[A-Za-z][A-Za-z0-9]*(?:['’][A-Za-z0-9]+)*/g;
|
|
5
|
+
const keyTokenPattern = /([A-Za-z][A-Za-z0-9_.:/-]*(?:\s+[A-Za-z][A-Za-z0-9_.:/-]*){0,7})\s*$/;
|
|
6
|
+
const tableEnvironments = new Set(["tabular", "tabularx", "tabulary", "longtable", "array", "matrix", "pmatrix", "bmatrix", "Bmatrix", "vmatrix", "Vmatrix"]);
|
|
7
|
+
const optionCommands = new Set(["documentclass", "usepackage", "RequirePackage", "includegraphics", "tikzset", "pgfplotsset", "hypersetup", "lstset", "definecolor", "colorlet", "setlength", "setcounter", "draw", "path", "fill", "filldraw", "shade", "node", "addplot"]);
|
|
8
|
+
const identifierCommands = new Set(["label", "hypertarget", "ref", "pageref", "autoref", "nameref", "hyperref", "index", "gls", "Gls", "glspl", "Glspl", "cite", "citep", "citet", "citeauthor", "citeyear", "citenum", "parencite", "textcite", "autocite", "footcite"]);
|
|
9
|
+
function addRange(ranges, from, to) {
|
|
10
|
+
if (to > from)
|
|
11
|
+
ranges.push({ from, to });
|
|
12
|
+
}
|
|
13
|
+
function balancedArgument(source, start, open, close) {
|
|
14
|
+
if (source[start] !== open)
|
|
15
|
+
return null;
|
|
16
|
+
let depth = 0;
|
|
17
|
+
for (let index = start; index < source.length; index += 1) {
|
|
18
|
+
const escaped = source[index - 1] === "\\" && source[index - 2] !== "\\";
|
|
19
|
+
if (!escaped && source[index] === open)
|
|
20
|
+
depth += 1;
|
|
21
|
+
if (!escaped && source[index] === close) {
|
|
22
|
+
depth -= 1;
|
|
23
|
+
if (depth === 0)
|
|
24
|
+
return { from: start, to: index + 1, end: index + 1 };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
function skipWhitespace(source, start) {
|
|
30
|
+
let index = start;
|
|
31
|
+
while (index < source.length && /[ \t\r\n]/.test(source[index]))
|
|
32
|
+
index += 1;
|
|
33
|
+
return index;
|
|
34
|
+
}
|
|
35
|
+
function isCommentStart(source, position) {
|
|
36
|
+
if (source[position] !== "%")
|
|
37
|
+
return false;
|
|
38
|
+
let slashes = 0;
|
|
39
|
+
for (let index = position - 1; index >= 0 && source[index] === "\\"; index -= 1)
|
|
40
|
+
slashes += 1;
|
|
41
|
+
return slashes % 2 === 0;
|
|
42
|
+
}
|
|
43
|
+
function optionLike(value) {
|
|
44
|
+
return /[=,!/]/.test(value) || /\b(?:colorbar|legend|width|height|draw|fill|style|domain|samples|anchor|at|axis)\b/.test(value);
|
|
45
|
+
}
|
|
46
|
+
function identifierCommand(name) {
|
|
47
|
+
return identifierCommands.has(name) || /^cite[A-Za-z]*/.test(name);
|
|
48
|
+
}
|
|
49
|
+
function addKeyValueRanges(source, ranges) {
|
|
50
|
+
for (let equals = source.indexOf("="); equals >= 0; equals = source.indexOf("=", equals + 1)) {
|
|
51
|
+
const delimiters = ["\n", "[", "{", "]", "}", ",", ";"];
|
|
52
|
+
const segmentStart = Math.max(...delimiters.map((delimiter) => source.lastIndexOf(delimiter, equals - 1) + 1));
|
|
53
|
+
const segment = source.slice(segmentStart, equals);
|
|
54
|
+
const key = segment.match(keyTokenPattern);
|
|
55
|
+
if (key?.index !== undefined)
|
|
56
|
+
addRange(ranges, segmentStart + key.index, equals);
|
|
57
|
+
let valueStart = equals + 1;
|
|
58
|
+
while (valueStart < source.length && /\s/.test(source[valueStart]))
|
|
59
|
+
valueStart += 1;
|
|
60
|
+
let valueEnd = valueStart;
|
|
61
|
+
if (source[valueStart] === "{") {
|
|
62
|
+
const argument = balancedArgument(source, valueStart, "{", "}");
|
|
63
|
+
valueEnd = argument?.end ?? valueStart;
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
while (valueEnd < source.length && !/[,\]\}\n]/.test(source[valueEnd]))
|
|
67
|
+
valueEnd += 1;
|
|
68
|
+
}
|
|
69
|
+
addRange(ranges, valueStart, valueEnd);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function ignoredRanges(source) {
|
|
73
|
+
const ranges = [];
|
|
74
|
+
const commandPattern = /[A-Za-z@]/;
|
|
75
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
76
|
+
if (isCommentStart(source, index)) {
|
|
77
|
+
const end = source.indexOf("\n", index);
|
|
78
|
+
addRange(ranges, index, end < 0 ? source.length : end);
|
|
79
|
+
index = end < 0 ? source.length : end;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (source[index] !== "\\")
|
|
83
|
+
continue;
|
|
84
|
+
const commandStart = index;
|
|
85
|
+
let commandEnd = index + 1;
|
|
86
|
+
if (commandPattern.test(source[commandEnd] ?? "")) {
|
|
87
|
+
while (commandEnd < source.length && /[A-Za-z@0-9:_]/.test(source[commandEnd]))
|
|
88
|
+
commandEnd += 1;
|
|
89
|
+
}
|
|
90
|
+
else if (commandEnd < source.length) {
|
|
91
|
+
commandEnd += 1;
|
|
92
|
+
}
|
|
93
|
+
const name = source.slice(index + 1, commandEnd);
|
|
94
|
+
addRange(ranges, commandStart, commandEnd);
|
|
95
|
+
let cursor = skipWhitespace(source, commandEnd);
|
|
96
|
+
if (name === "begin" || name === "end") {
|
|
97
|
+
const environment = balancedArgument(source, cursor, "{", "}");
|
|
98
|
+
if (environment) {
|
|
99
|
+
addRange(ranges, environment.from, environment.to);
|
|
100
|
+
const environmentName = source.slice(environment.from + 1, environment.to - 1).trim();
|
|
101
|
+
cursor = skipWhitespace(source, environment.end);
|
|
102
|
+
if (name === "begin") {
|
|
103
|
+
const options = balancedArgument(source, cursor, "[", "]");
|
|
104
|
+
if (options) {
|
|
105
|
+
addRange(ranges, options.from, options.to);
|
|
106
|
+
cursor = skipWhitespace(source, options.end);
|
|
107
|
+
}
|
|
108
|
+
if (tableEnvironments.has(environmentName)) {
|
|
109
|
+
const columnSpec = balancedArgument(source, cursor, "{", "}");
|
|
110
|
+
if (columnSpec)
|
|
111
|
+
addRange(ranges, columnSpec.from, columnSpec.to);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
index = commandEnd - 1;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (identifierCommand(name)) {
|
|
119
|
+
if (source[cursor] === "*")
|
|
120
|
+
cursor = skipWhitespace(source, cursor + 1);
|
|
121
|
+
while (true) {
|
|
122
|
+
const argument = balancedArgument(source, cursor, "[", "]") ?? balancedArgument(source, cursor, "{", "}");
|
|
123
|
+
if (!argument)
|
|
124
|
+
break;
|
|
125
|
+
addRange(ranges, argument.from, argument.to);
|
|
126
|
+
cursor = skipWhitespace(source, argument.end);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
const options = balancedArgument(source, cursor, "[", "]");
|
|
131
|
+
if (options && (optionCommands.has(name) || optionLike(source.slice(options.from + 1, options.to - 1)))) {
|
|
132
|
+
addRange(ranges, options.from, options.to);
|
|
133
|
+
cursor = skipWhitespace(source, options.end);
|
|
134
|
+
}
|
|
135
|
+
if (optionCommands.has(name)) {
|
|
136
|
+
const argumentsToSkip = name === "definecolor" || name === "colorlet" ? 3 : 1;
|
|
137
|
+
for (let count = 0; count < argumentsToSkip; count += 1) {
|
|
138
|
+
const argument = balancedArgument(source, cursor, "{", "}");
|
|
139
|
+
if (!argument)
|
|
140
|
+
break;
|
|
141
|
+
addRange(ranges, argument.from, argument.to);
|
|
142
|
+
cursor = skipWhitespace(source, argument.end);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
index = commandEnd - 1;
|
|
147
|
+
}
|
|
148
|
+
// Keep key/value handling as a general fallback for package settings that
|
|
149
|
+
// are not attached directly to a command.
|
|
150
|
+
addKeyValueRanges(source, ranges);
|
|
151
|
+
for (const match of source.matchAll(/(?:https?|ftp):\/\/[^\s]+/gi)) {
|
|
152
|
+
if (match.index !== undefined)
|
|
153
|
+
addRange(ranges, match.index, match.index + match[0].length);
|
|
154
|
+
}
|
|
155
|
+
ranges.sort((left, right) => left.from - right.from || left.to - right.to);
|
|
156
|
+
const merged = [];
|
|
157
|
+
for (const range of ranges) {
|
|
158
|
+
const previous = merged.at(-1);
|
|
159
|
+
if (previous && range.from <= previous.to)
|
|
160
|
+
previous.to = Math.max(previous.to, range.to);
|
|
161
|
+
else
|
|
162
|
+
merged.push({ ...range });
|
|
163
|
+
}
|
|
164
|
+
return merged;
|
|
165
|
+
}
|
|
166
|
+
function isInside(ranges, position, cursor) {
|
|
167
|
+
while (cursor.index < ranges.length && ranges[cursor.index].to <= position)
|
|
168
|
+
cursor.index += 1;
|
|
169
|
+
const range = ranges[cursor.index];
|
|
170
|
+
return Boolean(range && range.from <= position && position < range.to);
|
|
171
|
+
}
|
|
172
|
+
export function checkSpelling(source, customWords = []) {
|
|
173
|
+
const ignored = ignoredRanges(source);
|
|
174
|
+
const ignoredCursor = { index: 0 };
|
|
175
|
+
const custom = new Set(customWords.map((word) => word.toLocaleLowerCase("en-US")));
|
|
176
|
+
const issues = [];
|
|
177
|
+
for (const match of source.matchAll(wordPattern)) {
|
|
178
|
+
if (match.index === undefined)
|
|
179
|
+
continue;
|
|
180
|
+
const word = match[0];
|
|
181
|
+
const from = match.index;
|
|
182
|
+
const to = from + word.length;
|
|
183
|
+
if (isInside(ignored, from, ignoredCursor))
|
|
184
|
+
continue;
|
|
185
|
+
// Identifiers, acronyms, numbers, and title-case words are not natural-language
|
|
186
|
+
// prose for this editor. Hyphenated words are intentionally tokenized into
|
|
187
|
+
// separate words by `wordPattern`.
|
|
188
|
+
if (word.length < 2 || /\d/.test(word) || /^[A-Z]/.test(word))
|
|
189
|
+
continue;
|
|
190
|
+
if (custom.has(word.toLocaleLowerCase("en-US")) || checker.correct(word))
|
|
191
|
+
continue;
|
|
192
|
+
issues.push({ from, to, word });
|
|
193
|
+
}
|
|
194
|
+
return issues;
|
|
195
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { promisify } from "node:util";
|
|
4
|
+
const runFile = promisify(execFile);
|
|
5
|
+
async function runSynctex(args, cwd) {
|
|
6
|
+
try {
|
|
7
|
+
const { stdout } = await runFile("synctex", args, {
|
|
8
|
+
cwd, timeout: 10_000, maxBuffer: 1024 * 1024, encoding: "utf8"
|
|
9
|
+
});
|
|
10
|
+
return stdout;
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
const stderr = typeof error === "object" && error !== null && "stderr" in error
|
|
14
|
+
? String(error.stderr) : "";
|
|
15
|
+
throw new Error(stderr.trim() || "SyncTeX 无法定位该位置,请重新编译项目");
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export async function sourceToPdf(sourceDirectory, pdfPath, sourcePath, line, column) {
|
|
19
|
+
const output = await runSynctex([
|
|
20
|
+
"view", "-i", `${line}:${column}:${sourcePath}`, "-o", pdfPath
|
|
21
|
+
], sourceDirectory);
|
|
22
|
+
const page = numericField(output, "Page");
|
|
23
|
+
const x = numericField(output, "x");
|
|
24
|
+
const y = numericField(output, "y");
|
|
25
|
+
if (!page || x === null || y === null)
|
|
26
|
+
throw new Error("SyncTeX 没有找到对应的 PDF 位置");
|
|
27
|
+
return {
|
|
28
|
+
page,
|
|
29
|
+
x,
|
|
30
|
+
y,
|
|
31
|
+
width: numericField(output, "W") ?? 0,
|
|
32
|
+
height: numericField(output, "H") ?? 0
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
export async function pdfToSource(sourceDirectory, pdfPath, page, x, y) {
|
|
36
|
+
const output = await runSynctex([
|
|
37
|
+
"edit", "-o", `${page}:${x}:${y}:${pdfPath}`
|
|
38
|
+
], sourceDirectory);
|
|
39
|
+
const input = textField(output, "Input");
|
|
40
|
+
const line = numericField(output, "Line");
|
|
41
|
+
const column = numericField(output, "Column");
|
|
42
|
+
if (!input || !line)
|
|
43
|
+
throw new Error("SyncTeX 没有找到对应的源码位置");
|
|
44
|
+
const absoluteInput = path.isAbsolute(input) ? input : path.resolve(sourceDirectory, input);
|
|
45
|
+
return { input: absoluteInput, line, column: column && column > 0 ? column : 1 };
|
|
46
|
+
}
|
|
47
|
+
function numericField(output, name) {
|
|
48
|
+
const match = output.match(new RegExp(`^${name}:([-+]?\\d+(?:\\.\\d+)?)$`, "m"));
|
|
49
|
+
if (!match)
|
|
50
|
+
return null;
|
|
51
|
+
const value = Number(match[1]);
|
|
52
|
+
return Number.isFinite(value) ? value : null;
|
|
53
|
+
}
|
|
54
|
+
function textField(output, name) {
|
|
55
|
+
return output.match(new RegExp(`^${name}:(.+)$`, "m"))?.[1]?.trim() || null;
|
|
56
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { Transform } from "node:stream";
|
|
4
|
+
import { pipeline } from "node:stream/promises";
|
|
5
|
+
import yauzl from "yauzl";
|
|
6
|
+
import { safeRelativePath } from "./files.js";
|
|
7
|
+
const MAX_ENTRIES = 1_000;
|
|
8
|
+
const MAX_TOTAL_BYTES = 200 * 1024 * 1024;
|
|
9
|
+
export async function extractProjectZip(buffer, destination, maxFileBytes) {
|
|
10
|
+
const maxTotalBytes = Math.max(MAX_TOTAL_BYTES, maxFileBytes);
|
|
11
|
+
const entries = await inspectZip(buffer, maxFileBytes, maxTotalBytes);
|
|
12
|
+
const prefix = commonRootPrefix(entries.filter((entry) => !entry.directory).map((entry) => entry.fileName));
|
|
13
|
+
fs.mkdirSync(destination, { recursive: true, mode: 0o700 });
|
|
14
|
+
const files = await extractZip(buffer, destination, prefix, maxFileBytes, maxTotalBytes);
|
|
15
|
+
const mainFile = discoverMainFile(destination, files);
|
|
16
|
+
if (!mainFile)
|
|
17
|
+
throw new Error("压缩包中没有找到 LaTeX 主文档(.tex)");
|
|
18
|
+
return { files, mainFile };
|
|
19
|
+
}
|
|
20
|
+
async function inspectZip(buffer, maxFileBytes, maxTotalBytes) {
|
|
21
|
+
const zip = await openZip(buffer);
|
|
22
|
+
return await new Promise((resolve, reject) => {
|
|
23
|
+
const entries = [];
|
|
24
|
+
const seenPaths = new Set();
|
|
25
|
+
let totalBytes = 0;
|
|
26
|
+
const fail = (error) => { zip.close(); reject(error); };
|
|
27
|
+
zip.on("error", reject);
|
|
28
|
+
zip.on("entry", (entry) => {
|
|
29
|
+
try {
|
|
30
|
+
validateEntry(entry);
|
|
31
|
+
if (entries.length >= MAX_ENTRIES)
|
|
32
|
+
throw new Error(`ZIP 文件数量不能超过 ${MAX_ENTRIES}`);
|
|
33
|
+
totalBytes += entry.uncompressedSize;
|
|
34
|
+
if (entry.uncompressedSize > maxFileBytes)
|
|
35
|
+
throw new Error(`ZIP 中存在超过 ${formatMB(maxFileBytes)} MB 的单个文件`);
|
|
36
|
+
if (totalBytes > maxTotalBytes)
|
|
37
|
+
throw new Error(`ZIP 解压后总体积不能超过 ${formatMB(maxTotalBytes)} MB`);
|
|
38
|
+
const normalized = normalizedEntryName(entry.fileName);
|
|
39
|
+
// ZIP archives can contain duplicate entries. Extraction of the
|
|
40
|
+
// second entry would otherwise silently replace the first one (and a
|
|
41
|
+
// `name`/`name/` pair would collide on disk as well).
|
|
42
|
+
const collisionKey = safeRelativePath(normalized.replace(/\/+$/, ""));
|
|
43
|
+
if (seenPaths.has(collisionKey))
|
|
44
|
+
throw new Error(`ZIP 包含重复文件名:${collisionKey}`);
|
|
45
|
+
seenPaths.add(collisionKey);
|
|
46
|
+
entries.push({ fileName: normalized, uncompressedSize: entry.uncompressedSize, directory: /\/$/.test(entry.fileName) });
|
|
47
|
+
zip.readEntry();
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
fail(error instanceof Error ? error : new Error(String(error)));
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
zip.on("end", () => resolve(entries));
|
|
54
|
+
zip.readEntry();
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
async function extractZip(buffer, destination, prefix, maxFileBytes, maxTotalBytes) {
|
|
58
|
+
const zip = await openZip(buffer);
|
|
59
|
+
return await new Promise((resolve, reject) => {
|
|
60
|
+
const files = [];
|
|
61
|
+
let actualBytes = 0;
|
|
62
|
+
let settled = false;
|
|
63
|
+
const fail = (error) => {
|
|
64
|
+
if (settled)
|
|
65
|
+
return;
|
|
66
|
+
settled = true;
|
|
67
|
+
zip.close();
|
|
68
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
69
|
+
};
|
|
70
|
+
zip.on("error", fail);
|
|
71
|
+
zip.on("entry", (entry) => {
|
|
72
|
+
void (async () => {
|
|
73
|
+
validateEntry(entry);
|
|
74
|
+
const normalized = normalizedEntryName(entry.fileName);
|
|
75
|
+
const stripped = prefix && normalized.startsWith(prefix) ? normalized.slice(prefix.length) : normalized;
|
|
76
|
+
if (!stripped || stripped.startsWith("__MACOSX/") || stripped.endsWith("/.DS_Store") || stripped === ".DS_Store") {
|
|
77
|
+
zip.readEntry();
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const relative = safeRelativePath(stripped);
|
|
81
|
+
const absolute = path.join(destination, relative);
|
|
82
|
+
if (/\/$/.test(entry.fileName)) {
|
|
83
|
+
fs.mkdirSync(absolute, { recursive: true, mode: 0o700 });
|
|
84
|
+
zip.readEntry();
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
fs.mkdirSync(path.dirname(absolute), { recursive: true, mode: 0o700 });
|
|
88
|
+
const input = await openEntryStream(zip, entry);
|
|
89
|
+
let entryBytes = 0;
|
|
90
|
+
const limiter = new Transform({
|
|
91
|
+
transform(chunk, _encoding, callback) {
|
|
92
|
+
actualBytes += chunk.length;
|
|
93
|
+
entryBytes += chunk.length;
|
|
94
|
+
if (entryBytes > maxFileBytes)
|
|
95
|
+
callback(new Error(`ZIP 中单个文件实际解压体积超过 ${formatMB(maxFileBytes)} MB`));
|
|
96
|
+
else if (actualBytes > maxTotalBytes)
|
|
97
|
+
callback(new Error(`ZIP 实际解压体积超过 ${formatMB(maxTotalBytes)} MB`));
|
|
98
|
+
else
|
|
99
|
+
callback(null, chunk);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
await pipeline(input, limiter, fs.createWriteStream(absolute, { mode: 0o600 }));
|
|
103
|
+
files.push(relative);
|
|
104
|
+
zip.readEntry();
|
|
105
|
+
})().catch(fail);
|
|
106
|
+
});
|
|
107
|
+
zip.on("end", () => {
|
|
108
|
+
if (!settled) {
|
|
109
|
+
settled = true;
|
|
110
|
+
resolve(files);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
zip.readEntry();
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
function openZip(buffer) {
|
|
117
|
+
return new Promise((resolve, reject) => {
|
|
118
|
+
yauzl.fromBuffer(buffer, { lazyEntries: true, autoClose: true, strictFileNames: true, validateEntrySizes: true }, (error, zip) => {
|
|
119
|
+
if (error || !zip)
|
|
120
|
+
reject(error ?? new Error("无法读取 ZIP 文件"));
|
|
121
|
+
else
|
|
122
|
+
resolve(zip);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
function openEntryStream(zip, entry) {
|
|
127
|
+
return new Promise((resolve, reject) => {
|
|
128
|
+
zip.openReadStream(entry, (error, stream) => {
|
|
129
|
+
if (error || !stream)
|
|
130
|
+
reject(error ?? new Error("无法读取 ZIP 条目"));
|
|
131
|
+
else
|
|
132
|
+
resolve(stream);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
function validateEntry(entry) {
|
|
137
|
+
const name = entry.fileName;
|
|
138
|
+
if (!name || name.includes("\0") || name.startsWith("/") || name.startsWith("\\"))
|
|
139
|
+
throw new Error("ZIP 包含无效路径");
|
|
140
|
+
safeRelativePath(name.replace(/\/$/, "") || "invalid");
|
|
141
|
+
const unixType = (entry.externalFileAttributes >>> 16) & 0o170000;
|
|
142
|
+
if (unixType === 0o120000)
|
|
143
|
+
throw new Error("ZIP 不允许包含符号链接");
|
|
144
|
+
}
|
|
145
|
+
function normalizedEntryName(name) {
|
|
146
|
+
return name.replaceAll("\\", "/");
|
|
147
|
+
}
|
|
148
|
+
function commonRootPrefix(files) {
|
|
149
|
+
if (files.length === 0)
|
|
150
|
+
return "";
|
|
151
|
+
const first = files[0].split("/");
|
|
152
|
+
if (first.length < 2 || !first[0])
|
|
153
|
+
return "";
|
|
154
|
+
return files.every((file) => file.startsWith(`${first[0]}/`)) ? `${first[0]}/` : "";
|
|
155
|
+
}
|
|
156
|
+
function discoverMainFile(root, files) {
|
|
157
|
+
const texFiles = files.filter((file) => file.toLowerCase().endsWith(".tex"));
|
|
158
|
+
if (texFiles.length === 1)
|
|
159
|
+
return texFiles[0];
|
|
160
|
+
return texFiles.find((file) => {
|
|
161
|
+
const source = fs.readFileSync(path.join(root, file), "utf8");
|
|
162
|
+
return hasDocumentClass(source);
|
|
163
|
+
}) ?? "";
|
|
164
|
+
}
|
|
165
|
+
export function hasDocumentClass(source) {
|
|
166
|
+
const withoutComments = source.split(/(?<=\n)/).map((line) => {
|
|
167
|
+
for (let index = 0; index < line.length; index += 1) {
|
|
168
|
+
if (line[index] !== "%")
|
|
169
|
+
continue;
|
|
170
|
+
let slashes = 0;
|
|
171
|
+
for (let cursor = index - 1; cursor >= 0 && line[cursor] === "\\"; cursor -= 1)
|
|
172
|
+
slashes += 1;
|
|
173
|
+
if (slashes % 2 === 0)
|
|
174
|
+
return `${line.slice(0, index)}${line.endsWith("\n") ? "\n" : ""}`;
|
|
175
|
+
}
|
|
176
|
+
return line;
|
|
177
|
+
}).join("");
|
|
178
|
+
const withoutVerbatim = withoutComments
|
|
179
|
+
.replace(/\\verb\*?([^\s]).*?\1/g, "")
|
|
180
|
+
.replace(/\\begin\{(?:verbatim\*?|Verbatim|lstlisting|minted)\}[\s\S]*?\\end\{(?:verbatim\*?|Verbatim|lstlisting|minted)\}/g, "");
|
|
181
|
+
return /\\documentclass\s*(?:\[[^\]]*\]\s*)?\{/.test(withoutVerbatim);
|
|
182
|
+
}
|
|
183
|
+
function formatMB(bytes) {
|
|
184
|
+
return Math.floor(bytes / 1024 / 1024);
|
|
185
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "texlite",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "A lightweight local-first collaborative LaTeX workspace",
|
|
5
|
+
"license": "AGPL-3.0-only",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/SWUFE-DB-Group/TexLite.git"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://github.com/SWUFE-DB-Group/TexLite",
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/SWUFE-DB-Group/TexLite/issues"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [
|
|
15
|
+
"latex",
|
|
16
|
+
"overleaf",
|
|
17
|
+
"collaboration",
|
|
18
|
+
"tex"
|
|
19
|
+
],
|
|
20
|
+
"bin": {
|
|
21
|
+
"texlite": "dist/server/cli.js"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"dist",
|
|
25
|
+
"README.md",
|
|
26
|
+
"README.zh-CN.md",
|
|
27
|
+
"LICENSE",
|
|
28
|
+
"preview.png",
|
|
29
|
+
"texlite.config.example.json"
|
|
30
|
+
],
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=24"
|
|
33
|
+
},
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public"
|
|
36
|
+
},
|
|
37
|
+
"type": "module",
|
|
38
|
+
"scripts": {
|
|
39
|
+
"dev": "tsx watch src/server/index.ts",
|
|
40
|
+
"dev:web": "vite",
|
|
41
|
+
"build": "npm run build:web && npm run build:server",
|
|
42
|
+
"build:web": "vite build",
|
|
43
|
+
"build:server": "tsc -p tsconfig.server.json",
|
|
44
|
+
"start": "node dist/server/index.js",
|
|
45
|
+
"pm2:start": "pm2 start ecosystem.config.cjs",
|
|
46
|
+
"pm2:restart": "pm2 restart texlite --update-env",
|
|
47
|
+
"pm2:stop": "pm2 stop texlite",
|
|
48
|
+
"pm2:delete": "pm2 delete texlite",
|
|
49
|
+
"pm2:logs": "pm2 logs texlite",
|
|
50
|
+
"pm2:save": "pm2 save",
|
|
51
|
+
"init": "tsx src/server/cli.ts init",
|
|
52
|
+
"prepack": "npm run build",
|
|
53
|
+
"test": "vitest run --config vitest.config.ts",
|
|
54
|
+
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.server.json --noEmit"
|
|
55
|
+
},
|
|
56
|
+
"dependencies": {
|
|
57
|
+
"@codemirror/autocomplete": "^6.18.6",
|
|
58
|
+
"@codemirror/commands": "^6.8.1",
|
|
59
|
+
"@codemirror/lang-markdown": "^6.3.3",
|
|
60
|
+
"@codemirror/language": "^6.11.3",
|
|
61
|
+
"@codemirror/legacy-modes": "^6.5.1",
|
|
62
|
+
"@codemirror/search": "^6.7.1",
|
|
63
|
+
"@codemirror/state": "^6.5.2",
|
|
64
|
+
"@codemirror/view": "^6.38.1",
|
|
65
|
+
"@fastify/cookie": "^11.0.2",
|
|
66
|
+
"@fastify/multipart": "^9.2.1",
|
|
67
|
+
"@fastify/static": "^10.1.2",
|
|
68
|
+
"@fastify/websocket": "^11.3.0",
|
|
69
|
+
"@radix-ui/react-dialog": "^1.1.15",
|
|
70
|
+
"@replit/codemirror-vim": "^6.4.0",
|
|
71
|
+
"better-sqlite3": "^13.0.2",
|
|
72
|
+
"dictionary-en": "^4.0.0",
|
|
73
|
+
"diff-match-patch": "^1.0.5",
|
|
74
|
+
"fastify": "^5.4.0",
|
|
75
|
+
"i18next": "^25.3.2",
|
|
76
|
+
"i18next-browser-languagedetector": "^8.2.0",
|
|
77
|
+
"lib0": "^0.2.117",
|
|
78
|
+
"lucide-react": "^0.468.0",
|
|
79
|
+
"nspell": "^2.1.5",
|
|
80
|
+
"pdfjs-dist": "^6.2.108",
|
|
81
|
+
"pm2": "^6.0.14",
|
|
82
|
+
"react": "^19.1.1",
|
|
83
|
+
"react-dom": "^19.1.1",
|
|
84
|
+
"react-i18next": "^15.6.1",
|
|
85
|
+
"react-resizable-panels": "^2.1.9",
|
|
86
|
+
"ws": "^8.21.1",
|
|
87
|
+
"y-codemirror.next": "^0.3.5",
|
|
88
|
+
"y-indexeddb": "^9.0.12",
|
|
89
|
+
"y-protocols": "^1.0.7",
|
|
90
|
+
"y-websocket": "^3.0.0",
|
|
91
|
+
"yauzl": "^3.2.0",
|
|
92
|
+
"yazl": "^3.3.1",
|
|
93
|
+
"yjs": "^13.6.31"
|
|
94
|
+
},
|
|
95
|
+
"devDependencies": {
|
|
96
|
+
"@types/better-sqlite3": "^9.6.0",
|
|
97
|
+
"@types/diff-match-patch": "^1.0.36",
|
|
98
|
+
"@types/node": "^24.1.0",
|
|
99
|
+
"@types/react": "^19.1.9",
|
|
100
|
+
"@types/react-dom": "^19.1.7",
|
|
101
|
+
"@types/ws": "^8.18.1",
|
|
102
|
+
"@types/yauzl": "^2.10.3",
|
|
103
|
+
"@types/yazl": "^3.3.0",
|
|
104
|
+
"@vitejs/plugin-react": "^4.7.0",
|
|
105
|
+
"tsx": "^4.20.3",
|
|
106
|
+
"typescript": "^5.8.3",
|
|
107
|
+
"vite": "^7.0.6",
|
|
108
|
+
"vitest": "^3.2.4"
|
|
109
|
+
}
|
|
110
|
+
}
|
package/preview.png
ADDED
|
Binary file
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"siteName": "TexLite",
|
|
3
|
+
"adminEmail": "admin@example.com",
|
|
4
|
+
"sessionDays": 14,
|
|
5
|
+
"server": {
|
|
6
|
+
"host": "127.0.0.1",
|
|
7
|
+
"port": 3000
|
|
8
|
+
},
|
|
9
|
+
"storage": {
|
|
10
|
+
"dataDir": ".texlite"
|
|
11
|
+
},
|
|
12
|
+
"uploads": {
|
|
13
|
+
"maxFileSizeMB": 50
|
|
14
|
+
},
|
|
15
|
+
"history": {
|
|
16
|
+
"maxVersions": 200,
|
|
17
|
+
"maxStorageMB": 512
|
|
18
|
+
},
|
|
19
|
+
"git": {
|
|
20
|
+
"binary": "git",
|
|
21
|
+
"operationTimeoutSeconds": 120,
|
|
22
|
+
"githubApiBaseUrl": "https://api.github.com"
|
|
23
|
+
},
|
|
24
|
+
"latex": {
|
|
25
|
+
"latexmk": "latexmk",
|
|
26
|
+
"defaultEngine": "xelatex",
|
|
27
|
+
"allowedEngines": ["pdflatex", "xelatex", "lualatex"],
|
|
28
|
+
"extraArgs": [],
|
|
29
|
+
"allowProjectLatexmkrc": true,
|
|
30
|
+
"compileTimeoutSeconds": 600,
|
|
31
|
+
"maxCompileJobs": 10
|
|
32
|
+
}
|
|
33
|
+
}
|