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
|
@@ -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/harper.js
CHANGED
|
@@ -1,144 +1,235 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { maskLatexSource } from "./latexSpellMask.js";
|
|
7
|
+
const commandProbeTimeoutMs = 5_000;
|
|
3
8
|
const lintTimeoutMs = 30_000;
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
+
const maxCommandOutputBytes = 8 * 1024 * 1024;
|
|
10
|
+
const maxCachedSourceBytes = 512 * 1024;
|
|
11
|
+
const maxCachedResults = 24;
|
|
12
|
+
const cacheTtlMs = 15_000;
|
|
13
|
+
const unavailableRetryMs = 15_000;
|
|
14
|
+
export class HarperUnavailableError extends Error {
|
|
15
|
+
constructor(message) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.name = "HarperUnavailableError";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function lintCacheKey(source, filePath) {
|
|
21
|
+
// Harper selects its parser from the input suffix, so equivalent text in a
|
|
22
|
+
// .tex and a .sty file must not share a diagnostic result.
|
|
23
|
+
return createHash("sha256").update(temporaryFileExtension(filePath)).update("\0").update(source).digest("base64url");
|
|
24
|
+
}
|
|
25
|
+
function temporaryFileExtension(filePath) {
|
|
26
|
+
const extension = path.extname(filePath).toLowerCase();
|
|
27
|
+
// Harper selects its TeX parser by extension. Treat source-like companion
|
|
28
|
+
// files as TeX too; BibTeX keys are intentionally not grammar-checked.
|
|
29
|
+
return extension === ".tex" || extension === ".sty" || extension === ".cls" ? extension : ".tex";
|
|
30
|
+
}
|
|
31
|
+
function replacementText(suggestion) {
|
|
32
|
+
const match = /^Replace with:\s*[“"]([\s\S]*)[”"]$/u.exec(suggestion.trim());
|
|
33
|
+
return match?.[1] ?? null;
|
|
34
|
+
}
|
|
35
|
+
/** Parse `harper-cli lint --format json` output into the browser API shape. */
|
|
36
|
+
export function parseHarperCliOutput(output) {
|
|
37
|
+
let documents;
|
|
38
|
+
try {
|
|
39
|
+
documents = JSON.parse(output);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
throw new Error("Harper returned invalid JSON.");
|
|
43
|
+
}
|
|
44
|
+
if (!Array.isArray(documents))
|
|
45
|
+
throw new Error("Harper returned an unexpected result.");
|
|
46
|
+
const lints = [];
|
|
47
|
+
for (const document of documents) {
|
|
48
|
+
if (!Array.isArray(document.lints))
|
|
49
|
+
continue;
|
|
50
|
+
for (const lint of document.lints) {
|
|
51
|
+
const start = lint.span?.char_start;
|
|
52
|
+
const end = lint.span?.char_end;
|
|
53
|
+
if (typeof start !== "number" || typeof end !== "number" || !Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end <= start)
|
|
54
|
+
continue;
|
|
55
|
+
const suggestions = Array.isArray(lint.suggestions)
|
|
56
|
+
? lint.suggestions.filter((value) => typeof value === "string")
|
|
57
|
+
.map(replacementText).filter((value) => value !== null)
|
|
58
|
+
: [];
|
|
59
|
+
lints.push({
|
|
60
|
+
start,
|
|
61
|
+
end,
|
|
62
|
+
problem: typeof lint.matched_text === "string" ? lint.matched_text : "",
|
|
63
|
+
kind: typeof lint.kind === "string" ? lint.kind : "Grammar",
|
|
64
|
+
message: typeof lint.message === "string" ? lint.message : "Harper found a writing issue.",
|
|
65
|
+
suggestions: [...new Set(suggestions)].slice(0, 5)
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return lints;
|
|
9
70
|
}
|
|
10
71
|
/**
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* entry does not exist until the server build has completed.
|
|
72
|
+
* Optional host-side Harper integration. `harper-cli` is distributed with
|
|
73
|
+
* Harper/harper-ls and provides native TeX parsing plus structured fixes,
|
|
74
|
+
* without bundling a WASM runtime into TexLite.
|
|
15
75
|
*/
|
|
16
76
|
export class HarperService {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
nextRequestId = 1;
|
|
22
|
-
pending = new Map();
|
|
77
|
+
command;
|
|
78
|
+
availability = "unknown";
|
|
79
|
+
lastUnavailableAt = 0;
|
|
80
|
+
probePromise = null;
|
|
23
81
|
queue = Promise.resolve();
|
|
82
|
+
inFlight = new Map();
|
|
83
|
+
cache = new Map();
|
|
84
|
+
activeChild = null;
|
|
85
|
+
disposed = false;
|
|
86
|
+
constructor(command = "harper-cli") {
|
|
87
|
+
this.command = command;
|
|
88
|
+
}
|
|
24
89
|
async preload() {
|
|
25
|
-
await this.
|
|
90
|
+
await this.ensureAvailable();
|
|
26
91
|
}
|
|
27
|
-
async lint(source) {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
92
|
+
async lint(source, filePath = "main.tex") {
|
|
93
|
+
if (this.disposed)
|
|
94
|
+
throw new HarperUnavailableError("Harper service stopped.");
|
|
95
|
+
const key = lintCacheKey(source, filePath);
|
|
96
|
+
const cacheable = Buffer.byteLength(source, "utf8") <= maxCachedSourceBytes;
|
|
97
|
+
const cached = cacheable ? this.cachedResult(key) : null;
|
|
98
|
+
if (cached)
|
|
99
|
+
return cached;
|
|
100
|
+
const existing = cacheable ? this.inFlight.get(key) : undefined;
|
|
101
|
+
if (existing)
|
|
102
|
+
return existing;
|
|
103
|
+
const operation = this.queue.then(() => this.lintOnce(source, filePath));
|
|
39
104
|
this.queue = operation.then(() => undefined, () => undefined);
|
|
105
|
+
if (cacheable) {
|
|
106
|
+
this.inFlight.set(key, operation);
|
|
107
|
+
void operation.then((lints) => this.cacheResult(key, lints), () => undefined).finally(() => {
|
|
108
|
+
if (this.inFlight.get(key) === operation)
|
|
109
|
+
this.inFlight.delete(key);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
40
112
|
return operation;
|
|
41
113
|
}
|
|
42
114
|
async dispose() {
|
|
115
|
+
this.disposed = true;
|
|
116
|
+
this.activeChild?.kill("SIGTERM");
|
|
43
117
|
await this.queue.catch(() => undefined);
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
this.worker = null;
|
|
47
|
-
if (worker)
|
|
48
|
-
await worker.terminate().catch(() => undefined);
|
|
49
|
-
const linter = this.linter;
|
|
50
|
-
this.linter = null;
|
|
51
|
-
if (linter)
|
|
52
|
-
await linter.dispose().catch(() => undefined);
|
|
53
|
-
this.setupPromise = null;
|
|
54
|
-
this.rejectPending(new Error("Harper service stopped."));
|
|
118
|
+
this.inFlight.clear();
|
|
119
|
+
this.cache.clear();
|
|
55
120
|
}
|
|
56
|
-
|
|
57
|
-
if (this.
|
|
58
|
-
return
|
|
59
|
-
if (this.
|
|
60
|
-
|
|
61
|
-
|
|
121
|
+
async ensureAvailable() {
|
|
122
|
+
if (this.availability === "available")
|
|
123
|
+
return;
|
|
124
|
+
if (this.availability === "unavailable" && Date.now() - this.lastUnavailableAt < unavailableRetryMs) {
|
|
125
|
+
throw new HarperUnavailableError(`The optional ${this.command} command is not available.`);
|
|
126
|
+
}
|
|
127
|
+
if (this.probePromise)
|
|
128
|
+
return this.probePromise;
|
|
129
|
+
this.probePromise = this.runCommand(["--version"], commandProbeTimeoutMs, 64 * 1024)
|
|
130
|
+
.then(({ code }) => {
|
|
131
|
+
if (code !== 0)
|
|
132
|
+
throw new Error(`${this.command} exited with code ${code}.`);
|
|
133
|
+
this.availability = "available";
|
|
134
|
+
})
|
|
62
135
|
.catch((error) => {
|
|
63
|
-
this.
|
|
64
|
-
|
|
136
|
+
this.availability = "unavailable";
|
|
137
|
+
this.lastUnavailableAt = Date.now();
|
|
138
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
139
|
+
throw new HarperUnavailableError(`The optional ${this.command} command is unavailable: ${detail}`);
|
|
65
140
|
})
|
|
66
|
-
.finally(() => { this.
|
|
67
|
-
return this.
|
|
141
|
+
.finally(() => { this.probePromise = null; });
|
|
142
|
+
return this.probePromise;
|
|
68
143
|
}
|
|
69
|
-
async
|
|
70
|
-
|
|
71
|
-
|
|
144
|
+
async lintOnce(source, filePath) {
|
|
145
|
+
await this.ensureAvailable();
|
|
146
|
+
const directory = await mkdtemp(path.join(tmpdir(), "texlite-harper-"));
|
|
147
|
+
const input = path.join(directory, `document${temporaryFileExtension(filePath)}`);
|
|
148
|
+
try {
|
|
149
|
+
await writeFile(input, maskLatexSource(source), { encoding: "utf8", mode: 0o600 });
|
|
150
|
+
const { code, stdout, stderr } = await this.runCommand([
|
|
151
|
+
"lint", "--format", "json", "--quiet",
|
|
152
|
+
"--user-dict-path", path.join(directory, "dictionary.txt"),
|
|
153
|
+
"--file-dict-path", path.join(directory, "file-dictionaries"),
|
|
154
|
+
input
|
|
155
|
+
], lintTimeoutMs, maxCommandOutputBytes);
|
|
156
|
+
// Harper exits with one when it found lints. Any other non-zero status is
|
|
157
|
+
// a command failure, not a spelling result.
|
|
158
|
+
if (code !== 0 && code !== 1)
|
|
159
|
+
throw new Error(stderr.trim() || `${this.command} exited with code ${code}.`);
|
|
160
|
+
return parseHarperCliOutput(stdout);
|
|
161
|
+
}
|
|
162
|
+
finally {
|
|
163
|
+
await rm(directory, { recursive: true, force: true }).catch(() => undefined);
|
|
164
|
+
}
|
|
72
165
|
}
|
|
73
|
-
|
|
166
|
+
cachedResult(key) {
|
|
167
|
+
const cached = this.cache.get(key);
|
|
168
|
+
if (!cached || cached.expiresAt < Date.now()) {
|
|
169
|
+
this.cache.delete(key);
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
this.cache.delete(key);
|
|
173
|
+
this.cache.set(key, cached);
|
|
174
|
+
return cached.lints;
|
|
175
|
+
}
|
|
176
|
+
cacheResult(key, lints) {
|
|
177
|
+
this.cache.set(key, { expiresAt: Date.now() + cacheTtlMs, lints });
|
|
178
|
+
while (this.cache.size > maxCachedResults) {
|
|
179
|
+
const oldest = this.cache.keys().next().value;
|
|
180
|
+
if (oldest === undefined)
|
|
181
|
+
break;
|
|
182
|
+
this.cache.delete(oldest);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
runCommand(args, timeoutMs, outputLimit) {
|
|
74
186
|
return new Promise((resolve, reject) => {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
187
|
+
let child;
|
|
188
|
+
try {
|
|
189
|
+
child = spawn(this.command, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
reject(error);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
this.activeChild = child;
|
|
196
|
+
const stdout = [];
|
|
197
|
+
const stderr = [];
|
|
198
|
+
let outputBytes = 0;
|
|
199
|
+
let settled = false;
|
|
200
|
+
let timer;
|
|
201
|
+
const finish = (callback) => {
|
|
202
|
+
if (settled)
|
|
80
203
|
return;
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
204
|
+
settled = true;
|
|
205
|
+
if (timer)
|
|
206
|
+
clearTimeout(timer);
|
|
207
|
+
if (this.activeChild === child)
|
|
208
|
+
this.activeChild = null;
|
|
209
|
+
callback();
|
|
87
210
|
};
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
211
|
+
const fail = (error) => finish(() => reject(error));
|
|
212
|
+
timer = setTimeout(() => {
|
|
213
|
+
child.kill("SIGTERM");
|
|
214
|
+
fail(new Error(`${this.command} timed out after ${Math.ceil(timeoutMs / 1000)} seconds.`));
|
|
215
|
+
}, timeoutMs);
|
|
216
|
+
const collect = (target) => (chunk) => {
|
|
217
|
+
outputBytes += chunk.length;
|
|
218
|
+
if (outputBytes > outputLimit) {
|
|
219
|
+
child.kill("SIGTERM");
|
|
220
|
+
fail(new Error(`${this.command} produced too much output.`));
|
|
94
221
|
return;
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
this.pending.set(id, { resolve, reject });
|
|
107
|
-
worker.postMessage({ id, source });
|
|
222
|
+
}
|
|
223
|
+
target.push(chunk);
|
|
224
|
+
};
|
|
225
|
+
child.stdout.on("data", collect(stdout));
|
|
226
|
+
child.stderr.on("data", collect(stderr));
|
|
227
|
+
child.once("error", (error) => fail(error));
|
|
228
|
+
child.once("close", (code) => finish(() => resolve({
|
|
229
|
+
code,
|
|
230
|
+
stdout: Buffer.concat(stdout).toString("utf8"),
|
|
231
|
+
stderr: Buffer.concat(stderr).toString("utf8")
|
|
232
|
+
})));
|
|
108
233
|
});
|
|
109
234
|
}
|
|
110
|
-
async lintInProcess(source) {
|
|
111
|
-
if (!this.linter)
|
|
112
|
-
throw new Error("Harper is not ready.");
|
|
113
|
-
const { collectHarperLints } = await import("./harperRuntime.js");
|
|
114
|
-
return collectHarperLints(this.linter, source);
|
|
115
|
-
}
|
|
116
|
-
handleWorkerMessage(message) {
|
|
117
|
-
if ("type" in message)
|
|
118
|
-
return;
|
|
119
|
-
const pending = this.pending.get(message.id);
|
|
120
|
-
if (!pending)
|
|
121
|
-
return;
|
|
122
|
-
this.pending.delete(message.id);
|
|
123
|
-
if (message.ok)
|
|
124
|
-
pending.resolve(message.lints);
|
|
125
|
-
else
|
|
126
|
-
pending.reject(new Error(message.message));
|
|
127
|
-
}
|
|
128
|
-
stopFailedWorker(error) {
|
|
129
|
-
const worker = this.worker;
|
|
130
|
-
this.worker = null;
|
|
131
|
-
if (worker)
|
|
132
|
-
void worker.terminate().catch(() => undefined);
|
|
133
|
-
const linter = this.linter;
|
|
134
|
-
this.linter = null;
|
|
135
|
-
if (linter)
|
|
136
|
-
void linter.dispose().catch(() => undefined);
|
|
137
|
-
this.rejectPending(error);
|
|
138
|
-
}
|
|
139
|
-
rejectPending(error) {
|
|
140
|
-
for (const pending of this.pending.values())
|
|
141
|
-
pending.reject(error);
|
|
142
|
-
this.pending.clear();
|
|
143
|
-
}
|
|
144
235
|
}
|
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 压缩包中存在超过限制的文件。",
|