@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.
- package/README.md +539 -0
- package/dist/chunk-5PM6EQE5.js +151 -0
- package/dist/chunk-5PM6EQE5.js.map +1 -0
- package/dist/chunk-7XU5X6CW.js +1331 -0
- package/dist/chunk-7XU5X6CW.js.map +1 -0
- package/dist/chunk-AAOQHQPU.js +574 -0
- package/dist/chunk-AAOQHQPU.js.map +1 -0
- package/dist/chunk-CF2XXJFF.js +1410 -0
- package/dist/chunk-CF2XXJFF.js.map +1 -0
- package/dist/chunk-CRPZUUDU.js +52 -0
- package/dist/chunk-CRPZUUDU.js.map +1 -0
- package/dist/chunk-CYLDJ3HZ.js +310 -0
- package/dist/chunk-CYLDJ3HZ.js.map +1 -0
- package/dist/chunk-KIKIPIFA.js +1 -0
- package/dist/chunk-KIKIPIFA.js.map +1 -0
- package/dist/chunk-XNTQTTJU.js +145 -0
- package/dist/chunk-XNTQTTJU.js.map +1 -0
- package/dist/client/index.css +2 -0
- package/dist/client/index.css.map +1 -0
- package/dist/client/index.js +375 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/styles.css +584 -0
- package/dist/client/variables.css +304 -0
- package/dist/config/index.d.ts +54 -0
- package/dist/config/index.js +38 -0
- package/dist/config/index.js.map +1 -0
- package/dist/config-BmEdBDo_.d.ts +220 -0
- package/dist/content-BWR52vD-.d.ts +64 -0
- package/dist/discovery/index.d.ts +310 -0
- package/dist/discovery/index.js +38 -0
- package/dist/discovery/index.js.map +1 -0
- package/dist/errors-C0iYiDTv.d.ts +107 -0
- package/dist/filesystem/index.d.ts +1292 -0
- package/dist/filesystem/index.js +203 -0
- package/dist/filesystem/index.js.map +1 -0
- package/dist/image-FP7w5ZIs.d.ts +47 -0
- package/dist/index.d.ts +64 -0
- package/dist/index.js +151 -0
- package/dist/index.js.map +1 -0
- package/dist/loader-55LWCXHA.js +12 -0
- package/dist/loader-55LWCXHA.js.map +1 -0
- package/dist/loader-CrdnaAWR.d.ts +327 -0
- package/dist/server/index.d.ts +357 -0
- package/dist/server/index.js +37 -0
- package/dist/server/index.js.map +1 -0
- package/package.json +94 -0
- package/src/client/App.tsx +900 -0
- package/src/client/components/ConfigPanel/ConfigPanel.css +553 -0
- package/src/client/components/ConfigPanel/ConfigPanel.tsx +396 -0
- package/src/client/components/ConfigPanel/index.ts +6 -0
- package/src/client/components/CreateContentModal/CreateContentModal.css +327 -0
- package/src/client/components/CreateContentModal/CreateContentModal.tsx +216 -0
- package/src/client/components/CreateContentModal/index.ts +7 -0
- package/src/client/components/Editor/Editor.css +885 -0
- package/src/client/components/Editor/Editor.tsx +484 -0
- package/src/client/components/Editor/ImageDialog.css +344 -0
- package/src/client/components/Editor/ImageDialog.tsx +367 -0
- package/src/client/components/Editor/LinkDialog.css +326 -0
- package/src/client/components/Editor/LinkDialog.tsx +332 -0
- package/src/client/components/Editor/index.ts +6 -0
- package/src/client/components/FrontmatterForm/FrontmatterForm.css +468 -0
- package/src/client/components/FrontmatterForm/FrontmatterForm.tsx +914 -0
- package/src/client/components/FrontmatterForm/index.ts +7 -0
- package/src/client/components/Header/Header.css +300 -0
- package/src/client/components/Header/Header.tsx +300 -0
- package/src/client/components/Header/index.ts +7 -0
- package/src/client/components/KeyboardShortcuts/KeyboardShortcuts.css +239 -0
- package/src/client/components/KeyboardShortcuts/KeyboardShortcuts.tsx +151 -0
- package/src/client/components/KeyboardShortcuts/index.ts +6 -0
- package/src/client/components/LazyEditor.tsx +75 -0
- package/src/client/components/LiveRegion/LiveRegion.css +19 -0
- package/src/client/components/LiveRegion/LiveRegion.tsx +60 -0
- package/src/client/components/LiveRegion/index.ts +7 -0
- package/src/client/components/SearchReplace/SearchReplacePanel.css +300 -0
- package/src/client/components/SearchReplace/SearchReplacePanel.tsx +332 -0
- package/src/client/components/SearchReplace/index.ts +7 -0
- package/src/client/components/SelectCollectionModal/SelectCollectionModal.css +308 -0
- package/src/client/components/SelectCollectionModal/SelectCollectionModal.tsx +223 -0
- package/src/client/components/SelectCollectionModal/index.ts +7 -0
- package/src/client/components/Sidebar/Sidebar.css +570 -0
- package/src/client/components/Sidebar/Sidebar.tsx +617 -0
- package/src/client/components/Sidebar/index.ts +7 -0
- package/src/client/components/SkipLink/SkipLink.css +51 -0
- package/src/client/components/SkipLink/SkipLink.tsx +67 -0
- package/src/client/components/SkipLink/index.ts +7 -0
- package/src/client/components/UnsavedChangesModal/UnsavedChangesModal.css +233 -0
- package/src/client/components/UnsavedChangesModal/UnsavedChangesModal.tsx +160 -0
- package/src/client/components/UnsavedChangesModal/index.ts +1 -0
- package/src/client/components/VersionHistory/DiffViewer.css +430 -0
- package/src/client/components/VersionHistory/DiffViewer.tsx +383 -0
- package/src/client/components/VersionHistory/VersionActions.css +318 -0
- package/src/client/components/VersionHistory/VersionActions.tsx +277 -0
- package/src/client/components/VersionHistory/VersionHistoryPanel.css +369 -0
- package/src/client/components/VersionHistory/VersionHistoryPanel.tsx +469 -0
- package/src/client/components/VersionHistory/index.ts +9 -0
- package/src/client/context/ApiContext.tsx +154 -0
- package/src/client/context/ThemeContext.tsx +172 -0
- package/src/client/hooks/useAnnounce.ts +201 -0
- package/src/client/hooks/useApi.ts +374 -0
- package/src/client/hooks/useArrowNavigation.ts +286 -0
- package/src/client/hooks/useAutosave.ts +241 -0
- package/src/client/hooks/useFocusTrap.ts +178 -0
- package/src/client/hooks/useKeyboardShortcuts.ts +203 -0
- package/src/client/hooks/useSearch.ts +206 -0
- package/src/client/hooks/useVersionHistory.ts +451 -0
- package/src/client/index.tsx +70 -0
- package/src/client/styles.css +584 -0
- package/src/client/utils/focus.ts +57 -0
- package/src/client/utils/openInEditor.ts +130 -0
- package/src/client/variables.css +304 -0
- package/src/config/defaults.ts +109 -0
- package/src/config/index.ts +32 -0
- package/src/config/loader.ts +174 -0
- package/src/config/schema.ts +161 -0
- package/src/core/constants.ts +39 -0
- package/src/core/errors.ts +739 -0
- package/src/core/index.ts +11 -0
- package/src/discovery/collections.ts +216 -0
- package/src/discovery/index.ts +33 -0
- package/src/discovery/patterns.ts +702 -0
- package/src/discovery/schema.ts +453 -0
- package/src/filesystem/images.ts +798 -0
- package/src/filesystem/index.ts +107 -0
- package/src/filesystem/reader.ts +452 -0
- package/src/filesystem/version-config.ts +390 -0
- package/src/filesystem/versions.ts +1339 -0
- package/src/filesystem/watcher.ts +226 -0
- package/src/filesystem/writer.ts +540 -0
- package/src/index.ts +61 -0
- package/src/integration.ts +228 -0
- package/src/server/assets.ts +254 -0
- package/src/server/cache.ts +355 -0
- package/src/server/index.ts +33 -0
- package/src/server/middleware.ts +209 -0
- package/src/server/routes.ts +1428 -0
- package/src/types/api.ts +61 -0
- package/src/types/config.ts +134 -0
- package/src/types/content.ts +64 -0
- package/src/types/image.ts +48 -0
- package/src/types/index.ts +58 -0
- 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
|
+
}
|