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.
Files changed (93) 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-3CHb5012.js → AdminUsers-Buf5ZclR.js} +1 -1
  7. package/dist/client/assets/{CitationLibraryDialog-zPpsUhWJ.js → CitationLibraryDialog-DrKbIlD-.js} +1 -1
  8. package/dist/client/assets/Dashboard-DLk2j3oq.js +2 -0
  9. package/dist/client/assets/GitDialog-CTWAU8q6.js +3 -0
  10. package/dist/client/assets/HistoryDialog-Mo4p8HEg.js +7 -0
  11. package/dist/client/assets/LatexEditor-CwvakYva.js +135 -0
  12. package/dist/client/assets/{PdfPreview-BFVNczSk.js → PdfPreview-BUpELZd-.js} +1 -1
  13. package/dist/client/assets/{ProjectIconPickerDialog-Cf0zBf8u.js → ProjectIconPickerDialog-BiWSwDdt.js} +1 -1
  14. package/dist/client/assets/{ProjectNavigationDialogs-TPbe11iR.js → ProjectNavigationDialogs-CpjkXZTz.js} +1 -1
  15. package/dist/client/assets/{ProjectSettings-CDQvIlQ4.js → ProjectSettings-DoxitHbX.js} +1 -1
  16. package/dist/client/assets/ProjectWorkspace-iNqXZlsj.js +29 -0
  17. package/dist/client/assets/SelectionHistoryDialog-9FWlc3Rb.js +3 -0
  18. package/dist/client/assets/{SystemMetricsDialog-CuqHPUnn.js → SystemMetricsDialog-24kifZO6.js} +1 -1
  19. package/dist/client/assets/TagManagementDialog-BHIA0vEA.js +1 -0
  20. package/dist/client/assets/{WordCountDialog-B4PI0vEk.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-6uYFyezl.js → hard-drive-B-NSDVVA.js} +1 -1
  25. package/dist/client/assets/{index-BRT5O-Hg.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-DOAxDSfz.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-DDCq8c49.js +1 -0
  32. package/dist/client/assets/{minus-9eVJnugf.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-BGXipRoG.js → plus-CezUzSIZ.js} +1 -1
  35. package/dist/client/assets/{refresh-cw-Dvp3O90f.js → refresh-cw-CDFNL-qu.js} +1 -1
  36. package/dist/client/assets/{search-b1ORp1k8.js → search-CrDJjHoj.js} +1 -1
  37. package/dist/client/assets/{sigma-Bk0_MzWi.js → sigma-DOcJr7VG.js} +1 -1
  38. package/dist/client/assets/spellCheck-BgWyesvf.js +1 -0
  39. package/dist/client/assets/{tags-JAkbmWns.js → tags-BedQ75lh.js} +1 -1
  40. package/dist/client/assets/{type-CE2DYkPH.js → type-glZNdzZR.js} +1 -1
  41. package/dist/client/assets/{user-plus-CKalKSsw.js → user-plus-gQ6q5Nom.js} +1 -1
  42. package/dist/client/assets/{x-Bq29njHL.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/harper.js +205 -8
  49. package/dist/server/history.js +53 -3
  50. package/dist/server/i18n.js +8 -0
  51. package/dist/server/index.js +4 -2
  52. package/dist/server/instanceLock.js +4 -2
  53. package/dist/server/latexCompletion.js +126 -63
  54. package/dist/server/latexDocumentGraph.js +106 -0
  55. package/dist/server/latexSpellMask.js +196 -43
  56. package/dist/server/pm2.js +3 -2
  57. package/dist/server/projectReferences.js +109 -0
  58. package/dist/server/routes/auth.js +5 -2
  59. package/dist/server/routes/comments.js +13 -1
  60. package/dist/server/routes/compile.js +2 -2
  61. package/dist/server/routes/projectFiles.js +12 -3
  62. package/dist/server/routes/projectHistory.js +13 -1
  63. package/dist/server/routes/projectReferences.js +36 -0
  64. package/dist/server/routes/projects.js +41 -4
  65. package/dist/server/routes/system.js +1 -0
  66. package/dist/server/zip.js +2 -16
  67. package/dist/shared/basePath.js +44 -0
  68. package/dist/shared/bibtexReferences.js +135 -0
  69. package/dist/shared/latexCommandSnippets.js +91 -0
  70. package/dist/shared/latexDependencies.js +71 -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/Dashboard-CWfSLzD3.js +0 -2
  80. package/dist/client/assets/GitDialog-BUU2U_LV.js +0 -3
  81. package/dist/client/assets/HistoryDialog-B6aopsrw.js +0 -7
  82. package/dist/client/assets/LatexEditor-CM445Tk0.js +0 -65
  83. package/dist/client/assets/ProjectWorkspace-C6GrvKN6.js +0 -27
  84. package/dist/client/assets/SelectionHistoryDialog-CyE0_YDs.js +0 -3
  85. package/dist/client/assets/TagManagementDialog-gRLM3lLx.js +0 -1
  86. package/dist/client/assets/check-BXvzrdoz.js +0 -1
  87. package/dist/client/assets/chevron-left-BB5MYb7g.js +0 -1
  88. package/dist/client/assets/editor-CiK7TK0p.js +0 -14
  89. package/dist/client/assets/index-C0t11tlT.js +0 -14
  90. package/dist/client/assets/index-DXWp2bzM.css +0 -1
  91. package/dist/client/assets/katex-CEK31ho9.css +0 -1
  92. package/dist/client/assets/minimize-2-BWtbag2I.js +0 -1
  93. package/dist/client/assets/spellCheck-BJjWgnef.js +0 -1
@@ -10,9 +10,12 @@ import { lucideIconSvg, resolveLucideIconName } from "../lucideIcons.js";
10
10
  import { accessibleProject, canEdit } from "../projects.js";
11
11
  import { writeProjectArchive } from "../archive.js";
12
12
  import { extractProjectZip, ZipValidationError } from "../zip.js";
13
- import { HarperUnavailableError } from "../harper.js";
13
+ import { HarperLintSupersededError, HarperUnavailableError } from "../harper.js";
14
+ import { digestToken } from "../security.js";
15
+ import { supportsWritingChecks } from "../../shared/writingChecks.js";
14
16
  import { unreadMentionCountsForProjects } from "../commentMentions.js";
15
17
  import { commentsSummaryForProject, commentsSummaryForProjects, dictionaryWord, escapeLikePattern, now, projectJson, requireProjectOwnerPermission, tagColors, tagsForProject, tagsForProjects, text } from "./projectShared.js";
18
+ const clientIdPattern = /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i;
16
19
  /** Register project catalog, metadata, archive, dictionary, tag, export, and deletion routes. */
17
20
  export function registerProjectCatalogRoutes(app, context) {
18
21
  const { config, db, collaboration, projectMutations, latexCompletions, projectOutlines, harper, recordHistory } = context;
@@ -362,15 +365,49 @@ export function registerProjectCatalogRoutes(app, context) {
362
365
  if (!accessibleProject(db, id, user))
363
366
  return apiError(reply, 404, "PROJECT_NOT_FOUND");
364
367
  const body = request.body;
365
- if (typeof body?.source !== "string" || typeof body.path !== "string")
368
+ const source = body?.source;
369
+ const sourcePath = body?.path;
370
+ const clientId = body?.clientId;
371
+ const sequence = body?.sequence;
372
+ if (typeof source !== "string" || typeof sourcePath !== "string") {
366
373
  return apiError(reply, 400, "SPELLCHECK_SOURCE_INVALID");
367
- if (Buffer.byteLength(body.source, "utf8") > maxCollaborativeFileBytes(config)) {
374
+ }
375
+ // Accept already-open clients during a rolling update. Current clients
376
+ // pair a page-local UUID with a monotonically increasing sequence so a
377
+ // delayed HTTP request cannot replace a newer queued check.
378
+ if (clientId !== undefined && (typeof clientId !== "string" || !clientIdPattern.test(clientId))) {
379
+ return apiError(reply, 400, "SPELLCHECK_SOURCE_INVALID");
380
+ }
381
+ if (sequence !== undefined && (typeof sequence !== "number" || !Number.isSafeInteger(sequence) || sequence <= 0 || clientId === undefined)) {
382
+ return apiError(reply, 400, "SPELLCHECK_SOURCE_INVALID");
383
+ }
384
+ if (Buffer.byteLength(source, "utf8") > maxCollaborativeFileBytes(config)) {
368
385
  return apiError(reply, 413, "SPELLCHECK_SOURCE_TOO_LARGE");
369
386
  }
387
+ let filePath;
370
388
  try {
371
- return { lints: await harper.lint(body.source, body.path) };
389
+ filePath = safeRelativePath(sourcePath);
390
+ }
391
+ catch {
392
+ return apiError(reply, 400, "SPELLCHECK_SOURCE_INVALID");
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: [] };
398
+ // A login session is shared by browser tabs. Pair the authenticated user
399
+ // with the page-local client ID so each new editor can replace only its
400
+ // own obsolete waiting work. Legacy clients retain their former session
401
+ // grouping until their page is refreshed.
402
+ const laneClientId = clientId ?? `legacy:${digestToken(request.cookies.texlite_session ?? "")}`;
403
+ const lane = `${id}\0${user.id}\0${laneClientId}\0${filePath}`;
404
+ try {
405
+ return { lints: await harper.lint(source, filePath, lane, typeof sequence === "number" ? sequence : undefined) };
372
406
  }
373
407
  catch (error) {
408
+ if (error instanceof HarperLintSupersededError) {
409
+ return apiError(reply, 409, "SPELLCHECK_SUPERSEDED");
410
+ }
374
411
  // A missing optional command is an expected fallback condition. Keep it
375
412
  // out of normal logs while preserving diagnostics for an actual failure.
376
413
  if (error instanceof HarperUnavailableError)
@@ -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),
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import { Transform } from "node:stream";
4
4
  import { pipeline } from "node:stream/promises";
5
5
  import yauzl from "yauzl";
6
+ import { hasLatexDocumentClass } from "../shared/latexRoot.js";
6
7
  import { assertNoSymbolicLinks, safeRelativePath } from "./files.js";
7
8
  const MAX_ENTRIES = 1_000;
8
9
  const MAX_TOTAL_BYTES = 200 * 1024 * 1024;
@@ -180,22 +181,7 @@ function discoverMainFile(root, files) {
180
181
  }) ?? "";
181
182
  }
182
183
  export function hasDocumentClass(source) {
183
- const withoutComments = source.split(/(?<=\n)/).map((line) => {
184
- for (let index = 0; index < line.length; index += 1) {
185
- if (line[index] !== "%")
186
- continue;
187
- let slashes = 0;
188
- for (let cursor = index - 1; cursor >= 0 && line[cursor] === "\\"; cursor -= 1)
189
- slashes += 1;
190
- if (slashes % 2 === 0)
191
- return `${line.slice(0, index)}${line.endsWith("\n") ? "\n" : ""}`;
192
- }
193
- return line;
194
- }).join("");
195
- const withoutVerbatim = withoutComments
196
- .replace(/\\verb\*?([^\s]).*?\1/g, "")
197
- .replace(/\\begin\{(?:verbatim\*?|Verbatim|lstlisting|minted)\}(?:\[[^\]]*\])?[\s\S]*?\\end\{(?:verbatim\*?|Verbatim|lstlisting|minted)\}/g, "");
198
- return /\\documentclass\s*(?:\[[^\]]*\]\s*)?\{/.test(withoutVerbatim);
184
+ return hasLatexDocumentClass(source);
199
185
  }
200
186
  function formatMB(bytes) {
201
187
  return Math.floor(bytes / 1024 / 1024);
@@ -0,0 +1,44 @@
1
+ export const ROOT_BASE_PATH = "/";
2
+ /**
3
+ * Normalize a configured URL path prefix. Root is represented as `/`; every
4
+ * other value starts with one slash and has no trailing slash.
5
+ */
6
+ export function normalizeBasePath(value) {
7
+ const trimmed = value.trim();
8
+ if (trimmed === ROOT_BASE_PATH)
9
+ return ROOT_BASE_PATH;
10
+ const normalized = trimmed.replace(/\/+$/, "");
11
+ if (!normalized.startsWith("/") || normalized.includes("//"))
12
+ return null;
13
+ const segments = normalized.slice(1).split("/");
14
+ if (!segments.length || segments.some((segment) => segment === "." || segment === ".." || !/^[A-Za-z0-9._~-]+$/.test(segment)))
15
+ return null;
16
+ return normalized;
17
+ }
18
+ /** Return the prefix used when registering or composing a path. */
19
+ export function basePathPrefix(basePath) {
20
+ return basePath === ROOT_BASE_PATH ? "" : basePath;
21
+ }
22
+ /** Prefix an origin-local absolute path with the configured application path. */
23
+ export function withBasePath(basePath, pathname) {
24
+ if (!pathname.startsWith("/"))
25
+ throw new Error(`Application path must start with /: ${pathname}`);
26
+ const prefix = basePathPrefix(basePath);
27
+ if (!prefix)
28
+ return pathname;
29
+ return pathname === "/" ? `${prefix}/` : `${prefix}${pathname}`;
30
+ }
31
+ /** Remove the configured prefix, or return null when a path belongs elsewhere. */
32
+ export function withoutBasePath(basePath, pathname) {
33
+ const prefix = basePathPrefix(basePath);
34
+ if (!prefix)
35
+ return pathname;
36
+ if (pathname === prefix || pathname === `${prefix}/`)
37
+ return "/";
38
+ if (!pathname.startsWith(`${prefix}/`))
39
+ return null;
40
+ return pathname.slice(prefix.length) || "/";
41
+ }
42
+ export function basePathHref(basePath) {
43
+ return basePath === ROOT_BASE_PATH ? ROOT_BASE_PATH : `${basePath}/`;
44
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Lightweight BibTeX entry-key scanner for server-side completion and source
3
+ * navigation. The browser editor uses the full Lezer grammar; this companion
4
+ * deliberately only finds complete entry boundaries and keys, so server code
5
+ * does not need to load a browser-oriented CodeMirror parser.
6
+ */
7
+ import { isLatexCommentStart, skipLatexComment, skipLatexTrivia } from "./latexScanner.js";
8
+ function readBibtexBlock(source, start) {
9
+ const opening = source[start];
10
+ const closing = opening === "{" ? "}" : ")";
11
+ if (opening !== "{" && opening !== "(")
12
+ return null;
13
+ // Braced and parenthesized BibTeX entries have different nesting rules. A
14
+ // quote is ordinary content inside a braced entry, while a parenthesized
15
+ // entry must protect its closing paren from braced and quoted values.
16
+ let depth = 0;
17
+ let braceDepth = 0;
18
+ let quoted = false;
19
+ for (let index = start; index < source.length; index += 1) {
20
+ const character = source[index];
21
+ if (character === "\\" && index + 1 < source.length) {
22
+ index += 1;
23
+ continue;
24
+ }
25
+ if (opening === "{") {
26
+ if (character === "{") {
27
+ depth += 1;
28
+ continue;
29
+ }
30
+ if (character !== "}")
31
+ continue;
32
+ depth -= 1;
33
+ if (depth === 0) {
34
+ return { from: start, to: index + 1, contentFrom: start + 1, contentTo: index };
35
+ }
36
+ continue;
37
+ }
38
+ if (quoted) {
39
+ if (character === "\"")
40
+ quoted = false;
41
+ continue;
42
+ }
43
+ if (braceDepth > 0) {
44
+ if (character === "{")
45
+ braceDepth += 1;
46
+ else if (character === "}")
47
+ braceDepth -= 1;
48
+ continue;
49
+ }
50
+ if (character === "{") {
51
+ braceDepth = 1;
52
+ continue;
53
+ }
54
+ if (character === "\"") {
55
+ quoted = true;
56
+ continue;
57
+ }
58
+ if (character === "(") {
59
+ depth += 1;
60
+ continue;
61
+ }
62
+ if (character !== closing)
63
+ continue;
64
+ depth -= 1;
65
+ if (depth === 0) {
66
+ return { from: start, to: index + 1, contentFrom: start + 1, contentTo: index };
67
+ }
68
+ }
69
+ return null;
70
+ }
71
+ function bibtexKeyRange(source, entry) {
72
+ let from = entry.contentFrom;
73
+ while (from < entry.contentTo && /\s/.test(source[from]))
74
+ from += 1;
75
+ if (from >= entry.contentTo)
76
+ return null;
77
+ let to = from;
78
+ while (to < entry.contentTo && source[to] !== "," && !/\s/.test(source[to]))
79
+ to += 1;
80
+ let trimmedTo = to;
81
+ while (trimmedTo > from && /\s/.test(source[trimmedTo - 1]))
82
+ trimmedTo -= 1;
83
+ if (trimmedTo <= from)
84
+ return null;
85
+ const separator = skipLatexTrivia(source, to);
86
+ if (separator < entry.contentTo && source[separator] !== ",")
87
+ return null;
88
+ return { key: source.slice(from, trimmedTo), from, to: trimmedTo };
89
+ }
90
+ /**
91
+ * Find ordinary BibTeX/BibLaTeX entry keys. `@string`, `@preamble`, and
92
+ * `@comment` are intentionally skipped because their leading token is not a
93
+ * citeable entry key.
94
+ */
95
+ export function findBibtexEntryKeys(source) {
96
+ const entries = [];
97
+ let index = 0;
98
+ while (index < source.length) {
99
+ // Percent signs are comments at top level. Once inside an entry a percent
100
+ // may be part of a braced URL (for example `%20`), so the block reader
101
+ // must receive the original source unchanged.
102
+ if (isLatexCommentStart(source, index)) {
103
+ index = skipLatexComment(source, index);
104
+ continue;
105
+ }
106
+ if (source[index] !== "@") {
107
+ index += 1;
108
+ continue;
109
+ }
110
+ let typeEnd = index + 1;
111
+ while (typeEnd < source.length && /[A-Za-z]/.test(source[typeEnd]))
112
+ typeEnd += 1;
113
+ if (typeEnd === index + 1) {
114
+ index += 1;
115
+ continue;
116
+ }
117
+ let opening = typeEnd;
118
+ while (opening < source.length && /\s/.test(source[opening]))
119
+ opening += 1;
120
+ const block = readBibtexBlock(source, opening);
121
+ if (!block) {
122
+ index = typeEnd;
123
+ continue;
124
+ }
125
+ const type = source.slice(index + 1, typeEnd).toLowerCase();
126
+ if (type !== "string" && type !== "preamble" && type !== "comment") {
127
+ const key = bibtexKeyRange(source, block);
128
+ if (key)
129
+ entries.push({ ...key, entryFrom: index, entryTo: block.to });
130
+ }
131
+ // Do not scan @-looking text inside an already complete entry.
132
+ index = block.to;
133
+ }
134
+ return entries;
135
+ }
@@ -0,0 +1,91 @@
1
+ function skipWhitespace(source, position) {
2
+ let cursor = position;
3
+ while (/[ \t\r\n]/.test(source[cursor] ?? ""))
4
+ cursor += 1;
5
+ return cursor;
6
+ }
7
+ export function readBalancedLatexArgument(source, start, open = "{", close = "}") {
8
+ const from = skipWhitespace(source, start);
9
+ if (source[from] !== open)
10
+ return null;
11
+ let depth = 1;
12
+ for (let cursor = from + 1; cursor < source.length; cursor += 1) {
13
+ if (source[cursor] === "\\") {
14
+ cursor += 1;
15
+ continue;
16
+ }
17
+ if (source[cursor] === open)
18
+ depth += 1;
19
+ else if (source[cursor] === close && --depth === 0) {
20
+ return { from, to: cursor + 1, content: source.slice(from + 1, cursor) };
21
+ }
22
+ }
23
+ return null;
24
+ }
25
+ export function commandSnippet(name, arguments_) {
26
+ if (arguments_.length === 0)
27
+ return undefined;
28
+ return name + arguments_.map((argument, index) => {
29
+ const placeholder = `\${${index + 1}}`;
30
+ return argument.optional ? `[${placeholder}]` : `{${placeholder}}`;
31
+ }).join("");
32
+ }
33
+ export function newCommandArguments(argumentCount, hasDefault) {
34
+ if (!Number.isFinite(argumentCount) || argumentCount <= 0)
35
+ return [];
36
+ if (!hasDefault)
37
+ return Array.from({ length: argumentCount }, () => ({ optional: false }));
38
+ return [{ optional: true }, ...Array.from({ length: Math.max(0, argumentCount - 1) }, () => ({ optional: false }))];
39
+ }
40
+ /**
41
+ * Interpret only xparse forms that have a safe ordinary LaTeX insertion. For
42
+ * delimiter, verbatim, star, and token arguments, omit a snippet rather than
43
+ * generate invalid syntax.
44
+ */
45
+ export function xparseCommandArguments(specification) {
46
+ const result = [];
47
+ for (let cursor = 0; cursor < specification.length;) {
48
+ const character = specification[cursor];
49
+ if (/[ \t\r\n+\-!]/.test(character)) {
50
+ cursor += 1;
51
+ continue;
52
+ }
53
+ if (character === "m") {
54
+ result.push({ optional: false });
55
+ cursor += 1;
56
+ continue;
57
+ }
58
+ if (character === "o") {
59
+ result.push({ optional: true });
60
+ cursor += 1;
61
+ continue;
62
+ }
63
+ if (character === "O") {
64
+ const defaultValue = readBalancedLatexArgument(specification, cursor + 1);
65
+ if (!defaultValue)
66
+ return null;
67
+ result.push({ optional: true });
68
+ cursor = defaultValue.to;
69
+ continue;
70
+ }
71
+ return null;
72
+ }
73
+ return result;
74
+ }
75
+ const xparseDeclaration = /\\(?:NewDocumentCommand|RenewDocumentCommand|ProvideDocumentCommand|DeclareDocumentCommand|DeclareExpandableDocumentCommand|RenewExpandableDocumentCommand|ProvideExpandableDocumentCommand)\b/g;
76
+ export function xparseCommandDefinitions(source) {
77
+ const definitions = [];
78
+ for (const match of source.matchAll(xparseDeclaration)) {
79
+ const target = readBalancedLatexArgument(source, match.index + match[0].length);
80
+ if (!target)
81
+ continue;
82
+ const name = /^\s*\\([A-Za-z@][A-Za-z@0-9:_]*)\s*$/.exec(target.content)?.[1];
83
+ if (!name)
84
+ continue;
85
+ const specification = readBalancedLatexArgument(source, target.to);
86
+ if (!specification)
87
+ continue;
88
+ definitions.push({ name, specification: specification.content });
89
+ }
90
+ return definitions;
91
+ }
@@ -0,0 +1,71 @@
1
+ /** Lightweight extraction of source and bibliography dependencies from TeX. */
2
+ import { forEachLatexCommand, readLatexMandatoryArguments } from "./latexScanner.js";
3
+ function pathRanges(source, argument, splitOnComma) {
4
+ const paths = [];
5
+ let segmentStart = argument.contentFrom;
6
+ for (let index = argument.contentFrom; index <= argument.contentTo; index += 1) {
7
+ if (index !== argument.contentTo && (!splitOnComma || source[index] !== ","))
8
+ continue;
9
+ let from = segmentStart;
10
+ let to = index;
11
+ while (from < to && /\s/.test(source[from]))
12
+ from += 1;
13
+ while (to > from && /\s/.test(source[to - 1]))
14
+ to -= 1;
15
+ if (to > from)
16
+ paths.push({ path: source.slice(from, to), command: "", from, to });
17
+ segmentStart = index + 1;
18
+ }
19
+ return paths;
20
+ }
21
+ /**
22
+ * Find source files pulled into a LaTeX document. Paths intentionally remain
23
+ * raw: project-level code is responsible for resolving them safely.
24
+ */
25
+ export function findLatexSourceIncludes(source) {
26
+ const includes = [];
27
+ forEachLatexCommand(source, (command) => {
28
+ const name = command.name.toLowerCase();
29
+ if (name === "input" || name === "include" || name === "subfile") {
30
+ const argument = readLatexMandatoryArguments(source, command.to, 1)[0];
31
+ if (!argument)
32
+ return;
33
+ for (const include of pathRanges(source, argument, false))
34
+ includes.push({ ...include, command: command.name });
35
+ return;
36
+ }
37
+ if (name !== "import" && name !== "subimport" && name !== "includefrom" && name !== "inputfrom")
38
+ return;
39
+ const argumentsFound = readLatexMandatoryArguments(source, command.to, 2);
40
+ if (argumentsFound.length !== 2)
41
+ return;
42
+ const directory = source.slice(argumentsFound[0].contentFrom, argumentsFound[0].contentTo).trim();
43
+ const file = source.slice(argumentsFound[1].contentFrom, argumentsFound[1].contentTo).trim();
44
+ if (!directory || !file)
45
+ return;
46
+ const separator = directory.endsWith("/") ? "" : "/";
47
+ includes.push({
48
+ path: directory + separator + file,
49
+ command: command.name,
50
+ from: argumentsFound[1].contentFrom,
51
+ to: argumentsFound[1].contentTo
52
+ });
53
+ });
54
+ return includes;
55
+ }
56
+ /** Find bibliography resources declared by a LaTeX document. */
57
+ export function findLatexBibliographyFiles(source) {
58
+ const files = [];
59
+ forEachLatexCommand(source, (command) => {
60
+ const name = command.name.toLowerCase();
61
+ if (name !== "bibliography" && name !== "addbibresource" && name !== "addglobalbib" && name !== "addsectionbib")
62
+ return;
63
+ const argument = readLatexMandatoryArguments(source, command.to, 1)[0];
64
+ if (!argument)
65
+ return;
66
+ const splitOnComma = name === "bibliography";
67
+ for (const file of pathRanges(source, argument, splitOnComma))
68
+ files.push({ ...file, command: command.name });
69
+ });
70
+ return files;
71
+ }
@@ -0,0 +1,215 @@
1
+ // Literal TeX syntax used as opaque editor regions. `alltt` does permit a
2
+ // subset of TeX commands, but treating its raw body as opaque prevents its
3
+ // delimiters from leaking into ordinary source highlighting and tooling.
4
+ export const literalEnvironmentNames = [
5
+ "verbatim", "verbatim*", "Verbatim", "BVerbatim", "LVerbatim", "SaveVerbatim", "VerbatimOut",
6
+ "bverbatim", "bverbatim*", "lverbatim", "lverbatim*", "saveverbatim", "saveverbatim*", "verbatimout",
7
+ "verbatimwrite", "lstlisting", "lstlisting*", "minted", "minted*", "tcblisting", "tcblisting*", "pygmented", "alltt",
8
+ "filecontents", "filecontents*", "luacode", "luacodestar", "comment"
9
+ ];
10
+ const literalEnvironments = new Set(literalEnvironmentNames);
11
+ export function isLatexLiteralEnvironment(name) {
12
+ return literalEnvironments.has(name);
13
+ }
14
+ function skipOptionalArgument(source, position, end) {
15
+ if (source[position] !== "[")
16
+ return position;
17
+ let depth = 1;
18
+ for (let index = position + 1; index < end; index += 1) {
19
+ if (source[index] === "\\") {
20
+ index += 1;
21
+ continue;
22
+ }
23
+ if (source[index] === "[")
24
+ depth += 1;
25
+ if (source[index] === "]") {
26
+ depth -= 1;
27
+ if (depth === 0)
28
+ return index + 1;
29
+ }
30
+ }
31
+ return null;
32
+ }
33
+ function skipRequiredArgument(source, position, end) {
34
+ if (source[position] !== "{")
35
+ return null;
36
+ let depth = 1;
37
+ for (let index = position + 1; index < end; index += 1) {
38
+ if (source[index] === "\\") {
39
+ index += 1;
40
+ continue;
41
+ }
42
+ if (source[index] === "{")
43
+ depth += 1;
44
+ if (source[index] === "}") {
45
+ depth -= 1;
46
+ if (depth === 0)
47
+ return index + 1;
48
+ }
49
+ }
50
+ return null;
51
+ }
52
+ function inlineLatexLiteralSpan(source, start, end = source.length) {
53
+ const newline = source.indexOf("\n", start);
54
+ if (newline >= 0)
55
+ end = Math.min(end, newline);
56
+ const command = /^\\(verb\*?|Verb\*?|lstinline\*?|mintinline\*?)(?![A-Za-z@])/.exec(source.slice(start, end));
57
+ if (!command)
58
+ return null;
59
+ const name = command[1].replace(/\*$/, "");
60
+ let position = start + command[0].length;
61
+ if (name !== "verb") {
62
+ const optionalEnd = skipOptionalArgument(source, position, end);
63
+ if (optionalEnd === null)
64
+ return null;
65
+ position = optionalEnd;
66
+ }
67
+ if (name === "mintinline") {
68
+ const languageEnd = skipRequiredArgument(source, position, end);
69
+ if (languageEnd === null)
70
+ return null;
71
+ position = languageEnd;
72
+ }
73
+ const delimiter = source[position];
74
+ if (!delimiter || /\s/.test(delimiter))
75
+ return null;
76
+ // For verb, '{' is still a symmetric delimiter, not an argument opener.
77
+ if (delimiter === "{" && name !== "verb") {
78
+ const requiredEnd = skipRequiredArgument(source, position, end);
79
+ return { end: requiredEnd ?? end, closed: requiredEnd !== null };
80
+ }
81
+ const closing = source.indexOf(delimiter, position + 1);
82
+ if (closing < 0 || closing >= end)
83
+ return { end, closed: false };
84
+ return { end: closing + 1, closed: true };
85
+ }
86
+ export function inlineLatexLiteralEnd(source, start, end = source.length) {
87
+ return inlineLatexLiteralSpan(source, start, end)?.end ?? null;
88
+ }
89
+ export function literalEnvironmentEnd(source, start, name) {
90
+ const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
91
+ const pattern = new RegExp(String.raw `\\end\s*\{\s*${escapedName}\s*\}`, "g");
92
+ pattern.lastIndex = start;
93
+ const match = pattern.exec(source);
94
+ return match ? { from: match.index, to: match.index + match[0].length } : null;
95
+ }
96
+ function isEscaped(source, index) {
97
+ let backslashes = 0;
98
+ for (let cursor = index - 1; cursor >= 0 && source[cursor] === "\\"; cursor -= 1)
99
+ backslashes += 1;
100
+ return backslashes % 2 === 1;
101
+ }
102
+ function commandEnd(source, start) {
103
+ let cursor = start + 1;
104
+ if (/[A-Za-z@]/.test(source[cursor] ?? "")) {
105
+ while (/[A-Za-z@]/.test(source[cursor] ?? ""))
106
+ cursor += 1;
107
+ if (source[cursor] === "*")
108
+ cursor += 1;
109
+ return cursor;
110
+ }
111
+ return Math.min(source.length, cursor + 1);
112
+ }
113
+ function literalEnvironmentBegin(source, start) {
114
+ const match = /^\\begin\s*\{\s*([A-Za-z0-9@:_*.\-]+)\s*\}/.exec(source.slice(start));
115
+ if (!match || !isLatexLiteralEnvironment(match[1]))
116
+ return null;
117
+ return { name: match[1], to: start + match[0].length };
118
+ }
119
+ /**
120
+ * Return whether a position is inside a TeX comment or one of the literal
121
+ * code forms shared by editor tooling. Positions after a completed literal are
122
+ * deliberately treated as normal source, including when its delimiter is '%'.
123
+ */
124
+ export function latexOpaqueContextAt(source, position) {
125
+ const target = Math.max(0, Math.min(source.length, position));
126
+ for (let cursor = 0; cursor < target;) {
127
+ if (source[cursor] === "%" && !isEscaped(source, cursor)) {
128
+ const newline = source.indexOf("\n", cursor);
129
+ const end = newline < 0 ? source.length : newline;
130
+ if (target <= end)
131
+ return "comment";
132
+ cursor = end + 1;
133
+ continue;
134
+ }
135
+ if (source[cursor] !== "\\" || isEscaped(source, cursor)) {
136
+ cursor += 1;
137
+ continue;
138
+ }
139
+ const inline = inlineLatexLiteralSpan(source, cursor);
140
+ if (inline !== null) {
141
+ if (target < inline.end || (!inline.closed && target <= inline.end))
142
+ return "literal";
143
+ cursor = inline.end;
144
+ continue;
145
+ }
146
+ const environment = literalEnvironmentBegin(source, cursor);
147
+ if (environment) {
148
+ const close = literalEnvironmentEnd(source, environment.to, environment.name);
149
+ const end = close?.to ?? source.length;
150
+ if (target < end || (!close && target <= end))
151
+ return "literal";
152
+ cursor = end;
153
+ continue;
154
+ }
155
+ cursor = commandEnd(source, cursor);
156
+ }
157
+ return null;
158
+ }
159
+ function maskRange(source, from, to) {
160
+ return source.slice(from, to).replace(/[^\r\n]/g, " ");
161
+ }
162
+ /** Mask ordinary TeX comments while preserving every source offset. */
163
+ export function maskLatexComments(source) {
164
+ const characters = source.split("");
165
+ for (let cursor = 0; cursor < source.length; cursor += 1) {
166
+ if (source[cursor] !== "%" || isEscaped(source, cursor))
167
+ continue;
168
+ const newline = source.indexOf("\n", cursor);
169
+ const end = newline < 0 ? source.length : newline;
170
+ for (let index = cursor; index < end; index += 1)
171
+ characters[index] = " ";
172
+ cursor = end;
173
+ }
174
+ return characters.join("");
175
+ }
176
+ /**
177
+ * Mask literal source while preserving offsets and line structure. Callers can
178
+ * then use lightweight regular expressions without learning project commands
179
+ * declared inside listings or verbatim examples.
180
+ */
181
+ export function maskLatexLiteralContent(source) {
182
+ let result = "";
183
+ let cursor = 0;
184
+ while (cursor < source.length) {
185
+ if (source[cursor] === "%" && !isEscaped(source, cursor)) {
186
+ const newline = source.indexOf("\n", cursor);
187
+ const end = newline < 0 ? source.length : newline;
188
+ result += source.slice(cursor, end);
189
+ cursor = end;
190
+ continue;
191
+ }
192
+ if (source[cursor] !== "\\" || isEscaped(source, cursor)) {
193
+ result += source[cursor];
194
+ cursor += 1;
195
+ continue;
196
+ }
197
+ const inlineEnd = inlineLatexLiteralEnd(source, cursor);
198
+ if (inlineEnd !== null) {
199
+ result += maskRange(source, cursor, inlineEnd);
200
+ cursor = inlineEnd;
201
+ continue;
202
+ }
203
+ const environment = literalEnvironmentBegin(source, cursor);
204
+ if (environment) {
205
+ const close = literalEnvironmentEnd(source, environment.to, environment.name);
206
+ const end = close?.to ?? source.length;
207
+ result += maskRange(source, cursor, end);
208
+ cursor = end;
209
+ continue;
210
+ }
211
+ result += source[cursor];
212
+ cursor += 1;
213
+ }
214
+ return result;
215
+ }