@nuasite/cms 0.5.1 → 0.7.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.
@@ -175,7 +175,8 @@ export function SeoEditor() {
175
175
  })
176
176
 
177
177
  if (result.errors && result.errors.length > 0) {
178
- showToast(`Saved ${result.updated} SEO changes, ${result.errors.length} failed`, 'error')
178
+ const details = result.errors.map(e => e.error).join('; ')
179
+ showToast(`SEO save failed: ${details}`, 'error')
179
180
  } else {
180
181
  showToast(`Saved ${result.updated} SEO change(s) successfully!`, 'success')
181
182
  clearPendingSeoChanges()
@@ -196,7 +196,7 @@ export function TextStyleToolbar({ visible, rect, element, onStyleChange }: Text
196
196
  return (
197
197
  <div
198
198
  data-cms-ui
199
- onMouseDown={(e) => e.stopPropagation()}
199
+ onMouseDown={(e) => { e.preventDefault(); e.stopPropagation() }}
200
200
  onClick={(e) => e.stopPropagation()}
201
201
  style={{
202
202
  position: 'fixed',
@@ -1,4 +1,4 @@
1
- import { useEffect, useState } from 'preact/hooks'
1
+ import { useCallback, useEffect, useState } from 'preact/hooks'
2
2
  import { TIMING } from '../../constants'
3
3
  import type { ToastMessage } from './types'
4
4
 
@@ -8,8 +8,16 @@ interface ToastProps extends ToastMessage {
8
8
 
9
9
  export const Toast = ({ id, message, type, onRemove }: ToastProps) => {
10
10
  const [isVisible, setIsVisible] = useState(true)
11
+ const persistent = type === 'error'
12
+
13
+ const dismiss = useCallback(() => {
14
+ setIsVisible(false)
15
+ setTimeout(() => onRemove(id), TIMING.TOAST_FADE_DURATION_MS)
16
+ }, [id, onRemove])
11
17
 
12
18
  useEffect(() => {
19
+ if (persistent) return
20
+
13
21
  const hideTimer = setTimeout(() => {
14
22
  setIsVisible(false)
15
23
  }, TIMING.TOAST_VISIBLE_DURATION_MS)
@@ -22,7 +30,7 @@ export const Toast = ({ id, message, type, onRemove }: ToastProps) => {
22
30
  clearTimeout(hideTimer)
23
31
  clearTimeout(removeTimer)
24
32
  }
25
- }, [id, onRemove])
33
+ }, [id, onRemove, persistent])
26
34
 
27
35
  const typeClasses = {
28
36
  error: 'bg-cms-dark border-l-4 border-l-cms-error text-white',
@@ -44,6 +52,15 @@ export const Toast = ({ id, message, type, onRemove }: ToastProps) => {
44
52
  {type === 'error' && <span class="text-cms-error text-lg">✕</span>}
45
53
  {type === 'info' && <span class="w-2.5 h-2.5 rounded-full bg-white/50 shrink-0" />}
46
54
  {message}
55
+ {persistent && (
56
+ <button
57
+ onClick={dismiss}
58
+ class="ml-1 text-white/60 hover:text-white transition-colors text-lg leading-none cursor-pointer"
59
+ aria-label="Dismiss"
60
+ >
61
+
62
+ </button>
63
+ )}
47
64
  </div>
48
65
  )
49
66
  }
@@ -154,27 +154,13 @@ export async function startEditMode(
154
154
  makeElementEditable(el)
155
155
 
156
156
  // Suppress browser native contentEditable undo/redo (we handle it ourselves)
157
- // Also convert Enter (insertParagraph) to <br> instead of the browser's
158
- // default behavior which creates <div> elements with &nbsp; characters
157
+ // Also prevent Enter/Shift+Enter from inserting line breaks
159
158
  el.addEventListener('beforeinput', (e) => {
160
159
  if (e.inputType === 'historyUndo' || e.inputType === 'historyRedo') {
161
160
  e.preventDefault()
162
161
  }
163
- if (e.inputType === 'insertParagraph') {
162
+ if (e.inputType === 'insertParagraph' || e.inputType === 'insertLineBreak') {
164
163
  e.preventDefault()
165
- const selection = window.getSelection()
166
- if (selection && selection.rangeCount > 0) {
167
- const range = selection.getRangeAt(0)
168
- range.deleteContents()
169
- const br = document.createElement('br')
170
- range.insertNode(br)
171
- range.setStartAfter(br)
172
- range.collapse(true)
173
- selection.removeAllRanges()
174
- selection.addRange(range)
175
- // Trigger input event for change tracking
176
- el.dispatchEvent(new Event('input', { bubbles: true }))
177
- }
178
164
  }
179
165
  })
180
166
 
@@ -633,14 +619,14 @@ export async function saveAllChanges(
633
619
  // For each color type that changed, add a separate change entry
634
620
  const { originalClasses, newClasses } = change
635
621
  const entry = manifest.entries[cmsId]
636
- const colorTypes = ['bg', 'text', 'border', 'hoverBg', 'hoverText'] as const
622
+ const classTypes = ['bg', 'text', 'border', 'hoverBg', 'hoverText', 'fontWeight', 'fontStyle', 'textDecoration', 'fontSize'] as const
637
623
 
638
624
  // Find the best source info from any color type that has it
639
625
  // (all color types share the same class attribute on the same element)
640
626
  let sharedSourcePath: string | undefined
641
627
  let sharedSourceLine: number | undefined
642
628
  let sharedSourceSnippet: string | undefined
643
- for (const ct of colorTypes) {
629
+ for (const ct of classTypes) {
644
630
  const orig = originalClasses[ct]
645
631
  const curr = newClasses[ct]
646
632
  const sp = curr?.sourcePath ?? orig?.sourcePath
@@ -653,10 +639,10 @@ export async function saveAllChanges(
653
639
  }
654
640
  }
655
641
 
656
- for (const colorType of colorTypes) {
657
- const origAttr = originalClasses[colorType]
658
- const newAttr = newClasses[colorType]
659
- if (newAttr?.value && newAttr.value !== origAttr?.value) {
642
+ for (const classType of classTypes) {
643
+ const origAttr = originalClasses[classType]
644
+ const newAttr = newClasses[classType]
645
+ if (newAttr && newAttr.value !== (origAttr?.value ?? '')) {
660
646
  const bestSourcePath = newAttr.sourcePath ?? origAttr?.sourcePath ?? sharedSourcePath
661
647
  const bestSourceLine = newAttr.sourceLine ?? origAttr?.sourceLine ?? sharedSourceLine
662
648
  const bestSourceSnippet = newAttr.sourceSnippet ?? origAttr?.sourceSnippet ?? sharedSourceSnippet
@@ -670,7 +656,7 @@ export async function saveAllChanges(
670
656
  colorChange: {
671
657
  oldClass: origAttr?.value || '',
672
658
  newClass: newAttr.value,
673
- type: colorType,
659
+ type: classType,
674
660
  sourcePath: bestSourcePath,
675
661
  sourceLine: bestSourceLine,
676
662
  sourceSnippet: bestSourceSnippet,
@@ -1047,7 +1033,7 @@ function setupColorTracking(
1047
1033
  export function handleColorChange(
1048
1034
  config: CmsConfig,
1049
1035
  cmsId: string,
1050
- colorType: 'bg' | 'text' | 'border' | 'hoverBg' | 'hoverText',
1036
+ colorType: string,
1051
1037
  oldClass: string,
1052
1038
  newClass: string,
1053
1039
  onStateChange?: () => void,
@@ -1140,11 +1126,24 @@ export interface DeploymentPollingOptions {
1140
1126
  * Start polling for deployment status after a save operation.
1141
1127
  * Polls the API every 3 seconds until deployment completes or fails.
1142
1128
  * Waits for deployment to appear for up to 30 seconds before giving up.
1129
+ * Skips polling entirely when deployment is not available (e.g. local dev).
1143
1130
  */
1144
- export function startDeploymentPolling(config: CmsConfig, options?: DeploymentPollingOptions): void {
1131
+ export async function startDeploymentPolling(config: CmsConfig, options?: DeploymentPollingOptions): Promise<void> {
1145
1132
  // Clear any existing timers
1146
1133
  stopDeploymentPolling()
1147
1134
 
1135
+ // Do a preflight check to see if deployment is available
1136
+ try {
1137
+ const preflight = await getDeploymentStatus(config.apiBase)
1138
+ if (preflight.deploymentEnabled === false) {
1139
+ // Deployment not available (e.g. local dev) — skip polling entirely
1140
+ return
1141
+ }
1142
+ } catch {
1143
+ // If we can't even reach the endpoint, skip polling
1144
+ return
1145
+ }
1146
+
1148
1147
  // Reset wait attempts counter and store the timestamp when we started
1149
1148
  deploymentWaitAttempts = 0
1150
1149
  deploymentStartTimestamp = new Date().toISOString()
@@ -55,6 +55,26 @@ import { hasPendingEntryNavigation, loadEditingState, loadSettingsFromStorage, s
55
55
  import CMS_STYLES from './styles.css?inline'
56
56
  import { generateCSSVariables, resolveTheme } from './themes'
57
57
 
58
+ /** Inline CSS values for Tailwind text style classes (for preview before save) */
59
+ const TEXT_STYLE_INLINE_CSS: Record<string, Record<string, string>> = {
60
+ 'font-normal': { fontWeight: '400' },
61
+ 'font-medium': { fontWeight: '500' },
62
+ 'font-semibold': { fontWeight: '600' },
63
+ 'font-bold': { fontWeight: '700' },
64
+ 'italic': { fontStyle: 'italic' },
65
+ 'not-italic': { fontStyle: 'normal' },
66
+ 'underline': { textDecoration: 'underline' },
67
+ 'line-through': { textDecoration: 'line-through' },
68
+ 'no-underline': { textDecoration: 'none' },
69
+ 'text-xs': { fontSize: '0.75rem', lineHeight: '1rem' },
70
+ 'text-sm': { fontSize: '0.875rem', lineHeight: '1.25rem' },
71
+ 'text-base': { fontSize: '1rem', lineHeight: '1.5rem' },
72
+ 'text-lg': { fontSize: '1.125rem', lineHeight: '1.75rem' },
73
+ 'text-xl': { fontSize: '1.25rem', lineHeight: '1.75rem' },
74
+ 'text-2xl': { fontSize: '1.5rem', lineHeight: '2rem' },
75
+ 'text-3xl': { fontSize: '1.875rem', lineHeight: '2.25rem' },
76
+ }
77
+
58
78
  const CmsUI = () => {
59
79
  const config = signals.config.value
60
80
  const outlineState = useElementDetection()
@@ -142,10 +162,12 @@ const CmsUI = () => {
142
162
  if (result.success) {
143
163
  signals.showToast(`Saved ${result.updated} change(s) successfully!`, 'success')
144
164
  } else if (result.errors) {
145
- signals.showToast(`Saved ${result.updated}, ${result.errors.length} failed`, 'error')
165
+ const details = result.errors.map(e => e.error).join('; ')
166
+ signals.showToast(`Save failed: ${details}`, 'error')
146
167
  }
147
168
  } catch (err) {
148
- signals.showToast('Save failed see console', 'error')
169
+ const message = err instanceof Error ? err.message : 'Unknown error'
170
+ signals.showToast(`Save failed: ${message}`, 'error')
149
171
  }
150
172
  }, [config, updateUI])
151
173
 
@@ -216,6 +238,65 @@ const CmsUI = () => {
216
238
  signals.openAttributeEditor(cmsId, rect)
217
239
  }, [])
218
240
 
241
+ // Handle text style change from outline (element-level styling via class toggle)
242
+ const handleOutlineTextStyleChange = useCallback((cmsId: string, styleType: string, oldClass: string, newClass: string) => {
243
+ let change = signals.pendingColorChanges.value.get(cmsId)
244
+
245
+ // Create pending color change entry if it doesn't exist yet
246
+ // (elements with allowStyling=false may lack colorClasses but still need text style tracking)
247
+ if (!change) {
248
+ const el = document.querySelector(`[data-cms-id="${cmsId}"]`) as HTMLElement
249
+ if (!el) return
250
+
251
+ const entry = signals.manifest.value.entries[cmsId]
252
+ const originalClasses: Record<string, import('../types').Attribute> = {}
253
+ const newClasses: Record<string, import('../types').Attribute> = {}
254
+ if (entry?.colorClasses) {
255
+ for (const [key, attr] of Object.entries(entry.colorClasses)) {
256
+ originalClasses[key] = { ...attr }
257
+ newClasses[key] = { ...attr }
258
+ }
259
+ }
260
+
261
+ signals.setPendingColorChange(cmsId, {
262
+ element: el,
263
+ cmsId,
264
+ originalClasses,
265
+ newClasses,
266
+ isDirty: false,
267
+ })
268
+ change = signals.pendingColorChanges.value.get(cmsId)!
269
+ }
270
+
271
+ // Apply the class change on the DOM element
272
+ const el = change.element
273
+ const previousClassName = el.className
274
+ const previousStyleCssText = el.style.cssText
275
+
276
+ if (oldClass) {
277
+ el.classList.remove(oldClass)
278
+ const oldCss = TEXT_STYLE_INLINE_CSS[oldClass]
279
+ if (oldCss) {
280
+ for (const prop of Object.keys(oldCss)) {
281
+ ;(el.style as any)[prop] = ''
282
+ }
283
+ }
284
+ }
285
+ el.classList.add(newClass)
286
+
287
+ // Apply inline styles for immediate visual preview
288
+ // (Tailwind classes not present in source won't be in the compiled CSS)
289
+ const newCss = TEXT_STYLE_INLINE_CSS[newClass]
290
+ if (newCss) {
291
+ for (const [prop, value] of Object.entries(newCss)) {
292
+ ;(el.style as any)[prop] = value
293
+ }
294
+ }
295
+
296
+ // Delegate to handleColorChange (same class-replacement mechanism)
297
+ handleColorChange(config, cmsId, styleType, oldClass, newClass, updateUI, previousClassName, previousStyleCssText)
298
+ }, [config, updateUI])
299
+
219
300
  // Handle attribute editor close
220
301
  const handleAttributeEditorClose = useCallback(() => {
221
302
  signals.closeAttributeEditor()
@@ -233,12 +314,24 @@ const CmsUI = () => {
233
314
  const showEditableHighlights = signals.showEditableHighlights.value
234
315
  const hasSeoData = !!(manifest as any).seo
235
316
 
317
+ // Check if selected text element allows inline styling
318
+ const selectedElementCmsId = textSelectionState.element?.getAttribute('data-cms-id')
319
+ const selectedEntry = selectedElementCmsId ? manifest.entries[selectedElementCmsId] : undefined
320
+ const isTextStylingAllowed = selectedEntry?.allowStyling !== false
321
+
236
322
  // Get color toolbar data
323
+ const pendingColorChanges = signals.pendingColorChanges.value
237
324
  const colorEditorElement = colorEditorState.targetElementId
238
- ? signals.pendingColorChanges.value.get(colorEditorState.targetElementId)?.element ?? null
325
+ ? pendingColorChanges.get(colorEditorState.targetElementId)?.element ?? null
239
326
  : null
240
327
  const colorEditorCurrentClasses = colorEditorState.targetElementId
241
- ? signals.pendingColorChanges.value.get(colorEditorState.targetElementId)?.newClasses
328
+ ? pendingColorChanges.get(colorEditorState.targetElementId)?.newClasses
329
+ : undefined
330
+
331
+ // Get current text style classes for the outlined element (reactive - triggers re-render on change)
332
+ const outlineCmsId = outlineState.cmsId
333
+ const outlineTextStyleClasses = outlineCmsId
334
+ ? pendingColorChanges.get(outlineCmsId)?.newClasses
242
335
  : undefined
243
336
 
244
337
  return (
@@ -256,8 +349,10 @@ const CmsUI = () => {
256
349
  tagName={outlineState.tagName}
257
350
  element={outlineState.element}
258
351
  cmsId={outlineState.cmsId}
352
+ textStyleClasses={outlineTextStyleClasses}
259
353
  onColorClick={handleOutlineColorClick}
260
354
  onAttributeClick={handleOutlineAttributeClick}
355
+ onTextStyleChange={handleOutlineTextStyleChange}
261
356
  />
262
357
  </ErrorBoundary>
263
358
 
@@ -307,7 +402,7 @@ const CmsUI = () => {
307
402
 
308
403
  <ErrorBoundary componentName="Text Style Toolbar">
309
404
  <TextStyleToolbar
310
- visible={textSelectionState.hasSelection && isEditing && !isAIProcessing}
405
+ visible={textSelectionState.hasSelection && isEditing && !isAIProcessing && isTextStylingAllowed}
311
406
  rect={textSelectionState.rect}
312
407
  element={textSelectionState.element}
313
408
  onStyleChange={updateUI}
@@ -138,8 +138,8 @@ export interface ColorChangePayload {
138
138
  oldClass: string
139
139
  /** The new color class (e.g., 'bg-red-500') */
140
140
  newClass: string
141
- /** Type of color change: 'bg' | 'text' | 'border' | 'hoverBg' | 'hoverText' */
142
- type: 'bg' | 'text' | 'border' | 'hoverBg' | 'hoverText'
141
+ /** Type of color/style change */
142
+ type: 'bg' | 'text' | 'border' | 'hoverBg' | 'hoverText' | 'fontWeight' | 'fontStyle' | 'textDecoration' | 'fontSize'
143
143
  /** Path to the source file where the color class is defined */
144
144
  sourcePath?: string
145
145
  /** Line number where the color class is defined */
@@ -418,6 +418,8 @@ export interface DeploymentStatusResponse {
418
418
  publishedUrl: string
419
419
  } | null
420
420
  pendingCount: number
421
+ /** When false, deployment is not available (e.g. local dev) and polling should be skipped */
422
+ deploymentEnabled?: boolean
421
423
  }
422
424
 
423
425
  export interface DeploymentState {
@@ -142,6 +142,108 @@ function extractElementBounds(
142
142
  return bounds
143
143
  }
144
144
 
145
+ /**
146
+ * Extract property values from a specific array element in the frontmatter.
147
+ *
148
+ * Parses the frontmatter code with Babel, finds the array variable declaration,
149
+ * and returns the property values from the element at the given index.
150
+ * Used to resolve spread props for array-rendered components (e.g. `{...item}`).
151
+ */
152
+ export function extractArrayElementProps(
153
+ frontmatterContent: string,
154
+ arrayVarName: string,
155
+ elementIndex: number,
156
+ ): Record<string, any> | null {
157
+ let ast: ReturnType<typeof parseBabel>
158
+ try {
159
+ ast = parseBabel(frontmatterContent, {
160
+ sourceType: 'module',
161
+ plugins: ['typescript'],
162
+ errorRecovery: true,
163
+ })
164
+ } catch {
165
+ return null
166
+ }
167
+
168
+ for (const node of ast.program.body) {
169
+ const arrayExpr = findArrayExpression(node, arrayVarName)
170
+ if (arrayExpr && elementIndex < arrayExpr.elements.length) {
171
+ const element = arrayExpr.elements[elementIndex]
172
+ if (element?.type === 'ObjectExpression') {
173
+ return extractObjectValues(element, frontmatterContent)
174
+ }
175
+ }
176
+ }
177
+
178
+ return null
179
+ }
180
+
181
+ function findArrayExpression(node: any, varName: string): any | null {
182
+ if (node.type === 'VariableDeclaration') {
183
+ for (const decl of node.declarations) {
184
+ if (
185
+ decl.id.type === 'Identifier'
186
+ && decl.id.name === varName
187
+ && decl.init?.type === 'ArrayExpression'
188
+ ) {
189
+ return decl.init
190
+ }
191
+ }
192
+ }
193
+ if (node.type === 'ExportNamedDeclaration' && node.declaration?.type === 'VariableDeclaration') {
194
+ for (const decl of node.declaration.declarations) {
195
+ if (
196
+ decl.id.type === 'Identifier'
197
+ && decl.id.name === varName
198
+ && decl.init?.type === 'ArrayExpression'
199
+ ) {
200
+ return decl.init
201
+ }
202
+ }
203
+ }
204
+ return null
205
+ }
206
+
207
+ function extractObjectValues(node: any, source: string): Record<string, any> {
208
+ const props: Record<string, any> = {}
209
+ for (const prop of node.properties) {
210
+ if (prop.type !== 'ObjectProperty') continue
211
+ const key = prop.key.type === 'Identifier' ? prop.key.name : prop.key.value
212
+ if (!key) continue
213
+ props[key] = extractAstValue(prop.value, source)
214
+ }
215
+ return props
216
+ }
217
+
218
+ function extractAstValue(node: any, source: string): any {
219
+ switch (node.type) {
220
+ case 'StringLiteral':
221
+ return node.value
222
+ case 'NumericLiteral':
223
+ return node.value
224
+ case 'BooleanLiteral':
225
+ return node.value
226
+ case 'NullLiteral':
227
+ return null
228
+ case 'TemplateLiteral':
229
+ if (node.expressions.length === 0 && node.quasis.length === 1) {
230
+ return node.quasis[0].value.cooked
231
+ }
232
+ return source.slice(node.start, node.end)
233
+ case 'ArrayExpression':
234
+ return node.elements.map((el: any) => el ? extractAstValue(el, source) : null)
235
+ case 'ObjectExpression':
236
+ return extractObjectValues(node, source)
237
+ case 'UnaryExpression':
238
+ if (node.operator === '-' && node.argument.type === 'NumericLiteral') {
239
+ return -node.argument.value
240
+ }
241
+ return source.slice(node.start, node.end)
242
+ default:
243
+ return source.slice(node.start, node.end)
244
+ }
245
+ }
246
+
145
247
  /**
146
248
  * Resolve the file, lines, invocation index, and array info for a component.
147
249
  */
@@ -8,7 +8,7 @@ import { acquireFileLock, escapeReplacement, normalizePagePath, resolveAndValida
8
8
  export interface ColorChangePayload {
9
9
  oldClass: string
10
10
  newClass: string
11
- type: 'bg' | 'text' | 'border' | 'hoverBg' | 'hoverText'
11
+ type: 'bg' | 'text' | 'border' | 'hoverBg' | 'hoverText' | 'fontWeight' | 'fontStyle' | 'textDecoration' | 'fontSize'
12
12
  sourcePath?: string
13
13
  sourceLine?: number
14
14
  sourceSnippet?: string
@@ -360,7 +360,11 @@ function replaceClassInAttribute(
360
360
  const idx = classes.indexOf(oldClass)
361
361
  if (idx === -1) return null
362
362
 
363
- classes[idx] = newClass
363
+ if (newClass) {
364
+ classes[idx] = newClass
365
+ } else {
366
+ classes.splice(idx, 1)
367
+ }
364
368
  return line.replace(classAttrPattern, `${prefix}${quote}${classes.join(' ')}${quote}`)
365
369
  }
366
370
 
@@ -1,7 +1,7 @@
1
1
  import { type HTMLElement as ParsedHTMLElement, parse } from 'node-html-parser'
2
2
  import { processSeoFromHtml } from './seo-processor'
3
3
  import { enhanceManifestWithSourceSnippets } from './source-finder'
4
- import { extractColorClasses } from './tailwind-colors'
4
+ import { extractColorClasses, extractTextStyleClasses } from './tailwind-colors'
5
5
  import type { Attribute, ComponentInstance, ImageMetadata, ManifestEntry, PageSeoData, SeoOptions } from './types'
6
6
  import { generateStableId } from './utils'
7
7
 
@@ -830,9 +830,13 @@ export async function processHtml(
830
830
  // Generate stable ID based on content and context
831
831
  const stableId = generateStableId(tag, entryText, entrySourcePath)
832
832
 
833
- // Extract color classes for buttons and other elements
833
+ // Extract color classes and text style classes for buttons and other elements
834
834
  const classAttr = node.getAttribute('class')
835
835
  const colorClasses = extractColorClasses(classAttr)
836
+ const textStyleClasses = extractTextStyleClasses(classAttr)
837
+ const allTrackedClasses = colorClasses || textStyleClasses
838
+ ? { ...colorClasses, ...textStyleClasses }
839
+ : undefined
836
840
 
837
841
  // Extract all relevant attributes for git diff tracking
838
842
  const attributes = extractAllAttributes(node)
@@ -856,8 +860,8 @@ export async function processHtml(
856
860
  stableId,
857
861
  // Image metadata for image entries
858
862
  imageMetadata: imageInfo?.metadata,
859
- // Color classes for buttons/styled elements
860
- colorClasses,
863
+ // Color and text style classes for buttons/styled elements
864
+ colorClasses: allTrackedClasses,
861
865
  // All attributes with resolved values (isStatic will be updated later from source)
862
866
  attributes,
863
867
  }
package/src/index.ts CHANGED
@@ -93,7 +93,7 @@ export default function nuaCms(options: NuaCmsOptions = {}): AstroIntegration {
93
93
  }
94
94
 
95
95
  return {
96
- name: '@nuasite/astro-cms',
96
+ name: '@nuasite/cms',
97
97
  hooks: {
98
98
  'astro:config:setup': async ({ updateConfig, command, injectScript, logger }) => {
99
99
  // --- CMS Marker setup ---
@@ -158,6 +158,28 @@ async function searchDirForExpressionProp(
158
158
  // we look for cardProps.title in the definitions
159
159
  const spreadPropPath = `${spreadVarName}.${propName}`
160
160
 
161
+ // When spread is inside a .map() call, search for array element definitions
162
+ // e.g., packages.map(pkg => <Card {...pkg} />) -> look for packages[N].propName
163
+ if (spreadMatch.mapSourceArray) {
164
+ const mapSourceArray = spreadMatch.mapSourceArray
165
+ for (const def of cached.variableDefinitions) {
166
+ if (
167
+ def.name === propName
168
+ && def.parentName?.startsWith(mapSourceArray + '[')
169
+ && normalizeText(def.value) === normalizedSearch
170
+ ) {
171
+ return {
172
+ file: path.relative(getProjectRoot(), fullPath),
173
+ line: def.line,
174
+ snippet: cached.lines[def.line - 1] || '',
175
+ type: 'variable',
176
+ variableName: buildDefinitionPath(def),
177
+ definitionLine: def.line,
178
+ }
179
+ }
180
+ }
181
+ }
182
+
161
183
  for (const def of cached.variableDefinitions) {
162
184
  const defPath = buildDefinitionPath(def)
163
185
  if (defPath === spreadPropPath) {
@@ -671,6 +693,26 @@ async function searchDirForAttributeProp(
671
693
  // Try spread prop usage
672
694
  const spreadMatch = findSpreadProp(cached.ast, componentName)
673
695
  if (spreadMatch) {
696
+ // When spread is inside a .map() call, search for array element definitions
697
+ if (spreadMatch.mapSourceArray) {
698
+ const mapSourceArray = spreadMatch.mapSourceArray
699
+ for (const def of cached.variableDefinitions) {
700
+ if (
701
+ def.name === propName
702
+ && def.parentName?.startsWith(mapSourceArray + '[')
703
+ ) {
704
+ return {
705
+ file: path.relative(getProjectRoot(), fullPath),
706
+ line: def.line,
707
+ snippet: cached.lines[def.line - 1] || '',
708
+ type: 'variable',
709
+ variableName: buildDefinitionPath(def),
710
+ definitionLine: def.line,
711
+ }
712
+ }
713
+ }
714
+ }
715
+
674
716
  const spreadPropPath = `${spreadMatch.spreadVarName}.${propName}`
675
717
  for (const def of cached.variableDefinitions) {
676
718
  const defPath = buildDefinitionPath(def)
@@ -353,7 +353,7 @@ export function findSpreadProp(
353
353
  ast: AstroNode,
354
354
  componentName: string,
355
355
  ): SpreadPropMatch | null {
356
- function visit(node: AstroNode): SpreadPropMatch | null {
356
+ function visit(node: AstroNode, parentExpression: AstroNode | null): SpreadPropMatch | null {
357
357
  // Check component nodes matching the name
358
358
  if (node.type === 'component') {
359
359
  const compNode = node as ComponentNode
@@ -362,20 +362,34 @@ export function findSpreadProp(
362
362
  // Check for spread attributes: {...cardProps}
363
363
  // In Astro AST: type='attribute', kind='spread', name=variable name
364
364
  if (attr.type === 'attribute' && attr.kind === 'spread' && attr.name) {
365
- return {
365
+ const match: SpreadPropMatch = {
366
366
  componentName,
367
367
  spreadVarName: attr.name,
368
368
  line: attr.position?.start.line ?? compNode.position?.start.line ?? 0,
369
369
  }
370
+
371
+ // Check if this spread is inside a .map() call by examining parent expression
372
+ if (parentExpression) {
373
+ const exprText = getTextContent(parentExpression)
374
+ const mapMatch = exprText.match(/(\w+(?:\.\w+)*)\.map\s*\(\s*\(?(\w+)\)?\s*=>/)
375
+ if (mapMatch && mapMatch[2] === attr.name) {
376
+ match.mapSourceArray = mapMatch[1]
377
+ }
378
+ }
379
+
380
+ return match
370
381
  }
371
382
  }
372
383
  }
373
384
  }
374
385
 
386
+ // Track the nearest ancestor expression node
387
+ const nextParentExpression = node.type === 'expression' ? node : parentExpression
388
+
375
389
  // Recursively visit children
376
390
  if ('children' in node && Array.isArray(node.children)) {
377
391
  for (const child of node.children) {
378
- const result = visit(child)
392
+ const result = visit(child, nextParentExpression)
379
393
  if (result) return result
380
394
  }
381
395
  }
@@ -383,5 +397,5 @@ export function findSpreadProp(
383
397
  return null
384
398
  }
385
399
 
386
- return visit(ast)
400
+ return visit(ast, null)
387
401
  }
@@ -618,6 +618,7 @@ export async function enhanceManifestWithSourceSnippets(
618
618
  sourceLine: matchingDef.line,
619
619
  sourceSnippet: defSnippet,
620
620
  variableName: buildDefinitionPath(matchingDef),
621
+ allowStyling: false,
621
622
  attributes,
622
623
  colorClasses,
623
624
  sourceHash,
@@ -666,6 +667,7 @@ export async function enhanceManifestWithSourceSnippets(
666
667
  sourceLine: result.line,
667
668
  sourceSnippet: propSnippet,
668
669
  variableName: result.variableName,
670
+ allowStyling: false,
669
671
  attributes,
670
672
  colorClasses,
671
673
  sourceHash: propSourceHash,
@@ -691,6 +693,7 @@ export async function enhanceManifestWithSourceSnippets(
691
693
  sourceLine: result.line,
692
694
  sourceSnippet: parentSnippet,
693
695
  variableName: result.variableName,
696
+ allowStyling: false,
694
697
  attributes,
695
698
  colorClasses,
696
699
  sourceHash: propSourceHash,
@@ -184,6 +184,9 @@ export interface SpreadPropMatch {
184
184
  /** The variable name being spread (e.g., 'cardProps' from {...cardProps}) */
185
185
  spreadVarName: string
186
186
  line: number
187
+ /** Source array name when spread is inside a .map() call
188
+ * e.g., 'packages' from packages.map((pkg) => <Card {...pkg} />) */
189
+ mapSourceArray?: string
187
190
  }
188
191
 
189
192
  export interface ImageMatch {