texlite 0.9.1 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/DESIGN.md +7 -0
  2. package/OPERATIONS.md +70 -3
  3. package/README.md +5 -0
  4. package/README.zh-CN.md +5 -0
  5. package/THIRD_PARTY_NOTICES.md +29 -0
  6. package/dist/client/assets/{AdminUsers-Dazar9cj.js → AdminUsers-Buf5ZclR.js} +1 -1
  7. package/dist/client/assets/{CitationLibraryDialog-VSUiSPgG.js → CitationLibraryDialog-DrKbIlD-.js} +1 -1
  8. package/dist/client/assets/Dashboard-DLk2j3oq.js +2 -0
  9. package/dist/client/assets/{GitDialog-C9Loz4Al.js → GitDialog-CTWAU8q6.js} +1 -1
  10. package/dist/client/assets/{HistoryDialog-3UNN_jhU.js → HistoryDialog-Mo4p8HEg.js} +1 -1
  11. package/dist/client/assets/LatexEditor-CwvakYva.js +135 -0
  12. package/dist/client/assets/{PdfPreview-BKhcS-2c.js → PdfPreview-BUpELZd-.js} +1 -1
  13. package/dist/client/assets/{ProjectIconPickerDialog-BoWMjQVm.js → ProjectIconPickerDialog-BiWSwDdt.js} +1 -1
  14. package/dist/client/assets/{ProjectNavigationDialogs-CZKTnxrl.js → ProjectNavigationDialogs-CpjkXZTz.js} +1 -1
  15. package/dist/client/assets/{ProjectSettings-DiOQVJ40.js → ProjectSettings-DoxitHbX.js} +1 -1
  16. package/dist/client/assets/ProjectWorkspace-iNqXZlsj.js +29 -0
  17. package/dist/client/assets/{SelectionHistoryDialog-D3dO83lg.js → SelectionHistoryDialog-9FWlc3Rb.js} +1 -1
  18. package/dist/client/assets/{SystemMetricsDialog-0zCwLtH_.js → SystemMetricsDialog-24kifZO6.js} +1 -1
  19. package/dist/client/assets/TagManagementDialog-BHIA0vEA.js +1 -0
  20. package/dist/client/assets/{WordCountDialog-TwpyAP3m.js → WordCountDialog-DFIWvLrP.js} +1 -1
  21. package/dist/client/assets/check-C3GZh6a1.js +1 -0
  22. package/dist/client/assets/chevron-left-l1oupvGO.js +1 -0
  23. package/dist/client/assets/editor-3rgn4IYh.js +14 -0
  24. package/dist/client/assets/{hard-drive-XHSiS_A7.js → hard-drive-B-NSDVVA.js} +1 -1
  25. package/dist/client/assets/{index-Cd6-F29b.js → index-CNTGCh7t.js} +1 -1
  26. package/dist/client/assets/index-CrlfruX_.css +1 -0
  27. package/dist/client/assets/index-DIM_KiG2.js +12 -0
  28. package/dist/client/assets/katex-D9PXdmFF.css +1 -0
  29. package/dist/client/assets/{key-round-C-CXCWnz.js → key-round-BjL9H8-H.js} +1 -1
  30. package/dist/client/assets/{latexFormatterWorker-DYkYtcFy.js → latexFormatterWorker-BRmsns8a.js} +2 -2
  31. package/dist/client/assets/{minimize-2-BMDPVaF-.js → minimize-2-DDCq8c49.js} +1 -1
  32. package/dist/client/assets/{minus-BIa7-KuM.js → minus-DgKZCT9F.js} +1 -1
  33. package/dist/client/assets/{pdf-BB1XvBIF.js → pdf-DUYcbPMK.js} +1 -1
  34. package/dist/client/assets/{plus-BKDWy4Ui.js → plus-CezUzSIZ.js} +1 -1
  35. package/dist/client/assets/{refresh-cw-Bo7-a7-I.js → refresh-cw-CDFNL-qu.js} +1 -1
  36. package/dist/client/assets/{search-Bz9Ob5mT.js → search-CrDJjHoj.js} +1 -1
  37. package/dist/client/assets/{sigma-DxlHf9gv.js → sigma-DOcJr7VG.js} +1 -1
  38. package/dist/client/assets/spellCheck-BgWyesvf.js +1 -0
  39. package/dist/client/assets/{tags-IMZehjzE.js → tags-BedQ75lh.js} +1 -1
  40. package/dist/client/assets/{type-DTWpFQNQ.js → type-glZNdzZR.js} +1 -1
  41. package/dist/client/assets/{user-plus--aVpd8K6.js → user-plus-gQ6q5Nom.js} +1 -1
  42. package/dist/client/assets/{x-Tbo3aA0J.js → x-CSzFikn2.js} +1 -1
  43. package/dist/client/index.html +6 -4
  44. package/dist/server/app.js +130 -83
  45. package/dist/server/cli.js +4 -2
  46. package/dist/server/compileArtifacts.js +3 -2
  47. package/dist/server/config.js +27 -0
  48. package/dist/server/index.js +4 -2
  49. package/dist/server/instanceLock.js +4 -2
  50. package/dist/server/latexCompletion.js +126 -63
  51. package/dist/server/latexDocumentGraph.js +106 -0
  52. package/dist/server/latexSpellMask.js +3 -4
  53. package/dist/server/pm2.js +3 -2
  54. package/dist/server/projectReferences.js +109 -0
  55. package/dist/server/routes/auth.js +5 -2
  56. package/dist/server/routes/compile.js +2 -2
  57. package/dist/server/routes/projectFiles.js +12 -3
  58. package/dist/server/routes/projectReferences.js +36 -0
  59. package/dist/server/routes/projects.js +5 -0
  60. package/dist/server/routes/system.js +1 -0
  61. package/dist/server/zip.js +2 -16
  62. package/dist/shared/basePath.js +44 -0
  63. package/dist/shared/bibtexReferences.js +135 -0
  64. package/dist/shared/latexCommandSnippets.js +91 -0
  65. package/dist/shared/latexDependencies.js +71 -0
  66. package/dist/shared/latexLiterals.js +215 -0
  67. package/dist/shared/latexReferenceCommands.js +75 -0
  68. package/dist/shared/latexReferences.js +193 -0
  69. package/dist/shared/latexRoot.js +21 -0
  70. package/dist/shared/latexScanner.js +183 -0
  71. package/dist/shared/writingChecks.js +4 -0
  72. package/package.json +11 -4
  73. package/texlite.config.example.json +2 -4
  74. package/dist/client/assets/Dashboard-CoE5ESfb.js +0 -2
  75. package/dist/client/assets/LatexEditor-Bq76v8sO.js +0 -65
  76. package/dist/client/assets/ProjectWorkspace-DqLwkGpT.js +0 -27
  77. package/dist/client/assets/TagManagementDialog-C6KDwtvd.js +0 -1
  78. package/dist/client/assets/check-sZxOOKtS.js +0 -1
  79. package/dist/client/assets/chevron-left-32SrOwa5.js +0 -1
  80. package/dist/client/assets/editor-CiK7TK0p.js +0 -14
  81. package/dist/client/assets/index-BRr3Kuxh.css +0 -1
  82. package/dist/client/assets/index-BjzELSQ-.js +0 -12
  83. package/dist/client/assets/katex-CEK31ho9.css +0 -1
  84. package/dist/client/assets/spellCheck-BfZEnpwY.js +0 -1
@@ -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
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Declarative classification for the small set of LaTeX commands that carry
3
+ * citation keys or cross-reference labels. The generic fallbacks deliberately
4
+ * preserve support for package-defined commands such as `\\smartcite` and
5
+ * `\\myref`, while exact entries cover commands whose arguments differ from
6
+ * their name-based convention.
7
+ */
8
+ const singleCitation = {
9
+ kind: "citation",
10
+ argumentKind: "mandatory",
11
+ maximumArguments: 1
12
+ };
13
+ const multipleCitation = {
14
+ kind: "citation",
15
+ argumentKind: "mandatory",
16
+ maximumArguments: Number.MAX_SAFE_INTEGER
17
+ };
18
+ const singleLabel = {
19
+ kind: "label",
20
+ argumentKind: "mandatory",
21
+ maximumArguments: 1
22
+ };
23
+ const rangeLabel = {
24
+ kind: "label",
25
+ argumentKind: "mandatory",
26
+ maximumArguments: 2
27
+ };
28
+ const optionalLabel = {
29
+ kind: "label",
30
+ argumentKind: "optional",
31
+ maximumArguments: 1
32
+ };
33
+ /**
34
+ * `null` explicitly means a command must never be interpreted as a reference.
35
+ * Several biblatex setup commands contain “cite”, but their braced arguments
36
+ * configure rendering or define commands instead of naming bibliography keys.
37
+ */
38
+ const exactReferenceCommands = {
39
+ href: null,
40
+ hyperref: optionalLabel,
41
+ crefrange: rangeLabel,
42
+ citestyle: null,
43
+ setcitestyle: null,
44
+ newcites: null,
45
+ citetext: null,
46
+ declarecitecommand: null,
47
+ declaremulticitecommand: null,
48
+ declareautocitecommand: null,
49
+ declarecitewrappercommand: null,
50
+ declarecitepunctuation: null,
51
+ declarecitedelimiter: null,
52
+ declarecitedriver: null,
53
+ declarecitealias: null,
54
+ ateverycite: null,
55
+ ateverycitekey: null,
56
+ atnextcite: null,
57
+ atnextcitekey: null
58
+ };
59
+ /**
60
+ * Return the key-bearing argument shape for a command, or null when it is not
61
+ * a reference command. Command names are case-insensitive for classification;
62
+ * the original spelling remains available to callers for display and links.
63
+ */
64
+ export function classifyLatexReferenceCommand(name) {
65
+ const normalized = name.toLowerCase();
66
+ if (Object.prototype.hasOwnProperty.call(exactReferenceCommands, normalized)) {
67
+ return exactReferenceCommands[normalized] ?? null;
68
+ }
69
+ if (normalized === "nocite" || normalized.includes("cite")) {
70
+ return normalized.endsWith("cites") ? multipleCitation : singleCitation;
71
+ }
72
+ if (normalized === "ref" || normalized.endsWith("ref"))
73
+ return singleLabel;
74
+ return null;
75
+ }
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Source-level LaTeX reference scanning. Lexical mechanics, dependency
3
+ * extraction, command classification, and BibTeX key scanning live in their
4
+ * own focused modules; this file only assembles them into editor-facing
5
+ * references and definition targets.
6
+ */
7
+ import { findBibtexEntryKeys } from "./bibtexReferences.js";
8
+ import { maskLatexComments } from "./latexLiterals.js";
9
+ import { forEachLatexCommand, isLatexCommentStart, readLatexBalancedArgument, readLatexMandatoryArguments, readLatexOptionalArguments, skipLatexComment, skipLatexTrivia } from "./latexScanner.js";
10
+ import { classifyLatexReferenceCommand } from "./latexReferenceCommands.js";
11
+ export { findLatexBibliographyFiles, findLatexSourceIncludes } from "./latexDependencies.js";
12
+ export { maskLatexComments } from "./latexLiterals.js";
13
+ function keyRanges(source, argument) {
14
+ const ranges = [];
15
+ // Preserve source offsets while treating comments as whitespace. In
16
+ // particular, a comment after a comma must not become part of the next key.
17
+ const masked = maskLatexComments(source.slice(argument.contentFrom, argument.contentTo));
18
+ let segmentStart = 0;
19
+ for (let index = 0; index <= masked.length; index += 1) {
20
+ if (index !== masked.length && masked[index] !== ",")
21
+ continue;
22
+ let from = segmentStart;
23
+ let to = index;
24
+ while (from < to && /\s/.test(masked[from]))
25
+ from += 1;
26
+ while (to > from && /\s/.test(masked[to - 1]))
27
+ to -= 1;
28
+ if (to > from) {
29
+ const absoluteFrom = argument.contentFrom + from;
30
+ const absoluteTo = argument.contentFrom + to;
31
+ ranges.push({ key: source.slice(absoluteFrom, absoluteTo), from: absoluteFrom, to: absoluteTo });
32
+ }
33
+ segmentStart = index + 1;
34
+ }
35
+ return ranges;
36
+ }
37
+ /**
38
+ * Return the unclosed key argument for a known command shape. Mandatory
39
+ * arguments may be preceded by completed optional arguments; `\\hyperref`
40
+ * is the notable optional-key form.
41
+ */
42
+ function unfinishedReferenceArgument(source, start, spec) {
43
+ let index = start;
44
+ for (let count = 0; count < spec.maximumArguments; count += 1) {
45
+ index = skipLatexTrivia(source, index);
46
+ if (spec.argumentKind === "optional") {
47
+ if (source[index] !== "[")
48
+ return null;
49
+ const optional = readLatexBalancedArgument(source, index, "[", "]");
50
+ if (!optional)
51
+ return index + 1;
52
+ index = optional.to;
53
+ continue;
54
+ }
55
+ while (source[index] === "[") {
56
+ const optional = readLatexBalancedArgument(source, index, "[", "]");
57
+ if (!optional)
58
+ return null;
59
+ index = skipLatexTrivia(source, optional.to);
60
+ }
61
+ if (source[index] !== "{")
62
+ return null;
63
+ const mandatory = readLatexBalancedArgument(source, index, "{", "}");
64
+ if (!mandatory)
65
+ return index + 1;
66
+ index = mandatory.to;
67
+ }
68
+ return null;
69
+ }
70
+ function currentReferenceKey(source, start) {
71
+ let itemStart = start;
72
+ for (let index = start; index < source.length; index += 1) {
73
+ if (isLatexCommentStart(source, index)) {
74
+ index = skipLatexComment(source, index) - 1;
75
+ continue;
76
+ }
77
+ if (source[index] === ",")
78
+ itemStart = index + 1;
79
+ }
80
+ const from = skipLatexTrivia(source, itemStart);
81
+ return { from, query: source.slice(from) };
82
+ }
83
+ function findLatexReferenceCompletion(source, kind) {
84
+ let argumentStart = null;
85
+ forEachLatexCommand(source, (command) => {
86
+ const spec = classifyLatexReferenceCommand(command.name);
87
+ if (!spec || spec.kind !== kind)
88
+ return;
89
+ const active = unfinishedReferenceArgument(source, command.to, spec);
90
+ if (active !== null)
91
+ argumentStart = active;
92
+ });
93
+ return argumentStart === null ? null : currentReferenceKey(source, argumentStart);
94
+ }
95
+ /**
96
+ * Return the active key range while a citation command is still being
97
+ * written. This shares navigation's command classification and lexical rules.
98
+ */
99
+ export function findLatexCitationCompletion(source) {
100
+ return findLatexReferenceCompletion(source, "citation");
101
+ }
102
+ /**
103
+ * Return the active label range while a cross-reference command is being
104
+ * written, including both arguments of `\\crefrange`.
105
+ */
106
+ export function findLatexLabelCompletion(source) {
107
+ return findLatexReferenceCompletion(source, "label");
108
+ }
109
+ /**
110
+ * Return individual keys inside visible citation and cross-reference commands.
111
+ * Nested commands remain visible, for example a citation in a caption option.
112
+ */
113
+ export function findLatexReferences(source) {
114
+ const references = [];
115
+ forEachLatexCommand(source, (command) => {
116
+ const spec = classifyLatexReferenceCommand(command.name);
117
+ if (!spec)
118
+ return;
119
+ const argumentsFound = spec.argumentKind === "optional"
120
+ ? readLatexOptionalArguments(source, command.to, spec.maximumArguments)
121
+ : readLatexMandatoryArguments(source, command.to, spec.maximumArguments);
122
+ for (const argument of argumentsFound) {
123
+ for (const range of keyRanges(source, argument)) {
124
+ references.push({ kind: spec.kind, command: command.name, ...range });
125
+ }
126
+ }
127
+ });
128
+ return references.sort((left, right) => left.from - right.from || left.to - right.to);
129
+ }
130
+ export function latexReferenceAt(source, offset) {
131
+ return findLatexReferences(source).find((reference) => offset >= reference.from && offset < reference.to) ?? null;
132
+ }
133
+ function definitionsFromLatex(source, kind) {
134
+ const definitions = [];
135
+ forEachLatexCommand(source, (command) => {
136
+ if (kind === "label" && (command.name === "label" || command.name === "hypertarget")) {
137
+ const argument = readLatexMandatoryArguments(source, command.to, 1)[0];
138
+ if (!argument)
139
+ return;
140
+ for (const range of keyRanges(source, argument)) {
141
+ definitions.push({
142
+ key: range.key,
143
+ command: command.name,
144
+ targetFrom: command.from,
145
+ targetTo: argument.to,
146
+ source: "label"
147
+ });
148
+ }
149
+ return;
150
+ }
151
+ if (kind === "citation" && command.name === "bibitem") {
152
+ const argument = readLatexMandatoryArguments(source, command.to, 1)[0];
153
+ if (!argument)
154
+ return;
155
+ for (const range of keyRanges(source, argument)) {
156
+ definitions.push({
157
+ key: range.key,
158
+ command: command.name,
159
+ targetFrom: command.from,
160
+ targetTo: argument.to,
161
+ source: "bibitem"
162
+ });
163
+ }
164
+ }
165
+ });
166
+ return definitions;
167
+ }
168
+ function definitionsFromBibtex(source) {
169
+ return findBibtexEntryKeys(source).map((entry) => ({
170
+ key: entry.key,
171
+ command: "bibtex",
172
+ targetFrom: entry.entryFrom,
173
+ targetTo: entry.entryTo,
174
+ source: "bibtex"
175
+ }));
176
+ }
177
+ export function findLatexReferenceDefinitions(source, kind, bibtex = false) {
178
+ if (bibtex)
179
+ return kind === "citation" ? definitionsFromBibtex(source) : [];
180
+ return definitionsFromLatex(source, kind);
181
+ }
182
+ export function lineAndColumnAt(source, offset) {
183
+ const boundedOffset = Math.max(0, Math.min(offset, source.length));
184
+ let line = 1;
185
+ let lineStart = 0;
186
+ for (let index = 0; index < boundedOffset; index += 1) {
187
+ if (source[index] !== "\n")
188
+ continue;
189
+ line += 1;
190
+ lineStart = index + 1;
191
+ }
192
+ return { line, column: boundedOffset - lineStart + 1 };
193
+ }
@@ -0,0 +1,21 @@
1
+ import { maskLatexComments, maskLatexLiteralContent } from "./latexLiterals.js";
2
+ function isEscaped(source, index) {
3
+ let backslashes = 0;
4
+ for (let cursor = index - 1; cursor >= 0 && source[cursor] === "\\"; cursor -= 1)
5
+ backslashes += 1;
6
+ return backslashes % 2 === 1;
7
+ }
8
+ /**
9
+ * Return whether source contains a real `\\documentclass` declaration.
10
+ * Literal code and comments are ignored consistently by browser-side root
11
+ * selection and server-side ZIP import.
12
+ */
13
+ export function hasLatexDocumentClass(source) {
14
+ const visible = maskLatexComments(maskLatexLiteralContent(source));
15
+ const declaration = /\\documentclass(?![A-Za-z@])\s*(?:\[[^\]]*\]\s*)?\{/g;
16
+ for (const match of visible.matchAll(declaration)) {
17
+ if (match.index !== undefined && !isEscaped(visible, match.index))
18
+ return true;
19
+ }
20
+ return false;
21
+ }
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Offset-preserving lexical helpers shared by LaTeX reference, dependency,
3
+ * outline-adjacent, and completion logic. This is intentionally a scanner,
4
+ * not a TeX expansion engine: it understands comments, literal forms, command
5
+ * boundaries, and balanced arguments without assigning package semantics.
6
+ */
7
+ import { inlineLatexLiteralEnd, literalEnvironmentEnd, literalEnvironmentNames } from "./latexLiterals.js";
8
+ const literalEnvironments = new Set(literalEnvironmentNames);
9
+ function isCommandNameCharacter(character) {
10
+ return /[A-Za-z@0-9:_-]/.test(character);
11
+ }
12
+ export function isLatexEscaped(source, index) {
13
+ let backslashes = 0;
14
+ for (let cursor = index - 1; cursor >= 0 && source[cursor] === "\\"; cursor -= 1)
15
+ backslashes += 1;
16
+ return backslashes % 2 === 1;
17
+ }
18
+ export function isLatexCommentStart(source, index) {
19
+ return source[index] === "%" && !isLatexEscaped(source, index);
20
+ }
21
+ export function skipLatexComment(source, index) {
22
+ const newline = source.indexOf("\n", index);
23
+ return newline === -1 ? source.length : newline;
24
+ }
25
+ export function skipLatexTrivia(source, start) {
26
+ let index = start;
27
+ while (index < source.length) {
28
+ if (/\s/.test(source[index])) {
29
+ index += 1;
30
+ continue;
31
+ }
32
+ if (isLatexCommentStart(source, index)) {
33
+ index = skipLatexComment(source, index);
34
+ continue;
35
+ }
36
+ break;
37
+ }
38
+ return index;
39
+ }
40
+ /** Read one balanced braced or bracketed argument without changing offsets. */
41
+ export function readLatexBalancedArgument(source, start, opening, closing) {
42
+ if (source[start] !== opening)
43
+ return null;
44
+ let depth = 0;
45
+ let braceDepth = 0;
46
+ for (let index = start; index < source.length; index += 1) {
47
+ const character = source[index];
48
+ if (character === "\\" && index + 1 < source.length) {
49
+ index += 1;
50
+ continue;
51
+ }
52
+ if (isLatexCommentStart(source, index)) {
53
+ index = skipLatexComment(source, index) - 1;
54
+ continue;
55
+ }
56
+ // Braced content inside an optional argument can contain literal square
57
+ // brackets, which must not close its outer option.
58
+ if (opening === "[") {
59
+ if (character === "{") {
60
+ braceDepth += 1;
61
+ continue;
62
+ }
63
+ if (character === "}" && braceDepth > 0) {
64
+ braceDepth -= 1;
65
+ continue;
66
+ }
67
+ if (braceDepth > 0)
68
+ continue;
69
+ }
70
+ if (character === opening) {
71
+ depth += 1;
72
+ continue;
73
+ }
74
+ if (character !== closing)
75
+ continue;
76
+ depth -= 1;
77
+ if (depth === 0) {
78
+ return {
79
+ from: start,
80
+ to: index + 1,
81
+ contentFrom: start + 1,
82
+ contentTo: index
83
+ };
84
+ }
85
+ }
86
+ return null;
87
+ }
88
+ export function readLatexMandatoryArguments(source, start, maximum) {
89
+ const argumentsFound = [];
90
+ let index = start;
91
+ while (argumentsFound.length < maximum) {
92
+ index = skipLatexTrivia(source, index);
93
+ while (source[index] === "[") {
94
+ const optional = readLatexBalancedArgument(source, index, "[", "]");
95
+ if (!optional)
96
+ return argumentsFound;
97
+ index = skipLatexTrivia(source, optional.to);
98
+ }
99
+ if (source[index] !== "{")
100
+ break;
101
+ const mandatory = readLatexBalancedArgument(source, index, "{", "}");
102
+ if (!mandatory)
103
+ break;
104
+ argumentsFound.push(mandatory);
105
+ index = mandatory.to;
106
+ }
107
+ return argumentsFound;
108
+ }
109
+ export function readLatexOptionalArguments(source, start, maximum) {
110
+ const argumentsFound = [];
111
+ let index = start;
112
+ while (argumentsFound.length < maximum) {
113
+ index = skipLatexTrivia(source, index);
114
+ if (source[index] !== "[")
115
+ break;
116
+ const optional = readLatexBalancedArgument(source, index, "[", "]");
117
+ if (!optional)
118
+ break;
119
+ argumentsFound.push(optional);
120
+ index = optional.to;
121
+ }
122
+ return argumentsFound;
123
+ }
124
+ function readLatexCommand(source, start) {
125
+ if (source[start] !== "\\" || start + 1 >= source.length)
126
+ return null;
127
+ let index = start + 1;
128
+ if (!/[A-Za-z@]/.test(source[index])) {
129
+ return { name: source[index], from: start, to: index + 1 };
130
+ }
131
+ while (index < source.length && isCommandNameCharacter(source[index]))
132
+ index += 1;
133
+ const name = source.slice(start + 1, index);
134
+ if (source[index] === "*")
135
+ index += 1;
136
+ return { name, from: start, to: index };
137
+ }
138
+ function skipLiteralEnvironment(source, command) {
139
+ if (command.name !== "begin")
140
+ return command.to;
141
+ const environment = readLatexMandatoryArguments(source, command.to, 1)[0];
142
+ if (!environment)
143
+ return command.to;
144
+ const name = source.slice(environment.contentFrom, environment.contentTo).trim();
145
+ if (!literalEnvironments.has(name))
146
+ return command.to;
147
+ return literalEnvironmentEnd(source, environment.to, name)?.to ?? source.length;
148
+ }
149
+ /**
150
+ * Visit visible TeX commands in source order. Commands nested in arguments are
151
+ * intentionally still visited; commands in comments and literal source forms
152
+ * are not.
153
+ */
154
+ export function forEachLatexCommand(source, visitor) {
155
+ let index = 0;
156
+ while (index < source.length) {
157
+ if (isLatexCommentStart(source, index)) {
158
+ index = skipLatexComment(source, index);
159
+ continue;
160
+ }
161
+ if (source[index] !== "\\") {
162
+ index += 1;
163
+ continue;
164
+ }
165
+ const command = readLatexCommand(source, index);
166
+ if (!command) {
167
+ index += 1;
168
+ continue;
169
+ }
170
+ const literalEnd = inlineLatexLiteralEnd(source, command.from);
171
+ if (literalEnd !== null && literalEnd > command.to) {
172
+ index = literalEnd;
173
+ continue;
174
+ }
175
+ const environmentEnd = skipLiteralEnvironment(source, command);
176
+ if (environmentEnd > command.to) {
177
+ index = environmentEnd;
178
+ continue;
179
+ }
180
+ visitor(command);
181
+ index = command.to;
182
+ }
183
+ }
@@ -0,0 +1,4 @@
1
+ /** Bibliographies are structured reference data, not prose. */
2
+ export function supportsWritingChecks(path) {
3
+ return !/\.bib$/i.test(path.trim());
4
+ }