@nuasite/cms 0.38.0 → 0.39.1

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/src/index.ts CHANGED
@@ -65,8 +65,16 @@ export interface NuaCmsOptions extends CmsMarkerOptions {
65
65
  * @default false
66
66
  */
67
67
  usePolling?: boolean
68
+ /**
69
+ * Maximum upload size in bytes. Applied to `/_nua/cms/media/upload` requests
70
+ * (enforced on the server and pre-checked on the client).
71
+ * @default 10 * 1024 * 1024 (10 MB)
72
+ */
73
+ maxUploadSize?: number
68
74
  }
69
75
 
76
+ const DEFAULT_MAX_UPLOAD_SIZE = 10 * 1024 * 1024
77
+
70
78
  const VIRTUAL_CMS_PATH = '/@nuasite/cms-editor.js'
71
79
 
72
80
  export default function nuaCms(options: NuaCmsOptions = {}): AstroIntegration {
@@ -89,16 +97,20 @@ export default function nuaCms(options: NuaCmsOptions = {}): AstroIntegration {
89
97
  mdxComponentDirs,
90
98
  usePolling = false,
91
99
  seo = { trackSeo: true, markTitle: true, parseJsonLd: true },
100
+ maxUploadSize = DEFAULT_MAX_UPLOAD_SIZE,
92
101
  } = options
93
102
 
94
103
  // When no proxy, enable local CMS API with default media adapter
95
104
  const enableCmsApi = !proxy
96
105
  const mediaAdapter = media ?? (enableCmsApi ? createLocalStorageAdapter() : undefined)
97
106
 
98
- // Default apiBase to local dev server when no proxy
99
- const resolvedCmsConfig = enableCmsApi && !cmsConfig?.apiBase
100
- ? { ...cmsConfig, apiBase: '/_nua/cms' }
101
- : cmsConfig
107
+ // Default apiBase to local dev server when no proxy; thread maxUploadSize through
108
+ // to the editor so the client can pre-check before sending the upload.
109
+ const resolvedCmsConfig = {
110
+ ...(cmsConfig ?? {}),
111
+ ...(enableCmsApi && !cmsConfig?.apiBase ? { apiBase: '/_nua/cms' } : {}),
112
+ maxUploadSize,
113
+ }
102
114
 
103
115
  let componentDefinitions: Record<string, ComponentDefinition> = {}
104
116
  let isPublicStaticFile: ((urlPath: string) => boolean) | undefined
@@ -203,9 +215,7 @@ export default function nuaCms(options: NuaCmsOptions = {}): AstroIntegration {
203
215
  if (command === 'dev') {
204
216
  const editorSrc = src ?? VIRTUAL_CMS_PATH
205
217
 
206
- const configScript = resolvedCmsConfig
207
- ? `window.NuaCmsConfig = ${JSON.stringify(resolvedCmsConfig)};`
208
- : ''
218
+ const configScript = `window.NuaCmsConfig = ${JSON.stringify(resolvedCmsConfig)};`
209
219
 
210
220
  injectScript(
211
221
  'page',
@@ -229,7 +239,8 @@ export default function nuaCms(options: NuaCmsOptions = {}): AstroIntegration {
229
239
  if (hasPrebuiltBundle) {
230
240
  // Pre-built bundle exists (npm install case):
231
241
  // Serve it via a virtual module — no JSX pragma, Tailwind, or aliases needed.
232
- const bundleContent = readFileSync(editorBundlePath!, 'utf-8')
242
+ // Read on every load() so rebuilds during dev pick up without restarting
243
+ // the host (Astro, pletivo, etc).
233
244
  vitePlugins.push({
234
245
  name: 'nuasite-cms-editor',
235
246
  resolveId(id: string) {
@@ -239,7 +250,7 @@ export default function nuaCms(options: NuaCmsOptions = {}): AstroIntegration {
239
250
  },
240
251
  load(id: string) {
241
252
  if (id === VIRTUAL_CMS_PATH) {
242
- return bundleContent
253
+ return readFileSync(editorBundlePath!, 'utf-8')
243
254
  }
244
255
  },
245
256
  })
@@ -319,7 +330,7 @@ export default function nuaCms(options: NuaCmsOptions = {}): AstroIntegration {
319
330
  manifestWriter,
320
331
  componentDefinitions,
321
332
  idCounter,
322
- { enableCmsApi, mediaAdapter, isPublicStaticFile },
333
+ { enableCmsApi, mediaAdapter, isPublicStaticFile, maxUploadSize },
323
334
  )
324
335
  logger.info('CMS dev middleware initialized')
325
336
  if (enableCmsApi) {
@@ -141,21 +141,28 @@ function parseSrcsetUrls(srcSet: string): string[] {
141
141
  * Also checks srcset URLs as fallback when src doesn't match (e.g., when src
142
142
  * is a local upload path but srcset contains CDN-transformed original URLs).
143
143
  * Uses pre-built search index for fast lookups.
144
+ *
145
+ * `pageFiles` disambiguates the same image URL across pages.
146
+ * `preferredLocation` disambiguates the same image URL within one page —
147
+ * pass the manifest entry's Astro attribution and the lookup will return the
148
+ * matching per-occurrence index entry when one exists.
144
149
  */
145
150
  export async function findImageSourceLocation(
146
151
  imageSrc: string,
147
152
  imageSrcSet?: string,
153
+ pageFiles?: readonly string[],
154
+ preferredLocation?: { file: string; line?: number; srcOccurrence?: number },
148
155
  ): Promise<SourceLocation | undefined> {
149
156
  // Use index if available (much faster)
150
157
  if (isSearchIndexInitialized()) {
151
- const result = findInImageIndex(imageSrc)
158
+ const result = findInImageIndex(imageSrc, pageFiles, preferredLocation)
152
159
  if (result) return result
153
160
 
154
161
  // Fallback: try URLs extracted from srcset
155
162
  if (imageSrcSet) {
156
163
  const srcsetUrls = parseSrcsetUrls(imageSrcSet)
157
164
  for (const url of srcsetUrls) {
158
- const srcsetResult = findInImageIndex(url)
165
+ const srcsetResult = findInImageIndex(url, pageFiles, preferredLocation)
159
166
  if (srcsetResult) return srcsetResult
160
167
  }
161
168
  }
@@ -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
 
@@ -22,7 +24,7 @@ import {
22
24
  setSearchIndexInitialized,
23
25
  } from './cache'
24
26
  import { extractAstroImageOriginalUrl, extractImageSnippet, extractInnerHtmlFromSnippet, normalizeText } from './snippet-utils'
25
- import type { CachedParsedFile, SearchIndexEntry, SourceLocation } from './types'
27
+ import type { CachedParsedFile, ImageIndexEntry, SearchIndexEntry, SourceLocation } from './types'
26
28
 
27
29
  /** Collection data files live under this path — used to prefer them over templates */
28
30
  const CONTENT_DIR_PREFIX = 'src/content/'
@@ -622,6 +624,42 @@ function parseMapInvocations(fullText: string): MapInvocation[] {
622
624
  return maps
623
625
  }
624
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
660
+ }
661
+ }
662
+
625
663
  /**
626
664
  * Resolve a `.map()` callback parameter back to the source array path.
627
665
  *
@@ -632,6 +670,8 @@ function parseMapInvocations(fullText: string): MapInvocation[] {
632
670
  * → `{ arrayPath: "categories[*].images", leafSuffix: "" }`
633
671
  * `links.map(({ label, href }) => …)` looking for `label`
634
672
  * → `{ arrayPath: "links", leafSuffix: ".label" }`
673
+ * `services.map((service) => …)` looking for `service.image`
674
+ * → `{ arrayPath: "services", leafSuffix: ".image" }`
635
675
  *
636
676
  * Returns null when the name doesn't appear as a parameter or destructured binding.
637
677
  */
@@ -639,13 +679,16 @@ export function resolveMapChain(exprTexts: string[], paramName: string): Resolve
639
679
  const maps = parseMapInvocations(exprTexts.join(''))
640
680
  if (maps.length === 0) return null
641
681
 
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))
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))
645
688
  if (!directMap) return null
646
689
 
647
- const isDestructured = directMap.param !== paramName
648
- const leafSuffix = isDestructured ? `.${paramName}` : ''
690
+ const isDestructured = directMap.param !== baseName
691
+ const leafSuffix = (isDestructured ? `.${baseName}` : '') + memberSuffix
649
692
 
650
693
  // Resolve the array expression by substituting outer .map() params (chained / nested loops).
651
694
  let arrayPath = directMap.arrayExpr
@@ -1225,6 +1268,37 @@ function textEntryToLocation(entry: SearchIndexEntry): SourceLocation {
1225
1268
  }
1226
1269
  }
1227
1270
 
1271
+ /** Helper to build SourceLocation from an image index entry */
1272
+ function imageEntryToLocation(entry: ImageIndexEntry): SourceLocation {
1273
+ return {
1274
+ file: entry.file,
1275
+ line: entry.line,
1276
+ snippet: entry.snippet,
1277
+ type: 'static',
1278
+ }
1279
+ }
1280
+
1281
+ /**
1282
+ * Classify a candidate result by file priority and stash it into the right
1283
+ * bucket. Returns the result if it's a collection-file match (caller should
1284
+ * return immediately — collection data files are always authoritative).
1285
+ */
1286
+ interface RankedMatches {
1287
+ page?: SourceLocation
1288
+ other?: SourceLocation
1289
+ }
1290
+ function rankAndStash(
1291
+ file: string,
1292
+ result: SourceLocation,
1293
+ pageFiles: readonly string[] | undefined,
1294
+ matches: RankedMatches,
1295
+ ): SourceLocation | undefined {
1296
+ if (isCollectionFile(file)) return result
1297
+ if (pageFiles?.includes(file)) matches.page ??= result
1298
+ else matches.other ??= result
1299
+ return undefined
1300
+ }
1301
+
1228
1302
  /**
1229
1303
  * Look up an i18n dictionary entry by its literal key (e.g. `nav.prague4`),
1230
1304
  * preferring the match whose value equals `normalizedText` so the right locale
@@ -1282,55 +1356,56 @@ function toProjectRelativePath(file: string): string {
1282
1356
  }
1283
1357
 
1284
1358
  /**
1285
- * Fast text lookup using pre-built index
1359
+ * Fast text lookup using pre-built index.
1360
+ *
1361
+ * Priority order: collection data files > entries in `pageFiles` > anything
1362
+ * else. Translation-tag (i18n dictionary) hits beat non-collection template
1363
+ * matches — a dictionary entry is an authoritative translatable signal
1364
+ * whereas a same-text template hit is often coincidental.
1286
1365
  */
1287
- export function findInTextIndex(textContent: string, tag: string): SourceLocation | undefined {
1366
+ export function findInTextIndex(
1367
+ textContent: string,
1368
+ tag: string,
1369
+ pageFiles?: readonly string[],
1370
+ ): SourceLocation | undefined {
1288
1371
  const normalizedSearch = normalizeText(textContent)
1289
1372
  const tagLower = tag.toLowerCase()
1290
1373
  const index = getTextSearchIndex()
1291
-
1292
- // Single pass for exact matches: collect the best same-tag template hit
1293
- // *and* any i18n dictionary hit at once. A JSON dictionary entry is an
1294
- // authoritative translatable signal, so it beats a non-collection
1295
- // template match (which is often a coincidental same-text element).
1296
- let bestMatch: SourceLocation | undefined
1374
+ const matches: RankedMatches = {}
1297
1375
  let translationHit: SourceLocation | undefined
1376
+
1298
1377
  for (const entry of index) {
1299
1378
  if (entry.normalizedText !== normalizedSearch) continue
1300
1379
  if (entry.tag === tagLower) {
1301
- const result = textEntryToLocation(entry)
1302
- if (isCollectionFile(entry.file)) return result
1303
- bestMatch ??= result
1380
+ const collectionHit = rankAndStash(entry.file, textEntryToLocation(entry), pageFiles, matches)
1381
+ if (collectionHit) return collectionHit
1304
1382
  } else if (entry.tag === TRANSLATION_TAG_MARKER) {
1305
1383
  translationHit ??= textEntryToLocation(entry)
1306
1384
  }
1307
1385
  }
1308
1386
  if (translationHit) return translationHit
1309
- if (bestMatch) return bestMatch
1387
+ const sameTag = matches.page ?? matches.other
1388
+ if (sameTag) return sameTag
1310
1389
 
1311
- // Then try partial match for longer text — prefer collection data files
1312
1390
  if (normalizedSearch.length > 10) {
1313
1391
  const textPreview = normalizedSearch.slice(0, Math.min(30, normalizedSearch.length))
1314
1392
  for (const entry of index) {
1315
- if (entry.tag === tagLower && entry.normalizedText.includes(textPreview)) {
1316
- const result = textEntryToLocation(entry)
1317
- if (isCollectionFile(entry.file)) return result
1318
- bestMatch ??= result
1319
- }
1393
+ if (entry.tag !== tagLower) continue
1394
+ if (!entry.normalizedText.includes(textPreview)) continue
1395
+ const collectionHit = rankAndStash(entry.file, textEntryToLocation(entry), pageFiles, matches)
1396
+ if (collectionHit) return collectionHit
1320
1397
  }
1321
- if (bestMatch) return bestMatch
1398
+ const partial = matches.page ?? matches.other
1399
+ if (partial) return partial
1322
1400
  }
1323
1401
 
1324
- // Try any tag match — prefer collection data files
1325
1402
  for (const entry of index) {
1326
- if (entry.normalizedText === normalizedSearch) {
1327
- const result = textEntryToLocation(entry)
1328
- if (isCollectionFile(entry.file)) return result
1329
- bestMatch ??= result
1330
- }
1403
+ if (entry.normalizedText !== normalizedSearch) continue
1404
+ const collectionHit = rankAndStash(entry.file, textEntryToLocation(entry), pageFiles, matches)
1405
+ if (collectionHit) return collectionHit
1331
1406
  }
1332
1407
 
1333
- return bestMatch
1408
+ return matches.page ?? matches.other
1334
1409
  }
1335
1410
 
1336
1411
  /**
@@ -1345,34 +1420,57 @@ function extractPathname(src: string): string {
1345
1420
  }
1346
1421
 
1347
1422
  /**
1348
- * Fast image lookup using pre-built index
1423
+ * Fast image lookup using pre-built index.
1424
+ *
1425
+ * Priority order:
1426
+ * 1. `preferredLocation` (file, srcOccurrence) — Nth index entry for
1427
+ * (src, file). Deterministic per-DOM-order; preferred over `line` because
1428
+ * Astro's `data-astro-source-loc` often points at a common ancestor and
1429
+ * collides for multiple imgs sharing the same src.
1430
+ * 2. `preferredLocation` (file, line) exact match — used when occurrence
1431
+ * isn't provided (Astro's stamping happens to be precise enough).
1432
+ * 3. Collection data files (always authoritative).
1433
+ * 4. Entries in `pageFiles` — disambiguates same image across multiple pages.
1434
+ * 5. Any other match.
1349
1435
  */
1350
- export function findInImageIndex(imageSrc: string): SourceLocation | undefined {
1436
+ export function findInImageIndex(
1437
+ imageSrc: string,
1438
+ pageFiles?: readonly string[],
1439
+ preferredLocation?: { file: string; line?: number; srcOccurrence?: number },
1440
+ ): SourceLocation | undefined {
1351
1441
  const index = getImageSearchIndex()
1352
1442
 
1353
1443
  // Dev-mode optimized URLs (`/_image?href=...`, `/@image/...?f=...`) embed the source
1354
1444
  // path; try both the raw URL and the decoded path so callers don't need to pre-decode.
1355
1445
  const decoded = extractAstroImageOriginalUrl(imageSrc)
1356
1446
  const candidates = decoded && decoded !== imageSrc ? [imageSrc, decoded] : [imageSrc]
1357
-
1358
- // Exact match prefer collection data files (src/content/) over templates.
1359
- // The same image URL can appear in both a collection data file and a template
1360
- // that statically renders the collection. The data file is the authoritative source.
1361
- let bestMatch: SourceLocation | undefined
1447
+ // Astro stamps absolute paths in `data-astro-source-file`; the index uses
1448
+ // project-relative paths. Normalize before comparing.
1449
+ const preferredFile = preferredLocation ? toProjectRelativePath(preferredLocation.file) : undefined
1450
+ const preferredLine = preferredLocation?.line
1451
+ const preferredOccurrence = preferredLocation?.srcOccurrence
1452
+
1453
+ const matches: RankedMatches = {}
1454
+ let occurrenceCounter = 0
1455
+ let occurrenceMatch: SourceLocation | undefined
1456
+ let lineMatch: SourceLocation | undefined
1362
1457
  for (const entry of index) {
1363
1458
  if (!candidates.includes(entry.src)) continue
1364
- const result: SourceLocation = {
1365
- file: entry.file,
1366
- line: entry.line,
1367
- snippet: entry.snippet,
1368
- type: 'static',
1369
- }
1370
- if (isCollectionFile(entry.file)) {
1371
- return result // Collection data file — always preferred
1459
+ if (preferredFile && entry.file === preferredFile) {
1460
+ if (preferredOccurrence !== undefined && occurrenceCounter++ === preferredOccurrence) {
1461
+ occurrenceMatch ??= imageEntryToLocation(entry)
1462
+ }
1463
+ if (preferredLine !== undefined && entry.line === preferredLine) {
1464
+ lineMatch ??= imageEntryToLocation(entry)
1465
+ }
1372
1466
  }
1373
- bestMatch ??= result // Keep first non-collection match as fallback
1467
+ const collectionHit = rankAndStash(entry.file, imageEntryToLocation(entry), pageFiles, matches)
1468
+ if (collectionHit) return collectionHit
1374
1469
  }
1375
- if (bestMatch) return bestMatch
1470
+ if (occurrenceMatch) return occurrenceMatch
1471
+ if (lineMatch) return lineMatch
1472
+ const exact = matches.page ?? matches.other
1473
+ if (exact) return exact
1376
1474
 
1377
1475
  // Fallback: path suffix matching for CDN-transformed URLs
1378
1476
  // e.g., rendered src "/cdn-cgi/image/.../assets/photo.webp" should match
@@ -1380,19 +1478,11 @@ export function findInImageIndex(imageSrc: string): SourceLocation | undefined {
1380
1478
  const targetPath = extractPathname(imageSrc)
1381
1479
  for (const entry of index) {
1382
1480
  const entryPath = extractPathname(entry.src)
1383
- if (entryPath.length > 5 && (targetPath.endsWith(entryPath) || entryPath.endsWith(targetPath))) {
1384
- const result: SourceLocation = {
1385
- file: entry.file,
1386
- line: entry.line,
1387
- snippet: entry.snippet,
1388
- type: 'static',
1389
- }
1390
- if (isCollectionFile(entry.file)) {
1391
- return result
1392
- }
1393
- bestMatch ??= result
1394
- }
1481
+ if (entryPath.length <= 5) continue
1482
+ if (!targetPath.endsWith(entryPath) && !entryPath.endsWith(targetPath)) continue
1483
+ const collectionHit = rankAndStash(entry.file, imageEntryToLocation(entry), pageFiles, matches)
1484
+ if (collectionHit) return collectionHit
1395
1485
  }
1396
1486
 
1397
- return bestMatch
1487
+ return matches.page ?? matches.other
1398
1488
  }
@@ -629,6 +629,7 @@ function resolveTranslationKeyFromSnippet(
629
629
  export async function enhanceManifestWithSourceSnippets(
630
630
  entries: Record<string, ManifestEntry>,
631
631
  collectionDefinitions?: Record<string, CollectionDefinition>,
632
+ pageFiles?: readonly string[],
632
633
  ): Promise<Record<string, ManifestEntry>> {
633
634
  // Ensure the search index is ready (returns immediately if already built,
634
635
  // otherwise waits for the in-flight initialization or triggers a new one).
@@ -750,7 +751,19 @@ export async function enhanceManifestWithSourceSnippets(
750
751
  }
751
752
 
752
753
  // ── Non-collection images: find via search index / AST ──
753
- const imageLocation = await findImageSourceLocation(entry.imageMetadata.src, entry.imageMetadata.srcSet)
754
+ const preferredLocation = entry.sourcePath
755
+ ? {
756
+ file: entry.sourcePath,
757
+ line: entry.sourceLine,
758
+ srcOccurrence: entry.imageMetadata.srcOccurrence,
759
+ }
760
+ : undefined
761
+ const imageLocation = await findImageSourceLocation(
762
+ entry.imageMetadata.src,
763
+ entry.imageMetadata.srcSet,
764
+ pageFiles,
765
+ preferredLocation,
766
+ )
754
767
  if (imageLocation) {
755
768
  const sourceHash = generateSourceHash(imageLocation.snippet || entry.imageMetadata.src)
756
769
  const updated: ManifestEntry = {
@@ -862,7 +875,7 @@ export async function enhanceManifestWithSourceSnippets(
862
875
  ? findVariableHitInFile(trimmedEntryText, entry.tag, entry.sourcePath)
863
876
  : undefined
864
877
  const noCoords = !entry.sourcePath || !entry.sourceLine
865
- const winner = sameFileHit ?? (noCoords ? findInTextIndex(trimmedEntryText, entry.tag) : undefined)
878
+ const winner = sameFileHit ?? (noCoords ? findInTextIndex(trimmedEntryText, entry.tag, pageFiles) : undefined)
866
879
 
867
880
  if (winner) {
868
881
  if (isTranslationFilePath(winner.file)) {
@@ -1059,7 +1072,7 @@ export async function enhanceManifestWithSourceSnippets(
1059
1072
 
1060
1073
  // Last resort — consult the text index (covers i18n JSON dictionaries
1061
1074
  // and any other indexed text that shares no tag with the rendered element).
1062
- const indexHit = findInTextIndex(trimmedText, entry.tag)
1075
+ const indexHit = findInTextIndex(trimmedText, entry.tag, pageFiles)
1063
1076
  if (indexHit && indexHit.file !== entry.sourcePath) {
1064
1077
  const resolved = await applyTranslationSource(entry, indexHit, attributes, colorClasses)
1065
1078
  return [id, resolved] as const
@@ -17,14 +17,18 @@ import type { SourceLocation } from './types'
17
17
  /**
18
18
  * Find source file and line number for text content.
19
19
  * Uses pre-built search index for fast lookups.
20
+ *
21
+ * `pageFiles` disambiguates when the same text appears on multiple pages —
22
+ * the lookup prefers index entries whose file is one of the candidates.
20
23
  */
21
24
  export async function findSourceLocation(
22
25
  textContent: string,
23
26
  tag: string,
27
+ pageFiles?: readonly string[],
24
28
  ): Promise<SourceLocation | undefined> {
25
29
  // Use index if available (much faster)
26
30
  if (isSearchIndexInitialized()) {
27
- const indexHit = findInTextIndex(textContent, tag)
31
+ const indexHit = findInTextIndex(textContent, tag, pageFiles)
28
32
  if (indexHit) return indexHit
29
33
  // Fall through to slow search on miss — covers cases the per-file
30
34
  // indexer can't pre-emit, like a child component's `.map()` over a
package/src/types.ts CHANGED
@@ -70,6 +70,10 @@ export interface ImageMetadata {
70
70
  srcSet?: string
71
71
  /** Image sizes attribute */
72
72
  sizes?: string
73
+ /** 0-based DOM-order index of this `<img>` among same-(src, sourceFile)
74
+ * occurrences on the page. Disambiguates source location when the same
75
+ * image URL appears multiple times in the same source file. */
76
+ srcOccurrence?: number
73
77
  }
74
78
 
75
79
  /** Identifies the (collection, entry, field) destination for an editor upload. */