texlite 0.7.7 → 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.
Files changed (30) hide show
  1. package/DESIGN.md +460 -0
  2. package/NPM_TESTING.md +130 -0
  3. package/OPERATIONS.md +246 -0
  4. package/README.md +78 -306
  5. package/README.zh-CN.md +61 -330
  6. package/dist/client/assets/{GitDialog-Bm5yMpQK.js → GitDialog-DdLrbz_H.js} +1 -1
  7. package/dist/client/assets/{HistoryDialog-Po4KHT_2.js → HistoryDialog-AlF9iozX.js} +1 -1
  8. package/dist/client/assets/{LatexEditor-CnKCXaHr.js → LatexEditor-DH9v1wgS.js} +22 -22
  9. package/dist/client/assets/{PdfPreview-Cd20VIjT.js → PdfPreview-CanYnyfJ.js} +1 -1
  10. package/dist/client/assets/ProjectNavigationDialogs-DiXtkTj1.js +1 -0
  11. package/dist/client/assets/{SystemMetricsDialog-DdOlcqqK.js → SystemMetricsDialog-DimBI7er.js} +1 -1
  12. package/dist/client/assets/index-CdiutUgf.css +1 -0
  13. package/dist/client/assets/index-vpWlpUOr.js +64 -0
  14. package/dist/client/assets/{minus-O6pK-6mA.js → minus-DR-ttrx3.js} +1 -1
  15. package/dist/client/assets/spellCheck-Wn7_eF9j.js +1 -0
  16. package/dist/client/index.html +2 -2
  17. package/dist/server/app.js +3 -3
  18. package/dist/server/compiler.js +139 -10
  19. package/dist/server/harper.js +210 -119
  20. package/dist/server/latexSpellMask.js +257 -0
  21. package/dist/server/projects.js +0 -12
  22. package/dist/server/routes/compile.js +54 -41
  23. package/dist/server/routes/projectFiles.js +11 -0
  24. package/dist/server/routes/projectShared.js +1 -1
  25. package/dist/server/routes/projects.js +20 -19
  26. package/package.json +4 -2
  27. package/dist/client/assets/ProjectNavigationDialogs-BDOtIoTy.js +0 -1
  28. package/dist/client/assets/index-MDwr9W9R.css +0 -1
  29. package/dist/client/assets/index-zIjCeZFK.js +0 -64
  30. package/dist/client/assets/spellCheck-Ce3o_xO-.js +0 -4
@@ -1 +1 @@
1
- import{j as e}from"./index-zIjCeZFK.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};
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};
@@ -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-zIjCeZFK.js"></script>
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-MDwr9W9R.css">
15
+ <link rel="stylesheet" crossorigin href="/assets/index-CdiutUgf.css">
16
16
  </head>
17
17
  <body>
18
18
  <div id="root"></div>
@@ -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
- // Warm the single server-side runtime without delaying startup. The first
54
- // request shares this promise if initialization is still in progress.
55
- void harper.preload().catch((error) => app.log.warn({ err: error }, "Harper could not be initialized"));
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);
@@ -9,6 +9,13 @@ import { gunzipSync, gzipSync } from "node:zlib";
9
9
  import { listProjectFilesAsync, outputRoot, resolveSourcePath, safeRelativePath, texFileStem } from "./files.js";
10
10
  import { parseCompileDiagnostics } from "./compileDiagnostics.js";
11
11
  import { detachedProcessGroup, killProcessGroup } from "./processTree.js";
12
+ /** A controlled stop is a normal compile outcome, never a server error. */
13
+ export class CompileCancelledError extends Error {
14
+ constructor() {
15
+ super("Compilation was cancelled.");
16
+ this.name = "CompileCancelledError";
17
+ }
18
+ }
12
19
  export class CompileQueue {
13
20
  concurrency;
14
21
  running = 0;
@@ -16,16 +23,39 @@ export class CompileQueue {
16
23
  constructor(concurrency) {
17
24
  this.concurrency = concurrency;
18
25
  }
19
- add(job) {
26
+ add(job, signal) {
20
27
  return new Promise((resolve, reject) => {
21
- this.pending.push(async () => {
28
+ let started = false;
29
+ const cancelPending = () => {
30
+ if (started)
31
+ return;
32
+ const index = this.pending.indexOf(task);
33
+ if (index < 0)
34
+ return;
35
+ this.pending.splice(index, 1);
36
+ reject(new CompileCancelledError());
37
+ this.drain();
38
+ };
39
+ const task = async () => {
40
+ started = true;
41
+ signal?.removeEventListener("abort", cancelPending);
42
+ if (signal?.aborted) {
43
+ reject(new CompileCancelledError());
44
+ return;
45
+ }
22
46
  try {
23
47
  resolve(await job());
24
48
  }
25
49
  catch (error) {
26
50
  reject(error);
27
51
  }
28
- });
52
+ };
53
+ if (signal?.aborted) {
54
+ reject(new CompileCancelledError());
55
+ return;
56
+ }
57
+ this.pending.push(task);
58
+ signal?.addEventListener("abort", cancelPending, { once: true });
29
59
  this.drain();
30
60
  });
31
61
  }
@@ -94,8 +124,42 @@ export class ProjectCompileCoordinator {
94
124
  }
95
125
  return managed.promise;
96
126
  }
127
+ /**
128
+ * Stop the active target and discard any newer pending request for it. A
129
+ * target has one writer-visible compile at a time, so cancelling the whole
130
+ * target avoids immediately starting a stale follow-up compilation.
131
+ */
132
+ cancel(projectId, target) {
133
+ const targetKey = `${projectId}\0${target ?? ""}`;
134
+ const state = this.targets.get(targetKey);
135
+ const active = state?.active;
136
+ if (!active)
137
+ return null;
138
+ const status = active.started ? "running" : "queued";
139
+ active.controller.abort();
140
+ if (state?.pending) {
141
+ const pending = state.pending;
142
+ state.pending = null;
143
+ pending.controller.abort();
144
+ pending.input.onDiscarded("cancelled");
145
+ pending.resolve(cancelledCompileResult(pending.input));
146
+ }
147
+ return { runId: active.input.runId, status };
148
+ }
97
149
  start(targetKey, state, job) {
98
- void this.queue.add(job.input.execute).then(job.resolve, job.reject).finally(() => {
150
+ void this.queue.add(async () => {
151
+ job.started = true;
152
+ if (job.controller.signal.aborted)
153
+ throw new CompileCancelledError();
154
+ return await job.input.execute(job.controller.signal);
155
+ }, job.controller.signal).then(job.resolve, (error) => {
156
+ if (error instanceof CompileCancelledError) {
157
+ job.input.onCancelled?.();
158
+ job.resolve(cancelledCompileResult(job.input));
159
+ return;
160
+ }
161
+ job.reject(error);
162
+ }).finally(() => {
99
163
  if (state.active !== job)
100
164
  return;
101
165
  state.active = null;
@@ -122,7 +186,19 @@ function deferredJob(input) {
122
186
  resolve = resolvePromise;
123
187
  reject = rejectPromise;
124
188
  });
125
- return { input, promise, resolve, reject };
189
+ return { input, promise, resolve, reject, controller: new AbortController(), started: false };
190
+ }
191
+ function cancelledCompileResult(input) {
192
+ return {
193
+ runId: input.runId,
194
+ revision: input.revision,
195
+ ok: false,
196
+ cancelled: true,
197
+ log: new CompileCancelledError().message,
198
+ diagnostics: { warnings: [], errors: [] },
199
+ pdfPath: null,
200
+ synctexPath: null
201
+ };
126
202
  }
127
203
  export async function captureCompileSnapshot(config, projectId, runId, settings) {
128
204
  const root = compileRunRoot(config, projectId, runId);
@@ -532,7 +608,10 @@ function rewriteSynctexSource(synctexPath, cacheSource, snapshotSource) {
532
608
  if (rewritten !== original)
533
609
  fs.writeFileSync(synctexPath, gzipSync(Buffer.from(rewritten)), { mode: 0o600 });
534
610
  }
535
- export async function compileProject(config, snapshot, mainFileInput, engine, latexmkrcInput) {
611
+ export async function compileProject(config, snapshot, mainFileInput, engine, latexmkrcInput, options = {}) {
612
+ const { signal } = options;
613
+ if (signal?.aborted)
614
+ return cancelledCompileResultForProject();
536
615
  const mainFile = safeRelativePath(mainFileInput);
537
616
  if (!/\.tex$/i.test(mainFile))
538
617
  throw new Error("The main file must have a .tex extension");
@@ -548,6 +627,10 @@ export async function compileProject(config, snapshot, mainFileInput, engine, la
548
627
  const cacheStartedAt = performance.now();
549
628
  const cache = prepareCompileCache(config, snapshot, mainFile, engine, latexmkrc);
550
629
  const cacheSyncMs = performance.now() - cacheStartedAt;
630
+ if (signal?.aborted)
631
+ return cancelledCompileResultForProject({
632
+ cacheSyncMs, latexmkMs: 0, artifactCopyMs: 0, totalMs: performance.now() - startedAt
633
+ });
551
634
  const cwd = cache.sourceDir;
552
635
  const outDir = cache.outputDir;
553
636
  const engineFlag = engine === "xelatex" ? "-xelatex" : engine === "lualatex" ? "-lualatex" : "-pdf";
@@ -578,6 +661,7 @@ export async function compileProject(config, snapshot, mainFileInput, engine, la
578
661
  stdio: ["ignore", "pipe", "pipe"]
579
662
  });
580
663
  let log = "";
664
+ let cancelled = false;
581
665
  const append = (chunk) => {
582
666
  log += chunk.toString("utf8");
583
667
  if (log.length > 2_000_000)
@@ -585,19 +669,46 @@ export async function compileProject(config, snapshot, mainFileInput, engine, la
585
669
  };
586
670
  child.stdout.on("data", append);
587
671
  child.stderr.on("data", append);
588
- child.on("error", reject);
589
- const timeout = setTimeout(() => {
672
+ let timeout = null;
673
+ const cancel = () => {
674
+ if (cancelled)
675
+ return;
676
+ cancelled = true;
677
+ log += "\nCompilation was cancelled.\n";
678
+ killProcessGroup(child);
679
+ };
680
+ const removeAbortListener = () => signal?.removeEventListener("abort", cancel);
681
+ child.once("error", (error) => {
682
+ if (timeout)
683
+ clearTimeout(timeout);
684
+ removeAbortListener();
685
+ reject(error);
686
+ });
687
+ timeout = setTimeout(() => {
688
+ if (cancelled)
689
+ return;
590
690
  log += `\nCompilation exceeded ${config.compileTimeoutMs / 1000} seconds and was terminated.\n`;
591
691
  killProcessGroup(child);
592
692
  }, config.compileTimeoutMs);
693
+ if (signal?.aborted)
694
+ cancel();
695
+ else
696
+ signal?.addEventListener("abort", cancel, { once: true });
593
697
  child.on("close", (code) => {
594
- clearTimeout(timeout);
595
- resolve({ code, log });
698
+ if (timeout)
699
+ clearTimeout(timeout);
700
+ removeAbortListener();
701
+ resolve({ code, log, cancelled });
596
702
  });
597
703
  });
598
704
  const latexmkMs = performance.now() - latexmkStartedAt;
599
705
  const basename = texFileStem(mainFile);
600
706
  const cachedPdf = path.join(outDir, `${basename}.pdf`);
707
+ if (processResult.cancelled || signal?.aborted) {
708
+ return cancelledCompileResultForProject({
709
+ cacheSyncMs, latexmkMs, artifactCopyMs: 0, totalMs: performance.now() - startedAt
710
+ }, processResult.log);
711
+ }
601
712
  const ok = processResult.code === 0 && fs.existsSync(cachedPdf);
602
713
  const diagnostics = parseCompileDiagnostics(processResult.log, ok ? "succeeded" : "failed");
603
714
  if (!ok) {
@@ -606,6 +717,10 @@ export async function compileProject(config, snapshot, mainFileInput, engine, la
606
717
  timings: { cacheSyncMs, latexmkMs, artifactCopyMs: 0, totalMs: performance.now() - startedAt }
607
718
  };
608
719
  }
720
+ if (signal?.aborted)
721
+ return cancelledCompileResultForProject({
722
+ cacheSyncMs, latexmkMs, artifactCopyMs: 0, totalMs: performance.now() - startedAt
723
+ });
609
724
  const artifactStartedAt = performance.now();
610
725
  const artifacts = materializeCompileArtifacts(cache, snapshot, basename);
611
726
  const artifactCopyMs = performance.now() - artifactStartedAt;
@@ -614,3 +729,17 @@ export async function compileProject(config, snapshot, mainFileInput, engine, la
614
729
  timings: { cacheSyncMs, latexmkMs, artifactCopyMs, totalMs: performance.now() - startedAt }
615
730
  };
616
731
  }
732
+ function cancelledCompileResultForProject(timings, log = "") {
733
+ const message = log.includes("Compilation was cancelled.")
734
+ ? log
735
+ : `${log}${log && !log.endsWith("\n") ? "\n" : ""}Compilation was cancelled.\n`;
736
+ return {
737
+ ok: false,
738
+ cancelled: true,
739
+ log: message,
740
+ diagnostics: parseCompileDiagnostics(message, "failed"),
741
+ pdfPath: null,
742
+ synctexPath: null,
743
+ ...(timings ? { timings } : {})
744
+ };
745
+ }
@@ -1,144 +1,235 @@
1
- import { Worker } from "node:worker_threads";
2
- const setupTimeoutMs = 45_000;
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
- function withTimeout(operation, timeoutMs, message) {
5
- return new Promise((resolve, reject) => {
6
- const timer = setTimeout(() => reject(new Error(message)), timeoutMs);
7
- operation.then((value) => { clearTimeout(timer); resolve(value); }, (error) => { clearTimeout(timer); reject(error); });
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
- * One Harper runtime per TexLite server process. Production uses one Node
12
- * Worker so WASM checks cannot block Fastify or collaboration messages. Source
13
- * mode (tsx/Vitest) uses the same runtime in-process because the emitted worker
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
- useWorker = import.meta.url.endsWith(".js");
18
- linter = null;
19
- setupPromise = null;
20
- worker = null;
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.ensureRuntime();
90
+ await this.ensureAvailable();
26
91
  }
27
- async lint(source) {
28
- const operation = this.queue.then(async () => {
29
- await this.ensureRuntime();
30
- try {
31
- return await withTimeout(this.useWorker ? this.lintInWorker(source) : this.lintInProcess(source), lintTimeoutMs, "Harper writing check timed out.");
32
- }
33
- catch (error) {
34
- if (error instanceof Error && error.message === "Harper writing check timed out.")
35
- this.stopFailedWorker(error);
36
- throw error;
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
- await this.setupPromise?.catch(() => undefined);
45
- const worker = this.worker;
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
- ensureRuntime() {
57
- if (this.setupPromise)
58
- return this.setupPromise;
59
- if (this.worker || this.linter)
60
- return Promise.resolve();
61
- this.setupPromise = withTimeout(this.useWorker ? this.startWorker() : this.startInProcess(), setupTimeoutMs, "Harper initialization timed out.")
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.stopFailedWorker(error instanceof Error ? error : new Error(String(error)));
64
- throw error;
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.setupPromise = null; });
67
- return this.setupPromise;
141
+ .finally(() => { this.probePromise = null; });
142
+ return this.probePromise;
68
143
  }
69
- async startInProcess() {
70
- const { createHarperLinter } = await import("./harperRuntime.js");
71
- this.linter = await createHarperLinter();
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
- startWorker() {
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
- const worker = new Worker(new URL("./harperWorker.js", import.meta.url), { name: "texlite-harper" });
76
- this.worker = worker;
77
- const rejectStart = (error) => reject(error);
78
- const onInitialMessage = (message) => {
79
- if (!("type" in message))
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
- worker.off("message", onInitialMessage);
82
- worker.off("error", rejectStart);
83
- if (message.type === "ready")
84
- resolve();
85
- else
86
- reject(new Error(message.message));
204
+ settled = true;
205
+ if (timer)
206
+ clearTimeout(timer);
207
+ if (this.activeChild === child)
208
+ this.activeChild = null;
209
+ callback();
87
210
  };
88
- worker.on("message", onInitialMessage);
89
- worker.once("error", rejectStart);
90
- worker.on("message", (message) => this.handleWorkerMessage(message));
91
- worker.on("error", (error) => this.stopFailedWorker(error));
92
- worker.on("exit", (code) => {
93
- if (this.worker !== worker)
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
- this.worker = null;
96
- this.rejectPending(new Error(`Harper worker exited unexpectedly with code ${code}.`));
97
- });
98
- });
99
- }
100
- lintInWorker(source) {
101
- const worker = this.worker;
102
- if (!worker)
103
- return Promise.reject(new Error("Harper worker is not ready."));
104
- const id = this.nextRequestId++;
105
- return new Promise((resolve, reject) => {
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
  }