docrev 0.2.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/CLAUDE.md +75 -0
- package/README.md +313 -0
- package/bin/rev.js +2645 -0
- package/lib/annotations.js +321 -0
- package/lib/build.js +486 -0
- package/lib/citations.js +149 -0
- package/lib/config.js +60 -0
- package/lib/crossref.js +426 -0
- package/lib/doi.js +823 -0
- package/lib/equations.js +258 -0
- package/lib/format.js +420 -0
- package/lib/import.js +1018 -0
- package/lib/response.js +182 -0
- package/lib/review.js +208 -0
- package/lib/sections.js +345 -0
- package/lib/templates.js +305 -0
- package/package.json +43 -0
package/lib/build.js
ADDED
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build system - combines sections → paper.md → PDF/DOCX/TEX
|
|
3
|
+
*
|
|
4
|
+
* Features:
|
|
5
|
+
* - Reads rev.yaml config
|
|
6
|
+
* - Combines section files into paper.md (persisted)
|
|
7
|
+
* - Strips annotations appropriately per output format
|
|
8
|
+
* - Runs pandoc with crossref filter
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import * as fs from 'fs';
|
|
12
|
+
import * as path from 'path';
|
|
13
|
+
import { execSync, spawn } from 'child_process';
|
|
14
|
+
import yaml from 'js-yaml';
|
|
15
|
+
import { stripAnnotations } from './annotations.js';
|
|
16
|
+
import { buildRegistry, labelToDisplay, detectDynamicRefs } from './crossref.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Default rev.yaml configuration
|
|
20
|
+
*/
|
|
21
|
+
export const DEFAULT_CONFIG = {
|
|
22
|
+
title: 'Untitled Document',
|
|
23
|
+
authors: [],
|
|
24
|
+
sections: [],
|
|
25
|
+
bibliography: null,
|
|
26
|
+
csl: null,
|
|
27
|
+
crossref: {
|
|
28
|
+
figureTitle: 'Figure',
|
|
29
|
+
tableTitle: 'Table',
|
|
30
|
+
figPrefix: ['Fig.', 'Figs.'],
|
|
31
|
+
tblPrefix: ['Table', 'Tables'],
|
|
32
|
+
secPrefix: ['Section', 'Sections'],
|
|
33
|
+
linkReferences: true,
|
|
34
|
+
},
|
|
35
|
+
pdf: {
|
|
36
|
+
template: null,
|
|
37
|
+
documentclass: 'article',
|
|
38
|
+
fontsize: '12pt',
|
|
39
|
+
geometry: 'margin=1in',
|
|
40
|
+
linestretch: 1.5,
|
|
41
|
+
numbersections: false,
|
|
42
|
+
},
|
|
43
|
+
docx: {
|
|
44
|
+
reference: null,
|
|
45
|
+
keepComments: true,
|
|
46
|
+
},
|
|
47
|
+
tex: {
|
|
48
|
+
standalone: true,
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Load rev.yaml config from directory
|
|
54
|
+
* @param {string} directory
|
|
55
|
+
* @returns {object} merged config with defaults
|
|
56
|
+
*/
|
|
57
|
+
export function loadConfig(directory) {
|
|
58
|
+
const configPath = path.join(directory, 'rev.yaml');
|
|
59
|
+
|
|
60
|
+
if (!fs.existsSync(configPath)) {
|
|
61
|
+
return { ...DEFAULT_CONFIG, _configPath: null };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
const content = fs.readFileSync(configPath, 'utf-8');
|
|
66
|
+
const userConfig = yaml.load(content) || {};
|
|
67
|
+
|
|
68
|
+
// Deep merge with defaults
|
|
69
|
+
const config = {
|
|
70
|
+
...DEFAULT_CONFIG,
|
|
71
|
+
...userConfig,
|
|
72
|
+
crossref: { ...DEFAULT_CONFIG.crossref, ...userConfig.crossref },
|
|
73
|
+
pdf: { ...DEFAULT_CONFIG.pdf, ...userConfig.pdf },
|
|
74
|
+
docx: { ...DEFAULT_CONFIG.docx, ...userConfig.docx },
|
|
75
|
+
tex: { ...DEFAULT_CONFIG.tex, ...userConfig.tex },
|
|
76
|
+
_configPath: configPath,
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
return config;
|
|
80
|
+
} catch (err) {
|
|
81
|
+
throw new Error(`Failed to parse rev.yaml: ${err.message}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Find section files in directory
|
|
87
|
+
* @param {string} directory
|
|
88
|
+
* @param {string[]} configSections - sections from rev.yaml (optional)
|
|
89
|
+
* @returns {string[]} ordered list of section files
|
|
90
|
+
*/
|
|
91
|
+
export function findSections(directory, configSections = []) {
|
|
92
|
+
// If sections specified in config, use that order
|
|
93
|
+
if (configSections.length > 0) {
|
|
94
|
+
const sections = [];
|
|
95
|
+
for (const section of configSections) {
|
|
96
|
+
const filePath = path.join(directory, section);
|
|
97
|
+
if (fs.existsSync(filePath)) {
|
|
98
|
+
sections.push(section);
|
|
99
|
+
} else {
|
|
100
|
+
console.warn(`Warning: Section file not found: ${section}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return sections;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Try sections.yaml
|
|
107
|
+
const sectionsYamlPath = path.join(directory, 'sections.yaml');
|
|
108
|
+
if (fs.existsSync(sectionsYamlPath)) {
|
|
109
|
+
try {
|
|
110
|
+
const sectionsConfig = yaml.load(fs.readFileSync(sectionsYamlPath, 'utf-8'));
|
|
111
|
+
if (sectionsConfig.sections) {
|
|
112
|
+
return Object.entries(sectionsConfig.sections)
|
|
113
|
+
.sort((a, b) => (a[1].order ?? 999) - (b[1].order ?? 999))
|
|
114
|
+
.map(([file]) => file)
|
|
115
|
+
.filter((f) => fs.existsSync(path.join(directory, f)));
|
|
116
|
+
}
|
|
117
|
+
} catch {
|
|
118
|
+
// Ignore yaml errors
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Default: find all .md files except special ones
|
|
123
|
+
const exclude = ['paper.md', 'readme.md', 'claude.md'];
|
|
124
|
+
const files = fs.readdirSync(directory).filter((f) => {
|
|
125
|
+
if (!f.endsWith('.md')) return false;
|
|
126
|
+
if (exclude.includes(f.toLowerCase())) return false;
|
|
127
|
+
return true;
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// Sort alphabetically as fallback
|
|
131
|
+
return files.sort();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Combine section files into paper.md
|
|
136
|
+
* @param {string} directory
|
|
137
|
+
* @param {object} config
|
|
138
|
+
* @param {object} options
|
|
139
|
+
* @returns {string} path to paper.md
|
|
140
|
+
*/
|
|
141
|
+
export function combineSections(directory, config, options = {}) {
|
|
142
|
+
const sections = findSections(directory, config.sections);
|
|
143
|
+
|
|
144
|
+
if (sections.length === 0) {
|
|
145
|
+
throw new Error('No section files found. Create .md files or specify sections in rev.yaml');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const parts = [];
|
|
149
|
+
|
|
150
|
+
// Add YAML frontmatter
|
|
151
|
+
const frontmatter = buildFrontmatter(config);
|
|
152
|
+
parts.push('---');
|
|
153
|
+
parts.push(yaml.dump(frontmatter).trim());
|
|
154
|
+
parts.push('---');
|
|
155
|
+
parts.push('');
|
|
156
|
+
|
|
157
|
+
// Combine sections
|
|
158
|
+
for (const section of sections) {
|
|
159
|
+
const filePath = path.join(directory, section);
|
|
160
|
+
let content = fs.readFileSync(filePath, 'utf-8');
|
|
161
|
+
|
|
162
|
+
// Remove any existing frontmatter from section files
|
|
163
|
+
content = stripFrontmatter(content);
|
|
164
|
+
|
|
165
|
+
parts.push(content.trim());
|
|
166
|
+
parts.push('');
|
|
167
|
+
parts.push(''); // Double newline between sections
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const paperContent = parts.join('\n');
|
|
171
|
+
const paperPath = path.join(directory, 'paper.md');
|
|
172
|
+
|
|
173
|
+
fs.writeFileSync(paperPath, paperContent, 'utf-8');
|
|
174
|
+
|
|
175
|
+
return paperPath;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Build YAML frontmatter from config
|
|
180
|
+
* @param {object} config
|
|
181
|
+
* @returns {object}
|
|
182
|
+
*/
|
|
183
|
+
function buildFrontmatter(config) {
|
|
184
|
+
const fm = {};
|
|
185
|
+
|
|
186
|
+
if (config.title) fm.title = config.title;
|
|
187
|
+
|
|
188
|
+
if (config.authors && config.authors.length > 0) {
|
|
189
|
+
fm.author = config.authors;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (config.bibliography) {
|
|
193
|
+
fm.bibliography = config.bibliography;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (config.csl) {
|
|
197
|
+
fm.csl = config.csl;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return fm;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Strip YAML frontmatter from content
|
|
205
|
+
* @param {string} content
|
|
206
|
+
* @returns {string}
|
|
207
|
+
*/
|
|
208
|
+
function stripFrontmatter(content) {
|
|
209
|
+
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/);
|
|
210
|
+
if (match) {
|
|
211
|
+
return content.slice(match[0].length);
|
|
212
|
+
}
|
|
213
|
+
return content;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Prepare paper.md for specific output format
|
|
218
|
+
* @param {string} paperPath
|
|
219
|
+
* @param {string} format - 'pdf', 'docx', 'tex'
|
|
220
|
+
* @param {object} config
|
|
221
|
+
* @param {object} options
|
|
222
|
+
* @returns {string} path to prepared file
|
|
223
|
+
*/
|
|
224
|
+
export function prepareForFormat(paperPath, format, config, options = {}) {
|
|
225
|
+
const directory = path.dirname(paperPath);
|
|
226
|
+
let content = fs.readFileSync(paperPath, 'utf-8');
|
|
227
|
+
|
|
228
|
+
// Build crossref registry for reference conversion
|
|
229
|
+
const registry = buildRegistry(directory);
|
|
230
|
+
|
|
231
|
+
if (format === 'pdf' || format === 'tex') {
|
|
232
|
+
// Strip all annotations for clean output
|
|
233
|
+
content = stripAnnotations(content);
|
|
234
|
+
} else if (format === 'docx') {
|
|
235
|
+
// Strip track changes, optionally keep comments
|
|
236
|
+
content = stripAnnotations(content, { keepComments: config.docx.keepComments });
|
|
237
|
+
|
|
238
|
+
// Convert @fig:label to "Figure 1" for Word readers
|
|
239
|
+
content = convertDynamicRefsToDisplay(content, registry);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Write to temporary file
|
|
243
|
+
const preparedPath = path.join(directory, `.paper-${format}.md`);
|
|
244
|
+
fs.writeFileSync(preparedPath, content, 'utf-8');
|
|
245
|
+
|
|
246
|
+
return preparedPath;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Convert @fig:label references to display format (Figure 1)
|
|
251
|
+
* @param {string} text
|
|
252
|
+
* @param {object} registry
|
|
253
|
+
* @returns {string}
|
|
254
|
+
*/
|
|
255
|
+
function convertDynamicRefsToDisplay(text, registry) {
|
|
256
|
+
const refs = detectDynamicRefs(text);
|
|
257
|
+
|
|
258
|
+
// Process in reverse order to preserve positions
|
|
259
|
+
let result = text;
|
|
260
|
+
for (let i = refs.length - 1; i >= 0; i--) {
|
|
261
|
+
const ref = refs[i];
|
|
262
|
+
const display = labelToDisplay(ref.type, ref.label, registry);
|
|
263
|
+
|
|
264
|
+
if (display) {
|
|
265
|
+
result = result.slice(0, ref.position) + display + result.slice(ref.position + ref.match.length);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return result;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Build pandoc arguments for format
|
|
274
|
+
* @param {string} format
|
|
275
|
+
* @param {object} config
|
|
276
|
+
* @param {string} outputPath
|
|
277
|
+
* @returns {string[]}
|
|
278
|
+
*/
|
|
279
|
+
export function buildPandocArgs(format, config, outputPath) {
|
|
280
|
+
const args = [];
|
|
281
|
+
|
|
282
|
+
// Output format
|
|
283
|
+
if (format === 'tex') {
|
|
284
|
+
args.push('-t', 'latex');
|
|
285
|
+
if (config.tex.standalone) {
|
|
286
|
+
args.push('-s');
|
|
287
|
+
}
|
|
288
|
+
} else if (format === 'pdf') {
|
|
289
|
+
args.push('-t', 'pdf');
|
|
290
|
+
} else if (format === 'docx') {
|
|
291
|
+
args.push('-t', 'docx');
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
args.push('-o', outputPath);
|
|
295
|
+
|
|
296
|
+
// Crossref filter (if available)
|
|
297
|
+
if (hasPandocCrossref()) {
|
|
298
|
+
args.push('--filter', 'pandoc-crossref');
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Bibliography
|
|
302
|
+
if (config.bibliography) {
|
|
303
|
+
args.push('--citeproc');
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Format-specific options
|
|
307
|
+
if (format === 'pdf') {
|
|
308
|
+
if (config.pdf.template) {
|
|
309
|
+
args.push('--template', config.pdf.template);
|
|
310
|
+
}
|
|
311
|
+
args.push('-V', `documentclass=${config.pdf.documentclass}`);
|
|
312
|
+
args.push('-V', `fontsize=${config.pdf.fontsize}`);
|
|
313
|
+
args.push('-V', `geometry:${config.pdf.geometry}`);
|
|
314
|
+
if (config.pdf.linestretch !== 1) {
|
|
315
|
+
args.push('-V', `linestretch=${config.pdf.linestretch}`);
|
|
316
|
+
}
|
|
317
|
+
if (config.pdf.numbersections) {
|
|
318
|
+
args.push('--number-sections');
|
|
319
|
+
}
|
|
320
|
+
} else if (format === 'docx') {
|
|
321
|
+
if (config.docx.reference) {
|
|
322
|
+
args.push('--reference-doc', config.docx.reference);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return args;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Check if pandoc-crossref is available
|
|
331
|
+
* @returns {boolean}
|
|
332
|
+
*/
|
|
333
|
+
export function hasPandocCrossref() {
|
|
334
|
+
try {
|
|
335
|
+
execSync('pandoc-crossref --version', { stdio: 'ignore' });
|
|
336
|
+
return true;
|
|
337
|
+
} catch {
|
|
338
|
+
return false;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Check if pandoc is available
|
|
344
|
+
* @returns {boolean}
|
|
345
|
+
*/
|
|
346
|
+
export function hasPandoc() {
|
|
347
|
+
try {
|
|
348
|
+
execSync('pandoc --version', { stdio: 'ignore' });
|
|
349
|
+
return true;
|
|
350
|
+
} catch {
|
|
351
|
+
return false;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Write crossref.yaml if needed
|
|
357
|
+
* @param {string} directory
|
|
358
|
+
* @param {object} config
|
|
359
|
+
*/
|
|
360
|
+
function ensureCrossrefConfig(directory, config) {
|
|
361
|
+
const crossrefPath = path.join(directory, 'crossref.yaml');
|
|
362
|
+
|
|
363
|
+
if (!fs.existsSync(crossrefPath) && hasPandocCrossref()) {
|
|
364
|
+
fs.writeFileSync(crossrefPath, yaml.dump(config.crossref), 'utf-8');
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Run pandoc build
|
|
370
|
+
* @param {string} inputPath
|
|
371
|
+
* @param {string} format
|
|
372
|
+
* @param {object} config
|
|
373
|
+
* @param {object} options
|
|
374
|
+
* @returns {Promise<{outputPath: string, success: boolean, error?: string}>}
|
|
375
|
+
*/
|
|
376
|
+
export async function runPandoc(inputPath, format, config, options = {}) {
|
|
377
|
+
const directory = path.dirname(inputPath);
|
|
378
|
+
const baseName = config.title
|
|
379
|
+
? config.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 50)
|
|
380
|
+
: 'paper';
|
|
381
|
+
|
|
382
|
+
const ext = format === 'tex' ? '.tex' : format === 'pdf' ? '.pdf' : '.docx';
|
|
383
|
+
const outputPath = path.join(directory, `${baseName}${ext}`);
|
|
384
|
+
|
|
385
|
+
// Ensure crossref.yaml exists
|
|
386
|
+
ensureCrossrefConfig(directory, config);
|
|
387
|
+
|
|
388
|
+
const args = buildPandocArgs(format, config, outputPath);
|
|
389
|
+
|
|
390
|
+
// Add crossref metadata file if exists
|
|
391
|
+
const crossrefPath = path.join(directory, 'crossref.yaml');
|
|
392
|
+
if (fs.existsSync(crossrefPath) && hasPandocCrossref()) {
|
|
393
|
+
args.push('--metadata-file', crossrefPath);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Input file
|
|
397
|
+
args.push(inputPath);
|
|
398
|
+
|
|
399
|
+
return new Promise((resolve) => {
|
|
400
|
+
const pandoc = spawn('pandoc', args, {
|
|
401
|
+
cwd: directory,
|
|
402
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
let stderr = '';
|
|
406
|
+
pandoc.stderr.on('data', (data) => {
|
|
407
|
+
stderr += data.toString();
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
pandoc.on('close', (code) => {
|
|
411
|
+
if (code === 0) {
|
|
412
|
+
resolve({ outputPath, success: true });
|
|
413
|
+
} else {
|
|
414
|
+
resolve({ outputPath, success: false, error: stderr || `Exit code ${code}` });
|
|
415
|
+
}
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
pandoc.on('error', (err) => {
|
|
419
|
+
resolve({ outputPath, success: false, error: err.message });
|
|
420
|
+
});
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Full build pipeline
|
|
426
|
+
* @param {string} directory
|
|
427
|
+
* @param {string[]} formats - ['pdf', 'docx', 'tex'] or ['all']
|
|
428
|
+
* @param {object} options
|
|
429
|
+
* @returns {Promise<{results: object[], paperPath: string}>}
|
|
430
|
+
*/
|
|
431
|
+
export async function build(directory, formats = ['pdf', 'docx'], options = {}) {
|
|
432
|
+
// Check pandoc
|
|
433
|
+
if (!hasPandoc()) {
|
|
434
|
+
throw new Error('pandoc not found. Run `rev install` to install dependencies.');
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Load config
|
|
438
|
+
const config = loadConfig(directory);
|
|
439
|
+
|
|
440
|
+
// Combine sections → paper.md
|
|
441
|
+
const paperPath = combineSections(directory, config, options);
|
|
442
|
+
|
|
443
|
+
// Expand 'all' to all formats
|
|
444
|
+
if (formats.includes('all')) {
|
|
445
|
+
formats = ['pdf', 'docx', 'tex'];
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const results = [];
|
|
449
|
+
|
|
450
|
+
for (const format of formats) {
|
|
451
|
+
// Prepare format-specific version
|
|
452
|
+
const preparedPath = prepareForFormat(paperPath, format, config, options);
|
|
453
|
+
|
|
454
|
+
// Run pandoc
|
|
455
|
+
const result = await runPandoc(preparedPath, format, config, options);
|
|
456
|
+
results.push({ format, ...result });
|
|
457
|
+
|
|
458
|
+
// Clean up temp file
|
|
459
|
+
try {
|
|
460
|
+
fs.unlinkSync(preparedPath);
|
|
461
|
+
} catch {
|
|
462
|
+
// Ignore cleanup errors
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
return { results, paperPath };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Get build status summary
|
|
471
|
+
* @param {object[]} results
|
|
472
|
+
* @returns {string}
|
|
473
|
+
*/
|
|
474
|
+
export function formatBuildResults(results) {
|
|
475
|
+
const lines = [];
|
|
476
|
+
|
|
477
|
+
for (const r of results) {
|
|
478
|
+
if (r.success) {
|
|
479
|
+
lines.push(` ${r.format.toUpperCase()}: ${path.basename(r.outputPath)}`);
|
|
480
|
+
} else {
|
|
481
|
+
lines.push(` ${r.format.toUpperCase()}: FAILED - ${r.error}`);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
return lines.join('\n');
|
|
486
|
+
}
|
package/lib/citations.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Citation validation utilities
|
|
3
|
+
* Check that all [@cite] references exist in .bib file
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import * as fs from 'fs';
|
|
7
|
+
import * as path from 'path';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Extract all citation keys from markdown text
|
|
11
|
+
* Handles: [@Key], [@Key1; @Key2], @Key (inline)
|
|
12
|
+
* @param {string} text
|
|
13
|
+
* @returns {Array<{key: string, line: number, file: string}>}
|
|
14
|
+
*/
|
|
15
|
+
export function extractCitations(text, file = '') {
|
|
16
|
+
const citations = [];
|
|
17
|
+
const lines = text.split('\n');
|
|
18
|
+
|
|
19
|
+
// Pattern for bracketed citations: [@Key] or [@Key1; @Key2]
|
|
20
|
+
const bracketPattern = /\[@([^\]]+)\]/g;
|
|
21
|
+
// Pattern for inline citations: @Key (word boundary)
|
|
22
|
+
const inlinePattern = /(?<!\[)@([A-Za-z][A-Za-z0-9_-]*\d{4}[a-z]?)(?![;\]])/g;
|
|
23
|
+
|
|
24
|
+
for (let lineNum = 0; lineNum < lines.length; lineNum++) {
|
|
25
|
+
const line = lines[lineNum];
|
|
26
|
+
|
|
27
|
+
// Skip code blocks and comments
|
|
28
|
+
if (line.trim().startsWith('```') || line.trim().startsWith('<!--')) continue;
|
|
29
|
+
|
|
30
|
+
// Bracketed citations
|
|
31
|
+
let match;
|
|
32
|
+
while ((match = bracketPattern.exec(line)) !== null) {
|
|
33
|
+
// Split by ; for multiple citations
|
|
34
|
+
const keys = match[1].split(';').map(k => k.trim().replace(/^@/, ''));
|
|
35
|
+
for (const key of keys) {
|
|
36
|
+
if (key) {
|
|
37
|
+
citations.push({ key, line: lineNum + 1, file });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Inline citations (reset lastIndex)
|
|
43
|
+
inlinePattern.lastIndex = 0;
|
|
44
|
+
while ((match = inlinePattern.exec(line)) !== null) {
|
|
45
|
+
citations.push({ key: match[1], line: lineNum + 1, file });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return citations;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Parse .bib file and extract all entry keys
|
|
54
|
+
* @param {string} bibPath
|
|
55
|
+
* @returns {Set<string>}
|
|
56
|
+
*/
|
|
57
|
+
export function parseBibFile(bibPath) {
|
|
58
|
+
const keys = new Set();
|
|
59
|
+
|
|
60
|
+
if (!fs.existsSync(bibPath)) {
|
|
61
|
+
return keys;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const content = fs.readFileSync(bibPath, 'utf-8');
|
|
65
|
+
|
|
66
|
+
// Pattern for bib entries: @type{key,
|
|
67
|
+
const entryPattern = /@\w+\s*\{\s*([^,\s]+)\s*,/g;
|
|
68
|
+
|
|
69
|
+
let match;
|
|
70
|
+
while ((match = entryPattern.exec(content)) !== null) {
|
|
71
|
+
keys.add(match[1]);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return keys;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Validate citations against bib file
|
|
79
|
+
* @param {string[]} mdFiles - Markdown files to check
|
|
80
|
+
* @param {string} bibPath - Path to .bib file
|
|
81
|
+
* @returns {{valid: Array, missing: Array, unused: Array, duplicates: Array}}
|
|
82
|
+
*/
|
|
83
|
+
export function validateCitations(mdFiles, bibPath) {
|
|
84
|
+
// Collect all citations from markdown
|
|
85
|
+
const allCitations = [];
|
|
86
|
+
for (const file of mdFiles) {
|
|
87
|
+
if (!fs.existsSync(file)) continue;
|
|
88
|
+
const text = fs.readFileSync(file, 'utf-8');
|
|
89
|
+
const citations = extractCitations(text, path.basename(file));
|
|
90
|
+
allCitations.push(...citations);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Get bib keys
|
|
94
|
+
const bibKeys = parseBibFile(bibPath);
|
|
95
|
+
|
|
96
|
+
// Categorize
|
|
97
|
+
const valid = [];
|
|
98
|
+
const missing = [];
|
|
99
|
+
const citedKeys = new Set();
|
|
100
|
+
const keyOccurrences = new Map();
|
|
101
|
+
|
|
102
|
+
for (const citation of allCitations) {
|
|
103
|
+
citedKeys.add(citation.key);
|
|
104
|
+
|
|
105
|
+
// Track occurrences for duplicates
|
|
106
|
+
if (!keyOccurrences.has(citation.key)) {
|
|
107
|
+
keyOccurrences.set(citation.key, []);
|
|
108
|
+
}
|
|
109
|
+
keyOccurrences.get(citation.key).push(citation);
|
|
110
|
+
|
|
111
|
+
if (bibKeys.has(citation.key)) {
|
|
112
|
+
valid.push(citation);
|
|
113
|
+
} else {
|
|
114
|
+
missing.push(citation);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Find unused bib entries
|
|
119
|
+
const unused = [...bibKeys].filter(key => !citedKeys.has(key));
|
|
120
|
+
|
|
121
|
+
// Find duplicate citations (same key cited multiple times - not an error, just info)
|
|
122
|
+
const duplicates = [...keyOccurrences.entries()]
|
|
123
|
+
.filter(([key, occurrences]) => occurrences.length > 1)
|
|
124
|
+
.map(([key, occurrences]) => ({ key, count: occurrences.length, locations: occurrences }));
|
|
125
|
+
|
|
126
|
+
return { valid, missing, unused, duplicates };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Get citation statistics
|
|
131
|
+
* @param {string[]} mdFiles
|
|
132
|
+
* @param {string} bibPath
|
|
133
|
+
* @returns {object}
|
|
134
|
+
*/
|
|
135
|
+
export function getCitationStats(mdFiles, bibPath) {
|
|
136
|
+
const result = validateCitations(mdFiles, bibPath);
|
|
137
|
+
const bibKeys = parseBibFile(bibPath);
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
totalCitations: result.valid.length + result.missing.length,
|
|
141
|
+
uniqueCited: new Set([...result.valid, ...result.missing].map(c => c.key)).size,
|
|
142
|
+
valid: result.valid.length,
|
|
143
|
+
missing: result.missing.length,
|
|
144
|
+
missingKeys: [...new Set(result.missing.map(c => c.key))],
|
|
145
|
+
bibEntries: bibKeys.size,
|
|
146
|
+
unused: result.unused.length,
|
|
147
|
+
unusedKeys: result.unused,
|
|
148
|
+
};
|
|
149
|
+
}
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User configuration management
|
|
3
|
+
* Stores user preferences in ~/.revrc
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import * as fs from 'fs';
|
|
7
|
+
import * as path from 'path';
|
|
8
|
+
import * as os from 'os';
|
|
9
|
+
|
|
10
|
+
const CONFIG_PATH = path.join(os.homedir(), '.revrc');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Load user config
|
|
14
|
+
* @returns {object}
|
|
15
|
+
*/
|
|
16
|
+
export function loadUserConfig() {
|
|
17
|
+
try {
|
|
18
|
+
if (fs.existsSync(CONFIG_PATH)) {
|
|
19
|
+
return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
|
|
20
|
+
}
|
|
21
|
+
} catch {
|
|
22
|
+
// Ignore parse errors
|
|
23
|
+
}
|
|
24
|
+
return {};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Save user config
|
|
29
|
+
* @param {object} config
|
|
30
|
+
*/
|
|
31
|
+
export function saveUserConfig(config) {
|
|
32
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), 'utf-8');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Get user name
|
|
37
|
+
* @returns {string|null}
|
|
38
|
+
*/
|
|
39
|
+
export function getUserName() {
|
|
40
|
+
const config = loadUserConfig();
|
|
41
|
+
return config.userName || null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Set user name
|
|
46
|
+
* @param {string} name
|
|
47
|
+
*/
|
|
48
|
+
export function setUserName(name) {
|
|
49
|
+
const config = loadUserConfig();
|
|
50
|
+
config.userName = name;
|
|
51
|
+
saveUserConfig(config);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Get config file path
|
|
56
|
+
* @returns {string}
|
|
57
|
+
*/
|
|
58
|
+
export function getConfigPath() {
|
|
59
|
+
return CONFIG_PATH;
|
|
60
|
+
}
|