@nuasite/cms-marker 0.0.72 → 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.
- package/dist/types/build-processor.d.ts.map +1 -1
- package/dist/types/dev-middleware.d.ts.map +1 -1
- package/dist/types/source-finder/ast-extractors.d.ts +35 -0
- package/dist/types/source-finder/ast-extractors.d.ts.map +1 -0
- package/dist/types/source-finder/ast-parser.d.ts +16 -0
- package/dist/types/source-finder/ast-parser.d.ts.map +1 -0
- package/dist/types/source-finder/cache.d.ts +18 -0
- package/dist/types/source-finder/cache.d.ts.map +1 -0
- package/dist/types/source-finder/collection-finder.d.ts +24 -0
- package/dist/types/source-finder/collection-finder.d.ts.map +1 -0
- package/dist/types/source-finder/cross-file-tracker.d.ts +29 -0
- package/dist/types/source-finder/cross-file-tracker.d.ts.map +1 -0
- package/dist/types/source-finder/element-finder.d.ts +42 -0
- package/dist/types/source-finder/element-finder.d.ts.map +1 -0
- package/dist/types/source-finder/image-finder.d.ts +16 -0
- package/dist/types/source-finder/image-finder.d.ts.map +1 -0
- package/dist/types/source-finder/index.d.ts +8 -0
- package/dist/types/source-finder/index.d.ts.map +1 -0
- package/dist/types/source-finder/search-index.d.ts +27 -0
- package/dist/types/source-finder/search-index.d.ts.map +1 -0
- package/dist/types/source-finder/snippet-utils.d.ts +49 -0
- package/dist/types/source-finder/snippet-utils.d.ts.map +1 -0
- package/dist/types/source-finder/source-lookup.d.ts +16 -0
- package/dist/types/source-finder/source-lookup.d.ts.map +1 -0
- package/dist/types/source-finder/types.d.ts +163 -0
- package/dist/types/source-finder/types.d.ts.map +1 -0
- package/dist/types/source-finder/variable-extraction.d.ts +37 -0
- package/dist/types/source-finder/variable-extraction.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/build-processor.ts +33 -1
- package/src/dev-middleware.ts +33 -1
- package/src/source-finder/ast-extractors.ts +175 -0
- package/src/source-finder/ast-parser.ts +127 -0
- package/src/source-finder/cache.ts +75 -0
- package/src/source-finder/collection-finder.ts +321 -0
- package/src/source-finder/cross-file-tracker.ts +337 -0
- package/src/source-finder/element-finder.ts +383 -0
- package/src/source-finder/image-finder.ts +189 -0
- package/src/source-finder/index.ts +26 -0
- package/src/source-finder/search-index.ts +418 -0
- package/src/source-finder/snippet-utils.ts +268 -0
- package/src/source-finder/source-lookup.ts +197 -0
- package/src/source-finder/types.ts +206 -0
- package/src/source-finder/variable-extraction.ts +355 -0
- package/dist/types/source-finder.d.ts +0 -117
- package/dist/types/source-finder.d.ts.map +0 -1
- package/src/source-finder.ts +0 -2765
|
@@ -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
|
+
}
|