@nuasite/cms 0.36.1 → 0.38.0

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.
@@ -265,6 +265,8 @@ function hasExpressionChild(node: AstroNode): { found: boolean; varNames: string
265
265
  function extractCompleteTagSnippet(lines: string[], startLine: number, tag: string): string {
266
266
  const escapedTag = escapeRegex(tag)
267
267
  const openTagPattern = new RegExp(`<${escapedTag}(?:[\\s>]|$)`, 'gi')
268
+ const selfClosingPattern = new RegExp(`<${escapedTag}[^>]*/>`, 'gi')
269
+ const closeTagPattern = new RegExp(`</${escapedTag}>`, 'gi')
268
270
 
269
271
  let actualStartLine = startLine
270
272
  const startLineContent = lines[startLine] || ''
@@ -285,14 +287,16 @@ function extractCompleteTagSnippet(lines: string[], startLine: number, tag: stri
285
287
  let foundClosing = false
286
288
 
287
289
  for (let i = actualStartLine; i < Math.min(actualStartLine + 30, lines.length); i++) {
288
- const line = lines[i]
289
- if (!line) continue
290
+ const line = lines[i] ?? ''
290
291
 
292
+ // Preserve blank lines verbatim so the writer's `content.includes(snippet)`
293
+ // check passes — file content has the blank lines, the snippet must too.
291
294
  snippetLines.push(line)
295
+ if (!line) continue
292
296
 
293
- const openTags = (line.match(new RegExp(`<${escapedTag}(?:[\\s>]|$)`, 'gi')) || []).length
294
- const selfClosing = (line.match(new RegExp(`<${escapedTag}[^>]*/>`, 'gi')) || []).length
295
- const closeTags = (line.match(new RegExp(`</${escapedTag}>`, 'gi')) || []).length
297
+ const openTags = countMatches(line, openTagPattern)
298
+ const selfClosing = countMatches(line, selfClosingPattern)
299
+ const closeTags = countMatches(line, closeTagPattern)
296
300
 
297
301
  depth += openTags - selfClosing - closeTags
298
302
 
@@ -309,6 +313,14 @@ function extractCompleteTagSnippet(lines: string[], startLine: number, tag: stri
309
313
  return snippetLines.join('\n')
310
314
  }
311
315
 
316
+ /** Count global-regex matches on a string without allocating the match array. */
317
+ function countMatches(str: string, pattern: RegExp): number {
318
+ pattern.lastIndex = 0
319
+ let count = 0
320
+ while (pattern.exec(str) !== null) count++
321
+ return count
322
+ }
323
+
312
324
  /**
313
325
  * Extract the opening tag from source lines with its start line number.
314
326
  * Local version for indexing (to avoid circular dependency)
@@ -320,6 +332,8 @@ function extractOpeningTagWithLine(
320
332
  ): { snippet: string; startLine: number } | undefined {
321
333
  const escapedTag = escapeRegex(tag)
322
334
  const openTagPattern = new RegExp(`<${escapedTag}(?:[\\s>]|$)`, 'gi')
335
+ const openTagMatcher = new RegExp(`<${escapedTag}[^>]*>`, 'i')
336
+ const selfClosingMatcher = new RegExp(`<${escapedTag}[^>]*/\\s*>`, 'i')
323
337
 
324
338
  let actualStartLine = startLine
325
339
  const startLineContent = lines[startLine] || ''
@@ -343,12 +357,12 @@ function extractOpeningTagWithLine(
343
357
  snippetLines.push(line)
344
358
  const combined = snippetLines.join('\n')
345
359
 
346
- const openTagMatch = combined.match(new RegExp(`<${escapedTag}[^>]*>`, 'i'))
360
+ const openTagMatch = combined.match(openTagMatcher)
347
361
  if (openTagMatch) {
348
362
  return { snippet: openTagMatch[0], startLine: actualStartLine }
349
363
  }
350
364
 
351
- const selfClosingMatch = combined.match(new RegExp(`<${escapedTag}[^>]*/\\s*>`, 'i'))
365
+ const selfClosingMatch = combined.match(selfClosingMatcher)
352
366
  if (selfClosingMatch) {
353
367
  return { snippet: selfClosingMatch[0], startLine: actualStartLine }
354
368
  }
@@ -357,12 +371,49 @@ function extractOpeningTagWithLine(
357
371
  return undefined
358
372
  }
359
373
 
374
+ /**
375
+ * Extract a plain string from a JS expression source when it is a single string
376
+ * literal — `'foo'`, `"foo"`, or a substitution-free template literal `` `foo` ``.
377
+ * Returns null for anything else (variables, calls, concatenation, templates with `${}`).
378
+ *
379
+ * Used to index component props authored as `prop={\`literal text\`}` — the
380
+ * Astro compiler exposes the raw expression source, not the resolved value.
381
+ */
382
+ function extractSimpleStringLiteral(exprText: string): string | null {
383
+ const trimmed = exprText.trim()
384
+ if (trimmed.length < 2) return null
385
+ const quote = trimmed[0]
386
+ if (quote !== "'" && quote !== '"' && quote !== '`') return null
387
+ if (trimmed[trimmed.length - 1] !== quote) return null
388
+ const inner = trimmed.slice(1, -1)
389
+ // Reject template literals containing `${...}` substitutions — only literal templates qualify.
390
+ if (quote === '`') {
391
+ // `\\` → escaped backslash (still literal), `\$` → escaped dollar (still literal),
392
+ // any other `$` followed by `{` means an expression substitution.
393
+ const stripped = inner.replace(/\\[\\$`]/g, '')
394
+ if (/\$\{/.test(stripped)) return null
395
+ } else if (/[\r\n]/.test(inner)) {
396
+ // Multi-line plain string isn't valid JS; bail.
397
+ return null
398
+ }
399
+ // Decode common escapes: \\ \' \" \` \n \r \t plus generic `\X`.
400
+ return inner.replace(/\\(.)/g, (_, ch) => {
401
+ if (ch === 'n') return '\n'
402
+ if (ch === 'r') return '\r'
403
+ if (ch === 't') return '\t'
404
+ return ch
405
+ })
406
+ }
407
+
360
408
  /**
361
409
  * Index all searchable text content from a parsed file
362
410
  */
363
411
  export function indexFileContent(cached: CachedParsedFile, relFile: string): void {
364
412
  // Walk AST and collect all text elements
365
- function visit(node: AstroNode) {
413
+ function visit(node: AstroNode, parentExpression: AstroNode | null) {
414
+ // Track the nearest ancestor expression node (contains .map() context)
415
+ const currentExpr = node.type === 'expression' ? node : parentExpression
416
+
366
417
  if ((node.type === 'element' || node.type === 'component')) {
367
418
  const elemNode = node as ElementNode | ComponentNode
368
419
  const tag = elemNode.name.toLowerCase()
@@ -370,38 +421,41 @@ export function indexFileContent(cached: CachedParsedFile, relFile: string): voi
370
421
  const normalizedText = normalizeText(textContent)
371
422
  const line = elemNode.position?.start.line ?? 0
372
423
 
373
- if (normalizedText && normalizedText.length >= 2) {
374
- // Check for variable references
375
- const exprInfo = hasExpressionChild(elemNode)
376
- if (exprInfo.found && exprInfo.varNames.length > 0) {
377
- for (const exprPath of exprInfo.varNames) {
378
- for (const def of cached.variableDefinitions) {
379
- // Build the full definition path for comparison
380
- // For array indices (numeric names), use bracket notation
381
- const defPath = buildDefinitionPath(def)
382
- // Check if the expression path matches the definition path
383
- // e.g., 'config.nav.title' matches def with parentName='config.nav', name='title'
384
- // or 'items[0]' matches def with parentName='items', name='0'
385
- if (defPath === exprPath) {
386
- const normalizedDef = normalizeText(def.value)
387
- const completeSnippet = extractCompleteTagSnippet(cached.lines, line - 1, tag)
388
- const snippet = extractInnerHtmlFromSnippet(completeSnippet, tag) ?? completeSnippet
389
-
390
- addToTextSearchIndex({
391
- file: relFile,
392
- line: def.line,
393
- snippet: cached.lines[def.line - 1] || '',
394
- type: 'variable',
395
- variableName: defPath,
396
- definitionLine: def.line,
397
- normalizedText: normalizedDef,
398
- tag,
399
- })
400
- }
424
+ // Variable references are indexed by their *resolved* value, so we don't
425
+ // gate on the rendered expression text length — `<li>{t}</li>` has
426
+ // textContent "t" (length 1) but resolves to real strings via .map().
427
+ const exprInfo = hasExpressionChild(elemNode)
428
+ if (exprInfo.found && exprInfo.varNames.length > 0) {
429
+ for (const exprPath of exprInfo.varNames) {
430
+ let directMatch = false
431
+ for (const def of cached.variableDefinitions) {
432
+ const defPath = buildDefinitionPath(def)
433
+ if (defPath === exprPath) {
434
+ directMatch = true
435
+ const normalizedDef = normalizeText(def.value)
436
+
437
+ addToTextSearchIndex({
438
+ file: relFile,
439
+ line: def.line,
440
+ snippet: cached.lines[def.line - 1] || '',
441
+ type: 'variable',
442
+ variableName: defPath,
443
+ definitionLine: def.line,
444
+ normalizedText: normalizedDef,
445
+ tag,
446
+ })
401
447
  }
402
448
  }
449
+
450
+ // `.map()`-driven {item.label} — trace the loop param through to the
451
+ // data source array and index every concrete element.
452
+ if (!directMatch && currentExpr) {
453
+ indexExpressionTextRef(exprPath, currentExpr, cached, relFile, tag)
454
+ }
403
455
  }
456
+ }
404
457
 
458
+ if (normalizedText && normalizedText.length >= 2) {
405
459
  // Index static text content
406
460
  const completeSnippet = extractCompleteTagSnippet(cached.lines, line - 1, tag)
407
461
  const snippet = extractInnerHtmlFromSnippet(completeSnippet, tag) ?? completeSnippet
@@ -421,81 +475,191 @@ export function indexFileContent(cached: CachedParsedFile, relFile: string): voi
421
475
  // Also index component props
422
476
  if (node.type === 'component') {
423
477
  for (const attr of elemNode.attributes) {
424
- if (attr.type === 'attribute' && attr.kind === 'quoted' && attr.value) {
425
- const normalizedValue = normalizeText(attr.value)
426
- if (normalizedValue && normalizedValue.length >= 2) {
427
- addToTextSearchIndex({
428
- file: relFile,
429
- line: attr.position?.start.line ?? line,
430
- snippet: cached.lines[(attr.position?.start.line ?? line) - 1] || '',
431
- type: 'prop',
432
- variableName: attr.name,
433
- normalizedText: normalizedValue,
434
- tag,
435
- })
436
- }
478
+ if (attr.type !== 'attribute' || !attr.value) continue
479
+
480
+ let propValue: string | null = null
481
+ if (attr.kind === 'quoted') {
482
+ propValue = attr.value
483
+ } else if (attr.kind === 'expression') {
484
+ // Common author pattern: pass a string-literal prop with backticks
485
+ // (e.g. `heading={\`Tři bytové domy...\`}`) so smart quotes can sit
486
+ // inside without escaping. Render-time the value is plain text — to
487
+ // the dev-middleware fallback, the rendered element looks like static
488
+ // content. Index the literal so source lookup can find the call site.
489
+ propValue = extractSimpleStringLiteral(attr.value)
437
490
  }
491
+ if (!propValue) continue
492
+
493
+ const normalizedValue = normalizeText(propValue)
494
+ if (!normalizedValue || normalizedValue.length < 2) continue
495
+
496
+ addToTextSearchIndex({
497
+ file: relFile,
498
+ line: attr.position?.start.line ?? line,
499
+ snippet: cached.lines[(attr.position?.start.line ?? line) - 1] || '',
500
+ type: 'prop',
501
+ variableName: attr.name,
502
+ normalizedText: normalizedValue,
503
+ tag,
504
+ })
438
505
  }
439
506
  }
440
507
  }
441
508
 
442
509
  if ('children' in node && Array.isArray(node.children)) {
443
510
  for (const child of node.children) {
444
- visit(child)
511
+ visit(child, currentExpr)
445
512
  }
446
513
  }
447
514
  }
448
515
 
449
- visit(cached.ast)
516
+ visit(cached.ast, null)
450
517
  }
451
518
 
452
519
  /**
453
- * Resolve a .map() callback parameter back to the source array path.
454
- *
455
- * Given expression text like:
456
- * "categories.map((cat) => (\n cat.images.map((img, i) => (\n "
457
- * and a parameter name like "img", returns "categories[*].images" — the
458
- * array path that the parameter iterates over.
520
+ * Index text from an expression-based `{loopVar.prop}` (or `{loopVar}`) by tracing
521
+ * the loop variable through enclosing `.map()` calls back to the data source array,
522
+ * then adding every concrete array element's matching property to the text index.
459
523
  *
460
- * Supports chained .map() calls (nested loops).
524
+ * Without this, `<a>{item.label}</a>` inside `links.map((item) => ...)` would
525
+ * never match definitions like `links[0].label` because `item` isn't a variable
526
+ * definition — it's a `.map()` callback parameter.
527
+ */
528
+ function indexExpressionTextRef(
529
+ exprPath: string,
530
+ parentExpression: AstroNode,
531
+ cached: CachedParsedFile,
532
+ relFile: string,
533
+ tag: string,
534
+ ): void {
535
+ const baseMatch = exprPath.match(/^(\w+)(.*)$/)
536
+ if (!baseMatch) return
537
+ const baseVar = baseMatch[1]!
538
+ const suffix = baseMatch[2] ?? ''
539
+
540
+ const exprTexts: string[] = []
541
+ if ('children' in parentExpression && Array.isArray(parentExpression.children)) {
542
+ for (const child of parentExpression.children) {
543
+ if (child.type === 'text' && (child as TextNode).value) {
544
+ exprTexts.push((child as TextNode).value)
545
+ }
546
+ }
547
+ }
548
+ if (exprTexts.length === 0) return
549
+
550
+ const resolved = resolveMapChain(exprTexts, baseVar)
551
+ if (!resolved) return
552
+
553
+ // Only one of leafSuffix and suffix is non-empty in well-formed code:
554
+ // destructured `{label}` resolves to leafSuffix=".label"; simple-param
555
+ // `{item.label}` resolves to leafSuffix="" with suffix=".label".
556
+ const leafRegex = makeLeafPathRegex({
557
+ arrayPath: resolved.arrayPath,
558
+ leafSuffix: resolved.leafSuffix + suffix,
559
+ })
560
+
561
+ for (const def of cached.variableDefinitions) {
562
+ const defPath = buildDefinitionPath(def)
563
+ if (!leafRegex.test(defPath)) continue
564
+ const normalizedDef = normalizeText(def.value)
565
+ if (normalizedDef.length < 2) continue
566
+ addToTextSearchIndex({
567
+ file: relFile,
568
+ line: def.line,
569
+ snippet: cached.lines[def.line - 1] || '',
570
+ type: 'variable',
571
+ variableName: defPath,
572
+ definitionLine: def.line,
573
+ normalizedText: normalizedDef,
574
+ tag,
575
+ })
576
+ }
577
+ }
578
+
579
+ export interface ResolvedMapChain {
580
+ /** Source array path with [*] wildcards for nested chains, e.g. "links" or "categories[*].images" */
581
+ arrayPath: string
582
+ /** Leaf suffix appended to a concrete element. Empty for simple params, ".<propName>" for destructured. */
583
+ leafSuffix: string
584
+ }
585
+
586
+ /**
587
+ * Parsed `.map()` invocation found in expression text.
588
+ * Either has a simple parameter name OR a list of destructured property names.
461
589
  */
462
- export function resolveMapChain(exprTexts: string[], paramName: string): string | null {
463
- const fullText = exprTexts.join('')
590
+ interface MapInvocation {
591
+ arrayExpr: string
592
+ /** Simple callback parameter, e.g. `(item)` → `"item"`. Null when the callback destructures. */
593
+ param: string | null
594
+ /** Destructured property names from `({ a, b: bAlias = 'x' })`. Captures the local binding name. */
595
+ destructured: string[]
596
+ }
464
597
 
465
- // Find all .map() calls: <arrayExpr>.map((<param>, ...) =>
466
- // Capture: [1] = array expression, [2] = first callback parameter
467
- const mapPattern = /([\w.[\]]+)\.map\(\s*\(\s*(\w+)/g
468
- const maps: Array<{ arrayExpr: string; param: string }> = []
598
+ /**
599
+ * Parse all `.map(...)` invocations from joined expression text. Captures both simple
600
+ * params (`(item)`) and destructured object params (`({ label, href })`).
601
+ */
602
+ function parseMapInvocations(fullText: string): MapInvocation[] {
603
+ const mapPattern = /([\w.[\]]+)\.map\(\s*(?:\(\s*(\w+)|\(\s*\{([^}]+)\})/g
604
+ const maps: MapInvocation[] = []
469
605
  let match: RegExpExecArray | null
470
606
  while ((match = mapPattern.exec(fullText)) !== null) {
471
- maps.push({ arrayExpr: match[1]!, param: match[2]! })
607
+ const arrayExpr = match[1]!
608
+ const simple = match[2] ?? null
609
+ const destructured: string[] = match[3]
610
+ ? match[3].split(',').map((entry) => {
611
+ // Capture the *local binding* — alias for `prop: alias`, otherwise prop itself.
612
+ const trimmed = entry.trim()
613
+ if (!trimmed) return ''
614
+ const renameMatch = trimmed.match(/^(\w+)\s*:\s*(\w+)/)
615
+ if (renameMatch) return renameMatch[2]!
616
+ const nameMatch = trimmed.match(/^(\w+)/)
617
+ return nameMatch?.[1] ?? ''
618
+ }).filter(Boolean)
619
+ : []
620
+ maps.push({ arrayExpr, param: simple, destructured })
472
621
  }
622
+ return maps
623
+ }
473
624
 
625
+ /**
626
+ * Resolve a `.map()` callback parameter back to the source array path.
627
+ *
628
+ * Examples:
629
+ * `images.map((img) => …)` looking for `img`
630
+ * → `{ arrayPath: "images", leafSuffix: "" }`
631
+ * `categories.map((cat) => cat.images.map((img) => …))` looking for `img`
632
+ * → `{ arrayPath: "categories[*].images", leafSuffix: "" }`
633
+ * `links.map(({ label, href }) => …)` looking for `label`
634
+ * → `{ arrayPath: "links", leafSuffix: ".label" }`
635
+ *
636
+ * Returns null when the name doesn't appear as a parameter or destructured binding.
637
+ */
638
+ export function resolveMapChain(exprTexts: string[], paramName: string): ResolvedMapChain | null {
639
+ const maps = parseMapInvocations(exprTexts.join(''))
474
640
  if (maps.length === 0) return null
475
641
 
476
- // Find which .map() provides our paramName
477
- const directMap = maps.find(m => m.param === paramName)
642
+ // Prefer simple-param match (most common); fall back to destructured.
643
+ const directMap = maps.find((m) => m.param === paramName)
644
+ ?? maps.find((m) => m.destructured.includes(paramName))
478
645
  if (!directMap) return null
479
646
 
480
- // Resolve the array expression by substituting outer .map() params
481
- // e.g., "cat.images" where "cat" comes from "categories.map((cat) => ...)"
647
+ const isDestructured = directMap.param !== paramName
648
+ const leafSuffix = isDestructured ? `.${paramName}` : ''
649
+
650
+ // Resolve the array expression by substituting outer .map() params (chained / nested loops).
482
651
  let arrayPath = directMap.arrayExpr
483
652
  for (const outerMap of maps) {
484
653
  if (outerMap === directMap) continue
485
- // If arrayPath starts with an outer param name, substitute it
486
- // e.g., "cat.images" and cat comes from "categories" → "categories[*].images"
654
+ if (!outerMap.param) continue // Outer destructure can't appear as the head of `arrayExpr`.
487
655
  if (arrayPath === outerMap.param || arrayPath.startsWith(outerMap.param + '.')) {
488
- const suffix = arrayPath.slice(outerMap.param.length) // ".images" or ""
656
+ const suffix = arrayPath.slice(outerMap.param.length)
489
657
  const resolvedOuter = resolveMapChain(exprTexts, outerMap.param)
490
- if (resolvedOuter) {
491
- arrayPath = resolvedOuter + '[*]' + suffix
492
- } else {
493
- arrayPath = outerMap.arrayExpr + '[*]' + suffix
494
- }
658
+ arrayPath = (resolvedOuter ? resolvedOuter.arrayPath : outerMap.arrayExpr) + '[*]' + suffix
495
659
  }
496
660
  }
497
661
 
498
- return arrayPath
662
+ return { arrayPath, leafSuffix }
499
663
  }
500
664
 
501
665
  /**
@@ -537,15 +701,18 @@ function indexExpressionImageSrc(
537
701
  if (exprTexts.length === 0) return
538
702
 
539
703
  // Resolve the .map() chain to find the source array path
540
- const arrayPath = resolveMapChain(exprTexts, exprValue)
541
- if (!arrayPath) return
704
+ const resolved = resolveMapChain(exprTexts, exprValue)
705
+ if (!resolved) return
706
+
707
+ const leafRegex = makeLeafPathRegex(resolved)
542
708
 
543
709
  for (const def of cached.variableDefinitions) {
544
710
  const defPath = buildDefinitionPath(def)
545
- // Match definitions that are direct children of the array
546
- // e.g., for "images" match "images[0]", "images[1]"
547
- // e.g., for "categories[*].images" match "categories[0].images[0]", etc.
548
- if (isChildOfArray(defPath, arrayPath)) {
711
+ // Match definitions that are leaves under the array
712
+ // e.g., simple: "images" matches "images[0]", "images[1]"
713
+ // e.g., destructured: arrayPath="links", leafSuffix=".src" matches "links[0].src"
714
+ // e.g., nested: "categories[*].images" matches "categories[0].images[0]", etc.
715
+ if (leafRegex.test(defPath)) {
549
716
  const snippet = cached.lines[def.line - 1]?.trim() || ''
550
717
  addToImageSearchIndex({
551
718
  file: relFile,
@@ -557,28 +724,42 @@ function indexExpressionImageSrc(
557
724
  }
558
725
  }
559
726
 
727
+ /**
728
+ * Compile a wildcard array path into the inner regex body. `[*]` segments
729
+ * become `\[\d+\]`; everything else is escaped literal.
730
+ *
731
+ * Examples:
732
+ * "links" → /links/
733
+ * "categories[*].images" → /categories\[\d+\]\.images/
734
+ */
735
+ export function wildcardPathToRegexBody(arrayPath: string): string {
736
+ const segments = arrayPath.split('[*]')
737
+ let body = ''
738
+ for (let i = 0; i < segments.length; i++) {
739
+ body += escapeRegex(segments[i]!)
740
+ if (i < segments.length - 1) body += '\\[\\d+\\]'
741
+ }
742
+ return body
743
+ }
744
+
745
+ /**
746
+ * Build a regex that matches concrete leaf paths *one level under* a resolved
747
+ * .map() chain — appends `\[\d+\]` for the array element index, then the
748
+ * literal `leafSuffix` (e.g. `.label`).
749
+ */
750
+ export function makeLeafPathRegex({ arrayPath, leafSuffix }: ResolvedMapChain): RegExp {
751
+ return new RegExp('^' + wildcardPathToRegexBody(arrayPath) + '\\[\\d+\\]' + escapeRegex(leafSuffix) + '$')
752
+ }
753
+
560
754
  /**
561
755
  * Check if a definition path is a direct element of the given array path.
562
- * Converts the arrayPath pattern (with optional [*] wildcards) into a regex
563
- * that matches concrete indices.
564
756
  *
565
757
  * e.g., "images[0]" is a child of "images"
566
758
  * e.g., "categories[0].images[1]" is a child of "categories[*].images"
567
759
  * e.g., "categories[0].images[1].url" is NOT a child (too deep)
568
760
  */
569
761
  export function isChildOfArray(defPath: string, arrayPath: string): boolean {
570
- // Split arrayPath on [*] to get segments, then build a regex
571
- // "categories[*].images" → ["categories", ".images"] → /^categories\[\d+\]\.images\[\d+\]$/
572
- const segments = arrayPath.split('[*]')
573
- let regexStr = '^'
574
- for (let i = 0; i < segments.length; i++) {
575
- regexStr += escapeRegex(segments[i]!)
576
- if (i < segments.length - 1) {
577
- regexStr += '\\[\\d+\\]'
578
- }
579
- }
580
- regexStr += '\\[\\d+\\]$'
581
- return new RegExp(regexStr).test(defPath)
762
+ return makeLeafPathRegex({ arrayPath, leafSuffix: '' }).test(defPath)
582
763
  }
583
764
 
584
765
  /**
@@ -1060,6 +1241,46 @@ export function findTranslationByKeyAndText(key: string, normalizedText: string)
1060
1241
  return fallback
1061
1242
  }
1062
1243
 
1244
+ /**
1245
+ * Look up a `variable`-type index hit for `textContent`+`tag` constrained to a
1246
+ * specific source file. Used when an entry already has a sourcePath (set from
1247
+ * Astro's `data-astro-source-file`) but we want to upgrade the sourceLine from
1248
+ * the JSX template line to the actual variable definition line. Same-file
1249
+ * constraint avoids cross-file false positives where the same string lives in
1250
+ * an unrelated file.
1251
+ *
1252
+ * Astro stamps `data-astro-source-file` with an absolute filesystem path while
1253
+ * the search index stores paths relative to the project root, so the caller's
1254
+ * `file` is normalized to relative form before comparison.
1255
+ */
1256
+ export function findVariableHitInFile(
1257
+ textContent: string,
1258
+ tag: string,
1259
+ file: string,
1260
+ ): SourceLocation | undefined {
1261
+ const normalizedSearch = normalizeText(textContent)
1262
+ const tagLower = tag.toLowerCase()
1263
+ const fileRel = toProjectRelativePath(file)
1264
+ const index = getTextSearchIndex()
1265
+ for (const entry of index) {
1266
+ if (entry.file !== fileRel) continue
1267
+ if (entry.type !== 'variable') continue
1268
+ if (entry.normalizedText !== normalizedSearch) continue
1269
+ if (entry.tag !== tagLower) continue
1270
+ return textEntryToLocation(entry)
1271
+ }
1272
+ return undefined
1273
+ }
1274
+
1275
+ /**
1276
+ * Convert a file path to project-relative form, accepting either absolute
1277
+ * paths (as Astro stamps them) or already-relative paths (as the index uses).
1278
+ */
1279
+ function toProjectRelativePath(file: string): string {
1280
+ if (!path.isAbsolute(file)) return file
1281
+ return path.relative(getProjectRoot(), file)
1282
+ }
1283
+
1063
1284
  /**
1064
1285
  * Fast text lookup using pre-built index
1065
1286
  */