@nuasite/cms 0.5.0 → 0.6.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.
@@ -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
  */
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
  }
@@ -6,7 +6,7 @@ import type { Attribute, ManifestEntry } from '../types'
6
6
  import { escapeRegex, generateSourceHash } from '../utils'
7
7
  import { buildDefinitionPath } from './ast-extractors'
8
8
  import { getCachedParsedFile } from './ast-parser'
9
- import { findAttributeSourceLocation } from './cross-file-tracker'
9
+ import { findAttributeSourceLocation, searchForExpressionProp, searchForPropInParents } from './cross-file-tracker'
10
10
  import { findImageElementNearLine, findImageSourceLocation } from './image-finder'
11
11
 
12
12
  // ============================================================================
@@ -640,6 +640,67 @@ export async function enhanceManifestWithSourceSnippets(
640
640
  sourceHash,
641
641
  }] as const
642
642
  }
643
+
644
+ // Cross-file search for prop-driven dynamic text
645
+ // When text comes from a prop (e.g., {title} where title = Astro.props.title),
646
+ // trace it to where the prop value is actually defined in a parent component
647
+ if (cached) {
648
+ // Extract expression variables from the snippet to find props
649
+ const exprPattern = /\{(\w+(?:\.\w+|\[\d+\])*)\}/g
650
+ let exprMatch: RegExpExecArray | null
651
+ while ((exprMatch = exprPattern.exec(sourceSnippet)) !== null) {
652
+ const exprPath = exprMatch[1]!
653
+ const baseVar = exprPath.match(/^(\w+)/)?.[1]
654
+ if (baseVar && cached.propAliases.has(baseVar)) {
655
+ const propName = cached.propAliases.get(baseVar)!
656
+ const componentFileName = path.basename(filePath)
657
+ const result = await searchForExpressionProp(
658
+ componentFileName, propName, exprPath, entry.text!,
659
+ )
660
+ if (result) {
661
+ const propSnippet = result.snippet ?? trimmedText
662
+ const propSourceHash = generateSourceHash(propSnippet)
663
+ return [id, {
664
+ ...entry,
665
+ sourcePath: result.file,
666
+ sourceLine: result.line,
667
+ sourceSnippet: propSnippet,
668
+ variableName: result.variableName,
669
+ attributes,
670
+ colorClasses,
671
+ sourceHash: propSourceHash,
672
+ }] as const
673
+ }
674
+ }
675
+ }
676
+
677
+ // Search for quoted prop values in parent components
678
+ // (handles <Component title="literal text" />)
679
+ const srcDir = path.join(getProjectRoot(), 'src')
680
+ for (const searchDir of ['pages', 'components', 'layouts']) {
681
+ try {
682
+ const result = await searchForPropInParents(
683
+ path.join(srcDir, searchDir), trimmedText,
684
+ )
685
+ if (result) {
686
+ const parentSnippet = result.snippet ?? trimmedText
687
+ const propSourceHash = generateSourceHash(parentSnippet)
688
+ return [id, {
689
+ ...entry,
690
+ sourcePath: result.file,
691
+ sourceLine: result.line,
692
+ sourceSnippet: parentSnippet,
693
+ variableName: result.variableName,
694
+ attributes,
695
+ colorClasses,
696
+ sourceHash: propSourceHash,
697
+ }] as const
698
+ }
699
+ } catch {
700
+ // Directory doesn't exist
701
+ }
702
+ }
703
+ }
643
704
  }
644
705
 
645
706
  // Original static content path
@@ -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 {
@@ -300,8 +300,8 @@ export async function resolveImportPath(source: string, fromFile: string): Promi
300
300
  for (const ext of extensions) {
301
301
  const fullPath = basePath + ext
302
302
  try {
303
- await fs.access(fullPath)
304
- return fullPath
303
+ const stat = await fs.stat(fullPath)
304
+ if (stat.isFile()) return fullPath
305
305
  } catch {
306
306
  // File doesn't exist with this extension
307
307
  }