@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,486 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
+ import { existsSync, mkdirSync, rmSync, writeFileSync, readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ import { http, HttpResponse } from 'msw';
6
+ import { SyncEngine } from '../lib/sync/sync-engine.js';
7
+ import { writeSpaceConfig, type SpaceConfigWithState } from '../lib/space-config.js';
8
+ import { server } from './setup-msw.js';
9
+ import { createValidPage, createValidSpace } from './msw-schema-validation.js';
10
+ import { parseMarkdown } from '../lib/markdown/frontmatter.js';
11
+
12
+ const testConfig = {
13
+ confluenceUrl: 'https://test.atlassian.net',
14
+ email: 'test@example.com',
15
+ apiToken: 'test-token',
16
+ };
17
+
18
+ describe('SyncEngine', () => {
19
+ let testDir: string;
20
+
21
+ beforeEach(() => {
22
+ testDir = join(tmpdir(), `cn-test-${Date.now()}`);
23
+ mkdirSync(testDir, { recursive: true });
24
+ });
25
+
26
+ afterEach(() => {
27
+ if (existsSync(testDir)) {
28
+ rmSync(testDir, { recursive: true });
29
+ }
30
+ });
31
+
32
+ describe('initSync', () => {
33
+ test('initializes sync for a space', async () => {
34
+ server.use(
35
+ http.get('*/wiki/api/v2/spaces', ({ request }) => {
36
+ const url = new URL(request.url);
37
+ const keys = url.searchParams.get('keys');
38
+ if (keys === 'TEST') {
39
+ return HttpResponse.json({
40
+ results: [createValidSpace({ id: 'space-123', key: 'TEST', name: 'Test Space' })],
41
+ });
42
+ }
43
+ return HttpResponse.json({ results: [] });
44
+ }),
45
+ );
46
+
47
+ const engine = new SyncEngine(testConfig);
48
+ const config = await engine.initSync(testDir, 'TEST');
49
+
50
+ expect(config.spaceKey).toBe('TEST');
51
+ expect(config.spaceId).toBe('space-123');
52
+ expect(config.spaceName).toBe('Test Space');
53
+
54
+ // Check that .confluence.json was created
55
+ const configPath = join(testDir, '.confluence.json');
56
+ expect(existsSync(configPath)).toBe(true);
57
+ });
58
+ });
59
+
60
+ describe('fetchPageTree', () => {
61
+ test('fetches all pages in a space', async () => {
62
+ const engine = new SyncEngine(testConfig);
63
+ const pages = await engine.fetchPageTree('space-123');
64
+
65
+ expect(pages).toBeArray();
66
+ });
67
+ });
68
+
69
+ describe('buildPageTree', () => {
70
+ test('builds tree from flat pages', () => {
71
+ const pages = [
72
+ { id: 'page-1', title: 'Home', spaceId: 'space-123', status: 'current', parentId: null },
73
+ { id: 'page-2', title: 'Getting Started', spaceId: 'space-123', status: 'current', parentId: 'page-1' },
74
+ { id: 'page-3', title: 'API Reference', spaceId: 'space-123', status: 'current', parentId: 'page-1' },
75
+ { id: 'page-4', title: 'Installation', spaceId: 'space-123', status: 'current', parentId: 'page-2' },
76
+ ];
77
+
78
+ const engine = new SyncEngine(testConfig);
79
+ const tree = engine.buildPageTree(pages);
80
+
81
+ expect(tree).toHaveLength(1);
82
+ expect(tree[0].page.title).toBe('Home');
83
+ expect(tree[0].children).toHaveLength(2);
84
+ });
85
+
86
+ test('handles orphan pages', () => {
87
+ const pages = [
88
+ { id: 'page-1', title: 'Page 1', spaceId: 'space-123', status: 'current', parentId: 'missing-parent' },
89
+ { id: 'page-2', title: 'Page 2', spaceId: 'space-123', status: 'current', parentId: null },
90
+ ];
91
+
92
+ const engine = new SyncEngine(testConfig);
93
+ const tree = engine.buildPageTree(pages);
94
+
95
+ expect(tree).toHaveLength(2);
96
+ });
97
+ });
98
+
99
+ describe('computeDiff', () => {
100
+ test('detects added pages', () => {
101
+ const remotePages = [
102
+ { id: 'page-1', title: 'Page 1', spaceId: 'space-123', status: 'current', version: { number: 1 } },
103
+ { id: 'page-2', title: 'Page 2', spaceId: 'space-123', status: 'current', version: { number: 1 } },
104
+ ];
105
+
106
+ const localConfig: SpaceConfigWithState = {
107
+ spaceKey: 'TEST',
108
+ spaceId: 'space-123',
109
+ spaceName: 'Test Space',
110
+ pages: {},
111
+ };
112
+
113
+ const engine = new SyncEngine(testConfig);
114
+ const diff = engine.computeDiff(remotePages, localConfig);
115
+
116
+ expect(diff.added).toHaveLength(2);
117
+ expect(diff.modified).toHaveLength(0);
118
+ expect(diff.deleted).toHaveLength(0);
119
+ });
120
+
121
+ test('detects modified pages', () => {
122
+ const remotePages = [
123
+ { id: 'page-1', title: 'Page 1', spaceId: 'space-123', status: 'current', version: { number: 2 } },
124
+ ];
125
+
126
+ // Per ADR-0024: pages is now Record<string, string> (pageId -> localPath)
127
+ const localConfig: SpaceConfigWithState = {
128
+ spaceKey: 'TEST',
129
+ spaceId: 'space-123',
130
+ spaceName: 'Test Space',
131
+ pages: {
132
+ 'page-1': 'page-1.md',
133
+ },
134
+ };
135
+
136
+ const engine = new SyncEngine(testConfig);
137
+ // Without PageStateCache, local version defaults to 0, so remote v2 > local v0 -> modified
138
+ const diff = engine.computeDiff(remotePages, localConfig);
139
+
140
+ expect(diff.added).toHaveLength(0);
141
+ expect(diff.modified).toHaveLength(1);
142
+ expect(diff.deleted).toHaveLength(0);
143
+ });
144
+
145
+ test('detects deleted pages', () => {
146
+ const remotePages: any[] = [];
147
+
148
+ // Per ADR-0024: pages is now Record<string, string> (pageId -> localPath)
149
+ const localConfig: SpaceConfigWithState = {
150
+ spaceKey: 'TEST',
151
+ spaceId: 'space-123',
152
+ spaceName: 'Test Space',
153
+ pages: {
154
+ 'page-1': 'page-1.md',
155
+ },
156
+ };
157
+
158
+ const engine = new SyncEngine(testConfig);
159
+ const diff = engine.computeDiff(remotePages, localConfig);
160
+
161
+ expect(diff.added).toHaveLength(0);
162
+ expect(diff.modified).toHaveLength(0);
163
+ expect(diff.deleted).toHaveLength(1);
164
+ });
165
+
166
+ test('handles null localConfig', () => {
167
+ const remotePages = [
168
+ { id: 'page-1', title: 'Page 1', spaceId: 'space-123', status: 'current', version: { number: 1 } },
169
+ ];
170
+
171
+ const engine = new SyncEngine(testConfig);
172
+ const diff = engine.computeDiff(remotePages, null);
173
+
174
+ expect(diff.added).toHaveLength(1);
175
+ expect(diff.modified).toHaveLength(0);
176
+ expect(diff.deleted).toHaveLength(0);
177
+ });
178
+
179
+ test('filters out archived pages from remote', () => {
180
+ const remotePages = [
181
+ { id: 'page-1', title: 'Current Page', spaceId: 'space-123', status: 'current', version: { number: 1 } },
182
+ { id: 'page-2', title: 'Archived Page', spaceId: 'space-123', status: 'archived', version: { number: 1 } },
183
+ { id: 'page-3', title: 'Another Current', spaceId: 'space-123', status: 'current', version: { number: 1 } },
184
+ { id: 'page-4', title: 'Draft Page', spaceId: 'space-123', status: 'draft', version: { number: 1 } },
185
+ { id: 'page-5', title: 'Trashed Page', spaceId: 'space-123', status: 'trashed', version: { number: 1 } },
186
+ ];
187
+
188
+ const localConfig: SpaceConfigWithState = {
189
+ spaceKey: 'TEST',
190
+ spaceId: 'space-123',
191
+ spaceName: 'Test Space',
192
+ pages: {},
193
+ };
194
+
195
+ const engine = new SyncEngine(testConfig);
196
+ const diff = engine.computeDiff(remotePages, localConfig);
197
+
198
+ // Only the 2 current pages should be added (filters out archived, draft, and trashed)
199
+ expect(diff.added).toHaveLength(2);
200
+ expect(diff.added[0].pageId).toBe('page-1');
201
+ expect(diff.added[1].pageId).toBe('page-3');
202
+ expect(diff.modified).toHaveLength(0);
203
+ expect(diff.deleted).toHaveLength(0);
204
+ });
205
+
206
+ test('treats locally-synced archived pages as deleted', () => {
207
+ const remotePages = [
208
+ { id: 'page-1', title: 'Current Page', spaceId: 'space-123', status: 'current', version: { number: 1 } },
209
+ { id: 'page-2', title: 'Archived Page', spaceId: 'space-123', status: 'archived', version: { number: 1 } },
210
+ ];
211
+
212
+ const localConfig: SpaceConfigWithState = {
213
+ spaceKey: 'TEST',
214
+ spaceId: 'space-123',
215
+ spaceName: 'Test Space',
216
+ pages: {
217
+ 'page-1': 'page-1.md',
218
+ 'page-2': 'page-2.md', // This page is archived remotely
219
+ },
220
+ };
221
+
222
+ // Provide PageStateCache so page-1 is not seen as modified
223
+ const pageState = {
224
+ pages: new Map([
225
+ ['page-1', { pageId: 'page-1', localPath: 'page-1.md', title: 'Current Page', version: 1 }],
226
+ ['page-2', { pageId: 'page-2', localPath: 'page-2.md', title: 'Archived Page', version: 1 }],
227
+ ]),
228
+ pathToPageId: new Map([
229
+ ['page-1.md', 'page-1'],
230
+ ['page-2.md', 'page-2'],
231
+ ]),
232
+ };
233
+
234
+ const engine = new SyncEngine(testConfig);
235
+ const diff = engine.computeDiff(remotePages, localConfig, pageState);
236
+
237
+ expect(diff.added).toHaveLength(0);
238
+ expect(diff.modified).toHaveLength(0);
239
+ // page-2 should be detected as deleted because it's archived
240
+ expect(diff.deleted).toHaveLength(1);
241
+ expect(diff.deleted[0].pageId).toBe('page-2');
242
+ });
243
+ });
244
+
245
+ describe('sync', () => {
246
+ test('fails without space configuration', async () => {
247
+ const engine = new SyncEngine(testConfig);
248
+ const result = await engine.sync(testDir);
249
+
250
+ expect(result.success).toBe(false);
251
+ expect(result.errors).toHaveLength(1);
252
+ expect(result.errors[0]).toContain('No space configuration found');
253
+ });
254
+
255
+ test('performs dry run without changes', async () => {
256
+ // Set up space config
257
+ const spaceConfig: SpaceConfigWithState = {
258
+ spaceKey: 'TEST',
259
+ spaceId: 'space-123',
260
+ spaceName: 'Test Space',
261
+ pages: {},
262
+ };
263
+ writeSpaceConfig(testDir, spaceConfig);
264
+
265
+ const engine = new SyncEngine(testConfig);
266
+ const result = await engine.sync(testDir, { dryRun: true });
267
+
268
+ expect(result.success).toBe(true);
269
+ // In dry run, no files should be created
270
+ const files = existsSync(join(testDir, 'home.md'));
271
+ expect(files).toBe(false);
272
+ });
273
+
274
+ test('syncs new pages', async () => {
275
+ // Set up mocks for pages
276
+ server.use(
277
+ http.get('*/wiki/api/v2/spaces/:spaceId/pages', () => {
278
+ return HttpResponse.json({
279
+ results: [
280
+ createValidPage({
281
+ id: 'page-1',
282
+ title: 'Home',
283
+ spaceId: 'space-123',
284
+ body: '<p>Welcome!</p>',
285
+ }),
286
+ ],
287
+ });
288
+ }),
289
+ http.get('*/wiki/api/v2/pages/:pageId', ({ params }) => {
290
+ return HttpResponse.json(
291
+ createValidPage({
292
+ id: params.pageId as string,
293
+ title: 'Home',
294
+ spaceId: 'space-123',
295
+ body: '<p>Welcome!</p>',
296
+ }),
297
+ );
298
+ }),
299
+ );
300
+
301
+ // Set up space config
302
+ const spaceConfig: SpaceConfigWithState = {
303
+ spaceKey: 'TEST',
304
+ spaceId: 'space-123',
305
+ spaceName: 'Test Space',
306
+ pages: {},
307
+ };
308
+ writeSpaceConfig(testDir, spaceConfig);
309
+
310
+ const engine = new SyncEngine(testConfig);
311
+ const result = await engine.sync(testDir);
312
+
313
+ expect(result.success).toBe(true);
314
+ expect(result.changes.added).toHaveLength(1);
315
+ });
316
+
317
+ test('skips pages with reserved filenames during sync', async () => {
318
+ // Set up mocks for pages - include a page titled "Claude" which would generate claude.md
319
+ server.use(
320
+ http.get('*/wiki/api/v2/spaces/:spaceId/pages', () => {
321
+ return HttpResponse.json({
322
+ results: [
323
+ createValidPage({
324
+ id: 'page-1',
325
+ title: 'Home',
326
+ spaceId: 'space-123',
327
+ body: '<p>Welcome!</p>',
328
+ }),
329
+ createValidPage({
330
+ id: 'page-2',
331
+ title: 'Claude',
332
+ spaceId: 'space-123',
333
+ parentId: 'page-1',
334
+ body: '<p>This should be skipped</p>',
335
+ }),
336
+ createValidPage({
337
+ id: 'page-3',
338
+ title: 'Agents',
339
+ spaceId: 'space-123',
340
+ parentId: 'page-1',
341
+ body: '<p>This should also be skipped</p>',
342
+ }),
343
+ ],
344
+ });
345
+ }),
346
+ http.get('*/wiki/api/v2/pages/:pageId', ({ params }) => {
347
+ const pageId = params.pageId as string;
348
+ const titles: Record<string, string> = {
349
+ 'page-1': 'Home',
350
+ 'page-2': 'Claude',
351
+ 'page-3': 'Agents',
352
+ };
353
+ return HttpResponse.json(
354
+ createValidPage({
355
+ id: pageId,
356
+ title: titles[pageId] || 'Unknown',
357
+ spaceId: 'space-123',
358
+ parentId: pageId === 'page-1' ? undefined : 'page-1',
359
+ body: '<p>Content</p>',
360
+ }),
361
+ );
362
+ }),
363
+ );
364
+
365
+ // Set up space config
366
+ const spaceConfig: SpaceConfigWithState = {
367
+ spaceKey: 'TEST',
368
+ spaceId: 'space-123',
369
+ spaceName: 'Test Space',
370
+ pages: {},
371
+ };
372
+ writeSpaceConfig(testDir, spaceConfig);
373
+
374
+ const engine = new SyncEngine(testConfig);
375
+ const result = await engine.sync(testDir);
376
+
377
+ expect(result.success).toBe(true);
378
+ // 3 pages were added to diff, but 2 should be skipped
379
+ expect(result.changes.added).toHaveLength(3);
380
+ // Only README.md (home page) should exist, not claude.md or agents.md
381
+ expect(existsSync(join(testDir, 'README.md'))).toBe(true);
382
+ expect(existsSync(join(testDir, 'claude.md'))).toBe(false);
383
+ expect(existsSync(join(testDir, 'agents.md'))).toBe(false);
384
+ // Should have warnings about skipped pages (check for "reserved filename" in the message)
385
+ expect(result.warnings.some((w) => w.includes('reserved filename') && w.includes('Claude'))).toBe(true);
386
+ expect(result.warnings.some((w) => w.includes('reserved filename') && w.includes('Agents'))).toBe(true);
387
+ });
388
+
389
+ test('includes child_count in frontmatter for synced pages', async () => {
390
+ // Set up page hierarchy:
391
+ // Root (page-root) - 2 children
392
+ // ├─ Child 1 (page-child1) - 0 children
393
+ // └─ Child 2 (page-child2) - 1 child
394
+ // └─ Grandchild (page-grandchild) - 0 children
395
+ server.use(
396
+ http.get('*/wiki/api/v2/spaces/:spaceId/pages', () => {
397
+ return HttpResponse.json({
398
+ results: [
399
+ createValidPage({
400
+ id: 'page-root',
401
+ title: 'Root Page',
402
+ spaceId: 'space-123',
403
+ body: '<p>Root content</p>',
404
+ }),
405
+ createValidPage({
406
+ id: 'page-child1',
407
+ title: 'Child 1',
408
+ spaceId: 'space-123',
409
+ parentId: 'page-root',
410
+ body: '<p>Child 1 content</p>',
411
+ }),
412
+ createValidPage({
413
+ id: 'page-child2',
414
+ title: 'Child 2',
415
+ spaceId: 'space-123',
416
+ parentId: 'page-root',
417
+ body: '<p>Child 2 content</p>',
418
+ }),
419
+ createValidPage({
420
+ id: 'page-grandchild',
421
+ title: 'Grandchild',
422
+ spaceId: 'space-123',
423
+ parentId: 'page-child2',
424
+ body: '<p>Grandchild content</p>',
425
+ }),
426
+ ],
427
+ });
428
+ }),
429
+ http.get('*/wiki/api/v2/pages/:pageId', ({ params }) => {
430
+ const pageId = params.pageId as string;
431
+ const pageData: Record<string, { title: string; parentId?: string }> = {
432
+ 'page-root': { title: 'Root Page' },
433
+ 'page-child1': { title: 'Child 1', parentId: 'page-root' },
434
+ 'page-child2': { title: 'Child 2', parentId: 'page-root' },
435
+ 'page-grandchild': { title: 'Grandchild', parentId: 'page-child2' },
436
+ };
437
+ const data = pageData[pageId] || { title: 'Unknown' };
438
+ return HttpResponse.json(
439
+ createValidPage({
440
+ id: pageId,
441
+ title: data.title,
442
+ spaceId: 'space-123',
443
+ parentId: data.parentId,
444
+ body: `<p>${data.title} content</p>`,
445
+ }),
446
+ );
447
+ }),
448
+ );
449
+
450
+ // Set up space config
451
+ const spaceConfig: SpaceConfigWithState = {
452
+ spaceKey: 'TEST',
453
+ spaceId: 'space-123',
454
+ spaceName: 'Test Space',
455
+ pages: {},
456
+ };
457
+ writeSpaceConfig(testDir, spaceConfig);
458
+
459
+ const engine = new SyncEngine(testConfig);
460
+ const result = await engine.sync(testDir);
461
+
462
+ expect(result.success).toBe(true);
463
+ expect(result.changes.added).toHaveLength(4);
464
+
465
+ // Verify child_count in synced files
466
+ const rootContent = readFileSync(join(testDir, 'README.md'), 'utf-8');
467
+ const child1Content = readFileSync(join(testDir, 'child-1.md'), 'utf-8');
468
+ const child2Content = readFileSync(join(testDir, 'child-2/README.md'), 'utf-8');
469
+ const grandchildContent = readFileSync(join(testDir, 'child-2/grandchild.md'), 'utf-8');
470
+
471
+ const rootFrontmatter = parseMarkdown(rootContent).frontmatter;
472
+ const child1Frontmatter = parseMarkdown(child1Content).frontmatter;
473
+ const child2Frontmatter = parseMarkdown(child2Content).frontmatter;
474
+ const grandchildFrontmatter = parseMarkdown(grandchildContent).frontmatter;
475
+
476
+ // Root has 2 direct children
477
+ expect(rootFrontmatter.child_count).toBe(2);
478
+ // Child 1 has 0 children (leaf page)
479
+ expect(child1Frontmatter.child_count).toBe(0);
480
+ // Child 2 has 1 child
481
+ expect(child2Frontmatter.child_count).toBe(1);
482
+ // Grandchild has 0 children (leaf page)
483
+ expect(grandchildFrontmatter.child_count).toBe(0);
484
+ });
485
+ });
486
+ });
@@ -0,0 +1,9 @@
1
+ declare module 'turndown-plugin-gfm' {
2
+ import type TurndownService from 'turndown';
3
+
4
+ export function gfm(turndownService: TurndownService): void;
5
+ export function tables(turndownService: TurndownService): void;
6
+ export function strikethrough(turndownService: TurndownService): void;
7
+ export function taskListItems(turndownService: TurndownService): void;
8
+ export function highlightedCodeBlock(turndownService: TurndownService): void;
9
+ }