@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,189 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { MarkdownConverter } from '../lib/markdown/converter.js';
3
+ import { HtmlConverter } from '../lib/markdown/html-converter.js';
4
+ import { parseMarkdown } from '../lib/markdown/frontmatter.js';
5
+ import { buildPageLookupMapFromCache } from '../lib/markdown/link-converter.js';
6
+ import type { PageStateCache, FullPageInfo } from '../lib/page-state.js';
7
+
8
+ /**
9
+ * Helper to create PageStateCache from a simple record
10
+ */
11
+ function createPageStateCache(
12
+ pages: Record<string, { pageId: string; localPath: string; title: string }>,
13
+ ): PageStateCache {
14
+ const pagesMap = new Map<string, FullPageInfo>();
15
+ const pathToPageId = new Map<string, string>();
16
+
17
+ for (const [pageId, info] of Object.entries(pages)) {
18
+ pagesMap.set(pageId, {
19
+ pageId: info.pageId,
20
+ localPath: info.localPath,
21
+ title: info.title,
22
+ version: 1,
23
+ });
24
+ pathToPageId.set(info.localPath, pageId);
25
+ }
26
+
27
+ return { pages: pagesMap, pathToPageId };
28
+ }
29
+
30
+ describe('Link Conversion Integration Tests', () => {
31
+ // TODO: This test requires enhancements to turndown parsing of ac:link elements
32
+ // The link conversion logic is implemented correctly, but turndown needs custom
33
+ // handling for CDATA content in ac:plain-text-link-body elements
34
+ test.skip('full pull-modify-push cycle converts links correctly', () => {
35
+ // Setup: Create mock page state with multiple pages
36
+ // Per ADR-0024: Use PageStateCache built from frontmatter
37
+ const pageState = createPageStateCache({
38
+ 'page-1': {
39
+ pageId: 'page-1',
40
+ localPath: 'home.md',
41
+ title: 'Home',
42
+ },
43
+ 'page-2': {
44
+ pageId: 'page-2',
45
+ localPath: 'architecture/overview.md',
46
+ title: 'Architecture Overview',
47
+ },
48
+ 'page-3': {
49
+ pageId: 'page-3',
50
+ localPath: 'architecture/database.md',
51
+ title: 'Database Design',
52
+ },
53
+ });
54
+
55
+ const pageLookupMap = buildPageLookupMapFromCache(pageState);
56
+
57
+ // Step 1: PULL - Convert Confluence HTML with page links to markdown
58
+ const confluenceHtml = `<p>Welcome to our documentation!</p><p>Check out the <ac:link><ri:page ri:content-title="Architecture Overview" ri:space-key="TEST" /><ac:plain-text-link-body><![CDATA[Architecture]]></ac:plain-text-link-body></ac:link> for design details.</p><p>Also see <ac:link><ri:page ri:content-title="Database Design" ri:space-key="TEST" /><ac:plain-text-link-body><![CDATA[Database Schema]]></ac:plain-text-link-body></ac:link>.</p>`;
59
+
60
+ const markdownConverter = new MarkdownConverter();
61
+ const { markdown, warnings: pullWarnings } = markdownConverter.convertPage(
62
+ {
63
+ id: 'page-1',
64
+ status: 'current',
65
+ title: 'Home',
66
+ spaceId: 'space-123',
67
+ authorId: 'user-1',
68
+ body: {
69
+ storage: {
70
+ value: confluenceHtml,
71
+ representation: 'storage',
72
+ },
73
+ },
74
+ version: {
75
+ number: 1,
76
+ createdAt: '2024-01-01T00:00:00Z',
77
+ },
78
+ },
79
+ [],
80
+ undefined,
81
+ '',
82
+ undefined,
83
+ undefined,
84
+ 'home.md',
85
+ pageLookupMap,
86
+ );
87
+
88
+ // Verify: Links were converted to relative paths during pull
89
+ expect(markdown).toContain('[Architecture](./architecture/overview.md)');
90
+ expect(markdown).toContain('[Database Schema](./architecture/database.md)');
91
+ expect(markdown).not.toContain('ri:page');
92
+ expect(pullWarnings.length).toBe(0);
93
+
94
+ // Step 2: Simulate user modifying the markdown locally
95
+ // Extract just the content (without frontmatter) for the push
96
+ const { content: markdownContent } = parseMarkdown(markdown);
97
+
98
+ // Step 3: PUSH - Convert markdown with relative paths back to Confluence HTML
99
+ const htmlConverter = new HtmlConverter();
100
+ const { html, warnings: pushWarnings } = htmlConverter.convert(
101
+ markdownContent,
102
+ '/test/space',
103
+ 'home.md',
104
+ 'TEST',
105
+ pageLookupMap,
106
+ );
107
+
108
+ // Verify: Links were converted back to Confluence format during push
109
+ expect(html).toContain('<ac:link>');
110
+ expect(html).toContain('ri:page ri:content-title="Architecture Overview"');
111
+ expect(html).toContain('ri:space-key="TEST"');
112
+ expect(html).toContain('ri:page ri:content-title="Database Design"');
113
+ expect(html).toContain('<ac:plain-text-link-body><![CDATA[Architecture]]></ac:plain-text-link-body>');
114
+ expect(html).toContain('<ac:plain-text-link-body><![CDATA[Database Schema]]></ac:plain-text-link-body>');
115
+ expect(pushWarnings.length).toBe(0);
116
+
117
+ // Verify: Round-trip conversion preserves link semantics
118
+ // (The exact formatting might differ slightly, but the meaning should be the same)
119
+ expect(html).not.toContain('./architecture/overview.md');
120
+ expect(html).not.toContain('./architecture/database.md');
121
+ });
122
+
123
+ // TODO: Same issue as above - requires turndown enhancement
124
+ test.skip('handles broken links gracefully during pull and push', () => {
125
+ // Per ADR-0024: Use PageStateCache built from frontmatter
126
+ const pageState = createPageStateCache({
127
+ 'page-1': {
128
+ pageId: 'page-1',
129
+ localPath: 'home.md',
130
+ title: 'Home',
131
+ },
132
+ });
133
+
134
+ const pageLookupMap = buildPageLookupMapFromCache(pageState);
135
+
136
+ // PULL: Link to non-existent page in Confluence
137
+ const confluenceHtml = `<p>See <ac:link><ri:page ri:content-title="Non Existent Page" ri:space-key="TEST" /><ac:plain-text-link-body><![CDATA[Non Existent]]></ac:plain-text-link-body></ac:link>.</p>`;
138
+
139
+ const markdownConverter = new MarkdownConverter();
140
+ const { markdown, warnings: pullWarnings } = markdownConverter.convertPage(
141
+ {
142
+ id: 'page-1',
143
+ status: 'current',
144
+ title: 'Home',
145
+ spaceId: 'space-123',
146
+ authorId: 'user-1',
147
+ body: {
148
+ storage: {
149
+ value: confluenceHtml,
150
+ representation: 'storage',
151
+ },
152
+ },
153
+ version: {
154
+ number: 1,
155
+ createdAt: '2024-01-01T00:00:00Z',
156
+ },
157
+ },
158
+ [],
159
+ undefined,
160
+ '',
161
+ undefined,
162
+ undefined,
163
+ 'home.md',
164
+ pageLookupMap,
165
+ );
166
+
167
+ // Should preserve original content and warn
168
+ expect(pullWarnings.length).toBeGreaterThan(0);
169
+ expect(pullWarnings[0]).toContain('could not be resolved');
170
+ expect(markdown).not.toContain('./non-existent-page.md');
171
+
172
+ // PUSH: Link to non-existent local file
173
+ const localMarkdown = 'See [this page](./non-existent.md).';
174
+ const htmlConverter = new HtmlConverter();
175
+ const { html, warnings: pushWarnings } = htmlConverter.convert(
176
+ localMarkdown,
177
+ '/test/space',
178
+ 'home.md',
179
+ 'TEST',
180
+ pageLookupMap,
181
+ );
182
+
183
+ // Should fall back to HTML link and warn
184
+ expect(pushWarnings.length).toBeGreaterThan(0);
185
+ expect(pushWarnings[0]).toContain('could not be resolved');
186
+ expect(html).toContain('<a href="./non-existent.md">');
187
+ expect(html).not.toContain('ri:page');
188
+ });
189
+ });
@@ -0,0 +1,413 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import {
3
+ buildPageLookupMapFromCache,
4
+ confluenceLinkToRelativePath,
5
+ extractPageTitleFromLink,
6
+ relativePathToConfluenceLink,
7
+ type PageLookupMap,
8
+ } from '../lib/markdown/link-converter.js';
9
+ import type { PageStateCache, FullPageInfo } from '../lib/page-state.js';
10
+
11
+ /**
12
+ * Helper to create PageStateCache from a simple record
13
+ * Simplifies test setup by avoiding need to create actual files
14
+ */
15
+ function createPageStateCache(
16
+ pages: Record<string, { pageId: string; localPath: string; title: string; version?: number }>,
17
+ ): PageStateCache {
18
+ const pagesMap = new Map<string, FullPageInfo>();
19
+ const pathToPageId = new Map<string, string>();
20
+
21
+ for (const [pageId, info] of Object.entries(pages)) {
22
+ pagesMap.set(pageId, {
23
+ pageId: info.pageId,
24
+ localPath: info.localPath,
25
+ title: info.title,
26
+ version: info.version ?? 1,
27
+ });
28
+ pathToPageId.set(info.localPath, pageId);
29
+ }
30
+
31
+ return { pages: pagesMap, pathToPageId };
32
+ }
33
+
34
+ describe('buildPageLookupMapFromCache', () => {
35
+ test('builds lookup maps from PageStateCache', () => {
36
+ const pageState = createPageStateCache({
37
+ 'page-1': {
38
+ pageId: 'page-1',
39
+ localPath: 'getting-started.md',
40
+ title: 'Getting Started',
41
+ },
42
+ 'page-2': {
43
+ pageId: 'page-2',
44
+ localPath: 'architecture/overview.md',
45
+ title: 'Architecture Overview',
46
+ },
47
+ });
48
+
49
+ const lookupMap = buildPageLookupMapFromCache(pageState);
50
+
51
+ expect(lookupMap.idToPage.get('page-1')?.localPath).toBe('getting-started.md');
52
+ expect(lookupMap.idToPage.get('page-2')?.localPath).toBe('architecture/overview.md');
53
+ expect(lookupMap.titleToPage.get('Getting Started')?.pageId).toBe('page-1');
54
+ expect(lookupMap.titleToPage.get('Architecture Overview')?.pageId).toBe('page-2');
55
+ expect(lookupMap.pathToPage.get('getting-started.md')?.pageId).toBe('page-1');
56
+ expect(lookupMap.pathToPage.get('architecture/overview.md')?.pageId).toBe('page-2');
57
+ });
58
+
59
+ test('handles duplicate titles (deterministic ordering)', () => {
60
+ const pageState = createPageStateCache({
61
+ 'page-2': {
62
+ pageId: 'page-2',
63
+ localPath: 'architecture/overview.md',
64
+ title: 'Overview',
65
+ },
66
+ 'page-1': {
67
+ pageId: 'page-1',
68
+ localPath: 'overview.md',
69
+ title: 'Overview',
70
+ },
71
+ });
72
+
73
+ const lookupMap = buildPageLookupMapFromCache(pageState);
74
+
75
+ // Page with lexicographically smallest pageId should win (page-1 < page-2)
76
+ expect(lookupMap.titleToPage.get('Overview')?.pageId).toBe('page-1');
77
+ });
78
+
79
+ test('warns about duplicate titles when enabled', () => {
80
+ const pageState = createPageStateCache({
81
+ 'page-2': {
82
+ pageId: 'page-2',
83
+ localPath: 'architecture/overview.md',
84
+ title: 'Overview',
85
+ },
86
+ 'page-1': {
87
+ pageId: 'page-1',
88
+ localPath: 'overview.md',
89
+ title: 'Overview',
90
+ },
91
+ });
92
+
93
+ // Capture console.warn calls
94
+ const originalWarn = console.warn;
95
+ const warnings: string[] = [];
96
+ console.warn = (msg: string) => warnings.push(msg);
97
+
98
+ try {
99
+ buildPageLookupMapFromCache(pageState, true);
100
+
101
+ // Should have warned about duplicate
102
+ expect(warnings.length).toBe(1);
103
+ expect(warnings[0]).toContain('Duplicate page title "Overview"');
104
+ expect(warnings[0]).toContain('overview.md');
105
+ expect(warnings[0]).toContain('architecture/overview.md');
106
+ // Verify it indicates page-1 will be used (lexicographically smallest)
107
+ expect(warnings[0]).toContain('overview.md');
108
+ } finally {
109
+ console.warn = originalWarn;
110
+ }
111
+ });
112
+
113
+ test('handles empty PageStateCache', () => {
114
+ const pageState: PageStateCache = {
115
+ pages: new Map(),
116
+ pathToPageId: new Map(),
117
+ };
118
+
119
+ const lookupMap = buildPageLookupMapFromCache(pageState);
120
+
121
+ expect(lookupMap.idToPage.size).toBe(0);
122
+ expect(lookupMap.titleToPage.size).toBe(0);
123
+ expect(lookupMap.pathToPage.size).toBe(0);
124
+ });
125
+ });
126
+
127
+ describe('confluenceLinkToRelativePath', () => {
128
+ let lookupMap: PageLookupMap;
129
+
130
+ const pageState = createPageStateCache({
131
+ 'page-1': {
132
+ pageId: 'page-1',
133
+ localPath: 'getting-started.md',
134
+ title: 'Getting Started',
135
+ },
136
+ 'page-2': {
137
+ pageId: 'page-2',
138
+ localPath: 'architecture/overview.md',
139
+ title: 'Architecture Overview',
140
+ },
141
+ 'page-3': {
142
+ pageId: 'page-3',
143
+ localPath: 'architecture/database.md',
144
+ title: 'Database Design',
145
+ },
146
+ });
147
+
148
+ test('converts Confluence link to relative path (same directory)', () => {
149
+ lookupMap = buildPageLookupMapFromCache(pageState);
150
+ const result = confluenceLinkToRelativePath('Architecture Overview', 'architecture/database.md', lookupMap);
151
+
152
+ expect(result).toBe('./overview.md');
153
+ });
154
+
155
+ test('converts Confluence link to relative path (parent directory)', () => {
156
+ lookupMap = buildPageLookupMapFromCache(pageState);
157
+ const result = confluenceLinkToRelativePath('Getting Started', 'architecture/database.md', lookupMap);
158
+
159
+ expect(result).toBe('../getting-started.md');
160
+ });
161
+
162
+ test('converts Confluence link to relative path (child directory)', () => {
163
+ lookupMap = buildPageLookupMapFromCache(pageState);
164
+ const result = confluenceLinkToRelativePath('Architecture Overview', 'getting-started.md', lookupMap);
165
+
166
+ expect(result).toBe('./architecture/overview.md');
167
+ });
168
+
169
+ test('returns null for non-existent page', () => {
170
+ lookupMap = buildPageLookupMapFromCache(pageState);
171
+ const result = confluenceLinkToRelativePath('Non Existent Page', 'getting-started.md', lookupMap);
172
+
173
+ expect(result).toBeNull();
174
+ });
175
+
176
+ test('handles paths that need ./ prefix', () => {
177
+ lookupMap = buildPageLookupMapFromCache(pageState);
178
+ const result = confluenceLinkToRelativePath('Database Design', 'architecture/overview.md', lookupMap);
179
+
180
+ expect(result).toBe('./database.md');
181
+ });
182
+ });
183
+
184
+ describe('extractPageTitleFromLink', () => {
185
+ test('extracts title from ri:page element', () => {
186
+ const html = '<ri:page ri:content-title="Getting Started" />';
187
+ const title = extractPageTitleFromLink(html);
188
+
189
+ expect(title).toBe('Getting Started');
190
+ });
191
+
192
+ test('extracts title from ac:link element', () => {
193
+ const html = '<ac:link><ri:page ri:content-title="Architecture Overview" /></ac:link>';
194
+ const title = extractPageTitleFromLink(html);
195
+
196
+ expect(title).toBe('Architecture Overview');
197
+ });
198
+
199
+ test('handles double quotes', () => {
200
+ const html = '<ri:page ri:content-title="Test Page" />';
201
+ const title = extractPageTitleFromLink(html);
202
+
203
+ expect(title).toBe('Test Page');
204
+ });
205
+
206
+ test('handles single quotes', () => {
207
+ const html = "<ri:page ri:content-title='Test Page' />";
208
+ const title = extractPageTitleFromLink(html);
209
+
210
+ expect(title).toBe('Test Page');
211
+ });
212
+
213
+ test('returns null if no title found', () => {
214
+ const html = '<ac:link></ac:link>';
215
+ const title = extractPageTitleFromLink(html);
216
+
217
+ expect(title).toBeNull();
218
+ });
219
+
220
+ test('decodes HTML entities in titles', () => {
221
+ const html = '<ri:page ri:content-title="Page with &amp; ampersand" />';
222
+ const title = extractPageTitleFromLink(html);
223
+
224
+ // HTML entities should be decoded
225
+ expect(title).toBe('Page with & ampersand');
226
+ });
227
+
228
+ test('decodes angle brackets in titles', () => {
229
+ const html = '<ri:page ri:content-title="Page &lt;with&gt; brackets" />';
230
+ const title = extractPageTitleFromLink(html);
231
+
232
+ expect(title).toBe('Page <with> brackets');
233
+ });
234
+
235
+ test('decodes quotes in titles', () => {
236
+ const html = '<ri:page ri:content-title="Page with &quot;quotes&quot;" />';
237
+ const title = extractPageTitleFromLink(html);
238
+
239
+ expect(title).toBe('Page with "quotes"');
240
+ });
241
+ });
242
+
243
+ describe('relativePathToConfluenceLink', () => {
244
+ let lookupMap: PageLookupMap;
245
+
246
+ const pageState = createPageStateCache({
247
+ 'page-1': {
248
+ pageId: 'page-1',
249
+ localPath: 'getting-started.md',
250
+ title: 'Getting Started',
251
+ },
252
+ 'page-2': {
253
+ pageId: 'page-2',
254
+ localPath: 'architecture/overview.md',
255
+ title: 'Architecture Overview',
256
+ },
257
+ 'page-3': {
258
+ pageId: 'page-3',
259
+ localPath: 'architecture/database.md',
260
+ title: 'Database Design',
261
+ },
262
+ });
263
+
264
+ test('converts relative path to Confluence link (same directory)', () => {
265
+ lookupMap = buildPageLookupMapFromCache(pageState);
266
+ const result = relativePathToConfluenceLink('./overview.md', 'architecture/database.md', '/test/space', lookupMap);
267
+
268
+ expect(result).not.toBeNull();
269
+ expect(result?.title).toBe('Architecture Overview');
270
+ expect(result?.pageId).toBe('page-2');
271
+ });
272
+
273
+ test('converts relative path to Confluence link (parent directory)', () => {
274
+ lookupMap = buildPageLookupMapFromCache(pageState);
275
+ const result = relativePathToConfluenceLink(
276
+ '../getting-started.md',
277
+ 'architecture/database.md',
278
+ '/test/space',
279
+ lookupMap,
280
+ );
281
+
282
+ expect(result).not.toBeNull();
283
+ expect(result?.title).toBe('Getting Started');
284
+ expect(result?.pageId).toBe('page-1');
285
+ });
286
+
287
+ test('converts relative path to Confluence link (child directory)', () => {
288
+ lookupMap = buildPageLookupMapFromCache(pageState);
289
+ const result = relativePathToConfluenceLink(
290
+ './architecture/overview.md',
291
+ 'getting-started.md',
292
+ '/test/space',
293
+ lookupMap,
294
+ );
295
+
296
+ expect(result).not.toBeNull();
297
+ expect(result?.title).toBe('Architecture Overview');
298
+ expect(result?.pageId).toBe('page-2');
299
+ });
300
+
301
+ test('returns null for non-existent file', () => {
302
+ lookupMap = buildPageLookupMapFromCache(pageState);
303
+ const result = relativePathToConfluenceLink('./non-existent.md', 'getting-started.md', '/test/space', lookupMap);
304
+
305
+ expect(result).toBeNull();
306
+ });
307
+
308
+ test('handles paths without ./ prefix', () => {
309
+ lookupMap = buildPageLookupMapFromCache(pageState);
310
+ const result = relativePathToConfluenceLink('database.md', 'architecture/overview.md', '/test/space', lookupMap);
311
+
312
+ expect(result).not.toBeNull();
313
+ expect(result?.title).toBe('Database Design');
314
+ expect(result?.pageId).toBe('page-3');
315
+ });
316
+
317
+ test('handles deeply nested paths', () => {
318
+ const deepPageState = createPageStateCache({
319
+ 'page-deep': {
320
+ pageId: 'page-deep',
321
+ localPath: 'a/b/c/d/e/f/g/h/page.md',
322
+ title: 'Deep Page',
323
+ },
324
+ });
325
+
326
+ lookupMap = buildPageLookupMapFromCache(deepPageState);
327
+ const result = relativePathToConfluenceLink('./a/b/c/d/e/f/g/h/page.md', 'home.md', '/test/space', lookupMap);
328
+
329
+ expect(result).not.toBeNull();
330
+ expect(result?.title).toBe('Deep Page');
331
+ expect(result?.pageId).toBe('page-deep');
332
+ });
333
+ });
334
+
335
+ describe('batch config propagation', () => {
336
+ test('in-memory PageStateCache updates allow subsequent link resolution', () => {
337
+ // This test verifies the fix for batch push link resolution
338
+ // When pushing multiple files, the first file's page info should be available
339
+ // for link resolution in subsequent files
340
+
341
+ // Start with an empty PageStateCache (simulating start of batch push)
342
+ const pageState: PageStateCache = {
343
+ pages: new Map(),
344
+ pathToPageId: new Map(),
345
+ };
346
+
347
+ // At this point, links to page-a.md cannot be resolved
348
+ let lookupMap = buildPageLookupMapFromCache(pageState);
349
+ let result = relativePathToConfluenceLink('./page-a.md', 'page-b.md', '/test/space', lookupMap);
350
+ expect(result).toBeNull();
351
+
352
+ // Simulate pushing page-a.md - this updates the in-memory PageStateCache
353
+ const pageAInfo: FullPageInfo = {
354
+ pageId: 'page-a-id',
355
+ localPath: 'page-a.md',
356
+ title: 'Page A',
357
+ version: 1,
358
+ };
359
+ pageState.pages.set('page-a-id', pageAInfo);
360
+ pageState.pathToPageId.set('page-a.md', 'page-a-id');
361
+
362
+ // Now rebuild the lookup map with the updated PageStateCache
363
+ // This is what happens during batch push - the updated state is passed to the next file
364
+ lookupMap = buildPageLookupMapFromCache(pageState);
365
+
366
+ // Now links to page-a.md CAN be resolved
367
+ result = relativePathToConfluenceLink('./page-a.md', 'page-b.md', '/test/space', lookupMap);
368
+ expect(result).not.toBeNull();
369
+ expect(result?.pageId).toBe('page-a-id');
370
+ expect(result?.title).toBe('Page A');
371
+ });
372
+
373
+ test('multiple pages pushed in sequence are all resolvable', () => {
374
+ // Simulates pushing three files where each links to the previous
375
+ const pageState: PageStateCache = {
376
+ pages: new Map(),
377
+ pathToPageId: new Map(),
378
+ };
379
+
380
+ // Push page-a.md
381
+ const pageAInfo: FullPageInfo = {
382
+ pageId: 'page-a-id',
383
+ localPath: 'page-a.md',
384
+ title: 'Page A',
385
+ version: 1,
386
+ };
387
+ pageState.pages.set('page-a-id', pageAInfo);
388
+ pageState.pathToPageId.set('page-a.md', 'page-a-id');
389
+
390
+ // Push page-b.md (can now link to page-a)
391
+ let lookupMap = buildPageLookupMapFromCache(pageState);
392
+ let result = relativePathToConfluenceLink('./page-a.md', 'page-b.md', '/test/space', lookupMap);
393
+ expect(result?.pageId).toBe('page-a-id');
394
+
395
+ const pageBInfo: FullPageInfo = {
396
+ pageId: 'page-b-id',
397
+ localPath: 'page-b.md',
398
+ title: 'Page B',
399
+ version: 1,
400
+ };
401
+ pageState.pages.set('page-b-id', pageBInfo);
402
+ pageState.pathToPageId.set('page-b.md', 'page-b-id');
403
+
404
+ // Push page-c.md (can now link to both page-a and page-b)
405
+ lookupMap = buildPageLookupMapFromCache(pageState);
406
+
407
+ result = relativePathToConfluenceLink('./page-a.md', 'page-c.md', '/test/space', lookupMap);
408
+ expect(result?.pageId).toBe('page-a-id');
409
+
410
+ result = relativePathToConfluenceLink('./page-b.md', 'page-c.md', '/test/space', lookupMap);
411
+ expect(result?.pageId).toBe('page-b-id');
412
+ });
413
+ });