@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,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
|
+
};
|