texlite 0.7.8 → 0.8.0
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 +460 -0
- package/NPM_TESTING.md +130 -0
- package/OPERATIONS.md +246 -0
- package/README.md +78 -306
- package/README.zh-CN.md +61 -330
- package/dist/client/assets/{GitDialog-CqGyUxfP.js → GitDialog-DdLrbz_H.js} +1 -1
- package/dist/client/assets/{HistoryDialog-BMl2zxBa.js → HistoryDialog-AlF9iozX.js} +1 -1
- package/dist/client/assets/{LatexEditor-C4bFVwJZ.js → LatexEditor-DH9v1wgS.js} +1 -1
- package/dist/client/assets/{PdfPreview-BpP2LnX7.js → PdfPreview-CanYnyfJ.js} +1 -1
- package/dist/client/assets/{ProjectNavigationDialogs-B7mxG8Fz.js → ProjectNavigationDialogs-DiXtkTj1.js} +1 -1
- package/dist/client/assets/{SystemMetricsDialog-BK_K3ah8.js → SystemMetricsDialog-DimBI7er.js} +1 -1
- package/dist/client/assets/index-CdiutUgf.css +1 -0
- package/dist/client/assets/index-vpWlpUOr.js +64 -0
- package/dist/client/assets/{minus-BwzSZenU.js → minus-DR-ttrx3.js} +1 -1
- package/dist/client/assets/spellCheck-Wn7_eF9j.js +1 -0
- package/dist/client/index.html +2 -2
- package/dist/server/app.js +3 -3
- package/dist/server/harper.js +210 -119
- 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/package.json +4 -2
- 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
|
@@ -1 +1 @@
|
|
|
1
|
-
import{j as e}from"./index-
|
|
1
|
+
import{j as e}from"./index-vpWlpUOr.js";const n=e("Maximize2",[["polyline",{points:"15 3 21 3 21 9",key:"mznyad"}],["polyline",{points:"9 21 3 21 3 15",key:"1avn1i"}],["line",{x1:"21",x2:"14",y1:"3",y2:"10",key:"ota7mn"}],["line",{x1:"3",x2:"10",y1:"21",y2:"14",key:"1atl0r"}]]);const a=e("Minus",[["path",{d:"M5 12h14",key:"1ays0h"}]]);export{a as M,n as a};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{a as k}from"./index-vpWlpUOr.js";import"./react-BoEjKzFZ.js";const S=5,u=new Map,x=12;let p=!1,f=null;class h extends Error{constructor(){super("A newer writing check replaced this request."),this.name="HarperLintSupersededError"}}function A(t){return t instanceof h}function v(t){const e=[0];let n=0;for(const r of t)n+=r.length,e.push(n);return e}function L(t){return[...new Set(t.map(e=>e.trim()).filter(Boolean))].sort((e,n)=>e.localeCompare(n)).join("\0")}async function C(t,e,n){return(await k(`/api/projects/${t}/spellcheck`,{method:"POST",body:JSON.stringify({path:e,source:n})})).lints}function R(t,e,n){if(!e.problem)return null;const r={from:e.start,to:e.end},o={from:n[e.start]??-1,to:n[e.end]??-1},i=c=>c.from>=0&&c.to>c.from&&c.to<=t.length,a=c=>i(c)&&t.slice(c.from,c.to)===e.problem;return a(o)?o:a(r)?r:null}function $(t,e,n){const r=R(t,e,n);if(!r)return null;const{from:o,to:i}=r,a=t.slice(o,i);if(!a.trim()||!/[A-Za-z]/.test(a)||e.kind==="Formatting")return null;const c=e.kind==="Spelling"||e.kind==="Typo"?"spelling":"grammar",m=e.suggestions.filter((d,s,l)=>s<S&&l.indexOf(d)===s);return{from:o,to:i,word:a,kind:c,message:e.message,suggestions:m}}function g(t,e){let n=2166136261;for(let r=0;r<e.length;r+=1)n^=e.charCodeAt(r),n=Math.imul(n,16777619);return`${t}\0${e.length}:${n>>>0}`}function y(t,e,n){const r=g(t,e),o=u.get(r);return!o||o.path!==t||o.source!==e||o.dictionary!==n?null:(u.delete(r),u.set(r,o),o.issues)}function j(t,e,n,r){const o=g(t,e);for(u.set(o,{path:t,source:e,dictionary:n,issues:r});u.size>x;){const i=u.keys().next().value;if(i===void 0)break;u.delete(i)}}async function q(t,e,n,r="main.tex"){const o=L(e),i=y(r,t,o);if(i)return i;const a=v(t),c=new Set(e.map(s=>s.trim().toLocaleLowerCase("en-US")).filter(Boolean)),m=new Set,d=n.map(s=>$(t,s,a)).filter(s=>!!s).filter(s=>s.kind!=="spelling"||!c.has(s.word.trim().toLocaleLowerCase("en-US"))).filter(s=>{const l=`${s.from}:${s.to}:${s.kind}`;return m.has(l)?!1:(m.add(l),!0)}).sort((s,l)=>s.from-l.from||s.to-l.to);return j(r,t,o,d),d}async function b(t,e,n,r){const o=L(r),i=y(e,n,o);if(i)return i;const a=await C(t,e,n);return q(n,r,a,e)}async function w(){if(p||!f)return;const t=f;f=null,p=!0;try{t.resolve(await b(t.projectId,t.path,t.source,t.customWords))}catch(e){t.reject(e)}finally{p=!1,w()}}function B(t,e,n,r=[]){return new Promise((o,i)=>{f?.reject(new h),f={projectId:t,path:e,source:n,customWords:[...r],resolve:o,reject:i},w()})}export{h as HarperLintSupersededError,A as isHarperLintSupersededError,B as lintLatex,q as mapLatexLints};
|
package/dist/client/index.html
CHANGED
|
@@ -10,9 +10,9 @@
|
|
|
10
10
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fontsource/iosevka@5.2.5/400.css" />
|
|
11
11
|
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
|
12
12
|
<title>TexLite</title>
|
|
13
|
-
<script type="module" crossorigin src="/assets/index-
|
|
13
|
+
<script type="module" crossorigin src="/assets/index-vpWlpUOr.js"></script>
|
|
14
14
|
<link rel="modulepreload" crossorigin href="/assets/react-BoEjKzFZ.js">
|
|
15
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
15
|
+
<link rel="stylesheet" crossorigin href="/assets/index-CdiutUgf.css">
|
|
16
16
|
</head>
|
|
17
17
|
<body>
|
|
18
18
|
<div id="root"></div>
|
package/dist/server/app.js
CHANGED
|
@@ -50,9 +50,9 @@ export async function buildApp(config, db, options = {}) {
|
|
|
50
50
|
const latexCompletions = new LatexCompletionService(config);
|
|
51
51
|
const projectOutlines = new ProjectOutlineService(config);
|
|
52
52
|
const harper = new HarperService();
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
void harper.preload().catch((error) => app.log.
|
|
53
|
+
// Probe the optional host Harper CLI without delaying startup. Its absence is
|
|
54
|
+
// supported: the browser spellchecker remains the writing-check fallback.
|
|
55
|
+
void harper.preload().catch((error) => app.log.info({ err: error }, "Optional Harper CLI is unavailable"));
|
|
56
56
|
const recordHistory = (projectId, userId, reason, paths) => {
|
|
57
57
|
try {
|
|
58
58
|
return history.record(projectId, userId, reason, paths);
|
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
|
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
// This intentionally recognizes only the parts of LaTeX that are clearly not
|
|
2
|
+
// prose. It is a single-pass lexer, not a parser: malformed delimiters remain
|
|
3
|
+
// narrow, so they never hide the rest of a document or cause backtracking.
|
|
4
|
+
const nonProseEnvironments = new Set([
|
|
5
|
+
"math", "displaymath", "equation", "equation*", "align", "align*", "alignat", "alignat*", "gather", "gather*", "multline", "multline*", "flalign", "flalign*",
|
|
6
|
+
"tikzpicture", "axis", "scope", "pgfonlayer", "pgfpicture",
|
|
7
|
+
"tabular", "tabularx", "tabulary", "longtable", "array", "matrix", "pmatrix", "bmatrix", "vmatrix",
|
|
8
|
+
"verbatim", "lstlisting", "minted", "comment", "algorithmic"
|
|
9
|
+
]);
|
|
10
|
+
const opaqueArgumentCommands = new Set([
|
|
11
|
+
"documentclass", "usepackage", "requirepackage", "includegraphics", "tikzset", "pgfplotsset", "hypersetup", "lstset",
|
|
12
|
+
"label", "hypertarget", "ref", "pageref", "autoref", "nameref", "hyperref", "index", "gls", "glspl",
|
|
13
|
+
"bibliography", "addbibresource", "input", "include", "url", "path", "setlength", "setcounter", "color", "textcolor", "colorbox", "pagecolor"
|
|
14
|
+
]);
|
|
15
|
+
function addRange(ranges, from, to) {
|
|
16
|
+
if (to > from)
|
|
17
|
+
ranges.push({ from, to });
|
|
18
|
+
}
|
|
19
|
+
function isWhitespace(character) {
|
|
20
|
+
return character === " " || character === "\t" || character === "\r" || character === "\n";
|
|
21
|
+
}
|
|
22
|
+
function isCommandNameCharacter(character) {
|
|
23
|
+
if (!character)
|
|
24
|
+
return false;
|
|
25
|
+
const code = character.charCodeAt(0);
|
|
26
|
+
return (code >= 65 && code <= 90) || (code >= 97 && code <= 122) || (code >= 48 && code <= 57)
|
|
27
|
+
|| character === "@" || character === ":" || character === "_";
|
|
28
|
+
}
|
|
29
|
+
function balancedArgument(source, start, open, close) {
|
|
30
|
+
if (source[start] !== open)
|
|
31
|
+
return null;
|
|
32
|
+
let depth = 1;
|
|
33
|
+
for (let index = start + 1; index < source.length; index += 1) {
|
|
34
|
+
if (source[index] === "\\") {
|
|
35
|
+
index += 1;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (source[index] === open)
|
|
39
|
+
depth += 1;
|
|
40
|
+
else if (source[index] === close && --depth === 0)
|
|
41
|
+
return { from: start, to: index + 1 };
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
function skipWhitespace(source, start) {
|
|
46
|
+
let index = start;
|
|
47
|
+
while (index < source.length && isWhitespace(source[index]))
|
|
48
|
+
index += 1;
|
|
49
|
+
return index;
|
|
50
|
+
}
|
|
51
|
+
function readCommand(source, start) {
|
|
52
|
+
let end = start + 1;
|
|
53
|
+
if (isCommandNameCharacter(source[end]))
|
|
54
|
+
while (isCommandNameCharacter(source[end]))
|
|
55
|
+
end += 1;
|
|
56
|
+
else if (end < source.length)
|
|
57
|
+
end += 1;
|
|
58
|
+
const name = source.slice(start + 1, end);
|
|
59
|
+
if (source[end] === "*")
|
|
60
|
+
end += 1;
|
|
61
|
+
return { name, end };
|
|
62
|
+
}
|
|
63
|
+
function isCitationCommand(name) {
|
|
64
|
+
const normalized = name.toLocaleLowerCase("en-US");
|
|
65
|
+
return normalized === "cite" || normalized.startsWith("cite") || normalized.endsWith("cite");
|
|
66
|
+
}
|
|
67
|
+
function masksOpaqueArguments(name) {
|
|
68
|
+
const normalized = name.toLocaleLowerCase("en-US");
|
|
69
|
+
return opaqueArgumentCommands.has(normalized) || isCitationCommand(normalized) || normalized === "definecolor" || normalized === "colorlet" || normalized === "href";
|
|
70
|
+
}
|
|
71
|
+
function maskOpaqueArguments(source, start, name, ranges) {
|
|
72
|
+
let cursor = start;
|
|
73
|
+
const normalized = name.toLocaleLowerCase("en-US");
|
|
74
|
+
const count = normalized === "definecolor" || normalized === "colorlet" ? 3 : 1;
|
|
75
|
+
for (let index = 0; index < count; index += 1) {
|
|
76
|
+
const argument = balancedArgument(source, cursor, "{", "}");
|
|
77
|
+
if (!argument)
|
|
78
|
+
break;
|
|
79
|
+
addRange(ranges, argument.from, argument.to);
|
|
80
|
+
cursor = skipWhitespace(source, argument.to);
|
|
81
|
+
}
|
|
82
|
+
return cursor;
|
|
83
|
+
}
|
|
84
|
+
function rawUrlEnd(source, start) {
|
|
85
|
+
const first = source[start];
|
|
86
|
+
if (first !== "h" && first !== "H" && first !== "f" && first !== "F")
|
|
87
|
+
return null;
|
|
88
|
+
for (const prefix of ["http://", "https://", "ftp://"]) {
|
|
89
|
+
if (source.slice(start, start + prefix.length).toLowerCase() !== prefix)
|
|
90
|
+
continue;
|
|
91
|
+
let end = start + prefix.length;
|
|
92
|
+
while (end < source.length && !isWhitespace(source[end]))
|
|
93
|
+
end += 1;
|
|
94
|
+
return end;
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
function mergeOrderedRanges(ranges) {
|
|
99
|
+
const merged = [];
|
|
100
|
+
for (const range of ranges) {
|
|
101
|
+
const previous = merged.at(-1);
|
|
102
|
+
if (previous && range.from <= previous.to)
|
|
103
|
+
previous.to = Math.max(previous.to, range.to);
|
|
104
|
+
else
|
|
105
|
+
merged.push({ ...range });
|
|
106
|
+
}
|
|
107
|
+
return merged;
|
|
108
|
+
}
|
|
109
|
+
/** Return ordered source spans that should not be sent to a prose checker. */
|
|
110
|
+
export function ignoredLatexRanges(source) {
|
|
111
|
+
const ranges = [];
|
|
112
|
+
// Each non-prose environment installs its span at \begin and extends it at
|
|
113
|
+
// \end. This preserves source order, so final merging stays linear.
|
|
114
|
+
const nonProseStack = [];
|
|
115
|
+
// The same technique keeps unmatched $ / \[ delimiters cheap and local.
|
|
116
|
+
let pendingMath = null;
|
|
117
|
+
for (let index = 0; index < source.length;) {
|
|
118
|
+
if (pendingMath?.close.startsWith("\\") && source.startsWith(pendingMath.close, index)) {
|
|
119
|
+
pendingMath.range.to = index + pendingMath.close.length;
|
|
120
|
+
index += pendingMath.close.length;
|
|
121
|
+
pendingMath = null;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (source[index] === "%") {
|
|
125
|
+
const end = source.indexOf("\n", index);
|
|
126
|
+
addRange(ranges, index, end < 0 ? source.length : end);
|
|
127
|
+
index = end < 0 ? source.length : end + 1;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (source[index] === "$") {
|
|
131
|
+
if (pendingMath) {
|
|
132
|
+
if (pendingMath.close === "$" || (pendingMath.close === "$$" && source.startsWith("$$", index))) {
|
|
133
|
+
pendingMath.range.to = index + pendingMath.close.length;
|
|
134
|
+
index += pendingMath.close.length;
|
|
135
|
+
pendingMath = null;
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
index += 1;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
const delimiter = source[index + 1] === "$" ? "$$" : "$";
|
|
143
|
+
const range = { from: index, to: index + delimiter.length };
|
|
144
|
+
ranges.push(range);
|
|
145
|
+
pendingMath = { close: delimiter, range };
|
|
146
|
+
index += delimiter.length;
|
|
147
|
+
}
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
const urlEnd = rawUrlEnd(source, index);
|
|
151
|
+
if (urlEnd !== null) {
|
|
152
|
+
addRange(ranges, index, urlEnd);
|
|
153
|
+
index = urlEnd;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (source[index] !== "\\") {
|
|
157
|
+
index += 1;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
const commandStart = index;
|
|
161
|
+
if (source[index + 1] === "(" || source[index + 1] === "[") {
|
|
162
|
+
if (pendingMath) {
|
|
163
|
+
addRange(ranges, commandStart, index + 2);
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
const range = { from: commandStart, to: index + 2 };
|
|
167
|
+
ranges.push(range);
|
|
168
|
+
pendingMath = { close: source[index + 1] === "(" ? "\\)" : "\\]", range };
|
|
169
|
+
}
|
|
170
|
+
index += 2;
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
const command = readCommand(source, commandStart);
|
|
174
|
+
let cursor = skipWhitespace(source, command.end);
|
|
175
|
+
if (command.name === "verb") {
|
|
176
|
+
addRange(ranges, commandStart, command.end);
|
|
177
|
+
const delimiter = source[cursor];
|
|
178
|
+
if (delimiter && !isWhitespace(delimiter)) {
|
|
179
|
+
const end = source.indexOf(delimiter, cursor + 1);
|
|
180
|
+
if (end >= 0) {
|
|
181
|
+
addRange(ranges, cursor, end + 1);
|
|
182
|
+
cursor = end + 1;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
index = Math.max(command.end, cursor);
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
if (command.name === "begin" || command.name === "end") {
|
|
189
|
+
const environment = balancedArgument(source, cursor, "{", "}");
|
|
190
|
+
if (!environment) {
|
|
191
|
+
addRange(ranges, commandStart, command.end);
|
|
192
|
+
index = command.end;
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
const environmentName = source.slice(environment.from + 1, environment.to - 1).trim().toLocaleLowerCase("en-US");
|
|
196
|
+
cursor = skipWhitespace(source, environment.to);
|
|
197
|
+
let options = null;
|
|
198
|
+
if (command.name === "begin") {
|
|
199
|
+
options = balancedArgument(source, cursor, "[", "]");
|
|
200
|
+
if (options)
|
|
201
|
+
cursor = skipWhitespace(source, options.to);
|
|
202
|
+
if (nonProseEnvironments.has(environmentName)) {
|
|
203
|
+
const range = { from: commandStart, to: cursor };
|
|
204
|
+
ranges.push(range);
|
|
205
|
+
nonProseStack.push({ name: environmentName, range });
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
addRange(ranges, commandStart, command.end);
|
|
209
|
+
addRange(ranges, environment.from, environment.to);
|
|
210
|
+
if (options)
|
|
211
|
+
addRange(ranges, options.from, options.to);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
const open = nonProseStack.at(-1);
|
|
216
|
+
if (open?.name === environmentName) {
|
|
217
|
+
nonProseStack.pop();
|
|
218
|
+
open.range.to = cursor;
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
addRange(ranges, commandStart, command.end);
|
|
222
|
+
addRange(ranges, environment.from, environment.to);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
index = Math.max(command.end, cursor);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
addRange(ranges, commandStart, command.end);
|
|
229
|
+
const options = balancedArgument(source, cursor, "[", "]");
|
|
230
|
+
if (options) {
|
|
231
|
+
addRange(ranges, options.from, options.to);
|
|
232
|
+
cursor = skipWhitespace(source, options.to);
|
|
233
|
+
}
|
|
234
|
+
if (masksOpaqueArguments(command.name))
|
|
235
|
+
cursor = maskOpaqueArguments(source, cursor, command.name, ranges);
|
|
236
|
+
index = Math.max(command.end, cursor);
|
|
237
|
+
}
|
|
238
|
+
return mergeOrderedRanges(ranges);
|
|
239
|
+
}
|
|
240
|
+
/** Replace syntax with spaces while retaining Unicode-scalar offsets and line breaks. */
|
|
241
|
+
export function maskLatexSource(source) {
|
|
242
|
+
const ranges = ignoredLatexRanges(source);
|
|
243
|
+
const chars = [];
|
|
244
|
+
let offset = 0;
|
|
245
|
+
let rangeIndex = 0;
|
|
246
|
+
for (const character of source) {
|
|
247
|
+
while (rangeIndex < ranges.length && ranges[rangeIndex].to <= offset)
|
|
248
|
+
rangeIndex += 1;
|
|
249
|
+
const range = ranges[rangeIndex];
|
|
250
|
+
const masked = range && range.from <= offset && offset < range.to && character !== "\n" && character !== "\r";
|
|
251
|
+
// `harper-cli` reports Unicode scalar positions. One space for every
|
|
252
|
+
// source scalar keeps diagnostics aligned after masked astral characters.
|
|
253
|
+
chars.push(masked ? " " : character);
|
|
254
|
+
offset += character.length;
|
|
255
|
+
}
|
|
256
|
+
return chars.join("");
|
|
257
|
+
}
|