@nuasite/cms 0.35.0 → 0.36.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 CHANGED
@@ -381,7 +381,7 @@ function PS(t, e) {
381
381
  function LS(t, e) {
382
382
  return typeof e == "function" ? e(t) : e;
383
383
  }
384
- const o3 = "0.35.0", a3 = o3, nt = {
384
+ const o3 = "0.36.0", a3 = o3, nt = {
385
385
  /** Highlight overlay for hovered elements */
386
386
  HIGHLIGHT: 2147483644,
387
387
  /** Hover outline for elements/components */
package/package.json CHANGED
@@ -14,7 +14,7 @@
14
14
  "directory": "packages/astro-cms"
15
15
  },
16
16
  "license": "Apache-2.0",
17
- "version": "0.35.0",
17
+ "version": "0.36.0",
18
18
  "module": "src/index.ts",
19
19
  "types": "src/index.ts",
20
20
  "type": "module",
@@ -47,22 +47,41 @@ export async function pickAstroImageTarget(args: {
47
47
  return { absPath: baseAbs, relPath: `./${baseName}` }
48
48
  }
49
49
 
50
- const hash = shortContentHash(args.compareBuffer)
51
- const ext = path.extname(safeFilename)
50
+ const candidateAbs = await pickHashedSibling(entryDir, baseName, args.compareBuffer)
51
+ return { absPath: candidateAbs, relPath: `./${path.basename(candidateAbs)}` }
52
+ }
53
+
54
+ /**
55
+ * Pick an absolute path in `dir` for `filename`; if a file with the same name already
56
+ * exists, content-hash-suffix it. Reuses an existing file with identical bytes.
57
+ *
58
+ * `filename` may come from a URL pathname or other untrusted source — directory
59
+ * components are stripped via `path.basename` so a `../` segment can't escape `dir`.
60
+ */
61
+ export async function pickSiblingTarget(dir: string, filename: string, buf: Buffer): Promise<string> {
62
+ const safe = path.basename(filename)
63
+ if (!safe || safe === '.' || safe === '..') {
64
+ throw new Error(`Invalid filename: ${filename}`)
65
+ }
66
+ const baseAbs = path.join(dir, safe)
67
+ if (await isFreeOrMatching(baseAbs, buf)) return baseAbs
68
+ return pickHashedSibling(dir, safe, buf)
69
+ }
70
+
71
+ async function pickHashedSibling(dir: string, baseName: string, buf: Buffer): Promise<string> {
72
+ const hash = shortContentHash(buf)
73
+ const ext = path.extname(baseName)
52
74
  const stem = path.basename(baseName, ext)
53
75
  for (let attempt = 0; attempt < 5; attempt++) {
54
76
  const suffix = attempt === 0 ? hash : `${hash}-${attempt}`
55
- const candidateName = `${stem}-${suffix}${ext}`
56
- const candidateAbs = path.join(entryDir, candidateName)
57
- if (await isFreeOrMatching(candidateAbs, args.compareBuffer)) {
58
- return { absPath: candidateAbs, relPath: `./${candidateName}` }
59
- }
77
+ const candidateAbs = path.join(dir, `${stem}-${suffix}${ext}`)
78
+ if (await isFreeOrMatching(candidateAbs, buf)) return candidateAbs
60
79
  }
61
- throw new Error(`Could not pick a unique filename for ${safeFilename} in ${entryDir}`)
80
+ throw new Error(`Could not pick a unique filename for ${baseName} in ${dir}`)
62
81
  }
63
82
 
64
83
  /** True if the slot is empty, or holds a file with identical bytes to `compareBuffer`. */
65
- async function isFreeOrMatching(absPath: string, compareBuffer: Buffer): Promise<boolean> {
84
+ export async function isFreeOrMatching(absPath: string, compareBuffer: Buffer): Promise<boolean> {
66
85
  try {
67
86
  const stat = await fs.stat(absPath)
68
87
  if (stat.size !== compareBuffer.length) return false
@@ -2,12 +2,13 @@ import { NodeType, parse as parseHtml } from 'node-html-parser'
2
2
  import fs from 'node:fs/promises'
3
3
  import path from 'node:path'
4
4
  import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'
5
+ import { pickSiblingTarget } from '../astro-image-paths'
5
6
  import { getProjectRoot } from '../config'
6
7
  import type { AttributeChangePayload, ChangePayload, SaveBatchRequest } from '../editor/types'
7
8
  import type { ManifestWriter } from '../manifest-writer'
8
9
  import { extractAstroImageOriginalUrl } from '../source-finder/snippet-utils'
9
10
  import type { CmsManifest, ManifestEntry } from '../types'
10
- import { acquireFileLock, escapeRegex, escapeReplacement, normalizePagePath, resolveAndValidatePath } from '../utils'
11
+ import { acquireFileLock, escapeRegex, escapeReplacement, normalizePagePath, relativeImportPath, resolveAndValidatePath } from '../utils'
11
12
 
12
13
  export interface SaveBatchResponse {
13
14
  updated: number
@@ -56,16 +57,23 @@ export async function handleUpdate(
56
57
  try {
57
58
  const currentContent = await fs.readFile(fullPath, 'utf-8')
58
59
 
59
- const { newContent, appliedCount, failedChanges } = applyChanges(
60
+ const { newContent, appliedCount, failedChanges, fileOps } = await applyChanges(
60
61
  currentContent,
61
62
  fileChanges,
62
63
  manifest,
64
+ fullPath,
65
+ meta.url,
63
66
  )
64
67
  if (failedChanges.length > 0) {
65
68
  errors.push(...failedChanges)
66
69
  }
67
70
 
68
71
  if (appliedCount > 0 && newContent !== currentContent) {
72
+ // Write assets first so the source file never points at missing files.
73
+ for (const op of fileOps) {
74
+ await fs.mkdir(path.dirname(op.target), { recursive: true })
75
+ await fs.writeFile(op.target, op.bytes)
76
+ }
69
77
  await fs.writeFile(fullPath, newContent, 'utf-8')
70
78
  updated += appliedCount
71
79
  }
@@ -86,18 +94,28 @@ export async function handleUpdate(
86
94
  }
87
95
  }
88
96
 
89
- function applyChanges(
97
+ /** Asset write that must land alongside the source rewrite (assets first, then source). */
98
+ interface PendingFileOp {
99
+ target: string
100
+ bytes: Buffer
101
+ }
102
+
103
+ async function applyChanges(
90
104
  content: string,
91
105
  changes: ChangePayload[],
92
106
  manifest: CmsManifest,
93
- ): {
107
+ absFilePath: string,
108
+ originUrl: string,
109
+ ): Promise<{
94
110
  newContent: string
95
111
  appliedCount: number
96
112
  failedChanges: Array<{ cmsId: string; error: string }>
97
- } {
113
+ fileOps: PendingFileOp[]
114
+ }> {
98
115
  let newContent = content
99
116
  let appliedCount = 0
100
117
  const failedChanges: Array<{ cmsId: string; error: string }> = []
118
+ const fileOps: PendingFileOp[] = []
101
119
 
102
120
  // Sort changes by source line descending to prevent offset shifts
103
121
  const sortedChanges = [...changes].sort(
@@ -107,9 +125,10 @@ function applyChanges(
107
125
  for (const change of sortedChanges) {
108
126
  // Handle image changes
109
127
  if (change.imageChange) {
110
- const result = applyImageChange(newContent, change)
128
+ const result = await applyImageChange(newContent, change, absFilePath, originUrl)
111
129
  if (result.success) {
112
130
  newContent = result.content
131
+ if (result.fileOp) fileOps.push(result.fileOp)
113
132
  appliedCount++
114
133
  } else {
115
134
  failedChanges.push({ cmsId: change.cmsId, error: result.error })
@@ -150,13 +169,15 @@ function applyChanges(
150
169
  }
151
170
  }
152
171
 
153
- return { newContent, appliedCount, failedChanges }
172
+ return { newContent, appliedCount, failedChanges, fileOps }
154
173
  }
155
174
 
156
- export function applyImageChange(
175
+ export async function applyImageChange(
157
176
  content: string,
158
177
  change: ChangePayload,
159
- ): { success: true; content: string } | { success: false; error: string } {
178
+ absFilePath?: string,
179
+ originUrl?: string,
180
+ ): Promise<{ success: true; content: string; fileOp?: PendingFileOp } | { success: false; error: string }> {
160
181
  const { newSrc, newAlt } = change.imageChange!
161
182
  const originalSrc = change.originalValue
162
183
 
@@ -278,6 +299,7 @@ export function applyImageChange(
278
299
 
279
300
  // Fallback: if literal src not found, try to find an expression-based src attribute
280
301
  // near the source line (handles src={variable}, src={obj.prop}, etc.)
302
+ let pendingFileOp: PendingFileOp | undefined
281
303
  if (replacedIndex < 0 && change.sourceLine > 0) {
282
304
  const lines = newContent.split('\n')
283
305
  const targetLineIdx = change.sourceLine - 1
@@ -292,13 +314,31 @@ export function applyImageChange(
292
314
  if (/<img\b/i.test(regionText) || /<Image\b/.test(regionText)) {
293
315
  const exprMatch = findExpressionSrcAttribute(regionText)
294
316
  if (exprMatch) {
295
- // Any expression-based src (variable, function call, template literal, etc.)
296
- // cannot be safely replaced with a static string — refuse the edit.
297
317
  const exprContent = regionText.slice(
298
318
  exprMatch.index + regionText.slice(exprMatch.index).indexOf('{') + 1,
299
319
  exprMatch.index + exprMatch.length - 1,
300
320
  ).trim()
301
- return { success: false, error: `Image src uses a dynamic expression (src={${exprContent}}) — edit the data source directly` }
321
+
322
+ // `<Image src={importedAsset}>` where `importedAsset` is a frontmatter asset
323
+ // import: prefer rewriting the import target so Astro's asset pipeline still
324
+ // processes the new image. Falls back to inline JSX replacement when the new
325
+ // src can't be resolved on disk (e.g. external URLs, non-local media adapters).
326
+ const importInfo = /^\w+$/.test(exprContent) ? findFrontmatterAssetImport(content, exprContent) : null
327
+ if (!importInfo) {
328
+ return { success: false, error: `Image src uses a dynamic expression (src={${exprContent}}) — edit the data source directly` }
329
+ }
330
+ const rewrite = absFilePath
331
+ ? await tryRewriteAssetImport(content, importInfo, newSrc, absFilePath, originUrl)
332
+ : null
333
+ if (rewrite) {
334
+ newContent = rewrite.content
335
+ pendingFileOp = rewrite.fileOp
336
+ replacedIndex = rewrite.importSourceIndex
337
+ } else {
338
+ const literalResult = inlineJsxLiteralReplace(newContent, lines, regionStart, exprMatch, newSrc)
339
+ newContent = literalResult.content
340
+ replacedIndex = literalResult.replacedIndex
341
+ }
302
342
  }
303
343
  }
304
344
  }
@@ -352,7 +392,159 @@ export function applyImageChange(
352
392
  }
353
393
  }
354
394
 
355
- return { success: true, content: newContent }
395
+ return { success: true, content: newContent, fileOp: pendingFileOp }
396
+ }
397
+
398
+ interface FrontmatterAssetImport {
399
+ /** Local binding name (e.g., `hero`). */
400
+ localName: string
401
+ /** Import source as written in the frontmatter (e.g., `'../assets/hero.png'`). */
402
+ source: string
403
+ /** Character offset of `source` (without quotes) in `content`. */
404
+ sourceStart: number
405
+ /** Character offset just past the `source` string (without quotes) in `content`. */
406
+ sourceEnd: number
407
+ }
408
+
409
+ const ASSET_IMPORT_EXT_RE = /\.(jpe?g|png|gif|webp|avif|svg|ico|bmp|tiff?)$/i
410
+
411
+ /**
412
+ * Locate the frontmatter `import varName from '<asset-path>'` statement that binds
413
+ * `varName` to a relative image asset. Returns the binding's source-string position
414
+ * so callers can rewrite just the path without re-tokenizing the import.
415
+ */
416
+ function findFrontmatterAssetImport(content: string, varName: string): FrontmatterAssetImport | null {
417
+ const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
418
+ if (!fmMatch) return null
419
+ const fmStart = fmMatch[0].indexOf(fmMatch[1]!)
420
+ const importRe = /^\s*import\s+(?!type\b)([\s\S]+?)\s+from\s+(['"])([^'"]+)\2/gm
421
+ let m: RegExpExecArray | null
422
+ while ((m = importRe.exec(fmMatch[1]!)) !== null) {
423
+ const source = m[3]!
424
+ if (!source.startsWith('.') || !ASSET_IMPORT_EXT_RE.test(source)) continue
425
+ // Skip per-binding `import { type X } from '...'` — those are erased at compile time.
426
+ const tokens = m[1]!.replace(/[{}]/g, ',').split(',').map(s => s.trim()).filter(t => t && !t.startsWith('type '))
427
+ const matches = tokens.some(tok => {
428
+ const aliasMatch = tok.match(/^\S+\s+as\s+(\S+)$/)
429
+ return (aliasMatch ? aliasMatch[1]! : tok) === varName
430
+ })
431
+ if (!matches) continue
432
+ // Compute absolute position of the path string (between the quote chars).
433
+ const matchStart = fmStart + m.index
434
+ const sourceStart = matchStart + m[0]!.indexOf(m[2]!) + 1
435
+ return { localName: varName, source, sourceStart, sourceEnd: sourceStart + source.length }
436
+ }
437
+ return null
438
+ }
439
+
440
+ /**
441
+ * Pure literal swap of `src={var}` → `src="<newSrc>"`. The fallback when import-rewrite
442
+ * isn't possible (e.g. the new src can't be read from disk).
443
+ */
444
+ function inlineJsxLiteralReplace(
445
+ content: string,
446
+ lines: string[],
447
+ regionStart: number,
448
+ exprMatch: { index: number; length: number },
449
+ newSrc: string,
450
+ ): { content: string; replacedIndex: number } {
451
+ let regionStartOffset = 0
452
+ for (let i = 0; i < regionStart; i++) regionStartOffset += lines[i]!.length + 1
453
+ const absIndex = regionStartOffset + exprMatch.index
454
+ return {
455
+ content: content.slice(0, absIndex) + `src="${escapeReplacement(newSrc)}"` + content.slice(absIndex + exprMatch.length),
456
+ replacedIndex: absIndex,
457
+ }
458
+ }
459
+
460
+ /**
461
+ * Rewrite the frontmatter import target so Astro's asset pipeline picks up the new image,
462
+ * and emit a paired file write for the bytes. Returns null only when the new src can't
463
+ * be resolved at all — caller falls back to inline JSX.
464
+ */
465
+ async function tryRewriteAssetImport(
466
+ content: string,
467
+ importInfo: FrontmatterAssetImport,
468
+ newSrc: string,
469
+ absFilePath: string,
470
+ originUrl?: string,
471
+ ): Promise<{ content: string; fileOp: PendingFileOp; importSourceIndex: number } | null> {
472
+ const resolved = await resolveNewSrcBytes(newSrc, originUrl)
473
+ if (!resolved) return null
474
+
475
+ const originalAssetAbs = path.resolve(path.dirname(absFilePath), importInfo.source)
476
+ const targetAbs = await pickSiblingTarget(path.dirname(originalAssetAbs), resolved.filename, resolved.bytes)
477
+
478
+ const newRelImport = relativeImportPath(absFilePath, targetAbs)
479
+ const newContent = content.slice(0, importInfo.sourceStart) + newRelImport + content.slice(importInfo.sourceEnd)
480
+
481
+ return {
482
+ content: newContent,
483
+ fileOp: { target: targetAbs, bytes: resolved.bytes },
484
+ importSourceIndex: importInfo.sourceStart,
485
+ }
486
+ }
487
+
488
+ /**
489
+ * Resolve a new image src to bytes. Tries (in order): the local on-disk location matching
490
+ * the path's prefix (`/src/...` → project, `/...` → public/), then an HTTP fetch as a
491
+ * universal fallback for external URLs and remote media adapters.
492
+ */
493
+ async function resolveNewSrcBytes(
494
+ newSrc: string,
495
+ originUrl: string | undefined,
496
+ ): Promise<{ bytes: Buffer; filename: string } | null> {
497
+ const filenameFromPath = (p: string) => path.basename(p.split('?')[0] ?? p)
498
+
499
+ const diskPath = newSrc.startsWith('/src/')
500
+ ? path.join(getProjectRoot(), newSrc.slice(1))
501
+ : newSrc.startsWith('/') && !newSrc.startsWith('//')
502
+ ? path.join(getProjectRoot(), 'public', newSrc.replace(/^\/+/, ''))
503
+ : null
504
+ if (diskPath) {
505
+ try {
506
+ return { bytes: await fs.readFile(diskPath), filename: filenameFromPath(newSrc) }
507
+ } catch {
508
+ // Fall through to HTTP fetch
509
+ }
510
+ }
511
+
512
+ try {
513
+ const isAbsolute = /^https?:\/\//.test(newSrc)
514
+ if (!isAbsolute && !originUrl) return null
515
+ const fetchUrl = isAbsolute ? newSrc : new URL(newSrc, originUrl).toString()
516
+ const res = await fetch(fetchUrl, { signal: AbortSignal.timeout(REMOTE_FETCH_TIMEOUT_MS) })
517
+ if (!res.ok) return null
518
+ // Cap the response so a malicious or misbehaving remote can't OOM the dev server.
519
+ const bytes = await readBoundedBody(res, REMOTE_FETCH_MAX_BYTES)
520
+ if (!bytes) return null
521
+ return { bytes, filename: filenameFromPath(new URL(fetchUrl).pathname) }
522
+ } catch {
523
+ return null
524
+ }
525
+ }
526
+
527
+ const REMOTE_FETCH_TIMEOUT_MS = 15_000
528
+ const REMOTE_FETCH_MAX_BYTES = 50 * 1024 * 1024
529
+
530
+ async function readBoundedBody(res: Response, maxBytes: number): Promise<Buffer | null> {
531
+ const declared = Number(res.headers.get('content-length'))
532
+ if (declared > maxBytes) return null
533
+ if (!res.body) return null
534
+ const reader = res.body.getReader()
535
+ const chunks: Uint8Array[] = []
536
+ let total = 0
537
+ while (true) {
538
+ const { done, value } = await reader.read()
539
+ if (done) break
540
+ total += value.byteLength
541
+ if (total > maxBytes) {
542
+ await reader.cancel()
543
+ return null
544
+ }
545
+ chunks.push(value)
546
+ }
547
+ return Buffer.concat(chunks, total)
356
548
  }
357
549
 
358
550
  function applyColorChange(
@@ -881,6 +1073,11 @@ export function findExpressionAltAttribute(text: string): { index: number; lengt
881
1073
  return findExpressionAttribute(text, 'alt')
882
1074
  }
883
1075
 
1076
+ /** True when `varName` is bound by a frontmatter `import ... from '<relative-asset-path>'`. */
1077
+ export function isFrontmatterAssetImport(content: string, varName: string): boolean {
1078
+ return findFrontmatterAssetImport(content, varName) !== null
1079
+ }
1080
+
884
1081
  /**
885
1082
  * Extract visible text from an HTML string the way a browser would render it.
886
1083
  * Text nodes contribute their content, <br> elements become '\n',
@@ -21,7 +21,7 @@ import {
21
21
  setCollectionTextIndex,
22
22
  setSearchIndexInitialized,
23
23
  } from './cache'
24
- import { extractImageSnippet, extractInnerHtmlFromSnippet, normalizeText } from './snippet-utils'
24
+ import { extractAstroImageOriginalUrl, extractImageSnippet, extractInnerHtmlFromSnippet, normalizeText } from './snippet-utils'
25
25
  import type { CachedParsedFile, SearchIndexEntry, SourceLocation } from './types'
26
26
 
27
27
  /** Collection data files live under this path — used to prefer them over templates */
@@ -498,6 +498,21 @@ export function resolveMapChain(exprTexts: string[], paramName: string): string
498
498
  return arrayPath
499
499
  }
500
500
 
501
+ /**
502
+ * Build a map of locally-imported asset bindings (e.g. `import hero from './x.png'`)
503
+ * to their absolute on-disk paths. Only relative imports with an image extension are
504
+ * included — these are the bindings that can appear as `<Image src={hero} />`.
505
+ */
506
+ function buildImportedAssetMap(imports: CachedParsedFile['imports'], relFile: string): Map<string, string> {
507
+ const map = new Map<string, string>()
508
+ const fromDir = path.dirname(path.join(getProjectRoot(), relFile))
509
+ for (const imp of imports) {
510
+ if (!imp.source.startsWith('.') || !IMAGE_EXTENSIONS.test(imp.source)) continue
511
+ map.set(imp.localName, path.resolve(fromDir, imp.source))
512
+ }
513
+ return map
514
+ }
515
+
501
516
  /**
502
517
  * Index images from an expression-based src={variable} by tracing
503
518
  * the variable through .map() calls back to the data source array,
@@ -572,6 +587,12 @@ export function isChildOfArray(defPath: string, arrayPath: string): boolean {
572
587
  export function indexFileImages(cached: CachedParsedFile, relFile: string): void {
573
588
  // For Astro files, use AST
574
589
  if (relFile.endsWith('.astro')) {
590
+ // Map locally-imported asset bindings (e.g. `import hero from './hero.png'`)
591
+ // to the absolute on-disk path so dev-mode optimized URLs that embed that
592
+ // path (e.g. `/@image/...?f=/abs/path/hero.png`) can resolve back to the
593
+ // `<Image src={hero} />` JSX site.
594
+ const importedAssetAbsPath = buildImportedAssetMap(cached.imports, relFile)
595
+
575
596
  function visit(node: AstroNode, parentExpression: AstroNode | null) {
576
597
  // Track the nearest ancestor expression node (contains .map() context)
577
598
  const currentExpr = node.type === 'expression' ? node : parentExpression
@@ -585,24 +606,23 @@ export function indexFileImages(cached: CachedParsedFile, relFile: string): void
585
606
  for (const attr of elemNode.attributes) {
586
607
  if (attr.type !== 'attribute' || attr.name !== 'src' || !attr.value) continue
587
608
 
588
- if ((attr as any).kind === 'expression' && currentExpr) {
589
- // Expression src={variable} trace through .map() to data source
590
- indexExpressionImageSrc(
591
- attr.value,
592
- currentExpr,
593
- cached,
594
- relFile,
595
- )
596
- } else if ((attr as any).kind !== 'expression') {
609
+ const isExpression = (attr as any).kind === 'expression'
610
+ const srcLine = attr.position?.start.line ?? elemNode.position?.start.line ?? 0
611
+
612
+ if (isExpression) {
613
+ const importedAbs = importedAssetAbsPath.get(attr.value)
614
+ if (importedAbs) {
615
+ const snippet = extractImageSnippet(cached.lines, srcLine - 1)
616
+ addToImageSearchIndex({ file: relFile, line: srcLine, snippet, src: importedAbs })
617
+ }
618
+ if (currentExpr) {
619
+ // `.map()`-driven src={item.foo} — trace through to data source
620
+ indexExpressionImageSrc(attr.value, currentExpr, cached, relFile)
621
+ }
622
+ } else {
597
623
  // Static src="..." — index directly
598
- const srcLine = attr.position?.start.line ?? elemNode.position?.start.line ?? 0
599
624
  const snippet = extractImageSnippet(cached.lines, srcLine - 1)
600
- addToImageSearchIndex({
601
- file: relFile,
602
- line: srcLine,
603
- snippet,
604
- src: attr.value,
605
- })
625
+ addToImageSearchIndex({ file: relFile, line: srcLine, snippet, src: attr.value })
606
626
  }
607
627
  }
608
628
  }
@@ -1109,23 +1129,27 @@ function extractPathname(src: string): string {
1109
1129
  export function findInImageIndex(imageSrc: string): SourceLocation | undefined {
1110
1130
  const index = getImageSearchIndex()
1111
1131
 
1132
+ // Dev-mode optimized URLs (`/_image?href=...`, `/@image/...?f=...`) embed the source
1133
+ // path; try both the raw URL and the decoded path so callers don't need to pre-decode.
1134
+ const decoded = extractAstroImageOriginalUrl(imageSrc)
1135
+ const candidates = decoded && decoded !== imageSrc ? [imageSrc, decoded] : [imageSrc]
1136
+
1112
1137
  // Exact match — prefer collection data files (src/content/) over templates.
1113
1138
  // The same image URL can appear in both a collection data file and a template
1114
1139
  // that statically renders the collection. The data file is the authoritative source.
1115
1140
  let bestMatch: SourceLocation | undefined
1116
1141
  for (const entry of index) {
1117
- if (entry.src === imageSrc) {
1118
- const result: SourceLocation = {
1119
- file: entry.file,
1120
- line: entry.line,
1121
- snippet: entry.snippet,
1122
- type: 'static',
1123
- }
1124
- if (isCollectionFile(entry.file)) {
1125
- return result // Collection data file — always preferred
1126
- }
1127
- bestMatch ??= result // Keep first non-collection match as fallback
1142
+ if (!candidates.includes(entry.src)) continue
1143
+ const result: SourceLocation = {
1144
+ file: entry.file,
1145
+ line: entry.line,
1146
+ snippet: entry.snippet,
1147
+ type: 'static',
1148
+ }
1149
+ if (isCollectionFile(entry.file)) {
1150
+ return result // Collection data file — always preferred
1128
1151
  }
1152
+ bestMatch ??= result // Keep first non-collection match as fallback
1129
1153
  }
1130
1154
  if (bestMatch) return bestMatch
1131
1155