@nuasite/cms 0.13.0 → 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/dist/editor.js +4993 -4912
- package/package.json +1 -1
- package/src/dev-middleware.ts +33 -2
- package/src/editor/components/collections-browser.tsx +112 -25
- package/src/editor/markdown-api.ts +27 -0
- package/src/handlers/markdown-ops.ts +29 -0
- package/src/vite-plugin.ts +26 -2
package/package.json
CHANGED
package/src/dev-middleware.ts
CHANGED
|
@@ -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
|
-
<
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
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
|
/**
|
package/src/vite-plugin.ts
CHANGED
|
@@ -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
|
}
|