@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
package/src/source-finder.ts
DELETED
|
@@ -1,2765 +0,0 @@
|
|
|
1
|
-
import { parse as parseAstro } from '@astrojs/compiler'
|
|
2
|
-
import type { ComponentNode, ElementNode, Node as AstroNode, TextNode } from '@astrojs/compiler/types'
|
|
3
|
-
import { parse as parseBabel } from '@babel/parser'
|
|
4
|
-
import fs from 'node:fs/promises'
|
|
5
|
-
import path from 'node:path'
|
|
6
|
-
import { getProjectRoot } from './config'
|
|
7
|
-
import { getErrorCollector } from './error-collector'
|
|
8
|
-
import type { ManifestEntry } from './types'
|
|
9
|
-
import { generateSourceHash } from './utils'
|
|
10
|
-
|
|
11
|
-
// ============================================================================
|
|
12
|
-
// File Parsing Cache - Avoid re-parsing the same files
|
|
13
|
-
// ============================================================================
|
|
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
|
-
|
|
25
|
-
interface CachedParsedFile {
|
|
26
|
-
content: string
|
|
27
|
-
lines: string[]
|
|
28
|
-
ast: AstroNode
|
|
29
|
-
frontmatterContent: string | null
|
|
30
|
-
frontmatterStartLine: number
|
|
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[]
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/** Cache for parsed Astro files - cleared between builds */
|
|
40
|
-
const parsedFileCache = new Map<string, CachedParsedFile>()
|
|
41
|
-
|
|
42
|
-
/** Cache for directory listings - cleared between builds */
|
|
43
|
-
const directoryCache = new Map<string, string[]>()
|
|
44
|
-
|
|
45
|
-
/** Cache for markdown file contents - cleared between builds */
|
|
46
|
-
const markdownFileCache = new Map<string, { content: string; lines: string[] }>()
|
|
47
|
-
|
|
48
|
-
/** Pre-built search index for fast lookups */
|
|
49
|
-
interface SearchIndexEntry {
|
|
50
|
-
file: string
|
|
51
|
-
line: number
|
|
52
|
-
snippet: string
|
|
53
|
-
type: 'static' | 'variable' | 'prop' | 'computed'
|
|
54
|
-
variableName?: string
|
|
55
|
-
definitionLine?: number
|
|
56
|
-
normalizedText: string
|
|
57
|
-
tag: string
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
interface ImageIndexEntry {
|
|
61
|
-
file: string
|
|
62
|
-
line: number
|
|
63
|
-
snippet: string
|
|
64
|
-
src: string
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/** Search indexes built once per build */
|
|
68
|
-
let textSearchIndex: SearchIndexEntry[] = []
|
|
69
|
-
let imageSearchIndex: ImageIndexEntry[] = []
|
|
70
|
-
let searchIndexInitialized = false
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Clear all caches - call at start of each build
|
|
74
|
-
*/
|
|
75
|
-
export function clearSourceFinderCache(): void {
|
|
76
|
-
parsedFileCache.clear()
|
|
77
|
-
directoryCache.clear()
|
|
78
|
-
markdownFileCache.clear()
|
|
79
|
-
textSearchIndex = []
|
|
80
|
-
imageSearchIndex = []
|
|
81
|
-
searchIndexInitialized = false
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* Initialize search index by pre-scanning all source files.
|
|
86
|
-
* This is much faster than searching per-entry.
|
|
87
|
-
*/
|
|
88
|
-
export async function initializeSearchIndex(): Promise<void> {
|
|
89
|
-
if (searchIndexInitialized) return
|
|
90
|
-
|
|
91
|
-
const srcDir = path.join(getProjectRoot(), 'src')
|
|
92
|
-
const searchDirs = [
|
|
93
|
-
path.join(srcDir, 'components'),
|
|
94
|
-
path.join(srcDir, 'pages'),
|
|
95
|
-
path.join(srcDir, 'layouts'),
|
|
96
|
-
]
|
|
97
|
-
|
|
98
|
-
// Collect all Astro files first
|
|
99
|
-
const allFiles: string[] = []
|
|
100
|
-
for (const dir of searchDirs) {
|
|
101
|
-
try {
|
|
102
|
-
const files = await collectAstroFiles(dir)
|
|
103
|
-
allFiles.push(...files)
|
|
104
|
-
} catch {
|
|
105
|
-
// Directory doesn't exist
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
// Parse all files in parallel and build indexes
|
|
110
|
-
await Promise.all(allFiles.map(async (filePath) => {
|
|
111
|
-
try {
|
|
112
|
-
const cached = await getCachedParsedFile(filePath)
|
|
113
|
-
if (!cached) return
|
|
114
|
-
|
|
115
|
-
const relFile = path.relative(getProjectRoot(), filePath)
|
|
116
|
-
|
|
117
|
-
// Index all text content from this file
|
|
118
|
-
indexFileContent(cached, relFile)
|
|
119
|
-
|
|
120
|
-
// Index all images from this file
|
|
121
|
-
indexFileImages(cached, relFile)
|
|
122
|
-
} catch {
|
|
123
|
-
// Skip files that fail to parse
|
|
124
|
-
}
|
|
125
|
-
}))
|
|
126
|
-
|
|
127
|
-
searchIndexInitialized = true
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/**
|
|
131
|
-
* Collect all .astro files in a directory recursively
|
|
132
|
-
*/
|
|
133
|
-
async function collectAstroFiles(dir: string): Promise<string[]> {
|
|
134
|
-
const cached = directoryCache.get(dir)
|
|
135
|
-
if (cached) return cached
|
|
136
|
-
|
|
137
|
-
const results: string[] = []
|
|
138
|
-
|
|
139
|
-
try {
|
|
140
|
-
const entries = await fs.readdir(dir, { withFileTypes: true })
|
|
141
|
-
|
|
142
|
-
await Promise.all(entries.map(async (entry) => {
|
|
143
|
-
const fullPath = path.join(dir, entry.name)
|
|
144
|
-
if (entry.isDirectory()) {
|
|
145
|
-
const subFiles = await collectAstroFiles(fullPath)
|
|
146
|
-
results.push(...subFiles)
|
|
147
|
-
} else if (entry.isFile() && (entry.name.endsWith('.astro') || entry.name.endsWith('.tsx') || entry.name.endsWith('.jsx'))) {
|
|
148
|
-
results.push(fullPath)
|
|
149
|
-
}
|
|
150
|
-
}))
|
|
151
|
-
} catch {
|
|
152
|
-
// Directory doesn't exist
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
directoryCache.set(dir, results)
|
|
156
|
-
return results
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/**
|
|
160
|
-
* Get a cached parsed file, parsing it if not cached
|
|
161
|
-
*/
|
|
162
|
-
async function getCachedParsedFile(filePath: string): Promise<CachedParsedFile | null> {
|
|
163
|
-
const cached = parsedFileCache.get(filePath)
|
|
164
|
-
if (cached) return cached
|
|
165
|
-
|
|
166
|
-
try {
|
|
167
|
-
const content = await fs.readFile(filePath, 'utf-8')
|
|
168
|
-
const lines = content.split('\n')
|
|
169
|
-
|
|
170
|
-
// Only parse .astro files with AST
|
|
171
|
-
if (!filePath.endsWith('.astro')) {
|
|
172
|
-
// For tsx/jsx, just cache content/lines for regex search
|
|
173
|
-
const entry: CachedParsedFile = {
|
|
174
|
-
content,
|
|
175
|
-
lines,
|
|
176
|
-
ast: { type: 'root', children: [] } as unknown as AstroNode,
|
|
177
|
-
frontmatterContent: null,
|
|
178
|
-
frontmatterStartLine: 0,
|
|
179
|
-
variableDefinitions: [],
|
|
180
|
-
propAliases: new Map(),
|
|
181
|
-
imports: [],
|
|
182
|
-
}
|
|
183
|
-
parsedFileCache.set(filePath, entry)
|
|
184
|
-
return entry
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
const { ast, frontmatterContent, frontmatterStartLine } = await parseAstroFile(content)
|
|
188
|
-
|
|
189
|
-
let variableDefinitions: VariableDefinition[] = []
|
|
190
|
-
let propAliases = new Map<string, string>()
|
|
191
|
-
let imports: ImportInfo[] = []
|
|
192
|
-
if (frontmatterContent) {
|
|
193
|
-
const frontmatterAst = parseFrontmatter(frontmatterContent, filePath)
|
|
194
|
-
if (frontmatterAst) {
|
|
195
|
-
variableDefinitions = extractVariableDefinitions(frontmatterAst, frontmatterStartLine)
|
|
196
|
-
propAliases = extractPropAliases(frontmatterAst)
|
|
197
|
-
imports = extractImports(frontmatterAst)
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
const entry: CachedParsedFile = {
|
|
202
|
-
content,
|
|
203
|
-
lines,
|
|
204
|
-
ast,
|
|
205
|
-
frontmatterContent,
|
|
206
|
-
frontmatterStartLine,
|
|
207
|
-
variableDefinitions,
|
|
208
|
-
propAliases,
|
|
209
|
-
imports,
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
parsedFileCache.set(filePath, entry)
|
|
213
|
-
return entry
|
|
214
|
-
} catch {
|
|
215
|
-
return null
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
/**
|
|
220
|
-
* Index all searchable text content from a parsed file
|
|
221
|
-
*/
|
|
222
|
-
function indexFileContent(cached: CachedParsedFile, relFile: string): void {
|
|
223
|
-
// Walk AST and collect all text elements
|
|
224
|
-
function visit(node: AstroNode) {
|
|
225
|
-
if ((node.type === 'element' || node.type === 'component')) {
|
|
226
|
-
const elemNode = node as ElementNode | ComponentNode
|
|
227
|
-
const tag = elemNode.name.toLowerCase()
|
|
228
|
-
const textContent = getTextContent(elemNode)
|
|
229
|
-
const normalizedText = normalizeText(textContent)
|
|
230
|
-
const line = elemNode.position?.start.line ?? 0
|
|
231
|
-
|
|
232
|
-
if (normalizedText && normalizedText.length >= 2) {
|
|
233
|
-
// Check for variable references
|
|
234
|
-
const exprInfo = hasExpressionChild(elemNode)
|
|
235
|
-
if (exprInfo.found && exprInfo.varNames.length > 0) {
|
|
236
|
-
for (const exprPath of exprInfo.varNames) {
|
|
237
|
-
for (const def of cached.variableDefinitions) {
|
|
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) {
|
|
245
|
-
const normalizedDef = normalizeText(def.value)
|
|
246
|
-
const completeSnippet = extractCompleteTagSnippet(cached.lines, line - 1, tag)
|
|
247
|
-
const snippet = extractInnerHtmlFromSnippet(completeSnippet, tag) ?? completeSnippet
|
|
248
|
-
|
|
249
|
-
textSearchIndex.push({
|
|
250
|
-
file: relFile,
|
|
251
|
-
line: def.line,
|
|
252
|
-
snippet: cached.lines[def.line - 1] || '',
|
|
253
|
-
type: 'variable',
|
|
254
|
-
variableName: defPath,
|
|
255
|
-
definitionLine: def.line,
|
|
256
|
-
normalizedText: normalizedDef,
|
|
257
|
-
tag,
|
|
258
|
-
})
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
// Index static text content
|
|
265
|
-
const completeSnippet = extractCompleteTagSnippet(cached.lines, line - 1, tag)
|
|
266
|
-
const snippet = extractInnerHtmlFromSnippet(completeSnippet, tag) ?? completeSnippet
|
|
267
|
-
|
|
268
|
-
textSearchIndex.push({
|
|
269
|
-
file: relFile,
|
|
270
|
-
line,
|
|
271
|
-
snippet,
|
|
272
|
-
type: 'static',
|
|
273
|
-
normalizedText,
|
|
274
|
-
tag,
|
|
275
|
-
})
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
// Also index component props
|
|
279
|
-
if (node.type === 'component') {
|
|
280
|
-
for (const attr of elemNode.attributes) {
|
|
281
|
-
if (attr.type === 'attribute' && attr.kind === 'quoted' && attr.value) {
|
|
282
|
-
const normalizedValue = normalizeText(attr.value)
|
|
283
|
-
if (normalizedValue && normalizedValue.length >= 2) {
|
|
284
|
-
textSearchIndex.push({
|
|
285
|
-
file: relFile,
|
|
286
|
-
line: attr.position?.start.line ?? line,
|
|
287
|
-
snippet: cached.lines[(attr.position?.start.line ?? line) - 1] || '',
|
|
288
|
-
type: 'prop',
|
|
289
|
-
variableName: attr.name,
|
|
290
|
-
normalizedText: normalizedValue,
|
|
291
|
-
tag,
|
|
292
|
-
})
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
if ('children' in node && Array.isArray(node.children)) {
|
|
300
|
-
for (const child of node.children) {
|
|
301
|
-
visit(child)
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
visit(cached.ast)
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
/**
|
|
310
|
-
* Index all images from a parsed file
|
|
311
|
-
*/
|
|
312
|
-
function indexFileImages(cached: CachedParsedFile, relFile: string): void {
|
|
313
|
-
// For Astro files, use AST
|
|
314
|
-
if (relFile.endsWith('.astro')) {
|
|
315
|
-
function visit(node: AstroNode) {
|
|
316
|
-
if (node.type === 'element') {
|
|
317
|
-
const elemNode = node as ElementNode
|
|
318
|
-
if (elemNode.name.toLowerCase() === 'img') {
|
|
319
|
-
for (const attr of elemNode.attributes) {
|
|
320
|
-
if (attr.type === 'attribute' && attr.name === 'src' && attr.value) {
|
|
321
|
-
const srcLine = attr.position?.start.line ?? elemNode.position?.start.line ?? 0
|
|
322
|
-
const snippet = extractImageSnippet(cached.lines, srcLine - 1)
|
|
323
|
-
imageSearchIndex.push({
|
|
324
|
-
file: relFile,
|
|
325
|
-
line: srcLine,
|
|
326
|
-
snippet,
|
|
327
|
-
src: attr.value,
|
|
328
|
-
})
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
if ('children' in node && Array.isArray(node.children)) {
|
|
335
|
-
for (const child of node.children) {
|
|
336
|
-
visit(child)
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
visit(cached.ast)
|
|
341
|
-
} else {
|
|
342
|
-
// For tsx/jsx, use regex
|
|
343
|
-
const srcPatterns = [/src="([^"]+)"/g, /src='([^']+)'/g]
|
|
344
|
-
for (let i = 0; i < cached.lines.length; i++) {
|
|
345
|
-
const line = cached.lines[i]
|
|
346
|
-
if (!line) continue
|
|
347
|
-
|
|
348
|
-
for (const pattern of srcPatterns) {
|
|
349
|
-
pattern.lastIndex = 0
|
|
350
|
-
let match: RegExpExecArray | null
|
|
351
|
-
while ((match = pattern.exec(line)) !== null) {
|
|
352
|
-
const snippet = extractImageSnippet(cached.lines, i)
|
|
353
|
-
imageSearchIndex.push({
|
|
354
|
-
file: relFile,
|
|
355
|
-
line: i + 1,
|
|
356
|
-
snippet,
|
|
357
|
-
src: match[1]!,
|
|
358
|
-
})
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
/**
|
|
366
|
-
* Fast text lookup using pre-built index
|
|
367
|
-
*/
|
|
368
|
-
function findInTextIndex(textContent: string, tag: string): SourceLocation | undefined {
|
|
369
|
-
const normalizedSearch = normalizeText(textContent)
|
|
370
|
-
const tagLower = tag.toLowerCase()
|
|
371
|
-
|
|
372
|
-
// First try exact match with same tag
|
|
373
|
-
for (const entry of textSearchIndex) {
|
|
374
|
-
if (entry.tag === tagLower && entry.normalizedText === normalizedSearch) {
|
|
375
|
-
return {
|
|
376
|
-
file: entry.file,
|
|
377
|
-
line: entry.line,
|
|
378
|
-
snippet: entry.snippet,
|
|
379
|
-
type: entry.type,
|
|
380
|
-
variableName: entry.variableName,
|
|
381
|
-
definitionLine: entry.definitionLine,
|
|
382
|
-
}
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
// Then try partial match for longer text
|
|
387
|
-
if (normalizedSearch.length > 10) {
|
|
388
|
-
const textPreview = normalizedSearch.slice(0, Math.min(30, normalizedSearch.length))
|
|
389
|
-
for (const entry of textSearchIndex) {
|
|
390
|
-
if (entry.tag === tagLower && entry.normalizedText.includes(textPreview)) {
|
|
391
|
-
return {
|
|
392
|
-
file: entry.file,
|
|
393
|
-
line: entry.line,
|
|
394
|
-
snippet: entry.snippet,
|
|
395
|
-
type: entry.type,
|
|
396
|
-
variableName: entry.variableName,
|
|
397
|
-
definitionLine: entry.definitionLine,
|
|
398
|
-
}
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
// Try any tag match
|
|
404
|
-
for (const entry of textSearchIndex) {
|
|
405
|
-
if (entry.normalizedText === normalizedSearch) {
|
|
406
|
-
return {
|
|
407
|
-
file: entry.file,
|
|
408
|
-
line: entry.line,
|
|
409
|
-
snippet: entry.snippet,
|
|
410
|
-
type: entry.type,
|
|
411
|
-
variableName: entry.variableName,
|
|
412
|
-
definitionLine: entry.definitionLine,
|
|
413
|
-
}
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
return undefined
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
/**
|
|
421
|
-
* Fast image lookup using pre-built index
|
|
422
|
-
*/
|
|
423
|
-
function findInImageIndex(imageSrc: string): SourceLocation | undefined {
|
|
424
|
-
for (const entry of imageSearchIndex) {
|
|
425
|
-
if (entry.src === imageSrc) {
|
|
426
|
-
return {
|
|
427
|
-
file: entry.file,
|
|
428
|
-
line: entry.line,
|
|
429
|
-
snippet: entry.snippet,
|
|
430
|
-
type: 'static',
|
|
431
|
-
}
|
|
432
|
-
}
|
|
433
|
-
}
|
|
434
|
-
return undefined
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
// Helper for indexing - get text content from node
|
|
438
|
-
function getTextContent(node: AstroNode): string {
|
|
439
|
-
if (node.type === 'text') {
|
|
440
|
-
return (node as TextNode).value
|
|
441
|
-
}
|
|
442
|
-
if ('children' in node && Array.isArray(node.children)) {
|
|
443
|
-
return node.children.map(getTextContent).join('')
|
|
444
|
-
}
|
|
445
|
-
return ''
|
|
446
|
-
}
|
|
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
|
-
|
|
479
|
-
// Helper for indexing - check for expression children
|
|
480
|
-
function hasExpressionChild(node: AstroNode): { found: boolean; varNames: string[] } {
|
|
481
|
-
const varNames: string[] = []
|
|
482
|
-
if (node.type === 'expression') {
|
|
483
|
-
const exprText = getTextContent(node)
|
|
484
|
-
const fullPath = parseExpressionPath(exprText)
|
|
485
|
-
if (fullPath) {
|
|
486
|
-
varNames.push(fullPath)
|
|
487
|
-
}
|
|
488
|
-
return { found: true, varNames }
|
|
489
|
-
}
|
|
490
|
-
if ('children' in node && Array.isArray(node.children)) {
|
|
491
|
-
for (const child of node.children) {
|
|
492
|
-
const result = hasExpressionChild(child)
|
|
493
|
-
if (result.found) {
|
|
494
|
-
varNames.push(...result.varNames)
|
|
495
|
-
}
|
|
496
|
-
}
|
|
497
|
-
}
|
|
498
|
-
return { found: varNames.length > 0, varNames }
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
export interface SourceLocation {
|
|
502
|
-
file: string
|
|
503
|
-
line: number
|
|
504
|
-
snippet?: string
|
|
505
|
-
type?: 'static' | 'variable' | 'prop' | 'computed' | 'collection'
|
|
506
|
-
variableName?: string
|
|
507
|
-
definitionLine?: number
|
|
508
|
-
/** Collection name for collection entries */
|
|
509
|
-
collectionName?: string
|
|
510
|
-
/** Entry slug for collection entries */
|
|
511
|
-
collectionSlug?: string
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
export interface VariableReference {
|
|
515
|
-
name: string
|
|
516
|
-
pattern: string
|
|
517
|
-
definitionLine: number
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
export interface CollectionInfo {
|
|
521
|
-
name: string
|
|
522
|
-
slug: string
|
|
523
|
-
file: string
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
export interface MarkdownContent {
|
|
527
|
-
/** Frontmatter fields as key-value pairs with line numbers */
|
|
528
|
-
frontmatter: Record<string, { value: string; line: number }>
|
|
529
|
-
/** The full markdown body content */
|
|
530
|
-
body: string
|
|
531
|
-
/** Line number where body starts */
|
|
532
|
-
bodyStartLine: number
|
|
533
|
-
/** File path relative to cwd */
|
|
534
|
-
file: string
|
|
535
|
-
/** Collection name */
|
|
536
|
-
collectionName: string
|
|
537
|
-
/** Collection slug */
|
|
538
|
-
collectionSlug: string
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
// ============================================================================
|
|
542
|
-
// AST Parsing Utilities
|
|
543
|
-
// ============================================================================
|
|
544
|
-
|
|
545
|
-
interface ParsedAstroFile {
|
|
546
|
-
ast: AstroNode
|
|
547
|
-
frontmatterContent: string | null
|
|
548
|
-
frontmatterStartLine: number
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
/**
|
|
552
|
-
* Parse an Astro file and return both template AST and frontmatter content
|
|
553
|
-
*/
|
|
554
|
-
async function parseAstroFile(content: string): Promise<ParsedAstroFile> {
|
|
555
|
-
const result = await parseAstro(content, { position: true })
|
|
556
|
-
|
|
557
|
-
// Find frontmatter node
|
|
558
|
-
let frontmatterContent: string | null = null
|
|
559
|
-
let frontmatterStartLine = 0
|
|
560
|
-
|
|
561
|
-
for (const child of result.ast.children) {
|
|
562
|
-
if (child.type === 'frontmatter') {
|
|
563
|
-
frontmatterContent = child.value
|
|
564
|
-
frontmatterStartLine = child.position?.start.line ?? 1
|
|
565
|
-
break
|
|
566
|
-
}
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
return {
|
|
570
|
-
ast: result.ast,
|
|
571
|
-
frontmatterContent,
|
|
572
|
-
frontmatterStartLine,
|
|
573
|
-
}
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
/** Minimal Babel AST node type for our usage */
|
|
577
|
-
interface BabelNode {
|
|
578
|
-
type: string
|
|
579
|
-
[key: string]: unknown
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
/** Minimal Babel File type */
|
|
583
|
-
interface BabelFile {
|
|
584
|
-
type: 'File'
|
|
585
|
-
program: BabelNode & { body: BabelNode[] }
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
/**
|
|
589
|
-
* Parse frontmatter JavaScript/TypeScript with Babel
|
|
590
|
-
* @param content - The frontmatter content to parse
|
|
591
|
-
* @param filePath - Optional file path for error reporting
|
|
592
|
-
*/
|
|
593
|
-
function parseFrontmatter(content: string, filePath?: string): BabelFile | null {
|
|
594
|
-
try {
|
|
595
|
-
return parseBabel(content, {
|
|
596
|
-
sourceType: 'module',
|
|
597
|
-
plugins: ['typescript'],
|
|
598
|
-
errorRecovery: true,
|
|
599
|
-
}) as unknown as BabelFile
|
|
600
|
-
} catch (error) {
|
|
601
|
-
// Record parse errors for aggregated reporting
|
|
602
|
-
if (filePath) {
|
|
603
|
-
getErrorCollector().addWarning(
|
|
604
|
-
`Frontmatter parse: ${filePath}`,
|
|
605
|
-
error instanceof Error ? error.message : String(error),
|
|
606
|
-
)
|
|
607
|
-
}
|
|
608
|
-
return null
|
|
609
|
-
}
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
interface VariableDefinition {
|
|
613
|
-
name: string
|
|
614
|
-
value: string
|
|
615
|
-
line: number
|
|
616
|
-
/** For object properties, the parent variable name */
|
|
617
|
-
parentName?: string
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
/**
|
|
621
|
-
* Extract variable definitions from Babel AST
|
|
622
|
-
* Finds const/let/var declarations with string literal values
|
|
623
|
-
*
|
|
624
|
-
* Note: Babel parses the frontmatter content (without --- delimiters) starting at line 1.
|
|
625
|
-
* frontmatterStartLine is the actual file line where the content begins (after first ---).
|
|
626
|
-
* So we convert: file_line = (babel_line - 1) + frontmatterStartLine
|
|
627
|
-
*/
|
|
628
|
-
function extractVariableDefinitions(ast: BabelFile, frontmatterStartLine: number): VariableDefinition[] {
|
|
629
|
-
const definitions: VariableDefinition[] = []
|
|
630
|
-
|
|
631
|
-
function getStringValue(node: BabelNode): string | null {
|
|
632
|
-
if (node.type === 'StringLiteral') {
|
|
633
|
-
return node.value as string
|
|
634
|
-
}
|
|
635
|
-
if (node.type === 'TemplateLiteral') {
|
|
636
|
-
const quasis = node.quasis as Array<{ value: { cooked: string | null } }> | undefined
|
|
637
|
-
const expressions = node.expressions as unknown[] | undefined
|
|
638
|
-
if (quasis?.length === 1 && expressions?.length === 0) {
|
|
639
|
-
return quasis[0]?.value.cooked ?? null
|
|
640
|
-
}
|
|
641
|
-
}
|
|
642
|
-
return null
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
function babelLineToFileLine(babelLine: number): number {
|
|
646
|
-
// Babel's line 1 = frontmatterStartLine in the actual file
|
|
647
|
-
return (babelLine - 1) + frontmatterStartLine
|
|
648
|
-
}
|
|
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
|
-
|
|
749
|
-
function visitNode(node: BabelNode) {
|
|
750
|
-
if (node.type === 'VariableDeclaration') {
|
|
751
|
-
const declarations = node.declarations as BabelNode[] | undefined
|
|
752
|
-
for (const decl of declarations ?? []) {
|
|
753
|
-
const id = decl.id as BabelNode | undefined
|
|
754
|
-
const init = decl.init as BabelNode | undefined
|
|
755
|
-
if (id?.type === 'Identifier' && init) {
|
|
756
|
-
const varName = id.name as string
|
|
757
|
-
const loc = decl.loc as { start: { line: number } } | undefined
|
|
758
|
-
const line = babelLineToFileLine(loc?.start.line ?? 1)
|
|
759
|
-
|
|
760
|
-
// Simple string value
|
|
761
|
-
const stringValue = getStringValue(init)
|
|
762
|
-
if (stringValue !== null) {
|
|
763
|
-
definitions.push({ name: varName, value: stringValue, line })
|
|
764
|
-
}
|
|
765
|
-
|
|
766
|
-
// Object expression - extract properties recursively
|
|
767
|
-
if (init.type === 'ObjectExpression') {
|
|
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
|
|
831
|
-
for (const prop of properties ?? []) {
|
|
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, '...')
|
|
860
|
-
}
|
|
861
|
-
}
|
|
862
|
-
}
|
|
863
|
-
}
|
|
864
|
-
}
|
|
865
|
-
}
|
|
866
|
-
}
|
|
867
|
-
|
|
868
|
-
// Recursively visit child nodes
|
|
869
|
-
for (const key of Object.keys(node)) {
|
|
870
|
-
const value = node[key]
|
|
871
|
-
if (value && typeof value === 'object') {
|
|
872
|
-
if (Array.isArray(value)) {
|
|
873
|
-
for (const item of value) {
|
|
874
|
-
if (item && typeof item === 'object' && 'type' in item) {
|
|
875
|
-
visitNode(item as BabelNode)
|
|
876
|
-
}
|
|
877
|
-
}
|
|
878
|
-
} else if ('type' in value) {
|
|
879
|
-
visitNode(value as BabelNode)
|
|
880
|
-
}
|
|
881
|
-
}
|
|
882
|
-
}
|
|
883
|
-
}
|
|
884
|
-
|
|
885
|
-
visitNode(ast.program)
|
|
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
|
-
}
|
|
1161
|
-
}
|
|
1162
|
-
|
|
1163
|
-
interface TemplateMatch {
|
|
1164
|
-
line: number
|
|
1165
|
-
type: 'static' | 'variable' | 'computed'
|
|
1166
|
-
variableName?: string
|
|
1167
|
-
/** For variables, the definition line in frontmatter */
|
|
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[]
|
|
1189
|
-
}
|
|
1190
|
-
|
|
1191
|
-
/**
|
|
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)
|
|
1197
|
-
*/
|
|
1198
|
-
function findElementWithText(
|
|
1199
|
-
ast: AstroNode,
|
|
1200
|
-
tag: string,
|
|
1201
|
-
searchText: string,
|
|
1202
|
-
variableDefinitions: VariableDefinition[],
|
|
1203
|
-
propAliases: Map<string, string> = new Map(),
|
|
1204
|
-
imports: ImportInfo[] = [],
|
|
1205
|
-
): FindElementResult {
|
|
1206
|
-
const normalizedSearch = normalizeText(searchText)
|
|
1207
|
-
const tagLower = tag.toLowerCase()
|
|
1208
|
-
let bestMatch: TemplateMatch | null = null
|
|
1209
|
-
let bestScore = 0
|
|
1210
|
-
const propCandidates: TemplateMatch[] = []
|
|
1211
|
-
const importCandidates: TemplateMatch[] = []
|
|
1212
|
-
|
|
1213
|
-
function getTextContent(node: AstroNode): string {
|
|
1214
|
-
if (node.type === 'text') {
|
|
1215
|
-
return (node as TextNode).value
|
|
1216
|
-
}
|
|
1217
|
-
if ('children' in node && Array.isArray(node.children)) {
|
|
1218
|
-
return node.children.map(getTextContent).join('')
|
|
1219
|
-
}
|
|
1220
|
-
return ''
|
|
1221
|
-
}
|
|
1222
|
-
|
|
1223
|
-
function hasExpressionChild(node: AstroNode): { found: boolean; varNames: string[] } {
|
|
1224
|
-
const varNames: string[] = []
|
|
1225
|
-
if (node.type === 'expression') {
|
|
1226
|
-
// Try to extract variable name from expression
|
|
1227
|
-
// The expression node children contain the text representation
|
|
1228
|
-
const exprText = getTextContent(node)
|
|
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)
|
|
1233
|
-
}
|
|
1234
|
-
return { found: true, varNames }
|
|
1235
|
-
}
|
|
1236
|
-
if ('children' in node && Array.isArray(node.children)) {
|
|
1237
|
-
for (const child of node.children) {
|
|
1238
|
-
const result = hasExpressionChild(child)
|
|
1239
|
-
if (result.found) {
|
|
1240
|
-
varNames.push(...result.varNames)
|
|
1241
|
-
}
|
|
1242
|
-
}
|
|
1243
|
-
}
|
|
1244
|
-
return { found: varNames.length > 0, varNames }
|
|
1245
|
-
}
|
|
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
|
-
|
|
1256
|
-
function visit(node: AstroNode) {
|
|
1257
|
-
// Check if this is an element or component matching our tag
|
|
1258
|
-
if ((node.type === 'element' || node.type === 'component') && node.name.toLowerCase() === tagLower) {
|
|
1259
|
-
const elemNode = node as ElementNode | ComponentNode
|
|
1260
|
-
const textContent = getTextContent(elemNode)
|
|
1261
|
-
const normalizedContent = normalizeText(textContent)
|
|
1262
|
-
const line = elemNode.position?.start.line ?? 0
|
|
1263
|
-
|
|
1264
|
-
// Check for expression (variable reference)
|
|
1265
|
-
const exprInfo = hasExpressionChild(elemNode)
|
|
1266
|
-
if (exprInfo.found && exprInfo.varNames.length > 0) {
|
|
1267
|
-
// Look for matching variable definition
|
|
1268
|
-
for (const exprPath of exprInfo.varNames) {
|
|
1269
|
-
let foundInLocal = false
|
|
1270
|
-
|
|
1271
|
-
for (const def of variableDefinitions) {
|
|
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
|
|
1277
|
-
const normalizedDef = normalizeText(def.value)
|
|
1278
|
-
if (normalizedDef === normalizedSearch) {
|
|
1279
|
-
// Found a variable match - this is highest priority
|
|
1280
|
-
if (bestScore < 100) {
|
|
1281
|
-
bestScore = 100
|
|
1282
|
-
bestMatch = {
|
|
1283
|
-
line,
|
|
1284
|
-
type: 'variable',
|
|
1285
|
-
variableName: defPath,
|
|
1286
|
-
definitionLine: def.line,
|
|
1287
|
-
}
|
|
1288
|
-
}
|
|
1289
|
-
return
|
|
1290
|
-
}
|
|
1291
|
-
}
|
|
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
|
-
}
|
|
1325
|
-
}
|
|
1326
|
-
}
|
|
1327
|
-
|
|
1328
|
-
// Check for direct text match (static content)
|
|
1329
|
-
// Only match if there's meaningful text content (not just variable names/expressions)
|
|
1330
|
-
if (normalizedContent && normalizedContent.length >= 2 && normalizedSearch.length > 0) {
|
|
1331
|
-
// For short search text (<= 10 chars), require exact match
|
|
1332
|
-
if (normalizedSearch.length <= 10) {
|
|
1333
|
-
if (normalizedContent.includes(normalizedSearch)) {
|
|
1334
|
-
const score = 80
|
|
1335
|
-
if (score > bestScore) {
|
|
1336
|
-
bestScore = score
|
|
1337
|
-
const actualLine = findTextLine(elemNode, normalizedSearch)
|
|
1338
|
-
bestMatch = {
|
|
1339
|
-
line: actualLine ?? line,
|
|
1340
|
-
type: 'static',
|
|
1341
|
-
}
|
|
1342
|
-
}
|
|
1343
|
-
}
|
|
1344
|
-
} // For longer search text, check if content contains a significant portion
|
|
1345
|
-
else if (normalizedSearch.length > 10) {
|
|
1346
|
-
const textPreview = normalizedSearch.slice(0, Math.min(30, normalizedSearch.length))
|
|
1347
|
-
if (normalizedContent.includes(textPreview)) {
|
|
1348
|
-
const matchLength = Math.min(normalizedSearch.length, normalizedContent.length)
|
|
1349
|
-
const score = 50 + (matchLength / normalizedSearch.length) * 40
|
|
1350
|
-
if (score > bestScore) {
|
|
1351
|
-
bestScore = score
|
|
1352
|
-
const actualLine = findTextLine(elemNode, textPreview)
|
|
1353
|
-
bestMatch = {
|
|
1354
|
-
line: actualLine ?? line,
|
|
1355
|
-
type: 'static',
|
|
1356
|
-
}
|
|
1357
|
-
}
|
|
1358
|
-
} // Try matching first few words for very long text
|
|
1359
|
-
else if (normalizedSearch.length > 20) {
|
|
1360
|
-
const firstWords = normalizedSearch.split(' ').slice(0, 3).join(' ')
|
|
1361
|
-
if (firstWords && normalizedContent.includes(firstWords)) {
|
|
1362
|
-
const score = 40
|
|
1363
|
-
if (score > bestScore) {
|
|
1364
|
-
bestScore = score
|
|
1365
|
-
const actualLine = findTextLine(elemNode, firstWords)
|
|
1366
|
-
bestMatch = {
|
|
1367
|
-
line: actualLine ?? line,
|
|
1368
|
-
type: 'static',
|
|
1369
|
-
}
|
|
1370
|
-
}
|
|
1371
|
-
}
|
|
1372
|
-
}
|
|
1373
|
-
}
|
|
1374
|
-
}
|
|
1375
|
-
}
|
|
1376
|
-
|
|
1377
|
-
// Recursively visit children
|
|
1378
|
-
if ('children' in node && Array.isArray(node.children)) {
|
|
1379
|
-
for (const child of node.children) {
|
|
1380
|
-
visit(child)
|
|
1381
|
-
}
|
|
1382
|
-
}
|
|
1383
|
-
}
|
|
1384
|
-
|
|
1385
|
-
function findTextLine(node: AstroNode, searchText: string): number | null {
|
|
1386
|
-
if (node.type === 'text') {
|
|
1387
|
-
const textNode = node as TextNode
|
|
1388
|
-
if (normalizeText(textNode.value).includes(searchText)) {
|
|
1389
|
-
return textNode.position?.start.line ?? null
|
|
1390
|
-
}
|
|
1391
|
-
}
|
|
1392
|
-
if ('children' in node && Array.isArray(node.children)) {
|
|
1393
|
-
for (const child of node.children) {
|
|
1394
|
-
const line = findTextLine(child, searchText)
|
|
1395
|
-
if (line !== null) return line
|
|
1396
|
-
}
|
|
1397
|
-
}
|
|
1398
|
-
return null
|
|
1399
|
-
}
|
|
1400
|
-
|
|
1401
|
-
visit(ast)
|
|
1402
|
-
return { bestMatch, propCandidates, importCandidates }
|
|
1403
|
-
}
|
|
1404
|
-
|
|
1405
|
-
interface ComponentPropMatch {
|
|
1406
|
-
line: number
|
|
1407
|
-
propName: string
|
|
1408
|
-
propValue: string
|
|
1409
|
-
}
|
|
1410
|
-
|
|
1411
|
-
/**
|
|
1412
|
-
* Walk the Astro AST to find component props with specific text value
|
|
1413
|
-
*/
|
|
1414
|
-
function findComponentProp(
|
|
1415
|
-
ast: AstroNode,
|
|
1416
|
-
searchText: string,
|
|
1417
|
-
): ComponentPropMatch | null {
|
|
1418
|
-
const normalizedSearch = normalizeText(searchText)
|
|
1419
|
-
|
|
1420
|
-
function visit(node: AstroNode): ComponentPropMatch | null {
|
|
1421
|
-
// Check component nodes (PascalCase names)
|
|
1422
|
-
if (node.type === 'component') {
|
|
1423
|
-
const compNode = node as ComponentNode
|
|
1424
|
-
for (const attr of compNode.attributes) {
|
|
1425
|
-
if (attr.type === 'attribute' && attr.kind === 'quoted') {
|
|
1426
|
-
const normalizedValue = normalizeText(attr.value)
|
|
1427
|
-
if (normalizedValue === normalizedSearch) {
|
|
1428
|
-
return {
|
|
1429
|
-
line: attr.position?.start.line ?? compNode.position?.start.line ?? 0,
|
|
1430
|
-
propName: attr.name,
|
|
1431
|
-
propValue: attr.value,
|
|
1432
|
-
}
|
|
1433
|
-
}
|
|
1434
|
-
}
|
|
1435
|
-
}
|
|
1436
|
-
}
|
|
1437
|
-
|
|
1438
|
-
// Recursively visit children
|
|
1439
|
-
if ('children' in node && Array.isArray(node.children)) {
|
|
1440
|
-
for (const child of node.children) {
|
|
1441
|
-
const result = visit(child)
|
|
1442
|
-
if (result) return result
|
|
1443
|
-
}
|
|
1444
|
-
}
|
|
1445
|
-
|
|
1446
|
-
return null
|
|
1447
|
-
}
|
|
1448
|
-
|
|
1449
|
-
return visit(ast)
|
|
1450
|
-
}
|
|
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
|
-
|
|
1743
|
-
interface ImageMatch {
|
|
1744
|
-
line: number
|
|
1745
|
-
src: string
|
|
1746
|
-
snippet: string
|
|
1747
|
-
}
|
|
1748
|
-
|
|
1749
|
-
/**
|
|
1750
|
-
* Walk the Astro AST to find img elements with specific src
|
|
1751
|
-
*/
|
|
1752
|
-
function findImageElement(
|
|
1753
|
-
ast: AstroNode,
|
|
1754
|
-
imageSrc: string,
|
|
1755
|
-
lines: string[],
|
|
1756
|
-
): ImageMatch | null {
|
|
1757
|
-
function visit(node: AstroNode): ImageMatch | null {
|
|
1758
|
-
if (node.type === 'element') {
|
|
1759
|
-
const elemNode = node as ElementNode
|
|
1760
|
-
if (elemNode.name.toLowerCase() === 'img') {
|
|
1761
|
-
for (const attr of elemNode.attributes) {
|
|
1762
|
-
if (attr.type === 'attribute' && attr.name === 'src' && attr.value === imageSrc) {
|
|
1763
|
-
const srcLine = attr.position?.start.line ?? elemNode.position?.start.line ?? 0
|
|
1764
|
-
const snippet = extractImageSnippet(lines, srcLine - 1)
|
|
1765
|
-
return {
|
|
1766
|
-
line: srcLine,
|
|
1767
|
-
src: imageSrc,
|
|
1768
|
-
snippet,
|
|
1769
|
-
}
|
|
1770
|
-
}
|
|
1771
|
-
}
|
|
1772
|
-
}
|
|
1773
|
-
}
|
|
1774
|
-
|
|
1775
|
-
// Recursively visit children
|
|
1776
|
-
if ('children' in node && Array.isArray(node.children)) {
|
|
1777
|
-
for (const child of node.children) {
|
|
1778
|
-
const result = visit(child)
|
|
1779
|
-
if (result) return result
|
|
1780
|
-
}
|
|
1781
|
-
}
|
|
1782
|
-
|
|
1783
|
-
return null
|
|
1784
|
-
}
|
|
1785
|
-
|
|
1786
|
-
return visit(ast)
|
|
1787
|
-
}
|
|
1788
|
-
|
|
1789
|
-
/**
|
|
1790
|
-
* Find source file and line number for text content.
|
|
1791
|
-
* Uses pre-built search index for fast lookups.
|
|
1792
|
-
*/
|
|
1793
|
-
export async function findSourceLocation(
|
|
1794
|
-
textContent: string,
|
|
1795
|
-
tag: string,
|
|
1796
|
-
): Promise<SourceLocation | undefined> {
|
|
1797
|
-
// Use index if available (much faster)
|
|
1798
|
-
if (searchIndexInitialized) {
|
|
1799
|
-
return findInTextIndex(textContent, tag)
|
|
1800
|
-
}
|
|
1801
|
-
|
|
1802
|
-
// Fallback to slow search if index not initialized
|
|
1803
|
-
const srcDir = path.join(getProjectRoot(), 'src')
|
|
1804
|
-
|
|
1805
|
-
try {
|
|
1806
|
-
const searchDirs = [
|
|
1807
|
-
path.join(srcDir, 'components'),
|
|
1808
|
-
path.join(srcDir, 'pages'),
|
|
1809
|
-
path.join(srcDir, 'layouts'),
|
|
1810
|
-
]
|
|
1811
|
-
|
|
1812
|
-
for (const dir of searchDirs) {
|
|
1813
|
-
try {
|
|
1814
|
-
const result = await searchDirectory(dir, textContent, tag)
|
|
1815
|
-
if (result) {
|
|
1816
|
-
return result
|
|
1817
|
-
}
|
|
1818
|
-
} catch {
|
|
1819
|
-
// Directory doesn't exist, continue
|
|
1820
|
-
}
|
|
1821
|
-
}
|
|
1822
|
-
|
|
1823
|
-
// If not found directly, try searching for prop values in parent components
|
|
1824
|
-
for (const dir of searchDirs) {
|
|
1825
|
-
try {
|
|
1826
|
-
const result = await searchForPropInParents(dir, textContent)
|
|
1827
|
-
if (result) {
|
|
1828
|
-
return result
|
|
1829
|
-
}
|
|
1830
|
-
} catch {
|
|
1831
|
-
// Directory doesn't exist, continue
|
|
1832
|
-
}
|
|
1833
|
-
}
|
|
1834
|
-
} catch {
|
|
1835
|
-
// Search failed
|
|
1836
|
-
}
|
|
1837
|
-
|
|
1838
|
-
return undefined
|
|
1839
|
-
}
|
|
1840
|
-
|
|
1841
|
-
/**
|
|
1842
|
-
* Find source file and line number for an image by its src attribute.
|
|
1843
|
-
* Uses pre-built search index for fast lookups.
|
|
1844
|
-
*/
|
|
1845
|
-
export async function findImageSourceLocation(
|
|
1846
|
-
imageSrc: string,
|
|
1847
|
-
): Promise<SourceLocation | undefined> {
|
|
1848
|
-
// Use index if available (much faster)
|
|
1849
|
-
if (searchIndexInitialized) {
|
|
1850
|
-
return findInImageIndex(imageSrc)
|
|
1851
|
-
}
|
|
1852
|
-
|
|
1853
|
-
// Fallback to slow search if index not initialized
|
|
1854
|
-
const srcDir = path.join(getProjectRoot(), 'src')
|
|
1855
|
-
|
|
1856
|
-
try {
|
|
1857
|
-
const searchDirs = [
|
|
1858
|
-
path.join(srcDir, 'pages'),
|
|
1859
|
-
path.join(srcDir, 'components'),
|
|
1860
|
-
path.join(srcDir, 'layouts'),
|
|
1861
|
-
]
|
|
1862
|
-
|
|
1863
|
-
for (const dir of searchDirs) {
|
|
1864
|
-
try {
|
|
1865
|
-
const result = await searchDirectoryForImage(dir, imageSrc)
|
|
1866
|
-
if (result) {
|
|
1867
|
-
return result
|
|
1868
|
-
}
|
|
1869
|
-
} catch {
|
|
1870
|
-
// Directory doesn't exist, continue
|
|
1871
|
-
}
|
|
1872
|
-
}
|
|
1873
|
-
} catch {
|
|
1874
|
-
// Search failed
|
|
1875
|
-
}
|
|
1876
|
-
|
|
1877
|
-
return undefined
|
|
1878
|
-
}
|
|
1879
|
-
|
|
1880
|
-
/**
|
|
1881
|
-
* Recursively search directory for image with matching src
|
|
1882
|
-
*/
|
|
1883
|
-
async function searchDirectoryForImage(
|
|
1884
|
-
dir: string,
|
|
1885
|
-
imageSrc: string,
|
|
1886
|
-
): Promise<SourceLocation | undefined> {
|
|
1887
|
-
try {
|
|
1888
|
-
const entries = await fs.readdir(dir, { withFileTypes: true })
|
|
1889
|
-
|
|
1890
|
-
for (const entry of entries) {
|
|
1891
|
-
const fullPath = path.join(dir, entry.name)
|
|
1892
|
-
|
|
1893
|
-
if (entry.isDirectory()) {
|
|
1894
|
-
const result = await searchDirectoryForImage(fullPath, imageSrc)
|
|
1895
|
-
if (result) return result
|
|
1896
|
-
} else if (entry.isFile() && (entry.name.endsWith('.astro') || entry.name.endsWith('.tsx') || entry.name.endsWith('.jsx'))) {
|
|
1897
|
-
const result = await searchFileForImage(fullPath, imageSrc)
|
|
1898
|
-
if (result) return result
|
|
1899
|
-
}
|
|
1900
|
-
}
|
|
1901
|
-
} catch {
|
|
1902
|
-
// Error reading directory
|
|
1903
|
-
}
|
|
1904
|
-
|
|
1905
|
-
return undefined
|
|
1906
|
-
}
|
|
1907
|
-
|
|
1908
|
-
/**
|
|
1909
|
-
* Search a single file for an image with matching src.
|
|
1910
|
-
* Uses caching for better performance.
|
|
1911
|
-
*/
|
|
1912
|
-
async function searchFileForImage(
|
|
1913
|
-
filePath: string,
|
|
1914
|
-
imageSrc: string,
|
|
1915
|
-
): Promise<SourceLocation | undefined> {
|
|
1916
|
-
try {
|
|
1917
|
-
// Use cached parsed file
|
|
1918
|
-
const cached = await getCachedParsedFile(filePath)
|
|
1919
|
-
if (!cached) return undefined
|
|
1920
|
-
|
|
1921
|
-
const { lines, ast } = cached
|
|
1922
|
-
|
|
1923
|
-
// Use AST parsing for Astro files
|
|
1924
|
-
if (filePath.endsWith('.astro')) {
|
|
1925
|
-
const imageMatch = findImageElement(ast, imageSrc, lines)
|
|
1926
|
-
|
|
1927
|
-
if (imageMatch) {
|
|
1928
|
-
return {
|
|
1929
|
-
file: path.relative(getProjectRoot(), filePath),
|
|
1930
|
-
line: imageMatch.line,
|
|
1931
|
-
snippet: imageMatch.snippet,
|
|
1932
|
-
type: 'static',
|
|
1933
|
-
}
|
|
1934
|
-
}
|
|
1935
|
-
}
|
|
1936
|
-
|
|
1937
|
-
// Regex fallback for TSX/JSX files or if AST parsing failed
|
|
1938
|
-
const srcPatterns = [
|
|
1939
|
-
`src="${imageSrc}"`,
|
|
1940
|
-
`src='${imageSrc}'`,
|
|
1941
|
-
]
|
|
1942
|
-
|
|
1943
|
-
for (let i = 0; i < lines.length; i++) {
|
|
1944
|
-
const line = lines[i]
|
|
1945
|
-
if (!line) continue
|
|
1946
|
-
|
|
1947
|
-
for (const pattern of srcPatterns) {
|
|
1948
|
-
if (line.includes(pattern)) {
|
|
1949
|
-
// Found the image, extract the full <img> tag as snippet
|
|
1950
|
-
const snippet = extractImageSnippet(lines, i)
|
|
1951
|
-
|
|
1952
|
-
return {
|
|
1953
|
-
file: path.relative(getProjectRoot(), filePath),
|
|
1954
|
-
line: i + 1,
|
|
1955
|
-
snippet,
|
|
1956
|
-
type: 'static',
|
|
1957
|
-
}
|
|
1958
|
-
}
|
|
1959
|
-
}
|
|
1960
|
-
}
|
|
1961
|
-
} catch {
|
|
1962
|
-
// Error reading file
|
|
1963
|
-
}
|
|
1964
|
-
|
|
1965
|
-
return undefined
|
|
1966
|
-
}
|
|
1967
|
-
|
|
1968
|
-
/**
|
|
1969
|
-
* Extract the full <img> tag snippet from source lines
|
|
1970
|
-
*/
|
|
1971
|
-
function extractImageSnippet(lines: string[], startLine: number): string {
|
|
1972
|
-
const snippetLines: string[] = []
|
|
1973
|
-
let foundClosing = false
|
|
1974
|
-
|
|
1975
|
-
for (let i = startLine; i < Math.min(startLine + 10, lines.length); i++) {
|
|
1976
|
-
const line = lines[i]
|
|
1977
|
-
if (!line) continue
|
|
1978
|
-
|
|
1979
|
-
snippetLines.push(line)
|
|
1980
|
-
|
|
1981
|
-
// Check if this line contains the closing of the img tag
|
|
1982
|
-
// img tags can be self-closing /> or just >
|
|
1983
|
-
if (line.includes('/>') || (line.includes('<img') && line.includes('>'))) {
|
|
1984
|
-
foundClosing = true
|
|
1985
|
-
break
|
|
1986
|
-
}
|
|
1987
|
-
}
|
|
1988
|
-
|
|
1989
|
-
if (!foundClosing && snippetLines.length > 1) {
|
|
1990
|
-
return snippetLines[0]!
|
|
1991
|
-
}
|
|
1992
|
-
|
|
1993
|
-
return snippetLines.join('\n')
|
|
1994
|
-
}
|
|
1995
|
-
|
|
1996
|
-
/**
|
|
1997
|
-
* Recursively search directory for matching content
|
|
1998
|
-
*/
|
|
1999
|
-
async function searchDirectory(
|
|
2000
|
-
dir: string,
|
|
2001
|
-
textContent: string,
|
|
2002
|
-
tag: string,
|
|
2003
|
-
): Promise<SourceLocation | undefined> {
|
|
2004
|
-
try {
|
|
2005
|
-
const entries = await fs.readdir(dir, { withFileTypes: true })
|
|
2006
|
-
|
|
2007
|
-
for (const entry of entries) {
|
|
2008
|
-
const fullPath = path.join(dir, entry.name)
|
|
2009
|
-
|
|
2010
|
-
if (entry.isDirectory()) {
|
|
2011
|
-
const result = await searchDirectory(fullPath, textContent, tag)
|
|
2012
|
-
if (result) return result
|
|
2013
|
-
} else if (entry.isFile() && entry.name.endsWith('.astro')) {
|
|
2014
|
-
const result = await searchAstroFile(fullPath, textContent, tag)
|
|
2015
|
-
if (result) return result
|
|
2016
|
-
}
|
|
2017
|
-
}
|
|
2018
|
-
} catch {
|
|
2019
|
-
// Error reading directory
|
|
2020
|
-
}
|
|
2021
|
-
|
|
2022
|
-
return undefined
|
|
2023
|
-
}
|
|
2024
|
-
|
|
2025
|
-
/**
|
|
2026
|
-
* Search a single Astro file for matching content using AST parsing.
|
|
2027
|
-
* Uses caching for better performance.
|
|
2028
|
-
*/
|
|
2029
|
-
async function searchAstroFile(
|
|
2030
|
-
filePath: string,
|
|
2031
|
-
textContent: string,
|
|
2032
|
-
tag: string,
|
|
2033
|
-
): Promise<SourceLocation | undefined> {
|
|
2034
|
-
try {
|
|
2035
|
-
// Use cached parsed file
|
|
2036
|
-
const cached = await getCachedParsedFile(filePath)
|
|
2037
|
-
if (!cached) return undefined
|
|
2038
|
-
|
|
2039
|
-
const { lines, ast, variableDefinitions, propAliases, imports } = cached
|
|
2040
|
-
|
|
2041
|
-
// Find matching element in template AST
|
|
2042
|
-
const { bestMatch, propCandidates, importCandidates } = findElementWithText(
|
|
2043
|
-
ast,
|
|
2044
|
-
tag,
|
|
2045
|
-
textContent,
|
|
2046
|
-
variableDefinitions,
|
|
2047
|
-
propAliases,
|
|
2048
|
-
imports,
|
|
2049
|
-
)
|
|
2050
|
-
|
|
2051
|
-
// First, check if we have a direct match (local variable or static content)
|
|
2052
|
-
if (bestMatch && !bestMatch.usesProp && !bestMatch.usesImport) {
|
|
2053
|
-
// Determine the editable line (definition for variables, usage for static)
|
|
2054
|
-
const editableLine = bestMatch.type === 'variable' && bestMatch.definitionLine
|
|
2055
|
-
? bestMatch.definitionLine
|
|
2056
|
-
: bestMatch.line
|
|
2057
|
-
|
|
2058
|
-
// Get the source snippet - innerHTML for static content, definition line for variables
|
|
2059
|
-
let snippet: string
|
|
2060
|
-
if (bestMatch.type === 'static') {
|
|
2061
|
-
// For static content, extract only the innerHTML (not the wrapper element)
|
|
2062
|
-
const completeSnippet = extractCompleteTagSnippet(lines, editableLine - 1, tag)
|
|
2063
|
-
snippet = extractInnerHtmlFromSnippet(completeSnippet, tag) ?? completeSnippet
|
|
2064
|
-
} else {
|
|
2065
|
-
// For variables/props, just the definition line with indentation
|
|
2066
|
-
snippet = lines[editableLine - 1] || ''
|
|
2067
|
-
}
|
|
2068
|
-
|
|
2069
|
-
return {
|
|
2070
|
-
file: path.relative(getProjectRoot(), filePath),
|
|
2071
|
-
line: editableLine,
|
|
2072
|
-
snippet,
|
|
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
|
-
}
|
|
2109
|
-
}
|
|
2110
|
-
}
|
|
2111
|
-
} catch {
|
|
2112
|
-
// Error reading/parsing file
|
|
2113
|
-
}
|
|
2114
|
-
|
|
2115
|
-
return undefined
|
|
2116
|
-
}
|
|
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
|
-
|
|
2182
|
-
/**
|
|
2183
|
-
* Search for prop values passed to components using AST parsing.
|
|
2184
|
-
* Uses caching for better performance.
|
|
2185
|
-
*/
|
|
2186
|
-
async function searchForPropInParents(dir: string, textContent: string): Promise<SourceLocation | undefined> {
|
|
2187
|
-
const entries = await fs.readdir(dir, { withFileTypes: true })
|
|
2188
|
-
|
|
2189
|
-
for (const entry of entries) {
|
|
2190
|
-
const fullPath = path.join(dir, entry.name)
|
|
2191
|
-
|
|
2192
|
-
if (entry.isDirectory()) {
|
|
2193
|
-
const result = await searchForPropInParents(fullPath, textContent)
|
|
2194
|
-
if (result) return result
|
|
2195
|
-
} else if (entry.isFile() && entry.name.endsWith('.astro')) {
|
|
2196
|
-
try {
|
|
2197
|
-
// Use cached parsed file
|
|
2198
|
-
const cached = await getCachedParsedFile(fullPath)
|
|
2199
|
-
if (!cached) continue
|
|
2200
|
-
|
|
2201
|
-
const { lines, ast } = cached
|
|
2202
|
-
|
|
2203
|
-
// Find component props matching our text
|
|
2204
|
-
const propMatch = findComponentProp(ast, textContent)
|
|
2205
|
-
|
|
2206
|
-
if (propMatch) {
|
|
2207
|
-
// Extract component snippet for context
|
|
2208
|
-
const componentStart = propMatch.line - 1
|
|
2209
|
-
const snippetLines: string[] = []
|
|
2210
|
-
let depth = 0
|
|
2211
|
-
|
|
2212
|
-
for (let i = componentStart; i < Math.min(componentStart + 10, lines.length); i++) {
|
|
2213
|
-
const line = lines[i]
|
|
2214
|
-
if (!line) continue
|
|
2215
|
-
snippetLines.push(line)
|
|
2216
|
-
|
|
2217
|
-
// Check for self-closing or end of opening tag
|
|
2218
|
-
if (line.includes('/>')) {
|
|
2219
|
-
break
|
|
2220
|
-
}
|
|
2221
|
-
if (line.includes('>') && !line.includes('/>')) {
|
|
2222
|
-
// Count opening tags
|
|
2223
|
-
const opens = (line.match(/<[A-Z]/g) || []).length
|
|
2224
|
-
const closes = (line.match(/\/>/g) || []).length
|
|
2225
|
-
depth += opens - closes
|
|
2226
|
-
if (depth <= 0 || (i > componentStart && line.includes('>'))) {
|
|
2227
|
-
break
|
|
2228
|
-
}
|
|
2229
|
-
}
|
|
2230
|
-
}
|
|
2231
|
-
|
|
2232
|
-
return {
|
|
2233
|
-
file: path.relative(getProjectRoot(), fullPath),
|
|
2234
|
-
line: propMatch.line,
|
|
2235
|
-
snippet: snippetLines.join('\n'),
|
|
2236
|
-
type: 'prop',
|
|
2237
|
-
variableName: propMatch.propName,
|
|
2238
|
-
}
|
|
2239
|
-
}
|
|
2240
|
-
} catch {
|
|
2241
|
-
// Error parsing file, continue
|
|
2242
|
-
}
|
|
2243
|
-
}
|
|
2244
|
-
}
|
|
2245
|
-
|
|
2246
|
-
return undefined
|
|
2247
|
-
}
|
|
2248
|
-
|
|
2249
|
-
/**
|
|
2250
|
-
* Extract complete tag snippet including content and indentation.
|
|
2251
|
-
* Exported for use in html-processor to populate sourceSnippet.
|
|
2252
|
-
*
|
|
2253
|
-
* When startLine points to a line inside the element (e.g., the text content line),
|
|
2254
|
-
* this function searches backwards to find the opening tag first.
|
|
2255
|
-
*/
|
|
2256
|
-
export function extractCompleteTagSnippet(lines: string[], startLine: number, tag: string): string {
|
|
2257
|
-
// Pattern to match opening tag - either followed by whitespace/>, or at end of line (multi-line tag)
|
|
2258
|
-
const openTagPattern = new RegExp(`<${tag}(?:[\\s>]|$)`, 'gi')
|
|
2259
|
-
|
|
2260
|
-
// Check if the start line contains the opening tag
|
|
2261
|
-
let actualStartLine = startLine
|
|
2262
|
-
const startLineContent = lines[startLine] || ''
|
|
2263
|
-
if (!openTagPattern.test(startLineContent)) {
|
|
2264
|
-
// Search backwards to find the opening tag
|
|
2265
|
-
for (let i = startLine - 1; i >= Math.max(0, startLine - 20); i--) {
|
|
2266
|
-
const line = lines[i]
|
|
2267
|
-
if (!line) continue
|
|
2268
|
-
|
|
2269
|
-
// Reset regex lastIndex for fresh test
|
|
2270
|
-
openTagPattern.lastIndex = 0
|
|
2271
|
-
if (openTagPattern.test(line)) {
|
|
2272
|
-
actualStartLine = i
|
|
2273
|
-
break
|
|
2274
|
-
}
|
|
2275
|
-
}
|
|
2276
|
-
}
|
|
2277
|
-
|
|
2278
|
-
const snippetLines: string[] = []
|
|
2279
|
-
let depth = 0
|
|
2280
|
-
let foundClosing = false
|
|
2281
|
-
|
|
2282
|
-
// Start from the opening tag line
|
|
2283
|
-
for (let i = actualStartLine; i < Math.min(actualStartLine + 30, lines.length); i++) {
|
|
2284
|
-
const line = lines[i]
|
|
2285
|
-
|
|
2286
|
-
if (!line) {
|
|
2287
|
-
continue
|
|
2288
|
-
}
|
|
2289
|
-
|
|
2290
|
-
snippetLines.push(line)
|
|
2291
|
-
|
|
2292
|
-
// Count opening and closing tags
|
|
2293
|
-
// Opening tag can be followed by whitespace, >, or end of line (multi-line tag)
|
|
2294
|
-
const openTags = (line.match(new RegExp(`<${tag}(?:[\\s>]|$)`, 'gi')) || []).length
|
|
2295
|
-
const selfClosing = (line.match(new RegExp(`<${tag}[^>]*/>`, 'gi')) || []).length
|
|
2296
|
-
const closeTags = (line.match(new RegExp(`</${tag}>`, 'gi')) || []).length
|
|
2297
|
-
|
|
2298
|
-
depth += openTags - selfClosing - closeTags
|
|
2299
|
-
|
|
2300
|
-
// If we found a self-closing tag or closed all tags, we're done
|
|
2301
|
-
if (selfClosing > 0 || (depth <= 0 && (closeTags > 0 || openTags > 0))) {
|
|
2302
|
-
foundClosing = true
|
|
2303
|
-
break
|
|
2304
|
-
}
|
|
2305
|
-
}
|
|
2306
|
-
|
|
2307
|
-
// If we didn't find closing tag, just return the first line
|
|
2308
|
-
if (!foundClosing && snippetLines.length > 1) {
|
|
2309
|
-
return snippetLines[0]!
|
|
2310
|
-
}
|
|
2311
|
-
|
|
2312
|
-
return snippetLines.join('\n')
|
|
2313
|
-
}
|
|
2314
|
-
|
|
2315
|
-
/**
|
|
2316
|
-
* Extract innerHTML from a complete tag snippet.
|
|
2317
|
-
* Given `<p class="foo">content here</p>`, returns `content here`.
|
|
2318
|
-
*
|
|
2319
|
-
* @param snippet - The complete tag snippet from source
|
|
2320
|
-
* @param tag - The tag name (e.g., 'p', 'h1')
|
|
2321
|
-
* @returns The innerHTML portion, or undefined if can't extract
|
|
2322
|
-
*/
|
|
2323
|
-
export function extractInnerHtmlFromSnippet(snippet: string, tag: string): string | undefined {
|
|
2324
|
-
// Match opening tag (with any attributes) and extract content until closing tag
|
|
2325
|
-
// Handle both single-line and multi-line cases
|
|
2326
|
-
const openTagPattern = new RegExp(`<${tag}(?:\\s[^>]*)?>`, 'i')
|
|
2327
|
-
const closeTagPattern = new RegExp(`</${tag}>`, 'i')
|
|
2328
|
-
|
|
2329
|
-
const openMatch = snippet.match(openTagPattern)
|
|
2330
|
-
if (!openMatch) return undefined
|
|
2331
|
-
|
|
2332
|
-
const openTagEnd = openMatch.index! + openMatch[0].length
|
|
2333
|
-
const closeMatch = snippet.match(closeTagPattern)
|
|
2334
|
-
if (!closeMatch) return undefined
|
|
2335
|
-
|
|
2336
|
-
const closeTagStart = closeMatch.index!
|
|
2337
|
-
|
|
2338
|
-
// Extract content between opening and closing tags
|
|
2339
|
-
if (closeTagStart > openTagEnd) {
|
|
2340
|
-
return snippet.substring(openTagEnd, closeTagStart)
|
|
2341
|
-
}
|
|
2342
|
-
|
|
2343
|
-
return undefined
|
|
2344
|
-
}
|
|
2345
|
-
|
|
2346
|
-
/**
|
|
2347
|
-
* Read source file and extract the innerHTML at the specified line.
|
|
2348
|
-
*
|
|
2349
|
-
* @param sourceFile - Path to source file (relative to cwd)
|
|
2350
|
-
* @param sourceLine - 1-indexed line number
|
|
2351
|
-
* @param tag - The tag name
|
|
2352
|
-
* @returns The innerHTML from source, or undefined if can't extract
|
|
2353
|
-
*/
|
|
2354
|
-
export async function extractSourceInnerHtml(
|
|
2355
|
-
sourceFile: string,
|
|
2356
|
-
sourceLine: number,
|
|
2357
|
-
tag: string,
|
|
2358
|
-
): Promise<string | undefined> {
|
|
2359
|
-
try {
|
|
2360
|
-
const filePath = path.isAbsolute(sourceFile)
|
|
2361
|
-
? sourceFile
|
|
2362
|
-
: path.join(getProjectRoot(), sourceFile)
|
|
2363
|
-
|
|
2364
|
-
const content = await fs.readFile(filePath, 'utf-8')
|
|
2365
|
-
const lines = content.split('\n')
|
|
2366
|
-
|
|
2367
|
-
// Extract the complete tag snippet
|
|
2368
|
-
const snippet = extractCompleteTagSnippet(lines, sourceLine - 1, tag)
|
|
2369
|
-
|
|
2370
|
-
// Extract innerHTML from the snippet
|
|
2371
|
-
return extractInnerHtmlFromSnippet(snippet, tag)
|
|
2372
|
-
} catch {
|
|
2373
|
-
return undefined
|
|
2374
|
-
}
|
|
2375
|
-
}
|
|
2376
|
-
|
|
2377
|
-
/**
|
|
2378
|
-
* Normalize text for comparison (handles escaping and entities)
|
|
2379
|
-
*/
|
|
2380
|
-
function normalizeText(text: string): string {
|
|
2381
|
-
return text
|
|
2382
|
-
.trim()
|
|
2383
|
-
.replace(/\\'/g, "'") // Escaped single quotes
|
|
2384
|
-
.replace(/\\"/g, '"') // Escaped double quotes
|
|
2385
|
-
.replace(/'/g, "'") // HTML entity for apostrophe
|
|
2386
|
-
.replace(/"/g, '"') // HTML entity for quote
|
|
2387
|
-
.replace(/'/g, "'") // HTML entity for apostrophe (alternative)
|
|
2388
|
-
.replace(/&/g, '&') // HTML entity for ampersand
|
|
2389
|
-
.replace(/\s+/g, ' ') // Normalize whitespace
|
|
2390
|
-
.toLowerCase()
|
|
2391
|
-
}
|
|
2392
|
-
|
|
2393
|
-
/**
|
|
2394
|
-
* Find markdown collection file for a given page path
|
|
2395
|
-
* @param pagePath - The URL path of the page (e.g., '/services/3d-tisk')
|
|
2396
|
-
* @param contentDir - The content directory (default: 'src/content')
|
|
2397
|
-
* @returns Collection info if found, undefined otherwise
|
|
2398
|
-
*/
|
|
2399
|
-
export async function findCollectionSource(
|
|
2400
|
-
pagePath: string,
|
|
2401
|
-
contentDir: string = 'src/content',
|
|
2402
|
-
): Promise<CollectionInfo | undefined> {
|
|
2403
|
-
// Remove leading/trailing slashes
|
|
2404
|
-
const cleanPath = pagePath.replace(/^\/+|\/+$/g, '')
|
|
2405
|
-
const pathParts = cleanPath.split('/')
|
|
2406
|
-
|
|
2407
|
-
if (pathParts.length < 2) {
|
|
2408
|
-
// Need at least collection/slug
|
|
2409
|
-
return undefined
|
|
2410
|
-
}
|
|
2411
|
-
|
|
2412
|
-
const contentPath = path.join(getProjectRoot(), contentDir)
|
|
2413
|
-
|
|
2414
|
-
try {
|
|
2415
|
-
// Check if content directory exists
|
|
2416
|
-
await fs.access(contentPath)
|
|
2417
|
-
} catch {
|
|
2418
|
-
return undefined
|
|
2419
|
-
}
|
|
2420
|
-
|
|
2421
|
-
// Try different collection/slug combinations
|
|
2422
|
-
// Strategy 1: First segment is collection, rest is slug
|
|
2423
|
-
// e.g., /services/3d-tisk -> collection: services, slug: 3d-tisk
|
|
2424
|
-
const collectionName = pathParts[0]
|
|
2425
|
-
const slug = pathParts.slice(1).join('/')
|
|
2426
|
-
|
|
2427
|
-
if (!collectionName || !slug) {
|
|
2428
|
-
return undefined
|
|
2429
|
-
}
|
|
2430
|
-
|
|
2431
|
-
const collectionPath = path.join(contentPath, collectionName)
|
|
2432
|
-
|
|
2433
|
-
try {
|
|
2434
|
-
await fs.access(collectionPath)
|
|
2435
|
-
const stat = await fs.stat(collectionPath)
|
|
2436
|
-
if (!stat.isDirectory()) {
|
|
2437
|
-
return undefined
|
|
2438
|
-
}
|
|
2439
|
-
} catch {
|
|
2440
|
-
return undefined
|
|
2441
|
-
}
|
|
2442
|
-
|
|
2443
|
-
// Look for markdown files matching the slug
|
|
2444
|
-
const mdFile = await findMarkdownFile(collectionPath, slug)
|
|
2445
|
-
if (mdFile) {
|
|
2446
|
-
return {
|
|
2447
|
-
name: collectionName,
|
|
2448
|
-
slug,
|
|
2449
|
-
file: path.relative(getProjectRoot(), mdFile),
|
|
2450
|
-
}
|
|
2451
|
-
}
|
|
2452
|
-
|
|
2453
|
-
return undefined
|
|
2454
|
-
}
|
|
2455
|
-
|
|
2456
|
-
/**
|
|
2457
|
-
* Find a markdown file in a collection directory by slug
|
|
2458
|
-
*/
|
|
2459
|
-
async function findMarkdownFile(collectionPath: string, slug: string): Promise<string | undefined> {
|
|
2460
|
-
// Try direct match: slug.md or slug.mdx
|
|
2461
|
-
const directPaths = [
|
|
2462
|
-
path.join(collectionPath, `${slug}.md`),
|
|
2463
|
-
path.join(collectionPath, `${slug}.mdx`),
|
|
2464
|
-
]
|
|
2465
|
-
|
|
2466
|
-
for (const p of directPaths) {
|
|
2467
|
-
try {
|
|
2468
|
-
await fs.access(p)
|
|
2469
|
-
return p
|
|
2470
|
-
} catch {
|
|
2471
|
-
// File doesn't exist, continue
|
|
2472
|
-
}
|
|
2473
|
-
}
|
|
2474
|
-
|
|
2475
|
-
// Try nested path for slugs with slashes
|
|
2476
|
-
const slugParts = slug.split('/')
|
|
2477
|
-
if (slugParts.length > 1) {
|
|
2478
|
-
const nestedPath = path.join(collectionPath, ...slugParts.slice(0, -1))
|
|
2479
|
-
const fileName = slugParts[slugParts.length - 1]
|
|
2480
|
-
const nestedPaths = [
|
|
2481
|
-
path.join(nestedPath, `${fileName}.md`),
|
|
2482
|
-
path.join(nestedPath, `${fileName}.mdx`),
|
|
2483
|
-
]
|
|
2484
|
-
for (const p of nestedPaths) {
|
|
2485
|
-
try {
|
|
2486
|
-
await fs.access(p)
|
|
2487
|
-
return p
|
|
2488
|
-
} catch {
|
|
2489
|
-
// File doesn't exist, continue
|
|
2490
|
-
}
|
|
2491
|
-
}
|
|
2492
|
-
}
|
|
2493
|
-
|
|
2494
|
-
// Try index file in slug directory
|
|
2495
|
-
const indexPaths = [
|
|
2496
|
-
path.join(collectionPath, slug, 'index.md'),
|
|
2497
|
-
path.join(collectionPath, slug, 'index.mdx'),
|
|
2498
|
-
]
|
|
2499
|
-
|
|
2500
|
-
for (const p of indexPaths) {
|
|
2501
|
-
try {
|
|
2502
|
-
await fs.access(p)
|
|
2503
|
-
return p
|
|
2504
|
-
} catch {
|
|
2505
|
-
// File doesn't exist, continue
|
|
2506
|
-
}
|
|
2507
|
-
}
|
|
2508
|
-
|
|
2509
|
-
return undefined
|
|
2510
|
-
}
|
|
2511
|
-
|
|
2512
|
-
/**
|
|
2513
|
-
* Get cached markdown file content
|
|
2514
|
-
*/
|
|
2515
|
-
async function getCachedMarkdownFile(filePath: string): Promise<{ content: string; lines: string[] } | null> {
|
|
2516
|
-
const cached = markdownFileCache.get(filePath)
|
|
2517
|
-
if (cached) return cached
|
|
2518
|
-
|
|
2519
|
-
try {
|
|
2520
|
-
const content = await fs.readFile(filePath, 'utf-8')
|
|
2521
|
-
const lines = content.split('\n')
|
|
2522
|
-
const entry = { content, lines }
|
|
2523
|
-
markdownFileCache.set(filePath, entry)
|
|
2524
|
-
return entry
|
|
2525
|
-
} catch {
|
|
2526
|
-
return null
|
|
2527
|
-
}
|
|
2528
|
-
}
|
|
2529
|
-
|
|
2530
|
-
/**
|
|
2531
|
-
* Find text content in a markdown file and return source location
|
|
2532
|
-
* Only matches frontmatter fields, not body content (body is handled separately as a whole)
|
|
2533
|
-
* @param textContent - The text content to search for
|
|
2534
|
-
* @param collectionInfo - Collection information (name, slug, file path)
|
|
2535
|
-
* @returns Source location if found in frontmatter
|
|
2536
|
-
*/
|
|
2537
|
-
export async function findMarkdownSourceLocation(
|
|
2538
|
-
textContent: string,
|
|
2539
|
-
collectionInfo: CollectionInfo,
|
|
2540
|
-
): Promise<SourceLocation | undefined> {
|
|
2541
|
-
try {
|
|
2542
|
-
const filePath = path.join(getProjectRoot(), collectionInfo.file)
|
|
2543
|
-
const cached = await getCachedMarkdownFile(filePath)
|
|
2544
|
-
if (!cached) return undefined
|
|
2545
|
-
|
|
2546
|
-
const { lines } = cached
|
|
2547
|
-
const normalizedSearch = normalizeText(textContent)
|
|
2548
|
-
|
|
2549
|
-
// Parse frontmatter
|
|
2550
|
-
let frontmatterEnd = -1
|
|
2551
|
-
let inFrontmatter = false
|
|
2552
|
-
|
|
2553
|
-
for (let i = 0; i < lines.length; i++) {
|
|
2554
|
-
const line = lines[i]?.trim()
|
|
2555
|
-
if (line === '---') {
|
|
2556
|
-
if (!inFrontmatter) {
|
|
2557
|
-
inFrontmatter = true
|
|
2558
|
-
} else {
|
|
2559
|
-
frontmatterEnd = i
|
|
2560
|
-
break
|
|
2561
|
-
}
|
|
2562
|
-
}
|
|
2563
|
-
}
|
|
2564
|
-
|
|
2565
|
-
// Search in frontmatter only (for title, subtitle, etc.)
|
|
2566
|
-
if (frontmatterEnd > 0) {
|
|
2567
|
-
for (let i = 1; i < frontmatterEnd; i++) {
|
|
2568
|
-
const line = lines[i]
|
|
2569
|
-
if (!line) continue
|
|
2570
|
-
|
|
2571
|
-
// Extract value from YAML key: value
|
|
2572
|
-
const match = line.match(/^\s*(\w+):\s*(.+)$/)
|
|
2573
|
-
if (match) {
|
|
2574
|
-
const key = match[1]
|
|
2575
|
-
let value = match[2]?.trim() || ''
|
|
2576
|
-
|
|
2577
|
-
// Handle quoted strings
|
|
2578
|
-
if (
|
|
2579
|
-
(value.startsWith('"') && value.endsWith('"'))
|
|
2580
|
-
|| (value.startsWith("'") && value.endsWith("'"))
|
|
2581
|
-
) {
|
|
2582
|
-
value = value.slice(1, -1)
|
|
2583
|
-
}
|
|
2584
|
-
|
|
2585
|
-
if (normalizeText(value) === normalizedSearch) {
|
|
2586
|
-
return {
|
|
2587
|
-
file: collectionInfo.file,
|
|
2588
|
-
line: i + 1,
|
|
2589
|
-
snippet: line,
|
|
2590
|
-
type: 'collection',
|
|
2591
|
-
variableName: key,
|
|
2592
|
-
collectionName: collectionInfo.name,
|
|
2593
|
-
collectionSlug: collectionInfo.slug,
|
|
2594
|
-
}
|
|
2595
|
-
}
|
|
2596
|
-
}
|
|
2597
|
-
}
|
|
2598
|
-
}
|
|
2599
|
-
|
|
2600
|
-
// Body content is not searched line-by-line anymore
|
|
2601
|
-
// Use parseMarkdownContent to get the full body as one entry
|
|
2602
|
-
} catch {
|
|
2603
|
-
// Error reading file
|
|
2604
|
-
}
|
|
2605
|
-
|
|
2606
|
-
return undefined
|
|
2607
|
-
}
|
|
2608
|
-
|
|
2609
|
-
/**
|
|
2610
|
-
* Parse markdown file and extract frontmatter fields and full body content.
|
|
2611
|
-
* Uses caching for better performance.
|
|
2612
|
-
* @param collectionInfo - Collection information (name, slug, file path)
|
|
2613
|
-
* @returns Parsed markdown content with frontmatter and body
|
|
2614
|
-
*/
|
|
2615
|
-
export async function parseMarkdownContent(
|
|
2616
|
-
collectionInfo: CollectionInfo,
|
|
2617
|
-
): Promise<MarkdownContent | undefined> {
|
|
2618
|
-
try {
|
|
2619
|
-
const filePath = path.join(getProjectRoot(), collectionInfo.file)
|
|
2620
|
-
const cached = await getCachedMarkdownFile(filePath)
|
|
2621
|
-
if (!cached) return undefined
|
|
2622
|
-
|
|
2623
|
-
const { lines } = cached
|
|
2624
|
-
|
|
2625
|
-
// Parse frontmatter
|
|
2626
|
-
let frontmatterStart = -1
|
|
2627
|
-
let frontmatterEnd = -1
|
|
2628
|
-
|
|
2629
|
-
for (let i = 0; i < lines.length; i++) {
|
|
2630
|
-
const line = lines[i]?.trim()
|
|
2631
|
-
if (line === '---') {
|
|
2632
|
-
if (frontmatterStart === -1) {
|
|
2633
|
-
frontmatterStart = i
|
|
2634
|
-
} else {
|
|
2635
|
-
frontmatterEnd = i
|
|
2636
|
-
break
|
|
2637
|
-
}
|
|
2638
|
-
}
|
|
2639
|
-
}
|
|
2640
|
-
|
|
2641
|
-
const frontmatter: Record<string, { value: string; line: number }> = {}
|
|
2642
|
-
|
|
2643
|
-
// Extract frontmatter fields
|
|
2644
|
-
if (frontmatterEnd > 0) {
|
|
2645
|
-
for (let i = frontmatterStart + 1; i < frontmatterEnd; i++) {
|
|
2646
|
-
const line = lines[i]
|
|
2647
|
-
if (!line) continue
|
|
2648
|
-
|
|
2649
|
-
// Extract value from YAML key: value (simple single-line values only)
|
|
2650
|
-
const match = line.match(/^\s*(\w+):\s*(.+)$/)
|
|
2651
|
-
if (match) {
|
|
2652
|
-
const key = match[1]
|
|
2653
|
-
let value = match[2]?.trim() || ''
|
|
2654
|
-
|
|
2655
|
-
// Handle quoted strings
|
|
2656
|
-
if (
|
|
2657
|
-
(value.startsWith('"') && value.endsWith('"'))
|
|
2658
|
-
|| (value.startsWith("'") && value.endsWith("'"))
|
|
2659
|
-
) {
|
|
2660
|
-
value = value.slice(1, -1)
|
|
2661
|
-
}
|
|
2662
|
-
|
|
2663
|
-
if (key && value) {
|
|
2664
|
-
frontmatter[key] = { value, line: i + 1 }
|
|
2665
|
-
}
|
|
2666
|
-
}
|
|
2667
|
-
}
|
|
2668
|
-
}
|
|
2669
|
-
|
|
2670
|
-
// Extract body (everything after frontmatter)
|
|
2671
|
-
const bodyStartLine = frontmatterEnd > 0 ? frontmatterEnd + 1 : 0
|
|
2672
|
-
const bodyLines = lines.slice(bodyStartLine)
|
|
2673
|
-
const body = bodyLines.join('\n').trim()
|
|
2674
|
-
|
|
2675
|
-
return {
|
|
2676
|
-
frontmatter,
|
|
2677
|
-
body,
|
|
2678
|
-
bodyStartLine: bodyStartLine + 1, // 1-indexed
|
|
2679
|
-
file: collectionInfo.file,
|
|
2680
|
-
collectionName: collectionInfo.name,
|
|
2681
|
-
collectionSlug: collectionInfo.slug,
|
|
2682
|
-
}
|
|
2683
|
-
} catch {
|
|
2684
|
-
// Error reading file
|
|
2685
|
-
}
|
|
2686
|
-
|
|
2687
|
-
return undefined
|
|
2688
|
-
}
|
|
2689
|
-
|
|
2690
|
-
/**
|
|
2691
|
-
* Strip markdown syntax for text comparison
|
|
2692
|
-
*/
|
|
2693
|
-
function stripMarkdownSyntax(text: string): string {
|
|
2694
|
-
return text
|
|
2695
|
-
.replace(/^#+\s+/, '') // Headers
|
|
2696
|
-
.replace(/\*\*([^*]+)\*\*/g, '$1') // Bold
|
|
2697
|
-
.replace(/\*([^*]+)\*/g, '$1') // Italic
|
|
2698
|
-
.replace(/__([^_]+)__/g, '$1') // Bold (underscore)
|
|
2699
|
-
.replace(/_([^_]+)_/g, '$1') // Italic (underscore)
|
|
2700
|
-
.replace(/`([^`]+)`/g, '$1') // Inline code
|
|
2701
|
-
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // Links
|
|
2702
|
-
.replace(/^\s*[-*+]\s+/, '') // List items
|
|
2703
|
-
.replace(/^\s*\d+\.\s+/, '') // Numbered lists
|
|
2704
|
-
.trim()
|
|
2705
|
-
}
|
|
2706
|
-
|
|
2707
|
-
/**
|
|
2708
|
-
* Enhance manifest entries with actual source snippets from source files.
|
|
2709
|
-
* This reads the source files and extracts the innerHTML at the specified locations.
|
|
2710
|
-
* For images, it finds the correct line containing the src attribute.
|
|
2711
|
-
*
|
|
2712
|
-
* @param entries - Manifest entries to enhance
|
|
2713
|
-
* @returns Enhanced entries with sourceSnippet populated
|
|
2714
|
-
*/
|
|
2715
|
-
export async function enhanceManifestWithSourceSnippets(
|
|
2716
|
-
entries: Record<string, ManifestEntry>,
|
|
2717
|
-
): Promise<Record<string, ManifestEntry>> {
|
|
2718
|
-
const enhanced: Record<string, ManifestEntry> = {}
|
|
2719
|
-
|
|
2720
|
-
// Process entries in parallel for better performance
|
|
2721
|
-
const entryPromises = Object.entries(entries).map(async ([id, entry]) => {
|
|
2722
|
-
// Handle image entries specially - find the line with src attribute
|
|
2723
|
-
if (entry.sourceType === 'image' && entry.imageMetadata?.src) {
|
|
2724
|
-
const imageLocation = await findImageSourceLocation(entry.imageMetadata.src)
|
|
2725
|
-
if (imageLocation) {
|
|
2726
|
-
const sourceHash = generateSourceHash(imageLocation.snippet || entry.imageMetadata.src)
|
|
2727
|
-
return [id, {
|
|
2728
|
-
...entry,
|
|
2729
|
-
sourcePath: imageLocation.file,
|
|
2730
|
-
sourceLine: imageLocation.line,
|
|
2731
|
-
sourceSnippet: imageLocation.snippet,
|
|
2732
|
-
sourceHash,
|
|
2733
|
-
}] as const
|
|
2734
|
-
}
|
|
2735
|
-
return [id, entry] as const
|
|
2736
|
-
}
|
|
2737
|
-
|
|
2738
|
-
// Skip if already has sourceSnippet or missing source info
|
|
2739
|
-
if (entry.sourceSnippet || !entry.sourcePath || !entry.sourceLine || !entry.tag) {
|
|
2740
|
-
return [id, entry] as const
|
|
2741
|
-
}
|
|
2742
|
-
|
|
2743
|
-
// Extract the actual source innerHTML
|
|
2744
|
-
const sourceSnippet = await extractSourceInnerHtml(
|
|
2745
|
-
entry.sourcePath,
|
|
2746
|
-
entry.sourceLine,
|
|
2747
|
-
entry.tag,
|
|
2748
|
-
)
|
|
2749
|
-
|
|
2750
|
-
if (sourceSnippet) {
|
|
2751
|
-
// Generate hash of source snippet for conflict detection
|
|
2752
|
-
const sourceHash = generateSourceHash(sourceSnippet)
|
|
2753
|
-
return [id, { ...entry, sourceSnippet, sourceHash }] as const
|
|
2754
|
-
}
|
|
2755
|
-
|
|
2756
|
-
return [id, entry] as const
|
|
2757
|
-
})
|
|
2758
|
-
|
|
2759
|
-
const results = await Promise.all(entryPromises)
|
|
2760
|
-
for (const [id, entry] of results) {
|
|
2761
|
-
enhanced[id] = entry
|
|
2762
|
-
}
|
|
2763
|
-
|
|
2764
|
-
return enhanced
|
|
2765
|
-
}
|