@nuasite/cms 0.2.2 → 0.3.0
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/README.md +81 -73
- package/dist/src/build-processor.d.ts.map +1 -1
- package/dist/src/component-registry.d.ts +6 -2
- package/dist/src/component-registry.d.ts.map +1 -1
- package/dist/src/dev-middleware.d.ts.map +1 -1
- package/dist/src/editor/api.d.ts +14 -0
- package/dist/src/editor/api.d.ts.map +1 -1
- package/dist/src/editor/components/ai-chat.d.ts.map +1 -1
- package/dist/src/editor/components/block-editor.d.ts.map +1 -1
- package/dist/src/editor/components/color-toolbar.d.ts.map +1 -1
- package/dist/src/editor/components/editable-highlights.d.ts.map +1 -1
- package/dist/src/editor/components/outline.d.ts.map +1 -1
- package/dist/src/editor/constants.d.ts +1 -0
- package/dist/src/editor/constants.d.ts.map +1 -1
- package/dist/src/editor/dom.d.ts +9 -0
- package/dist/src/editor/dom.d.ts.map +1 -1
- package/dist/src/editor/editor.d.ts.map +1 -1
- package/dist/src/editor/history.d.ts.map +1 -1
- package/dist/src/editor/hooks/useBlockEditorHandlers.d.ts.map +1 -1
- package/dist/src/editor/index.d.ts.map +1 -1
- package/dist/src/editor/storage.d.ts +2 -0
- package/dist/src/editor/storage.d.ts.map +1 -1
- package/dist/src/handlers/array-ops.d.ts +59 -0
- package/dist/src/handlers/array-ops.d.ts.map +1 -0
- package/dist/src/handlers/component-ops.d.ts +26 -0
- package/dist/src/handlers/component-ops.d.ts.map +1 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/source-finder/cross-file-tracker.d.ts.map +1 -1
- package/dist/src/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/build-processor.ts +27 -0
- package/src/component-registry.ts +125 -76
- package/src/dev-middleware.ts +85 -16
- package/src/editor/api.ts +72 -0
- package/src/editor/components/ai-chat.tsx +0 -1
- package/src/editor/components/block-editor.tsx +92 -17
- package/src/editor/components/color-toolbar.tsx +7 -1
- package/src/editor/components/editable-highlights.tsx +4 -1
- package/src/editor/components/outline.tsx +11 -6
- package/src/editor/constants.ts +1 -0
- package/src/editor/dom.ts +46 -1
- package/src/editor/editor.ts +5 -2
- package/src/editor/history.ts +1 -6
- package/src/editor/hooks/useBlockEditorHandlers.ts +86 -29
- package/src/editor/index.tsx +24 -8
- package/src/editor/storage.ts +24 -0
- package/src/handlers/array-ops.ts +452 -0
- package/src/handlers/component-ops.ts +269 -18
- package/src/handlers/markdown-ops.ts +7 -4
- package/src/handlers/request-utils.ts +1 -1
- package/src/handlers/source-writer.ts +4 -5
- package/src/index.ts +15 -10
- package/src/manifest-writer.ts +1 -1
- package/src/source-finder/cross-file-tracker.ts +1 -1
- package/src/source-finder/search-index.ts +1 -1
package/src/editor/index.tsx
CHANGED
|
@@ -32,7 +32,6 @@ import {
|
|
|
32
32
|
toggleShowOriginal,
|
|
33
33
|
} from './editor'
|
|
34
34
|
import { performRedo, performUndo } from './history'
|
|
35
|
-
import CMS_STYLES from './styles.css?inline'
|
|
36
35
|
import {
|
|
37
36
|
useAIHandlers,
|
|
38
37
|
useBlockEditorHandlers,
|
|
@@ -52,7 +51,8 @@ import {
|
|
|
52
51
|
updateSettings,
|
|
53
52
|
} from './signals'
|
|
54
53
|
import * as signals from './signals'
|
|
55
|
-
import { hasPendingEntryNavigation, loadSettingsFromStorage, saveSettingsToStorage } from './storage'
|
|
54
|
+
import { hasPendingEntryNavigation, loadEditingState, loadSettingsFromStorage, saveSettingsToStorage } from './storage'
|
|
55
|
+
import CMS_STYLES from './styles.css?inline'
|
|
56
56
|
import { generateCSSVariables, resolveTheme } from './themes'
|
|
57
57
|
|
|
58
58
|
const CmsUI = () => {
|
|
@@ -80,6 +80,13 @@ const CmsUI = () => {
|
|
|
80
80
|
}).catch(() => {})
|
|
81
81
|
}, [])
|
|
82
82
|
|
|
83
|
+
// Auto-restore edit mode if it was active before a page refresh (e.g. after save triggers HMR)
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
if (loadEditingState() && !signals.isEditing.value) {
|
|
86
|
+
startEditMode(config, updateUI)
|
|
87
|
+
}
|
|
88
|
+
}, [config, updateUI])
|
|
89
|
+
|
|
83
90
|
// Auto-open markdown editor when there's a pending entry navigation from collections browser
|
|
84
91
|
useEffect(() => {
|
|
85
92
|
if (hasPendingEntryNavigation()) {
|
|
@@ -178,12 +185,21 @@ const CmsUI = () => {
|
|
|
178
185
|
}, [])
|
|
179
186
|
|
|
180
187
|
// Color toolbar handlers
|
|
181
|
-
const handleColorToolbarChange = useCallback(
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
188
|
+
const handleColorToolbarChange = useCallback(
|
|
189
|
+
(
|
|
190
|
+
colorType: 'bg' | 'text' | 'border' | 'hoverBg' | 'hoverText',
|
|
191
|
+
oldClass: string,
|
|
192
|
+
newClass: string,
|
|
193
|
+
previousClassName: string,
|
|
194
|
+
previousStyleCssText: string,
|
|
195
|
+
) => {
|
|
196
|
+
const targetId = signals.colorEditorState.value.targetElementId
|
|
197
|
+
if (!targetId) return
|
|
198
|
+
|
|
199
|
+
handleColorChange(config, targetId, colorType, oldClass, newClass, updateUI, previousClassName, previousStyleCssText)
|
|
200
|
+
},
|
|
201
|
+
[config, updateUI],
|
|
202
|
+
)
|
|
187
203
|
|
|
188
204
|
const handleColorToolbarClose = useCallback(() => {
|
|
189
205
|
signals.closeColorEditor()
|
package/src/editor/storage.ts
CHANGED
|
@@ -254,6 +254,30 @@ export function loadPendingEntryNavigation(): PendingEntryNavigation | null {
|
|
|
254
254
|
}
|
|
255
255
|
}
|
|
256
256
|
|
|
257
|
+
// ============================================================================
|
|
258
|
+
// Editing State (persist edit mode across HMR/refresh)
|
|
259
|
+
// ============================================================================
|
|
260
|
+
|
|
261
|
+
export function saveEditingState(isEditing: boolean): void {
|
|
262
|
+
try {
|
|
263
|
+
if (isEditing) {
|
|
264
|
+
sessionStorage.setItem(STORAGE_KEYS.IS_EDITING, '1')
|
|
265
|
+
} else {
|
|
266
|
+
sessionStorage.removeItem(STORAGE_KEYS.IS_EDITING)
|
|
267
|
+
}
|
|
268
|
+
} catch (e) {
|
|
269
|
+
console.warn('[CMS] Failed to save editing state:', e)
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export function loadEditingState(): boolean {
|
|
274
|
+
try {
|
|
275
|
+
return sessionStorage.getItem(STORAGE_KEYS.IS_EDITING) === '1'
|
|
276
|
+
} catch {
|
|
277
|
+
return false
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
257
281
|
// ============================================================================
|
|
258
282
|
// Clear All
|
|
259
283
|
// ============================================================================
|
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
import { parse as parseBabel } from '@babel/parser'
|
|
2
|
+
import fs from 'node:fs/promises'
|
|
3
|
+
import { getProjectRoot } from '../config'
|
|
4
|
+
import type { ManifestWriter } from '../manifest-writer'
|
|
5
|
+
import type { CmsManifest, ComponentInstance } from '../types'
|
|
6
|
+
import { acquireFileLock, normalizePagePath, resolveAndValidatePath } from '../utils'
|
|
7
|
+
import {
|
|
8
|
+
findComponentInvocationFile,
|
|
9
|
+
findComponentInvocationLine,
|
|
10
|
+
findFrontmatterEnd,
|
|
11
|
+
getComponentOccurrenceIndex,
|
|
12
|
+
getIndentation,
|
|
13
|
+
normalizeFilePath,
|
|
14
|
+
} from './component-ops'
|
|
15
|
+
|
|
16
|
+
export interface AddArrayItemRequest {
|
|
17
|
+
referenceComponentId: string
|
|
18
|
+
position: 'before' | 'after'
|
|
19
|
+
props: Record<string, unknown>
|
|
20
|
+
meta?: { source: string; url: string }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface AddArrayItemResponse {
|
|
24
|
+
success: boolean
|
|
25
|
+
message?: string
|
|
26
|
+
sourceFile?: string
|
|
27
|
+
error?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface RemoveArrayItemRequest {
|
|
31
|
+
componentId: string
|
|
32
|
+
meta?: { source: string; url: string }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface RemoveArrayItemResponse {
|
|
36
|
+
success: boolean
|
|
37
|
+
message?: string
|
|
38
|
+
sourceFile?: string
|
|
39
|
+
error?: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Scan backwards from a component invocation line to find a `.map(` pattern
|
|
44
|
+
* and extract the array variable name.
|
|
45
|
+
*
|
|
46
|
+
* Looks for patterns like:
|
|
47
|
+
* {packages.map((pkg) => <PackageCard {...pkg} />)}
|
|
48
|
+
* {items.map(item => (
|
|
49
|
+
*/
|
|
50
|
+
export function detectArrayPattern(
|
|
51
|
+
lines: string[],
|
|
52
|
+
invocationLineIndex: number,
|
|
53
|
+
): { arrayVarName: string; mapLineIndex: number } | null {
|
|
54
|
+
// Search up to 5 lines above (the `.map(` may be on the same line or a few lines above)
|
|
55
|
+
const searchStart = Math.max(0, invocationLineIndex - 5)
|
|
56
|
+
for (let i = invocationLineIndex; i >= searchStart; i--) {
|
|
57
|
+
const line = lines[i]!
|
|
58
|
+
// Match patterns like: {varName.map( or varName.map(
|
|
59
|
+
const match = line.match(/\{?\s*(\w+)\.map\s*\(/)
|
|
60
|
+
if (match) {
|
|
61
|
+
return { arrayVarName: match[1]!, mapLineIndex: i }
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return null
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface ArrayElementBounds {
|
|
68
|
+
startLine: number
|
|
69
|
+
endLine: number
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Parse frontmatter with Babel, walk the AST to find the array variable declaration,
|
|
74
|
+
* and return the line bounds of each element.
|
|
75
|
+
*
|
|
76
|
+
* @param frontmatterContent - The raw frontmatter code (between --- delimiters)
|
|
77
|
+
* @param frontmatterStartLine - The 0-indexed line where frontmatter content starts in the file
|
|
78
|
+
* (line after the opening `---`)
|
|
79
|
+
* @param arrayVarName - The variable name of the array to find
|
|
80
|
+
*/
|
|
81
|
+
export function findArrayDeclaration(
|
|
82
|
+
frontmatterContent: string,
|
|
83
|
+
frontmatterStartLine: number,
|
|
84
|
+
arrayVarName: string,
|
|
85
|
+
): ArrayElementBounds[] | null {
|
|
86
|
+
let ast: ReturnType<typeof parseBabel>
|
|
87
|
+
try {
|
|
88
|
+
ast = parseBabel(frontmatterContent, {
|
|
89
|
+
sourceType: 'module',
|
|
90
|
+
plugins: ['typescript'],
|
|
91
|
+
errorRecovery: true,
|
|
92
|
+
})
|
|
93
|
+
} catch {
|
|
94
|
+
return null
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Walk the top-level statements to find the array declaration
|
|
98
|
+
for (const node of ast.program.body) {
|
|
99
|
+
// Handle: const foo = [...]
|
|
100
|
+
if (node.type === 'VariableDeclaration') {
|
|
101
|
+
for (const decl of node.declarations) {
|
|
102
|
+
if (
|
|
103
|
+
decl.id.type === 'Identifier'
|
|
104
|
+
&& decl.id.name === arrayVarName
|
|
105
|
+
&& decl.init?.type === 'ArrayExpression'
|
|
106
|
+
) {
|
|
107
|
+
return extractElementBounds(decl.init.elements, frontmatterStartLine)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// Handle: export const foo = [...]
|
|
112
|
+
if (node.type === 'ExportNamedDeclaration' && node.declaration?.type === 'VariableDeclaration') {
|
|
113
|
+
for (const decl of node.declaration.declarations) {
|
|
114
|
+
if (
|
|
115
|
+
decl.id.type === 'Identifier'
|
|
116
|
+
&& decl.id.name === arrayVarName
|
|
117
|
+
&& decl.init?.type === 'ArrayExpression'
|
|
118
|
+
) {
|
|
119
|
+
return extractElementBounds(decl.init.elements, frontmatterStartLine)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return null
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function extractElementBounds(
|
|
129
|
+
elements: any[],
|
|
130
|
+
frontmatterStartLine: number,
|
|
131
|
+
): ArrayElementBounds[] {
|
|
132
|
+
const bounds: ArrayElementBounds[] = []
|
|
133
|
+
for (const el of elements) {
|
|
134
|
+
if (el && el.loc) {
|
|
135
|
+
bounds.push({
|
|
136
|
+
// Babel loc is 1-indexed; convert to 0-indexed file lines
|
|
137
|
+
startLine: el.loc.start.line - 1 + frontmatterStartLine,
|
|
138
|
+
endLine: el.loc.end.line - 1 + frontmatterStartLine,
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return bounds
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Resolve the file, lines, invocation index, and array info for a component.
|
|
147
|
+
*/
|
|
148
|
+
async function resolveArrayContext(
|
|
149
|
+
component: ComponentInstance,
|
|
150
|
+
manifest: CmsManifest,
|
|
151
|
+
pageUrl: string,
|
|
152
|
+
) {
|
|
153
|
+
const projectRoot = getProjectRoot()
|
|
154
|
+
|
|
155
|
+
const invocation = await findComponentInvocationFile(
|
|
156
|
+
projectRoot,
|
|
157
|
+
pageUrl,
|
|
158
|
+
manifest,
|
|
159
|
+
component,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
const filePath = invocation?.filePath
|
|
163
|
+
?? normalizeFilePath(component.invocationSourcePath ?? component.sourcePath)
|
|
164
|
+
|
|
165
|
+
const fullPath = resolveAndValidatePath(filePath)
|
|
166
|
+
const content = await fs.readFile(fullPath, 'utf-8')
|
|
167
|
+
const lines = content.split('\n')
|
|
168
|
+
|
|
169
|
+
let refLineIndex: number
|
|
170
|
+
if (invocation) {
|
|
171
|
+
refLineIndex = invocation.lineIndex
|
|
172
|
+
} else {
|
|
173
|
+
const occurrenceIndex = getComponentOccurrenceIndex(manifest, component)
|
|
174
|
+
refLineIndex = findComponentInvocationLine(lines, component.componentName, occurrenceIndex)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (refLineIndex < 0 || refLineIndex >= lines.length) {
|
|
178
|
+
return null
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const pattern = detectArrayPattern(lines, refLineIndex)
|
|
182
|
+
if (!pattern) {
|
|
183
|
+
return null
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Extract frontmatter content
|
|
187
|
+
const fmEnd = findFrontmatterEnd(lines)
|
|
188
|
+
if (fmEnd === 0) return null // No frontmatter
|
|
189
|
+
|
|
190
|
+
// frontmatterStartLine is the line after the opening ---
|
|
191
|
+
const frontmatterStartLine = 1 // Line 0 is `---`, line 1 is first content line
|
|
192
|
+
const frontmatterContent = lines.slice(1, fmEnd - 1).join('\n')
|
|
193
|
+
|
|
194
|
+
const elementBounds = findArrayDeclaration(
|
|
195
|
+
frontmatterContent,
|
|
196
|
+
frontmatterStartLine,
|
|
197
|
+
pattern.arrayVarName,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
if (!elementBounds || elementBounds.length === 0) {
|
|
201
|
+
return null
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Determine which array element this component corresponds to.
|
|
205
|
+
// The invocationIndex tells us the Nth occurrence of this component in the template,
|
|
206
|
+
// which maps directly to the Nth array element.
|
|
207
|
+
const occurrenceIndex = getComponentOccurrenceIndex(manifest, component)
|
|
208
|
+
// Count only components with the same name AND same invocationSourcePath to get array index
|
|
209
|
+
const sameSourceComponents = Object.values(manifest.components)
|
|
210
|
+
.filter(c =>
|
|
211
|
+
c.componentName === component.componentName
|
|
212
|
+
&& c.invocationSourcePath === component.invocationSourcePath
|
|
213
|
+
)
|
|
214
|
+
const arrayIndex = sameSourceComponents.findIndex(c => c.id === component.id)
|
|
215
|
+
|
|
216
|
+
if (arrayIndex < 0 || arrayIndex >= elementBounds.length) {
|
|
217
|
+
return null
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
filePath,
|
|
222
|
+
fullPath,
|
|
223
|
+
lines,
|
|
224
|
+
content,
|
|
225
|
+
elementBounds,
|
|
226
|
+
arrayIndex,
|
|
227
|
+
frontmatterContent,
|
|
228
|
+
frontmatterStartLine,
|
|
229
|
+
arrayVarName: pattern.arrayVarName,
|
|
230
|
+
occurrenceIndex,
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export async function handleRemoveArrayItem(
|
|
235
|
+
request: RemoveArrayItemRequest,
|
|
236
|
+
manifestWriter: ManifestWriter,
|
|
237
|
+
): Promise<RemoveArrayItemResponse> {
|
|
238
|
+
const { componentId, meta } = request
|
|
239
|
+
|
|
240
|
+
if (!meta?.url) {
|
|
241
|
+
return { success: false, error: 'Page URL is required in meta' }
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const pagePath = normalizePagePath(meta.url)
|
|
245
|
+
const pageData = manifestWriter.getPageManifest(pagePath)
|
|
246
|
+
if (!pageData) {
|
|
247
|
+
return { success: false, error: 'Page manifest not found' }
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const manifest: CmsManifest = {
|
|
251
|
+
entries: pageData.entries,
|
|
252
|
+
components: pageData.components,
|
|
253
|
+
componentDefinitions: manifestWriter.getComponentDefinitions(),
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const component = manifest.components[componentId]
|
|
257
|
+
if (!component) {
|
|
258
|
+
return { success: false, error: `Component '${componentId}' not found in manifest` }
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
const ctx = await resolveArrayContext(component, manifest, meta.url)
|
|
263
|
+
if (!ctx) {
|
|
264
|
+
return { success: false, error: 'Could not detect array pattern for this component' }
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const { fullPath, lines, elementBounds, arrayIndex } = ctx
|
|
268
|
+
|
|
269
|
+
const release = await acquireFileLock(fullPath)
|
|
270
|
+
try {
|
|
271
|
+
// Re-read the file to avoid stale data
|
|
272
|
+
const freshContent = await fs.readFile(fullPath, 'utf-8')
|
|
273
|
+
const freshLines = freshContent.split('\n')
|
|
274
|
+
|
|
275
|
+
const bounds = elementBounds[arrayIndex]!
|
|
276
|
+
let removeStart = bounds.startLine
|
|
277
|
+
let removeEnd = bounds.endLine
|
|
278
|
+
|
|
279
|
+
// Clean up trailing comma on the line after the element, or leading comma
|
|
280
|
+
// Check if there's a trailing comma after the element's end line
|
|
281
|
+
const afterEndLine = freshLines[removeEnd]
|
|
282
|
+
if (afterEndLine !== undefined) {
|
|
283
|
+
// If the element's end line has a trailing comma, it'll be removed with the element
|
|
284
|
+
// But we also need to handle the case where the PREVIOUS element's trailing comma
|
|
285
|
+
// now becomes the last element (remove its trailing comma)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Check line after removeEnd for a comma-only or blank line
|
|
289
|
+
if (removeEnd + 1 < freshLines.length && freshLines[removeEnd + 1]!.trim() === '') {
|
|
290
|
+
removeEnd++
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Remove the element lines
|
|
294
|
+
freshLines.splice(removeStart, removeEnd - removeStart + 1)
|
|
295
|
+
|
|
296
|
+
// Clean up: if the previous element now ends with a trailing comma
|
|
297
|
+
// and there's a closing bracket right after, remove the comma
|
|
298
|
+
if (removeStart > 0 && removeStart <= freshLines.length) {
|
|
299
|
+
const prevLine = freshLines[removeStart - 1]!
|
|
300
|
+
const nextLine = freshLines[removeStart]
|
|
301
|
+
if (nextLine !== undefined && nextLine.trim().startsWith(']') && prevLine.trimEnd().endsWith(',')) {
|
|
302
|
+
freshLines[removeStart - 1] = prevLine.replace(/,\s*$/, '')
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
await fs.writeFile(fullPath, freshLines.join('\n'), 'utf-8')
|
|
307
|
+
|
|
308
|
+
return {
|
|
309
|
+
success: true,
|
|
310
|
+
message: `Successfully removed array item (${component.componentName} at index ${arrayIndex})`,
|
|
311
|
+
sourceFile: ctx.filePath,
|
|
312
|
+
}
|
|
313
|
+
} finally {
|
|
314
|
+
release()
|
|
315
|
+
}
|
|
316
|
+
} catch (error) {
|
|
317
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
318
|
+
return { success: false, error: message }
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export async function handleAddArrayItem(
|
|
323
|
+
request: AddArrayItemRequest,
|
|
324
|
+
manifestWriter: ManifestWriter,
|
|
325
|
+
): Promise<AddArrayItemResponse> {
|
|
326
|
+
const { referenceComponentId, position, props, meta } = request
|
|
327
|
+
|
|
328
|
+
if (!meta?.url) {
|
|
329
|
+
return { success: false, error: 'Page URL is required in meta' }
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const pagePath = normalizePagePath(meta.url)
|
|
333
|
+
const pageData = manifestWriter.getPageManifest(pagePath)
|
|
334
|
+
if (!pageData) {
|
|
335
|
+
return { success: false, error: 'Page manifest not found' }
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const manifest: CmsManifest = {
|
|
339
|
+
entries: pageData.entries,
|
|
340
|
+
components: pageData.components,
|
|
341
|
+
componentDefinitions: manifestWriter.getComponentDefinitions(),
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const referenceComponent = manifest.components[referenceComponentId]
|
|
345
|
+
if (!referenceComponent) {
|
|
346
|
+
return { success: false, error: `Reference component '${referenceComponentId}' not found in manifest` }
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
try {
|
|
350
|
+
const ctx = await resolveArrayContext(referenceComponent, manifest, meta.url)
|
|
351
|
+
if (!ctx) {
|
|
352
|
+
return { success: false, error: 'Could not detect array pattern for this component' }
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const { fullPath, elementBounds, arrayIndex } = ctx
|
|
356
|
+
|
|
357
|
+
const release = await acquireFileLock(fullPath)
|
|
358
|
+
try {
|
|
359
|
+
const freshContent = await fs.readFile(fullPath, 'utf-8')
|
|
360
|
+
const freshLines = freshContent.split('\n')
|
|
361
|
+
|
|
362
|
+
const refBounds = elementBounds[arrayIndex]!
|
|
363
|
+
|
|
364
|
+
// Generate JS object literal from props
|
|
365
|
+
const newElement = generateObjectLiteral(props)
|
|
366
|
+
|
|
367
|
+
// Get indentation from the reference element
|
|
368
|
+
const indentation = getIndentation(freshLines[refBounds.startLine]!)
|
|
369
|
+
|
|
370
|
+
// Indent the new element
|
|
371
|
+
const indentedLines = newElement
|
|
372
|
+
.split('\n')
|
|
373
|
+
.map((line, i) => {
|
|
374
|
+
if (i === 0) return indentation + line
|
|
375
|
+
if (line.trim()) return indentation + line
|
|
376
|
+
return line
|
|
377
|
+
})
|
|
378
|
+
.join('\n')
|
|
379
|
+
|
|
380
|
+
if (position === 'before') {
|
|
381
|
+
// Insert before the reference element
|
|
382
|
+
const insertLine = refBounds.startLine
|
|
383
|
+
freshLines.splice(insertLine, 0, indentedLines + ',')
|
|
384
|
+
} else {
|
|
385
|
+
// Insert after the reference element
|
|
386
|
+
const insertLine = refBounds.endLine + 1
|
|
387
|
+
// Ensure the reference element has a trailing comma
|
|
388
|
+
const refEndLine = freshLines[refBounds.endLine]!
|
|
389
|
+
if (!refEndLine.trimEnd().endsWith(',')) {
|
|
390
|
+
freshLines[refBounds.endLine] = refEndLine.replace(/(\s*)$/, ',$1')
|
|
391
|
+
}
|
|
392
|
+
freshLines.splice(insertLine, 0, indentedLines + ',')
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Clean up trailing comma before closing bracket
|
|
396
|
+
// Find the closing ] and remove comma from the last element
|
|
397
|
+
for (let i = freshLines.length - 1; i >= 0; i--) {
|
|
398
|
+
if (freshLines[i]!.trim().startsWith(']')) {
|
|
399
|
+
const prev = freshLines[i - 1]
|
|
400
|
+
if (prev && prev.trimEnd().endsWith(',')) {
|
|
401
|
+
// Check if this is the array we're editing by scanning backwards
|
|
402
|
+
// to find the array variable
|
|
403
|
+
freshLines[i - 1] = prev.replace(/,(\s*)$/, '$1')
|
|
404
|
+
}
|
|
405
|
+
break
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
await fs.writeFile(fullPath, freshLines.join('\n'), 'utf-8')
|
|
410
|
+
|
|
411
|
+
return {
|
|
412
|
+
success: true,
|
|
413
|
+
message: `Successfully added array item ${position} index ${arrayIndex}`,
|
|
414
|
+
sourceFile: ctx.filePath,
|
|
415
|
+
}
|
|
416
|
+
} finally {
|
|
417
|
+
release()
|
|
418
|
+
}
|
|
419
|
+
} catch (error) {
|
|
420
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
421
|
+
return { success: false, error: message }
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Generate a JavaScript object literal string from props.
|
|
427
|
+
* Example: { name: 'Components', slug: 'components' }
|
|
428
|
+
*/
|
|
429
|
+
function generateObjectLiteral(props: Record<string, unknown>): string {
|
|
430
|
+
const entries = Object.entries(props)
|
|
431
|
+
if (entries.length === 0) return '{}'
|
|
432
|
+
|
|
433
|
+
const parts = entries.map(([key, value]) => {
|
|
434
|
+
const safeKey = /^[a-zA-Z_$]\w*$/.test(key) ? key : `'${key}'`
|
|
435
|
+
return `${safeKey}: ${formatValue(value)}`
|
|
436
|
+
})
|
|
437
|
+
|
|
438
|
+
if (parts.length <= 3 && parts.join(', ').length < 60) {
|
|
439
|
+
return `{ ${parts.join(', ')} }`
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
return `{\n\t${parts.join(',\n\t')},\n}`
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function formatValue(value: unknown): string {
|
|
446
|
+
if (typeof value === 'string') return `'${value.replace(/'/g, "\\'")}'`
|
|
447
|
+
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
|
|
448
|
+
if (value === null || value === undefined) return 'undefined'
|
|
449
|
+
if (Array.isArray(value)) return `[${value.map(formatValue).join(', ')}]`
|
|
450
|
+
if (typeof value === 'object') return generateObjectLiteral(value as Record<string, unknown>)
|
|
451
|
+
return String(value)
|
|
452
|
+
}
|