@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,1339 @@
1
+ /**
2
+ * @fileoverview Version history management for content files
3
+ *
4
+ * This module provides functions for creating, reading, and managing
5
+ * version history (shadow copies) of content files. Versions are stored
6
+ * as markdown files in a hidden directory structure with a JSON manifest
7
+ * tracking metadata.
8
+ *
9
+ * ## Storage Structure:
10
+ * ```
11
+ * .writenex/versions/
12
+ * ├── .gitignore # Contains "*" to exclude from Git
13
+ * └── {collection}/
14
+ * └── {contentId}/
15
+ * ├── manifest.json # Version metadata
16
+ * └── {timestamp}.md # Version files
17
+ * ```
18
+ *
19
+ * @module @writenex/astro/filesystem/versions
20
+ * @see {@link VersionEntry} - Version metadata type
21
+ * @see {@link VersionManifest} - Manifest structure type
22
+ */
23
+
24
+ import {
25
+ readFile,
26
+ writeFile,
27
+ mkdir,
28
+ readdir,
29
+ stat,
30
+ unlink,
31
+ } from "node:fs/promises";
32
+ import { existsSync } from "node:fs";
33
+ import { join, basename } from "node:path";
34
+ import matter from "gray-matter";
35
+ import type {
36
+ VersionEntry,
37
+ VersionManifest,
38
+ VersionHistoryConfig,
39
+ Version,
40
+ VersionResult,
41
+ SaveVersionOptions,
42
+ RestoreVersionOptions,
43
+ RestoreResult,
44
+ } from "@/types";
45
+
46
+ // =============================================================================
47
+ // Constants
48
+ // =============================================================================
49
+
50
+ /** Maximum characters for content preview */
51
+ const PREVIEW_MAX_LENGTH = 100;
52
+
53
+ /** Default gitignore content for version storage */
54
+ const GITIGNORE_CONTENT = "*\n";
55
+
56
+ /** Frontmatter key for storing version label (prefixed to avoid conflicts) */
57
+ const LABEL_FRONTMATTER_KEY = "_writenex_label";
58
+
59
+ /** Lock timeout in milliseconds */
60
+ const LOCK_TIMEOUT_MS = 30000;
61
+
62
+ /** Lock retry interval in milliseconds */
63
+ const LOCK_RETRY_INTERVAL_MS = 50;
64
+
65
+ // =============================================================================
66
+ // Locking Mechanism
67
+ // =============================================================================
68
+
69
+ /**
70
+ * In-memory lock manager for preventing concurrent manifest operations.
71
+ *
72
+ * Uses a Map to track locks per storage path, with each lock containing
73
+ * a promise that resolves when the lock is released.
74
+ */
75
+ interface LockEntry {
76
+ /** Promise that resolves when lock is released */
77
+ promise: Promise<void>;
78
+ /** Function to release the lock */
79
+ release: () => void;
80
+ /** Timestamp when lock was acquired */
81
+ acquiredAt: number;
82
+ }
83
+
84
+ /** Map of storage paths to their lock entries */
85
+ const locks = new Map<string, LockEntry>();
86
+
87
+ /**
88
+ * Acquire a lock for a specific storage path.
89
+ *
90
+ * If the path is already locked, waits until the lock is released
91
+ * or timeout is reached.
92
+ *
93
+ * @param storagePath - Path to lock
94
+ * @param timeoutMs - Maximum time to wait for lock (default: 30s)
95
+ * @returns Release function to call when done
96
+ * @throws Error if lock cannot be acquired within timeout
97
+ */
98
+ async function acquireLock(
99
+ storagePath: string,
100
+ timeoutMs: number = LOCK_TIMEOUT_MS
101
+ ): Promise<() => void> {
102
+ const startTime = Date.now();
103
+
104
+ // Wait for existing lock to be released
105
+ while (locks.has(storagePath)) {
106
+ const existingLock = locks.get(storagePath)!;
107
+
108
+ // Check for stale lock (acquired more than timeout ago)
109
+ if (Date.now() - existingLock.acquiredAt > timeoutMs) {
110
+ console.warn(
111
+ `[writenex] Releasing stale lock for ${storagePath} (held for ${Date.now() - existingLock.acquiredAt}ms)`
112
+ );
113
+ existingLock.release();
114
+ locks.delete(storagePath);
115
+ break;
116
+ }
117
+
118
+ // Check if we've exceeded our timeout
119
+ if (Date.now() - startTime > timeoutMs) {
120
+ throw new Error(
121
+ `[writenex] Timeout waiting for lock on ${storagePath} after ${timeoutMs}ms`
122
+ );
123
+ }
124
+
125
+ // Wait for lock to be released or retry interval
126
+ await Promise.race([
127
+ existingLock.promise,
128
+ new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_INTERVAL_MS)),
129
+ ]);
130
+ }
131
+
132
+ // Create new lock
133
+ let releaseFunc: () => void;
134
+ const lockPromise = new Promise<void>((resolve) => {
135
+ releaseFunc = resolve;
136
+ });
137
+
138
+ const lockEntry: LockEntry = {
139
+ promise: lockPromise,
140
+ release: releaseFunc!,
141
+ acquiredAt: Date.now(),
142
+ };
143
+
144
+ locks.set(storagePath, lockEntry);
145
+
146
+ // Return release function that also cleans up the map
147
+ return () => {
148
+ lockEntry.release();
149
+ locks.delete(storagePath);
150
+ };
151
+ }
152
+
153
+ /**
154
+ * Execute a function with an exclusive lock on the storage path.
155
+ *
156
+ * Ensures only one operation can modify the manifest at a time,
157
+ * preventing race conditions during concurrent saves.
158
+ *
159
+ * @param storagePath - Path to lock
160
+ * @param fn - Function to execute while holding the lock
161
+ * @returns Result of the function
162
+ */
163
+ async function withLock<T>(
164
+ storagePath: string,
165
+ fn: () => Promise<T>
166
+ ): Promise<T> {
167
+ const release = await acquireLock(storagePath);
168
+ try {
169
+ return await fn();
170
+ } finally {
171
+ release();
172
+ }
173
+ }
174
+
175
+ // =============================================================================
176
+ // Utility Functions
177
+ // =============================================================================
178
+
179
+ /**
180
+ * Generate a unique version ID based on current timestamp with random suffix.
181
+ *
182
+ * The ID is an ISO-8601 timestamp with colons replaced by hyphens,
183
+ * plus a 4-character random suffix to ensure uniqueness even when
184
+ * multiple versions are created within the same millisecond.
185
+ *
186
+ * Format: YYYY-MM-DDTHH-MM-SS.mmmZ-xxxx
187
+ * Where xxxx is a random alphanumeric suffix.
188
+ *
189
+ * @returns Version ID string (e.g., "2024-12-11T10-30-00.000Z-a1b2")
190
+ *
191
+ * @example
192
+ * ```typescript
193
+ * const id = generateVersionId();
194
+ * // Returns: "2024-12-11T10-30-00.000Z-a1b2"
195
+ * ```
196
+ */
197
+ export function generateVersionId(): string {
198
+ const timestamp = new Date().toISOString().replace(/:/g, "-");
199
+ const randomSuffix = Math.random().toString(36).substring(2, 6);
200
+ return `${timestamp}-${randomSuffix}`;
201
+ }
202
+
203
+ /**
204
+ * Parse a version ID back to a Date object.
205
+ *
206
+ * Handles both old format (without suffix) and new format (with random suffix).
207
+ *
208
+ * @param versionId - Version ID string
209
+ * @returns Date object or null if invalid
210
+ */
211
+ export function parseVersionId(versionId: string): Date | null {
212
+ // Remove random suffix if present (format: ...Z-xxxx)
213
+ const withoutSuffix = versionId.replace(/-[a-z0-9]{4}$/, "");
214
+
215
+ // Convert hyphens back to colons for ISO parsing
216
+ // Format: 2024-12-11T10-30-00.000Z -> 2024-12-11T10:30:00.000Z
217
+ // Note: The dot before milliseconds is preserved by generateVersionId()
218
+ const isoString = withoutSuffix.replace(
219
+ /T(\d{2})-(\d{2})-(\d{2})\.(\d{3})Z/,
220
+ "T$1:$2:$3.$4Z"
221
+ );
222
+
223
+ const date = new Date(isoString);
224
+ return isNaN(date.getTime()) ? null : date;
225
+ }
226
+
227
+ /**
228
+ * Get the storage path for version files of a content item.
229
+ *
230
+ * @param projectRoot - Absolute path to project root
231
+ * @param collection - Collection name
232
+ * @param contentId - Content item ID (slug)
233
+ * @param config - Version history configuration
234
+ * @returns Absolute path to version storage directory
235
+ *
236
+ * @example
237
+ * ```typescript
238
+ * const path = getVersionStoragePath(
239
+ * '/project',
240
+ * 'blog',
241
+ * 'my-post',
242
+ * { storagePath: '.writenex/versions' }
243
+ * );
244
+ * // Returns: "/project/.writenex/versions/blog/my-post"
245
+ * ```
246
+ */
247
+ export function getVersionStoragePath(
248
+ projectRoot: string,
249
+ collection: string,
250
+ contentId: string,
251
+ config: Required<VersionHistoryConfig>
252
+ ): string {
253
+ return join(projectRoot, config.storagePath, collection, contentId);
254
+ }
255
+
256
+ /**
257
+ * Get the path to a specific version file.
258
+ *
259
+ * @param storagePath - Version storage directory path
260
+ * @param versionId - Version ID
261
+ * @returns Absolute path to version file
262
+ */
263
+ export function getVersionFilePath(
264
+ storagePath: string,
265
+ versionId: string
266
+ ): string {
267
+ return join(storagePath, `${versionId}.md`);
268
+ }
269
+
270
+ /**
271
+ * Get the path to the manifest file for a content item.
272
+ *
273
+ * @param storagePath - Version storage directory path
274
+ * @returns Absolute path to manifest file
275
+ */
276
+ export function getManifestPath(storagePath: string): string {
277
+ return join(storagePath, "manifest.json");
278
+ }
279
+
280
+ /**
281
+ * Generate a preview string from content.
282
+ *
283
+ * Extracts the first 100 characters of the content body,
284
+ * stripping frontmatter if present.
285
+ *
286
+ * @param content - Full markdown content
287
+ * @returns Preview string (max 100 characters)
288
+ *
289
+ * @example
290
+ * ```typescript
291
+ * const preview = generatePreview("---\ntitle: Test\n---\n\n# Hello World\n\nThis is content.");
292
+ * // Returns: "# Hello World\n\nThis is content."
293
+ * ```
294
+ */
295
+ export function generatePreview(content: string): string {
296
+ // Parse frontmatter to get body only
297
+ try {
298
+ const { content: body } = matter(content);
299
+ const trimmed = body.trim();
300
+
301
+ if (trimmed.length <= PREVIEW_MAX_LENGTH) {
302
+ return trimmed;
303
+ }
304
+
305
+ return trimmed.substring(0, PREVIEW_MAX_LENGTH);
306
+ } catch {
307
+ // If parsing fails, use raw content
308
+ const trimmed = content.trim();
309
+ return trimmed.length <= PREVIEW_MAX_LENGTH
310
+ ? trimmed
311
+ : trimmed.substring(0, PREVIEW_MAX_LENGTH);
312
+ }
313
+ }
314
+
315
+ /**
316
+ * Extract label from version file content.
317
+ *
318
+ * Reads the special _writenex_label frontmatter field that stores
319
+ * the version label for recovery purposes.
320
+ *
321
+ * @param content - Full markdown content of version file
322
+ * @returns Label string or undefined if not present
323
+ */
324
+ export function extractLabelFromContent(content: string): string | undefined {
325
+ try {
326
+ const { data } = matter(content);
327
+ const label = data[LABEL_FRONTMATTER_KEY];
328
+ return typeof label === "string" ? label : undefined;
329
+ } catch {
330
+ return undefined;
331
+ }
332
+ }
333
+
334
+ /**
335
+ * Inject label into content as frontmatter for persistence.
336
+ *
337
+ * Adds the _writenex_label field to frontmatter so the label
338
+ * can be recovered if the manifest is lost or corrupted.
339
+ *
340
+ * @param content - Original markdown content
341
+ * @param label - Label to inject
342
+ * @returns Content with label injected in frontmatter
343
+ */
344
+ export function injectLabelIntoContent(content: string, label: string): string {
345
+ try {
346
+ const { data, content: body } = matter(content);
347
+
348
+ // Add label to frontmatter
349
+ const newData = { ...data, [LABEL_FRONTMATTER_KEY]: label };
350
+
351
+ // Reconstruct the file with updated frontmatter
352
+ return matter.stringify(body, newData);
353
+ } catch {
354
+ // If parsing fails, prepend frontmatter with just the label
355
+ return `---\n${LABEL_FRONTMATTER_KEY}: "${label}"\n---\n\n${content}`;
356
+ }
357
+ }
358
+
359
+ /**
360
+ * Remove the internal label field from content for user-facing operations.
361
+ *
362
+ * Strips the _writenex_label field from frontmatter when returning
363
+ * content to users (e.g., during restore).
364
+ *
365
+ * @param content - Content that may contain internal label field
366
+ * @returns Content with internal label field removed
367
+ */
368
+ export function stripLabelFromContent(content: string): string {
369
+ try {
370
+ const { data, content: body } = matter(content);
371
+
372
+ // Remove the internal label field
373
+ if (LABEL_FRONTMATTER_KEY in data) {
374
+ const { [LABEL_FRONTMATTER_KEY]: _, ...cleanData } = data;
375
+
376
+ // If no other frontmatter, return just the body
377
+ if (Object.keys(cleanData).length === 0) {
378
+ return body.startsWith("\n") ? body.slice(1) : body;
379
+ }
380
+
381
+ return matter.stringify(body, cleanData);
382
+ }
383
+
384
+ return content;
385
+ } catch {
386
+ return content;
387
+ }
388
+ }
389
+
390
+ /**
391
+ * Ensure the .gitignore file exists in the version storage root.
392
+ *
393
+ * Creates a .gitignore file with "*" pattern to exclude all version
394
+ * files from Git tracking.
395
+ *
396
+ * @param projectRoot - Absolute path to project root
397
+ * @param config - Version history configuration
398
+ *
399
+ * @example
400
+ * ```typescript
401
+ * await ensureGitignore('/project', { storagePath: '.writenex/versions' });
402
+ * // Creates: /project/.writenex/versions/.gitignore with content "*"
403
+ * ```
404
+ */
405
+ export async function ensureGitignore(
406
+ projectRoot: string,
407
+ config: Required<VersionHistoryConfig>
408
+ ): Promise<void> {
409
+ const storageRoot = join(projectRoot, config.storagePath);
410
+ const gitignorePath = join(storageRoot, ".gitignore");
411
+
412
+ // Ensure storage directory exists
413
+ if (!existsSync(storageRoot)) {
414
+ await mkdir(storageRoot, { recursive: true });
415
+ }
416
+
417
+ // Create .gitignore if it doesn't exist
418
+ if (!existsSync(gitignorePath)) {
419
+ await writeFile(gitignorePath, GITIGNORE_CONTENT, "utf-8");
420
+ }
421
+ }
422
+
423
+ /**
424
+ * Ensure the version storage directory exists for a content item.
425
+ *
426
+ * @param storagePath - Version storage directory path
427
+ */
428
+ export async function ensureStorageDirectory(
429
+ storagePath: string
430
+ ): Promise<void> {
431
+ if (!existsSync(storagePath)) {
432
+ await mkdir(storagePath, { recursive: true });
433
+ }
434
+ }
435
+
436
+ // =============================================================================
437
+ // Manifest Operations
438
+ // =============================================================================
439
+
440
+ /**
441
+ * Read the version manifest for a content item.
442
+ *
443
+ * @param storagePath - Version storage directory path
444
+ * @returns Version manifest or null if not found/corrupted
445
+ *
446
+ * @example
447
+ * ```typescript
448
+ * const manifest = await readManifest('/project/.writenex/versions/blog/my-post');
449
+ * if (manifest) {
450
+ * console.log(`Found ${manifest.versions.length} versions`);
451
+ * }
452
+ * ```
453
+ */
454
+ export async function readManifest(
455
+ storagePath: string
456
+ ): Promise<VersionManifest | null> {
457
+ const manifestPath = getManifestPath(storagePath);
458
+
459
+ if (!existsSync(manifestPath)) {
460
+ return null;
461
+ }
462
+
463
+ try {
464
+ const content = await readFile(manifestPath, "utf-8");
465
+ const data = JSON.parse(content) as VersionManifest;
466
+
467
+ // Validate required fields
468
+ if (!data.contentId || !data.collection || !Array.isArray(data.versions)) {
469
+ console.warn(`[writenex] Corrupted manifest at ${manifestPath}`);
470
+ return null;
471
+ }
472
+
473
+ return data;
474
+ } catch (error) {
475
+ console.warn(
476
+ `[writenex] Failed to read manifest at ${manifestPath}:`,
477
+ error
478
+ );
479
+ return null;
480
+ }
481
+ }
482
+
483
+ /**
484
+ * Write the version manifest for a content item.
485
+ *
486
+ * @param storagePath - Version storage directory path
487
+ * @param manifest - Version manifest to write
488
+ *
489
+ * @example
490
+ * ```typescript
491
+ * await writeManifest('/project/.writenex/versions/blog/my-post', {
492
+ * contentId: 'my-post',
493
+ * collection: 'blog',
494
+ * versions: [],
495
+ * updatedAt: new Date().toISOString(),
496
+ * });
497
+ * ```
498
+ */
499
+ export async function writeManifest(
500
+ storagePath: string,
501
+ manifest: VersionManifest
502
+ ): Promise<void> {
503
+ await ensureStorageDirectory(storagePath);
504
+
505
+ const manifestPath = getManifestPath(storagePath);
506
+ const content = JSON.stringify(manifest, null, 2);
507
+
508
+ await writeFile(manifestPath, content, "utf-8");
509
+ }
510
+
511
+ /**
512
+ * Create an empty manifest for a content item.
513
+ *
514
+ * @param collection - Collection name
515
+ * @param contentId - Content item ID
516
+ * @returns New empty manifest
517
+ */
518
+ export function createEmptyManifest(
519
+ collection: string,
520
+ contentId: string
521
+ ): VersionManifest {
522
+ return {
523
+ contentId,
524
+ collection,
525
+ versions: [],
526
+ updatedAt: new Date().toISOString(),
527
+ };
528
+ }
529
+
530
+ /**
531
+ * Recover manifest by scanning version files in the storage directory.
532
+ *
533
+ * This function rebuilds the manifest from existing version files
534
+ * when the manifest is corrupted or missing.
535
+ *
536
+ * @param storagePath - Version storage directory path
537
+ * @param collection - Collection name
538
+ * @param contentId - Content item ID
539
+ * @returns Recovered manifest
540
+ *
541
+ * @example
542
+ * ```typescript
543
+ * const manifest = await recoverManifest(
544
+ * '/project/.writenex/versions/blog/my-post',
545
+ * 'blog',
546
+ * 'my-post'
547
+ * );
548
+ * ```
549
+ */
550
+ export async function recoverManifest(
551
+ storagePath: string,
552
+ collection: string,
553
+ contentId: string
554
+ ): Promise<VersionManifest> {
555
+ const manifest = createEmptyManifest(collection, contentId);
556
+
557
+ if (!existsSync(storagePath)) {
558
+ return manifest;
559
+ }
560
+
561
+ try {
562
+ const files = await readdir(storagePath);
563
+ const versionFiles = files.filter(
564
+ (f) => f.endsWith(".md") && f !== "manifest.json"
565
+ );
566
+
567
+ for (const file of versionFiles) {
568
+ const versionId = basename(file, ".md");
569
+ const filePath = join(storagePath, file);
570
+
571
+ try {
572
+ const content = await readFile(filePath, "utf-8");
573
+ const stats = await stat(filePath);
574
+ const timestamp = parseVersionId(versionId);
575
+
576
+ if (timestamp) {
577
+ // Extract label from content if present (for recovery)
578
+ const label = extractLabelFromContent(content);
579
+
580
+ const entry: VersionEntry = {
581
+ id: versionId,
582
+ timestamp: timestamp.toISOString(),
583
+ preview: generatePreview(content),
584
+ size: stats.size,
585
+ ...(label ? { label } : {}),
586
+ };
587
+
588
+ manifest.versions.push(entry);
589
+ }
590
+ } catch {
591
+ // Skip files that can't be read
592
+ console.warn(`[writenex] Skipping unreadable version file: ${file}`);
593
+ }
594
+ }
595
+
596
+ // Sort by timestamp descending (newest first)
597
+ manifest.versions.sort(
598
+ (a, b) =>
599
+ new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
600
+ );
601
+
602
+ manifest.updatedAt = new Date().toISOString();
603
+
604
+ // Write recovered manifest
605
+ await writeManifest(storagePath, manifest);
606
+
607
+ return manifest;
608
+ } catch (error) {
609
+ console.warn(`[writenex] Failed to recover manifest:`, error);
610
+ return manifest;
611
+ }
612
+ }
613
+
614
+ /**
615
+ * Get or recover manifest for a content item.
616
+ *
617
+ * Attempts to read existing manifest, falls back to recovery if corrupted.
618
+ *
619
+ * @param storagePath - Version storage directory path
620
+ * @param collection - Collection name
621
+ * @param contentId - Content item ID
622
+ * @returns Version manifest
623
+ */
624
+ export async function getOrRecoverManifest(
625
+ storagePath: string,
626
+ collection: string,
627
+ contentId: string
628
+ ): Promise<VersionManifest> {
629
+ const manifest = await readManifest(storagePath);
630
+
631
+ if (manifest) {
632
+ return manifest;
633
+ }
634
+
635
+ // Try to recover from version files
636
+ return recoverManifest(storagePath, collection, contentId);
637
+ }
638
+
639
+ // =============================================================================
640
+ // Version CRUD Operations
641
+ // =============================================================================
642
+
643
+ /**
644
+ * Save a version snapshot of content.
645
+ *
646
+ * Creates a new version file with the provided content and updates
647
+ * the manifest. Automatically prunes old versions if the limit is exceeded.
648
+ *
649
+ * @param projectRoot - Absolute path to project root
650
+ * @param collection - Collection name
651
+ * @param contentId - Content item ID (slug)
652
+ * @param content - Full markdown content to save
653
+ * @param config - Version history configuration
654
+ * @param options - Save options
655
+ * @returns Result of the save operation
656
+ *
657
+ * @example
658
+ * ```typescript
659
+ * const result = await saveVersion(
660
+ * '/project',
661
+ * 'blog',
662
+ * 'my-post',
663
+ * '---\ntitle: My Post\n---\n\nContent here...',
664
+ * { enabled: true, maxVersions: 20, storagePath: '.writenex/versions' }
665
+ * );
666
+ *
667
+ * if (result.success) {
668
+ * console.log(`Created version: ${result.version?.id}`);
669
+ * }
670
+ * ```
671
+ */
672
+ export async function saveVersion(
673
+ projectRoot: string,
674
+ collection: string,
675
+ contentId: string,
676
+ content: string,
677
+ config: Required<VersionHistoryConfig>,
678
+ options: SaveVersionOptions = {}
679
+ ): Promise<VersionResult> {
680
+ const { label, skipIfIdentical = false } = options;
681
+
682
+ // Check if version history is enabled
683
+ if (!config.enabled) {
684
+ return { success: true };
685
+ }
686
+
687
+ // Get storage path for this content item
688
+ const storagePath = getVersionStoragePath(
689
+ projectRoot,
690
+ collection,
691
+ contentId,
692
+ config
693
+ );
694
+
695
+ // Use lock to prevent concurrent manifest modifications
696
+ return withLock(storagePath, async () => {
697
+ try {
698
+ // Ensure gitignore exists in storage root
699
+ await ensureGitignore(projectRoot, config);
700
+
701
+ await ensureStorageDirectory(storagePath);
702
+
703
+ // Get or create manifest
704
+ const manifest = await getOrRecoverManifest(
705
+ storagePath,
706
+ collection,
707
+ contentId
708
+ );
709
+
710
+ // Check if content is identical to last version (if skipIfIdentical is true)
711
+ if (skipIfIdentical && manifest.versions.length > 0) {
712
+ const lastVersion = manifest.versions[0];
713
+ if (lastVersion) {
714
+ const lastVersionPath = getVersionFilePath(
715
+ storagePath,
716
+ lastVersion.id
717
+ );
718
+ if (existsSync(lastVersionPath)) {
719
+ try {
720
+ const lastContent = await readFile(lastVersionPath, "utf-8");
721
+ if (lastContent === content) {
722
+ return { success: true, version: lastVersion };
723
+ }
724
+ } catch {
725
+ // If we can't read the last version, proceed with saving
726
+ }
727
+ }
728
+ }
729
+ }
730
+
731
+ // Generate version ID and create version file
732
+ // Use the same timestamp for both id and timestamp to ensure consistency
733
+ const now = new Date();
734
+ const versionId = now.toISOString().replace(/:/g, "-");
735
+ const versionPath = getVersionFilePath(storagePath, versionId);
736
+
737
+ // If label is provided, inject it into content for recovery purposes
738
+ const contentToSave = label
739
+ ? injectLabelIntoContent(content, label)
740
+ : content;
741
+
742
+ // Write version file
743
+ await writeFile(versionPath, contentToSave, "utf-8");
744
+
745
+ // Get file stats for size
746
+ const stats = await stat(versionPath);
747
+
748
+ // Create version entry
749
+ const entry: VersionEntry = {
750
+ id: versionId,
751
+ timestamp: now.toISOString(),
752
+ preview: generatePreview(content),
753
+ size: stats.size,
754
+ ...(label ? { label } : {}),
755
+ };
756
+
757
+ // Add to manifest (newest first)
758
+ manifest.versions.unshift(entry);
759
+ manifest.updatedAt = new Date().toISOString();
760
+
761
+ // Write updated manifest
762
+ await writeManifest(storagePath, manifest);
763
+
764
+ // Prune old versions (inside lock to prevent race)
765
+ await pruneVersionsInternal(storagePath, config);
766
+
767
+ return { success: true, version: entry };
768
+ } catch (error) {
769
+ const message = error instanceof Error ? error.message : String(error);
770
+ console.error(`[writenex] Failed to save version:`, error);
771
+ return { success: false, error: `Failed to save version: ${message}` };
772
+ }
773
+ });
774
+ }
775
+
776
+ /**
777
+ * Get all versions for a content item.
778
+ *
779
+ * Returns versions sorted by timestamp in descending order (newest first).
780
+ * Handles missing or corrupted manifests gracefully.
781
+ *
782
+ * @param projectRoot - Absolute path to project root
783
+ * @param collection - Collection name
784
+ * @param contentId - Content item ID (slug)
785
+ * @param config - Version history configuration
786
+ * @returns Array of version entries
787
+ *
788
+ * @example
789
+ * ```typescript
790
+ * const versions = await getVersions(
791
+ * '/project',
792
+ * 'blog',
793
+ * 'my-post',
794
+ * { enabled: true, maxVersions: 20, storagePath: '.writenex/versions' }
795
+ * );
796
+ *
797
+ * console.log(`Found ${versions.length} versions`);
798
+ * ```
799
+ */
800
+ export async function getVersions(
801
+ projectRoot: string,
802
+ collection: string,
803
+ contentId: string,
804
+ config: Required<VersionHistoryConfig>
805
+ ): Promise<VersionEntry[]> {
806
+ // Check if version history is enabled
807
+ if (!config.enabled) {
808
+ return [];
809
+ }
810
+
811
+ try {
812
+ const storagePath = getVersionStoragePath(
813
+ projectRoot,
814
+ collection,
815
+ contentId,
816
+ config
817
+ );
818
+
819
+ // Check if storage directory exists
820
+ if (!existsSync(storagePath)) {
821
+ return [];
822
+ }
823
+
824
+ // Get or recover manifest
825
+ const manifest = await getOrRecoverManifest(
826
+ storagePath,
827
+ collection,
828
+ contentId
829
+ );
830
+
831
+ // Return versions sorted by timestamp descending (newest first)
832
+ return [...manifest.versions].sort(
833
+ (a, b) =>
834
+ new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
835
+ );
836
+ } catch (error) {
837
+ console.warn(`[writenex] Failed to get versions:`, error);
838
+ return [];
839
+ }
840
+ }
841
+
842
+ /**
843
+ * Get a specific version with full content.
844
+ *
845
+ * Reads the version file and parses it to return structured data
846
+ * with frontmatter and body separated.
847
+ *
848
+ * @param projectRoot - Absolute path to project root
849
+ * @param collection - Collection name
850
+ * @param contentId - Content item ID (slug)
851
+ * @param versionId - Version ID to retrieve
852
+ * @param config - Version history configuration
853
+ * @returns Full version data or null if not found
854
+ *
855
+ * @example
856
+ * ```typescript
857
+ * const version = await getVersion(
858
+ * '/project',
859
+ * 'blog',
860
+ * 'my-post',
861
+ * '2024-12-11T10-30-00-000Z',
862
+ * { enabled: true, maxVersions: 20, storagePath: '.writenex/versions' }
863
+ * );
864
+ *
865
+ * if (version) {
866
+ * console.log(`Title: ${version.frontmatter.title}`);
867
+ * console.log(`Body: ${version.body}`);
868
+ * }
869
+ * ```
870
+ */
871
+ export async function getVersion(
872
+ projectRoot: string,
873
+ collection: string,
874
+ contentId: string,
875
+ versionId: string,
876
+ config: Required<VersionHistoryConfig>
877
+ ): Promise<Version | null> {
878
+ // Check if version history is enabled
879
+ if (!config.enabled) {
880
+ return null;
881
+ }
882
+
883
+ try {
884
+ const storagePath = getVersionStoragePath(
885
+ projectRoot,
886
+ collection,
887
+ contentId,
888
+ config
889
+ );
890
+ const versionPath = getVersionFilePath(storagePath, versionId);
891
+
892
+ // Check if version file exists
893
+ if (!existsSync(versionPath)) {
894
+ return null;
895
+ }
896
+
897
+ // Read version file
898
+ const rawContent = await readFile(versionPath, "utf-8");
899
+ const stats = await stat(versionPath);
900
+
901
+ // Extract label from content (stored for recovery)
902
+ const labelFromContent = extractLabelFromContent(rawContent);
903
+
904
+ // Strip internal label field before returning to user
905
+ const content = stripLabelFromContent(rawContent);
906
+
907
+ // Parse frontmatter from cleaned content
908
+ const { data: frontmatter, content: body } = matter(content);
909
+
910
+ // Get timestamp from version ID
911
+ const timestamp = parseVersionId(versionId);
912
+ if (!timestamp) {
913
+ return null;
914
+ }
915
+
916
+ // Get label from manifest first, fall back to content-embedded label
917
+ const manifest = await readManifest(storagePath);
918
+ const manifestEntry = manifest?.versions.find((v) => v.id === versionId);
919
+ const label = manifestEntry?.label ?? labelFromContent;
920
+
921
+ return {
922
+ id: versionId,
923
+ timestamp: timestamp.toISOString(),
924
+ preview: generatePreview(content),
925
+ size: stats.size,
926
+ content,
927
+ frontmatter,
928
+ body: body.trim(),
929
+ ...(label ? { label } : {}),
930
+ };
931
+ } catch (error) {
932
+ console.warn(`[writenex] Failed to get version ${versionId}:`, error);
933
+ return null;
934
+ }
935
+ }
936
+
937
+ /**
938
+ * Delete a specific version.
939
+ *
940
+ * Removes the version file from the filesystem and updates the manifest.
941
+ *
942
+ * @param projectRoot - Absolute path to project root
943
+ * @param collection - Collection name
944
+ * @param contentId - Content item ID (slug)
945
+ * @param versionId - Version ID to delete
946
+ * @param config - Version history configuration
947
+ * @returns Result of the delete operation
948
+ *
949
+ * @example
950
+ * ```typescript
951
+ * const result = await deleteVersion(
952
+ * '/project',
953
+ * 'blog',
954
+ * 'my-post',
955
+ * '2024-12-11T10-30-00-000Z',
956
+ * { enabled: true, maxVersions: 20, storagePath: '.writenex/versions' }
957
+ * );
958
+ *
959
+ * if (result.success) {
960
+ * console.log('Version deleted');
961
+ * }
962
+ * ```
963
+ */
964
+ export async function deleteVersion(
965
+ projectRoot: string,
966
+ collection: string,
967
+ contentId: string,
968
+ versionId: string,
969
+ config: Required<VersionHistoryConfig>
970
+ ): Promise<VersionResult> {
971
+ const storagePath = getVersionStoragePath(
972
+ projectRoot,
973
+ collection,
974
+ contentId,
975
+ config
976
+ );
977
+
978
+ // Use lock to prevent concurrent manifest modifications
979
+ return withLock(storagePath, async () => {
980
+ try {
981
+ const versionPath = getVersionFilePath(storagePath, versionId);
982
+
983
+ // Check if version file exists
984
+ if (!existsSync(versionPath)) {
985
+ return { success: false, error: `Version not found: ${versionId}` };
986
+ }
987
+
988
+ // Get manifest
989
+ const manifest = await getOrRecoverManifest(
990
+ storagePath,
991
+ collection,
992
+ contentId
993
+ );
994
+
995
+ // Find version entry
996
+ const entryIndex = manifest.versions.findIndex((v) => v.id === versionId);
997
+ const entry = entryIndex >= 0 ? manifest.versions[entryIndex] : undefined;
998
+
999
+ // Delete version file
1000
+ await unlink(versionPath);
1001
+
1002
+ // Update manifest
1003
+ if (entryIndex >= 0) {
1004
+ manifest.versions.splice(entryIndex, 1);
1005
+ manifest.updatedAt = new Date().toISOString();
1006
+ await writeManifest(storagePath, manifest);
1007
+ }
1008
+
1009
+ return { success: true, version: entry };
1010
+ } catch (error) {
1011
+ const message = error instanceof Error ? error.message : String(error);
1012
+ console.error(`[writenex] Failed to delete version:`, error);
1013
+ return { success: false, error: `Failed to delete version: ${message}` };
1014
+ }
1015
+ });
1016
+ }
1017
+
1018
+ /**
1019
+ * Clear all versions for a content item.
1020
+ *
1021
+ * Deletes all version files and resets the manifest to empty state.
1022
+ *
1023
+ * @param projectRoot - Absolute path to project root
1024
+ * @param collection - Collection name
1025
+ * @param contentId - Content item ID (slug)
1026
+ * @param config - Version history configuration
1027
+ * @returns Result of the clear operation
1028
+ *
1029
+ * @example
1030
+ * ```typescript
1031
+ * const result = await clearVersions(
1032
+ * '/project',
1033
+ * 'blog',
1034
+ * 'my-post',
1035
+ * { enabled: true, maxVersions: 20, storagePath: '.writenex/versions' }
1036
+ * );
1037
+ *
1038
+ * if (result.success) {
1039
+ * console.log('All versions cleared');
1040
+ * }
1041
+ * ```
1042
+ */
1043
+ export async function clearVersions(
1044
+ projectRoot: string,
1045
+ collection: string,
1046
+ contentId: string,
1047
+ config: Required<VersionHistoryConfig>
1048
+ ): Promise<VersionResult> {
1049
+ const storagePath = getVersionStoragePath(
1050
+ projectRoot,
1051
+ collection,
1052
+ contentId,
1053
+ config
1054
+ );
1055
+
1056
+ // Check if storage directory exists (no lock needed for this check)
1057
+ if (!existsSync(storagePath)) {
1058
+ return { success: true };
1059
+ }
1060
+
1061
+ // Use lock to prevent concurrent manifest modifications
1062
+ return withLock(storagePath, async () => {
1063
+ try {
1064
+ // Get all version files
1065
+ const files = await readdir(storagePath);
1066
+ const versionFiles = files.filter(
1067
+ (f) => f.endsWith(".md") && f !== "manifest.json"
1068
+ );
1069
+
1070
+ // Delete all version files
1071
+ for (const file of versionFiles) {
1072
+ const filePath = join(storagePath, file);
1073
+ try {
1074
+ await unlink(filePath);
1075
+ } catch {
1076
+ // Ignore errors for individual files
1077
+ }
1078
+ }
1079
+
1080
+ // Reset manifest
1081
+ const manifest = createEmptyManifest(collection, contentId);
1082
+ await writeManifest(storagePath, manifest);
1083
+
1084
+ return { success: true };
1085
+ } catch (error) {
1086
+ const message = error instanceof Error ? error.message : String(error);
1087
+ console.error(`[writenex] Failed to clear versions:`, error);
1088
+ return { success: false, error: `Failed to clear versions: ${message}` };
1089
+ }
1090
+ });
1091
+ }
1092
+
1093
+ /**
1094
+ * Prune old versions to maintain the maximum limit.
1095
+ *
1096
+ * Deletes the oldest unlabeled versions when the count exceeds maxVersions.
1097
+ * Labeled versions are preserved regardless of count.
1098
+ *
1099
+ * @param projectRoot - Absolute path to project root
1100
+ * @param collection - Collection name
1101
+ * @param contentId - Content item ID (slug)
1102
+ * @param config - Version history configuration
1103
+ * @returns Result of the prune operation
1104
+ *
1105
+ * @example
1106
+ * ```typescript
1107
+ * const result = await pruneVersions(
1108
+ * '/project',
1109
+ * 'blog',
1110
+ * 'my-post',
1111
+ * { enabled: true, maxVersions: 20, storagePath: '.writenex/versions' }
1112
+ * );
1113
+ * ```
1114
+ */
1115
+ /**
1116
+ * Internal prune function that assumes lock is already held.
1117
+ *
1118
+ * @param storagePath - Version storage directory path
1119
+ * @param config - Version history configuration
1120
+ * @returns Result of the prune operation
1121
+ */
1122
+ async function pruneVersionsInternal(
1123
+ storagePath: string,
1124
+ config: Required<VersionHistoryConfig>
1125
+ ): Promise<VersionResult> {
1126
+ try {
1127
+ // Check if storage directory exists
1128
+ if (!existsSync(storagePath)) {
1129
+ return { success: true };
1130
+ }
1131
+
1132
+ // Get manifest (read fresh to ensure we have latest data)
1133
+ const manifest = await readManifest(storagePath);
1134
+ if (!manifest) {
1135
+ return { success: true };
1136
+ }
1137
+
1138
+ // Separate labeled and unlabeled versions
1139
+ const labeledVersions = manifest.versions.filter((v) => v.label);
1140
+ const unlabeledVersions = manifest.versions.filter((v) => !v.label);
1141
+
1142
+ // Check if pruning is needed
1143
+ if (unlabeledVersions.length <= config.maxVersions) {
1144
+ return { success: true };
1145
+ }
1146
+
1147
+ // Sort unlabeled versions by timestamp (oldest first for deletion)
1148
+ const sortedUnlabeled = [...unlabeledVersions].sort(
1149
+ (a, b) =>
1150
+ new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
1151
+ );
1152
+
1153
+ // Calculate how many to delete
1154
+ const toDelete = sortedUnlabeled.slice(
1155
+ 0,
1156
+ unlabeledVersions.length - config.maxVersions
1157
+ );
1158
+
1159
+ // Delete old version files
1160
+ for (const version of toDelete) {
1161
+ const versionPath = getVersionFilePath(storagePath, version.id);
1162
+ try {
1163
+ if (existsSync(versionPath)) {
1164
+ await unlink(versionPath);
1165
+ }
1166
+ } catch {
1167
+ // Ignore errors for individual files
1168
+ }
1169
+ }
1170
+
1171
+ // Update manifest - keep labeled + remaining unlabeled
1172
+ const remainingUnlabeled = sortedUnlabeled.slice(
1173
+ unlabeledVersions.length - config.maxVersions
1174
+ );
1175
+ manifest.versions = [...labeledVersions, ...remainingUnlabeled].sort(
1176
+ (a, b) =>
1177
+ new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
1178
+ );
1179
+ manifest.updatedAt = new Date().toISOString();
1180
+
1181
+ await writeManifest(storagePath, manifest);
1182
+
1183
+ return { success: true };
1184
+ } catch (error) {
1185
+ const message = error instanceof Error ? error.message : String(error);
1186
+ console.error(`[writenex] Failed to prune versions:`, error);
1187
+ return { success: false, error: `Failed to prune versions: ${message}` };
1188
+ }
1189
+ }
1190
+
1191
+ export async function pruneVersions(
1192
+ projectRoot: string,
1193
+ collection: string,
1194
+ contentId: string,
1195
+ config: Required<VersionHistoryConfig>
1196
+ ): Promise<VersionResult> {
1197
+ const storagePath = getVersionStoragePath(
1198
+ projectRoot,
1199
+ collection,
1200
+ contentId,
1201
+ config
1202
+ );
1203
+
1204
+ // Check if storage directory exists (no lock needed for this check)
1205
+ if (!existsSync(storagePath)) {
1206
+ return { success: true };
1207
+ }
1208
+
1209
+ // Use lock to prevent concurrent manifest modifications
1210
+ return withLock(storagePath, () =>
1211
+ pruneVersionsInternal(storagePath, config)
1212
+ );
1213
+ }
1214
+
1215
+ // =============================================================================
1216
+ // Restore Operations
1217
+ // =============================================================================
1218
+
1219
+ /**
1220
+ * Restore a version to current content.
1221
+ *
1222
+ * This function:
1223
+ * 1. Creates a safety snapshot of the current content before restoring
1224
+ * 2. Reads the version content to restore
1225
+ * 3. Overwrites the current content file with the version content
1226
+ *
1227
+ * Note: Cache invalidation should be handled by the caller (API route)
1228
+ * since the cache is managed at the server level.
1229
+ *
1230
+ * @param projectRoot - Absolute path to project root
1231
+ * @param collection - Collection name
1232
+ * @param contentId - Content item ID (slug)
1233
+ * @param versionId - Version ID to restore
1234
+ * @param contentFilePath - Absolute path to the current content file
1235
+ * @param config - Version history configuration
1236
+ * @param options - Restore options
1237
+ * @returns Result of the restore operation
1238
+ *
1239
+ * @example
1240
+ * ```typescript
1241
+ * const result = await restoreVersion(
1242
+ * '/project',
1243
+ * 'blog',
1244
+ * 'my-post',
1245
+ * '2024-12-11T10-30-00-000Z',
1246
+ * '/project/src/content/blog/my-post.md',
1247
+ * { enabled: true, maxVersions: 20, storagePath: '.writenex/versions' }
1248
+ * );
1249
+ *
1250
+ * if (result.success) {
1251
+ * console.log('Restored content:', result.content);
1252
+ * if (result.safetySnapshot) {
1253
+ * console.log('Safety snapshot created:', result.safetySnapshot.id);
1254
+ * }
1255
+ * }
1256
+ * ```
1257
+ */
1258
+ export async function restoreVersion(
1259
+ projectRoot: string,
1260
+ collection: string,
1261
+ contentId: string,
1262
+ versionId: string,
1263
+ contentFilePath: string,
1264
+ config: Required<VersionHistoryConfig>,
1265
+ options: RestoreVersionOptions = {}
1266
+ ): Promise<RestoreResult> {
1267
+ const { safetySnapshotLabel = "Before restore", skipSafetySnapshot = false } =
1268
+ options;
1269
+
1270
+ try {
1271
+ // Step 1: Get the version to restore
1272
+ const versionToRestore = await getVersion(
1273
+ projectRoot,
1274
+ collection,
1275
+ contentId,
1276
+ versionId,
1277
+ config
1278
+ );
1279
+
1280
+ if (!versionToRestore) {
1281
+ return {
1282
+ success: false,
1283
+ error: `Version not found: ${versionId}`,
1284
+ };
1285
+ }
1286
+
1287
+ // Step 2: Read current content and create safety snapshot
1288
+ let safetySnapshot: VersionEntry | undefined;
1289
+
1290
+ if (!skipSafetySnapshot && existsSync(contentFilePath)) {
1291
+ try {
1292
+ const currentContent = await readFile(contentFilePath, "utf-8");
1293
+
1294
+ // Create safety snapshot with label
1295
+ const snapshotResult = await saveVersion(
1296
+ projectRoot,
1297
+ collection,
1298
+ contentId,
1299
+ currentContent,
1300
+ config,
1301
+ { label: safetySnapshotLabel }
1302
+ );
1303
+
1304
+ if (snapshotResult.success && snapshotResult.version) {
1305
+ safetySnapshot = snapshotResult.version;
1306
+ }
1307
+ } catch (error) {
1308
+ // Log warning but continue with restore
1309
+ console.warn(
1310
+ `[writenex] Failed to create safety snapshot before restore:`,
1311
+ error
1312
+ );
1313
+ }
1314
+ }
1315
+
1316
+ // Step 3: Overwrite current content file with version content
1317
+ await writeFile(contentFilePath, versionToRestore.content, "utf-8");
1318
+
1319
+ return {
1320
+ success: true,
1321
+ version: {
1322
+ id: versionToRestore.id,
1323
+ timestamp: versionToRestore.timestamp,
1324
+ preview: versionToRestore.preview,
1325
+ size: versionToRestore.size,
1326
+ ...(versionToRestore.label ? { label: versionToRestore.label } : {}),
1327
+ },
1328
+ content: versionToRestore.content,
1329
+ safetySnapshot,
1330
+ };
1331
+ } catch (error) {
1332
+ const message = error instanceof Error ? error.message : String(error);
1333
+ console.error(`[writenex] Failed to restore version:`, error);
1334
+ return {
1335
+ success: false,
1336
+ error: `Failed to restore version: ${message}`,
1337
+ };
1338
+ }
1339
+ }