@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,739 @@
1
+ /**
2
+ * @fileoverview Custom error classes for @writenex/astro
3
+ *
4
+ * This module provides categorized error types for better error handling,
5
+ * debugging, and user-facing error messages across the integration.
6
+ *
7
+ * ## Error Categories:
8
+ * - Configuration errors (invalid config, missing files)
9
+ * - Filesystem errors (read/write failures, permissions)
10
+ * - Content errors (parsing, validation, not found)
11
+ * - API errors (request handling, validation)
12
+ * - Version history errors (manifest, storage)
13
+ *
14
+ * @module @writenex/astro/core/errors
15
+ */
16
+
17
+ /**
18
+ * Error codes for categorization and i18n support
19
+ */
20
+ export enum WritenexErrorCode {
21
+ // Configuration errors (1xx)
22
+ CONFIG_NOT_FOUND = "CONFIG_NOT_FOUND",
23
+ CONFIG_INVALID = "CONFIG_INVALID",
24
+ CONFIG_PARSE_ERROR = "CONFIG_PARSE_ERROR",
25
+
26
+ // Filesystem errors (2xx)
27
+ FS_READ_ERROR = "FS_READ_ERROR",
28
+ FS_WRITE_ERROR = "FS_WRITE_ERROR",
29
+ FS_DELETE_ERROR = "FS_DELETE_ERROR",
30
+ FS_PERMISSION_DENIED = "FS_PERMISSION_DENIED",
31
+ FS_PATH_NOT_FOUND = "FS_PATH_NOT_FOUND",
32
+ FS_PATH_TRAVERSAL = "FS_PATH_TRAVERSAL",
33
+
34
+ // Content errors (3xx)
35
+ CONTENT_NOT_FOUND = "CONTENT_NOT_FOUND",
36
+ CONTENT_PARSE_ERROR = "CONTENT_PARSE_ERROR",
37
+ CONTENT_VALIDATION_ERROR = "CONTENT_VALIDATION_ERROR",
38
+ CONTENT_ALREADY_EXISTS = "CONTENT_ALREADY_EXISTS",
39
+ CONTENT_INVALID_SLUG = "CONTENT_INVALID_SLUG",
40
+ CONTENT_CONFLICT = "CONTENT_CONFLICT",
41
+
42
+ // Collection errors (4xx)
43
+ COLLECTION_NOT_FOUND = "COLLECTION_NOT_FOUND",
44
+ COLLECTION_EMPTY = "COLLECTION_EMPTY",
45
+ COLLECTION_DISCOVERY_ERROR = "COLLECTION_DISCOVERY_ERROR",
46
+
47
+ // API errors (5xx)
48
+ API_BAD_REQUEST = "API_BAD_REQUEST",
49
+ API_METHOD_NOT_ALLOWED = "API_METHOD_NOT_ALLOWED",
50
+ API_INTERNAL_ERROR = "API_INTERNAL_ERROR",
51
+ API_TIMEOUT = "API_TIMEOUT",
52
+
53
+ // Image errors (6xx)
54
+ IMAGE_INVALID_TYPE = "IMAGE_INVALID_TYPE",
55
+ IMAGE_TOO_LARGE = "IMAGE_TOO_LARGE",
56
+ IMAGE_UPLOAD_ERROR = "IMAGE_UPLOAD_ERROR",
57
+ IMAGE_NOT_FOUND = "IMAGE_NOT_FOUND",
58
+
59
+ // Version history errors (7xx)
60
+ VERSION_NOT_FOUND = "VERSION_NOT_FOUND",
61
+ VERSION_MANIFEST_CORRUPT = "VERSION_MANIFEST_CORRUPT",
62
+ VERSION_LOCK_TIMEOUT = "VERSION_LOCK_TIMEOUT",
63
+ VERSION_SAVE_ERROR = "VERSION_SAVE_ERROR",
64
+ VERSION_RESTORE_ERROR = "VERSION_RESTORE_ERROR",
65
+
66
+ // Pattern errors (8xx)
67
+ PATTERN_INVALID = "PATTERN_INVALID",
68
+ PATTERN_MISSING_TOKEN = "PATTERN_MISSING_TOKEN",
69
+
70
+ // Unknown
71
+ UNKNOWN_ERROR = "UNKNOWN_ERROR",
72
+ }
73
+
74
+ /**
75
+ * HTTP status codes mapped to error categories
76
+ */
77
+ export const ERROR_HTTP_STATUS: Record<WritenexErrorCode, number> = {
78
+ // Configuration errors - 500 (server misconfiguration)
79
+ [WritenexErrorCode.CONFIG_NOT_FOUND]: 500,
80
+ [WritenexErrorCode.CONFIG_INVALID]: 500,
81
+ [WritenexErrorCode.CONFIG_PARSE_ERROR]: 500,
82
+
83
+ // Filesystem errors
84
+ [WritenexErrorCode.FS_READ_ERROR]: 500,
85
+ [WritenexErrorCode.FS_WRITE_ERROR]: 500,
86
+ [WritenexErrorCode.FS_DELETE_ERROR]: 500,
87
+ [WritenexErrorCode.FS_PERMISSION_DENIED]: 403,
88
+ [WritenexErrorCode.FS_PATH_NOT_FOUND]: 404,
89
+ [WritenexErrorCode.FS_PATH_TRAVERSAL]: 400,
90
+
91
+ // Content errors
92
+ [WritenexErrorCode.CONTENT_NOT_FOUND]: 404,
93
+ [WritenexErrorCode.CONTENT_PARSE_ERROR]: 500,
94
+ [WritenexErrorCode.CONTENT_VALIDATION_ERROR]: 400,
95
+ [WritenexErrorCode.CONTENT_ALREADY_EXISTS]: 409,
96
+ [WritenexErrorCode.CONTENT_INVALID_SLUG]: 400,
97
+ [WritenexErrorCode.CONTENT_CONFLICT]: 409,
98
+
99
+ // Collection errors
100
+ [WritenexErrorCode.COLLECTION_NOT_FOUND]: 404,
101
+ [WritenexErrorCode.COLLECTION_EMPTY]: 404,
102
+ [WritenexErrorCode.COLLECTION_DISCOVERY_ERROR]: 500,
103
+
104
+ // API errors
105
+ [WritenexErrorCode.API_BAD_REQUEST]: 400,
106
+ [WritenexErrorCode.API_METHOD_NOT_ALLOWED]: 405,
107
+ [WritenexErrorCode.API_INTERNAL_ERROR]: 500,
108
+ [WritenexErrorCode.API_TIMEOUT]: 504,
109
+
110
+ // Image errors
111
+ [WritenexErrorCode.IMAGE_INVALID_TYPE]: 400,
112
+ [WritenexErrorCode.IMAGE_TOO_LARGE]: 413,
113
+ [WritenexErrorCode.IMAGE_UPLOAD_ERROR]: 500,
114
+ [WritenexErrorCode.IMAGE_NOT_FOUND]: 404,
115
+
116
+ // Version history errors
117
+ [WritenexErrorCode.VERSION_NOT_FOUND]: 404,
118
+ [WritenexErrorCode.VERSION_MANIFEST_CORRUPT]: 500,
119
+ [WritenexErrorCode.VERSION_LOCK_TIMEOUT]: 503,
120
+ [WritenexErrorCode.VERSION_SAVE_ERROR]: 500,
121
+ [WritenexErrorCode.VERSION_RESTORE_ERROR]: 500,
122
+
123
+ // Pattern errors
124
+ [WritenexErrorCode.PATTERN_INVALID]: 400,
125
+ [WritenexErrorCode.PATTERN_MISSING_TOKEN]: 400,
126
+
127
+ // Unknown
128
+ [WritenexErrorCode.UNKNOWN_ERROR]: 500,
129
+ };
130
+
131
+ /**
132
+ * Base error class for all Writenex errors
133
+ *
134
+ * Provides structured error information including error code,
135
+ * HTTP status, and optional context data for debugging.
136
+ */
137
+ export class WritenexError extends Error {
138
+ /** Error code for categorization */
139
+ readonly code: WritenexErrorCode;
140
+
141
+ /** HTTP status code for API responses */
142
+ readonly httpStatus: number;
143
+
144
+ /** Additional context data for debugging */
145
+ readonly context?: Record<string, unknown>;
146
+
147
+ /** Original error if this wraps another error */
148
+ readonly cause?: Error;
149
+
150
+ constructor(
151
+ code: WritenexErrorCode,
152
+ message: string,
153
+ options?: {
154
+ context?: Record<string, unknown>;
155
+ cause?: Error;
156
+ }
157
+ ) {
158
+ super(message);
159
+ this.name = "WritenexError";
160
+ this.code = code;
161
+ this.httpStatus = ERROR_HTTP_STATUS[code];
162
+ this.context = options?.context;
163
+ this.cause = options?.cause;
164
+
165
+ // Maintains proper stack trace for where error was thrown
166
+ if (Error.captureStackTrace) {
167
+ Error.captureStackTrace(this, WritenexError);
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Convert error to JSON for API responses
173
+ */
174
+ toJSON(): Record<string, unknown> {
175
+ return {
176
+ error: this.message,
177
+ code: this.code,
178
+ ...(this.context ? { context: this.context } : {}),
179
+ };
180
+ }
181
+
182
+ /**
183
+ * Create a user-friendly error message
184
+ */
185
+ toUserMessage(): string {
186
+ return this.message;
187
+ }
188
+ }
189
+
190
+ // =============================================================================
191
+ // Configuration Errors
192
+ // =============================================================================
193
+
194
+ /**
195
+ * Error thrown when configuration file is not found
196
+ */
197
+ export class ConfigNotFoundError extends WritenexError {
198
+ constructor(searchPaths: string[]) {
199
+ super(
200
+ WritenexErrorCode.CONFIG_NOT_FOUND,
201
+ `Configuration file not found. Searched: ${searchPaths.join(", ")}`,
202
+ { context: { searchPaths } }
203
+ );
204
+ this.name = "ConfigNotFoundError";
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Error thrown when configuration is invalid
210
+ */
211
+ export class ConfigInvalidError extends WritenexError {
212
+ constructor(errors: string[], configPath?: string) {
213
+ super(
214
+ WritenexErrorCode.CONFIG_INVALID,
215
+ `Invalid configuration: ${errors.join("; ")}`,
216
+ { context: { errors, configPath } }
217
+ );
218
+ this.name = "ConfigInvalidError";
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Error thrown when configuration file cannot be parsed
224
+ */
225
+ export class ConfigParseError extends WritenexError {
226
+ constructor(configPath: string, cause?: Error) {
227
+ super(
228
+ WritenexErrorCode.CONFIG_PARSE_ERROR,
229
+ `Failed to parse configuration file: ${configPath}`,
230
+ { context: { configPath }, cause }
231
+ );
232
+ this.name = "ConfigParseError";
233
+ }
234
+ }
235
+
236
+ // =============================================================================
237
+ // Filesystem Errors
238
+ // =============================================================================
239
+
240
+ /**
241
+ * Error thrown when file read operation fails
242
+ */
243
+ export class FileReadError extends WritenexError {
244
+ constructor(filePath: string, cause?: Error) {
245
+ super(WritenexErrorCode.FS_READ_ERROR, `Failed to read file: ${filePath}`, {
246
+ context: { filePath },
247
+ cause,
248
+ });
249
+ this.name = "FileReadError";
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Error thrown when file write operation fails
255
+ */
256
+ export class FileWriteError extends WritenexError {
257
+ constructor(filePath: string, cause?: Error) {
258
+ super(
259
+ WritenexErrorCode.FS_WRITE_ERROR,
260
+ `Failed to write file: ${filePath}`,
261
+ { context: { filePath }, cause }
262
+ );
263
+ this.name = "FileWriteError";
264
+ }
265
+ }
266
+
267
+ /**
268
+ * Error thrown when file delete operation fails
269
+ */
270
+ export class FileDeleteError extends WritenexError {
271
+ constructor(filePath: string, cause?: Error) {
272
+ super(
273
+ WritenexErrorCode.FS_DELETE_ERROR,
274
+ `Failed to delete file: ${filePath}`,
275
+ { context: { filePath }, cause }
276
+ );
277
+ this.name = "FileDeleteError";
278
+ }
279
+ }
280
+
281
+ /**
282
+ * Error thrown when path is not found
283
+ */
284
+ export class PathNotFoundError extends WritenexError {
285
+ constructor(path: string, type: "file" | "directory" = "file") {
286
+ super(
287
+ WritenexErrorCode.FS_PATH_NOT_FOUND,
288
+ `${type === "directory" ? "Directory" : "File"} not found: ${path}`,
289
+ { context: { path, type } }
290
+ );
291
+ this.name = "PathNotFoundError";
292
+ }
293
+ }
294
+
295
+ /**
296
+ * Error thrown when path traversal attack is detected
297
+ */
298
+ export class PathTraversalError extends WritenexError {
299
+ constructor(requestedPath: string, basePath: string) {
300
+ super(
301
+ WritenexErrorCode.FS_PATH_TRAVERSAL,
302
+ "Invalid path: attempted path traversal detected",
303
+ { context: { requestedPath, basePath } }
304
+ );
305
+ this.name = "PathTraversalError";
306
+ }
307
+ }
308
+
309
+ // =============================================================================
310
+ // Content Errors
311
+ // =============================================================================
312
+
313
+ /**
314
+ * Error thrown when content item is not found
315
+ */
316
+ export class ContentNotFoundError extends WritenexError {
317
+ constructor(collection: string, contentId: string) {
318
+ super(
319
+ WritenexErrorCode.CONTENT_NOT_FOUND,
320
+ `Content '${contentId}' not found in collection '${collection}'`,
321
+ { context: { collection, contentId } }
322
+ );
323
+ this.name = "ContentNotFoundError";
324
+ }
325
+ }
326
+
327
+ /**
328
+ * Error thrown when content parsing fails
329
+ */
330
+ export class ContentParseError extends WritenexError {
331
+ constructor(filePath: string, cause?: Error) {
332
+ super(
333
+ WritenexErrorCode.CONTENT_PARSE_ERROR,
334
+ `Failed to parse content file: ${filePath}`,
335
+ { context: { filePath }, cause }
336
+ );
337
+ this.name = "ContentParseError";
338
+ }
339
+ }
340
+
341
+ /**
342
+ * Error thrown when content validation fails
343
+ */
344
+ export class ContentValidationError extends WritenexError {
345
+ constructor(errors: string[], contentId?: string) {
346
+ super(
347
+ WritenexErrorCode.CONTENT_VALIDATION_ERROR,
348
+ `Content validation failed: ${errors.join("; ")}`,
349
+ { context: { errors, contentId } }
350
+ );
351
+ this.name = "ContentValidationError";
352
+ }
353
+ }
354
+
355
+ /**
356
+ * Error thrown when content already exists
357
+ */
358
+ export class ContentAlreadyExistsError extends WritenexError {
359
+ constructor(collection: string, slug: string) {
360
+ super(
361
+ WritenexErrorCode.CONTENT_ALREADY_EXISTS,
362
+ `Content with slug '${slug}' already exists in collection '${collection}'`,
363
+ { context: { collection, slug } }
364
+ );
365
+ this.name = "ContentAlreadyExistsError";
366
+ }
367
+ }
368
+
369
+ /**
370
+ * Error thrown when content was modified externally (conflict detection)
371
+ *
372
+ * This error includes both the server version and the client's expected mtime
373
+ * to help resolve the conflict.
374
+ */
375
+ export class ContentConflictError extends WritenexError {
376
+ /** Current content on disk */
377
+ readonly serverContent: string;
378
+ /** Server's current mtime */
379
+ readonly serverMtime: number;
380
+ /** Client's expected mtime */
381
+ readonly clientMtime: number;
382
+
383
+ constructor(
384
+ collection: string,
385
+ contentId: string,
386
+ serverContent: string,
387
+ serverMtime: number,
388
+ clientMtime: number
389
+ ) {
390
+ super(
391
+ WritenexErrorCode.CONTENT_CONFLICT,
392
+ `Content '${contentId}' in '${collection}' was modified externally. ` +
393
+ `Expected mtime: ${clientMtime}, actual: ${serverMtime}`,
394
+ {
395
+ context: {
396
+ collection,
397
+ contentId,
398
+ serverMtime,
399
+ clientMtime,
400
+ timeDiff: serverMtime - clientMtime,
401
+ },
402
+ }
403
+ );
404
+ this.name = "ContentConflictError";
405
+ this.serverContent = serverContent;
406
+ this.serverMtime = serverMtime;
407
+ this.clientMtime = clientMtime;
408
+ }
409
+
410
+ /**
411
+ * Override toJSON to include conflict-specific data
412
+ */
413
+ override toJSON(): Record<string, unknown> {
414
+ return {
415
+ ...super.toJSON(),
416
+ serverContent: this.serverContent,
417
+ serverMtime: this.serverMtime,
418
+ clientMtime: this.clientMtime,
419
+ };
420
+ }
421
+ }
422
+
423
+ // =============================================================================
424
+ // Collection Errors
425
+ // =============================================================================
426
+
427
+ /**
428
+ * Error thrown when collection is not found
429
+ */
430
+ export class CollectionNotFoundError extends WritenexError {
431
+ constructor(collectionName: string) {
432
+ super(
433
+ WritenexErrorCode.COLLECTION_NOT_FOUND,
434
+ `Collection '${collectionName}' not found`,
435
+ { context: { collectionName } }
436
+ );
437
+ this.name = "CollectionNotFoundError";
438
+ }
439
+ }
440
+
441
+ /**
442
+ * Error thrown when collection discovery fails
443
+ */
444
+ export class CollectionDiscoveryError extends WritenexError {
445
+ constructor(contentPath: string, cause?: Error) {
446
+ super(
447
+ WritenexErrorCode.COLLECTION_DISCOVERY_ERROR,
448
+ `Failed to discover collections in: ${contentPath}`,
449
+ { context: { contentPath }, cause }
450
+ );
451
+ this.name = "CollectionDiscoveryError";
452
+ }
453
+ }
454
+
455
+ // =============================================================================
456
+ // API Errors
457
+ // =============================================================================
458
+
459
+ /**
460
+ * Error thrown for bad API requests
461
+ */
462
+ export class ApiBadRequestError extends WritenexError {
463
+ constructor(message: string, details?: Record<string, unknown>) {
464
+ super(WritenexErrorCode.API_BAD_REQUEST, message, { context: details });
465
+ this.name = "ApiBadRequestError";
466
+ }
467
+ }
468
+
469
+ /**
470
+ * Error thrown when HTTP method is not allowed
471
+ */
472
+ export class ApiMethodNotAllowedError extends WritenexError {
473
+ constructor(method: string, allowedMethods: string[]) {
474
+ super(
475
+ WritenexErrorCode.API_METHOD_NOT_ALLOWED,
476
+ `Method ${method} not allowed. Allowed: ${allowedMethods.join(", ")}`,
477
+ { context: { method, allowedMethods } }
478
+ );
479
+ this.name = "ApiMethodNotAllowedError";
480
+ }
481
+ }
482
+
483
+ /**
484
+ * Error thrown for API timeout
485
+ */
486
+ export class ApiTimeoutError extends WritenexError {
487
+ constructor(operation: string, timeoutMs: number) {
488
+ super(
489
+ WritenexErrorCode.API_TIMEOUT,
490
+ `Operation '${operation}' timed out after ${timeoutMs}ms`,
491
+ { context: { operation, timeoutMs } }
492
+ );
493
+ this.name = "ApiTimeoutError";
494
+ }
495
+ }
496
+
497
+ // =============================================================================
498
+ // Image Errors
499
+ // =============================================================================
500
+
501
+ /**
502
+ * Error thrown when image type is invalid
503
+ */
504
+ export class ImageInvalidTypeError extends WritenexError {
505
+ constructor(filename: string, supportedTypes: string[]) {
506
+ super(
507
+ WritenexErrorCode.IMAGE_INVALID_TYPE,
508
+ `Invalid image type for '${filename}'. Supported: ${supportedTypes.join(", ")}`,
509
+ { context: { filename, supportedTypes } }
510
+ );
511
+ this.name = "ImageInvalidTypeError";
512
+ }
513
+ }
514
+
515
+ /**
516
+ * Error thrown when image is too large
517
+ */
518
+ export class ImageTooLargeError extends WritenexError {
519
+ constructor(filename: string, size: number, maxSize: number) {
520
+ super(
521
+ WritenexErrorCode.IMAGE_TOO_LARGE,
522
+ `Image '${filename}' is too large (${formatBytes(size)}). Maximum: ${formatBytes(maxSize)}`,
523
+ { context: { filename, size, maxSize } }
524
+ );
525
+ this.name = "ImageTooLargeError";
526
+ }
527
+ }
528
+
529
+ /**
530
+ * Error thrown when image upload fails
531
+ */
532
+ export class ImageUploadError extends WritenexError {
533
+ constructor(filename: string, cause?: Error) {
534
+ super(
535
+ WritenexErrorCode.IMAGE_UPLOAD_ERROR,
536
+ `Failed to upload image: ${filename}`,
537
+ { context: { filename }, cause }
538
+ );
539
+ this.name = "ImageUploadError";
540
+ }
541
+ }
542
+
543
+ /**
544
+ * Error thrown when image is not found
545
+ */
546
+ export class ImageNotFoundError extends WritenexError {
547
+ constructor(imagePath: string) {
548
+ super(WritenexErrorCode.IMAGE_NOT_FOUND, `Image not found: ${imagePath}`, {
549
+ context: { imagePath },
550
+ });
551
+ this.name = "ImageNotFoundError";
552
+ }
553
+ }
554
+
555
+ // =============================================================================
556
+ // Version History Errors
557
+ // =============================================================================
558
+
559
+ /**
560
+ * Error thrown when version is not found
561
+ */
562
+ export class VersionNotFoundError extends WritenexError {
563
+ constructor(collection: string, contentId: string, versionId: string) {
564
+ super(
565
+ WritenexErrorCode.VERSION_NOT_FOUND,
566
+ `Version '${versionId}' not found for content '${contentId}' in '${collection}'`,
567
+ { context: { collection, contentId, versionId } }
568
+ );
569
+ this.name = "VersionNotFoundError";
570
+ }
571
+ }
572
+
573
+ /**
574
+ * Error thrown when version manifest is corrupt
575
+ */
576
+ export class VersionManifestCorruptError extends WritenexError {
577
+ constructor(manifestPath: string, cause?: Error) {
578
+ super(
579
+ WritenexErrorCode.VERSION_MANIFEST_CORRUPT,
580
+ `Version manifest is corrupt: ${manifestPath}`,
581
+ { context: { manifestPath }, cause }
582
+ );
583
+ this.name = "VersionManifestCorruptError";
584
+ }
585
+ }
586
+
587
+ /**
588
+ * Error thrown when version lock times out
589
+ */
590
+ export class VersionLockTimeoutError extends WritenexError {
591
+ constructor(storagePath: string, timeoutMs: number) {
592
+ super(
593
+ WritenexErrorCode.VERSION_LOCK_TIMEOUT,
594
+ `Timeout waiting for version lock on ${storagePath} after ${timeoutMs}ms`,
595
+ { context: { storagePath, timeoutMs } }
596
+ );
597
+ this.name = "VersionLockTimeoutError";
598
+ }
599
+ }
600
+
601
+ /**
602
+ * Error thrown when version save fails
603
+ */
604
+ export class VersionSaveError extends WritenexError {
605
+ constructor(collection: string, contentId: string, cause?: Error) {
606
+ super(
607
+ WritenexErrorCode.VERSION_SAVE_ERROR,
608
+ `Failed to save version for '${contentId}' in '${collection}'`,
609
+ { context: { collection, contentId }, cause }
610
+ );
611
+ this.name = "VersionSaveError";
612
+ }
613
+ }
614
+
615
+ /**
616
+ * Error thrown when version restore fails
617
+ */
618
+ export class VersionRestoreError extends WritenexError {
619
+ constructor(
620
+ collection: string,
621
+ contentId: string,
622
+ versionId: string,
623
+ cause?: Error
624
+ ) {
625
+ super(
626
+ WritenexErrorCode.VERSION_RESTORE_ERROR,
627
+ `Failed to restore version '${versionId}' for '${contentId}' in '${collection}'`,
628
+ { context: { collection, contentId, versionId }, cause }
629
+ );
630
+ this.name = "VersionRestoreError";
631
+ }
632
+ }
633
+
634
+ // =============================================================================
635
+ // Pattern Errors
636
+ // =============================================================================
637
+
638
+ /**
639
+ * Error thrown when file pattern is invalid
640
+ */
641
+ export class PatternInvalidError extends WritenexError {
642
+ constructor(pattern: string, reason: string) {
643
+ super(
644
+ WritenexErrorCode.PATTERN_INVALID,
645
+ `Invalid file pattern '${pattern}': ${reason}`,
646
+ { context: { pattern, reason } }
647
+ );
648
+ this.name = "PatternInvalidError";
649
+ }
650
+ }
651
+
652
+ /**
653
+ * Error thrown when required pattern token is missing
654
+ */
655
+ export class PatternMissingTokenError extends WritenexError {
656
+ constructor(pattern: string, missingToken: string) {
657
+ super(
658
+ WritenexErrorCode.PATTERN_MISSING_TOKEN,
659
+ `Pattern '${pattern}' is missing required token: {${missingToken}}`,
660
+ { context: { pattern, missingToken } }
661
+ );
662
+ this.name = "PatternMissingTokenError";
663
+ }
664
+ }
665
+
666
+ // =============================================================================
667
+ // Utility Functions
668
+ // =============================================================================
669
+
670
+ /**
671
+ * Format bytes to human-readable string
672
+ */
673
+ function formatBytes(bytes: number): string {
674
+ if (bytes === 0) return "0 Bytes";
675
+ const k = 1024;
676
+ const sizes = ["Bytes", "KB", "MB", "GB"];
677
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
678
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
679
+ }
680
+
681
+ /**
682
+ * Check if an error is a WritenexError
683
+ */
684
+ export function isWritenexError(error: unknown): error is WritenexError {
685
+ return error instanceof WritenexError;
686
+ }
687
+
688
+ /**
689
+ * Wrap an unknown error as a WritenexError
690
+ *
691
+ * Useful for catch blocks where the error type is unknown.
692
+ */
693
+ export function wrapError(
694
+ error: unknown,
695
+ defaultCode: WritenexErrorCode = WritenexErrorCode.UNKNOWN_ERROR
696
+ ): WritenexError {
697
+ if (isWritenexError(error)) {
698
+ return error;
699
+ }
700
+
701
+ const message = error instanceof Error ? error.message : String(error);
702
+ const cause = error instanceof Error ? error : undefined;
703
+
704
+ return new WritenexError(defaultCode, message, { cause });
705
+ }
706
+
707
+ /**
708
+ * Create error from Node.js filesystem error
709
+ */
710
+ export function fromNodeError(
711
+ error: NodeJS.ErrnoException,
712
+ filePath: string
713
+ ): WritenexError {
714
+ switch (error.code) {
715
+ case "ENOENT":
716
+ return new PathNotFoundError(filePath);
717
+ case "EACCES":
718
+ case "EPERM":
719
+ return new WritenexError(
720
+ WritenexErrorCode.FS_PERMISSION_DENIED,
721
+ `Permission denied: ${filePath}`,
722
+ { context: { filePath, errno: error.code }, cause: error }
723
+ );
724
+ case "EISDIR":
725
+ return new WritenexError(
726
+ WritenexErrorCode.FS_READ_ERROR,
727
+ `Expected file but found directory: ${filePath}`,
728
+ { context: { filePath }, cause: error }
729
+ );
730
+ case "ENOTDIR":
731
+ return new WritenexError(
732
+ WritenexErrorCode.FS_READ_ERROR,
733
+ `Expected directory but found file: ${filePath}`,
734
+ { context: { filePath }, cause: error }
735
+ );
736
+ default:
737
+ return new FileReadError(filePath, error);
738
+ }
739
+ }