@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.
Files changed (48) hide show
  1. package/dist/types/build-processor.d.ts.map +1 -1
  2. package/dist/types/dev-middleware.d.ts.map +1 -1
  3. package/dist/types/source-finder/ast-extractors.d.ts +35 -0
  4. package/dist/types/source-finder/ast-extractors.d.ts.map +1 -0
  5. package/dist/types/source-finder/ast-parser.d.ts +16 -0
  6. package/dist/types/source-finder/ast-parser.d.ts.map +1 -0
  7. package/dist/types/source-finder/cache.d.ts +18 -0
  8. package/dist/types/source-finder/cache.d.ts.map +1 -0
  9. package/dist/types/source-finder/collection-finder.d.ts +24 -0
  10. package/dist/types/source-finder/collection-finder.d.ts.map +1 -0
  11. package/dist/types/source-finder/cross-file-tracker.d.ts +29 -0
  12. package/dist/types/source-finder/cross-file-tracker.d.ts.map +1 -0
  13. package/dist/types/source-finder/element-finder.d.ts +42 -0
  14. package/dist/types/source-finder/element-finder.d.ts.map +1 -0
  15. package/dist/types/source-finder/image-finder.d.ts +16 -0
  16. package/dist/types/source-finder/image-finder.d.ts.map +1 -0
  17. package/dist/types/source-finder/index.d.ts +8 -0
  18. package/dist/types/source-finder/index.d.ts.map +1 -0
  19. package/dist/types/source-finder/search-index.d.ts +27 -0
  20. package/dist/types/source-finder/search-index.d.ts.map +1 -0
  21. package/dist/types/source-finder/snippet-utils.d.ts +49 -0
  22. package/dist/types/source-finder/snippet-utils.d.ts.map +1 -0
  23. package/dist/types/source-finder/source-lookup.d.ts +16 -0
  24. package/dist/types/source-finder/source-lookup.d.ts.map +1 -0
  25. package/dist/types/source-finder/types.d.ts +163 -0
  26. package/dist/types/source-finder/types.d.ts.map +1 -0
  27. package/dist/types/source-finder/variable-extraction.d.ts +37 -0
  28. package/dist/types/source-finder/variable-extraction.d.ts.map +1 -0
  29. package/dist/types/tsconfig.tsbuildinfo +1 -1
  30. package/package.json +1 -1
  31. package/src/build-processor.ts +33 -1
  32. package/src/dev-middleware.ts +33 -1
  33. package/src/source-finder/ast-extractors.ts +175 -0
  34. package/src/source-finder/ast-parser.ts +127 -0
  35. package/src/source-finder/cache.ts +75 -0
  36. package/src/source-finder/collection-finder.ts +321 -0
  37. package/src/source-finder/cross-file-tracker.ts +337 -0
  38. package/src/source-finder/element-finder.ts +383 -0
  39. package/src/source-finder/image-finder.ts +189 -0
  40. package/src/source-finder/index.ts +26 -0
  41. package/src/source-finder/search-index.ts +418 -0
  42. package/src/source-finder/snippet-utils.ts +268 -0
  43. package/src/source-finder/source-lookup.ts +197 -0
  44. package/src/source-finder/types.ts +206 -0
  45. package/src/source-finder/variable-extraction.ts +355 -0
  46. package/dist/types/source-finder.d.ts +0 -117
  47. package/dist/types/source-finder.d.ts.map +0 -1
  48. package/src/source-finder.ts +0 -2765
@@ -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(/&#39;/g, "'") // HTML entity for apostrophe
2386
- .replace(/&quot;/g, '"') // HTML entity for quote
2387
- .replace(/&apos;/g, "'") // HTML entity for apostrophe (alternative)
2388
- .replace(/&amp;/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
- }