@aaronshaf/confluence-cli 0.1.15

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 (94) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +69 -0
  3. package/package.json +73 -0
  4. package/src/cli/commands/attachments.ts +113 -0
  5. package/src/cli/commands/clone.ts +188 -0
  6. package/src/cli/commands/comments.ts +56 -0
  7. package/src/cli/commands/create.ts +58 -0
  8. package/src/cli/commands/delete.ts +46 -0
  9. package/src/cli/commands/doctor.ts +161 -0
  10. package/src/cli/commands/duplicate-check.ts +89 -0
  11. package/src/cli/commands/file-rename.ts +113 -0
  12. package/src/cli/commands/folder-hierarchy.ts +241 -0
  13. package/src/cli/commands/info.ts +56 -0
  14. package/src/cli/commands/labels.ts +53 -0
  15. package/src/cli/commands/move.ts +23 -0
  16. package/src/cli/commands/open.ts +145 -0
  17. package/src/cli/commands/pull.ts +241 -0
  18. package/src/cli/commands/push-errors.ts +40 -0
  19. package/src/cli/commands/push.ts +699 -0
  20. package/src/cli/commands/search.ts +62 -0
  21. package/src/cli/commands/setup.ts +124 -0
  22. package/src/cli/commands/spaces.ts +42 -0
  23. package/src/cli/commands/status.ts +88 -0
  24. package/src/cli/commands/tree.ts +190 -0
  25. package/src/cli/help.ts +425 -0
  26. package/src/cli/index.ts +413 -0
  27. package/src/cli/utils/browser.ts +34 -0
  28. package/src/cli/utils/progress-reporter.ts +49 -0
  29. package/src/cli.ts +6 -0
  30. package/src/lib/config.ts +156 -0
  31. package/src/lib/confluence-client/attachment-operations.ts +221 -0
  32. package/src/lib/confluence-client/client.ts +653 -0
  33. package/src/lib/confluence-client/comment-operations.ts +60 -0
  34. package/src/lib/confluence-client/folder-operations.ts +203 -0
  35. package/src/lib/confluence-client/index.ts +47 -0
  36. package/src/lib/confluence-client/label-operations.ts +102 -0
  37. package/src/lib/confluence-client/page-operations.ts +270 -0
  38. package/src/lib/confluence-client/search-operations.ts +60 -0
  39. package/src/lib/confluence-client/types.ts +329 -0
  40. package/src/lib/confluence-client/user-operations.ts +58 -0
  41. package/src/lib/dependency-sorter.ts +233 -0
  42. package/src/lib/errors.ts +237 -0
  43. package/src/lib/file-scanner.ts +195 -0
  44. package/src/lib/formatters.ts +314 -0
  45. package/src/lib/health-check.ts +204 -0
  46. package/src/lib/markdown/converter.ts +427 -0
  47. package/src/lib/markdown/frontmatter.ts +116 -0
  48. package/src/lib/markdown/html-converter.ts +398 -0
  49. package/src/lib/markdown/index.ts +21 -0
  50. package/src/lib/markdown/link-converter.ts +189 -0
  51. package/src/lib/markdown/reference-updater.ts +251 -0
  52. package/src/lib/markdown/slugify.ts +32 -0
  53. package/src/lib/page-state.ts +195 -0
  54. package/src/lib/resolve-page-target.ts +33 -0
  55. package/src/lib/space-config.ts +264 -0
  56. package/src/lib/sync/cleanup.ts +50 -0
  57. package/src/lib/sync/folder-path.ts +61 -0
  58. package/src/lib/sync/index.ts +2 -0
  59. package/src/lib/sync/link-resolution-pass.ts +139 -0
  60. package/src/lib/sync/sync-engine.ts +681 -0
  61. package/src/lib/sync/sync-specific.ts +221 -0
  62. package/src/lib/sync/types.ts +42 -0
  63. package/src/test/attachments.test.ts +68 -0
  64. package/src/test/clone.test.ts +373 -0
  65. package/src/test/comments.test.ts +53 -0
  66. package/src/test/config.test.ts +209 -0
  67. package/src/test/confluence-client.test.ts +535 -0
  68. package/src/test/delete.test.ts +39 -0
  69. package/src/test/dependency-sorter.test.ts +384 -0
  70. package/src/test/errors.test.ts +199 -0
  71. package/src/test/file-rename.test.ts +305 -0
  72. package/src/test/file-scanner.test.ts +331 -0
  73. package/src/test/folder-hierarchy.test.ts +337 -0
  74. package/src/test/formatters.test.ts +213 -0
  75. package/src/test/html-converter.test.ts +399 -0
  76. package/src/test/info.test.ts +56 -0
  77. package/src/test/labels.test.ts +70 -0
  78. package/src/test/link-conversion-integration.test.ts +189 -0
  79. package/src/test/link-converter.test.ts +413 -0
  80. package/src/test/link-resolution-pass.test.ts +368 -0
  81. package/src/test/markdown.test.ts +443 -0
  82. package/src/test/mocks/handlers.ts +228 -0
  83. package/src/test/move.test.ts +53 -0
  84. package/src/test/msw-schema-validation.ts +151 -0
  85. package/src/test/page-state.test.ts +542 -0
  86. package/src/test/push.test.ts +551 -0
  87. package/src/test/reference-updater.test.ts +293 -0
  88. package/src/test/resolve-page-target.test.ts +55 -0
  89. package/src/test/search.test.ts +64 -0
  90. package/src/test/setup-msw.ts +75 -0
  91. package/src/test/space-config.test.ts +516 -0
  92. package/src/test/spaces.test.ts +53 -0
  93. package/src/test/sync-engine.test.ts +486 -0
  94. package/src/types/turndown-plugin-gfm.d.ts +9 -0
@@ -0,0 +1,398 @@
1
+ import { Marked, type Tokens, type Renderer } from 'marked';
2
+ import { relativePathToConfluenceLink, type PageLookupMap } from './link-converter.js';
3
+
4
+ /**
5
+ * HTML converter that transforms Markdown to Confluence Storage Format
6
+ * Inverse of MarkdownConverter - used for pushing local changes to Confluence
7
+ * Per ADR-0022: Converts relative markdown links to Confluence page links
8
+ */
9
+ export class HtmlConverter {
10
+ private warnings: string[] = [];
11
+ private spaceRoot: string = '';
12
+ private currentPagePath: string = '';
13
+ private spaceKey: string = '';
14
+ private pageLookupMap: PageLookupMap | null = null;
15
+
16
+ /**
17
+ * Escape special XML characters for use in attributes
18
+ * Converts: & < > " ' to their XML entity equivalents
19
+ * @param text - Text to escape
20
+ * @returns XML-safe text
21
+ */
22
+ private escapeXml(text: string): string {
23
+ return text
24
+ .replace(/&/g, '&amp;')
25
+ .replace(/</g, '&lt;')
26
+ .replace(/>/g, '&gt;')
27
+ .replace(/"/g, '&quot;')
28
+ .replace(/'/g, '&apos;');
29
+ }
30
+
31
+ /**
32
+ * Escape CDATA sections by replacing ]]> with ]]]]><![CDATA[>
33
+ * This allows code containing ]]> to be safely embedded in CDATA
34
+ * @param text - Text to escape
35
+ * @returns CDATA-safe text
36
+ */
37
+ private escapeCdata(text: string): string {
38
+ return text.replace(/]]>/g, ']]]]><![CDATA[>');
39
+ }
40
+
41
+ /**
42
+ * Validate and sanitize language identifier for code blocks
43
+ * Only allows alphanumeric, dash, underscore, and plus
44
+ * @param lang - Language identifier from code fence (e.g., "javascript", "python")
45
+ * @returns Sanitized language string safe for XML attributes
46
+ */
47
+ private sanitizeLanguage(lang: string | undefined): string {
48
+ if (!lang) return '';
49
+ // Allow only safe characters for language identifiers
50
+ return lang.replace(/[^a-zA-Z0-9\-_+]/g, '');
51
+ }
52
+
53
+ /**
54
+ * Create a configured Marked instance with custom renderer
55
+ * Uses marked v12+ token-based API
56
+ */
57
+ private createMarkedInstance(): Marked {
58
+ const self = this;
59
+
60
+ const renderer: Partial<Renderer> = {
61
+ // Code blocks - use Confluence code macro for syntax highlighting
62
+ code(this: Renderer, token: Tokens.Code): string {
63
+ const language = self.sanitizeLanguage(token.lang);
64
+ const escapedCode = self.escapeCdata(token.text);
65
+ return `<ac:structured-macro ac:name="code" ac:schema-version="1">
66
+ <ac:parameter ac:name="language">${language}</ac:parameter>
67
+ <ac:plain-text-body><![CDATA[${escapedCode}]]></ac:plain-text-body>
68
+ </ac:structured-macro>\n`;
69
+ },
70
+
71
+ // Blockquotes - convert to Confluence info panel for better visibility
72
+ blockquote(this: Renderer, token: Tokens.Blockquote): string {
73
+ // Parse inner tokens to get HTML content
74
+ const innerHtml = this.parser.parse(token.tokens);
75
+
76
+ // Check if this is a special panel (Info:, Note:, Warning:, Tip:)
77
+ const panelMatch = innerHtml.match(/^<p>\s*<strong>(Info|Note|Warning|Tip):<\/strong>\s*/i);
78
+ if (panelMatch) {
79
+ const panelType = panelMatch[1].toLowerCase();
80
+ const content = innerHtml.replace(panelMatch[0], '<p>');
81
+ return `<ac:structured-macro ac:name="${panelType}" ac:schema-version="1">
82
+ <ac:rich-text-body>${content}</ac:rich-text-body>
83
+ </ac:structured-macro>\n`;
84
+ }
85
+ // Regular blockquote - just use standard HTML
86
+ return `<blockquote>${innerHtml}</blockquote>\n`;
87
+ },
88
+
89
+ // Tables - standard XHTML tables work in Confluence
90
+ table(this: Renderer, token: Tokens.Table): string {
91
+ let header = '<tr>';
92
+ for (const cell of token.header) {
93
+ const align = cell.align ? ` style="text-align:${cell.align}"` : '';
94
+ const content = this.parser.parseInline(cell.tokens);
95
+ header += `<th${align}>${content}</th>`;
96
+ }
97
+ header += '</tr>\n';
98
+
99
+ let body = '';
100
+ for (const row of token.rows) {
101
+ body += '<tr>';
102
+ for (const cell of row) {
103
+ const align = cell.align ? ` style="text-align:${cell.align}"` : '';
104
+ const content = this.parser.parseInline(cell.tokens);
105
+ body += `<td${align}>${content}</td>`;
106
+ }
107
+ body += '</tr>\n';
108
+ }
109
+
110
+ return `<table>
111
+ <thead>${header}</thead>
112
+ <tbody>${body}</tbody>
113
+ </table>\n`;
114
+ },
115
+
116
+ // Links - convert relative .md links to Confluence page links (ADR-0022)
117
+ link(this: Renderer, token: Tokens.Link): string {
118
+ const text = this.parser.parseInline(token.tokens);
119
+
120
+ // Check if this is a relative .md link (local page reference)
121
+ if (token.href.endsWith('.md') && !token.href.startsWith('http://') && !token.href.startsWith('https://')) {
122
+ // Try to convert to Confluence page link - requires full context
123
+ if (self.pageLookupMap && self.currentPagePath && self.spaceRoot && self.spaceKey) {
124
+ const linkInfo = relativePathToConfluenceLink(
125
+ token.href,
126
+ self.currentPagePath,
127
+ self.spaceRoot,
128
+ self.pageLookupMap,
129
+ );
130
+
131
+ if (linkInfo?.title) {
132
+ // Generate Confluence page link format
133
+ return `<ac:link>
134
+ <ri:page ri:content-title="${self.escapeXml(linkInfo.title)}" ri:space-key="${self.spaceKey}" />
135
+ <ac:plain-text-link-body><![CDATA[${text}]]></ac:plain-text-link-body>
136
+ </ac:link>`;
137
+ }
138
+
139
+ // Target not found or missing title - warn and fall through to standard HTML link
140
+ if (linkInfo && !linkInfo.title) {
141
+ self.warnings.push(`Link to "${token.href}" has missing title in sync state - preserving as HTML link`);
142
+ } else {
143
+ self.warnings.push(`Link to "${token.href}" could not be resolved - target page not found in sync state`);
144
+ }
145
+ } else if (process.env.DEBUG) {
146
+ // DEBUG mode: warn about missing context for link conversion
147
+ const missingContext = [];
148
+ if (!self.pageLookupMap) missingContext.push('pageLookupMap');
149
+ if (!self.currentPagePath) missingContext.push('currentPagePath');
150
+ if (!self.spaceRoot) missingContext.push('spaceRoot');
151
+ if (!self.spaceKey) missingContext.push('spaceKey');
152
+ console.warn(`DEBUG: Link "${token.href}" not converted - missing context: ${missingContext.join(', ')}`);
153
+ }
154
+ // If missing context (pageLookupMap, etc.), silently fall through to standard HTML link
155
+ // This handles cases where conversion wasn't set up (e.g., standalone markdown processing)
156
+ }
157
+
158
+ // External link or fallback - use standard HTML
159
+ const titleAttr = token.title ? ` title="${self.escapeXml(token.title)}"` : '';
160
+ return `<a href="${self.escapeXml(token.href)}"${titleAttr}>${text}</a>`;
161
+ },
162
+
163
+ // Images - standard HTML, warn about local images
164
+ image(this: Renderer, token: Tokens.Image): string {
165
+ // Warn about non-URL images (local references)
166
+ if (!token.href.startsWith('http://') && !token.href.startsWith('https://')) {
167
+ self.warnings.push(`Local image "${token.href}" will not display in Confluence. Use absolute URLs.`);
168
+ }
169
+ const alt = token.text ? ` alt="${self.escapeXml(token.text)}"` : '';
170
+ const titleAttr = token.title ? ` title="${self.escapeXml(token.title)}"` : '';
171
+ return `<img src="${self.escapeXml(token.href)}"${alt}${titleAttr} />`;
172
+ },
173
+
174
+ // Paragraphs
175
+ paragraph(this: Renderer, token: Tokens.Paragraph): string {
176
+ const text = this.parser.parseInline(token.tokens);
177
+ return `<p>${text}</p>\n`;
178
+ },
179
+
180
+ // Headings
181
+ heading(this: Renderer, token: Tokens.Heading): string {
182
+ const text = this.parser.parseInline(token.tokens);
183
+ return `<h${token.depth}>${text}</h${token.depth}>\n`;
184
+ },
185
+
186
+ // Bold
187
+ strong(this: Renderer, token: Tokens.Strong): string {
188
+ const text = this.parser.parseInline(token.tokens);
189
+ return `<strong>${text}</strong>`;
190
+ },
191
+
192
+ // Italic
193
+ em(this: Renderer, token: Tokens.Em): string {
194
+ const text = this.parser.parseInline(token.tokens);
195
+ return `<em>${text}</em>`;
196
+ },
197
+
198
+ // Strikethrough
199
+ del(this: Renderer, token: Tokens.Del): string {
200
+ const text = this.parser.parseInline(token.tokens);
201
+ return `<del>${text}</del>`;
202
+ },
203
+
204
+ // Inline code
205
+ codespan(this: Renderer, token: Tokens.Codespan): string {
206
+ return `<code>${self.escapeXml(token.text)}</code>`;
207
+ },
208
+
209
+ // Line breaks
210
+ br(this: Renderer): string {
211
+ return '<br />';
212
+ },
213
+
214
+ // Horizontal rule
215
+ hr(this: Renderer): string {
216
+ return '<hr />\n';
217
+ },
218
+
219
+ // Lists
220
+ list(this: Renderer, token: Tokens.List): string {
221
+ const tag = token.ordered ? 'ol' : 'ul';
222
+ const startAttr = token.ordered && token.start !== 1 ? ` start="${token.start}"` : '';
223
+ let body = '';
224
+ for (const item of token.items) {
225
+ let itemContent = '';
226
+ if (item.tokens) {
227
+ itemContent = this.parser.parse(item.tokens);
228
+ }
229
+ // Remove wrapping <p> tags for simple list items
230
+ itemContent = itemContent.replace(/^<p>(.*)<\/p>\n?$/s, '$1');
231
+ body += `<li>${itemContent}</li>\n`;
232
+ }
233
+ return `<${tag}${startAttr}>\n${body}</${tag}>\n`;
234
+ },
235
+
236
+ // HTML passthrough - sanitize dangerous elements
237
+ html(this: Renderer, token: Tokens.HTML): string {
238
+ let html = token.raw;
239
+ let sanitized = false;
240
+
241
+ // Remove script tags and their content
242
+ const withoutScripts = html.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
243
+ if (withoutScripts !== html) sanitized = true;
244
+ html = withoutScripts;
245
+
246
+ // Remove iframe tags (can load arbitrary content)
247
+ const withoutIframes = html.replace(/<iframe\b[^<]*(?:(?!<\/iframe>)<[^<]*)*<\/iframe>/gi, '');
248
+ if (withoutIframes !== html) sanitized = true;
249
+ html = withoutIframes;
250
+
251
+ // Remove object and embed tags (can execute plugins/ActiveX)
252
+ const withoutObjects = html
253
+ .replace(/<object\b[^<]*(?:(?!<\/object>)<[^<]*)*<\/object>/gi, '')
254
+ .replace(/<embed\b[^>]*>/gi, '');
255
+ if (withoutObjects !== html) sanitized = true;
256
+ html = withoutObjects;
257
+
258
+ // Remove event handlers (onclick, onerror, etc.)
259
+ const withoutHandlers = html.replace(/\son\w+\s*=\s*["'][^"']*["']/gi, '');
260
+ if (withoutHandlers !== html) sanitized = true;
261
+ html = withoutHandlers;
262
+
263
+ // Remove javascript: protocol in hrefs and srcs
264
+ const withoutJsProtocol = html
265
+ .replace(/href\s*=\s*["']javascript:[^"']*["']/gi, 'href="#"')
266
+ .replace(/src\s*=\s*["']javascript:[^"']*["']/gi, 'src=""');
267
+ if (withoutJsProtocol !== html) sanitized = true;
268
+ html = withoutJsProtocol;
269
+
270
+ // Remove data: URLs in images (can contain embedded scripts)
271
+ const withoutDataUrls = html.replace(/src\s*=\s*["']data:[^"']*["']/gi, 'src=""');
272
+ if (withoutDataUrls !== html) sanitized = true;
273
+ html = withoutDataUrls;
274
+
275
+ // Warn if we sanitized anything
276
+ if (sanitized) {
277
+ self.warnings.push(
278
+ 'Potentially unsafe HTML was sanitized (scripts, iframes, event handlers, or dangerous URLs removed).',
279
+ );
280
+ }
281
+
282
+ return html;
283
+ },
284
+
285
+ // Text
286
+ text(this: Renderer, token: Tokens.Text): string {
287
+ // If text has nested tokens (e.g., bold/italic/links inside list items),
288
+ // parse them instead of returning raw text
289
+ if ('tokens' in token && token.tokens && token.tokens.length > 0) {
290
+ return this.parser.parseInline(token.tokens);
291
+ }
292
+ return token.text;
293
+ },
294
+ };
295
+
296
+ return new Marked({
297
+ gfm: true,
298
+ breaks: false,
299
+ renderer,
300
+ });
301
+ }
302
+
303
+ /**
304
+ * Strip @ prefix from user mentions, converting @username to just username
305
+ * Confluence requires account IDs for proper mentions, so we render as plain text
306
+ * Avoids email addresses (user@example.com) by checking preceding character
307
+ */
308
+ private stripMentionPrefix(markdown: string): string {
309
+ // Match @username at word boundaries, but not in email addresses
310
+ // Pattern: @ preceded by start-of-line or whitespace/punctuation (not alphanumeric or dot)
311
+ return markdown.replace(/(?<=^|[^a-zA-Z0-9.])@([a-zA-Z0-9_-]+)/gm, '$1');
312
+ }
313
+
314
+ /**
315
+ * Detect unsupported markdown features and add warnings
316
+ */
317
+ private detectUnsupportedFeatures(markdown: string): void {
318
+ // Check for task lists with checkboxes
319
+ if (/^\s*-\s*\[[x ]\]/im.test(markdown)) {
320
+ this.warnings.push('Task list checkboxes (- [x]) will be converted to regular list items.');
321
+ }
322
+
323
+ // Check for footnotes
324
+ if (/\[\^.+\]/.test(markdown)) {
325
+ this.warnings.push('Footnotes are not supported and will render as plain text.');
326
+ }
327
+ // Note: Local images are detected in the image renderer itself
328
+ }
329
+
330
+ /**
331
+ * Ensure XHTML compliance (self-closing tags, etc.)
332
+ */
333
+ private ensureXhtmlCompliance(html: string): string {
334
+ return (
335
+ html
336
+ // Ensure br tags are self-closing
337
+ .replace(/<br\s*>/gi, '<br />')
338
+ // Ensure hr tags are self-closing
339
+ .replace(/<hr\s*>/gi, '<hr />')
340
+ // Ensure img tags are self-closing (not already handled)
341
+ .replace(/<img([^>]+)(?<!\/)>/gi, '<img$1 />')
342
+ // Clean up extra whitespace
343
+ .replace(/\n{3,}/g, '\n\n')
344
+ .trim()
345
+ );
346
+ }
347
+
348
+ /**
349
+ * Convert Markdown to Confluence Storage Format HTML
350
+ * Per ADR-0022: Converts relative .md links to Confluence page links
351
+ *
352
+ * @param markdown - Markdown content to convert
353
+ * @param spaceRoot - Absolute path to space root directory (for link resolution)
354
+ * @param currentPagePath - Current page's path relative to space root (for link resolution)
355
+ * @param spaceKey - Confluence space key (for link generation)
356
+ * @param pageLookupMap - Page lookup map for finding target pages
357
+ */
358
+ convert(
359
+ markdown: string,
360
+ spaceRoot?: string,
361
+ currentPagePath?: string,
362
+ spaceKey?: string,
363
+ pageLookupMap?: PageLookupMap,
364
+ ): { html: string; warnings: string[] } {
365
+ this.warnings = [];
366
+ this.spaceRoot = spaceRoot || '';
367
+ this.currentPagePath = currentPagePath || '';
368
+ this.spaceKey = spaceKey || '';
369
+ this.pageLookupMap = pageLookupMap || null;
370
+
371
+ // Preprocess: strip @ from mentions (Confluence requires account IDs for real mentions)
372
+ const preprocessedMarkdown = this.stripMentionPrefix(markdown);
373
+
374
+ // Detect unsupported features before conversion
375
+ this.detectUnsupportedFeatures(preprocessedMarkdown);
376
+
377
+ // Create marked instance with custom renderer for this conversion
378
+ const markedInstance = this.createMarkedInstance();
379
+
380
+ // Convert markdown to HTML using custom renderer
381
+ const rawHtml = markedInstance.parse(preprocessedMarkdown);
382
+
383
+ // Ensure XHTML compliance
384
+ const html = this.ensureXhtmlCompliance(rawHtml as string);
385
+
386
+ return {
387
+ html,
388
+ warnings: [...this.warnings],
389
+ };
390
+ }
391
+
392
+ /**
393
+ * Get any warnings from the last conversion
394
+ */
395
+ getWarnings(): string[] {
396
+ return [...this.warnings];
397
+ }
398
+ }
@@ -0,0 +1,21 @@
1
+ export { MarkdownConverter } from './converter.js';
2
+ export { HtmlConverter } from './html-converter.js';
3
+ export {
4
+ createFrontmatter,
5
+ extractH1Title,
6
+ extractPageId,
7
+ parseMarkdown,
8
+ serializeMarkdown,
9
+ stripH1Title,
10
+ type PageFrontmatter,
11
+ } from './frontmatter.js';
12
+ export { generateUniqueFilename, slugify } from './slugify.js';
13
+ export {
14
+ buildPageLookupMapFromCache,
15
+ confluenceLinkToRelativePath,
16
+ extractPageTitleFromLink,
17
+ relativePathToConfluenceLink,
18
+ type PageLinkInfo,
19
+ type PageLookupMap,
20
+ } from './link-converter.js';
21
+ export { updateReferencesAfterRename, type ReferenceUpdateResult } from './reference-updater.js';
@@ -0,0 +1,189 @@
1
+ import { dirname, relative, resolve } from 'node:path';
2
+ import type { FullPageInfo, PageStateCache } from '../page-state.js';
3
+
4
+ /**
5
+ * Decode common HTML entities in a string
6
+ * Handles the most common entities: &amp; &lt; &gt; &quot; &#39;
7
+ */
8
+ function decodeHtmlEntities(text: string): string {
9
+ return text
10
+ .replace(/&amp;/g, '&')
11
+ .replace(/&lt;/g, '<')
12
+ .replace(/&gt;/g, '>')
13
+ .replace(/&quot;/g, '"')
14
+ .replace(/&#39;/g, "'")
15
+ .replace(/&#x27;/g, "'");
16
+ }
17
+
18
+ /**
19
+ * Page info for link conversion (subset of FullPageInfo)
20
+ */
21
+ export interface PageLinkInfo {
22
+ pageId: string;
23
+ localPath: string;
24
+ title: string;
25
+ }
26
+
27
+ /**
28
+ * Page lookup map for link conversion
29
+ * Per ADR-0024: Uses PageLinkInfo which can come from FullPageInfo or PageStateCache
30
+ */
31
+ export interface PageLookupMap {
32
+ // Title -> PageLinkInfo mapping for quick lookup
33
+ titleToPage: Map<string, PageLinkInfo>;
34
+ // PageId -> PageLinkInfo mapping
35
+ idToPage: Map<string, PageLinkInfo>;
36
+ // LocalPath -> PageLinkInfo mapping for O(1) path lookups
37
+ pathToPage: Map<string, PageLinkInfo>;
38
+ }
39
+
40
+ /**
41
+ * Build a lookup map from PageStateCache for efficient link conversion
42
+ * Per ADR-0024: This is the primary way to build lookup maps
43
+ *
44
+ * @param pageState - PageStateCache built from frontmatter
45
+ * @param warnDuplicates - If true, log warnings for duplicate titles
46
+ * @returns Page lookup map with three indices for efficient lookups
47
+ */
48
+ export function buildPageLookupMapFromCache(pageState: PageStateCache, warnDuplicates = false): PageLookupMap {
49
+ const titleToPage = new Map<string, PageLinkInfo>();
50
+ const idToPage = new Map<string, PageLinkInfo>();
51
+ const pathToPage = new Map<string, PageLinkInfo>();
52
+
53
+ for (const [pageId, pageInfo] of pageState.pages) {
54
+ const linkInfo: PageLinkInfo = {
55
+ pageId: pageInfo.pageId,
56
+ localPath: pageInfo.localPath,
57
+ title: pageInfo.title,
58
+ };
59
+
60
+ idToPage.set(pageId, linkInfo);
61
+ pathToPage.set(pageInfo.localPath, linkInfo);
62
+
63
+ const title = pageInfo.title || '';
64
+
65
+ // Skip entries without titles
66
+ if (!title) {
67
+ continue;
68
+ }
69
+
70
+ // Check for duplicate titles
71
+ const existingPage = titleToPage.get(title);
72
+ if (existingPage) {
73
+ // Use deterministic ordering: prefer page with lexicographically smaller pageId
74
+ // This ensures consistent behavior across runs
75
+ const shouldReplace = pageId < existingPage.pageId;
76
+
77
+ if (warnDuplicates) {
78
+ console.warn(
79
+ `Warning: Duplicate page title "${title}" found:\n` +
80
+ ` - ${existingPage.localPath} (page ID: ${existingPage.pageId})\n` +
81
+ ` - ${linkInfo.localPath} (page ID: ${pageId})\n` +
82
+ ` Links to this title will point to ${shouldReplace ? linkInfo.localPath : existingPage.localPath}\n` +
83
+ ` Recommendation: Rename one of these pages in Confluence to make titles unique.`,
84
+ );
85
+ }
86
+
87
+ if (shouldReplace) {
88
+ titleToPage.set(title, linkInfo);
89
+ }
90
+ // else: keep existing page
91
+ } else {
92
+ // First page with this title
93
+ titleToPage.set(title, linkInfo);
94
+ }
95
+ }
96
+
97
+ return { titleToPage, idToPage, pathToPage };
98
+ }
99
+
100
+ /**
101
+ * Convert Confluence page link to relative markdown path
102
+ *
103
+ * @param targetTitle - Title from ri:content-title attribute
104
+ * @param currentPagePath - Current page's local path (relative to space root)
105
+ * @param lookupMap - Page lookup map for finding target pages
106
+ * @returns Relative path to target page, or null if not found
107
+ *
108
+ * TODO: Add support for cross-space links by accepting optional targetSpaceKey parameter
109
+ * and maintaining separate lookup maps per space. Cross-space links could use a format
110
+ * like `../OTHER-SPACE/path/to/page.md` or a special prefix.
111
+ */
112
+ export function confluenceLinkToRelativePath(
113
+ targetTitle: string,
114
+ currentPagePath: string,
115
+ lookupMap: PageLookupMap,
116
+ ): string | null {
117
+ // Look up target page by title
118
+ const targetPage = lookupMap.titleToPage.get(targetTitle);
119
+ if (!targetPage) {
120
+ return null;
121
+ }
122
+
123
+ // Get directory of current page
124
+ const currentDir = dirname(currentPagePath);
125
+
126
+ // Calculate relative path from current page to target page
127
+ // Both paths are relative to space root, so we need to resolve them
128
+ const relativePath = relative(currentDir, targetPage.localPath);
129
+
130
+ // Ensure path starts with ./ for relative paths in same directory
131
+ // or ../ for parent directories
132
+ if (!relativePath.startsWith('.')) {
133
+ return `./${relativePath}`;
134
+ }
135
+
136
+ return relativePath;
137
+ }
138
+
139
+ /**
140
+ * Extract title from Confluence page link element
141
+ * Handles both <ac:link><ri:page> and direct <ri:page> elements
142
+ * Decodes HTML entities in the title
143
+ *
144
+ * @param html - HTML containing Confluence link
145
+ * @returns Page title from ri:content-title attribute (decoded), or null
146
+ */
147
+ export function extractPageTitleFromLink(html: string): string | null {
148
+ // Match ri:content-title attribute in ri:page elements
149
+ const match = html.match(/ri:content-title=["']([^"']+)["']/);
150
+ return match ? decodeHtmlEntities(match[1]) : null;
151
+ }
152
+
153
+ /**
154
+ * Convert relative markdown path to Confluence page link components
155
+ *
156
+ * @param relativePath - Relative path from markdown link (e.g., "./path/to/page.md")
157
+ * @param currentPagePath - Current page's local path (relative to space root)
158
+ * @param spaceRoot - Absolute path to space root directory
159
+ * @param lookupMap - Page lookup map for finding target pages
160
+ * @returns Object with title and pageId, or null if not found
161
+ *
162
+ * TODO: Add support for cross-space links by detecting paths like `../OTHER-SPACE/path.md`
163
+ * and looking up pages in different space configs.
164
+ */
165
+ export function relativePathToConfluenceLink(
166
+ relativePath: string,
167
+ currentPagePath: string,
168
+ spaceRoot: string,
169
+ lookupMap: PageLookupMap,
170
+ ): { title: string; pageId: string } | null {
171
+ // Resolve relative path to absolute path
172
+ const currentDir = resolve(spaceRoot, dirname(currentPagePath));
173
+ const targetAbsolutePath = resolve(currentDir, relativePath);
174
+
175
+ // Convert back to path relative to space root
176
+ const targetRelativePath = relative(spaceRoot, targetAbsolutePath);
177
+
178
+ // O(1) lookup by path
179
+ const pageInfo = lookupMap.pathToPage.get(targetRelativePath);
180
+ if (pageInfo?.title) {
181
+ return {
182
+ title: pageInfo.title,
183
+ pageId: pageInfo.pageId,
184
+ };
185
+ }
186
+
187
+ // Page found but missing title, or page not found
188
+ return null;
189
+ }