@nuasite/cms 0.18.1 → 0.19.1
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/dist/editor.js +52746 -36711
- package/package.json +16 -14
- package/src/build-processor.ts +4 -1
- package/src/collection-scanner.ts +425 -48
- package/src/dev-middleware.ts +26 -203
- package/src/editor/api.ts +1 -22
- package/src/editor/components/ai-chat.tsx +3 -3
- package/src/editor/components/ai-tooltip.tsx +2 -1
- package/src/editor/components/block-editor.tsx +13 -108
- package/src/editor/components/collections-browser.tsx +168 -205
- package/src/editor/components/component-card.tsx +49 -0
- package/src/editor/components/confirm-dialog.tsx +34 -47
- package/src/editor/components/create-page-modal.tsx +529 -101
- package/src/editor/components/delete-page-dialog.tsx +100 -0
- package/src/editor/components/fields.tsx +175 -0
- package/src/editor/components/frontmatter-fields.tsx +281 -70
- package/src/editor/components/frontmatter-sidebar.tsx +223 -0
- package/src/editor/components/highlight-overlay.ts +3 -2
- package/src/editor/components/markdown-editor-overlay.tsx +131 -85
- package/src/editor/components/markdown-inline-editor.tsx +74 -5
- package/src/editor/components/mdx-block-view.tsx +102 -0
- package/src/editor/components/mdx-component-picker.tsx +123 -0
- package/src/editor/components/mdx-props-editor.tsx +94 -0
- package/src/editor/components/media-library.tsx +373 -100
- package/src/editor/components/modal-shell.tsx +87 -0
- package/src/editor/components/prop-editor.tsx +52 -0
- package/src/editor/components/redirect-countdown.tsx +3 -1
- package/src/editor/components/redirects-manager.tsx +269 -0
- package/src/editor/components/reference-picker.tsx +203 -0
- package/src/editor/components/seo-editor.tsx +285 -303
- package/src/editor/components/toast/toast-container.tsx +2 -1
- package/src/editor/components/toolbar.tsx +177 -46
- package/src/editor/constants.ts +26 -0
- package/src/editor/editor.ts +112 -0
- package/src/editor/fetch.ts +62 -0
- package/src/editor/index.tsx +19 -1
- package/src/editor/markdown-api.ts +105 -156
- package/src/editor/milkdown-mdx-plugin.tsx +269 -0
- package/src/editor/signals.ts +206 -13
- package/src/editor/types.ts +52 -1
- package/src/handlers/api-routes.ts +251 -0
- package/src/handlers/component-ops.ts +2 -18
- package/src/handlers/markdown-ops.ts +202 -47
- package/src/handlers/page-ops.ts +229 -0
- package/src/handlers/redirect-ops.ts +163 -0
- package/src/handlers/source-writer.ts +157 -1
- package/src/html-processor.ts +14 -2
- package/src/index.ts +78 -14
- package/src/manifest-writer.ts +19 -1
- package/src/media/contember.ts +2 -1
- package/src/media/local.ts +66 -28
- package/src/media/project-images.ts +81 -0
- package/src/media/s3.ts +32 -11
- package/src/media/types.ts +24 -2
- package/src/shared.ts +27 -0
- package/src/source-finder/collection-finder.ts +219 -41
- package/src/source-finder/index.ts +7 -1
- package/src/source-finder/search-index.ts +178 -36
- package/src/source-finder/snippet-utils.ts +423 -3
- package/src/types.ts +111 -2
- package/src/utils.ts +40 -4
|
@@ -3,7 +3,7 @@ import path from 'node:path'
|
|
|
3
3
|
import { getProjectRoot } from '../config'
|
|
4
4
|
import type { ManifestWriter } from '../manifest-writer'
|
|
5
5
|
import type { CmsManifest, ComponentDefinition, ComponentInstance } from '../types'
|
|
6
|
-
import { acquireFileLock, escapeRegex, normalizePagePath, resolveAndValidatePath } from '../utils'
|
|
6
|
+
import { acquireFileLock, escapeHtml, escapeRegex, normalizePagePath, relativeImportPath, resolveAndValidatePath } from '../utils'
|
|
7
7
|
|
|
8
8
|
export type InsertPosition = 'before' | 'after'
|
|
9
9
|
|
|
@@ -455,15 +455,6 @@ function generateComponentJsx(
|
|
|
455
455
|
return `<${componentName} />`
|
|
456
456
|
}
|
|
457
457
|
|
|
458
|
-
function escapeHtml(str: string): string {
|
|
459
|
-
return str
|
|
460
|
-
.replace(/&/g, '&')
|
|
461
|
-
.replace(/"/g, '"')
|
|
462
|
-
.replace(/'/g, ''')
|
|
463
|
-
.replace(/</g, '<')
|
|
464
|
-
.replace(/>/g, '>')
|
|
465
|
-
}
|
|
466
|
-
|
|
467
458
|
export function getIndentation(line: string): string {
|
|
468
459
|
const match = line.match(/^(\s*)/)
|
|
469
460
|
return match ? match[1]! : ''
|
|
@@ -688,14 +679,7 @@ export function ensureComponentImport(
|
|
|
688
679
|
}
|
|
689
680
|
}
|
|
690
681
|
|
|
691
|
-
|
|
692
|
-
const targetDir = path.dirname(targetFile)
|
|
693
|
-
let relativePath = path.relative(targetDir, componentFile)
|
|
694
|
-
if (!relativePath.startsWith('.')) {
|
|
695
|
-
relativePath = './' + relativePath
|
|
696
|
-
}
|
|
697
|
-
|
|
698
|
-
const importStatement = `import ${componentName} from '${relativePath}'`
|
|
682
|
+
const importStatement = `import ${componentName} from '${relativeImportPath(targetFile, componentFile)}'`
|
|
699
683
|
|
|
700
684
|
if (frontmatterEnd > 0) {
|
|
701
685
|
// Has frontmatter — insert import before the closing ---
|
|
@@ -2,7 +2,8 @@ import fs from 'node:fs/promises'
|
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
import yaml from 'yaml'
|
|
4
4
|
import { getProjectRoot } from '../config'
|
|
5
|
-
import {
|
|
5
|
+
import type { ComponentDefinition } from '../types'
|
|
6
|
+
import { acquireFileLock, isNodeError, relativeImportPath, resolveAndValidatePath, slugify } from '../utils'
|
|
6
7
|
|
|
7
8
|
export interface BlogFrontmatter {
|
|
8
9
|
title: string
|
|
@@ -21,6 +22,8 @@ export interface CreateMarkdownRequest {
|
|
|
21
22
|
slug: string
|
|
22
23
|
frontmatter?: Partial<BlogFrontmatter>
|
|
23
24
|
content?: string
|
|
25
|
+
/** File extension override for data collections (e.g. 'json', 'yaml') */
|
|
26
|
+
fileExtension?: string
|
|
24
27
|
}
|
|
25
28
|
|
|
26
29
|
export interface CreateMarkdownResponse {
|
|
@@ -62,8 +65,17 @@ export async function handleGetMarkdownContent(
|
|
|
62
65
|
try {
|
|
63
66
|
const fullPath = resolveAndValidatePath(filePath)
|
|
64
67
|
const raw = await fs.readFile(fullPath, 'utf-8')
|
|
65
|
-
const { frontmatter, content } = parseFrontmatter(raw)
|
|
66
68
|
|
|
69
|
+
if (isDataFile(filePath)) {
|
|
70
|
+
const data = filePath.endsWith('.json') ? JSON.parse(raw) : yaml.parse(raw)
|
|
71
|
+
return {
|
|
72
|
+
content: '',
|
|
73
|
+
frontmatter: (data && typeof data === 'object' ? data : {}) as BlogFrontmatter,
|
|
74
|
+
filePath,
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const { frontmatter, content } = parseFrontmatter(raw)
|
|
67
79
|
return {
|
|
68
80
|
content,
|
|
69
81
|
frontmatter: frontmatter as BlogFrontmatter,
|
|
@@ -76,24 +88,41 @@ export async function handleGetMarkdownContent(
|
|
|
76
88
|
|
|
77
89
|
export async function handleUpdateMarkdown(
|
|
78
90
|
request: UpdateMarkdownRequest,
|
|
91
|
+
componentDefinitions?: Record<string, ComponentDefinition>,
|
|
79
92
|
): Promise<UpdateMarkdownResponse> {
|
|
80
93
|
try {
|
|
81
94
|
const fullPath = resolveAndValidatePath(request.filePath)
|
|
82
95
|
const release = await acquireFileLock(fullPath)
|
|
83
96
|
try {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
...(existing
|
|
89
|
-
|
|
97
|
+
if (isDataFile(request.filePath)) {
|
|
98
|
+
// Data collections: merge and write JSON/YAML directly
|
|
99
|
+
const raw = await fs.readFile(fullPath, 'utf-8')
|
|
100
|
+
const existing = request.filePath.endsWith('.json') ? JSON.parse(raw) : yaml.parse(raw)
|
|
101
|
+
const merged = { ...(existing ?? {}), ...request.frontmatter }
|
|
102
|
+
|
|
103
|
+
const output = request.filePath.endsWith('.json')
|
|
104
|
+
? JSON.stringify(merged, null, 2) + '\n'
|
|
105
|
+
: yaml.stringify(merged)
|
|
106
|
+
await fs.writeFile(fullPath, output, 'utf-8')
|
|
107
|
+
} else {
|
|
108
|
+
const raw = await fs.readFile(fullPath, 'utf-8')
|
|
109
|
+
const existing = parseFrontmatter(raw)
|
|
110
|
+
|
|
111
|
+
const mergedFrontmatter: BlogFrontmatter = {
|
|
112
|
+
...(existing.frontmatter as BlogFrontmatter),
|
|
113
|
+
...request.frontmatter,
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
let finalContent = request.content ?? existing.content
|
|
117
|
+
|
|
118
|
+
if (request.filePath.endsWith('.mdx') && componentDefinitions) {
|
|
119
|
+
finalContent = ensureMdxImports(finalContent, request.filePath, componentDefinitions)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const markdownContent = serializeFrontmatter(mergedFrontmatter, finalContent)
|
|
123
|
+
await fs.writeFile(fullPath, markdownContent, 'utf-8')
|
|
90
124
|
}
|
|
91
125
|
|
|
92
|
-
const finalContent = request.content ?? existing.content
|
|
93
|
-
const markdownContent = serializeFrontmatter(mergedFrontmatter, finalContent)
|
|
94
|
-
|
|
95
|
-
await fs.writeFile(fullPath, markdownContent, 'utf-8')
|
|
96
|
-
|
|
97
126
|
return { success: true }
|
|
98
127
|
} finally {
|
|
99
128
|
release()
|
|
@@ -113,22 +142,35 @@ export async function handleCreateMarkdown(
|
|
|
113
142
|
if (!normalizedSlug) {
|
|
114
143
|
return { success: false, error: 'Could not generate a valid slug from the provided title/slug' }
|
|
115
144
|
}
|
|
116
|
-
const filePath = `src/content/${collection}/${normalizedSlug}.md`
|
|
117
|
-
const fullPath = resolveAndValidatePath(filePath)
|
|
118
145
|
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
...frontmatter,
|
|
146
|
+
const allowedExtensions = ['md', 'mdx', 'json', 'yaml', 'yml']
|
|
147
|
+
const ext = request.fileExtension ?? 'md'
|
|
148
|
+
if (!allowedExtensions.includes(ext)) {
|
|
149
|
+
return { success: false, error: `Invalid file extension "${ext}". Allowed: ${allowedExtensions.join(', ')}` }
|
|
124
150
|
}
|
|
151
|
+
const isData = ext === 'json' || ext === 'yaml' || ext === 'yml'
|
|
152
|
+
const filePath = `src/content/${collection}/${normalizedSlug}.${ext}`
|
|
153
|
+
const fullPath = resolveAndValidatePath(filePath)
|
|
125
154
|
|
|
126
|
-
|
|
155
|
+
let fileContent: string
|
|
156
|
+
if (isData) {
|
|
157
|
+
const data = { ...frontmatter }
|
|
158
|
+
fileContent = ext === 'json'
|
|
159
|
+
? JSON.stringify(data, null, 2) + '\n'
|
|
160
|
+
: yaml.stringify(data)
|
|
161
|
+
} else {
|
|
162
|
+
const fullFrontmatter: BlogFrontmatter = {
|
|
163
|
+
title,
|
|
164
|
+
date: new Date().toISOString().split('T')[0]!,
|
|
165
|
+
draft: true,
|
|
166
|
+
...frontmatter,
|
|
167
|
+
}
|
|
168
|
+
fileContent = serializeFrontmatter(fullFrontmatter, content)
|
|
169
|
+
}
|
|
127
170
|
|
|
128
171
|
try {
|
|
129
172
|
await fs.mkdir(path.dirname(fullPath), { recursive: true })
|
|
130
|
-
|
|
131
|
-
await fs.writeFile(fullPath, markdownContent, { encoding: 'utf-8', flag: 'wx' })
|
|
173
|
+
await fs.writeFile(fullPath, fileContent, { encoding: 'utf-8', flag: 'wx' })
|
|
132
174
|
|
|
133
175
|
return {
|
|
134
176
|
success: true,
|
|
@@ -164,27 +206,74 @@ export async function handleDeleteMarkdown(
|
|
|
164
206
|
}
|
|
165
207
|
}
|
|
166
208
|
|
|
167
|
-
|
|
209
|
+
export interface RenameMarkdownRequest {
|
|
210
|
+
filePath: string
|
|
211
|
+
newSlug: string
|
|
212
|
+
}
|
|
168
213
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
const resolvedRoot = path.resolve(projectRoot)
|
|
176
|
-
// Absolute filesystem paths (e.g. /Users/...) stay intact;
|
|
177
|
-
// project-relative paths with a leading slash (e.g. /src/content/...) get it stripped
|
|
178
|
-
const isAbsoluteFs = filePath.startsWith(resolvedRoot)
|
|
179
|
-
const normalizedPath = (!isAbsoluteFs && filePath.startsWith('/')) ? filePath.slice(1) : filePath
|
|
180
|
-
const fullPath = path.isAbsolute(normalizedPath) ? path.resolve(normalizedPath) : path.resolve(projectRoot, normalizedPath)
|
|
214
|
+
export interface RenameMarkdownResponse {
|
|
215
|
+
success: boolean
|
|
216
|
+
newFilePath?: string
|
|
217
|
+
newSlug?: string
|
|
218
|
+
error?: string
|
|
219
|
+
}
|
|
181
220
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
221
|
+
export async function handleRenameMarkdown(
|
|
222
|
+
request: RenameMarkdownRequest,
|
|
223
|
+
): Promise<RenameMarkdownResponse> {
|
|
224
|
+
try {
|
|
225
|
+
const fullPath = resolveAndValidatePath(request.filePath)
|
|
226
|
+
const normalizedSlug = slugify(request.newSlug)
|
|
227
|
+
if (!normalizedSlug) {
|
|
228
|
+
return { success: false, error: 'Invalid slug' }
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const dir = path.dirname(fullPath)
|
|
232
|
+
const ext = path.extname(fullPath)
|
|
233
|
+
const newFullPath = path.join(dir, `${normalizedSlug}${ext}`)
|
|
234
|
+
|
|
235
|
+
if (fullPath === newFullPath) {
|
|
236
|
+
return { success: true, newFilePath: request.filePath, newSlug: normalizedSlug }
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Acquire lock to prevent concurrent access during rename
|
|
240
|
+
const release = await acquireFileLock(fullPath)
|
|
241
|
+
try {
|
|
242
|
+
// Use link+unlink for atomic rename that fails if target exists
|
|
243
|
+
try {
|
|
244
|
+
await fs.link(fullPath, newFullPath)
|
|
245
|
+
} catch (err) {
|
|
246
|
+
if (isNodeError(err, 'EEXIST')) {
|
|
247
|
+
return { success: false, error: `File already exists: ${normalizedSlug}${ext}` }
|
|
248
|
+
}
|
|
249
|
+
throw err
|
|
250
|
+
}
|
|
251
|
+
try {
|
|
252
|
+
await fs.unlink(fullPath)
|
|
253
|
+
} catch (err) {
|
|
254
|
+
// Clean up the new file if unlink of original fails
|
|
255
|
+
await fs.unlink(newFullPath).catch(() => {})
|
|
256
|
+
throw err
|
|
257
|
+
}
|
|
258
|
+
} finally {
|
|
259
|
+
release()
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Build project-relative path (normalize to forward slashes)
|
|
263
|
+
const projectRoot = getProjectRoot()
|
|
264
|
+
const newFilePath = path.relative(projectRoot, newFullPath).split(path.sep).join('/')
|
|
265
|
+
|
|
266
|
+
return { success: true, newFilePath, newSlug: normalizedSlug }
|
|
267
|
+
} catch (error) {
|
|
268
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
269
|
+
return { success: false, error: message }
|
|
185
270
|
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// --- Internal helpers ---
|
|
186
274
|
|
|
187
|
-
|
|
275
|
+
function isDataFile(filePath: string): boolean {
|
|
276
|
+
return filePath.endsWith('.json') || filePath.endsWith('.yaml') || filePath.endsWith('.yml')
|
|
188
277
|
}
|
|
189
278
|
|
|
190
279
|
function parseFrontmatter(raw: string): { frontmatter: Record<string, unknown>; content: string } {
|
|
@@ -224,11 +313,77 @@ function serializeFrontmatter(frontmatter: Record<string, unknown>, content: str
|
|
|
224
313
|
return `---\n${yamlStr}\n---\n${content}`
|
|
225
314
|
}
|
|
226
315
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
316
|
+
/**
|
|
317
|
+
* Ensure MDX content has import statements for all components used in the body.
|
|
318
|
+
* Scans for `<ComponentName` tags, checks for existing imports, and prepends missing ones.
|
|
319
|
+
*/
|
|
320
|
+
/** @internal Exported for testing */
|
|
321
|
+
export function ensureMdxImports(
|
|
322
|
+
content: string,
|
|
323
|
+
filePath: string,
|
|
324
|
+
componentDefinitions: Record<string, ComponentDefinition>,
|
|
325
|
+
): string {
|
|
326
|
+
// Find all component-like tags (capitalized names)
|
|
327
|
+
const usedComponents = new Set<string>()
|
|
328
|
+
const tagRegex = /<([A-Z][A-Za-z0-9]*)\b/g
|
|
329
|
+
let match
|
|
330
|
+
while ((match = tagRegex.exec(content)) !== null) {
|
|
331
|
+
if (match[1]) usedComponents.add(match[1])
|
|
332
|
+
}
|
|
333
|
+
if (usedComponents.size === 0) return content
|
|
334
|
+
|
|
335
|
+
// Find already-imported names and track the last import position in a single pass
|
|
336
|
+
const importedNames = new Set<string>()
|
|
337
|
+
const importLineRegex = /^import\s+(.+)\s+from\s+/gm
|
|
338
|
+
let lastImportEnd = -1
|
|
339
|
+
while ((match = importLineRegex.exec(content)) !== null) {
|
|
340
|
+
lastImportEnd = match.index + match[0].length
|
|
341
|
+
// Advance past the `from '...'` portion to find the true line end
|
|
342
|
+
const fromRest = content.slice(lastImportEnd)
|
|
343
|
+
const lineEnd = fromRest.indexOf('\n')
|
|
344
|
+
if (lineEnd >= 0) lastImportEnd += lineEnd
|
|
345
|
+
else lastImportEnd = content.length
|
|
346
|
+
|
|
347
|
+
const clause = match[1]!
|
|
348
|
+
// Extract named imports from braces: { A, B as C }
|
|
349
|
+
const braceMatch = clause.match(/\{([^}]+)\}/)
|
|
350
|
+
if (braceMatch?.[1]) {
|
|
351
|
+
for (const name of braceMatch[1].split(',')) {
|
|
352
|
+
const parts = name.trim().split(/\s+as\s+/)
|
|
353
|
+
const imported = (parts[1] ?? parts[0])?.trim()
|
|
354
|
+
if (imported) importedNames.add(imported)
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
// Extract default import and namespace import (* as X)
|
|
358
|
+
const withoutBraces = clause.replace(/\{[^}]*\}/, '').replace(/,/g, ' ').trim()
|
|
359
|
+
for (const token of withoutBraces.split(/\s+/)) {
|
|
360
|
+
if (token === '*' || token === 'as' || token === '') continue
|
|
361
|
+
importedNames.add(token)
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const root = getProjectRoot()
|
|
366
|
+
const mdxFullPath = path.join(root, filePath)
|
|
367
|
+
const missingImports: string[] = []
|
|
368
|
+
|
|
369
|
+
for (const name of usedComponents) {
|
|
370
|
+
if (importedNames.has(name)) continue
|
|
371
|
+
const def = componentDefinitions[name]
|
|
372
|
+
if (!def) continue
|
|
373
|
+
|
|
374
|
+
const componentAbsPath = path.join(root, def.file)
|
|
375
|
+
const rel = relativeImportPath(mdxFullPath, componentAbsPath)
|
|
376
|
+
missingImports.push(`import ${name} from '${rel}'`)
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (missingImports.length === 0) return content
|
|
380
|
+
|
|
381
|
+
// Place after any existing import block, or at the top
|
|
382
|
+
const importBlock = missingImports.join('\n')
|
|
383
|
+
|
|
384
|
+
if (lastImportEnd >= 0) {
|
|
385
|
+
return content.slice(0, lastImportEnd) + '\n' + importBlock + content.slice(lastImportEnd)
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return importBlock + '\n\n' + content
|
|
234
389
|
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { getProjectRoot } from '../config'
|
|
4
|
+
import type { CreatePageRequest, DeletePageRequest, DuplicatePageRequest, LayoutInfo, PageOperationResponse } from '../types'
|
|
5
|
+
import { escapeHtml, isNodeError, resolveAndValidatePath, slugify } from '../utils'
|
|
6
|
+
|
|
7
|
+
const PAGE_EXTENSIONS = ['.astro', '.md', '.mdx']
|
|
8
|
+
|
|
9
|
+
export async function handleCreatePage(request: CreatePageRequest): Promise<PageOperationResponse> {
|
|
10
|
+
const { title, slug } = request
|
|
11
|
+
const normalizedSlug = slugify(slug || title)
|
|
12
|
+
|
|
13
|
+
if (!normalizedSlug) {
|
|
14
|
+
return { success: false, error: 'Could not generate a valid slug from the provided title/slug' }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const filePath = `src/pages/${normalizedSlug}.astro`
|
|
18
|
+
const fullPath = resolveAndValidatePath(filePath)
|
|
19
|
+
|
|
20
|
+
const layoutImport = await resolveLayoutImport(request.layoutPath)
|
|
21
|
+
const content = generatePageContent(title, layoutImport)
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
await fs.mkdir(path.dirname(fullPath), { recursive: true })
|
|
25
|
+
// 'wx' flag atomically fails if file exists — no pre-check needed
|
|
26
|
+
await fs.writeFile(fullPath, content, { encoding: 'utf-8', flag: 'wx' })
|
|
27
|
+
|
|
28
|
+
const url = normalizedSlug === 'index' ? '/' : `/${normalizedSlug}`
|
|
29
|
+
return { success: true, filePath, slug: normalizedSlug, url }
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if (isNodeError(error, 'EEXIST')) {
|
|
32
|
+
return { success: false, error: `Page already exists: ${filePath}` }
|
|
33
|
+
}
|
|
34
|
+
return { success: false, error: errorMessage(error) }
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function handleDuplicatePage(request: DuplicatePageRequest): Promise<PageOperationResponse> {
|
|
39
|
+
const { sourcePagePath, slug, title } = request
|
|
40
|
+
const normalizedSlug = slugify(slug)
|
|
41
|
+
|
|
42
|
+
if (!normalizedSlug) {
|
|
43
|
+
return { success: false, error: 'Could not generate a valid slug' }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const sourceFile = await findPageFile(sourcePagePath)
|
|
47
|
+
if (!sourceFile) {
|
|
48
|
+
return { success: false, error: `Source page not found: ${sourcePagePath}` }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let content: string
|
|
52
|
+
try {
|
|
53
|
+
content = await fs.readFile(resolveAndValidatePath(sourceFile), 'utf-8')
|
|
54
|
+
} catch {
|
|
55
|
+
return { success: false, error: `Could not read source file: ${sourceFile}` }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (title) {
|
|
59
|
+
content = replacePageTitle(content, title)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const newFilePath = `src/pages/${normalizedSlug}.astro`
|
|
63
|
+
const newFullPath = resolveAndValidatePath(newFilePath)
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
await fs.mkdir(path.dirname(newFullPath), { recursive: true })
|
|
67
|
+
await fs.writeFile(newFullPath, content, { encoding: 'utf-8', flag: 'wx' })
|
|
68
|
+
|
|
69
|
+
const url = normalizedSlug === 'index' ? '/' : `/${normalizedSlug}`
|
|
70
|
+
return { success: true, filePath: newFilePath, slug: normalizedSlug, url }
|
|
71
|
+
} catch (error) {
|
|
72
|
+
if (isNodeError(error, 'EEXIST')) {
|
|
73
|
+
return { success: false, error: `Page already exists: ${newFilePath}` }
|
|
74
|
+
}
|
|
75
|
+
return { success: false, error: errorMessage(error) }
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function handleDeletePage(request: DeletePageRequest): Promise<PageOperationResponse> {
|
|
80
|
+
const { pagePath } = request
|
|
81
|
+
|
|
82
|
+
const pageFile = await findPageFile(pagePath)
|
|
83
|
+
if (!pageFile) {
|
|
84
|
+
return { success: false, error: `Page not found: ${pagePath}` }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
// No pre-check — just unlink and handle ENOENT
|
|
89
|
+
await fs.unlink(resolveAndValidatePath(pageFile))
|
|
90
|
+
return { success: true, filePath: pageFile, url: pagePath }
|
|
91
|
+
} catch (error) {
|
|
92
|
+
if (isNodeError(error, 'ENOENT')) {
|
|
93
|
+
return { success: false, error: `File not found: ${pageFile}` }
|
|
94
|
+
}
|
|
95
|
+
return { success: false, error: errorMessage(error) }
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Reuses findPageFile to check whether a slug is already taken.
|
|
101
|
+
*/
|
|
102
|
+
export async function handleCheckSlugExists(slug: string): Promise<{ exists: boolean; filePath?: string }> {
|
|
103
|
+
const normalizedSlug = slugify(slug)
|
|
104
|
+
if (!normalizedSlug) return { exists: false }
|
|
105
|
+
|
|
106
|
+
const found = await findPageFile(`/${normalizedSlug}`)
|
|
107
|
+
return found ? { exists: true, filePath: found } : { exists: false }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function handleGetLayouts(): Promise<LayoutInfo[]> {
|
|
111
|
+
const layoutsDir = path.join(getProjectRoot(), 'src', 'layouts')
|
|
112
|
+
|
|
113
|
+
let entries
|
|
114
|
+
try {
|
|
115
|
+
entries = await fs.readdir(layoutsDir, { withFileTypes: true })
|
|
116
|
+
} catch {
|
|
117
|
+
return []
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const layouts: LayoutInfo[] = []
|
|
121
|
+
for (const entry of entries) {
|
|
122
|
+
if (entry.isFile() && entry.name.endsWith('.astro')) {
|
|
123
|
+
layouts.push({
|
|
124
|
+
name: path.basename(entry.name, '.astro'),
|
|
125
|
+
path: `src/layouts/${entry.name}`,
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return layouts.sort((a, b) => a.name.localeCompare(b.name))
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// --- Internal helpers ---
|
|
134
|
+
|
|
135
|
+
function errorMessage(error: unknown): string {
|
|
136
|
+
return error instanceof Error ? error.message : String(error)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function fileExists(fullPath: string): Promise<boolean> {
|
|
140
|
+
try {
|
|
141
|
+
await fs.access(fullPath)
|
|
142
|
+
return true
|
|
143
|
+
} catch {
|
|
144
|
+
return false
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function findPageFile(pagePath: string): Promise<string | null> {
|
|
149
|
+
const normalized = pagePath.replace(/^\//, '').replace(/\/$/, '') || 'index'
|
|
150
|
+
|
|
151
|
+
for (const ext of PAGE_EXTENSIONS) {
|
|
152
|
+
const direct = `src/pages/${normalized}${ext}`
|
|
153
|
+
if (await fileExists(resolveAndValidatePath(direct))) return direct
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
for (const ext of PAGE_EXTENSIONS) {
|
|
157
|
+
const indexFile = `src/pages/${normalized}/index${ext}`
|
|
158
|
+
if (await fileExists(resolveAndValidatePath(indexFile))) return indexFile
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return null
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function resolveLayoutImport(layoutPath?: string): Promise<{ importPath: string; componentName: string } | null> {
|
|
165
|
+
if (layoutPath) {
|
|
166
|
+
const name = path.basename(layoutPath, '.astro')
|
|
167
|
+
const importPath = `../${layoutPath.replace(/^src\//, '')}`
|
|
168
|
+
return { importPath, componentName: pascalCase(name) }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const layouts = await handleGetLayouts()
|
|
172
|
+
if (layouts.length === 0) return null
|
|
173
|
+
|
|
174
|
+
const layout = layouts[0]!
|
|
175
|
+
const importPath = `../${layout.path.replace(/^src\//, '')}`
|
|
176
|
+
return { importPath, componentName: pascalCase(layout.name) }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function pascalCase(name: string): string {
|
|
180
|
+
return name.replace(/(^|[-_])(\w)/g, (_, _sep, char) => char.toUpperCase())
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function generatePageContent(
|
|
184
|
+
title: string,
|
|
185
|
+
layoutImport: { importPath: string; componentName: string } | null,
|
|
186
|
+
): string {
|
|
187
|
+
const escapedTitle = title.replace(/'/g, "\\'").replace(/`/g, '\\`')
|
|
188
|
+
const htmlTitle = escapeHtml(title)
|
|
189
|
+
|
|
190
|
+
if (layoutImport) {
|
|
191
|
+
const { importPath, componentName } = layoutImport
|
|
192
|
+
return `---
|
|
193
|
+
import ${componentName} from '${importPath}'
|
|
194
|
+
---
|
|
195
|
+
|
|
196
|
+
<${componentName} title="${escapedTitle}" description="">
|
|
197
|
+
\t<main>
|
|
198
|
+
\t\t<h1>${htmlTitle}</h1>
|
|
199
|
+
\t</main>
|
|
200
|
+
</${componentName}>
|
|
201
|
+
`
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return `---
|
|
205
|
+
|
|
206
|
+
---
|
|
207
|
+
|
|
208
|
+
<html lang="en">
|
|
209
|
+
\t<head>
|
|
210
|
+
\t\t<meta charset="utf-8" />
|
|
211
|
+
\t\t<meta name="viewport" content="width=device-width" />
|
|
212
|
+
\t\t<title>${escapedTitle}</title>
|
|
213
|
+
\t</head>
|
|
214
|
+
\t<body>
|
|
215
|
+
\t\t<main>
|
|
216
|
+
\t\t\t<h1>${htmlTitle}</h1>
|
|
217
|
+
\t\t</main>
|
|
218
|
+
\t</body>
|
|
219
|
+
</html>
|
|
220
|
+
`
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function replacePageTitle(content: string, newTitle: string): string {
|
|
224
|
+
let result = content
|
|
225
|
+
result = result.replace(/(title\s*=\s*")([^"]*)(")/, `$1${newTitle}$3`)
|
|
226
|
+
result = result.replace(/(<title>)([^<]*)(<\/title>)/, `$1${newTitle}$3`)
|
|
227
|
+
result = result.replace(/(<h1[^>]*>)([^<]*)(<\/h1>)/, `$1${escapeHtml(newTitle)}$3`)
|
|
228
|
+
return result
|
|
229
|
+
}
|