Repository Public Research preview
1#!/usr/bin/env node2/**3 * Command-line front end of the LaTeX QA engine (no dependencies).4 * Works inside LATEX_MCP_ROOT (default: the current directory). Prints JSON.5 * Exit codes: 0 = done and the check passed, 1 = the check failed, 2 = usage or tool error.6 */7import { Latex, explainError } from "./lib/engine.mjs";89const USAGE = `Usage: node latex.mjs <command> [file.tex] [options]1011 status installed tools, engines, root12 qa <file> whole procedure: clean build, log, overflow, references, bibliography; one verdict13 compile <file> build to PDF --engine pdflatex|xelatex|lualatex --clean --keep-going --driver builtin14 log <file> parsed log of the last build15 overflow <file> overfull boxes; overflowing Beamer frames --hbox <pt> --vbox <pt>16 beamer <file> frame-by-frame report of a deck17 refs <file> labels, references, citations (static, no build needed)18 bib <file> bibliography backend and its log19 count <file> word count20 lint <file> chktex, curated rules --all21 numbering <file> numbers of labels, captions, citations from the .aux22 docx <file> LaTeX → Word with resolved numbers --out <path.docx> --overwrite --csl <file>23 text <file> text of the PDF --from <page> --to <page>24 explain "<error message>" cause and fix of an error25 clean <file> --yes remove the build directory made by this tool2627Common options: --build-dir <dir> --timeout <seconds>28Shell escape is always off. Paths must stay inside LATEX_MCP_ROOT (default: current directory).`;2930const argv = process.argv.slice(2);31const cmd = argv.shift();32const opts = {}, pos = [];33const FLAGS = new Set(["clean", "keep-going", "all", "overwrite", "yes", "help"]);34for (let i = 0; i < argv.length; i++) {35 const a = argv[i];36 if (a.startsWith("--")) { const k = a.slice(2); if (FLAGS.has(k)) opts[k] = true; else opts[k] = argv[++i]; }37 else pos.push(a);38}39const out = (data, code = 0) => { process.stdout.write(JSON.stringify(data, null, 2) + "\n"); process.exitCode = code; };40const num = (v) => (v == null ? undefined : Number(v));4142if (!cmd || cmd === "help" || cmd === "--help" || opts.help) { process.stdout.write(USAGE + "\n"); process.exitCode = cmd ? 0 : 2; }43else {44 try {45 if (cmd === "explain") {46 if (!pos[0]) throw Object.assign(new Error("explain needs the error message"), { code: "usage" });47 out({ message: pos.join(" "), ...explainError(pos.join(" ")) });48 } else {49 const L = new Latex(process.env);50 const base = { file: pos[0], build_dir: opts["build-dir"] };51 const needFile = () => { if (!pos[0]) throw Object.assign(new Error(`${cmd} needs a .tex file`), { code: "usage" }); };52 const compileArgs = { ...base, engine: opts.engine, clean: !!opts.clean, halt_on_error: !opts["keep-going"], driver: opts.driver, timeout_s: num(opts.timeout) };53 switch (cmd) {54 case "status": out(await L.toolchainStatus()); break;55 case "compile": { needFile(); const r = await L.compile(compileArgs); out(r, r.success ? 0 : 1); break; }56 case "log": { needFile(); const r = L.parseLogFile(base); out(r, r.summary.errors ? 1 : 0); break; }57 case "overflow": { needFile(); const r = L.checkOverflow({ ...base, hbox_threshold_pt: num(opts.hbox), vbox_threshold_pt: num(opts.vbox) }); out(r, r.passed && !(r.beamer?.overflowing_frames.length) ? 0 : 1); break; }58 case "beamer": { needFile(); const r = L.beamerReport({ ...base, hbox_threshold_pt: num(opts.hbox), vbox_threshold_pt: num(opts.vbox) }); out(r, r.overflowing_frames.length ? 1 : 0); break; }59 case "refs": { needFile(); const r = L.auditReferences(base); out(r, r.passed ? 0 : 1); break; }60 case "bib": { needFile(); const r = L.bibAudit(base); out(r, r.passed || r.expected_backend == null ? 0 : 1); break; }61 case "count": needFile(); out(await L.wordCount(base)); break;62 case "lint": needFile(); out(await L.lint({ ...base, all: !!opts.all })); break;63 case "numbering": needFile(); out(L.extractNumbering(base)); break;64 case "docx": { needFile(); const r = await L.toDocx({ ...base, output: opts.out, overwrite: !!opts.overwrite, csl: opts.csl }); out(r, r.unresolved_labels.length ? 1 : 0); break; }65 case "text": needFile(); out(await L.pdfText({ ...base, first_page: num(opts.from), last_page: num(opts.to) })); break;66 case "clean": needFile(); out(L.cleanBuild({ ...base, confirm: !!opts.yes })); break;67 case "qa": {68 needFile();69 const build = await L.compile({ ...compileArgs, clean: true });70 const checks = [{ check: "clean build from scratch", passed: build.success, detail: build.success ? `${build.pages} pages` : build.errors.map((e) => `${e.file ?? "?"}:${e.line ?? "?"} ${e.message}`).slice(0, 5) }];71 let overflow = null, refs = null, bib = null;72 if (build.log) {73 const s = build.summary;74 checks.push({ check: "no undefined reference or citation in the log", passed: s.undefined_references + s.undefined_citations === 0, detail: [...build.undefined_references.map((r) => `ref ${r.label} (${r.file}:${r.line})`), ...build.undefined_citations.map((r) => `cite ${r.key} (${r.file}:${r.line})`)] });75 checks.push({ check: "no multiply defined label", passed: s.duplicate_labels === 0, detail: build.duplicate_labels.map((d) => d.label) });76 checks.push({ check: "no rerun pending", passed: !s.rerun_needed, detail: [] });77 overflow = L.checkOverflow({ ...base, hbox_threshold_pt: num(opts.hbox), vbox_threshold_pt: num(opts.vbox) });78 checks.push({ check: `no overfull box above ${overflow.thresholds_pt.hbox} pt`, passed: overflow.passed, detail: [...overflow.overfull_hbox, ...overflow.overfull_vbox].slice(0, 10).map((b) => `${b.box} ${b.amount_pt} pt at ${b.file ?? "?"}:${b.line_start ?? "?"}`) });79 if (overflow.beamer) checks.push({ check: "no Beamer frame overflows", passed: overflow.beamer.overflowing_frames.length === 0, detail: overflow.beamer.overflowing_frames.map((f) => `frame ${f.number} "${f.title ?? "untitled"}" (${f.file}:${f.line}) +${f.overflow_pt} pt`) });80 }81 refs = L.auditReferences(base);82 checks.push({ check: "every float is referenced", passed: refs.unreferenced_floats.length === 0, detail: refs.unreferenced_floats.map((f) => `${f.label} (${f.file}:${f.line})`) });83 checks.push({ check: "no duplicate label in the sources", passed: refs.duplicate_labels.length === 0, detail: refs.duplicate_labels.map((d) => `${d.label}: ${d.locations.map((l) => `${l.file}:${l.line}`).join(", ")}`) });84 bib = L.bibAudit(base);85 if (bib.expected_backend) checks.push({ check: `bibliography (${bib.expected_backend}) ran without missing entry`, passed: bib.passed, detail: [...(bib.missing_entries ?? []), ...(bib.backend_mismatch ? [bib.backend_mismatch] : []), ...(bib.note ? [bib.note] : [])] });86 const passed = checks.every((c) => c.passed);87 out({ file: pos[0], passed, checks, pdf: build.pdf, pages: build.pages, engine: build.engine, shell_commands_blocked: build.shell_commands_blocked, advisory: { unused_labels: refs.unused_labels.length, unused_bib_entries: refs.unused_bib_entries.map((e) => e.key), incomplete_bib_entries: bib.incomplete_entries, underfull_boxes: overflow?.underfull_count ?? null } }, passed ? 0 : 1);88 break;89 }90 default: process.stderr.write(`Unknown command: ${cmd}\n\n${USAGE}\n`); process.exitCode = 2;91 }92 }93 } catch (e) {94 process.stdout.write(JSON.stringify({ error: { code: e.code ?? "error", message: String(e.message ?? e) } }, null, 2) + "\n");95 process.exitCode = 2;96 }97}98