@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.
- package/dist/editor.js +6119 -6090
- package/package.json +1 -1
- package/src/dev-middleware.ts +17 -1
- package/src/editor/components/editable-highlights.tsx +18 -56
- package/src/editor/dom.ts +37 -0
- package/src/editor/editor.ts +42 -1
- package/src/index.ts +8 -1
- package/src/source-finder/cross-file-tracker.ts +16 -6
- package/src/source-finder/element-finder.ts +135 -3
- package/src/source-finder/search-index.ts +319 -98
- package/src/source-finder/snippet-utils.ts +45 -42
- package/src/source-finder/source-lookup.ts +5 -2
- package/src/utils.ts +41 -0
|
@@ -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
|
|
294
|
-
const selfClosing = (line
|
|
295
|
-
const closeTags = (line
|
|
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(
|
|
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(
|
|
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
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
-
*
|
|
454
|
-
*
|
|
455
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
463
|
-
|
|
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
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
481
|
-
|
|
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
|
-
//
|
|
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)
|
|
656
|
+
const suffix = arrayPath.slice(outerMap.param.length)
|
|
489
657
|
const resolvedOuter = resolveMapChain(exprTexts, outerMap.param)
|
|
490
|
-
|
|
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
|
|
541
|
-
if (!
|
|
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
|
|
546
|
-
// e.g.,
|
|
547
|
-
// e.g.,
|
|
548
|
-
|
|
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
|
-
|
|
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
|
*/
|