docrev 0.11.3 → 0.12.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.
Files changed (193) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/LICENSE +21 -0
  3. package/dist/bin/rev.js +29 -21
  4. package/dist/bin/rev.js.map +1 -1
  5. package/dist/lib/build.d.ts +43 -0
  6. package/dist/lib/build.d.ts.map +1 -1
  7. package/dist/lib/build.js +175 -5
  8. package/dist/lib/build.js.map +1 -1
  9. package/dist/lib/commands/build.d.ts.map +1 -1
  10. package/dist/lib/commands/build.js +39 -125
  11. package/dist/lib/commands/build.js.map +1 -1
  12. package/dist/lib/commands/comments.js +3 -3
  13. package/dist/lib/commands/comments.js.map +1 -1
  14. package/dist/lib/commands/context.d.ts +12 -1
  15. package/dist/lib/commands/context.d.ts.map +1 -1
  16. package/dist/lib/commands/context.js +34 -1
  17. package/dist/lib/commands/context.js.map +1 -1
  18. package/dist/lib/commands/merge-resolve.js +1 -1
  19. package/dist/lib/commands/merge-resolve.js.map +1 -1
  20. package/dist/lib/commands/quality.js +2 -2
  21. package/dist/lib/commands/quality.js.map +1 -1
  22. package/dist/lib/commands/sections.js +1 -1
  23. package/dist/lib/commands/sections.js.map +1 -1
  24. package/dist/lib/commands/text-ops.js +1 -1
  25. package/dist/lib/commands/text-ops.js.map +1 -1
  26. package/dist/lib/commands/utilities.d.ts.map +1 -1
  27. package/dist/lib/commands/utilities.js +5 -2
  28. package/dist/lib/commands/utilities.js.map +1 -1
  29. package/dist/lib/commands/word-tools.js +2 -2
  30. package/dist/lib/commands/word-tools.js.map +1 -1
  31. package/dist/lib/crossref.d.ts.map +1 -1
  32. package/dist/lib/crossref.js +12 -41
  33. package/dist/lib/crossref.js.map +1 -1
  34. package/dist/lib/csl.d.ts.map +1 -1
  35. package/dist/lib/csl.js +14 -22
  36. package/dist/lib/csl.js.map +1 -1
  37. package/dist/lib/diff-engine.js +1 -1
  38. package/dist/lib/diff-engine.js.map +1 -1
  39. package/dist/lib/equations.d.ts.map +1 -1
  40. package/dist/lib/equations.js +12 -3
  41. package/dist/lib/equations.js.map +1 -1
  42. package/dist/lib/errors.d.ts.map +1 -1
  43. package/dist/lib/errors.js +1 -30
  44. package/dist/lib/errors.js.map +1 -1
  45. package/dist/lib/image-registry.d.ts.map +1 -1
  46. package/dist/lib/image-registry.js +4 -0
  47. package/dist/lib/image-registry.js.map +1 -1
  48. package/dist/lib/import.d.ts +1 -1
  49. package/dist/lib/import.d.ts.map +1 -1
  50. package/dist/lib/import.js +1 -1
  51. package/dist/lib/import.js.map +1 -1
  52. package/dist/lib/input.d.ts +0 -11
  53. package/dist/lib/input.d.ts.map +1 -1
  54. package/dist/lib/input.js +0 -35
  55. package/dist/lib/input.js.map +1 -1
  56. package/dist/lib/merge.d.ts.map +1 -1
  57. package/dist/lib/merge.js +9 -1
  58. package/dist/lib/merge.js.map +1 -1
  59. package/dist/lib/ooxml.d.ts +22 -0
  60. package/dist/lib/ooxml.d.ts.map +1 -1
  61. package/dist/lib/ooxml.js +130 -0
  62. package/dist/lib/ooxml.js.map +1 -1
  63. package/dist/lib/orcid.d.ts.map +1 -1
  64. package/dist/lib/orcid.js +16 -7
  65. package/dist/lib/orcid.js.map +1 -1
  66. package/dist/lib/plugins.d.ts.map +1 -1
  67. package/dist/lib/plugins.js +8 -4
  68. package/dist/lib/plugins.js.map +1 -1
  69. package/dist/lib/protect-restore.d.ts.map +1 -1
  70. package/dist/lib/protect-restore.js +7 -5
  71. package/dist/lib/protect-restore.js.map +1 -1
  72. package/dist/lib/restore-references.d.ts.map +1 -1
  73. package/dist/lib/restore-references.js +25 -3
  74. package/dist/lib/restore-references.js.map +1 -1
  75. package/dist/lib/schema.d.ts.map +1 -1
  76. package/dist/lib/schema.js +1 -19
  77. package/dist/lib/schema.js.map +1 -1
  78. package/dist/lib/slides.js +1 -1
  79. package/dist/lib/slides.js.map +1 -1
  80. package/dist/lib/spelling.js +1 -1
  81. package/dist/lib/spelling.js.map +1 -1
  82. package/dist/lib/tui.js +0 -1
  83. package/dist/lib/tui.js.map +1 -1
  84. package/dist/lib/utils.d.ts +14 -0
  85. package/dist/lib/utils.d.ts.map +1 -1
  86. package/dist/lib/utils.js +41 -0
  87. package/dist/lib/utils.js.map +1 -1
  88. package/dist/lib/word-extraction.d.ts +5 -5
  89. package/dist/lib/word-extraction.d.ts.map +1 -1
  90. package/dist/lib/word-extraction.js +25 -106
  91. package/dist/lib/word-extraction.js.map +1 -1
  92. package/dist/lib/word.d.ts +14 -38
  93. package/dist/lib/word.d.ts.map +1 -1
  94. package/dist/lib/word.js +24 -179
  95. package/dist/lib/word.js.map +1 -1
  96. package/dist/lib/wordcomments.d.ts.map +1 -1
  97. package/dist/lib/wordcomments.js +9 -7
  98. package/dist/lib/wordcomments.js.map +1 -1
  99. package/package.json +17 -6
  100. package/.gitattributes +0 -1
  101. package/PLAN-tables-and-postprocess.md +0 -850
  102. package/bin/rev.ts +0 -145
  103. package/docs-src/build.py +0 -113
  104. package/docs-src/extra.css +0 -208
  105. package/docs-src/md-to-html.lua +0 -6
  106. package/docs-src/template.html +0 -116
  107. package/eslint.config.js +0 -27
  108. package/lib/anchor-match.ts +0 -307
  109. package/lib/annotations.ts +0 -695
  110. package/lib/build.ts +0 -2047
  111. package/lib/citations.ts +0 -160
  112. package/lib/commands/build.ts +0 -885
  113. package/lib/commands/citations.ts +0 -515
  114. package/lib/commands/comments.ts +0 -1077
  115. package/lib/commands/context.ts +0 -185
  116. package/lib/commands/core.ts +0 -328
  117. package/lib/commands/doi.ts +0 -451
  118. package/lib/commands/file-ops.ts +0 -372
  119. package/lib/commands/history.ts +0 -320
  120. package/lib/commands/index.ts +0 -87
  121. package/lib/commands/init.ts +0 -259
  122. package/lib/commands/merge-resolve.ts +0 -378
  123. package/lib/commands/preview.ts +0 -178
  124. package/lib/commands/project-info.ts +0 -244
  125. package/lib/commands/quality.ts +0 -517
  126. package/lib/commands/response.ts +0 -454
  127. package/lib/commands/section-boundaries.ts +0 -82
  128. package/lib/commands/sections.ts +0 -451
  129. package/lib/commands/sync.ts +0 -689
  130. package/lib/commands/text-ops.ts +0 -449
  131. package/lib/commands/utilities.ts +0 -448
  132. package/lib/commands/verify-anchors.ts +0 -272
  133. package/lib/commands/word-tools.ts +0 -340
  134. package/lib/comment-realign.ts +0 -99
  135. package/lib/config.ts +0 -84
  136. package/lib/crossref.ts +0 -781
  137. package/lib/csl.ts +0 -191
  138. package/lib/dependencies.ts +0 -132
  139. package/lib/diff-engine.ts +0 -465
  140. package/lib/doi-cache.ts +0 -115
  141. package/lib/doi.ts +0 -879
  142. package/lib/equations.ts +0 -506
  143. package/lib/errors.ts +0 -350
  144. package/lib/format.ts +0 -541
  145. package/lib/git.ts +0 -326
  146. package/lib/grammar.ts +0 -303
  147. package/lib/image-registry.ts +0 -180
  148. package/lib/import.ts +0 -932
  149. package/lib/input.ts +0 -174
  150. package/lib/journals.ts +0 -543
  151. package/lib/macro-filter.lua +0 -201
  152. package/lib/macros.ts +0 -273
  153. package/lib/merge.ts +0 -633
  154. package/lib/ooxml.ts +0 -768
  155. package/lib/orcid.ts +0 -144
  156. package/lib/pdf-comments.ts +0 -263
  157. package/lib/pdf-import.ts +0 -524
  158. package/lib/plugins.ts +0 -362
  159. package/lib/postprocess.ts +0 -188
  160. package/lib/pptx-color-filter.lua +0 -37
  161. package/lib/pptx-template.ts +0 -469
  162. package/lib/pptx-themes/academic.pptx +0 -0
  163. package/lib/pptx-themes/corporate.pptx +0 -0
  164. package/lib/pptx-themes/dark.pptx +0 -0
  165. package/lib/pptx-themes/default.pptx +0 -0
  166. package/lib/pptx-themes/minimal.pptx +0 -0
  167. package/lib/pptx-themes/plant.pptx +0 -0
  168. package/lib/pptx-themes.ts +0 -483
  169. package/lib/protect-restore.ts +0 -520
  170. package/lib/rate-limiter.ts +0 -127
  171. package/lib/response.ts +0 -200
  172. package/lib/restore-references.ts +0 -240
  173. package/lib/review.ts +0 -327
  174. package/lib/schema.ts +0 -494
  175. package/lib/scientific-words.ts +0 -73
  176. package/lib/sections.ts +0 -428
  177. package/lib/slides.ts +0 -756
  178. package/lib/spelling.ts +0 -334
  179. package/lib/templates.ts +0 -526
  180. package/lib/themes.ts +0 -742
  181. package/lib/trackchanges.ts +0 -166
  182. package/lib/tui.ts +0 -450
  183. package/lib/types.ts +0 -546
  184. package/lib/undo.ts +0 -250
  185. package/lib/utils.ts +0 -69
  186. package/lib/variables.ts +0 -179
  187. package/lib/word-extraction.ts +0 -525
  188. package/lib/word.ts +0 -526
  189. package/lib/wordcomments.ts +0 -789
  190. package/mkdocs.yml +0 -64
  191. package/scripts/postbuild.js +0 -47
  192. package/tsconfig.json +0 -26
  193. package/types/index.d.ts +0 -531
@@ -1,695 +0,0 @@
1
- /**
2
- * CriticMarkup annotation parsing and manipulation
3
- *
4
- * Syntax:
5
- * {++inserted text++} - Insertions
6
- * {--deleted text--} - Deletions
7
- * {~~old~>new~~} - Substitutions
8
- * {>>Author: comment<<} - Comments
9
- * {==text==} - Highlights
10
- */
11
-
12
- import type { Annotation, AnnotationCounts, StripOptions, CommentFilterOptions } from './types.js';
13
-
14
- // =============================================================================
15
- // Constants
16
- // =============================================================================
17
-
18
- /** Window size for context lookup (characters before/after position) */
19
- const CONTEXT_WINDOW_SIZE = 2000;
20
-
21
- /** Characters of context to include in annotation results */
22
- const CONTEXT_SNIPPET_SIZE = 50;
23
-
24
- /** Maximum iterations for nested annotation stripping (safety limit) */
25
- const MAX_STRIP_ITERATIONS = 20;
26
-
27
- /** Maximum author name length in comments */
28
- const MAX_AUTHOR_LENGTH = 30;
29
-
30
- /** Maximum content length before heuristic assumes it's not a comment */
31
- const MAX_COMMENT_CONTENT_LENGTH = 200;
32
-
33
- // =============================================================================
34
- // Patterns
35
- // =============================================================================
36
-
37
- // Patterns for each annotation type
38
- const PATTERNS = {
39
- insert: /\{\+\+(.+?)\+\+\}/gs,
40
- delete: /\{--(.+?)--\}/gs,
41
- substitute: /\{~~(.+?)~>(.+?)~~\}/gs,
42
- comment: /\{>>(.+?)<<\}/gs,
43
- highlight: /\{==(.+?)==\}/gs,
44
- };
45
-
46
- /**
47
- * Check if a potential comment is actually a false positive
48
- * (e.g., figure caption, nested inside other annotation, code block, etc.)
49
- * @param commentContent - The content inside {>>...<<}
50
- * @param fullText - The full document text
51
- * @param position - Position of the comment in the text
52
- * @returns true if this is a false positive (not a real comment)
53
- */
54
- function isCommentFalsePositive(commentContent: string, fullText: string, position: number): boolean {
55
- // Check if inside a code block (fenced or indented)
56
- const textBefore = fullText.slice(Math.max(0, position - CONTEXT_WINDOW_SIZE), position);
57
- const textAfter = fullText.slice(position, Math.min(fullText.length, position + CONTEXT_WINDOW_SIZE));
58
-
59
- // Count unclosed fenced code blocks (``` or ~~~)
60
- const fenceOpens = (textBefore.match(/^```|^~~~/gm) || []).length;
61
- const fenceCloses = (textBefore.match(/```$|~~~$/gm) || []).length;
62
- if (fenceOpens > fenceCloses) return true; // Inside code block
63
-
64
- // Check if on an indented line (4+ spaces or tab at line start = code)
65
- const lineStart = textBefore.lastIndexOf('\n') + 1;
66
- const linePrefix = fullText.slice(lineStart, position);
67
- if (/^(\t| )/.test(linePrefix)) return true; // Indented code
68
-
69
- // Check if inside inline code backticks
70
- const backticksBefore = (linePrefix.match(/`/g) || []).length;
71
- if (backticksBefore % 2 === 1) return true; // Inside inline code
72
-
73
- // Check if nested inside a deletion or insertion block
74
- const nearTextBefore = fullText.slice(Math.max(0, position - 500), position);
75
-
76
- // Count unclosed deletion markers
77
- const delOpens = (nearTextBefore.match(/\{--/g) || []).length;
78
- const delCloses = (nearTextBefore.match(/--\}/g) || []).length;
79
- if (delOpens > delCloses) return true; // Nested inside deletion
80
-
81
- // Count unclosed insertion markers
82
- const insOpens = (nearTextBefore.match(/\{\+\+/g) || []).length;
83
- const insCloses = (nearTextBefore.match(/\+\+\}/g) || []).length;
84
- if (insOpens > insCloses) return true; // Nested inside insertion
85
-
86
- // Heuristics for figure captions and other false positives:
87
-
88
- // Contains image/figure path patterns
89
- if (/\(figures?\/|\(images?\/|\.png|\.jpg|\.jpeg|\.gif|\.svg|\.pdf/i.test(commentContent)) return true;
90
-
91
- // Contains markdown figure reference syntax
92
- if (/\{#fig:|!\[/.test(commentContent)) return true;
93
-
94
- // Real comments typically have "Author:" at start. Accept hyphens, apostrophes,
95
- // periods, and Unicode letters so names like "Jens-Christian Svenning" or
96
- // "Camilla T Colding-Jørgensen" don't get rejected. See gcol33/docrev#1.
97
- const hasAuthorPrefix = /^[\p{L}][\p{L}\s\-'.]{0,30}:\s/u.test(commentContent.trim());
98
- const hasResolvedMark = /^[✓✔]\s/.test(commentContent.trim());
99
-
100
- // Contains URL patterns (likely a link, not a comment) — only filter when
101
- // there is no real author prefix, since reviewers legitimately cite URLs/DOIs.
102
- if (!hasAuthorPrefix && /https?:\/\/|www\./i.test(commentContent) && commentContent.length < 150) return true;
103
-
104
- // Looks like code (contains programming patterns)
105
- if (/function\s*\(|=>|import\s+|export\s+|const\s+|let\s+|var\s+/.test(commentContent)) return true;
106
-
107
- // Very long without clear author pattern (likely caption, not comment)
108
- if (!hasAuthorPrefix && !hasResolvedMark && commentContent.length > MAX_COMMENT_CONTENT_LENGTH) return true;
109
-
110
- // Looks like a figure caption (starts with "Fig" or contains typical caption words)
111
- if (/^(Fig\.?|Figure|Table|Sankey|Diagram|Proportion|Distribution|Map|Chart|Graph|Plot|Panel)/i.test(commentContent.trim())) {
112
- return true;
113
- }
114
-
115
- // Contains LaTeX-like patterns (likely equation, not comment)
116
- if (/\\[a-z]+\{|\\frac|\\sum|\\int|\\begin\{/.test(commentContent)) return true;
117
-
118
- // Looks like BibTeX entry (not a comment)
119
- if (/@article\{|@book\{|@inproceedings\{/i.test(commentContent)) return true;
120
-
121
- return false;
122
- }
123
-
124
- // Combined pattern for any track change (not comments)
125
- const TRACK_CHANGE_PATTERN = /(\{\+\+.+?\+\+\}|\{--.+?--\}|\{~~.+?~>.+?~~\})/gs;
126
-
127
- /**
128
- * Reject binary content before it reaches the CriticMarkup regexes. A `.docx`
129
- * is a ZIP; read as UTF-8 it starts with `PK\x03\x04` and is riddled with NUL
130
- * bytes from the DEFLATE stream. Running the annotation patterns over that
131
- * yields accidental byte-matches that look like a small, plausible count —
132
- * silent garbage. This guard turns that whole failure class into a loud error.
133
- * Callers that legitimately handle Word documents route them through
134
- * `readAnnotatedInput` (lib/input.ts), which converts the docx to CriticMarkup
135
- * first, so this only fires on a raw binary that reached a text-only path.
136
- *
137
- * UTF-8 Markdown never contains a NUL byte, so the check cannot false-positive
138
- * on real input; it scans only the head to stay O(1) on large documents.
139
- * @throws Error if the text is binary rather than text/Markdown.
140
- */
141
- function assertNotBinary(text: string): void {
142
- // ZIP local-file-header magic "PK\x03\x04" (docx/xlsx/pptx read as text),
143
- // or any NUL byte in the head: a reliable binary marker absent from UTF-8
144
- // Markdown, so this never false-positives on real input. Scans only the
145
- // head to stay cheap on large documents.
146
- if (text.startsWith('PK\u0003\u0004') || text.slice(0, 8192).includes('\u0000')) {
147
- throw new Error(
148
- 'Expected text/Markdown but received binary content. If this is a Word ' +
149
- 'document, convert it first with "rev import <docx>" (rev status/comments ' +
150
- 'read .docx directly; other commands need the imported Markdown).',
151
- );
152
- }
153
- }
154
-
155
- // =============================================================================
156
- // Public API
157
- // =============================================================================
158
-
159
- /**
160
- * Parse all annotations from text
161
- * @param text - Markdown text containing CriticMarkup annotations
162
- * @returns Array of parsed annotations sorted by position
163
- * @throws TypeError If text is not a string
164
- */
165
- export function parseAnnotations(text: string): Annotation[] {
166
- if (typeof text !== 'string') {
167
- throw new TypeError(`text must be a string, got ${typeof text}`);
168
- }
169
- assertNotBinary(text);
170
-
171
- const annotations: Annotation[] = [];
172
-
173
- // Build line number lookup
174
- const lines = text.split('\n');
175
- let pos = 0;
176
- const lineStarts = lines.map((line) => {
177
- const start = pos;
178
- pos += line.length + 1;
179
- return start;
180
- });
181
-
182
- function getLine(position: number): number {
183
- for (let i = 0; i < lineStarts.length; i++) {
184
- const start = lineStarts[i];
185
- if (start !== undefined && start > position) return i;
186
- }
187
- return lineStarts.length;
188
- }
189
-
190
- function getContext(position: number, length: number): { before: string; after: string } {
191
- const start = Math.max(0, position - CONTEXT_SNIPPET_SIZE);
192
- const end = Math.min(text.length, position + length + CONTEXT_SNIPPET_SIZE);
193
- const before = text.slice(start, position).split('\n').pop() || '';
194
- const after = text.slice(position + length, end).split('\n')[0] || '';
195
- return { before, after };
196
- }
197
-
198
- // Parse insertions
199
- for (const match of text.matchAll(PATTERNS.insert)) {
200
- if (match.index === undefined) continue;
201
- const ctx = getContext(match.index, match[0].length);
202
- annotations.push({
203
- type: 'insert',
204
- match: match[0],
205
- content: match[1] || '',
206
- position: match.index,
207
- line: getLine(match.index),
208
- ...ctx,
209
- });
210
- }
211
-
212
- // Parse deletions
213
- for (const match of text.matchAll(PATTERNS.delete)) {
214
- if (match.index === undefined) continue;
215
- const ctx = getContext(match.index, match[0].length);
216
- annotations.push({
217
- type: 'delete',
218
- match: match[0],
219
- content: match[1] || '',
220
- position: match.index,
221
- line: getLine(match.index),
222
- ...ctx,
223
- });
224
- }
225
-
226
- // Parse substitutions
227
- for (const match of text.matchAll(PATTERNS.substitute)) {
228
- if (match.index === undefined) continue;
229
- const ctx = getContext(match.index, match[0].length);
230
- annotations.push({
231
- type: 'substitute',
232
- match: match[0],
233
- content: match[1] || '',
234
- replacement: match[2] || '',
235
- position: match.index,
236
- line: getLine(match.index),
237
- ...ctx,
238
- });
239
- }
240
-
241
- // Parse comments (with false positive filtering)
242
- for (const match of text.matchAll(PATTERNS.comment)) {
243
- if (match.index === undefined) continue;
244
- // Skip false positives (figure captions, nested annotations, etc.)
245
- const commentContent = match[1] || '';
246
- if (isCommentFalsePositive(commentContent, text, match.index)) {
247
- continue;
248
- }
249
-
250
- const ctx = getContext(match.index, match[0].length);
251
- let commentText = commentContent;
252
- let author = '';
253
-
254
- // Extract author if present (format: "Author: comment")
255
- const colonIdx = commentText.indexOf(':');
256
- if (colonIdx > 0 && colonIdx < MAX_AUTHOR_LENGTH) {
257
- author = commentText.slice(0, colonIdx).trim();
258
- commentText = commentText.slice(colonIdx + 1).trim();
259
- }
260
-
261
- annotations.push({
262
- type: 'comment',
263
- match: match[0],
264
- content: commentText,
265
- author,
266
- position: match.index,
267
- line: getLine(match.index),
268
- ...ctx,
269
- });
270
- }
271
-
272
- // Sort by position
273
- annotations.sort((a, b) => a.position - b.position);
274
- return annotations;
275
- }
276
-
277
- /**
278
- * Strip annotations from text, applying changes
279
- * Handles nested annotations by iterating until stable
280
- * @param text - Markdown text with CriticMarkup annotations
281
- * @param options - Strip options
282
- * @returns Clean text with annotations applied/removed
283
- * @throws TypeError If text is not a string
284
- */
285
- export function stripAnnotations(text: string, options: StripOptions = {}): string {
286
- if (typeof text !== 'string') {
287
- throw new TypeError(`text must be a string, got ${typeof text}`);
288
- }
289
- assertNotBinary(text);
290
-
291
- const { keepComments = false } = options;
292
-
293
- // Iterate until no more changes (handles nested annotations)
294
- let prev: string;
295
- let iterations = 0;
296
-
297
- do {
298
- prev = text;
299
-
300
- // Apply substitutions: {~~old~>new~~} → new
301
- text = text.replace(PATTERNS.substitute, '$2');
302
-
303
- // Apply insertions: {++text++} → text
304
- text = text.replace(PATTERNS.insert, '$1');
305
-
306
- // Apply deletions: {--text--} → nothing
307
- // Don't touch surrounding whitespace - just remove the annotation
308
- text = text.replace(PATTERNS.delete, '');
309
-
310
- // Remove highlights: {==text==} → text
311
- text = text.replace(PATTERNS.highlight, '$1');
312
-
313
- // Remove comments unless keeping
314
- if (!keepComments) {
315
- text = text.replace(PATTERNS.comment, '');
316
- }
317
-
318
- // Strip pandoc highlight spans: [text]{.mark} → text.
319
- // When `keepComments=true`, preserve `[anchor]{.mark}` that is the
320
- // anchor of a kept `{>>...<<}` comment. The dual-build flow runs
321
- // stripAnnotations() before prepareMarkdownWithMarkers(), and stripping
322
- // the anchor span here would leave the marker generator with no anchor
323
- // text — collapsing every multi-word anchor to a single fallback word
324
- // in the rebuilt docx.
325
- text = keepComments
326
- ? text.replace(/(?<!<<\}\s{0,3})\[([^\]]*)\]\{\.mark\}/g, '$1')
327
- : text.replace(/\[([^\]]*)\]\{\.mark\}/g, '$1');
328
-
329
- // Clean up partial/orphaned markers within the loop
330
- // This handles cases where nested annotations leave behind fragments
331
-
332
- // Empty annotations (from nested stripping)
333
- text = text.replace(/\{----\}/g, '');
334
- text = text.replace(/\{\+\+\+\+\}/g, '');
335
- text = text.replace(/\{--\s*--\}/g, '');
336
- text = text.replace(/\{\+\+\s*\+\+\}/g, '');
337
-
338
- // Orphaned substitution fragments: ~>text~~} or {~~text (no proper pairs)
339
- text = text.replace(/~>[^{]*?~~\}/g, '');
340
- text = text.replace(/\{~~[^~}]*$/gm, '');
341
-
342
- // Handle malformed substitution from nested: {~~{~~old → just strip the {~~
343
- text = text.replace(/\{~~\{~~/g, '{~~');
344
- text = text.replace(/~~\}~~\}/g, '~~}');
345
-
346
- iterations++;
347
- } while (text !== prev && iterations < MAX_STRIP_ITERATIONS);
348
-
349
- // Final cleanup of any remaining orphaned markers
350
- // Orphaned closing markers
351
- text = text.replace(/--\}(?:--\})+/g, '');
352
- text = text.replace(/\+\+\}(?:\+\+\})+/g, '');
353
- text = text.replace(/~~\}(?:~~\})+/g, '');
354
- text = text.replace(/--\}/g, '');
355
- text = text.replace(/\+\+\}/g, '');
356
- text = text.replace(/~~\}/g, '');
357
-
358
- // Orphaned opening markers
359
- text = text.replace(/\{--(?:\{--)+/g, '');
360
- text = text.replace(/\{\+\+(?:\{\+\+)+/g, '');
361
- text = text.replace(/\{~~(?:\{~~)+/g, '');
362
- text = text.replace(/\{--/g, '');
363
- text = text.replace(/\{\+\+/g, '');
364
- text = text.replace(/\{~~/g, '');
365
- text = text.replace(/~>/g, '');
366
-
367
- // Remove orphan [ from stripped {.mark} spans where the closing ]{.mark}
368
- // was inside a comment. A [ is orphan if no `]` follows before end of line.
369
- // We deliberately allow other `[` between the candidate and the matching `]`
370
- // — otherwise nested forms like `[[0..9]]{.mark}` would have their outer
371
- // `[` stripped because the lookahead saw the inner `[` as a barrier.
372
- text = text.replace(/\[(?![^\]\n]*\])/g, '');
373
-
374
- return text;
375
- }
376
-
377
- /**
378
- * Collapse multiple spaces to single space, preserving table formatting
379
- * Useful for cleaning up messy Word imports
380
- * @param text - Text to normalize
381
- * @returns Text with multiple spaces collapsed to single spaces
382
- * @throws TypeError If text is not a string
383
- */
384
- export function stripToSingleSpace(text: string): string {
385
- if (typeof text !== 'string') {
386
- throw new TypeError(`text must be a string, got ${typeof text}`);
387
- }
388
-
389
- const lines = text.split('\n');
390
- let inTable = false;
391
-
392
- // Helper to check if a line looks like table content
393
- const looksLikeTableRow = (ln: string): boolean => {
394
- const trimmed = ln.trim();
395
- if (!trimmed) return false;
396
- // Has multiple consecutive spaces (column spacing)
397
- // OR italicized category header with trailing spaces
398
- return /\S\s{2,}\S/.test(trimmed) || (/^\*[^*]+\*\s*$/.test(trimmed) && /\s{2,}$/.test(ln));
399
- };
400
-
401
- for (let i = 0; i < lines.length; i++) {
402
- const line = lines[i];
403
- if (!line) continue;
404
-
405
- // Detect table separator line
406
- const isTableSeparator = /^\|?[\s-]*[-]{3,}[\s|:-]+[-]{3,}/.test(line) ||
407
- /^[-]{3,}\s{2,}[-]{3,}/.test(line);
408
-
409
- if (isTableSeparator) {
410
- inTable = true;
411
- continue;
412
- }
413
-
414
- // Check if we're exiting the table
415
- if (inTable && line.trim() === '') {
416
- let nextContentLine = '';
417
- for (let j = i + 1; j < lines.length && j < i + 5; j++) {
418
- const nextLine = lines[j];
419
- if (nextLine && nextLine.trim() !== '') {
420
- nextContentLine = nextLine;
421
- break;
422
- }
423
- }
424
- if (!looksLikeTableRow(nextContentLine) && !/^[-]{3,}/.test(nextContentLine.trim())) {
425
- inTable = false;
426
- }
427
- continue;
428
- }
429
-
430
- // Only collapse spaces outside tables
431
- if (!inTable) {
432
- lines[i] = line.replace(/ +/g, ' ');
433
- }
434
- }
435
-
436
- return lines.join('\n');
437
- }
438
-
439
- /**
440
- * Check if text contains any CriticMarkup annotations
441
- * @param text - Text to check
442
- * @returns True if text contains any annotations
443
- * @throws TypeError If text is not a string
444
- */
445
- // Non-global copies for membership tests. `.test()` on a global regex advances
446
- // its lastIndex, so reusing PATTERNS here would make the result depend on prior
447
- // calls and intermittently miss real annotations.
448
- const ANNOTATION_TESTERS = [
449
- PATTERNS.insert,
450
- PATTERNS.delete,
451
- PATTERNS.substitute,
452
- PATTERNS.comment,
453
- PATTERNS.highlight,
454
- ].map((re) => new RegExp(re.source, re.flags.replace('g', '')));
455
-
456
- export function hasAnnotations(text: string): boolean {
457
- if (typeof text !== 'string') {
458
- throw new TypeError(`text must be a string, got ${typeof text}`);
459
- }
460
- assertNotBinary(text);
461
-
462
- return ANNOTATION_TESTERS.some((re) => re.test(text));
463
- }
464
-
465
- /**
466
- * Replace the specific annotation occurrence at `position` rather than the
467
- * first textual match. Two identical annotations (e.g. the same `{++the++}`
468
- * twice) would otherwise have the wrong one edited. Falls back to a
469
- * first-occurrence replace only if the recorded position no longer lines up.
470
- */
471
- function replaceAnnotationAt(text: string, match: string, position: number, replacement: string): string {
472
- if (position >= 0 && text.startsWith(match, position)) {
473
- return text.slice(0, position) + replacement + text.slice(position + match.length);
474
- }
475
- return text.replace(match, replacement);
476
- }
477
-
478
- /**
479
- * Apply a decision to a single annotation (accept or reject)
480
- * @param text - Document text containing the annotation
481
- * @param annotation - Annotation object from parseAnnotations()
482
- * @param accept - True to accept the change, false to reject
483
- * @returns Updated text with the decision applied
484
- * @throws TypeError If text is not a string or annotation is invalid
485
- */
486
- export function applyDecision(text: string, annotation: Annotation, accept: boolean): string {
487
- if (typeof text !== 'string') {
488
- throw new TypeError(`text must be a string, got ${typeof text}`);
489
- }
490
- if (!annotation || typeof annotation.type !== 'string' || typeof annotation.match !== 'string') {
491
- throw new TypeError('annotation must have type and match properties');
492
- }
493
- let replacement: string;
494
-
495
- // Extract any comments embedded in the annotation content
496
- // These should be preserved when accepting deletions or rejecting insertions
497
- const commentPattern = /\{>>[\s\S]*?<<\}/g;
498
- const embeddedComments = (annotation.match || '').match(commentPattern) || [];
499
-
500
- switch (annotation.type) {
501
- case 'insert':
502
- if (accept) {
503
- replacement = annotation.content;
504
- } else {
505
- // Rejecting insertion - preserve any comments that were inside
506
- replacement = embeddedComments.length > 0 ? embeddedComments.join('') : '';
507
- }
508
- break;
509
- case 'delete':
510
- if (accept) {
511
- // Accepting deletion - preserve any comments by placing them before
512
- replacement = embeddedComments.length > 0 ? embeddedComments.join('') : '';
513
- } else {
514
- replacement = annotation.content;
515
- }
516
- break;
517
- case 'substitute':
518
- if (accept) {
519
- // For substitutions, check if comments are in the old text being replaced
520
- const oldTextComments = (annotation.content || '').match(commentPattern) || [];
521
- replacement = annotation.replacement || '';
522
- if (oldTextComments.length > 0) {
523
- // Prepend comments that were in the old text
524
- replacement = oldTextComments.join('') + replacement;
525
- }
526
- } else {
527
- replacement = annotation.content;
528
- }
529
- break;
530
- default:
531
- return text;
532
- }
533
-
534
- return replaceAnnotationAt(text, annotation.match, annotation.position, replacement);
535
- }
536
-
537
- /**
538
- * Get track changes only (no comments)
539
- * @param text - Markdown text with CriticMarkup annotations
540
- * @returns Array of insert/delete/substitute annotations
541
- * @throws TypeError If text is not a string
542
- */
543
- export function getTrackChanges(text: string): Annotation[] {
544
- // Input validation delegated to parseAnnotations
545
- return parseAnnotations(text).filter((a) => a.type !== 'comment');
546
- }
547
-
548
- /**
549
- * Get comments only
550
- * @param text - Markdown text with CriticMarkup annotations
551
- * @param options - Filter options
552
- * @returns Array of comment annotations
553
- * @throws TypeError If text is not a string
554
- */
555
- export function getComments(text: string, options: CommentFilterOptions = {}): Annotation[] {
556
- // Input validation delegated to parseAnnotations
557
- const { pendingOnly = false, resolvedOnly = false } = options;
558
- let comments = parseAnnotations(text).filter((a) => a.type === 'comment');
559
-
560
- // Check for resolved status marker at end of comment
561
- comments = comments.map((c) => {
562
- const resolved = c.content.endsWith('[RESOLVED]') || c.content.endsWith('[✓]');
563
- return {
564
- ...c,
565
- resolved,
566
- content: resolved
567
- ? c.content.replace(/\s*\[(RESOLVED|✓)\]$/, '').trim()
568
- : c.content,
569
- };
570
- });
571
-
572
- if (pendingOnly) {
573
- comments = comments.filter((c) => !c.resolved);
574
- }
575
- if (resolvedOnly) {
576
- comments = comments.filter((c) => c.resolved);
577
- }
578
-
579
- return comments;
580
- }
581
-
582
- /**
583
- * Mark a comment as resolved or pending
584
- * @param text - Document text containing the comment
585
- * @param comment - Comment annotation object from getComments()
586
- * @param resolved - True to mark resolved, false to mark pending
587
- * @returns Updated text with status marker applied
588
- * @throws TypeError If text is not a string or comment is invalid
589
- */
590
- export function setCommentStatus(text: string, comment: Annotation, resolved: boolean): string {
591
- if (typeof text !== 'string') {
592
- throw new TypeError(`text must be a string, got ${typeof text}`);
593
- }
594
- if (!comment || typeof comment.match !== 'string') {
595
- throw new TypeError('comment must have a match property');
596
- }
597
- // Find the comment in the text
598
- const originalMatch = comment.match;
599
-
600
- if (resolved) {
601
- // Add [RESOLVED] marker before the closing <<
602
- const newMatch = originalMatch.replace(/<<\}$/, ' [RESOLVED]<<}');
603
- return replaceAnnotationAt(text, originalMatch, comment.position, newMatch);
604
- } else {
605
- // Remove resolved markers
606
- const newMatch = originalMatch.replace(/\s*\[(RESOLVED|✓)\]<<\}$/, '<<}');
607
- return replaceAnnotationAt(text, originalMatch, comment.position, newMatch);
608
- }
609
- }
610
-
611
- /**
612
- * Count annotations by type
613
- * @param text - Markdown text with CriticMarkup annotations
614
- * @returns Counts by annotation type
615
- * @throws TypeError If text is not a string
616
- */
617
- export function countAnnotations(text: string): AnnotationCounts {
618
- // Input validation delegated to parseAnnotations
619
- const annotations = parseAnnotations(text);
620
- const counts: AnnotationCounts = { inserts: 0, deletes: 0, substitutes: 0, comments: 0, total: 0 };
621
-
622
- for (const a of annotations) {
623
- counts.total++;
624
- switch (a.type) {
625
- case 'insert':
626
- counts.inserts++;
627
- break;
628
- case 'delete':
629
- counts.deletes++;
630
- break;
631
- case 'substitute':
632
- counts.substitutes++;
633
- break;
634
- case 'comment':
635
- counts.comments++;
636
- break;
637
- }
638
- }
639
-
640
- return counts;
641
- }
642
-
643
- /**
644
- * Clean up orphaned/malformed CriticMarkup markers
645
- * This can happen when track changes span across comment boundaries
646
- * @param text - Document text with potentially malformed markers
647
- * @returns Cleaned text with orphaned markers removed
648
- * @throws TypeError If text is not a string
649
- */
650
- export function cleanupOrphanedMarkers(text: string): string {
651
- if (typeof text !== 'string') {
652
- throw new TypeError(`text must be a string, got ${typeof text}`);
653
- }
654
- let result = text;
655
-
656
- // Remove orphaned insertion end markers (++} not preceded by {++)
657
- // These occur when an insertion's start was inside something that got deleted/replaced
658
- result = result.replace(/(?<!\{\+\+[^}]*)\+\+\}/g, '');
659
-
660
- // Remove orphaned deletion end markers (--} not preceded by {--)
661
- result = result.replace(/(?<!\{--[^}]*)--\}/g, '');
662
-
663
- // Remove orphaned substitution end markers (~~} not preceded by {~~)
664
- result = result.replace(/(?<!\{~~[^}]*)~~\}/g, '');
665
-
666
- // Fix unclosed insertions: {++ without matching ++}
667
- // Find {++ and check if there's a matching ++} before the next { marker
668
- result = result.replace(/\{\+\+([^+]*?)(?=\{[+\-~>]|\{>>|$)/g, (match, content) => {
669
- // If content has no ++}, it's unclosed - just keep the content
670
- if (!content.includes('++}')) {
671
- return content;
672
- }
673
- return match;
674
- });
675
-
676
- // Fix unclosed deletions: {-- without matching --}
677
- result = result.replace(/\{--([^-]*?)(?=\{[+\-~>]|\{>>|$)/g, (match, content) => {
678
- if (!content.includes('--}')) {
679
- return content;
680
- }
681
- return match;
682
- });
683
-
684
- // Fix unclosed substitutions: {~~ without matching ~~}
685
- // This is trickier because we need both ~> and ~~}
686
- result = result.replace(/\{~~([^~]*?)~>([^~]*?)(?=\{[+\-~>]|\{>>|$)/g, (match, old, newText) => {
687
- if (!match.includes('~~}')) {
688
- // Unclosed substitution - keep the new text
689
- return newText;
690
- }
691
- return match;
692
- });
693
-
694
- return result;
695
- }