@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,540 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Filesystem writer for content operations
|
|
3
|
+
*
|
|
4
|
+
* This module provides functions for creating, updating, and deleting
|
|
5
|
+
* content files in Astro content collections.
|
|
6
|
+
*
|
|
7
|
+
* ## Features:
|
|
8
|
+
* - Create new content files with frontmatter
|
|
9
|
+
* - Update existing content files
|
|
10
|
+
* - Delete content files
|
|
11
|
+
* - Generate unique slugs to avoid collisions
|
|
12
|
+
* - Support for different file patterns (flat, folder-based, date-prefixed)
|
|
13
|
+
* - Automatic version history creation before updates
|
|
14
|
+
*
|
|
15
|
+
* @module @writenex/astro/filesystem/writer
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { writeFile, unlink, mkdir, readFile, stat } from "node:fs/promises";
|
|
19
|
+
import { existsSync } from "node:fs";
|
|
20
|
+
import { join, dirname, basename } from "node:path";
|
|
21
|
+
import slugify from "slugify";
|
|
22
|
+
import { readContentFile } from "./reader";
|
|
23
|
+
import { saveVersion } from "./versions";
|
|
24
|
+
import {
|
|
25
|
+
generatePathFromPattern,
|
|
26
|
+
resolvePatternTokens,
|
|
27
|
+
isValidPattern,
|
|
28
|
+
} from "@/discovery/patterns";
|
|
29
|
+
import type { VersionHistoryConfig } from "@/types";
|
|
30
|
+
import { ContentConflictError } from "@/core/errors";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Options for creating content
|
|
34
|
+
*/
|
|
35
|
+
export interface CreateContentOptions {
|
|
36
|
+
/** Frontmatter data */
|
|
37
|
+
frontmatter: Record<string, unknown>;
|
|
38
|
+
/** Markdown body content */
|
|
39
|
+
body: string;
|
|
40
|
+
/** Custom slug (optional, generated from title if not provided) */
|
|
41
|
+
slug?: string;
|
|
42
|
+
/** File pattern for the collection (e.g., "{slug}/index.md", "{date}-{slug}.md") */
|
|
43
|
+
filePattern?: string;
|
|
44
|
+
/** Custom token values to override automatic resolution */
|
|
45
|
+
customTokens?: Record<string, string>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Options for updating content
|
|
50
|
+
*/
|
|
51
|
+
export interface UpdateContentOptions {
|
|
52
|
+
/** Updated frontmatter data */
|
|
53
|
+
frontmatter?: Record<string, unknown>;
|
|
54
|
+
/** Updated markdown body content */
|
|
55
|
+
body?: string;
|
|
56
|
+
/** Project root for version history (required for version creation) */
|
|
57
|
+
projectRoot?: string;
|
|
58
|
+
/** Collection name for version history */
|
|
59
|
+
collection?: string;
|
|
60
|
+
/** Version history configuration */
|
|
61
|
+
versionHistoryConfig?: Required<VersionHistoryConfig>;
|
|
62
|
+
/**
|
|
63
|
+
* Expected modification time for conflict detection.
|
|
64
|
+
* If provided and the file's mtime differs, the update will fail with a conflict error.
|
|
65
|
+
*/
|
|
66
|
+
expectedMtime?: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Result of a write operation
|
|
71
|
+
*/
|
|
72
|
+
export interface WriteResult {
|
|
73
|
+
/** Whether the operation was successful */
|
|
74
|
+
success: boolean;
|
|
75
|
+
/** The content ID (slug) */
|
|
76
|
+
id?: string;
|
|
77
|
+
/** The file path */
|
|
78
|
+
path?: string;
|
|
79
|
+
/** Error message if failed */
|
|
80
|
+
error?: string;
|
|
81
|
+
/** New modification time after write (for conflict detection) */
|
|
82
|
+
mtime?: number;
|
|
83
|
+
/** Conflict error if update failed due to external modification */
|
|
84
|
+
conflict?: ContentConflictError;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Generate a URL-safe slug from a string
|
|
89
|
+
*
|
|
90
|
+
* @param text - Text to slugify
|
|
91
|
+
* @returns URL-safe slug
|
|
92
|
+
*/
|
|
93
|
+
export function generateSlug(text: string): string {
|
|
94
|
+
return slugify(text, {
|
|
95
|
+
lower: true,
|
|
96
|
+
strict: true,
|
|
97
|
+
trim: true,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Check if a content file already exists for a given slug and pattern
|
|
103
|
+
*
|
|
104
|
+
* @param slug - The slug to check
|
|
105
|
+
* @param collectionPath - Path to the collection directory
|
|
106
|
+
* @param filePattern - File pattern (e.g., "{slug}.md", "{slug}/index.md")
|
|
107
|
+
* @returns True if content already exists
|
|
108
|
+
*/
|
|
109
|
+
function contentExists(
|
|
110
|
+
slug: string,
|
|
111
|
+
collectionPath: string,
|
|
112
|
+
filePattern: string
|
|
113
|
+
): boolean {
|
|
114
|
+
const relativePath = generatePathFromPattern(filePattern, { slug });
|
|
115
|
+
const fullPath = join(collectionPath, relativePath);
|
|
116
|
+
|
|
117
|
+
// For folder-based patterns, check if the folder exists
|
|
118
|
+
if (filePattern.includes("/index.")) {
|
|
119
|
+
const folderPath = join(collectionPath, slug);
|
|
120
|
+
return existsSync(folderPath);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return existsSync(fullPath);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Generate a unique slug that doesn't conflict with existing files
|
|
128
|
+
*
|
|
129
|
+
* @param baseSlug - The base slug to start with
|
|
130
|
+
* @param collectionPath - Path to the collection directory
|
|
131
|
+
* @param filePattern - File pattern (default: "{slug}.md")
|
|
132
|
+
* @returns A unique slug
|
|
133
|
+
*/
|
|
134
|
+
export async function generateUniqueSlug(
|
|
135
|
+
baseSlug: string,
|
|
136
|
+
collectionPath: string,
|
|
137
|
+
filePattern: string = "{slug}.md"
|
|
138
|
+
): Promise<string> {
|
|
139
|
+
let slug = baseSlug;
|
|
140
|
+
let counter = 2;
|
|
141
|
+
|
|
142
|
+
while (contentExists(slug, collectionPath, filePattern)) {
|
|
143
|
+
slug = `${baseSlug}-${counter}`;
|
|
144
|
+
counter++;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return slug;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Convert frontmatter object to YAML string
|
|
152
|
+
*
|
|
153
|
+
* @param frontmatter - Frontmatter data
|
|
154
|
+
* @returns YAML string
|
|
155
|
+
*/
|
|
156
|
+
function frontmatterToYaml(frontmatter: Record<string, unknown>): string {
|
|
157
|
+
const lines: string[] = [];
|
|
158
|
+
|
|
159
|
+
for (const [key, value] of Object.entries(frontmatter)) {
|
|
160
|
+
if (value === undefined || value === null) {
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (typeof value === "string") {
|
|
165
|
+
// Quote strings that contain special characters
|
|
166
|
+
if (value.includes(":") || value.includes("#") || value.includes("\n")) {
|
|
167
|
+
lines.push(`${key}: "${value.replace(/"/g, '\\"')}"`);
|
|
168
|
+
} else {
|
|
169
|
+
lines.push(`${key}: ${value}`);
|
|
170
|
+
}
|
|
171
|
+
} else if (typeof value === "number" || typeof value === "boolean") {
|
|
172
|
+
lines.push(`${key}: ${value}`);
|
|
173
|
+
} else if (value instanceof Date) {
|
|
174
|
+
lines.push(`${key}: ${value.toISOString().split("T")[0]}`);
|
|
175
|
+
} else if (Array.isArray(value)) {
|
|
176
|
+
if (value.length === 0) {
|
|
177
|
+
lines.push(`${key}: []`);
|
|
178
|
+
} else {
|
|
179
|
+
lines.push(`${key}:`);
|
|
180
|
+
for (const item of value) {
|
|
181
|
+
lines.push(` - ${item}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
} else if (typeof value === "object") {
|
|
185
|
+
// Simple object serialization
|
|
186
|
+
lines.push(`${key}:`);
|
|
187
|
+
for (const [subKey, subValue] of Object.entries(value)) {
|
|
188
|
+
lines.push(` ${subKey}: ${subValue}`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return lines.join("\n");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Create a content file with frontmatter and body
|
|
198
|
+
*
|
|
199
|
+
* @param frontmatter - Frontmatter data
|
|
200
|
+
* @param body - Markdown body content
|
|
201
|
+
* @returns Complete file content
|
|
202
|
+
*/
|
|
203
|
+
function createFileContent(
|
|
204
|
+
frontmatter: Record<string, unknown>,
|
|
205
|
+
body: string
|
|
206
|
+
): string {
|
|
207
|
+
const yaml = frontmatterToYaml(frontmatter);
|
|
208
|
+
return `---\n${yaml}\n---\n\n${body}`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Create a new content file in a collection
|
|
213
|
+
*
|
|
214
|
+
* Supports various file patterns with automatic token resolution:
|
|
215
|
+
* - `{slug}.md` - Simple flat structure (default)
|
|
216
|
+
* - `{slug}/index.md` - Folder-based content
|
|
217
|
+
* - `{date}-{slug}.md` - Date-prefixed naming
|
|
218
|
+
* - `{year}/{slug}.md` - Year folder structure
|
|
219
|
+
* - `{year}/{month}/{slug}.md` - Year/month folder structure
|
|
220
|
+
* - `{year}/{month}/{day}/{slug}.md` - Full date folder structure
|
|
221
|
+
* - `{lang}/{slug}.md` - Language-prefixed (i18n)
|
|
222
|
+
* - `{category}/{slug}.md` - Category folder structure
|
|
223
|
+
* - `{author}/{slug}.md` - Author folder structure
|
|
224
|
+
* - Any custom pattern with tokens from frontmatter
|
|
225
|
+
*
|
|
226
|
+
* Token resolution priority:
|
|
227
|
+
* 1. Custom tokens (explicitly provided via customTokens)
|
|
228
|
+
* 2. Known token resolvers (date, year, month, lang, category, etc.)
|
|
229
|
+
* 3. Frontmatter values (for custom tokens)
|
|
230
|
+
* 4. Default values
|
|
231
|
+
*
|
|
232
|
+
* @param collectionPath - Absolute path to the collection directory
|
|
233
|
+
* @param options - Content creation options
|
|
234
|
+
* @returns WriteResult with success status and file info
|
|
235
|
+
*
|
|
236
|
+
* @example
|
|
237
|
+
* ```typescript
|
|
238
|
+
* // Flat structure
|
|
239
|
+
* const result = await createContent('/project/src/content/blog', {
|
|
240
|
+
* frontmatter: { title: 'My New Post', pubDate: new Date() },
|
|
241
|
+
* body: '# Hello World',
|
|
242
|
+
* });
|
|
243
|
+
*
|
|
244
|
+
* // Folder-based structure
|
|
245
|
+
* const result = await createContent('/project/src/content/blog', {
|
|
246
|
+
* frontmatter: { title: 'My New Post', pubDate: new Date() },
|
|
247
|
+
* body: '# Hello World',
|
|
248
|
+
* filePattern: '{slug}/index.md',
|
|
249
|
+
* });
|
|
250
|
+
*
|
|
251
|
+
* // i18n structure with custom token
|
|
252
|
+
* const result = await createContent('/project/src/content/blog', {
|
|
253
|
+
* frontmatter: { title: 'My New Post', lang: 'id' },
|
|
254
|
+
* body: '# Hello World',
|
|
255
|
+
* filePattern: '{lang}/{slug}.md',
|
|
256
|
+
* });
|
|
257
|
+
*
|
|
258
|
+
* // Custom pattern with explicit token
|
|
259
|
+
* const result = await createContent('/project/src/content/blog', {
|
|
260
|
+
* frontmatter: { title: 'My New Post' },
|
|
261
|
+
* body: '# Hello World',
|
|
262
|
+
* filePattern: '{category}/{slug}.md',
|
|
263
|
+
* customTokens: { category: 'tutorials' },
|
|
264
|
+
* });
|
|
265
|
+
* ```
|
|
266
|
+
*/
|
|
267
|
+
export async function createContent(
|
|
268
|
+
collectionPath: string,
|
|
269
|
+
options: CreateContentOptions
|
|
270
|
+
): Promise<WriteResult> {
|
|
271
|
+
const {
|
|
272
|
+
frontmatter,
|
|
273
|
+
body,
|
|
274
|
+
slug: customSlug,
|
|
275
|
+
filePattern = "{slug}.md",
|
|
276
|
+
customTokens = {},
|
|
277
|
+
} = options;
|
|
278
|
+
|
|
279
|
+
try {
|
|
280
|
+
// Validate pattern
|
|
281
|
+
const validation = isValidPattern(filePattern);
|
|
282
|
+
if (!validation.valid) {
|
|
283
|
+
return {
|
|
284
|
+
success: false,
|
|
285
|
+
error: `Invalid file pattern: ${validation.error}`,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Generate slug from title or use custom slug
|
|
290
|
+
const title = frontmatter.title as string | undefined;
|
|
291
|
+
const baseSlug = customSlug ?? (title ? generateSlug(title) : "untitled");
|
|
292
|
+
|
|
293
|
+
// Ensure unique slug using the file pattern
|
|
294
|
+
const slug = await generateUniqueSlug(
|
|
295
|
+
baseSlug,
|
|
296
|
+
collectionPath,
|
|
297
|
+
filePattern
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
// Resolve all tokens using the flexible token resolver
|
|
301
|
+
const tokens = resolvePatternTokens(filePattern, {
|
|
302
|
+
slug,
|
|
303
|
+
frontmatter,
|
|
304
|
+
customTokens,
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
// Generate the relative file path from the pattern
|
|
308
|
+
const relativePath = generatePathFromPattern(filePattern, tokens);
|
|
309
|
+
const filePath = join(collectionPath, relativePath);
|
|
310
|
+
|
|
311
|
+
// Ensure parent directory exists (important for folder-based patterns)
|
|
312
|
+
const parentDir = dirname(filePath);
|
|
313
|
+
if (!existsSync(parentDir)) {
|
|
314
|
+
await mkdir(parentDir, { recursive: true });
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Create file content
|
|
318
|
+
const content = createFileContent(frontmatter, body);
|
|
319
|
+
|
|
320
|
+
// Write file
|
|
321
|
+
await writeFile(filePath, content, "utf-8");
|
|
322
|
+
|
|
323
|
+
return {
|
|
324
|
+
success: true,
|
|
325
|
+
id: slug,
|
|
326
|
+
path: filePath,
|
|
327
|
+
};
|
|
328
|
+
} catch (error) {
|
|
329
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
330
|
+
return {
|
|
331
|
+
success: false,
|
|
332
|
+
error: `Failed to create content: ${message}`,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Update an existing content file
|
|
339
|
+
*
|
|
340
|
+
* Creates a version snapshot of the current content before updating
|
|
341
|
+
* when version history is configured. Skips both version creation and
|
|
342
|
+
* file write if the new content is identical to the current file content.
|
|
343
|
+
* Version creation errors are logged but do not fail the save operation.
|
|
344
|
+
*
|
|
345
|
+
* @param filePath - Absolute path to the content file
|
|
346
|
+
* @param collectionPath - Path to the collection directory
|
|
347
|
+
* @param options - Update options including version history config
|
|
348
|
+
* @returns WriteResult with success status
|
|
349
|
+
*
|
|
350
|
+
* @example
|
|
351
|
+
* ```typescript
|
|
352
|
+
* const result = await updateContent(
|
|
353
|
+
* '/project/src/content/blog/my-post.md',
|
|
354
|
+
* '/project/src/content/blog',
|
|
355
|
+
* {
|
|
356
|
+
* frontmatter: { title: 'Updated Title' },
|
|
357
|
+
* body: '# Updated Content',
|
|
358
|
+
* projectRoot: '/project',
|
|
359
|
+
* collection: 'blog',
|
|
360
|
+
* versionHistoryConfig: { enabled: true, maxVersions: 20, storagePath: '.writenex/versions' },
|
|
361
|
+
* }
|
|
362
|
+
* );
|
|
363
|
+
* ```
|
|
364
|
+
*/
|
|
365
|
+
export async function updateContent(
|
|
366
|
+
filePath: string,
|
|
367
|
+
collectionPath: string,
|
|
368
|
+
options: UpdateContentOptions
|
|
369
|
+
): Promise<WriteResult> {
|
|
370
|
+
const { projectRoot, collection, versionHistoryConfig, expectedMtime } =
|
|
371
|
+
options;
|
|
372
|
+
|
|
373
|
+
try {
|
|
374
|
+
// Read existing content
|
|
375
|
+
const existing = await readContentFile(filePath, collectionPath);
|
|
376
|
+
|
|
377
|
+
if (!existing.success || !existing.content) {
|
|
378
|
+
return {
|
|
379
|
+
success: false,
|
|
380
|
+
error: existing.error ?? "Content not found",
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Conflict detection: check if file was modified externally
|
|
385
|
+
if (expectedMtime !== undefined && existing.content.mtime !== undefined) {
|
|
386
|
+
// Allow small tolerance (1ms) for filesystem precision differences
|
|
387
|
+
const mtimeDiff = Math.abs(existing.content.mtime - expectedMtime);
|
|
388
|
+
if (mtimeDiff > 1) {
|
|
389
|
+
// File was modified externally - return conflict error
|
|
390
|
+
const conflictError = new ContentConflictError(
|
|
391
|
+
collection ?? "unknown",
|
|
392
|
+
existing.content.id,
|
|
393
|
+
existing.content.raw,
|
|
394
|
+
existing.content.mtime,
|
|
395
|
+
expectedMtime
|
|
396
|
+
);
|
|
397
|
+
|
|
398
|
+
return {
|
|
399
|
+
success: false,
|
|
400
|
+
error: conflictError.message,
|
|
401
|
+
conflict: conflictError,
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Merge frontmatter
|
|
407
|
+
const frontmatter = options.frontmatter
|
|
408
|
+
? { ...existing.content.frontmatter, ...options.frontmatter }
|
|
409
|
+
: existing.content.frontmatter;
|
|
410
|
+
|
|
411
|
+
// Use new body or existing
|
|
412
|
+
const body = options.body ?? existing.content.body;
|
|
413
|
+
|
|
414
|
+
// Create updated content
|
|
415
|
+
const newContent = createFileContent(frontmatter, body);
|
|
416
|
+
|
|
417
|
+
// Read current file content for comparison
|
|
418
|
+
const currentContent = existsSync(filePath)
|
|
419
|
+
? await readFile(filePath, "utf-8")
|
|
420
|
+
: "";
|
|
421
|
+
|
|
422
|
+
// Skip if content is identical (no changes to save)
|
|
423
|
+
if (newContent === currentContent) {
|
|
424
|
+
return {
|
|
425
|
+
success: true,
|
|
426
|
+
id: existing.content.id,
|
|
427
|
+
path: filePath,
|
|
428
|
+
mtime: existing.content.mtime,
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Create version snapshot before updating (if version history is configured)
|
|
433
|
+
// Only create version if content actually changed
|
|
434
|
+
if (
|
|
435
|
+
projectRoot &&
|
|
436
|
+
collection &&
|
|
437
|
+
versionHistoryConfig &&
|
|
438
|
+
versionHistoryConfig.enabled &&
|
|
439
|
+
currentContent
|
|
440
|
+
) {
|
|
441
|
+
try {
|
|
442
|
+
// Extract content ID from file path
|
|
443
|
+
const fileName = basename(filePath);
|
|
444
|
+
const contentId =
|
|
445
|
+
fileName === "index.md" || fileName === "index.mdx"
|
|
446
|
+
? basename(dirname(filePath))
|
|
447
|
+
: fileName.replace(/\.(md|mdx)$/, "");
|
|
448
|
+
|
|
449
|
+
// Save version of the current content before overwriting
|
|
450
|
+
// skipIfIdentical compares with last version in history
|
|
451
|
+
const versionResult = await saveVersion(
|
|
452
|
+
projectRoot,
|
|
453
|
+
collection,
|
|
454
|
+
contentId,
|
|
455
|
+
currentContent,
|
|
456
|
+
versionHistoryConfig,
|
|
457
|
+
{ skipIfIdentical: true }
|
|
458
|
+
);
|
|
459
|
+
|
|
460
|
+
if (!versionResult.success) {
|
|
461
|
+
console.warn(
|
|
462
|
+
`[writenex] Failed to create version snapshot: ${versionResult.error}`
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
} catch (versionError) {
|
|
466
|
+
// Log version creation error but continue with save
|
|
467
|
+
console.warn(
|
|
468
|
+
`[writenex] Version creation error (save will continue):`,
|
|
469
|
+
versionError
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Write file with new content
|
|
475
|
+
await writeFile(filePath, newContent, "utf-8");
|
|
476
|
+
|
|
477
|
+
// Get new mtime after write
|
|
478
|
+
const newStats = await stat(filePath);
|
|
479
|
+
|
|
480
|
+
return {
|
|
481
|
+
success: true,
|
|
482
|
+
id: existing.content.id,
|
|
483
|
+
path: filePath,
|
|
484
|
+
mtime: newStats.mtimeMs,
|
|
485
|
+
};
|
|
486
|
+
} catch (error) {
|
|
487
|
+
// Re-throw ContentConflictError as-is
|
|
488
|
+
if (error instanceof ContentConflictError) {
|
|
489
|
+
return {
|
|
490
|
+
success: false,
|
|
491
|
+
error: error.message,
|
|
492
|
+
conflict: error,
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
497
|
+
return {
|
|
498
|
+
success: false,
|
|
499
|
+
error: `Failed to update content: ${message}`,
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Delete a content file
|
|
506
|
+
*
|
|
507
|
+
* @param filePath - Absolute path to the content file
|
|
508
|
+
* @returns WriteResult with success status
|
|
509
|
+
*
|
|
510
|
+
* @example
|
|
511
|
+
* ```typescript
|
|
512
|
+
* const result = await deleteContent('/project/src/content/blog/my-post.md');
|
|
513
|
+
* ```
|
|
514
|
+
*/
|
|
515
|
+
export async function deleteContent(filePath: string): Promise<WriteResult> {
|
|
516
|
+
try {
|
|
517
|
+
if (!existsSync(filePath)) {
|
|
518
|
+
return {
|
|
519
|
+
success: false,
|
|
520
|
+
error: "Content file not found",
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
await unlink(filePath);
|
|
525
|
+
|
|
526
|
+
return {
|
|
527
|
+
success: true,
|
|
528
|
+
path: filePath,
|
|
529
|
+
};
|
|
530
|
+
} catch (error) {
|
|
531
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
532
|
+
return {
|
|
533
|
+
success: false,
|
|
534
|
+
error: `Failed to delete content: ${message}`,
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Re-export getContentFilePath from reader for backward compatibility
|
|
540
|
+
export { getContentFilePath } from "./reader";
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Main entry point for @writenex/astro
|
|
3
|
+
*
|
|
4
|
+
* This file exports the minimal public API for the Astro integration.
|
|
5
|
+
* For advanced usage, import from sub-modules:
|
|
6
|
+
* - @writenex/astro/config
|
|
7
|
+
* - @writenex/astro/discovery
|
|
8
|
+
* - @writenex/astro/filesystem
|
|
9
|
+
* - @writenex/astro/server
|
|
10
|
+
*
|
|
11
|
+
* @module @writenex/astro
|
|
12
|
+
* @example
|
|
13
|
+
* ```typescript
|
|
14
|
+
* // astro.config.mjs
|
|
15
|
+
* import { defineConfig } from 'astro/config';
|
|
16
|
+
* import writenex from '@writenex/astro';
|
|
17
|
+
*
|
|
18
|
+
* export default defineConfig({
|
|
19
|
+
* integrations: [writenex()],
|
|
20
|
+
* });
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
// =============================================================================
|
|
25
|
+
// Main Integration
|
|
26
|
+
// =============================================================================
|
|
27
|
+
|
|
28
|
+
export { default, default as writenex } from "./integration";
|
|
29
|
+
|
|
30
|
+
// =============================================================================
|
|
31
|
+
// Configuration Utilities (most commonly used)
|
|
32
|
+
// =============================================================================
|
|
33
|
+
|
|
34
|
+
export { defineConfig, validateConfig, loadConfig } from "@/config";
|
|
35
|
+
|
|
36
|
+
// =============================================================================
|
|
37
|
+
// Core Types (essential for consumers)
|
|
38
|
+
// =============================================================================
|
|
39
|
+
|
|
40
|
+
export type {
|
|
41
|
+
WritenexConfig,
|
|
42
|
+
WritenexOptions,
|
|
43
|
+
CollectionConfig,
|
|
44
|
+
CollectionSchema,
|
|
45
|
+
SchemaField,
|
|
46
|
+
FieldType,
|
|
47
|
+
ImageConfig,
|
|
48
|
+
ImageStrategy,
|
|
49
|
+
DiscoveryConfig,
|
|
50
|
+
EditorConfig,
|
|
51
|
+
ContentItem,
|
|
52
|
+
ContentSummary,
|
|
53
|
+
DiscoveredCollection,
|
|
54
|
+
VersionHistoryConfig,
|
|
55
|
+
} from "@/types";
|
|
56
|
+
|
|
57
|
+
// =============================================================================
|
|
58
|
+
// Error Handling (commonly needed)
|
|
59
|
+
// =============================================================================
|
|
60
|
+
|
|
61
|
+
export { WritenexError, WritenexErrorCode, isWritenexError } from "@/core";
|