overleaf-forge 2.9.1 → 2.12.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/README.md +91 -42
- package/dependency-index.js +189 -0
- package/efficiency.js +138 -0
- package/overleaf-mcp-server.js +457 -243
- package/package.json +7 -2
- package/render-cache.js +161 -0
- package/runtime-observability.js +109 -0
- package/transactions.js +219 -0
- package/writing-guidelines.md +24 -222
package/efficiency.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { lstat, readFile, readdir, realpath } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
|
|
6
|
+
const hash = value => createHash('sha256').update(value).digest('hex');
|
|
7
|
+
export function versionedContext(key, text, previousVersion) {
|
|
8
|
+
const version = hash(JSON.stringify([key, text]));
|
|
9
|
+
return { version, unchanged: previousVersion === version, text: previousVersion === version ? 'Context unchanged.' : text };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Cache keys include file contents, not mtimes. Hash external recorder inputs too;
|
|
13
|
+
// an edited image, bibliography, package or deleted PDF must invalidate reuse.
|
|
14
|
+
// Controlled mode is safe to cache only when the caller also controls the
|
|
15
|
+
// latexmk invocation. It relaxes the project-config check, while executable
|
|
16
|
+
// TeX features remain ineligible because their dependency closure is opaque.
|
|
17
|
+
export function controlledBuildOptions(options = {}) {
|
|
18
|
+
if (!options || typeof options !== 'object' || Array.isArray(options)) throw new TypeError('build options must be an object');
|
|
19
|
+
const controlled = options.controlled === true;
|
|
20
|
+
const externalInputs = options.externalInputs ?? [];
|
|
21
|
+
if (!Array.isArray(externalInputs) || externalInputs.some(input => typeof input !== 'string' || !path.isAbsolute(input))) {
|
|
22
|
+
throw new TypeError('externalInputs must contain absolute paths');
|
|
23
|
+
}
|
|
24
|
+
return { sourcesOnly: options.sourcesOnly === true, controlled, externalInputs: [...new Set(externalInputs)] };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// The rc files latexmk reads besides the project's own (latexmk(1), CONFIGURATION FILES).
|
|
28
|
+
export function latexmkUserRcFiles(env = process.env, home = os.homedir()) {
|
|
29
|
+
const xdg = env.XDG_CONFIG_HOME || path.join(home, '.config');
|
|
30
|
+
return [
|
|
31
|
+
env.LATEXMKRCSYS,
|
|
32
|
+
path.join(xdg, 'latexmk', 'latexmkrc'),
|
|
33
|
+
path.join(home, '.latexmkrc'),
|
|
34
|
+
].filter(Boolean);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function buildFingerprint(root, entry, engine, options = {}) {
|
|
38
|
+
const { sourcesOnly, controlled, externalInputs } = controlledBuildOptions(options);
|
|
39
|
+
const records = [];
|
|
40
|
+
const job = path.resolve(root, entry.replace(/\.tex$/, ''));
|
|
41
|
+
const generated = new Set(['aux','log','pdf','fls','fdb_latexmk','synctex.gz','toc','out','bbl','bcf','blg','run.xml','lof','lot'].map(e => `${job}.${e}`));
|
|
42
|
+
let unsafe = false;
|
|
43
|
+
const visit = async dir => {
|
|
44
|
+
for (const d of (await readdir(dir, { withFileTypes: true })).sort((a,b)=>a.name.localeCompare(b.name))) {
|
|
45
|
+
if (d.name === '.git') continue;
|
|
46
|
+
const f = path.join(dir,d.name);
|
|
47
|
+
if (d.isSymbolicLink()) { unsafe = true; continue; }
|
|
48
|
+
if (d.isDirectory()) await visit(f);
|
|
49
|
+
else if (d.isFile() && !(sourcesOnly && generated.has(f))) {
|
|
50
|
+
const bytes = await readFile(f);
|
|
51
|
+
records.push([f,hash(bytes)]);
|
|
52
|
+
// Executable config and shell/Lua-driven generation can read dependencies
|
|
53
|
+
// outside the TeX recorder. Rebuild rather than certify an incomplete key.
|
|
54
|
+
if ((!controlled && /latexmkrc$/.test(f)) || (/\.(tex|sty|cls)$/.test(f) && /\\(?:write18|directlua|inputminted)|\\begin\{minted\}/.test(bytes.toString()))) unsafe = true;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
await visit(root);
|
|
59
|
+
// latexmk also executes user and system rc files outside the project. They
|
|
60
|
+
// are arbitrary Perl like a project .latexmkrc, so they get the same rule:
|
|
61
|
+
// their content enters the key, and outside controlled mode (-norc) their
|
|
62
|
+
// presence makes the full cache ineligible.
|
|
63
|
+
if (!controlled) {
|
|
64
|
+
for (const rc of latexmkUserRcFiles()) {
|
|
65
|
+
const bytes = await readFile(rc).catch(() => null);
|
|
66
|
+
if (bytes === null) continue;
|
|
67
|
+
records.push([rc, hash(bytes)]);
|
|
68
|
+
unsafe = true;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
for (const input of externalInputs) {
|
|
72
|
+
const stat = await lstat(input).catch(() => null);
|
|
73
|
+
if (!stat || !stat.isFile() || stat.isSymbolicLink()) throw new Error(`external input must be a regular non-symlink file: ${input}`);
|
|
74
|
+
records.push([input, hash(await readFile(input))]);
|
|
75
|
+
}
|
|
76
|
+
if (unsafe && !sourcesOnly) return null;
|
|
77
|
+
if (!sourcesOnly) {
|
|
78
|
+
const fls = await readFile(`${job}.fls`,'utf8');
|
|
79
|
+
const inputs = new Set(fls.split(/\r?\n/).filter(l=>l.startsWith('INPUT ')).map(l=>path.resolve(root,l.slice(6))));
|
|
80
|
+
if (!inputs.size) return null;
|
|
81
|
+
for (const f of [...inputs].sort()) records.push([await realpath(f),hash(await readFile(f))]);
|
|
82
|
+
for (const tool of ['latexmk',engine]) {
|
|
83
|
+
const f = await realpath(`/Library/TeX/texbin/${tool}`);
|
|
84
|
+
records.push([f,hash(await readFile(f))]);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return hash(JSON.stringify([engine,entry,process.env,new Date().toDateString(),{ controlled, externalInputs },records]));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function sectionText(content, title) {
|
|
91
|
+
const re = /\\(section|subsection|subsubsection|paragraph)\*?\{((?:[^{}]|\{[^{}]*\})*)\}/g;
|
|
92
|
+
const headings = [...content.matchAll(re)];
|
|
93
|
+
const matches = headings.filter(m=>m[2]===title);
|
|
94
|
+
if (matches.length !== 1) throw new Error(`Expected one section titled "${title}"; found ${matches.length}.`);
|
|
95
|
+
const ranks = {section:1,subsection:2,subsubsection:3,paragraph:4};
|
|
96
|
+
const t = matches[0];
|
|
97
|
+
const next = headings.find(m=>m.index>t.index && ranks[m[1]]<=ranks[t[1]]);
|
|
98
|
+
return content.slice(t.index,next?.index ?? content.length);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Brace scanning preserves nested BibTeX values and equation/figure bodies.
|
|
102
|
+
function entries(text) {
|
|
103
|
+
const out=[]; const re=/@\w+\s*\{\s*([^,\s]+)\s*,/g; let m;
|
|
104
|
+
while ((m=re.exec(text))) {
|
|
105
|
+
let depth=1,i=re.lastIndex;
|
|
106
|
+
for (;i<text.length && depth;i++) { if (text[i-1]==='\\') continue; if(text[i]==='{')depth++; if(text[i]==='}')depth--; }
|
|
107
|
+
out.push({key:m[1],text:text.slice(m.index,i)}); re.lastIndex=i;
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
export async function sectionBundle(root, filePath, title, maxChars = 16000) {
|
|
112
|
+
const full=path.resolve(root,filePath);
|
|
113
|
+
if (!full.startsWith(path.resolve(root)+path.sep)) throw new Error('Section path must be inside project');
|
|
114
|
+
if (!(await realpath(full)).startsWith(await realpath(root) + path.sep)) throw new Error('Section symlink escapes project');
|
|
115
|
+
const source=await readFile(full,'utf8'); const body=sectionText(source,title);
|
|
116
|
+
const wanted=new Set([...body.matchAll(/\\(?:auto|eq|page)?ref\{([^}]+)\}/g)].map(m=>m[1]));
|
|
117
|
+
const citations=new Set([...body.matchAll(/\\(?:cite\w*|autocite|parencite|textcite)\*?(?:\[[^\]]*\])*\{([^}]+)\}/g)].flatMap(m=>m[1].split(',').map(k=>k.trim())));
|
|
118
|
+
const blocks=[];const bibliography=[];
|
|
119
|
+
const walk=async dir=>{
|
|
120
|
+
for(const d of await readdir(dir,{withFileTypes:true})) {
|
|
121
|
+
if(d.name==='.git'||d.isSymbolicLink())continue;
|
|
122
|
+
const f=path.join(dir,d.name); if(d.isDirectory()){await walk(f);continue;}
|
|
123
|
+
if(!/\.(tex|bib)$/.test(f))continue;
|
|
124
|
+
const t=await readFile(f,'utf8');
|
|
125
|
+
if(f.endsWith('.bib')) { for(const e of entries(t))if(citations.has(e.key))bibliography.push({file:path.relative(root,f),...e}); continue; }
|
|
126
|
+
for(const m of t.matchAll(/\\begin\{(figure\*?|equation\*?|align\*?|gather\*?|multline\*?)\}[\s\S]*?\\end\{\1\}/g)) {
|
|
127
|
+
const labels=[...m[0].matchAll(/\\label\{([^}]+)\}/g)].map(x=>x[1]);
|
|
128
|
+
if(labels.some(l=>wanted.has(l))) blocks.push({file:path.relative(root,f),labels,text:m[0]});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}; await walk(root);
|
|
132
|
+
const resolved=new Set(blocks.flatMap(b=>b.labels));
|
|
133
|
+
const result={file:filePath,section:title,body,blocks,bibliography,unresolvedLabels:[...wanted].filter(l=>!resolved.has(l)),unresolvedCitations:[...citations].filter(k=>!bibliography.some(e=>e.key===k)),assets:[...body.matchAll(/\\(?:includegraphics(?:\[[^\]]*\])?|input)\{([^}]+)\}/g)].map(m=>m[1]),truncated:false};
|
|
134
|
+
// Never silently truncate: preserve the main section first and identify omissions.
|
|
135
|
+
while(JSON.stringify(result).length>maxChars && (result.blocks.length||result.bibliography.length)) {result.truncated=true;if(result.blocks.length)result.blocks.pop();else result.bibliography.pop();}
|
|
136
|
+
if(JSON.stringify(result).length>maxChars){result.truncated=true;result.body=body.slice(0,Math.max(0,maxChars-JSON.stringify({...result,body:''}).length-100));}
|
|
137
|
+
return result;
|
|
138
|
+
}
|