docrev 0.9.18 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,116 +1,116 @@
1
- <!DOCTYPE html>
2
- <html lang="en" data-bs-theme="light">
3
- <head>
4
- <meta charset="utf-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1">
6
- <title>$if(pagetitle)$$pagetitle$ — $endif$docrev</title>
7
- <meta name="description" content="CLI for writing documents in Markdown while collaborating with Word users.">
8
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootswatch@5.3.3/dist/sandstone/bootstrap.min.css">
9
- $if(highlighting-css)$
10
- <style>
11
- $highlighting-css$
12
- </style>
13
- $endif$
14
- <link rel="stylesheet" href="assets/extra.css">
15
- </head>
16
- <body>
17
-
18
- <nav class="navbar navbar-expand-lg fixed-top" aria-label="Site navigation">
19
- <div class="container">
20
- <a class="navbar-brand me-2" href="index.html">docrev</a>
21
- <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbar-main" aria-controls="navbar-main" aria-expanded="false" aria-label="Toggle navigation">
22
- <span class="navbar-toggler-icon"></span>
23
- </button>
24
- <div class="collapse navbar-collapse" id="navbar-main">
25
- <ul class="navbar-nav me-auto">
26
- <li class="nav-item">
27
- <a class="nav-link$if(active-home)$ active$endif$" $if(active-home)$aria-current="page"$endif$ href="index.html">Home</a>
28
- </li>
29
- <li class="nav-item">
30
- <a class="nav-link$if(active-workflow)$ active$endif$" $if(active-workflow)$aria-current="page"$endif$ href="workflow.html">Get Started</a>
31
- </li>
32
- <li class="nav-item">
33
- <a class="nav-link$if(active-commands)$ active$endif$" $if(active-commands)$aria-current="page"$endif$ href="commands.html">Commands</a>
34
- </li>
35
- <li class="nav-item">
36
- <a class="nav-link$if(active-configuration)$ active$endif$" $if(active-configuration)$aria-current="page"$endif$ href="configuration.html">Configuration</a>
37
- </li>
38
- <li class="nav-item">
39
- <a class="nav-link$if(active-troubleshooting)$ active$endif$" $if(active-troubleshooting)$aria-current="page"$endif$ href="troubleshooting.html">Troubleshooting</a>
40
- </li>
41
- </ul>
42
- <ul class="navbar-nav">
43
- <li class="nav-item">
44
- <button id="theme-toggle" class="btn btn-link nav-link border-0" aria-label="Toggle dark mode">
45
- <svg id="icon-sun" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
46
- <path d="M8 11a3 3 0 1 1 0-6 3 3 0 0 1 0 6m0 1a4 4 0 1 0 0-8 4 4 0 0 0 0 8M8 0a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 0m0 13a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 13m8-5a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1 0-1h2a.5.5 0 0 1 .5.5M3 8a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1 0-1h2A.5.5 0 0 1 3 8m10.657-5.657a.5.5 0 0 1 0 .707l-1.414 1.415a.5.5 0 1 1-.707-.708l1.414-1.414a.5.5 0 0 1 .707 0m-9.193 9.193a.5.5 0 0 1 0 .707L3.05 13.657a.5.5 0 0 1-.707-.707l1.414-1.414a.5.5 0 0 1 .707 0m9.193 2.121a.5.5 0 0 1-.707 0l-1.414-1.414a.5.5 0 0 1 .707-.707l1.414 1.414a.5.5 0 0 1 0 .707M4.464 4.465a.5.5 0 0 1-.707 0L2.343 3.05a.5.5 0 1 1 .707-.707l1.414 1.414a.5.5 0 0 1 0 .707"/>
47
- </svg>
48
- <svg id="icon-moon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16" style="display:none">
49
- <path d="M6 .278a.768.768 0 0 1 .08.858 7.208 7.208 0 0 0-.878 3.46c0 4.021 3.278 7.277 7.318 7.277.527 0 1.04-.055 1.533-.16a.787.787 0 0 1 .81.316.733.733 0 0 1-.031.893A8.349 8.349 0 0 1 8.344 16C3.734 16 0 12.286 0 7.71 0 4.266 2.114 1.312 5.124.06A.752.752 0 0 1 6 .278"/>
50
- </svg>
51
- </button>
52
- </li>
53
- <li class="nav-item">
54
- <a class="nav-link" href="https://github.com/gcol33/docrev" aria-label="GitHub">
55
- <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" viewBox="0 0 16 16">
56
- <path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.012 8.012 0 0 0 16 8c0-4.42-3.58-8-8-8"/>
57
- </svg>
58
- </a>
59
- </li>
60
- <li class="nav-item">
61
- <a class="nav-link" href="https://www.npmjs.com/package/docrev" aria-label="npm">
62
- <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 16 16">
63
- <path d="M0 0v16h16V0zm5.333 13.333H2.667V5.333h2.666v5.334H8V5.333h2.667v8H5.333z"/>
64
- </svg>
65
- </a>
66
- </li>
67
- </ul>
68
- </div>
69
- </div>
70
- </nav>
71
-
72
- <div class="container template-$if(active-home)$home$else$article$endif$">
73
- <div class="row">
74
- <main id="main" class="$if(toc)$col-md-9$else$col-12$endif$">
75
- $body$
76
- </main>
77
- $if(toc)$
78
- <aside class="col-md-3 d-none d-md-block">
79
- <nav id="toc" data-toggle="toc" aria-label="On this page">
80
- <h2 class="h6">On this page</h2>
81
- <div class="toc">$toc$</div>
82
- </nav>
83
- </aside>
84
- $endif$
85
- </div>
86
- </div>
87
-
88
- <footer class="border-top py-3 mt-auto">
89
- <div class="container d-flex justify-content-between align-items-center text-muted" style="font-size:.85rem">
90
- <span>docrev$if(version)$ $version$$endif$</span>
91
- <span>Built with <a href="https://pandoc.org" class="text-muted">pandoc</a></span>
92
- </div>
93
- </footer>
94
-
95
- <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
96
- <script>
97
- (function () {
98
- var html = document.documentElement;
99
- var sun = document.getElementById('icon-sun');
100
- var moon = document.getElementById('icon-moon');
101
- function apply(t) {
102
- html.setAttribute('data-bs-theme', t);
103
- sun.style.display = t === 'dark' ? 'none' : '';
104
- moon.style.display = t === 'dark' ? '' : 'none';
105
- }
106
- apply(localStorage.getItem('docrev-theme') || 'light');
107
- document.getElementById('theme-toggle').addEventListener('click', function () {
108
- var next = html.getAttribute('data-bs-theme') === 'dark' ? 'light' : 'dark';
109
- localStorage.setItem('docrev-theme', next);
110
- apply(next);
111
- });
112
- })();
113
- </script>
114
-
115
- </body>
116
- </html>
1
+ <!DOCTYPE html>
2
+ <html lang="en" data-bs-theme="light">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>$if(pagetitle)$$pagetitle$ — $endif$docrev</title>
7
+ <meta name="description" content="CLI for writing documents in Markdown while collaborating with Word users.">
8
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootswatch@5.3.3/dist/sandstone/bootstrap.min.css">
9
+ $if(highlighting-css)$
10
+ <style>
11
+ $highlighting-css$
12
+ </style>
13
+ $endif$
14
+ <link rel="stylesheet" href="assets/extra.css">
15
+ </head>
16
+ <body>
17
+
18
+ <nav class="navbar navbar-expand-lg fixed-top" aria-label="Site navigation">
19
+ <div class="container">
20
+ <a class="navbar-brand me-2" href="index.html">docrev</a>
21
+ <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbar-main" aria-controls="navbar-main" aria-expanded="false" aria-label="Toggle navigation">
22
+ <span class="navbar-toggler-icon"></span>
23
+ </button>
24
+ <div class="collapse navbar-collapse" id="navbar-main">
25
+ <ul class="navbar-nav me-auto">
26
+ <li class="nav-item">
27
+ <a class="nav-link$if(active-home)$ active$endif$" $if(active-home)$aria-current="page"$endif$ href="index.html">Home</a>
28
+ </li>
29
+ <li class="nav-item">
30
+ <a class="nav-link$if(active-workflow)$ active$endif$" $if(active-workflow)$aria-current="page"$endif$ href="workflow.html">Get Started</a>
31
+ </li>
32
+ <li class="nav-item">
33
+ <a class="nav-link$if(active-commands)$ active$endif$" $if(active-commands)$aria-current="page"$endif$ href="commands.html">Commands</a>
34
+ </li>
35
+ <li class="nav-item">
36
+ <a class="nav-link$if(active-configuration)$ active$endif$" $if(active-configuration)$aria-current="page"$endif$ href="configuration.html">Configuration</a>
37
+ </li>
38
+ <li class="nav-item">
39
+ <a class="nav-link$if(active-troubleshooting)$ active$endif$" $if(active-troubleshooting)$aria-current="page"$endif$ href="troubleshooting.html">Troubleshooting</a>
40
+ </li>
41
+ </ul>
42
+ <ul class="navbar-nav">
43
+ <li class="nav-item">
44
+ <button id="theme-toggle" class="btn btn-link nav-link border-0" aria-label="Toggle dark mode">
45
+ <svg id="icon-sun" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
46
+ <path d="M8 11a3 3 0 1 1 0-6 3 3 0 0 1 0 6m0 1a4 4 0 1 0 0-8 4 4 0 0 0 0 8M8 0a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 0m0 13a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 13m8-5a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1 0-1h2a.5.5 0 0 1 .5.5M3 8a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1 0-1h2A.5.5 0 0 1 3 8m10.657-5.657a.5.5 0 0 1 0 .707l-1.414 1.415a.5.5 0 1 1-.707-.708l1.414-1.414a.5.5 0 0 1 .707 0m-9.193 9.193a.5.5 0 0 1 0 .707L3.05 13.657a.5.5 0 0 1-.707-.707l1.414-1.414a.5.5 0 0 1 .707 0m9.193 2.121a.5.5 0 0 1-.707 0l-1.414-1.414a.5.5 0 0 1 .707-.707l1.414 1.414a.5.5 0 0 1 0 .707M4.464 4.465a.5.5 0 0 1-.707 0L2.343 3.05a.5.5 0 1 1 .707-.707l1.414 1.414a.5.5 0 0 1 0 .707"/>
47
+ </svg>
48
+ <svg id="icon-moon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16" style="display:none">
49
+ <path d="M6 .278a.768.768 0 0 1 .08.858 7.208 7.208 0 0 0-.878 3.46c0 4.021 3.278 7.277 7.318 7.277.527 0 1.04-.055 1.533-.16a.787.787 0 0 1 .81.316.733.733 0 0 1-.031.893A8.349 8.349 0 0 1 8.344 16C3.734 16 0 12.286 0 7.71 0 4.266 2.114 1.312 5.124.06A.752.752 0 0 1 6 .278"/>
50
+ </svg>
51
+ </button>
52
+ </li>
53
+ <li class="nav-item">
54
+ <a class="nav-link" href="https://github.com/gcol33/docrev" aria-label="GitHub">
55
+ <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" viewBox="0 0 16 16">
56
+ <path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.012 8.012 0 0 0 16 8c0-4.42-3.58-8-8-8"/>
57
+ </svg>
58
+ </a>
59
+ </li>
60
+ <li class="nav-item">
61
+ <a class="nav-link" href="https://www.npmjs.com/package/docrev" aria-label="npm">
62
+ <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 16 16">
63
+ <path d="M0 0v16h16V0zm5.333 13.333H2.667V5.333h2.666v5.334H8V5.333h2.667v8H5.333z"/>
64
+ </svg>
65
+ </a>
66
+ </li>
67
+ </ul>
68
+ </div>
69
+ </div>
70
+ </nav>
71
+
72
+ <div class="container template-$if(active-home)$home$else$article$endif$">
73
+ <div class="row">
74
+ <main id="main" class="$if(toc)$col-md-9$else$col-12$endif$">
75
+ $body$
76
+ </main>
77
+ $if(toc)$
78
+ <aside class="col-md-3 d-none d-md-block">
79
+ <nav id="toc" data-toggle="toc" aria-label="On this page">
80
+ <h2 class="h6">On this page</h2>
81
+ <div class="toc">$toc$</div>
82
+ </nav>
83
+ </aside>
84
+ $endif$
85
+ </div>
86
+ </div>
87
+
88
+ <footer class="border-top py-3 mt-auto">
89
+ <div class="container d-flex justify-content-between align-items-center text-muted" style="font-size:.85rem">
90
+ <span>docrev$if(version)$ $version$$endif$</span>
91
+ <span>Built with <a href="https://pandoc.org" class="text-muted">pandoc</a></span>
92
+ </div>
93
+ </footer>
94
+
95
+ <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
96
+ <script>
97
+ (function () {
98
+ var html = document.documentElement;
99
+ var sun = document.getElementById('icon-sun');
100
+ var moon = document.getElementById('icon-moon');
101
+ function apply(t) {
102
+ html.setAttribute('data-bs-theme', t);
103
+ sun.style.display = t === 'dark' ? 'none' : '';
104
+ moon.style.display = t === 'dark' ? '' : 'none';
105
+ }
106
+ apply(localStorage.getItem('docrev-theme') || 'light');
107
+ document.getElementById('theme-toggle').addEventListener('click', function () {
108
+ var next = html.getAttribute('data-bs-theme') === 'dark' ? 'light' : 'dark';
109
+ localStorage.setItem('docrev-theme', next);
110
+ apply(next);
111
+ });
112
+ })();
113
+ </script>
114
+
115
+ </body>
116
+ </html>
@@ -11,6 +11,8 @@ export type AnchorStrategy =
11
11
  | 'stripped'
12
12
  | 'partial-start'
13
13
  | 'partial-start-stripped'
14
+ | 'partial-window'
15
+ | 'partial-window-stripped'
14
16
  | 'context-both'
15
17
  | 'context-before'
16
18
  | 'context-after'
@@ -171,41 +173,69 @@ export function findAnchorInText(
171
173
  return { occurrences, matchedAnchor: anchor, strategy: 'stripped', stripped: true };
172
174
  }
173
175
 
174
- // Strategy 4: first N words of anchor (long anchors)
176
+ // Strategy 4: word window from anchor (prefix or interior).
177
+ // Sliding the window across the anchor catches the case where the
178
+ // anchor's prefix has been edited but a chunk in the middle/end
179
+ // survived intact (e.g. "Sensitivity analyses were performed by
180
+ // perturbing the prior variance" → drifted "Sensitivity analyses
181
+ // perturbed the prior variance" still contains "the prior variance").
175
182
  const words = anchor.split(/\s+/);
176
183
  if (words.length > 3) {
177
184
  for (let n = Math.min(6, words.length); n >= 3; n--) {
178
- const partialAnchor = words.slice(0, n).join(' ').toLowerCase();
179
- if (partialAnchor.length >= 15) {
180
- occurrences = findAllOccurrences(textLower, partialAnchor);
181
- if (occurrences.length > 0) {
182
- return { occurrences, matchedAnchor: words.slice(0, n).join(' '), strategy: 'partial-start' };
185
+ for (let start = 0; start + n <= words.length; start++) {
186
+ const window = words.slice(start, start + n).join(' ');
187
+ const windowLower = window.toLowerCase();
188
+ if (windowLower.length < 15) continue;
189
+
190
+ let occ = findAllOccurrences(textLower, windowLower);
191
+ if (occ.length > 0) {
192
+ const strategy: AnchorStrategy = start === 0 ? 'partial-start' : 'partial-window';
193
+ return { occurrences: occ, matchedAnchor: window, strategy };
183
194
  }
184
- occurrences = findAllOccurrences(strippedLower, partialAnchor);
185
- if (occurrences.length > 0) {
186
- return {
187
- occurrences,
188
- matchedAnchor: words.slice(0, n).join(' '),
189
- strategy: 'partial-start-stripped',
190
- stripped: true,
191
- };
195
+ occ = findAllOccurrences(strippedLower, windowLower);
196
+ if (occ.length > 0) {
197
+ const strategy: AnchorStrategy = start === 0 ? 'partial-start-stripped' : 'partial-window-stripped';
198
+ return { occurrences: occ, matchedAnchor: window, strategy, stripped: true };
192
199
  }
193
200
  }
194
201
  }
195
202
  }
196
203
 
197
- // Strategy 5: context (before/after) only
204
+ // Strategy 5: context (before/after) only.
205
+ //
206
+ // For a non-empty anchor that already failed every text-based strategy
207
+ // above, we treat context as a degraded placement: classify it
208
+ // 'context-only' so callers can warn the user. We also reject
209
+ // implausible brackets — if both contexts match but the gap between
210
+ // them is far too small to contain the anchor (e.g. the anchored
211
+ // sentence was deleted), do not silently land the comment between
212
+ // the surviving sentences. Return 'failed' so the user is told to
213
+ // place it manually.
198
214
  if (before || after) {
199
215
  const beforeLower = before.toLowerCase();
200
216
  const afterLower = after.toLowerCase();
217
+ const anchorLen = anchor.length;
201
218
 
202
219
  if (before && after) {
203
220
  const beforeIdx = textLower.indexOf(beforeLower.slice(-50));
204
221
  if (beforeIdx !== -1) {
205
222
  const searchStart = beforeIdx + beforeLower.slice(-50).length;
206
223
  const afterIdx = textLower.indexOf(afterLower.slice(0, 50), searchStart);
207
- if (afterIdx !== -1 && afterIdx - searchStart < 500) {
208
- return { occurrences: [searchStart], matchedAnchor: null, strategy: 'context-both' };
224
+ if (afterIdx !== -1) {
225
+ const gap = afterIdx - searchStart;
226
+ // Require the bracket to plausibly contain a remnant of the anchor.
227
+ // Below 30% of anchor length: anchor was deleted — refuse to place.
228
+ // Above 2× anchor length + slack: brackets are too far apart, the
229
+ // matcher has latched onto unrelated repeats of common context.
230
+ const minGap = Math.floor(anchorLen * 0.3);
231
+ const maxGap = Math.min(500, anchorLen * 2 + 50);
232
+ if (gap >= minGap && gap <= maxGap) {
233
+ return { occurrences: [searchStart], matchedAnchor: null, strategy: 'context-both' };
234
+ }
235
+ // Both brackets found but gap implausible: anchor likely deleted.
236
+ // Don't fall back to single-side context — that would silently
237
+ // place the comment in the wrong location.
238
+ return { occurrences: [], matchedAnchor: null, strategy: 'failed' };
209
239
  }
210
240
  }
211
241
  }
@@ -262,6 +292,8 @@ export function classifyStrategy(strategy: AnchorStrategy, occurrences: number):
262
292
  case 'stripped':
263
293
  case 'partial-start':
264
294
  case 'partial-start-stripped':
295
+ case 'partial-window':
296
+ case 'partial-window-stripped':
265
297
  case 'split-match':
266
298
  return 'drift';
267
299
  case 'context-both':
package/lib/build.ts CHANGED
@@ -10,6 +10,7 @@
10
10
 
11
11
  import * as fs from 'fs';
12
12
  import * as path from 'path';
13
+ import { fileURLToPath } from 'url';
13
14
  import { execSync, spawn, ChildProcess } from 'child_process';
14
15
  import YAML from 'yaml';
15
16
  import { stripAnnotations } from './annotations.js';
@@ -24,6 +25,13 @@ import { buildImageRegistry, writeImageRegistry } from './image-registry.js';
24
25
  import type { Author, JournalFormatting } from './types.js';
25
26
  import { getJournalProfile } from './journals.js';
26
27
  import { resolveCSL } from './csl.js';
28
+ import {
29
+ type MacroDef,
30
+ mergeMacros,
31
+ generateLatexPreamble,
32
+ writeMacrosSidecar,
33
+ getMacroFilterPath,
34
+ } from './macros.js';
27
35
 
28
36
  // =============================================================================
29
37
  // Constants
@@ -155,6 +163,13 @@ export interface BuildConfig {
155
163
  pptx: PptxConfig;
156
164
  tables: TablesConfig;
157
165
  postprocess: PostprocessConfig;
166
+ /**
167
+ * User-declared placeholder macros. Merged with the built-in macros
168
+ * (currently \tofill). Each entry overrides a built-in by name.
169
+ *
170
+ * See lib/macros.ts for the per-format rendering rules.
171
+ */
172
+ macros?: MacroDef[];
158
173
  /**
159
174
  * Directory (relative to the project) where final outputs land. Created on
160
175
  * demand. Set to null/empty to keep outputs alongside paper.md (legacy
@@ -313,6 +328,9 @@ export const DEFAULT_CONFIG: BuildConfig = {
313
328
  beamer: null,
314
329
  all: null, // Runs after any format
315
330
  },
331
+ // Placeholder/highlight macros. Defaults are the built-ins from
332
+ // lib/macros.ts; users append their own here.
333
+ macros: [],
316
334
  // Final outputs land here (created on demand). Set to null or '' to keep
317
335
  // outputs in the project root.
318
336
  outputDir: 'output',
@@ -1503,13 +1521,50 @@ export async function runPandoc(
1503
1521
  args.push('--reference-doc', referenceDoc);
1504
1522
  }
1505
1523
 
1506
- // Add color filter for PPTX (handles [text]{color=#RRGGBB} syntax)
1507
- const colorFilterPath = path.join(path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Z]:)/, '$1')), 'pptx-color-filter.lua');
1524
+ // Add color filter for PPTX (handles [text]{color=#RRGGBB} syntax).
1525
+ // fileURLToPath handles Windows paths with spaces — the old
1526
+ // `new URL(...).pathname` returned URL-encoded `%20` and fs.existsSync
1527
+ // silently failed.
1528
+ const colorFilterPath = path.join(
1529
+ path.dirname(fileURLToPath(import.meta.url)),
1530
+ 'pptx-color-filter.lua'
1531
+ );
1508
1532
  if (fs.existsSync(colorFilterPath)) {
1509
1533
  args.push('--lua-filter', colorFilterPath);
1510
1534
  }
1511
1535
  }
1512
1536
 
1537
+ // Wire placeholder macros (built-in \tofill plus user-declared entries).
1538
+ // - docx/html: lua filter expands \name{X} to format-specific raw runs.
1539
+ // - pdf/tex/beamer: inject a \providecommand preamble so LaTeX renders it
1540
+ // directly. `\providecommand` is non-clobbering, so a user who already
1541
+ // has `\providecommand{\tofill}{...}` in their own header keeps theirs.
1542
+ //
1543
+ // Sidecar path is passed to the lua filter via DOCREV_MACROS_FILE in the
1544
+ // child env (not pandoc metadata) because pandoc walks RawInline/RawBlock
1545
+ // BEFORE Meta — by the time a Meta handler could read the path, the inline
1546
+ // expansion has already happened.
1547
+ const macroTempFiles: string[] = [];
1548
+ let macroEnvFile: string | null = null;
1549
+ const macros = mergeMacros((config as { macros?: unknown }).macros);
1550
+ if (macros.length > 0) {
1551
+ if (format === 'docx' || format === 'html' || format === 'html5' || format === 'html4') {
1552
+ const sidecarPath = writeMacrosSidecar(directory, macros);
1553
+ macroTempFiles.push(sidecarPath);
1554
+ macroEnvFile = sidecarPath;
1555
+ const filterPath = getMacroFilterPath();
1556
+ if (fs.existsSync(filterPath)) {
1557
+ args.push('--lua-filter', filterPath);
1558
+ }
1559
+ } else if (format === 'pdf' || format === 'tex' || format === 'beamer') {
1560
+ const preamble = generateLatexPreamble(macros);
1561
+ const preamblePath = path.join(directory, '.macros.tex');
1562
+ fs.writeFileSync(preamblePath, preamble, 'utf-8');
1563
+ macroTempFiles.push(preamblePath);
1564
+ args.push('-H', path.basename(preamblePath));
1565
+ }
1566
+ }
1567
+
1513
1568
  // Add crossref metadata file if exists (skip for slides - they don't use crossref)
1514
1569
  if (format !== 'beamer' && format !== 'pptx') {
1515
1570
  const crossrefPath = path.join(directory, 'crossref.yaml');
@@ -1532,9 +1587,14 @@ export async function runPandoc(
1532
1587
  }
1533
1588
 
1534
1589
  return new Promise((resolve) => {
1590
+ const pandocEnv: NodeJS.ProcessEnv = { ...process.env };
1591
+ if (macroEnvFile) {
1592
+ pandocEnv.DOCREV_MACROS_FILE = macroEnvFile;
1593
+ }
1535
1594
  const pandoc: ChildProcess = spawn('pandoc', args, {
1536
1595
  cwd: directory,
1537
1596
  stdio: ['ignore', 'pipe', 'pipe'],
1597
+ env: pandocEnv,
1538
1598
  });
1539
1599
 
1540
1600
  let stderr = '';
@@ -1542,7 +1602,18 @@ export async function runPandoc(
1542
1602
  stderr += data.toString();
1543
1603
  });
1544
1604
 
1605
+ const cleanupMacroTempFiles = (): void => {
1606
+ for (const tmp of macroTempFiles) {
1607
+ try {
1608
+ fs.unlinkSync(tmp);
1609
+ } catch {
1610
+ // ignore — best-effort cleanup
1611
+ }
1612
+ }
1613
+ };
1614
+
1545
1615
  pandoc.on('close', async (code) => {
1616
+ cleanupMacroTempFiles();
1546
1617
  if (code === 0) {
1547
1618
  // For PPTX, post-process to add slide numbers, buildup colors, and logos
1548
1619
  if (format === 'pptx') {
@@ -1592,6 +1663,7 @@ export async function runPandoc(
1592
1663
  });
1593
1664
 
1594
1665
  pandoc.on('error', (err) => {
1666
+ cleanupMacroTempFiles();
1595
1667
  resolve({ outputPath, success: false, error: err.message });
1596
1668
  });
1597
1669
  });