@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,1428 @@
1
+ /**
2
+ * @fileoverview API route handlers for Writenex
3
+ *
4
+ * This module provides the API router that handles CRUD operations
5
+ * for content collections.
6
+ *
7
+ * ## API Endpoints:
8
+ * - GET /api/collections - List all collections
9
+ * - GET /api/content/:collection - List content in collection
10
+ * - GET /api/content/:collection/:id - Get single content item
11
+ * - POST /api/content/:collection - Create new content
12
+ * - PUT /api/content/:collection/:id - Update content
13
+ * - DELETE /api/content/:collection/:id - Delete content
14
+ * - GET /api/images/:collection/:contentId - Discover images for content
15
+ * - GET /api/images/:collection/:contentId/* - Serve image file
16
+ * - POST /api/images - Upload image
17
+ * - GET /api/versions/:collection/:id - List versions
18
+ * - GET /api/versions/:collection/:id/:versionId - Get version
19
+ * - POST /api/versions/:collection/:id - Create manual version
20
+ * - POST /api/versions/:collection/:id/:versionId/restore - Restore version
21
+ * - GET /api/versions/:collection/:id/:versionId/diff - Get diff data
22
+ * - DELETE /api/versions/:collection/:id/:versionId - Delete version
23
+ * - DELETE /api/versions/:collection/:id - Clear all versions
24
+ *
25
+ * @module @writenex/astro/server/routes
26
+ */
27
+
28
+ import type { IncomingMessage, ServerResponse } from "node:http";
29
+ import { createReadStream, existsSync, statSync } from "node:fs";
30
+ import { join, extname } from "node:path";
31
+ import type { MiddlewareContext } from "./middleware";
32
+ import {
33
+ sendJson,
34
+ sendError,
35
+ sendWritenexError,
36
+ parseQueryParams,
37
+ parseJsonBody,
38
+ } from "./middleware";
39
+ import {
40
+ ApiBadRequestError,
41
+ ApiMethodNotAllowedError,
42
+ CollectionNotFoundError,
43
+ CollectionDiscoveryError,
44
+ ContentNotFoundError,
45
+ ImageInvalidTypeError,
46
+ ImageNotFoundError,
47
+ PathTraversalError,
48
+ VersionNotFoundError,
49
+ isWritenexError,
50
+ wrapError,
51
+ WritenexErrorCode,
52
+ } from "@/core/errors";
53
+ import { getCache } from "./cache";
54
+ import { discoverCollections, mergeCollections } from "@/discovery/collections";
55
+ import { getCollectionSummaries, readContentFile } from "@/filesystem/reader";
56
+ import {
57
+ createContent,
58
+ updateContent,
59
+ deleteContent,
60
+ getContentFilePath,
61
+ } from "@/filesystem/writer";
62
+ import {
63
+ uploadImage,
64
+ parseMultipartFormData,
65
+ isValidImageFile,
66
+ discoverContentImages,
67
+ } from "@/filesystem/images";
68
+ import {
69
+ getVersions,
70
+ getVersion,
71
+ saveVersion,
72
+ restoreVersion,
73
+ deleteVersion,
74
+ clearVersions,
75
+ } from "@/filesystem/versions";
76
+ import type { VersionHistoryConfig } from "@/types";
77
+
78
+ /**
79
+ * API route handler function type
80
+ */
81
+ type RouteHandler = (
82
+ req: IncomingMessage,
83
+ res: ServerResponse,
84
+ params: RouteParams,
85
+ context: MiddlewareContext
86
+ ) => Promise<void>;
87
+
88
+ /**
89
+ * Route parameters extracted from URL
90
+ */
91
+ interface RouteParams {
92
+ collection?: string;
93
+ id?: string;
94
+ versionId?: string;
95
+ query: Record<string, string>;
96
+ }
97
+
98
+ /**
99
+ * Create the API router
100
+ *
101
+ * @param context - Middleware context
102
+ * @returns Router function that handles API requests
103
+ */
104
+ export function createApiRouter(
105
+ context: MiddlewareContext
106
+ ): (req: IncomingMessage, res: ServerResponse, path: string) => Promise<void> {
107
+ return async (req, res, path) => {
108
+ const method = req.method?.toUpperCase() ?? "GET";
109
+ const query = parseQueryParams(req.url ?? "");
110
+
111
+ // Strip query string from path before parsing segments
112
+ const pathWithoutQuery = path.split("?")[0] ?? path;
113
+
114
+ // Parse route segments
115
+ const segments = pathWithoutQuery.split("/").filter(Boolean);
116
+ const params: RouteParams = { query };
117
+
118
+ // Route: /collections
119
+ if (segments[0] === "collections") {
120
+ if (method === "GET") {
121
+ return handleGetCollections(req, res, params, context);
122
+ }
123
+ return sendWritenexError(
124
+ res,
125
+ new ApiMethodNotAllowedError(method, ["GET"])
126
+ );
127
+ }
128
+
129
+ // Route: /config or /config/path
130
+ if (segments[0] === "config") {
131
+ if (method === "GET") {
132
+ if (segments[1] === "path") {
133
+ return handleGetConfigPath(req, res, params, context);
134
+ }
135
+ return handleGetConfig(req, res, params, context);
136
+ }
137
+ return sendWritenexError(
138
+ res,
139
+ new ApiMethodNotAllowedError(method, ["GET"])
140
+ );
141
+ }
142
+
143
+ // Route: /content/:collection/:id?
144
+ if (segments[0] === "content") {
145
+ params.collection = segments[1];
146
+ params.id = segments[2];
147
+
148
+ switch (method) {
149
+ case "GET":
150
+ if (params.id) {
151
+ return handleGetContent(req, res, params, context);
152
+ }
153
+ return handleListContent(req, res, params, context);
154
+ case "POST":
155
+ return handleCreateContent(req, res, params, context);
156
+ case "PUT":
157
+ return handleUpdateContent(req, res, params, context);
158
+ case "DELETE":
159
+ return handleDeleteContent(req, res, params, context);
160
+ default:
161
+ return sendWritenexError(
162
+ res,
163
+ new ApiMethodNotAllowedError(method, [
164
+ "GET",
165
+ "POST",
166
+ "PUT",
167
+ "DELETE",
168
+ ])
169
+ );
170
+ }
171
+ }
172
+
173
+ // Route: /images/:collection/:contentId - Image discovery
174
+ // Route: /images/:collection/:contentId/* - Serve image file
175
+ if (segments[0] === "images") {
176
+ params.collection = segments[1];
177
+ params.id = segments[2];
178
+
179
+ // Check if this is a file request (has more segments after contentId)
180
+ if (
181
+ method === "GET" &&
182
+ params.collection &&
183
+ params.id &&
184
+ segments.length > 3
185
+ ) {
186
+ // Serve image file: /images/:collection/:contentId/path/to/image.jpg
187
+ const imagePath = segments.slice(3).join("/");
188
+ return handleServeImage(req, res, params, imagePath, context);
189
+ }
190
+
191
+ if (method === "GET" && params.collection && params.id) {
192
+ return handleImageDiscovery(req, res, params, context);
193
+ }
194
+ if (method === "POST") {
195
+ return handleImageUpload(req, res, params, context);
196
+ }
197
+ return sendWritenexError(
198
+ res,
199
+ new ApiMethodNotAllowedError(method, ["GET", "POST"])
200
+ );
201
+ }
202
+
203
+ // Route: /versions/:collection/:id/:versionId?
204
+ if (segments[0] === "versions") {
205
+ params.collection = segments[1];
206
+ params.id = segments[2];
207
+ params.versionId = segments[3];
208
+
209
+ // Check for special action routes (restore, diff)
210
+ const action = segments[4];
211
+
212
+ switch (method) {
213
+ case "GET":
214
+ if (params.versionId) {
215
+ // Check if this is a diff request
216
+ if (action === "diff") {
217
+ return handleGetVersionDiff(req, res, params, context);
218
+ }
219
+ return handleGetVersion(req, res, params, context);
220
+ }
221
+ return handleListVersions(req, res, params, context);
222
+ case "POST":
223
+ if (params.versionId && action === "restore") {
224
+ return handleRestoreVersion(req, res, params, context);
225
+ }
226
+ if (!params.versionId) {
227
+ return handleCreateVersion(req, res, params, context);
228
+ }
229
+ return sendWritenexError(
230
+ res,
231
+ new ApiMethodNotAllowedError(method, ["GET", "POST", "DELETE"])
232
+ );
233
+ case "DELETE":
234
+ if (params.versionId) {
235
+ return handleDeleteVersion(req, res, params, context);
236
+ }
237
+ return handleClearVersions(req, res, params, context);
238
+ default:
239
+ return sendWritenexError(
240
+ res,
241
+ new ApiMethodNotAllowedError(method, ["GET", "POST", "DELETE"])
242
+ );
243
+ }
244
+ }
245
+
246
+ // Route: /health (for testing)
247
+ if (segments[0] === "health") {
248
+ return sendJson(res, {
249
+ status: "ok",
250
+ timestamp: new Date().toISOString(),
251
+ });
252
+ }
253
+
254
+ // Unknown route
255
+ return sendError(res, "Not found", 404);
256
+ };
257
+ }
258
+
259
+ /**
260
+ * GET /api/config - Get current configuration
261
+ *
262
+ * Returns the current Writenex configuration including image settings
263
+ * and Astro's trailingSlash setting for preview URLs.
264
+ */
265
+ const handleGetConfig: RouteHandler = async (_req, res, _params, context) => {
266
+ const { config, trailingSlash } = context;
267
+
268
+ sendJson(res, {
269
+ images: config.images,
270
+ editor: config.editor,
271
+ trailingSlash,
272
+ });
273
+ };
274
+
275
+ /**
276
+ * GET /api/config/path - Get config file path
277
+ *
278
+ * Returns the absolute path to the configuration file for opening in editor.
279
+ * Also returns the project root for reference.
280
+ */
281
+ const handleGetConfigPath: RouteHandler = async (
282
+ _req,
283
+ res,
284
+ _params,
285
+ context
286
+ ) => {
287
+ const { projectRoot } = context;
288
+
289
+ // Import findConfigFile from config loader
290
+ const { findConfigFile } = await import("@/config/loader");
291
+ const configPath = findConfigFile(projectRoot);
292
+
293
+ sendJson(res, {
294
+ configPath,
295
+ projectRoot,
296
+ hasConfigFile: configPath !== null,
297
+ });
298
+ };
299
+
300
+ /**
301
+ * GET /api/collections - List all collections
302
+ *
303
+ * Returns discovered and configured collections with metadata.
304
+ * Results are cached for performance.
305
+ */
306
+ const handleGetCollections: RouteHandler = async (
307
+ _req,
308
+ res,
309
+ _params,
310
+ context
311
+ ) => {
312
+ const { config, projectRoot } = context;
313
+ const cache = getCache();
314
+
315
+ try {
316
+ // Try to get from cache first
317
+ let collections = cache.getCollections();
318
+
319
+ if (!collections) {
320
+ // Cache miss - discover and merge collections
321
+ const discovered = await discoverCollections(projectRoot);
322
+ collections = mergeCollections(discovered, config.collections);
323
+
324
+ // Store in cache
325
+ cache.setCollections(collections);
326
+ }
327
+
328
+ sendJson(res, { collections });
329
+ } catch (error) {
330
+ const wrappedError = isWritenexError(error)
331
+ ? error
332
+ : new CollectionDiscoveryError(
333
+ join(projectRoot, "src/content"),
334
+ error instanceof Error ? error : undefined
335
+ );
336
+ sendWritenexError(res, wrappedError);
337
+ }
338
+ };
339
+
340
+ /**
341
+ * GET /api/content/:collection - List content in collection
342
+ *
343
+ * Query params:
344
+ * - draft: Include drafts (default: false)
345
+ * - sort: Sort field (default: pubDate)
346
+ * - order: Sort order (asc/desc, default: desc)
347
+ *
348
+ * Results are cached for performance.
349
+ */
350
+ const handleListContent: RouteHandler = async (_req, res, params, context) => {
351
+ const { collection, query } = params;
352
+ const { projectRoot } = context;
353
+
354
+ if (!collection) {
355
+ return sendWritenexError(
356
+ res,
357
+ new ApiBadRequestError("Collection name required")
358
+ );
359
+ }
360
+
361
+ const cache = getCache();
362
+
363
+ try {
364
+ const collectionPath = join(projectRoot, "src/content", collection);
365
+
366
+ // Check if collection exists
367
+ if (!existsSync(collectionPath)) {
368
+ return sendWritenexError(res, new CollectionNotFoundError(collection));
369
+ }
370
+
371
+ // Parse query parameters
372
+ const includeDrafts = query.draft === "true";
373
+ const sortBy = query.sort ?? "pubDate";
374
+ const sortOrder = (query.order as "asc" | "desc") ?? "desc";
375
+
376
+ // Try to get from cache first (only for default queries)
377
+ // We cache the "all content" query (includeDrafts=true, default sort)
378
+ const isDefaultQuery =
379
+ includeDrafts && sortBy === "pubDate" && sortOrder === "desc";
380
+
381
+ let items = isDefaultQuery ? cache.getContent(collection) : null;
382
+
383
+ if (!items) {
384
+ items = await getCollectionSummaries(collectionPath, {
385
+ includeDrafts,
386
+ sortBy,
387
+ sortOrder,
388
+ });
389
+
390
+ // Cache only the "all content" query
391
+ if (isDefaultQuery) {
392
+ cache.setContent(collection, items);
393
+ }
394
+ }
395
+
396
+ sendJson(res, {
397
+ items,
398
+ total: items.length,
399
+ });
400
+ } catch (error) {
401
+ console.error("[writenex] List content error:", error);
402
+ const wrappedError = isWritenexError(error)
403
+ ? error
404
+ : wrapError(error, WritenexErrorCode.API_INTERNAL_ERROR);
405
+ sendWritenexError(res, wrappedError);
406
+ }
407
+ };
408
+
409
+ /**
410
+ * GET /api/content/:collection/:id - Get single content item
411
+ */
412
+ const handleGetContent: RouteHandler = async (_req, res, params, context) => {
413
+ const { collection, id } = params;
414
+ const { projectRoot } = context;
415
+
416
+ if (!collection || !id) {
417
+ return sendWritenexError(
418
+ res,
419
+ new ApiBadRequestError("Collection and content ID required")
420
+ );
421
+ }
422
+
423
+ try {
424
+ const collectionPath = join(projectRoot, "src/content", collection);
425
+ const filePath = getContentFilePath(collectionPath, id);
426
+
427
+ if (!filePath) {
428
+ return sendWritenexError(res, new ContentNotFoundError(collection, id));
429
+ }
430
+
431
+ const result = await readContentFile(filePath, collectionPath);
432
+
433
+ if (!result.success || !result.content) {
434
+ return sendError(res, result.error ?? "Failed to read content", 500);
435
+ }
436
+
437
+ sendJson(res, result.content);
438
+ } catch (error) {
439
+ const wrappedError = isWritenexError(error)
440
+ ? error
441
+ : wrapError(error, WritenexErrorCode.API_INTERNAL_ERROR);
442
+ sendWritenexError(res, wrappedError);
443
+ }
444
+ };
445
+
446
+ /**
447
+ * POST /api/content/:collection - Create new content
448
+ *
449
+ * Automatically detects the file pattern from existing content in the collection
450
+ * and creates new content following the same pattern.
451
+ */
452
+ const handleCreateContent: RouteHandler = async (req, res, params, context) => {
453
+ const { collection } = params;
454
+ const { projectRoot, config } = context;
455
+
456
+ if (!collection) {
457
+ return sendError(res, "Collection name required", 400);
458
+ }
459
+
460
+ try {
461
+ const body = await parseJsonBody(req);
462
+
463
+ if (!body || typeof body !== "object") {
464
+ return sendError(res, "Invalid request body", 400);
465
+ }
466
+
467
+ const {
468
+ frontmatter,
469
+ body: contentBody,
470
+ slug,
471
+ } = body as {
472
+ frontmatter?: Record<string, unknown>;
473
+ body?: string;
474
+ slug?: string;
475
+ };
476
+
477
+ if (!frontmatter) {
478
+ return sendError(res, "Frontmatter is required", 400);
479
+ }
480
+
481
+ const collectionPath = join(projectRoot, "src/content", collection);
482
+ const cache = getCache();
483
+
484
+ // Get the file pattern for this collection
485
+ let filePattern: string | undefined;
486
+
487
+ // First, check if there's a configured pattern for this collection
488
+ const configuredCollection = config.collections.find(
489
+ (c) => c.name === collection
490
+ );
491
+ if (configuredCollection?.filePattern) {
492
+ filePattern = configuredCollection.filePattern;
493
+ } else {
494
+ // Otherwise, get the detected pattern from discovered collections
495
+ let collections = cache.getCollections();
496
+ if (!collections) {
497
+ const discovered = await discoverCollections(projectRoot);
498
+ collections = mergeCollections(discovered, config.collections);
499
+ cache.setCollections(collections);
500
+ }
501
+
502
+ const discoveredCollection = collections.find(
503
+ (c) => c.name === collection
504
+ );
505
+ if (discoveredCollection?.filePattern) {
506
+ filePattern = discoveredCollection.filePattern;
507
+ }
508
+ }
509
+
510
+ const result = await createContent(collectionPath, {
511
+ frontmatter,
512
+ body: contentBody ?? "",
513
+ slug,
514
+ filePattern,
515
+ });
516
+
517
+ if (!result.success) {
518
+ return sendError(res, result.error ?? "Failed to create content", 500);
519
+ }
520
+
521
+ // Invalidate cache for this collection (new content added)
522
+ cache.handleFileChange("add", collection);
523
+
524
+ sendJson(res, {
525
+ success: true,
526
+ id: result.id,
527
+ path: result.path,
528
+ });
529
+ } catch (error) {
530
+ const message = error instanceof Error ? error.message : "Unknown error";
531
+ sendError(res, `Failed to create content: ${message}`, 500);
532
+ }
533
+ };
534
+
535
+ /**
536
+ * PUT /api/content/:collection/:id - Update content
537
+ *
538
+ * Creates a version snapshot of the current content before updating
539
+ * when version history is enabled in configuration.
540
+ *
541
+ * Supports conflict detection via expectedMtime parameter:
542
+ * - If expectedMtime is provided and differs from current file mtime,
543
+ * returns 409 Conflict with both versions for client-side resolution.
544
+ *
545
+ * Request body:
546
+ * {
547
+ * frontmatter?: Record<string, unknown>;
548
+ * body?: string;
549
+ * expectedMtime?: number; // For conflict detection
550
+ * forceOverwrite?: boolean; // Skip conflict check (use with caution)
551
+ * }
552
+ *
553
+ * Response on conflict (409):
554
+ * {
555
+ * error: string;
556
+ * code: "CONTENT_CONFLICT";
557
+ * serverContent: string;
558
+ * serverMtime: number;
559
+ * clientMtime: number;
560
+ * }
561
+ */
562
+ const handleUpdateContent: RouteHandler = async (req, res, params, context) => {
563
+ const { collection, id } = params;
564
+ const { projectRoot, config } = context;
565
+
566
+ if (!collection || !id) {
567
+ return sendWritenexError(
568
+ res,
569
+ new ApiBadRequestError("Collection and content ID required")
570
+ );
571
+ }
572
+
573
+ try {
574
+ const body = await parseJsonBody(req);
575
+
576
+ if (!body || typeof body !== "object") {
577
+ return sendWritenexError(
578
+ res,
579
+ new ApiBadRequestError("Invalid request body")
580
+ );
581
+ }
582
+
583
+ const {
584
+ frontmatter,
585
+ body: contentBody,
586
+ expectedMtime,
587
+ forceOverwrite,
588
+ } = body as {
589
+ frontmatter?: Record<string, unknown>;
590
+ body?: string;
591
+ expectedMtime?: number;
592
+ forceOverwrite?: boolean;
593
+ };
594
+
595
+ const collectionPath = join(projectRoot, "src/content", collection);
596
+ const filePath = getContentFilePath(collectionPath, id);
597
+
598
+ if (!filePath) {
599
+ return sendWritenexError(res, new ContentNotFoundError(collection, id));
600
+ }
601
+
602
+ // Pass version history config to updateContent for automatic version creation
603
+ // Note: config.versionHistory is guaranteed to have all required fields
604
+ // because applyConfigDefaults() applies DEFAULT_VERSION_HISTORY_CONFIG
605
+ const result = await updateContent(filePath, collectionPath, {
606
+ frontmatter,
607
+ body: contentBody,
608
+ projectRoot,
609
+ collection,
610
+ versionHistoryConfig: config.versionHistory as Required<
611
+ typeof config.versionHistory
612
+ >,
613
+ // Only check mtime if not forcing overwrite
614
+ expectedMtime: forceOverwrite ? undefined : expectedMtime,
615
+ });
616
+
617
+ // Handle conflict error specially
618
+ if (!result.success && result.conflict) {
619
+ return sendWritenexError(res, result.conflict);
620
+ }
621
+
622
+ if (!result.success) {
623
+ return sendError(res, result.error ?? "Failed to update content", 500);
624
+ }
625
+
626
+ // Invalidate cache for this collection (content modified)
627
+ const cache = getCache();
628
+ cache.handleFileChange("change", collection);
629
+
630
+ sendJson(res, {
631
+ success: true,
632
+ id: result.id,
633
+ path: result.path,
634
+ mtime: result.mtime,
635
+ });
636
+ } catch (error) {
637
+ const wrappedError = isWritenexError(error)
638
+ ? error
639
+ : wrapError(error, WritenexErrorCode.API_INTERNAL_ERROR);
640
+ sendWritenexError(res, wrappedError);
641
+ }
642
+ };
643
+
644
+ /**
645
+ * DELETE /api/content/:collection/:id - Delete content
646
+ */
647
+ const handleDeleteContent: RouteHandler = async (
648
+ _req,
649
+ res,
650
+ params,
651
+ context
652
+ ) => {
653
+ const { collection, id } = params;
654
+ const { projectRoot } = context;
655
+
656
+ if (!collection || !id) {
657
+ return sendError(res, "Collection and content ID required", 400);
658
+ }
659
+
660
+ try {
661
+ const collectionPath = join(projectRoot, "src/content", collection);
662
+ const filePath = getContentFilePath(collectionPath, id);
663
+
664
+ if (!filePath) {
665
+ return sendError(
666
+ res,
667
+ `Content '${id}' not found in '${collection}'`,
668
+ 404
669
+ );
670
+ }
671
+
672
+ const result = await deleteContent(filePath);
673
+
674
+ if (!result.success) {
675
+ return sendError(res, result.error ?? "Failed to delete content", 500);
676
+ }
677
+
678
+ // Invalidate cache for this collection (content removed)
679
+ const cache = getCache();
680
+ cache.handleFileChange("unlink", collection);
681
+
682
+ sendJson(res, {
683
+ success: true,
684
+ path: result.path,
685
+ });
686
+ } catch (error) {
687
+ const message = error instanceof Error ? error.message : "Unknown error";
688
+ sendError(res, `Failed to delete content: ${message}`, 500);
689
+ }
690
+ };
691
+
692
+ /**
693
+ * POST /api/images - Upload image
694
+ *
695
+ * Expects multipart/form-data with:
696
+ * - file: The image file
697
+ * - collection: Collection name
698
+ * - contentId: Content ID (slug)
699
+ */
700
+ const handleImageUpload: RouteHandler = async (req, res, _params, context) => {
701
+ const { projectRoot, config } = context;
702
+
703
+ try {
704
+ // Read raw body
705
+ const chunks: Buffer[] = [];
706
+ for await (const chunk of req) {
707
+ chunks.push(chunk);
708
+ }
709
+ const body = Buffer.concat(chunks);
710
+
711
+ // Get content type
712
+ const contentType = req.headers["content-type"] ?? "";
713
+
714
+ if (!contentType.includes("multipart/form-data")) {
715
+ return sendWritenexError(
716
+ res,
717
+ new ApiBadRequestError("Content-Type must be multipart/form-data")
718
+ );
719
+ }
720
+
721
+ // Parse multipart data
722
+ const { file, fields } = parseMultipartFormData(body, contentType);
723
+
724
+ if (!file) {
725
+ return sendWritenexError(res, new ApiBadRequestError("No file uploaded"));
726
+ }
727
+
728
+ if (!fields.collection || !fields.contentId) {
729
+ return sendWritenexError(
730
+ res,
731
+ new ApiBadRequestError("collection and contentId are required")
732
+ );
733
+ }
734
+
735
+ if (!isValidImageFile(file.filename)) {
736
+ return sendWritenexError(
737
+ res,
738
+ new ImageInvalidTypeError(file.filename, [
739
+ ".jpg",
740
+ ".jpeg",
741
+ ".png",
742
+ ".gif",
743
+ ".webp",
744
+ ".avif",
745
+ ".svg",
746
+ ])
747
+ );
748
+ }
749
+
750
+ // Upload image
751
+ const result = await uploadImage({
752
+ filename: file.filename,
753
+ data: file.data,
754
+ collection: fields.collection,
755
+ contentId: fields.contentId,
756
+ projectRoot,
757
+ config: config.images,
758
+ });
759
+
760
+ if (!result.success) {
761
+ return sendError(res, result.error ?? "Failed to upload image", 500);
762
+ }
763
+
764
+ sendJson(res, {
765
+ success: true,
766
+ path: result.path,
767
+ url: result.url,
768
+ });
769
+ } catch (error) {
770
+ const wrappedError = isWritenexError(error)
771
+ ? error
772
+ : wrapError(error, WritenexErrorCode.IMAGE_UPLOAD_ERROR);
773
+ sendWritenexError(res, wrappedError);
774
+ }
775
+ };
776
+
777
+ /**
778
+ * GET /api/images/:collection/:contentId - Discover images for content
779
+ *
780
+ * Returns list of discovered images for a content item.
781
+ * Results are cached for performance.
782
+ *
783
+ * Response:
784
+ * {
785
+ * success: boolean;
786
+ * images: DiscoveredImage[];
787
+ * contentPath: string;
788
+ * }
789
+ */
790
+ const handleImageDiscovery: RouteHandler = async (
791
+ _req,
792
+ res,
793
+ params,
794
+ context
795
+ ) => {
796
+ const { collection, id: contentId } = params;
797
+ const { projectRoot } = context;
798
+
799
+ if (!collection || !contentId) {
800
+ return sendError(res, "Collection and content ID required", 400);
801
+ }
802
+
803
+ const cache = getCache();
804
+
805
+ try {
806
+ const collectionPath = join(projectRoot, "src/content", collection);
807
+
808
+ // Check if collection exists by discovering collections
809
+ let collections = cache.getCollections();
810
+ if (!collections) {
811
+ // Cache miss - discover collections
812
+ collections = await discoverCollections(projectRoot);
813
+ cache.setCollections(collections);
814
+ }
815
+
816
+ if (!collections.some((c) => c.name === collection)) {
817
+ return sendError(res, `Collection '${collection}' not found`, 404);
818
+ }
819
+
820
+ // Check if content exists
821
+ const contentFilePath = getContentFilePath(collectionPath, contentId);
822
+ if (!contentFilePath) {
823
+ return sendError(
824
+ res,
825
+ `Content '${contentId}' not found in '${collection}'`,
826
+ 404
827
+ );
828
+ }
829
+
830
+ // Try to get from cache first
831
+ let images = cache.getImages(collection, contentId);
832
+
833
+ if (!images) {
834
+ // Cache miss - discover images
835
+ const result = await discoverContentImages(collectionPath, contentId);
836
+
837
+ if (!result.success) {
838
+ return sendError(res, result.error ?? "Failed to discover images", 500);
839
+ }
840
+
841
+ images = result.images;
842
+
843
+ // Store in cache
844
+ cache.setImages(collection, contentId, images);
845
+ }
846
+
847
+ sendJson(res, {
848
+ success: true,
849
+ images,
850
+ contentPath: contentFilePath,
851
+ });
852
+ } catch (error) {
853
+ const message = error instanceof Error ? error.message : "Unknown error";
854
+ sendError(res, `Failed to discover images: ${message}`, 500);
855
+ }
856
+ };
857
+
858
+ /**
859
+ * MIME types for image files
860
+ */
861
+ const IMAGE_MIME_TYPES: Record<string, string> = {
862
+ ".jpg": "image/jpeg",
863
+ ".jpeg": "image/jpeg",
864
+ ".png": "image/png",
865
+ ".gif": "image/gif",
866
+ ".webp": "image/webp",
867
+ ".avif": "image/avif",
868
+ ".svg": "image/svg+xml",
869
+ };
870
+
871
+ /**
872
+ * GET /api/images/:collection/:contentId/* - Serve image file
873
+ *
874
+ * Serves an image file from the content folder.
875
+ * This allows the editor to display images with relative paths.
876
+ */
877
+ const handleServeImage = async (
878
+ _req: IncomingMessage,
879
+ res: ServerResponse,
880
+ params: RouteParams,
881
+ imagePath: string,
882
+ context: MiddlewareContext
883
+ ): Promise<void> => {
884
+ const { collection, id: contentId } = params;
885
+ const { projectRoot } = context;
886
+
887
+ if (!collection || !contentId) {
888
+ return sendWritenexError(
889
+ res,
890
+ new ApiBadRequestError("Collection and content ID required")
891
+ );
892
+ }
893
+
894
+ try {
895
+ const collectionPath = join(projectRoot, "src/content", collection);
896
+
897
+ // Check if content exists
898
+ const contentFilePath = getContentFilePath(collectionPath, contentId);
899
+ if (!contentFilePath) {
900
+ return sendWritenexError(
901
+ res,
902
+ new ContentNotFoundError(collection, contentId)
903
+ );
904
+ }
905
+
906
+ // Build the full image path
907
+ // For folder-based content (index.md), images are in the same folder
908
+ // For flat files (slug.md), images are in a sibling folder with the same name
909
+ let fullImagePath: string;
910
+
911
+ if (
912
+ contentFilePath.endsWith("/index.md") ||
913
+ contentFilePath.endsWith("/index.mdx")
914
+ ) {
915
+ // Folder-based: content is at slug/index.md, images are at slug/imagePath
916
+ const contentFolder = contentFilePath.replace(/\/index\.mdx?$/, "");
917
+ fullImagePath = join(contentFolder, imagePath);
918
+ } else {
919
+ // Flat file: content is at slug.md, images are at slug/imagePath
920
+ fullImagePath = join(collectionPath, contentId, imagePath);
921
+ }
922
+
923
+ // Security check: ensure the path is within the content folder
924
+ const normalizedPath = join(fullImagePath);
925
+ if (!normalizedPath.startsWith(collectionPath)) {
926
+ return sendWritenexError(
927
+ res,
928
+ new PathTraversalError(imagePath, collectionPath)
929
+ );
930
+ }
931
+
932
+ // Check if file exists
933
+ if (!existsSync(fullImagePath)) {
934
+ return sendWritenexError(res, new ImageNotFoundError(imagePath));
935
+ }
936
+
937
+ // Get file stats
938
+ const stats = statSync(fullImagePath);
939
+ if (!stats.isFile()) {
940
+ return sendWritenexError(
941
+ res,
942
+ new ApiBadRequestError("Requested path is not a file")
943
+ );
944
+ }
945
+
946
+ // Determine MIME type
947
+ const ext = extname(fullImagePath).toLowerCase();
948
+ const mimeType = IMAGE_MIME_TYPES[ext] ?? "application/octet-stream";
949
+
950
+ // Set headers
951
+ res.setHeader("Content-Type", mimeType);
952
+ res.setHeader("Content-Length", stats.size);
953
+ res.setHeader("Cache-Control", "public, max-age=3600");
954
+
955
+ // Stream the file
956
+ const stream = createReadStream(fullImagePath);
957
+ stream.pipe(res);
958
+ } catch (error) {
959
+ const wrappedError = isWritenexError(error)
960
+ ? error
961
+ : wrapError(error, WritenexErrorCode.API_INTERNAL_ERROR);
962
+ sendWritenexError(res, wrappedError);
963
+ }
964
+ };
965
+
966
+ // =============================================================================
967
+ // Version History Route Handlers
968
+ // =============================================================================
969
+
970
+ /**
971
+ * Get the resolved version history config with all required fields
972
+ *
973
+ * @param config - The version history config from context
974
+ * @returns Resolved config with all required fields
975
+ */
976
+ function getResolvedVersionConfig(
977
+ config: VersionHistoryConfig | undefined
978
+ ): Required<VersionHistoryConfig> {
979
+ return {
980
+ enabled: config?.enabled ?? true,
981
+ maxVersions: config?.maxVersions ?? 20,
982
+ storagePath: config?.storagePath ?? ".writenex/versions",
983
+ };
984
+ }
985
+
986
+ /**
987
+ * GET /api/versions/:collection/:id - List all versions
988
+ *
989
+ * Returns versions sorted by timestamp in descending order (newest first).
990
+ *
991
+ * Response:
992
+ * {
993
+ * success: boolean;
994
+ * versions: VersionEntry[];
995
+ * total: number;
996
+ * }
997
+ */
998
+ const handleListVersions: RouteHandler = async (_req, res, params, context) => {
999
+ const { collection, id } = params;
1000
+ const { projectRoot, config } = context;
1001
+
1002
+ if (!collection || !id) {
1003
+ return sendError(res, "Collection and content ID required", 400);
1004
+ }
1005
+
1006
+ try {
1007
+ const versionConfig = getResolvedVersionConfig(config.versionHistory);
1008
+
1009
+ const versions = await getVersions(
1010
+ projectRoot,
1011
+ collection,
1012
+ id,
1013
+ versionConfig
1014
+ );
1015
+
1016
+ sendJson(res, {
1017
+ success: true,
1018
+ versions,
1019
+ total: versions.length,
1020
+ });
1021
+ } catch (error) {
1022
+ const message = error instanceof Error ? error.message : "Unknown error";
1023
+ sendError(res, `Failed to list versions: ${message}`, 500);
1024
+ }
1025
+ };
1026
+
1027
+ /**
1028
+ * GET /api/versions/:collection/:id/:versionId - Get specific version
1029
+ *
1030
+ * Returns the full content of a specific version.
1031
+ *
1032
+ * Response:
1033
+ * {
1034
+ * success: boolean;
1035
+ * version: Version;
1036
+ * }
1037
+ */
1038
+ const handleGetVersion: RouteHandler = async (_req, res, params, context) => {
1039
+ const { collection, id, versionId } = params;
1040
+ const { projectRoot, config } = context;
1041
+
1042
+ if (!collection || !id || !versionId) {
1043
+ return sendWritenexError(
1044
+ res,
1045
+ new ApiBadRequestError("Collection, content ID, and version ID required")
1046
+ );
1047
+ }
1048
+
1049
+ try {
1050
+ const versionConfig = getResolvedVersionConfig(config.versionHistory);
1051
+
1052
+ const version = await getVersion(
1053
+ projectRoot,
1054
+ collection,
1055
+ id,
1056
+ versionId,
1057
+ versionConfig
1058
+ );
1059
+
1060
+ if (!version) {
1061
+ return sendWritenexError(
1062
+ res,
1063
+ new VersionNotFoundError(collection, id, versionId)
1064
+ );
1065
+ }
1066
+
1067
+ sendJson(res, {
1068
+ success: true,
1069
+ version,
1070
+ });
1071
+ } catch (error) {
1072
+ const wrappedError = isWritenexError(error)
1073
+ ? error
1074
+ : wrapError(error, WritenexErrorCode.API_INTERNAL_ERROR);
1075
+ sendWritenexError(res, wrappedError);
1076
+ }
1077
+ };
1078
+
1079
+ /**
1080
+ * POST /api/versions/:collection/:id - Create manual version
1081
+ *
1082
+ * Creates a manual version snapshot with optional label.
1083
+ *
1084
+ * Request body:
1085
+ * {
1086
+ * label?: string;
1087
+ * }
1088
+ *
1089
+ * Response:
1090
+ * {
1091
+ * success: boolean;
1092
+ * version?: VersionEntry;
1093
+ * }
1094
+ */
1095
+ const handleCreateVersion: RouteHandler = async (req, res, params, context) => {
1096
+ const { collection, id } = params;
1097
+ const { projectRoot, config } = context;
1098
+
1099
+ if (!collection || !id) {
1100
+ return sendError(res, "Collection and content ID required", 400);
1101
+ }
1102
+
1103
+ try {
1104
+ const versionConfig = getResolvedVersionConfig(config.versionHistory);
1105
+
1106
+ // Check if version history is enabled
1107
+ if (!versionConfig.enabled) {
1108
+ return sendError(res, "Version history is disabled", 400);
1109
+ }
1110
+
1111
+ // Parse request body for optional label
1112
+ const body = await parseJsonBody(req);
1113
+ const label =
1114
+ body && typeof body === "object" && "label" in body
1115
+ ? String(body.label)
1116
+ : undefined;
1117
+
1118
+ // Get current content
1119
+ const collectionPath = join(projectRoot, "src/content", collection);
1120
+ const filePath = getContentFilePath(collectionPath, id);
1121
+
1122
+ if (!filePath) {
1123
+ return sendError(
1124
+ res,
1125
+ `Content '${id}' not found in '${collection}'`,
1126
+ 404
1127
+ );
1128
+ }
1129
+
1130
+ // Read current content
1131
+ const readResult = await readContentFile(filePath, collectionPath);
1132
+
1133
+ if (!readResult.success || !readResult.content) {
1134
+ return sendError(res, readResult.error ?? "Failed to read content", 500);
1135
+ }
1136
+
1137
+ // Create version
1138
+ const result = await saveVersion(
1139
+ projectRoot,
1140
+ collection,
1141
+ id,
1142
+ readResult.content.raw,
1143
+ versionConfig,
1144
+ { label }
1145
+ );
1146
+
1147
+ if (!result.success) {
1148
+ return sendError(res, result.error ?? "Failed to create version", 500);
1149
+ }
1150
+
1151
+ sendJson(res, {
1152
+ success: true,
1153
+ version: result.version,
1154
+ });
1155
+ } catch (error) {
1156
+ const message = error instanceof Error ? error.message : "Unknown error";
1157
+ sendError(res, `Failed to create version: ${message}`, 500);
1158
+ }
1159
+ };
1160
+
1161
+ /**
1162
+ * POST /api/versions/:collection/:id/:versionId/restore - Restore version
1163
+ *
1164
+ * Restores a version to the current content file.
1165
+ * Creates a safety snapshot before restoring.
1166
+ *
1167
+ * Response:
1168
+ * {
1169
+ * success: boolean;
1170
+ * version?: VersionEntry;
1171
+ * content?: string;
1172
+ * safetySnapshot?: VersionEntry;
1173
+ * }
1174
+ */
1175
+ const handleRestoreVersion: RouteHandler = async (
1176
+ _req,
1177
+ res,
1178
+ params,
1179
+ context
1180
+ ) => {
1181
+ const { collection, id, versionId } = params;
1182
+ const { projectRoot, config } = context;
1183
+
1184
+ if (!collection || !id || !versionId) {
1185
+ return sendError(
1186
+ res,
1187
+ "Collection, content ID, and version ID required",
1188
+ 400
1189
+ );
1190
+ }
1191
+
1192
+ try {
1193
+ const versionConfig = getResolvedVersionConfig(config.versionHistory);
1194
+
1195
+ // Get content file path
1196
+ const collectionPath = join(projectRoot, "src/content", collection);
1197
+ const filePath = getContentFilePath(collectionPath, id);
1198
+
1199
+ if (!filePath) {
1200
+ return sendError(
1201
+ res,
1202
+ `Content '${id}' not found in '${collection}'`,
1203
+ 404
1204
+ );
1205
+ }
1206
+
1207
+ // Restore version
1208
+ const result = await restoreVersion(
1209
+ projectRoot,
1210
+ collection,
1211
+ id,
1212
+ versionId,
1213
+ filePath,
1214
+ versionConfig
1215
+ );
1216
+
1217
+ if (!result.success) {
1218
+ // Check if it's a not found error
1219
+ if (result.error?.includes("not found")) {
1220
+ return sendError(res, result.error, 404);
1221
+ }
1222
+ return sendError(res, result.error ?? "Failed to restore version", 500);
1223
+ }
1224
+
1225
+ // Invalidate cache for this collection (content modified)
1226
+ const cache = getCache();
1227
+ cache.handleFileChange("change", collection);
1228
+
1229
+ sendJson(res, {
1230
+ success: true,
1231
+ version: result.version,
1232
+ content: result.content,
1233
+ safetySnapshot: result.safetySnapshot,
1234
+ });
1235
+ } catch (error) {
1236
+ const message = error instanceof Error ? error.message : "Unknown error";
1237
+ sendError(res, `Failed to restore version: ${message}`, 500);
1238
+ }
1239
+ };
1240
+
1241
+ /**
1242
+ * GET /api/versions/:collection/:id/:versionId/diff - Get diff data
1243
+ *
1244
+ * Returns both the version content and current content for comparison.
1245
+ *
1246
+ * Response:
1247
+ * {
1248
+ * success: boolean;
1249
+ * version: Version;
1250
+ * current: {
1251
+ * content: string;
1252
+ * frontmatter: Record<string, unknown>;
1253
+ * body: string;
1254
+ * };
1255
+ * }
1256
+ */
1257
+ const handleGetVersionDiff: RouteHandler = async (
1258
+ _req,
1259
+ res,
1260
+ params,
1261
+ context
1262
+ ) => {
1263
+ const { collection, id, versionId } = params;
1264
+ const { projectRoot, config } = context;
1265
+
1266
+ if (!collection || !id || !versionId) {
1267
+ return sendError(
1268
+ res,
1269
+ "Collection, content ID, and version ID required",
1270
+ 400
1271
+ );
1272
+ }
1273
+
1274
+ try {
1275
+ const versionConfig = getResolvedVersionConfig(config.versionHistory);
1276
+
1277
+ // Get version content
1278
+ const version = await getVersion(
1279
+ projectRoot,
1280
+ collection,
1281
+ id,
1282
+ versionId,
1283
+ versionConfig
1284
+ );
1285
+
1286
+ if (!version) {
1287
+ return sendError(res, `Version '${versionId}' not found`, 404);
1288
+ }
1289
+
1290
+ // Get current content
1291
+ const collectionPath = join(projectRoot, "src/content", collection);
1292
+ const filePath = getContentFilePath(collectionPath, id);
1293
+
1294
+ if (!filePath) {
1295
+ return sendError(
1296
+ res,
1297
+ `Content '${id}' not found in '${collection}'`,
1298
+ 404
1299
+ );
1300
+ }
1301
+
1302
+ const readResult = await readContentFile(filePath, collectionPath);
1303
+
1304
+ if (!readResult.success || !readResult.content) {
1305
+ return sendError(
1306
+ res,
1307
+ readResult.error ?? "Failed to read current content",
1308
+ 500
1309
+ );
1310
+ }
1311
+
1312
+ sendJson(res, {
1313
+ success: true,
1314
+ version,
1315
+ current: {
1316
+ content: readResult.content.raw,
1317
+ frontmatter: readResult.content.frontmatter,
1318
+ body: readResult.content.body,
1319
+ },
1320
+ });
1321
+ } catch (error) {
1322
+ const message = error instanceof Error ? error.message : "Unknown error";
1323
+ sendError(res, `Failed to get diff data: ${message}`, 500);
1324
+ }
1325
+ };
1326
+
1327
+ /**
1328
+ * DELETE /api/versions/:collection/:id/:versionId - Delete specific version
1329
+ *
1330
+ * Deletes a specific version file and removes it from the manifest.
1331
+ *
1332
+ * Response:
1333
+ * {
1334
+ * success: boolean;
1335
+ * version?: VersionEntry;
1336
+ * }
1337
+ */
1338
+ const handleDeleteVersion: RouteHandler = async (
1339
+ _req,
1340
+ res,
1341
+ params,
1342
+ context
1343
+ ) => {
1344
+ const { collection, id, versionId } = params;
1345
+ const { projectRoot, config } = context;
1346
+
1347
+ if (!collection || !id || !versionId) {
1348
+ return sendError(
1349
+ res,
1350
+ "Collection, content ID, and version ID required",
1351
+ 400
1352
+ );
1353
+ }
1354
+
1355
+ try {
1356
+ const versionConfig = getResolvedVersionConfig(config.versionHistory);
1357
+
1358
+ const result = await deleteVersion(
1359
+ projectRoot,
1360
+ collection,
1361
+ id,
1362
+ versionId,
1363
+ versionConfig
1364
+ );
1365
+
1366
+ if (!result.success) {
1367
+ // Check if it's a not found error
1368
+ if (result.error?.includes("not found")) {
1369
+ return sendError(res, result.error, 404);
1370
+ }
1371
+ return sendError(res, result.error ?? "Failed to delete version", 500);
1372
+ }
1373
+
1374
+ sendJson(res, {
1375
+ success: true,
1376
+ version: result.version,
1377
+ });
1378
+ } catch (error) {
1379
+ const message = error instanceof Error ? error.message : "Unknown error";
1380
+ sendError(res, `Failed to delete version: ${message}`, 500);
1381
+ }
1382
+ };
1383
+
1384
+ /**
1385
+ * DELETE /api/versions/:collection/:id - Clear all versions
1386
+ *
1387
+ * Deletes all version files for a content item and resets the manifest.
1388
+ *
1389
+ * Response:
1390
+ * {
1391
+ * success: boolean;
1392
+ * }
1393
+ */
1394
+ const handleClearVersions: RouteHandler = async (
1395
+ _req,
1396
+ res,
1397
+ params,
1398
+ context
1399
+ ) => {
1400
+ const { collection, id } = params;
1401
+ const { projectRoot, config } = context;
1402
+
1403
+ if (!collection || !id) {
1404
+ return sendError(res, "Collection and content ID required", 400);
1405
+ }
1406
+
1407
+ try {
1408
+ const versionConfig = getResolvedVersionConfig(config.versionHistory);
1409
+
1410
+ const result = await clearVersions(
1411
+ projectRoot,
1412
+ collection,
1413
+ id,
1414
+ versionConfig
1415
+ );
1416
+
1417
+ if (!result.success) {
1418
+ return sendError(res, result.error ?? "Failed to clear versions", 500);
1419
+ }
1420
+
1421
+ sendJson(res, {
1422
+ success: true,
1423
+ });
1424
+ } catch (error) {
1425
+ const message = error instanceof Error ? error.message : "Unknown error";
1426
+ sendError(res, `Failed to clear versions: ${message}`, 500);
1427
+ }
1428
+ };