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/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
+ }
@@ -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
+ }