@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/dist/editor.js +4993 -4912
- package/package.json +1 -1
- package/src/dev-middleware.ts +66 -9
- package/src/editor/components/collections-browser.tsx +112 -25
- package/src/editor/markdown-api.ts +27 -0
- package/src/handlers/array-ops.ts +139 -21
- package/src/handlers/markdown-ops.ts +29 -0
- package/src/handlers/source-writer.ts +1 -1
- package/src/html-processor.ts +60 -0
- package/src/types.ts +2 -0
- package/src/vite-plugin-array-transform.ts +119 -0
- package/src/vite-plugin.ts +27 -2
package/package.json
CHANGED
package/src/dev-middleware.ts
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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
|
-
<
|
|
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
|
*/
|
|
@@ -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
|
-
|
|
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,
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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
|
-
//
|
|
499
|
-
|
|
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
|
|
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'
|