texlite 0.9.0 → 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DESIGN.md +7 -0
- package/OPERATIONS.md +70 -3
- package/README.md +5 -0
- package/README.zh-CN.md +5 -0
- package/THIRD_PARTY_NOTICES.md +29 -0
- package/dist/client/assets/{AdminUsers-3CHb5012.js → AdminUsers-Buf5ZclR.js} +1 -1
- package/dist/client/assets/{CitationLibraryDialog-zPpsUhWJ.js → CitationLibraryDialog-DrKbIlD-.js} +1 -1
- package/dist/client/assets/Dashboard-DLk2j3oq.js +2 -0
- package/dist/client/assets/GitDialog-CTWAU8q6.js +3 -0
- package/dist/client/assets/HistoryDialog-Mo4p8HEg.js +7 -0
- package/dist/client/assets/LatexEditor-CwvakYva.js +135 -0
- package/dist/client/assets/{PdfPreview-BFVNczSk.js → PdfPreview-BUpELZd-.js} +1 -1
- package/dist/client/assets/{ProjectIconPickerDialog-Cf0zBf8u.js → ProjectIconPickerDialog-BiWSwDdt.js} +1 -1
- package/dist/client/assets/{ProjectNavigationDialogs-TPbe11iR.js → ProjectNavigationDialogs-CpjkXZTz.js} +1 -1
- package/dist/client/assets/{ProjectSettings-CDQvIlQ4.js → ProjectSettings-DoxitHbX.js} +1 -1
- package/dist/client/assets/ProjectWorkspace-iNqXZlsj.js +29 -0
- package/dist/client/assets/SelectionHistoryDialog-9FWlc3Rb.js +3 -0
- package/dist/client/assets/{SystemMetricsDialog-CuqHPUnn.js → SystemMetricsDialog-24kifZO6.js} +1 -1
- package/dist/client/assets/TagManagementDialog-BHIA0vEA.js +1 -0
- package/dist/client/assets/{WordCountDialog-B4PI0vEk.js → WordCountDialog-DFIWvLrP.js} +1 -1
- package/dist/client/assets/check-C3GZh6a1.js +1 -0
- package/dist/client/assets/chevron-left-l1oupvGO.js +1 -0
- package/dist/client/assets/editor-3rgn4IYh.js +14 -0
- package/dist/client/assets/{hard-drive-6uYFyezl.js → hard-drive-B-NSDVVA.js} +1 -1
- package/dist/client/assets/{index-BRT5O-Hg.js → index-CNTGCh7t.js} +1 -1
- package/dist/client/assets/index-CrlfruX_.css +1 -0
- package/dist/client/assets/index-DIM_KiG2.js +12 -0
- package/dist/client/assets/katex-D9PXdmFF.css +1 -0
- package/dist/client/assets/{key-round-DOAxDSfz.js → key-round-BjL9H8-H.js} +1 -1
- package/dist/client/assets/{latexFormatterWorker-DYkYtcFy.js → latexFormatterWorker-BRmsns8a.js} +2 -2
- package/dist/client/assets/minimize-2-DDCq8c49.js +1 -0
- package/dist/client/assets/{minus-9eVJnugf.js → minus-DgKZCT9F.js} +1 -1
- package/dist/client/assets/{pdf-BB1XvBIF.js → pdf-DUYcbPMK.js} +1 -1
- package/dist/client/assets/{plus-BGXipRoG.js → plus-CezUzSIZ.js} +1 -1
- package/dist/client/assets/{refresh-cw-Dvp3O90f.js → refresh-cw-CDFNL-qu.js} +1 -1
- package/dist/client/assets/{search-b1ORp1k8.js → search-CrDJjHoj.js} +1 -1
- package/dist/client/assets/{sigma-Bk0_MzWi.js → sigma-DOcJr7VG.js} +1 -1
- package/dist/client/assets/spellCheck-BgWyesvf.js +1 -0
- package/dist/client/assets/{tags-JAkbmWns.js → tags-BedQ75lh.js} +1 -1
- package/dist/client/assets/{type-CE2DYkPH.js → type-glZNdzZR.js} +1 -1
- package/dist/client/assets/{user-plus-CKalKSsw.js → user-plus-gQ6q5Nom.js} +1 -1
- package/dist/client/assets/{x-Bq29njHL.js → x-CSzFikn2.js} +1 -1
- package/dist/client/index.html +6 -4
- package/dist/server/app.js +130 -83
- package/dist/server/cli.js +4 -2
- package/dist/server/compileArtifacts.js +3 -2
- package/dist/server/config.js +27 -0
- package/dist/server/harper.js +205 -8
- package/dist/server/history.js +53 -3
- package/dist/server/i18n.js +8 -0
- package/dist/server/index.js +4 -2
- package/dist/server/instanceLock.js +4 -2
- package/dist/server/latexCompletion.js +126 -63
- package/dist/server/latexDocumentGraph.js +106 -0
- package/dist/server/latexSpellMask.js +196 -43
- package/dist/server/pm2.js +3 -2
- package/dist/server/projectReferences.js +109 -0
- package/dist/server/routes/auth.js +5 -2
- package/dist/server/routes/comments.js +13 -1
- package/dist/server/routes/compile.js +2 -2
- package/dist/server/routes/projectFiles.js +12 -3
- package/dist/server/routes/projectHistory.js +13 -1
- package/dist/server/routes/projectReferences.js +36 -0
- package/dist/server/routes/projects.js +41 -4
- package/dist/server/routes/system.js +1 -0
- package/dist/server/zip.js +2 -16
- package/dist/shared/basePath.js +44 -0
- package/dist/shared/bibtexReferences.js +135 -0
- package/dist/shared/latexCommandSnippets.js +91 -0
- package/dist/shared/latexDependencies.js +71 -0
- package/dist/shared/latexLiterals.js +215 -0
- package/dist/shared/latexReferenceCommands.js +75 -0
- package/dist/shared/latexReferences.js +193 -0
- package/dist/shared/latexRoot.js +21 -0
- package/dist/shared/latexScanner.js +183 -0
- package/dist/shared/writingChecks.js +4 -0
- package/package.json +11 -4
- package/texlite.config.example.json +2 -4
- package/dist/client/assets/Dashboard-CWfSLzD3.js +0 -2
- package/dist/client/assets/GitDialog-BUU2U_LV.js +0 -3
- package/dist/client/assets/HistoryDialog-B6aopsrw.js +0 -7
- package/dist/client/assets/LatexEditor-CM445Tk0.js +0 -65
- package/dist/client/assets/ProjectWorkspace-C6GrvKN6.js +0 -27
- package/dist/client/assets/SelectionHistoryDialog-CyE0_YDs.js +0 -3
- package/dist/client/assets/TagManagementDialog-gRLM3lLx.js +0 -1
- package/dist/client/assets/check-BXvzrdoz.js +0 -1
- package/dist/client/assets/chevron-left-BB5MYb7g.js +0 -1
- package/dist/client/assets/editor-CiK7TK0p.js +0 -14
- package/dist/client/assets/index-C0t11tlT.js +0 -14
- package/dist/client/assets/index-DXWp2bzM.css +0 -1
- package/dist/client/assets/katex-CEK31ho9.css +0 -1
- package/dist/client/assets/minimize-2-BWtbag2I.js +0 -1
- package/dist/client/assets/spellCheck-BJjWgnef.js +0 -1
package/dist/server/app.js
CHANGED
|
@@ -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("&", "&").replaceAll('"', """).replaceAll("<", "<").replaceAll(">", ">");
|
|
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
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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());
|
package/dist/server/cli.js
CHANGED
|
@@ -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 = [];
|
package/dist/server/config.js
CHANGED
|
@@ -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");
|
package/dist/server/harper.js
CHANGED
|
@@ -11,12 +11,26 @@ const maxCachedSourceBytes = 512 * 1024;
|
|
|
11
11
|
const maxCachedResults = 24;
|
|
12
12
|
const cacheTtlMs = 15_000;
|
|
13
13
|
const unavailableRetryMs = 15_000;
|
|
14
|
+
// A browser can send an older request after a newer one when HTTP requests
|
|
15
|
+
// race. Keep the latest sequence briefly so that late arrivals cannot replace
|
|
16
|
+
// a newer queued revision. The bound is intentionally generous for a small
|
|
17
|
+
// collaborative installation while keeping abandoned browser-page lanes from
|
|
18
|
+
// accumulating forever.
|
|
19
|
+
const laneSequenceRetentionMs = 10 * 60_000;
|
|
20
|
+
const maxTrackedLaneSequences = 512;
|
|
14
21
|
export class HarperUnavailableError extends Error {
|
|
15
22
|
constructor(message) {
|
|
16
23
|
super(message);
|
|
17
24
|
this.name = "HarperUnavailableError";
|
|
18
25
|
}
|
|
19
26
|
}
|
|
27
|
+
/** A newer source revision made a waiting writing check irrelevant. */
|
|
28
|
+
export class HarperLintSupersededError extends Error {
|
|
29
|
+
constructor() {
|
|
30
|
+
super("A newer writing check replaced this request.");
|
|
31
|
+
this.name = "HarperLintSupersededError";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
20
34
|
function lintCacheKey(source, filePath) {
|
|
21
35
|
// Harper selects its parser from the input suffix, so equivalent text in a
|
|
22
36
|
// .tex and a .sty file must not share a diagnostic result.
|
|
@@ -78,8 +92,20 @@ export class HarperService {
|
|
|
78
92
|
availability = "unknown";
|
|
79
93
|
lastUnavailableAt = 0;
|
|
80
94
|
probePromise = null;
|
|
81
|
-
|
|
95
|
+
/** One harper-cli process at a time; Harper itself is comparatively heavy. */
|
|
96
|
+
queueRunner = null;
|
|
97
|
+
// Set preserves insertion order, giving the scheduler FIFO behaviour while
|
|
98
|
+
// allowing a superseded waiting item to be removed immediately. An array
|
|
99
|
+
// would otherwise retain its complete source string until the active check
|
|
100
|
+
// finishes.
|
|
101
|
+
queued = new Set();
|
|
102
|
+
/** Only waiting work belongs here; a running process must finish safely. */
|
|
103
|
+
waitingByLane = new Map();
|
|
104
|
+
/** Latest accepted browser revision for each project/user/page/file lane. */
|
|
105
|
+
latestSequenceByLane = new Map();
|
|
82
106
|
inFlight = new Map();
|
|
107
|
+
/** Lets coalesced browser lanes share one queued operation safely. */
|
|
108
|
+
scheduledByKey = new Map();
|
|
83
109
|
cache = new Map();
|
|
84
110
|
activeChild = null;
|
|
85
111
|
disposed = false;
|
|
@@ -89,35 +115,206 @@ export class HarperService {
|
|
|
89
115
|
async preload() {
|
|
90
116
|
await this.ensureAvailable();
|
|
91
117
|
}
|
|
92
|
-
|
|
118
|
+
/**
|
|
119
|
+
* Schedule one host-side check. A lane represents one browser page's
|
|
120
|
+
* current file, so an edit can replace only its own stale waiting request
|
|
121
|
+
* without cancelling checks requested by collaborators.
|
|
122
|
+
*/
|
|
123
|
+
async lint(source, filePath = "main.tex", lane, sequence) {
|
|
93
124
|
if (this.disposed)
|
|
94
125
|
throw new HarperUnavailableError("Harper service stopped.");
|
|
95
126
|
const key = lintCacheKey(source, filePath);
|
|
127
|
+
const laneKey = lane || null;
|
|
128
|
+
if (laneKey && sequence !== undefined)
|
|
129
|
+
this.acceptLaneSequence(laneKey, sequence, key);
|
|
130
|
+
const waiting = laneKey ? this.waitingByLane.get(laneKey) : undefined;
|
|
131
|
+
// Repeated requests for the same queued revision share it. A newer
|
|
132
|
+
// revision replaces only work that has not started yet.
|
|
133
|
+
if (waiting?.key === key)
|
|
134
|
+
return waiting.promise;
|
|
135
|
+
if (laneKey && waiting)
|
|
136
|
+
this.releaseWaitingLane(laneKey, waiting);
|
|
96
137
|
const cacheable = Buffer.byteLength(source, "utf8") <= maxCachedSourceBytes;
|
|
97
138
|
const cached = cacheable ? this.cachedResult(key) : null;
|
|
98
139
|
if (cached)
|
|
99
140
|
return cached;
|
|
100
|
-
|
|
101
|
-
|
|
141
|
+
// Result caching is deliberately size-bounded, but duplicate active work
|
|
142
|
+
// is still coalesced for large files. The latter is transient and avoids
|
|
143
|
+
// starting multiple expensive CLI processes for identical content.
|
|
144
|
+
const existing = this.inFlight.get(key);
|
|
145
|
+
if (existing) {
|
|
146
|
+
const scheduled = this.scheduledByKey.get(key);
|
|
147
|
+
if (laneKey && scheduled)
|
|
148
|
+
this.addWaitingLane(scheduled, laneKey);
|
|
149
|
+
else if (scheduled && !scheduled.started)
|
|
150
|
+
scheduled.hasUnscopedWaiter = true;
|
|
102
151
|
return existing;
|
|
103
|
-
|
|
104
|
-
|
|
152
|
+
}
|
|
153
|
+
let resolve;
|
|
154
|
+
let reject;
|
|
155
|
+
const operation = new Promise((resolveOperation, rejectOperation) => {
|
|
156
|
+
resolve = resolveOperation;
|
|
157
|
+
reject = rejectOperation;
|
|
158
|
+
});
|
|
159
|
+
const scheduled = {
|
|
160
|
+
key,
|
|
161
|
+
source,
|
|
162
|
+
filePath,
|
|
163
|
+
started: false,
|
|
164
|
+
cancelled: false,
|
|
165
|
+
lanes: laneKey ? new Set([laneKey]) : new Set(),
|
|
166
|
+
hasUnscopedWaiter: laneKey === null,
|
|
167
|
+
promise: operation,
|
|
168
|
+
resolve,
|
|
169
|
+
reject
|
|
170
|
+
};
|
|
171
|
+
this.inFlight.set(key, operation);
|
|
172
|
+
this.scheduledByKey.set(key, scheduled);
|
|
173
|
+
if (laneKey)
|
|
174
|
+
this.waitingByLane.set(laneKey, scheduled);
|
|
175
|
+
this.queued.add(scheduled);
|
|
176
|
+
this.startQueue();
|
|
105
177
|
if (cacheable) {
|
|
106
|
-
this.inFlight.set(key, operation);
|
|
107
178
|
void operation.then((lints) => this.cacheResult(key, lints), () => undefined).finally(() => {
|
|
108
179
|
if (this.inFlight.get(key) === operation)
|
|
109
180
|
this.inFlight.delete(key);
|
|
181
|
+
if (this.scheduledByKey.get(key) === scheduled)
|
|
182
|
+
this.scheduledByKey.delete(key);
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
// Attach a rejection handler even for uncached results. This keeps a
|
|
187
|
+
// superseded request from becoming an unhandled rejection when a client
|
|
188
|
+
// disconnects before it awaits the response.
|
|
189
|
+
void operation.catch(() => undefined).finally(() => {
|
|
190
|
+
if (this.inFlight.get(key) === operation)
|
|
191
|
+
this.inFlight.delete(key);
|
|
192
|
+
if (this.scheduledByKey.get(key) === scheduled)
|
|
193
|
+
this.scheduledByKey.delete(key);
|
|
110
194
|
});
|
|
111
195
|
}
|
|
112
196
|
return operation;
|
|
113
197
|
}
|
|
114
198
|
async dispose() {
|
|
115
199
|
this.disposed = true;
|
|
200
|
+
const stopped = new HarperUnavailableError("Harper service stopped.");
|
|
201
|
+
for (const scheduled of [...this.queued]) {
|
|
202
|
+
if (!scheduled.cancelled && !scheduled.started) {
|
|
203
|
+
scheduled.cancelled = true;
|
|
204
|
+
this.discardQueuedLint(scheduled);
|
|
205
|
+
scheduled.reject(stopped);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
this.waitingByLane.clear();
|
|
209
|
+
this.latestSequenceByLane.clear();
|
|
116
210
|
this.activeChild?.kill("SIGTERM");
|
|
117
|
-
await this.
|
|
211
|
+
await this.queueRunner?.catch(() => undefined);
|
|
118
212
|
this.inFlight.clear();
|
|
213
|
+
this.scheduledByKey.clear();
|
|
119
214
|
this.cache.clear();
|
|
120
215
|
}
|
|
216
|
+
addWaitingLane(scheduled, lane) {
|
|
217
|
+
if (scheduled.started || scheduled.cancelled)
|
|
218
|
+
return;
|
|
219
|
+
scheduled.lanes.add(lane);
|
|
220
|
+
this.waitingByLane.set(lane, scheduled);
|
|
221
|
+
}
|
|
222
|
+
releaseWaitingLane(lane, scheduled) {
|
|
223
|
+
if (this.waitingByLane.get(lane) === scheduled)
|
|
224
|
+
this.waitingByLane.delete(lane);
|
|
225
|
+
scheduled.lanes.delete(lane);
|
|
226
|
+
// The exact same queued source can be requested by multiple sessions.
|
|
227
|
+
// Discard it only after every interested lane has moved on.
|
|
228
|
+
if (scheduled.started || scheduled.cancelled || scheduled.hasUnscopedWaiter || scheduled.lanes.size > 0)
|
|
229
|
+
return;
|
|
230
|
+
this.cancelScheduledLint(scheduled);
|
|
231
|
+
}
|
|
232
|
+
cancelScheduledLint(scheduled) {
|
|
233
|
+
if (scheduled.started || scheduled.cancelled)
|
|
234
|
+
return;
|
|
235
|
+
scheduled.cancelled = true;
|
|
236
|
+
for (const lane of scheduled.lanes) {
|
|
237
|
+
if (this.waitingByLane.get(lane) === scheduled)
|
|
238
|
+
this.waitingByLane.delete(lane);
|
|
239
|
+
}
|
|
240
|
+
scheduled.lanes.clear();
|
|
241
|
+
// Remove this rejected promise immediately so a later return to the same
|
|
242
|
+
// text can enqueue fresh work instead of inheriting a stale rejection.
|
|
243
|
+
if (this.inFlight.get(scheduled.key) === scheduled.promise) {
|
|
244
|
+
this.inFlight.delete(scheduled.key);
|
|
245
|
+
}
|
|
246
|
+
if (this.scheduledByKey.get(scheduled.key) === scheduled) {
|
|
247
|
+
this.scheduledByKey.delete(scheduled.key);
|
|
248
|
+
}
|
|
249
|
+
this.discardQueuedLint(scheduled);
|
|
250
|
+
scheduled.reject(new HarperLintSupersededError());
|
|
251
|
+
}
|
|
252
|
+
/** Remove a waiting task and promptly release its potentially large text. */
|
|
253
|
+
discardQueuedLint(scheduled) {
|
|
254
|
+
this.queued.delete(scheduled);
|
|
255
|
+
scheduled.source = "";
|
|
256
|
+
scheduled.filePath = "";
|
|
257
|
+
}
|
|
258
|
+
/** Reject an out-of-order request before it can alter queued work. */
|
|
259
|
+
acceptLaneSequence(lane, sequence, key) {
|
|
260
|
+
if (!Number.isSafeInteger(sequence) || sequence <= 0) {
|
|
261
|
+
throw new HarperLintSupersededError();
|
|
262
|
+
}
|
|
263
|
+
const now = Date.now();
|
|
264
|
+
this.pruneLaneSequences(now);
|
|
265
|
+
const previous = this.latestSequenceByLane.get(lane);
|
|
266
|
+
if (previous && (sequence < previous.sequence || (sequence === previous.sequence && previous.key !== key))) {
|
|
267
|
+
throw new HarperLintSupersededError();
|
|
268
|
+
}
|
|
269
|
+
// Refresh insertion order so the bounded map evicts the least recently
|
|
270
|
+
// used abandoned page lane first.
|
|
271
|
+
this.latestSequenceByLane.delete(lane);
|
|
272
|
+
this.latestSequenceByLane.set(lane, { sequence, key, seenAt: now });
|
|
273
|
+
while (this.latestSequenceByLane.size > maxTrackedLaneSequences) {
|
|
274
|
+
const oldest = this.latestSequenceByLane.keys().next().value;
|
|
275
|
+
if (oldest === undefined)
|
|
276
|
+
break;
|
|
277
|
+
this.latestSequenceByLane.delete(oldest);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
pruneLaneSequences(now) {
|
|
281
|
+
for (const [lane, state] of this.latestSequenceByLane) {
|
|
282
|
+
if (now - state.seenAt <= laneSequenceRetentionMs)
|
|
283
|
+
continue;
|
|
284
|
+
this.latestSequenceByLane.delete(lane);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
startQueue() {
|
|
288
|
+
if (this.queueRunner)
|
|
289
|
+
return;
|
|
290
|
+
this.queueRunner = (async () => {
|
|
291
|
+
while (!this.disposed) {
|
|
292
|
+
const scheduled = this.queued.values().next().value;
|
|
293
|
+
if (!scheduled)
|
|
294
|
+
return;
|
|
295
|
+
this.queued.delete(scheduled);
|
|
296
|
+
if (scheduled.cancelled)
|
|
297
|
+
continue;
|
|
298
|
+
scheduled.started = true;
|
|
299
|
+
for (const lane of scheduled.lanes) {
|
|
300
|
+
if (this.waitingByLane.get(lane) === scheduled)
|
|
301
|
+
this.waitingByLane.delete(lane);
|
|
302
|
+
}
|
|
303
|
+
scheduled.lanes.clear();
|
|
304
|
+
try {
|
|
305
|
+
scheduled.resolve(await this.lintOnce(scheduled.source, scheduled.filePath));
|
|
306
|
+
}
|
|
307
|
+
catch (error) {
|
|
308
|
+
scheduled.reject(error);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
})().finally(() => {
|
|
312
|
+
this.queueRunner = null;
|
|
313
|
+
// A request can arrive just after the loop observes an empty queue.
|
|
314
|
+
if (!this.disposed && this.queued.size)
|
|
315
|
+
this.startQueue();
|
|
316
|
+
});
|
|
317
|
+
}
|
|
121
318
|
async ensureAvailable() {
|
|
122
319
|
if (this.availability === "available")
|
|
123
320
|
return;
|