texlite 0.9.1 → 0.9.3

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 (95) hide show
  1. package/DESIGN.md +36 -6
  2. package/OPERATIONS.md +70 -3
  3. package/README.md +11 -3
  4. package/README.zh-CN.md +11 -2
  5. package/THIRD_PARTY_NOTICES.md +29 -0
  6. package/dist/client/assets/AdminUsers-huwQK7Ly.js +1 -0
  7. package/dist/client/assets/CitationLibraryDialog-C5C3sPdw.js +1 -0
  8. package/dist/client/assets/Dashboard-DtHSc55D.js +2 -0
  9. package/dist/client/assets/{GitDialog-C9Loz4Al.js → GitDialog-BI7QIMsh.js} +1 -1
  10. package/dist/client/assets/{HistoryDialog-3UNN_jhU.js → HistoryDialog-DA7O9aF2.js} +1 -1
  11. package/dist/client/assets/LatexEditor-BYWWx5Km.js +138 -0
  12. package/dist/client/assets/{PdfPreview-BKhcS-2c.js → PdfPreview-mO9WS01q.js} +1 -1
  13. package/dist/client/assets/ProjectIconPickerDialog-B0q_r5dX.js +1 -0
  14. package/dist/client/assets/{ProjectNavigationDialogs-CZKTnxrl.js → ProjectNavigationDialogs-DwWYbB1A.js} +1 -1
  15. package/dist/client/assets/ProjectSettings-BpPFgdoj.js +1 -0
  16. package/dist/client/assets/ProjectWorkspace-CI2zKon5.js +32 -0
  17. package/dist/client/assets/{SelectionHistoryDialog-D3dO83lg.js → SelectionHistoryDialog-pWMI5izu.js} +2 -2
  18. package/dist/client/assets/SystemMetricsDialog-BmlO7oNg.js +1 -0
  19. package/dist/client/assets/TagManagementDialog-h4Ves4FR.js +1 -0
  20. package/dist/client/assets/{WordCountDialog-TwpyAP3m.js → WordCountDialog-DlBSP4y7.js} +1 -1
  21. package/dist/client/assets/check-BAdi_Lxq.js +1 -0
  22. package/dist/client/assets/editor-DZS7-wrd.js +14 -0
  23. package/dist/client/assets/{hard-drive-XHSiS_A7.js → hard-drive-DP2OHKMC.js} +1 -1
  24. package/dist/client/assets/index-CBrIrzgg.css +1 -0
  25. package/dist/client/assets/{index-Cd6-F29b.js → index-i5IY_PMw.js} +1 -1
  26. package/dist/client/assets/index-teah8iB2.js +12 -0
  27. package/dist/client/assets/katex-D9PXdmFF.css +1 -0
  28. package/dist/client/assets/{key-round-C-CXCWnz.js → key-round-D8sCqL2b.js} +1 -1
  29. package/dist/client/assets/{latexFormatterWorker-DYkYtcFy.js → latexFormatterWorker-BRmsns8a.js} +2 -2
  30. package/dist/client/assets/{minimize-2-BMDPVaF-.js → minimize-2-6tf519Ts.js} +1 -1
  31. package/dist/client/assets/{minus-BIa7-KuM.js → minus-CqvESp9M.js} +1 -1
  32. package/dist/client/assets/{pdf-BB1XvBIF.js → pdf-DUYcbPMK.js} +1 -1
  33. package/dist/client/assets/{plus-BKDWy4Ui.js → plus-DIrweesj.js} +1 -1
  34. package/dist/client/assets/{refresh-cw-Bo7-a7-I.js → refresh-cw-BDcwKu_d.js} +1 -1
  35. package/dist/client/assets/{search-Bz9Ob5mT.js → search-C9_Jyrnx.js} +1 -1
  36. package/dist/client/assets/{sigma-DxlHf9gv.js → sigma-p9-fBP0d.js} +1 -1
  37. package/dist/client/assets/spellCheck-Y0RlmXbl.js +1 -0
  38. package/dist/client/assets/{tags-IMZehjzE.js → tags-DjF3z21M.js} +1 -1
  39. package/dist/client/assets/{type-DTWpFQNQ.js → type-CNrDJnzR.js} +1 -1
  40. package/dist/client/assets/{user-plus--aVpd8K6.js → user-plus-C7exNawX.js} +1 -1
  41. package/dist/client/assets/x-BEVz0wdw.js +1 -0
  42. package/dist/client/index.html +6 -4
  43. package/dist/server/app.js +130 -83
  44. package/dist/server/cli.js +4 -2
  45. package/dist/server/collaboration.js +6 -19
  46. package/dist/server/compileArtifacts.js +3 -2
  47. package/dist/server/config.js +27 -0
  48. package/dist/server/db.js +91 -10
  49. package/dist/server/index.js +4 -2
  50. package/dist/server/instanceLock.js +4 -2
  51. package/dist/server/latexCompletion.js +126 -63
  52. package/dist/server/latexDocumentGraph.js +217 -0
  53. package/dist/server/latexSpellMask.js +3 -4
  54. package/dist/server/pm2.js +3 -2
  55. package/dist/server/projectOutline.js +103 -91
  56. package/dist/server/projectReferences.js +109 -0
  57. package/dist/server/routes/auth.js +5 -2
  58. package/dist/server/routes/comments.js +10 -4
  59. package/dist/server/routes/compile.js +2 -2
  60. package/dist/server/routes/projectFiles.js +12 -3
  61. package/dist/server/routes/projectReferences.js +36 -0
  62. package/dist/server/routes/projectShared.js +33 -12
  63. package/dist/server/routes/projects.js +5 -0
  64. package/dist/server/routes/system.js +1 -0
  65. package/dist/server/zip.js +2 -16
  66. package/dist/shared/basePath.js +44 -0
  67. package/dist/shared/bibtexReferences.js +135 -0
  68. package/dist/shared/collaborationProtocol.js +31 -0
  69. package/dist/shared/latexCommandSnippets.js +91 -0
  70. package/dist/shared/latexDependencies.js +131 -0
  71. package/dist/shared/latexLiterals.js +215 -0
  72. package/dist/shared/latexReferenceCommands.js +75 -0
  73. package/dist/shared/latexReferences.js +193 -0
  74. package/dist/shared/latexRoot.js +21 -0
  75. package/dist/shared/latexScanner.js +183 -0
  76. package/dist/shared/writingChecks.js +4 -0
  77. package/package.json +11 -4
  78. package/texlite.config.example.json +2 -4
  79. package/dist/client/assets/AdminUsers-Dazar9cj.js +0 -1
  80. package/dist/client/assets/CitationLibraryDialog-VSUiSPgG.js +0 -1
  81. package/dist/client/assets/Dashboard-CoE5ESfb.js +0 -2
  82. package/dist/client/assets/LatexEditor-Bq76v8sO.js +0 -65
  83. package/dist/client/assets/ProjectIconPickerDialog-BoWMjQVm.js +0 -1
  84. package/dist/client/assets/ProjectSettings-DiOQVJ40.js +0 -1
  85. package/dist/client/assets/ProjectWorkspace-DqLwkGpT.js +0 -27
  86. package/dist/client/assets/SystemMetricsDialog-0zCwLtH_.js +0 -1
  87. package/dist/client/assets/TagManagementDialog-C6KDwtvd.js +0 -1
  88. package/dist/client/assets/check-sZxOOKtS.js +0 -1
  89. package/dist/client/assets/chevron-left-32SrOwa5.js +0 -1
  90. package/dist/client/assets/editor-CiK7TK0p.js +0 -14
  91. package/dist/client/assets/index-BRr3Kuxh.css +0 -1
  92. package/dist/client/assets/index-BjzELSQ-.js +0 -12
  93. package/dist/client/assets/katex-CEK31ho9.css +0 -1
  94. package/dist/client/assets/spellCheck-BfZEnpwY.js +0 -1
  95. package/dist/client/assets/x-Tbo3aA0J.js +0 -1
@@ -1,45 +1,55 @@
1
1
  import fs from "node:fs";
2
- import path from "node:path";
3
- import { listProjectFilesAsync, resolveSourcePath, safeRelativePath } from "./files.js";
4
- const levels = { part: 0, chapter: 0, section: 1, subsection: 2, subsubsection: 3, paragraph: 4 };
5
- const commandPattern = /\\(part|chapter|section|subsection|subsubsection|paragraph)\*?(?:\s*\[[^\]]*\])?\s*\{|\\(?:input|include|subfile)\s*\{/g;
2
+ import { maskLatexComments } from "../shared/latexLiterals.js";
3
+ import { findLatexSourceIncludes } from "../shared/latexDependencies.js";
4
+ import { forEachLatexCommand, readLatexMandatoryArguments } from "../shared/latexScanner.js";
5
+ import { resolveLatexInclude, toLatexIncludeDirective } from "./latexDocumentGraph.js";
6
+ import { listProjectFiles, listProjectFilesAsync, resolveSourcePath, safeRelativePath } from "./files.js";
7
+ const levels = new Map([
8
+ ["part", 0],
9
+ ["chapter", 0],
10
+ ["section", 1],
11
+ ["subsection", 2],
12
+ ["subsubsection", 3],
13
+ ["paragraph", 4]
14
+ ]);
6
15
  export function buildProjectOutline(config, projectId, mainFileInput) {
7
16
  const mainFile = safeRelativePath(mainFileInput);
17
+ return buildProjectOutlineFromFiles(config, projectId, mainFile, sourceFilePaths(listProjectFiles(config, projectId)));
18
+ }
19
+ function buildProjectOutlineFromFiles(config, projectId, mainFile, availableFiles) {
8
20
  const result = [];
9
21
  const visited = new Set();
10
- const visit = (filePath) => {
22
+ const visit = (filePath, importBase) => {
11
23
  if (visited.has(filePath) || visited.size >= 200)
12
24
  return;
13
25
  visited.add(filePath);
14
26
  const absolute = resolveSourcePath(config, projectId, filePath);
15
27
  if (!fs.existsSync(absolute) || !fs.statSync(absolute).isFile() || fs.statSync(absolute).size > 3 * 1024 * 1024)
16
28
  return;
17
- const content = stripCommentsPreserveLines(fs.readFileSync(absolute, "utf8"));
18
- const lineStarts = sourceLineStarts(content);
19
- commandPattern.lastIndex = 0;
20
- for (const command of content.matchAll(commandPattern)) {
21
- const start = (command.index ?? 0) + command[0].length;
22
- const argument = balancedArgument(content, start);
23
- if (!argument)
24
- continue;
25
- if (command[1]) {
26
- result.push({ path: filePath, line: lineAtOffset(lineStarts, command.index ?? 0), level: levels[command[1]], title: cleanTitle(argument.value) });
29
+ const content = fs.readFileSync(absolute, "utf8");
30
+ for (const event of scanOutlineSource(content)) {
31
+ if (event.type === "heading") {
32
+ result.push({ path: filePath, line: event.line, level: event.level, title: event.title });
27
33
  continue;
28
34
  }
29
- const included = resolveIncludedFile(config, projectId, filePath, argument.value);
35
+ const included = resolveLatexInclude(event.directive, importBase, availableFiles);
30
36
  if (included)
31
- visit(included);
37
+ visit(included.path, included.importBase);
32
38
  }
33
39
  };
34
- visit(mainFile);
40
+ visit(mainFile, "");
35
41
  return result;
36
42
  }
37
43
  /** Async outline builder used by the HTTP path so large projects do not block the event loop. */
38
44
  export async function buildProjectOutlineAsync(config, projectId, mainFileInput) {
39
45
  const mainFile = safeRelativePath(mainFileInput);
46
+ const availableFiles = sourceFilePaths(await listProjectFilesAsync(config, projectId));
47
+ return buildProjectOutlineAsyncFromFiles(config, projectId, mainFile, availableFiles);
48
+ }
49
+ async function buildProjectOutlineAsyncFromFiles(config, projectId, mainFile, availableFiles) {
40
50
  const result = [];
41
51
  const visited = new Set();
42
- const visit = async (filePath) => {
52
+ const visit = async (filePath, importBase) => {
43
53
  if (visited.has(filePath) || visited.size >= 200)
44
54
  return;
45
55
  visited.add(filePath);
@@ -54,28 +64,22 @@ export async function buildProjectOutlineAsync(config, projectId, mainFileInput)
54
64
  return;
55
65
  let content;
56
66
  try {
57
- content = stripCommentsPreserveLines(await fs.promises.readFile(resolveSourcePath(config, projectId, filePath), "utf8"));
67
+ content = await fs.promises.readFile(resolveSourcePath(config, projectId, filePath), "utf8");
58
68
  }
59
69
  catch {
60
70
  return;
61
71
  }
62
- const lineStarts = sourceLineStarts(content);
63
- const pattern = new RegExp(commandPattern.source, commandPattern.flags);
64
- for (const command of content.matchAll(pattern)) {
65
- const start = (command.index ?? 0) + command[0].length;
66
- const argument = balancedArgument(content, start);
67
- if (!argument)
68
- continue;
69
- if (command[1]) {
70
- result.push({ path: filePath, line: lineAtOffset(lineStarts, command.index ?? 0), level: levels[command[1]], title: cleanTitle(argument.value) });
72
+ for (const event of scanOutlineSource(content)) {
73
+ if (event.type === "heading") {
74
+ result.push({ path: filePath, line: event.line, level: event.level, title: event.title });
71
75
  continue;
72
76
  }
73
- const included = await resolveIncludedFileAsync(config, projectId, filePath, argument.value);
77
+ const included = resolveLatexInclude(event.directive, importBase, availableFiles);
74
78
  if (included)
75
- await visit(included);
79
+ await visit(included.path, included.importBase);
76
80
  }
77
81
  };
78
- await visit(mainFile);
82
+ await visit(mainFile, "");
79
83
  return result;
80
84
  }
81
85
  /**
@@ -119,7 +123,7 @@ export class ProjectOutlineService {
119
123
  cached.touched = Date.now();
120
124
  return cached.outline;
121
125
  }
122
- const outline = await buildProjectOutlineAsync(this.config, projectId, mainFile);
126
+ const outline = await buildProjectOutlineAsyncFromFiles(this.config, projectId, mainFile, sourceFilePaths(entries));
123
127
  this.cache.set(key, { signature, outline, touched: Date.now() });
124
128
  if (this.cache.size > 64) {
125
129
  const oldest = [...this.cache.entries()].sort((left, right) => left[1].touched - right[1].touched)[0];
@@ -129,65 +133,51 @@ export class ProjectOutlineService {
129
133
  return outline;
130
134
  }
131
135
  }
132
- function resolveIncludedFile(config, projectId, currentFile, value) {
133
- const raw = value.trim();
134
- if (!raw || /[\\#]/.test(raw))
135
- return null;
136
- const withExtension = path.posix.extname(raw) ? raw : `${raw}.tex`;
137
- const candidates = [withExtension, path.posix.join(path.posix.dirname(currentFile), withExtension)];
138
- for (const candidate of [...new Set(candidates)]) {
139
- try {
140
- const safe = safeRelativePath(candidate);
141
- if (fs.existsSync(resolveSourcePath(config, projectId, safe)))
142
- return safe;
143
- }
144
- catch { /* Ignore includes outside the project. */ }
145
- }
146
- return null;
136
+ function sourceFilePaths(entries) {
137
+ return new Set(entries.filter((entry) => entry.type === "file").map((entry) => entry.path));
147
138
  }
148
- async function resolveIncludedFileAsync(config, projectId, currentFile, value) {
149
- const raw = value.trim();
150
- if (!raw || /[\\#]/.test(raw))
151
- return null;
152
- const withExtension = path.posix.extname(raw) ? raw : `${raw}.tex`;
153
- const candidates = [withExtension, path.posix.join(path.posix.dirname(currentFile), withExtension)];
154
- for (const candidate of [...new Set(candidates)]) {
155
- try {
156
- const safe = safeRelativePath(candidate);
157
- const stat = await fs.promises.stat(resolveSourcePath(config, projectId, safe));
158
- if (stat.isFile())
159
- return safe;
160
- }
161
- catch { /* Ignore includes outside the project. */ }
162
- }
163
- return null;
164
- }
165
- function balancedArgument(source, start) {
166
- let depth = 1;
167
- for (let index = start; index < source.length; index += 1) {
168
- if (source[index] === "{" && !escapedAt(source, index))
169
- depth += 1;
170
- if (source[index] === "}" && !escapedAt(source, index))
171
- depth -= 1;
172
- if (depth === 0)
173
- return { value: source.slice(start, index), end: index };
139
+ /**
140
+ * Read one source file into ordered outline events. The shared scanner keeps
141
+ * offsets from the original source while skipping comments and literal TeX
142
+ * forms, so the synchronous and asynchronous builders cannot drift in their
143
+ * interpretation of headings and arguments.
144
+ */
145
+ function scanOutlineSource(source) {
146
+ const lineStarts = sourceLineStarts(source);
147
+ const events = [];
148
+ forEachLatexCommand(source, (command) => {
149
+ const level = levels.get(command.name);
150
+ if (level === undefined)
151
+ return;
152
+ const argument = readLatexMandatoryArguments(source, command.to, 1)[0];
153
+ if (!argument)
154
+ return;
155
+ const value = source.slice(argument.contentFrom, argument.contentTo);
156
+ events.push({
157
+ type: "heading",
158
+ from: command.from,
159
+ line: lineAtOffset(lineStarts, command.from),
160
+ level,
161
+ title: cleanTitle(maskLatexComments(value))
162
+ });
163
+ });
164
+ for (const reference of findLatexSourceIncludes(source)) {
165
+ const directive = toLatexIncludeDirective(reference);
166
+ if (!isStaticOutlineInclude(directive))
167
+ continue;
168
+ events.push({
169
+ type: "include",
170
+ from: reference.from,
171
+ directive
172
+ });
174
173
  }
175
- return null;
176
- }
177
- function stripCommentsPreserveLines(source) {
178
- return source.split(/(?<=\n)/).map((line) => {
179
- for (let index = 0; index < line.length; index += 1) {
180
- if (line[index] === "%" && !escapedAt(line, index))
181
- return `${line.slice(0, index)}${line.endsWith("\n") ? "\n" : ""}`;
182
- }
183
- return line;
184
- }).join("");
185
- }
186
- function escapedAt(source, index) {
187
- let slashes = 0;
188
- for (let cursor = index - 1; cursor >= 0 && source[cursor] === "\\"; cursor -= 1)
189
- slashes += 1;
190
- return slashes % 2 === 1;
174
+ return events.sort((left, right) => {
175
+ if (left.from !== right.from)
176
+ return left.from - right.from;
177
+ if (left.type === right.type)
178
+ return 0;
179
+ return left.type === "heading" ? -1 : 1;
180
+ });
191
181
  }
192
182
  function sourceLineStarts(source) {
193
183
  const starts = [0];
@@ -209,5 +199,27 @@ function lineAtOffset(starts, offset) {
209
199
  return Math.max(1, low);
210
200
  }
211
201
  function cleanTitle(value) {
212
- return value.replace(/\\(?:texorpdfstring|MakeUppercase)\s*\{([^{}]*)\}(?:\{[^{}]*\})?/g, "$1").replace(/\\[a-zA-Z@]+\*?/g, "").replace(/[{}]/g, "").trim() || value.trim();
202
+ const cleaned = value
203
+ .replace(/\\(?:texorpdfstring|MakeUppercase)\s*\{([^{}]*)\}(?:\{[^{}]*\})?/g, "$1")
204
+ .replace(/\\[a-zA-Z@]+\*?/g, "")
205
+ .replace(/[{}]/g, "");
206
+ return compactOutlineWhitespace(cleaned) || compactOutlineWhitespace(value);
207
+ }
208
+ /**
209
+ * `maskLatexComments` intentionally keeps source offsets intact, so a long
210
+ * comment becomes a long run of spaces. Outline titles are display-only: keep
211
+ * their original line breaks but collapse that offset-preserving padding.
212
+ */
213
+ function compactOutlineWhitespace(value) {
214
+ return value.replace(/[^\S\r\n]{2,}/g, " ").trim();
215
+ }
216
+ /**
217
+ * TeX paths containing a command or parameter marker need macro expansion.
218
+ * Do not guess their target while building an outline. The generic document
219
+ * graph deliberately supports backslash-separated paths for other consumers,
220
+ * while the legacy outline already skipped these dynamic forms.
221
+ */
222
+ function isStaticOutlineInclude(directive) {
223
+ return !/[\\#]/.test(directive.path)
224
+ && (directive.directory === undefined || !/[\\#]/.test(directive.directory));
213
225
  }
@@ -0,0 +1,109 @@
1
+ import fs from "node:fs";
2
+ import { listProjectFilesAsync, resolveSourcePath } from "./files.js";
3
+ import { findLatexReferenceDefinitions, lineAndColumnAt } from "../shared/latexReferences.js";
4
+ import { documentSourceOrder, latexDocumentDirectives, resolveProjectReferencePath, uniqueProjectPaths } from "./latexDocumentGraph.js";
5
+ const definitionExtensions = /\.(?:tex|sty|cls)$/i;
6
+ const texExtension = /\.tex$/i;
7
+ const bibExtension = /\.bib$/i;
8
+ const MAX_REFERENCE_KEY_LENGTH = 512;
9
+ export function validReferenceKey(value) {
10
+ return typeof value === "string"
11
+ && value.trim().length > 0
12
+ && value.trim().length <= MAX_REFERENCE_KEY_LENGTH
13
+ && !/[\u0000-\u001f\u007f]/.test(value);
14
+ }
15
+ /**
16
+ * Resolve a source-level citation or label target from the project tree.
17
+ *
18
+ * A document's own input graph and declared bibliography files are checked
19
+ * before unrelated files in the project. This keeps a stale \bibitem in an
20
+ * archive or template from shadowing the bibliography used by the selected
21
+ * main document.
22
+ */
23
+ export async function resolveProjectReference(config, projectId, kind, keyInput, options = {}) {
24
+ // Keep the former preferred-path argument working for internal callers
25
+ // during the transition to the richer main-document-aware lookup options.
26
+ const lookup = typeof options === "string" ? { preferredPath: options } : options;
27
+ const key = keyInput.trim();
28
+ const files = (await listProjectFilesAsync(config, projectId))
29
+ .filter((entry) => entry.type === "file" && (definitionExtensions.test(entry.path) || bibExtension.test(entry.path)));
30
+ const sourceEntries = files.filter((entry) => definitionExtensions.test(entry.path));
31
+ const bibEntries = files.filter((entry) => bibExtension.test(entry.path));
32
+ const sourceContents = await readProjectSources(config, projectId, sourceEntries.map((entry) => entry.path));
33
+ const sourcePaths = [...sourceContents.keys()].sort((left, right) => left.localeCompare(right));
34
+ const bibPaths = bibEntries.map((entry) => entry.path).sort((left, right) => left.localeCompare(right));
35
+ const sourceDirectives = new Map();
36
+ for (const [entryPath, source] of sourceContents)
37
+ sourceDirectives.set(entryPath, latexDocumentDirectives(source));
38
+ const preferredPath = sourceContents.has(lookup.preferredPath ?? "") ? lookup.preferredPath ?? "" : "";
39
+ const mainFile = sourceContents.has(lookup.mainFile ?? "") && texExtension.test(lookup.mainFile ?? "")
40
+ ? lookup.mainFile ?? ""
41
+ : "";
42
+ const documentSources = documentSourceOrder(sourceDirectives, mainFile);
43
+ const prioritizedSources = uniqueProjectPaths([
44
+ ...(preferredPath ? [preferredPath] : []),
45
+ ...documentSources
46
+ ]);
47
+ const orderedPaths = kind === "label"
48
+ ? uniqueProjectPaths([...prioritizedSources, ...sourcePaths])
49
+ : citationLookupOrder(sourceDirectives, prioritizedSources, bibPaths, sourcePaths);
50
+ for (const entryPath of orderedPaths) {
51
+ const source = sourceContents.get(entryPath)
52
+ ?? await readProjectFile(config, projectId, entryPath);
53
+ if (source === null)
54
+ continue;
55
+ const definition = findLatexReferenceDefinitions(source, kind, bibExtension.test(entryPath))
56
+ .find((candidate) => candidate.key === key);
57
+ if (!definition)
58
+ continue;
59
+ const location = lineAndColumnAt(source, definition.targetFrom);
60
+ return { path: entryPath, ...location, kind, source: definition.source };
61
+ }
62
+ return null;
63
+ }
64
+ async function readProjectSources(config, projectId, paths) {
65
+ const contents = new Map();
66
+ await Promise.all(paths.map(async (entryPath) => {
67
+ const source = await readProjectFile(config, projectId, entryPath);
68
+ if (source !== null)
69
+ contents.set(entryPath, source);
70
+ }));
71
+ return contents;
72
+ }
73
+ async function readProjectFile(config, projectId, entryPath) {
74
+ try {
75
+ return await fs.promises.readFile(resolveSourcePath(config, projectId, entryPath), "utf8");
76
+ }
77
+ catch (error) {
78
+ // A concurrent file deletion is harmless for navigation. The project
79
+ // mutation barrier prevents partial writes; simply continue to the next
80
+ // possible definition when a file has disappeared.
81
+ if (error.code === "ENOENT")
82
+ return null;
83
+ throw error;
84
+ }
85
+ }
86
+ function citationLookupOrder(sourceDirectives, documentSources, bibPaths, sourcePaths) {
87
+ // Callers without a selected root retain the conservative legacy behavior:
88
+ // prefer in-source thebibliography entries before standalone .bib files.
89
+ if (documentSources.length === 0)
90
+ return uniqueProjectPaths([...sourcePaths, ...bibPaths]);
91
+ const bibPathSet = new Set(bibPaths);
92
+ const declaredBibliographies = [];
93
+ for (const sourcePath of documentSources) {
94
+ const directives = sourceDirectives.get(sourcePath);
95
+ if (!directives)
96
+ continue;
97
+ for (const bibliography of directives.bibliographies) {
98
+ const resolved = resolveProjectReferencePath(bibliography, sourcePath, ".bib", bibPathSet, "project-root-then-source-file");
99
+ if (resolved)
100
+ declaredBibliographies.push(resolved);
101
+ }
102
+ }
103
+ return uniqueProjectPaths([
104
+ ...documentSources,
105
+ ...declaredBibliographies,
106
+ ...bibPaths,
107
+ ...sourcePaths
108
+ ]);
109
+ }
@@ -1,6 +1,7 @@
1
1
  import { publicUser, requireUser } from "../auth.js";
2
2
  import { createSessionToken, digestToken, hashPassword, verifyPassword } from "../security.js";
3
3
  import { apiError, ValidationError } from "../http.js";
4
+ import { basePathHref } from "../../shared/basePath.js";
4
5
  const now = () => new Date().toISOString();
5
6
  function text(value, max = 200) {
6
7
  if (typeof value !== "string" || !value.trim() || value.length > max) {
@@ -35,8 +36,9 @@ export function registerAuthRoutes(app, context) {
35
36
  db.prepare("INSERT INTO sessions (id, user_id, expires_at, created_at) VALUES (?, ?, ?, ?)")
36
37
  .run(session.digest, user.id, expires.toISOString(), createdAt);
37
38
  const isSecure = request.protocol === "https" || request.headers["x-forwarded-proto"] === "https";
39
+ const cookiePath = basePathHref(config.basePath);
38
40
  reply.setCookie("texlite_session", session.token, {
39
- path: "/",
41
+ path: cookiePath,
40
42
  httpOnly: true,
41
43
  sameSite: "strict",
42
44
  secure: isSecure,
@@ -48,7 +50,8 @@ export function registerAuthRoutes(app, context) {
48
50
  const token = request.cookies.texlite_session;
49
51
  if (token)
50
52
  db.prepare("DELETE FROM sessions WHERE id = ?").run(digestToken(token));
51
- reply.clearCookie("texlite_session", { path: "/" });
53
+ const cookiePath = basePathHref(config.basePath);
54
+ reply.clearCookie("texlite_session", { path: cookiePath });
52
55
  return { ok: true };
53
56
  });
54
57
  app.get("/api/me", async (request, reply) => {
@@ -6,7 +6,7 @@ import { resolveSourcePath, safeRelativePath } from "../files.js";
6
6
  import { apiError, httpError } from "../http.js";
7
7
  import { accessibleProject } from "../projects.js";
8
8
  import { commentMentionForUser, createCommentMentions, listCommentMentions, markAllCommentMentionsRead, markCommentMentionRead, markCommentMentionsResolved, mentionableUsersForProject } from "../commentMentions.js";
9
- import { commentsForFile, now, repliesForComment, text } from "./projectShared.js";
9
+ import { commentsForFile, commentsForProject, now, repliesForComment, text } from "./projectShared.js";
10
10
  /** Register source-anchored comments and reply routes. */
11
11
  export function registerCommentRoutes(app, context) {
12
12
  const { config, db, collaboration, projectMutations } = context;
@@ -75,12 +75,18 @@ export function registerCommentRoutes(app, context) {
75
75
  if (!user)
76
76
  return;
77
77
  const { id } = request.params;
78
- const { path: filePath } = request.query;
78
+ const { path: filePath, scope } = request.query;
79
79
  if (!accessibleProject(db, id, user))
80
80
  return apiError(reply, 404, "PROJECT_NOT_FOUND");
81
- const relative = safeRelativePath(filePath ?? "");
81
+ if (scope !== undefined && scope !== "project")
82
+ return apiError(reply, 400, "REQUEST_INVALID");
83
+ if (scope === "project" && filePath !== undefined)
84
+ return apiError(reply, 400, "REQUEST_INVALID");
85
+ const load = scope === "project"
86
+ ? () => commentsForProject(db, config, id)
87
+ : () => commentsForFile(db, config, id, safeRelativePath(filePath ?? ""));
82
88
  return {
83
- comments: await projectMutations.runConsistentRead(id, () => commentsForFile(db, config, id, relative), {
89
+ comments: await projectMutations.runConsistentRead(id, load, {
84
90
  preflight: () => {
85
91
  if (!accessibleProject(db, id, user))
86
92
  throw httpError(404, "PROJECT_NOT_FOUND");
@@ -85,7 +85,7 @@ export function registerCompileRoutes(app, context) {
85
85
  } : null
86
86
  } : null,
87
87
  hasPdf: Boolean(pdf),
88
- pdfUrl: pdf ? compilePdfUrl(id, mainFile, pdfVersion ?? pdf.version) : null,
88
+ pdfUrl: pdf ? compilePdfUrl(config, id, mainFile, pdfVersion ?? pdf.version) : null,
89
89
  pdfCompiledAt: publishedRun?.finished_at ?? latestSuccess?.finished_at ?? null,
90
90
  ...pdfMetadata
91
91
  };
@@ -674,7 +674,7 @@ export function registerCompileRoutes(app, context) {
674
674
  ].filter(Boolean).join(", "));
675
675
  return {
676
676
  mainFile, runId: result.runId, ok: result.ok, cancelled: result.cancelled === true, skipped: result.skipped === true, log: result.log, diagnostics: result.diagnostics,
677
- pdfUrl: result.ok ? compilePdfUrl(id, mainFile, result.runId) : null,
677
+ pdfUrl: result.ok ? compilePdfUrl(config, id, mainFile, result.runId) : null,
678
678
  pdfCompiledAt: result.ok ? completed?.finished_at ?? null : null,
679
679
  ...pdfMetadata,
680
680
  timings
@@ -149,14 +149,23 @@ export function registerProjectFileRoutes(app, context) {
149
149
  if (!user)
150
150
  return;
151
151
  const { id } = request.params;
152
- if (!accessibleProject(db, id, user))
152
+ const project = accessibleProject(db, id, user);
153
+ if (!project)
153
154
  return apiError(reply, 404, "PROJECT_NOT_FOUND");
155
+ const query = request.query;
156
+ const mainFile = compileMainFile(config, id, project.main_file, query.mainFile);
157
+ if (!mainFile)
158
+ return apiError(reply, 400, "MAIN_DOCUMENT_INVALID");
154
159
  const startedAt = performance.now();
155
160
  try {
156
- return await projectMutations.runConsistentRead(id, async () => ({ index: await latexCompletions.build(id) }), {
161
+ return await projectMutations.runConsistentRead(id, async () => ({ index: await latexCompletions.build(id, mainFile) }), {
157
162
  preflight: () => {
158
- if (!accessibleProject(db, id, user))
163
+ const current = accessibleProject(db, id, user);
164
+ if (!current)
159
165
  throw httpError(404, "PROJECT_NOT_FOUND");
166
+ const selected = compileMainFile(config, id, current.main_file, query.mainFile);
167
+ if (!selected || selected !== mainFile)
168
+ throw httpError(400, "MAIN_DOCUMENT_INVALID");
160
169
  }
161
170
  });
162
171
  }
@@ -0,0 +1,36 @@
1
+ import { requireUser } from "../auth.js";
2
+ import { safeRelativePath } from "../files.js";
3
+ import { apiError, httpError } from "../http.js";
4
+ import { resolveProjectReference, validReferenceKey } from "../projectReferences.js";
5
+ import { accessibleProject } from "../projects.js";
6
+ /** Register Ctrl/Cmd-click source-reference navigation routes. */
7
+ export function registerProjectReferenceRoutes(app, context) {
8
+ const { config, db, projectMutations } = context;
9
+ app.get("/api/projects/:id/references/resolve", async (request, reply) => {
10
+ const user = requireUser(request, reply, db);
11
+ if (!user)
12
+ return;
13
+ const { id } = request.params;
14
+ const project = accessibleProject(db, id, user);
15
+ if (!project)
16
+ return apiError(reply, 404, "PROJECT_NOT_FOUND");
17
+ const query = request.query;
18
+ const kind = query.kind === "citation" || query.kind === "label" ? query.kind : null;
19
+ if (!kind || !validReferenceKey(query.key))
20
+ return apiError(reply, 400, "REQUEST_INVALID");
21
+ const key = query.key;
22
+ const preferredPath = typeof query.path === "string" && query.path ? safeRelativePath(query.path) : "";
23
+ const requestedMainFile = typeof query.mainFile === "string" && query.mainFile
24
+ ? safeRelativePath(query.mainFile)
25
+ : "";
26
+ const mainFile = /\.tex$/i.test(requestedMainFile) ? requestedMainFile : project.main_file;
27
+ return await projectMutations.runConsistentRead(id, async () => ({
28
+ target: await resolveProjectReference(config, id, kind, key, { preferredPath, mainFile })
29
+ }), {
30
+ preflight: () => {
31
+ if (!accessibleProject(db, id, user))
32
+ throw httpError(404, "PROJECT_NOT_FOUND");
33
+ }
34
+ });
35
+ });
36
+ }
@@ -1,6 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import { reanchorFileComments, offsetToLine } from "../anchors.js";
3
- import { maxCollaborativeFileBytes } from "../collaboration.js";
3
+ import { isCollaborativeTextFile, maxCollaborativeFileBytes } from "../collaboration.js";
4
4
  import { listProjectFiles, resolveSourcePath } from "../files.js";
5
5
  import { httpError, ValidationError } from "../http.js";
6
6
  import { accessibleProject, canEdit } from "../projects.js";
@@ -154,8 +154,7 @@ export function requireActualProjectOwner(db, projectId, user) {
154
154
  return project;
155
155
  }
156
156
  export function projectTextSnapshot(config, projectId) {
157
- const versionedText = (filePath) => /(?:\.tex|\.bib|\.sty|\.cls|\.txt|\.md|latexmkrc)$/i.test(filePath);
158
- return new Map(listProjectFiles(config, projectId).filter((entry) => entry.type === "file" && versionedText(entry.path)).map((entry) => {
157
+ return new Map(listProjectFiles(config, projectId).filter((entry) => entry.type === "file" && isCollaborativeTextFile(entry.path)).map((entry) => {
159
158
  const absolute = resolveSourcePath(config, projectId, entry.path);
160
159
  return [entry.path, fs.statSync(absolute).size <= maxCollaborativeFileBytes(config) ? fs.readFileSync(absolute, "utf8") : ""];
161
160
  }));
@@ -189,21 +188,34 @@ export function repliesForComment(db, commentId) {
189
188
  editedAt: reply.edited_at
190
189
  }));
191
190
  }
192
- export function commentsForFile(db, config, projectId, filePath) {
193
- const absolute = resolveSourcePath(config, projectId, filePath);
194
- const source = fs.existsSync(absolute) ? fs.readFileSync(absolute, "utf8") : "";
191
+ function commentsForScope(db, config, projectId, filePath) {
192
+ const hasFileScope = typeof filePath === "string";
193
+ const fileClause = hasFileScope ? " AND c.file_path = ?" : "";
194
+ // File-scoped reads retain chronological order for backwards compatibility.
195
+ // A project review is grouped by file and source position so next/previous
196
+ // follows the manuscript rather than arbitrary database insertion order.
197
+ const commentOrder = hasFileScope
198
+ ? "c.created_at"
199
+ : "c.file_path COLLATE NOCASE, c.start_line, c.created_at";
195
200
  const rows = db.prepare(`SELECT c.*, u.username AS author_username, u.display_name AS author_display_name FROM comments c
196
- LEFT JOIN users u ON u.id = c.author_id WHERE c.project_id = ? AND c.file_path = ? ORDER BY c.created_at`)
197
- .all(projectId, filePath);
201
+ LEFT JOIN users u ON u.id = c.author_id WHERE c.project_id = ?${fileClause} ORDER BY ${commentOrder}`)
202
+ .all(projectId, ...(hasFileScope ? [filePath] : []));
198
203
  if (!rows.length)
199
204
  return [];
205
+ const sourceByPath = new Map();
206
+ for (const row of rows) {
207
+ if (sourceByPath.has(row.file_path))
208
+ continue;
209
+ const absolute = resolveSourcePath(config, projectId, row.file_path);
210
+ sourceByPath.set(row.file_path, fs.existsSync(absolute) ? fs.readFileSync(absolute, "utf8") : "");
211
+ }
200
212
  const replies = db.prepare(`SELECT reply.*, user.username AS author_username, user.display_name AS author_display_name
201
213
  FROM comment_replies reply
202
214
  JOIN comments c ON c.id = reply.comment_id
203
215
  LEFT JOIN users user ON user.id = reply.author_id
204
- WHERE c.project_id = ? AND c.file_path = ?
216
+ WHERE c.project_id = ?${fileClause}
205
217
  ORDER BY reply.created_at`)
206
- .all(projectId, filePath);
218
+ .all(projectId, ...(hasFileScope ? [filePath] : []));
207
219
  const replyMap = new Map();
208
220
  for (const reply of replies) {
209
221
  const list = replyMap.get(reply.comment_id) ?? [];
@@ -221,14 +233,15 @@ export function commentsForFile(db, config, projectId, filePath) {
221
233
  }
222
234
  return rows.map((comment) => ({
223
235
  id: comment.id,
236
+ filePath: comment.file_path,
224
237
  authorId: comment.author_id,
225
238
  authorUsername: comment.author_username,
226
239
  authorDisplayName: comment.author_display_name,
227
240
  selectedText: comment.selected_text,
228
241
  startOffset: comment.start_offset,
229
242
  endOffset: comment.end_offset,
230
- startLine: offsetToLine(source, comment.start_offset),
231
- endLine: offsetToLine(source, comment.end_offset),
243
+ startLine: offsetToLine(sourceByPath.get(comment.file_path) ?? "", comment.start_offset),
244
+ endLine: offsetToLine(sourceByPath.get(comment.file_path) ?? "", comment.end_offset),
232
245
  content: comment.content,
233
246
  resolved: Boolean(comment.resolved),
234
247
  orphaned: Boolean(comment.orphaned),
@@ -238,3 +251,11 @@ export function commentsForFile(db, config, projectId, filePath) {
238
251
  replies: replyMap.get(comment.id) ?? []
239
252
  }));
240
253
  }
254
+ /** Comments anchored in one source file, used for editor decorations. */
255
+ export function commentsForFile(db, config, projectId, filePath) {
256
+ return commentsForScope(db, config, projectId, filePath);
257
+ }
258
+ /** All project comments, grouped in manuscript order for the review drawer. */
259
+ export function commentsForProject(db, config, projectId) {
260
+ return commentsForScope(db, config, projectId);
261
+ }
@@ -12,6 +12,7 @@ import { writeProjectArchive } from "../archive.js";
12
12
  import { extractProjectZip, ZipValidationError } from "../zip.js";
13
13
  import { HarperLintSupersededError, HarperUnavailableError } from "../harper.js";
14
14
  import { digestToken } from "../security.js";
15
+ import { supportsWritingChecks } from "../../shared/writingChecks.js";
15
16
  import { unreadMentionCountsForProjects } from "../commentMentions.js";
16
17
  import { commentsSummaryForProject, commentsSummaryForProjects, dictionaryWord, escapeLikePattern, now, projectJson, requireProjectOwnerPermission, tagColors, tagsForProject, tagsForProjects, text } from "./projectShared.js";
17
18
  const clientIdPattern = /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i;
@@ -390,6 +391,10 @@ export function registerProjectCatalogRoutes(app, context) {
390
391
  catch {
391
392
  return apiError(reply, 400, "SPELLCHECK_SOURCE_INVALID");
392
393
  }
394
+ // Bibliography files are structured reference data rather than prose.
395
+ // Keep this server-side guard for legacy clients and direct API callers.
396
+ if (!supportsWritingChecks(filePath))
397
+ return { lints: [] };
393
398
  // A login session is shared by browser tabs. Pair the authenticated user
394
399
  // with the page-local client ID so each new editor can replace only its
395
400
  // own obsolete waiting work. Legacy clients retain their former session
@@ -8,6 +8,7 @@ export function registerSystemRoutes(app, context) {
8
8
  app.get("/api/config", async () => ({
9
9
  siteName: config.siteName,
10
10
  adminEmail: config.adminEmail,
11
+ basePath: config.basePath,
11
12
  minPasswordLength: MIN_PASSWORD_LENGTH,
12
13
  maxCitationBibtexBytes: MAX_CITATION_BIBTEX_BYTES,
13
14
  maxUploadSizeMB: Math.floor(config.maxUploadBytes / 1024 / 1024),