docrev 0.3.0 → 0.5.1
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/.rev-dictionary +4 -0
- package/CHANGELOG.md +76 -0
- package/README.md +78 -0
- package/bin/rev.js +1652 -0
- package/completions/rev.bash +127 -0
- package/completions/rev.zsh +207 -0
- package/lib/annotations.js +75 -12
- package/lib/build.js +12 -1
- package/lib/doi.js +6 -2
- package/lib/equations.js +29 -12
- package/lib/grammar.js +290 -0
- package/lib/import.js +6 -1
- package/lib/journals.js +185 -0
- package/lib/merge.js +3 -3
- package/lib/scientific-words.js +73 -0
- package/lib/spelling.js +350 -0
- package/lib/trackchanges.js +159 -203
- package/lib/variables.js +173 -0
- package/package.json +79 -2
- package/skill/REFERENCE.md +279 -0
- package/skill/SKILL.md +137 -0
- package/types/index.d.ts +525 -0
- package/CLAUDE.md +0 -75
package/lib/trackchanges.js
CHANGED
|
@@ -1,37 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Track
|
|
3
|
-
*
|
|
2
|
+
* Track changes module - Apply markdown annotations as Word track changes
|
|
3
|
+
*
|
|
4
|
+
* Converts CriticMarkup annotations to Word OOXML track changes format.
|
|
4
5
|
*/
|
|
5
6
|
|
|
6
7
|
import * as fs from 'fs';
|
|
7
8
|
import * as path from 'path';
|
|
9
|
+
import { execSync } from 'child_process';
|
|
8
10
|
import AdmZip from 'adm-zip';
|
|
9
|
-
import { parseAnnotations } from './annotations.js';
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Generate a unique revision ID
|
|
13
|
-
* @returns {number}
|
|
14
|
-
*/
|
|
15
|
-
let revisionId = 0;
|
|
16
|
-
function getNextRevId() {
|
|
17
|
-
return revisionId++;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Format date for Word revision
|
|
22
|
-
* @returns {string}
|
|
23
|
-
*/
|
|
24
|
-
function getRevisionDate() {
|
|
25
|
-
return new Date().toISOString().replace('Z', '');
|
|
26
|
-
}
|
|
27
11
|
|
|
28
12
|
/**
|
|
29
13
|
* Escape XML special characters
|
|
30
|
-
* @param {string} text
|
|
31
|
-
* @returns {string}
|
|
32
14
|
*/
|
|
33
|
-
function escapeXml(
|
|
34
|
-
return
|
|
15
|
+
function escapeXml(str) {
|
|
16
|
+
return str
|
|
35
17
|
.replace(/&/g, '&')
|
|
36
18
|
.replace(/</g, '<')
|
|
37
19
|
.replace(/>/g, '>')
|
|
@@ -40,71 +22,87 @@ function escapeXml(text) {
|
|
|
40
22
|
}
|
|
41
23
|
|
|
42
24
|
/**
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
* @param {string} author - Author name
|
|
46
|
-
* @returns {string}
|
|
47
|
-
*/
|
|
48
|
-
function createInsertionXml(text, author = 'Author') {
|
|
49
|
-
const id = getNextRevId();
|
|
50
|
-
const date = getRevisionDate();
|
|
51
|
-
|
|
52
|
-
return `<w:ins w:id="${id}" w:author="${escapeXml(author)}" w:date="${date}"><w:r><w:t>${escapeXml(text)}</w:t></w:r></w:ins>`;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Create Word deletion markup
|
|
57
|
-
* @param {string} text - Text to delete
|
|
58
|
-
* @param {string} author - Author name
|
|
59
|
-
* @returns {string}
|
|
60
|
-
*/
|
|
61
|
-
function createDeletionXml(text, author = 'Author') {
|
|
62
|
-
const id = getNextRevId();
|
|
63
|
-
const date = getRevisionDate();
|
|
64
|
-
|
|
65
|
-
return `<w:del w:id="${id}" w:author="${escapeXml(author)}" w:date="${date}"><w:r><w:delText>${escapeXml(text)}</w:delText></w:r></w:del>`;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* Convert CriticMarkup to Word track changes in markdown
|
|
70
|
-
* This creates a special markdown format that can be processed after pandoc
|
|
25
|
+
* Prepare text with CriticMarkup annotations for track changes
|
|
26
|
+
* Replaces annotations with markers that can be processed in DOCX
|
|
71
27
|
*
|
|
72
|
-
* @param {string} text -
|
|
73
|
-
* @
|
|
28
|
+
* @param {string} text - Text with CriticMarkup annotations
|
|
29
|
+
* @param {object} options - Options
|
|
30
|
+
* @param {string} options.author - Default author for track changes
|
|
31
|
+
* @returns {{text: string, markers: Array}} Processed text and marker info
|
|
74
32
|
*/
|
|
75
|
-
export function prepareForTrackChanges(text) {
|
|
76
|
-
const
|
|
33
|
+
export function prepareForTrackChanges(text, options = {}) {
|
|
34
|
+
const { author = 'Reviewer' } = options;
|
|
77
35
|
const markers = [];
|
|
78
|
-
|
|
79
|
-
// Sort by position descending to replace from end
|
|
80
|
-
const sorted = [...annotations].sort((a, b) => b.position - a.position);
|
|
36
|
+
let markerId = 0;
|
|
81
37
|
|
|
82
38
|
let result = text;
|
|
83
39
|
|
|
84
|
-
|
|
85
|
-
|
|
40
|
+
// Process insertions: {++text++}
|
|
41
|
+
result = result.replace(/\{\+\+(.+?)\+\+\}/gs, (match, content) => {
|
|
42
|
+
const id = markerId++;
|
|
43
|
+
markers.push({
|
|
44
|
+
id,
|
|
45
|
+
type: 'insert',
|
|
46
|
+
content,
|
|
47
|
+
author,
|
|
48
|
+
});
|
|
49
|
+
return `{{TC_${id}}}`;
|
|
50
|
+
});
|
|
86
51
|
|
|
52
|
+
// Process deletions: {--text--}
|
|
53
|
+
result = result.replace(/\{--(.+?)--\}/gs, (match, content) => {
|
|
54
|
+
const id = markerId++;
|
|
87
55
|
markers.push({
|
|
88
|
-
id
|
|
89
|
-
type:
|
|
90
|
-
content
|
|
91
|
-
|
|
92
|
-
author: ann.author || 'Reviewer',
|
|
56
|
+
id,
|
|
57
|
+
type: 'delete',
|
|
58
|
+
content,
|
|
59
|
+
author,
|
|
93
60
|
});
|
|
61
|
+
return `{{TC_${id}}}`;
|
|
62
|
+
});
|
|
94
63
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
64
|
+
// Process substitutions: {~~old~>new~~}
|
|
65
|
+
result = result.replace(/\{~~(.+?)~>(.+?)~~\}/gs, (match, old, replacement) => {
|
|
66
|
+
const id = markerId++;
|
|
67
|
+
markers.push({
|
|
68
|
+
id,
|
|
69
|
+
type: 'substitute',
|
|
70
|
+
content: old,
|
|
71
|
+
replacement,
|
|
72
|
+
author,
|
|
73
|
+
});
|
|
74
|
+
return `{{TC_${id}}}`;
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Process comments: {>>Author: comment<<}
|
|
78
|
+
result = result.replace(/\{>>(.+?)<<\}/gs, (match, content) => {
|
|
79
|
+
const id = markerId++;
|
|
80
|
+
// Extract author if present (format: "Author: comment")
|
|
81
|
+
const colonIdx = content.indexOf(':');
|
|
82
|
+
let commentAuthor = author;
|
|
83
|
+
let commentText = content;
|
|
84
|
+
if (colonIdx > 0 && colonIdx < 30) {
|
|
85
|
+
commentAuthor = content.slice(0, colonIdx).trim();
|
|
86
|
+
commentText = content.slice(colonIdx + 1).trim();
|
|
87
|
+
}
|
|
88
|
+
markers.push({
|
|
89
|
+
id,
|
|
90
|
+
type: 'comment',
|
|
91
|
+
content: commentText,
|
|
92
|
+
author: commentAuthor,
|
|
93
|
+
});
|
|
94
|
+
return `{{TC_${id}}}`;
|
|
95
|
+
});
|
|
98
96
|
|
|
99
97
|
return { text: result, markers };
|
|
100
98
|
}
|
|
101
99
|
|
|
102
100
|
/**
|
|
103
|
-
*
|
|
101
|
+
* Apply track changes markers to a Word document
|
|
104
102
|
*
|
|
105
|
-
* @param {string} docxPath - Path to DOCX file
|
|
103
|
+
* @param {string} docxPath - Path to input DOCX file
|
|
106
104
|
* @param {Array} markers - Markers from prepareForTrackChanges
|
|
107
|
-
* @param {string} outputPath -
|
|
105
|
+
* @param {string} outputPath - Path for output DOCX file
|
|
108
106
|
* @returns {Promise<{success: boolean, message: string}>}
|
|
109
107
|
*/
|
|
110
108
|
export async function applyTrackChangesToDocx(docxPath, markers, outputPath) {
|
|
@@ -112,162 +110,120 @@ export async function applyTrackChangesToDocx(docxPath, markers, outputPath) {
|
|
|
112
110
|
return { success: false, message: `File not found: ${docxPath}` };
|
|
113
111
|
}
|
|
114
112
|
|
|
113
|
+
let zip;
|
|
115
114
|
try {
|
|
116
|
-
|
|
117
|
-
|
|
115
|
+
zip = new AdmZip(docxPath);
|
|
116
|
+
} catch (err) {
|
|
117
|
+
return { success: false, message: `Invalid DOCX file: ${err.message}` };
|
|
118
|
+
}
|
|
118
119
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
120
|
+
// Read document.xml
|
|
121
|
+
const docEntry = zip.getEntry('word/document.xml');
|
|
122
|
+
if (!docEntry) {
|
|
123
|
+
return { success: false, message: 'Invalid DOCX: no document.xml' };
|
|
124
|
+
}
|
|
122
125
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
// Enable track changes in settings
|
|
126
|
-
const settingsEntry = zip.getEntry('word/settings.xml');
|
|
127
|
-
if (settingsEntry) {
|
|
128
|
-
let settingsXml = zip.readAsText(settingsEntry);
|
|
129
|
-
// Add trackRevisions setting if not present
|
|
130
|
-
if (!settingsXml.includes('w:trackRevisions')) {
|
|
131
|
-
settingsXml = settingsXml.replace(
|
|
132
|
-
'</w:settings>',
|
|
133
|
-
'<w:trackRevisions/></w:settings>'
|
|
134
|
-
);
|
|
135
|
-
zip.updateFile('word/settings.xml', Buffer.from(settingsXml, 'utf-8'));
|
|
136
|
-
}
|
|
137
|
-
}
|
|
126
|
+
let documentXml = zip.readAsText(docEntry);
|
|
138
127
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
const markerText = `{{TC_${marker.id}}}`;
|
|
142
|
-
|
|
143
|
-
// Find the marker in document.xml (may be split across runs)
|
|
144
|
-
// First try simple replacement
|
|
145
|
-
if (documentXml.includes(markerText)) {
|
|
146
|
-
let replacement;
|
|
147
|
-
|
|
148
|
-
switch (marker.type) {
|
|
149
|
-
case 'insert':
|
|
150
|
-
replacement = createInsertionXml(marker.content, marker.author);
|
|
151
|
-
break;
|
|
152
|
-
case 'delete':
|
|
153
|
-
replacement = createDeletionXml(marker.content, marker.author);
|
|
154
|
-
break;
|
|
155
|
-
case 'substitute':
|
|
156
|
-
// Substitution = deletion + insertion
|
|
157
|
-
replacement =
|
|
158
|
-
createDeletionXml(marker.content, marker.author) +
|
|
159
|
-
createInsertionXml(marker.replacement, marker.author);
|
|
160
|
-
break;
|
|
161
|
-
case 'comment':
|
|
162
|
-
// Comments are handled differently - skip for track changes
|
|
163
|
-
replacement = '';
|
|
164
|
-
break;
|
|
165
|
-
default:
|
|
166
|
-
replacement = '';
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
documentXml = documentXml.replace(markerText, replacement);
|
|
170
|
-
} else {
|
|
171
|
-
// Marker might be split across <w:t> elements
|
|
172
|
-
// Try to find and reconstruct
|
|
173
|
-
const markerPattern = markerText.split('').join('(?:</w:t></w:r><w:r><w:t>)?');
|
|
174
|
-
const regex = new RegExp(markerPattern, 'g');
|
|
175
|
-
|
|
176
|
-
if (regex.test(documentXml)) {
|
|
177
|
-
let replacement;
|
|
178
|
-
|
|
179
|
-
switch (marker.type) {
|
|
180
|
-
case 'insert':
|
|
181
|
-
replacement = `</w:t></w:r>${createInsertionXml(marker.content, marker.author)}<w:r><w:t>`;
|
|
182
|
-
break;
|
|
183
|
-
case 'delete':
|
|
184
|
-
replacement = `</w:t></w:r>${createDeletionXml(marker.content, marker.author)}<w:r><w:t>`;
|
|
185
|
-
break;
|
|
186
|
-
case 'substitute':
|
|
187
|
-
replacement =
|
|
188
|
-
`</w:t></w:r>${createDeletionXml(marker.content, marker.author)}` +
|
|
189
|
-
`${createInsertionXml(marker.replacement, marker.author)}<w:r><w:t>`;
|
|
190
|
-
break;
|
|
191
|
-
default:
|
|
192
|
-
replacement = '';
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
documentXml = documentXml.replace(regex, replacement);
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
}
|
|
128
|
+
// Generate ISO date for track changes
|
|
129
|
+
const now = new Date().toISOString();
|
|
199
130
|
|
|
200
|
-
|
|
201
|
-
|
|
131
|
+
// Replace markers with track change XML
|
|
132
|
+
for (const marker of markers) {
|
|
133
|
+
const placeholder = `{{TC_${marker.id}}}`;
|
|
134
|
+
let replacement = '';
|
|
135
|
+
|
|
136
|
+
const escapedContent = escapeXml(marker.content);
|
|
137
|
+
const escapedAuthor = escapeXml(marker.author);
|
|
138
|
+
|
|
139
|
+
if (marker.type === 'insert') {
|
|
140
|
+
replacement = `<w:ins w:id="${marker.id}" w:author="${escapedAuthor}" w:date="${now}"><w:r><w:t>${escapedContent}</w:t></w:r></w:ins>`;
|
|
141
|
+
} else if (marker.type === 'delete') {
|
|
142
|
+
replacement = `<w:del w:id="${marker.id}" w:author="${escapedAuthor}" w:date="${now}"><w:r><w:delText>${escapedContent}</w:delText></w:r></w:del>`;
|
|
143
|
+
} else if (marker.type === 'substitute') {
|
|
144
|
+
const escapedReplacement = escapeXml(marker.replacement);
|
|
145
|
+
replacement = `<w:del w:id="${marker.id}" w:author="${escapedAuthor}" w:date="${now}"><w:r><w:delText>${escapedContent}</w:delText></w:r></w:del><w:ins w:id="${marker.id + 1000}" w:author="${escapedAuthor}" w:date="${now}"><w:r><w:t>${escapedReplacement}</w:t></w:r></w:ins>`;
|
|
146
|
+
}
|
|
202
147
|
|
|
203
|
-
|
|
204
|
-
|
|
148
|
+
documentXml = documentXml.replace(placeholder, replacement);
|
|
149
|
+
}
|
|
205
150
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
151
|
+
// Update document.xml
|
|
152
|
+
zip.updateFile('word/document.xml', Buffer.from(documentXml));
|
|
153
|
+
|
|
154
|
+
// Enable track revisions in settings.xml
|
|
155
|
+
const settingsEntry = zip.getEntry('word/settings.xml');
|
|
156
|
+
if (settingsEntry) {
|
|
157
|
+
let settingsXml = zip.readAsText(settingsEntry);
|
|
158
|
+
if (!settingsXml.includes('w:trackRevisions')) {
|
|
159
|
+
settingsXml = settingsXml.replace(
|
|
160
|
+
'</w:settings>',
|
|
161
|
+
'<w:trackRevisions/></w:settings>'
|
|
162
|
+
);
|
|
163
|
+
zip.updateFile('word/settings.xml', Buffer.from(settingsXml));
|
|
164
|
+
}
|
|
209
165
|
}
|
|
166
|
+
|
|
167
|
+
// Write output
|
|
168
|
+
zip.writeZip(outputPath);
|
|
169
|
+
|
|
170
|
+
return { success: true, message: `Created ${outputPath} with track changes` };
|
|
210
171
|
}
|
|
211
172
|
|
|
212
173
|
/**
|
|
213
|
-
* Build
|
|
214
|
-
* This is the main entry point for the audit export feature
|
|
174
|
+
* Build a Word document with track changes from annotated markdown
|
|
215
175
|
*
|
|
216
|
-
* @param {string}
|
|
217
|
-
* @param {string}
|
|
218
|
-
* @param {
|
|
219
|
-
* @
|
|
176
|
+
* @param {string} mdPath - Path to markdown file with CriticMarkup
|
|
177
|
+
* @param {string} docxPath - Output path for Word document
|
|
178
|
+
* @param {object} options - Options
|
|
179
|
+
* @param {string} options.author - Author name for track changes
|
|
180
|
+
* @returns {Promise<{success: boolean, message: string}>}
|
|
220
181
|
*/
|
|
221
|
-
export async function buildWithTrackChanges(
|
|
222
|
-
const { author = '
|
|
182
|
+
export async function buildWithTrackChanges(mdPath, docxPath, options = {}) {
|
|
183
|
+
const { author = 'Author' } = options;
|
|
223
184
|
|
|
224
|
-
if (!fs.existsSync(
|
|
225
|
-
return { success: false, message: `File not found: ${
|
|
185
|
+
if (!fs.existsSync(mdPath)) {
|
|
186
|
+
return { success: false, message: `File not found: ${mdPath}` };
|
|
226
187
|
}
|
|
227
188
|
|
|
228
|
-
const
|
|
229
|
-
const { text: preparedText, markers } = prepareForTrackChanges(text);
|
|
189
|
+
const content = fs.readFileSync(mdPath, 'utf-8');
|
|
230
190
|
|
|
231
|
-
//
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
191
|
+
// Prepare for track changes
|
|
192
|
+
const { text: prepared, markers } = prepareForTrackChanges(content, { author });
|
|
193
|
+
|
|
194
|
+
// If no annotations, just build normally
|
|
195
|
+
if (markers.length === 0) {
|
|
196
|
+
try {
|
|
197
|
+
execSync(`pandoc "${mdPath}" -o "${docxPath}"`, { encoding: 'utf-8' });
|
|
198
|
+
return { success: true, message: `Created ${docxPath}` };
|
|
199
|
+
} catch (err) {
|
|
200
|
+
return { success: false, message: err.message };
|
|
235
201
|
}
|
|
236
202
|
}
|
|
237
203
|
|
|
238
|
-
// Write
|
|
239
|
-
const
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
fs.writeFileSync(tempMd, preparedText, 'utf-8');
|
|
243
|
-
|
|
244
|
-
// Run pandoc to create initial DOCX
|
|
245
|
-
const { execSync } = await import('child_process');
|
|
204
|
+
// Write prepared content to temp file
|
|
205
|
+
const tempDir = path.dirname(mdPath);
|
|
206
|
+
const tempMd = path.join(tempDir, `.temp-${Date.now()}.md`);
|
|
207
|
+
const tempDocx = path.join(tempDir, `.temp-${Date.now()}.docx`);
|
|
246
208
|
|
|
247
209
|
try {
|
|
248
|
-
|
|
249
|
-
} catch (err) {
|
|
250
|
-
fs.unlinkSync(tempMd);
|
|
251
|
-
return { success: false, message: `Pandoc failed: ${err.message}`, stats: null };
|
|
252
|
-
}
|
|
210
|
+
fs.writeFileSync(tempMd, prepared, 'utf-8');
|
|
253
211
|
|
|
254
|
-
|
|
255
|
-
|
|
212
|
+
// Build with pandoc
|
|
213
|
+
execSync(`pandoc "${tempMd}" -o "${tempDocx}"`, { encoding: 'utf-8' });
|
|
256
214
|
|
|
257
|
-
|
|
258
|
-
|
|
215
|
+
// Apply track changes
|
|
216
|
+
const result = await applyTrackChangesToDocx(tempDocx, markers, docxPath);
|
|
217
|
+
|
|
218
|
+
// Clean up temp files
|
|
259
219
|
fs.unlinkSync(tempMd);
|
|
260
220
|
fs.unlinkSync(tempDocx);
|
|
261
|
-
} catch {
|
|
262
|
-
// Ignore cleanup errors
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
const stats = {
|
|
266
|
-
insertions: markers.filter(m => m.type === 'insert').length,
|
|
267
|
-
deletions: markers.filter(m => m.type === 'delete').length,
|
|
268
|
-
substitutions: markers.filter(m => m.type === 'substitute').length,
|
|
269
|
-
total: markers.length,
|
|
270
|
-
};
|
|
271
221
|
|
|
272
|
-
|
|
222
|
+
return result;
|
|
223
|
+
} catch (err) {
|
|
224
|
+
// Clean up on error
|
|
225
|
+
if (fs.existsSync(tempMd)) fs.unlinkSync(tempMd);
|
|
226
|
+
if (fs.existsSync(tempDocx)) fs.unlinkSync(tempDocx);
|
|
227
|
+
return { success: false, message: err.message };
|
|
228
|
+
}
|
|
273
229
|
}
|
package/lib/variables.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Template variable substitution for rev
|
|
3
|
+
*
|
|
4
|
+
* Supported variables:
|
|
5
|
+
* {{date}} - Current date (YYYY-MM-DD)
|
|
6
|
+
* {{date:format}} - Custom date format (e.g., {{date:MMMM D, YYYY}})
|
|
7
|
+
* {{version}} - Version from rev.yaml
|
|
8
|
+
* {{word_count}} - Total word count
|
|
9
|
+
* {{author}} - First author name
|
|
10
|
+
* {{authors}} - All authors (comma-separated)
|
|
11
|
+
* {{title}} - Document title
|
|
12
|
+
* {{year}} - Current year
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import * as fs from 'fs';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Format date with simple pattern
|
|
19
|
+
* @param {Date} date
|
|
20
|
+
* @param {string} format - Pattern (YYYY, MM, DD, MMMM, MMM, D)
|
|
21
|
+
* @returns {string}
|
|
22
|
+
*/
|
|
23
|
+
function formatDate(date, format = 'YYYY-MM-DD') {
|
|
24
|
+
const months = [
|
|
25
|
+
'January', 'February', 'March', 'April', 'May', 'June',
|
|
26
|
+
'July', 'August', 'September', 'October', 'November', 'December'
|
|
27
|
+
];
|
|
28
|
+
const monthsShort = [
|
|
29
|
+
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
|
30
|
+
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
const year = date.getFullYear();
|
|
34
|
+
const month = date.getMonth();
|
|
35
|
+
const day = date.getDate();
|
|
36
|
+
|
|
37
|
+
// Use placeholders to avoid replacement conflicts (e.g., D in December)
|
|
38
|
+
return format
|
|
39
|
+
.replace('YYYY', '\x00YEAR\x00')
|
|
40
|
+
.replace('MMMM', '\x00MONTHFULL\x00')
|
|
41
|
+
.replace('MMM', '\x00MONTHSHORT\x00')
|
|
42
|
+
.replace('MM', '\x00MONTHNUM\x00')
|
|
43
|
+
.replace('DD', '\x00DAYPAD\x00')
|
|
44
|
+
.replace(/\bD\b/, '\x00DAY\x00')
|
|
45
|
+
.replace('\x00YEAR\x00', year.toString())
|
|
46
|
+
.replace('\x00MONTHFULL\x00', months[month])
|
|
47
|
+
.replace('\x00MONTHSHORT\x00', monthsShort[month])
|
|
48
|
+
.replace('\x00MONTHNUM\x00', (month + 1).toString().padStart(2, '0'))
|
|
49
|
+
.replace('\x00DAYPAD\x00', day.toString().padStart(2, '0'))
|
|
50
|
+
.replace('\x00DAY\x00', day.toString());
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Count words in text (excluding markdown syntax)
|
|
55
|
+
* @param {string} text
|
|
56
|
+
* @returns {number}
|
|
57
|
+
*/
|
|
58
|
+
function countWords(text) {
|
|
59
|
+
return text
|
|
60
|
+
.replace(/^---[\s\S]*?---/m, '') // Remove frontmatter
|
|
61
|
+
.replace(/!\[.*?\]\(.*?\)/g, '') // Remove images
|
|
62
|
+
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // Keep link text
|
|
63
|
+
.replace(/#+\s*/g, '') // Remove headers
|
|
64
|
+
.replace(/\*\*|__|[*_`]/g, '') // Remove formatting
|
|
65
|
+
.replace(/```[\s\S]*?```/g, '') // Remove code blocks
|
|
66
|
+
.replace(/\{[^}]+\}/g, '') // Remove annotations
|
|
67
|
+
.replace(/@\w+:\w+/g, '') // Remove refs
|
|
68
|
+
.replace(/@\w+/g, '') // Remove citations
|
|
69
|
+
.replace(/\|[^|]+\|/g, ' ') // Remove tables
|
|
70
|
+
.replace(/\n+/g, ' ')
|
|
71
|
+
.trim()
|
|
72
|
+
.split(/\s+/)
|
|
73
|
+
.filter(w => w.length > 0).length;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Get first author name from authors array
|
|
78
|
+
* @param {Array|string} authors
|
|
79
|
+
* @returns {string}
|
|
80
|
+
*/
|
|
81
|
+
function getFirstAuthor(authors) {
|
|
82
|
+
if (!authors || authors.length === 0) return '';
|
|
83
|
+
|
|
84
|
+
const first = Array.isArray(authors) ? authors[0] : authors;
|
|
85
|
+
|
|
86
|
+
if (typeof first === 'string') return first;
|
|
87
|
+
if (first.name) return first.name;
|
|
88
|
+
|
|
89
|
+
return '';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Get all author names
|
|
94
|
+
* @param {Array|string} authors
|
|
95
|
+
* @returns {string}
|
|
96
|
+
*/
|
|
97
|
+
function getAllAuthors(authors) {
|
|
98
|
+
if (!authors) return '';
|
|
99
|
+
if (typeof authors === 'string') return authors;
|
|
100
|
+
|
|
101
|
+
return authors
|
|
102
|
+
.map(a => typeof a === 'string' ? a : a.name)
|
|
103
|
+
.filter(Boolean)
|
|
104
|
+
.join(', ');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Process template variables in text
|
|
109
|
+
* @param {string} text - Text with {{variable}} placeholders
|
|
110
|
+
* @param {object} config - rev.yaml config
|
|
111
|
+
* @param {object} options - Additional options
|
|
112
|
+
* @param {string[]} options.sections - Section file contents for word count
|
|
113
|
+
* @returns {string} Text with variables replaced
|
|
114
|
+
*/
|
|
115
|
+
export function processVariables(text, config = {}, options = {}) {
|
|
116
|
+
const now = new Date();
|
|
117
|
+
let result = text;
|
|
118
|
+
|
|
119
|
+
// Calculate word count from sections if provided
|
|
120
|
+
let wordCount = 0;
|
|
121
|
+
if (options.sectionContents) {
|
|
122
|
+
for (const content of options.sectionContents) {
|
|
123
|
+
wordCount += countWords(content);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// {{date}} - Current date
|
|
128
|
+
result = result.replace(/\{\{date\}\}/g, formatDate(now));
|
|
129
|
+
|
|
130
|
+
// {{date:format}} - Custom date format
|
|
131
|
+
result = result.replace(/\{\{date:([^}]+)\}\}/g, (match, format) => {
|
|
132
|
+
return formatDate(now, format);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// {{year}} - Current year
|
|
136
|
+
result = result.replace(/\{\{year\}\}/g, now.getFullYear().toString());
|
|
137
|
+
|
|
138
|
+
// {{version}} - From config
|
|
139
|
+
result = result.replace(/\{\{version\}\}/g, config.version || '');
|
|
140
|
+
|
|
141
|
+
// {{title}} - Document title
|
|
142
|
+
result = result.replace(/\{\{title\}\}/g, config.title || '');
|
|
143
|
+
|
|
144
|
+
// {{author}} - First author
|
|
145
|
+
result = result.replace(/\{\{author\}\}/g, getFirstAuthor(config.authors));
|
|
146
|
+
|
|
147
|
+
// {{authors}} - All authors
|
|
148
|
+
result = result.replace(/\{\{authors\}\}/g, getAllAuthors(config.authors));
|
|
149
|
+
|
|
150
|
+
// {{word_count}} - Total word count
|
|
151
|
+
result = result.replace(/\{\{word_count\}\}/g, wordCount.toLocaleString());
|
|
152
|
+
|
|
153
|
+
return result;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Check if text contains any template variables
|
|
158
|
+
* @param {string} text
|
|
159
|
+
* @returns {boolean}
|
|
160
|
+
*/
|
|
161
|
+
export function hasVariables(text) {
|
|
162
|
+
return /\{\{[^}]+\}\}/.test(text);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* List all variables found in text
|
|
167
|
+
* @param {string} text
|
|
168
|
+
* @returns {string[]}
|
|
169
|
+
*/
|
|
170
|
+
export function findVariables(text) {
|
|
171
|
+
const matches = text.match(/\{\{([^}]+)\}\}/g) || [];
|
|
172
|
+
return [...new Set(matches.map(m => m.slice(2, -2)))];
|
|
173
|
+
}
|