texlite 0.8.0 → 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 +7 -4
- package/NPM_TESTING.md +8 -2
- package/OPERATIONS.md +21 -6
- package/README.md +6 -1
- package/README.zh-CN.md +5 -1
- package/dist/client/assets/{GitDialog-DdLrbz_H.js → GitDialog-CZRQPg3O.js} +1 -1
- package/dist/client/assets/{HistoryDialog-AlF9iozX.js → HistoryDialog-BQtr58Co.js} +1 -1
- package/dist/client/assets/{LatexEditor-DH9v1wgS.js → LatexEditor-XLwZxO6_.js} +1 -1
- package/dist/client/assets/{PdfPreview-CanYnyfJ.js → PdfPreview-D5CC-vqV.js} +1 -1
- package/dist/client/assets/{ProjectNavigationDialogs-DiXtkTj1.js → ProjectNavigationDialogs-BdtTldSa.js} +1 -1
- package/dist/client/assets/{SystemMetricsDialog-DimBI7er.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-DR-ttrx3.js → minus-KzdrD7TK.js} +1 -1
- package/dist/client/assets/{spellCheck-Wn7_eF9j.js → spellCheck-CDZ9ZXo6.js} +1 -1
- package/dist/client/index.html +2 -2
- package/dist/server/app.js +5 -0
- package/dist/server/cli.js +170 -32
- package/dist/server/environment.js +168 -38
- package/dist/server/i18n.js +8 -0
- package/dist/server/routes/wordCount.js +83 -0
- package/dist/server/texcount.js +244 -0
- package/package.json +3 -2
- package/preview-1.png +0 -0
- package/preview-2.png +0 -0
- package/dist/client/assets/index-CdiutUgf.css +0 -1
- package/dist/client/assets/index-vpWlpUOr.js +0 -64
- package/preview.png +0 -0
|
@@ -1,50 +1,167 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { LATEX_ENGINES } from "./config.js";
|
|
2
3
|
import { httpError } from "./http.js";
|
|
3
4
|
import { detachedProcessGroup, killProcessGroup } from "./processTree.js";
|
|
4
|
-
export
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
5
|
+
export const MIN_NODE_MAJOR = 24;
|
|
6
|
+
const commandProbeTimeoutMs = 10_000;
|
|
7
|
+
const maxCommandOutputBytes = 64 * 1024;
|
|
8
|
+
/**
|
|
9
|
+
* Probe all tools that are relevant to a TexLite host. Optional tools are
|
|
10
|
+
* included deliberately: their absence must be visible in `doctor`, but does
|
|
11
|
+
* not prevent TexLite from starting.
|
|
12
|
+
*/
|
|
13
|
+
export async function inspectHostEnvironment(config) {
|
|
14
|
+
return Promise.all(hostToolDefinitions(config).map((tool) => inspectTool(tool)));
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Probe the default commands on PATH without loading a TexLite configuration.
|
|
18
|
+
* This supports pre-installation checks through `texlite requirements`.
|
|
19
|
+
*/
|
|
20
|
+
export async function inspectHostRequirements() {
|
|
21
|
+
return Promise.all(hostRequirementToolDefinitions().map((tool) => inspectTool(tool)));
|
|
22
|
+
}
|
|
23
|
+
export function hostRequirementsSatisfied(tools) {
|
|
24
|
+
if (tools.some((tool) => tool.requirement === "required" && tool.status !== "installed"))
|
|
25
|
+
return false;
|
|
26
|
+
const oneOfGroups = new Map();
|
|
27
|
+
for (const tool of tools) {
|
|
28
|
+
if (tool.requirement !== "one-of")
|
|
13
29
|
continue;
|
|
14
|
-
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
30
|
+
const group = tool.requirementGroup ?? tool.id;
|
|
31
|
+
const members = oneOfGroups.get(group) ?? [];
|
|
32
|
+
members.push(tool);
|
|
33
|
+
oneOfGroups.set(group, members);
|
|
34
|
+
}
|
|
35
|
+
return [...oneOfGroups.values()].every((members) => members.some((tool) => tool.status === "installed"));
|
|
36
|
+
}
|
|
37
|
+
export async function assertEnvironment(config) {
|
|
38
|
+
const tools = await Promise.all(requiredToolDefinitions(config).map((tool) => inspectTool(tool)));
|
|
39
|
+
const unavailable = tools.find((tool) => tool.status !== "installed");
|
|
40
|
+
if (unavailable) {
|
|
41
|
+
const detail = unavailable.detail ?? "unknown error";
|
|
42
|
+
throw new Error(`Environment check failed: could not run the ${unavailable.name} command "${unavailable.command}" (${detail}). Initialization/startup has been stopped.`);
|
|
22
43
|
}
|
|
23
|
-
return
|
|
44
|
+
return tools.map(({ name, command, version }) => ({ name, command, version: version ?? "available" }));
|
|
24
45
|
}
|
|
25
46
|
export async function assertGitAvailable(config) {
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
if (result.error || result.status !== 0) {
|
|
29
|
-
const detail = result.error || result.stderr.trim() || `exit ${result.status ?? "unknown"}`;
|
|
30
|
-
// Command output can reveal host paths; it remains a startup diagnostic,
|
|
31
|
-
// while API clients receive the localized, stable error code.
|
|
32
|
-
void detail;
|
|
47
|
+
const tool = await inspectTool(gitToolDefinition(config.git, Math.min(config.gitOperationTimeoutMs, commandProbeTimeoutMs)));
|
|
48
|
+
if (tool.status !== "installed")
|
|
33
49
|
throw httpError(503, "GIT_UNAVAILABLE");
|
|
50
|
+
return { name: tool.name, command: tool.command, version: tool.version ?? "available" };
|
|
51
|
+
}
|
|
52
|
+
function requiredToolDefinitions(config) {
|
|
53
|
+
return [
|
|
54
|
+
{ id: "node", name: `Node.js ${MIN_NODE_MAJOR}+`, command: "node", requirement: "required", purpose: "TexLite runtime" },
|
|
55
|
+
{ id: "latexmk", name: "latexmk", command: config.latexmk, requirement: "required", purpose: "LaTeX compilation" },
|
|
56
|
+
...config.allowedEngines.map((command) => ({
|
|
57
|
+
id: `engine:${command}`, name: command, command, requirement: "required",
|
|
58
|
+
purpose: "Configured LaTeX engine"
|
|
59
|
+
}))
|
|
60
|
+
];
|
|
61
|
+
}
|
|
62
|
+
function hostToolDefinitions(config) {
|
|
63
|
+
return [
|
|
64
|
+
...requiredToolDefinitions(config),
|
|
65
|
+
...optionalToolDefinitions(config.git, Math.min(config.gitOperationTimeoutMs, commandProbeTimeoutMs))
|
|
66
|
+
];
|
|
67
|
+
}
|
|
68
|
+
function hostRequirementToolDefinitions() {
|
|
69
|
+
return [
|
|
70
|
+
{ id: "node", name: `Node.js ${MIN_NODE_MAJOR}+`, command: "node", requirement: "required", purpose: "TexLite runtime" },
|
|
71
|
+
{ id: "latexmk", name: "latexmk", command: "latexmk", requirement: "required", purpose: "LaTeX compilation" },
|
|
72
|
+
...LATEX_ENGINES.map((command) => ({
|
|
73
|
+
id: `engine:${command}`, name: command, command, requirement: "one-of",
|
|
74
|
+
requirementGroup: "latex-engine", purpose: "Supported LaTeX engine"
|
|
75
|
+
})),
|
|
76
|
+
...optionalToolDefinitions("git", commandProbeTimeoutMs)
|
|
77
|
+
];
|
|
78
|
+
}
|
|
79
|
+
function optionalToolDefinitions(gitCommand, gitTimeoutMs) {
|
|
80
|
+
return [
|
|
81
|
+
gitToolDefinition(gitCommand, gitTimeoutMs),
|
|
82
|
+
{ id: "texcount", name: "TeXcount", command: "texcount", requirement: "optional", purpose: "Word and character statistics", versionArgs: ["-version"] },
|
|
83
|
+
{ id: "bibtex", name: "BibTeX", command: "bibtex", requirement: "optional", purpose: "BibTeX bibliography builds" },
|
|
84
|
+
{ id: "biber", name: "Biber", command: "biber", requirement: "optional", purpose: "Biber bibliography builds" },
|
|
85
|
+
{ id: "makeindex", name: "MakeIndex", command: "makeindex", requirement: "optional", purpose: "Index generation", versionArgs: [] },
|
|
86
|
+
{ id: "harper-cli", name: "Harper CLI", command: "harper-cli", requirement: "optional", purpose: "TexLite spelling and grammar checks" },
|
|
87
|
+
{ id: "harper-ls", name: "Harper language server", command: "harper-ls", requirement: "optional", purpose: "External editor integration (not used by TexLite)" }
|
|
88
|
+
];
|
|
89
|
+
}
|
|
90
|
+
function gitToolDefinition(command, timeoutMs) {
|
|
91
|
+
return { id: "git", name: "Git", command, requirement: "optional", purpose: "Git and GitHub backup", timeoutMs };
|
|
92
|
+
}
|
|
93
|
+
async function inspectTool(tool) {
|
|
94
|
+
if (tool.id === "node")
|
|
95
|
+
return inspectNode(tool);
|
|
96
|
+
const identity = toolIdentity(tool);
|
|
97
|
+
const result = await commandVersion(tool.command, tool.timeoutMs ?? commandProbeTimeoutMs, tool.versionArgs ?? ["--version"]);
|
|
98
|
+
if (!result.error && result.status === 0) {
|
|
99
|
+
return { ...identity, status: "installed", version: versionFromResult(result), detail: null };
|
|
34
100
|
}
|
|
35
|
-
const
|
|
36
|
-
return {
|
|
101
|
+
const missing = result.errorCode === "ENOENT";
|
|
102
|
+
return {
|
|
103
|
+
...identity,
|
|
104
|
+
status: missing ? "missing" : "failed",
|
|
105
|
+
version: null,
|
|
106
|
+
detail: commandFailureDetail(result)
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
function inspectNode(tool) {
|
|
110
|
+
const identity = toolIdentity(tool);
|
|
111
|
+
const match = /^v(\d+)/.exec(process.version);
|
|
112
|
+
const major = match ? Number(match[1]) : Number.NaN;
|
|
113
|
+
if (Number.isInteger(major) && major >= MIN_NODE_MAJOR) {
|
|
114
|
+
return { ...identity, status: "installed", version: process.version, detail: null };
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
...identity,
|
|
118
|
+
status: "failed",
|
|
119
|
+
version: process.version || null,
|
|
120
|
+
detail: `TexLite requires Node.js ${MIN_NODE_MAJOR} or newer.`
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
function toolIdentity(tool) {
|
|
124
|
+
return {
|
|
125
|
+
id: tool.id,
|
|
126
|
+
name: tool.name,
|
|
127
|
+
command: tool.command,
|
|
128
|
+
requirement: tool.requirement,
|
|
129
|
+
requirementGroup: tool.requirementGroup,
|
|
130
|
+
purpose: tool.purpose
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
function versionFromResult(result) {
|
|
134
|
+
const lines = `${result.stdout}\n${result.stderr}`.split(/\r?\n/)
|
|
135
|
+
.map((line) => line.trim())
|
|
136
|
+
.filter((line) => line && !/^warning\b/i.test(line) && !/^possible precedence problem\b/i.test(line));
|
|
137
|
+
const versionLine = lines.find((line) => /\bversion\b|\bv?\d+\.\d+/i.test(line)) ?? lines[0];
|
|
138
|
+
return (versionLine ?? "available").slice(0, 160);
|
|
37
139
|
}
|
|
38
|
-
function
|
|
140
|
+
function commandFailureDetail(result) {
|
|
141
|
+
const detail = result.error || result.stderr.trim() || result.stdout.trim() || `exit ${result.status ?? "unknown"}`;
|
|
142
|
+
return detail.replace(/\s+/g, " ").slice(0, 240);
|
|
143
|
+
}
|
|
144
|
+
function commandVersion(command, timeoutMs, args) {
|
|
39
145
|
return new Promise((resolve) => {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
146
|
+
let child;
|
|
147
|
+
try {
|
|
148
|
+
child = spawn(command, args, {
|
|
149
|
+
env: { ...process.env, LC_ALL: "C" }, shell: false, detached: detachedProcessGroup(),
|
|
150
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
155
|
+
const errorCode = error && typeof error === "object" && "code" in error && typeof error.code === "string" ? error.code : null;
|
|
156
|
+
resolve({ status: null, stdout: "", stderr: "", error: message, errorCode });
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
44
159
|
const stdout = [];
|
|
45
160
|
const stderr = [];
|
|
161
|
+
let outputBytes = 0;
|
|
46
162
|
let settled = false;
|
|
47
163
|
let timedOut = false;
|
|
164
|
+
let outputExceeded = false;
|
|
48
165
|
const finish = (result) => {
|
|
49
166
|
if (settled)
|
|
50
167
|
return;
|
|
@@ -53,11 +170,24 @@ function commandVersion(command, timeoutMs) {
|
|
|
53
170
|
resolve({ ...result, stdout: Buffer.concat(stdout).toString("utf8"), stderr: Buffer.concat(stderr).toString("utf8") });
|
|
54
171
|
};
|
|
55
172
|
const timer = setTimeout(() => { timedOut = true; killProcessGroup(child); }, timeoutMs);
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
173
|
+
const collect = (target) => (chunk) => {
|
|
174
|
+
if (settled)
|
|
175
|
+
return;
|
|
176
|
+
outputBytes += chunk.length;
|
|
177
|
+
if (outputBytes > maxCommandOutputBytes) {
|
|
178
|
+
outputExceeded = true;
|
|
179
|
+
killProcessGroup(child);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
target.push(chunk);
|
|
183
|
+
};
|
|
184
|
+
child.stdout.on("data", collect(stdout));
|
|
185
|
+
child.stderr.on("data", collect(stderr));
|
|
186
|
+
child.once("error", (error) => finish({ status: null, error: error.message, errorCode: error.code ?? null }));
|
|
187
|
+
child.once("close", (status) => finish({
|
|
188
|
+
status,
|
|
189
|
+
error: timedOut ? "command timed out" : outputExceeded ? "command produced too much output" : null,
|
|
190
|
+
errorCode: null
|
|
191
|
+
}));
|
|
62
192
|
});
|
|
63
193
|
}
|
package/dist/server/i18n.js
CHANGED
|
@@ -95,6 +95,10 @@ const enMessages = {
|
|
|
95
95
|
UPLOAD_EMPTY: "No file was selected for upload.",
|
|
96
96
|
USERNAME_INVALID: "The username may contain only letters, numbers, dots, hyphens, or underscores.",
|
|
97
97
|
USER_NOT_FOUND: "The user does not exist or is disabled.",
|
|
98
|
+
WORD_COUNT_FAILED: "TeXcount could not count this source.",
|
|
99
|
+
WORD_COUNT_SOURCE_INVALID: "Select LaTeX source text to count.",
|
|
100
|
+
WORD_COUNT_SOURCE_TOO_LARGE: "The selected source is too large to count.",
|
|
101
|
+
WORD_COUNT_UNAVAILABLE: "TeXcount is not available on this server. Install it from TeX Live and try again.",
|
|
98
102
|
ZIP_DUPLICATE_ENTRY: "The ZIP archive contains a duplicate filename: {{path}}.",
|
|
99
103
|
ZIP_ENTRY_READ_FAILED: "A ZIP archive entry could not be read.",
|
|
100
104
|
ZIP_ENTRY_TOO_LARGE: "The ZIP archive contains a file that exceeds the permitted size.",
|
|
@@ -215,6 +219,10 @@ const zhMessages = {
|
|
|
215
219
|
UPLOAD_EMPTY: "没有选择要上传的文件。",
|
|
216
220
|
USERNAME_INVALID: "用户名只能包含字母、数字、点、横线或下划线。",
|
|
217
221
|
USER_NOT_FOUND: "用户不存在或已被禁用。",
|
|
222
|
+
WORD_COUNT_FAILED: "TeXcount 无法统计这段源码。",
|
|
223
|
+
WORD_COUNT_SOURCE_INVALID: "请选择 LaTeX 源码后再统计。",
|
|
224
|
+
WORD_COUNT_SOURCE_TOO_LARGE: "所选源码过大,无法统计。",
|
|
225
|
+
WORD_COUNT_UNAVAILABLE: "服务器未安装 TeXcount,请安装 TeX Live 后重试。",
|
|
218
226
|
ZIP_DUPLICATE_ENTRY: "ZIP 压缩包包含重复文件名:{{path}}。",
|
|
219
227
|
ZIP_ENTRY_READ_FAILED: "无法读取 ZIP 压缩包中的文件。",
|
|
220
228
|
ZIP_ENTRY_TOO_LARGE: "ZIP 压缩包中存在超过限制的文件。",
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { requireUser } from "../auth.js";
|
|
2
|
+
import { maxCollaborativeFileBytes } from "../collaboration.js";
|
|
3
|
+
import { compileMainFile } from "../compileArtifacts.js";
|
|
4
|
+
import { assertNoSourceSymlinks, safeRelativePath, sourceRoot } from "../files.js";
|
|
5
|
+
import { apiError, httpError } from "../http.js";
|
|
6
|
+
import { accessibleProject } from "../projects.js";
|
|
7
|
+
import { TexcountExecutionError, TexcountUnavailableError } from "../texcount.js";
|
|
8
|
+
/** Register host TeXcount-backed full-document and editor-selection counts. */
|
|
9
|
+
export function registerWordCountRoutes(app, context) {
|
|
10
|
+
const { config, db, projectMutations, texcount } = context;
|
|
11
|
+
app.post("/api/projects/:id/word-count", async (request, reply) => {
|
|
12
|
+
const user = requireUser(request, reply, db);
|
|
13
|
+
if (!user)
|
|
14
|
+
return;
|
|
15
|
+
const { id } = request.params;
|
|
16
|
+
const project = accessibleProject(db, id, user);
|
|
17
|
+
if (!project)
|
|
18
|
+
return apiError(reply, 404, "PROJECT_NOT_FOUND");
|
|
19
|
+
const body = (request.body ?? {});
|
|
20
|
+
const mode = body.mode;
|
|
21
|
+
if (mode !== "full" && mode !== "selection")
|
|
22
|
+
return apiError(reply, 400, "WORD_COUNT_SOURCE_INVALID");
|
|
23
|
+
try {
|
|
24
|
+
if (mode === "selection") {
|
|
25
|
+
return await countSelection(config, id, body, texcount);
|
|
26
|
+
}
|
|
27
|
+
return await countDocument(config, db, projectMutations, texcount, id, user, body.mainFile);
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
if (error instanceof TexcountUnavailableError)
|
|
31
|
+
return apiError(reply, 503, "WORD_COUNT_UNAVAILABLE");
|
|
32
|
+
if (error instanceof TexcountExecutionError)
|
|
33
|
+
return apiError(reply, 422, "WORD_COUNT_FAILED");
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
async function countSelection(config, projectId, body, texcount) {
|
|
39
|
+
if (typeof body.path !== "string" || typeof body.source !== "string") {
|
|
40
|
+
throw httpError(400, "WORD_COUNT_SOURCE_INVALID");
|
|
41
|
+
}
|
|
42
|
+
let filePath;
|
|
43
|
+
try {
|
|
44
|
+
filePath = safeRelativePath(body.path);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
throw httpError(400, "WORD_COUNT_SOURCE_INVALID");
|
|
48
|
+
}
|
|
49
|
+
// TeXcount's stdin parser is intended for TeX-like source. Keep BibTeX and
|
|
50
|
+
// binary resources out of this endpoint while allowing .sty/.cls snippets.
|
|
51
|
+
if (!/\.(?:tex|sty|cls)$/i.test(filePath) || !body.source.trim()) {
|
|
52
|
+
throw httpError(400, "WORD_COUNT_SOURCE_INVALID");
|
|
53
|
+
}
|
|
54
|
+
if (Buffer.byteLength(body.source, "utf8") > maxCollaborativeFileBytes(config)) {
|
|
55
|
+
throw httpError(413, "WORD_COUNT_SOURCE_TOO_LARGE");
|
|
56
|
+
}
|
|
57
|
+
const counts = await texcount.countSource(sourceRoot(config, projectId), body.source);
|
|
58
|
+
return { mode: "selection", path: filePath, ...counts };
|
|
59
|
+
}
|
|
60
|
+
async function countDocument(config, db, projectMutations, texcount, projectId, user, requestedMainFile) {
|
|
61
|
+
const initialProject = accessibleProject(db, projectId, user);
|
|
62
|
+
if (!initialProject)
|
|
63
|
+
throw httpError(404, "PROJECT_NOT_FOUND");
|
|
64
|
+
const mainFile = compileMainFile(config, projectId, initialProject.main_file, requestedMainFile);
|
|
65
|
+
if (!mainFile)
|
|
66
|
+
throw httpError(400, "MAIN_DOCUMENT_INVALID");
|
|
67
|
+
return await projectMutations.runConsistentRead(projectId, async () => {
|
|
68
|
+
// TeXcount follows \input/\include paths. Reject links before handing it
|
|
69
|
+
// the source root so a checked-out link cannot expose files outside it.
|
|
70
|
+
assertNoSourceSymlinks(config, projectId);
|
|
71
|
+
const counts = await texcount.countFile(sourceRoot(config, projectId), mainFile);
|
|
72
|
+
return { mode: "full", path: mainFile, ...counts };
|
|
73
|
+
}, {
|
|
74
|
+
preflight: () => {
|
|
75
|
+
const current = accessibleProject(db, projectId, user);
|
|
76
|
+
if (!current)
|
|
77
|
+
throw httpError(404, "PROJECT_NOT_FOUND");
|
|
78
|
+
const selected = compileMainFile(config, projectId, current.main_file, requestedMainFile);
|
|
79
|
+
if (!selected || selected !== mainFile)
|
|
80
|
+
throw httpError(400, "MAIN_DOCUMENT_INVALID");
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
}
|
|
@@ -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.8.
|
|
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": {
|
|
@@ -28,7 +28,8 @@
|
|
|
28
28
|
"DESIGN.md",
|
|
29
29
|
"NPM_TESTING.md",
|
|
30
30
|
"LICENSE",
|
|
31
|
-
"preview.png",
|
|
31
|
+
"preview-1.png",
|
|
32
|
+
"preview-2.png",
|
|
32
33
|
"texlite.config.example.json"
|
|
33
34
|
],
|
|
34
35
|
"engines": {
|
package/preview-1.png
ADDED
|
Binary file
|
package/preview-2.png
ADDED
|
Binary file
|