@nuasite/cms-marker 0.0.71 → 0.0.73

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.
Files changed (48) hide show
  1. package/dist/types/build-processor.d.ts.map +1 -1
  2. package/dist/types/dev-middleware.d.ts.map +1 -1
  3. package/dist/types/source-finder/ast-extractors.d.ts +35 -0
  4. package/dist/types/source-finder/ast-extractors.d.ts.map +1 -0
  5. package/dist/types/source-finder/ast-parser.d.ts +16 -0
  6. package/dist/types/source-finder/ast-parser.d.ts.map +1 -0
  7. package/dist/types/source-finder/cache.d.ts +18 -0
  8. package/dist/types/source-finder/cache.d.ts.map +1 -0
  9. package/dist/types/source-finder/collection-finder.d.ts +24 -0
  10. package/dist/types/source-finder/collection-finder.d.ts.map +1 -0
  11. package/dist/types/source-finder/cross-file-tracker.d.ts +29 -0
  12. package/dist/types/source-finder/cross-file-tracker.d.ts.map +1 -0
  13. package/dist/types/source-finder/element-finder.d.ts +42 -0
  14. package/dist/types/source-finder/element-finder.d.ts.map +1 -0
  15. package/dist/types/source-finder/image-finder.d.ts +16 -0
  16. package/dist/types/source-finder/image-finder.d.ts.map +1 -0
  17. package/dist/types/source-finder/index.d.ts +8 -0
  18. package/dist/types/source-finder/index.d.ts.map +1 -0
  19. package/dist/types/source-finder/search-index.d.ts +27 -0
  20. package/dist/types/source-finder/search-index.d.ts.map +1 -0
  21. package/dist/types/source-finder/snippet-utils.d.ts +49 -0
  22. package/dist/types/source-finder/snippet-utils.d.ts.map +1 -0
  23. package/dist/types/source-finder/source-lookup.d.ts +16 -0
  24. package/dist/types/source-finder/source-lookup.d.ts.map +1 -0
  25. package/dist/types/source-finder/types.d.ts +163 -0
  26. package/dist/types/source-finder/types.d.ts.map +1 -0
  27. package/dist/types/source-finder/variable-extraction.d.ts +37 -0
  28. package/dist/types/source-finder/variable-extraction.d.ts.map +1 -0
  29. package/dist/types/tsconfig.tsbuildinfo +1 -1
  30. package/package.json +1 -1
  31. package/src/build-processor.ts +33 -1
  32. package/src/dev-middleware.ts +33 -1
  33. package/src/source-finder/ast-extractors.ts +175 -0
  34. package/src/source-finder/ast-parser.ts +127 -0
  35. package/src/source-finder/cache.ts +75 -0
  36. package/src/source-finder/collection-finder.ts +321 -0
  37. package/src/source-finder/cross-file-tracker.ts +337 -0
  38. package/src/source-finder/element-finder.ts +383 -0
  39. package/src/source-finder/image-finder.ts +189 -0
  40. package/src/source-finder/index.ts +26 -0
  41. package/src/source-finder/search-index.ts +418 -0
  42. package/src/source-finder/snippet-utils.ts +268 -0
  43. package/src/source-finder/source-lookup.ts +197 -0
  44. package/src/source-finder/types.ts +206 -0
  45. package/src/source-finder/variable-extraction.ts +355 -0
  46. package/dist/types/source-finder.d.ts +0 -117
  47. package/dist/types/source-finder.d.ts.map +0 -1
  48. package/src/source-finder.ts +0 -1784
@@ -0,0 +1,321 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+
4
+ import { getProjectRoot } from '../config'
5
+ import { getMarkdownFileCache } from './cache'
6
+ import { normalizeText } from './snippet-utils'
7
+ import type { CollectionInfo, MarkdownContent, SourceLocation } from './types'
8
+
9
+ // ============================================================================
10
+ // Markdown File Cache
11
+ // ============================================================================
12
+
13
+ /**
14
+ * Get cached markdown file content
15
+ */
16
+ async function getCachedMarkdownFile(filePath: string): Promise<{ content: string; lines: string[] } | null> {
17
+ const cache = getMarkdownFileCache()
18
+ const cached = cache.get(filePath)
19
+ if (cached) return cached
20
+
21
+ try {
22
+ const content = await fs.readFile(filePath, 'utf-8')
23
+ const lines = content.split('\n')
24
+ const entry = { content, lines }
25
+ cache.set(filePath, entry)
26
+ return entry
27
+ } catch {
28
+ return null
29
+ }
30
+ }
31
+
32
+ // ============================================================================
33
+ // Collection Source Finding
34
+ // ============================================================================
35
+
36
+ /**
37
+ * Find markdown collection file for a given page path
38
+ * @param pagePath - The URL path of the page (e.g., '/services/3d-tisk')
39
+ * @param contentDir - The content directory (default: 'src/content')
40
+ * @returns Collection info if found, undefined otherwise
41
+ */
42
+ export async function findCollectionSource(
43
+ pagePath: string,
44
+ contentDir: string = 'src/content',
45
+ ): Promise<CollectionInfo | undefined> {
46
+ // Remove leading/trailing slashes
47
+ const cleanPath = pagePath.replace(/^\/+|\/+$/g, '')
48
+ const pathParts = cleanPath.split('/')
49
+
50
+ if (pathParts.length < 2) {
51
+ // Need at least collection/slug
52
+ return undefined
53
+ }
54
+
55
+ const contentPath = path.join(getProjectRoot(), contentDir)
56
+
57
+ try {
58
+ // Check if content directory exists
59
+ await fs.access(contentPath)
60
+ } catch {
61
+ return undefined
62
+ }
63
+
64
+ // Try different collection/slug combinations
65
+ // Strategy 1: First segment is collection, rest is slug
66
+ // e.g., /services/3d-tisk -> collection: services, slug: 3d-tisk
67
+ const collectionName = pathParts[0]
68
+ const slug = pathParts.slice(1).join('/')
69
+
70
+ if (!collectionName || !slug) {
71
+ return undefined
72
+ }
73
+
74
+ const collectionPath = path.join(contentPath, collectionName)
75
+
76
+ try {
77
+ await fs.access(collectionPath)
78
+ const stat = await fs.stat(collectionPath)
79
+ if (!stat.isDirectory()) {
80
+ return undefined
81
+ }
82
+ } catch {
83
+ return undefined
84
+ }
85
+
86
+ // Look for markdown files matching the slug
87
+ const mdFile = await findMarkdownFile(collectionPath, slug)
88
+ if (mdFile) {
89
+ return {
90
+ name: collectionName,
91
+ slug,
92
+ file: path.relative(getProjectRoot(), mdFile),
93
+ }
94
+ }
95
+
96
+ return undefined
97
+ }
98
+
99
+ /**
100
+ * Find a markdown file in a collection directory by slug
101
+ */
102
+ async function findMarkdownFile(collectionPath: string, slug: string): Promise<string | undefined> {
103
+ // Try direct match: slug.md or slug.mdx
104
+ const directPaths = [
105
+ path.join(collectionPath, `${slug}.md`),
106
+ path.join(collectionPath, `${slug}.mdx`),
107
+ ]
108
+
109
+ for (const p of directPaths) {
110
+ try {
111
+ await fs.access(p)
112
+ return p
113
+ } catch {
114
+ // File doesn't exist, continue
115
+ }
116
+ }
117
+
118
+ // Try nested path for slugs with slashes
119
+ const slugParts = slug.split('/')
120
+ if (slugParts.length > 1) {
121
+ const nestedPath = path.join(collectionPath, ...slugParts.slice(0, -1))
122
+ const fileName = slugParts[slugParts.length - 1]
123
+ const nestedPaths = [
124
+ path.join(nestedPath, `${fileName}.md`),
125
+ path.join(nestedPath, `${fileName}.mdx`),
126
+ ]
127
+ for (const p of nestedPaths) {
128
+ try {
129
+ await fs.access(p)
130
+ return p
131
+ } catch {
132
+ // File doesn't exist, continue
133
+ }
134
+ }
135
+ }
136
+
137
+ // Try index file in slug directory
138
+ const indexPaths = [
139
+ path.join(collectionPath, slug, 'index.md'),
140
+ path.join(collectionPath, slug, 'index.mdx'),
141
+ ]
142
+
143
+ for (const p of indexPaths) {
144
+ try {
145
+ await fs.access(p)
146
+ return p
147
+ } catch {
148
+ // File doesn't exist, continue
149
+ }
150
+ }
151
+
152
+ return undefined
153
+ }
154
+
155
+ // ============================================================================
156
+ // Markdown Source Location Finding
157
+ // ============================================================================
158
+
159
+ /**
160
+ * Find text content in a markdown file and return source location
161
+ * Only matches frontmatter fields, not body content (body is handled separately as a whole)
162
+ * @param textContent - The text content to search for
163
+ * @param collectionInfo - Collection information (name, slug, file path)
164
+ * @returns Source location if found in frontmatter
165
+ */
166
+ export async function findMarkdownSourceLocation(
167
+ textContent: string,
168
+ collectionInfo: CollectionInfo,
169
+ ): Promise<SourceLocation | undefined> {
170
+ try {
171
+ const filePath = path.join(getProjectRoot(), collectionInfo.file)
172
+ const cached = await getCachedMarkdownFile(filePath)
173
+ if (!cached) return undefined
174
+
175
+ const { lines } = cached
176
+ const normalizedSearch = normalizeText(textContent)
177
+
178
+ // Parse frontmatter
179
+ let frontmatterEnd = -1
180
+ let inFrontmatter = false
181
+
182
+ for (let i = 0; i < lines.length; i++) {
183
+ const line = lines[i]?.trim()
184
+ if (line === '---') {
185
+ if (!inFrontmatter) {
186
+ inFrontmatter = true
187
+ } else {
188
+ frontmatterEnd = i
189
+ break
190
+ }
191
+ }
192
+ }
193
+
194
+ // Search in frontmatter only (for title, subtitle, etc.)
195
+ if (frontmatterEnd > 0) {
196
+ for (let i = 1; i < frontmatterEnd; i++) {
197
+ const line = lines[i]
198
+ if (!line) continue
199
+
200
+ // Extract value from YAML key: value
201
+ const match = line.match(/^\s*(\w+):\s*(.+)$/)
202
+ if (match) {
203
+ const key = match[1]
204
+ let value = match[2]?.trim() || ''
205
+
206
+ // Handle quoted strings
207
+ if (
208
+ (value.startsWith('"') && value.endsWith('"'))
209
+ || (value.startsWith("'") && value.endsWith("'"))
210
+ ) {
211
+ value = value.slice(1, -1)
212
+ }
213
+
214
+ if (normalizeText(value) === normalizedSearch) {
215
+ return {
216
+ file: collectionInfo.file,
217
+ line: i + 1,
218
+ snippet: line,
219
+ type: 'collection',
220
+ variableName: key,
221
+ collectionName: collectionInfo.name,
222
+ collectionSlug: collectionInfo.slug,
223
+ }
224
+ }
225
+ }
226
+ }
227
+ }
228
+
229
+ // Body content is not searched line-by-line anymore
230
+ // Use parseMarkdownContent to get the full body as one entry
231
+ } catch {
232
+ // Error reading file
233
+ }
234
+
235
+ return undefined
236
+ }
237
+
238
+ // ============================================================================
239
+ // Markdown Content Parsing
240
+ // ============================================================================
241
+
242
+ /**
243
+ * Parse markdown file and extract frontmatter fields and full body content.
244
+ * Uses caching for better performance.
245
+ * @param collectionInfo - Collection information (name, slug, file path)
246
+ * @returns Parsed markdown content with frontmatter and body
247
+ */
248
+ export async function parseMarkdownContent(
249
+ collectionInfo: CollectionInfo,
250
+ ): Promise<MarkdownContent | undefined> {
251
+ try {
252
+ const filePath = path.join(getProjectRoot(), collectionInfo.file)
253
+ const cached = await getCachedMarkdownFile(filePath)
254
+ if (!cached) return undefined
255
+
256
+ const { lines } = cached
257
+
258
+ // Parse frontmatter
259
+ let frontmatterStart = -1
260
+ let frontmatterEnd = -1
261
+
262
+ for (let i = 0; i < lines.length; i++) {
263
+ const line = lines[i]?.trim()
264
+ if (line === '---') {
265
+ if (frontmatterStart === -1) {
266
+ frontmatterStart = i
267
+ } else {
268
+ frontmatterEnd = i
269
+ break
270
+ }
271
+ }
272
+ }
273
+
274
+ const frontmatter: Record<string, { value: string; line: number }> = {}
275
+
276
+ // Extract frontmatter fields
277
+ if (frontmatterEnd > 0) {
278
+ for (let i = frontmatterStart + 1; i < frontmatterEnd; i++) {
279
+ const line = lines[i]
280
+ if (!line) continue
281
+
282
+ // Extract value from YAML key: value (simple single-line values only)
283
+ const match = line.match(/^\s*(\w+):\s*(.+)$/)
284
+ if (match) {
285
+ const key = match[1]
286
+ let value = match[2]?.trim() || ''
287
+
288
+ // Handle quoted strings
289
+ if (
290
+ (value.startsWith('"') && value.endsWith('"'))
291
+ || (value.startsWith("'") && value.endsWith("'"))
292
+ ) {
293
+ value = value.slice(1, -1)
294
+ }
295
+
296
+ if (key && value) {
297
+ frontmatter[key] = { value, line: i + 1 }
298
+ }
299
+ }
300
+ }
301
+ }
302
+
303
+ // Extract body (everything after frontmatter)
304
+ const bodyStartLine = frontmatterEnd > 0 ? frontmatterEnd + 1 : 0
305
+ const bodyLines = lines.slice(bodyStartLine)
306
+ const body = bodyLines.join('\n').trim()
307
+
308
+ return {
309
+ frontmatter,
310
+ body,
311
+ bodyStartLine: bodyStartLine + 1, // 1-indexed
312
+ file: collectionInfo.file,
313
+ collectionName: collectionInfo.name,
314
+ collectionSlug: collectionInfo.slug,
315
+ }
316
+ } catch {
317
+ // Error reading file
318
+ }
319
+
320
+ return undefined
321
+ }
@@ -0,0 +1,337 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+
4
+ import { getProjectRoot } from '../config'
5
+ import { buildDefinitionPath } from './ast-extractors'
6
+ import { getCachedParsedFile } from './ast-parser'
7
+ import { findComponentProp, findExpressionProp, findSpreadProp } from './element-finder'
8
+ import { normalizeText } from './snippet-utils'
9
+ import type { ImportInfo, SourceLocation } from './types'
10
+ import { getExportedDefinitions, resolveImportPath } from './variable-extraction'
11
+
12
+ // ============================================================================
13
+ // Expression Prop Search
14
+ // ============================================================================
15
+
16
+ /**
17
+ * Search for a component usage with an expression prop across all files.
18
+ * When we find an expression like {items[0]} in a component where items comes from props,
19
+ * we search for where that component is used and track the expression prop back.
20
+ * Supports multi-level prop drilling with a depth limit.
21
+ *
22
+ * @param componentFileName - The file name of the component (e.g., 'Nav.astro')
23
+ * @param propName - The prop name we're looking for (e.g., 'items')
24
+ * @param expressionPath - The full expression path (e.g., 'items[0]')
25
+ * @param searchText - The text content we're searching for
26
+ * @param depth - Current recursion depth (default 0, max 5)
27
+ * @returns Source location if found
28
+ */
29
+ export async function searchForExpressionProp(
30
+ componentFileName: string,
31
+ propName: string,
32
+ expressionPath: string,
33
+ searchText: string,
34
+ depth: number = 0,
35
+ ): Promise<SourceLocation | undefined> {
36
+ // Limit recursion depth to prevent infinite loops
37
+ if (depth > 5) return undefined
38
+
39
+ const srcDir = path.join(getProjectRoot(), 'src')
40
+ const searchDirs = [
41
+ path.join(srcDir, 'pages'),
42
+ path.join(srcDir, 'components'),
43
+ path.join(srcDir, 'layouts'),
44
+ ]
45
+
46
+ // Extract the component name from file name (e.g., 'Nav.astro' -> 'Nav')
47
+ const componentName = path.basename(componentFileName, '.astro')
48
+ const normalizedSearch = normalizeText(searchText)
49
+
50
+ for (const dir of searchDirs) {
51
+ try {
52
+ const result = await searchDirForExpressionProp(
53
+ dir,
54
+ componentName,
55
+ propName,
56
+ expressionPath,
57
+ normalizedSearch,
58
+ searchText,
59
+ depth,
60
+ )
61
+ if (result) return result
62
+ } catch {
63
+ // Directory doesn't exist, continue
64
+ }
65
+ }
66
+
67
+ return undefined
68
+ }
69
+
70
+ async function searchDirForExpressionProp(
71
+ dir: string,
72
+ componentName: string,
73
+ propName: string,
74
+ expressionPath: string,
75
+ normalizedSearch: string,
76
+ searchText: string,
77
+ depth: number,
78
+ ): Promise<SourceLocation | undefined> {
79
+ try {
80
+ const entries = await fs.readdir(dir, { withFileTypes: true })
81
+
82
+ for (const entry of entries) {
83
+ const fullPath = path.join(dir, entry.name)
84
+
85
+ if (entry.isDirectory()) {
86
+ const result = await searchDirForExpressionProp(
87
+ fullPath,
88
+ componentName,
89
+ propName,
90
+ expressionPath,
91
+ normalizedSearch,
92
+ searchText,
93
+ depth,
94
+ )
95
+ if (result) return result
96
+ } else if (entry.isFile() && entry.name.endsWith('.astro')) {
97
+ const cached = await getCachedParsedFile(fullPath)
98
+ if (!cached) continue
99
+
100
+ // First, try to find expression prop usage: <Nav items={navItems} />
101
+ const exprPropMatch = findExpressionProp(cached.ast, componentName, propName)
102
+
103
+ if (exprPropMatch) {
104
+ // The expression text might be a simple variable like 'navItems'
105
+ const exprText = exprPropMatch.expressionText
106
+
107
+ // Build the corresponding path in the parent's variable definitions
108
+ // e.g., if expressionPath is 'items[0]' and exprText is 'navItems',
109
+ // we look for 'navItems[0]' in the parent's definitions
110
+ const parentPath = expressionPath.replace(/^[^.[]+/, exprText)
111
+
112
+ // Check if the value is in local variable definitions
113
+ for (const def of cached.variableDefinitions) {
114
+ const defPath = buildDefinitionPath(def)
115
+ if (defPath === parentPath) {
116
+ const normalizedDef = normalizeText(def.value)
117
+ if (normalizedDef === normalizedSearch) {
118
+ return {
119
+ file: path.relative(getProjectRoot(), fullPath),
120
+ line: def.line,
121
+ snippet: cached.lines[def.line - 1] || '',
122
+ type: 'variable',
123
+ variableName: defPath,
124
+ definitionLine: def.line,
125
+ }
126
+ }
127
+ }
128
+ }
129
+
130
+ // Check if exprText is itself from props (multi-level prop drilling)
131
+ const baseVar = exprText.match(/^(\w+)/)?.[1]
132
+ if (baseVar && cached.propAliases.has(baseVar)) {
133
+ const actualPropName = cached.propAliases.get(baseVar)!
134
+ // Recursively search for where this component is used
135
+ const result = await searchForExpressionProp(
136
+ entry.name,
137
+ actualPropName,
138
+ parentPath, // Use the path with the parent's variable name
139
+ searchText,
140
+ depth + 1,
141
+ )
142
+ if (result) return result
143
+ }
144
+
145
+ continue
146
+ }
147
+
148
+ // Second, try to find spread prop usage: <Card {...cardProps} />
149
+ const spreadMatch = findSpreadProp(cached.ast, componentName)
150
+
151
+ if (spreadMatch) {
152
+ // Find the spread variable's definition
153
+ const spreadVarName = spreadMatch.spreadVarName
154
+
155
+ // The propName we're looking for should be a property of the spread object
156
+ // e.g., if propName is 'title' and spread is {...cardProps},
157
+ // we look for cardProps.title in the definitions
158
+ const spreadPropPath = `${spreadVarName}.${propName}`
159
+
160
+ for (const def of cached.variableDefinitions) {
161
+ const defPath = buildDefinitionPath(def)
162
+ if (defPath === spreadPropPath) {
163
+ const normalizedDef = normalizeText(def.value)
164
+ if (normalizedDef === normalizedSearch) {
165
+ return {
166
+ file: path.relative(getProjectRoot(), fullPath),
167
+ line: def.line,
168
+ snippet: cached.lines[def.line - 1] || '',
169
+ type: 'variable',
170
+ variableName: defPath,
171
+ definitionLine: def.line,
172
+ }
173
+ }
174
+ }
175
+ }
176
+
177
+ // Check if the spread variable itself comes from props
178
+ if (cached.propAliases.has(spreadVarName)) {
179
+ const actualPropName = cached.propAliases.get(spreadVarName)!
180
+ // For spread from props, we need to search for the full path
181
+ const result = await searchForExpressionProp(
182
+ entry.name,
183
+ actualPropName,
184
+ expressionPath,
185
+ searchText,
186
+ depth + 1,
187
+ )
188
+ if (result) return result
189
+ }
190
+ }
191
+ }
192
+ }
193
+ } catch {
194
+ // Error reading directory
195
+ }
196
+
197
+ return undefined
198
+ }
199
+
200
+ // ============================================================================
201
+ // Imported Value Search
202
+ // ============================================================================
203
+
204
+ /**
205
+ * Search for a value in an imported file.
206
+ * @param fromFile - The file that contains the import
207
+ * @param importInfo - Information about the import
208
+ * @param expressionPath - The full expression path (e.g., 'config.title' or 'navItems[0]')
209
+ * @param searchText - The text content we're searching for
210
+ */
211
+ export async function searchForImportedValue(
212
+ fromFile: string,
213
+ importInfo: ImportInfo,
214
+ expressionPath: string,
215
+ searchText: string,
216
+ ): Promise<SourceLocation | undefined> {
217
+ // Resolve the import path to an absolute file path
218
+ const importedFilePath = await resolveImportPath(importInfo.source, fromFile)
219
+ if (!importedFilePath) return undefined
220
+
221
+ // Get exported definitions from the imported file
222
+ const exportedDefs = await getExportedDefinitions(importedFilePath)
223
+ if (exportedDefs.length === 0) return undefined
224
+
225
+ const normalizedSearch = normalizeText(searchText)
226
+
227
+ // Build the path we're looking for in the imported file
228
+ // e.g., if expressionPath is 'config.title' and localName is 'config',
229
+ // and importedName is 'siteConfig', we look for 'siteConfig.title'
230
+ let targetPath: string
231
+ if (importInfo.importedName === 'default' || importInfo.importedName === importInfo.localName) {
232
+ // Direct import: import { config } from './file' or import config from './file'
233
+ // The expression path uses the local name, which matches the exported name
234
+ targetPath = expressionPath
235
+ } else {
236
+ // Renamed import: import { config as siteConfig } from './file'
237
+ // Replace the local name with the original exported name
238
+ targetPath = expressionPath.replace(
239
+ new RegExp(`^${importInfo.localName}`),
240
+ importInfo.importedName,
241
+ )
242
+ }
243
+
244
+ // Search for the target path in the exported definitions
245
+ for (const def of exportedDefs) {
246
+ const defPath = buildDefinitionPath(def)
247
+ if (defPath === targetPath) {
248
+ const normalizedDef = normalizeText(def.value)
249
+ if (normalizedDef === normalizedSearch) {
250
+ const importedFileContent = await fs.readFile(importedFilePath, 'utf-8')
251
+ const importedLines = importedFileContent.split('\n')
252
+
253
+ return {
254
+ file: path.relative(getProjectRoot(), importedFilePath),
255
+ line: def.line,
256
+ snippet: importedLines[def.line - 1] || '',
257
+ type: 'variable',
258
+ variableName: defPath,
259
+ definitionLine: def.line,
260
+ }
261
+ }
262
+ }
263
+ }
264
+
265
+ return undefined
266
+ }
267
+
268
+ // ============================================================================
269
+ // Prop in Parents Search
270
+ // ============================================================================
271
+
272
+ /**
273
+ * Search for prop values passed to components using AST parsing.
274
+ * Uses caching for better performance.
275
+ */
276
+ export async function searchForPropInParents(dir: string, textContent: string): Promise<SourceLocation | undefined> {
277
+ const entries = await fs.readdir(dir, { withFileTypes: true })
278
+
279
+ for (const entry of entries) {
280
+ const fullPath = path.join(dir, entry.name)
281
+
282
+ if (entry.isDirectory()) {
283
+ const result = await searchForPropInParents(fullPath, textContent)
284
+ if (result) return result
285
+ } else if (entry.isFile() && entry.name.endsWith('.astro')) {
286
+ try {
287
+ // Use cached parsed file
288
+ const cached = await getCachedParsedFile(fullPath)
289
+ if (!cached) continue
290
+
291
+ const { lines, ast } = cached
292
+
293
+ // Find component props matching our text
294
+ const propMatch = findComponentProp(ast, textContent)
295
+
296
+ if (propMatch) {
297
+ // Extract component snippet for context
298
+ const componentStart = propMatch.line - 1
299
+ const snippetLines: string[] = []
300
+ let depth = 0
301
+
302
+ for (let i = componentStart; i < Math.min(componentStart + 10, lines.length); i++) {
303
+ const line = lines[i]
304
+ if (!line) continue
305
+ snippetLines.push(line)
306
+
307
+ // Check for self-closing or end of opening tag
308
+ if (line.includes('/>')) {
309
+ break
310
+ }
311
+ if (line.includes('>') && !line.includes('/>')) {
312
+ // Count opening tags
313
+ const opens = (line.match(/<[A-Z]/g) || []).length
314
+ const closes = (line.match(/\/>/g) || []).length
315
+ depth += opens - closes
316
+ if (depth <= 0 || (i > componentStart && line.includes('>'))) {
317
+ break
318
+ }
319
+ }
320
+ }
321
+
322
+ return {
323
+ file: path.relative(getProjectRoot(), fullPath),
324
+ line: propMatch.line,
325
+ snippet: snippetLines.join('\n'),
326
+ type: 'prop',
327
+ variableName: propMatch.propName,
328
+ }
329
+ }
330
+ } catch {
331
+ // Error parsing file, continue
332
+ }
333
+ }
334
+ }
335
+
336
+ return undefined
337
+ }