@imjp/writenex-astro 0.1.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 (141) hide show
  1. package/README.md +539 -0
  2. package/dist/chunk-5PM6EQE5.js +151 -0
  3. package/dist/chunk-5PM6EQE5.js.map +1 -0
  4. package/dist/chunk-7XU5X6CW.js +1331 -0
  5. package/dist/chunk-7XU5X6CW.js.map +1 -0
  6. package/dist/chunk-AAOQHQPU.js +574 -0
  7. package/dist/chunk-AAOQHQPU.js.map +1 -0
  8. package/dist/chunk-CF2XXJFF.js +1410 -0
  9. package/dist/chunk-CF2XXJFF.js.map +1 -0
  10. package/dist/chunk-CRPZUUDU.js +52 -0
  11. package/dist/chunk-CRPZUUDU.js.map +1 -0
  12. package/dist/chunk-CYLDJ3HZ.js +310 -0
  13. package/dist/chunk-CYLDJ3HZ.js.map +1 -0
  14. package/dist/chunk-KIKIPIFA.js +1 -0
  15. package/dist/chunk-KIKIPIFA.js.map +1 -0
  16. package/dist/chunk-XNTQTTJU.js +145 -0
  17. package/dist/chunk-XNTQTTJU.js.map +1 -0
  18. package/dist/client/index.css +2 -0
  19. package/dist/client/index.css.map +1 -0
  20. package/dist/client/index.js +375 -0
  21. package/dist/client/index.js.map +1 -0
  22. package/dist/client/styles.css +584 -0
  23. package/dist/client/variables.css +304 -0
  24. package/dist/config/index.d.ts +54 -0
  25. package/dist/config/index.js +38 -0
  26. package/dist/config/index.js.map +1 -0
  27. package/dist/config-BmEdBDo_.d.ts +220 -0
  28. package/dist/content-BWR52vD-.d.ts +64 -0
  29. package/dist/discovery/index.d.ts +310 -0
  30. package/dist/discovery/index.js +38 -0
  31. package/dist/discovery/index.js.map +1 -0
  32. package/dist/errors-C0iYiDTv.d.ts +107 -0
  33. package/dist/filesystem/index.d.ts +1292 -0
  34. package/dist/filesystem/index.js +203 -0
  35. package/dist/filesystem/index.js.map +1 -0
  36. package/dist/image-FP7w5ZIs.d.ts +47 -0
  37. package/dist/index.d.ts +64 -0
  38. package/dist/index.js +151 -0
  39. package/dist/index.js.map +1 -0
  40. package/dist/loader-55LWCXHA.js +12 -0
  41. package/dist/loader-55LWCXHA.js.map +1 -0
  42. package/dist/loader-CrdnaAWR.d.ts +327 -0
  43. package/dist/server/index.d.ts +357 -0
  44. package/dist/server/index.js +37 -0
  45. package/dist/server/index.js.map +1 -0
  46. package/package.json +94 -0
  47. package/src/client/App.tsx +900 -0
  48. package/src/client/components/ConfigPanel/ConfigPanel.css +553 -0
  49. package/src/client/components/ConfigPanel/ConfigPanel.tsx +396 -0
  50. package/src/client/components/ConfigPanel/index.ts +6 -0
  51. package/src/client/components/CreateContentModal/CreateContentModal.css +327 -0
  52. package/src/client/components/CreateContentModal/CreateContentModal.tsx +216 -0
  53. package/src/client/components/CreateContentModal/index.ts +7 -0
  54. package/src/client/components/Editor/Editor.css +885 -0
  55. package/src/client/components/Editor/Editor.tsx +484 -0
  56. package/src/client/components/Editor/ImageDialog.css +344 -0
  57. package/src/client/components/Editor/ImageDialog.tsx +367 -0
  58. package/src/client/components/Editor/LinkDialog.css +326 -0
  59. package/src/client/components/Editor/LinkDialog.tsx +332 -0
  60. package/src/client/components/Editor/index.ts +6 -0
  61. package/src/client/components/FrontmatterForm/FrontmatterForm.css +468 -0
  62. package/src/client/components/FrontmatterForm/FrontmatterForm.tsx +914 -0
  63. package/src/client/components/FrontmatterForm/index.ts +7 -0
  64. package/src/client/components/Header/Header.css +300 -0
  65. package/src/client/components/Header/Header.tsx +300 -0
  66. package/src/client/components/Header/index.ts +7 -0
  67. package/src/client/components/KeyboardShortcuts/KeyboardShortcuts.css +239 -0
  68. package/src/client/components/KeyboardShortcuts/KeyboardShortcuts.tsx +151 -0
  69. package/src/client/components/KeyboardShortcuts/index.ts +6 -0
  70. package/src/client/components/LazyEditor.tsx +75 -0
  71. package/src/client/components/LiveRegion/LiveRegion.css +19 -0
  72. package/src/client/components/LiveRegion/LiveRegion.tsx +60 -0
  73. package/src/client/components/LiveRegion/index.ts +7 -0
  74. package/src/client/components/SearchReplace/SearchReplacePanel.css +300 -0
  75. package/src/client/components/SearchReplace/SearchReplacePanel.tsx +332 -0
  76. package/src/client/components/SearchReplace/index.ts +7 -0
  77. package/src/client/components/SelectCollectionModal/SelectCollectionModal.css +308 -0
  78. package/src/client/components/SelectCollectionModal/SelectCollectionModal.tsx +223 -0
  79. package/src/client/components/SelectCollectionModal/index.ts +7 -0
  80. package/src/client/components/Sidebar/Sidebar.css +570 -0
  81. package/src/client/components/Sidebar/Sidebar.tsx +617 -0
  82. package/src/client/components/Sidebar/index.ts +7 -0
  83. package/src/client/components/SkipLink/SkipLink.css +51 -0
  84. package/src/client/components/SkipLink/SkipLink.tsx +67 -0
  85. package/src/client/components/SkipLink/index.ts +7 -0
  86. package/src/client/components/UnsavedChangesModal/UnsavedChangesModal.css +233 -0
  87. package/src/client/components/UnsavedChangesModal/UnsavedChangesModal.tsx +160 -0
  88. package/src/client/components/UnsavedChangesModal/index.ts +1 -0
  89. package/src/client/components/VersionHistory/DiffViewer.css +430 -0
  90. package/src/client/components/VersionHistory/DiffViewer.tsx +383 -0
  91. package/src/client/components/VersionHistory/VersionActions.css +318 -0
  92. package/src/client/components/VersionHistory/VersionActions.tsx +277 -0
  93. package/src/client/components/VersionHistory/VersionHistoryPanel.css +369 -0
  94. package/src/client/components/VersionHistory/VersionHistoryPanel.tsx +469 -0
  95. package/src/client/components/VersionHistory/index.ts +9 -0
  96. package/src/client/context/ApiContext.tsx +154 -0
  97. package/src/client/context/ThemeContext.tsx +172 -0
  98. package/src/client/hooks/useAnnounce.ts +201 -0
  99. package/src/client/hooks/useApi.ts +374 -0
  100. package/src/client/hooks/useArrowNavigation.ts +286 -0
  101. package/src/client/hooks/useAutosave.ts +241 -0
  102. package/src/client/hooks/useFocusTrap.ts +178 -0
  103. package/src/client/hooks/useKeyboardShortcuts.ts +203 -0
  104. package/src/client/hooks/useSearch.ts +206 -0
  105. package/src/client/hooks/useVersionHistory.ts +451 -0
  106. package/src/client/index.tsx +70 -0
  107. package/src/client/styles.css +584 -0
  108. package/src/client/utils/focus.ts +57 -0
  109. package/src/client/utils/openInEditor.ts +130 -0
  110. package/src/client/variables.css +304 -0
  111. package/src/config/defaults.ts +109 -0
  112. package/src/config/index.ts +32 -0
  113. package/src/config/loader.ts +174 -0
  114. package/src/config/schema.ts +161 -0
  115. package/src/core/constants.ts +39 -0
  116. package/src/core/errors.ts +739 -0
  117. package/src/core/index.ts +11 -0
  118. package/src/discovery/collections.ts +216 -0
  119. package/src/discovery/index.ts +33 -0
  120. package/src/discovery/patterns.ts +702 -0
  121. package/src/discovery/schema.ts +453 -0
  122. package/src/filesystem/images.ts +798 -0
  123. package/src/filesystem/index.ts +107 -0
  124. package/src/filesystem/reader.ts +452 -0
  125. package/src/filesystem/version-config.ts +390 -0
  126. package/src/filesystem/versions.ts +1339 -0
  127. package/src/filesystem/watcher.ts +226 -0
  128. package/src/filesystem/writer.ts +540 -0
  129. package/src/index.ts +61 -0
  130. package/src/integration.ts +228 -0
  131. package/src/server/assets.ts +254 -0
  132. package/src/server/cache.ts +355 -0
  133. package/src/server/index.ts +33 -0
  134. package/src/server/middleware.ts +209 -0
  135. package/src/server/routes.ts +1428 -0
  136. package/src/types/api.ts +61 -0
  137. package/src/types/config.ts +134 -0
  138. package/src/types/content.ts +64 -0
  139. package/src/types/image.ts +48 -0
  140. package/src/types/index.ts +58 -0
  141. package/src/types/version.ts +117 -0
@@ -0,0 +1,798 @@
1
+ /**
2
+ * @fileoverview Image handling for content collections
3
+ *
4
+ * This module provides functions for uploading and managing images
5
+ * in content collections with support for different storage strategies.
6
+ *
7
+ * ## Strategies:
8
+ * - colocated: Images stored alongside content files
9
+ * - public: Images stored in public directory
10
+ * - custom: User-defined storage paths
11
+ *
12
+ * @module @writenex/astro/filesystem/images
13
+ */
14
+
15
+ import { writeFile, mkdir, readdir, stat } from "node:fs/promises";
16
+ import { existsSync } from "node:fs";
17
+ import { join, dirname, basename, extname, relative } from "node:path";
18
+ import type {
19
+ ImageConfig,
20
+ DiscoveredImage,
21
+ ImageDiscoveryOptions,
22
+ ImageDiscoveryResult,
23
+ } from "@/types";
24
+ import { getContentFilePath } from "./reader";
25
+
26
+ /**
27
+ * Default image configuration
28
+ */
29
+ export const DEFAULT_IMAGE_CONFIG: ImageConfig = {
30
+ strategy: "colocated",
31
+ publicPath: "/images",
32
+ storagePath: "public/images",
33
+ };
34
+
35
+ /**
36
+ * Supported image extensions
37
+ */
38
+ const SUPPORTED_EXTENSIONS = new Set([
39
+ ".jpg",
40
+ ".jpeg",
41
+ ".png",
42
+ ".gif",
43
+ ".webp",
44
+ ".avif",
45
+ ".svg",
46
+ ]);
47
+
48
+ /**
49
+ * Result of image upload operation
50
+ */
51
+ export interface ImageUploadResult {
52
+ success: boolean;
53
+ /** Markdown-compatible path for the image */
54
+ path?: string;
55
+ /** Public URL for the image */
56
+ url?: string;
57
+ /** Error message if failed */
58
+ error?: string;
59
+ }
60
+
61
+ /**
62
+ * Options for image upload
63
+ */
64
+ export interface ImageUploadOptions {
65
+ /** Original filename */
66
+ filename: string;
67
+ /** Image binary data */
68
+ data: Buffer;
69
+ /** Collection name */
70
+ collection: string;
71
+ /** Content ID (slug) */
72
+ contentId: string;
73
+ /** Project root path */
74
+ projectRoot: string;
75
+ /** Image configuration */
76
+ config?: ImageConfig;
77
+ }
78
+
79
+ /**
80
+ * Validate image file
81
+ *
82
+ * @param filename - Original filename
83
+ * @returns True if valid image file
84
+ */
85
+ export function isValidImageFile(filename: string): boolean {
86
+ const ext = extname(filename).toLowerCase();
87
+ return SUPPORTED_EXTENSIONS.has(ext);
88
+ }
89
+
90
+ /**
91
+ * Generate a unique filename for uploaded image
92
+ *
93
+ * @param originalName - Original filename
94
+ * @param _contentId - Content ID for context (reserved for future use)
95
+ * @returns Unique filename
96
+ */
97
+ function generateUniqueFilename(
98
+ originalName: string,
99
+ _contentId: string
100
+ ): string {
101
+ const ext = extname(originalName).toLowerCase();
102
+ const baseName = basename(originalName, ext)
103
+ .toLowerCase()
104
+ .replace(/[^a-z0-9]/g, "-")
105
+ .replace(/-+/g, "-")
106
+ .substring(0, 50);
107
+
108
+ const timestamp = Date.now().toString(36);
109
+ return `${baseName}-${timestamp}${ext}`;
110
+ }
111
+
112
+ /**
113
+ * Get storage path for colocated strategy
114
+ *
115
+ * Images are stored in a folder named after the content file.
116
+ * The markdown path is calculated based on the content structure:
117
+ * - Folder-based (slug/index.md): ./filename (image in same folder as index.md)
118
+ * - Flat file (slug.md): ./slug/filename (image in sibling folder)
119
+ *
120
+ * @param projectRoot - Project root path
121
+ * @param collection - Collection name
122
+ * @param contentId - Content ID
123
+ * @param filename - Image filename
124
+ * @returns Absolute path to store the image and markdown-compatible path
125
+ */
126
+ function getColocatedPath(
127
+ projectRoot: string,
128
+ collection: string,
129
+ contentId: string,
130
+ filename: string
131
+ ): { storagePath: string; markdownPath: string } {
132
+ const collectionPath = join(projectRoot, "src/content", collection);
133
+ const imageDir = join(collectionPath, contentId);
134
+ const storagePath = join(imageDir, filename);
135
+
136
+ // Detect content structure to determine correct markdown path
137
+ // Check if content is folder-based (slug/index.md or slug/index.mdx)
138
+ const indexMdPath = join(collectionPath, contentId, "index.md");
139
+ const indexMdxPath = join(collectionPath, contentId, "index.mdx");
140
+ const isFolderBased = existsSync(indexMdPath) || existsSync(indexMdxPath);
141
+
142
+ // For folder-based: image is in same folder as index.md, so path is ./filename
143
+ // For flat file: image is in sibling folder, so path is ./contentId/filename
144
+ const markdownPath = isFolderBased
145
+ ? `./${filename}`
146
+ : `./${contentId}/${filename}`;
147
+
148
+ return { storagePath, markdownPath };
149
+ }
150
+
151
+ /**
152
+ * Get storage path for public strategy
153
+ *
154
+ * Images are stored in public/images/{collection}/{filename}
155
+ *
156
+ * @param projectRoot - Project root path
157
+ * @param collection - Collection name
158
+ * @param filename - Image filename
159
+ * @param config - Image configuration
160
+ * @returns Absolute path to store the image
161
+ */
162
+ function getPublicPath(
163
+ projectRoot: string,
164
+ collection: string,
165
+ filename: string,
166
+ config: ImageConfig
167
+ ): { storagePath: string; markdownPath: string; url: string } {
168
+ const storagePath = join(
169
+ projectRoot,
170
+ config.storagePath ?? "public/images",
171
+ collection,
172
+ filename
173
+ );
174
+ const publicPath = config.publicPath ?? "/images";
175
+ const url = `${publicPath}/${collection}/${filename}`;
176
+
177
+ return { storagePath, markdownPath: url, url };
178
+ }
179
+
180
+ /**
181
+ * Upload an image file
182
+ *
183
+ * @param options - Upload options
184
+ * @returns Upload result with paths
185
+ *
186
+ * @example
187
+ * ```typescript
188
+ * const result = await uploadImage({
189
+ * filename: "hero.jpg",
190
+ * data: imageBuffer,
191
+ * collection: "blog",
192
+ * contentId: "my-post",
193
+ * projectRoot: "/path/to/project",
194
+ * });
195
+ *
196
+ * if (result.success) {
197
+ * console.log(result.path); // "./my-post/hero-abc123.jpg"
198
+ * }
199
+ * ```
200
+ */
201
+ export async function uploadImage(
202
+ options: ImageUploadOptions
203
+ ): Promise<ImageUploadResult> {
204
+ const {
205
+ filename,
206
+ data,
207
+ collection,
208
+ contentId,
209
+ projectRoot,
210
+ config = DEFAULT_IMAGE_CONFIG,
211
+ } = options;
212
+
213
+ // Validate file
214
+ if (!isValidImageFile(filename)) {
215
+ return {
216
+ success: false,
217
+ error: `Invalid image file type. Supported: ${[...SUPPORTED_EXTENSIONS].join(", ")}`,
218
+ };
219
+ }
220
+
221
+ // Generate unique filename
222
+ const uniqueFilename = generateUniqueFilename(filename, contentId);
223
+
224
+ try {
225
+ let storagePath: string;
226
+ let markdownPath: string;
227
+ let url: string | undefined;
228
+
229
+ switch (config.strategy) {
230
+ case "public": {
231
+ const paths = getPublicPath(
232
+ projectRoot,
233
+ collection,
234
+ uniqueFilename,
235
+ config
236
+ );
237
+ storagePath = paths.storagePath;
238
+ markdownPath = paths.markdownPath;
239
+ url = paths.url;
240
+ break;
241
+ }
242
+
243
+ case "colocated":
244
+ default: {
245
+ const paths = getColocatedPath(
246
+ projectRoot,
247
+ collection,
248
+ contentId,
249
+ uniqueFilename
250
+ );
251
+ storagePath = paths.storagePath;
252
+ markdownPath = paths.markdownPath;
253
+ break;
254
+ }
255
+ }
256
+
257
+ // Ensure directory exists
258
+ const dir = dirname(storagePath);
259
+ if (!existsSync(dir)) {
260
+ await mkdir(dir, { recursive: true });
261
+ }
262
+
263
+ // Write file
264
+ await writeFile(storagePath, data);
265
+
266
+ return {
267
+ success: true,
268
+ path: markdownPath,
269
+ url: url ?? markdownPath,
270
+ };
271
+ } catch (error) {
272
+ const message = error instanceof Error ? error.message : "Unknown error";
273
+ return {
274
+ success: false,
275
+ error: `Failed to upload image: ${message}`,
276
+ };
277
+ }
278
+ }
279
+
280
+ /**
281
+ * Parse multipart form data for image upload
282
+ *
283
+ * Simple parser for multipart/form-data with single file upload.
284
+ * For production, consider using a proper multipart parser library.
285
+ *
286
+ * @param body - Raw request body
287
+ * @param contentType - Content-Type header
288
+ * @returns Parsed file data and fields
289
+ */
290
+ export function parseMultipartFormData(
291
+ body: Buffer,
292
+ contentType: string
293
+ ): {
294
+ file?: { filename: string; data: Buffer; contentType: string };
295
+ fields: Record<string, string>;
296
+ } {
297
+ const result: {
298
+ file?: { filename: string; data: Buffer; contentType: string };
299
+ fields: Record<string, string>;
300
+ } = { fields: {} };
301
+
302
+ // Extract boundary from content-type
303
+ const boundaryMatch = contentType.match(/boundary=(?:"([^"]+)"|([^;]+))/);
304
+ if (!boundaryMatch) {
305
+ return result;
306
+ }
307
+
308
+ const boundary = boundaryMatch[1] ?? boundaryMatch[2];
309
+ if (!boundary) {
310
+ return result;
311
+ }
312
+
313
+ const boundaryBuffer = Buffer.from(`--${boundary}`);
314
+ const parts = splitBuffer(body, boundaryBuffer);
315
+
316
+ for (const part of parts) {
317
+ // Skip empty parts and closing boundary
318
+ if (part.length < 10) continue;
319
+
320
+ // Find header/body separator (double CRLF)
321
+ const separatorIndex = part.indexOf("\r\n\r\n");
322
+ if (separatorIndex === -1) continue;
323
+
324
+ const headerSection = part.slice(0, separatorIndex).toString("utf-8");
325
+ const bodySection = part.slice(separatorIndex + 4);
326
+
327
+ // Remove trailing CRLF from body
328
+ const bodyEnd = bodySection.length - 2;
329
+ const cleanBody = bodyEnd > 0 ? bodySection.slice(0, bodyEnd) : bodySection;
330
+
331
+ // Parse headers
332
+ const headers = parseHeaders(headerSection);
333
+ const disposition = headers["content-disposition"];
334
+
335
+ if (!disposition) continue;
336
+
337
+ // Extract name and filename from Content-Disposition
338
+ const nameMatch = disposition.match(/name="([^"]+)"/);
339
+ const filenameMatch = disposition.match(/filename="([^"]+)"/);
340
+
341
+ if (filenameMatch) {
342
+ // This is a file field
343
+ result.file = {
344
+ filename: filenameMatch[1] ?? "unknown",
345
+ data: cleanBody,
346
+ contentType: headers["content-type"] ?? "application/octet-stream",
347
+ };
348
+ } else if (nameMatch) {
349
+ // This is a regular field
350
+ result.fields[nameMatch[1] ?? ""] = cleanBody.toString("utf-8");
351
+ }
352
+ }
353
+
354
+ return result;
355
+ }
356
+
357
+ /**
358
+ * Split buffer by delimiter
359
+ */
360
+ function splitBuffer(buffer: Buffer, delimiter: Buffer): Buffer[] {
361
+ const parts: Buffer[] = [];
362
+ let start = 0;
363
+ let index: number;
364
+
365
+ while ((index = buffer.indexOf(delimiter, start)) !== -1) {
366
+ if (index > start) {
367
+ parts.push(buffer.slice(start, index));
368
+ }
369
+ start = index + delimiter.length;
370
+ }
371
+
372
+ if (start < buffer.length) {
373
+ parts.push(buffer.slice(start));
374
+ }
375
+
376
+ return parts;
377
+ }
378
+
379
+ /**
380
+ * Parse header section into key-value pairs
381
+ */
382
+ function parseHeaders(headerSection: string): Record<string, string> {
383
+ const headers: Record<string, string> = {};
384
+ const lines = headerSection.split("\r\n");
385
+
386
+ for (const line of lines) {
387
+ const colonIndex = line.indexOf(":");
388
+ if (colonIndex > 0) {
389
+ const key = line.slice(0, colonIndex).trim().toLowerCase();
390
+ const value = line.slice(colonIndex + 1).trim();
391
+ headers[key] = value;
392
+ }
393
+ }
394
+
395
+ return headers;
396
+ }
397
+
398
+ // =============================================================================
399
+ // Image Discovery Functions
400
+ // =============================================================================
401
+
402
+ /**
403
+ * Content structure type detected from file path
404
+ */
405
+ export type ContentStructure = "flat" | "folder-based" | "date-prefixed";
406
+
407
+ /**
408
+ * Result of content structure detection
409
+ */
410
+ export interface ContentStructureResult {
411
+ /** Detected structure type */
412
+ structure: ContentStructure;
413
+ /** Path to the image folder (null if doesn't exist) */
414
+ imageFolderPath: string | null;
415
+ }
416
+
417
+ /**
418
+ * Date prefix pattern for content files (e.g., 2024-01-15-my-post.md)
419
+ */
420
+ const DATE_PREFIX_PATTERN = /^\d{4}-\d{2}-\d{2}-/;
421
+
422
+ /**
423
+ * Detect content structure and get the image folder path
424
+ *
425
+ * Handles three content structures:
426
+ * - Flat file: `my-post.md` -> looks for `my-post/` sibling folder
427
+ * - Folder-based: `slug/index.md` -> uses `slug/` parent folder
428
+ * - Date-prefixed: `2024-01-15-my-post.md` -> looks for `2024-01-15-my-post/` sibling folder
429
+ *
430
+ * @param collectionPath - Absolute path to the collection directory
431
+ * @param contentId - Content ID (slug)
432
+ * @param contentFilePath - Absolute path to the content file
433
+ * @returns Path to the image folder, or null if no image folder exists
434
+ *
435
+ * @example
436
+ * ```typescript
437
+ * // Flat file structure
438
+ * const folder = getContentImageFolder(
439
+ * '/project/src/content/blog',
440
+ * 'my-post',
441
+ * '/project/src/content/blog/my-post.md'
442
+ * );
443
+ * // Returns: '/project/src/content/blog/my-post' (if exists)
444
+ *
445
+ * // Folder-based structure
446
+ * const folder = getContentImageFolder(
447
+ * '/project/src/content/blog',
448
+ * 'my-post',
449
+ * '/project/src/content/blog/my-post/index.md'
450
+ * );
451
+ * // Returns: '/project/src/content/blog/my-post'
452
+ * ```
453
+ */
454
+ export function getContentImageFolder(
455
+ collectionPath: string,
456
+ contentId: string,
457
+ contentFilePath: string
458
+ ): string | null {
459
+ const filename = basename(contentFilePath);
460
+ const contentDir = dirname(contentFilePath);
461
+
462
+ // Check for folder-based structure (index.md or index.mdx)
463
+ if (filename === "index.md" || filename === "index.mdx") {
464
+ // For folder-based content, the image folder is the parent folder itself
465
+ // The folder already exists since it contains the index file
466
+ return contentDir;
467
+ }
468
+
469
+ // For flat file and date-prefixed structures, look for sibling folder
470
+ // Both use the contentId as the folder name
471
+ const siblingFolderPath = join(collectionPath, contentId);
472
+
473
+ if (existsSync(siblingFolderPath)) {
474
+ return siblingFolderPath;
475
+ }
476
+
477
+ return null;
478
+ }
479
+
480
+ /**
481
+ * Detect the content structure type from a content file path
482
+ *
483
+ * @param contentFilePath - Absolute path to the content file
484
+ * @returns The detected content structure type
485
+ */
486
+ export function detectContentStructure(
487
+ contentFilePath: string
488
+ ): ContentStructure {
489
+ const filename = basename(contentFilePath);
490
+
491
+ // Check for folder-based structure
492
+ if (filename === "index.md" || filename === "index.mdx") {
493
+ return "folder-based";
494
+ }
495
+
496
+ // Check for date-prefixed structure
497
+ const nameWithoutExt = filename.replace(/\.(md|mdx)$/, "");
498
+ if (DATE_PREFIX_PATTERN.test(nameWithoutExt)) {
499
+ return "date-prefixed";
500
+ }
501
+
502
+ // Default to flat file structure
503
+ return "flat";
504
+ }
505
+
506
+ // getContentFilePath is imported from ./reader
507
+
508
+ // =============================================================================
509
+ // Recursive Image Scanner
510
+ // =============================================================================
511
+
512
+ /**
513
+ * Options for recursive directory scanning
514
+ */
515
+ interface ScanOptions {
516
+ /** Maximum recursion depth */
517
+ maxDepth: number;
518
+ /** Current recursion depth */
519
+ currentDepth: number;
520
+ /** Base path for calculating relative paths */
521
+ basePath: string;
522
+ }
523
+
524
+ /**
525
+ * Check if a directory should be skipped during scanning
526
+ *
527
+ * Skips hidden folders (starting with .) and special folders (starting with _)
528
+ *
529
+ * @param dirName - Directory name to check
530
+ * @returns True if the directory should be skipped
531
+ */
532
+ function shouldSkipDirectory(dirName: string): boolean {
533
+ return dirName.startsWith(".") || dirName.startsWith("_");
534
+ }
535
+
536
+ /**
537
+ * Scan a directory recursively for image files
538
+ *
539
+ * Recursively scans the given directory for image files with supported extensions.
540
+ * Skips hidden folders (starting with .) and special folders (starting with _).
541
+ * Limits recursion to the specified maxDepth.
542
+ *
543
+ * @param dirPath - Absolute path to the directory to scan
544
+ * @param basePath - Base path for calculating relative paths
545
+ * @param options - Scan options including maxDepth and currentDepth
546
+ * @returns Array of discovered images
547
+ *
548
+ * @example
549
+ * ```typescript
550
+ * const images = await scanDirectoryForImages(
551
+ * '/project/src/content/blog/my-post',
552
+ * '/project/src/content/blog/my-post',
553
+ * { maxDepth: 5, currentDepth: 0, basePath: '/project/src/content/blog/my-post' }
554
+ * );
555
+ * ```
556
+ */
557
+ export async function scanDirectoryForImages(
558
+ dirPath: string,
559
+ basePath: string,
560
+ options: ScanOptions
561
+ ): Promise<DiscoveredImage[]> {
562
+ const { maxDepth, currentDepth } = options;
563
+
564
+ // Stop if we've exceeded max depth
565
+ if (currentDepth >= maxDepth) {
566
+ return [];
567
+ }
568
+
569
+ // Check if directory exists
570
+ if (!existsSync(dirPath)) {
571
+ return [];
572
+ }
573
+
574
+ const images: DiscoveredImage[] = [];
575
+
576
+ try {
577
+ const entries = await readdir(dirPath, { withFileTypes: true });
578
+
579
+ for (const entry of entries) {
580
+ const entryPath = join(dirPath, entry.name);
581
+
582
+ if (entry.isDirectory()) {
583
+ // Skip hidden and special folders
584
+ if (shouldSkipDirectory(entry.name)) {
585
+ continue;
586
+ }
587
+
588
+ // Recursively scan subdirectory
589
+ const subImages = await scanDirectoryForImages(entryPath, basePath, {
590
+ maxDepth,
591
+ currentDepth: currentDepth + 1,
592
+ basePath,
593
+ });
594
+ images.push(...subImages);
595
+ } else if (entry.isFile()) {
596
+ // Check if it's a valid image file
597
+ if (isValidImageFile(entry.name)) {
598
+ const fileStat = await stat(entryPath);
599
+ const extension = extname(entry.name).toLowerCase();
600
+
601
+ // Calculate relative path from base path
602
+ const relativePath = calculateRelativePathFromBase(
603
+ basePath,
604
+ entryPath
605
+ );
606
+
607
+ images.push({
608
+ filename: entry.name,
609
+ relativePath,
610
+ absolutePath: entryPath,
611
+ size: fileStat.size,
612
+ extension,
613
+ });
614
+ }
615
+ }
616
+ }
617
+ } catch {
618
+ // Return empty array on error (e.g., permission denied)
619
+ return [];
620
+ }
621
+
622
+ return images;
623
+ }
624
+
625
+ /**
626
+ * Calculate relative path from base path to target path
627
+ *
628
+ * @param basePath - Base path (image folder root)
629
+ * @param targetPath - Target path (image file)
630
+ * @returns Relative path starting with ./
631
+ */
632
+ function calculateRelativePathFromBase(
633
+ basePath: string,
634
+ targetPath: string
635
+ ): string {
636
+ const relPath = relative(basePath, targetPath);
637
+ return `./${relPath}`;
638
+ }
639
+
640
+ /**
641
+ * Calculate relative path from content file to image file
642
+ *
643
+ * Calculates the path that can be used in markdown to reference an image
644
+ * relative to the content file's location. The path always starts with ./
645
+ * to ensure it's treated as a relative reference.
646
+ *
647
+ * @param contentFilePath - Absolute path to the content file (e.g., /project/src/content/blog/my-post.md)
648
+ * @param imagePath - Absolute path to the image file (e.g., /project/src/content/blog/my-post/images/hero.jpg)
649
+ * @returns Relative path starting with ./ (e.g., ./my-post/images/hero.jpg)
650
+ *
651
+ * @example
652
+ * ```typescript
653
+ * // Flat file structure
654
+ * const relPath = calculateRelativePath(
655
+ * '/project/src/content/blog/my-post.md',
656
+ * '/project/src/content/blog/my-post/hero.jpg'
657
+ * );
658
+ * // Returns: './my-post/hero.jpg'
659
+ *
660
+ * // Folder-based structure
661
+ * const relPath = calculateRelativePath(
662
+ * '/project/src/content/blog/my-post/index.md',
663
+ * '/project/src/content/blog/my-post/images/hero.jpg'
664
+ * );
665
+ * // Returns: './images/hero.jpg'
666
+ *
667
+ * // Nested subfolder
668
+ * const relPath = calculateRelativePath(
669
+ * '/project/src/content/blog/my-post/index.md',
670
+ * '/project/src/content/blog/my-post/assets/photos/hero.jpg'
671
+ * );
672
+ * // Returns: './assets/photos/hero.jpg'
673
+ * ```
674
+ */
675
+ export function calculateRelativePath(
676
+ contentFilePath: string,
677
+ imagePath: string
678
+ ): string {
679
+ // Get the directory containing the content file
680
+ const contentDir = dirname(contentFilePath);
681
+
682
+ // Calculate relative path from content directory to image
683
+ const relPath = relative(contentDir, imagePath);
684
+
685
+ // Ensure path starts with ./ for relative references
686
+ // The relative() function may return paths without ./ prefix
687
+ if (relPath.startsWith("..")) {
688
+ // Path goes up directories - keep as is but ensure ./ prefix
689
+ return relPath;
690
+ }
691
+
692
+ // For paths in same or child directories, ensure ./ prefix
693
+ return `./${relPath}`;
694
+ }
695
+
696
+ // =============================================================================
697
+ // Main Discovery Function
698
+ // =============================================================================
699
+
700
+ /**
701
+ * Default maximum recursion depth for image discovery
702
+ */
703
+ const DEFAULT_MAX_DEPTH = 5;
704
+
705
+ /**
706
+ * Discover all images associated with a content item
707
+ *
708
+ * This is the main entry point for image discovery. It:
709
+ * 1. Locates the content file using getContentFilePath
710
+ * 2. Determines the image folder using getContentImageFolder
711
+ * 3. Scans the folder recursively using scanDirectoryForImages
712
+ * 4. Calculates relative paths for all discovered images
713
+ *
714
+ * @param collectionPath - Absolute path to the collection directory
715
+ * @param contentId - Content ID (slug)
716
+ * @param options - Optional discovery options
717
+ * @returns ImageDiscoveryResult with discovered images or error
718
+ *
719
+ * @example
720
+ * ```typescript
721
+ * const result = await discoverContentImages(
722
+ * '/project/src/content/blog',
723
+ * 'my-post',
724
+ * { maxDepth: 3 }
725
+ * );
726
+ *
727
+ * if (result.success) {
728
+ * console.log(`Found ${result.images.length} images`);
729
+ * for (const img of result.images) {
730
+ * console.log(`- ${img.relativePath}`);
731
+ * }
732
+ * }
733
+ * ```
734
+ */
735
+ export async function discoverContentImages(
736
+ collectionPath: string,
737
+ contentId: string,
738
+ options?: ImageDiscoveryOptions
739
+ ): Promise<ImageDiscoveryResult> {
740
+ const maxDepth = options?.maxDepth ?? DEFAULT_MAX_DEPTH;
741
+
742
+ try {
743
+ // Step 1: Get content file path
744
+ const contentFilePath = getContentFilePath(collectionPath, contentId);
745
+
746
+ if (!contentFilePath) {
747
+ return {
748
+ success: false,
749
+ images: [],
750
+ error: `Content '${contentId}' not found in collection`,
751
+ };
752
+ }
753
+
754
+ // Step 2: Determine image folder
755
+ const imageFolderPath = getContentImageFolder(
756
+ collectionPath,
757
+ contentId,
758
+ contentFilePath
759
+ );
760
+
761
+ // If no image folder exists, return empty array (not an error per Requirement 1.4)
762
+ if (!imageFolderPath) {
763
+ return {
764
+ success: true,
765
+ images: [],
766
+ };
767
+ }
768
+
769
+ // Step 3: Scan folder for images
770
+ const scannedImages = await scanDirectoryForImages(
771
+ imageFolderPath,
772
+ imageFolderPath,
773
+ {
774
+ maxDepth,
775
+ currentDepth: 0,
776
+ basePath: imageFolderPath,
777
+ }
778
+ );
779
+
780
+ // Step 4: Calculate relative paths from content file to each image
781
+ const images: DiscoveredImage[] = scannedImages.map((img) => ({
782
+ ...img,
783
+ relativePath: calculateRelativePath(contentFilePath, img.absolutePath),
784
+ }));
785
+
786
+ return {
787
+ success: true,
788
+ images,
789
+ };
790
+ } catch (error) {
791
+ const message = error instanceof Error ? error.message : "Unknown error";
792
+ return {
793
+ success: false,
794
+ images: [],
795
+ error: `Failed to discover images: ${message}`,
796
+ };
797
+ }
798
+ }