@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.
- package/dist/editor.js +11524 -11456
- package/package.json +1 -1
- package/src/dev-middleware.ts +51 -6
- package/src/editor/components/editable-highlights.tsx +18 -56
- package/src/editor/components/markdown-editor-overlay.tsx +2 -0
- package/src/editor/constants.ts +2 -0
- package/src/editor/dom.ts +37 -0
- package/src/editor/editor.ts +66 -8
- package/src/editor/index.tsx +11 -6
- package/src/editor/markdown-api.ts +9 -0
- package/src/editor/signals.ts +113 -7
- package/src/editor/storage.ts +172 -196
- package/src/editor/types.ts +2 -0
- package/src/handlers/api-routes.ts +27 -14
- package/src/handlers/request-utils.ts +10 -1
- package/src/index.ts +21 -10
- 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 +362 -98
- package/src/source-finder/snippet-utils.ts +45 -42
- package/src/source-finder/source-lookup.ts +5 -2
|
@@ -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
|
|
294
|
-
const selfClosing = (line
|
|
295
|
-
const closeTags = (line
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
-
*
|
|
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.
|
|
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
|
-
*
|
|
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
|
-
|
|
463
|
-
|
|
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
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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
|
-
|
|
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
|
-
|
|
477
|
-
const
|
|
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
|
-
|
|
481
|
-
|
|
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
|
-
//
|
|
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)
|
|
699
|
+
const suffix = arrayPath.slice(outerMap.param.length)
|
|
489
700
|
const resolvedOuter = resolveMapChain(exprTexts, outerMap.param)
|
|
490
|
-
|
|
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
|
|
541
|
-
if (!
|
|
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
|
|
546
|
-
// e.g.,
|
|
547
|
-
// e.g.,
|
|
548
|
-
|
|
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
|
-
|
|
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
|
*/
|