texlite 0.7.8 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DESIGN.md +463 -0
- package/NPM_TESTING.md +136 -0
- package/OPERATIONS.md +261 -0
- package/README.md +82 -305
- package/README.zh-CN.md +64 -329
- package/dist/client/assets/{GitDialog-CqGyUxfP.js → GitDialog-CZRQPg3O.js} +1 -1
- package/dist/client/assets/{HistoryDialog-BMl2zxBa.js → HistoryDialog-BQtr58Co.js} +1 -1
- package/dist/client/assets/{LatexEditor-C4bFVwJZ.js → LatexEditor-XLwZxO6_.js} +1 -1
- package/dist/client/assets/{PdfPreview-BpP2LnX7.js → PdfPreview-D5CC-vqV.js} +1 -1
- package/dist/client/assets/{ProjectNavigationDialogs-B7mxG8Fz.js → ProjectNavigationDialogs-BdtTldSa.js} +1 -1
- package/dist/client/assets/{SystemMetricsDialog-BK_K3ah8.js → SystemMetricsDialog-CEbYJdj7.js} +1 -1
- package/dist/client/assets/index-BAFR6u6_.js +64 -0
- package/dist/client/assets/index-g5CaToD6.css +1 -0
- package/dist/client/assets/{minus-BwzSZenU.js → minus-KzdrD7TK.js} +1 -1
- package/dist/client/assets/spellCheck-CDZ9ZXo6.js +1 -0
- package/dist/client/index.html +2 -2
- package/dist/server/app.js +8 -3
- package/dist/server/cli.js +170 -32
- package/dist/server/environment.js +168 -38
- package/dist/server/harper.js +210 -119
- package/dist/server/i18n.js +8 -0
- package/dist/server/latexSpellMask.js +257 -0
- package/dist/server/projects.js +0 -12
- package/dist/server/routes/projectShared.js +1 -1
- package/dist/server/routes/projects.js +20 -19
- package/dist/server/routes/wordCount.js +83 -0
- package/dist/server/texcount.js +244 -0
- package/package.json +6 -3
- package/preview-1.png +0 -0
- package/preview-2.png +0 -0
- package/dist/client/assets/index-B9n8hfV0.js +0 -64
- package/dist/client/assets/index-BhyTKMoO.css +0 -1
- package/dist/client/assets/spellCheck-CvOAlLWp.js +0 -4
- package/preview.png +0 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { detachedProcessGroup, killProcessGroup } from "./processTree.js";
|
|
3
|
+
/** TeXcount is an optional host command supplied by TeX Live. */
|
|
4
|
+
export const TEXCOUNT_COMMAND = "texcount";
|
|
5
|
+
const TEXLITE_WORD_REPORT_PREFIX = "__TEXLITE_TEXCOUNT_WORDS__";
|
|
6
|
+
const TEXLITE_CHARACTER_REPORT_PREFIX = "__TEXLITE_TEXCOUNT_CHARS__";
|
|
7
|
+
// TeXcount placeholders {1} through {7} are, respectively: words in text,
|
|
8
|
+
// header words, caption words, header count, float count, inline math, and
|
|
9
|
+
// displayed math. Do not parse TeXcount's human-facing prose report.
|
|
10
|
+
const TEXLITE_WORD_REPORT_TEMPLATE = `${TEXLITE_WORD_REPORT_PREFIX}|{1}|{2}|{3}|{4}|{5}|{6}|{7}`;
|
|
11
|
+
const TEXLITE_CHARACTER_REPORT_TEMPLATE = `${TEXLITE_CHARACTER_REPORT_PREFIX}|{1}|{2}|{3}|{4}|{5}|{6}|{7}`;
|
|
12
|
+
const PROBE_TIMEOUT_MS = 5_000;
|
|
13
|
+
const COUNT_TIMEOUT_MS = 30_000;
|
|
14
|
+
const UNAVAILABLE_RETRY_MS = 15_000;
|
|
15
|
+
const MAX_OUTPUT_BYTES = 512 * 1024;
|
|
16
|
+
export class TexcountUnavailableError extends Error {
|
|
17
|
+
constructor(message) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.name = "TexcountUnavailableError";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export class TexcountExecutionError extends Error {
|
|
23
|
+
constructor(message) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.name = "TexcountExecutionError";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Run the optional host TeXcount binary and normalize its explicit report
|
|
30
|
+
* template into stable fields for the browser. The command is intentionally
|
|
31
|
+
* fixed (rather than shell-evaluated) so project sources cannot inject flags.
|
|
32
|
+
*/
|
|
33
|
+
export class TexcountService {
|
|
34
|
+
command;
|
|
35
|
+
availability = "unknown";
|
|
36
|
+
lastUnavailableAt = 0;
|
|
37
|
+
probePromise = null;
|
|
38
|
+
activeChildren = new Set();
|
|
39
|
+
disposed = false;
|
|
40
|
+
constructor(command = TEXCOUNT_COMMAND) {
|
|
41
|
+
this.command = command;
|
|
42
|
+
}
|
|
43
|
+
async countFile(cwd, filePath) {
|
|
44
|
+
await this.ensureAvailable();
|
|
45
|
+
return this.count(cwd, ["-inc", "-total"], filePath);
|
|
46
|
+
}
|
|
47
|
+
async countSource(cwd, source) {
|
|
48
|
+
await this.ensureAvailable();
|
|
49
|
+
// A dash tells TeXcount to read one virtual TeX document from stdin. No
|
|
50
|
+
// include traversal is requested for a selection because the selection
|
|
51
|
+
// is not a complete project tree.
|
|
52
|
+
return this.count(cwd, ["-total"], "-", source);
|
|
53
|
+
}
|
|
54
|
+
async dispose() {
|
|
55
|
+
this.disposed = true;
|
|
56
|
+
for (const child of this.activeChildren)
|
|
57
|
+
killProcessGroup(child, "SIGTERM");
|
|
58
|
+
this.activeChildren.clear();
|
|
59
|
+
await this.probePromise?.catch(() => undefined);
|
|
60
|
+
this.probePromise = null;
|
|
61
|
+
}
|
|
62
|
+
async ensureAvailable() {
|
|
63
|
+
if (this.disposed)
|
|
64
|
+
throw new TexcountUnavailableError("TeXcount service stopped.");
|
|
65
|
+
if (this.availability === "available")
|
|
66
|
+
return;
|
|
67
|
+
if (this.availability === "unavailable" && Date.now() - this.lastUnavailableAt < UNAVAILABLE_RETRY_MS) {
|
|
68
|
+
throw new TexcountUnavailableError(`The optional ${this.command} command is not available.`);
|
|
69
|
+
}
|
|
70
|
+
if (this.probePromise)
|
|
71
|
+
return this.probePromise;
|
|
72
|
+
this.probePromise = this.runCommand(["--version"], undefined, undefined, PROBE_TIMEOUT_MS, 64 * 1024)
|
|
73
|
+
.then(({ code, stdout, stderr }) => {
|
|
74
|
+
if (code !== 0)
|
|
75
|
+
throw new Error((stderr || stdout).trim() || `exited with code ${code}`);
|
|
76
|
+
this.availability = "available";
|
|
77
|
+
})
|
|
78
|
+
.catch((error) => {
|
|
79
|
+
this.availability = "unavailable";
|
|
80
|
+
this.lastUnavailableAt = Date.now();
|
|
81
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
82
|
+
throw new TexcountUnavailableError(`The optional ${this.command} command is unavailable: ${detail}`);
|
|
83
|
+
})
|
|
84
|
+
.finally(() => { this.probePromise = null; });
|
|
85
|
+
return this.probePromise;
|
|
86
|
+
}
|
|
87
|
+
async count(cwd, options, target, input) {
|
|
88
|
+
const [wordResult, characterResult] = await Promise.all([
|
|
89
|
+
this.runCommand([...options, `-template=${TEXLITE_WORD_REPORT_TEMPLATE}`, target], cwd, input),
|
|
90
|
+
this.runCommand([...options, "-char", `-template=${TEXLITE_CHARACTER_REPORT_TEMPLATE}`, target], cwd, input)
|
|
91
|
+
]);
|
|
92
|
+
const words = this.finishWordCount(wordResult);
|
|
93
|
+
const characters = this.finishCharacterCount(characterResult);
|
|
94
|
+
return {
|
|
95
|
+
...words,
|
|
96
|
+
totalCharacters: characters.text + characters.headers + characters.captions,
|
|
97
|
+
// TeXcount parses the same source twice. Do not show each syntax issue
|
|
98
|
+
// twice merely because character counting was requested as well.
|
|
99
|
+
parserErrors: Math.max(words.parserErrors, characters.parserErrors)
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
finishWordCount(result) {
|
|
103
|
+
if (result.code !== 0) {
|
|
104
|
+
throw new TexcountExecutionError(result.stderr.trim() || `${this.command} exited with code ${result.code}`);
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
return parseTexcountOutput(result.stdout);
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
throw new TexcountExecutionError(error instanceof Error ? error.message : String(error));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
finishCharacterCount(result) {
|
|
114
|
+
if (result.code !== 0) {
|
|
115
|
+
throw new TexcountExecutionError(result.stderr.trim() || `${this.command} exited with code ${result.code}`);
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
return parseTexcountReport(result.stdout, TEXLITE_CHARACTER_REPORT_PREFIX);
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
throw new TexcountExecutionError(error instanceof Error ? error.message : String(error));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
runCommand(args, cwd, input, timeoutMs = COUNT_TIMEOUT_MS, outputLimit = MAX_OUTPUT_BYTES) {
|
|
125
|
+
return new Promise((resolve, reject) => {
|
|
126
|
+
if (this.disposed) {
|
|
127
|
+
reject(new TexcountUnavailableError("TeXcount service stopped."));
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
let child;
|
|
131
|
+
try {
|
|
132
|
+
child = spawn(this.command, args, {
|
|
133
|
+
cwd,
|
|
134
|
+
env: { ...process.env, LC_ALL: "C" },
|
|
135
|
+
shell: false,
|
|
136
|
+
detached: detachedProcessGroup(),
|
|
137
|
+
stdio: [input === undefined ? "ignore" : "pipe", "pipe", "pipe"]
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
reject(error);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
this.activeChildren.add(child);
|
|
145
|
+
const stdout = [];
|
|
146
|
+
const stderr = [];
|
|
147
|
+
let outputBytes = 0;
|
|
148
|
+
let settled = false;
|
|
149
|
+
let timedOut = false;
|
|
150
|
+
let outputTooLarge = false;
|
|
151
|
+
const timer = setTimeout(() => {
|
|
152
|
+
timedOut = true;
|
|
153
|
+
killProcessGroup(child);
|
|
154
|
+
}, timeoutMs);
|
|
155
|
+
const finish = (callback) => {
|
|
156
|
+
if (settled)
|
|
157
|
+
return;
|
|
158
|
+
settled = true;
|
|
159
|
+
clearTimeout(timer);
|
|
160
|
+
this.activeChildren.delete(child);
|
|
161
|
+
callback();
|
|
162
|
+
};
|
|
163
|
+
const terminateWithError = (error) => {
|
|
164
|
+
killProcessGroup(child);
|
|
165
|
+
finish(() => reject(error));
|
|
166
|
+
};
|
|
167
|
+
const collect = (target) => (chunk) => {
|
|
168
|
+
outputBytes += chunk.length;
|
|
169
|
+
if (outputBytes > outputLimit) {
|
|
170
|
+
outputTooLarge = true;
|
|
171
|
+
terminateWithError(new TexcountExecutionError(`${this.command} produced too much output.`));
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
target.push(chunk);
|
|
175
|
+
};
|
|
176
|
+
child.stdout?.on("data", collect(stdout));
|
|
177
|
+
child.stderr?.on("data", collect(stderr));
|
|
178
|
+
child.stdin?.on("error", (error) => terminateWithError(error));
|
|
179
|
+
child.once("error", (error) => finish(() => {
|
|
180
|
+
if (timedOut) {
|
|
181
|
+
reject(new TexcountExecutionError(`${this.command} timed out after ${Math.ceil(timeoutMs / 1000)} seconds.`));
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (error.code === "ENOENT") {
|
|
185
|
+
reject(new TexcountUnavailableError(`The optional ${this.command} command is not available.`));
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
reject(error);
|
|
189
|
+
}));
|
|
190
|
+
child.once("close", (code) => finish(() => {
|
|
191
|
+
if (timedOut) {
|
|
192
|
+
reject(new TexcountExecutionError(`${this.command} timed out after ${Math.ceil(timeoutMs / 1000)} seconds.`));
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (outputTooLarge) {
|
|
196
|
+
reject(new TexcountExecutionError(`${this.command} produced too much output.`));
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
resolve({
|
|
200
|
+
code,
|
|
201
|
+
stdout: Buffer.concat(stdout).toString("utf8"),
|
|
202
|
+
stderr: Buffer.concat(stderr).toString("utf8")
|
|
203
|
+
});
|
|
204
|
+
}));
|
|
205
|
+
if (input !== undefined)
|
|
206
|
+
child.stdin?.end(input, "utf8");
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
/** Parse TexLite's explicit TeXcount template, rather than prose output. */
|
|
211
|
+
export function parseTexcountOutput(output) {
|
|
212
|
+
const report = parseTexcountReport(output, TEXLITE_WORD_REPORT_PREFIX);
|
|
213
|
+
const filesMatch = /Files:\s*(\d+)/i.exec(output);
|
|
214
|
+
return {
|
|
215
|
+
textWords: report.text,
|
|
216
|
+
headerWords: report.headers,
|
|
217
|
+
captionWords: report.captions,
|
|
218
|
+
totalWords: report.text + report.headers + report.captions,
|
|
219
|
+
headers: report.headerCount,
|
|
220
|
+
floats: report.floatCount,
|
|
221
|
+
inlineMath: report.inlineMathCount,
|
|
222
|
+
displayMath: report.displayMathCount,
|
|
223
|
+
files: filesMatch ? Number(filesMatch[1]) : null,
|
|
224
|
+
parserErrors: report.parserErrors
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
function parseTexcountReport(output, prefix) {
|
|
228
|
+
const report = new RegExp(`${prefix}\\|(\\d+)\\|(\\d+)\\|(\\d+)\\|(\\d+)\\|(\\d+)\\|(\\d+)\\|(\\d+)`).exec(output);
|
|
229
|
+
if (!report) {
|
|
230
|
+
throw new Error("TeXcount did not return TexLite's expected report; a %TC:newtemplate or %TC:template directive may have overridden it.");
|
|
231
|
+
}
|
|
232
|
+
const [, text, header, caption, headerCount, floatCount, inlineMathCount, displayMathCount] = report;
|
|
233
|
+
const errorsMatch = /\(errors:\s*(\d+)\)/i.exec(output);
|
|
234
|
+
return {
|
|
235
|
+
text: Number(text),
|
|
236
|
+
headers: Number(header),
|
|
237
|
+
captions: Number(caption),
|
|
238
|
+
headerCount: Number(headerCount),
|
|
239
|
+
floatCount: Number(floatCount),
|
|
240
|
+
inlineMathCount: Number(inlineMathCount),
|
|
241
|
+
displayMathCount: Number(displayMathCount),
|
|
242
|
+
parserErrors: errorsMatch ? Number(errorsMatch[1]) : 0
|
|
243
|
+
};
|
|
244
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "texlite",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"description": "A lightweight local-first collaborative LaTeX workspace",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"repository": {
|
|
@@ -24,8 +24,12 @@
|
|
|
24
24
|
"dist",
|
|
25
25
|
"README.md",
|
|
26
26
|
"README.zh-CN.md",
|
|
27
|
+
"OPERATIONS.md",
|
|
28
|
+
"DESIGN.md",
|
|
29
|
+
"NPM_TESTING.md",
|
|
27
30
|
"LICENSE",
|
|
28
|
-
"preview.png",
|
|
31
|
+
"preview-1.png",
|
|
32
|
+
"preview-2.png",
|
|
29
33
|
"texlite.config.example.json"
|
|
30
34
|
],
|
|
31
35
|
"engines": {
|
|
@@ -72,7 +76,6 @@
|
|
|
72
76
|
"bibtex-tidy": "1.14.0",
|
|
73
77
|
"diff-match-patch": "^1.0.5",
|
|
74
78
|
"fastify": "^5.4.0",
|
|
75
|
-
"harper.js": "^2.7.0",
|
|
76
79
|
"i18next": "^25.3.2",
|
|
77
80
|
"i18next-browser-languagedetector": "^8.2.0",
|
|
78
81
|
"lib0": "^0.2.117",
|
package/preview-1.png
ADDED
|
Binary file
|
package/preview-2.png
ADDED
|
Binary file
|