@aaronshaf/confluence-cli 0.1.15 → 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.
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/cli/commands/folder.ts +189 -0
- package/src/cli/help.ts +30 -36
- package/src/cli/index.ts +12 -14
- package/src/lib/confluence-client/client.ts +19 -3
- package/src/lib/confluence-client/folder-operations.ts +41 -0
- package/src/lib/confluence-client/search-operations.ts +2 -1
- package/src/test/folder-command.test.ts +182 -0
- package/src/cli/commands/duplicate-check.ts +0 -89
- package/src/cli/commands/file-rename.ts +0 -113
- package/src/cli/commands/folder-hierarchy.ts +0 -241
- package/src/cli/commands/push-errors.ts +0 -40
- package/src/cli/commands/push.ts +0 -699
- package/src/lib/dependency-sorter.ts +0 -233
- package/src/test/dependency-sorter.test.ts +0 -384
- package/src/test/file-rename.test.ts +0 -305
- package/src/test/folder-hierarchy.test.ts +0 -337
- package/src/test/push.test.ts +0 -551
|
@@ -1,233 +0,0 @@
|
|
|
1
|
-
import { readFileSync } from 'node:fs';
|
|
2
|
-
import { dirname, join, normalize, relative } from 'node:path';
|
|
3
|
-
import type { PushCandidate } from './file-scanner.js';
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Extract all local markdown links from content.
|
|
7
|
-
*
|
|
8
|
-
* Matches markdown links like [text](path.md) but excludes:
|
|
9
|
-
* - http:// and https:// URLs
|
|
10
|
-
* - Links to non-.md files
|
|
11
|
-
*
|
|
12
|
-
* @param content - Markdown content to extract links from
|
|
13
|
-
* @returns Array of relative paths (as written in the markdown)
|
|
14
|
-
*/
|
|
15
|
-
export function extractLocalLinks(content: string): string[] {
|
|
16
|
-
// Match markdown links: [text](path.md) or [text](path.md#anchor)
|
|
17
|
-
// The text part can contain nested brackets (e.g., [text [nested]](link.md))
|
|
18
|
-
// Pattern explanation:
|
|
19
|
-
// \[([^\[\]]+(?:\[[^\]]*\][^\[\]]*)*)\] - Match [text] allowing nested brackets
|
|
20
|
-
// \(([^)#]+\.md)(?:#[^)]*)?\) - Match (path.md) or (path.md#anchor)
|
|
21
|
-
const linkPattern = /\[([^[\]]+(?:\[[^\]]*\][^[\]]*)*)\]\(([^)#]+\.md)(?:#[^)]*)?\)/g;
|
|
22
|
-
|
|
23
|
-
const links: string[] = [];
|
|
24
|
-
let match: RegExpExecArray | null;
|
|
25
|
-
|
|
26
|
-
while ((match = linkPattern.exec(content)) !== null) {
|
|
27
|
-
const linkPath = match[2];
|
|
28
|
-
|
|
29
|
-
// Skip external URLs
|
|
30
|
-
if (linkPath.startsWith('http://') || linkPath.startsWith('https://')) {
|
|
31
|
-
continue;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// Remove any anchor fragments (e.g., file.md#section -> file.md)
|
|
35
|
-
const pathWithoutAnchor = linkPath.split('#')[0];
|
|
36
|
-
|
|
37
|
-
links.push(pathWithoutAnchor);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
return links;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Resolve a link path relative to the source file's directory.
|
|
45
|
-
*
|
|
46
|
-
* @param linkPath - The link path as written in markdown
|
|
47
|
-
* @param sourceFilePath - The path of the file containing the link (relative to directory)
|
|
48
|
-
* @returns Normalized path relative to directory root
|
|
49
|
-
*/
|
|
50
|
-
function resolveLinkPath(linkPath: string, sourceFilePath: string): string {
|
|
51
|
-
// Get the directory containing the source file
|
|
52
|
-
const sourceDir = dirname(sourceFilePath);
|
|
53
|
-
|
|
54
|
-
// Join and normalize the path
|
|
55
|
-
const resolvedPath = normalize(join(sourceDir, linkPath));
|
|
56
|
-
|
|
57
|
-
return resolvedPath;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Sort push candidates by dependencies so linked-to pages are pushed first.
|
|
62
|
-
*
|
|
63
|
-
* Uses Kahn's algorithm for topological sorting with cycle detection.
|
|
64
|
-
* If cycles are detected, the files involved in cycles are still included
|
|
65
|
-
* in the output (in their original relative order).
|
|
66
|
-
*
|
|
67
|
-
* @param candidates - Array of push candidates to sort
|
|
68
|
-
* @param directory - Root directory for resolving file paths
|
|
69
|
-
* @returns Object containing:
|
|
70
|
-
* - sorted: Candidates sorted so dependencies come first
|
|
71
|
-
* - cycles: Array of detected cycles (each cycle is array of paths)
|
|
72
|
-
*/
|
|
73
|
-
export function sortByDependencies(
|
|
74
|
-
candidates: PushCandidate[],
|
|
75
|
-
directory: string,
|
|
76
|
-
): { sorted: PushCandidate[]; cycles: string[][] } {
|
|
77
|
-
// Build a set of candidate paths for quick lookup
|
|
78
|
-
const candidatePaths = new Set(candidates.map((c) => c.path));
|
|
79
|
-
|
|
80
|
-
// Build dependency graph: file -> files it depends on (links to)
|
|
81
|
-
// dependsOn[A] = [B, C] means A links to B and C
|
|
82
|
-
const dependsOn = new Map<string, Set<string>>();
|
|
83
|
-
|
|
84
|
-
// Build reverse graph: file -> files that depend on it (link to it)
|
|
85
|
-
// dependedBy[B] = [A] means A links to B
|
|
86
|
-
const dependedBy = new Map<string, Set<string>>();
|
|
87
|
-
|
|
88
|
-
// Initialize maps for all candidates
|
|
89
|
-
for (const candidate of candidates) {
|
|
90
|
-
dependsOn.set(candidate.path, new Set());
|
|
91
|
-
dependedBy.set(candidate.path, new Set());
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// Read each candidate's content and extract dependencies
|
|
95
|
-
for (const candidate of candidates) {
|
|
96
|
-
const fullPath = join(directory, candidate.path);
|
|
97
|
-
|
|
98
|
-
let content: string;
|
|
99
|
-
try {
|
|
100
|
-
content = readFileSync(fullPath, 'utf-8');
|
|
101
|
-
} catch {
|
|
102
|
-
// If we can't read the file, skip dependency analysis for it
|
|
103
|
-
continue;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
const links = extractLocalLinks(content);
|
|
107
|
-
|
|
108
|
-
for (const link of links) {
|
|
109
|
-
// Resolve the link relative to the candidate's location
|
|
110
|
-
const resolvedLink = resolveLinkPath(link, candidate.path);
|
|
111
|
-
|
|
112
|
-
// Only track dependencies that are also candidates
|
|
113
|
-
if (candidatePaths.has(resolvedLink)) {
|
|
114
|
-
// candidate.path depends on resolvedLink
|
|
115
|
-
dependsOn.get(candidate.path)?.add(resolvedLink);
|
|
116
|
-
dependedBy.get(resolvedLink)?.add(candidate.path);
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
// Kahn's algorithm for topological sort
|
|
122
|
-
// Start with nodes that have no dependencies
|
|
123
|
-
const sorted: PushCandidate[] = [];
|
|
124
|
-
const candidateMap = new Map(candidates.map((c) => [c.path, c]));
|
|
125
|
-
|
|
126
|
-
// Queue of nodes with no remaining dependencies
|
|
127
|
-
const queue: string[] = [];
|
|
128
|
-
|
|
129
|
-
// Count of unprocessed dependencies for each node
|
|
130
|
-
const inDegree = new Map<string, number>();
|
|
131
|
-
|
|
132
|
-
// Initialize in-degrees
|
|
133
|
-
for (const candidate of candidates) {
|
|
134
|
-
const deps = dependsOn.get(candidate.path);
|
|
135
|
-
const depCount = deps?.size ?? 0;
|
|
136
|
-
inDegree.set(candidate.path, depCount);
|
|
137
|
-
|
|
138
|
-
if (depCount === 0) {
|
|
139
|
-
queue.push(candidate.path);
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
// Process queue
|
|
144
|
-
while (queue.length > 0) {
|
|
145
|
-
const current = queue.shift();
|
|
146
|
-
if (!current) break;
|
|
147
|
-
|
|
148
|
-
const candidate = candidateMap.get(current);
|
|
149
|
-
if (candidate) {
|
|
150
|
-
sorted.push(candidate);
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
// For each file that depends on current, decrement its in-degree
|
|
154
|
-
const dependents = dependedBy.get(current);
|
|
155
|
-
if (dependents) {
|
|
156
|
-
for (const dependent of dependents) {
|
|
157
|
-
const currentDegree = inDegree.get(dependent) ?? 0;
|
|
158
|
-
const newDegree = currentDegree - 1;
|
|
159
|
-
inDegree.set(dependent, newDegree);
|
|
160
|
-
|
|
161
|
-
if (newDegree === 0) {
|
|
162
|
-
queue.push(dependent);
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
// Detect cycles: any nodes not in sorted output are part of cycles
|
|
169
|
-
const cycles: string[][] = [];
|
|
170
|
-
const sortedPaths = new Set(sorted.map((c) => c.path));
|
|
171
|
-
const remainingPaths = candidates.filter((c) => !sortedPaths.has(c.path)).map((c) => c.path);
|
|
172
|
-
|
|
173
|
-
if (remainingPaths.length > 0) {
|
|
174
|
-
// Find cycles using DFS
|
|
175
|
-
const visited = new Set<string>();
|
|
176
|
-
const inStack = new Set<string>();
|
|
177
|
-
|
|
178
|
-
function findCycles(node: string, path: string[]): void {
|
|
179
|
-
if (inStack.has(node)) {
|
|
180
|
-
// Found a cycle - extract it from path
|
|
181
|
-
const cycleStart = path.indexOf(node);
|
|
182
|
-
const cycle = path.slice(cycleStart);
|
|
183
|
-
cycles.push(cycle);
|
|
184
|
-
return;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
if (visited.has(node)) {
|
|
188
|
-
return;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
visited.add(node);
|
|
192
|
-
inStack.add(node);
|
|
193
|
-
path.push(node);
|
|
194
|
-
|
|
195
|
-
const deps = dependsOn.get(node);
|
|
196
|
-
if (deps) {
|
|
197
|
-
for (const dep of deps) {
|
|
198
|
-
if (remainingPaths.includes(dep)) {
|
|
199
|
-
findCycles(dep, [...path]);
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
inStack.delete(node);
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
for (const start of remainingPaths) {
|
|
208
|
-
if (!visited.has(start)) {
|
|
209
|
-
findCycles(start, []);
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
// Add remaining candidates to sorted output, prioritizing new pages first
|
|
214
|
-
// so they get created and receive page_ids before modified pages are pushed.
|
|
215
|
-
// This allows modified pages to resolve links to the newly created pages.
|
|
216
|
-
const remainingCandidates = remainingPaths
|
|
217
|
-
.map((path) => candidateMap.get(path))
|
|
218
|
-
.filter((c): c is PushCandidate => c !== undefined)
|
|
219
|
-
.sort((a, b) => {
|
|
220
|
-
// New pages first, then modified
|
|
221
|
-
if (a.type === 'new' && b.type !== 'new') return -1;
|
|
222
|
-
if (a.type !== 'new' && b.type === 'new') return 1;
|
|
223
|
-
// Within same type, preserve original order (alphabetical)
|
|
224
|
-
return remainingPaths.indexOf(a.path) - remainingPaths.indexOf(b.path);
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
for (const candidate of remainingCandidates) {
|
|
228
|
-
sorted.push(candidate);
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
return { sorted, cycles };
|
|
233
|
-
}
|
|
@@ -1,384 +0,0 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
2
|
-
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
-
import { tmpdir } from 'node:os';
|
|
4
|
-
import { join } from 'node:path';
|
|
5
|
-
import { extractLocalLinks, sortByDependencies } from '../lib/dependency-sorter.js';
|
|
6
|
-
import type { PushCandidate } from '../lib/file-scanner.js';
|
|
7
|
-
|
|
8
|
-
describe('dependency-sorter', () => {
|
|
9
|
-
let testDir: string;
|
|
10
|
-
|
|
11
|
-
beforeEach(() => {
|
|
12
|
-
testDir = mkdtempSync(join(tmpdir(), 'cn-test-'));
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
afterEach(() => {
|
|
16
|
-
rmSync(testDir, { recursive: true, force: true });
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
describe('extractLocalLinks', () => {
|
|
20
|
-
test('extracts simple markdown links to .md files', () => {
|
|
21
|
-
const content = 'Check out [my page](other-page.md) for more info.';
|
|
22
|
-
|
|
23
|
-
const links = extractLocalLinks(content);
|
|
24
|
-
|
|
25
|
-
expect(links).toEqual(['other-page.md']);
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
test('extracts multiple links', () => {
|
|
29
|
-
const content = `
|
|
30
|
-
# Documentation
|
|
31
|
-
|
|
32
|
-
See [intro](intro.md) for getting started.
|
|
33
|
-
Then read [guide](guide.md) for details.
|
|
34
|
-
`;
|
|
35
|
-
|
|
36
|
-
const links = extractLocalLinks(content);
|
|
37
|
-
|
|
38
|
-
expect(links).toEqual(['intro.md', 'guide.md']);
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
test('extracts links with relative paths', () => {
|
|
42
|
-
const content = 'See [parent](../parent.md) and [child](subdir/child.md).';
|
|
43
|
-
|
|
44
|
-
const links = extractLocalLinks(content);
|
|
45
|
-
|
|
46
|
-
expect(links).toEqual(['../parent.md', 'subdir/child.md']);
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
test('ignores http links', () => {
|
|
50
|
-
const content = 'See [external](http://example.com/page.md) for more.';
|
|
51
|
-
|
|
52
|
-
const links = extractLocalLinks(content);
|
|
53
|
-
|
|
54
|
-
expect(links).toEqual([]);
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
test('ignores https links', () => {
|
|
58
|
-
const content = 'See [secure](https://example.com/page.md) for more.';
|
|
59
|
-
|
|
60
|
-
const links = extractLocalLinks(content);
|
|
61
|
-
|
|
62
|
-
expect(links).toEqual([]);
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
test('removes anchor fragments from links', () => {
|
|
66
|
-
const content = 'See [section](guide.md#installation) for setup.';
|
|
67
|
-
|
|
68
|
-
const links = extractLocalLinks(content);
|
|
69
|
-
|
|
70
|
-
expect(links).toEqual(['guide.md']);
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
test('handles nested brackets in link text', () => {
|
|
74
|
-
const content = 'See [text [nested]](page.md) for info.';
|
|
75
|
-
|
|
76
|
-
const links = extractLocalLinks(content);
|
|
77
|
-
|
|
78
|
-
expect(links).toEqual(['page.md']);
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
test('returns empty array for content with no links', () => {
|
|
82
|
-
const content = '# Just a heading\n\nSome plain text.';
|
|
83
|
-
|
|
84
|
-
const links = extractLocalLinks(content);
|
|
85
|
-
|
|
86
|
-
expect(links).toEqual([]);
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
test('returns empty array for links to non-md files', () => {
|
|
90
|
-
const content = 'See [image](photo.png) and [doc](file.pdf).';
|
|
91
|
-
|
|
92
|
-
const links = extractLocalLinks(content);
|
|
93
|
-
|
|
94
|
-
expect(links).toEqual([]);
|
|
95
|
-
});
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
describe('sortByDependencies', () => {
|
|
99
|
-
function createCandidate(path: string, type: 'new' | 'modified' = 'new'): PushCandidate {
|
|
100
|
-
return {
|
|
101
|
-
path,
|
|
102
|
-
type,
|
|
103
|
-
title: path.replace('.md', ''),
|
|
104
|
-
};
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
test('returns original order when no files have links', () => {
|
|
108
|
-
writeFileSync(join(testDir, 'a.md'), '# File A');
|
|
109
|
-
writeFileSync(join(testDir, 'b.md'), '# File B');
|
|
110
|
-
writeFileSync(join(testDir, 'c.md'), '# File C');
|
|
111
|
-
|
|
112
|
-
const candidates: PushCandidate[] = [createCandidate('a.md'), createCandidate('b.md'), createCandidate('c.md')];
|
|
113
|
-
|
|
114
|
-
const { sorted, cycles } = sortByDependencies(candidates, testDir);
|
|
115
|
-
|
|
116
|
-
expect(cycles).toEqual([]);
|
|
117
|
-
expect(sorted.map((c) => c.path)).toEqual(['a.md', 'b.md', 'c.md']);
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
test('sorts linear chain so dependencies come first', () => {
|
|
121
|
-
// A links to B, B links to C
|
|
122
|
-
// Expected order: C, B, A
|
|
123
|
-
writeFileSync(join(testDir, 'a.md'), '# A\nSee [B](b.md)');
|
|
124
|
-
writeFileSync(join(testDir, 'b.md'), '# B\nSee [C](c.md)');
|
|
125
|
-
writeFileSync(join(testDir, 'c.md'), '# C');
|
|
126
|
-
|
|
127
|
-
const candidates: PushCandidate[] = [createCandidate('a.md'), createCandidate('b.md'), createCandidate('c.md')];
|
|
128
|
-
|
|
129
|
-
const { sorted, cycles } = sortByDependencies(candidates, testDir);
|
|
130
|
-
|
|
131
|
-
expect(cycles).toEqual([]);
|
|
132
|
-
expect(sorted.map((c) => c.path)).toEqual(['c.md', 'b.md', 'a.md']);
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
test('handles diamond dependency pattern', () => {
|
|
136
|
-
// A -> B, A -> C, B -> D, C -> D
|
|
137
|
-
// Expected: D first, then B and C (in any order), then A
|
|
138
|
-
writeFileSync(join(testDir, 'a.md'), '# A\nSee [B](b.md) and [C](c.md)');
|
|
139
|
-
writeFileSync(join(testDir, 'b.md'), '# B\nSee [D](d.md)');
|
|
140
|
-
writeFileSync(join(testDir, 'c.md'), '# C\nSee [D](d.md)');
|
|
141
|
-
writeFileSync(join(testDir, 'd.md'), '# D');
|
|
142
|
-
|
|
143
|
-
const candidates: PushCandidate[] = [
|
|
144
|
-
createCandidate('a.md'),
|
|
145
|
-
createCandidate('b.md'),
|
|
146
|
-
createCandidate('c.md'),
|
|
147
|
-
createCandidate('d.md'),
|
|
148
|
-
];
|
|
149
|
-
|
|
150
|
-
const { sorted, cycles } = sortByDependencies(candidates, testDir);
|
|
151
|
-
|
|
152
|
-
expect(cycles).toEqual([]);
|
|
153
|
-
|
|
154
|
-
const paths = sorted.map((c) => c.path);
|
|
155
|
-
// D must come before B and C
|
|
156
|
-
expect(paths.indexOf('d.md')).toBeLessThan(paths.indexOf('b.md'));
|
|
157
|
-
expect(paths.indexOf('d.md')).toBeLessThan(paths.indexOf('c.md'));
|
|
158
|
-
// B and C must come before A
|
|
159
|
-
expect(paths.indexOf('b.md')).toBeLessThan(paths.indexOf('a.md'));
|
|
160
|
-
expect(paths.indexOf('c.md')).toBeLessThan(paths.indexOf('a.md'));
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
test('detects and reports simple circular dependency', () => {
|
|
164
|
-
// A -> B, B -> A
|
|
165
|
-
writeFileSync(join(testDir, 'a.md'), '# A\nSee [B](b.md)');
|
|
166
|
-
writeFileSync(join(testDir, 'b.md'), '# B\nSee [A](a.md)');
|
|
167
|
-
|
|
168
|
-
const candidates: PushCandidate[] = [createCandidate('a.md'), createCandidate('b.md')];
|
|
169
|
-
|
|
170
|
-
const { sorted, cycles } = sortByDependencies(candidates, testDir);
|
|
171
|
-
|
|
172
|
-
// Should detect cycle
|
|
173
|
-
expect(cycles.length).toBeGreaterThan(0);
|
|
174
|
-
// Both files should still be in output
|
|
175
|
-
expect(sorted).toHaveLength(2);
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
test('detects longer cycles', () => {
|
|
179
|
-
// A -> B -> C -> A
|
|
180
|
-
writeFileSync(join(testDir, 'a.md'), '# A\nSee [B](b.md)');
|
|
181
|
-
writeFileSync(join(testDir, 'b.md'), '# B\nSee [C](c.md)');
|
|
182
|
-
writeFileSync(join(testDir, 'c.md'), '# C\nSee [A](a.md)');
|
|
183
|
-
|
|
184
|
-
const candidates: PushCandidate[] = [createCandidate('a.md'), createCandidate('b.md'), createCandidate('c.md')];
|
|
185
|
-
|
|
186
|
-
const { sorted, cycles } = sortByDependencies(candidates, testDir);
|
|
187
|
-
|
|
188
|
-
expect(cycles.length).toBeGreaterThan(0);
|
|
189
|
-
// All files should still be in output
|
|
190
|
-
expect(sorted).toHaveLength(3);
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
test('ignores links to files not in candidates', () => {
|
|
194
|
-
// A links to B (candidate) and X (not a candidate)
|
|
195
|
-
writeFileSync(join(testDir, 'a.md'), '# A\nSee [B](b.md) and [X](x.md)');
|
|
196
|
-
writeFileSync(join(testDir, 'b.md'), '# B');
|
|
197
|
-
writeFileSync(join(testDir, 'x.md'), '# X (already synced)');
|
|
198
|
-
|
|
199
|
-
const candidates: PushCandidate[] = [createCandidate('a.md'), createCandidate('b.md')];
|
|
200
|
-
|
|
201
|
-
const { sorted, cycles } = sortByDependencies(candidates, testDir);
|
|
202
|
-
|
|
203
|
-
expect(cycles).toEqual([]);
|
|
204
|
-
// B should come before A because A depends on B
|
|
205
|
-
expect(sorted.map((c) => c.path)).toEqual(['b.md', 'a.md']);
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
test('handles links in subdirectories', () => {
|
|
209
|
-
mkdirSync(join(testDir, 'docs'));
|
|
210
|
-
|
|
211
|
-
// docs/a.md links to docs/b.md
|
|
212
|
-
writeFileSync(join(testDir, 'docs', 'a.md'), '# A\nSee [B](b.md)');
|
|
213
|
-
writeFileSync(join(testDir, 'docs', 'b.md'), '# B');
|
|
214
|
-
|
|
215
|
-
const candidates: PushCandidate[] = [createCandidate('docs/a.md'), createCandidate('docs/b.md')];
|
|
216
|
-
|
|
217
|
-
const { sorted, cycles } = sortByDependencies(candidates, testDir);
|
|
218
|
-
|
|
219
|
-
expect(cycles).toEqual([]);
|
|
220
|
-
expect(sorted.map((c) => c.path)).toEqual(['docs/b.md', 'docs/a.md']);
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
test('handles cross-directory links', () => {
|
|
224
|
-
mkdirSync(join(testDir, 'guides'));
|
|
225
|
-
mkdirSync(join(testDir, 'reference'));
|
|
226
|
-
|
|
227
|
-
// guides/a.md links to reference/b.md
|
|
228
|
-
writeFileSync(join(testDir, 'guides', 'a.md'), '# A\nSee [B](../reference/b.md)');
|
|
229
|
-
writeFileSync(join(testDir, 'reference', 'b.md'), '# B');
|
|
230
|
-
|
|
231
|
-
const candidates: PushCandidate[] = [createCandidate('guides/a.md'), createCandidate('reference/b.md')];
|
|
232
|
-
|
|
233
|
-
const { sorted, cycles } = sortByDependencies(candidates, testDir);
|
|
234
|
-
|
|
235
|
-
expect(cycles).toEqual([]);
|
|
236
|
-
expect(sorted.map((c) => c.path)).toEqual(['reference/b.md', 'guides/a.md']);
|
|
237
|
-
});
|
|
238
|
-
|
|
239
|
-
test('handles empty candidate list', () => {
|
|
240
|
-
const { sorted, cycles } = sortByDependencies([], testDir);
|
|
241
|
-
|
|
242
|
-
expect(sorted).toEqual([]);
|
|
243
|
-
expect(cycles).toEqual([]);
|
|
244
|
-
});
|
|
245
|
-
|
|
246
|
-
test('handles single candidate with no links', () => {
|
|
247
|
-
writeFileSync(join(testDir, 'only.md'), '# Only file');
|
|
248
|
-
|
|
249
|
-
const candidates: PushCandidate[] = [createCandidate('only.md')];
|
|
250
|
-
|
|
251
|
-
const { sorted, cycles } = sortByDependencies(candidates, testDir);
|
|
252
|
-
|
|
253
|
-
expect(cycles).toEqual([]);
|
|
254
|
-
expect(sorted).toEqual(candidates);
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
test('handles single candidate with self-link', () => {
|
|
258
|
-
writeFileSync(join(testDir, 'self.md'), '# Self\nSee [Self](self.md)');
|
|
259
|
-
|
|
260
|
-
const candidates: PushCandidate[] = [createCandidate('self.md')];
|
|
261
|
-
|
|
262
|
-
const { sorted, cycles } = sortByDependencies(candidates, testDir);
|
|
263
|
-
|
|
264
|
-
// Self-link creates a cycle
|
|
265
|
-
expect(cycles.length).toBeGreaterThan(0);
|
|
266
|
-
expect(sorted).toHaveLength(1);
|
|
267
|
-
});
|
|
268
|
-
|
|
269
|
-
test('preserves candidate type and title', () => {
|
|
270
|
-
writeFileSync(join(testDir, 'new.md'), '# New\nSee [modified](modified.md)');
|
|
271
|
-
writeFileSync(join(testDir, 'modified.md'), '# Modified');
|
|
272
|
-
|
|
273
|
-
const candidates: PushCandidate[] = [
|
|
274
|
-
{ path: 'new.md', type: 'new', title: 'New Page' },
|
|
275
|
-
{ path: 'modified.md', type: 'modified', title: 'Modified Page', pageId: '123' },
|
|
276
|
-
];
|
|
277
|
-
|
|
278
|
-
const { sorted } = sortByDependencies(candidates, testDir);
|
|
279
|
-
|
|
280
|
-
// modified.md should come first (dependency)
|
|
281
|
-
expect(sorted[0]).toEqual({
|
|
282
|
-
path: 'modified.md',
|
|
283
|
-
type: 'modified',
|
|
284
|
-
title: 'Modified Page',
|
|
285
|
-
pageId: '123',
|
|
286
|
-
});
|
|
287
|
-
expect(sorted[1]).toEqual({
|
|
288
|
-
path: 'new.md',
|
|
289
|
-
type: 'new',
|
|
290
|
-
title: 'New Page',
|
|
291
|
-
});
|
|
292
|
-
});
|
|
293
|
-
|
|
294
|
-
test('handles mixed candidates with and without dependencies', () => {
|
|
295
|
-
// A -> B, C has no links, D -> E
|
|
296
|
-
writeFileSync(join(testDir, 'a.md'), '# A\nSee [B](b.md)');
|
|
297
|
-
writeFileSync(join(testDir, 'b.md'), '# B');
|
|
298
|
-
writeFileSync(join(testDir, 'c.md'), '# C (independent)');
|
|
299
|
-
writeFileSync(join(testDir, 'd.md'), '# D\nSee [E](e.md)');
|
|
300
|
-
writeFileSync(join(testDir, 'e.md'), '# E');
|
|
301
|
-
|
|
302
|
-
const candidates: PushCandidate[] = [
|
|
303
|
-
createCandidate('a.md'),
|
|
304
|
-
createCandidate('b.md'),
|
|
305
|
-
createCandidate('c.md'),
|
|
306
|
-
createCandidate('d.md'),
|
|
307
|
-
createCandidate('e.md'),
|
|
308
|
-
];
|
|
309
|
-
|
|
310
|
-
const { sorted, cycles } = sortByDependencies(candidates, testDir);
|
|
311
|
-
|
|
312
|
-
expect(cycles).toEqual([]);
|
|
313
|
-
|
|
314
|
-
const paths = sorted.map((c) => c.path);
|
|
315
|
-
// B before A
|
|
316
|
-
expect(paths.indexOf('b.md')).toBeLessThan(paths.indexOf('a.md'));
|
|
317
|
-
// E before D
|
|
318
|
-
expect(paths.indexOf('e.md')).toBeLessThan(paths.indexOf('d.md'));
|
|
319
|
-
// All files included
|
|
320
|
-
expect(paths).toHaveLength(5);
|
|
321
|
-
});
|
|
322
|
-
|
|
323
|
-
test('gracefully handles unreadable files', () => {
|
|
324
|
-
writeFileSync(join(testDir, 'readable.md'), '# Readable\nSee [unreadable](unreadable.md)');
|
|
325
|
-
// unreadable.md doesn't exist as a file, but is in candidates
|
|
326
|
-
|
|
327
|
-
const candidates: PushCandidate[] = [createCandidate('readable.md'), createCandidate('unreadable.md')];
|
|
328
|
-
|
|
329
|
-
// Should not throw, even though unreadable.md can't be read
|
|
330
|
-
const { sorted, cycles } = sortByDependencies(candidates, testDir);
|
|
331
|
-
|
|
332
|
-
expect(cycles).toEqual([]);
|
|
333
|
-
expect(sorted).toHaveLength(2);
|
|
334
|
-
});
|
|
335
|
-
|
|
336
|
-
test('prioritizes new pages before modified pages in cycles', () => {
|
|
337
|
-
// Create circular dependency: modified -> new -> modified
|
|
338
|
-
// The new page should be pushed first so it gets a page_id,
|
|
339
|
-
// allowing the modified page to resolve links to it
|
|
340
|
-
mkdirSync(join(testDir, 'getting-started'));
|
|
341
|
-
mkdirSync(join(testDir, 'tools'));
|
|
342
|
-
|
|
343
|
-
writeFileSync(join(testDir, 'getting-started', 'onboarding.md'), '# Onboarding\nSee [CLI](../tools/cli.md)');
|
|
344
|
-
writeFileSync(join(testDir, 'tools', 'cli.md'), '# CLI\nSee [Onboarding](../getting-started/onboarding.md)');
|
|
345
|
-
|
|
346
|
-
const candidates: PushCandidate[] = [
|
|
347
|
-
{ path: 'getting-started/onboarding.md', type: 'modified', title: 'Onboarding', pageId: '123' },
|
|
348
|
-
{ path: 'tools/cli.md', type: 'new', title: 'CLI' },
|
|
349
|
-
];
|
|
350
|
-
|
|
351
|
-
const { sorted, cycles } = sortByDependencies(candidates, testDir);
|
|
352
|
-
|
|
353
|
-
// Should detect the cycle
|
|
354
|
-
expect(cycles.length).toBeGreaterThan(0);
|
|
355
|
-
// Both files should be in output
|
|
356
|
-
expect(sorted).toHaveLength(2);
|
|
357
|
-
// New page should come FIRST (before modified) so it gets created first
|
|
358
|
-
expect(sorted[0].type).toBe('new');
|
|
359
|
-
expect(sorted[0].path).toBe('tools/cli.md');
|
|
360
|
-
expect(sorted[1].type).toBe('modified');
|
|
361
|
-
expect(sorted[1].path).toBe('getting-started/onboarding.md');
|
|
362
|
-
});
|
|
363
|
-
|
|
364
|
-
test('maintains alphabetical order within same type in cycles', () => {
|
|
365
|
-
// All new pages in a cycle should maintain alphabetical order
|
|
366
|
-
writeFileSync(join(testDir, 'c.md'), '# C\nSee [A](a.md)');
|
|
367
|
-
writeFileSync(join(testDir, 'a.md'), '# A\nSee [B](b.md)');
|
|
368
|
-
writeFileSync(join(testDir, 'b.md'), '# B\nSee [C](c.md)');
|
|
369
|
-
|
|
370
|
-
const candidates: PushCandidate[] = [
|
|
371
|
-
{ path: 'c.md', type: 'new', title: 'C' },
|
|
372
|
-
{ path: 'a.md', type: 'new', title: 'A' },
|
|
373
|
-
{ path: 'b.md', type: 'new', title: 'B' },
|
|
374
|
-
];
|
|
375
|
-
|
|
376
|
-
const { sorted, cycles } = sortByDependencies(candidates, testDir);
|
|
377
|
-
|
|
378
|
-
expect(cycles.length).toBeGreaterThan(0);
|
|
379
|
-
// All same type (new), should preserve original order from candidates array
|
|
380
|
-
// Original order is c, a, b
|
|
381
|
-
expect(sorted.map((c) => c.path)).toEqual(['c.md', 'a.md', 'b.md']);
|
|
382
|
-
});
|
|
383
|
-
});
|
|
384
|
-
});
|