docrev 0.2.0 → 0.3.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 +2 -2
- package/README.md +35 -2
- package/bin/rev.js +696 -5
- package/lib/build.js +10 -2
- package/lib/crossref.js +138 -49
- package/lib/equations.js +235 -0
- package/lib/git.js +238 -0
- package/lib/journals.js +420 -0
- package/lib/merge.js +365 -0
- package/lib/trackchanges.js +273 -0
- package/lib/word.js +225 -0
- package/package.json +7 -5
package/lib/git.js
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git integration utilities
|
|
3
|
+
* Compare sections against git history
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { execSync } from 'child_process';
|
|
7
|
+
import * as fs from 'fs';
|
|
8
|
+
import * as path from 'path';
|
|
9
|
+
import { diffWords } from 'diff';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Check if current directory is a git repository
|
|
13
|
+
* @returns {boolean}
|
|
14
|
+
*/
|
|
15
|
+
export function isGitRepo() {
|
|
16
|
+
try {
|
|
17
|
+
execSync('git rev-parse --is-inside-work-tree', { stdio: 'pipe' });
|
|
18
|
+
return true;
|
|
19
|
+
} catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Get the current git branch
|
|
26
|
+
* @returns {string|null}
|
|
27
|
+
*/
|
|
28
|
+
export function getCurrentBranch() {
|
|
29
|
+
try {
|
|
30
|
+
return execSync('git rev-parse --abbrev-ref HEAD', { stdio: 'pipe' })
|
|
31
|
+
.toString()
|
|
32
|
+
.trim();
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Get the default branch (main or master)
|
|
40
|
+
* @returns {string}
|
|
41
|
+
*/
|
|
42
|
+
export function getDefaultBranch() {
|
|
43
|
+
try {
|
|
44
|
+
// Try to get the remote default branch
|
|
45
|
+
const remote = execSync('git remote show origin', { stdio: 'pipe' })
|
|
46
|
+
.toString();
|
|
47
|
+
const match = remote.match(/HEAD branch:\s*(\S+)/);
|
|
48
|
+
if (match) return match[1];
|
|
49
|
+
} catch {
|
|
50
|
+
// Fall through
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Check if main or master exists
|
|
54
|
+
try {
|
|
55
|
+
execSync('git rev-parse --verify main', { stdio: 'pipe' });
|
|
56
|
+
return 'main';
|
|
57
|
+
} catch {
|
|
58
|
+
try {
|
|
59
|
+
execSync('git rev-parse --verify master', { stdio: 'pipe' });
|
|
60
|
+
return 'master';
|
|
61
|
+
} catch {
|
|
62
|
+
return 'main'; // Default fallback
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Get file content from a specific git ref
|
|
69
|
+
* @param {string} filePath
|
|
70
|
+
* @param {string} ref - Git reference (branch, tag, commit)
|
|
71
|
+
* @returns {string|null}
|
|
72
|
+
*/
|
|
73
|
+
export function getFileAtRef(filePath, ref) {
|
|
74
|
+
try {
|
|
75
|
+
return execSync(`git show ${ref}:${filePath}`, { stdio: 'pipe' }).toString();
|
|
76
|
+
} catch {
|
|
77
|
+
return null; // File doesn't exist at that ref
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Get list of changed files between refs
|
|
83
|
+
* @param {string} fromRef
|
|
84
|
+
* @param {string} toRef - Default: HEAD
|
|
85
|
+
* @returns {Array<{file: string, status: string}>}
|
|
86
|
+
*/
|
|
87
|
+
export function getChangedFiles(fromRef, toRef = 'HEAD') {
|
|
88
|
+
try {
|
|
89
|
+
const output = execSync(`git diff --name-status ${fromRef}..${toRef}`, { stdio: 'pipe' })
|
|
90
|
+
.toString()
|
|
91
|
+
.trim();
|
|
92
|
+
|
|
93
|
+
if (!output) return [];
|
|
94
|
+
|
|
95
|
+
return output.split('\n').map(line => {
|
|
96
|
+
const [status, file] = line.split('\t');
|
|
97
|
+
return {
|
|
98
|
+
file,
|
|
99
|
+
status: status === 'A' ? 'added' : status === 'D' ? 'deleted' : 'modified',
|
|
100
|
+
};
|
|
101
|
+
});
|
|
102
|
+
} catch {
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Get commit history for a file
|
|
109
|
+
* @param {string} filePath
|
|
110
|
+
* @param {number} limit
|
|
111
|
+
* @returns {Array<{hash: string, date: string, message: string}>}
|
|
112
|
+
*/
|
|
113
|
+
export function getFileHistory(filePath, limit = 10) {
|
|
114
|
+
try {
|
|
115
|
+
const output = execSync(
|
|
116
|
+
`git log --format="%h|%ci|%s" -n ${limit} -- "${filePath}"`,
|
|
117
|
+
{ stdio: 'pipe' }
|
|
118
|
+
).toString().trim();
|
|
119
|
+
|
|
120
|
+
if (!output) return [];
|
|
121
|
+
|
|
122
|
+
return output.split('\n').map(line => {
|
|
123
|
+
const [hash, date, message] = line.split('|');
|
|
124
|
+
return { hash, date, message };
|
|
125
|
+
});
|
|
126
|
+
} catch {
|
|
127
|
+
return [];
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Compare file content between two refs
|
|
133
|
+
* @param {string} filePath
|
|
134
|
+
* @param {string} fromRef
|
|
135
|
+
* @param {string} toRef
|
|
136
|
+
* @returns {{added: number, removed: number, changes: Array}}
|
|
137
|
+
*/
|
|
138
|
+
export function compareFileVersions(filePath, fromRef, toRef = 'HEAD') {
|
|
139
|
+
const oldContent = getFileAtRef(filePath, fromRef) || '';
|
|
140
|
+
const newContent = toRef === 'HEAD'
|
|
141
|
+
? fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf-8') : ''
|
|
142
|
+
: getFileAtRef(filePath, toRef) || '';
|
|
143
|
+
|
|
144
|
+
const diffs = diffWords(oldContent, newContent);
|
|
145
|
+
|
|
146
|
+
let added = 0;
|
|
147
|
+
let removed = 0;
|
|
148
|
+
const changes = [];
|
|
149
|
+
|
|
150
|
+
for (const part of diffs) {
|
|
151
|
+
if (part.added) {
|
|
152
|
+
added += part.value.split(/\s+/).filter(w => w).length;
|
|
153
|
+
changes.push({ type: 'add', text: part.value });
|
|
154
|
+
} else if (part.removed) {
|
|
155
|
+
removed += part.value.split(/\s+/).filter(w => w).length;
|
|
156
|
+
changes.push({ type: 'remove', text: part.value });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return { added, removed, changes };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Get word count difference between refs
|
|
165
|
+
* @param {string[]} files
|
|
166
|
+
* @param {string} fromRef
|
|
167
|
+
* @param {string} toRef
|
|
168
|
+
* @returns {{total: {added: number, removed: number}, byFile: Object}}
|
|
169
|
+
*/
|
|
170
|
+
export function getWordCountDiff(files, fromRef, toRef = 'HEAD') {
|
|
171
|
+
let totalAdded = 0;
|
|
172
|
+
let totalRemoved = 0;
|
|
173
|
+
const byFile = {};
|
|
174
|
+
|
|
175
|
+
for (const file of files) {
|
|
176
|
+
const { added, removed } = compareFileVersions(file, fromRef, toRef);
|
|
177
|
+
totalAdded += added;
|
|
178
|
+
totalRemoved += removed;
|
|
179
|
+
byFile[file] = { added, removed };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
total: { added: totalAdded, removed: totalRemoved },
|
|
184
|
+
byFile,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Get recent commits
|
|
190
|
+
* @param {number} limit
|
|
191
|
+
* @returns {Array<{hash: string, date: string, message: string, author: string}>}
|
|
192
|
+
*/
|
|
193
|
+
export function getRecentCommits(limit = 10) {
|
|
194
|
+
try {
|
|
195
|
+
const output = execSync(
|
|
196
|
+
`git log --format="%h|%ci|%an|%s" -n ${limit}`,
|
|
197
|
+
{ stdio: 'pipe' }
|
|
198
|
+
).toString().trim();
|
|
199
|
+
|
|
200
|
+
if (!output) return [];
|
|
201
|
+
|
|
202
|
+
return output.split('\n').map(line => {
|
|
203
|
+
const [hash, date, author, message] = line.split('|');
|
|
204
|
+
return { hash, date, author, message };
|
|
205
|
+
});
|
|
206
|
+
} catch {
|
|
207
|
+
return [];
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Check if there are uncommitted changes
|
|
213
|
+
* @returns {boolean}
|
|
214
|
+
*/
|
|
215
|
+
export function hasUncommittedChanges() {
|
|
216
|
+
try {
|
|
217
|
+
const output = execSync('git status --porcelain', { stdio: 'pipe' }).toString();
|
|
218
|
+
return output.trim().length > 0;
|
|
219
|
+
} catch {
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Get tags
|
|
226
|
+
* @returns {string[]}
|
|
227
|
+
*/
|
|
228
|
+
export function getTags() {
|
|
229
|
+
try {
|
|
230
|
+
return execSync('git tag --sort=-creatordate', { stdio: 'pipe' })
|
|
231
|
+
.toString()
|
|
232
|
+
.trim()
|
|
233
|
+
.split('\n')
|
|
234
|
+
.filter(t => t);
|
|
235
|
+
} catch {
|
|
236
|
+
return [];
|
|
237
|
+
}
|
|
238
|
+
}
|
package/lib/journals.js
ADDED
|
@@ -0,0 +1,420 @@
|
|
|
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
|
+
|
|
135
|
+
/**
|
|
136
|
+
* List all available journal profiles
|
|
137
|
+
* @returns {Array<{id: string, name: string, url: string}>}
|
|
138
|
+
*/
|
|
139
|
+
export function listJournals() {
|
|
140
|
+
return Object.entries(JOURNAL_PROFILES).map(([id, profile]) => ({
|
|
141
|
+
id,
|
|
142
|
+
name: profile.name,
|
|
143
|
+
url: profile.url,
|
|
144
|
+
}));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Get a specific journal profile
|
|
149
|
+
* @param {string} journalId
|
|
150
|
+
* @returns {Object|null}
|
|
151
|
+
*/
|
|
152
|
+
export function getJournalProfile(journalId) {
|
|
153
|
+
const normalized = journalId.toLowerCase().replace(/\s+/g, '-');
|
|
154
|
+
return JOURNAL_PROFILES[normalized] || null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Count words in text (excluding markdown syntax)
|
|
159
|
+
* @param {string} text
|
|
160
|
+
* @returns {number}
|
|
161
|
+
*/
|
|
162
|
+
function countWords(text) {
|
|
163
|
+
// Remove markdown syntax
|
|
164
|
+
let clean = text
|
|
165
|
+
.replace(/^---[\s\S]*?---/m, '') // YAML frontmatter
|
|
166
|
+
.replace(/!\[.*?\]\(.*?\)/g, '') // Images
|
|
167
|
+
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // Links
|
|
168
|
+
.replace(/#+\s*/g, '') // Headers
|
|
169
|
+
.replace(/\*\*|__/g, '') // Bold
|
|
170
|
+
.replace(/\*|_/g, '') // Italic
|
|
171
|
+
.replace(/`[^`]+`/g, '') // Inline code
|
|
172
|
+
.replace(/```[\s\S]*?```/g, '') // Code blocks
|
|
173
|
+
.replace(/\{[^}]+\}/g, '') // CriticMarkup and attributes
|
|
174
|
+
.replace(/@\w+:\w+/g, '') // Cross-references
|
|
175
|
+
.replace(/@\w+/g, '') // Citations
|
|
176
|
+
.replace(/\|[^|]+\|/g, ' ') // Table cells
|
|
177
|
+
.replace(/[-=]{3,}/g, '') // Horizontal rules
|
|
178
|
+
.replace(/\n+/g, ' ') // Newlines
|
|
179
|
+
.replace(/\s+/g, ' ') // Multiple spaces
|
|
180
|
+
.trim();
|
|
181
|
+
|
|
182
|
+
return clean.split(/\s+/).filter(w => w.length > 0).length;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Extract abstract from markdown
|
|
187
|
+
* @param {string} text
|
|
188
|
+
* @returns {string|null}
|
|
189
|
+
*/
|
|
190
|
+
function extractAbstract(text) {
|
|
191
|
+
// Try to find abstract section
|
|
192
|
+
const patterns = [
|
|
193
|
+
/^#+\s*Abstract\s*\n([\s\S]*?)(?=^#+|\Z)/mi,
|
|
194
|
+
/^Abstract[:\s]*\n([\s\S]*?)(?=^#+|\n\n)/mi,
|
|
195
|
+
];
|
|
196
|
+
|
|
197
|
+
for (const pattern of patterns) {
|
|
198
|
+
const match = text.match(pattern);
|
|
199
|
+
if (match) {
|
|
200
|
+
return match[1].trim();
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Extract title from markdown
|
|
209
|
+
* @param {string} text
|
|
210
|
+
* @returns {string|null}
|
|
211
|
+
*/
|
|
212
|
+
function extractTitle(text) {
|
|
213
|
+
// Try YAML frontmatter
|
|
214
|
+
const yamlMatch = text.match(/^---\n[\s\S]*?title:\s*["']?([^"'\n]+)["']?[\s\S]*?\n---/m);
|
|
215
|
+
if (yamlMatch) {
|
|
216
|
+
return yamlMatch[1].trim();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Try first H1
|
|
220
|
+
const h1Match = text.match(/^#\s+(.+)$/m);
|
|
221
|
+
if (h1Match) {
|
|
222
|
+
return h1Match[1].trim();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Extract sections from markdown
|
|
230
|
+
* @param {string} text
|
|
231
|
+
* @returns {string[]}
|
|
232
|
+
*/
|
|
233
|
+
function extractSections(text) {
|
|
234
|
+
const sections = [];
|
|
235
|
+
const headerPattern = /^#+\s+(.+)$/gm;
|
|
236
|
+
let match;
|
|
237
|
+
|
|
238
|
+
while ((match = headerPattern.exec(text)) !== null) {
|
|
239
|
+
sections.push(match[1].trim());
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return sections;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Count figures in markdown
|
|
247
|
+
* @param {string} text
|
|
248
|
+
* @returns {number}
|
|
249
|
+
*/
|
|
250
|
+
function countFigures(text) {
|
|
251
|
+
// Count images with figure captions
|
|
252
|
+
const figurePattern = /!\[.*?\]\(.*?\)(\{#fig:[^}]+\})?/g;
|
|
253
|
+
const matches = text.match(figurePattern) || [];
|
|
254
|
+
return matches.length;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Count tables in markdown
|
|
259
|
+
* @param {string} text
|
|
260
|
+
* @returns {number}
|
|
261
|
+
*/
|
|
262
|
+
function countTables(text) {
|
|
263
|
+
// Count tables (lines starting with |)
|
|
264
|
+
const tablePattern = /^\|[^|]+\|/gm;
|
|
265
|
+
const matches = text.match(tablePattern) || [];
|
|
266
|
+
// Divide by approximate rows per table
|
|
267
|
+
return Math.ceil(matches.length / 5);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Count references/citations in markdown
|
|
272
|
+
* @param {string} text
|
|
273
|
+
* @returns {number}
|
|
274
|
+
*/
|
|
275
|
+
function countReferences(text) {
|
|
276
|
+
// Count unique citation keys
|
|
277
|
+
const citationPattern = /@(\w+)/g;
|
|
278
|
+
const citations = new Set();
|
|
279
|
+
let match;
|
|
280
|
+
|
|
281
|
+
while ((match = citationPattern.exec(text)) !== null) {
|
|
282
|
+
// Exclude cross-refs like @fig:label
|
|
283
|
+
if (!match[0].includes(':')) {
|
|
284
|
+
citations.add(match[1]);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return citations.size;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Validate manuscript against journal requirements
|
|
293
|
+
* @param {string} text - Markdown content
|
|
294
|
+
* @param {string} journalId - Journal profile ID
|
|
295
|
+
* @returns {{valid: boolean, errors: string[], warnings: string[], stats: Object}}
|
|
296
|
+
*/
|
|
297
|
+
export function validateManuscript(text, journalId) {
|
|
298
|
+
const profile = getJournalProfile(journalId);
|
|
299
|
+
|
|
300
|
+
if (!profile) {
|
|
301
|
+
return {
|
|
302
|
+
valid: false,
|
|
303
|
+
errors: [`Unknown journal: ${journalId}`],
|
|
304
|
+
warnings: [],
|
|
305
|
+
stats: null,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const req = profile.requirements;
|
|
310
|
+
const errors = [];
|
|
311
|
+
const warnings = [];
|
|
312
|
+
|
|
313
|
+
// Extract content
|
|
314
|
+
const abstract = extractAbstract(text);
|
|
315
|
+
const title = extractTitle(text);
|
|
316
|
+
const sections = extractSections(text);
|
|
317
|
+
const mainWordCount = countWords(text);
|
|
318
|
+
const figureCount = countFigures(text);
|
|
319
|
+
const tableCount = countTables(text);
|
|
320
|
+
const refCount = countReferences(text);
|
|
321
|
+
|
|
322
|
+
const stats = {
|
|
323
|
+
wordCount: mainWordCount,
|
|
324
|
+
abstractWords: abstract ? countWords(abstract) : 0,
|
|
325
|
+
titleChars: title ? title.length : 0,
|
|
326
|
+
figures: figureCount,
|
|
327
|
+
tables: tableCount,
|
|
328
|
+
references: refCount,
|
|
329
|
+
sections: sections.length,
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
// Word limits
|
|
333
|
+
if (req.wordLimit) {
|
|
334
|
+
if (req.wordLimit.main && mainWordCount > req.wordLimit.main) {
|
|
335
|
+
errors.push(`Main text exceeds ${req.wordLimit.main} words (current: ${mainWordCount})`);
|
|
336
|
+
}
|
|
337
|
+
if (req.wordLimit.abstract && abstract) {
|
|
338
|
+
const absWords = countWords(abstract);
|
|
339
|
+
if (absWords > req.wordLimit.abstract) {
|
|
340
|
+
errors.push(`Abstract exceeds ${req.wordLimit.abstract} words (current: ${absWords})`);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
if (req.wordLimit.title && title) {
|
|
344
|
+
if (title.length > req.wordLimit.title) {
|
|
345
|
+
warnings.push(`Title exceeds ${req.wordLimit.title} characters (current: ${title.length})`);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// References
|
|
351
|
+
if (req.references) {
|
|
352
|
+
if (req.references.max && refCount > req.references.max) {
|
|
353
|
+
errors.push(`References exceed ${req.references.max} (current: ${refCount})`);
|
|
354
|
+
}
|
|
355
|
+
if (req.references.doiRequired) {
|
|
356
|
+
warnings.push('DOI required for all references - run "rev doi check" to verify');
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Figures/tables
|
|
361
|
+
if (req.figures) {
|
|
362
|
+
const totalVisual = req.figures.combinedWithTables
|
|
363
|
+
? figureCount + tableCount
|
|
364
|
+
: figureCount;
|
|
365
|
+
const label = req.figures.combinedWithTables ? 'figures + tables' : 'figures';
|
|
366
|
+
|
|
367
|
+
if (req.figures.max && totalVisual > req.figures.max) {
|
|
368
|
+
errors.push(`${label} exceed ${req.figures.max} (current: ${totalVisual})`);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Required sections
|
|
373
|
+
if (req.sections?.required) {
|
|
374
|
+
for (const reqSection of req.sections.required) {
|
|
375
|
+
const found = sections.some(s =>
|
|
376
|
+
s.toLowerCase().includes(reqSection.toLowerCase())
|
|
377
|
+
);
|
|
378
|
+
if (!found) {
|
|
379
|
+
warnings.push(`Missing required section: ${reqSection}`);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Data availability
|
|
385
|
+
if (req.dataAvailability) {
|
|
386
|
+
const hasDataStatement = sections.some(s =>
|
|
387
|
+
s.toLowerCase().includes('data') ||
|
|
388
|
+
text.toLowerCase().includes('data availability') ||
|
|
389
|
+
text.toLowerCase().includes('data statement')
|
|
390
|
+
);
|
|
391
|
+
if (!hasDataStatement) {
|
|
392
|
+
warnings.push('Data availability statement may be required');
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
return {
|
|
397
|
+
valid: errors.length === 0,
|
|
398
|
+
errors,
|
|
399
|
+
warnings,
|
|
400
|
+
stats,
|
|
401
|
+
journal: profile.name,
|
|
402
|
+
url: profile.url,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Validate multiple files against journal requirements
|
|
408
|
+
* @param {string[]} files - Markdown file paths
|
|
409
|
+
* @param {string} journalId - Journal profile ID
|
|
410
|
+
* @returns {Object}
|
|
411
|
+
*/
|
|
412
|
+
export function validateProject(files, journalId) {
|
|
413
|
+
// Combine all file contents
|
|
414
|
+
const combined = files
|
|
415
|
+
.filter(f => fs.existsSync(f))
|
|
416
|
+
.map(f => fs.readFileSync(f, 'utf-8'))
|
|
417
|
+
.join('\n\n');
|
|
418
|
+
|
|
419
|
+
return validateManuscript(combined, journalId);
|
|
420
|
+
}
|