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.
Files changed (51) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +403 -0
  3. package/README.zh-CN.md +391 -0
  4. package/dist/client/assets/GitDialog-BHHxSBn3.js +3 -0
  5. package/dist/client/assets/HistoryDialog-Dlv57tYf.js +5 -0
  6. package/dist/client/assets/LatexEditor-B-0VRUPv.js +55 -0
  7. package/dist/client/assets/PdfPreview-617TWFJN.js +1 -0
  8. package/dist/client/assets/ProjectNavigationDialogs-Cpt2LVEt.js +1 -0
  9. package/dist/client/assets/SystemMetricsDialog-fkFHVVzJ.js +1 -0
  10. package/dist/client/assets/editor-JxT6uSYX.js +14 -0
  11. package/dist/client/assets/index-Bc5CmFMl.js +15 -0
  12. package/dist/client/assets/index-CMqYAbDx.css +1 -0
  13. package/dist/client/assets/pdf-DPnwNQHb.js +55 -0
  14. package/dist/client/assets/pdf.worker.min-CHFwMXne.mjs +29 -0
  15. package/dist/client/assets/plus-DzbLkp6l.js +1 -0
  16. package/dist/client/assets/react-CsO_xvbl.js +49 -0
  17. package/dist/client/assets/spellCheck-R6fUM9oA.js +49783 -0
  18. package/dist/client/index.html +20 -0
  19. package/dist/client/logo.svg +4 -0
  20. package/dist/server/anchors.js +95 -0
  21. package/dist/server/app.js +1624 -0
  22. package/dist/server/archive.js +26 -0
  23. package/dist/server/auth.js +42 -0
  24. package/dist/server/cli.js +318 -0
  25. package/dist/server/collaboration.js +635 -0
  26. package/dist/server/compileArtifacts.js +86 -0
  27. package/dist/server/compileDiagnostics.js +227 -0
  28. package/dist/server/compiler.js +514 -0
  29. package/dist/server/config.js +336 -0
  30. package/dist/server/db.js +280 -0
  31. package/dist/server/environment.js +57 -0
  32. package/dist/server/files.js +121 -0
  33. package/dist/server/git.js +414 -0
  34. package/dist/server/history.js +379 -0
  35. package/dist/server/http.js +7 -0
  36. package/dist/server/index.js +75 -0
  37. package/dist/server/latexCompletion.js +296 -0
  38. package/dist/server/metrics.js +34 -0
  39. package/dist/server/pm2.js +234 -0
  40. package/dist/server/projectOutline.js +101 -0
  41. package/dist/server/projectSearch.js +112 -0
  42. package/dist/server/projects.js +29 -0
  43. package/dist/server/routes/compile.js +343 -0
  44. package/dist/server/runtimePaths.js +42 -0
  45. package/dist/server/security.js +29 -0
  46. package/dist/server/spellCheck.js +195 -0
  47. package/dist/server/synctex.js +56 -0
  48. package/dist/server/zip.js +185 -0
  49. package/package.json +110 -0
  50. package/preview.png +0 -0
  51. package/texlite.config.example.json +33 -0
@@ -0,0 +1,26 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { ZipFile } from "yazl";
4
+ import { sourceRoot } from "./files.js";
5
+ export function createProjectArchive(config, projectId) {
6
+ const root = sourceRoot(config, projectId);
7
+ const archive = new ZipFile();
8
+ const addDirectory = (directory, prefix) => {
9
+ const entries = fs.readdirSync(directory, { withFileTypes: true });
10
+ if (entries.length === 0 && prefix)
11
+ archive.addEmptyDirectory(`${prefix}/`);
12
+ for (const entry of entries) {
13
+ if (entry.name === ".git" || entry.isSymbolicLink())
14
+ continue;
15
+ const absolute = path.join(directory, entry.name);
16
+ const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
17
+ if (entry.isDirectory())
18
+ addDirectory(absolute, relative);
19
+ else if (entry.isFile())
20
+ archive.addFile(absolute, relative);
21
+ }
22
+ };
23
+ addDirectory(root, "");
24
+ archive.end();
25
+ return archive;
26
+ }
@@ -0,0 +1,42 @@
1
+ import { digestToken } from "./security.js";
2
+ export function publicUser(user) {
3
+ return {
4
+ id: user.id,
5
+ username: user.username,
6
+ displayName: user.display_name,
7
+ role: user.role,
8
+ disabled: Boolean(user.disabled),
9
+ mustChangePassword: Boolean(user.must_change_password),
10
+ canCreateProjects: Boolean(user.can_create_projects) || user.role === "admin",
11
+ createdAt: user.created_at
12
+ };
13
+ }
14
+ export function currentUser(request, db) {
15
+ const token = request.cookies.texlite_session;
16
+ if (!token)
17
+ return null;
18
+ const row = db.prepare(`
19
+ SELECT u.* FROM sessions s
20
+ JOIN users u ON u.id = s.user_id
21
+ WHERE s.id = ? AND s.expires_at > ? AND u.disabled = 0
22
+ `).get(digestToken(token), new Date().toISOString());
23
+ return row ?? null;
24
+ }
25
+ export function requireUser(request, reply, db) {
26
+ const user = currentUser(request, db);
27
+ if (!user) {
28
+ void reply.code(401).send({ code: "AUTH_REQUIRED", error: "请先登录" });
29
+ return null;
30
+ }
31
+ return user;
32
+ }
33
+ export function requireAdmin(request, reply, db) {
34
+ const user = requireUser(request, reply, db);
35
+ if (!user)
36
+ return null;
37
+ if (user.role !== "admin") {
38
+ void reply.code(403).send({ code: "ADMIN_REQUIRED", error: "需要管理员权限" });
39
+ return null;
40
+ }
41
+ return user;
42
+ }
@@ -0,0 +1,318 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { randomUUID } from "node:crypto";
5
+ import { createInterface } from "node:readline/promises";
6
+ import { stdin, stdout } from "node:process";
7
+ import { fileURLToPath } from "node:url";
8
+ import { CONFIG_DEFAULTS, loadConfig } from "./config.js";
9
+ import { activeAdminCount, openDatabase } from "./db.js";
10
+ import { hashPassword } from "./security.js";
11
+ import { assertEnvironment, assertGitAvailable } from "./environment.js";
12
+ import { serve } from "./index.js";
13
+ import { processStatus, restartManaged, startManaged, stopManaged, streamLogs, waitForOnline } from "./pm2.js";
14
+ import { defaultDataDirectory, resolveConfigPath } from "./runtimePaths.js";
15
+ const HELP = `TexLite ${packageVersion()}
16
+
17
+ Usage:
18
+ texlite <command> [options]
19
+
20
+ Commands:
21
+ init Create the configuration, initialize storage, and create the first administrator.
22
+ serve Run the server in the foreground for debugging, Docker, or systemd.
23
+ start Start the server in the background under PM2.
24
+ status Show the status of the PM2-managed server.
25
+ stop Stop the PM2-managed server.
26
+ restart Restart (or start) the PM2-managed server.
27
+ logs Stream logs from the PM2-managed server.
28
+ doctor Validate configuration, paths, LaTeX, and the administrator.
29
+ config Print the effective configuration as JSON without changing it.
30
+ help Show this help message.
31
+ version Print the installed TexLite version.
32
+
33
+ Options:
34
+ --config PATH Use this configuration file instead of TEXLITE_CONFIG or the XDG default.
35
+ --git Make doctor check the optional Git integration as well.
36
+ -h, --help Show this help message.
37
+ -v, --version Print the installed TexLite version.
38
+ `;
39
+ export function parseArgs(args) {
40
+ let command = "help";
41
+ let configPath;
42
+ let checkGit = false;
43
+ for (let index = 0; index < args.length; index += 1) {
44
+ const argument = args[index];
45
+ if (!argument)
46
+ continue;
47
+ if (argument === "-h" || argument === "--help")
48
+ return { command: "help", configPath, checkGit };
49
+ if (argument === "-v" || argument === "--version")
50
+ return { command: "version", configPath, checkGit };
51
+ if (argument === "--git") {
52
+ checkGit = true;
53
+ continue;
54
+ }
55
+ if (argument === "--config") {
56
+ const value = args[++index];
57
+ if (!value)
58
+ throw new Error("--config requires a configuration file path");
59
+ configPath = value;
60
+ continue;
61
+ }
62
+ if (argument.startsWith("--config=")) {
63
+ const value = argument.slice("--config=".length);
64
+ if (!value)
65
+ throw new Error("--config requires a configuration file path");
66
+ configPath = value;
67
+ continue;
68
+ }
69
+ if (argument.startsWith("-"))
70
+ throw new Error(`Unknown option: ${argument}`);
71
+ if (command !== "help")
72
+ throw new Error(`Only one command may be specified (already received ${command})`);
73
+ command = argument;
74
+ }
75
+ return { command, configPath, checkGit };
76
+ }
77
+ function output(message) {
78
+ stdout.write(`${message}\n`);
79
+ }
80
+ function configPathFor(options) {
81
+ return resolveConfigPath(options.configPath);
82
+ }
83
+ function writeInitialConfig(configPath, siteName, adminEmail) {
84
+ const dataDirectory = defaultDataDirectory();
85
+ const configuredDataDirectory = process.env.TEXLITE_DATA_DIR?.trim();
86
+ const effectiveDataDirectory = configuredDataDirectory
87
+ ? path.resolve(path.dirname(configPath), configuredDataDirectory)
88
+ : dataDirectory;
89
+ fs.mkdirSync(path.dirname(configPath), { recursive: true, mode: 0o700 });
90
+ // Data parents are not guaranteed to exist on a fresh account. Create only
91
+ // the parent here; the configured data directory itself is created by
92
+ // openDatabase after configuration validation.
93
+ for (const parent of new Set([path.dirname(dataDirectory), path.dirname(effectiveDataDirectory)])) {
94
+ fs.mkdirSync(parent, { recursive: true, mode: 0o700 });
95
+ }
96
+ try {
97
+ fs.chmodSync(path.dirname(configPath), 0o700);
98
+ }
99
+ catch { /* Windows and restricted filesystems may not support chmod. */ }
100
+ const configFile = {
101
+ siteName,
102
+ adminEmail,
103
+ sessionDays: CONFIG_DEFAULTS.sessionDays,
104
+ server: { host: CONFIG_DEFAULTS.host, port: CONFIG_DEFAULTS.port },
105
+ storage: { dataDir: dataDirectory },
106
+ uploads: { maxFileSizeMB: CONFIG_DEFAULTS.maxFileSizeMB },
107
+ history: {
108
+ maxVersions: CONFIG_DEFAULTS.historyMaxVersions,
109
+ maxStorageMB: CONFIG_DEFAULTS.historyMaxStorageMB
110
+ },
111
+ git: {
112
+ binary: CONFIG_DEFAULTS.git,
113
+ operationTimeoutSeconds: CONFIG_DEFAULTS.gitOperationTimeoutSeconds,
114
+ githubApiBaseUrl: CONFIG_DEFAULTS.githubApiBaseUrl
115
+ },
116
+ latex: {
117
+ latexmk: CONFIG_DEFAULTS.latexmk,
118
+ defaultEngine: CONFIG_DEFAULTS.defaultEngine,
119
+ allowedEngines: CONFIG_DEFAULTS.allowedEngines,
120
+ extraArgs: CONFIG_DEFAULTS.extraArgs,
121
+ allowProjectLatexmkrc: CONFIG_DEFAULTS.allowProjectLatexmkrc,
122
+ compileTimeoutSeconds: CONFIG_DEFAULTS.compileTimeoutSeconds,
123
+ maxCompileJobs: CONFIG_DEFAULTS.maxCompileJobs
124
+ }
125
+ };
126
+ fs.writeFileSync(configPath, `${JSON.stringify(configFile, null, 2)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
127
+ try {
128
+ fs.chmodSync(configPath, 0o600);
129
+ }
130
+ catch { /* Windows and restricted filesystems may not support chmod. */ }
131
+ }
132
+ async function initialize(options) {
133
+ const configPath = configPathFor(options);
134
+ const interactive = Boolean(stdin.isTTY);
135
+ const rl = interactive ? createInterface({ input: stdin, output: stdout }) : null;
136
+ let db = null;
137
+ try {
138
+ if (!fs.existsSync(configPath)) {
139
+ const siteName = rl
140
+ ? (await rl.question(`Site name [${CONFIG_DEFAULTS.siteName}]: `)).trim() || CONFIG_DEFAULTS.siteName
141
+ : CONFIG_DEFAULTS.siteName;
142
+ const adminEmail = rl ? (await rl.question("Administrator contact email (optional): ")).trim() : "";
143
+ writeInitialConfig(configPath, siteName, adminEmail);
144
+ output(`Created configuration file: ${configPath}`);
145
+ }
146
+ else {
147
+ output(`Using existing configuration file: ${configPath}`);
148
+ }
149
+ const config = loadConfig(configPath);
150
+ const environment = await assertEnvironment(config);
151
+ output(`Environment checks passed: ${environment.map((item) => `${item.name} ${item.version}`).join("; ")}`);
152
+ db = openDatabase(config);
153
+ if (activeAdminCount(db) > 0) {
154
+ throw new Error("An active administrator already exists. Add additional administrators from the administration page.");
155
+ }
156
+ const username = process.env.TEXLITE_INIT_USERNAME
157
+ ?? (rl ? (await rl.question("Administrator username [admin]: ")).trim() || "admin" : "admin");
158
+ const displayName = process.env.TEXLITE_INIT_DISPLAY_NAME
159
+ ?? (rl ? (await rl.question("Administrator display name [Administrator]: ")).trim() || "Administrator" : "Administrator");
160
+ const password = process.env.TEXLITE_INIT_PASSWORD
161
+ ?? (rl ? await rl.question("Administrator password (at least 8 characters; input is visible): ") : "");
162
+ if (!password)
163
+ throw new Error("Non-interactive initialization requires TEXLITE_INIT_PASSWORD to be set.");
164
+ const timestamp = new Date().toISOString();
165
+ db.prepare(`INSERT INTO users
166
+ (id, username, display_name, password_hash, role, disabled, must_change_password, can_create_projects, created_at)
167
+ VALUES (?, ?, ?, ?, 'admin', 0, 0, 1, ?)`)
168
+ .run(randomUUID(), username, displayName, await hashPassword(password), timestamp);
169
+ output(`Administrator ${username} created. Configuration file: ${config.configPath}`);
170
+ output(`Data directory: ${config.dataDir}`);
171
+ }
172
+ finally {
173
+ db?.close();
174
+ rl?.close();
175
+ }
176
+ }
177
+ async function loadValidatedConfig(options) {
178
+ const configPath = configPathFor(options);
179
+ const config = loadConfig(configPath);
180
+ return { config, configPath };
181
+ }
182
+ async function assertAdmin(config) {
183
+ const db = openDatabase(config);
184
+ try {
185
+ if (activeAdminCount(db) === 0)
186
+ throw new Error("No active administrator found. Run `texlite init` first; the server will not start.");
187
+ }
188
+ finally {
189
+ db.close();
190
+ }
191
+ }
192
+ async function doctor(options) {
193
+ const { config, configPath } = await loadValidatedConfig(options);
194
+ const environment = await assertEnvironment(config);
195
+ await assertAdmin(config);
196
+ output(`Configuration is valid: ${configPath}`);
197
+ output(`Data directory: ${config.dataDir}`);
198
+ output(`Client assets: ${config.clientDir}`);
199
+ output(`Environment: ${environment.map((item) => `${item.name} ${item.version}`).join("; ")}`);
200
+ if (options.checkGit)
201
+ output(`Git: ${(await assertGitAvailable(config)).version}`);
202
+ else
203
+ output("Git: not checked (Git integration is optional; use --git to check it)");
204
+ }
205
+ async function printConfig(options) {
206
+ const { config, configPath } = await loadValidatedConfig(options);
207
+ output(JSON.stringify({
208
+ configPath,
209
+ siteName: config.siteName,
210
+ host: config.host,
211
+ port: config.port,
212
+ dataDir: config.dataDir,
213
+ databasePath: config.databasePath,
214
+ projectsDir: config.projectsDir,
215
+ clientDir: config.clientDir,
216
+ sessionDays: config.sessionDays,
217
+ compileTimeoutSeconds: config.compileTimeoutMs / 1000,
218
+ maxCompileJobs: config.maxCompileJobs,
219
+ latexmk: config.latexmk,
220
+ defaultEngine: config.defaultEngine,
221
+ allowedEngines: config.allowedEngines,
222
+ maxUploadBytes: config.maxUploadBytes,
223
+ historyMaxVersions: config.historyMaxVersions,
224
+ historyMaxStorageBytes: config.historyMaxStorageBytes,
225
+ git: config.git
226
+ }, null, 2));
227
+ }
228
+ async function start(options, restart = false) {
229
+ const { config, configPath } = await loadValidatedConfig(options);
230
+ await assertEnvironment(config);
231
+ await assertAdmin(config);
232
+ restart ? await restartManaged(config) : await startManaged(config);
233
+ const managed = await waitForOnline(config);
234
+ const status = managed.description?.pm2_env?.status ?? "unknown";
235
+ output(`TexLite ${status}: ${config.siteName}`);
236
+ output(`Address: http://${config.host}:${config.port}`);
237
+ output(`Configuration file: ${configPath}`);
238
+ output(`PM2 process: ${managed.name}`);
239
+ }
240
+ async function status(options) {
241
+ const { config } = await loadValidatedConfig(options);
242
+ const result = await processStatus(config);
243
+ output(JSON.stringify(result, null, 2));
244
+ if (result.status !== "online")
245
+ process.exitCode = 2;
246
+ }
247
+ async function stop(options) {
248
+ const { config } = await loadValidatedConfig(options);
249
+ const managed = await stopManaged(config.configPath);
250
+ output(managed.description ? `TexLite stopped: ${managed.name}` : `TexLite is not running: ${managed.name}`);
251
+ }
252
+ async function run(options) {
253
+ switch (options.command) {
254
+ case "help":
255
+ output(HELP);
256
+ return;
257
+ case "version":
258
+ output(packageVersion());
259
+ return;
260
+ case "init":
261
+ await initialize(options);
262
+ return;
263
+ case "serve":
264
+ await serve(configPathFor(options));
265
+ return;
266
+ case "start":
267
+ await start(options);
268
+ return;
269
+ case "restart":
270
+ await start(options, true);
271
+ return;
272
+ case "status":
273
+ await status(options);
274
+ return;
275
+ case "stop":
276
+ await stop(options);
277
+ return;
278
+ case "doctor":
279
+ await doctor(options);
280
+ return;
281
+ case "config":
282
+ await printConfig(options);
283
+ return;
284
+ case "logs": {
285
+ const { config } = await loadValidatedConfig(options);
286
+ await streamLogs(config);
287
+ return;
288
+ }
289
+ default: throw new Error(`Unknown command: ${options.command}\n\n${HELP}`);
290
+ }
291
+ }
292
+ function packageVersion() {
293
+ try {
294
+ const packagePath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json");
295
+ const parsed = JSON.parse(fs.readFileSync(packagePath, "utf8"));
296
+ return typeof parsed.version === "string" ? parsed.version : "unknown";
297
+ }
298
+ catch {
299
+ return "unknown";
300
+ }
301
+ }
302
+ export async function main(argv = process.argv.slice(2)) {
303
+ await run(parseArgs(argv));
304
+ }
305
+ if (isMainModule()) {
306
+ main().catch((error) => {
307
+ console.error(error instanceof Error ? error.message : error);
308
+ process.exitCode = 1;
309
+ });
310
+ }
311
+ function isMainModule() {
312
+ try {
313
+ return fs.realpathSync(process.argv[1] ?? "") === fs.realpathSync(fileURLToPath(import.meta.url));
314
+ }
315
+ catch {
316
+ return false;
317
+ }
318
+ }