@nuasite/cms 0.13.0 → 0.13.3

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.13.0",
17
+ "version": "0.13.3",
18
18
  "module": "src/index.ts",
19
19
  "types": "src/index.ts",
20
20
  "type": "module",
@@ -2,6 +2,7 @@ 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
7
  import { buildMapPattern, detectArrayPattern, extractArrayElementProps, handleAddArrayItem, handleRemoveArrayItem, parseInlineArrayName } from './handlers/array-ops'
7
8
  import {
@@ -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) {
@@ -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
  */
@@ -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,4 +1,5 @@
1
1
  import type { Plugin } from 'vite'
2
+ import { expectedDeletions } from './dev-middleware'
2
3
  import type { ManifestWriter } from './manifest-writer'
3
4
  import type { CmsMarkerOptions, ComponentDefinition } from './types'
4
5
  import { createArrayTransformPlugin } from './vite-plugin-array-transform'
@@ -12,7 +13,7 @@ export interface VitePluginContext {
12
13
  }
13
14
 
14
15
  export function createVitePlugin(context: VitePluginContext): Plugin[] {
15
- const { manifestWriter, componentDefinitions } = context
16
+ const { manifestWriter, componentDefinitions, command } = context
16
17
 
17
18
  const virtualManifestPlugin: Plugin = {
18
19
  name: 'cms-marker-virtual-manifest',
@@ -34,10 +35,33 @@ export function createVitePlugin(context: VitePluginContext): Plugin[] {
34
35
  },
35
36
  }
36
37
 
38
+ // Intercept Vite's file watcher to suppress full page reloads when the CMS
39
+ // deletes a content collection entry. Without this, Vite/Astro detects the
40
+ // unlink and forces a reload, undoing the optimistic UI update.
41
+ const watcherPlugin: Plugin = {
42
+ name: 'cms-suppress-delete-reload',
43
+ configureServer(server) {
44
+ if (command !== 'dev') return
45
+
46
+ // Monkey-patch the watcher to intercept unlink events before Vite/Astro
47
+ // processes them. We use prependListener so our handler runs first.
48
+ const watcher = server.watcher
49
+ const origEmit = watcher.emit.bind(watcher)
50
+ watcher.emit = function (event: string, filePath: string, ...args: any[]) {
51
+ if ((event === 'unlink' || event === 'unlinkDir') && expectedDeletions.has(filePath)) {
52
+ expectedDeletions.delete(filePath)
53
+ // Swallow the event — don't let Vite/Astro see it
54
+ return true
55
+ }
56
+ return origEmit(event, filePath, ...args)
57
+ } as typeof watcher.emit
58
+ },
59
+ }
60
+
37
61
  // Note: We cannot use transformIndexHtml for static Astro builds because
38
62
  // Astro generates HTML files directly without going through Vite's HTML pipeline.
39
63
  // HTML processing is done in build-processor.ts after pages are generated.
40
64
  // Source location attributes are provided natively by Astro's compiler
41
65
  // (data-astro-source-file, data-astro-source-loc) in dev mode.
42
- return [virtualManifestPlugin, createArrayTransformPlugin()]
66
+ return [virtualManifestPlugin, watcherPlugin, createArrayTransformPlugin()]
43
67
  }