confluence-exporter 1.0.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 (91) hide show
  1. package/.eslintrc.cjs +18 -0
  2. package/.github/copilot-instructions.md +3 -0
  3. package/.github/prompts/analyze.prompt.md +101 -0
  4. package/.github/prompts/clarify.prompt.md +158 -0
  5. package/.github/prompts/constitution.prompt.md +73 -0
  6. package/.github/prompts/implement.prompt.md +56 -0
  7. package/.github/prompts/plan.prompt.md +50 -0
  8. package/.github/prompts/specify.prompt.md +21 -0
  9. package/.github/prompts/tasks.prompt.md +69 -0
  10. package/LICENSE +21 -0
  11. package/README.md +332 -0
  12. package/agents.md +1174 -0
  13. package/dist/api.d.ts +73 -0
  14. package/dist/api.js +387 -0
  15. package/dist/api.js.map +1 -0
  16. package/dist/commands/download.command.d.ts +18 -0
  17. package/dist/commands/download.command.js +257 -0
  18. package/dist/commands/download.command.js.map +1 -0
  19. package/dist/commands/executor.d.ts +22 -0
  20. package/dist/commands/executor.js +52 -0
  21. package/dist/commands/executor.js.map +1 -0
  22. package/dist/commands/help.command.d.ts +8 -0
  23. package/dist/commands/help.command.js +68 -0
  24. package/dist/commands/help.command.js.map +1 -0
  25. package/dist/commands/index.command.d.ts +14 -0
  26. package/dist/commands/index.command.js +95 -0
  27. package/dist/commands/index.command.js.map +1 -0
  28. package/dist/commands/index.d.ts +13 -0
  29. package/dist/commands/index.js +13 -0
  30. package/dist/commands/index.js.map +1 -0
  31. package/dist/commands/plan.command.d.ts +54 -0
  32. package/dist/commands/plan.command.js +272 -0
  33. package/dist/commands/plan.command.js.map +1 -0
  34. package/dist/commands/registry.d.ts +12 -0
  35. package/dist/commands/registry.js +32 -0
  36. package/dist/commands/registry.js.map +1 -0
  37. package/dist/commands/transform.command.d.ts +69 -0
  38. package/dist/commands/transform.command.js +951 -0
  39. package/dist/commands/transform.command.js.map +1 -0
  40. package/dist/commands/types.d.ts +12 -0
  41. package/dist/commands/types.js +5 -0
  42. package/dist/commands/types.js.map +1 -0
  43. package/dist/commands/update.command.d.ts +10 -0
  44. package/dist/commands/update.command.js +201 -0
  45. package/dist/commands/update.command.js.map +1 -0
  46. package/dist/constants.d.ts +1 -0
  47. package/dist/constants.js +2 -0
  48. package/dist/constants.js.map +1 -0
  49. package/dist/index.d.ts +5 -0
  50. package/dist/index.js +110 -0
  51. package/dist/index.js.map +1 -0
  52. package/dist/logger.d.ts +15 -0
  53. package/dist/logger.js +52 -0
  54. package/dist/logger.js.map +1 -0
  55. package/dist/types.d.ts +167 -0
  56. package/dist/types.js +5 -0
  57. package/dist/types.js.map +1 -0
  58. package/dist/utils.d.ts +56 -0
  59. package/dist/utils.js +178 -0
  60. package/dist/utils.js.map +1 -0
  61. package/eslint.config.js +29 -0
  62. package/jest.config.cjs +25 -0
  63. package/migrate-meta.js +132 -0
  64. package/package.json +53 -0
  65. package/src/api.ts +469 -0
  66. package/src/commands/download.command.ts +324 -0
  67. package/src/commands/executor.ts +62 -0
  68. package/src/commands/help.command.ts +72 -0
  69. package/src/commands/index.command.ts +111 -0
  70. package/src/commands/index.ts +14 -0
  71. package/src/commands/plan.command.ts +318 -0
  72. package/src/commands/registry.ts +39 -0
  73. package/src/commands/transform.command.ts +1103 -0
  74. package/src/commands/types.ts +16 -0
  75. package/src/commands/update.command.ts +229 -0
  76. package/src/constants.ts +0 -0
  77. package/src/index.ts +120 -0
  78. package/src/logger.ts +60 -0
  79. package/src/test.sh +66 -0
  80. package/src/types.ts +176 -0
  81. package/src/utils.ts +204 -0
  82. package/tests/commands/README.md +123 -0
  83. package/tests/commands/download.command.test.ts +8 -0
  84. package/tests/commands/help.command.test.ts +8 -0
  85. package/tests/commands/index.command.test.ts +8 -0
  86. package/tests/commands/plan.command.test.ts +15 -0
  87. package/tests/commands/transform.command.test.ts +8 -0
  88. package/tests/fixtures/_index.yaml +38 -0
  89. package/tests/fixtures/mock-pages.ts +62 -0
  90. package/tsconfig.json +25 -0
  91. package/vite.config.ts +45 -0
package/src/utils.ts ADDED
@@ -0,0 +1,204 @@
1
+ import { parse, stringify } from 'yaml';
2
+ import fs, { readFileSync, existsSync, writeFileSync, readdirSync, statSync } from 'fs';
3
+ import { join, dirname, basename } from 'path';
4
+ import type { ConfluenceConfig, PageIndexEntry, PageMeta } from './types.js';
5
+
6
+ /**
7
+ * Utility functions used across the application
8
+ */
9
+
10
+ /**
11
+ * Convert text to safe filename/slug
12
+ *
13
+ * @param text - Text to slugify
14
+ * @returns Slugified text (lowercase, hyphens, no special chars)
15
+ *
16
+ * @example
17
+ * slugify("My Page Title!") // "my-page-title"
18
+ */
19
+ export function slugify(text: string): string {
20
+ return text
21
+ .toLowerCase()
22
+ .replace(/[^\w\s-]/g, '') // Remove special chars
23
+ .replace(/\s+/g, '-') // Replace spaces with hyphens
24
+ .replace(/-+/g, '-') // Replace multiple hyphens with single
25
+ .trim();
26
+ }
27
+
28
+ /**
29
+ * Attempt to reverse slugification (best effort)
30
+ * Converts hyphens to spaces and capitalizes first letter of each word
31
+ *
32
+ * @param slug - Slugified text to convert back
33
+ * @returns Title-cased text with spaces
34
+ *
35
+ * @example
36
+ * unslugify("my-page-title") // "My Page Title"
37
+ */
38
+ export function unslugify(slug: string): string {
39
+ return slug
40
+ .replace(/-/g, ' ') // Replace hyphens with spaces
41
+ .replace(/\b\w/g, c => c.toUpperCase()); // Capitalize first letter of each word
42
+ }
43
+
44
+ export const pageFilename = (item: PageIndexEntry, ext='.md') => {
45
+ const slug = slugify(item.title);
46
+ return `${item.id}-${slug}${ext}`;
47
+ };
48
+
49
+ export const pagePath = (id: string, config: ConfluenceConfig) => {
50
+ const indexFile = join(config.outputDir, '_index.yaml');
51
+ const indexContent = readFileSync(indexFile, 'utf-8');
52
+ const index = parse(indexContent) as PageIndexEntry[];
53
+
54
+ const findParents = (id: string, path: string[]): string[] => {
55
+ const entry = index.find(e => e.id === id);
56
+ if (!entry) return [];
57
+ path.unshift(pageFilename(entry, ''));
58
+ if (entry.parentId) {
59
+ return findParents(entry.parentId, path);
60
+ }
61
+ return path;
62
+ };
63
+ const item = index.find(e => e.id === id);
64
+ if (!item) {
65
+ throw new Error(`Page with ID ${id} not found in index`);
66
+ }
67
+ const parents = item.parentId ? findParents(item.parentId, []) : [];
68
+ return join(config.outputDir, config.spaceKey, ...parents, pageFilename(item, '.html'));
69
+ }
70
+
71
+ // ============================================================================
72
+ // Page Metadata Utilities (Index-based)
73
+ // ============================================================================
74
+
75
+ /**
76
+ * Read a specific entry from _index.yaml
77
+ */
78
+ export function readIndexEntry(indexPath: string, pageId: string): PageIndexEntry | null {
79
+ if (!existsSync(indexPath)) return null;
80
+
81
+ try {
82
+ const content = readFileSync(indexPath, 'utf-8');
83
+ const index: PageIndexEntry[] = parse(content);
84
+ return index.find(entry => entry.id === pageId) || null;
85
+ } catch {
86
+ return null;
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Update a specific entry in _index.yaml
92
+ */
93
+ export function updateIndexEntry(
94
+ indexPath: string,
95
+ pageId: string,
96
+ updates: Partial<PageIndexEntry>
97
+ ): boolean {
98
+ if (!existsSync(indexPath)) return false;
99
+
100
+ try {
101
+ const content = readFileSync(indexPath, 'utf-8');
102
+ const index: PageIndexEntry[] = parse(content);
103
+
104
+ const entryIndex = index.findIndex(entry => entry.id === pageId);
105
+ if (entryIndex === -1) return false;
106
+
107
+ // Update the entry
108
+ index[entryIndex] = { ...index[entryIndex], ...updates };
109
+
110
+ // Write back to file
111
+ const yamlContent = stringify(index, {
112
+ indent: 2,
113
+ lineWidth: 0
114
+ });
115
+ writeFileSync(indexPath, yamlContent, 'utf-8');
116
+
117
+ return true;
118
+ } catch {
119
+ return false;
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Find existing HTML file for a page by ID
125
+ * Searches recursively in the output directory for {pageId}-*.html pattern
126
+ *
127
+ * @param outputDir - Root directory to search
128
+ * @param pageId - Page ID to find
129
+ * @returns Path to HTML file or null if not found
130
+ */
131
+ export function findExistingFile(outputDir: string, pageId: string): string | null {
132
+ const pattern = new RegExp(`^${pageId}-.*\\.html$`);
133
+
134
+ function searchDir(dir: string): string | null {
135
+ if (!existsSync(dir)) return null;
136
+
137
+ try {
138
+ const entries = readdirSync(dir, { withFileTypes: true });
139
+
140
+ for (const entry of entries) {
141
+ const fullPath = join(dir, entry.name);
142
+
143
+ if (entry.isFile() && pattern.test(entry.name)) {
144
+ return fullPath;
145
+ }
146
+
147
+ if (entry.isDirectory() && !entry.name.startsWith('_') && entry.name !== 'images') {
148
+ const found = searchDir(fullPath);
149
+ if (found) return found;
150
+ }
151
+ }
152
+ } catch {
153
+ // Ignore permission errors, etc.
154
+ }
155
+
156
+ return null;
157
+ }
158
+
159
+ return searchDir(outputDir);
160
+ }
161
+
162
+ /**
163
+ * Check if a page needs to be downloaded based on version comparison
164
+ * Uses index entry download tracking fields
165
+ *
166
+ * @param indexEntry - Page entry from index with current version
167
+ * @returns Object with needsDownload boolean and reason
168
+ */
169
+ export function checkPageStatus(
170
+ indexEntry: PageIndexEntry
171
+ ): { needsDownload: boolean; reason: 'new' | 'updated' | 'up-to-date'; details?: string } {
172
+ const downloadedVersion = indexEntry.downloadedVersion ?? 0;
173
+ const currentVersion = indexEntry.version ?? 0;
174
+
175
+ // If never downloaded, it's new
176
+ if (indexEntry.downloadedAt === undefined) {
177
+ return { needsDownload: true, reason: 'new' };
178
+ }
179
+
180
+ // Compare versions (primary check)
181
+ if (currentVersion > downloadedVersion) {
182
+ return {
183
+ needsDownload: true,
184
+ reason: 'updated',
185
+ details: `v${downloadedVersion} → v${currentVersion}`
186
+ };
187
+ }
188
+
189
+ // If versions match but downloadedVersion is 0 (fallback), compare dates
190
+ if (downloadedVersion === 0 && indexEntry.modifiedDate && indexEntry.downloadedAt) {
191
+ const currentDate = new Date(indexEntry.modifiedDate);
192
+ const downloadedDate = new Date(indexEntry.downloadedAt);
193
+
194
+ if (currentDate > downloadedDate) {
195
+ return {
196
+ needsDownload: true,
197
+ reason: 'updated',
198
+ details: `modified after download`
199
+ };
200
+ }
201
+ }
202
+
203
+ return { needsDownload: false, reason: 'up-to-date' };
204
+ }
@@ -0,0 +1,123 @@
1
+ # Command Tests
2
+
3
+ This directory contains tests for all CLI commands in the Confluence to Markdown Exporter.
4
+
5
+ ## Test Files
6
+
7
+ ### `help.command.test.ts`
8
+ Basic constructor test for the Help command.
9
+
10
+ **Coverage:**
11
+ - ✅ Instance creation
12
+
13
+ ### `index.command.test.ts`
14
+ Basic constructor test for the Index command.
15
+
16
+ **Coverage:**
17
+ - ✅ Instance creation
18
+
19
+ **Note:** Full API-dependent tests are not included because IndexCommand creates its own ConfluenceApi instance internally, which cannot be easily mocked with Jest + ESM modules. To fully test this command, it would need refactoring to use dependency injection.
20
+
21
+ ### `plan.command.test.ts`
22
+ Basic constructor test for the Plan command.
23
+
24
+ **Coverage:**
25
+ - ✅ Instance creation
26
+
27
+ **Note:** Full API-dependent tests require dependency injection refactoring.
28
+
29
+ ### `download.command.test.ts`
30
+ Basic constructor test for the Download command.
31
+
32
+ **Coverage:**
33
+ - ✅ Instance creation
34
+
35
+ **Note:** Full API-dependent tests require dependency injection refactoring.
36
+
37
+ ### `transform.command.test.ts`
38
+ Basic constructor test for the Transform command.
39
+
40
+ **Coverage:**
41
+ - ✅ Instance creation
42
+
43
+ **Note:** Full API-dependent tests require dependency injection refactoring.
44
+
45
+ ## Running Tests
46
+
47
+ ```bash
48
+ # Run all tests
49
+ npm test
50
+
51
+ # Run tests in watch mode
52
+ npm run test:watch
53
+
54
+ # Run tests with coverage
55
+ npm run test:coverage
56
+
57
+ # Run specific test file
58
+ npm test -- help.command.test.ts
59
+
60
+ # Run tests matching pattern
61
+ npm test -- --testNamePattern="should transform"
62
+ ```
63
+
64
+ ## Test Structure
65
+
66
+ All command tests follow a consistent structure:
67
+
68
+ ```typescript
69
+ describe('CommandName', () => {
70
+ let command: CommandClass;
71
+ let mockContext: CommandContext;
72
+
73
+ beforeEach(() => {
74
+ // Setup
75
+ });
76
+
77
+ afterEach(() => {
78
+ // Cleanup
79
+ });
80
+
81
+ describe('feature group', () => {
82
+ it('should do something specific', async () => {
83
+ // Arrange
84
+ // Act
85
+ // Assert
86
+ });
87
+ });
88
+ });
89
+ ```
90
+
91
+ ## Mocking Strategy
92
+
93
+ Due to ESM module constraints with Jest, most commands cannot be fully tested without refactoring:
94
+
95
+ - Commands create their own `ConfluenceApi` instances internally
96
+ - `jest.mock()` doesn't work reliably with ESM modules
97
+ - To enable full testing, commands would need to use **dependency injection** (accepting an API instance as a parameter)
98
+
99
+ ## Current Test Coverage
100
+
101
+ - ✅ **Command instantiation** - All 5 commands can be constructed
102
+ - ✅ **Core transformations** - Transformer and Cleaner have full coverage
103
+ - ⚠️ **Command execution** - Requires refactoring for dependency injection
104
+
105
+ ## Future Enhancements
106
+
107
+ To improve test coverage:
108
+
109
+ 1. **Refactor commands for dependency injection**
110
+ - Pass `ConfluenceApi` instance to command constructors
111
+ - Enables easy mocking in tests
112
+
113
+ 2. **Add integration tests**
114
+ - Full workflow end-to-end testing
115
+ - Mock Confluence server or use recorded responses
116
+
117
+ 3. **Add performance tests**
118
+ - Large space exports
119
+ - Memory usage monitoring
120
+
121
+ 4. **Add resilience tests**
122
+ - API rate limiting
123
+ - Network timeouts and retries
@@ -0,0 +1,8 @@
1
+ import { DownloadCommand } from '../../src/commands/download.command.js';
2
+
3
+ describe('DownloadCommand', () => {
4
+ it('should create an instance', () => {
5
+ const command = new DownloadCommand();
6
+ expect(command).toBeInstanceOf(DownloadCommand);
7
+ });
8
+ });
@@ -0,0 +1,8 @@
1
+ import { HelpCommand } from '../../src/commands/help.command.js';
2
+
3
+ describe('HelpCommand', () => {
4
+ it('should create an instance', () => {
5
+ const command = new HelpCommand();
6
+ expect(command).toBeInstanceOf(HelpCommand);
7
+ });
8
+ });
@@ -0,0 +1,8 @@
1
+ import { IndexCommand } from '../../src/commands/index.command.js';
2
+
3
+ describe('IndexCommand', () => {
4
+ it('should create an instance', () => {
5
+ const command = new IndexCommand();
6
+ expect(command).toBeInstanceOf(IndexCommand);
7
+ });
8
+ });
@@ -0,0 +1,15 @@
1
+ import { PlanCommand } from '../../src/commands/plan.command.js';
2
+
3
+ describe('PlanCommand', () => {
4
+ it('should create an instance', () => {
5
+ const mockConfig = {
6
+ baseUrl: 'https://example.com',
7
+ username: 'user',
8
+ password: 'pass',
9
+ spaceKey: 'TEST',
10
+ outputDir: './output'
11
+ };
12
+ const command = new PlanCommand(mockConfig);
13
+ expect(command).toBeInstanceOf(PlanCommand);
14
+ });
15
+ });
@@ -0,0 +1,8 @@
1
+ import { TransformCommand } from '../../src/commands/transform.command.js';
2
+
3
+ describe('TransformCommand', () => {
4
+ it('should create an instance', () => {
5
+ const command = new TransformCommand();
6
+ expect(command).toBeInstanceOf(TransformCommand);
7
+ });
8
+ });
@@ -0,0 +1,38 @@
1
+ # Confluence Export Index
2
+ # Space: TEST
3
+ # Export Date: 2025-10-17T10:00:00.000Z
4
+ # Page Size: 25
5
+
6
+ - id: "100001"
7
+ title: Parent Page 1
8
+ version: 1
9
+ modifiedDate: 2025-10-01T10:00:00.000Z
10
+ indexedDate: 2025-10-17T10:00:00.000Z
11
+ pageNumber: 1
12
+ - id: "100002"
13
+ title: Parent Page 2
14
+ version: 2
15
+ parentId: "100001"
16
+ modifiedDate: 2025-10-02T10:00:00.000Z
17
+ indexedDate: 2025-10-17T10:00:01.000Z
18
+ pageNumber: 1
19
+ - id: "100003"
20
+ title: Parent Page 3
21
+ version: 3
22
+ modifiedDate: 2025-10-03T10:00:00.000Z
23
+ indexedDate: 2025-10-17T10:00:02.000Z
24
+ pageNumber: 1
25
+ - id: "100004"
26
+ title: Child Page A
27
+ version: 1
28
+ parentId: "100001"
29
+ modifiedDate: 2025-10-04T10:00:00.000Z
30
+ indexedDate: 2025-10-17T10:00:03.000Z
31
+ pageNumber: 2
32
+ - id: "100005"
33
+ title: Child Page B
34
+ version: 1
35
+ parentId: "100002"
36
+ modifiedDate: 2025-10-05T10:00:00.000Z
37
+ indexedDate: 2025-10-17T10:00:04.000Z
38
+ pageNumber: 2
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Mock page data for testing
3
+ */
4
+
5
+ import type { Page } from '../../src/types.js';
6
+
7
+ export const mockRootPage: Page = {
8
+ id: '200001',
9
+ title: 'Root Page',
10
+ body: '<p>Root content</p>',
11
+ version: 1,
12
+ modifiedDate: '2025-10-01T10:00:00.000Z',
13
+ };
14
+
15
+ export const mockChild1: Page = {
16
+ id: '200002',
17
+ title: 'Child 1',
18
+ body: '<p>Child 1 content</p>',
19
+ version: 1,
20
+ parentId: '200001',
21
+ modifiedDate: '2025-10-02T10:00:00.000Z',
22
+ };
23
+
24
+ export const mockChild2: Page = {
25
+ id: '200003',
26
+ title: 'Child 2',
27
+ body: '<p>Child 2 content</p>',
28
+ version: 1,
29
+ parentId: '200001',
30
+ modifiedDate: '2025-10-03T10:00:00.000Z',
31
+ };
32
+
33
+ export const mockGrandchild: Page = {
34
+ id: '200004',
35
+ title: 'Grandchild',
36
+ body: '<p>Grandchild content</p>',
37
+ version: 1,
38
+ parentId: '200002',
39
+ modifiedDate: '2025-10-04T10:00:00.000Z',
40
+ };
41
+
42
+ export const mockSinglePage: Page = {
43
+ id: '300001',
44
+ title: 'Single Page',
45
+ body: '<p>Content</p>',
46
+ version: 1,
47
+ modifiedDate: '2025-10-01T10:00:00.000Z',
48
+ };
49
+
50
+ export const mockQueuePage: Page = {
51
+ id: '500002',
52
+ title: 'Queue Page',
53
+ body: '<p>Queue content</p>',
54
+ version: 1,
55
+ };
56
+
57
+ export const mockIndexPage: Page = {
58
+ id: '600001',
59
+ title: 'Index Page',
60
+ body: '<p>Index content</p>',
61
+ version: 1,
62
+ };
package/tsconfig.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "allowImportingTsExtensions": false,
7
+ "esModuleInterop": true,
8
+ "forceConsistentCasingInFileNames": true,
9
+ "strict": false,
10
+ "skipLibCheck": true,
11
+ "isolatedModules": true,
12
+ "resolveJsonModule": true,
13
+ "rootDir": "src",
14
+ "outDir": "dist",
15
+ "declaration": true,
16
+ "sourceMap": true,
17
+ "baseUrl": ".",
18
+ "paths": {
19
+ "@/cleanupRules/*": ["src/transform/cleanupRules/*"],
20
+ "@/cleanup": ["src/services/markdownCleanupService"]
21
+ }
22
+ },
23
+ "include": ["src"],
24
+ "exclude": ["node_modules", "dist", "tests"]
25
+ }
package/vite.config.ts ADDED
@@ -0,0 +1,45 @@
1
+ import { defineConfig } from 'vite';
2
+ import { resolve } from 'path';
3
+
4
+ export default defineConfig({
5
+ build: {
6
+ lib: {
7
+ entry: resolve(__dirname, 'src/index.ts'),
8
+ formats: ['es'],
9
+ fileName: 'index',
10
+ },
11
+ rollupOptions: {
12
+ external: [
13
+ // Node.js built-ins
14
+ 'fs',
15
+ 'path',
16
+ 'url',
17
+ 'util',
18
+ 'stream',
19
+ 'crypto',
20
+ 'events',
21
+ 'buffer',
22
+ 'process',
23
+ // Dependencies
24
+ 'dotenv',
25
+ ],
26
+ },
27
+ outDir: 'dist',
28
+ sourcemap: true,
29
+ target: 'node18',
30
+ minify: false,
31
+ },
32
+ resolve: {
33
+ alias: {
34
+ '@/cleanupRules': resolve(__dirname, './src/transform/cleanupRules'),
35
+ '@/cleanup': resolve(__dirname, './src/services/markdownCleanupService'),
36
+ },
37
+ },
38
+ // Optimize dependencies for Node.js
39
+ optimizeDeps: {
40
+ disabled: true,
41
+ },
42
+ ssr: {
43
+ noExternal: true,
44
+ },
45
+ });