@nuasite/cms 0.39.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/dist/editor.js CHANGED
@@ -381,7 +381,7 @@ function WS(t, e) {
381
381
  function KS(t, e) {
382
382
  return typeof e == "function" ? e(t) : e;
383
383
  }
384
- const v3 = "0.39.0", k3 = v3, nt = {
384
+ const v3 = "0.39.1", k3 = v3, 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.39.0",
17
+ "version": "0.39.1",
18
18
  "module": "src/index.ts",
19
19
  "types": "src/index.ts",
20
20
  "type": "module",
@@ -5,7 +5,7 @@ import path from 'node:path'
5
5
  import { fileURLToPath } from 'node:url'
6
6
  import { getProjectRoot } from './config'
7
7
  import { detectArrayPattern, extractArrayElementProps } from './handlers/array-ops'
8
- import { extractPropsFromSource, findComponentInvocationLine, findFrontmatterEnd } from './handlers/component-ops'
8
+ import { extractPropsFromSource, findComponentInvocationLine, findFrontmatterEnd, getPageFileCandidates } from './handlers/component-ops'
9
9
  import { extractComponentName, processHtml } from './html-processor'
10
10
  import type { ManifestWriter } from './manifest-writer'
11
11
  import { generateComponentPreviews } from './preview-generator'
@@ -374,13 +374,27 @@ async function processFile(
374
374
  }
375
375
  }
376
376
 
377
+ const pageFiles = getPageFileCandidates(pagePath)
378
+
377
379
  // Process entries in parallel for better performance
378
380
  const entryLookups = Object.values(result.entries).map(async (entry) => {
379
381
  // Handle image entries specially - always search by image src
380
382
  // The sourcePath from HTML attributes may point to a shared Image component
381
383
  // rather than the file that actually uses the component with the src value
382
384
  if (entry.imageMetadata?.src) {
383
- const imageSource = await findImageSourceLocation(entry.imageMetadata.src, entry.imageMetadata.srcSet)
385
+ const preferredLocation = entry.sourcePath
386
+ ? {
387
+ file: entry.sourcePath,
388
+ line: entry.sourceLine,
389
+ srcOccurrence: entry.imageMetadata.srcOccurrence,
390
+ }
391
+ : undefined
392
+ const imageSource = await findImageSourceLocation(
393
+ entry.imageMetadata.src,
394
+ entry.imageMetadata.srcSet,
395
+ pageFiles,
396
+ preferredLocation,
397
+ )
384
398
  if (imageSource) {
385
399
  entry.sourcePath = imageSource.file
386
400
  entry.sourceLine = imageSource.line
@@ -412,7 +426,7 @@ async function processFile(
412
426
  }
413
427
 
414
428
  // Fall back to searching Astro files
415
- const sourceLocation = await findSourceLocation(entry.text, entry.tag)
429
+ const sourceLocation = await findSourceLocation(entry.text, entry.tag, pageFiles)
416
430
  if (sourceLocation) {
417
431
  entry.sourcePath = sourceLocation.file
418
432
  entry.sourceLine = sourceLocation.line
@@ -591,20 +591,33 @@ export async function enhanceManifestInBackground(
591
591
  }
592
592
  }
593
593
 
594
- const enhanced = await enhanceManifestWithSourceSnippets(entries, collectionDefinitions)
594
+ const pageFiles = getPageFileCandidates(pagePath)
595
+ const enhanced = await enhanceManifestWithSourceSnippets(entries, collectionDefinitions, pageFiles)
595
596
 
596
597
  // Fallback for entries without sourcePath — search index can still find them
597
598
  for (const entry of Object.values(enhanced)) {
598
599
  if (entry.sourceSnippet || entry.sourcePath) continue
599
600
  if (entry.imageMetadata?.src) {
600
- const loc = await findImageSourceLocation(entry.imageMetadata.src, entry.imageMetadata.srcSet)
601
+ const preferredLocation = entry.sourcePath
602
+ ? {
603
+ file: entry.sourcePath,
604
+ line: entry.sourceLine,
605
+ srcOccurrence: entry.imageMetadata.srcOccurrence,
606
+ }
607
+ : undefined
608
+ const loc = await findImageSourceLocation(
609
+ entry.imageMetadata.src,
610
+ entry.imageMetadata.srcSet,
611
+ pageFiles,
612
+ preferredLocation,
613
+ )
601
614
  if (loc) {
602
615
  entry.sourcePath = loc.file
603
616
  entry.sourceLine = loc.line
604
617
  entry.sourceSnippet = loc.snippet
605
618
  }
606
619
  } else if (entry.text && entry.tag) {
607
- const loc = await findSourceLocation(entry.text, entry.tag)
620
+ const loc = await findSourceLocation(entry.text, entry.tag, pageFiles)
608
621
  if (loc) {
609
622
  entry.sourcePath = loc.file
610
623
  entry.sourceLine = loc.line
@@ -662,6 +662,12 @@ export async function processHtml(
662
662
  sourceLine?: number
663
663
  }
664
664
  const imageEntries = new Map<string, ImageEntry>()
665
+ // Per (sourceFile, src) DOM-order occurrence counter. When the same image
666
+ // URL appears N times rendered from the same source file, this lets us
667
+ // map each rendered `<img>` to the Nth index entry for that (file, src)
668
+ // — disambiguating cases where data-astro-source-loc alone doesn't (e.g.
669
+ // when only a wrapper element carries the source attribution).
670
+ const srcOccurrenceCounts = new Map<string, number>()
665
671
  root.querySelectorAll('img').forEach((node) => {
666
672
  // Skip if already marked
667
673
  if (node.getAttribute(attributeName)) return
@@ -682,12 +688,17 @@ export async function processHtml(
682
688
 
683
689
  const { sourceFile, sourceLine } = findAncestorSourceLocation(node)
684
690
 
691
+ const occurrenceKey = `${sourceFile ?? ''}\0${src}`
692
+ const srcOccurrence = srcOccurrenceCounts.get(occurrenceKey) ?? 0
693
+ srcOccurrenceCounts.set(occurrenceKey, srcOccurrence + 1)
694
+
685
695
  // Build image metadata
686
696
  const metadata: ImageMetadata = {
687
697
  src,
688
698
  alt: node.getAttribute('alt') || '',
689
699
  srcSet: node.getAttribute('srcset') || undefined,
690
700
  sizes: node.getAttribute('sizes') || undefined,
701
+ srcOccurrence,
691
702
  }
692
703
 
693
704
  // Store image info for manifest
@@ -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
  }
@@ -24,7 +24,7 @@ import {
24
24
  setSearchIndexInitialized,
25
25
  } from './cache'
26
26
  import { extractAstroImageOriginalUrl, extractImageSnippet, extractInnerHtmlFromSnippet, normalizeText } from './snippet-utils'
27
- import type { CachedParsedFile, SearchIndexEntry, SourceLocation } from './types'
27
+ import type { CachedParsedFile, ImageIndexEntry, SearchIndexEntry, SourceLocation } from './types'
28
28
 
29
29
  /** Collection data files live under this path — used to prefer them over templates */
30
30
  const CONTENT_DIR_PREFIX = 'src/content/'
@@ -1268,6 +1268,37 @@ function textEntryToLocation(entry: SearchIndexEntry): SourceLocation {
1268
1268
  }
1269
1269
  }
1270
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
+
1271
1302
  /**
1272
1303
  * Look up an i18n dictionary entry by its literal key (e.g. `nav.prague4`),
1273
1304
  * preferring the match whose value equals `normalizedText` so the right locale
@@ -1325,55 +1356,56 @@ function toProjectRelativePath(file: string): string {
1325
1356
  }
1326
1357
 
1327
1358
  /**
1328
- * 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.
1329
1365
  */
1330
- 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 {
1331
1371
  const normalizedSearch = normalizeText(textContent)
1332
1372
  const tagLower = tag.toLowerCase()
1333
1373
  const index = getTextSearchIndex()
1334
-
1335
- // Single pass for exact matches: collect the best same-tag template hit
1336
- // *and* any i18n dictionary hit at once. A JSON dictionary entry is an
1337
- // authoritative translatable signal, so it beats a non-collection
1338
- // template match (which is often a coincidental same-text element).
1339
- let bestMatch: SourceLocation | undefined
1374
+ const matches: RankedMatches = {}
1340
1375
  let translationHit: SourceLocation | undefined
1376
+
1341
1377
  for (const entry of index) {
1342
1378
  if (entry.normalizedText !== normalizedSearch) continue
1343
1379
  if (entry.tag === tagLower) {
1344
- const result = textEntryToLocation(entry)
1345
- if (isCollectionFile(entry.file)) return result
1346
- bestMatch ??= result
1380
+ const collectionHit = rankAndStash(entry.file, textEntryToLocation(entry), pageFiles, matches)
1381
+ if (collectionHit) return collectionHit
1347
1382
  } else if (entry.tag === TRANSLATION_TAG_MARKER) {
1348
1383
  translationHit ??= textEntryToLocation(entry)
1349
1384
  }
1350
1385
  }
1351
1386
  if (translationHit) return translationHit
1352
- if (bestMatch) return bestMatch
1387
+ const sameTag = matches.page ?? matches.other
1388
+ if (sameTag) return sameTag
1353
1389
 
1354
- // Then try partial match for longer text — prefer collection data files
1355
1390
  if (normalizedSearch.length > 10) {
1356
1391
  const textPreview = normalizedSearch.slice(0, Math.min(30, normalizedSearch.length))
1357
1392
  for (const entry of index) {
1358
- if (entry.tag === tagLower && entry.normalizedText.includes(textPreview)) {
1359
- const result = textEntryToLocation(entry)
1360
- if (isCollectionFile(entry.file)) return result
1361
- bestMatch ??= result
1362
- }
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
1363
1397
  }
1364
- if (bestMatch) return bestMatch
1398
+ const partial = matches.page ?? matches.other
1399
+ if (partial) return partial
1365
1400
  }
1366
1401
 
1367
- // Try any tag match — prefer collection data files
1368
1402
  for (const entry of index) {
1369
- if (entry.normalizedText === normalizedSearch) {
1370
- const result = textEntryToLocation(entry)
1371
- if (isCollectionFile(entry.file)) return result
1372
- bestMatch ??= result
1373
- }
1403
+ if (entry.normalizedText !== normalizedSearch) continue
1404
+ const collectionHit = rankAndStash(entry.file, textEntryToLocation(entry), pageFiles, matches)
1405
+ if (collectionHit) return collectionHit
1374
1406
  }
1375
1407
 
1376
- return bestMatch
1408
+ return matches.page ?? matches.other
1377
1409
  }
1378
1410
 
1379
1411
  /**
@@ -1388,34 +1420,57 @@ function extractPathname(src: string): string {
1388
1420
  }
1389
1421
 
1390
1422
  /**
1391
- * 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.
1392
1435
  */
1393
- 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 {
1394
1441
  const index = getImageSearchIndex()
1395
1442
 
1396
1443
  // Dev-mode optimized URLs (`/_image?href=...`, `/@image/...?f=...`) embed the source
1397
1444
  // path; try both the raw URL and the decoded path so callers don't need to pre-decode.
1398
1445
  const decoded = extractAstroImageOriginalUrl(imageSrc)
1399
1446
  const candidates = decoded && decoded !== imageSrc ? [imageSrc, decoded] : [imageSrc]
1400
-
1401
- // Exact match prefer collection data files (src/content/) over templates.
1402
- // The same image URL can appear in both a collection data file and a template
1403
- // that statically renders the collection. The data file is the authoritative source.
1404
- 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
1405
1457
  for (const entry of index) {
1406
1458
  if (!candidates.includes(entry.src)) continue
1407
- const result: SourceLocation = {
1408
- file: entry.file,
1409
- line: entry.line,
1410
- snippet: entry.snippet,
1411
- type: 'static',
1412
- }
1413
- if (isCollectionFile(entry.file)) {
1414
- 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
+ }
1415
1466
  }
1416
- bestMatch ??= result // Keep first non-collection match as fallback
1467
+ const collectionHit = rankAndStash(entry.file, imageEntryToLocation(entry), pageFiles, matches)
1468
+ if (collectionHit) return collectionHit
1417
1469
  }
1418
- 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
1419
1474
 
1420
1475
  // Fallback: path suffix matching for CDN-transformed URLs
1421
1476
  // e.g., rendered src "/cdn-cgi/image/.../assets/photo.webp" should match
@@ -1423,19 +1478,11 @@ export function findInImageIndex(imageSrc: string): SourceLocation | undefined {
1423
1478
  const targetPath = extractPathname(imageSrc)
1424
1479
  for (const entry of index) {
1425
1480
  const entryPath = extractPathname(entry.src)
1426
- if (entryPath.length > 5 && (targetPath.endsWith(entryPath) || entryPath.endsWith(targetPath))) {
1427
- const result: SourceLocation = {
1428
- file: entry.file,
1429
- line: entry.line,
1430
- snippet: entry.snippet,
1431
- type: 'static',
1432
- }
1433
- if (isCollectionFile(entry.file)) {
1434
- return result
1435
- }
1436
- bestMatch ??= result
1437
- }
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
1438
1485
  }
1439
1486
 
1440
- return bestMatch
1487
+ return matches.page ?? matches.other
1441
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. */