docrev 0.2.1 → 0.5.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.
@@ -0,0 +1,605 @@
1
+ /**
2
+ * Journal validation profiles
3
+ * Check manuscripts against journal-specific requirements
4
+ */
5
+
6
+ import * as fs from 'fs';
7
+ import * as path from 'path';
8
+
9
+ /**
10
+ * Journal requirement profiles
11
+ * Based on publicly available author guidelines
12
+ */
13
+ export const JOURNAL_PROFILES = {
14
+ nature: {
15
+ name: 'Nature',
16
+ url: 'https://www.nature.com/nature/for-authors',
17
+ requirements: {
18
+ wordLimit: { main: 3000, abstract: 150, title: 90 },
19
+ references: { max: 50, doiRequired: true },
20
+ figures: { max: 6, combinedWithTables: true },
21
+ sections: {
22
+ required: ['Abstract', 'Introduction', 'Results', 'Discussion', 'Methods'],
23
+ methodsPosition: 'end',
24
+ },
25
+ authors: { maxInitial: null, correspondingRequired: true },
26
+ },
27
+ },
28
+
29
+ science: {
30
+ name: 'Science',
31
+ url: 'https://www.science.org/content/page/instructions-preparing-initial-manuscript',
32
+ requirements: {
33
+ wordLimit: { main: 2500, abstract: 125, title: 120 },
34
+ references: { max: 40, doiRequired: true },
35
+ figures: { max: 4, combinedWithTables: true },
36
+ sections: {
37
+ required: ['Abstract', 'Introduction', 'Results', 'Discussion'],
38
+ supplementary: true,
39
+ },
40
+ authors: { maxInitial: null, correspondingRequired: true },
41
+ },
42
+ },
43
+
44
+ 'plos-one': {
45
+ name: 'PLOS ONE',
46
+ url: 'https://journals.plos.org/plosone/s/submission-guidelines',
47
+ requirements: {
48
+ wordLimit: { main: null, abstract: 300, title: 250 },
49
+ references: { max: null, doiRequired: false },
50
+ figures: { max: null, combinedWithTables: false },
51
+ sections: {
52
+ required: ['Abstract', 'Introduction', 'Methods', 'Results', 'Discussion'],
53
+ methodsPosition: 'before-results',
54
+ },
55
+ authors: { maxInitial: null, correspondingRequired: true },
56
+ dataAvailability: true,
57
+ },
58
+ },
59
+
60
+ 'pnas': {
61
+ name: 'PNAS',
62
+ url: 'https://www.pnas.org/author-center/submitting-your-manuscript',
63
+ requirements: {
64
+ wordLimit: { main: 4500, abstract: 250, title: null },
65
+ references: { max: 50, doiRequired: true },
66
+ figures: { max: 6, combinedWithTables: true },
67
+ sections: {
68
+ required: ['Abstract', 'Introduction', 'Results', 'Discussion'],
69
+ significanceStatement: true,
70
+ },
71
+ authors: { maxInitial: null, correspondingRequired: true },
72
+ },
73
+ },
74
+
75
+ 'ecology-letters': {
76
+ name: 'Ecology Letters',
77
+ url: 'https://onlinelibrary.wiley.com/page/journal/14610248/homepage/forauthors.html',
78
+ requirements: {
79
+ wordLimit: { main: 5000, abstract: 150, title: null },
80
+ references: { max: 50, doiRequired: true },
81
+ figures: { max: 6, combinedWithTables: true },
82
+ sections: {
83
+ required: ['Abstract', 'Introduction', 'Methods', 'Results', 'Discussion'],
84
+ },
85
+ authors: { maxInitial: null, correspondingRequired: true },
86
+ keywords: { min: 3, max: 10 },
87
+ },
88
+ },
89
+
90
+ 'ecological-applications': {
91
+ name: 'Ecological Applications',
92
+ url: 'https://esajournals.onlinelibrary.wiley.com/hub/journal/19395582/author-guidelines',
93
+ requirements: {
94
+ wordLimit: { main: 7000, abstract: 350, title: null },
95
+ references: { max: null, doiRequired: true },
96
+ figures: { max: null, combinedWithTables: false },
97
+ sections: {
98
+ required: ['Abstract', 'Introduction', 'Methods', 'Results', 'Discussion'],
99
+ },
100
+ dataAvailability: true,
101
+ },
102
+ },
103
+
104
+ 'molecular-ecology': {
105
+ name: 'Molecular Ecology',
106
+ url: 'https://onlinelibrary.wiley.com/page/journal/1365294x/homepage/forauthors.html',
107
+ requirements: {
108
+ wordLimit: { main: 8000, abstract: 250, title: null },
109
+ references: { max: null, doiRequired: true },
110
+ figures: { max: 8, combinedWithTables: false },
111
+ sections: {
112
+ required: ['Abstract', 'Introduction', 'Methods', 'Results', 'Discussion'],
113
+ },
114
+ dataAvailability: true,
115
+ keywords: { min: 4, max: 8 },
116
+ },
117
+ },
118
+
119
+ 'elife': {
120
+ name: 'eLife',
121
+ url: 'https://reviewer.elifesciences.org/author-guide/full',
122
+ requirements: {
123
+ wordLimit: { main: null, abstract: 150, title: null },
124
+ references: { max: null, doiRequired: true },
125
+ figures: { max: null, combinedWithTables: false },
126
+ sections: {
127
+ required: ['Abstract', 'Introduction', 'Results', 'Discussion', 'Methods'],
128
+ methodsPosition: 'end',
129
+ },
130
+ impactStatement: true,
131
+ },
132
+ },
133
+
134
+ 'cell': {
135
+ name: 'Cell',
136
+ url: 'https://www.cell.com/cell/authors',
137
+ requirements: {
138
+ wordLimit: { main: 7000, abstract: 150, title: null },
139
+ references: { max: 100, doiRequired: true },
140
+ figures: { max: 7, combinedWithTables: true },
141
+ sections: {
142
+ required: ['Abstract', 'Introduction', 'Results', 'Discussion'],
143
+ graphicalAbstract: true,
144
+ highlights: true,
145
+ },
146
+ authors: { maxInitial: null, correspondingRequired: true },
147
+ },
148
+ },
149
+
150
+ 'current-biology': {
151
+ name: 'Current Biology',
152
+ url: 'https://www.cell.com/current-biology/authors',
153
+ requirements: {
154
+ wordLimit: { main: 5000, abstract: 150, title: 150 },
155
+ references: { max: 60, doiRequired: true },
156
+ figures: { max: 4, combinedWithTables: true },
157
+ sections: {
158
+ required: ['Summary', 'Results', 'Discussion'],
159
+ },
160
+ authors: { maxInitial: null, correspondingRequired: true },
161
+ },
162
+ },
163
+
164
+ 'conservation-biology': {
165
+ name: 'Conservation Biology',
166
+ url: 'https://conbio.onlinelibrary.wiley.com/hub/journal/15231739/homepage/forauthors.html',
167
+ requirements: {
168
+ wordLimit: { main: 7000, abstract: 300, title: null },
169
+ references: { max: null, doiRequired: true },
170
+ figures: { max: 6, combinedWithTables: true },
171
+ sections: {
172
+ required: ['Abstract', 'Introduction', 'Methods', 'Results', 'Discussion'],
173
+ },
174
+ keywords: { min: 5, max: 10 },
175
+ },
176
+ },
177
+
178
+ 'biological-conservation': {
179
+ name: 'Biological Conservation',
180
+ url: 'https://www.elsevier.com/journals/biological-conservation/0006-3207/guide-for-authors',
181
+ requirements: {
182
+ wordLimit: { main: 8000, abstract: 400, title: null },
183
+ references: { max: null, doiRequired: true },
184
+ figures: { max: null, combinedWithTables: false },
185
+ sections: {
186
+ required: ['Abstract', 'Introduction', 'Methods', 'Results', 'Discussion'],
187
+ },
188
+ highlights: true,
189
+ keywords: { min: 4, max: 6 },
190
+ },
191
+ },
192
+
193
+ 'journal-of-ecology': {
194
+ name: 'Journal of Ecology',
195
+ url: 'https://besjournals.onlinelibrary.wiley.com/hub/journal/13652745/author-guidelines',
196
+ requirements: {
197
+ wordLimit: { main: 7000, abstract: 350, title: null },
198
+ references: { max: null, doiRequired: true },
199
+ figures: { max: null, combinedWithTables: false },
200
+ sections: {
201
+ required: ['Abstract', 'Introduction', 'Methods', 'Results', 'Discussion'],
202
+ },
203
+ keywords: { min: 4, max: 8 },
204
+ },
205
+ },
206
+
207
+ 'functional-ecology': {
208
+ name: 'Functional Ecology',
209
+ url: 'https://besjournals.onlinelibrary.wiley.com/hub/journal/13652435/author-guidelines',
210
+ requirements: {
211
+ wordLimit: { main: 7000, abstract: 350, title: null },
212
+ references: { max: null, doiRequired: true },
213
+ figures: { max: null, combinedWithTables: false },
214
+ sections: {
215
+ required: ['Abstract', 'Introduction', 'Methods', 'Results', 'Discussion'],
216
+ },
217
+ keywords: { min: 4, max: 8 },
218
+ },
219
+ },
220
+
221
+ 'global-change-biology': {
222
+ name: 'Global Change Biology',
223
+ url: 'https://onlinelibrary.wiley.com/page/journal/13652486/homepage/forauthors.html',
224
+ requirements: {
225
+ wordLimit: { main: 7000, abstract: 300, title: null },
226
+ references: { max: null, doiRequired: true },
227
+ figures: { max: 8, combinedWithTables: false },
228
+ sections: {
229
+ required: ['Abstract', 'Introduction', 'Methods', 'Results', 'Discussion'],
230
+ },
231
+ keywords: { min: 4, max: 8 },
232
+ },
233
+ },
234
+
235
+ 'oikos': {
236
+ name: 'Oikos',
237
+ url: 'https://nsojournals.onlinelibrary.wiley.com/hub/journal/16000706/author-guidelines',
238
+ requirements: {
239
+ wordLimit: { main: 8000, abstract: 350, title: null },
240
+ references: { max: null, doiRequired: true },
241
+ figures: { max: null, combinedWithTables: false },
242
+ sections: {
243
+ required: ['Abstract', 'Introduction', 'Methods', 'Results', 'Discussion'],
244
+ },
245
+ keywords: { min: 4, max: 10 },
246
+ },
247
+ },
248
+
249
+ 'oecologia': {
250
+ name: 'Oecologia',
251
+ url: 'https://www.springer.com/journal/442/submission-guidelines',
252
+ requirements: {
253
+ wordLimit: { main: 8000, abstract: 250, title: null },
254
+ references: { max: null, doiRequired: true },
255
+ figures: { max: null, combinedWithTables: false },
256
+ sections: {
257
+ required: ['Abstract', 'Introduction', 'Methods', 'Results', 'Discussion'],
258
+ },
259
+ keywords: { min: 4, max: 6 },
260
+ },
261
+ },
262
+
263
+ 'biological-invasions': {
264
+ name: 'Biological Invasions',
265
+ url: 'https://www.springer.com/journal/10530/submission-guidelines',
266
+ requirements: {
267
+ wordLimit: { main: null, abstract: 250, title: null },
268
+ references: { max: null, doiRequired: true },
269
+ figures: { max: null, combinedWithTables: false },
270
+ sections: {
271
+ required: ['Abstract', 'Introduction', 'Methods', 'Results', 'Discussion'],
272
+ },
273
+ keywords: { min: 4, max: 6 },
274
+ },
275
+ },
276
+
277
+ 'diversity-distributions': {
278
+ name: 'Diversity and Distributions',
279
+ url: 'https://onlinelibrary.wiley.com/page/journal/14724642/homepage/forauthors.html',
280
+ requirements: {
281
+ wordLimit: { main: 6000, abstract: 300, title: null },
282
+ references: { max: null, doiRequired: true },
283
+ figures: { max: 6, combinedWithTables: true },
284
+ sections: {
285
+ required: ['Abstract', 'Introduction', 'Methods', 'Results', 'Discussion'],
286
+ },
287
+ keywords: { min: 4, max: 8 },
288
+ biosketch: true,
289
+ },
290
+ },
291
+
292
+ 'neobiota': {
293
+ name: 'NeoBiota',
294
+ url: 'https://neobiota.pensoft.net/about#Author_Guidelines',
295
+ requirements: {
296
+ wordLimit: { main: null, abstract: 350, title: null },
297
+ references: { max: null, doiRequired: true },
298
+ figures: { max: null, combinedWithTables: false },
299
+ sections: {
300
+ required: ['Abstract', 'Introduction', 'Methods', 'Results', 'Discussion'],
301
+ },
302
+ keywords: { min: 6, max: 12 },
303
+ },
304
+ },
305
+
306
+ 'peerj': {
307
+ name: 'PeerJ',
308
+ url: 'https://peerj.com/about/author-instructions/',
309
+ requirements: {
310
+ wordLimit: { main: null, abstract: 500, title: null },
311
+ references: { max: null, doiRequired: false },
312
+ figures: { max: null, combinedWithTables: false },
313
+ sections: {
314
+ required: ['Abstract', 'Introduction', 'Methods', 'Results', 'Discussion'],
315
+ },
316
+ },
317
+ },
318
+ };
319
+
320
+ /**
321
+ * List all available journal profiles
322
+ * @returns {Array<{id: string, name: string, url: string}>}
323
+ */
324
+ export function listJournals() {
325
+ return Object.entries(JOURNAL_PROFILES).map(([id, profile]) => ({
326
+ id,
327
+ name: profile.name,
328
+ url: profile.url,
329
+ }));
330
+ }
331
+
332
+ /**
333
+ * Get a specific journal profile
334
+ * @param {string} journalId
335
+ * @returns {Object|null}
336
+ */
337
+ export function getJournalProfile(journalId) {
338
+ const normalized = journalId.toLowerCase().replace(/\s+/g, '-');
339
+ return JOURNAL_PROFILES[normalized] || null;
340
+ }
341
+
342
+ /**
343
+ * Count words in text (excluding markdown syntax)
344
+ * @param {string} text
345
+ * @returns {number}
346
+ */
347
+ function countWords(text) {
348
+ // Remove markdown syntax
349
+ let clean = text
350
+ .replace(/^---[\s\S]*?---/m, '') // YAML frontmatter
351
+ .replace(/!\[.*?\]\(.*?\)/g, '') // Images
352
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // Links
353
+ .replace(/#+\s*/g, '') // Headers
354
+ .replace(/\*\*|__/g, '') // Bold
355
+ .replace(/\*|_/g, '') // Italic
356
+ .replace(/`[^`]+`/g, '') // Inline code
357
+ .replace(/```[\s\S]*?```/g, '') // Code blocks
358
+ .replace(/\{[^}]+\}/g, '') // CriticMarkup and attributes
359
+ .replace(/@\w+:\w+/g, '') // Cross-references
360
+ .replace(/@\w+/g, '') // Citations
361
+ .replace(/\|[^|]+\|/g, ' ') // Table cells
362
+ .replace(/[-=]{3,}/g, '') // Horizontal rules
363
+ .replace(/\n+/g, ' ') // Newlines
364
+ .replace(/\s+/g, ' ') // Multiple spaces
365
+ .trim();
366
+
367
+ return clean.split(/\s+/).filter(w => w.length > 0).length;
368
+ }
369
+
370
+ /**
371
+ * Extract abstract from markdown
372
+ * @param {string} text
373
+ * @returns {string|null}
374
+ */
375
+ function extractAbstract(text) {
376
+ // Try to find abstract section
377
+ const patterns = [
378
+ /^#+\s*Abstract\s*\n([\s\S]*?)(?=^#+|\Z)/mi,
379
+ /^Abstract[:\s]*\n([\s\S]*?)(?=^#+|\n\n)/mi,
380
+ ];
381
+
382
+ for (const pattern of patterns) {
383
+ const match = text.match(pattern);
384
+ if (match) {
385
+ return match[1].trim();
386
+ }
387
+ }
388
+
389
+ return null;
390
+ }
391
+
392
+ /**
393
+ * Extract title from markdown
394
+ * @param {string} text
395
+ * @returns {string|null}
396
+ */
397
+ function extractTitle(text) {
398
+ // Try YAML frontmatter
399
+ const yamlMatch = text.match(/^---\n[\s\S]*?title:\s*["']?([^"'\n]+)["']?[\s\S]*?\n---/m);
400
+ if (yamlMatch) {
401
+ return yamlMatch[1].trim();
402
+ }
403
+
404
+ // Try first H1
405
+ const h1Match = text.match(/^#\s+(.+)$/m);
406
+ if (h1Match) {
407
+ return h1Match[1].trim();
408
+ }
409
+
410
+ return null;
411
+ }
412
+
413
+ /**
414
+ * Extract sections from markdown
415
+ * @param {string} text
416
+ * @returns {string[]}
417
+ */
418
+ function extractSections(text) {
419
+ const sections = [];
420
+ const headerPattern = /^#+\s+(.+)$/gm;
421
+ let match;
422
+
423
+ while ((match = headerPattern.exec(text)) !== null) {
424
+ sections.push(match[1].trim());
425
+ }
426
+
427
+ return sections;
428
+ }
429
+
430
+ /**
431
+ * Count figures in markdown
432
+ * @param {string} text
433
+ * @returns {number}
434
+ */
435
+ function countFigures(text) {
436
+ // Count images with figure captions
437
+ const figurePattern = /!\[.*?\]\(.*?\)(\{#fig:[^}]+\})?/g;
438
+ const matches = text.match(figurePattern) || [];
439
+ return matches.length;
440
+ }
441
+
442
+ /**
443
+ * Count tables in markdown
444
+ * @param {string} text
445
+ * @returns {number}
446
+ */
447
+ function countTables(text) {
448
+ // Count tables (lines starting with |)
449
+ const tablePattern = /^\|[^|]+\|/gm;
450
+ const matches = text.match(tablePattern) || [];
451
+ // Divide by approximate rows per table
452
+ return Math.ceil(matches.length / 5);
453
+ }
454
+
455
+ /**
456
+ * Count references/citations in markdown
457
+ * @param {string} text
458
+ * @returns {number}
459
+ */
460
+ function countReferences(text) {
461
+ // Count unique citation keys
462
+ const citationPattern = /@(\w+)/g;
463
+ const citations = new Set();
464
+ let match;
465
+
466
+ while ((match = citationPattern.exec(text)) !== null) {
467
+ // Exclude cross-refs like @fig:label
468
+ if (!match[0].includes(':')) {
469
+ citations.add(match[1]);
470
+ }
471
+ }
472
+
473
+ return citations.size;
474
+ }
475
+
476
+ /**
477
+ * Validate manuscript against journal requirements
478
+ * @param {string} text - Markdown content
479
+ * @param {string} journalId - Journal profile ID
480
+ * @returns {{valid: boolean, errors: string[], warnings: string[], stats: Object}}
481
+ */
482
+ export function validateManuscript(text, journalId) {
483
+ const profile = getJournalProfile(journalId);
484
+
485
+ if (!profile) {
486
+ return {
487
+ valid: false,
488
+ errors: [`Unknown journal: ${journalId}`],
489
+ warnings: [],
490
+ stats: null,
491
+ };
492
+ }
493
+
494
+ const req = profile.requirements;
495
+ const errors = [];
496
+ const warnings = [];
497
+
498
+ // Extract content
499
+ const abstract = extractAbstract(text);
500
+ const title = extractTitle(text);
501
+ const sections = extractSections(text);
502
+ const mainWordCount = countWords(text);
503
+ const figureCount = countFigures(text);
504
+ const tableCount = countTables(text);
505
+ const refCount = countReferences(text);
506
+
507
+ const stats = {
508
+ wordCount: mainWordCount,
509
+ abstractWords: abstract ? countWords(abstract) : 0,
510
+ titleChars: title ? title.length : 0,
511
+ figures: figureCount,
512
+ tables: tableCount,
513
+ references: refCount,
514
+ sections: sections.length,
515
+ };
516
+
517
+ // Word limits
518
+ if (req.wordLimit) {
519
+ if (req.wordLimit.main && mainWordCount > req.wordLimit.main) {
520
+ errors.push(`Main text exceeds ${req.wordLimit.main} words (current: ${mainWordCount})`);
521
+ }
522
+ if (req.wordLimit.abstract && abstract) {
523
+ const absWords = countWords(abstract);
524
+ if (absWords > req.wordLimit.abstract) {
525
+ errors.push(`Abstract exceeds ${req.wordLimit.abstract} words (current: ${absWords})`);
526
+ }
527
+ }
528
+ if (req.wordLimit.title && title) {
529
+ if (title.length > req.wordLimit.title) {
530
+ warnings.push(`Title exceeds ${req.wordLimit.title} characters (current: ${title.length})`);
531
+ }
532
+ }
533
+ }
534
+
535
+ // References
536
+ if (req.references) {
537
+ if (req.references.max && refCount > req.references.max) {
538
+ errors.push(`References exceed ${req.references.max} (current: ${refCount})`);
539
+ }
540
+ if (req.references.doiRequired) {
541
+ warnings.push('DOI required for all references - run "rev doi check" to verify');
542
+ }
543
+ }
544
+
545
+ // Figures/tables
546
+ if (req.figures) {
547
+ const totalVisual = req.figures.combinedWithTables
548
+ ? figureCount + tableCount
549
+ : figureCount;
550
+ const label = req.figures.combinedWithTables ? 'figures + tables' : 'figures';
551
+
552
+ if (req.figures.max && totalVisual > req.figures.max) {
553
+ errors.push(`${label} exceed ${req.figures.max} (current: ${totalVisual})`);
554
+ }
555
+ }
556
+
557
+ // Required sections
558
+ if (req.sections?.required) {
559
+ for (const reqSection of req.sections.required) {
560
+ const found = sections.some(s =>
561
+ s.toLowerCase().includes(reqSection.toLowerCase())
562
+ );
563
+ if (!found) {
564
+ warnings.push(`Missing required section: ${reqSection}`);
565
+ }
566
+ }
567
+ }
568
+
569
+ // Data availability
570
+ if (req.dataAvailability) {
571
+ const hasDataStatement = sections.some(s =>
572
+ s.toLowerCase().includes('data') ||
573
+ text.toLowerCase().includes('data availability') ||
574
+ text.toLowerCase().includes('data statement')
575
+ );
576
+ if (!hasDataStatement) {
577
+ warnings.push('Data availability statement may be required');
578
+ }
579
+ }
580
+
581
+ return {
582
+ valid: errors.length === 0,
583
+ errors,
584
+ warnings,
585
+ stats,
586
+ journal: profile.name,
587
+ url: profile.url,
588
+ };
589
+ }
590
+
591
+ /**
592
+ * Validate multiple files against journal requirements
593
+ * @param {string[]} files - Markdown file paths
594
+ * @param {string} journalId - Journal profile ID
595
+ * @returns {Object}
596
+ */
597
+ export function validateProject(files, journalId) {
598
+ // Combine all file contents
599
+ const combined = files
600
+ .filter(f => fs.existsSync(f))
601
+ .map(f => fs.readFileSync(f, 'utf-8'))
602
+ .join('\n\n');
603
+
604
+ return validateManuscript(combined, journalId);
605
+ }