@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,251 @@
1
+ import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join, relative } from 'node:path';
3
+ import { parseMarkdown, serializeMarkdown } from './frontmatter.js';
4
+
5
+ /**
6
+ * Prefix for relative paths in markdown links
7
+ */
8
+ const RELATIVE_PREFIX = './';
9
+
10
+ /**
11
+ * Directories to exclude when scanning for markdown files
12
+ */
13
+ const EXCLUDE_DIRS = ['node_modules', '.git', 'dist', 'build', '.cache'];
14
+
15
+ /**
16
+ * Result of updating references in a file
17
+ */
18
+ export interface ReferenceUpdateResult {
19
+ filePath: string;
20
+ updatedCount: number;
21
+ }
22
+
23
+ /**
24
+ * Statistics for file scanning operations
25
+ */
26
+ interface ScanStats {
27
+ failedFiles: number;
28
+ failedDirs: number;
29
+ }
30
+
31
+ /**
32
+ * Find all markdown files in a directory recursively
33
+ * Excludes node_modules, .git, and other common ignore patterns
34
+ *
35
+ * @param directory - Root directory to scan
36
+ * @param excludeOldFile - Optional file path to exclude
37
+ * @param excludeNewFile - Optional file path to exclude
38
+ * @param stats - Optional stats object to track errors
39
+ * @returns Array of absolute file paths
40
+ */
41
+ function findMarkdownFiles(
42
+ directory: string,
43
+ excludeOldFile?: string,
44
+ excludeNewFile?: string,
45
+ stats?: ScanStats,
46
+ ): string[] {
47
+ const results: string[] = [];
48
+ const excludeDirs = EXCLUDE_DIRS;
49
+
50
+ function scan(dir: string): void {
51
+ try {
52
+ const entries = readdirSync(dir);
53
+
54
+ for (const entry of entries) {
55
+ const fullPath = join(dir, entry);
56
+
57
+ // Skip if this is one of the excluded files
58
+ if ((excludeOldFile && fullPath === excludeOldFile) || (excludeNewFile && fullPath === excludeNewFile)) {
59
+ continue;
60
+ }
61
+
62
+ try {
63
+ const stat = statSync(fullPath);
64
+
65
+ if (stat.isDirectory()) {
66
+ // Skip excluded directories
67
+ if (!excludeDirs.includes(entry)) {
68
+ scan(fullPath);
69
+ }
70
+ } else if (stat.isFile() && entry.endsWith('.md')) {
71
+ results.push(fullPath);
72
+ }
73
+ } catch (error) {
74
+ // Track files/dirs we can't access (likely permission issues)
75
+ if (stats) stats.failedFiles++;
76
+ if (process.env.DEBUG) {
77
+ console.error(`Failed to stat ${fullPath}:`, error);
78
+ }
79
+ }
80
+ }
81
+ } catch (error) {
82
+ // Track directories we can't read (likely permission issues)
83
+ if (stats) stats.failedDirs++;
84
+ if (process.env.DEBUG) {
85
+ console.error(`Failed to read directory ${dir}:`, error);
86
+ }
87
+ }
88
+ }
89
+
90
+ scan(directory);
91
+ return results;
92
+ }
93
+
94
+ /**
95
+ * Update references in a markdown file from oldPath to newPath
96
+ * Returns the number of references updated, or -1 if processing failed
97
+ */
98
+ function updateReferencesInFile(
99
+ filePath: string,
100
+ oldRelativePath: string,
101
+ newRelativePath: string,
102
+ spaceRoot: string,
103
+ ): number {
104
+ try {
105
+ const content = readFileSync(filePath, 'utf-8');
106
+ const { frontmatter, content: markdown } = parseMarkdown(content);
107
+
108
+ // Calculate what the old path would look like from this file's perspective
109
+ const fileDir = dirname(filePath);
110
+ const oldAbsolutePath = join(spaceRoot, oldRelativePath);
111
+ const newAbsolutePath = join(spaceRoot, newRelativePath);
112
+
113
+ // Calculate relative paths from this file to the old and new locations
114
+ const oldLinkFromFile = relative(fileDir, oldAbsolutePath);
115
+ const newLinkFromFile = relative(fileDir, newAbsolutePath);
116
+
117
+ // Links in markdown may or may not have ./ prefix, so we need to check both forms
118
+ // Paths starting with . (including ./ and ../) are kept as-is; others get ./ prefix added
119
+ const oldLinkWithPrefix = oldLinkFromFile.startsWith('.')
120
+ ? oldLinkFromFile
121
+ : `${RELATIVE_PREFIX}${oldLinkFromFile}`;
122
+ const oldLinkWithoutPrefix = oldLinkFromFile.startsWith('./') ? oldLinkFromFile.slice(2) : oldLinkFromFile;
123
+ const newLinkWithoutPrefix = newLinkFromFile.startsWith('./') ? newLinkFromFile.slice(2) : newLinkFromFile;
124
+
125
+ let updatedMarkdown = markdown;
126
+ let updateCount = 0;
127
+
128
+ // Try matching with ./ prefix first
129
+ const patternWithPrefix = createMarkdownLinkPattern(oldLinkWithPrefix);
130
+ const matchesWithPrefix = markdown.match(patternWithPrefix);
131
+ if (matchesWithPrefix) {
132
+ updateCount += matchesWithPrefix.length;
133
+ updatedMarkdown = updatedMarkdown.replace(patternWithPrefix, (_match, linkText) => {
134
+ return `[${linkText}](${oldLinkWithPrefix.startsWith('./') ? `${RELATIVE_PREFIX}${newLinkWithoutPrefix}` : newLinkWithoutPrefix})`;
135
+ });
136
+ }
137
+
138
+ // Also try matching without ./ prefix (common in markdown)
139
+ if (!oldLinkFromFile.startsWith('.')) {
140
+ const patternWithoutPrefix = createMarkdownLinkPattern(oldLinkWithoutPrefix);
141
+ const matchesWithoutPrefix = updatedMarkdown.match(patternWithoutPrefix);
142
+ if (matchesWithoutPrefix) {
143
+ updateCount += matchesWithoutPrefix.length;
144
+ updatedMarkdown = updatedMarkdown.replace(patternWithoutPrefix, (_match, linkText) => {
145
+ return `[${linkText}](${newLinkWithoutPrefix})`;
146
+ });
147
+ }
148
+ }
149
+
150
+ // Write updated content if any changes were made
151
+ if (updateCount > 0) {
152
+ const updatedContent = serializeMarkdown(frontmatter, updatedMarkdown);
153
+ writeFileSync(filePath, updatedContent, 'utf-8');
154
+ }
155
+
156
+ return updateCount;
157
+ } catch (error) {
158
+ // Track files that can't be processed (likely permission issues or malformed frontmatter)
159
+ if (process.env.DEBUG) {
160
+ console.error(`Failed to update references in ${filePath}:`, error);
161
+ }
162
+ return -1; // Signal processing failure
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Escape special regex characters in a string
168
+ */
169
+ function escapeRegex(str: string): string {
170
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
171
+ }
172
+
173
+ /**
174
+ * Create a regex pattern to match markdown links with a specific href
175
+ * Handles complex link text including nested brackets and parentheses
176
+ *
177
+ * Pattern explanation:
178
+ * - \[ - opening bracket
179
+ * - ([^\[\]]+(?:\[[^\]]*\][^\[\]]*)*) - captures link text including nested brackets:
180
+ * - [^\[\]]+ - one or more non-bracket characters
181
+ * - (?:\[[^\]]*\][^\[\]]*)* - zero or more groups of [nested] followed by non-brackets
182
+ * - \] - closing bracket
183
+ * - \(${escapedLink}\) - the exact link path in parentheses
184
+ *
185
+ * This handles cases like [text], [text [nested]], [text (with) parens], etc.
186
+ *
187
+ * Known limitations (not currently supported):
188
+ * - Links with titles: [text](path.md "title")
189
+ * - Reference-style links: [text][ref] with [ref]: path.md
190
+ *
191
+ * @param link - The link href to match (will be escaped)
192
+ * @returns RegExp pattern that matches markdown links with the given href
193
+ */
194
+ function createMarkdownLinkPattern(link: string): RegExp {
195
+ const escapedLink = escapeRegex(link);
196
+ return new RegExp(`\\[([^\\[\\]]+(?:\\[[^\\]]*\\][^\\[\\]]*)*)\\]\\(${escapedLink}\\)`, 'g');
197
+ }
198
+
199
+ /**
200
+ * Update all references to a renamed file across the entire space
201
+ * Per ADR-0022: When files are renamed, update all markdown links pointing to them
202
+ *
203
+ * Performance note: This scans all markdown files in the space directory.
204
+ * For large repositories, consider implementing an index of links or limiting
205
+ * the scan to common parent directories.
206
+ *
207
+ * @param spaceRoot - Absolute path to space root directory
208
+ * @param oldPath - Old path relative to space root
209
+ * @param newPath - New path relative to space root
210
+ * @returns Array of files that were updated with the number of references changed
211
+ */
212
+ export function updateReferencesAfterRename(
213
+ spaceRoot: string,
214
+ oldPath: string,
215
+ newPath: string,
216
+ ): ReferenceUpdateResult[] {
217
+ const results: ReferenceUpdateResult[] = [];
218
+ const scanStats: ScanStats = { failedFiles: 0, failedDirs: 0 };
219
+ let failedProcessing = 0;
220
+
221
+ // Find all markdown files in the space (except the renamed file at both old and new locations)
222
+ const oldFullPath = join(spaceRoot, oldPath);
223
+ const newFullPath = join(spaceRoot, newPath);
224
+ const markdownFiles = findMarkdownFiles(spaceRoot, oldFullPath, newFullPath, scanStats);
225
+
226
+ // Update references in each file
227
+ for (const filePath of markdownFiles) {
228
+ const updatedCount = updateReferencesInFile(filePath, oldPath, newPath, spaceRoot);
229
+
230
+ if (updatedCount > 0) {
231
+ results.push({
232
+ filePath: relative(spaceRoot, filePath),
233
+ updatedCount,
234
+ });
235
+ } else if (updatedCount === -1) {
236
+ failedProcessing++;
237
+ }
238
+ }
239
+
240
+ // Log summary of errors if any occurred
241
+ const totalErrors = scanStats.failedFiles + scanStats.failedDirs + failedProcessing;
242
+ if (totalErrors > 0 && process.env.DEBUG) {
243
+ console.warn(
244
+ `Reference update encountered ${totalErrors} errors: ` +
245
+ `${scanStats.failedFiles} files, ${scanStats.failedDirs} directories (scan), ` +
246
+ `${failedProcessing} files (processing)`,
247
+ );
248
+ }
249
+
250
+ return results;
251
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Slugify a string for use as a filename
3
+ * Converts titles to URL-friendly lowercase strings
4
+ */
5
+ export function slugify(text: string): string {
6
+ return text
7
+ .toLowerCase()
8
+ .trim()
9
+ .replace(/[^\w\s-]/g, '') // Remove special characters except spaces and hyphens
10
+ .replace(/\s+/g, '-') // Replace spaces with hyphens
11
+ .replace(/-+/g, '-') // Collapse multiple hyphens
12
+ .replace(/^-+|-+$/g, ''); // Trim hyphens from start and end
13
+ }
14
+
15
+ /**
16
+ * Generate a unique filename by appending a counter if needed
17
+ */
18
+ export function generateUniqueFilename(baseName: string, existingNames: Set<string>, extension = '.md'): string {
19
+ const slug = slugify(baseName);
20
+ const filename = `${slug}${extension}`;
21
+
22
+ if (!existingNames.has(filename)) {
23
+ return filename;
24
+ }
25
+
26
+ let counter = 2;
27
+ while (existingNames.has(`${slug}-${counter}${extension}`)) {
28
+ counter++;
29
+ }
30
+
31
+ return `${slug}-${counter}${extension}`;
32
+ }
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Page state module - builds full page info from frontmatter
3
+ * Per ADR-0024: Frontmatter is the source of truth for sync state
4
+ */
5
+
6
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
7
+ import { join, relative, resolve } from 'node:path';
8
+ import { EXCLUDED_DIRS, RESERVED_FILENAMES } from './file-scanner.js';
9
+ import { parseMarkdown, type PageFrontmatter } from './markdown/index.js';
10
+
11
+ /**
12
+ * Default version for pages without a version in frontmatter
13
+ */
14
+ const DEFAULT_VERSION = 1;
15
+
16
+ /**
17
+ * Full page information combining mapping and frontmatter data
18
+ */
19
+ export interface FullPageInfo {
20
+ pageId: string;
21
+ localPath: string;
22
+ title: string;
23
+ version: number;
24
+ updatedAt?: string;
25
+ syncedAt?: string;
26
+ }
27
+
28
+ /**
29
+ * Cache of all page information built from files
30
+ */
31
+ export interface PageStateCache {
32
+ pages: Map<string, FullPageInfo>;
33
+ pathToPageId: Map<string, string>;
34
+ }
35
+
36
+ /**
37
+ * Result of building page state, including any warnings
38
+ */
39
+ export interface PageStateBuildResult extends PageStateCache {
40
+ warnings: string[];
41
+ }
42
+
43
+ /**
44
+ * Build full page state by scanning markdown files and reading frontmatter
45
+ *
46
+ * @param directory - Space root directory
47
+ * @param pageMappings - Page mappings from .confluence.json (pageId -> localPath)
48
+ * @returns PageStateBuildResult with all page info and any warnings
49
+ */
50
+ export function buildPageStateFromFiles(directory: string, pageMappings: Record<string, string>): PageStateBuildResult {
51
+ const pages = new Map<string, FullPageInfo>();
52
+ const pathToPageId = new Map<string, string>();
53
+ const warnings: string[] = [];
54
+
55
+ // Read frontmatter from each mapped file
56
+ // Only add to pathToPageId if page is successfully parsed
57
+ const resolvedDirectory = resolve(directory);
58
+
59
+ for (const [pageId, localPath] of Object.entries(pageMappings)) {
60
+ const fullPath = resolve(directory, localPath);
61
+
62
+ // Path traversal protection: ensure resolved path is within directory
63
+ if (!fullPath.startsWith(resolvedDirectory)) {
64
+ warnings.push(`Skipping path outside directory for page ${pageId}: ${localPath}`);
65
+ continue;
66
+ }
67
+
68
+ if (!existsSync(fullPath)) {
69
+ // File doesn't exist - skip (might have been deleted)
70
+ warnings.push(`File not found for page ${pageId}: ${localPath}`);
71
+ continue;
72
+ }
73
+
74
+ try {
75
+ const content = readFileSync(fullPath, 'utf-8');
76
+ const { frontmatter } = parseMarkdown(content);
77
+
78
+ // Warn if frontmatter page_id doesn't match mapping key
79
+ if (frontmatter.page_id && frontmatter.page_id !== pageId) {
80
+ warnings.push(
81
+ `Page ID mismatch for ${localPath}: mapping has "${pageId}", frontmatter has "${frontmatter.page_id}"`,
82
+ );
83
+ }
84
+
85
+ const pageInfo: FullPageInfo = {
86
+ pageId,
87
+ localPath,
88
+ title: frontmatter.title || '',
89
+ version: frontmatter.version || DEFAULT_VERSION,
90
+ updatedAt: frontmatter.updated_at,
91
+ syncedAt: frontmatter.synced_at,
92
+ };
93
+
94
+ pages.set(pageId, pageInfo);
95
+ pathToPageId.set(localPath, pageId);
96
+ } catch (error) {
97
+ // Failed to read/parse file - skip but warn
98
+ const errorMsg = error instanceof Error ? error.message : String(error);
99
+ warnings.push(`Failed to parse frontmatter for ${localPath}: ${errorMsg}`);
100
+ }
101
+ }
102
+
103
+ return { pages, pathToPageId, warnings };
104
+ }
105
+
106
+ /**
107
+ * Get page info for a single file by reading its frontmatter
108
+ *
109
+ * @param directory - Space root directory
110
+ * @param localPath - Relative path to the file
111
+ * @returns FullPageInfo or null if file doesn't exist or has no page_id
112
+ */
113
+ export function getPageInfoByPath(directory: string, localPath: string): FullPageInfo | null {
114
+ const fullPath = join(directory, localPath);
115
+
116
+ if (!existsSync(fullPath)) {
117
+ return null;
118
+ }
119
+
120
+ try {
121
+ const content = readFileSync(fullPath, 'utf-8');
122
+ const { frontmatter } = parseMarkdown(content);
123
+
124
+ // Must have page_id to be a tracked page
125
+ if (!frontmatter.page_id) {
126
+ return null;
127
+ }
128
+
129
+ return {
130
+ pageId: frontmatter.page_id,
131
+ localPath,
132
+ title: frontmatter.title || '',
133
+ version: frontmatter.version || DEFAULT_VERSION,
134
+ updatedAt: frontmatter.updated_at,
135
+ syncedAt: frontmatter.synced_at,
136
+ };
137
+ } catch {
138
+ // Return null for any parse errors (malformed YAML, encoding issues, etc.)
139
+ // This is intentional - callers treat unreadable files the same as files without page_id.
140
+ // Use buildPageStateFromFiles() instead if you need detailed error reporting.
141
+ return null;
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Scan directory for all markdown files and build page state
147
+ * This is used when we need full state but don't have mappings (e.g., initial scan)
148
+ *
149
+ * @param directory - Space root directory
150
+ * @returns PageStateCache with all discovered pages
151
+ */
152
+ export function scanDirectoryForPages(directory: string): PageStateCache {
153
+ const pages = new Map<string, FullPageInfo>();
154
+ const pathToPageId = new Map<string, string>();
155
+
156
+ function scanDir(dir: string): void {
157
+ const entries = readdirSync(dir);
158
+
159
+ for (const entry of entries) {
160
+ // Skip hidden files/directories
161
+ if (entry.startsWith('.')) {
162
+ continue;
163
+ }
164
+
165
+ // Skip excluded directories
166
+ if (EXCLUDED_DIRS.has(entry)) {
167
+ continue;
168
+ }
169
+
170
+ const fullPath = join(dir, entry);
171
+ const stat = statSync(fullPath);
172
+
173
+ if (stat.isDirectory()) {
174
+ scanDir(fullPath);
175
+ } else if (entry.endsWith('.md')) {
176
+ // Skip reserved filenames (used by coding agents)
177
+ if (RESERVED_FILENAMES.has(entry.toLowerCase())) {
178
+ continue;
179
+ }
180
+
181
+ const localPath = relative(directory, fullPath);
182
+ const pageInfo = getPageInfoByPath(directory, localPath);
183
+
184
+ if (pageInfo) {
185
+ pages.set(pageInfo.pageId, pageInfo);
186
+ pathToPageId.set(localPath, pageInfo.pageId);
187
+ }
188
+ }
189
+ }
190
+ }
191
+
192
+ scanDir(directory);
193
+
194
+ return { pages, pathToPageId };
195
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Resolve a page target argument to a page ID.
3
+ *
4
+ * Accepts:
5
+ * - A path ending in .md or containing /: reads frontmatter to extract page_id
6
+ * - A numeric string: used directly as the page ID
7
+ * - Otherwise: throws with a usage hint
8
+ */
9
+
10
+ import { readFileSync } from 'node:fs';
11
+ import { extractPageId } from './markdown/frontmatter.js';
12
+
13
+ export function resolvePageTarget(target: string): string {
14
+ if (target.endsWith('.md') || target.includes('/')) {
15
+ let content: string;
16
+ try {
17
+ content = readFileSync(target, 'utf-8');
18
+ } catch {
19
+ throw new Error(`File not found: ${target}`);
20
+ }
21
+ const pageId = extractPageId(content);
22
+ if (!pageId) {
23
+ throw new Error(`No page_id found in frontmatter of ${target}`);
24
+ }
25
+ return pageId;
26
+ }
27
+
28
+ if (/^\d+$/.test(target)) {
29
+ return target;
30
+ }
31
+
32
+ throw new Error(`Invalid page target: "${target}". Provide a page ID (numeric) or a path to a .md file.`);
33
+ }