@nuasite/cms-marker 0.0.65 → 0.0.66

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 (36) hide show
  1. package/dist/types/build-processor.d.ts +2 -1
  2. package/dist/types/build-processor.d.ts.map +1 -1
  3. package/dist/types/component-registry.d.ts.map +1 -1
  4. package/dist/types/config.d.ts +19 -0
  5. package/dist/types/config.d.ts.map +1 -0
  6. package/dist/types/dev-middleware.d.ts +10 -2
  7. package/dist/types/dev-middleware.d.ts.map +1 -1
  8. package/dist/types/error-collector.d.ts +56 -0
  9. package/dist/types/error-collector.d.ts.map +1 -0
  10. package/dist/types/html-processor.d.ts.map +1 -1
  11. package/dist/types/index.d.ts +2 -1
  12. package/dist/types/index.d.ts.map +1 -1
  13. package/dist/types/manifest-writer.d.ts.map +1 -1
  14. package/dist/types/source-finder.d.ts +18 -3
  15. package/dist/types/source-finder.d.ts.map +1 -1
  16. package/dist/types/tailwind-colors.d.ts.map +1 -1
  17. package/dist/types/tsconfig.tsbuildinfo +1 -1
  18. package/dist/types/types.d.ts +0 -4
  19. package/dist/types/types.d.ts.map +1 -1
  20. package/dist/types/vite-plugin.d.ts.map +1 -1
  21. package/package.json +2 -1
  22. package/src/build-processor.ts +73 -19
  23. package/src/component-registry.ts +2 -0
  24. package/src/config.ts +29 -0
  25. package/src/dev-middleware.ts +12 -4
  26. package/src/error-collector.ts +106 -0
  27. package/src/html-processor.ts +55 -37
  28. package/src/index.ts +20 -4
  29. package/src/manifest-writer.ts +12 -2
  30. package/src/source-finder.ts +1003 -295
  31. package/src/tailwind-colors.ts +248 -48
  32. package/src/types.ts +0 -4
  33. package/src/vite-plugin.ts +4 -12
  34. package/dist/types/astro-transform.d.ts +0 -21
  35. package/dist/types/astro-transform.d.ts.map +0 -1
  36. package/src/astro-transform.ts +0 -205
@@ -1,8 +1,444 @@
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 type * as t from '@babel/types'
1
5
  import fs from 'node:fs/promises'
2
6
  import path from 'node:path'
7
+ import { getProjectRoot } from './config'
8
+ import { getErrorCollector } from './error-collector'
3
9
  import type { ManifestEntry } from './types'
4
10
  import { generateSourceHash } from './utils'
5
11
 
12
+ // ============================================================================
13
+ // File Parsing Cache - Avoid re-parsing the same files
14
+ // ============================================================================
15
+
16
+ interface CachedParsedFile {
17
+ content: string
18
+ lines: string[]
19
+ ast: AstroNode
20
+ frontmatterContent: string | null
21
+ frontmatterStartLine: number
22
+ variableDefinitions: VariableDefinition[]
23
+ }
24
+
25
+ /** Cache for parsed Astro files - cleared between builds */
26
+ const parsedFileCache = new Map<string, CachedParsedFile>()
27
+
28
+ /** Cache for directory listings - cleared between builds */
29
+ const directoryCache = new Map<string, string[]>()
30
+
31
+ /** Cache for markdown file contents - cleared between builds */
32
+ const markdownFileCache = new Map<string, { content: string; lines: string[] }>()
33
+
34
+ /** Pre-built search index for fast lookups */
35
+ interface SearchIndexEntry {
36
+ file: string
37
+ line: number
38
+ snippet: string
39
+ type: 'static' | 'variable' | 'prop' | 'computed'
40
+ variableName?: string
41
+ definitionLine?: number
42
+ normalizedText: string
43
+ tag: string
44
+ }
45
+
46
+ interface ImageIndexEntry {
47
+ file: string
48
+ line: number
49
+ snippet: string
50
+ src: string
51
+ }
52
+
53
+ /** Search indexes built once per build */
54
+ let textSearchIndex: SearchIndexEntry[] = []
55
+ let imageSearchIndex: ImageIndexEntry[] = []
56
+ let searchIndexInitialized = false
57
+
58
+ /**
59
+ * Clear all caches - call at start of each build
60
+ */
61
+ export function clearSourceFinderCache(): void {
62
+ parsedFileCache.clear()
63
+ directoryCache.clear()
64
+ markdownFileCache.clear()
65
+ textSearchIndex = []
66
+ imageSearchIndex = []
67
+ searchIndexInitialized = false
68
+ }
69
+
70
+ /**
71
+ * Initialize search index by pre-scanning all source files.
72
+ * This is much faster than searching per-entry.
73
+ */
74
+ export async function initializeSearchIndex(): Promise<void> {
75
+ if (searchIndexInitialized) return
76
+
77
+ const srcDir = path.join(getProjectRoot(), 'src')
78
+ const searchDirs = [
79
+ path.join(srcDir, 'components'),
80
+ path.join(srcDir, 'pages'),
81
+ path.join(srcDir, 'layouts'),
82
+ ]
83
+
84
+ // Collect all Astro files first
85
+ const allFiles: string[] = []
86
+ for (const dir of searchDirs) {
87
+ try {
88
+ const files = await collectAstroFiles(dir)
89
+ allFiles.push(...files)
90
+ } catch {
91
+ // Directory doesn't exist
92
+ }
93
+ }
94
+
95
+ // Parse all files in parallel and build indexes
96
+ await Promise.all(allFiles.map(async (filePath) => {
97
+ try {
98
+ const cached = await getCachedParsedFile(filePath)
99
+ if (!cached) return
100
+
101
+ const relFile = path.relative(getProjectRoot(), filePath)
102
+
103
+ // Index all text content from this file
104
+ indexFileContent(cached, relFile)
105
+
106
+ // Index all images from this file
107
+ indexFileImages(cached, relFile)
108
+ } catch {
109
+ // Skip files that fail to parse
110
+ }
111
+ }))
112
+
113
+ searchIndexInitialized = true
114
+ }
115
+
116
+ /**
117
+ * Collect all .astro files in a directory recursively
118
+ */
119
+ async function collectAstroFiles(dir: string): Promise<string[]> {
120
+ const cached = directoryCache.get(dir)
121
+ if (cached) return cached
122
+
123
+ const results: string[] = []
124
+
125
+ try {
126
+ const entries = await fs.readdir(dir, { withFileTypes: true })
127
+
128
+ await Promise.all(entries.map(async (entry) => {
129
+ const fullPath = path.join(dir, entry.name)
130
+ if (entry.isDirectory()) {
131
+ const subFiles = await collectAstroFiles(fullPath)
132
+ results.push(...subFiles)
133
+ } else if (entry.isFile() && (entry.name.endsWith('.astro') || entry.name.endsWith('.tsx') || entry.name.endsWith('.jsx'))) {
134
+ results.push(fullPath)
135
+ }
136
+ }))
137
+ } catch {
138
+ // Directory doesn't exist
139
+ }
140
+
141
+ directoryCache.set(dir, results)
142
+ return results
143
+ }
144
+
145
+ /**
146
+ * Get a cached parsed file, parsing it if not cached
147
+ */
148
+ async function getCachedParsedFile(filePath: string): Promise<CachedParsedFile | null> {
149
+ const cached = parsedFileCache.get(filePath)
150
+ if (cached) return cached
151
+
152
+ try {
153
+ const content = await fs.readFile(filePath, 'utf-8')
154
+ const lines = content.split('\n')
155
+
156
+ // Only parse .astro files with AST
157
+ if (!filePath.endsWith('.astro')) {
158
+ // For tsx/jsx, just cache content/lines for regex search
159
+ const entry: CachedParsedFile = {
160
+ content,
161
+ lines,
162
+ ast: { type: 'root', children: [] } as unknown as AstroNode,
163
+ frontmatterContent: null,
164
+ frontmatterStartLine: 0,
165
+ variableDefinitions: [],
166
+ }
167
+ parsedFileCache.set(filePath, entry)
168
+ return entry
169
+ }
170
+
171
+ const { ast, frontmatterContent, frontmatterStartLine } = await parseAstroFile(content)
172
+
173
+ let variableDefinitions: VariableDefinition[] = []
174
+ if (frontmatterContent) {
175
+ const frontmatterAst = parseFrontmatter(frontmatterContent, filePath)
176
+ if (frontmatterAst) {
177
+ variableDefinitions = extractVariableDefinitions(frontmatterAst, frontmatterStartLine)
178
+ }
179
+ }
180
+
181
+ const entry: CachedParsedFile = {
182
+ content,
183
+ lines,
184
+ ast,
185
+ frontmatterContent,
186
+ frontmatterStartLine,
187
+ variableDefinitions,
188
+ }
189
+
190
+ parsedFileCache.set(filePath, entry)
191
+ return entry
192
+ } catch {
193
+ return null
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Index all searchable text content from a parsed file
199
+ */
200
+ function indexFileContent(cached: CachedParsedFile, relFile: string): void {
201
+ // Walk AST and collect all text elements
202
+ function visit(node: AstroNode) {
203
+ if ((node.type === 'element' || node.type === 'component')) {
204
+ const elemNode = node as ElementNode | ComponentNode
205
+ const tag = elemNode.name.toLowerCase()
206
+ const textContent = getTextContent(elemNode)
207
+ const normalizedText = normalizeText(textContent)
208
+ const line = elemNode.position?.start.line ?? 0
209
+
210
+ if (normalizedText && normalizedText.length >= 2) {
211
+ // Check for variable references
212
+ const exprInfo = hasExpressionChild(elemNode)
213
+ if (exprInfo.found && exprInfo.varNames.length > 0) {
214
+ for (const varName of exprInfo.varNames) {
215
+ for (const def of cached.variableDefinitions) {
216
+ if (def.name === varName || (def.parentName && def.name === varName)) {
217
+ const normalizedDef = normalizeText(def.value)
218
+ const completeSnippet = extractCompleteTagSnippet(cached.lines, line - 1, tag)
219
+ const snippet = extractInnerHtmlFromSnippet(completeSnippet, tag) ?? completeSnippet
220
+
221
+ textSearchIndex.push({
222
+ file: relFile,
223
+ line: def.line,
224
+ snippet: cached.lines[def.line - 1] || '',
225
+ type: 'variable',
226
+ variableName: def.parentName ? `${def.parentName}.${def.name}` : def.name,
227
+ definitionLine: def.line,
228
+ normalizedText: normalizedDef,
229
+ tag,
230
+ })
231
+ }
232
+ }
233
+ }
234
+ }
235
+
236
+ // Index static text content
237
+ const completeSnippet = extractCompleteTagSnippet(cached.lines, line - 1, tag)
238
+ const snippet = extractInnerHtmlFromSnippet(completeSnippet, tag) ?? completeSnippet
239
+
240
+ textSearchIndex.push({
241
+ file: relFile,
242
+ line,
243
+ snippet,
244
+ type: 'static',
245
+ normalizedText,
246
+ tag,
247
+ })
248
+ }
249
+
250
+ // Also index component props
251
+ if (node.type === 'component') {
252
+ for (const attr of elemNode.attributes) {
253
+ if (attr.type === 'attribute' && attr.kind === 'quoted' && attr.value) {
254
+ const normalizedValue = normalizeText(attr.value)
255
+ if (normalizedValue && normalizedValue.length >= 2) {
256
+ textSearchIndex.push({
257
+ file: relFile,
258
+ line: attr.position?.start.line ?? line,
259
+ snippet: cached.lines[(attr.position?.start.line ?? line) - 1] || '',
260
+ type: 'prop',
261
+ variableName: attr.name,
262
+ normalizedText: normalizedValue,
263
+ tag,
264
+ })
265
+ }
266
+ }
267
+ }
268
+ }
269
+ }
270
+
271
+ if ('children' in node && Array.isArray(node.children)) {
272
+ for (const child of node.children) {
273
+ visit(child)
274
+ }
275
+ }
276
+ }
277
+
278
+ visit(cached.ast)
279
+ }
280
+
281
+ /**
282
+ * Index all images from a parsed file
283
+ */
284
+ function indexFileImages(cached: CachedParsedFile, relFile: string): void {
285
+ // For Astro files, use AST
286
+ if (relFile.endsWith('.astro')) {
287
+ function visit(node: AstroNode) {
288
+ if (node.type === 'element') {
289
+ const elemNode = node as ElementNode
290
+ if (elemNode.name.toLowerCase() === 'img') {
291
+ for (const attr of elemNode.attributes) {
292
+ if (attr.type === 'attribute' && attr.name === 'src' && attr.value) {
293
+ const srcLine = attr.position?.start.line ?? elemNode.position?.start.line ?? 0
294
+ const snippet = extractImageSnippet(cached.lines, srcLine - 1)
295
+ imageSearchIndex.push({
296
+ file: relFile,
297
+ line: srcLine,
298
+ snippet,
299
+ src: attr.value,
300
+ })
301
+ }
302
+ }
303
+ }
304
+ }
305
+
306
+ if ('children' in node && Array.isArray(node.children)) {
307
+ for (const child of node.children) {
308
+ visit(child)
309
+ }
310
+ }
311
+ }
312
+ visit(cached.ast)
313
+ } else {
314
+ // For tsx/jsx, use regex
315
+ const srcPatterns = [/src="([^"]+)"/g, /src='([^']+)'/g]
316
+ for (let i = 0; i < cached.lines.length; i++) {
317
+ const line = cached.lines[i]
318
+ if (!line) continue
319
+
320
+ for (const pattern of srcPatterns) {
321
+ pattern.lastIndex = 0
322
+ let match: RegExpExecArray | null
323
+ while ((match = pattern.exec(line)) !== null) {
324
+ const snippet = extractImageSnippet(cached.lines, i)
325
+ imageSearchIndex.push({
326
+ file: relFile,
327
+ line: i + 1,
328
+ snippet,
329
+ src: match[1]!,
330
+ })
331
+ }
332
+ }
333
+ }
334
+ }
335
+ }
336
+
337
+ /**
338
+ * Fast text lookup using pre-built index
339
+ */
340
+ function findInTextIndex(textContent: string, tag: string): SourceLocation | undefined {
341
+ const normalizedSearch = normalizeText(textContent)
342
+ const tagLower = tag.toLowerCase()
343
+
344
+ // First try exact match with same tag
345
+ for (const entry of textSearchIndex) {
346
+ if (entry.tag === tagLower && entry.normalizedText === normalizedSearch) {
347
+ return {
348
+ file: entry.file,
349
+ line: entry.line,
350
+ snippet: entry.snippet,
351
+ type: entry.type,
352
+ variableName: entry.variableName,
353
+ definitionLine: entry.definitionLine,
354
+ }
355
+ }
356
+ }
357
+
358
+ // Then try partial match for longer text
359
+ if (normalizedSearch.length > 10) {
360
+ const textPreview = normalizedSearch.slice(0, Math.min(30, normalizedSearch.length))
361
+ for (const entry of textSearchIndex) {
362
+ if (entry.tag === tagLower && entry.normalizedText.includes(textPreview)) {
363
+ return {
364
+ file: entry.file,
365
+ line: entry.line,
366
+ snippet: entry.snippet,
367
+ type: entry.type,
368
+ variableName: entry.variableName,
369
+ definitionLine: entry.definitionLine,
370
+ }
371
+ }
372
+ }
373
+ }
374
+
375
+ // Try any tag match
376
+ for (const entry of textSearchIndex) {
377
+ if (entry.normalizedText === normalizedSearch) {
378
+ return {
379
+ file: entry.file,
380
+ line: entry.line,
381
+ snippet: entry.snippet,
382
+ type: entry.type,
383
+ variableName: entry.variableName,
384
+ definitionLine: entry.definitionLine,
385
+ }
386
+ }
387
+ }
388
+
389
+ return undefined
390
+ }
391
+
392
+ /**
393
+ * Fast image lookup using pre-built index
394
+ */
395
+ function findInImageIndex(imageSrc: string): SourceLocation | undefined {
396
+ for (const entry of imageSearchIndex) {
397
+ if (entry.src === imageSrc) {
398
+ return {
399
+ file: entry.file,
400
+ line: entry.line,
401
+ snippet: entry.snippet,
402
+ type: 'static',
403
+ }
404
+ }
405
+ }
406
+ return undefined
407
+ }
408
+
409
+ // Helper for indexing - get text content from node
410
+ function getTextContent(node: AstroNode): string {
411
+ if (node.type === 'text') {
412
+ return (node as TextNode).value
413
+ }
414
+ if ('children' in node && Array.isArray(node.children)) {
415
+ return node.children.map(getTextContent).join('')
416
+ }
417
+ return ''
418
+ }
419
+
420
+ // Helper for indexing - check for expression children
421
+ function hasExpressionChild(node: AstroNode): { found: boolean; varNames: string[] } {
422
+ const varNames: string[] = []
423
+ if (node.type === 'expression') {
424
+ const exprText = getTextContent(node)
425
+ const match = exprText.match(/^\s*(\w+)(?:\.(\w+))?\s*$/)
426
+ if (match) {
427
+ varNames.push(match[2] ?? match[1]!)
428
+ }
429
+ return { found: true, varNames }
430
+ }
431
+ if ('children' in node && Array.isArray(node.children)) {
432
+ for (const child of node.children) {
433
+ const result = hasExpressionChild(child)
434
+ if (result.found) {
435
+ varNames.push(...result.varNames)
436
+ }
437
+ }
438
+ }
439
+ return { found: varNames.length > 0, varNames }
440
+ }
441
+
6
442
  export interface SourceLocation {
7
443
  file: string
8
444
  line: number
@@ -43,14 +479,429 @@ export interface MarkdownContent {
43
479
  collectionSlug: string
44
480
  }
45
481
 
482
+ // ============================================================================
483
+ // AST Parsing Utilities
484
+ // ============================================================================
485
+
486
+ interface ParsedAstroFile {
487
+ ast: AstroNode
488
+ frontmatterContent: string | null
489
+ frontmatterStartLine: number
490
+ }
491
+
492
+ /**
493
+ * Parse an Astro file and return both template AST and frontmatter content
494
+ */
495
+ async function parseAstroFile(content: string): Promise<ParsedAstroFile> {
496
+ const result = await parseAstro(content, { position: true })
497
+
498
+ // Find frontmatter node
499
+ let frontmatterContent: string | null = null
500
+ let frontmatterStartLine = 0
501
+
502
+ for (const child of result.ast.children) {
503
+ if (child.type === 'frontmatter') {
504
+ frontmatterContent = child.value
505
+ frontmatterStartLine = child.position?.start.line ?? 1
506
+ break
507
+ }
508
+ }
509
+
510
+ return {
511
+ ast: result.ast,
512
+ frontmatterContent,
513
+ frontmatterStartLine,
514
+ }
515
+ }
516
+
517
+ /**
518
+ * Parse frontmatter JavaScript/TypeScript with Babel
519
+ * @param content - The frontmatter content to parse
520
+ * @param filePath - Optional file path for error reporting
521
+ */
522
+ function parseFrontmatter(content: string, filePath?: string): t.File | null {
523
+ try {
524
+ return parseBabel(content, {
525
+ sourceType: 'module',
526
+ plugins: ['typescript'],
527
+ errorRecovery: true,
528
+ })
529
+ } catch (error) {
530
+ // Record parse errors for aggregated reporting
531
+ if (filePath) {
532
+ getErrorCollector().addWarning(
533
+ `Frontmatter parse: ${filePath}`,
534
+ error instanceof Error ? error.message : String(error),
535
+ )
536
+ }
537
+ return null
538
+ }
539
+ }
540
+
541
+ interface VariableDefinition {
542
+ name: string
543
+ value: string
544
+ line: number
545
+ /** For object properties, the parent variable name */
546
+ parentName?: string
547
+ }
548
+
46
549
  /**
47
- * Find source file and line number for text content
550
+ * Extract variable definitions from Babel AST
551
+ * Finds const/let/var declarations with string literal values
552
+ *
553
+ * Note: Babel parses the frontmatter content (without --- delimiters) starting at line 1.
554
+ * frontmatterStartLine is the actual file line where the content begins (after first ---).
555
+ * So we convert: file_line = (babel_line - 1) + frontmatterStartLine
556
+ */
557
+ function extractVariableDefinitions(ast: t.File, frontmatterStartLine: number): VariableDefinition[] {
558
+ const definitions: VariableDefinition[] = []
559
+
560
+ function getStringValue(node: t.Node): string | null {
561
+ if (node.type === 'StringLiteral') {
562
+ return node.value
563
+ }
564
+ if (node.type === 'TemplateLiteral' && node.quasis.length === 1 && node.expressions.length === 0) {
565
+ return node.quasis[0]?.value.cooked ?? null
566
+ }
567
+ return null
568
+ }
569
+
570
+ function babelLineToFileLine(babelLine: number): number {
571
+ // Babel's line 1 = frontmatterStartLine in the actual file
572
+ return (babelLine - 1) + frontmatterStartLine
573
+ }
574
+
575
+ function visitNode(node: t.Node) {
576
+ if (node.type === 'VariableDeclaration') {
577
+ for (const decl of node.declarations) {
578
+ if (decl.id.type === 'Identifier' && decl.init) {
579
+ const varName = decl.id.name
580
+ const line = babelLineToFileLine(decl.loc?.start.line ?? 1)
581
+
582
+ // Simple string value
583
+ const stringValue = getStringValue(decl.init)
584
+ if (stringValue !== null) {
585
+ definitions.push({ name: varName, value: stringValue, line })
586
+ }
587
+
588
+ // Object expression - extract properties
589
+ if (decl.init.type === 'ObjectExpression') {
590
+ for (const prop of decl.init.properties) {
591
+ if (prop.type === 'ObjectProperty' && prop.key.type === 'Identifier' && prop.value) {
592
+ const propValue = getStringValue(prop.value)
593
+ if (propValue !== null) {
594
+ const propLine = babelLineToFileLine(prop.loc?.start.line ?? 1)
595
+ definitions.push({
596
+ name: prop.key.name,
597
+ value: propValue,
598
+ line: propLine,
599
+ parentName: varName,
600
+ })
601
+ }
602
+ }
603
+ }
604
+ }
605
+ }
606
+ }
607
+ }
608
+
609
+ // Recursively visit child nodes
610
+ for (const key of Object.keys(node)) {
611
+ const value = (node as unknown as Record<string, unknown>)[key]
612
+ if (value && typeof value === 'object') {
613
+ if (Array.isArray(value)) {
614
+ for (const item of value) {
615
+ if (item && typeof item === 'object' && 'type' in item) {
616
+ visitNode(item as t.Node)
617
+ }
618
+ }
619
+ } else if ('type' in value) {
620
+ visitNode(value as t.Node)
621
+ }
622
+ }
623
+ }
624
+ }
625
+
626
+ visitNode(ast.program)
627
+ return definitions
628
+ }
629
+
630
+ interface TemplateMatch {
631
+ line: number
632
+ type: 'static' | 'variable' | 'computed'
633
+ variableName?: string
634
+ /** For variables, the definition line in frontmatter */
635
+ definitionLine?: number
636
+ }
637
+
638
+ /**
639
+ * Walk the Astro AST to find elements matching a tag with specific text content
640
+ */
641
+ function findElementWithText(
642
+ ast: AstroNode,
643
+ tag: string,
644
+ searchText: string,
645
+ variableDefinitions: VariableDefinition[],
646
+ ): TemplateMatch | null {
647
+ const normalizedSearch = normalizeText(searchText)
648
+ const tagLower = tag.toLowerCase()
649
+ let bestMatch: TemplateMatch | null = null
650
+ let bestScore = 0
651
+
652
+ function getTextContent(node: AstroNode): string {
653
+ if (node.type === 'text') {
654
+ return (node as TextNode).value
655
+ }
656
+ if ('children' in node && Array.isArray(node.children)) {
657
+ return node.children.map(getTextContent).join('')
658
+ }
659
+ return ''
660
+ }
661
+
662
+ function hasExpressionChild(node: AstroNode): { found: boolean; varNames: string[] } {
663
+ const varNames: string[] = []
664
+ if (node.type === 'expression') {
665
+ // Try to extract variable name from expression
666
+ // The expression node children contain the text representation
667
+ const exprText = getTextContent(node)
668
+ // Extract variable names like {foo} or {foo.bar}
669
+ const match = exprText.match(/^\s*(\w+)(?:\.(\w+))?\s*$/)
670
+ if (match) {
671
+ varNames.push(match[2] ?? match[1]!)
672
+ }
673
+ return { found: true, varNames }
674
+ }
675
+ if ('children' in node && Array.isArray(node.children)) {
676
+ for (const child of node.children) {
677
+ const result = hasExpressionChild(child)
678
+ if (result.found) {
679
+ varNames.push(...result.varNames)
680
+ }
681
+ }
682
+ }
683
+ return { found: varNames.length > 0, varNames }
684
+ }
685
+
686
+ function visit(node: AstroNode) {
687
+ // Check if this is an element or component matching our tag
688
+ if ((node.type === 'element' || node.type === 'component') && node.name.toLowerCase() === tagLower) {
689
+ const elemNode = node as ElementNode | ComponentNode
690
+ const textContent = getTextContent(elemNode)
691
+ const normalizedContent = normalizeText(textContent)
692
+ const line = elemNode.position?.start.line ?? 0
693
+
694
+ // Check for expression (variable reference)
695
+ const exprInfo = hasExpressionChild(elemNode)
696
+ if (exprInfo.found && exprInfo.varNames.length > 0) {
697
+ // Look for matching variable definition
698
+ for (const varName of exprInfo.varNames) {
699
+ for (const def of variableDefinitions) {
700
+ if (def.name === varName || (def.parentName && def.name === varName)) {
701
+ const normalizedDef = normalizeText(def.value)
702
+ if (normalizedDef === normalizedSearch) {
703
+ // Found a variable match - this is highest priority
704
+ if (bestScore < 100) {
705
+ bestScore = 100
706
+ bestMatch = {
707
+ line,
708
+ type: 'variable',
709
+ variableName: def.parentName ? `${def.parentName}.${def.name}` : def.name,
710
+ definitionLine: def.line,
711
+ }
712
+ }
713
+ return
714
+ }
715
+ }
716
+ }
717
+ }
718
+ }
719
+
720
+ // Check for direct text match (static content)
721
+ // Only match if there's meaningful text content (not just variable names/expressions)
722
+ if (normalizedContent && normalizedContent.length >= 2 && normalizedSearch.length > 0) {
723
+ // For short search text (<= 10 chars), require exact match
724
+ if (normalizedSearch.length <= 10) {
725
+ if (normalizedContent.includes(normalizedSearch)) {
726
+ const score = 80
727
+ if (score > bestScore) {
728
+ bestScore = score
729
+ const actualLine = findTextLine(elemNode, normalizedSearch)
730
+ bestMatch = {
731
+ line: actualLine ?? line,
732
+ type: 'static',
733
+ }
734
+ }
735
+ }
736
+ } // For longer search text, check if content contains a significant portion
737
+ else if (normalizedSearch.length > 10) {
738
+ const textPreview = normalizedSearch.slice(0, Math.min(30, normalizedSearch.length))
739
+ if (normalizedContent.includes(textPreview)) {
740
+ const matchLength = Math.min(normalizedSearch.length, normalizedContent.length)
741
+ const score = 50 + (matchLength / normalizedSearch.length) * 40
742
+ if (score > bestScore) {
743
+ bestScore = score
744
+ const actualLine = findTextLine(elemNode, textPreview)
745
+ bestMatch = {
746
+ line: actualLine ?? line,
747
+ type: 'static',
748
+ }
749
+ }
750
+ } // Try matching first few words for very long text
751
+ else if (normalizedSearch.length > 20) {
752
+ const firstWords = normalizedSearch.split(' ').slice(0, 3).join(' ')
753
+ if (firstWords && normalizedContent.includes(firstWords)) {
754
+ const score = 40
755
+ if (score > bestScore) {
756
+ bestScore = score
757
+ const actualLine = findTextLine(elemNode, firstWords)
758
+ bestMatch = {
759
+ line: actualLine ?? line,
760
+ type: 'static',
761
+ }
762
+ }
763
+ }
764
+ }
765
+ }
766
+ }
767
+ }
768
+
769
+ // Recursively visit children
770
+ if ('children' in node && Array.isArray(node.children)) {
771
+ for (const child of node.children) {
772
+ visit(child)
773
+ }
774
+ }
775
+ }
776
+
777
+ function findTextLine(node: AstroNode, searchText: string): number | null {
778
+ if (node.type === 'text') {
779
+ const textNode = node as TextNode
780
+ if (normalizeText(textNode.value).includes(searchText)) {
781
+ return textNode.position?.start.line ?? null
782
+ }
783
+ }
784
+ if ('children' in node && Array.isArray(node.children)) {
785
+ for (const child of node.children) {
786
+ const line = findTextLine(child, searchText)
787
+ if (line !== null) return line
788
+ }
789
+ }
790
+ return null
791
+ }
792
+
793
+ visit(ast)
794
+ return bestMatch
795
+ }
796
+
797
+ interface ComponentPropMatch {
798
+ line: number
799
+ propName: string
800
+ propValue: string
801
+ }
802
+
803
+ /**
804
+ * Walk the Astro AST to find component props with specific text value
805
+ */
806
+ function findComponentProp(
807
+ ast: AstroNode,
808
+ searchText: string,
809
+ ): ComponentPropMatch | null {
810
+ const normalizedSearch = normalizeText(searchText)
811
+
812
+ function visit(node: AstroNode): ComponentPropMatch | null {
813
+ // Check component nodes (PascalCase names)
814
+ if (node.type === 'component') {
815
+ const compNode = node as ComponentNode
816
+ for (const attr of compNode.attributes) {
817
+ if (attr.type === 'attribute' && attr.kind === 'quoted') {
818
+ const normalizedValue = normalizeText(attr.value)
819
+ if (normalizedValue === normalizedSearch) {
820
+ return {
821
+ line: attr.position?.start.line ?? compNode.position?.start.line ?? 0,
822
+ propName: attr.name,
823
+ propValue: attr.value,
824
+ }
825
+ }
826
+ }
827
+ }
828
+ }
829
+
830
+ // Recursively visit children
831
+ if ('children' in node && Array.isArray(node.children)) {
832
+ for (const child of node.children) {
833
+ const result = visit(child)
834
+ if (result) return result
835
+ }
836
+ }
837
+
838
+ return null
839
+ }
840
+
841
+ return visit(ast)
842
+ }
843
+
844
+ interface ImageMatch {
845
+ line: number
846
+ src: string
847
+ snippet: string
848
+ }
849
+
850
+ /**
851
+ * Walk the Astro AST to find img elements with specific src
852
+ */
853
+ function findImageElement(
854
+ ast: AstroNode,
855
+ imageSrc: string,
856
+ lines: string[],
857
+ ): ImageMatch | null {
858
+ function visit(node: AstroNode): ImageMatch | null {
859
+ if (node.type === 'element') {
860
+ const elemNode = node as ElementNode
861
+ if (elemNode.name.toLowerCase() === 'img') {
862
+ for (const attr of elemNode.attributes) {
863
+ if (attr.type === 'attribute' && attr.name === 'src' && attr.value === imageSrc) {
864
+ const srcLine = attr.position?.start.line ?? elemNode.position?.start.line ?? 0
865
+ const snippet = extractImageSnippet(lines, srcLine - 1)
866
+ return {
867
+ line: srcLine,
868
+ src: imageSrc,
869
+ snippet,
870
+ }
871
+ }
872
+ }
873
+ }
874
+ }
875
+
876
+ // Recursively visit children
877
+ if ('children' in node && Array.isArray(node.children)) {
878
+ for (const child of node.children) {
879
+ const result = visit(child)
880
+ if (result) return result
881
+ }
882
+ }
883
+
884
+ return null
885
+ }
886
+
887
+ return visit(ast)
888
+ }
889
+
890
+ /**
891
+ * Find source file and line number for text content.
892
+ * Uses pre-built search index for fast lookups.
48
893
  */
49
894
  export async function findSourceLocation(
50
895
  textContent: string,
51
896
  tag: string,
52
897
  ): Promise<SourceLocation | undefined> {
53
- const srcDir = path.join(process.cwd(), 'src')
898
+ // Use index if available (much faster)
899
+ if (searchIndexInitialized) {
900
+ return findInTextIndex(textContent, tag)
901
+ }
902
+
903
+ // Fallback to slow search if index not initialized
904
+ const srcDir = path.join(getProjectRoot(), 'src')
54
905
 
55
906
  try {
56
907
  const searchDirs = [
@@ -89,12 +940,19 @@ export async function findSourceLocation(
89
940
  }
90
941
 
91
942
  /**
92
- * Find source file and line number for an image by its src attribute
943
+ * Find source file and line number for an image by its src attribute.
944
+ * Uses pre-built search index for fast lookups.
93
945
  */
94
946
  export async function findImageSourceLocation(
95
947
  imageSrc: string,
96
948
  ): Promise<SourceLocation | undefined> {
97
- const srcDir = path.join(process.cwd(), 'src')
949
+ // Use index if available (much faster)
950
+ if (searchIndexInitialized) {
951
+ return findInImageIndex(imageSrc)
952
+ }
953
+
954
+ // Fallback to slow search if index not initialized
955
+ const srcDir = path.join(getProjectRoot(), 'src')
98
956
 
99
957
  try {
100
958
  const searchDirs = [
@@ -149,17 +1007,35 @@ async function searchDirectoryForImage(
149
1007
  }
150
1008
 
151
1009
  /**
152
- * Search a single file for an image with matching src
1010
+ * Search a single file for an image with matching src.
1011
+ * Uses caching for better performance.
153
1012
  */
154
1013
  async function searchFileForImage(
155
1014
  filePath: string,
156
1015
  imageSrc: string,
157
1016
  ): Promise<SourceLocation | undefined> {
158
1017
  try {
159
- const content = await fs.readFile(filePath, 'utf-8')
160
- const lines = content.split('\n')
1018
+ // Use cached parsed file
1019
+ const cached = await getCachedParsedFile(filePath)
1020
+ if (!cached) return undefined
1021
+
1022
+ const { lines, ast } = cached
1023
+
1024
+ // Use AST parsing for Astro files
1025
+ if (filePath.endsWith('.astro')) {
1026
+ const imageMatch = findImageElement(ast, imageSrc, lines)
1027
+
1028
+ if (imageMatch) {
1029
+ return {
1030
+ file: path.relative(getProjectRoot(), filePath),
1031
+ line: imageMatch.line,
1032
+ snippet: imageMatch.snippet,
1033
+ type: 'static',
1034
+ }
1035
+ }
1036
+ }
161
1037
 
162
- // Search for src="imageSrc" or src='imageSrc'
1038
+ // Regex fallback for TSX/JSX files or if AST parsing failed
163
1039
  const srcPatterns = [
164
1040
  `src="${imageSrc}"`,
165
1041
  `src='${imageSrc}'`,
@@ -175,7 +1051,7 @@ async function searchFileForImage(
175
1051
  const snippet = extractImageSnippet(lines, i)
176
1052
 
177
1053
  return {
178
- file: path.relative(process.cwd(), filePath),
1054
+ file: path.relative(getProjectRoot(), filePath),
179
1055
  line: i + 1,
180
1056
  snippet,
181
1057
  type: 'static',
@@ -248,7 +1124,8 @@ async function searchDirectory(
248
1124
  }
249
1125
 
250
1126
  /**
251
- * Search a single Astro file for matching content
1127
+ * Search a single Astro file for matching content using AST parsing.
1128
+ * Uses caching for better performance.
252
1129
  */
253
1130
  async function searchAstroFile(
254
1131
  filePath: string,
@@ -256,106 +1133,25 @@ async function searchAstroFile(
256
1133
  tag: string,
257
1134
  ): Promise<SourceLocation | undefined> {
258
1135
  try {
259
- const content = await fs.readFile(filePath, 'utf-8')
260
- const lines = content.split('\n')
261
-
262
- const cleanText = normalizeText(textContent)
263
- const textPreview = cleanText.slice(0, Math.min(30, cleanText.length))
264
-
265
- // Extract variable references from frontmatter
266
- const variableRefs = extractVariableReferences(content, cleanText)
267
-
268
- // Collect all potential matches with scores and metadata
269
- const matches: Array<{
270
- line: number
271
- score: number
272
- type: 'static' | 'variable' | 'prop' | 'computed'
273
- variableName?: string
274
- definitionLine?: number
275
- }> = []
276
-
277
- // Search for tag usage with matching text or variable
278
- for (let i = 0; i < lines.length; i++) {
279
- const line = lines[i]?.trim().toLowerCase()
280
-
281
- // Look for opening tag
282
- if (line?.includes(`<${tag.toLowerCase()}`) && !line.startsWith(`</${tag.toLowerCase()}`)) {
283
- // Collect content from this line and next few lines
284
- const section = collectSection(lines, i, 5)
285
- const sectionText = section.toLowerCase()
286
- const sectionTextOnly = stripHtmlTags(section).toLowerCase()
287
-
288
- let score = 0
289
- let matched = false
290
-
291
- // Check for variable reference match (highest priority)
292
- if (variableRefs.length > 0) {
293
- for (const varRef of variableRefs) {
294
- // Check case-insensitively since sectionText is lowercased
295
- if (sectionText.includes(`{`) && sectionText.includes(varRef.name.toLowerCase())) {
296
- score = 100
297
- matched = true
298
- // Store match metadata - this is the USAGE line, we need DEFINITION line
299
- matches.push({
300
- line: i + 1,
301
- score,
302
- type: 'variable',
303
- variableName: varRef.name,
304
- definitionLine: varRef.definitionLine,
305
- })
306
- break
307
- }
308
- }
309
- }
310
-
311
- // Check for direct text match (static content)
312
- if (!matched && cleanText.length > 10 && sectionTextOnly.includes(textPreview)) {
313
- // Score based on how much of the text matches
314
- const matchLength = Math.min(cleanText.length, sectionTextOnly.length)
315
- score = 50 + (matchLength / cleanText.length) * 40
316
- matched = true
317
- // Find the actual line containing the text
318
- const actualLine = findLineContainingText(lines, i, 5, textPreview)
319
- matches.push({ line: actualLine, score, type: 'static' })
320
- }
1136
+ // Use cached parsed file
1137
+ const cached = await getCachedParsedFile(filePath)
1138
+ if (!cached) return undefined
321
1139
 
322
- // Check for short exact text match (static content)
323
- if (!matched && cleanText.length > 0 && cleanText.length <= 10 && sectionTextOnly.includes(cleanText)) {
324
- score = 80
325
- matched = true
326
- // Find the actual line containing the text
327
- const actualLine = findLineContainingText(lines, i, 5, cleanText)
328
- matches.push({ line: actualLine, score, type: 'static' })
329
- }
1140
+ const { lines, ast, variableDefinitions } = cached
330
1141
 
331
- // Try matching first few words for longer text (static content)
332
- if (!matched && cleanText.length > 20) {
333
- const firstWords = cleanText.split(' ').slice(0, 3).join(' ')
334
- if (firstWords && sectionTextOnly.includes(firstWords)) {
335
- score = 40
336
- matched = true
337
- // Find the actual line containing the text
338
- const actualLine = findLineContainingText(lines, i, 5, firstWords)
339
- matches.push({ line: actualLine, score, type: 'static' })
340
- }
341
- }
342
- }
343
- }
344
-
345
- // Return the best match (highest score)
346
- if (matches.length > 0) {
347
- const bestMatch = matches.reduce((best, current) => current.score > best.score ? current : best)
1142
+ // Find matching element in template AST
1143
+ const match = findElementWithText(ast, tag, textContent, variableDefinitions)
348
1144
 
1145
+ if (match) {
349
1146
  // Determine the editable line (definition for variables, usage for static)
350
- const editableLine = bestMatch.type === 'variable' && bestMatch.definitionLine
351
- ? bestMatch.definitionLine
352
- : bestMatch.line
1147
+ const editableLine = match.type === 'variable' && match.definitionLine
1148
+ ? match.definitionLine
1149
+ : match.line
353
1150
 
354
1151
  // Get the source snippet - innerHTML for static content, definition line for variables
355
1152
  let snippet: string
356
- if (bestMatch.type === 'static') {
1153
+ if (match.type === 'static') {
357
1154
  // For static content, extract only the innerHTML (not the wrapper element)
358
- // This ensures that when replacing, we only replace the content, not the element structure
359
1155
  const completeSnippet = extractCompleteTagSnippet(lines, editableLine - 1, tag)
360
1156
  snippet = extractInnerHtmlFromSnippet(completeSnippet, tag) ?? completeSnippet
361
1157
  } else {
@@ -364,27 +1160,27 @@ async function searchAstroFile(
364
1160
  }
365
1161
 
366
1162
  return {
367
- file: path.relative(process.cwd(), filePath),
1163
+ file: path.relative(getProjectRoot(), filePath),
368
1164
  line: editableLine,
369
1165
  snippet,
370
- type: bestMatch.type,
371
- variableName: bestMatch.variableName,
372
- definitionLine: bestMatch.type === 'variable' ? bestMatch.definitionLine : undefined,
1166
+ type: match.type,
1167
+ variableName: match.variableName,
1168
+ definitionLine: match.type === 'variable' ? match.definitionLine : undefined,
373
1169
  }
374
1170
  }
375
1171
  } catch {
376
- // Error reading file
1172
+ // Error reading/parsing file
377
1173
  }
378
1174
 
379
1175
  return undefined
380
1176
  }
381
1177
 
382
1178
  /**
383
- * Search for prop values passed to components
1179
+ * Search for prop values passed to components using AST parsing.
1180
+ * Uses caching for better performance.
384
1181
  */
385
1182
  async function searchForPropInParents(dir: string, textContent: string): Promise<SourceLocation | undefined> {
386
1183
  const entries = await fs.readdir(dir, { withFileTypes: true })
387
- const cleanText = normalizeText(textContent)
388
1184
 
389
1185
  for (const entry of entries) {
390
1186
  const fullPath = path.join(dir, entry.name)
@@ -393,90 +1189,52 @@ async function searchForPropInParents(dir: string, textContent: string): Promise
393
1189
  const result = await searchForPropInParents(fullPath, textContent)
394
1190
  if (result) return result
395
1191
  } else if (entry.isFile() && entry.name.endsWith('.astro')) {
396
- const content = await fs.readFile(fullPath, 'utf-8')
397
- const lines = content.split('\n')
398
-
399
- // Look for component tags with prop values matching our text
400
- for (let i = 0; i < lines.length; i++) {
401
- const line = lines[i]
402
-
403
- // Match component usage like <ComponentName propName="value" />
404
- const componentMatch = line?.match(/<([A-Z]\w+)/)
405
- if (!componentMatch) continue
406
-
407
- // Collect only the opening tag (until first > or />), not nested content
408
- let openingTag = ''
409
- let endLine = i
410
- for (let j = i; j < Math.min(i + 10, lines.length); j++) {
411
- openingTag += ' ' + lines[j]
412
- endLine = j
413
-
414
- // Stop at the end of opening tag (either /> or >)
415
- if (lines[j]?.includes('/>')) {
416
- // Self-closing tag
417
- break
418
- } else if (lines[j]?.includes('>')) {
419
- // Opening tag ends here, don't include nested content
420
- // Truncate to just the opening tag part
421
- const tagEndIndex = openingTag.indexOf('>')
422
- if (tagEndIndex !== -1) {
423
- openingTag = openingTag.substring(0, tagEndIndex + 1)
424
- }
425
- break
426
- }
427
- }
428
-
429
- // Extract all prop values from the opening tag only
430
- const propMatches = openingTag.matchAll(/(\w+)=["']([^"']+)["']/g)
431
- for (const match of propMatches) {
432
- const propName = match[1]
433
- const propValue = match[2]
1192
+ try {
1193
+ // Use cached parsed file
1194
+ const cached = await getCachedParsedFile(fullPath)
1195
+ if (!cached) continue
434
1196
 
435
- if (!propValue) {
436
- continue
437
- }
1197
+ const { lines, ast } = cached
438
1198
 
439
- const normalizedValue = normalizeText(propValue)
1199
+ // Find component props matching our text
1200
+ const propMatch = findComponentProp(ast, textContent)
440
1201
 
441
- if (normalizedValue === cleanText) {
442
- // Find which line actually contains this prop
443
- let propLine = i
1202
+ if (propMatch) {
1203
+ // Extract component snippet for context
1204
+ const componentStart = propMatch.line - 1
1205
+ const snippetLines: string[] = []
1206
+ let depth = 0
444
1207
 
445
- for (let k = i; k <= endLine; k++) {
446
- const line = lines[k]
447
- if (!line) {
448
- continue
449
- }
1208
+ for (let i = componentStart; i < Math.min(componentStart + 10, lines.length); i++) {
1209
+ const line = lines[i]
1210
+ if (!line) continue
1211
+ snippetLines.push(line)
450
1212
 
451
- if (propName && line.includes(propName) && line.includes(propValue)) {
452
- propLine = k
453
- break
454
- }
1213
+ // Check for self-closing or end of opening tag
1214
+ if (line.includes('/>')) {
1215
+ break
455
1216
  }
456
-
457
- // Extract complete component tag starting from where the component tag opens
458
- const componentSnippetLines: string[] = []
459
- for (let k = i; k <= endLine; k++) {
460
- const line = lines[k]
461
- if (!line) {
462
- continue
1217
+ if (line.includes('>') && !line.includes('/>')) {
1218
+ // Count opening tags
1219
+ const opens = (line.match(/<[A-Z]/g) || []).length
1220
+ const closes = (line.match(/\/>/g) || []).length
1221
+ depth += opens - closes
1222
+ if (depth <= 0 || (i > componentStart && line.includes('>'))) {
1223
+ break
463
1224
  }
464
-
465
- componentSnippetLines.push(line)
466
1225
  }
1226
+ }
467
1227
 
468
- const propSnippet = componentSnippetLines.join('\n')
469
-
470
- // Found the prop being passed with our text value
471
- return {
472
- file: path.relative(process.cwd(), fullPath),
473
- line: propLine + 1,
474
- snippet: propSnippet,
475
- type: 'prop',
476
- variableName: propName,
477
- }
1228
+ return {
1229
+ file: path.relative(getProjectRoot(), fullPath),
1230
+ line: propMatch.line,
1231
+ snippet: snippetLines.join('\n'),
1232
+ type: 'prop',
1233
+ variableName: propMatch.propName,
478
1234
  }
479
1235
  }
1236
+ } catch {
1237
+ // Error parsing file, continue
480
1238
  }
481
1239
  }
482
1240
  }
@@ -487,14 +1245,38 @@ async function searchForPropInParents(dir: string, textContent: string): Promise
487
1245
  /**
488
1246
  * Extract complete tag snippet including content and indentation.
489
1247
  * Exported for use in html-processor to populate sourceSnippet.
1248
+ *
1249
+ * When startLine points to a line inside the element (e.g., the text content line),
1250
+ * this function searches backwards to find the opening tag first.
490
1251
  */
491
1252
  export function extractCompleteTagSnippet(lines: string[], startLine: number, tag: string): string {
1253
+ // Pattern to match opening tag - either followed by whitespace/>, or at end of line (multi-line tag)
1254
+ const openTagPattern = new RegExp(`<${tag}(?:[\\s>]|$)`, 'gi')
1255
+
1256
+ // Check if the start line contains the opening tag
1257
+ let actualStartLine = startLine
1258
+ const startLineContent = lines[startLine] || ''
1259
+ if (!openTagPattern.test(startLineContent)) {
1260
+ // Search backwards to find the opening tag
1261
+ for (let i = startLine - 1; i >= Math.max(0, startLine - 20); i--) {
1262
+ const line = lines[i]
1263
+ if (!line) continue
1264
+
1265
+ // Reset regex lastIndex for fresh test
1266
+ openTagPattern.lastIndex = 0
1267
+ if (openTagPattern.test(line)) {
1268
+ actualStartLine = i
1269
+ break
1270
+ }
1271
+ }
1272
+ }
1273
+
492
1274
  const snippetLines: string[] = []
493
1275
  let depth = 0
494
1276
  let foundClosing = false
495
1277
 
496
1278
  // Start from the opening tag line
497
- for (let i = startLine; i < Math.min(startLine + 20, lines.length); i++) {
1279
+ for (let i = actualStartLine; i < Math.min(actualStartLine + 30, lines.length); i++) {
498
1280
  const line = lines[i]
499
1281
 
500
1282
  if (!line) {
@@ -504,7 +1286,8 @@ export function extractCompleteTagSnippet(lines: string[], startLine: number, ta
504
1286
  snippetLines.push(line)
505
1287
 
506
1288
  // Count opening and closing tags
507
- const openTags = (line.match(new RegExp(`<${tag}[\\s>]`, 'gi')) || []).length
1289
+ // Opening tag can be followed by whitespace, >, or end of line (multi-line tag)
1290
+ const openTags = (line.match(new RegExp(`<${tag}(?:[\\s>]|$)`, 'gi')) || []).length
508
1291
  const selfClosing = (line.match(new RegExp(`<${tag}[^>]*/>`, 'gi')) || []).length
509
1292
  const closeTags = (line.match(new RegExp(`</${tag}>`, 'gi')) || []).length
510
1293
 
@@ -572,7 +1355,7 @@ export async function extractSourceInnerHtml(
572
1355
  try {
573
1356
  const filePath = path.isAbsolute(sourceFile)
574
1357
  ? sourceFile
575
- : path.join(process.cwd(), sourceFile)
1358
+ : path.join(getProjectRoot(), sourceFile)
576
1359
 
577
1360
  const content = await fs.readFile(filePath, 'utf-8')
578
1361
  const lines = content.split('\n')
@@ -587,104 +1370,6 @@ export async function extractSourceInnerHtml(
587
1370
  }
588
1371
  }
589
1372
 
590
- /**
591
- * Extract variable references from frontmatter
592
- */
593
- function extractVariableReferences(content: string, targetText: string): VariableReference[] {
594
- const refs: VariableReference[] = []
595
- const frontmatterEnd = content.indexOf('---', 3)
596
-
597
- if (frontmatterEnd <= 0) return refs
598
-
599
- const frontmatter = content.substring(0, frontmatterEnd)
600
- const lines = frontmatter.split('\n')
601
-
602
- for (const line of lines) {
603
- const trimmed = line.trim()
604
-
605
- // Match quoted text (handling escaped quotes)
606
- // Try single quotes with escaped quotes
607
- let quotedMatch = trimmed.match(/'((?:[^'\\]|\\.)*)'/)
608
- if (!quotedMatch) {
609
- // Try double quotes with escaped quotes
610
- quotedMatch = trimmed.match(/"((?:[^"\\]|\\.)*)"/)
611
- }
612
- if (!quotedMatch) {
613
- // Try backticks (template literals) - but only if no ${} interpolation
614
- const backtickMatch = trimmed.match(/`([^`]*)`/)
615
- if (backtickMatch && !backtickMatch[1]?.includes('${')) {
616
- quotedMatch = backtickMatch
617
- }
618
- }
619
- if (!quotedMatch?.[1]) continue
620
-
621
- const value = normalizeText(quotedMatch[1])
622
- const normalizedTarget = normalizeText(targetText)
623
-
624
- if (value !== normalizedTarget) continue
625
-
626
- // Try to extract variable name and line number
627
- const lineNumber = lines.indexOf(line) + 1
628
-
629
- // Pattern 1: Object property "key: 'value'"
630
- const propMatch = trimmed.match(/(\w+)\s*:\s*['"`]/)
631
- if (propMatch?.[1]) {
632
- refs.push({
633
- name: propMatch[1],
634
- pattern: `{.*${propMatch[1]}`,
635
- definitionLine: lineNumber,
636
- })
637
- continue
638
- }
639
-
640
- // Pattern 2: Variable declaration "const name = 'value'"
641
- const varMatch = trimmed.match(/(?:const|let|var)\s+(\w+)(?:\s*:\s*\w+)?\s*=/)
642
- if (varMatch?.[1]) {
643
- refs.push({
644
- name: varMatch[1],
645
- pattern: `{${varMatch[1]}}`,
646
- definitionLine: lineNumber,
647
- })
648
- }
649
- }
650
-
651
- return refs
652
- }
653
-
654
- /**
655
- * Collect text from multiple lines
656
- */
657
- function collectSection(lines: string[], startLine: number, numLines: number): string {
658
- let text = ''
659
- for (let i = startLine; i < Math.min(startLine + numLines, lines.length); i++) {
660
- text += ' ' + lines[i]?.trim().replace(/\s+/g, ' ')
661
- }
662
- return text
663
- }
664
-
665
- /**
666
- * Find the actual line containing the matched text within a section
667
- * Returns 1-indexed line number
668
- */
669
- function findLineContainingText(lines: string[], startLine: number, numLines: number, searchText: string): number {
670
- const normalizedSearch = searchText.toLowerCase()
671
- for (let i = startLine; i < Math.min(startLine + numLines, lines.length); i++) {
672
- const lineText = stripHtmlTags(lines[i] || '').toLowerCase()
673
- if (lineText.includes(normalizedSearch)) {
674
- return i + 1 // Return 1-indexed line number
675
- }
676
- }
677
- // If not found on a specific line, return the opening tag line
678
- return startLine + 1
679
- }
680
-
681
- /**
682
- * Strip HTML tags from text
683
- */
684
- function stripHtmlTags(text: string): string {
685
- return text.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim()
686
- }
687
-
688
1373
  /**
689
1374
  * Normalize text for comparison (handles escaping and entities)
690
1375
  */
@@ -720,7 +1405,7 @@ export async function findCollectionSource(
720
1405
  return undefined
721
1406
  }
722
1407
 
723
- const contentPath = path.join(process.cwd(), contentDir)
1408
+ const contentPath = path.join(getProjectRoot(), contentDir)
724
1409
 
725
1410
  try {
726
1411
  // Check if content directory exists
@@ -757,7 +1442,7 @@ export async function findCollectionSource(
757
1442
  return {
758
1443
  name: collectionName,
759
1444
  slug,
760
- file: path.relative(process.cwd(), mdFile),
1445
+ file: path.relative(getProjectRoot(), mdFile),
761
1446
  }
762
1447
  }
763
1448
 
@@ -820,6 +1505,24 @@ async function findMarkdownFile(collectionPath: string, slug: string): Promise<s
820
1505
  return undefined
821
1506
  }
822
1507
 
1508
+ /**
1509
+ * Get cached markdown file content
1510
+ */
1511
+ async function getCachedMarkdownFile(filePath: string): Promise<{ content: string; lines: string[] } | null> {
1512
+ const cached = markdownFileCache.get(filePath)
1513
+ if (cached) return cached
1514
+
1515
+ try {
1516
+ const content = await fs.readFile(filePath, 'utf-8')
1517
+ const lines = content.split('\n')
1518
+ const entry = { content, lines }
1519
+ markdownFileCache.set(filePath, entry)
1520
+ return entry
1521
+ } catch {
1522
+ return null
1523
+ }
1524
+ }
1525
+
823
1526
  /**
824
1527
  * Find text content in a markdown file and return source location
825
1528
  * Only matches frontmatter fields, not body content (body is handled separately as a whole)
@@ -832,9 +1535,11 @@ export async function findMarkdownSourceLocation(
832
1535
  collectionInfo: CollectionInfo,
833
1536
  ): Promise<SourceLocation | undefined> {
834
1537
  try {
835
- const filePath = path.join(process.cwd(), collectionInfo.file)
836
- const content = await fs.readFile(filePath, 'utf-8')
837
- const lines = content.split('\n')
1538
+ const filePath = path.join(getProjectRoot(), collectionInfo.file)
1539
+ const cached = await getCachedMarkdownFile(filePath)
1540
+ if (!cached) return undefined
1541
+
1542
+ const { lines } = cached
838
1543
  const normalizedSearch = normalizeText(textContent)
839
1544
 
840
1545
  // Parse frontmatter
@@ -898,7 +1603,8 @@ export async function findMarkdownSourceLocation(
898
1603
  }
899
1604
 
900
1605
  /**
901
- * Parse markdown file and extract frontmatter fields and full body content
1606
+ * Parse markdown file and extract frontmatter fields and full body content.
1607
+ * Uses caching for better performance.
902
1608
  * @param collectionInfo - Collection information (name, slug, file path)
903
1609
  * @returns Parsed markdown content with frontmatter and body
904
1610
  */
@@ -906,9 +1612,11 @@ export async function parseMarkdownContent(
906
1612
  collectionInfo: CollectionInfo,
907
1613
  ): Promise<MarkdownContent | undefined> {
908
1614
  try {
909
- const filePath = path.join(process.cwd(), collectionInfo.file)
910
- const content = await fs.readFile(filePath, 'utf-8')
911
- const lines = content.split('\n')
1615
+ const filePath = path.join(getProjectRoot(), collectionInfo.file)
1616
+ const cached = await getCachedMarkdownFile(filePath)
1617
+ if (!cached) return undefined
1618
+
1619
+ const { lines } = cached
912
1620
 
913
1621
  // Parse frontmatter
914
1622
  let frontmatterStart = -1
@@ -1008,10 +1716,10 @@ export async function enhanceManifestWithSourceSnippets(
1008
1716
  // Process entries in parallel for better performance
1009
1717
  const entryPromises = Object.entries(entries).map(async ([id, entry]) => {
1010
1718
  // Handle image entries specially - find the line with src attribute
1011
- if (entry.sourceType === 'image' && entry.imageSrc) {
1012
- const imageLocation = await findImageSourceLocation(entry.imageSrc)
1719
+ if (entry.sourceType === 'image' && entry.imageMetadata?.src) {
1720
+ const imageLocation = await findImageSourceLocation(entry.imageMetadata.src)
1013
1721
  if (imageLocation) {
1014
- const sourceHash = generateSourceHash(imageLocation.snippet || entry.imageSrc)
1722
+ const sourceHash = generateSourceHash(imageLocation.snippet || entry.imageMetadata.src)
1015
1723
  return [id, {
1016
1724
  ...entry,
1017
1725
  sourcePath: imageLocation.file,