@nuasite/cms 0.18.0 → 0.19.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.
Files changed (62) hide show
  1. package/dist/editor.js +44697 -26834
  2. package/package.json +23 -21
  3. package/src/build-processor.ts +4 -1
  4. package/src/collection-scanner.ts +425 -48
  5. package/src/dev-middleware.ts +26 -203
  6. package/src/editor/api.ts +1 -22
  7. package/src/editor/components/ai-chat.tsx +3 -3
  8. package/src/editor/components/ai-tooltip.tsx +2 -1
  9. package/src/editor/components/block-editor.tsx +13 -108
  10. package/src/editor/components/collections-browser.tsx +168 -205
  11. package/src/editor/components/component-card.tsx +49 -0
  12. package/src/editor/components/confirm-dialog.tsx +34 -47
  13. package/src/editor/components/create-page-modal.tsx +529 -101
  14. package/src/editor/components/delete-page-dialog.tsx +100 -0
  15. package/src/editor/components/fields.tsx +175 -0
  16. package/src/editor/components/frontmatter-fields.tsx +281 -70
  17. package/src/editor/components/frontmatter-sidebar.tsx +223 -0
  18. package/src/editor/components/highlight-overlay.ts +3 -2
  19. package/src/editor/components/markdown-editor-overlay.tsx +131 -85
  20. package/src/editor/components/markdown-inline-editor.tsx +74 -5
  21. package/src/editor/components/mdx-block-view.tsx +102 -0
  22. package/src/editor/components/mdx-component-picker.tsx +123 -0
  23. package/src/editor/components/mdx-props-editor.tsx +94 -0
  24. package/src/editor/components/media-library.tsx +373 -100
  25. package/src/editor/components/modal-shell.tsx +87 -0
  26. package/src/editor/components/prop-editor.tsx +52 -0
  27. package/src/editor/components/redirect-countdown.tsx +3 -1
  28. package/src/editor/components/redirects-manager.tsx +269 -0
  29. package/src/editor/components/reference-picker.tsx +203 -0
  30. package/src/editor/components/seo-editor.tsx +285 -303
  31. package/src/editor/components/toast/toast-container.tsx +2 -1
  32. package/src/editor/components/toolbar.tsx +177 -46
  33. package/src/editor/constants.ts +26 -0
  34. package/src/editor/editor.ts +112 -0
  35. package/src/editor/fetch.ts +62 -0
  36. package/src/editor/index.tsx +19 -1
  37. package/src/editor/markdown-api.ts +105 -156
  38. package/src/editor/milkdown-mdx-plugin.tsx +269 -0
  39. package/src/editor/signals.ts +206 -13
  40. package/src/editor/types.ts +52 -1
  41. package/src/handlers/api-routes.ts +251 -0
  42. package/src/handlers/component-ops.ts +2 -18
  43. package/src/handlers/markdown-ops.ts +202 -47
  44. package/src/handlers/page-ops.ts +229 -0
  45. package/src/handlers/redirect-ops.ts +163 -0
  46. package/src/handlers/source-writer.ts +157 -1
  47. package/src/html-processor.ts +14 -2
  48. package/src/index.ts +76 -2
  49. package/src/manifest-writer.ts +19 -1
  50. package/src/media/contember.ts +2 -1
  51. package/src/media/local.ts +66 -28
  52. package/src/media/project-images.ts +81 -0
  53. package/src/media/s3.ts +32 -11
  54. package/src/media/types.ts +24 -2
  55. package/src/shared.ts +27 -0
  56. package/src/source-finder/collection-finder.ts +219 -41
  57. package/src/source-finder/index.ts +7 -1
  58. package/src/source-finder/search-index.ts +178 -36
  59. package/src/source-finder/snippet-utils.ts +423 -3
  60. package/src/tsconfig.json +0 -2
  61. package/src/types.ts +111 -2
  62. package/src/utils.ts +40 -4
@@ -1,8 +1,9 @@
1
1
  import fs from 'node:fs/promises'
2
2
  import path from 'node:path'
3
- import { isMap, isPair, isScalar, LineCounter, parseDocument } from 'yaml'
3
+ import { isMap, isPair, isScalar, isSeq, LineCounter, parseDocument } from 'yaml'
4
4
 
5
5
  import { getProjectRoot } from '../config'
6
+ import type { CollectionDefinition } from '../types'
6
7
  import { getMarkdownFileCache } from './cache'
7
8
  import { normalizeText } from './snippet-utils'
8
9
  import type { CollectionInfo, MarkdownContent, SourceLocation } from './types'
@@ -194,63 +195,240 @@ export async function findMarkdownSourceLocation(
194
195
  const { lines } = cached
195
196
  const normalizedSearch = normalizeText(textContent)
196
197
 
197
- // Parse frontmatter
198
+ // Find frontmatter boundaries
199
+ let frontmatterStart = -1
198
200
  let frontmatterEnd = -1
199
- let inFrontmatter = false
200
-
201
201
  for (let i = 0; i < lines.length; i++) {
202
- const line = lines[i]?.trim()
203
- if (line === '---') {
204
- if (!inFrontmatter) {
205
- inFrontmatter = true
202
+ if (lines[i]?.trim() === '---') {
203
+ if (frontmatterStart === -1) {
204
+ frontmatterStart = i
206
205
  } else {
207
206
  frontmatterEnd = i
208
207
  break
209
208
  }
210
209
  }
211
210
  }
211
+ if (frontmatterEnd <= 0) return undefined
212
212
 
213
- // Search in frontmatter only (for title, subtitle, etc.)
214
- if (frontmatterEnd > 0) {
215
- for (let i = 1; i < frontmatterEnd; i++) {
216
- const line = lines[i]
217
- if (!line) continue
218
-
219
- // Extract value from YAML key: value (keys can contain hyphens)
220
- const match = line.match(/^\s*([\w-]+):\s*(.+)$/)
221
- if (match) {
222
- const key = match[1]
223
- let value = match[2]?.trim() || ''
224
-
225
- // Handle quoted strings
226
- if (
227
- (value.startsWith('"') && value.endsWith('"'))
228
- || (value.startsWith("'") && value.endsWith("'"))
229
- ) {
230
- value = value.slice(1, -1)
231
- }
213
+ const yamlStr = lines.slice(frontmatterStart + 1, frontmatterEnd).join('\n')
214
+ const lineOffset = frontmatterStart + 1
215
+ return findScalarInYamlAst(yamlStr, lineOffset, normalizedSearch, lines, collectionInfo)
216
+ } catch {
217
+ // Error reading file
218
+ }
232
219
 
233
- if (normalizeText(value) === normalizedSearch) {
234
- return {
235
- file: collectionInfo.file,
236
- line: i + 1,
237
- snippet: line,
238
- type: 'collection',
239
- variableName: key,
240
- collectionName: collectionInfo.name,
241
- collectionSlug: collectionInfo.slug,
242
- }
243
- }
244
- }
220
+ return undefined
221
+ }
222
+
223
+ /**
224
+ * Search all collection entries for a text value across all formats
225
+ * (markdown frontmatter, JSON, YAML data files).
226
+ */
227
+ export async function findTextInAnyCollectionFrontmatter(
228
+ textContent: string,
229
+ collections: Record<string, CollectionDefinition>,
230
+ ): Promise<SourceLocation | undefined> {
231
+ const normalizedSearch = normalizeText(textContent)
232
+
233
+ for (const def of Object.values(collections)) {
234
+ if (!def.entries || def.entries.length === 0) continue
235
+
236
+ for (const entry of def.entries) {
237
+ const info: CollectionInfo = { name: def.name, slug: entry.slug, file: entry.sourcePath }
238
+
239
+ if (def.type === 'data') {
240
+ const result = await findTextInDataFile(normalizedSearch, info)
241
+ if (result) return result
242
+ } else {
243
+ const result = await findMarkdownSourceLocation(textContent, info)
244
+ if (result) return result
245
245
  }
246
246
  }
247
+ }
248
+ return undefined
249
+ }
250
+
251
+ /**
252
+ * Search a data file (JSON, YAML, YML) for a scalar value using AST parsing.
253
+ * JSON is valid YAML, so parseDocument handles all formats uniformly.
254
+ */
255
+ async function findTextInDataFile(
256
+ normalizedSearch: string,
257
+ collectionInfo: CollectionInfo,
258
+ ): Promise<SourceLocation | undefined> {
259
+ try {
260
+ const filePath = path.join(getProjectRoot(), collectionInfo.file)
261
+ const cached = await getCachedMarkdownFile(filePath)
262
+ if (!cached) return undefined
247
263
 
248
- // Body content is not searched line-by-line anymore
249
- // Use parseMarkdownContent to get the full body as one entry
264
+ return findScalarInYamlAst(cached.content, 0, normalizedSearch, cached.lines, collectionInfo)
250
265
  } catch {
251
266
  // Error reading file
252
267
  }
268
+ return undefined
269
+ }
253
270
 
271
+ /**
272
+ * Walk a YAML AST to find a scalar value matching the search text.
273
+ * Handles nested maps and sequences.
274
+ */
275
+ function findScalarInYamlAst(
276
+ yamlStr: string,
277
+ lineOffset: number,
278
+ normalizedSearch: string,
279
+ fileLines: string[],
280
+ collectionInfo: CollectionInfo,
281
+ ): SourceLocation | undefined {
282
+ const lineCounter = new LineCounter()
283
+ const doc = parseDocument(yamlStr, { lineCounter })
284
+
285
+ const found = walkYamlNode(doc.contents, normalizedSearch, lineCounter)
286
+ if (!found) return undefined
287
+
288
+ const fileStartLine = found.startLine + lineOffset
289
+ const fileEndLine = found.endLine + lineOffset
290
+
291
+ // Build snippet spanning all lines of the key-value pair (handles multi-line YAML values)
292
+ const snippet = fileLines.slice(fileStartLine - 1, fileEndLine).join('\n')
293
+
294
+ return {
295
+ file: collectionInfo.file,
296
+ line: fileStartLine,
297
+ snippet,
298
+ type: 'collection',
299
+ variableName: found.key,
300
+ collectionName: collectionInfo.name,
301
+ collectionSlug: collectionInfo.slug,
302
+ }
303
+ }
304
+
305
+ /** Recursively walk a YAML node to find a scalar matching the search text */
306
+ function walkYamlNode(
307
+ node: unknown,
308
+ normalizedSearch: string,
309
+ lineCounter: LineCounter,
310
+ ): { key: string | undefined; startLine: number; endLine: number } | undefined {
311
+ if (isMap(node)) {
312
+ for (const pair of node.items) {
313
+ if (!isPair(pair) || !isScalar(pair.key)) continue
314
+ const key = String(pair.key.value)
315
+
316
+ if (isScalar(pair.value)) {
317
+ if (normalizeText(String(pair.value.value)) === normalizedSearch) {
318
+ const keyRange = (pair.key as any).range as [number, number, number] | undefined
319
+ const valRange = (pair.value as any).range as [number, number, number] | undefined
320
+ const startLine = keyRange ? lineCounter.linePos(keyRange[0]).line : 1
321
+ const endLine = valRange ? lineCounter.linePos(valRange[1]).line : startLine
322
+ return { key, startLine, endLine }
323
+ }
324
+ } else {
325
+ // Recurse into nested maps/sequences
326
+ const nested = walkYamlNode(pair.value, normalizedSearch, lineCounter)
327
+ if (nested) return nested
328
+ }
329
+ }
330
+ } else if (isSeq(node)) {
331
+ for (const item of node.items) {
332
+ if (isScalar(item)) {
333
+ if (normalizeText(String(item.value)) === normalizedSearch) {
334
+ const range = (item as any).range as [number, number, number] | undefined
335
+ const startLine = range ? lineCounter.linePos(range[0]).line : 1
336
+ const endLine = range ? lineCounter.linePos(range[1]).line : startLine
337
+ return { key: undefined, startLine, endLine }
338
+ }
339
+ } else {
340
+ const nested = walkYamlNode(item, normalizedSearch, lineCounter)
341
+ if (nested) return nested
342
+ }
343
+ }
344
+ }
345
+ return undefined
346
+ }
347
+
348
+ /**
349
+ * Find an image field by name in a specific collection entry's data file.
350
+ * Used when the rendered image URL has been transformed (e.g., Astro hashed filenames)
351
+ * and can't be matched by value, but we know the field name from the expression.
352
+ */
353
+ export async function findFieldInCollectionEntry(
354
+ fieldName: string,
355
+ collectionName: string,
356
+ collectionSlug: string,
357
+ collectionDefinitions: Record<string, CollectionDefinition>,
358
+ ): Promise<SourceLocation | undefined> {
359
+ const def = collectionDefinitions[collectionName]
360
+ if (!def?.entries) return undefined
361
+
362
+ const entry = def.entries.find((e) => e.slug === collectionSlug)
363
+ if (!entry) return undefined
364
+
365
+ const info: CollectionInfo = { name: collectionName, slug: collectionSlug, file: entry.sourcePath }
366
+
367
+ try {
368
+ const filePath = path.join(getProjectRoot(), entry.sourcePath)
369
+ const cached = await getCachedMarkdownFile(filePath)
370
+ if (!cached) return undefined
371
+
372
+ if (def.type === 'data') {
373
+ return findFieldByNameInYaml(cached.content, 0, fieldName, cached.lines, info)
374
+ }
375
+
376
+ // For markdown, search inside frontmatter only
377
+ const { lines } = cached
378
+ let fmStart = -1
379
+ let fmEnd = -1
380
+ for (let i = 0; i < lines.length; i++) {
381
+ if (lines[i]?.trim() === '---') {
382
+ if (fmStart === -1) fmStart = i
383
+ else {
384
+ fmEnd = i
385
+ break
386
+ }
387
+ }
388
+ }
389
+ if (fmEnd <= 0) return undefined
390
+ const yamlStr = lines.slice(fmStart + 1, fmEnd).join('\n')
391
+ return findFieldByNameInYaml(yamlStr, fmStart + 1, fieldName, lines, info)
392
+ } catch {
393
+ return undefined
394
+ }
395
+ }
396
+
397
+ /**
398
+ * Walk a YAML AST to find a field by key name (regardless of its value).
399
+ */
400
+ function findFieldByNameInYaml(
401
+ yamlStr: string,
402
+ lineOffset: number,
403
+ fieldName: string,
404
+ fileLines: string[],
405
+ collectionInfo: CollectionInfo,
406
+ ): SourceLocation | undefined {
407
+ const lineCounter = new LineCounter()
408
+ const doc = parseDocument(yamlStr, { lineCounter })
409
+ if (!isMap(doc.contents)) return undefined
410
+
411
+ for (const pair of doc.contents.items) {
412
+ if (!isPair(pair) || !isScalar(pair.key)) continue
413
+ if (String(pair.key.value) !== fieldName) continue
414
+ if (!isScalar(pair.value)) continue
415
+
416
+ const keyRange = (pair.key as any).range as [number, number, number] | undefined
417
+ const valRange = (pair.value as any).range as [number, number, number] | undefined
418
+ const startLine = (keyRange ? lineCounter.linePos(keyRange[0]).line : 1) + lineOffset
419
+ const endLine = (valRange ? lineCounter.linePos(valRange[1]).line : startLine - lineOffset) + lineOffset
420
+
421
+ const snippet = fileLines.slice(startLine - 1, endLine).join('\n')
422
+ return {
423
+ file: collectionInfo.file,
424
+ line: startLine,
425
+ snippet,
426
+ type: 'collection',
427
+ variableName: fieldName,
428
+ collectionName: collectionInfo.name,
429
+ collectionSlug: collectionInfo.slug,
430
+ }
431
+ }
254
432
  return undefined
255
433
  }
256
434
 
@@ -23,7 +23,13 @@ export { findAttributeSourceLocation } from './cross-file-tracker'
23
23
  export { findImageSourceLocation } from './image-finder'
24
24
 
25
25
  // Collection/markdown finding
26
- export { findCollectionSource, findMarkdownSourceLocation, parseMarkdownContent } from './collection-finder'
26
+ export {
27
+ findCollectionSource,
28
+ findFieldInCollectionEntry,
29
+ findMarkdownSourceLocation,
30
+ findTextInAnyCollectionFrontmatter,
31
+ parseMarkdownContent,
32
+ } from './collection-finder'
27
33
 
28
34
  // Snippet utilities (used by html-processor)
29
35
  export {
@@ -16,7 +16,14 @@ import {
16
16
  setSearchIndexInitialized,
17
17
  } from './cache'
18
18
  import { extractImageSnippet, extractInnerHtmlFromSnippet, normalizeText } from './snippet-utils'
19
- import type { CachedParsedFile, SourceLocation } from './types'
19
+ import type { CachedParsedFile, SearchIndexEntry, SourceLocation } from './types'
20
+
21
+ /** Collection data files live under this path — used to prefer them over templates */
22
+ const CONTENT_DIR_PREFIX = 'src/content/'
23
+
24
+ function isCollectionFile(file: string): boolean {
25
+ return file.includes(CONTENT_DIR_PREFIX)
26
+ }
20
27
 
21
28
  // ============================================================================
22
29
  // File Collection
@@ -56,13 +63,26 @@ export async function collectAstroFiles(dir: string): Promise<string[]> {
56
63
  // Index Initialization
57
64
  // ============================================================================
58
65
 
66
+ /** Shared promise so concurrent callers wait for the same initialization */
67
+ let initPromise: Promise<void> | null = null
68
+
59
69
  /**
60
70
  * Initialize search index by pre-scanning all source files.
61
71
  * This is much faster than searching per-entry.
72
+ * Safe to call concurrently — all callers share the same initialization.
62
73
  */
63
74
  export async function initializeSearchIndex(): Promise<void> {
64
75
  if (isSearchIndexInitialized()) return
76
+ if (initPromise) return initPromise
77
+ initPromise = doInitializeSearchIndex()
78
+ try {
79
+ await initPromise
80
+ } finally {
81
+ initPromise = null
82
+ }
83
+ }
65
84
 
85
+ async function doInitializeSearchIndex(): Promise<void> {
66
86
  const srcDir = path.join(getProjectRoot(), 'src')
67
87
  const searchDirs = [
68
88
  path.join(srcDir, 'components'),
@@ -99,6 +119,9 @@ export async function initializeSearchIndex(): Promise<void> {
99
119
  }
100
120
  }))
101
121
 
122
+ // Index image-like values from content collection data files (JSON/YAML)
123
+ await indexContentCollectionImages()
124
+
102
125
  setSearchIndexInitialized(true)
103
126
  }
104
127
 
@@ -529,6 +552,117 @@ export function indexFileImages(cached: CachedParsedFile, relFile: string): void
529
552
  }
530
553
  }
531
554
 
555
+ // ============================================================================
556
+ // Content Collection Data File Indexing
557
+ // ============================================================================
558
+
559
+ /** Image-like file extensions to match in data file values */
560
+ const IMAGE_EXTENSIONS = /\.(jpe?g|png|gif|webp|avif|svg|ico|bmp|tiff?)$/i
561
+
562
+ /**
563
+ * Index image paths found in content collection data files (JSON/YAML).
564
+ * These are values like `"image": "/assets/photo.webp"` that get rendered
565
+ * through template expressions (e.g., `src={person.image}`).
566
+ */
567
+ async function indexContentCollectionImages(): Promise<void> {
568
+ const contentDir = path.join(getProjectRoot(), 'src', 'content')
569
+ const entries = await fs.readdir(contentDir, { withFileTypes: true }).catch(() => null)
570
+ if (!entries) return // No content directory
571
+
572
+ const dataFiles: string[] = []
573
+ for (const entry of entries) {
574
+ if (entry.isDirectory()) {
575
+ await collectDataFiles(path.join(contentDir, entry.name), dataFiles)
576
+ }
577
+ }
578
+
579
+ await Promise.all(dataFiles.map(async (filePath) => {
580
+ try {
581
+ const content = await fs.readFile(filePath, 'utf-8')
582
+ const relFile = path.relative(getProjectRoot(), filePath)
583
+
584
+ if (filePath.endsWith('.json')) {
585
+ indexJsonImages(content, relFile)
586
+ } else if (filePath.endsWith('.yaml') || filePath.endsWith('.yml')) {
587
+ indexYamlImages(content, relFile)
588
+ } else if (filePath.endsWith('.md') || filePath.endsWith('.mdx')) {
589
+ indexFrontmatterImages(content, relFile)
590
+ }
591
+ } catch {
592
+ // Skip unreadable files
593
+ }
594
+ }))
595
+ }
596
+
597
+ const DATA_FILE_PATTERN = /\.(json|ya?ml|mdx?)$/
598
+
599
+ async function collectDataFiles(dir: string, results: string[]): Promise<void> {
600
+ try {
601
+ const entries = await fs.readdir(dir, { withFileTypes: true })
602
+ await Promise.all(entries.map(async (entry) => {
603
+ const fullPath = path.join(dir, entry.name)
604
+ if (entry.isDirectory()) {
605
+ await collectDataFiles(fullPath, results)
606
+ } else if (entry.isFile() && DATA_FILE_PATTERN.test(entry.name)) {
607
+ results.push(fullPath)
608
+ }
609
+ }))
610
+ } catch {
611
+ // Directory doesn't exist
612
+ }
613
+ }
614
+
615
+ function indexJsonImages(content: string, relFile: string): void {
616
+ const lines = content.split('\n')
617
+ // Match JSON string values that look like image paths
618
+ const pattern = /:\s*"([^"]+)"/g
619
+ for (let i = 0; i < lines.length; i++) {
620
+ const line = lines[i]!
621
+ let match
622
+ pattern.lastIndex = 0
623
+ while ((match = pattern.exec(line)) !== null) {
624
+ const value = match[1]!
625
+ if (IMAGE_EXTENSIONS.test(value)) {
626
+ addToImageSearchIndex({
627
+ file: relFile,
628
+ line: i + 1,
629
+ snippet: line.trim(),
630
+ src: value,
631
+ })
632
+ }
633
+ }
634
+ }
635
+ }
636
+
637
+ function indexYamlImages(content: string, relFile: string): void {
638
+ indexYamlLikeLines(content.split('\n'), relFile, 0)
639
+ }
640
+
641
+ function indexFrontmatterImages(content: string, relFile: string): void {
642
+ // Only scan YAML frontmatter (between --- markers)
643
+ const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
644
+ if (!fmMatch) return
645
+ indexYamlLikeLines(fmMatch[1]!.split('\n'), relFile, 1)
646
+ }
647
+
648
+ /** Shared YAML key-value image scanner used by both indexYamlImages and indexFrontmatterImages */
649
+ function indexYamlLikeLines(lines: string[], relFile: string, lineOffset: number): void {
650
+ const pattern = /^\s*[\w-]+:\s*(.+)/
651
+ for (let i = 0; i < lines.length; i++) {
652
+ const match = lines[i]!.match(pattern)
653
+ if (!match) continue
654
+ const value = match[1]!.trim().replace(/^['"]|['"]$/g, '')
655
+ if (IMAGE_EXTENSIONS.test(value)) {
656
+ addToImageSearchIndex({
657
+ file: relFile,
658
+ line: i + 1 + lineOffset,
659
+ snippet: lines[i]!.trim(),
660
+ src: value,
661
+ })
662
+ }
663
+ }
664
+ }
665
+
532
666
  // ============================================================================
533
667
  // Index Lookup
534
668
  // ============================================================================
@@ -541,55 +675,51 @@ export function findInTextIndex(textContent: string, tag: string): SourceLocatio
541
675
  const tagLower = tag.toLowerCase()
542
676
  const index = getTextSearchIndex()
543
677
 
544
- // First try exact match with same tag
678
+ // Helper to build SourceLocation from a text index entry
679
+ const toLocation = (entry: SearchIndexEntry): SourceLocation => ({
680
+ file: entry.file,
681
+ line: entry.line,
682
+ snippet: entry.snippet,
683
+ openingTagSnippet: entry.openingTagSnippet,
684
+ type: entry.type,
685
+ variableName: entry.variableName,
686
+ definitionLine: entry.definitionLine,
687
+ })
688
+
689
+ // First try exact match with same tag — prefer collection data files
690
+ let bestMatch: SourceLocation | undefined
545
691
  for (const entry of index) {
546
692
  if (entry.tag === tagLower && entry.normalizedText === normalizedSearch) {
547
- return {
548
- file: entry.file,
549
- line: entry.line,
550
- snippet: entry.snippet,
551
- openingTagSnippet: entry.openingTagSnippet,
552
- type: entry.type,
553
- variableName: entry.variableName,
554
- definitionLine: entry.definitionLine,
555
- }
693
+ const result = toLocation(entry)
694
+ if (isCollectionFile(entry.file)) return result
695
+ bestMatch ??= result
556
696
  }
557
697
  }
698
+ if (bestMatch) return bestMatch
558
699
 
559
- // Then try partial match for longer text
700
+ // Then try partial match for longer text — prefer collection data files
560
701
  if (normalizedSearch.length > 10) {
561
702
  const textPreview = normalizedSearch.slice(0, Math.min(30, normalizedSearch.length))
562
703
  for (const entry of index) {
563
704
  if (entry.tag === tagLower && entry.normalizedText.includes(textPreview)) {
564
- return {
565
- file: entry.file,
566
- line: entry.line,
567
- snippet: entry.snippet,
568
- openingTagSnippet: entry.openingTagSnippet,
569
- type: entry.type,
570
- variableName: entry.variableName,
571
- definitionLine: entry.definitionLine,
572
- }
705
+ const result = toLocation(entry)
706
+ if (isCollectionFile(entry.file)) return result
707
+ bestMatch ??= result
573
708
  }
574
709
  }
710
+ if (bestMatch) return bestMatch
575
711
  }
576
712
 
577
- // Try any tag match
713
+ // Try any tag match — prefer collection data files
578
714
  for (const entry of index) {
579
715
  if (entry.normalizedText === normalizedSearch) {
580
- return {
581
- file: entry.file,
582
- line: entry.line,
583
- snippet: entry.snippet,
584
- openingTagSnippet: entry.openingTagSnippet,
585
- type: entry.type,
586
- variableName: entry.variableName,
587
- definitionLine: entry.definitionLine,
588
- }
716
+ const result = toLocation(entry)
717
+ if (isCollectionFile(entry.file)) return result
718
+ bestMatch ??= result
589
719
  }
590
720
  }
591
721
 
592
- return undefined
722
+ return bestMatch
593
723
  }
594
724
 
595
725
  /**
@@ -609,17 +739,25 @@ function extractPathname(src: string): string {
609
739
  export function findInImageIndex(imageSrc: string): SourceLocation | undefined {
610
740
  const index = getImageSearchIndex()
611
741
 
612
- // Exact match first
742
+ // Exact match — prefer collection data files (src/content/) over templates.
743
+ // The same image URL can appear in both a collection data file and a template
744
+ // that statically renders the collection. The data file is the authoritative source.
745
+ let bestMatch: SourceLocation | undefined
613
746
  for (const entry of index) {
614
747
  if (entry.src === imageSrc) {
615
- return {
748
+ const result: SourceLocation = {
616
749
  file: entry.file,
617
750
  line: entry.line,
618
751
  snippet: entry.snippet,
619
752
  type: 'static',
620
753
  }
754
+ if (isCollectionFile(entry.file)) {
755
+ return result // Collection data file — always preferred
756
+ }
757
+ bestMatch ??= result // Keep first non-collection match as fallback
621
758
  }
622
759
  }
760
+ if (bestMatch) return bestMatch
623
761
 
624
762
  // Fallback: path suffix matching for CDN-transformed URLs
625
763
  // e.g., rendered src "/cdn-cgi/image/.../assets/photo.webp" should match
@@ -628,14 +766,18 @@ export function findInImageIndex(imageSrc: string): SourceLocation | undefined {
628
766
  for (const entry of index) {
629
767
  const entryPath = extractPathname(entry.src)
630
768
  if (entryPath.length > 5 && (targetPath.endsWith(entryPath) || entryPath.endsWith(targetPath))) {
631
- return {
769
+ const result: SourceLocation = {
632
770
  file: entry.file,
633
771
  line: entry.line,
634
772
  snippet: entry.snippet,
635
773
  type: 'static',
636
774
  }
775
+ if (isCollectionFile(entry.file)) {
776
+ return result
777
+ }
778
+ bestMatch ??= result
637
779
  }
638
780
  }
639
781
 
640
- return undefined
782
+ return bestMatch
641
783
  }