docrev 0.9.11 → 0.9.14

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.
Files changed (138) hide show
  1. package/.claude/settings.local.json +9 -9
  2. package/.gitattributes +1 -1
  3. package/CHANGELOG.md +149 -149
  4. package/PLAN-tables-and-postprocess.md +850 -850
  5. package/README.md +391 -391
  6. package/bin/rev.js +11 -11
  7. package/bin/rev.ts +145 -145
  8. package/completions/rev.bash +127 -127
  9. package/completions/rev.ps1 +210 -210
  10. package/completions/rev.zsh +207 -207
  11. package/dev_notes/stress2/build_adversarial.ts +186 -186
  12. package/dev_notes/stress2/drift_matcher.ts +62 -62
  13. package/dev_notes/stress2/probe_anchors.ts +35 -35
  14. package/dev_notes/stress2/project/discussion.before.md +3 -3
  15. package/dev_notes/stress2/project/discussion.md +3 -3
  16. package/dev_notes/stress2/project/methods.before.md +20 -20
  17. package/dev_notes/stress2/project/methods.md +20 -20
  18. package/dev_notes/stress2/project/rev.yaml +5 -5
  19. package/dev_notes/stress2/project/sections.yaml +4 -4
  20. package/dev_notes/stress2/sections.yaml +5 -5
  21. package/dev_notes/stress2/trace_placement.ts +50 -50
  22. package/dev_notes/stresstest_boundaries.ts +27 -27
  23. package/dev_notes/stresstest_drift_apply.ts +43 -43
  24. package/dev_notes/stresstest_drift_compare.ts +43 -43
  25. package/dev_notes/stresstest_drift_v2.ts +54 -54
  26. package/dev_notes/stresstest_inspect.ts +54 -54
  27. package/dev_notes/stresstest_pstyle.ts +55 -55
  28. package/dev_notes/stresstest_section_debug.ts +23 -23
  29. package/dev_notes/stresstest_split.ts +70 -70
  30. package/dev_notes/stresstest_trace.ts +19 -19
  31. package/dev_notes/stresstest_verify_no_overwrite.ts +40 -40
  32. package/dist/lib/build.d.ts +50 -1
  33. package/dist/lib/build.d.ts.map +1 -1
  34. package/dist/lib/build.js +80 -30
  35. package/dist/lib/build.js.map +1 -1
  36. package/dist/lib/commands/build.d.ts.map +1 -1
  37. package/dist/lib/commands/build.js +38 -5
  38. package/dist/lib/commands/build.js.map +1 -1
  39. package/dist/lib/commands/utilities.js +164 -164
  40. package/dist/lib/commands/word-tools.js +8 -8
  41. package/dist/lib/grammar.js +3 -3
  42. package/dist/lib/import.d.ts.map +1 -1
  43. package/dist/lib/import.js +146 -24
  44. package/dist/lib/import.js.map +1 -1
  45. package/dist/lib/pdf-comments.js +44 -44
  46. package/dist/lib/plugins.js +57 -57
  47. package/dist/lib/pptx-themes.js +115 -115
  48. package/dist/lib/spelling.js +2 -2
  49. package/dist/lib/templates.js +387 -387
  50. package/dist/lib/themes.js +51 -51
  51. package/dist/lib/types.d.ts +20 -0
  52. package/dist/lib/types.d.ts.map +1 -1
  53. package/dist/lib/word-extraction.d.ts +6 -0
  54. package/dist/lib/word-extraction.d.ts.map +1 -1
  55. package/dist/lib/word-extraction.js +46 -3
  56. package/dist/lib/word-extraction.js.map +1 -1
  57. package/dist/lib/wordcomments.d.ts.map +1 -1
  58. package/dist/lib/wordcomments.js +23 -5
  59. package/dist/lib/wordcomments.js.map +1 -1
  60. package/eslint.config.js +27 -27
  61. package/lib/anchor-match.ts +276 -276
  62. package/lib/annotations.ts +644 -644
  63. package/lib/build.ts +1300 -1227
  64. package/lib/citations.ts +160 -160
  65. package/lib/commands/build.ts +833 -801
  66. package/lib/commands/citations.ts +515 -515
  67. package/lib/commands/comments.ts +1050 -1050
  68. package/lib/commands/context.ts +174 -174
  69. package/lib/commands/core.ts +309 -309
  70. package/lib/commands/doi.ts +435 -435
  71. package/lib/commands/file-ops.ts +372 -372
  72. package/lib/commands/history.ts +320 -320
  73. package/lib/commands/index.ts +87 -87
  74. package/lib/commands/init.ts +259 -259
  75. package/lib/commands/merge-resolve.ts +378 -378
  76. package/lib/commands/preview.ts +178 -178
  77. package/lib/commands/project-info.ts +244 -244
  78. package/lib/commands/quality.ts +517 -517
  79. package/lib/commands/response.ts +454 -454
  80. package/lib/commands/section-boundaries.ts +82 -82
  81. package/lib/commands/sections.ts +451 -451
  82. package/lib/commands/sync.ts +706 -706
  83. package/lib/commands/text-ops.ts +449 -449
  84. package/lib/commands/utilities.ts +448 -448
  85. package/lib/commands/verify-anchors.ts +272 -272
  86. package/lib/commands/word-tools.ts +340 -340
  87. package/lib/comment-realign.ts +517 -517
  88. package/lib/config.ts +84 -84
  89. package/lib/crossref.ts +781 -781
  90. package/lib/csl.ts +191 -191
  91. package/lib/dependencies.ts +98 -98
  92. package/lib/diff-engine.ts +465 -465
  93. package/lib/doi-cache.ts +115 -115
  94. package/lib/doi.ts +897 -897
  95. package/lib/equations.ts +506 -506
  96. package/lib/errors.ts +346 -346
  97. package/lib/format.ts +541 -541
  98. package/lib/git.ts +326 -326
  99. package/lib/grammar.ts +303 -303
  100. package/lib/image-registry.ts +180 -180
  101. package/lib/import.ts +911 -792
  102. package/lib/journals.ts +543 -543
  103. package/lib/merge.ts +633 -633
  104. package/lib/orcid.ts +144 -144
  105. package/lib/pdf-comments.ts +263 -263
  106. package/lib/pdf-import.ts +524 -524
  107. package/lib/plugins.ts +362 -362
  108. package/lib/postprocess.ts +188 -188
  109. package/lib/pptx-color-filter.lua +37 -37
  110. package/lib/pptx-template.ts +469 -469
  111. package/lib/pptx-themes.ts +483 -483
  112. package/lib/protect-restore.ts +520 -520
  113. package/lib/rate-limiter.ts +94 -94
  114. package/lib/response.ts +197 -197
  115. package/lib/restore-references.ts +240 -240
  116. package/lib/review.ts +327 -327
  117. package/lib/schema.ts +417 -417
  118. package/lib/scientific-words.ts +73 -73
  119. package/lib/sections.ts +335 -335
  120. package/lib/slides.ts +756 -756
  121. package/lib/spelling.ts +334 -334
  122. package/lib/templates.ts +526 -526
  123. package/lib/themes.ts +742 -742
  124. package/lib/trackchanges.ts +247 -247
  125. package/lib/tui.ts +450 -450
  126. package/lib/types.ts +550 -530
  127. package/lib/undo.ts +250 -250
  128. package/lib/utils.ts +69 -69
  129. package/lib/variables.ts +179 -179
  130. package/lib/word-extraction.ts +806 -759
  131. package/lib/word.ts +643 -643
  132. package/lib/wordcomments.ts +817 -798
  133. package/package.json +137 -137
  134. package/scripts/postbuild.js +28 -28
  135. package/skill/REFERENCE.md +431 -431
  136. package/skill/SKILL.md +258 -258
  137. package/tsconfig.json +26 -26
  138. package/types/index.d.ts +525 -525
@@ -1,801 +1,833 @@
1
- /**
2
- * Build commands: build, install, doctor, refs, migrate
3
- *
4
- * Commands for building documents and managing dependencies.
5
- */
6
-
7
- import {
8
- chalk,
9
- fs,
10
- path,
11
- fmt,
12
- findFiles,
13
- stripAnnotations,
14
- buildRegistry,
15
- detectHardcodedRefs,
16
- convertHardcodedRefs,
17
- getRefStatus,
18
- formatRegistry,
19
- build,
20
- loadBuildConfig,
21
- hasPandoc,
22
- hasPandocCrossref,
23
- formatBuildResults,
24
- getUserName,
25
- } from './context.js';
26
- import type { Command } from 'commander';
27
- import * as readline from 'readline';
28
-
29
- interface RefsOptions {
30
- dir: string;
31
- }
32
-
33
- interface MigrateOptions {
34
- dir: string;
35
- auto?: boolean;
36
- dryRun?: boolean;
37
- }
38
-
39
- interface InstallOptions {
40
- check?: boolean;
41
- }
42
-
43
- interface BuildOptions {
44
- dir: string;
45
- journal?: string;
46
- crossref?: boolean;
47
- toc?: boolean;
48
- showChanges?: boolean;
49
- dual?: boolean;
50
- reference?: string;
51
- theme?: string;
52
- colortheme?: string;
53
- aspectratio?: string;
54
- verbose?: boolean;
55
- }
56
-
57
- /**
58
- * Register build commands with the program
59
- */
60
- export function register(program: Command, pkg?: { version?: string }): void {
61
- // ==========================================================================
62
- // REFS command - Show figure/table reference status
63
- // ==========================================================================
64
-
65
- program
66
- .command('refs')
67
- .description('Show figure/table reference registry and status')
68
- .argument('[file]', 'Optional file to analyze for references')
69
- .option('-d, --dir <directory>', 'Directory to scan for anchors', '.')
70
- .action((file: string | undefined, options: RefsOptions) => {
71
- const dir = path.resolve(options.dir);
72
-
73
- if (!fs.existsSync(dir)) {
74
- console.error(chalk.red(`Directory not found: ${dir}`));
75
- process.exit(1);
76
- }
77
-
78
- console.log(chalk.cyan('Building figure/table registry...\n'));
79
-
80
- const registry = buildRegistry(dir);
81
-
82
- console.log(chalk.bold('Registry:'));
83
- console.log(formatRegistry(registry));
84
-
85
- if (file) {
86
- if (!fs.existsSync(file)) {
87
- console.error(chalk.red(`\nFile not found: ${file}`));
88
- process.exit(1);
89
- }
90
-
91
- const text = fs.readFileSync(file, 'utf-8');
92
- const status = getRefStatus(text, registry);
93
-
94
- console.log(chalk.cyan(`\nReferences in ${path.basename(file)}:\n`));
95
-
96
- if (status.dynamic.length > 0) {
97
- console.log(chalk.green(` Dynamic (@fig:, @tbl:): ${status.dynamic.length}`));
98
- for (const ref of status.dynamic.slice(0, 5)) {
99
- console.log(chalk.dim(` ${ref.match}`));
100
- }
101
- if (status.dynamic.length > 5) {
102
- console.log(chalk.dim(` ... and ${status.dynamic.length - 5} more`));
103
- }
104
- }
105
-
106
- if (status.hardcoded.length > 0) {
107
- console.log(chalk.yellow(`\n Hardcoded (Figure 1, Table 2): ${status.hardcoded.length}`));
108
- for (const ref of status.hardcoded.slice(0, 5)) {
109
- console.log(chalk.dim(` "${ref.match}"`));
110
- }
111
- if (status.hardcoded.length > 5) {
112
- console.log(chalk.dim(` ... and ${status.hardcoded.length - 5} more`));
113
- }
114
- console.log(chalk.cyan(`\n Run ${chalk.bold(`rev migrate ${file}`)} to convert to dynamic refs`));
115
- }
116
-
117
- if (status.dynamic.length === 0 && status.hardcoded.length === 0) {
118
- console.log(chalk.dim(' No figure/table references found.'));
119
- }
120
- }
121
- });
122
-
123
- // ==========================================================================
124
- // MIGRATE command - Convert hardcoded refs to dynamic
125
- // ==========================================================================
126
-
127
- program
128
- .command('migrate')
129
- .description('Convert hardcoded figure/table refs to dynamic @-syntax')
130
- .argument('<file>', 'Markdown file to migrate')
131
- .option('-d, --dir <directory>', 'Directory for registry', '.')
132
- .option('--auto', 'Auto-convert without prompting')
133
- .option('--dry-run', 'Preview without saving')
134
- .action(async (file: string, options: MigrateOptions) => {
135
- if (!fs.existsSync(file)) {
136
- console.error(chalk.red(`File not found: ${file}`));
137
- process.exit(1);
138
- }
139
-
140
- const dir = path.resolve(options.dir);
141
- console.log(chalk.cyan('Building figure/table registry...\n'));
142
-
143
- const registry = buildRegistry(dir);
144
- const text = fs.readFileSync(file, 'utf-8');
145
- const refs = detectHardcodedRefs(text);
146
-
147
- if (refs.length === 0) {
148
- console.log(chalk.green('No hardcoded references found.'));
149
- return;
150
- }
151
-
152
- console.log(chalk.yellow(`Found ${refs.length} hardcoded reference(s):\n`));
153
-
154
- if (options.auto) {
155
- const { converted, conversions, warnings } = convertHardcodedRefs(text, registry);
156
-
157
- for (const w of warnings) {
158
- console.log(chalk.yellow(` Warning: ${w}`));
159
- }
160
-
161
- for (const c of conversions) {
162
- console.log(chalk.green(` "${c.from}" → ${c.to}`));
163
- }
164
-
165
- if (options.dryRun) {
166
- console.log(chalk.yellow('\n(Dry run - no changes saved)'));
167
- } else if (conversions.length > 0) {
168
- fs.writeFileSync(file, converted, 'utf-8');
169
- console.log(chalk.green(`\nConverted ${conversions.length} reference(s) in ${file}`));
170
- }
171
- } else {
172
- const rl = readline.createInterface({
173
- input: process.stdin,
174
- output: process.stdout,
175
- });
176
-
177
- let result = text;
178
- let converted = 0;
179
- let skipped = 0;
180
-
181
- const askQuestion = (prompt: string): Promise<string> =>
182
- new Promise((resolve) => rl.question(prompt, resolve));
183
-
184
- const sortedRefs = [...refs].sort((a, b) => b.position - a.position);
185
-
186
- for (const ref of sortedRefs) {
187
- const num = ref.numbers[0];
188
- const { numberToLabel } = await import('../crossref.js');
189
- const label = numberToLabel(ref.type, num.num, num.isSupp, registry);
190
-
191
- if (!label) {
192
- console.log(chalk.yellow(` "${ref.match}" - no matching anchor found, skipping`));
193
- skipped++;
194
- continue;
195
- }
196
-
197
- const replacement = `@${ref.type}:${label}`;
198
- console.log(`\n ${chalk.yellow(`"${ref.match}"`)} → ${chalk.green(replacement)}`);
199
-
200
- const answer = await askQuestion(chalk.cyan(' Convert? [y/n/a/q] '));
201
-
202
- if (answer.toLowerCase() === 'q') {
203
- console.log(chalk.dim(' Quitting...'));
204
- break;
205
- } else if (answer.toLowerCase() === 'a') {
206
- result = result.slice(0, ref.position) + replacement + result.slice(ref.position + ref.match.length);
207
- converted++;
208
-
209
- for (const remaining of sortedRefs.slice(sortedRefs.indexOf(ref) + 1)) {
210
- const rNum = remaining.numbers[0];
211
- const rLabel = numberToLabel(remaining.type, rNum.num, rNum.isSupp, registry);
212
- if (rLabel) {
213
- const rReplacement = `@${remaining.type}:${rLabel}`;
214
- result = result.slice(0, remaining.position) + rReplacement + result.slice(remaining.position + remaining.match.length);
215
- converted++;
216
- console.log(chalk.green(` "${remaining.match}" → ${rReplacement}`));
217
- }
218
- }
219
- break;
220
- } else if (answer.toLowerCase() === 'y') {
221
- result = result.slice(0, ref.position) + replacement + result.slice(ref.position + ref.match.length);
222
- converted++;
223
- } else {
224
- skipped++;
225
- }
226
- }
227
-
228
- rl.close();
229
-
230
- console.log(chalk.cyan(`\nConverted: ${converted}, Skipped: ${skipped}`));
231
-
232
- if (converted > 0 && !options.dryRun) {
233
- fs.writeFileSync(file, result, 'utf-8');
234
- console.log(chalk.green(`Saved ${file}`));
235
- } else if (options.dryRun) {
236
- console.log(chalk.yellow('(Dry run - no changes saved)'));
237
- }
238
- }
239
- });
240
-
241
- // ==========================================================================
242
- // INSTALL command - Install dependencies
243
- // ==========================================================================
244
-
245
- program
246
- .command('install')
247
- .description('Check and install dependencies (pandoc-crossref)')
248
- .option('--check', 'Only check, don\'t install')
249
- .action(async (options: InstallOptions) => {
250
- const os = await import('os');
251
- const { execSync } = await import('child_process');
252
- const platform = os.platform();
253
-
254
- console.log(chalk.cyan('Checking dependencies...\n'));
255
-
256
- let hasPandocInstalled = false;
257
- try {
258
- const version = execSync('pandoc --version', { encoding: 'utf-8' }).split('\n')[0];
259
- console.log(chalk.green(` ✓ ${version}`));
260
- hasPandocInstalled = true;
261
- } catch {
262
- console.log(chalk.red(' ✗ pandoc not found'));
263
- }
264
-
265
- let hasCrossref = false;
266
- try {
267
- const version = execSync('pandoc-crossref --version', { encoding: 'utf-8' }).split('\n')[0];
268
- console.log(chalk.green(` ✓ pandoc-crossref ${version}`));
269
- hasCrossref = true;
270
- } catch {
271
- console.log(chalk.yellow(' ✗ pandoc-crossref not found'));
272
- }
273
-
274
- console.log('');
275
-
276
- if (hasPandocInstalled && hasCrossref) {
277
- console.log(chalk.green('All dependencies installed!'));
278
- return;
279
- }
280
-
281
- if (options.check) {
282
- if (!hasCrossref) {
283
- console.log(chalk.yellow('pandoc-crossref is optional but recommended for @fig: references.'));
284
- }
285
- return;
286
- }
287
-
288
- if (!hasPandocInstalled || !hasCrossref) {
289
- console.log(chalk.cyan('Installation options:\n'));
290
-
291
- if (platform === 'darwin') {
292
- console.log(chalk.bold('macOS (Homebrew):'));
293
- if (!hasPandocInstalled) console.log(chalk.dim(' brew install pandoc'));
294
- if (!hasCrossref) console.log(chalk.dim(' brew install pandoc-crossref'));
295
- console.log('');
296
- } else if (platform === 'win32') {
297
- console.log(chalk.bold('Windows (Chocolatey):'));
298
- if (!hasPandocInstalled) console.log(chalk.dim(' choco install pandoc'));
299
- if (!hasCrossref) console.log(chalk.dim(' choco install pandoc-crossref'));
300
- console.log('');
301
- console.log(chalk.bold('Windows (Scoop):'));
302
- if (!hasPandocInstalled) console.log(chalk.dim(' scoop install pandoc'));
303
- if (!hasCrossref) console.log(chalk.dim(' scoop install pandoc-crossref'));
304
- console.log('');
305
- } else {
306
- console.log(chalk.bold('Linux (apt):'));
307
- if (!hasPandocInstalled) console.log(chalk.dim(' sudo apt install pandoc'));
308
- console.log('');
309
- }
310
-
311
- console.log(chalk.bold('Cross-platform (conda):'));
312
- if (!hasPandocInstalled) console.log(chalk.dim(' conda install -c conda-forge pandoc'));
313
- if (!hasCrossref) console.log(chalk.dim(' conda install -c conda-forge pandoc-crossref'));
314
- console.log('');
315
-
316
- if (!hasCrossref) {
317
- console.log(chalk.bold('Manual download:'));
318
- console.log(chalk.dim(' https://github.com/lierdakil/pandoc-crossref/releases'));
319
- console.log('');
320
- }
321
-
322
- try {
323
- execSync('conda --version', { encoding: 'utf-8', stdio: 'pipe' });
324
- console.log(chalk.cyan('Conda detected. Install missing dependencies? [y/N] '));
325
-
326
- const rl = readline.createInterface({
327
- input: process.stdin,
328
- output: process.stdout,
329
- });
330
-
331
- rl.question('', (answer) => {
332
- rl.close();
333
- if (answer.toLowerCase() === 'y') {
334
- console.log(chalk.cyan('\nInstalling via conda...'));
335
- try {
336
- if (!hasPandocInstalled) {
337
- console.log(chalk.dim(' Installing pandoc...'));
338
- execSync('conda install -y -c conda-forge pandoc', { stdio: 'inherit' });
339
- }
340
- if (!hasCrossref) {
341
- console.log(chalk.dim(' Installing pandoc-crossref...'));
342
- execSync('conda install -y -c conda-forge pandoc-crossref', { stdio: 'inherit' });
343
- }
344
- console.log(chalk.green('\nDone! Run "rev install --check" to verify.'));
345
- } catch (err) {
346
- const error = err as Error;
347
- console.log(chalk.red(`\nInstallation failed: ${error.message}`));
348
- console.log(chalk.dim('Try installing manually with the commands above.'));
349
- }
350
- }
351
- });
352
- } catch {
353
- // Conda not available
354
- }
355
- }
356
- });
357
-
358
- // ==========================================================================
359
- // DOCTOR command - Diagnose setup and configuration issues
360
- // ==========================================================================
361
-
362
- program
363
- .command('doctor')
364
- .description('Diagnose setup and configuration issues')
365
- .action(async () => {
366
- const os = await import('os');
367
- const { execSync } = await import('child_process');
368
-
369
- const version = pkg?.version || 'unknown';
370
- console.log(chalk.bold.cyan(`\n rev doctor`) + chalk.dim(` v${version}\n`));
371
- console.log(chalk.dim(` ${os.platform()} ${os.release()} | Node ${process.version}\n`));
372
-
373
- let issues = 0;
374
- let warnings = 0;
375
-
376
- console.log(chalk.bold(' Environment'));
377
- console.log(chalk.dim(' ─────────────────────────────────'));
378
-
379
- const nodeVer = parseInt(process.version.slice(1).split('.')[0], 10);
380
- if (nodeVer >= 18) {
381
- console.log(chalk.green(' ✓') + ` Node.js ${process.version}`);
382
- } else {
383
- console.log(chalk.red(' ✗') + ` Node.js ${process.version} (requires >=18)`);
384
- issues++;
385
- }
386
-
387
- try {
388
- const pandocVer = execSync('pandoc --version', { encoding: 'utf-8' }).split('\n')[0];
389
- console.log(chalk.green(' ✓') + ` ${pandocVer}`);
390
- } catch {
391
- console.log(chalk.red(' ✗') + ' pandoc not found');
392
- issues++;
393
- }
394
-
395
- try {
396
- const crossrefVer = execSync('pandoc-crossref --version', { encoding: 'utf-8' }).split('\n')[0];
397
- console.log(chalk.green(' ✓') + ` pandoc-crossref ${crossrefVer}`);
398
- } catch {
399
- console.log(chalk.yellow(' !') + ' pandoc-crossref not found (optional)');
400
- warnings++;
401
- }
402
-
403
- console.log();
404
- console.log(chalk.bold(' Project'));
405
- console.log(chalk.dim(' ─────────────────────────────────'));
406
-
407
- const configPath = path.join(process.cwd(), 'rev.yaml');
408
- if (fs.existsSync(configPath)) {
409
- console.log(chalk.green(' ✓') + ' rev.yaml found');
410
-
411
- try {
412
- const { default: YAML } = await import('yaml');
413
- const config = YAML.parse(fs.readFileSync(configPath, 'utf-8'));
414
-
415
- if (config.title) {
416
- console.log(chalk.green(' ✓') + ` Title: ${config.title}`);
417
- } else {
418
- console.log(chalk.yellow(' !') + ' No title in rev.yaml');
419
- warnings++;
420
- }
421
-
422
- if (config.sections && config.sections.length > 0) {
423
- console.log(chalk.green(' ✓') + ` Sections: ${config.sections.length} defined`);
424
-
425
- let missing = 0;
426
- for (const sec of config.sections) {
427
- const secFile = typeof sec === 'string' ? sec : sec.file;
428
- if (!fs.existsSync(secFile)) missing++;
429
- }
430
- if (missing > 0) {
431
- console.log(chalk.yellow(' !') + ` ${missing} section file(s) missing`);
432
- warnings++;
433
- }
434
- }
435
-
436
- if (config.bibliography) {
437
- if (fs.existsSync(config.bibliography)) {
438
- console.log(chalk.green(' ✓') + ` Bibliography: ${config.bibliography}`);
439
- } else {
440
- console.log(chalk.yellow(' !') + ` Bibliography file not found: ${config.bibliography}`);
441
- warnings++;
442
- }
443
- }
444
- } catch (e) {
445
- const error = e as Error;
446
- console.log(chalk.red(' ✗') + ` rev.yaml parse error: ${error.message}`);
447
- issues++;
448
- }
449
- } else {
450
- console.log(chalk.dim(' ·') + ' No rev.yaml (not a rev project)');
451
- }
452
-
453
- const mdFiles = findFiles('.md');
454
- if (mdFiles.length > 0) {
455
- console.log(chalk.green(' ✓') + ` Markdown files: ${mdFiles.length}`);
456
- } else {
457
- console.log(chalk.dim(' ·') + ' No markdown files');
458
- }
459
-
460
- console.log();
461
- if (issues === 0 && warnings === 0) {
462
- console.log(chalk.green.bold(' All checks passed! ✓\n'));
463
- } else if (issues === 0) {
464
- console.log(chalk.yellow(` ${warnings} warning(s), no critical issues\n`));
465
- } else {
466
- console.log(chalk.red(` ${issues} issue(s), ${warnings} warning(s)\n`));
467
- console.log(chalk.dim(' Run "rev install" to fix missing dependencies.\n'));
468
- }
469
- });
470
-
471
- // ==========================================================================
472
- // BUILD command - Combine sections and run pandoc
473
- // ==========================================================================
474
-
475
- program
476
- .command('build')
477
- .alias('b')
478
- .description('Build PDF/DOCX/TEX/PPTX/Beamer from sections')
479
- .argument('[formats...]', 'Output formats: pdf, docx, tex, beamer, pptx, all', ['pdf', 'docx'])
480
- .option('-d, --dir <directory>', 'Project directory', '.')
481
- .option('-j, --journal <name>', 'Use journal profile for build formatting defaults')
482
- .option('--no-crossref', 'Skip pandoc-crossref filter')
483
- .option('--toc', 'Include table of contents')
484
- .option('--show-changes', 'Export DOCX with visible track changes (audit mode)')
485
- .option('--dual', 'Output both clean version and annotated version (with comments)')
486
- .option('--reference <docx>', 'Reference DOCX for comment position alignment (use with --dual)')
487
- .option('--theme <name>', 'Beamer theme (default, metropolis, etc.)')
488
- .option('--colortheme <name>', 'Beamer color theme')
489
- .option('--aspectratio <ratio>', 'Beamer aspect ratio (169, 43)')
490
- .option('--verbose', 'Show detailed output including postprocess scripts')
491
- .action(async (formats: string[], options: BuildOptions) => {
492
- const dir = path.resolve(options.dir);
493
-
494
- if (!fs.existsSync(dir)) {
495
- console.error(chalk.red(`Directory not found: ${dir}`));
496
- process.exit(1);
497
- }
498
-
499
- if (!hasPandoc()) {
500
- console.error(chalk.red('pandoc not found.'));
501
- console.error(chalk.dim('Run "rev install" to install dependencies.'));
502
- process.exit(1);
503
- }
504
-
505
- const config = loadBuildConfig(dir);
506
-
507
- if (!config._configPath) {
508
- console.error(chalk.yellow('No rev.yaml found.'));
509
- console.error(chalk.dim('Run "rev new" to create a project, or "rev init" for existing files.'));
510
- process.exit(1);
511
- }
512
-
513
- // Apply journal formatting from CLI flag (overrides rev.yaml journal field)
514
- let journalName: string | undefined;
515
- if (options.journal) {
516
- const { getJournalProfile } = await import('../journals.js');
517
- const { mergeJournalFormatting } = await import('../build.js');
518
- const profile = getJournalProfile(options.journal);
519
- if (!profile) {
520
- console.error(fmt.status('error', `Unknown journal: ${options.journal}`));
521
- console.error(chalk.dim('Use "rev validate --list" to see available profiles'));
522
- process.exit(1);
523
- }
524
- journalName = profile.name;
525
- if (profile.formatting) {
526
- Object.assign(config, mergeJournalFormatting(config, profile.formatting, dir));
527
- }
528
- } else if ((config as unknown as { journal?: string }).journal) {
529
- // Journal set in rev.yaml — already applied by loadConfig, just get name for display
530
- const { getJournalProfile } = await import('../journals.js');
531
- const profile = getJournalProfile((config as unknown as { journal?: string }).journal!);
532
- if (profile) journalName = profile.name;
533
- }
534
-
535
- console.log(fmt.header(`Building ${config.title || 'document'}`));
536
- console.log();
537
-
538
- const targetFormats = formats.length > 0 ? formats : ['pdf', 'docx'];
539
- const tocEnabled = options.toc || config.pdf?.toc || config.docx?.toc;
540
- if (journalName) console.log(chalk.dim(` Journal: ${journalName}`));
541
- console.log(chalk.dim(` Formats: ${targetFormats.join(', ')}`));
542
- console.log(chalk.dim(` Crossref: ${hasPandocCrossref() && options.crossref !== false ? 'enabled' : 'disabled'}`));
543
- if (tocEnabled) console.log(chalk.dim(` TOC: enabled`));
544
- if (options.showChanges) console.log(chalk.dim(` Track changes: visible`));
545
- if (options.dual) console.log(chalk.dim(` Dual output: clean + with comments`));
546
- console.log('');
547
-
548
- if (options.toc) {
549
- config.pdf = config.pdf || {};
550
- config.pdf.toc = true;
551
- config.docx = config.docx || {};
552
- config.docx.toc = true;
553
- }
554
-
555
- if (options.dual) {
556
- config.docx = config.docx || {};
557
- config.docx.keepComments = false;
558
- }
559
-
560
- // Apply beamer CLI options
561
- if (options.theme) {
562
- config.beamer = config.beamer || {};
563
- config.beamer.theme = options.theme;
564
- }
565
- if (options.colortheme) {
566
- config.beamer = config.beamer || {};
567
- config.beamer.colortheme = options.colortheme;
568
- }
569
- if (options.aspectratio) {
570
- config.beamer = config.beamer || {};
571
- config.beamer.aspectratio = options.aspectratio;
572
- }
573
-
574
- if (options.showChanges) {
575
- if (!targetFormats.includes('docx') && !targetFormats.includes('all')) {
576
- console.error(fmt.status('error', '--show-changes only applies to DOCX output'));
577
- process.exit(1);
578
- }
579
-
580
- const { combineSections } = await import('../build.js');
581
- const { buildWithTrackChanges } = await import('../trackchanges.js');
582
-
583
- const spin = fmt.spinner('Building with track changes...').start();
584
-
585
- try {
586
- const paperPath = combineSections(dir, config);
587
- spin.stop();
588
- console.log(chalk.cyan('Combined sections → paper.md'));
589
- console.log(chalk.dim(` ${paperPath}\n`));
590
-
591
- const baseName = config.title
592
- ? config.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 50)
593
- : 'paper';
594
- const outputPath = path.join(dir, `${baseName}-changes.docx`);
595
-
596
- const spinTc = fmt.spinner('Applying track changes...').start();
597
- const result = await buildWithTrackChanges(paperPath, outputPath, {
598
- author: getUserName() || 'Author',
599
- });
600
- spinTc.stop();
601
-
602
- if (result.success) {
603
- console.log(chalk.cyan('Output (with track changes):'));
604
- console.log(` DOCX: ${path.basename(outputPath)}`);
605
- if (result.stats) {
606
- console.log(chalk.dim(` ${result.stats.insertions} insertions, ${result.stats.deletions} deletions, ${result.stats.substitutions} substitutions`));
607
- }
608
- console.log(chalk.green('\nBuild complete!'));
609
- } else {
610
- console.error(fmt.status('error', result.message));
611
- process.exit(1);
612
- }
613
- } catch (err) {
614
- spin.stop();
615
- const error = err as Error;
616
- console.error(fmt.status('error', error.message));
617
- if (process.env.DEBUG) console.error(error.stack);
618
- process.exit(1);
619
- }
620
- return;
621
- }
622
-
623
- const spin = fmt.spinner('Building...').start();
624
-
625
- try {
626
- const { results, paperPath, forwardRefsResolved, refsAutoInjected } = await build(dir, targetFormats, {
627
- crossref: options.crossref,
628
- config,
629
- verbose: options.verbose,
630
- });
631
-
632
- spin.stop();
633
-
634
- console.log(chalk.cyan('Combined sections → paper.md'));
635
- console.log(chalk.dim(` ${paperPath}`));
636
- if (forwardRefsResolved > 0) {
637
- console.log(chalk.dim(` ${forwardRefsResolved} forward reference(s) pre-resolved`));
638
- }
639
- if (refsAutoInjected) {
640
- console.log(chalk.dim(` References section auto-injected before supplementary`));
641
- }
642
- console.log('');
643
-
644
- console.log(chalk.cyan('Output:'));
645
- console.log(formatBuildResults(results));
646
-
647
- const failed = results.filter((r) => !r.success);
648
- if (failed.length > 0) {
649
- console.log('');
650
- for (const f of failed) {
651
- console.error(chalk.red(`\n${f.format} error:\n${f.error}`));
652
- }
653
- process.exit(1);
654
- }
655
-
656
- // Handle --dual mode
657
- if (options.dual) {
658
- const docxResult = results.find(r => r.format === 'docx' && r.success);
659
- if (docxResult) {
660
- const { prepareMarkdownWithMarkers, injectCommentsAtMarkers } = await import('../wordcomments.js');
661
- const { runPandoc } = await import('../build.js');
662
-
663
- let markdown = fs.readFileSync(paperPath, 'utf-8');
664
-
665
- if (options.reference) {
666
- const refPath = path.resolve(dir, options.reference);
667
- if (fs.existsSync(refPath)) {
668
- const spinRealign = fmt.spinner('Realigning comments from reference...').start();
669
- const { realignMarkdown } = await import('../comment-realign.js');
670
- const realigned = await realignMarkdown(refPath, markdown);
671
- if (realigned.success) {
672
- markdown = realigned.markdown;
673
- spinRealign.stop();
674
- console.log(chalk.dim(` Realigned ${realigned.insertions} comments from reference`));
675
- } else {
676
- spinRealign.stop();
677
- console.log(chalk.yellow(` Warning: Could not realign comments: ${realigned.error}`));
678
- }
679
- } else {
680
- console.log(chalk.yellow(` Warning: Reference not found: ${options.reference}`));
681
- }
682
- }
683
-
684
- markdown = stripAnnotations(markdown, { keepComments: true });
685
-
686
- const spinMarkers = fmt.spinner('Preparing markers...').start();
687
- const { markedMarkdown, comments } = prepareMarkdownWithMarkers(markdown);
688
- spinMarkers.stop();
689
-
690
- if (comments.length === 0) {
691
- console.log(chalk.yellow('\nNo comments found - skipping comments DOCX'));
692
- } else {
693
- const markedPath = path.join(dir, '.paper-marked.md');
694
- fs.writeFileSync(markedPath, markedMarkdown, 'utf-8');
695
-
696
- const spinBuild = fmt.spinner('Building marked DOCX...').start();
697
- const markedDocxPath = path.join(dir, '.paper-marked.docx');
698
- const pandocResult = await runPandoc(markedPath, 'docx', config, { ...options, outputPath: markedDocxPath });
699
- spinBuild.stop();
700
-
701
- if (!pandocResult.success) {
702
- console.error(chalk.yellow(`\nWarning: Could not build marked DOCX: ${pandocResult.error}`));
703
- } else {
704
- const commentsDocxPath = docxResult.outputPath!.replace(/\.docx$/, '_comments.docx');
705
- const spinInject = fmt.spinner('Injecting comments at markers...').start();
706
- const commentResult = await injectCommentsAtMarkers(markedDocxPath, comments, commentsDocxPath);
707
- spinInject.stop();
708
-
709
- if (!process.env.DEBUG) {
710
- try {
711
- fs.unlinkSync(markedPath);
712
- fs.unlinkSync(markedDocxPath);
713
- } catch { /* ignore */ }
714
- }
715
-
716
- if (commentResult.success) {
717
- console.log(chalk.cyan('\nDual output:'));
718
- console.log(` Clean: ${path.basename(docxResult.outputPath!)}`);
719
- console.log(` Comments: ${path.basename(commentsDocxPath)} (${commentResult.commentCount} comments)`);
720
- if (commentResult.skippedComments > 0) {
721
- console.log(chalk.yellow(` Warning: ${commentResult.skippedComments} comments could not be anchored (markers not found)`));
722
- }
723
- } else {
724
- console.error(chalk.yellow(`\nWarning: Could not create comments DOCX: ${commentResult.error}`));
725
- }
726
- }
727
- }
728
- }
729
-
730
- const pdfResult = results.find(r => r.format === 'pdf' && r.success);
731
- if (pdfResult) {
732
- const { prepareMarkdownForAnnotatedPdf } = await import('../pdf-comments.js');
733
- const { runPandoc } = await import('../build.js');
734
-
735
- let markdown = fs.readFileSync(paperPath, 'utf-8');
736
- markdown = stripAnnotations(markdown, { keepComments: true });
737
-
738
- const spinPdf = fmt.spinner('Preparing annotated PDF...').start();
739
- const { markdown: annotatedMd, preamble, commentCount } = prepareMarkdownForAnnotatedPdf(markdown, {
740
- useTodonotes: true,
741
- stripResolved: true,
742
- });
743
-
744
- if (commentCount === 0) {
745
- spinPdf.stop();
746
- console.log(chalk.yellow('\nNo comments found - skipping annotated PDF'));
747
- } else {
748
- const annotatedPath = path.join(dir, '.paper-annotated.md');
749
- fs.writeFileSync(annotatedPath, annotatedMd, 'utf-8');
750
-
751
- const annotatedConfig = JSON.parse(JSON.stringify(config));
752
- annotatedConfig.pdf = annotatedConfig.pdf || {};
753
- annotatedConfig.pdf['header-includes'] = (annotatedConfig.pdf['header-includes'] || '') + preamble;
754
- annotatedConfig.pdf.geometry = 'left=2.5cm,right=4.5cm,top=2.5cm,bottom=2.5cm,marginparwidth=3.5cm';
755
-
756
- const annotatedPdfPath = pdfResult.outputPath!.replace(/\.pdf$/, '_comments.pdf');
757
- spinPdf.text = 'Building annotated PDF...';
758
- const pandocResult = await runPandoc(annotatedPath, 'pdf', annotatedConfig, { ...options, outputPath: annotatedPdfPath });
759
- spinPdf.stop();
760
-
761
- if (!process.env.DEBUG) {
762
- try { fs.unlinkSync(annotatedPath); } catch { /* ignore */ }
763
- }
764
-
765
- if (pandocResult.success) {
766
- console.log(chalk.cyan('\nPDF dual output:'));
767
- console.log(` Clean: ${path.basename(pdfResult.outputPath!)}`);
768
- console.log(` Comments: ${path.basename(annotatedPdfPath)} (${commentCount} margin notes)`);
769
- } else {
770
- console.error(chalk.yellow(`\nWarning: Could not create annotated PDF: ${pandocResult.error}`));
771
- }
772
- }
773
- }
774
- }
775
-
776
- // Store base document for three-way merge (only for DOCX, not dual)
777
- const docxResult = results.find(r => r.format === 'docx' && r.success);
778
- if (docxResult && !options.dual) {
779
- try {
780
- const { storeBaseDocument } = await import('../merge.js');
781
- storeBaseDocument(dir, docxResult.outputPath!);
782
- console.log(chalk.dim(`\n Saved as .rev/base.docx for merge`));
783
- } catch (err) {
784
- // Non-fatal - just log if DEBUG
785
- if (process.env.DEBUG) {
786
- const error = err as Error;
787
- console.log(chalk.dim(`\n Could not store base document: ${error.message}`));
788
- }
789
- }
790
- }
791
-
792
- console.log(chalk.green('\nBuild complete!'));
793
- } catch (err) {
794
- spin.stop();
795
- const error = err as Error;
796
- console.error(fmt.status('error', error.message));
797
- if (process.env.DEBUG) console.error(error.stack);
798
- process.exit(1);
799
- }
800
- });
801
- }
1
+ /**
2
+ * Build commands: build, install, doctor, refs, migrate
3
+ *
4
+ * Commands for building documents and managing dependencies.
5
+ */
6
+
7
+ import {
8
+ chalk,
9
+ fs,
10
+ path,
11
+ fmt,
12
+ findFiles,
13
+ stripAnnotations,
14
+ buildRegistry,
15
+ detectHardcodedRefs,
16
+ convertHardcodedRefs,
17
+ getRefStatus,
18
+ formatRegistry,
19
+ build,
20
+ loadBuildConfig,
21
+ hasPandoc,
22
+ hasPandocCrossref,
23
+ formatBuildResults,
24
+ getUserName,
25
+ } from './context.js';
26
+ import type { Command } from 'commander';
27
+ import * as readline from 'readline';
28
+
29
+ interface RefsOptions {
30
+ dir: string;
31
+ }
32
+
33
+ interface MigrateOptions {
34
+ dir: string;
35
+ auto?: boolean;
36
+ dryRun?: boolean;
37
+ }
38
+
39
+ interface InstallOptions {
40
+ check?: boolean;
41
+ }
42
+
43
+ interface BuildOptions {
44
+ dir: string;
45
+ journal?: string;
46
+ crossref?: boolean;
47
+ toc?: boolean;
48
+ showChanges?: boolean;
49
+ dual?: boolean;
50
+ reference?: string;
51
+ theme?: string;
52
+ colortheme?: string;
53
+ aspectratio?: string;
54
+ verbose?: boolean;
55
+ }
56
+
57
+ /**
58
+ * Register build commands with the program
59
+ */
60
+ export function register(program: Command, pkg?: { version?: string }): void {
61
+ // ==========================================================================
62
+ // REFS command - Show figure/table reference status
63
+ // ==========================================================================
64
+
65
+ program
66
+ .command('refs')
67
+ .description('Show figure/table reference registry and status')
68
+ .argument('[file]', 'Optional file to analyze for references')
69
+ .option('-d, --dir <directory>', 'Directory to scan for anchors', '.')
70
+ .action((file: string | undefined, options: RefsOptions) => {
71
+ const dir = path.resolve(options.dir);
72
+
73
+ if (!fs.existsSync(dir)) {
74
+ console.error(chalk.red(`Directory not found: ${dir}`));
75
+ process.exit(1);
76
+ }
77
+
78
+ console.log(chalk.cyan('Building figure/table registry...\n'));
79
+
80
+ const registry = buildRegistry(dir);
81
+
82
+ console.log(chalk.bold('Registry:'));
83
+ console.log(formatRegistry(registry));
84
+
85
+ if (file) {
86
+ if (!fs.existsSync(file)) {
87
+ console.error(chalk.red(`\nFile not found: ${file}`));
88
+ process.exit(1);
89
+ }
90
+
91
+ const text = fs.readFileSync(file, 'utf-8');
92
+ const status = getRefStatus(text, registry);
93
+
94
+ console.log(chalk.cyan(`\nReferences in ${path.basename(file)}:\n`));
95
+
96
+ if (status.dynamic.length > 0) {
97
+ console.log(chalk.green(` Dynamic (@fig:, @tbl:): ${status.dynamic.length}`));
98
+ for (const ref of status.dynamic.slice(0, 5)) {
99
+ console.log(chalk.dim(` ${ref.match}`));
100
+ }
101
+ if (status.dynamic.length > 5) {
102
+ console.log(chalk.dim(` ... and ${status.dynamic.length - 5} more`));
103
+ }
104
+ }
105
+
106
+ if (status.hardcoded.length > 0) {
107
+ console.log(chalk.yellow(`\n Hardcoded (Figure 1, Table 2): ${status.hardcoded.length}`));
108
+ for (const ref of status.hardcoded.slice(0, 5)) {
109
+ console.log(chalk.dim(` "${ref.match}"`));
110
+ }
111
+ if (status.hardcoded.length > 5) {
112
+ console.log(chalk.dim(` ... and ${status.hardcoded.length - 5} more`));
113
+ }
114
+ console.log(chalk.cyan(`\n Run ${chalk.bold(`rev migrate ${file}`)} to convert to dynamic refs`));
115
+ }
116
+
117
+ if (status.dynamic.length === 0 && status.hardcoded.length === 0) {
118
+ console.log(chalk.dim(' No figure/table references found.'));
119
+ }
120
+ }
121
+ });
122
+
123
+ // ==========================================================================
124
+ // MIGRATE command - Convert hardcoded refs to dynamic
125
+ // ==========================================================================
126
+
127
+ program
128
+ .command('migrate')
129
+ .description('Convert hardcoded figure/table refs to dynamic @-syntax')
130
+ .argument('<file>', 'Markdown file to migrate')
131
+ .option('-d, --dir <directory>', 'Directory for registry', '.')
132
+ .option('--auto', 'Auto-convert without prompting')
133
+ .option('--dry-run', 'Preview without saving')
134
+ .action(async (file: string, options: MigrateOptions) => {
135
+ if (!fs.existsSync(file)) {
136
+ console.error(chalk.red(`File not found: ${file}`));
137
+ process.exit(1);
138
+ }
139
+
140
+ const dir = path.resolve(options.dir);
141
+ console.log(chalk.cyan('Building figure/table registry...\n'));
142
+
143
+ const registry = buildRegistry(dir);
144
+ const text = fs.readFileSync(file, 'utf-8');
145
+ const refs = detectHardcodedRefs(text);
146
+
147
+ if (refs.length === 0) {
148
+ console.log(chalk.green('No hardcoded references found.'));
149
+ return;
150
+ }
151
+
152
+ console.log(chalk.yellow(`Found ${refs.length} hardcoded reference(s):\n`));
153
+
154
+ if (options.auto) {
155
+ const { converted, conversions, warnings } = convertHardcodedRefs(text, registry);
156
+
157
+ for (const w of warnings) {
158
+ console.log(chalk.yellow(` Warning: ${w}`));
159
+ }
160
+
161
+ for (const c of conversions) {
162
+ console.log(chalk.green(` "${c.from}" → ${c.to}`));
163
+ }
164
+
165
+ if (options.dryRun) {
166
+ console.log(chalk.yellow('\n(Dry run - no changes saved)'));
167
+ } else if (conversions.length > 0) {
168
+ fs.writeFileSync(file, converted, 'utf-8');
169
+ console.log(chalk.green(`\nConverted ${conversions.length} reference(s) in ${file}`));
170
+ }
171
+ } else {
172
+ const rl = readline.createInterface({
173
+ input: process.stdin,
174
+ output: process.stdout,
175
+ });
176
+
177
+ let result = text;
178
+ let converted = 0;
179
+ let skipped = 0;
180
+
181
+ const askQuestion = (prompt: string): Promise<string> =>
182
+ new Promise((resolve) => rl.question(prompt, resolve));
183
+
184
+ const sortedRefs = [...refs].sort((a, b) => b.position - a.position);
185
+
186
+ for (const ref of sortedRefs) {
187
+ const num = ref.numbers[0];
188
+ const { numberToLabel } = await import('../crossref.js');
189
+ const label = numberToLabel(ref.type, num.num, num.isSupp, registry);
190
+
191
+ if (!label) {
192
+ console.log(chalk.yellow(` "${ref.match}" - no matching anchor found, skipping`));
193
+ skipped++;
194
+ continue;
195
+ }
196
+
197
+ const replacement = `@${ref.type}:${label}`;
198
+ console.log(`\n ${chalk.yellow(`"${ref.match}"`)} → ${chalk.green(replacement)}`);
199
+
200
+ const answer = await askQuestion(chalk.cyan(' Convert? [y/n/a/q] '));
201
+
202
+ if (answer.toLowerCase() === 'q') {
203
+ console.log(chalk.dim(' Quitting...'));
204
+ break;
205
+ } else if (answer.toLowerCase() === 'a') {
206
+ result = result.slice(0, ref.position) + replacement + result.slice(ref.position + ref.match.length);
207
+ converted++;
208
+
209
+ for (const remaining of sortedRefs.slice(sortedRefs.indexOf(ref) + 1)) {
210
+ const rNum = remaining.numbers[0];
211
+ const rLabel = numberToLabel(remaining.type, rNum.num, rNum.isSupp, registry);
212
+ if (rLabel) {
213
+ const rReplacement = `@${remaining.type}:${rLabel}`;
214
+ result = result.slice(0, remaining.position) + rReplacement + result.slice(remaining.position + remaining.match.length);
215
+ converted++;
216
+ console.log(chalk.green(` "${remaining.match}" → ${rReplacement}`));
217
+ }
218
+ }
219
+ break;
220
+ } else if (answer.toLowerCase() === 'y') {
221
+ result = result.slice(0, ref.position) + replacement + result.slice(ref.position + ref.match.length);
222
+ converted++;
223
+ } else {
224
+ skipped++;
225
+ }
226
+ }
227
+
228
+ rl.close();
229
+
230
+ console.log(chalk.cyan(`\nConverted: ${converted}, Skipped: ${skipped}`));
231
+
232
+ if (converted > 0 && !options.dryRun) {
233
+ fs.writeFileSync(file, result, 'utf-8');
234
+ console.log(chalk.green(`Saved ${file}`));
235
+ } else if (options.dryRun) {
236
+ console.log(chalk.yellow('(Dry run - no changes saved)'));
237
+ }
238
+ }
239
+ });
240
+
241
+ // ==========================================================================
242
+ // INSTALL command - Install dependencies
243
+ // ==========================================================================
244
+
245
+ program
246
+ .command('install')
247
+ .description('Check and install dependencies (pandoc-crossref)')
248
+ .option('--check', 'Only check, don\'t install')
249
+ .action(async (options: InstallOptions) => {
250
+ const os = await import('os');
251
+ const { execSync } = await import('child_process');
252
+ const platform = os.platform();
253
+
254
+ console.log(chalk.cyan('Checking dependencies...\n'));
255
+
256
+ let hasPandocInstalled = false;
257
+ try {
258
+ const version = execSync('pandoc --version', { encoding: 'utf-8' }).split('\n')[0];
259
+ console.log(chalk.green(` ✓ ${version}`));
260
+ hasPandocInstalled = true;
261
+ } catch {
262
+ console.log(chalk.red(' ✗ pandoc not found'));
263
+ }
264
+
265
+ let hasCrossref = false;
266
+ try {
267
+ const version = execSync('pandoc-crossref --version', { encoding: 'utf-8' }).split('\n')[0];
268
+ console.log(chalk.green(` ✓ pandoc-crossref ${version}`));
269
+ hasCrossref = true;
270
+ } catch {
271
+ console.log(chalk.yellow(' ✗ pandoc-crossref not found'));
272
+ }
273
+
274
+ console.log('');
275
+
276
+ if (hasPandocInstalled && hasCrossref) {
277
+ console.log(chalk.green('All dependencies installed!'));
278
+ return;
279
+ }
280
+
281
+ if (options.check) {
282
+ if (!hasCrossref) {
283
+ console.log(chalk.yellow('pandoc-crossref is optional but recommended for @fig: references.'));
284
+ }
285
+ return;
286
+ }
287
+
288
+ if (!hasPandocInstalled || !hasCrossref) {
289
+ console.log(chalk.cyan('Installation options:\n'));
290
+
291
+ if (platform === 'darwin') {
292
+ console.log(chalk.bold('macOS (Homebrew):'));
293
+ if (!hasPandocInstalled) console.log(chalk.dim(' brew install pandoc'));
294
+ if (!hasCrossref) console.log(chalk.dim(' brew install pandoc-crossref'));
295
+ console.log('');
296
+ } else if (platform === 'win32') {
297
+ console.log(chalk.bold('Windows (Chocolatey):'));
298
+ if (!hasPandocInstalled) console.log(chalk.dim(' choco install pandoc'));
299
+ if (!hasCrossref) console.log(chalk.dim(' choco install pandoc-crossref'));
300
+ console.log('');
301
+ console.log(chalk.bold('Windows (Scoop):'));
302
+ if (!hasPandocInstalled) console.log(chalk.dim(' scoop install pandoc'));
303
+ if (!hasCrossref) console.log(chalk.dim(' scoop install pandoc-crossref'));
304
+ console.log('');
305
+ } else {
306
+ console.log(chalk.bold('Linux (apt):'));
307
+ if (!hasPandocInstalled) console.log(chalk.dim(' sudo apt install pandoc'));
308
+ console.log('');
309
+ }
310
+
311
+ console.log(chalk.bold('Cross-platform (conda):'));
312
+ if (!hasPandocInstalled) console.log(chalk.dim(' conda install -c conda-forge pandoc'));
313
+ if (!hasCrossref) console.log(chalk.dim(' conda install -c conda-forge pandoc-crossref'));
314
+ console.log('');
315
+
316
+ if (!hasCrossref) {
317
+ console.log(chalk.bold('Manual download:'));
318
+ console.log(chalk.dim(' https://github.com/lierdakil/pandoc-crossref/releases'));
319
+ console.log('');
320
+ }
321
+
322
+ try {
323
+ execSync('conda --version', { encoding: 'utf-8', stdio: 'pipe' });
324
+ console.log(chalk.cyan('Conda detected. Install missing dependencies? [y/N] '));
325
+
326
+ const rl = readline.createInterface({
327
+ input: process.stdin,
328
+ output: process.stdout,
329
+ });
330
+
331
+ rl.question('', (answer) => {
332
+ rl.close();
333
+ if (answer.toLowerCase() === 'y') {
334
+ console.log(chalk.cyan('\nInstalling via conda...'));
335
+ try {
336
+ if (!hasPandocInstalled) {
337
+ console.log(chalk.dim(' Installing pandoc...'));
338
+ execSync('conda install -y -c conda-forge pandoc', { stdio: 'inherit' });
339
+ }
340
+ if (!hasCrossref) {
341
+ console.log(chalk.dim(' Installing pandoc-crossref...'));
342
+ execSync('conda install -y -c conda-forge pandoc-crossref', { stdio: 'inherit' });
343
+ }
344
+ console.log(chalk.green('\nDone! Run "rev install --check" to verify.'));
345
+ } catch (err) {
346
+ const error = err as Error;
347
+ console.log(chalk.red(`\nInstallation failed: ${error.message}`));
348
+ console.log(chalk.dim('Try installing manually with the commands above.'));
349
+ }
350
+ }
351
+ });
352
+ } catch {
353
+ // Conda not available
354
+ }
355
+ }
356
+ });
357
+
358
+ // ==========================================================================
359
+ // DOCTOR command - Diagnose setup and configuration issues
360
+ // ==========================================================================
361
+
362
+ program
363
+ .command('doctor')
364
+ .description('Diagnose setup and configuration issues')
365
+ .action(async () => {
366
+ const os = await import('os');
367
+ const { execSync } = await import('child_process');
368
+
369
+ const version = pkg?.version || 'unknown';
370
+ console.log(chalk.bold.cyan(`\n rev doctor`) + chalk.dim(` v${version}\n`));
371
+ console.log(chalk.dim(` ${os.platform()} ${os.release()} | Node ${process.version}\n`));
372
+
373
+ let issues = 0;
374
+ let warnings = 0;
375
+
376
+ console.log(chalk.bold(' Environment'));
377
+ console.log(chalk.dim(' ─────────────────────────────────'));
378
+
379
+ const nodeVer = parseInt(process.version.slice(1).split('.')[0], 10);
380
+ if (nodeVer >= 18) {
381
+ console.log(chalk.green(' ✓') + ` Node.js ${process.version}`);
382
+ } else {
383
+ console.log(chalk.red(' ✗') + ` Node.js ${process.version} (requires >=18)`);
384
+ issues++;
385
+ }
386
+
387
+ try {
388
+ const pandocVer = execSync('pandoc --version', { encoding: 'utf-8' }).split('\n')[0];
389
+ console.log(chalk.green(' ✓') + ` ${pandocVer}`);
390
+ } catch {
391
+ console.log(chalk.red(' ✗') + ' pandoc not found');
392
+ issues++;
393
+ }
394
+
395
+ try {
396
+ const crossrefVer = execSync('pandoc-crossref --version', { encoding: 'utf-8' }).split('\n')[0];
397
+ console.log(chalk.green(' ✓') + ` pandoc-crossref ${crossrefVer}`);
398
+ } catch {
399
+ console.log(chalk.yellow(' !') + ' pandoc-crossref not found (optional)');
400
+ warnings++;
401
+ }
402
+
403
+ console.log();
404
+ console.log(chalk.bold(' Project'));
405
+ console.log(chalk.dim(' ─────────────────────────────────'));
406
+
407
+ const configPath = path.join(process.cwd(), 'rev.yaml');
408
+ if (fs.existsSync(configPath)) {
409
+ console.log(chalk.green(' ✓') + ' rev.yaml found');
410
+
411
+ try {
412
+ const { default: YAML } = await import('yaml');
413
+ const config = YAML.parse(fs.readFileSync(configPath, 'utf-8'));
414
+
415
+ if (config.title) {
416
+ console.log(chalk.green(' ✓') + ` Title: ${config.title}`);
417
+ } else {
418
+ console.log(chalk.yellow(' !') + ' No title in rev.yaml');
419
+ warnings++;
420
+ }
421
+
422
+ if (config.sections && config.sections.length > 0) {
423
+ console.log(chalk.green(' ✓') + ` Sections: ${config.sections.length} defined`);
424
+
425
+ let missing = 0;
426
+ for (const sec of config.sections) {
427
+ const secFile = typeof sec === 'string' ? sec : sec.file;
428
+ if (!fs.existsSync(secFile)) missing++;
429
+ }
430
+ if (missing > 0) {
431
+ console.log(chalk.yellow(' !') + ` ${missing} section file(s) missing`);
432
+ warnings++;
433
+ }
434
+ }
435
+
436
+ if (config.bibliography) {
437
+ if (fs.existsSync(config.bibliography)) {
438
+ console.log(chalk.green(' ✓') + ` Bibliography: ${config.bibliography}`);
439
+ } else {
440
+ console.log(chalk.yellow(' !') + ` Bibliography file not found: ${config.bibliography}`);
441
+ warnings++;
442
+ }
443
+ }
444
+ } catch (e) {
445
+ const error = e as Error;
446
+ console.log(chalk.red(' ✗') + ` rev.yaml parse error: ${error.message}`);
447
+ issues++;
448
+ }
449
+ } else {
450
+ console.log(chalk.dim(' ·') + ' No rev.yaml (not a rev project)');
451
+ }
452
+
453
+ const mdFiles = findFiles('.md');
454
+ if (mdFiles.length > 0) {
455
+ console.log(chalk.green(' ✓') + ` Markdown files: ${mdFiles.length}`);
456
+ } else {
457
+ console.log(chalk.dim(' ·') + ' No markdown files');
458
+ }
459
+
460
+ console.log();
461
+ if (issues === 0 && warnings === 0) {
462
+ console.log(chalk.green.bold(' All checks passed! ✓\n'));
463
+ } else if (issues === 0) {
464
+ console.log(chalk.yellow(` ${warnings} warning(s), no critical issues\n`));
465
+ } else {
466
+ console.log(chalk.red(` ${issues} issue(s), ${warnings} warning(s)\n`));
467
+ console.log(chalk.dim(' Run "rev install" to fix missing dependencies.\n'));
468
+ }
469
+ });
470
+
471
+ // ==========================================================================
472
+ // BUILD command - Combine sections and run pandoc
473
+ // ==========================================================================
474
+
475
+ program
476
+ .command('build')
477
+ .alias('b')
478
+ .description('Build PDF/DOCX/TEX/PPTX/Beamer from sections')
479
+ .argument('[formats...]', 'Output formats: pdf, docx, tex, beamer, pptx, all', ['pdf', 'docx'])
480
+ .option('-d, --dir <directory>', 'Project directory', '.')
481
+ .option('-j, --journal <name>', 'Use journal profile for build formatting defaults')
482
+ .option('--no-crossref', 'Skip pandoc-crossref filter')
483
+ .option('--toc', 'Include table of contents')
484
+ .option('--show-changes', 'Export DOCX with visible track changes (audit mode)')
485
+ .option('--dual', 'Output both clean version and annotated version (with comments)')
486
+ .option('--reference <docx>', 'Reference DOCX for comment position alignment (use with --dual)')
487
+ .option('--theme <name>', 'Beamer theme (default, metropolis, etc.)')
488
+ .option('--colortheme <name>', 'Beamer color theme')
489
+ .option('--aspectratio <ratio>', 'Beamer aspect ratio (169, 43)')
490
+ .option('--verbose', 'Show detailed output including postprocess scripts')
491
+ .action(async (formats: string[], options: BuildOptions) => {
492
+ const dir = path.resolve(options.dir);
493
+
494
+ if (!fs.existsSync(dir)) {
495
+ console.error(chalk.red(`Directory not found: ${dir}`));
496
+ process.exit(1);
497
+ }
498
+
499
+ if (!hasPandoc()) {
500
+ console.error(chalk.red('pandoc not found.'));
501
+ console.error(chalk.dim('Run "rev install" to install dependencies.'));
502
+ process.exit(1);
503
+ }
504
+
505
+ const config = loadBuildConfig(dir);
506
+
507
+ if (!config._configPath) {
508
+ console.error(chalk.yellow('No rev.yaml found.'));
509
+ console.error(chalk.dim('Run "rev new" to create a project, or "rev init" for existing files.'));
510
+ process.exit(1);
511
+ }
512
+
513
+ // Apply journal formatting from CLI flag (overrides rev.yaml journal field)
514
+ let journalName: string | undefined;
515
+ if (options.journal) {
516
+ const { getJournalProfile } = await import('../journals.js');
517
+ const { mergeJournalFormatting } = await import('../build.js');
518
+ const profile = getJournalProfile(options.journal);
519
+ if (!profile) {
520
+ console.error(fmt.status('error', `Unknown journal: ${options.journal}`));
521
+ console.error(chalk.dim('Use "rev validate --list" to see available profiles'));
522
+ process.exit(1);
523
+ }
524
+ journalName = profile.name;
525
+ if (profile.formatting) {
526
+ Object.assign(config, mergeJournalFormatting(config, profile.formatting, dir));
527
+ }
528
+ } else if ((config as unknown as { journal?: string }).journal) {
529
+ // Journal set in rev.yaml — already applied by loadConfig, just get name for display
530
+ const { getJournalProfile } = await import('../journals.js');
531
+ const profile = getJournalProfile((config as unknown as { journal?: string }).journal!);
532
+ if (profile) journalName = profile.name;
533
+ }
534
+
535
+ console.log(fmt.header(`Building ${config.title || 'document'}`));
536
+ console.log();
537
+
538
+ const targetFormats = formats.length > 0 ? formats : ['pdf', 'docx'];
539
+ const tocEnabled = options.toc || config.pdf?.toc || config.docx?.toc;
540
+ if (journalName) console.log(chalk.dim(` Journal: ${journalName}`));
541
+ console.log(chalk.dim(` Formats: ${targetFormats.join(', ')}`));
542
+ console.log(chalk.dim(` Crossref: ${hasPandocCrossref() && options.crossref !== false ? 'enabled' : 'disabled'}`));
543
+ if (tocEnabled) console.log(chalk.dim(` TOC: enabled`));
544
+ if (options.showChanges) console.log(chalk.dim(` Track changes: visible`));
545
+ if (options.dual) console.log(chalk.dim(` Dual output: clean + with comments`));
546
+ console.log('');
547
+
548
+ if (options.toc) {
549
+ config.pdf = config.pdf || {};
550
+ config.pdf.toc = true;
551
+ config.docx = config.docx || {};
552
+ config.docx.toc = true;
553
+ }
554
+
555
+ if (options.dual) {
556
+ config.docx = config.docx || {};
557
+ config.docx.keepComments = false;
558
+ }
559
+
560
+ // Apply beamer CLI options
561
+ if (options.theme) {
562
+ config.beamer = config.beamer || {};
563
+ config.beamer.theme = options.theme;
564
+ }
565
+ if (options.colortheme) {
566
+ config.beamer = config.beamer || {};
567
+ config.beamer.colortheme = options.colortheme;
568
+ }
569
+ if (options.aspectratio) {
570
+ config.beamer = config.beamer || {};
571
+ config.beamer.aspectratio = options.aspectratio;
572
+ }
573
+
574
+ if (options.showChanges) {
575
+ if (!targetFormats.includes('docx') && !targetFormats.includes('all')) {
576
+ console.error(fmt.status('error', '--show-changes only applies to DOCX output'));
577
+ process.exit(1);
578
+ }
579
+
580
+ const { combineSections, resolveOutputDir } = await import('../build.js');
581
+ const { buildWithTrackChanges } = await import('../trackchanges.js');
582
+
583
+ const spin = fmt.spinner('Building with track changes...').start();
584
+
585
+ try {
586
+ const paperPath = combineSections(dir, config);
587
+ spin.stop();
588
+ console.log(chalk.cyan('Combined sections → paper.md'));
589
+ console.log(chalk.dim(` ${paperPath}\n`));
590
+
591
+ const baseName = config.title
592
+ ? config.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 50)
593
+ : 'paper';
594
+ const outDir = resolveOutputDir(dir, config);
595
+ if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
596
+ const outputPath = path.join(outDir, `${baseName}-changes.docx`);
597
+
598
+ const spinTc = fmt.spinner('Applying track changes...').start();
599
+ const result = await buildWithTrackChanges(paperPath, outputPath, {
600
+ author: getUserName() || 'Author',
601
+ });
602
+ spinTc.stop();
603
+
604
+ if (result.success) {
605
+ console.log(chalk.cyan('Output (with track changes):'));
606
+ console.log(` DOCX: ${path.basename(outputPath)}`);
607
+ if (result.stats) {
608
+ console.log(chalk.dim(` ${result.stats.insertions} insertions, ${result.stats.deletions} deletions, ${result.stats.substitutions} substitutions`));
609
+ }
610
+ console.log(chalk.green('\nBuild complete!'));
611
+ } else {
612
+ console.error(fmt.status('error', result.message));
613
+ process.exit(1);
614
+ }
615
+ } catch (err) {
616
+ spin.stop();
617
+ const error = err as Error;
618
+ console.error(fmt.status('error', error.message));
619
+ if (process.env.DEBUG) console.error(error.stack);
620
+ process.exit(1);
621
+ }
622
+ return;
623
+ }
624
+
625
+ const spin = fmt.spinner('Building...').start();
626
+
627
+ try {
628
+ const { results, paperPath, forwardRefsResolved, refsAutoInjected } = await build(dir, targetFormats, {
629
+ crossref: options.crossref,
630
+ config,
631
+ verbose: options.verbose,
632
+ });
633
+
634
+ spin.stop();
635
+
636
+ console.log(chalk.cyan('Combined sections → paper.md'));
637
+ console.log(chalk.dim(` ${paperPath}`));
638
+ if (forwardRefsResolved > 0) {
639
+ console.log(chalk.dim(` ${forwardRefsResolved} forward reference(s) pre-resolved`));
640
+ }
641
+ if (refsAutoInjected) {
642
+ console.log(chalk.dim(` References section auto-injected before supplementary`));
643
+ }
644
+ console.log('');
645
+
646
+ console.log(chalk.cyan('Output:'));
647
+ console.log(formatBuildResults(results));
648
+
649
+ const failed = results.filter((r) => !r.success);
650
+ if (failed.length > 0) {
651
+ console.log('');
652
+ for (const f of failed) {
653
+ console.error(chalk.red(`\n${f.format} error:\n${f.error}`));
654
+ }
655
+ process.exit(1);
656
+ }
657
+
658
+ // Handle --dual mode
659
+ if (options.dual) {
660
+ const docxResult = results.find(r => r.format === 'docx' && r.success);
661
+ if (docxResult) {
662
+ const { prepareMarkdownWithMarkers, injectCommentsAtMarkers } = await import('../wordcomments.js');
663
+ const { runPandoc, applyFormatTransforms } = await import('../build.js');
664
+
665
+ let markdown = fs.readFileSync(paperPath, 'utf-8');
666
+
667
+ if (options.reference) {
668
+ const refPath = path.resolve(dir, options.reference);
669
+ if (fs.existsSync(refPath)) {
670
+ const spinRealign = fmt.spinner('Realigning comments from reference...').start();
671
+ const { realignMarkdown } = await import('../comment-realign.js');
672
+ const realigned = await realignMarkdown(refPath, markdown);
673
+ if (realigned.success) {
674
+ markdown = realigned.markdown;
675
+ spinRealign.stop();
676
+ console.log(chalk.dim(` Realigned ${realigned.insertions} comments from reference`));
677
+ } else {
678
+ spinRealign.stop();
679
+ console.log(chalk.yellow(` Warning: Could not realign comments: ${realigned.error}`));
680
+ }
681
+ } else {
682
+ console.log(chalk.yellow(` Warning: Reference not found: ${options.reference}`));
683
+ }
684
+ }
685
+
686
+ markdown = stripAnnotations(markdown, { keepComments: true });
687
+
688
+ // Apply DOCX transforms (author affiliations, @fig: → "Figure 1")
689
+ // before injecting markers, so the comments DOCX matches the clean
690
+ // DOCX in everything but the comments themselves.
691
+ const registry = buildRegistry(dir, config.sections);
692
+ markdown = applyFormatTransforms(markdown, 'docx', config, registry);
693
+
694
+ const spinMarkers = fmt.spinner('Preparing markers...').start();
695
+ const { markedMarkdown, comments } = prepareMarkdownWithMarkers(markdown);
696
+ spinMarkers.stop();
697
+
698
+ if (comments.length === 0) {
699
+ console.log(chalk.yellow('\nNo comments found - skipping comments DOCX'));
700
+ } else {
701
+ const markedPath = path.join(dir, '.paper-marked.md');
702
+ fs.writeFileSync(markedPath, markedMarkdown, 'utf-8');
703
+
704
+ const spinBuild = fmt.spinner('Building marked DOCX...').start();
705
+ const markedDocxPath = path.join(dir, '.paper-marked.docx');
706
+ const pandocResult = await runPandoc(markedPath, 'docx', config, { ...options, outputPath: markedDocxPath });
707
+ spinBuild.stop();
708
+
709
+ if (!pandocResult.success) {
710
+ console.error(chalk.yellow(`\nWarning: Could not build marked DOCX: ${pandocResult.error}`));
711
+ } else {
712
+ const commentsDocxPath = docxResult.outputPath!.replace(/\.docx$/, '_comments.docx');
713
+ const spinInject = fmt.spinner('Injecting comments at markers...').start();
714
+ const commentResult = await injectCommentsAtMarkers(markedDocxPath, comments, commentsDocxPath);
715
+ spinInject.stop();
716
+
717
+ if (!process.env.DEBUG) {
718
+ try {
719
+ fs.unlinkSync(markedPath);
720
+ fs.unlinkSync(markedDocxPath);
721
+ } catch { /* ignore */ }
722
+ }
723
+
724
+ if (commentResult.success) {
725
+ console.log(chalk.cyan('\nDual output:'));
726
+ console.log(` Clean: ${path.basename(docxResult.outputPath!)}`);
727
+ console.log(` Comments: ${path.basename(commentsDocxPath)} (${commentResult.commentCount} comments)`);
728
+ if (commentResult.skippedComments > 0) {
729
+ console.log(chalk.yellow(` Warning: ${commentResult.skippedComments} comments could not be anchored (markers not found)`));
730
+ }
731
+ } else {
732
+ console.error(chalk.yellow(`\nWarning: Could not create comments DOCX: ${commentResult.error}`));
733
+ }
734
+ }
735
+ }
736
+ }
737
+
738
+ const pdfResult = results.find(r => r.format === 'pdf' && r.success);
739
+ if (pdfResult) {
740
+ const { prepareMarkdownForAnnotatedPdf } = await import('../pdf-comments.js');
741
+ const { runPandoc, applyFormatTransforms } = await import('../build.js');
742
+
743
+ let markdown = fs.readFileSync(paperPath, 'utf-8');
744
+ markdown = stripAnnotations(markdown, { keepComments: true });
745
+
746
+ // Apply PDF transforms (table normalization, authblk header
747
+ // injection) before todonotes preamble work, so the comments PDF
748
+ // matches the clean PDF in everything but the margin notes.
749
+ const registry = buildRegistry(dir, config.sections);
750
+ markdown = applyFormatTransforms(markdown, 'pdf', config, registry);
751
+
752
+ const spinPdf = fmt.spinner('Preparing annotated PDF...').start();
753
+ const { markdown: annotatedMd, preamble, commentCount } = prepareMarkdownForAnnotatedPdf(markdown, {
754
+ useTodonotes: true,
755
+ stripResolved: true,
756
+ });
757
+
758
+ if (commentCount === 0) {
759
+ spinPdf.stop();
760
+ console.log(chalk.yellow('\nNo comments found - skipping annotated PDF'));
761
+ } else {
762
+ const annotatedPath = path.join(dir, '.paper-annotated.md');
763
+ fs.writeFileSync(annotatedPath, annotatedMd, 'utf-8');
764
+
765
+ const annotatedConfig = JSON.parse(JSON.stringify(config));
766
+ annotatedConfig.pdf = annotatedConfig.pdf || {};
767
+
768
+ // Pandoc consumes header-includes via -H <file>. Write preamble
769
+ // (plus any existing user header file) to a temp .tex and point
770
+ // headerIncludes at it.
771
+ const preambleParts: string[] = [];
772
+ const existingHeader = annotatedConfig.pdf.headerIncludes;
773
+ if (existingHeader) {
774
+ const existingPath = path.isAbsolute(existingHeader)
775
+ ? existingHeader
776
+ : path.join(dir, existingHeader);
777
+ if (fs.existsSync(existingPath)) {
778
+ preambleParts.push(fs.readFileSync(existingPath, 'utf-8'));
779
+ }
780
+ }
781
+ preambleParts.push(preamble);
782
+ const preamblePath = path.join(dir, '.paper-annotated.preamble.tex');
783
+ fs.writeFileSync(preamblePath, preambleParts.join('\n'), 'utf-8');
784
+ annotatedConfig.pdf.headerIncludes = preamblePath;
785
+ annotatedConfig.pdf.geometry = 'left=2.5cm,right=4.5cm,top=2.5cm,bottom=2.5cm,marginparwidth=3.5cm';
786
+
787
+ const annotatedPdfPath = pdfResult.outputPath!.replace(/\.pdf$/, '_comments.pdf');
788
+ spinPdf.text = 'Building annotated PDF...';
789
+ const pandocResult = await runPandoc(annotatedPath, 'pdf', annotatedConfig, { ...options, outputPath: annotatedPdfPath });
790
+ spinPdf.stop();
791
+
792
+ if (!process.env.DEBUG) {
793
+ try { fs.unlinkSync(annotatedPath); } catch { /* ignore */ }
794
+ try { fs.unlinkSync(preamblePath); } catch { /* ignore */ }
795
+ }
796
+
797
+ if (pandocResult.success) {
798
+ console.log(chalk.cyan('\nPDF dual output:'));
799
+ console.log(` Clean: ${path.basename(pdfResult.outputPath!)}`);
800
+ console.log(` Comments: ${path.basename(annotatedPdfPath)} (${commentCount} margin notes)`);
801
+ } else {
802
+ console.error(chalk.yellow(`\nWarning: Could not create annotated PDF: ${pandocResult.error}`));
803
+ }
804
+ }
805
+ }
806
+ }
807
+
808
+ // Store base document for three-way merge (only for DOCX, not dual)
809
+ const docxResult = results.find(r => r.format === 'docx' && r.success);
810
+ if (docxResult && !options.dual) {
811
+ try {
812
+ const { storeBaseDocument } = await import('../merge.js');
813
+ storeBaseDocument(dir, docxResult.outputPath!);
814
+ console.log(chalk.dim(`\n Saved as .rev/base.docx for merge`));
815
+ } catch (err) {
816
+ // Non-fatal - just log if DEBUG
817
+ if (process.env.DEBUG) {
818
+ const error = err as Error;
819
+ console.log(chalk.dim(`\n Could not store base document: ${error.message}`));
820
+ }
821
+ }
822
+ }
823
+
824
+ console.log(chalk.green('\nBuild complete!'));
825
+ } catch (err) {
826
+ spin.stop();
827
+ const error = err as Error;
828
+ console.error(fmt.status('error', error.message));
829
+ if (process.env.DEBUG) console.error(error.stack);
830
+ process.exit(1);
831
+ }
832
+ });
833
+ }