docrev 0.11.1 → 0.11.2

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