docrev 0.6.13 → 0.7.6

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/orcid.js ADDED
@@ -0,0 +1,149 @@
1
+ /**
2
+ * ORCID integration for author metadata
3
+ *
4
+ * Fetches author information from ORCID public API
5
+ */
6
+
7
+ /**
8
+ * Validate ORCID format (0000-0000-0000-0000)
9
+ * @param {string} orcid
10
+ * @returns {boolean}
11
+ */
12
+ export function isValidOrcid(orcid) {
13
+ return /^(\d{4}-){3}\d{3}[\dX]$/i.test(orcid);
14
+ }
15
+
16
+ /**
17
+ * Clean ORCID input (removes URLs, whitespace)
18
+ * @param {string} input
19
+ * @returns {string}
20
+ */
21
+ export function cleanOrcid(input) {
22
+ if (!input) return '';
23
+
24
+ // Remove URL prefix if present
25
+ let clean = input.trim()
26
+ .replace(/^https?:\/\/(www\.)?orcid\.org\//i, '')
27
+ .replace(/^orcid\.org\//i, '')
28
+ .trim();
29
+
30
+ return clean;
31
+ }
32
+
33
+ /**
34
+ * Fetch author info from ORCID public API
35
+ * @param {string} orcid
36
+ * @returns {Promise<{name: string, affiliation: string, email: string, works: number}>}
37
+ */
38
+ export async function fetchOrcidProfile(orcid) {
39
+ const cleanId = cleanOrcid(orcid);
40
+
41
+ if (!isValidOrcid(cleanId)) {
42
+ throw new Error(`Invalid ORCID format: ${orcid}`);
43
+ }
44
+
45
+ const url = `https://pub.orcid.org/v3.0/${cleanId}/person`;
46
+
47
+ const response = await fetch(url, {
48
+ headers: {
49
+ 'Accept': 'application/json',
50
+ },
51
+ });
52
+
53
+ if (!response.ok) {
54
+ if (response.status === 404) {
55
+ throw new Error(`ORCID not found: ${cleanId}`);
56
+ }
57
+ throw new Error(`ORCID API error: ${response.status}`);
58
+ }
59
+
60
+ const data = await response.json();
61
+
62
+ // Extract name
63
+ const nameData = data.name;
64
+ let name = '';
65
+ if (nameData) {
66
+ const given = nameData['given-names']?.value || '';
67
+ const family = nameData['family-name']?.value || '';
68
+ name = `${given} ${family}`.trim();
69
+ }
70
+
71
+ // Extract primary affiliation
72
+ let affiliation = '';
73
+ const affiliations = data.employments?.['affiliation-group'] || [];
74
+ if (affiliations.length > 0) {
75
+ const primary = affiliations[0]?.summaries?.[0]?.['employment-summary'];
76
+ affiliation = primary?.organization?.name || '';
77
+ }
78
+
79
+ // Extract email (if public)
80
+ let email = '';
81
+ const emails = data.emails?.email || [];
82
+ const primaryEmail = emails.find(e => e.primary) || emails[0];
83
+ if (primaryEmail?.email) {
84
+ email = primaryEmail.email;
85
+ }
86
+
87
+ return {
88
+ orcid: cleanId,
89
+ name,
90
+ affiliation,
91
+ email,
92
+ };
93
+ }
94
+
95
+ /**
96
+ * Fetch work count from ORCID
97
+ * @param {string} orcid
98
+ * @returns {Promise<number>}
99
+ */
100
+ export async function fetchOrcidWorkCount(orcid) {
101
+ const cleanId = cleanOrcid(orcid);
102
+
103
+ if (!isValidOrcid(cleanId)) {
104
+ throw new Error(`Invalid ORCID format: ${orcid}`);
105
+ }
106
+
107
+ const url = `https://pub.orcid.org/v3.0/${cleanId}/works`;
108
+
109
+ const response = await fetch(url, {
110
+ headers: {
111
+ 'Accept': 'application/json',
112
+ },
113
+ });
114
+
115
+ if (!response.ok) {
116
+ return 0;
117
+ }
118
+
119
+ const data = await response.json();
120
+ return data.group?.length || 0;
121
+ }
122
+
123
+ /**
124
+ * Format author for YAML
125
+ * @param {object} profile
126
+ * @returns {string}
127
+ */
128
+ export function formatAuthorYaml(profile) {
129
+ const lines = [];
130
+ lines.push(` - name: ${profile.name}`);
131
+ if (profile.affiliation) {
132
+ lines.push(` affiliation: ${profile.affiliation}`);
133
+ }
134
+ if (profile.email) {
135
+ lines.push(` email: ${profile.email}`);
136
+ }
137
+ lines.push(` orcid: ${profile.orcid}`);
138
+ return lines.join('\n');
139
+ }
140
+
141
+ /**
142
+ * Generate ORCID badge markdown
143
+ * @param {string} orcid
144
+ * @returns {string}
145
+ */
146
+ export function getOrcidBadge(orcid) {
147
+ const cleanId = cleanOrcid(orcid);
148
+ return `[![ORCID](https://img.shields.io/badge/ORCID-${cleanId}-a6ce39)](https://orcid.org/${cleanId})`;
149
+ }
@@ -0,0 +1,217 @@
1
+ /**
2
+ * PDF comment rendering for dual export
3
+ *
4
+ * Converts CriticMarkup comments to LaTeX margin notes for PDF output
5
+ */
6
+
7
+ /**
8
+ * LaTeX preamble for margin comments
9
+ * Uses todonotes package with custom styling
10
+ */
11
+ export const MARGIN_NOTES_PREAMBLE = `
12
+ % Margin notes for comments
13
+ \\usepackage[colorinlistoftodos,textsize=scriptsize]{todonotes}
14
+ \\usepackage{xcolor}
15
+
16
+ % Define comment colors by author
17
+ \\definecolor{commentblue}{RGB}{59, 130, 246}
18
+ \\definecolor{commentgreen}{RGB}{34, 197, 94}
19
+ \\definecolor{commentorange}{RGB}{249, 115, 22}
20
+ \\definecolor{commentpurple}{RGB}{168, 85, 247}
21
+ \\definecolor{commentgray}{RGB}{107, 114, 128}
22
+
23
+ % Custom margin note command
24
+ \\newcommand{\\margincomment}[2][]{%
25
+ \\todo[linecolor=commentblue,backgroundcolor=commentblue!10,bordercolor=commentblue,size=\\scriptsize,#1]{#2}%
26
+ }
27
+
28
+ % Author-specific commands
29
+ \\newcommand{\\reviewercomment}[2]{%
30
+ \\todo[linecolor=commentgreen,backgroundcolor=commentgreen!10,bordercolor=commentgreen,size=\\scriptsize]{\\textbf{#1:} #2}%
31
+ }
32
+
33
+ % Increase margin for notes (if needed)
34
+ % \\setlength{\\marginparwidth}{2.5cm}
35
+ `;
36
+
37
+ /**
38
+ * Simpler preamble using marginpar (no extra packages needed)
39
+ */
40
+ export const SIMPLE_MARGIN_PREAMBLE = `
41
+ % Simple margin notes for comments
42
+ \\usepackage{xcolor}
43
+ \\definecolor{commentcolor}{RGB}{59, 130, 246}
44
+
45
+ \\newcommand{\\margincomment}[1]{%
46
+ \\marginpar{\\raggedright\\scriptsize\\textcolor{commentcolor}{#1}}%
47
+ }
48
+ `;
49
+
50
+ /**
51
+ * Convert CriticMarkup comments to LaTeX margin notes
52
+ * {>>Author: comment text<<} -> \margincomment{Author: comment text}
53
+ *
54
+ * @param {string} markdown - Markdown with CriticMarkup comments
55
+ * @param {object} options - { useTodonotes: boolean, stripResolved: boolean }
56
+ * @returns {{markdown: string, commentCount: number, preamble: string}}
57
+ */
58
+ export function convertCommentsToMarginNotes(markdown, options = {}) {
59
+ const { useTodonotes = true, stripResolved = true } = options;
60
+
61
+ let commentCount = 0;
62
+
63
+ // Pattern for CriticMarkup comments: {>>author: text<<} or {>>text<<}
64
+ // Also handle resolved comments: {>>✓ author: text<<}
65
+ const commentPattern = /\{>>(✓\s*)?([^<]+)<<\}/g;
66
+
67
+ const converted = markdown.replace(commentPattern, (match, resolved, content) => {
68
+ // Skip resolved comments if requested
69
+ if (resolved && stripResolved) {
70
+ return '';
71
+ }
72
+
73
+ commentCount++;
74
+
75
+ // Escape LaTeX special characters
76
+ const escaped = escapeLatex(content.trim());
77
+
78
+ if (useTodonotes) {
79
+ // Check if content has author prefix (Author: text)
80
+ const authorMatch = escaped.match(/^([^:]+):\s*(.+)$/s);
81
+ if (authorMatch) {
82
+ const [, author, text] = authorMatch;
83
+ return `\\reviewercomment{${author}}{${text}}`;
84
+ }
85
+ return `\\margincomment{${escaped}}`;
86
+ } else {
87
+ return `\\margincomment{${escaped}}`;
88
+ }
89
+ });
90
+
91
+ const preamble = useTodonotes ? MARGIN_NOTES_PREAMBLE : SIMPLE_MARGIN_PREAMBLE;
92
+
93
+ return {
94
+ markdown: converted,
95
+ commentCount,
96
+ preamble,
97
+ };
98
+ }
99
+
100
+ /**
101
+ * Escape LaTeX special characters
102
+ * @param {string} text
103
+ * @returns {string}
104
+ */
105
+ function escapeLatex(text) {
106
+ return text
107
+ .replace(/\\/g, '\\textbackslash{}')
108
+ .replace(/([#$%&_{}])/g, '\\$1')
109
+ .replace(/\^/g, '\\textasciicircum{}')
110
+ .replace(/~/g, '\\textasciitilde{}')
111
+ .replace(/\n/g, ' '); // Replace newlines with spaces
112
+ }
113
+
114
+ /**
115
+ * Convert track changes to visible LaTeX formatting
116
+ * {++inserted++} -> \textcolor{green}{inserted}
117
+ * {--deleted--} -> \textcolor{red}{\sout{deleted}}
118
+ * {~~old~>new~~} -> \textcolor{red}{\sout{old}}\textcolor{green}{new}
119
+ *
120
+ * @param {string} markdown
121
+ * @returns {{markdown: string, preamble: string}}
122
+ */
123
+ export function convertTrackChangesToLatex(markdown) {
124
+ let result = markdown;
125
+
126
+ // Insertions: {++text++} -> green text
127
+ result = result.replace(/\{\+\+([^+]+)\+\+\}/g, (match, text) => {
128
+ return `\\textcolor{green}{${escapeLatex(text)}}`;
129
+ });
130
+
131
+ // Deletions: {--text--} -> red strikethrough
132
+ result = result.replace(/\{--([^-]+)--\}/g, (match, text) => {
133
+ return `\\textcolor{red}{\\sout{${escapeLatex(text)}}}`;
134
+ });
135
+
136
+ // Substitutions: {~~old~>new~~} -> red strikethrough + green new
137
+ result = result.replace(/\{~~([^~]+)~>([^~]+)~~\}/g, (match, oldText, newText) => {
138
+ return `\\textcolor{red}{\\sout{${escapeLatex(oldText)}}}\\textcolor{green}{${escapeLatex(newText)}}`;
139
+ });
140
+
141
+ const preamble = `
142
+ % Track changes visualization
143
+ \\usepackage{xcolor}
144
+ \\usepackage[normalem]{ulem}
145
+ \\definecolor{green}{RGB}{34, 197, 94}
146
+ \\definecolor{red}{RGB}{239, 68, 68}
147
+ `;
148
+
149
+ return { markdown: result, preamble };
150
+ }
151
+
152
+ /**
153
+ * Get combined preamble for comments and track changes
154
+ * @param {object} options - { comments: boolean, trackChanges: boolean, useTodonotes: boolean }
155
+ * @returns {string}
156
+ */
157
+ export function getCombinedPreamble(options = {}) {
158
+ const { comments = true, trackChanges = false, useTodonotes = true } = options;
159
+
160
+ let preamble = '';
161
+
162
+ if (comments) {
163
+ preamble += useTodonotes ? MARGIN_NOTES_PREAMBLE : SIMPLE_MARGIN_PREAMBLE;
164
+ }
165
+
166
+ if (trackChanges) {
167
+ preamble += `
168
+ % Track changes visualization
169
+ \\usepackage[normalem]{ulem}
170
+ `;
171
+ if (!comments) {
172
+ preamble += `\\usepackage{xcolor}\n`;
173
+ }
174
+ preamble += `
175
+ \\definecolor{insertgreen}{RGB}{34, 197, 94}
176
+ \\definecolor{deletered}{RGB}{239, 68, 68}
177
+ `;
178
+ }
179
+
180
+ return preamble;
181
+ }
182
+
183
+ /**
184
+ * Prepare markdown for PDF with visible comments
185
+ * Converts comments to margin notes and optionally shows track changes
186
+ *
187
+ * @param {string} markdown
188
+ * @param {object} options - { showTrackChanges: boolean, useTodonotes: boolean }
189
+ * @returns {{markdown: string, preamble: string, commentCount: number}}
190
+ */
191
+ export function prepareMarkdownForAnnotatedPdf(markdown, options = {}) {
192
+ const { showTrackChanges = false, useTodonotes = true, stripResolved = true } = options;
193
+
194
+ let result = markdown;
195
+ let preamble = '';
196
+ let commentCount = 0;
197
+
198
+ // Convert comments to margin notes
199
+ const commentResult = convertCommentsToMarginNotes(result, { useTodonotes, stripResolved });
200
+ result = commentResult.markdown;
201
+ commentCount = commentResult.commentCount;
202
+ preamble += commentResult.preamble;
203
+
204
+ // Optionally show track changes
205
+ if (showTrackChanges) {
206
+ const trackResult = convertTrackChangesToLatex(result);
207
+ result = trackResult.markdown;
208
+ // Add ulem package if not already in todonotes preamble
209
+ if (!useTodonotes) {
210
+ preamble += trackResult.preamble;
211
+ } else {
212
+ preamble += `\\usepackage[normalem]{ulem}\n`;
213
+ }
214
+ }
215
+
216
+ return { markdown: result, preamble, commentCount };
217
+ }