@nuasite/cms 0.12.4 → 0.13.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/package.json CHANGED
@@ -14,7 +14,7 @@
14
14
  "directory": "packages/astro-cms"
15
15
  },
16
16
  "license": "Apache-2.0",
17
- "version": "0.12.4",
17
+ "version": "0.13.1",
18
18
  "module": "src/index.ts",
19
19
  "types": "src/index.ts",
20
20
  "type": "module",
@@ -2,8 +2,9 @@ import { parse } from 'node-html-parser'
2
2
  import fs from 'node:fs/promises'
3
3
  import type { IncomingMessage, ServerResponse } from 'node:http'
4
4
  import path from 'node:path'
5
+ import { scanCollections } from './collection-scanner'
5
6
  import { getProjectRoot } from './config'
6
- import { detectArrayPattern, extractArrayElementProps, handleAddArrayItem, handleRemoveArrayItem } from './handlers/array-ops'
7
+ import { buildMapPattern, detectArrayPattern, extractArrayElementProps, handleAddArrayItem, handleRemoveArrayItem, parseInlineArrayName } from './handlers/array-ops'
7
8
  import {
8
9
  extractPropsFromSource,
9
10
  findComponentInvocationLine,
@@ -13,7 +14,7 @@ import {
13
14
  handleRemoveComponent,
14
15
  normalizeFilePath,
15
16
  } from './handlers/component-ops'
16
- import { handleCreateMarkdown, handleGetMarkdownContent, handleUpdateMarkdown } from './handlers/markdown-ops'
17
+ import { handleCreateMarkdown, handleDeleteMarkdown, handleGetMarkdownContent, handleUpdateMarkdown } from './handlers/markdown-ops'
17
18
  import { handleCors, parseJsonBody, parseMultipartFile, readBody, sendError, sendJson } from './handlers/request-utils'
18
19
  import { handleUpdate } from './handlers/source-writer'
19
20
  import { processHtml } from './html-processor'
@@ -29,8 +30,19 @@ interface ViteDevServerLike {
29
30
  use: (middleware: (req: IncomingMessage, res: ServerResponse, next: () => void) => void) => void
30
31
  }
31
32
  transformIndexHtml: (url: string, html: string) => Promise<string>
33
+ watcher?: {
34
+ on: (event: string, listener: (...args: any[]) => void) => any
35
+ removeListener: (event: string, listener: (...args: any[]) => void) => any
36
+ }
32
37
  }
33
38
 
39
+ /**
40
+ * Set of absolute file paths that the CMS expects to be deleted.
41
+ * When Vite/Astro detects an unlink for one of these files, the CMS
42
+ * intercepts the event and prevents a full page reload.
43
+ */
44
+ export const expectedDeletions = new Set<string>()
45
+
34
46
  export interface DevMiddlewareOptions {
35
47
  enableCmsApi?: boolean
36
48
  mediaAdapter?: MediaStorageAdapter
@@ -89,7 +101,7 @@ export function createDevMiddleware(
89
101
 
90
102
  const route = url.replace('/_nua/cms/', '').split('?')[0]!
91
103
 
92
- handleCmsApiRoute(route, req, res, manifestWriter, options.mediaAdapter).catch(
104
+ handleCmsApiRoute(route, req, res, manifestWriter, config.contentDir, options.mediaAdapter).catch(
93
105
  (error) => {
94
106
  console.error('[astro-cms] API error:', error)
95
107
  sendError(res, 'Internal server error', 500)
@@ -279,6 +291,7 @@ async function handleCmsApiRoute(
279
291
  req: IncomingMessage,
280
292
  res: ServerResponse,
281
293
  manifestWriter: ManifestWriter,
294
+ contentDir: string,
282
295
  mediaAdapter?: MediaStorageAdapter,
283
296
  ): Promise<void> {
284
297
  // POST /_nua/cms/update
@@ -354,6 +367,24 @@ async function handleCmsApiRoute(
354
367
  return
355
368
  }
356
369
 
370
+ // POST /_nua/cms/markdown/delete
371
+ if (route === 'markdown/delete' && req.method === 'POST') {
372
+ const body = await parseJsonBody<Parameters<typeof handleDeleteMarkdown>[0]>(req)
373
+ // Register expected deletion so the Vite watcher ignores the unlink
374
+ const fullPath = path.resolve(getProjectRoot(), body.filePath?.replace(/^\//, '') ?? '')
375
+ expectedDeletions.add(fullPath)
376
+ const result = await handleDeleteMarkdown(body)
377
+ if (result.success) {
378
+ // Re-scan collections so the manifest reflects the deletion
379
+ const updatedCollections = await scanCollections(contentDir)
380
+ manifestWriter.setCollectionDefinitions(updatedCollections)
381
+ } else {
382
+ expectedDeletions.delete(fullPath)
383
+ }
384
+ sendJson(res, result, result.success ? 200 : 400)
385
+ return
386
+ }
387
+
357
388
  // GET /_nua/cms/media/list
358
389
  if (route === 'media/list' && req.method === 'GET') {
359
390
  if (!mediaAdapter) {
@@ -507,6 +538,10 @@ async function processHtmlForDev(
507
538
  }
508
539
 
509
540
  for (const comp of Object.values(result.components)) {
541
+ // Skip inline array components — they have no <Tag> in source;
542
+ // their props are resolved in the array-group pass below
543
+ if (comp.componentName.startsWith('__array:')) continue
544
+
510
545
  let found = false
511
546
 
512
547
  // Try invocationSourcePath first (may point to a layout, not the page)
@@ -547,7 +582,7 @@ async function processHtmlForDev(
547
582
  }
548
583
 
549
584
  for (const group of componentGroups.values()) {
550
- if (group.length <= 1) continue
585
+ if (group.length < 1) continue
551
586
  // Only process groups where at least one component has empty props (spread case)
552
587
  if (!group.some(c => Object.keys(c.props).length === 0)) continue
553
588
 
@@ -556,11 +591,33 @@ async function processHtmlForDev(
556
591
  const lines = await readLines(path.resolve(projectRoot, filePath))
557
592
  if (!lines) continue
558
593
 
559
- // Find the invocation line (occurrence 0, since .map() has a single <Component> tag)
560
- const invLine = findComponentInvocationLine(lines, firstComp.componentName, 0)
561
- if (invLine < 0) continue
562
-
563
- const pattern = detectArrayPattern(lines, invLine)
594
+ // For inline array components (__array:varName or __array:varName#N), find the .map() line
595
+ // directly instead of searching for a component tag that won't exist
596
+ let pattern: ReturnType<typeof detectArrayPattern>
597
+ const parsed = parseInlineArrayName(firstComp.componentName)
598
+ if (parsed) {
599
+ const { arrayVarName, mapOccurrence } = parsed
600
+ const fmEndCheck = findFrontmatterEnd(lines)
601
+ const mapRegex = new RegExp(buildMapPattern(arrayVarName))
602
+ let mapLine = -1
603
+ let seen = 0
604
+ for (let i = fmEndCheck; i < lines.length; i++) {
605
+ if (mapRegex.test(lines[i]!)) {
606
+ if (seen === mapOccurrence) {
607
+ mapLine = i
608
+ break
609
+ }
610
+ seen++
611
+ }
612
+ }
613
+ if (mapLine < 0) continue
614
+ pattern = { arrayVarName, mapLineIndex: mapLine }
615
+ } else {
616
+ // Find the invocation line (occurrence 0, since .map() has a single <Component> tag)
617
+ const invLine = findComponentInvocationLine(lines, firstComp.componentName, 0)
618
+ if (invLine < 0) continue
619
+ pattern = detectArrayPattern(lines, invLine)
620
+ }
564
621
  if (!pattern) continue
565
622
 
566
623
  const fmEnd = findFrontmatterEnd(lines)
@@ -1,6 +1,9 @@
1
1
  import { useMemo } from 'preact/hooks'
2
+ import { signal } from '@preact/signals'
3
+ import { deleteMarkdownPage } from '../markdown-api'
2
4
  import {
3
5
  closeCollectionsBrowser,
6
+ config,
4
7
  isCollectionsBrowserOpen,
5
8
  manifest,
6
9
  openMarkdownEditorForEntry,
@@ -10,6 +13,9 @@ import {
10
13
  } from '../signals'
11
14
  import { savePendingEntryNavigation } from '../storage'
12
15
 
16
+ const deletingEntry = signal<string | null>(null)
17
+ const confirmDeleteSlug = signal<string | null>(null)
18
+
13
19
  export function CollectionsBrowser() {
14
20
  const visible = isCollectionsBrowserOpen.value
15
21
  const selected = selectedBrowserCollection.value
@@ -54,6 +60,44 @@ export function CollectionsBrowser() {
54
60
  openMarkdownEditorForNewPage(selected, def)
55
61
  }
56
62
 
63
+ const handleDeleteClick = (e: Event, slug: string) => {
64
+ e.stopPropagation()
65
+ confirmDeleteSlug.value = slug
66
+ }
67
+
68
+ const handleConfirmDelete = async (slug: string, sourcePath: string) => {
69
+ deletingEntry.value = slug
70
+
71
+ // Optimistically remove the entry from the manifest
72
+ const defs = manifest.value.collectionDefinitions
73
+ const currentDef = defs?.[selected]
74
+ if (currentDef) {
75
+ const updatedEntries = (currentDef.entries ?? []).filter(e => e.slug !== slug)
76
+ manifest.value = {
77
+ ...manifest.value,
78
+ collectionDefinitions: {
79
+ ...defs,
80
+ [selected]: {
81
+ ...currentDef,
82
+ entries: updatedEntries,
83
+ entryCount: updatedEntries.length,
84
+ },
85
+ },
86
+ }
87
+ }
88
+
89
+ deletingEntry.value = null
90
+ confirmDeleteSlug.value = null
91
+
92
+ // Fire the API call in the background
93
+ deleteMarkdownPage(config.value, sourcePath)
94
+ }
95
+
96
+ const handleCancelDelete = (e: Event) => {
97
+ e.stopPropagation()
98
+ confirmDeleteSlug.value = null
99
+ }
100
+
57
101
  return (
58
102
  <div
59
103
  class="fixed inset-0 z-2147483647 flex items-center justify-center bg-black/60 backdrop-blur-sm"
@@ -99,33 +143,68 @@ export function CollectionsBrowser() {
99
143
  </div>
100
144
  )}
101
145
  {entries.map((entry) => (
102
- <button
103
- key={entry.slug}
104
- type="button"
105
- onClick={() => handleEntryClick(entry.slug, entry.sourcePath, entry.pathname)}
106
- class="w-full flex items-center gap-3 px-4 py-3 hover:bg-white/10 rounded-cms-lg transition-colors text-left group"
107
- data-cms-ui
108
- >
109
- <div class="flex-1 min-w-0">
110
- <div class={`font-medium truncate ${entry.draft ? 'text-white/40' : 'text-white'}`}>
111
- {entry.title || entry.slug}
146
+ <div key={entry.slug} class="relative" data-cms-ui>
147
+ {confirmDeleteSlug.value === entry.slug ? (
148
+ <div class="flex items-center gap-2 px-4 py-3 bg-red-500/10 border border-red-500/20 rounded-cms-lg" data-cms-ui>
149
+ <div class="flex-1 min-w-0 text-sm text-white/70">
150
+ Delete "{entry.title || entry.slug}"?
151
+ </div>
152
+ <button
153
+ type="button"
154
+ onClick={() => handleConfirmDelete(entry.slug, entry.sourcePath)}
155
+ disabled={deletingEntry.value === entry.slug}
156
+ class="px-3 py-1 text-xs font-medium text-white bg-red-600 hover:bg-red-700 rounded-cms-pill transition-colors disabled:opacity-50"
157
+ data-cms-ui
158
+ >
159
+ {deletingEntry.value === entry.slug ? 'Deleting...' : 'Delete'}
160
+ </button>
161
+ <button
162
+ type="button"
163
+ onClick={handleCancelDelete}
164
+ class="px-3 py-1 text-xs font-medium text-white/60 hover:text-white bg-white/10 hover:bg-white/20 rounded-cms-pill transition-colors"
165
+ data-cms-ui
166
+ >
167
+ Cancel
168
+ </button>
112
169
  </div>
113
- {entry.title && <div class="text-white/30 text-xs truncate">{entry.slug}</div>}
114
- </div>
115
- {entry.draft && (
116
- <span class="shrink-0 px-2 py-0.5 text-xs font-medium text-amber-400/80 bg-amber-400/10 rounded-full border border-amber-400/20">
117
- Draft
118
- </span>
170
+ ) : (
171
+ <button
172
+ type="button"
173
+ onClick={() => handleEntryClick(entry.slug, entry.sourcePath, entry.pathname)}
174
+ class="w-full flex items-center gap-3 px-4 py-3 hover:bg-white/10 rounded-cms-lg transition-colors text-left group"
175
+ data-cms-ui
176
+ >
177
+ <div class="flex-1 min-w-0">
178
+ <div class={`font-medium truncate ${entry.draft ? 'text-white/40' : 'text-white'}`}>
179
+ {entry.title || entry.slug}
180
+ </div>
181
+ {entry.title && <div class="text-white/30 text-xs truncate">{entry.slug}</div>}
182
+ </div>
183
+ {entry.draft && (
184
+ <span class="shrink-0 px-2 py-0.5 text-xs font-medium text-amber-400/80 bg-amber-400/10 rounded-full border border-amber-400/20">
185
+ Draft
186
+ </span>
187
+ )}
188
+ <button
189
+ type="button"
190
+ onClick={(e) => handleDeleteClick(e, entry.slug)}
191
+ class="shrink-0 p-1 text-white/0 group-hover:text-white/30 hover:!text-red-400 rounded transition-colors"
192
+ title="Delete entry"
193
+ data-cms-ui
194
+ >
195
+ <TrashIcon />
196
+ </button>
197
+ <svg
198
+ class="w-4 h-4 text-white/20 group-hover:text-white/40 shrink-0 transition-colors"
199
+ fill="none"
200
+ stroke="currentColor"
201
+ viewBox="0 0 24 24"
202
+ >
203
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
204
+ </svg>
205
+ </button>
119
206
  )}
120
- <svg
121
- class="w-4 h-4 text-white/20 group-hover:text-white/40 shrink-0 transition-colors"
122
- fill="none"
123
- stroke="currentColor"
124
- viewBox="0 0 24 24"
125
- >
126
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
127
- </svg>
128
- </button>
207
+ </div>
129
208
  ))}
130
209
  </div>
131
210
  </div>
@@ -250,3 +329,11 @@ function BackArrowIcon() {
250
329
  </svg>
251
330
  )
252
331
  }
332
+
333
+ function TrashIcon() {
334
+ return (
335
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
336
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
337
+ </svg>
338
+ )
339
+ }
@@ -107,6 +107,33 @@ export async function fetchMarkdownContent(
107
107
  return res.json()
108
108
  }
109
109
 
110
+ /**
111
+ * Delete a markdown page (collection entry)
112
+ */
113
+ export async function deleteMarkdownPage(
114
+ config: CmsConfig,
115
+ filePath: string,
116
+ ): Promise<{ success: boolean; error?: string }> {
117
+ const res = await fetchWithTimeout(`${config.apiBase}/markdown/delete`, {
118
+ method: 'POST',
119
+ credentials: 'include',
120
+ headers: {
121
+ 'Content-Type': 'application/json',
122
+ },
123
+ body: JSON.stringify({ filePath }),
124
+ })
125
+
126
+ if (!res.ok) {
127
+ const text = await res.text().catch(() => '')
128
+ return {
129
+ success: false,
130
+ error: `Delete failed (${res.status}): ${text || res.statusText}`,
131
+ }
132
+ }
133
+
134
+ return res.json()
135
+ }
136
+
110
137
  /**
111
138
  * Fetch media library items
112
139
  */
@@ -3,7 +3,7 @@ import fs from 'node:fs/promises'
3
3
  import { getProjectRoot } from '../config'
4
4
  import type { ManifestWriter } from '../manifest-writer'
5
5
  import type { CmsManifest, ComponentInstance } from '../types'
6
- import { acquireFileLock, normalizePagePath, resolveAndValidatePath } from '../utils'
6
+ import { acquireFileLock, escapeRegex, normalizePagePath, resolveAndValidatePath } from '../utils'
7
7
  import {
8
8
  findComponentInvocationFile,
9
9
  findComponentInvocationLine,
@@ -13,6 +13,40 @@ import {
13
13
  normalizeFilePath,
14
14
  } from './component-ops'
15
15
 
16
+ /**
17
+ * Parse an inline array component name like `__array:varName` or `__array:varName#1`.
18
+ * Returns the variable name and the .map() occurrence index (0-based).
19
+ */
20
+ export function parseInlineArrayName(componentName: string): { arrayVarName: string; mapOccurrence: number } | null {
21
+ if (!componentName.startsWith('__array:')) return null
22
+ const rest = componentName.slice('__array:'.length)
23
+ const hashIndex = rest.indexOf('#')
24
+ if (hashIndex < 0) return { arrayVarName: rest, mapOccurrence: 0 }
25
+ const occurrence = Number(rest.slice(hashIndex + 1))
26
+ if (Number.isNaN(occurrence)) return null
27
+ return {
28
+ arrayVarName: rest.slice(0, hashIndex),
29
+ mapOccurrence: occurrence,
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Build a regex pattern string that matches `.map(` calls on variables/properties.
35
+ *
36
+ * Supports simple variables (`{items.map(`) and dotted property paths
37
+ * (`{data.items.map(`, `{Astro.props.items.map(`).
38
+ * Also handles optional chaining (`{items?.map(`).
39
+ *
40
+ * If `varName` is omitted, uses a capture group to match any variable name
41
+ * (captures only the last segment before `.map`).
42
+ */
43
+ export function buildMapPattern(varName?: string): string {
44
+ // Allow optional leading dotted path segments (e.g. `Astro.props.`)
45
+ const prefix = '(?:[\\w]+\\.)*'
46
+ const name = varName ? escapeRegex(varName) : '(\\w+)'
47
+ return `\\{${prefix}${name}\\??\\.map\\s*\\(`
48
+ }
49
+
16
50
  export interface AddArrayItemRequest {
17
51
  referenceComponentId: string
18
52
  position: 'before' | 'after'
@@ -55,8 +89,7 @@ export function detectArrayPattern(
55
89
  const searchStart = Math.max(0, invocationLineIndex - 5)
56
90
  for (let i = invocationLineIndex; i >= searchStart; i--) {
57
91
  const line = lines[i]!
58
- // Match patterns like: {varName.map( or varName.map(
59
- const match = line.match(/\{?\s*(\w+)\.map\s*\(/)
92
+ const match = line.match(new RegExp(buildMapPattern()))
60
93
  if (match) {
61
94
  return { arrayVarName: match[1]!, mapLineIndex: i }
62
95
  }
@@ -131,7 +164,7 @@ function extractElementBounds(
131
164
  ): ArrayElementBounds[] {
132
165
  const bounds: ArrayElementBounds[] = []
133
166
  for (const el of elements) {
134
- if (el?.loc) {
167
+ if (el?.loc && el.type !== 'SpreadElement') {
135
168
  bounds.push({
136
169
  // Babel loc is 1-indexed; convert to 0-indexed file lines
137
170
  startLine: el.loc.start.line - 1 + frontmatterStartLine,
@@ -254,6 +287,66 @@ async function resolveArrayContext(
254
287
  ) {
255
288
  const projectRoot = getProjectRoot()
256
289
 
290
+ // Inline array components (__array:varName or __array:varName#N) — find .map() line directly
291
+ const parsed = parseInlineArrayName(component.componentName)
292
+ if (parsed) {
293
+ const { arrayVarName, mapOccurrence } = parsed
294
+ const filePath = normalizeFilePath(component.invocationSourcePath ?? component.sourcePath)
295
+ const fullPath = resolveAndValidatePath(filePath)
296
+ const content = await fs.readFile(fullPath, 'utf-8')
297
+ const lines = content.split('\n')
298
+
299
+ // Find the Nth .map() line by searching the template section
300
+ const fmEnd = findFrontmatterEnd(lines)
301
+ if (fmEnd === 0) return null
302
+
303
+ let mapLineIndex = -1
304
+ let occurrencesSeen = 0
305
+ const mapPattern = new RegExp(buildMapPattern(arrayVarName))
306
+ for (let i = fmEnd; i < lines.length; i++) {
307
+ if (mapPattern.test(lines[i]!)) {
308
+ if (occurrencesSeen === mapOccurrence) {
309
+ mapLineIndex = i
310
+ break
311
+ }
312
+ occurrencesSeen++
313
+ }
314
+ }
315
+ if (mapLineIndex < 0) return null
316
+
317
+ const frontmatterStartLine = 1
318
+ const frontmatterContent = lines.slice(1, fmEnd - 1).join('\n')
319
+
320
+ const elementBounds = findArrayDeclaration(
321
+ frontmatterContent,
322
+ frontmatterStartLine,
323
+ arrayVarName,
324
+ )
325
+ if (!elementBounds || elementBounds.length === 0) return null
326
+
327
+ // Get array index from same-source components (sorted by invocationIndex for reliable ordering)
328
+ const sameSourceComponents = Object.values(manifest.components)
329
+ .filter(c =>
330
+ c.componentName === component.componentName
331
+ && c.invocationSourcePath === component.invocationSourcePath
332
+ )
333
+ .sort((a, b) => (a.invocationIndex ?? 0) - (b.invocationIndex ?? 0))
334
+ const arrayIndex = sameSourceComponents.findIndex(c => c.id === component.id)
335
+ if (arrayIndex < 0 || arrayIndex >= elementBounds.length) return null
336
+
337
+ return {
338
+ filePath,
339
+ fullPath,
340
+ lines,
341
+ content,
342
+ elementBounds,
343
+ arrayIndex,
344
+ frontmatterContent,
345
+ frontmatterStartLine,
346
+ arrayVarName,
347
+ }
348
+ }
349
+
257
350
  const invocation = await findComponentInvocationFile(
258
351
  projectRoot,
259
352
  pageUrl,
@@ -308,11 +401,13 @@ async function resolveArrayContext(
308
401
  // which maps directly to the Nth array element.
309
402
  const occurrenceIndex = getComponentOccurrenceIndex(manifest, component)
310
403
  // Count only components with the same name AND same invocationSourcePath to get array index
404
+ // Sort by invocationIndex for reliable ordering (don't rely on Object.values insertion order)
311
405
  const sameSourceComponents = Object.values(manifest.components)
312
406
  .filter(c =>
313
407
  c.componentName === component.componentName
314
408
  && c.invocationSourcePath === component.invocationSourcePath
315
409
  )
410
+ .sort((a, b) => (a.invocationIndex ?? 0) - (b.invocationIndex ?? 0))
316
411
  const arrayIndex = sameSourceComponents.findIndex(c => c.id === component.id)
317
412
 
318
413
  if (arrayIndex < 0 || arrayIndex >= elementBounds.length) {
@@ -329,7 +424,6 @@ async function resolveArrayContext(
329
424
  frontmatterContent,
330
425
  frontmatterStartLine,
331
426
  arrayVarName: pattern.arrayVarName,
332
- occurrenceIndex,
333
427
  }
334
428
  }
335
429
 
@@ -366,15 +460,25 @@ export async function handleRemoveArrayItem(
366
460
  return { success: false, error: 'Could not detect array pattern for this component' }
367
461
  }
368
462
 
369
- const { fullPath, lines, elementBounds, arrayIndex } = ctx
463
+ const { fullPath, arrayIndex } = ctx
370
464
 
371
465
  const release = await acquireFileLock(fullPath)
372
466
  try {
373
- // Re-read the file to avoid stale data
467
+ // Re-read the file and recompute bounds to avoid stale data
374
468
  const freshContent = await fs.readFile(fullPath, 'utf-8')
375
469
  const freshLines = freshContent.split('\n')
376
470
 
377
- const bounds = elementBounds[arrayIndex]!
471
+ const freshFmEnd = findFrontmatterEnd(freshLines)
472
+ if (freshFmEnd === 0) {
473
+ return { success: false, error: 'Could not find frontmatter in source file' }
474
+ }
475
+ const freshFmContent = freshLines.slice(1, freshFmEnd - 1).join('\n')
476
+ const freshBounds = findArrayDeclaration(freshFmContent, 1, ctx.arrayVarName)
477
+ if (!freshBounds || arrayIndex >= freshBounds.length) {
478
+ return { success: false, error: 'Array declaration not found or index out of bounds after re-read' }
479
+ }
480
+
481
+ const bounds = freshBounds[arrayIndex]!
378
482
  const removeStart = bounds.startLine
379
483
  let removeEnd = bounds.endLine
380
484
 
@@ -387,8 +491,8 @@ export async function handleRemoveArrayItem(
387
491
  // now becomes the last element (remove its trailing comma)
388
492
  }
389
493
 
390
- // Check line after removeEnd for a comma-only or blank line
391
- if (removeEnd + 1 < freshLines.length && freshLines[removeEnd + 1]!.trim() === '') {
494
+ // Check line after removeEnd for a blank separator line (only between elements, not at start)
495
+ if (removeStart > 0 && removeEnd + 1 < freshLines.length && freshLines[removeEnd + 1]!.trim() === '') {
392
496
  removeEnd++
393
497
  }
394
498
 
@@ -454,14 +558,25 @@ export async function handleAddArrayItem(
454
558
  return { success: false, error: 'Could not detect array pattern for this component' }
455
559
  }
456
560
 
457
- const { fullPath, elementBounds, arrayIndex } = ctx
561
+ const { fullPath, arrayIndex } = ctx
458
562
 
459
563
  const release = await acquireFileLock(fullPath)
460
564
  try {
565
+ // Re-read the file and recompute bounds to avoid stale data
461
566
  const freshContent = await fs.readFile(fullPath, 'utf-8')
462
567
  const freshLines = freshContent.split('\n')
463
568
 
464
- const refBounds = elementBounds[arrayIndex]!
569
+ const freshFmEnd = findFrontmatterEnd(freshLines)
570
+ if (freshFmEnd === 0) {
571
+ return { success: false, error: 'Could not find frontmatter in source file' }
572
+ }
573
+ const freshFmContent = freshLines.slice(1, freshFmEnd - 1).join('\n')
574
+ const freshBounds = findArrayDeclaration(freshFmContent, 1, ctx.arrayVarName)
575
+ if (!freshBounds || arrayIndex >= freshBounds.length) {
576
+ return { success: false, error: 'Array declaration not found or index out of bounds after re-read' }
577
+ }
578
+
579
+ const refBounds = freshBounds[arrayIndex]!
465
580
 
466
581
  // Generate JS object literal from props
467
582
  const newElement = generateObjectLiteral(props)
@@ -482,7 +597,7 @@ export async function handleAddArrayItem(
482
597
  if (position === 'before') {
483
598
  // Insert before the reference element
484
599
  const insertLine = refBounds.startLine
485
- freshLines.splice(insertLine, 0, indentedLines + ',')
600
+ freshLines.splice(insertLine, 0, ...(indentedLines + ',').split('\n'))
486
601
  } else {
487
602
  // Insert after the reference element
488
603
  const insertLine = refBounds.endLine + 1
@@ -491,17 +606,19 @@ export async function handleAddArrayItem(
491
606
  if (!refEndLine.trimEnd().endsWith(',')) {
492
607
  freshLines[refBounds.endLine] = refEndLine.replace(/(\s*)$/, ',$1')
493
608
  }
494
- freshLines.splice(insertLine, 0, indentedLines + ',')
609
+ freshLines.splice(insertLine, 0, ...(indentedLines + ',').split('\n'))
495
610
  }
496
611
 
497
612
  // Clean up trailing comma before closing bracket
498
- // Find the closing ] and remove comma from the last element
499
- for (let i = freshLines.length - 1; i >= 0; i--) {
613
+ // Search forward from the last element to find this array's closing ]
614
+ // The insertion always happens within the array, so the ] shifts by addedLineCount
615
+ const lastEl = freshBounds[freshBounds.length - 1]!
616
+ const addedLineCount = indentedLines.split('\n').length
617
+ const closingSearchStart = lastEl.endLine + addedLineCount + 1
618
+ for (let i = closingSearchStart; i < closingSearchStart + 5 && i < freshLines.length; i++) {
500
619
  if (freshLines[i]!.trim().startsWith(']')) {
501
620
  const prev = freshLines[i - 1]
502
621
  if (prev?.trimEnd().endsWith(',')) {
503
- // Check if this is the array we're editing by scanning backwards
504
- // to find the array variable
505
622
  freshLines[i - 1] = prev.replace(/,(\s*)$/, '$1')
506
623
  }
507
624
  break
@@ -528,7 +645,7 @@ export async function handleAddArrayItem(
528
645
  * Generate a JavaScript object literal string from props.
529
646
  * Example: { name: 'Components', slug: 'components' }
530
647
  */
531
- function generateObjectLiteral(props: Record<string, unknown>): string {
648
+ export function generateObjectLiteral(props: Record<string, unknown>): string {
532
649
  const entries = Object.entries(props)
533
650
  if (entries.length === 0) return '{}'
534
651
 
@@ -545,9 +662,10 @@ function generateObjectLiteral(props: Record<string, unknown>): string {
545
662
  }
546
663
 
547
664
  function formatValue(value: unknown): string {
548
- if (typeof value === 'string') return `'${value.replace(/'/g, "\\'")}'`
665
+ if (typeof value === 'string') return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`
549
666
  if (typeof value === 'number' || typeof value === 'boolean') return String(value)
550
- if (value === null || value === undefined) return 'undefined'
667
+ if (value === null) return 'null'
668
+ if (value === undefined) return 'undefined'
551
669
  if (Array.isArray(value)) return `[${value.map(formatValue).join(', ')}]`
552
670
  if (typeof value === 'object') return generateObjectLiteral(value as Record<string, unknown>)
553
671
  return String(value)
@@ -41,6 +41,15 @@ export interface UpdateMarkdownResponse {
41
41
  error?: string
42
42
  }
43
43
 
44
+ export interface DeleteMarkdownRequest {
45
+ filePath: string
46
+ }
47
+
48
+ export interface DeleteMarkdownResponse {
49
+ success: boolean
50
+ error?: string
51
+ }
52
+
44
53
  export interface GetMarkdownContentResponse {
45
54
  content: string
46
55
  frontmatter: BlogFrontmatter
@@ -135,6 +144,26 @@ export async function handleCreateMarkdown(
135
144
  }
136
145
  }
137
146
 
147
+ export async function handleDeleteMarkdown(
148
+ request: DeleteMarkdownRequest,
149
+ ): Promise<DeleteMarkdownResponse> {
150
+ try {
151
+ const fullPath = resolveAndValidatePath(request.filePath)
152
+
153
+ // Verify the file exists before deleting
154
+ await fs.access(fullPath)
155
+ await fs.unlink(fullPath)
156
+
157
+ return { success: true }
158
+ } catch (error) {
159
+ if (error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'ENOENT') {
160
+ return { success: false, error: `File not found: ${request.filePath}` }
161
+ }
162
+ const message = error instanceof Error ? error.message : String(error)
163
+ return { success: false, error: message }
164
+ }
165
+ }
166
+
138
167
  // --- Internal helpers ---
139
168
 
140
169
  /**
@@ -1,6 +1,6 @@
1
+ import { NodeType, parse as parseHtml } from 'node-html-parser'
1
2
  import fs from 'node:fs/promises'
2
3
  import path from 'node:path'
3
- import { NodeType, parse as parseHtml } from 'node-html-parser'
4
4
  import { getProjectRoot } from '../config'
5
5
  import type { AttributeChangePayload, ChangePayload, SaveBatchRequest } from '../editor/types'
6
6
  import type { ManifestWriter } from '../manifest-writer'