texlite 0.9.1 → 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.
Files changed (84) hide show
  1. package/DESIGN.md +7 -0
  2. package/OPERATIONS.md +70 -3
  3. package/README.md +5 -0
  4. package/README.zh-CN.md +5 -0
  5. package/THIRD_PARTY_NOTICES.md +29 -0
  6. package/dist/client/assets/{AdminUsers-Dazar9cj.js → AdminUsers-Buf5ZclR.js} +1 -1
  7. package/dist/client/assets/{CitationLibraryDialog-VSUiSPgG.js → CitationLibraryDialog-DrKbIlD-.js} +1 -1
  8. package/dist/client/assets/Dashboard-DLk2j3oq.js +2 -0
  9. package/dist/client/assets/{GitDialog-C9Loz4Al.js → GitDialog-CTWAU8q6.js} +1 -1
  10. package/dist/client/assets/{HistoryDialog-3UNN_jhU.js → HistoryDialog-Mo4p8HEg.js} +1 -1
  11. package/dist/client/assets/LatexEditor-CwvakYva.js +135 -0
  12. package/dist/client/assets/{PdfPreview-BKhcS-2c.js → PdfPreview-BUpELZd-.js} +1 -1
  13. package/dist/client/assets/{ProjectIconPickerDialog-BoWMjQVm.js → ProjectIconPickerDialog-BiWSwDdt.js} +1 -1
  14. package/dist/client/assets/{ProjectNavigationDialogs-CZKTnxrl.js → ProjectNavigationDialogs-CpjkXZTz.js} +1 -1
  15. package/dist/client/assets/{ProjectSettings-DiOQVJ40.js → ProjectSettings-DoxitHbX.js} +1 -1
  16. package/dist/client/assets/ProjectWorkspace-iNqXZlsj.js +29 -0
  17. package/dist/client/assets/{SelectionHistoryDialog-D3dO83lg.js → SelectionHistoryDialog-9FWlc3Rb.js} +1 -1
  18. package/dist/client/assets/{SystemMetricsDialog-0zCwLtH_.js → SystemMetricsDialog-24kifZO6.js} +1 -1
  19. package/dist/client/assets/TagManagementDialog-BHIA0vEA.js +1 -0
  20. package/dist/client/assets/{WordCountDialog-TwpyAP3m.js → WordCountDialog-DFIWvLrP.js} +1 -1
  21. package/dist/client/assets/check-C3GZh6a1.js +1 -0
  22. package/dist/client/assets/chevron-left-l1oupvGO.js +1 -0
  23. package/dist/client/assets/editor-3rgn4IYh.js +14 -0
  24. package/dist/client/assets/{hard-drive-XHSiS_A7.js → hard-drive-B-NSDVVA.js} +1 -1
  25. package/dist/client/assets/{index-Cd6-F29b.js → index-CNTGCh7t.js} +1 -1
  26. package/dist/client/assets/index-CrlfruX_.css +1 -0
  27. package/dist/client/assets/index-DIM_KiG2.js +12 -0
  28. package/dist/client/assets/katex-D9PXdmFF.css +1 -0
  29. package/dist/client/assets/{key-round-C-CXCWnz.js → key-round-BjL9H8-H.js} +1 -1
  30. package/dist/client/assets/{latexFormatterWorker-DYkYtcFy.js → latexFormatterWorker-BRmsns8a.js} +2 -2
  31. package/dist/client/assets/{minimize-2-BMDPVaF-.js → minimize-2-DDCq8c49.js} +1 -1
  32. package/dist/client/assets/{minus-BIa7-KuM.js → minus-DgKZCT9F.js} +1 -1
  33. package/dist/client/assets/{pdf-BB1XvBIF.js → pdf-DUYcbPMK.js} +1 -1
  34. package/dist/client/assets/{plus-BKDWy4Ui.js → plus-CezUzSIZ.js} +1 -1
  35. package/dist/client/assets/{refresh-cw-Bo7-a7-I.js → refresh-cw-CDFNL-qu.js} +1 -1
  36. package/dist/client/assets/{search-Bz9Ob5mT.js → search-CrDJjHoj.js} +1 -1
  37. package/dist/client/assets/{sigma-DxlHf9gv.js → sigma-DOcJr7VG.js} +1 -1
  38. package/dist/client/assets/spellCheck-BgWyesvf.js +1 -0
  39. package/dist/client/assets/{tags-IMZehjzE.js → tags-BedQ75lh.js} +1 -1
  40. package/dist/client/assets/{type-DTWpFQNQ.js → type-glZNdzZR.js} +1 -1
  41. package/dist/client/assets/{user-plus--aVpd8K6.js → user-plus-gQ6q5Nom.js} +1 -1
  42. package/dist/client/assets/{x-Tbo3aA0J.js → x-CSzFikn2.js} +1 -1
  43. package/dist/client/index.html +6 -4
  44. package/dist/server/app.js +130 -83
  45. package/dist/server/cli.js +4 -2
  46. package/dist/server/compileArtifacts.js +3 -2
  47. package/dist/server/config.js +27 -0
  48. package/dist/server/index.js +4 -2
  49. package/dist/server/instanceLock.js +4 -2
  50. package/dist/server/latexCompletion.js +126 -63
  51. package/dist/server/latexDocumentGraph.js +106 -0
  52. package/dist/server/latexSpellMask.js +3 -4
  53. package/dist/server/pm2.js +3 -2
  54. package/dist/server/projectReferences.js +109 -0
  55. package/dist/server/routes/auth.js +5 -2
  56. package/dist/server/routes/compile.js +2 -2
  57. package/dist/server/routes/projectFiles.js +12 -3
  58. package/dist/server/routes/projectReferences.js +36 -0
  59. package/dist/server/routes/projects.js +5 -0
  60. package/dist/server/routes/system.js +1 -0
  61. package/dist/server/zip.js +2 -16
  62. package/dist/shared/basePath.js +44 -0
  63. package/dist/shared/bibtexReferences.js +135 -0
  64. package/dist/shared/latexCommandSnippets.js +91 -0
  65. package/dist/shared/latexDependencies.js +71 -0
  66. package/dist/shared/latexLiterals.js +215 -0
  67. package/dist/shared/latexReferenceCommands.js +75 -0
  68. package/dist/shared/latexReferences.js +193 -0
  69. package/dist/shared/latexRoot.js +21 -0
  70. package/dist/shared/latexScanner.js +183 -0
  71. package/dist/shared/writingChecks.js +4 -0
  72. package/package.json +11 -4
  73. package/texlite.config.example.json +2 -4
  74. package/dist/client/assets/Dashboard-CoE5ESfb.js +0 -2
  75. package/dist/client/assets/LatexEditor-Bq76v8sO.js +0 -65
  76. package/dist/client/assets/ProjectWorkspace-DqLwkGpT.js +0 -27
  77. package/dist/client/assets/TagManagementDialog-C6KDwtvd.js +0 -1
  78. package/dist/client/assets/check-sZxOOKtS.js +0 -1
  79. package/dist/client/assets/chevron-left-32SrOwa5.js +0 -1
  80. package/dist/client/assets/editor-CiK7TK0p.js +0 -14
  81. package/dist/client/assets/index-BRr3Kuxh.css +0 -1
  82. package/dist/client/assets/index-BjzELSQ-.js +0 -12
  83. package/dist/client/assets/katex-CEK31ho9.css +0 -1
  84. package/dist/client/assets/spellCheck-BfZEnpwY.js +0 -1
@@ -29,6 +29,7 @@ import { registerCitationRoutes } from "./routes/citations.js";
29
29
  import { registerCommentRoutes } from "./routes/comments.js";
30
30
  import { registerProjectMemberRoutes } from "./routes/projectMembers.js";
31
31
  import { registerProjectFileRoutes } from "./routes/projectFiles.js";
32
+ import { registerProjectReferenceRoutes } from "./routes/projectReferences.js";
32
33
  import { registerProjectHistoryRoutes } from "./routes/projectHistory.js";
33
34
  import { registerProjectGitRoutes } from "./routes/projectGit.js";
34
35
  import { registerProjectCatalogRoutes } from "./routes/projects.js";
@@ -37,10 +38,36 @@ import { registerUserManagementRoutes } from "./routes/users.js";
37
38
  import { registerWordCountRoutes } from "./routes/wordCount.js";
38
39
  import { HarperService } from "./harper.js";
39
40
  import { TexcountService } from "./texcount.js";
41
+ import { basePathHref, basePathPrefix, withoutBasePath } from "../shared/basePath.js";
40
42
  // Retained public helper for callers and tests; implementation lives with the file routes.
41
43
  export { escapeGlobPattern } from "./routes/projectShared.js";
42
44
  const SESSION_CLEANUP_INTERVAL_MS = 15 * 60_000;
43
45
  const now = () => new Date().toISOString();
46
+ /** Inject the runtime mount point into a single, path-independent Vite build. */
47
+ export function renderClientIndex(source, basePath) {
48
+ const href = escapeHtmlAttribute(basePathHref(basePath));
49
+ const value = escapeHtmlAttribute(basePath);
50
+ const baseElement = `<base href="${href}" />`;
51
+ const metaElement = `<meta name="texlite-base-path" content="${value}" />`;
52
+ let hadBase = false;
53
+ let rendered = source.replace(/<base\s+href=(?:"[^"]*"|'[^']*')\s*\/?\s*>/i, () => {
54
+ hadBase = true;
55
+ return baseElement;
56
+ });
57
+ if (!hadBase)
58
+ rendered = rendered.replace(/<head(?:\s[^>]*)?>/i, (head) => `${head}\n ${baseElement}`);
59
+ let hadMeta = false;
60
+ rendered = rendered.replace(/<meta\s+name=(?:"texlite-base-path"|'texlite-base-path')\s+content=(?:"[^"]*"|'[^']*')\s*\/?\s*>/i, () => {
61
+ hadMeta = true;
62
+ return metaElement;
63
+ });
64
+ if (!hadMeta)
65
+ rendered = rendered.replace(baseElement, `${baseElement}\n ${metaElement}`);
66
+ return rendered;
67
+ }
68
+ function escapeHtmlAttribute(value) {
69
+ return value.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
70
+ }
44
71
  export async function buildApp(config, db, options = {}) {
45
72
  const app = Fastify({
46
73
  logger: options.logger ?? true,
@@ -161,7 +188,10 @@ export async function buildApp(config, db, options = {}) {
161
188
  const startedAt = requestStarts.get(request);
162
189
  if (startedAt === undefined)
163
190
  return;
164
- const route = request.routeOptions.url;
191
+ const registeredRoute = request.routeOptions.url;
192
+ const route = registeredRoute
193
+ ? withoutBasePath(config.basePath, registeredRoute) ?? registeredRoute
194
+ : null;
165
195
  if (!route)
166
196
  return;
167
197
  const metric = {
@@ -193,93 +223,110 @@ export async function buildApp(config, db, options = {}) {
193
223
  void apiError(reply, 500, "SERVER_ERROR");
194
224
  }
195
225
  });
196
- registerSystemRoutes(app, {
197
- config,
198
- db,
199
- queue,
200
- collaboration,
201
- latexCompletions,
202
- projectOutlines,
203
- metrics,
204
- eventLoopDelay
205
- });
206
- registerCollaborationRoutes(app, { db, collaboration, metrics });
207
- registerAuthRoutes(app, { config, db, loginLimiter });
208
- registerCitationRoutes(app, { db });
209
- registerUserManagementRoutes(app, {
210
- config,
211
- db,
212
- collaboration,
213
- projectMutations,
214
- latexCompletions,
215
- projectOutlines
216
- });
217
- registerCommentRoutes(app, { config, db, collaboration, projectMutations });
218
- registerProjectMemberRoutes(app, { db, collaboration, projectMutations });
219
- registerProjectFileRoutes(app, {
220
- config,
221
- db,
222
- collaboration,
223
- projectMutations,
224
- latexCompletions,
225
- projectOutlines,
226
- metrics,
227
- recordHistory
228
- });
229
- registerProjectHistoryRoutes(app, { config, db, history, editHistory, projectMutations, recordHistory,
230
- clearPendingEdits: (id) => { editRetry.clear(id); failedEdits.delete(id); signalHistory(id); },
231
- scheduleHistoryRetention: (id) => historyRetention.schedule(id) });
232
- registerProjectGitRoutes(app, { config, db, collaboration, projectMutations, projectGit, recordHistory });
233
- registerProjectCatalogRoutes(app, {
234
- config,
235
- db,
236
- collaboration,
237
- projectMutations,
238
- latexCompletions,
239
- projectOutlines,
240
- harper,
241
- recordHistory
242
- });
243
- registerWordCountRoutes(app, { config, db, projectMutations, texcount });
244
- registerCompileRoutes(app, { config, db, collaboration, projectMutations, compileCoordinator, metrics, pruneCompileRuns });
245
- // The factory owns shared services and lifecycle hooks; route modules own endpoint behavior.
246
- app.addHook("onRequest", async (request) => {
247
- if (request.url.startsWith("/api/") && request.url !== "/api/auth/login" && request.url !== "/api/health" && request.url !== "/api/config") {
248
- const token = request.cookies.texlite_session;
249
- if (token && !currentUser(request, db)) {
250
- db.prepare("DELETE FROM sessions WHERE id = ?").run(digestToken(token));
251
- }
252
- }
253
- });
254
226
  app.addHook("onClose", async () => collaboration.destroy());
255
- if (fs.existsSync(config.clientDir)) {
256
- await app.register(staticPlugin, {
257
- root: config.clientDir,
258
- wildcard: false,
259
- // Vite fingerprints everything under assets/. These large JS/WASM files
260
- // are safe to cache indefinitely; a new build produces a new URL. Keep
261
- // index.html fresh so it always points at the current fingerprints.
262
- cacheControl: false,
263
- setHeaders(reply, filePath) {
264
- const relativePath = path.relative(config.clientDir, filePath).split(path.sep).join("/");
265
- if (relativePath === "index.html") {
266
- reply.header("Cache-Control", "no-store");
267
- }
268
- else if (relativePath.startsWith("assets/")) {
269
- reply.header("Cache-Control", "public, max-age=31536000, immutable");
270
- }
271
- else {
272
- reply.header("Cache-Control", "public, max-age=3600");
227
+ const indexPath = path.join(config.clientDir, "index.html");
228
+ const renderedIndex = fs.existsSync(indexPath)
229
+ ? renderClientIndex(fs.readFileSync(indexPath, "utf8"), config.basePath)
230
+ : null;
231
+ const routePrefix = basePathPrefix(config.basePath);
232
+ await app.register(async (routes) => {
233
+ routes.addHook("onRequest", async (request) => {
234
+ const requestPath = withoutBasePath(config.basePath, request.url) ?? request.url;
235
+ if (requestPath.startsWith("/api/")
236
+ && requestPath !== "/api/auth/login"
237
+ && requestPath !== "/api/health"
238
+ && requestPath !== "/api/config") {
239
+ const token = request.cookies.texlite_session;
240
+ if (token && !currentUser(request, db)) {
241
+ db.prepare("DELETE FROM sessions WHERE id = ?").run(digestToken(token));
273
242
  }
274
243
  }
275
244
  });
276
- app.get("/*", async (request, reply) => {
277
- if (request.url.startsWith("/api/"))
278
- return apiError(reply, 404, "API_NOT_FOUND");
279
- reply.header("Cache-Control", "no-store");
280
- return reply.sendFile("index.html");
245
+ registerSystemRoutes(routes, {
246
+ config,
247
+ db,
248
+ queue,
249
+ collaboration,
250
+ latexCompletions,
251
+ projectOutlines,
252
+ metrics,
253
+ eventLoopDelay
281
254
  });
282
- }
255
+ registerCollaborationRoutes(routes, { db, collaboration, metrics });
256
+ registerAuthRoutes(routes, { config, db, loginLimiter });
257
+ registerCitationRoutes(routes, { db });
258
+ registerUserManagementRoutes(routes, {
259
+ config,
260
+ db,
261
+ collaboration,
262
+ projectMutations,
263
+ latexCompletions,
264
+ projectOutlines
265
+ });
266
+ registerCommentRoutes(routes, { config, db, collaboration, projectMutations });
267
+ registerProjectMemberRoutes(routes, { db, collaboration, projectMutations });
268
+ registerProjectFileRoutes(routes, {
269
+ config,
270
+ db,
271
+ collaboration,
272
+ projectMutations,
273
+ latexCompletions,
274
+ projectOutlines,
275
+ metrics,
276
+ recordHistory
277
+ });
278
+ registerProjectReferenceRoutes(routes, { config, db, projectMutations });
279
+ registerProjectHistoryRoutes(routes, { config, db, history, editHistory, projectMutations, recordHistory,
280
+ clearPendingEdits: (id) => { editRetry.clear(id); failedEdits.delete(id); signalHistory(id); },
281
+ scheduleHistoryRetention: (id) => historyRetention.schedule(id) });
282
+ registerProjectGitRoutes(routes, { config, db, collaboration, projectMutations, projectGit, recordHistory });
283
+ registerProjectCatalogRoutes(routes, {
284
+ config,
285
+ db,
286
+ collaboration,
287
+ projectMutations,
288
+ latexCompletions,
289
+ projectOutlines,
290
+ harper,
291
+ recordHistory
292
+ });
293
+ registerWordCountRoutes(routes, { config, db, projectMutations, texcount });
294
+ registerCompileRoutes(routes, { config, db, collaboration, projectMutations, compileCoordinator, metrics, pruneCompileRuns });
295
+ // The factory owns shared services and lifecycle hooks; route modules own endpoint behavior.
296
+ if (renderedIndex !== null) {
297
+ await routes.register(staticPlugin, {
298
+ root: config.clientDir,
299
+ wildcard: false,
300
+ index: false,
301
+ globIgnore: ["index.html"],
302
+ // Vite fingerprints everything under assets/. These large JS/WASM
303
+ // files are safe to cache indefinitely; a new build produces a new
304
+ // URL. The rendered SPA shell below always remains fresh.
305
+ cacheControl: false,
306
+ setHeaders(reply, filePath) {
307
+ const relativePath = path.relative(config.clientDir, filePath).split(path.sep).join("/");
308
+ if (relativePath.startsWith("assets/")) {
309
+ reply.header("Cache-Control", "public, max-age=31536000, immutable");
310
+ }
311
+ else {
312
+ reply.header("Cache-Control", "public, max-age=3600");
313
+ }
314
+ }
315
+ });
316
+ const sendIndex = async (_request, reply) => {
317
+ reply.header("Cache-Control", "no-store");
318
+ return reply.type("text/html; charset=utf-8").send(renderedIndex);
319
+ };
320
+ routes.get("/", sendIndex);
321
+ routes.get("/index.html", sendIndex);
322
+ routes.get("/*", async (request, reply) => {
323
+ const requestPath = withoutBasePath(config.basePath, request.url) ?? request.url;
324
+ if (requestPath.startsWith("/api/"))
325
+ return apiError(reply, 404, "API_NOT_FOUND");
326
+ return sendIndex(request, reply);
327
+ });
328
+ }
329
+ }, { prefix: routePrefix });
283
330
  const cleanupExpiredSessions = () => {
284
331
  try {
285
332
  pruneExpiredSessions(db, now());
@@ -12,6 +12,7 @@ import { assertEnvironment, hostRequirementsSatisfied, inspectHostEnvironment, i
12
12
  import { serve } from "./index.js";
13
13
  import { processStatus, restartManaged, startManaged, stopManaged, streamLogs, waitForOnline } from "./pm2.js";
14
14
  import { defaultDataDirectory, resolveConfigPath } from "./runtimePaths.js";
15
+ import { basePathHref } from "../shared/basePath.js";
15
16
  const HELP = `TexLite ${packageVersion()}
16
17
 
17
18
  Usage:
@@ -106,7 +107,7 @@ function writeInitialConfig(configPath, siteName, adminEmail) {
106
107
  siteName,
107
108
  adminEmail,
108
109
  sessionDays: CONFIG_DEFAULTS.sessionDays,
109
- server: { host: CONFIG_DEFAULTS.host, port: CONFIG_DEFAULTS.port },
110
+ server: { host: CONFIG_DEFAULTS.host, port: CONFIG_DEFAULTS.port, basePath: CONFIG_DEFAULTS.basePath },
110
111
  storage: { dataDir: dataDirectory },
111
112
  uploads: { maxFileSizeMB: CONFIG_DEFAULTS.maxFileSizeMB },
112
113
  pdf: {
@@ -277,6 +278,7 @@ async function printConfig(options) {
277
278
  siteName: config.siteName,
278
279
  host: config.host,
279
280
  port: config.port,
281
+ basePath: config.basePath,
280
282
  dataDir: config.dataDir,
281
283
  databasePath: config.databasePath,
282
284
  projectsDir: config.projectsDir,
@@ -304,7 +306,7 @@ async function start(options, restart = false) {
304
306
  const managed = await waitForOnline(config);
305
307
  const status = managed.description?.pm2_env?.status ?? "unknown";
306
308
  output(`TexLite ${status}: ${config.siteName}`);
307
- output(`Address: http://${config.host}:${config.port}`);
309
+ output(`Address: http://${config.host}:${config.port}${basePathHref(config.basePath)}`);
308
310
  output(`Configuration file: ${configPath}`);
309
311
  output(`PM2 process: ${managed.name}`);
310
312
  }
@@ -4,6 +4,7 @@ import { publishedCompileArtifacts } from "./compiler.js";
4
4
  import { outputRoot, resolveSourcePath, safeRelativePath, sourceRoot, texFileStem } from "./files.js";
5
5
  import { isMainDocumentCandidateSync } from "./latexRoot.js";
6
6
  import { MAX_TEXT_PREVIEW_BYTES } from "./limits.js";
7
+ import { withBasePath } from "../shared/basePath.js";
7
8
  export function pdfLoadingMode(config, pdfSizeBytes) {
8
9
  if (config.pdfLoadingStrategy === "full" || config.pdfLoadingStrategy === "range") {
9
10
  return config.pdfLoadingStrategy;
@@ -77,8 +78,8 @@ export function compileMainFile(config, projectId, defaultMainFile, value) {
77
78
  }
78
79
  return mainFile;
79
80
  }
80
- export function compilePdfUrl(projectId, mainFile, version) {
81
- return `/api/projects/${projectId}/pdf?mainFile=${encodeURIComponent(mainFile)}&run=${encodeURIComponent(version)}`;
81
+ export function compilePdfUrl(config, projectId, mainFile, version) {
82
+ return withBasePath(config.basePath, `/api/projects/${projectId}/pdf?mainFile=${encodeURIComponent(mainFile)}&run=${encodeURIComponent(version)}`);
82
83
  }
83
84
  export function listCompileArtifacts(directory) {
84
85
  const artifacts = [];
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { defaultDataDirectory, packageClientDirectory, resolveConfigPath } from "./runtimePaths.js";
4
+ import { normalizeBasePath, ROOT_BASE_PATH } from "../shared/basePath.js";
4
5
  export const LATEX_ENGINES = ["pdflatex", "xelatex", "lualatex"];
5
6
  export const PDF_LOADING_STRATEGIES = ["auto", "full", "range"];
6
7
  /** Effective values used when the corresponding file/env setting is omitted. */
@@ -9,6 +10,7 @@ export const CONFIG_DEFAULTS = {
9
10
  adminEmail: "",
10
11
  host: "127.0.0.1",
11
12
  port: 3000,
13
+ basePath: ROOT_BASE_PATH,
12
14
  dataDir: defaultDataDirectory(),
13
15
  clientDir: packageClientDirectory(),
14
16
  sessionDays: 14,
@@ -74,12 +76,14 @@ export function loadConfig(configPathOverride) {
74
76
  const historyMaxStorageMB = integerSetting("history.maxStorageMB", process.env.TEXLITE_HISTORY_MAX_STORAGE_MB, fileConfig.history?.maxStorageMB, CONFIG_DEFAULTS.historyMaxStorageMB, CONFIG_LIMITS.historyMaxStorageMB);
75
77
  const editHistoryMaxStorageMB = integerSetting("editHistory.maxStorageMB", process.env.TEXLITE_EDIT_HISTORY_MAX_STORAGE_MB, fileConfig.editHistory?.maxStorageMB, CONFIG_DEFAULTS.editHistoryMaxStorageMB, CONFIG_LIMITS.editHistoryMaxStorageMB);
76
78
  const gitOperationTimeoutSeconds = integerSetting("git.operationTimeoutSeconds", process.env.TEXLITE_GIT_TIMEOUT, fileConfig.git?.operationTimeoutSeconds, CONFIG_DEFAULTS.gitOperationTimeoutSeconds, CONFIG_LIMITS.gitOperationTimeoutSeconds);
79
+ const basePath = resolveBasePath(fileConfig);
77
80
  const config = {
78
81
  configPath,
79
82
  siteName: stringSetting("siteName", process.env.TEXLITE_SITE_NAME, fileConfig.siteName, CONFIG_DEFAULTS.siteName, { min: 1, max: 120 }),
80
83
  adminEmail: stringSetting("adminEmail", process.env.TEXLITE_ADMIN_EMAIL, fileConfig.adminEmail, CONFIG_DEFAULTS.adminEmail, { min: 0, max: 320 }),
81
84
  host: stringSetting("server.host", process.env.TEXLITE_HOST, fileConfig.server?.host, CONFIG_DEFAULTS.host, { min: 1, max: 255 }),
82
85
  port: integerSetting("server.port", process.env.TEXLITE_PORT, fileConfig.server?.port, CONFIG_DEFAULTS.port, CONFIG_LIMITS.port),
86
+ basePath,
83
87
  dataDir,
84
88
  databasePath: path.join(dataDir, "texlite.db"),
85
89
  projectsDir: path.join(dataDir, "projects"),
@@ -105,6 +109,17 @@ export function loadConfig(configPathOverride) {
105
109
  validateConfig(config);
106
110
  return config;
107
111
  }
112
+ /**
113
+ * Read only the configured public mount path. Vite uses this during local UI
114
+ * development, where checking server data paths would be unnecessary and can
115
+ * prevent the frontend from starting before the API server is configured.
116
+ */
117
+ export function loadBasePath(configPathOverride) {
118
+ const configPath = resolveConfigPath(configPathOverride);
119
+ const fileConfig = readConfigFile(configPath);
120
+ validateFileConfig(fileConfig);
121
+ return resolveBasePath(fileConfig);
122
+ }
108
123
  /** Validate an already materialized configuration (useful for startup/tests). */
109
124
  export function validateConfig(config) {
110
125
  if (!config.dataDir || path.parse(config.dataDir).root === config.dataDir) {
@@ -126,6 +141,9 @@ export function validateConfig(config) {
126
141
  throw configurationError("latex.defaultEngine", "must be one of the engines listed in latex.allowedEngines");
127
142
  }
128
143
  validateInteger("server.port", config.port, CONFIG_LIMITS.port);
144
+ if (normalizeBasePath(config.basePath) !== config.basePath) {
145
+ throw configurationError("server.basePath", "must be / or a normalized URL path such as /texlite or /tools/texlite");
146
+ }
129
147
  validateInteger("sessionDays", config.sessionDays, CONFIG_LIMITS.sessionDays);
130
148
  validateInteger("latex.compileTimeoutSeconds", config.compileTimeoutMs / 1000, CONFIG_LIMITS.compileTimeoutSeconds);
131
149
  validateInteger("latex.maxCompileJobs", config.maxCompileJobs, CONFIG_LIMITS.maxCompileJobs);
@@ -139,6 +157,14 @@ export function validateConfig(config) {
139
157
  validateInteger("editHistory.maxStorageMB", config.editHistoryMaxStorageBytes / (1024 * 1024), CONFIG_LIMITS.editHistoryMaxStorageMB);
140
158
  validateInteger("git.operationTimeoutSeconds", config.gitOperationTimeoutMs / 1000, CONFIG_LIMITS.gitOperationTimeoutSeconds);
141
159
  }
160
+ function resolveBasePath(fileConfig) {
161
+ const configuredBasePath = stringSetting("server.basePath", process.env.TEXLITE_BASE_PATH, fileConfig.server?.basePath, CONFIG_DEFAULTS.basePath, { min: 1, max: 1_024 });
162
+ const basePath = normalizeBasePath(configuredBasePath);
163
+ if (!basePath) {
164
+ throw configurationError("server.basePath", `must be / or a URL path such as /texlite or /tools/texlite; received ${displayValue(configuredBasePath)}`);
165
+ }
166
+ return basePath;
167
+ }
142
168
  function isEngine(value) {
143
169
  return typeof value === "string" && LATEX_ENGINES.includes(value);
144
170
  }
@@ -168,6 +194,7 @@ function validateFileConfig(config) {
168
194
  const server = optionalSection(config.server, "server");
169
195
  optionalString(server?.host, "server.host", { min: 1, max: 255 });
170
196
  optionalInteger(server?.port, "server.port", CONFIG_LIMITS.port);
197
+ optionalString(server?.basePath, "server.basePath", { min: 1, max: 1_024 });
171
198
  const storage = optionalSection(config.storage, "storage");
172
199
  optionalString(storage?.dataDir, "storage.dataDir", { min: 1, max: 4_096 });
173
200
  const uploads = optionalSection(config.uploads, "uploads");
@@ -6,6 +6,7 @@ import { buildApp } from "./app.js";
6
6
  import { assertEnvironment } from "./environment.js";
7
7
  import { acquireDataDirectoryLock } from "./instanceLock.js";
8
8
  import { LogRotation } from "./logRotation.js";
9
+ import { basePathHref } from "../shared/basePath.js";
9
10
  export async function startServer(configPath) {
10
11
  const config = loadConfig(configPath);
11
12
  const lock = acquireDataDirectoryLock(config);
@@ -23,11 +24,12 @@ export async function startServer(configPath) {
23
24
  app = await buildApp(config, db);
24
25
  await app.listen({ host: config.host, port: config.port });
25
26
  app.log.info(`Environment ready: ${environment.map((item) => `${item.name} ${item.version}`).join(", ")}`);
26
- app.log.info(`${config.siteName} running at http://${config.host}:${config.port}`);
27
+ const address = `http://${config.host}:${config.port}${basePathHref(config.basePath)}`;
28
+ app.log.info(`${config.siteName} running at ${address}`);
27
29
  if (typeof process.send === "function")
28
30
  process.send("ready");
29
31
  return {
30
- address: `http://${config.host}:${config.port}`,
32
+ address,
31
33
  close: async () => {
32
34
  try {
33
35
  if (app)
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { randomUUID } from "node:crypto";
4
+ import { basePathHref } from "../shared/basePath.js";
4
5
  export const LOCK_FILE_NAME = ".texlite.lock";
5
6
  export function isProcessAlive(pid) {
6
7
  if (!Number.isInteger(pid) || pid <= 0)
@@ -45,7 +46,8 @@ export function acquireDataDirectoryLock(config) {
45
46
  token: randomUUID(),
46
47
  configPath: config.configPath,
47
48
  host: config.host,
48
- port: config.port
49
+ port: config.port,
50
+ basePath: config.basePath
49
51
  };
50
52
  for (let attempt = 0; attempt < 2; attempt += 1) {
51
53
  try {
@@ -103,7 +105,7 @@ export function acquireDataDirectoryLock(config) {
103
105
  throw new Error(`The data directory is already locked by another TexLite instance:\n` +
104
106
  ` - PID: ${existing.pid}\n` +
105
107
  ` - Config: ${existing.configPath}\n` +
106
- ` - Address: http://${existing.host}:${existing.port}\n` +
108
+ ` - Address: http://${existing.host}:${existing.port}${basePathHref(existing.basePath ?? "/")}\n` +
107
109
  ` - Started: ${existing.startedAt}\n` +
108
110
  ` - Data directory: ${config.dataDir}\n` +
109
111
  `Do not run multiple TexLite instances with the same data directory.`);