@nuasite/cms 0.37.0 → 0.39.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.
@@ -1,4 +1,6 @@
1
1
  import type { ComponentNode, ElementNode, Node as AstroNode, TextNode } from '@astrojs/compiler/types'
2
+ import { parse as parseBabel } from '@babel/parser'
3
+ import type { Expression } from '@babel/types'
2
4
  import fs from 'node:fs/promises'
3
5
  import path from 'node:path'
4
6
 
@@ -265,6 +267,8 @@ function hasExpressionChild(node: AstroNode): { found: boolean; varNames: string
265
267
  function extractCompleteTagSnippet(lines: string[], startLine: number, tag: string): string {
266
268
  const escapedTag = escapeRegex(tag)
267
269
  const openTagPattern = new RegExp(`<${escapedTag}(?:[\\s>]|$)`, 'gi')
270
+ const selfClosingPattern = new RegExp(`<${escapedTag}[^>]*/>`, 'gi')
271
+ const closeTagPattern = new RegExp(`</${escapedTag}>`, 'gi')
268
272
 
269
273
  let actualStartLine = startLine
270
274
  const startLineContent = lines[startLine] || ''
@@ -285,14 +289,16 @@ function extractCompleteTagSnippet(lines: string[], startLine: number, tag: stri
285
289
  let foundClosing = false
286
290
 
287
291
  for (let i = actualStartLine; i < Math.min(actualStartLine + 30, lines.length); i++) {
288
- const line = lines[i]
289
- if (!line) continue
292
+ const line = lines[i] ?? ''
290
293
 
294
+ // Preserve blank lines verbatim so the writer's `content.includes(snippet)`
295
+ // check passes — file content has the blank lines, the snippet must too.
291
296
  snippetLines.push(line)
297
+ if (!line) continue
292
298
 
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
299
+ const openTags = countMatches(line, openTagPattern)
300
+ const selfClosing = countMatches(line, selfClosingPattern)
301
+ const closeTags = countMatches(line, closeTagPattern)
296
302
 
297
303
  depth += openTags - selfClosing - closeTags
298
304
 
@@ -309,6 +315,14 @@ function extractCompleteTagSnippet(lines: string[], startLine: number, tag: stri
309
315
  return snippetLines.join('\n')
310
316
  }
311
317
 
318
+ /** Count global-regex matches on a string without allocating the match array. */
319
+ function countMatches(str: string, pattern: RegExp): number {
320
+ pattern.lastIndex = 0
321
+ let count = 0
322
+ while (pattern.exec(str) !== null) count++
323
+ return count
324
+ }
325
+
312
326
  /**
313
327
  * Extract the opening tag from source lines with its start line number.
314
328
  * Local version for indexing (to avoid circular dependency)
@@ -320,6 +334,8 @@ function extractOpeningTagWithLine(
320
334
  ): { snippet: string; startLine: number } | undefined {
321
335
  const escapedTag = escapeRegex(tag)
322
336
  const openTagPattern = new RegExp(`<${escapedTag}(?:[\\s>]|$)`, 'gi')
337
+ const openTagMatcher = new RegExp(`<${escapedTag}[^>]*>`, 'i')
338
+ const selfClosingMatcher = new RegExp(`<${escapedTag}[^>]*/\\s*>`, 'i')
323
339
 
324
340
  let actualStartLine = startLine
325
341
  const startLineContent = lines[startLine] || ''
@@ -343,12 +359,12 @@ function extractOpeningTagWithLine(
343
359
  snippetLines.push(line)
344
360
  const combined = snippetLines.join('\n')
345
361
 
346
- const openTagMatch = combined.match(new RegExp(`<${escapedTag}[^>]*>`, 'i'))
362
+ const openTagMatch = combined.match(openTagMatcher)
347
363
  if (openTagMatch) {
348
364
  return { snippet: openTagMatch[0], startLine: actualStartLine }
349
365
  }
350
366
 
351
- const selfClosingMatch = combined.match(new RegExp(`<${escapedTag}[^>]*/\\s*>`, 'i'))
367
+ const selfClosingMatch = combined.match(selfClosingMatcher)
352
368
  if (selfClosingMatch) {
353
369
  return { snippet: selfClosingMatch[0], startLine: actualStartLine }
354
370
  }
@@ -357,12 +373,49 @@ function extractOpeningTagWithLine(
357
373
  return undefined
358
374
  }
359
375
 
376
+ /**
377
+ * Extract a plain string from a JS expression source when it is a single string
378
+ * literal — `'foo'`, `"foo"`, or a substitution-free template literal `` `foo` ``.
379
+ * Returns null for anything else (variables, calls, concatenation, templates with `${}`).
380
+ *
381
+ * Used to index component props authored as `prop={\`literal text\`}` — the
382
+ * Astro compiler exposes the raw expression source, not the resolved value.
383
+ */
384
+ function extractSimpleStringLiteral(exprText: string): string | null {
385
+ const trimmed = exprText.trim()
386
+ if (trimmed.length < 2) return null
387
+ const quote = trimmed[0]
388
+ if (quote !== "'" && quote !== '"' && quote !== '`') return null
389
+ if (trimmed[trimmed.length - 1] !== quote) return null
390
+ const inner = trimmed.slice(1, -1)
391
+ // Reject template literals containing `${...}` substitutions — only literal templates qualify.
392
+ if (quote === '`') {
393
+ // `\\` → escaped backslash (still literal), `\$` → escaped dollar (still literal),
394
+ // any other `$` followed by `{` means an expression substitution.
395
+ const stripped = inner.replace(/\\[\\$`]/g, '')
396
+ if (/\$\{/.test(stripped)) return null
397
+ } else if (/[\r\n]/.test(inner)) {
398
+ // Multi-line plain string isn't valid JS; bail.
399
+ return null
400
+ }
401
+ // Decode common escapes: \\ \' \" \` \n \r \t plus generic `\X`.
402
+ return inner.replace(/\\(.)/g, (_, ch) => {
403
+ if (ch === 'n') return '\n'
404
+ if (ch === 'r') return '\r'
405
+ if (ch === 't') return '\t'
406
+ return ch
407
+ })
408
+ }
409
+
360
410
  /**
361
411
  * Index all searchable text content from a parsed file
362
412
  */
363
413
  export function indexFileContent(cached: CachedParsedFile, relFile: string): void {
364
414
  // Walk AST and collect all text elements
365
- function visit(node: AstroNode) {
415
+ function visit(node: AstroNode, parentExpression: AstroNode | null) {
416
+ // Track the nearest ancestor expression node (contains .map() context)
417
+ const currentExpr = node.type === 'expression' ? node : parentExpression
418
+
366
419
  if ((node.type === 'element' || node.type === 'component')) {
367
420
  const elemNode = node as ElementNode | ComponentNode
368
421
  const tag = elemNode.name.toLowerCase()
@@ -370,38 +423,41 @@ export function indexFileContent(cached: CachedParsedFile, relFile: string): voi
370
423
  const normalizedText = normalizeText(textContent)
371
424
  const line = elemNode.position?.start.line ?? 0
372
425
 
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
- }
426
+ // Variable references are indexed by their *resolved* value, so we don't
427
+ // gate on the rendered expression text length — `<li>{t}</li>` has
428
+ // textContent "t" (length 1) but resolves to real strings via .map().
429
+ const exprInfo = hasExpressionChild(elemNode)
430
+ if (exprInfo.found && exprInfo.varNames.length > 0) {
431
+ for (const exprPath of exprInfo.varNames) {
432
+ let directMatch = false
433
+ for (const def of cached.variableDefinitions) {
434
+ const defPath = buildDefinitionPath(def)
435
+ if (defPath === exprPath) {
436
+ directMatch = true
437
+ const normalizedDef = normalizeText(def.value)
438
+
439
+ addToTextSearchIndex({
440
+ file: relFile,
441
+ line: def.line,
442
+ snippet: cached.lines[def.line - 1] || '',
443
+ type: 'variable',
444
+ variableName: defPath,
445
+ definitionLine: def.line,
446
+ normalizedText: normalizedDef,
447
+ tag,
448
+ })
401
449
  }
402
450
  }
451
+
452
+ // `.map()`-driven {item.label} — trace the loop param through to the
453
+ // data source array and index every concrete element.
454
+ if (!directMatch && currentExpr) {
455
+ indexExpressionTextRef(exprPath, currentExpr, cached, relFile, tag)
456
+ }
403
457
  }
458
+ }
404
459
 
460
+ if (normalizedText && normalizedText.length >= 2) {
405
461
  // Index static text content
406
462
  const completeSnippet = extractCompleteTagSnippet(cached.lines, line - 1, tag)
407
463
  const snippet = extractInnerHtmlFromSnippet(completeSnippet, tag) ?? completeSnippet
@@ -421,81 +477,232 @@ export function indexFileContent(cached: CachedParsedFile, relFile: string): voi
421
477
  // Also index component props
422
478
  if (node.type === 'component') {
423
479
  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
- }
480
+ if (attr.type !== 'attribute' || !attr.value) continue
481
+
482
+ let propValue: string | null = null
483
+ if (attr.kind === 'quoted') {
484
+ propValue = attr.value
485
+ } else if (attr.kind === 'expression') {
486
+ // Common author pattern: pass a string-literal prop with backticks
487
+ // (e.g. `heading={\`Tři bytové domy...\`}`) so smart quotes can sit
488
+ // inside without escaping. Render-time the value is plain text — to
489
+ // the dev-middleware fallback, the rendered element looks like static
490
+ // content. Index the literal so source lookup can find the call site.
491
+ propValue = extractSimpleStringLiteral(attr.value)
437
492
  }
493
+ if (!propValue) continue
494
+
495
+ const normalizedValue = normalizeText(propValue)
496
+ if (!normalizedValue || normalizedValue.length < 2) continue
497
+
498
+ addToTextSearchIndex({
499
+ file: relFile,
500
+ line: attr.position?.start.line ?? line,
501
+ snippet: cached.lines[(attr.position?.start.line ?? line) - 1] || '',
502
+ type: 'prop',
503
+ variableName: attr.name,
504
+ normalizedText: normalizedValue,
505
+ tag,
506
+ })
438
507
  }
439
508
  }
440
509
  }
441
510
 
442
511
  if ('children' in node && Array.isArray(node.children)) {
443
512
  for (const child of node.children) {
444
- visit(child)
513
+ visit(child, currentExpr)
445
514
  }
446
515
  }
447
516
  }
448
517
 
449
- visit(cached.ast)
518
+ visit(cached.ast, null)
450
519
  }
451
520
 
452
521
  /**
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.
522
+ * Index text from an expression-based `{loopVar.prop}` (or `{loopVar}`) by tracing
523
+ * the loop variable through enclosing `.map()` calls back to the data source array,
524
+ * then adding every concrete array element's matching property to the text index.
459
525
  *
460
- * Supports chained .map() calls (nested loops).
526
+ * Without this, `<a>{item.label}</a>` inside `links.map((item) => ...)` would
527
+ * never match definitions like `links[0].label` because `item` isn't a variable
528
+ * definition — it's a `.map()` callback parameter.
529
+ */
530
+ function indexExpressionTextRef(
531
+ exprPath: string,
532
+ parentExpression: AstroNode,
533
+ cached: CachedParsedFile,
534
+ relFile: string,
535
+ tag: string,
536
+ ): void {
537
+ const baseMatch = exprPath.match(/^(\w+)(.*)$/)
538
+ if (!baseMatch) return
539
+ const baseVar = baseMatch[1]!
540
+ const suffix = baseMatch[2] ?? ''
541
+
542
+ const exprTexts: string[] = []
543
+ if ('children' in parentExpression && Array.isArray(parentExpression.children)) {
544
+ for (const child of parentExpression.children) {
545
+ if (child.type === 'text' && (child as TextNode).value) {
546
+ exprTexts.push((child as TextNode).value)
547
+ }
548
+ }
549
+ }
550
+ if (exprTexts.length === 0) return
551
+
552
+ const resolved = resolveMapChain(exprTexts, baseVar)
553
+ if (!resolved) return
554
+
555
+ // Only one of leafSuffix and suffix is non-empty in well-formed code:
556
+ // destructured `{label}` resolves to leafSuffix=".label"; simple-param
557
+ // `{item.label}` resolves to leafSuffix="" with suffix=".label".
558
+ const leafRegex = makeLeafPathRegex({
559
+ arrayPath: resolved.arrayPath,
560
+ leafSuffix: resolved.leafSuffix + suffix,
561
+ })
562
+
563
+ for (const def of cached.variableDefinitions) {
564
+ const defPath = buildDefinitionPath(def)
565
+ if (!leafRegex.test(defPath)) continue
566
+ const normalizedDef = normalizeText(def.value)
567
+ if (normalizedDef.length < 2) continue
568
+ addToTextSearchIndex({
569
+ file: relFile,
570
+ line: def.line,
571
+ snippet: cached.lines[def.line - 1] || '',
572
+ type: 'variable',
573
+ variableName: defPath,
574
+ definitionLine: def.line,
575
+ normalizedText: normalizedDef,
576
+ tag,
577
+ })
578
+ }
579
+ }
580
+
581
+ export interface ResolvedMapChain {
582
+ /** Source array path with [*] wildcards for nested chains, e.g. "links" or "categories[*].images" */
583
+ arrayPath: string
584
+ /** Leaf suffix appended to a concrete element. Empty for simple params, ".<propName>" for destructured. */
585
+ leafSuffix: string
586
+ }
587
+
588
+ /**
589
+ * Parsed `.map()` invocation found in expression text.
590
+ * Either has a simple parameter name OR a list of destructured property names.
461
591
  */
462
- export function resolveMapChain(exprTexts: string[], paramName: string): string | null {
463
- const fullText = exprTexts.join('')
592
+ interface MapInvocation {
593
+ arrayExpr: string
594
+ /** Simple callback parameter, e.g. `(item)` → `"item"`. Null when the callback destructures. */
595
+ param: string | null
596
+ /** Destructured property names from `({ a, b: bAlias = 'x' })`. Captures the local binding name. */
597
+ destructured: string[]
598
+ }
464
599
 
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 }> = []
600
+ /**
601
+ * Parse all `.map(...)` invocations from joined expression text. Captures both simple
602
+ * params (`(item)`) and destructured object params (`({ label, href })`).
603
+ */
604
+ function parseMapInvocations(fullText: string): MapInvocation[] {
605
+ const mapPattern = /([\w.[\]]+)\.map\(\s*(?:\(\s*(\w+)|\(\s*\{([^}]+)\})/g
606
+ const maps: MapInvocation[] = []
469
607
  let match: RegExpExecArray | null
470
608
  while ((match = mapPattern.exec(fullText)) !== null) {
471
- maps.push({ arrayExpr: match[1]!, param: match[2]! })
609
+ const arrayExpr = match[1]!
610
+ const simple = match[2] ?? null
611
+ const destructured: string[] = match[3]
612
+ ? match[3].split(',').map((entry) => {
613
+ // Capture the *local binding* — alias for `prop: alias`, otherwise prop itself.
614
+ const trimmed = entry.trim()
615
+ if (!trimmed) return ''
616
+ const renameMatch = trimmed.match(/^(\w+)\s*:\s*(\w+)/)
617
+ if (renameMatch) return renameMatch[2]!
618
+ const nameMatch = trimmed.match(/^(\w+)/)
619
+ return nameMatch?.[1] ?? ''
620
+ }).filter(Boolean)
621
+ : []
622
+ maps.push({ arrayExpr, param: simple, destructured })
623
+ }
624
+ return maps
625
+ }
626
+
627
+ const BARE_IDENTIFIER = /^[A-Za-z_$][\w$]*$/
628
+
629
+ function walkAccessor(node: Expression): { base: string; suffix: string } | null {
630
+ if (node.type === 'Identifier') {
631
+ return { base: node.name, suffix: '' }
632
+ }
633
+ if (node.type === 'MemberExpression' || node.type === 'OptionalMemberExpression') {
634
+ if (node.object.type === 'Super') return null
635
+ const inner = walkAccessor(node.object)
636
+ if (!inner) return null
637
+ const { property, computed } = node
638
+ let part: string
639
+ if (!computed && property.type === 'Identifier') {
640
+ part = `.${property.name}`
641
+ } else if (computed && property.type === 'NumericLiteral') {
642
+ part = `[${property.value}]`
643
+ } else {
644
+ return null
645
+ }
646
+ return { base: inner.base, suffix: inner.suffix + part }
647
+ }
648
+ return null
649
+ }
650
+
651
+ function parseAccessorChain(exprText: string): { base: string; suffix: string } | null {
652
+ if (BARE_IDENTIFIER.test(exprText)) return { base: exprText, suffix: '' }
653
+ try {
654
+ const file = parseBabel(exprText, { sourceType: 'module', plugins: ['typescript'] })
655
+ const stmt = file.program.body[0]
656
+ if (!stmt || stmt.type !== 'ExpressionStatement') return null
657
+ return walkAccessor(stmt.expression)
658
+ } catch {
659
+ return null
472
660
  }
661
+ }
473
662
 
663
+ /**
664
+ * Resolve a `.map()` callback parameter back to the source array path.
665
+ *
666
+ * Examples:
667
+ * `images.map((img) => …)` looking for `img`
668
+ * → `{ arrayPath: "images", leafSuffix: "" }`
669
+ * `categories.map((cat) => cat.images.map((img) => …))` looking for `img`
670
+ * → `{ arrayPath: "categories[*].images", leafSuffix: "" }`
671
+ * `links.map(({ label, href }) => …)` looking for `label`
672
+ * → `{ arrayPath: "links", leafSuffix: ".label" }`
673
+ * `services.map((service) => …)` looking for `service.image`
674
+ * → `{ arrayPath: "services", leafSuffix: ".image" }`
675
+ *
676
+ * Returns null when the name doesn't appear as a parameter or destructured binding.
677
+ */
678
+ export function resolveMapChain(exprTexts: string[], paramName: string): ResolvedMapChain | null {
679
+ const maps = parseMapInvocations(exprTexts.join(''))
474
680
  if (maps.length === 0) return null
475
681
 
476
- // Find which .map() provides our paramName
477
- const directMap = maps.find(m => m.param === paramName)
682
+ const access = parseAccessorChain(paramName)
683
+ const baseName = access?.base ?? paramName
684
+ const memberSuffix = access?.suffix ?? ''
685
+
686
+ const directMap = maps.find((m) => m.param === baseName)
687
+ ?? maps.find((m) => m.destructured.includes(baseName))
478
688
  if (!directMap) return null
479
689
 
480
- // Resolve the array expression by substituting outer .map() params
481
- // e.g., "cat.images" where "cat" comes from "categories.map((cat) => ...)"
690
+ const isDestructured = directMap.param !== baseName
691
+ const leafSuffix = (isDestructured ? `.${baseName}` : '') + memberSuffix
692
+
693
+ // Resolve the array expression by substituting outer .map() params (chained / nested loops).
482
694
  let arrayPath = directMap.arrayExpr
483
695
  for (const outerMap of maps) {
484
696
  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"
697
+ if (!outerMap.param) continue // Outer destructure can't appear as the head of `arrayExpr`.
487
698
  if (arrayPath === outerMap.param || arrayPath.startsWith(outerMap.param + '.')) {
488
- const suffix = arrayPath.slice(outerMap.param.length) // ".images" or ""
699
+ const suffix = arrayPath.slice(outerMap.param.length)
489
700
  const resolvedOuter = resolveMapChain(exprTexts, outerMap.param)
490
- if (resolvedOuter) {
491
- arrayPath = resolvedOuter + '[*]' + suffix
492
- } else {
493
- arrayPath = outerMap.arrayExpr + '[*]' + suffix
494
- }
701
+ arrayPath = (resolvedOuter ? resolvedOuter.arrayPath : outerMap.arrayExpr) + '[*]' + suffix
495
702
  }
496
703
  }
497
704
 
498
- return arrayPath
705
+ return { arrayPath, leafSuffix }
499
706
  }
500
707
 
501
708
  /**
@@ -537,15 +744,18 @@ function indexExpressionImageSrc(
537
744
  if (exprTexts.length === 0) return
538
745
 
539
746
  // Resolve the .map() chain to find the source array path
540
- const arrayPath = resolveMapChain(exprTexts, exprValue)
541
- if (!arrayPath) return
747
+ const resolved = resolveMapChain(exprTexts, exprValue)
748
+ if (!resolved) return
749
+
750
+ const leafRegex = makeLeafPathRegex(resolved)
542
751
 
543
752
  for (const def of cached.variableDefinitions) {
544
753
  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)) {
754
+ // Match definitions that are leaves under the array
755
+ // e.g., simple: "images" matches "images[0]", "images[1]"
756
+ // e.g., destructured: arrayPath="links", leafSuffix=".src" matches "links[0].src"
757
+ // e.g., nested: "categories[*].images" matches "categories[0].images[0]", etc.
758
+ if (leafRegex.test(defPath)) {
549
759
  const snippet = cached.lines[def.line - 1]?.trim() || ''
550
760
  addToImageSearchIndex({
551
761
  file: relFile,
@@ -557,28 +767,42 @@ function indexExpressionImageSrc(
557
767
  }
558
768
  }
559
769
 
770
+ /**
771
+ * Compile a wildcard array path into the inner regex body. `[*]` segments
772
+ * become `\[\d+\]`; everything else is escaped literal.
773
+ *
774
+ * Examples:
775
+ * "links" → /links/
776
+ * "categories[*].images" → /categories\[\d+\]\.images/
777
+ */
778
+ export function wildcardPathToRegexBody(arrayPath: string): string {
779
+ const segments = arrayPath.split('[*]')
780
+ let body = ''
781
+ for (let i = 0; i < segments.length; i++) {
782
+ body += escapeRegex(segments[i]!)
783
+ if (i < segments.length - 1) body += '\\[\\d+\\]'
784
+ }
785
+ return body
786
+ }
787
+
788
+ /**
789
+ * Build a regex that matches concrete leaf paths *one level under* a resolved
790
+ * .map() chain — appends `\[\d+\]` for the array element index, then the
791
+ * literal `leafSuffix` (e.g. `.label`).
792
+ */
793
+ export function makeLeafPathRegex({ arrayPath, leafSuffix }: ResolvedMapChain): RegExp {
794
+ return new RegExp('^' + wildcardPathToRegexBody(arrayPath) + '\\[\\d+\\]' + escapeRegex(leafSuffix) + '$')
795
+ }
796
+
560
797
  /**
561
798
  * 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
799
  *
565
800
  * e.g., "images[0]" is a child of "images"
566
801
  * e.g., "categories[0].images[1]" is a child of "categories[*].images"
567
802
  * e.g., "categories[0].images[1].url" is NOT a child (too deep)
568
803
  */
569
804
  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)
805
+ return makeLeafPathRegex({ arrayPath, leafSuffix: '' }).test(defPath)
582
806
  }
583
807
 
584
808
  /**
@@ -1060,6 +1284,46 @@ export function findTranslationByKeyAndText(key: string, normalizedText: string)
1060
1284
  return fallback
1061
1285
  }
1062
1286
 
1287
+ /**
1288
+ * Look up a `variable`-type index hit for `textContent`+`tag` constrained to a
1289
+ * specific source file. Used when an entry already has a sourcePath (set from
1290
+ * Astro's `data-astro-source-file`) but we want to upgrade the sourceLine from
1291
+ * the JSX template line to the actual variable definition line. Same-file
1292
+ * constraint avoids cross-file false positives where the same string lives in
1293
+ * an unrelated file.
1294
+ *
1295
+ * Astro stamps `data-astro-source-file` with an absolute filesystem path while
1296
+ * the search index stores paths relative to the project root, so the caller's
1297
+ * `file` is normalized to relative form before comparison.
1298
+ */
1299
+ export function findVariableHitInFile(
1300
+ textContent: string,
1301
+ tag: string,
1302
+ file: string,
1303
+ ): SourceLocation | undefined {
1304
+ const normalizedSearch = normalizeText(textContent)
1305
+ const tagLower = tag.toLowerCase()
1306
+ const fileRel = toProjectRelativePath(file)
1307
+ const index = getTextSearchIndex()
1308
+ for (const entry of index) {
1309
+ if (entry.file !== fileRel) continue
1310
+ if (entry.type !== 'variable') continue
1311
+ if (entry.normalizedText !== normalizedSearch) continue
1312
+ if (entry.tag !== tagLower) continue
1313
+ return textEntryToLocation(entry)
1314
+ }
1315
+ return undefined
1316
+ }
1317
+
1318
+ /**
1319
+ * Convert a file path to project-relative form, accepting either absolute
1320
+ * paths (as Astro stamps them) or already-relative paths (as the index uses).
1321
+ */
1322
+ function toProjectRelativePath(file: string): string {
1323
+ if (!path.isAbsolute(file)) return file
1324
+ return path.relative(getProjectRoot(), file)
1325
+ }
1326
+
1063
1327
  /**
1064
1328
  * Fast text lookup using pre-built index
1065
1329
  */