@nuasite/cms-marker 0.0.71 → 0.0.72
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/src/source-finder.ts
CHANGED
|
@@ -12,6 +12,16 @@ import { generateSourceHash } from './utils'
|
|
|
12
12
|
// File Parsing Cache - Avoid re-parsing the same files
|
|
13
13
|
// ============================================================================
|
|
14
14
|
|
|
15
|
+
/** Import information from frontmatter */
|
|
16
|
+
interface ImportInfo {
|
|
17
|
+
/** Local name of the imported binding */
|
|
18
|
+
localName: string
|
|
19
|
+
/** Original exported name (or 'default' for default imports) */
|
|
20
|
+
importedName: string
|
|
21
|
+
/** The import source path (e.g., './config', '../data/nav') */
|
|
22
|
+
source: string
|
|
23
|
+
}
|
|
24
|
+
|
|
15
25
|
interface CachedParsedFile {
|
|
16
26
|
content: string
|
|
17
27
|
lines: string[]
|
|
@@ -19,6 +29,11 @@ interface CachedParsedFile {
|
|
|
19
29
|
frontmatterContent: string | null
|
|
20
30
|
frontmatterStartLine: number
|
|
21
31
|
variableDefinitions: VariableDefinition[]
|
|
32
|
+
/** Mapping of local variable names to prop names from Astro.props destructuring
|
|
33
|
+
* e.g., { navItems: 'items' } for `const { items: navItems } = Astro.props` */
|
|
34
|
+
propAliases: Map<string, string>
|
|
35
|
+
/** Import information from frontmatter */
|
|
36
|
+
imports: ImportInfo[]
|
|
22
37
|
}
|
|
23
38
|
|
|
24
39
|
/** Cache for parsed Astro files - cleared between builds */
|
|
@@ -162,6 +177,8 @@ async function getCachedParsedFile(filePath: string): Promise<CachedParsedFile |
|
|
|
162
177
|
frontmatterContent: null,
|
|
163
178
|
frontmatterStartLine: 0,
|
|
164
179
|
variableDefinitions: [],
|
|
180
|
+
propAliases: new Map(),
|
|
181
|
+
imports: [],
|
|
165
182
|
}
|
|
166
183
|
parsedFileCache.set(filePath, entry)
|
|
167
184
|
return entry
|
|
@@ -170,10 +187,14 @@ async function getCachedParsedFile(filePath: string): Promise<CachedParsedFile |
|
|
|
170
187
|
const { ast, frontmatterContent, frontmatterStartLine } = await parseAstroFile(content)
|
|
171
188
|
|
|
172
189
|
let variableDefinitions: VariableDefinition[] = []
|
|
190
|
+
let propAliases = new Map<string, string>()
|
|
191
|
+
let imports: ImportInfo[] = []
|
|
173
192
|
if (frontmatterContent) {
|
|
174
193
|
const frontmatterAst = parseFrontmatter(frontmatterContent, filePath)
|
|
175
194
|
if (frontmatterAst) {
|
|
176
195
|
variableDefinitions = extractVariableDefinitions(frontmatterAst, frontmatterStartLine)
|
|
196
|
+
propAliases = extractPropAliases(frontmatterAst)
|
|
197
|
+
imports = extractImports(frontmatterAst)
|
|
177
198
|
}
|
|
178
199
|
}
|
|
179
200
|
|
|
@@ -184,6 +205,8 @@ async function getCachedParsedFile(filePath: string): Promise<CachedParsedFile |
|
|
|
184
205
|
frontmatterContent,
|
|
185
206
|
frontmatterStartLine,
|
|
186
207
|
variableDefinitions,
|
|
208
|
+
propAliases,
|
|
209
|
+
imports,
|
|
187
210
|
}
|
|
188
211
|
|
|
189
212
|
parsedFileCache.set(filePath, entry)
|
|
@@ -210,9 +233,15 @@ function indexFileContent(cached: CachedParsedFile, relFile: string): void {
|
|
|
210
233
|
// Check for variable references
|
|
211
234
|
const exprInfo = hasExpressionChild(elemNode)
|
|
212
235
|
if (exprInfo.found && exprInfo.varNames.length > 0) {
|
|
213
|
-
for (const
|
|
236
|
+
for (const exprPath of exprInfo.varNames) {
|
|
214
237
|
for (const def of cached.variableDefinitions) {
|
|
215
|
-
|
|
238
|
+
// Build the full definition path for comparison
|
|
239
|
+
// For array indices (numeric names), use bracket notation
|
|
240
|
+
const defPath = buildDefinitionPath(def)
|
|
241
|
+
// Check if the expression path matches the definition path
|
|
242
|
+
// e.g., 'config.nav.title' matches def with parentName='config.nav', name='title'
|
|
243
|
+
// or 'items[0]' matches def with parentName='items', name='0'
|
|
244
|
+
if (defPath === exprPath) {
|
|
216
245
|
const normalizedDef = normalizeText(def.value)
|
|
217
246
|
const completeSnippet = extractCompleteTagSnippet(cached.lines, line - 1, tag)
|
|
218
247
|
const snippet = extractInnerHtmlFromSnippet(completeSnippet, tag) ?? completeSnippet
|
|
@@ -222,7 +251,7 @@ function indexFileContent(cached: CachedParsedFile, relFile: string): void {
|
|
|
222
251
|
line: def.line,
|
|
223
252
|
snippet: cached.lines[def.line - 1] || '',
|
|
224
253
|
type: 'variable',
|
|
225
|
-
variableName:
|
|
254
|
+
variableName: defPath,
|
|
226
255
|
definitionLine: def.line,
|
|
227
256
|
normalizedText: normalizedDef,
|
|
228
257
|
tag,
|
|
@@ -416,14 +445,45 @@ function getTextContent(node: AstroNode): string {
|
|
|
416
445
|
return ''
|
|
417
446
|
}
|
|
418
447
|
|
|
448
|
+
/**
|
|
449
|
+
* Parse an expression path and extract the full path for variable lookup.
|
|
450
|
+
* Handles patterns like: varName, obj.prop, items[0], config.nav.title, links[0].text
|
|
451
|
+
* @returns The full expression path or null if not a simple variable reference
|
|
452
|
+
*/
|
|
453
|
+
function parseExpressionPath(exprText: string): string | null {
|
|
454
|
+
// Match patterns like: varName, obj.prop, items[0], config.nav.title, links[0].text
|
|
455
|
+
// Pattern breakdown: word characters, dots, and bracket notation with numbers
|
|
456
|
+
const match = exprText.match(/^\s*([\w]+(?:\.[\w]+|\[\d+\])*(?:\.[\w]+)?)\s*$/)
|
|
457
|
+
if (match) {
|
|
458
|
+
return match[1]!
|
|
459
|
+
}
|
|
460
|
+
return null
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Build the full path for a variable definition.
|
|
465
|
+
* For array indices (numeric names), uses bracket notation: items[0]
|
|
466
|
+
* For object properties, uses dot notation: config.nav.title
|
|
467
|
+
*/
|
|
468
|
+
function buildDefinitionPath(def: VariableDefinition): string {
|
|
469
|
+
if (!def.parentName) {
|
|
470
|
+
return def.name
|
|
471
|
+
}
|
|
472
|
+
// Check if the name is a numeric index (for arrays)
|
|
473
|
+
if (/^\d+$/.test(def.name)) {
|
|
474
|
+
return `${def.parentName}[${def.name}]`
|
|
475
|
+
}
|
|
476
|
+
return `${def.parentName}.${def.name}`
|
|
477
|
+
}
|
|
478
|
+
|
|
419
479
|
// Helper for indexing - check for expression children
|
|
420
480
|
function hasExpressionChild(node: AstroNode): { found: boolean; varNames: string[] } {
|
|
421
481
|
const varNames: string[] = []
|
|
422
482
|
if (node.type === 'expression') {
|
|
423
483
|
const exprText = getTextContent(node)
|
|
424
|
-
const
|
|
425
|
-
if (
|
|
426
|
-
varNames.push(
|
|
484
|
+
const fullPath = parseExpressionPath(exprText)
|
|
485
|
+
if (fullPath) {
|
|
486
|
+
varNames.push(fullPath)
|
|
427
487
|
}
|
|
428
488
|
return { found: true, varNames }
|
|
429
489
|
}
|
|
@@ -587,6 +647,105 @@ function extractVariableDefinitions(ast: BabelFile, frontmatterStartLine: number
|
|
|
587
647
|
return (babelLine - 1) + frontmatterStartLine
|
|
588
648
|
}
|
|
589
649
|
|
|
650
|
+
/**
|
|
651
|
+
* Recursively extract properties from an object expression
|
|
652
|
+
* @param objNode - The ObjectExpression node
|
|
653
|
+
* @param parentPath - The full path to this object (e.g., 'config' or 'config.nav')
|
|
654
|
+
*/
|
|
655
|
+
function extractObjectProperties(objNode: BabelNode, parentPath: string): void {
|
|
656
|
+
const properties = objNode.properties as BabelNode[] | undefined
|
|
657
|
+
for (const prop of properties ?? []) {
|
|
658
|
+
if (prop.type !== 'ObjectProperty') continue
|
|
659
|
+
const key = prop.key as BabelNode | undefined
|
|
660
|
+
const value = prop.value as BabelNode | undefined
|
|
661
|
+
if (!key || key.type !== 'Identifier' || !value) continue
|
|
662
|
+
|
|
663
|
+
const propName = key.name as string
|
|
664
|
+
const fullPath = `${parentPath}.${propName}`
|
|
665
|
+
const propLoc = prop.loc as { start: { line: number } } | undefined
|
|
666
|
+
const propLine = babelLineToFileLine(propLoc?.start.line ?? 1)
|
|
667
|
+
|
|
668
|
+
const stringValue = getStringValue(value)
|
|
669
|
+
if (stringValue !== null) {
|
|
670
|
+
definitions.push({
|
|
671
|
+
name: propName,
|
|
672
|
+
value: stringValue,
|
|
673
|
+
line: propLine,
|
|
674
|
+
parentName: parentPath,
|
|
675
|
+
})
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// Recurse for nested objects
|
|
679
|
+
if (value.type === 'ObjectExpression') {
|
|
680
|
+
extractObjectProperties(value, fullPath)
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// Handle arrays within objects
|
|
684
|
+
if (value.type === 'ArrayExpression') {
|
|
685
|
+
extractArrayElements(value, fullPath, propLine)
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* Extract elements from an array expression
|
|
692
|
+
* @param arrNode - The ArrayExpression node
|
|
693
|
+
* @param parentPath - The full path to this array (e.g., 'items' or 'config.items')
|
|
694
|
+
* @param defaultLine - Fallback line if element has no location
|
|
695
|
+
*/
|
|
696
|
+
function extractArrayElements(arrNode: BabelNode, parentPath: string, defaultLine: number): void {
|
|
697
|
+
const elements = arrNode.elements as BabelNode[] | undefined
|
|
698
|
+
for (let i = 0; i < (elements?.length ?? 0); i++) {
|
|
699
|
+
const elem = elements![i]
|
|
700
|
+
if (!elem) continue
|
|
701
|
+
|
|
702
|
+
const elemLoc = elem.loc as { start: { line: number } } | undefined
|
|
703
|
+
const elemLine = babelLineToFileLine(elemLoc?.start.line ?? defaultLine)
|
|
704
|
+
const indexPath = `${parentPath}[${i}]`
|
|
705
|
+
|
|
706
|
+
// Handle string values in array
|
|
707
|
+
const elemValue = getStringValue(elem)
|
|
708
|
+
if (elemValue !== null) {
|
|
709
|
+
definitions.push({
|
|
710
|
+
name: String(i),
|
|
711
|
+
value: elemValue,
|
|
712
|
+
line: elemLine,
|
|
713
|
+
parentName: parentPath,
|
|
714
|
+
})
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// Handle array of objects: [{ text: 'Home' }]
|
|
718
|
+
if (elem.type === 'ObjectExpression') {
|
|
719
|
+
const objProperties = elem.properties as BabelNode[] | undefined
|
|
720
|
+
for (const prop of objProperties ?? []) {
|
|
721
|
+
if (prop.type !== 'ObjectProperty') continue
|
|
722
|
+
const key = prop.key as BabelNode | undefined
|
|
723
|
+
const value = prop.value as BabelNode | undefined
|
|
724
|
+
if (!key || key.type !== 'Identifier' || !value) continue
|
|
725
|
+
|
|
726
|
+
const propName = key.name as string
|
|
727
|
+
const propLoc = prop.loc as { start: { line: number } } | undefined
|
|
728
|
+
const propLine = babelLineToFileLine(propLoc?.start.line ?? elemLine)
|
|
729
|
+
|
|
730
|
+
const stringValue = getStringValue(value)
|
|
731
|
+
if (stringValue !== null) {
|
|
732
|
+
definitions.push({
|
|
733
|
+
name: propName,
|
|
734
|
+
value: stringValue,
|
|
735
|
+
line: propLine,
|
|
736
|
+
parentName: indexPath,
|
|
737
|
+
})
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// Recurse for nested objects within array elements
|
|
741
|
+
if (value.type === 'ObjectExpression') {
|
|
742
|
+
extractObjectProperties(value, `${indexPath}.${propName}`)
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
590
749
|
function visitNode(node: BabelNode) {
|
|
591
750
|
if (node.type === 'VariableDeclaration') {
|
|
592
751
|
const declarations = node.declarations as BabelNode[] | undefined
|
|
@@ -604,23 +763,100 @@ function extractVariableDefinitions(ast: BabelFile, frontmatterStartLine: number
|
|
|
604
763
|
definitions.push({ name: varName, value: stringValue, line })
|
|
605
764
|
}
|
|
606
765
|
|
|
607
|
-
// Object expression - extract properties
|
|
766
|
+
// Object expression - extract properties recursively
|
|
608
767
|
if (init.type === 'ObjectExpression') {
|
|
609
|
-
|
|
768
|
+
extractObjectProperties(init, varName)
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// Array expression - extract elements
|
|
772
|
+
if (init.type === 'ArrayExpression') {
|
|
773
|
+
extractArrayElements(init, varName, line)
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// Recursively visit child nodes
|
|
780
|
+
for (const key of Object.keys(node)) {
|
|
781
|
+
const value = node[key]
|
|
782
|
+
if (value && typeof value === 'object') {
|
|
783
|
+
if (Array.isArray(value)) {
|
|
784
|
+
for (const item of value) {
|
|
785
|
+
if (item && typeof item === 'object' && 'type' in item) {
|
|
786
|
+
visitNode(item as BabelNode)
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
} else if ('type' in value) {
|
|
790
|
+
visitNode(value as BabelNode)
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
visitNode(ast.program)
|
|
797
|
+
return definitions
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/**
|
|
801
|
+
* Extract prop aliases from Astro.props destructuring patterns.
|
|
802
|
+
* Returns a Map of local variable name -> prop name.
|
|
803
|
+
* Examples:
|
|
804
|
+
* const { title } = Astro.props -> Map { 'title' => 'title' }
|
|
805
|
+
* const { items: navItems } = Astro.props -> Map { 'navItems' => 'items' }
|
|
806
|
+
*/
|
|
807
|
+
function extractPropAliases(ast: BabelFile): Map<string, string> {
|
|
808
|
+
const propAliases = new Map<string, string>()
|
|
809
|
+
|
|
810
|
+
function visitNode(node: BabelNode) {
|
|
811
|
+
if (node.type === 'VariableDeclaration') {
|
|
812
|
+
const declarations = node.declarations as BabelNode[] | undefined
|
|
813
|
+
for (const decl of declarations ?? []) {
|
|
814
|
+
const id = decl.id as BabelNode | undefined
|
|
815
|
+
const init = decl.init as BabelNode | undefined
|
|
816
|
+
|
|
817
|
+
// Check for destructuring from Astro.props
|
|
818
|
+
// Pattern: const { x, y } = Astro.props;
|
|
819
|
+
if (id?.type === 'ObjectPattern' && init?.type === 'MemberExpression') {
|
|
820
|
+
const object = init.object as BabelNode | undefined
|
|
821
|
+
const property = init.property as BabelNode | undefined
|
|
822
|
+
|
|
823
|
+
if (
|
|
824
|
+
object?.type === 'Identifier'
|
|
825
|
+
&& (object.name as string) === 'Astro'
|
|
826
|
+
&& property?.type === 'Identifier'
|
|
827
|
+
&& (property.name as string) === 'props'
|
|
828
|
+
) {
|
|
829
|
+
// Extract property names from the destructuring pattern
|
|
830
|
+
const properties = id.properties as BabelNode[] | undefined
|
|
610
831
|
for (const prop of properties ?? []) {
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
if (
|
|
616
|
-
const
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
value
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
832
|
+
if (prop.type === 'ObjectProperty') {
|
|
833
|
+
const key = prop.key as BabelNode | undefined
|
|
834
|
+
const value = prop.value as BabelNode | undefined
|
|
835
|
+
|
|
836
|
+
if (key?.type === 'Identifier') {
|
|
837
|
+
const propName = key.name as string
|
|
838
|
+
// Check for renaming: { items: navItems }
|
|
839
|
+
// key is the prop name (items), value is the local name (navItems)
|
|
840
|
+
if (value?.type === 'Identifier') {
|
|
841
|
+
const localName = value.name as string
|
|
842
|
+
propAliases.set(localName, propName)
|
|
843
|
+
} else if (value?.type === 'AssignmentPattern') {
|
|
844
|
+
// Handle default values: { items: navItems = [] } or { items = [] }
|
|
845
|
+
const left = value.left as BabelNode | undefined
|
|
846
|
+
if (left?.type === 'Identifier') {
|
|
847
|
+
propAliases.set(left.name as string, propName)
|
|
848
|
+
}
|
|
849
|
+
} else {
|
|
850
|
+
// Simple case: { items } - key and value are the same
|
|
851
|
+
propAliases.set(propName, propName)
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
} else if (prop.type === 'RestElement') {
|
|
855
|
+
// Handle rest pattern: const { x, ...rest } = Astro.props;
|
|
856
|
+
const argument = prop.argument as BabelNode | undefined
|
|
857
|
+
if (argument?.type === 'Identifier') {
|
|
858
|
+
// Rest element captures all remaining props
|
|
859
|
+
propAliases.set(argument.name as string, '...')
|
|
624
860
|
}
|
|
625
861
|
}
|
|
626
862
|
}
|
|
@@ -647,7 +883,281 @@ function extractVariableDefinitions(ast: BabelFile, frontmatterStartLine: number
|
|
|
647
883
|
}
|
|
648
884
|
|
|
649
885
|
visitNode(ast.program)
|
|
650
|
-
return
|
|
886
|
+
return propAliases
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/**
|
|
890
|
+
* Extract import information from Babel AST.
|
|
891
|
+
* Handles:
|
|
892
|
+
* import { foo } from './file' -> { localName: 'foo', importedName: 'foo', source: './file' }
|
|
893
|
+
* import { foo as bar } from './file' -> { localName: 'bar', importedName: 'foo', source: './file' }
|
|
894
|
+
* import foo from './file' -> { localName: 'foo', importedName: 'default', source: './file' }
|
|
895
|
+
* import * as foo from './file' -> { localName: 'foo', importedName: '*', source: './file' }
|
|
896
|
+
*/
|
|
897
|
+
function extractImports(ast: BabelFile): ImportInfo[] {
|
|
898
|
+
const imports: ImportInfo[] = []
|
|
899
|
+
|
|
900
|
+
for (const node of ast.program.body) {
|
|
901
|
+
if (node.type === 'ImportDeclaration') {
|
|
902
|
+
const source = (node.source as BabelNode)?.value as string
|
|
903
|
+
if (!source) continue
|
|
904
|
+
|
|
905
|
+
const specifiers = node.specifiers as BabelNode[] | undefined
|
|
906
|
+
for (const spec of specifiers ?? []) {
|
|
907
|
+
if (spec.type === 'ImportSpecifier') {
|
|
908
|
+
// Named import: import { foo } from './file' or import { foo as bar } from './file'
|
|
909
|
+
const imported = spec.imported as BabelNode | undefined
|
|
910
|
+
const local = spec.local as BabelNode | undefined
|
|
911
|
+
if (imported?.type === 'Identifier' && local?.type === 'Identifier') {
|
|
912
|
+
imports.push({
|
|
913
|
+
localName: local.name as string,
|
|
914
|
+
importedName: imported.name as string,
|
|
915
|
+
source,
|
|
916
|
+
})
|
|
917
|
+
}
|
|
918
|
+
} else if (spec.type === 'ImportDefaultSpecifier') {
|
|
919
|
+
// Default import: import foo from './file'
|
|
920
|
+
const local = spec.local as BabelNode | undefined
|
|
921
|
+
if (local?.type === 'Identifier') {
|
|
922
|
+
imports.push({
|
|
923
|
+
localName: local.name as string,
|
|
924
|
+
importedName: 'default',
|
|
925
|
+
source,
|
|
926
|
+
})
|
|
927
|
+
}
|
|
928
|
+
} else if (spec.type === 'ImportNamespaceSpecifier') {
|
|
929
|
+
// Namespace import: import * as foo from './file'
|
|
930
|
+
const local = spec.local as BabelNode | undefined
|
|
931
|
+
if (local?.type === 'Identifier') {
|
|
932
|
+
imports.push({
|
|
933
|
+
localName: local.name as string,
|
|
934
|
+
importedName: '*',
|
|
935
|
+
source,
|
|
936
|
+
})
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
return imports
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
/**
|
|
947
|
+
* Resolve an import source path to an absolute file path.
|
|
948
|
+
* Handles relative paths and tries common extensions.
|
|
949
|
+
*/
|
|
950
|
+
async function resolveImportPath(source: string, fromFile: string): Promise<string | null> {
|
|
951
|
+
// Only handle relative imports
|
|
952
|
+
if (!source.startsWith('.')) {
|
|
953
|
+
return null
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
const fromDir = path.dirname(fromFile)
|
|
957
|
+
const basePath = path.resolve(fromDir, source)
|
|
958
|
+
|
|
959
|
+
// Try different extensions
|
|
960
|
+
const extensions = ['.ts', '.js', '.astro', '.tsx', '.jsx', '']
|
|
961
|
+
for (const ext of extensions) {
|
|
962
|
+
const fullPath = basePath + ext
|
|
963
|
+
try {
|
|
964
|
+
await fs.access(fullPath)
|
|
965
|
+
return fullPath
|
|
966
|
+
} catch {
|
|
967
|
+
// File doesn't exist with this extension
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// Try index files
|
|
972
|
+
for (const ext of ['.ts', '.js', '.tsx', '.jsx']) {
|
|
973
|
+
const indexPath = path.join(basePath, `index${ext}`)
|
|
974
|
+
try {
|
|
975
|
+
await fs.access(indexPath)
|
|
976
|
+
return indexPath
|
|
977
|
+
} catch {
|
|
978
|
+
// File doesn't exist
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
return null
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
/**
|
|
986
|
+
* Parse a TypeScript/JavaScript file and extract exported variable definitions.
|
|
987
|
+
*/
|
|
988
|
+
async function getExportedDefinitions(filePath: string): Promise<VariableDefinition[]> {
|
|
989
|
+
try {
|
|
990
|
+
const content = await fs.readFile(filePath, 'utf-8')
|
|
991
|
+
const ast = parseBabel(content, {
|
|
992
|
+
sourceType: 'module',
|
|
993
|
+
plugins: ['typescript'],
|
|
994
|
+
errorRecovery: true,
|
|
995
|
+
}) as unknown as BabelFile
|
|
996
|
+
|
|
997
|
+
const definitions: VariableDefinition[] = []
|
|
998
|
+
const lines = content.split('\n')
|
|
999
|
+
|
|
1000
|
+
function getStringValue(node: BabelNode): string | null {
|
|
1001
|
+
if (node.type === 'StringLiteral') {
|
|
1002
|
+
return node.value as string
|
|
1003
|
+
}
|
|
1004
|
+
if (node.type === 'TemplateLiteral') {
|
|
1005
|
+
const quasis = node.quasis as Array<{ value: { cooked: string | null } }> | undefined
|
|
1006
|
+
const expressions = node.expressions as unknown[] | undefined
|
|
1007
|
+
if (quasis?.length === 1 && expressions?.length === 0) {
|
|
1008
|
+
return quasis[0]?.value.cooked ?? null
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
return null
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
function extractObjectProperties(objNode: BabelNode, parentPath: string, line: number): void {
|
|
1015
|
+
const properties = objNode.properties as BabelNode[] | undefined
|
|
1016
|
+
for (const prop of properties ?? []) {
|
|
1017
|
+
if (prop.type !== 'ObjectProperty') continue
|
|
1018
|
+
const key = prop.key as BabelNode | undefined
|
|
1019
|
+
const value = prop.value as BabelNode | undefined
|
|
1020
|
+
if (!key || key.type !== 'Identifier' || !value) continue
|
|
1021
|
+
|
|
1022
|
+
const propName = key.name as string
|
|
1023
|
+
const fullPath = `${parentPath}.${propName}`
|
|
1024
|
+
const propLoc = prop.loc as { start: { line: number } } | undefined
|
|
1025
|
+
const propLine = propLoc?.start.line ?? line
|
|
1026
|
+
|
|
1027
|
+
const stringValue = getStringValue(value)
|
|
1028
|
+
if (stringValue !== null) {
|
|
1029
|
+
definitions.push({
|
|
1030
|
+
name: propName,
|
|
1031
|
+
value: stringValue,
|
|
1032
|
+
line: propLine,
|
|
1033
|
+
parentName: parentPath,
|
|
1034
|
+
})
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
if (value.type === 'ObjectExpression') {
|
|
1038
|
+
extractObjectProperties(value, fullPath, propLine)
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
if (value.type === 'ArrayExpression') {
|
|
1042
|
+
extractArrayElements(value, fullPath, propLine)
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
function extractArrayElements(arrNode: BabelNode, parentPath: string, defaultLine: number): void {
|
|
1048
|
+
const elements = arrNode.elements as BabelNode[] | undefined
|
|
1049
|
+
for (let i = 0; i < (elements?.length ?? 0); i++) {
|
|
1050
|
+
const elem = elements![i]
|
|
1051
|
+
if (!elem) continue
|
|
1052
|
+
|
|
1053
|
+
const elemLoc = elem.loc as { start: { line: number } } | undefined
|
|
1054
|
+
const elemLine = elemLoc?.start.line ?? defaultLine
|
|
1055
|
+
const indexPath = `${parentPath}[${i}]`
|
|
1056
|
+
|
|
1057
|
+
const elemValue = getStringValue(elem)
|
|
1058
|
+
if (elemValue !== null) {
|
|
1059
|
+
definitions.push({
|
|
1060
|
+
name: String(i),
|
|
1061
|
+
value: elemValue,
|
|
1062
|
+
line: elemLine,
|
|
1063
|
+
parentName: parentPath,
|
|
1064
|
+
})
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
if (elem.type === 'ObjectExpression') {
|
|
1068
|
+
const objProperties = elem.properties as BabelNode[] | undefined
|
|
1069
|
+
for (const prop of objProperties ?? []) {
|
|
1070
|
+
if (prop.type !== 'ObjectProperty') continue
|
|
1071
|
+
const key = prop.key as BabelNode | undefined
|
|
1072
|
+
const value = prop.value as BabelNode | undefined
|
|
1073
|
+
if (!key || key.type !== 'Identifier' || !value) continue
|
|
1074
|
+
|
|
1075
|
+
const propName = key.name as string
|
|
1076
|
+
const propLoc = prop.loc as { start: { line: number } } | undefined
|
|
1077
|
+
const propLine = propLoc?.start.line ?? elemLine
|
|
1078
|
+
|
|
1079
|
+
const stringValue = getStringValue(value)
|
|
1080
|
+
if (stringValue !== null) {
|
|
1081
|
+
definitions.push({
|
|
1082
|
+
name: propName,
|
|
1083
|
+
value: stringValue,
|
|
1084
|
+
line: propLine,
|
|
1085
|
+
parentName: indexPath,
|
|
1086
|
+
})
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
if (value.type === 'ObjectExpression') {
|
|
1090
|
+
extractObjectProperties(value, `${indexPath}.${propName}`, propLine)
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
for (const node of ast.program.body) {
|
|
1098
|
+
// Handle: export const foo = 'value'
|
|
1099
|
+
if (node.type === 'ExportNamedDeclaration') {
|
|
1100
|
+
const declaration = node.declaration as BabelNode | undefined
|
|
1101
|
+
if (declaration?.type === 'VariableDeclaration') {
|
|
1102
|
+
const declarations = declaration.declarations as BabelNode[] | undefined
|
|
1103
|
+
for (const decl of declarations ?? []) {
|
|
1104
|
+
const id = decl.id as BabelNode | undefined
|
|
1105
|
+
const init = decl.init as BabelNode | undefined
|
|
1106
|
+
if (id?.type === 'Identifier' && init) {
|
|
1107
|
+
const varName = id.name as string
|
|
1108
|
+
const loc = decl.loc as { start: { line: number } } | undefined
|
|
1109
|
+
const line = loc?.start.line ?? 1
|
|
1110
|
+
|
|
1111
|
+
const stringValue = getStringValue(init)
|
|
1112
|
+
if (stringValue !== null) {
|
|
1113
|
+
definitions.push({ name: varName, value: stringValue, line })
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
if (init.type === 'ObjectExpression') {
|
|
1117
|
+
extractObjectProperties(init, varName, line)
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
if (init.type === 'ArrayExpression') {
|
|
1121
|
+
extractArrayElements(init, varName, line)
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
// Handle: const foo = 'value'; export { foo }
|
|
1129
|
+
// First collect all variable declarations
|
|
1130
|
+
if (node.type === 'VariableDeclaration') {
|
|
1131
|
+
const declarations = node.declarations as BabelNode[] | undefined
|
|
1132
|
+
for (const decl of declarations ?? []) {
|
|
1133
|
+
const id = decl.id as BabelNode | undefined
|
|
1134
|
+
const init = decl.init as BabelNode | undefined
|
|
1135
|
+
if (id?.type === 'Identifier' && init) {
|
|
1136
|
+
const varName = id.name as string
|
|
1137
|
+
const loc = decl.loc as { start: { line: number } } | undefined
|
|
1138
|
+
const line = loc?.start.line ?? 1
|
|
1139
|
+
|
|
1140
|
+
const stringValue = getStringValue(init)
|
|
1141
|
+
if (stringValue !== null) {
|
|
1142
|
+
definitions.push({ name: varName, value: stringValue, line })
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
if (init.type === 'ObjectExpression') {
|
|
1146
|
+
extractObjectProperties(init, varName, line)
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
if (init.type === 'ArrayExpression') {
|
|
1150
|
+
extractArrayElements(init, varName, line)
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
return definitions
|
|
1158
|
+
} catch {
|
|
1159
|
+
return []
|
|
1160
|
+
}
|
|
651
1161
|
}
|
|
652
1162
|
|
|
653
1163
|
interface TemplateMatch {
|
|
@@ -656,21 +1166,49 @@ interface TemplateMatch {
|
|
|
656
1166
|
variableName?: string
|
|
657
1167
|
/** For variables, the definition line in frontmatter */
|
|
658
1168
|
definitionLine?: number
|
|
1169
|
+
/** If true, the expression uses a variable from props that needs cross-file tracking */
|
|
1170
|
+
usesProp?: boolean
|
|
1171
|
+
/** The prop name if usesProp is true */
|
|
1172
|
+
propName?: string
|
|
1173
|
+
/** The full expression path if usesProp is true (e.g., 'items[0]') */
|
|
1174
|
+
expressionPath?: string
|
|
1175
|
+
/** If true, the expression uses a variable from an import */
|
|
1176
|
+
usesImport?: boolean
|
|
1177
|
+
/** The import info if usesImport is true */
|
|
1178
|
+
importInfo?: ImportInfo
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
/** Result type for findElementWithText - returns best match and all prop/import candidates */
|
|
1182
|
+
interface FindElementResult {
|
|
1183
|
+
/** The best match found (local variables or static content) */
|
|
1184
|
+
bestMatch: TemplateMatch | null
|
|
1185
|
+
/** All prop-based matches for the tag (need cross-file verification) */
|
|
1186
|
+
propCandidates: TemplateMatch[]
|
|
1187
|
+
/** All import-based matches for the tag (need cross-file verification) */
|
|
1188
|
+
importCandidates: TemplateMatch[]
|
|
659
1189
|
}
|
|
660
1190
|
|
|
661
1191
|
/**
|
|
662
|
-
* Walk the Astro AST to find elements matching a tag with specific text content
|
|
1192
|
+
* Walk the Astro AST to find elements matching a tag with specific text content.
|
|
1193
|
+
* Returns the best match (local variables or static content) AND all prop/import candidates
|
|
1194
|
+
* that need cross-file verification for multiple same-tag elements.
|
|
1195
|
+
* @param propAliases - Map of local variable names to prop names from Astro.props (for cross-file tracking)
|
|
1196
|
+
* @param imports - Import information from frontmatter (for cross-file tracking)
|
|
663
1197
|
*/
|
|
664
1198
|
function findElementWithText(
|
|
665
1199
|
ast: AstroNode,
|
|
666
1200
|
tag: string,
|
|
667
1201
|
searchText: string,
|
|
668
1202
|
variableDefinitions: VariableDefinition[],
|
|
669
|
-
|
|
1203
|
+
propAliases: Map<string, string> = new Map(),
|
|
1204
|
+
imports: ImportInfo[] = [],
|
|
1205
|
+
): FindElementResult {
|
|
670
1206
|
const normalizedSearch = normalizeText(searchText)
|
|
671
1207
|
const tagLower = tag.toLowerCase()
|
|
672
1208
|
let bestMatch: TemplateMatch | null = null
|
|
673
1209
|
let bestScore = 0
|
|
1210
|
+
const propCandidates: TemplateMatch[] = []
|
|
1211
|
+
const importCandidates: TemplateMatch[] = []
|
|
674
1212
|
|
|
675
1213
|
function getTextContent(node: AstroNode): string {
|
|
676
1214
|
if (node.type === 'text') {
|
|
@@ -688,10 +1226,10 @@ function findElementWithText(
|
|
|
688
1226
|
// Try to extract variable name from expression
|
|
689
1227
|
// The expression node children contain the text representation
|
|
690
1228
|
const exprText = getTextContent(node)
|
|
691
|
-
// Extract variable
|
|
692
|
-
const
|
|
693
|
-
if (
|
|
694
|
-
varNames.push(
|
|
1229
|
+
// Extract variable paths like {foo}, {foo.bar}, {items[0]}, {config.nav.title}, {links[0].text}
|
|
1230
|
+
const fullPath = parseExpressionPath(exprText)
|
|
1231
|
+
if (fullPath) {
|
|
1232
|
+
varNames.push(fullPath)
|
|
695
1233
|
}
|
|
696
1234
|
return { found: true, varNames }
|
|
697
1235
|
}
|
|
@@ -706,6 +1244,15 @@ function findElementWithText(
|
|
|
706
1244
|
return { found: varNames.length > 0, varNames }
|
|
707
1245
|
}
|
|
708
1246
|
|
|
1247
|
+
/**
|
|
1248
|
+
* Extract the base variable name from an expression path.
|
|
1249
|
+
* e.g., 'items[0]' -> 'items', 'config.nav.title' -> 'config'
|
|
1250
|
+
*/
|
|
1251
|
+
function getBaseVarName(exprPath: string): string {
|
|
1252
|
+
const match = exprPath.match(/^(\w+)/)
|
|
1253
|
+
return match?.[1] ?? exprPath
|
|
1254
|
+
}
|
|
1255
|
+
|
|
709
1256
|
function visit(node: AstroNode) {
|
|
710
1257
|
// Check if this is an element or component matching our tag
|
|
711
1258
|
if ((node.type === 'element' || node.type === 'component') && node.name.toLowerCase() === tagLower) {
|
|
@@ -718,9 +1265,15 @@ function findElementWithText(
|
|
|
718
1265
|
const exprInfo = hasExpressionChild(elemNode)
|
|
719
1266
|
if (exprInfo.found && exprInfo.varNames.length > 0) {
|
|
720
1267
|
// Look for matching variable definition
|
|
721
|
-
for (const
|
|
1268
|
+
for (const exprPath of exprInfo.varNames) {
|
|
1269
|
+
let foundInLocal = false
|
|
1270
|
+
|
|
722
1271
|
for (const def of variableDefinitions) {
|
|
723
|
-
|
|
1272
|
+
// Build the full definition path for comparison
|
|
1273
|
+
const defPath = buildDefinitionPath(def)
|
|
1274
|
+
// Check if the expression path matches the definition path
|
|
1275
|
+
if (defPath === exprPath) {
|
|
1276
|
+
foundInLocal = true
|
|
724
1277
|
const normalizedDef = normalizeText(def.value)
|
|
725
1278
|
if (normalizedDef === normalizedSearch) {
|
|
726
1279
|
// Found a variable match - this is highest priority
|
|
@@ -729,7 +1282,7 @@ function findElementWithText(
|
|
|
729
1282
|
bestMatch = {
|
|
730
1283
|
line,
|
|
731
1284
|
type: 'variable',
|
|
732
|
-
variableName:
|
|
1285
|
+
variableName: defPath,
|
|
733
1286
|
definitionLine: def.line,
|
|
734
1287
|
}
|
|
735
1288
|
}
|
|
@@ -737,6 +1290,38 @@ function findElementWithText(
|
|
|
737
1290
|
}
|
|
738
1291
|
}
|
|
739
1292
|
}
|
|
1293
|
+
|
|
1294
|
+
// If not found in local definitions, check if it's from props or imports
|
|
1295
|
+
if (!foundInLocal) {
|
|
1296
|
+
const baseVar = getBaseVarName(exprPath)
|
|
1297
|
+
|
|
1298
|
+
// Check props first
|
|
1299
|
+
const actualPropName = propAliases.get(baseVar)
|
|
1300
|
+
if (actualPropName) {
|
|
1301
|
+
// This expression uses a prop - collect as candidate for cross-file verification
|
|
1302
|
+
// (don't set bestMatch yet - we need to verify each candidate)
|
|
1303
|
+
propCandidates.push({
|
|
1304
|
+
line,
|
|
1305
|
+
type: 'variable',
|
|
1306
|
+
usesProp: true,
|
|
1307
|
+
propName: actualPropName, // Use the actual prop name, not the local alias
|
|
1308
|
+
expressionPath: exprPath,
|
|
1309
|
+
})
|
|
1310
|
+
} else {
|
|
1311
|
+
// Check if it's from an import
|
|
1312
|
+
const importInfo = imports.find((imp) => imp.localName === baseVar)
|
|
1313
|
+
if (importInfo) {
|
|
1314
|
+
// This expression uses an import - collect as candidate for cross-file verification
|
|
1315
|
+
importCandidates.push({
|
|
1316
|
+
line,
|
|
1317
|
+
type: 'variable',
|
|
1318
|
+
usesImport: true,
|
|
1319
|
+
importInfo,
|
|
1320
|
+
expressionPath: exprPath,
|
|
1321
|
+
})
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
740
1325
|
}
|
|
741
1326
|
}
|
|
742
1327
|
|
|
@@ -814,7 +1399,7 @@ function findElementWithText(
|
|
|
814
1399
|
}
|
|
815
1400
|
|
|
816
1401
|
visit(ast)
|
|
817
|
-
return bestMatch
|
|
1402
|
+
return { bestMatch, propCandidates, importCandidates }
|
|
818
1403
|
}
|
|
819
1404
|
|
|
820
1405
|
interface ComponentPropMatch {
|
|
@@ -864,6 +1449,297 @@ function findComponentProp(
|
|
|
864
1449
|
return visit(ast)
|
|
865
1450
|
}
|
|
866
1451
|
|
|
1452
|
+
interface ExpressionPropMatch {
|
|
1453
|
+
componentName: string
|
|
1454
|
+
propName: string
|
|
1455
|
+
/** The expression text (e.g., 'navItems' from items={navItems}) */
|
|
1456
|
+
expressionText: string
|
|
1457
|
+
line: number
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
interface SpreadPropMatch {
|
|
1461
|
+
componentName: string
|
|
1462
|
+
/** The variable name being spread (e.g., 'cardProps' from {...cardProps}) */
|
|
1463
|
+
spreadVarName: string
|
|
1464
|
+
line: number
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
/**
|
|
1468
|
+
* Walk the Astro AST to find component usages with expression props.
|
|
1469
|
+
* Looks for patterns like: <Nav items={navItems} />
|
|
1470
|
+
* @param ast - The Astro AST
|
|
1471
|
+
* @param componentName - The component name to search for (e.g., 'Nav')
|
|
1472
|
+
* @param propName - The prop name to find (e.g., 'items')
|
|
1473
|
+
*/
|
|
1474
|
+
function findExpressionProp(
|
|
1475
|
+
ast: AstroNode,
|
|
1476
|
+
componentName: string,
|
|
1477
|
+
propName: string,
|
|
1478
|
+
): ExpressionPropMatch | null {
|
|
1479
|
+
function visit(node: AstroNode): ExpressionPropMatch | null {
|
|
1480
|
+
// Check component nodes matching the name
|
|
1481
|
+
if (node.type === 'component') {
|
|
1482
|
+
const compNode = node as ComponentNode
|
|
1483
|
+
if (compNode.name === componentName) {
|
|
1484
|
+
for (const attr of compNode.attributes) {
|
|
1485
|
+
// Check for expression attributes: items={navItems}
|
|
1486
|
+
if (attr.type === 'attribute' && attr.name === propName && attr.kind === 'expression') {
|
|
1487
|
+
// The value contains the expression text
|
|
1488
|
+
const exprText = attr.value?.trim() || ''
|
|
1489
|
+
if (exprText) {
|
|
1490
|
+
return {
|
|
1491
|
+
componentName,
|
|
1492
|
+
propName,
|
|
1493
|
+
expressionText: exprText,
|
|
1494
|
+
line: attr.position?.start.line ?? compNode.position?.start.line ?? 0,
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
// Recursively visit children
|
|
1503
|
+
if ('children' in node && Array.isArray(node.children)) {
|
|
1504
|
+
for (const child of node.children) {
|
|
1505
|
+
const result = visit(child)
|
|
1506
|
+
if (result) return result
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
return null
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
return visit(ast)
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
/**
|
|
1517
|
+
* Walk the Astro AST to find component usages with spread props.
|
|
1518
|
+
* Looks for patterns like: <Card {...cardProps} />
|
|
1519
|
+
* @param ast - The Astro AST
|
|
1520
|
+
* @param componentName - The component name to search for (e.g., 'Card')
|
|
1521
|
+
*/
|
|
1522
|
+
function findSpreadProp(
|
|
1523
|
+
ast: AstroNode,
|
|
1524
|
+
componentName: string,
|
|
1525
|
+
): SpreadPropMatch | null {
|
|
1526
|
+
function visit(node: AstroNode): SpreadPropMatch | null {
|
|
1527
|
+
// Check component nodes matching the name
|
|
1528
|
+
if (node.type === 'component') {
|
|
1529
|
+
const compNode = node as ComponentNode
|
|
1530
|
+
if (compNode.name === componentName) {
|
|
1531
|
+
for (const attr of compNode.attributes) {
|
|
1532
|
+
// Check for spread attributes: {...cardProps}
|
|
1533
|
+
// In Astro AST: type='attribute', kind='spread', name=variable name
|
|
1534
|
+
if (attr.type === 'attribute' && attr.kind === 'spread' && attr.name) {
|
|
1535
|
+
return {
|
|
1536
|
+
componentName,
|
|
1537
|
+
spreadVarName: attr.name,
|
|
1538
|
+
line: attr.position?.start.line ?? compNode.position?.start.line ?? 0,
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
// Recursively visit children
|
|
1546
|
+
if ('children' in node && Array.isArray(node.children)) {
|
|
1547
|
+
for (const child of node.children) {
|
|
1548
|
+
const result = visit(child)
|
|
1549
|
+
if (result) return result
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
return null
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
return visit(ast)
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
/**
|
|
1560
|
+
* Search for a component usage with an expression prop across all files.
|
|
1561
|
+
* When we find an expression like {items[0]} in a component where items comes from props,
|
|
1562
|
+
* we search for where that component is used and track the expression prop back.
|
|
1563
|
+
* Supports multi-level prop drilling with a depth limit.
|
|
1564
|
+
*
|
|
1565
|
+
* @param componentFileName - The file name of the component (e.g., 'Nav.astro')
|
|
1566
|
+
* @param propName - The prop name we're looking for (e.g., 'items')
|
|
1567
|
+
* @param expressionPath - The full expression path (e.g., 'items[0]')
|
|
1568
|
+
* @param searchText - The text content we're searching for
|
|
1569
|
+
* @param depth - Current recursion depth (default 0, max 5)
|
|
1570
|
+
* @returns Source location if found
|
|
1571
|
+
*/
|
|
1572
|
+
async function searchForExpressionProp(
|
|
1573
|
+
componentFileName: string,
|
|
1574
|
+
propName: string,
|
|
1575
|
+
expressionPath: string,
|
|
1576
|
+
searchText: string,
|
|
1577
|
+
depth: number = 0,
|
|
1578
|
+
): Promise<SourceLocation | undefined> {
|
|
1579
|
+
// Limit recursion depth to prevent infinite loops
|
|
1580
|
+
if (depth > 5) return undefined
|
|
1581
|
+
|
|
1582
|
+
const srcDir = path.join(getProjectRoot(), 'src')
|
|
1583
|
+
const searchDirs = [
|
|
1584
|
+
path.join(srcDir, 'pages'),
|
|
1585
|
+
path.join(srcDir, 'components'),
|
|
1586
|
+
path.join(srcDir, 'layouts'),
|
|
1587
|
+
]
|
|
1588
|
+
|
|
1589
|
+
// Extract the component name from file name (e.g., 'Nav.astro' -> 'Nav')
|
|
1590
|
+
const componentName = path.basename(componentFileName, '.astro')
|
|
1591
|
+
const normalizedSearch = normalizeText(searchText)
|
|
1592
|
+
|
|
1593
|
+
for (const dir of searchDirs) {
|
|
1594
|
+
try {
|
|
1595
|
+
const result = await searchDirForExpressionProp(
|
|
1596
|
+
dir,
|
|
1597
|
+
componentName,
|
|
1598
|
+
propName,
|
|
1599
|
+
expressionPath,
|
|
1600
|
+
normalizedSearch,
|
|
1601
|
+
searchText,
|
|
1602
|
+
depth,
|
|
1603
|
+
)
|
|
1604
|
+
if (result) return result
|
|
1605
|
+
} catch {
|
|
1606
|
+
// Directory doesn't exist, continue
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
return undefined
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
async function searchDirForExpressionProp(
|
|
1614
|
+
dir: string,
|
|
1615
|
+
componentName: string,
|
|
1616
|
+
propName: string,
|
|
1617
|
+
expressionPath: string,
|
|
1618
|
+
normalizedSearch: string,
|
|
1619
|
+
searchText: string,
|
|
1620
|
+
depth: number,
|
|
1621
|
+
): Promise<SourceLocation | undefined> {
|
|
1622
|
+
try {
|
|
1623
|
+
const entries = await fs.readdir(dir, { withFileTypes: true })
|
|
1624
|
+
|
|
1625
|
+
for (const entry of entries) {
|
|
1626
|
+
const fullPath = path.join(dir, entry.name)
|
|
1627
|
+
|
|
1628
|
+
if (entry.isDirectory()) {
|
|
1629
|
+
const result = await searchDirForExpressionProp(
|
|
1630
|
+
fullPath,
|
|
1631
|
+
componentName,
|
|
1632
|
+
propName,
|
|
1633
|
+
expressionPath,
|
|
1634
|
+
normalizedSearch,
|
|
1635
|
+
searchText,
|
|
1636
|
+
depth,
|
|
1637
|
+
)
|
|
1638
|
+
if (result) return result
|
|
1639
|
+
} else if (entry.isFile() && entry.name.endsWith('.astro')) {
|
|
1640
|
+
const cached = await getCachedParsedFile(fullPath)
|
|
1641
|
+
if (!cached) continue
|
|
1642
|
+
|
|
1643
|
+
// First, try to find expression prop usage: <Nav items={navItems} />
|
|
1644
|
+
const exprPropMatch = findExpressionProp(cached.ast, componentName, propName)
|
|
1645
|
+
|
|
1646
|
+
if (exprPropMatch) {
|
|
1647
|
+
// The expression text might be a simple variable like 'navItems'
|
|
1648
|
+
const exprText = exprPropMatch.expressionText
|
|
1649
|
+
|
|
1650
|
+
// Build the corresponding path in the parent's variable definitions
|
|
1651
|
+
// e.g., if expressionPath is 'items[0]' and exprText is 'navItems',
|
|
1652
|
+
// we look for 'navItems[0]' in the parent's definitions
|
|
1653
|
+
const parentPath = expressionPath.replace(/^[^.[]+/, exprText)
|
|
1654
|
+
|
|
1655
|
+
// Check if the value is in local variable definitions
|
|
1656
|
+
for (const def of cached.variableDefinitions) {
|
|
1657
|
+
const defPath = buildDefinitionPath(def)
|
|
1658
|
+
if (defPath === parentPath) {
|
|
1659
|
+
const normalizedDef = normalizeText(def.value)
|
|
1660
|
+
if (normalizedDef === normalizedSearch) {
|
|
1661
|
+
return {
|
|
1662
|
+
file: path.relative(getProjectRoot(), fullPath),
|
|
1663
|
+
line: def.line,
|
|
1664
|
+
snippet: cached.lines[def.line - 1] || '',
|
|
1665
|
+
type: 'variable',
|
|
1666
|
+
variableName: defPath,
|
|
1667
|
+
definitionLine: def.line,
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
// Check if exprText is itself from props (multi-level prop drilling)
|
|
1674
|
+
const baseVar = exprText.match(/^(\w+)/)?.[1]
|
|
1675
|
+
if (baseVar && cached.propAliases.has(baseVar)) {
|
|
1676
|
+
const actualPropName = cached.propAliases.get(baseVar)!
|
|
1677
|
+
// Recursively search for where this component is used
|
|
1678
|
+
const result = await searchForExpressionProp(
|
|
1679
|
+
entry.name,
|
|
1680
|
+
actualPropName,
|
|
1681
|
+
parentPath, // Use the path with the parent's variable name
|
|
1682
|
+
searchText,
|
|
1683
|
+
depth + 1,
|
|
1684
|
+
)
|
|
1685
|
+
if (result) return result
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
continue
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
// Second, try to find spread prop usage: <Card {...cardProps} />
|
|
1692
|
+
const spreadMatch = findSpreadProp(cached.ast, componentName)
|
|
1693
|
+
|
|
1694
|
+
if (spreadMatch) {
|
|
1695
|
+
// Find the spread variable's definition
|
|
1696
|
+
const spreadVarName = spreadMatch.spreadVarName
|
|
1697
|
+
|
|
1698
|
+
// The propName we're looking for should be a property of the spread object
|
|
1699
|
+
// e.g., if propName is 'title' and spread is {...cardProps},
|
|
1700
|
+
// we look for cardProps.title in the definitions
|
|
1701
|
+
const spreadPropPath = `${spreadVarName}.${propName}`
|
|
1702
|
+
|
|
1703
|
+
for (const def of cached.variableDefinitions) {
|
|
1704
|
+
const defPath = buildDefinitionPath(def)
|
|
1705
|
+
if (defPath === spreadPropPath) {
|
|
1706
|
+
const normalizedDef = normalizeText(def.value)
|
|
1707
|
+
if (normalizedDef === normalizedSearch) {
|
|
1708
|
+
return {
|
|
1709
|
+
file: path.relative(getProjectRoot(), fullPath),
|
|
1710
|
+
line: def.line,
|
|
1711
|
+
snippet: cached.lines[def.line - 1] || '',
|
|
1712
|
+
type: 'variable',
|
|
1713
|
+
variableName: defPath,
|
|
1714
|
+
definitionLine: def.line,
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
// Check if the spread variable itself comes from props
|
|
1721
|
+
if (cached.propAliases.has(spreadVarName)) {
|
|
1722
|
+
const actualPropName = cached.propAliases.get(spreadVarName)!
|
|
1723
|
+
// For spread from props, we need to search for the full path
|
|
1724
|
+
const result = await searchForExpressionProp(
|
|
1725
|
+
entry.name,
|
|
1726
|
+
actualPropName,
|
|
1727
|
+
expressionPath,
|
|
1728
|
+
searchText,
|
|
1729
|
+
depth + 1,
|
|
1730
|
+
)
|
|
1731
|
+
if (result) return result
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
} catch {
|
|
1737
|
+
// Error reading directory
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
return undefined
|
|
1741
|
+
}
|
|
1742
|
+
|
|
867
1743
|
interface ImageMatch {
|
|
868
1744
|
line: number
|
|
869
1745
|
src: string
|
|
@@ -1160,20 +2036,28 @@ async function searchAstroFile(
|
|
|
1160
2036
|
const cached = await getCachedParsedFile(filePath)
|
|
1161
2037
|
if (!cached) return undefined
|
|
1162
2038
|
|
|
1163
|
-
const { lines, ast, variableDefinitions } = cached
|
|
2039
|
+
const { lines, ast, variableDefinitions, propAliases, imports } = cached
|
|
1164
2040
|
|
|
1165
2041
|
// Find matching element in template AST
|
|
1166
|
-
const
|
|
2042
|
+
const { bestMatch, propCandidates, importCandidates } = findElementWithText(
|
|
2043
|
+
ast,
|
|
2044
|
+
tag,
|
|
2045
|
+
textContent,
|
|
2046
|
+
variableDefinitions,
|
|
2047
|
+
propAliases,
|
|
2048
|
+
imports,
|
|
2049
|
+
)
|
|
1167
2050
|
|
|
1168
|
-
if (
|
|
2051
|
+
// First, check if we have a direct match (local variable or static content)
|
|
2052
|
+
if (bestMatch && !bestMatch.usesProp && !bestMatch.usesImport) {
|
|
1169
2053
|
// Determine the editable line (definition for variables, usage for static)
|
|
1170
|
-
const editableLine =
|
|
1171
|
-
?
|
|
1172
|
-
:
|
|
2054
|
+
const editableLine = bestMatch.type === 'variable' && bestMatch.definitionLine
|
|
2055
|
+
? bestMatch.definitionLine
|
|
2056
|
+
: bestMatch.line
|
|
1173
2057
|
|
|
1174
2058
|
// Get the source snippet - innerHTML for static content, definition line for variables
|
|
1175
2059
|
let snippet: string
|
|
1176
|
-
if (
|
|
2060
|
+
if (bestMatch.type === 'static') {
|
|
1177
2061
|
// For static content, extract only the innerHTML (not the wrapper element)
|
|
1178
2062
|
const completeSnippet = extractCompleteTagSnippet(lines, editableLine - 1, tag)
|
|
1179
2063
|
snippet = extractInnerHtmlFromSnippet(completeSnippet, tag) ?? completeSnippet
|
|
@@ -1186,9 +2070,42 @@ async function searchAstroFile(
|
|
|
1186
2070
|
file: path.relative(getProjectRoot(), filePath),
|
|
1187
2071
|
line: editableLine,
|
|
1188
2072
|
snippet,
|
|
1189
|
-
type:
|
|
1190
|
-
variableName:
|
|
1191
|
-
definitionLine:
|
|
2073
|
+
type: bestMatch.type,
|
|
2074
|
+
variableName: bestMatch.variableName,
|
|
2075
|
+
definitionLine: bestMatch.type === 'variable' ? bestMatch.definitionLine : undefined,
|
|
2076
|
+
}
|
|
2077
|
+
}
|
|
2078
|
+
|
|
2079
|
+
// Try all prop candidates - verify each one to find the correct match
|
|
2080
|
+
// (handles multiple same-tag elements with different prop values)
|
|
2081
|
+
for (const propCandidate of propCandidates) {
|
|
2082
|
+
if (propCandidate.propName && propCandidate.expressionPath) {
|
|
2083
|
+
const componentFileName = path.basename(filePath)
|
|
2084
|
+
const exprPropResult = await searchForExpressionProp(
|
|
2085
|
+
componentFileName,
|
|
2086
|
+
propCandidate.propName,
|
|
2087
|
+
propCandidate.expressionPath,
|
|
2088
|
+
textContent,
|
|
2089
|
+
)
|
|
2090
|
+
if (exprPropResult) {
|
|
2091
|
+
return exprPropResult
|
|
2092
|
+
}
|
|
2093
|
+
}
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
// Try all import candidates - verify each one to find the correct match
|
|
2097
|
+
// (handles multiple same-tag elements with different imported values)
|
|
2098
|
+
for (const importCandidate of importCandidates) {
|
|
2099
|
+
if (importCandidate.importInfo && importCandidate.expressionPath) {
|
|
2100
|
+
const importResult = await searchForImportedValue(
|
|
2101
|
+
filePath,
|
|
2102
|
+
importCandidate.importInfo,
|
|
2103
|
+
importCandidate.expressionPath,
|
|
2104
|
+
textContent,
|
|
2105
|
+
)
|
|
2106
|
+
if (importResult) {
|
|
2107
|
+
return importResult
|
|
2108
|
+
}
|
|
1192
2109
|
}
|
|
1193
2110
|
}
|
|
1194
2111
|
} catch {
|
|
@@ -1198,6 +2115,70 @@ async function searchAstroFile(
|
|
|
1198
2115
|
return undefined
|
|
1199
2116
|
}
|
|
1200
2117
|
|
|
2118
|
+
/**
|
|
2119
|
+
* Search for a value in an imported file.
|
|
2120
|
+
* @param fromFile - The file that contains the import
|
|
2121
|
+
* @param importInfo - Information about the import
|
|
2122
|
+
* @param expressionPath - The full expression path (e.g., 'config.title' or 'navItems[0]')
|
|
2123
|
+
* @param searchText - The text content we're searching for
|
|
2124
|
+
*/
|
|
2125
|
+
async function searchForImportedValue(
|
|
2126
|
+
fromFile: string,
|
|
2127
|
+
importInfo: ImportInfo,
|
|
2128
|
+
expressionPath: string,
|
|
2129
|
+
searchText: string,
|
|
2130
|
+
): Promise<SourceLocation | undefined> {
|
|
2131
|
+
// Resolve the import path to an absolute file path
|
|
2132
|
+
const importedFilePath = await resolveImportPath(importInfo.source, fromFile)
|
|
2133
|
+
if (!importedFilePath) return undefined
|
|
2134
|
+
|
|
2135
|
+
// Get exported definitions from the imported file
|
|
2136
|
+
const exportedDefs = await getExportedDefinitions(importedFilePath)
|
|
2137
|
+
if (exportedDefs.length === 0) return undefined
|
|
2138
|
+
|
|
2139
|
+
const normalizedSearch = normalizeText(searchText)
|
|
2140
|
+
|
|
2141
|
+
// Build the path we're looking for in the imported file
|
|
2142
|
+
// e.g., if expressionPath is 'config.title' and localName is 'config',
|
|
2143
|
+
// and importedName is 'siteConfig', we look for 'siteConfig.title'
|
|
2144
|
+
let targetPath: string
|
|
2145
|
+
if (importInfo.importedName === 'default' || importInfo.importedName === importInfo.localName) {
|
|
2146
|
+
// Direct import: import { config } from './file' or import config from './file'
|
|
2147
|
+
// The expression path uses the local name, which matches the exported name
|
|
2148
|
+
targetPath = expressionPath
|
|
2149
|
+
} else {
|
|
2150
|
+
// Renamed import: import { config as siteConfig } from './file'
|
|
2151
|
+
// Replace the local name with the original exported name
|
|
2152
|
+
targetPath = expressionPath.replace(
|
|
2153
|
+
new RegExp(`^${importInfo.localName}`),
|
|
2154
|
+
importInfo.importedName,
|
|
2155
|
+
)
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2158
|
+
// Search for the target path in the exported definitions
|
|
2159
|
+
for (const def of exportedDefs) {
|
|
2160
|
+
const defPath = buildDefinitionPath(def)
|
|
2161
|
+
if (defPath === targetPath) {
|
|
2162
|
+
const normalizedDef = normalizeText(def.value)
|
|
2163
|
+
if (normalizedDef === normalizedSearch) {
|
|
2164
|
+
const importedFileContent = await fs.readFile(importedFilePath, 'utf-8')
|
|
2165
|
+
const importedLines = importedFileContent.split('\n')
|
|
2166
|
+
|
|
2167
|
+
return {
|
|
2168
|
+
file: path.relative(getProjectRoot(), importedFilePath),
|
|
2169
|
+
line: def.line,
|
|
2170
|
+
snippet: importedLines[def.line - 1] || '',
|
|
2171
|
+
type: 'variable',
|
|
2172
|
+
variableName: defPath,
|
|
2173
|
+
definitionLine: def.line,
|
|
2174
|
+
}
|
|
2175
|
+
}
|
|
2176
|
+
}
|
|
2177
|
+
}
|
|
2178
|
+
|
|
2179
|
+
return undefined
|
|
2180
|
+
}
|
|
2181
|
+
|
|
1201
2182
|
/**
|
|
1202
2183
|
* Search for prop values passed to components using AST parsing.
|
|
1203
2184
|
* Uses caching for better performance.
|