@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,60 @@
1
+ import { Effect, Schema, pipe } from 'effect';
2
+ import { ApiError, AuthError, NetworkError, RateLimitError } from '../errors.js';
3
+ import { SearchResponseSchema, type SearchResponse } from './types.js';
4
+
5
+ /** Retry schedule shared with client — exponential backoff capped at ~160s */
6
+ import { Schedule } from 'effect';
7
+ const MAX_RETRIES = 5;
8
+ const BASE_DELAY_MS = 1000;
9
+ const rateLimitRetrySchedule = Schedule.exponential(BASE_DELAY_MS).pipe(
10
+ Schedule.jittered,
11
+ Schedule.whileInput((error: unknown) => error instanceof RateLimitError),
12
+ Schedule.upTo(MAX_RETRIES * BASE_DELAY_MS * 32),
13
+ );
14
+
15
+ /**
16
+ * Search pages using CQL (Effect version)
17
+ * Uses GET /wiki/rest/api/search (v1 API - not prefixed with /api/v2)
18
+ */
19
+ export function searchEffect(
20
+ baseUrl: string,
21
+ authHeader: string,
22
+ cql: string,
23
+ limit = 10,
24
+ ): Effect.Effect<SearchResponse, ApiError | AuthError | NetworkError | RateLimitError> {
25
+ const url = `${baseUrl}/wiki/rest/api/search?cql=${encodeURIComponent(cql)}&limit=${limit}`;
26
+
27
+ const makeRequest = Effect.tryPromise({
28
+ try: async () => {
29
+ const response = await fetch(url, {
30
+ headers: { Authorization: authHeader, Accept: 'application/json' },
31
+ });
32
+
33
+ if (response.status === 429) {
34
+ const retryAfter = response.headers.get('Retry-After');
35
+ throw new RateLimitError('Rate limited', retryAfter ? Number.parseInt(retryAfter, 10) : undefined);
36
+ }
37
+ if (response.status === 401) throw new AuthError('Invalid credentials', 401);
38
+ if (response.status === 403) throw new AuthError('Access denied', 403);
39
+ if (!response.ok) {
40
+ const errorText = await response.text();
41
+ throw new ApiError(`Search failed: ${response.status} ${errorText}`, response.status);
42
+ }
43
+ return response.json();
44
+ },
45
+ catch: (error) => {
46
+ if (error instanceof RateLimitError || error instanceof AuthError || error instanceof ApiError) return error;
47
+ return new NetworkError(`Network error: ${error}`);
48
+ },
49
+ });
50
+
51
+ return pipe(
52
+ makeRequest,
53
+ Effect.flatMap((data) =>
54
+ Schema.decodeUnknown(SearchResponseSchema)(data).pipe(
55
+ Effect.mapError((e) => new ApiError(`Invalid response: ${e}`, 500)),
56
+ ),
57
+ ),
58
+ Effect.retry(rateLimitRetrySchedule),
59
+ );
60
+ }
@@ -0,0 +1,329 @@
1
+ import { Schema } from 'effect';
2
+
3
+ /**
4
+ * Confluence API v2 type definitions
5
+ * These schemas are used for both validation and type inference
6
+ */
7
+
8
+ /**
9
+ * User information
10
+ */
11
+ export const UserSchema = Schema.Struct({
12
+ accountId: Schema.String,
13
+ displayName: Schema.optional(Schema.String),
14
+ email: Schema.optional(Schema.String),
15
+ });
16
+ export type User = Schema.Schema.Type<typeof UserSchema>;
17
+
18
+ /**
19
+ * Space information from Confluence API v2
20
+ */
21
+ export const SpaceSchema = Schema.Struct({
22
+ id: Schema.String,
23
+ key: Schema.String,
24
+ name: Schema.String,
25
+ type: Schema.optional(Schema.String),
26
+ status: Schema.optional(Schema.String),
27
+ homepageId: Schema.optional(Schema.String),
28
+ description: Schema.optional(
29
+ Schema.NullOr(
30
+ Schema.Struct({
31
+ plain: Schema.optional(
32
+ Schema.Struct({
33
+ value: Schema.String,
34
+ }),
35
+ ),
36
+ }),
37
+ ),
38
+ ),
39
+ _links: Schema.optional(
40
+ Schema.Struct({
41
+ webui: Schema.optional(Schema.String),
42
+ }),
43
+ ),
44
+ });
45
+ export type Space = Schema.Schema.Type<typeof SpaceSchema>;
46
+
47
+ /**
48
+ * List of spaces response
49
+ */
50
+ export const SpacesResponseSchema = Schema.Struct({
51
+ results: Schema.Array(SpaceSchema),
52
+ _links: Schema.optional(
53
+ Schema.Struct({
54
+ next: Schema.optional(Schema.String),
55
+ }),
56
+ ),
57
+ });
58
+ export type SpacesResponse = Schema.Schema.Type<typeof SpacesResponseSchema>;
59
+
60
+ /**
61
+ * Page version information
62
+ */
63
+ export const VersionSchema = Schema.Struct({
64
+ number: Schema.Number,
65
+ createdAt: Schema.optional(Schema.String),
66
+ authorId: Schema.optional(Schema.String),
67
+ });
68
+ export type Version = Schema.Schema.Type<typeof VersionSchema>;
69
+
70
+ /**
71
+ * Page body content
72
+ */
73
+ export const BodySchema = Schema.Struct({
74
+ storage: Schema.optional(
75
+ Schema.Struct({
76
+ value: Schema.String,
77
+ representation: Schema.optional(Schema.String),
78
+ }),
79
+ ),
80
+ });
81
+ export type Body = Schema.Schema.Type<typeof BodySchema>;
82
+
83
+ /**
84
+ * Label information
85
+ */
86
+ export const LabelSchema = Schema.Struct({
87
+ id: Schema.String,
88
+ name: Schema.String,
89
+ prefix: Schema.optional(Schema.String),
90
+ });
91
+ export type Label = Schema.Schema.Type<typeof LabelSchema>;
92
+
93
+ /**
94
+ * Page information from Confluence API v2
95
+ */
96
+ export const PageSchema = Schema.Struct({
97
+ id: Schema.String,
98
+ title: Schema.String,
99
+ spaceId: Schema.String,
100
+ status: Schema.optional(Schema.String),
101
+ parentId: Schema.optional(Schema.NullOr(Schema.String)),
102
+ parentType: Schema.optional(Schema.NullOr(Schema.String)),
103
+ authorId: Schema.optional(Schema.String),
104
+ ownerId: Schema.optional(Schema.String),
105
+ createdAt: Schema.optional(Schema.String),
106
+ version: Schema.optional(VersionSchema),
107
+ body: Schema.optional(BodySchema),
108
+ _links: Schema.optional(
109
+ Schema.Struct({
110
+ webui: Schema.optional(Schema.String),
111
+ editui: Schema.optional(Schema.String),
112
+ tinyui: Schema.optional(Schema.String),
113
+ }),
114
+ ),
115
+ });
116
+ export type Page = Schema.Schema.Type<typeof PageSchema>;
117
+
118
+ /**
119
+ * List of pages response
120
+ */
121
+ export const PagesResponseSchema = Schema.Struct({
122
+ results: Schema.Array(PageSchema),
123
+ _links: Schema.optional(
124
+ Schema.Struct({
125
+ next: Schema.optional(Schema.String),
126
+ }),
127
+ ),
128
+ });
129
+ export type PagesResponse = Schema.Schema.Type<typeof PagesResponseSchema>;
130
+
131
+ /**
132
+ * Labels response
133
+ */
134
+ export const LabelsResponseSchema = Schema.Struct({
135
+ results: Schema.Array(LabelSchema),
136
+ _links: Schema.optional(
137
+ Schema.Struct({
138
+ next: Schema.optional(Schema.String),
139
+ }),
140
+ ),
141
+ });
142
+ export type LabelsResponse = Schema.Schema.Type<typeof LabelsResponseSchema>;
143
+
144
+ /**
145
+ * Folder information from Confluence API v2
146
+ */
147
+ export const FolderSchema = Schema.Struct({
148
+ id: Schema.String,
149
+ type: Schema.Literal('folder'),
150
+ title: Schema.String,
151
+ parentId: Schema.optional(Schema.NullOr(Schema.String)),
152
+ parentType: Schema.optional(Schema.NullOr(Schema.String)),
153
+ authorId: Schema.optional(Schema.String),
154
+ ownerId: Schema.optional(Schema.String),
155
+ createdAt: Schema.optional(Schema.Union(Schema.String, Schema.Number)),
156
+ status: Schema.optional(Schema.String),
157
+ version: Schema.optional(VersionSchema),
158
+ });
159
+ export type Folder = Schema.Schema.Type<typeof FolderSchema>;
160
+
161
+ /**
162
+ * Content item - either a page or folder
163
+ */
164
+ export type ContentItem = Page | Folder;
165
+
166
+ /**
167
+ * Check if content item is a folder
168
+ */
169
+ export function isFolder(item: ContentItem): item is Folder {
170
+ return 'type' in item && item.type === 'folder';
171
+ }
172
+
173
+ /**
174
+ * Error response for version conflicts (409)
175
+ */
176
+ export interface VersionConflictResponse {
177
+ version?: {
178
+ number?: number;
179
+ };
180
+ }
181
+
182
+ /**
183
+ * Request body for updating a page
184
+ */
185
+ export const UpdatePageRequestSchema = Schema.Struct({
186
+ id: Schema.String,
187
+ status: Schema.String,
188
+ title: Schema.String,
189
+ body: Schema.Struct({
190
+ representation: Schema.Literal('storage'),
191
+ value: Schema.String,
192
+ }),
193
+ version: Schema.Struct({
194
+ number: Schema.Number,
195
+ message: Schema.optional(Schema.String),
196
+ }),
197
+ });
198
+ export type UpdatePageRequest = Schema.Schema.Type<typeof UpdatePageRequestSchema>;
199
+
200
+ /**
201
+ * Request body for creating a new page
202
+ */
203
+ export const CreatePageRequestSchema = Schema.Struct({
204
+ spaceId: Schema.String,
205
+ status: Schema.String,
206
+ title: Schema.String,
207
+ parentId: Schema.optional(Schema.String),
208
+ body: Schema.Struct({
209
+ representation: Schema.Literal('storage'),
210
+ value: Schema.String,
211
+ }),
212
+ });
213
+ export type CreatePageRequest = Schema.Schema.Type<typeof CreatePageRequestSchema>;
214
+
215
+ /**
216
+ * Request body for creating a new folder
217
+ */
218
+ export const CreateFolderRequestSchema = Schema.Struct({
219
+ spaceId: Schema.String,
220
+ title: Schema.String,
221
+ parentId: Schema.optional(Schema.String),
222
+ });
223
+ export type CreateFolderRequest = Schema.Schema.Type<typeof CreateFolderRequestSchema>;
224
+
225
+ /**
226
+ * Comment information from Confluence API v2
227
+ */
228
+ export const CommentSchema = Schema.Struct({
229
+ id: Schema.String,
230
+ status: Schema.optional(Schema.String),
231
+ title: Schema.optional(Schema.String),
232
+ body: Schema.optional(BodySchema),
233
+ version: Schema.optional(VersionSchema),
234
+ authorId: Schema.optional(Schema.String),
235
+ createdAt: Schema.optional(Schema.String),
236
+ });
237
+ export type Comment = Schema.Schema.Type<typeof CommentSchema>;
238
+
239
+ export const CommentsResponseSchema = Schema.Struct({
240
+ results: Schema.Array(CommentSchema),
241
+ _links: Schema.optional(
242
+ Schema.Struct({
243
+ next: Schema.optional(Schema.String),
244
+ }),
245
+ ),
246
+ });
247
+ export type CommentsResponse = Schema.Schema.Type<typeof CommentsResponseSchema>;
248
+
249
+ /**
250
+ * Attachment information from Confluence API v2
251
+ */
252
+ export const AttachmentSchema = Schema.Struct({
253
+ id: Schema.String,
254
+ title: Schema.String,
255
+ status: Schema.optional(Schema.String),
256
+ mediaType: Schema.optional(Schema.String),
257
+ fileSize: Schema.optional(Schema.Number),
258
+ webuiLink: Schema.optional(Schema.String),
259
+ downloadLink: Schema.optional(Schema.String),
260
+ version: Schema.optional(VersionSchema),
261
+ });
262
+ export type Attachment = Schema.Schema.Type<typeof AttachmentSchema>;
263
+
264
+ export const AttachmentsResponseSchema = Schema.Struct({
265
+ results: Schema.Array(AttachmentSchema),
266
+ _links: Schema.optional(
267
+ Schema.Struct({
268
+ next: Schema.optional(Schema.String),
269
+ }),
270
+ ),
271
+ });
272
+ export type AttachmentsResponse = Schema.Schema.Type<typeof AttachmentsResponseSchema>;
273
+
274
+ /**
275
+ * Search result item from CQL search
276
+ */
277
+ export const SearchResultItemSchema = Schema.Struct({
278
+ id: Schema.optional(Schema.String),
279
+ title: Schema.optional(Schema.String),
280
+ type: Schema.optional(Schema.String),
281
+ status: Schema.optional(Schema.String),
282
+ space: Schema.optional(
283
+ Schema.Struct({
284
+ id: Schema.optional(Schema.String),
285
+ key: Schema.optional(Schema.String),
286
+ name: Schema.optional(Schema.String),
287
+ }),
288
+ ),
289
+ url: Schema.optional(Schema.String),
290
+ excerpt: Schema.optional(Schema.String),
291
+ lastModified: Schema.optional(Schema.String),
292
+ content: Schema.optional(
293
+ Schema.Struct({
294
+ id: Schema.optional(Schema.String),
295
+ type: Schema.optional(Schema.String),
296
+ title: Schema.optional(Schema.String),
297
+ _links: Schema.optional(
298
+ Schema.Struct({
299
+ webui: Schema.optional(Schema.String),
300
+ }),
301
+ ),
302
+ }),
303
+ ),
304
+ });
305
+ export type SearchResultItem = Schema.Schema.Type<typeof SearchResultItemSchema>;
306
+
307
+ export const SearchResponseSchema = Schema.Struct({
308
+ results: Schema.Array(SearchResultItemSchema),
309
+ totalSize: Schema.optional(Schema.Number),
310
+ start: Schema.optional(Schema.Number),
311
+ limit: Schema.optional(Schema.Number),
312
+ });
313
+ export type SearchResponse = Schema.Schema.Type<typeof SearchResponseSchema>;
314
+
315
+ /**
316
+ * Page with children tree structure (for building hierarchy)
317
+ */
318
+ export interface PageTreeNode {
319
+ page: Page;
320
+ children: PageTreeNode[];
321
+ }
322
+
323
+ /**
324
+ * Content tree node (page or folder with children)
325
+ */
326
+ export interface ContentTreeNode {
327
+ item: ContentItem;
328
+ children: ContentTreeNode[];
329
+ }
@@ -0,0 +1,58 @@
1
+ import { Effect, Schema, pipe } from 'effect';
2
+ import { Schedule } from 'effect';
3
+ import { ApiError, AuthError, NetworkError, RateLimitError } from '../errors.js';
4
+ import { UserSchema, type User } from './types.js';
5
+
6
+ const MAX_RETRIES = 5;
7
+ const BASE_DELAY_MS = 1000;
8
+ const rateLimitRetrySchedule = Schedule.exponential(BASE_DELAY_MS).pipe(
9
+ Schedule.jittered,
10
+ Schedule.whileInput((error: unknown) => error instanceof RateLimitError),
11
+ Schedule.upTo(MAX_RETRIES * BASE_DELAY_MS * 32),
12
+ );
13
+
14
+ /**
15
+ * Get user information by account ID (Effect version)
16
+ * Uses v1 API as v2 does not have a user endpoint
17
+ */
18
+ export function getUserEffect(
19
+ baseUrl: string,
20
+ authHeader: string,
21
+ accountId: string,
22
+ ): Effect.Effect<User, ApiError | AuthError | NetworkError | RateLimitError> {
23
+ const url = `${baseUrl}/wiki/rest/api/user?accountId=${encodeURIComponent(accountId)}`;
24
+
25
+ const makeRequest = Effect.tryPromise({
26
+ try: async () => {
27
+ const response = await fetch(url, {
28
+ headers: { Authorization: authHeader, Accept: 'application/json' },
29
+ });
30
+
31
+ if (response.status === 429) {
32
+ const retryAfter = response.headers.get('Retry-After');
33
+ throw new RateLimitError('Rate limited', retryAfter ? Number.parseInt(retryAfter, 10) : undefined);
34
+ }
35
+ if (response.status === 401) throw new AuthError('Invalid credentials', 401);
36
+ if (response.status === 403) throw new AuthError('Access denied', 403);
37
+ if (!response.ok) {
38
+ const errorText = await response.text();
39
+ throw new ApiError(`API request failed: ${response.status} ${errorText}`, response.status);
40
+ }
41
+ return response.json();
42
+ },
43
+ catch: (error) => {
44
+ if (error instanceof RateLimitError || error instanceof AuthError || error instanceof ApiError) return error;
45
+ return new NetworkError(`Network error: ${error}`);
46
+ },
47
+ });
48
+
49
+ return pipe(
50
+ makeRequest,
51
+ Effect.flatMap((data) =>
52
+ Schema.decodeUnknown(UserSchema)(data).pipe(
53
+ Effect.mapError((e) => new ApiError(`Invalid user response: ${e}`, 500)),
54
+ ),
55
+ ),
56
+ Effect.retry(rateLimitRetrySchedule),
57
+ );
58
+ }
@@ -0,0 +1,233 @@
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
+ }