@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.
Files changed (61) hide show
  1. package/dist/editor.js +52746 -36711
  2. package/package.json +16 -14
  3. package/src/build-processor.ts +4 -1
  4. package/src/collection-scanner.ts +425 -48
  5. package/src/dev-middleware.ts +26 -203
  6. package/src/editor/api.ts +1 -22
  7. package/src/editor/components/ai-chat.tsx +3 -3
  8. package/src/editor/components/ai-tooltip.tsx +2 -1
  9. package/src/editor/components/block-editor.tsx +13 -108
  10. package/src/editor/components/collections-browser.tsx +168 -205
  11. package/src/editor/components/component-card.tsx +49 -0
  12. package/src/editor/components/confirm-dialog.tsx +34 -47
  13. package/src/editor/components/create-page-modal.tsx +529 -101
  14. package/src/editor/components/delete-page-dialog.tsx +100 -0
  15. package/src/editor/components/fields.tsx +175 -0
  16. package/src/editor/components/frontmatter-fields.tsx +281 -70
  17. package/src/editor/components/frontmatter-sidebar.tsx +223 -0
  18. package/src/editor/components/highlight-overlay.ts +3 -2
  19. package/src/editor/components/markdown-editor-overlay.tsx +131 -85
  20. package/src/editor/components/markdown-inline-editor.tsx +74 -5
  21. package/src/editor/components/mdx-block-view.tsx +102 -0
  22. package/src/editor/components/mdx-component-picker.tsx +123 -0
  23. package/src/editor/components/mdx-props-editor.tsx +94 -0
  24. package/src/editor/components/media-library.tsx +373 -100
  25. package/src/editor/components/modal-shell.tsx +87 -0
  26. package/src/editor/components/prop-editor.tsx +52 -0
  27. package/src/editor/components/redirect-countdown.tsx +3 -1
  28. package/src/editor/components/redirects-manager.tsx +269 -0
  29. package/src/editor/components/reference-picker.tsx +203 -0
  30. package/src/editor/components/seo-editor.tsx +285 -303
  31. package/src/editor/components/toast/toast-container.tsx +2 -1
  32. package/src/editor/components/toolbar.tsx +177 -46
  33. package/src/editor/constants.ts +26 -0
  34. package/src/editor/editor.ts +112 -0
  35. package/src/editor/fetch.ts +62 -0
  36. package/src/editor/index.tsx +19 -1
  37. package/src/editor/markdown-api.ts +105 -156
  38. package/src/editor/milkdown-mdx-plugin.tsx +269 -0
  39. package/src/editor/signals.ts +206 -13
  40. package/src/editor/types.ts +52 -1
  41. package/src/handlers/api-routes.ts +251 -0
  42. package/src/handlers/component-ops.ts +2 -18
  43. package/src/handlers/markdown-ops.ts +202 -47
  44. package/src/handlers/page-ops.ts +229 -0
  45. package/src/handlers/redirect-ops.ts +163 -0
  46. package/src/handlers/source-writer.ts +157 -1
  47. package/src/html-processor.ts +14 -2
  48. package/src/index.ts +78 -14
  49. package/src/manifest-writer.ts +19 -1
  50. package/src/media/contember.ts +2 -1
  51. package/src/media/local.ts +66 -28
  52. package/src/media/project-images.ts +81 -0
  53. package/src/media/s3.ts +32 -11
  54. package/src/media/types.ts +24 -2
  55. package/src/shared.ts +27 -0
  56. package/src/source-finder/collection-finder.ts +219 -41
  57. package/src/source-finder/index.ts +7 -1
  58. package/src/source-finder/search-index.ts +178 -36
  59. package/src/source-finder/snippet-utils.ts +423 -3
  60. package/src/types.ts +111 -2
  61. 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, '&amp;')
461
- .replace(/"/g, '&quot;')
462
- .replace(/'/g, '&#39;')
463
- .replace(/</g, '&lt;')
464
- .replace(/>/g, '&gt;')
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
- // Compute relative import path from target file to component file
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 { acquireFileLock } from '../utils'
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
- const raw = await fs.readFile(fullPath, 'utf-8')
85
- const existing = parseFrontmatter(raw)
86
-
87
- const mergedFrontmatter: BlogFrontmatter = {
88
- ...(existing.frontmatter as BlogFrontmatter),
89
- ...request.frontmatter,
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 fullFrontmatter: BlogFrontmatter = {
120
- title,
121
- date: new Date().toISOString().split('T')[0]!,
122
- draft: true,
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
- const markdownContent = serializeFrontmatter(fullFrontmatter, content)
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
- // Use 'wx' flag for atomic exclusive create — fails if file already exists
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
- // --- Internal helpers ---
209
+ export interface RenameMarkdownRequest {
210
+ filePath: string
211
+ newSlug: string
212
+ }
168
213
 
169
- /**
170
- * Resolve a user-provided file path and ensure it stays within the project root.
171
- * Throws if the resolved path escapes the project boundary.
172
- */
173
- function resolveAndValidatePath(filePath: string): string {
174
- const projectRoot = getProjectRoot()
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
- // Ensure the resolved path is within the project root
183
- if (!fullPath.startsWith(resolvedRoot + path.sep) && fullPath !== resolvedRoot) {
184
- throw new Error(`Path traversal detected: ${filePath}`)
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
- return fullPath
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
- function slugify(text: string): string {
228
- return text
229
- .toLowerCase()
230
- .trim()
231
- .replace(/[^\w\s-]/g, '')
232
- .replace(/[\s_-]+/g, '-')
233
- .replace(/^-+|-+$/g, '')
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
+ }