@nuasite/cms 0.23.0 → 0.24.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/package.json CHANGED
@@ -14,7 +14,7 @@
14
14
  "directory": "packages/astro-cms"
15
15
  },
16
16
  "license": "Apache-2.0",
17
- "version": "0.23.0",
17
+ "version": "0.24.0",
18
18
  "module": "src/index.ts",
19
19
  "types": "src/index.ts",
20
20
  "type": "module",
@@ -24,7 +24,7 @@ import {
24
24
  } from './source-finder'
25
25
  import type { ComponentInstance } from './types'
26
26
  import type { CmsMarkerOptions, CollectionEntry } from './types'
27
- import { firstNonEmptyLine } from './utils'
27
+ import { firstNonEmptyLine, resolveSourcePath } from './utils'
28
28
 
29
29
  // Concurrency limit for parallel processing
30
30
  const MAX_CONCURRENT = 10
@@ -425,9 +425,7 @@ async function processFile(
425
425
 
426
426
  // Update attribute and colorClasses source information if we have an opening tag
427
427
  if (sourceLocation.openingTagSnippet) {
428
- const filePath = path.isAbsolute(sourceLocation.file)
429
- ? sourceLocation.file
430
- : path.join(getProjectRoot(), sourceLocation.file)
428
+ const filePath = resolveSourcePath(sourceLocation.file)
431
429
  try {
432
430
  const content = await fs.readFile(filePath, 'utf-8')
433
431
  const lines = content.split('\n')
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Vite SSR module cache invalidation + content-sync coordination.
3
+ *
4
+ * Astro's content layer chain (chokidar → glob loader → syncData → data store
5
+ * → fs.watch → invalidateModule) is racy and unreliable under several conditions:
6
+ *
7
+ * - Native fs.watch on Linux dies after the first atomic rename of the watched
8
+ * file (Astro writes data-store.json via writeFile-tmp + rename).
9
+ * - Vite's bundled chokidar 3.6.0 misses the same atomic-write events.
10
+ * - `invalidateModule(astro:data-layer-content)` alone does not propagate up
11
+ * the import graph, so route modules that already cached `getCollection`
12
+ * references keep returning stale data.
13
+ *
14
+ * This module exposes two things:
15
+ *
16
+ * - `invalidateContentCache(server)` — walks the SSR module graph from
17
+ * `astro:data-layer-content` upward and invalidates every transitive
18
+ * importer, then broadcasts `full-reload` to the client.
19
+ * - `notifyContentStoreUpdated` / `awaitNextContentStoreUpdate` — a shared
20
+ * rendezvous between the fs.watch plugin (which observes data-store.json
21
+ * writes) and the CMS API middleware (which needs to hold the HTTP
22
+ * response until the store is fresh). Keeps invalidation on a single path.
23
+ */
24
+
25
+ interface SsrModuleNode {
26
+ id: string | null
27
+ importers: Set<SsrModuleNode>
28
+ }
29
+
30
+ interface SsrModuleGraph {
31
+ getModuleById(id: string): SsrModuleNode | undefined
32
+ invalidateModule(
33
+ mod: SsrModuleNode,
34
+ seen?: Set<SsrModuleNode>,
35
+ timestamp?: number,
36
+ isHmr?: boolean,
37
+ ): void
38
+ }
39
+
40
+ interface SsrEnvironment {
41
+ moduleGraph: SsrModuleGraph
42
+ hot: { send: (event: string, data?: unknown) => void }
43
+ }
44
+
45
+ interface ClientEnvironment {
46
+ hot: { send: (payload: { type: string; path: string }) => void }
47
+ }
48
+
49
+ export interface ViteServerLike {
50
+ environments: { ssr: SsrEnvironment; client: ClientEnvironment }
51
+ }
52
+
53
+ // Astro exposes the content data store as a virtual module whose resolved id
54
+ // is `\0astro:data-layer-content` (see astro/dist/content/consts.js). Earlier
55
+ // versions of this file used `\0astro:data-store`, which does not exist and
56
+ // silently reduced `invalidateContentCache` to a no-op full-reload broadcast.
57
+ const DATA_STORE_VIRTUAL_ID = '\0astro:data-layer-content'
58
+
59
+ /**
60
+ * Invalidate the SSR `astro:data-layer-content` virtual module and every
61
+ * module that (transitively) imports it. After this returns, the next request
62
+ * that imports any of these modules will re-execute and read fresh content.
63
+ *
64
+ * Also broadcasts `full-reload` so any connected browser refreshes.
65
+ */
66
+ export function invalidateContentCache(server: ViteServerLike): void {
67
+ const ssr = server.environments.ssr
68
+ const dataStoreMod = ssr.moduleGraph.getModuleById(DATA_STORE_VIRTUAL_ID)
69
+ if (dataStoreMod) {
70
+ const seen = new Set<SsrModuleNode>()
71
+ const ts = Date.now()
72
+ const walk = (mod: SsrModuleNode) => {
73
+ if (seen.has(mod)) return
74
+ seen.add(mod)
75
+ ssr.moduleGraph.invalidateModule(mod, seen, ts, true)
76
+ for (const importer of mod.importers) {
77
+ walk(importer)
78
+ }
79
+ }
80
+ walk(dataStoreMod)
81
+ }
82
+ ssr.hot.send('astro:content-changed', {})
83
+ server.environments.client.hot.send({ type: 'full-reload', path: '*' })
84
+ }
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // Content-sync rendezvous
88
+ // ---------------------------------------------------------------------------
89
+ //
90
+ // The CMS API middleware writes a content file and then needs to hold the HTTP
91
+ // response until Astro has actually re-synced the data store — otherwise the
92
+ // browser reloads into a stale render. The fs.watch plugin is the component
93
+ // that observes the data-store.json write, so it is also the component that
94
+ // resolves these waiters.
95
+
96
+ type StoreUpdateResolver = () => void
97
+ const pendingStoreUpdateWaiters = new Set<StoreUpdateResolver>()
98
+
99
+ /**
100
+ * Called by the data-store fs.watch plugin after it has invalidated the SSR
101
+ * module cache in response to a data-store.json write. Wakes every middleware
102
+ * caller currently parked in `awaitNextContentStoreUpdate`.
103
+ */
104
+ export function notifyContentStoreUpdated(): void {
105
+ if (pendingStoreUpdateWaiters.size === 0) return
106
+ const resolvers = Array.from(pendingStoreUpdateWaiters)
107
+ pendingStoreUpdateWaiters.clear()
108
+ for (const resolve of resolvers) resolve()
109
+ }
110
+
111
+ /**
112
+ * Park until the next data-store.json write has been fully processed (store
113
+ * reloaded on disk, SSR module graph invalidated). Resolves with `true` on
114
+ * success or `false` if the timeout elapses first — callers should treat
115
+ * timeout as "best-effort, proceed anyway".
116
+ *
117
+ * The timeout fallback exists because some edits legitimately do not change
118
+ * the data store (e.g. whitespace-only edits are skipped by Astro's atomic
119
+ * write comparator), in which case no fs.watch event will ever fire.
120
+ */
121
+ export function awaitNextContentStoreUpdate(timeoutMs: number): Promise<boolean> {
122
+ return new Promise((resolve) => {
123
+ const resolver = () => {
124
+ clearTimeout(timer)
125
+ pendingStoreUpdateWaiters.delete(resolver)
126
+ resolve(true)
127
+ }
128
+ const timer = setTimeout(() => {
129
+ pendingStoreUpdateWaiters.delete(resolver)
130
+ resolve(false)
131
+ }, timeoutMs)
132
+ pendingStoreUpdateWaiters.add(resolver)
133
+ })
134
+ }
@@ -2,6 +2,7 @@ import fs from 'node:fs/promises'
2
2
  import type { IncomingMessage, ServerResponse } from 'node:http'
3
3
  import path from 'node:path'
4
4
  import { getProjectRoot } from './config'
5
+ import { awaitNextContentStoreUpdate } from './content-invalidator'
5
6
  import { handleCmsApiRoute } from './handlers/api-routes'
6
7
  import { buildMapPattern, detectArrayPattern, extractArrayElementProps, parseInlineArrayName } from './handlers/array-ops'
7
8
  import {
@@ -43,6 +44,7 @@ interface ViteDevServerLike {
43
44
  watcher?: {
44
45
  on: (event: string, listener: (...args: any[]) => void) => any
45
46
  removeListener: (event: string, listener: (...args: any[]) => void) => any
47
+ emit: (event: string, ...args: any[]) => boolean
46
48
  }
47
49
  }
48
50
 
@@ -100,6 +102,45 @@ export function createDevMiddleware(
100
102
 
101
103
  // CMS API endpoints (local dev server backend)
102
104
  if (options.enableCmsApi) {
105
+ const projectRoot = getProjectRoot()
106
+
107
+ /**
108
+ * Hold the HTTP response for a `markdown/update` (or equivalent) call
109
+ * until Astro's content layer has actually re-synced the edited file.
110
+ *
111
+ * The race we're fixing: handleUpdateMarkdown writes the file and
112
+ * returns immediately, the editor then triggers a full-reload, and
113
+ * the next page render reads a still-cached `astro:data-layer-content`
114
+ * virtual module — so the user sees their edit disappear until Astro's
115
+ * async chain (glob loader → syncData → 500 ms save debounce → atomic
116
+ * write → fs.watch → invalidateModule) finally catches up.
117
+ *
118
+ * The fix, end to end:
119
+ *
120
+ * 1. `server.watcher.emit('change', fullPath)` kicks Astro's glob
121
+ * loader directly. It is registered on this exact watcher (see
122
+ * astro/dist/core/dev/dev.js — `viteServer.watcher` is handed to
123
+ * `globalContentLayer.init`), so synthetic change events fire its
124
+ * `onChange` handler and trigger `syncData`. This also works
125
+ * around Vite's bundled chokidar missing some edits.
126
+ * 2. `awaitNextContentStoreUpdate` parks until the shared data-store
127
+ * watcher (in `vite-plugin.ts`) observes the resulting atomic
128
+ * write and finishes invalidating the SSR module graph.
129
+ * 3. Only then do we return — so the subsequent full-reload lands
130
+ * on a page that will re-execute with fresh content.
131
+ *
132
+ * The timeout fallback covers edits that legitimately do not rewrite
133
+ * the data store (Astro's MutableDataStore skips identical writes).
134
+ * In that case no fs.watch event will ever fire, and 3 s is plenty of
135
+ * budget before we give up and let the response through anyway.
136
+ */
137
+ const notifyContentChanged = async (filePath: string): Promise<void> => {
138
+ const fullPath = path.resolve(projectRoot, filePath)
139
+ const waiter = awaitNextContentStoreUpdate(3000)
140
+ server.watcher?.emit('change', fullPath)
141
+ await waiter
142
+ }
143
+
103
144
  server.middlewares.use((req, res, next) => {
104
145
  const url = req.url || ''
105
146
  if (!url.startsWith('/_nua/cms/')) {
@@ -111,7 +152,7 @@ export function createDevMiddleware(
111
152
 
112
153
  const route = url.replace('/_nua/cms/', '').split('?')[0]!
113
154
 
114
- handleCmsApiRoute(route, req, res, manifestWriter, config.contentDir, options.mediaAdapter)
155
+ handleCmsApiRoute(route, req, res, manifestWriter, config.contentDir, options.mediaAdapter, notifyContentChanged)
115
156
  .catch((error) => {
116
157
  console.error('[astro-cms] API error:', error)
117
158
  sendError(res, 'Internal server error', 500)
@@ -109,9 +109,8 @@ const CmsUI = () => {
109
109
  // Re-fetch manifest on View Transitions navigation (astro:after-swap)
110
110
  useEffect(() => {
111
111
  const onNavigation = () => {
112
- fetchManifest().then((manifest) => {
113
- signals.setManifest(manifest)
114
- }).catch(() => {})
112
+ postToParent({ type: 'cms-page-navigated', page: { pathname: window.location.pathname } })
113
+ fetchManifest().then((manifest) => signals.setManifest(manifest)).catch(() => {})
115
114
  }
116
115
  document.addEventListener('astro:after-swap', onNavigation)
117
116
  return () => document.removeEventListener('astro:after-swap', onNavigation)
@@ -21,6 +21,15 @@ export interface RouteContext {
21
21
  manifestWriter: ManifestWriter
22
22
  contentDir: string
23
23
  mediaAdapter?: MediaStorageAdapter
24
+ /**
25
+ * Triggered after a content file (markdown / data collection) is written so
26
+ * the dev middleware can synchronously refresh Astro's content layer and
27
+ * invalidate Vite's SSR module cache before responding to the client.
28
+ *
29
+ * Awaiting this is important: returning success before the cache is fresh
30
+ * causes the editor to reload the page into a stale render.
31
+ */
32
+ notifyContentChanged?: (filePath: string) => Promise<void>
24
33
  }
25
34
 
26
35
  type RouteHandler = (ctx: RouteContext) => Promise<void>
@@ -104,9 +113,13 @@ const routeMap = new Map<string, RouteHandler>([
104
113
  }
105
114
  sendJson(res, result)
106
115
  }),
107
- custom('POST', 'markdown/update', async ({ req, res, manifestWriter }) => {
116
+ custom('POST', 'markdown/update', async ({ req, res, manifestWriter, notifyContentChanged }) => {
108
117
  const body = await parseJsonBody<Parameters<typeof handleUpdateMarkdown>[0]>(req)
109
- sendJson(res, await handleUpdateMarkdown(body, manifestWriter.getComponentDefinitions()))
118
+ const result = await handleUpdateMarkdown(body, manifestWriter.getComponentDefinitions())
119
+ if (result.success && notifyContentChanged) {
120
+ await notifyContentChanged(body.filePath)
121
+ }
122
+ sendJson(res, result)
110
123
  }),
111
124
  post('markdown/rename', (body: Parameters<typeof handleRenameMarkdown>[0]) => handleRenameMarkdown(body)),
112
125
  postWithStatus('markdown/create', (body: Parameters<typeof handleCreateMarkdown>[0]) => handleCreateMarkdown(body)),
@@ -225,8 +238,9 @@ export async function handleCmsApiRoute(
225
238
  manifestWriter: ManifestWriter,
226
239
  contentDir: string,
227
240
  mediaAdapter?: MediaStorageAdapter,
241
+ notifyContentChanged?: (filePath: string) => Promise<void>,
228
242
  ): Promise<void> {
229
- const ctx: RouteContext = { req, res, route, manifestWriter, contentDir, mediaAdapter }
243
+ const ctx: RouteContext = { req, res, route, manifestWriter, contentDir, mediaAdapter, notifyContentChanged }
230
244
 
231
245
  // Exact match lookup
232
246
  const handler = routeMap.get(`${req.method}:${route}`)
@@ -575,6 +575,54 @@ export async function findFieldInCollectionEntry(
575
575
  }
576
576
  }
577
577
 
578
+ /**
579
+ * Find multiple fields by name in a specific collection entry's data file.
580
+ * Parses the YAML only once, unlike calling findFieldInCollectionEntry per field.
581
+ */
582
+ export async function findFieldsInCollectionEntry(
583
+ fieldNames: Set<string>,
584
+ collectionName: string,
585
+ collectionSlug: string,
586
+ collectionDefinitions: Record<string, CollectionDefinition>,
587
+ ): Promise<Map<string, SourceLocation>> {
588
+ const def = collectionDefinitions[collectionName]
589
+ if (!def?.entries) return new Map()
590
+
591
+ const entry = def.entries.find((e) => e.slug === collectionSlug)
592
+ if (!entry) return new Map()
593
+
594
+ const info: CollectionInfo = { name: collectionName, slug: collectionSlug, file: entry.sourcePath }
595
+
596
+ try {
597
+ const filePath = path.join(getProjectRoot(), entry.sourcePath)
598
+ const cached = await getCachedMarkdownFile(filePath)
599
+ if (!cached) return new Map()
600
+
601
+ if (def.type === 'data') {
602
+ return findFieldsByNameInYaml(cached.content, 0, fieldNames, cached.lines, info)
603
+ }
604
+
605
+ // For markdown, search inside frontmatter only
606
+ const { lines } = cached
607
+ let fmStart = -1
608
+ let fmEnd = -1
609
+ for (let i = 0; i < lines.length; i++) {
610
+ if (lines[i]?.trim() === '---') {
611
+ if (fmStart === -1) fmStart = i
612
+ else {
613
+ fmEnd = i
614
+ break
615
+ }
616
+ }
617
+ }
618
+ if (fmEnd <= 0) return new Map()
619
+ const yamlStr = lines.slice(fmStart + 1, fmEnd).join('\n')
620
+ return findFieldsByNameInYaml(yamlStr, fmStart + 1, fieldNames, lines, info)
621
+ } catch {
622
+ return new Map()
623
+ }
624
+ }
625
+
578
626
  /**
579
627
  * Walk a YAML AST to find a field by key name (regardless of its value).
580
628
  */
@@ -585,13 +633,30 @@ function findFieldByNameInYaml(
585
633
  fileLines: string[],
586
634
  collectionInfo: CollectionInfo,
587
635
  ): SourceLocation | undefined {
636
+ const results = findFieldsByNameInYaml(yamlStr, lineOffset, new Set([fieldName]), fileLines, collectionInfo)
637
+ return results.get(fieldName)
638
+ }
639
+
640
+ /**
641
+ * Walk a YAML AST to find multiple fields by key name in a single parse.
642
+ * Returns a map of fieldName → SourceLocation for all matched fields.
643
+ */
644
+ function findFieldsByNameInYaml(
645
+ yamlStr: string,
646
+ lineOffset: number,
647
+ fieldNames: Set<string>,
648
+ fileLines: string[],
649
+ collectionInfo: CollectionInfo,
650
+ ): Map<string, SourceLocation> {
588
651
  const lineCounter = new LineCounter()
589
652
  const doc = parseDocument(yamlStr, { lineCounter })
590
- if (!isMap(doc.contents)) return undefined
653
+ const results = new Map<string, SourceLocation>()
654
+ if (!isMap(doc.contents)) return results
591
655
 
592
656
  for (const pair of doc.contents.items) {
593
657
  if (!isPair(pair) || !isScalar(pair.key)) continue
594
- if (String(pair.key.value) !== fieldName) continue
658
+ const key = String(pair.key.value)
659
+ if (!fieldNames.has(key)) continue
595
660
  if (!isScalar(pair.value)) continue
596
661
 
597
662
  const keyRange = (pair.key as any).range as [number, number, number] | undefined
@@ -600,17 +665,20 @@ function findFieldByNameInYaml(
600
665
  const endLine = (valRange ? lineCounter.linePos(valRange[1]).line : startLine - lineOffset) + lineOffset
601
666
 
602
667
  const snippet = fileLines.slice(startLine - 1, endLine).join('\n')
603
- return {
668
+ results.set(key, {
604
669
  file: collectionInfo.file,
605
670
  line: startLine,
606
671
  snippet,
607
672
  type: 'collection',
608
- variableName: fieldName,
673
+ variableName: key,
609
674
  collectionName: collectionInfo.name,
610
675
  collectionSlug: collectionInfo.slug,
611
- }
676
+ })
677
+
678
+ // Early exit if all fields found
679
+ if (results.size === fieldNames.size) break
612
680
  }
613
- return undefined
681
+ return results
614
682
  }
615
683
 
616
684
  // ============================================================================
@@ -2,7 +2,7 @@ import fs from 'node:fs/promises'
2
2
  import path from 'node:path'
3
3
 
4
4
  import { getProjectRoot } from '../config'
5
- import { escapeRegex } from '../utils'
5
+ import { escapeRegex, resolveSourcePath } from '../utils'
6
6
  import { buildDefinitionPath, parseExpressionPath } from './ast-extractors'
7
7
  import { getCachedParsedFile } from './ast-parser'
8
8
  import { findComponentProp, findExpressionProp, findSpreadProp } from './element-finder'
@@ -384,9 +384,7 @@ export async function findAttributeSourceLocation(
384
384
  // Get the property name (last part of the expression)
385
385
  const propName = exprPath.includes('.') ? exprPath.split('.').pop()! : exprPath
386
386
 
387
- const filePath = path.isAbsolute(sourceFilePath)
388
- ? sourceFilePath
389
- : path.join(getProjectRoot(), sourceFilePath)
387
+ const filePath = resolveSourcePath(sourceFilePath)
390
388
 
391
389
  const cached = await getCachedParsedFile(filePath)
392
390
  if (!cached) return undefined
@@ -4,10 +4,16 @@ import { parse as parseYaml } from 'yaml'
4
4
 
5
5
  import { getProjectRoot } from '../config'
6
6
  import type { Attribute, CollectionDefinition, ManifestEntry } from '../types'
7
- import { escapeRegex, generateSourceHash } from '../utils'
7
+ import { escapeRegex, generateSourceHash, resolveSourcePath } from '../utils'
8
8
  import { buildDefinitionPath } from './ast-extractors'
9
9
  import { getCachedParsedFile } from './ast-parser'
10
- import { buildCollectionTextIndex, findFieldInCollectionEntry, findTextInAnyCollectionFrontmatter, lookupCollectionText } from './collection-finder'
10
+ import {
11
+ buildCollectionTextIndex,
12
+ findFieldInCollectionEntry,
13
+ findFieldsInCollectionEntry,
14
+ findTextInAnyCollectionFrontmatter,
15
+ lookupCollectionText,
16
+ } from './collection-finder'
11
17
  import { findAttributeSourceLocation, searchForExpressionProp, searchForPropInParents } from './cross-file-tracker'
12
18
  import { findImageElementNearLine, findImageSourceLocation } from './image-finder'
13
19
  import { initializeSearchIndex } from './search-index'
@@ -444,9 +450,7 @@ export async function extractSourceSnippet(
444
450
  tag: string,
445
451
  ): Promise<string | undefined> {
446
452
  try {
447
- const filePath = path.isAbsolute(sourceFile)
448
- ? sourceFile
449
- : path.join(getProjectRoot(), sourceFile)
453
+ const filePath = resolveSourcePath(sourceFile)
450
454
 
451
455
  const content = await fs.readFile(filePath, 'utf-8')
452
456
  const lines = content.split('\n')
@@ -583,9 +587,7 @@ export async function enhanceManifestWithSourceSnippets(
583
587
 
584
588
  // Also update attribute and colorClasses source info from the opening tag
585
589
  try {
586
- const filePath = path.isAbsolute(imageLocation.file)
587
- ? imageLocation.file
588
- : path.join(getProjectRoot(), imageLocation.file)
590
+ const filePath = resolveSourcePath(imageLocation.file)
589
591
  const { lines } = await readFileWithCache(filePath)
590
592
  const openingTagInfo = extractOpeningTagWithLine(lines, imageLocation.line - 1, entry.tag)
591
593
 
@@ -620,9 +622,7 @@ export async function enhanceManifestWithSourceSnippets(
620
622
  // Fallback for expression-based src attributes (src={variable})
621
623
  if (entry.sourcePath && entry.sourceLine) {
622
624
  try {
623
- const filePath = path.isAbsolute(entry.sourcePath)
624
- ? entry.sourcePath
625
- : path.join(getProjectRoot(), entry.sourcePath)
625
+ const filePath = resolveSourcePath(entry.sourcePath)
626
626
  const cached = await getCachedParsedFile(filePath)
627
627
  if (cached) {
628
628
  const nearbyImg = findImageElementNearLine(cached.ast, entry.sourceLine, cached.lines)
@@ -662,6 +662,18 @@ export async function enhanceManifestWithSourceSnippets(
662
662
  return [id, entry] as const
663
663
  }
664
664
 
665
+ // Collection text: resolve directly from the data file
666
+ if (entry.text?.trim() && entry.collectionName && entry.collectionSlug && collectionDefinitions) {
667
+ const textLocation = await resolveCollectionTextField(
668
+ entry,
669
+ collectionDefinitions,
670
+ referenceIndex,
671
+ )
672
+ if (textLocation) {
673
+ return [id, textLocation] as const
674
+ }
675
+ }
676
+
665
677
  // Skip if already has sourceSnippet or missing source info
666
678
  if (entry.sourceSnippet || !entry.sourcePath || !entry.sourceLine || !entry.tag) {
667
679
  return [id, entry] as const
@@ -669,9 +681,7 @@ export async function enhanceManifestWithSourceSnippets(
669
681
 
670
682
  // Read file once and extract both snippets
671
683
  try {
672
- const filePath = path.isAbsolute(entry.sourcePath)
673
- ? entry.sourcePath
674
- : path.join(getProjectRoot(), entry.sourcePath)
684
+ const filePath = resolveSourcePath(entry.sourcePath)
675
685
 
676
686
  const { content, lines } = await readFileWithCache(filePath)
677
687
 
@@ -973,36 +983,33 @@ async function resolveCollectionImageField(
973
983
  return undefined
974
984
  }
975
985
 
976
- // Multiple image fields — try to match the rendered URL to a field value.
986
+ // Multiple image fields — fetch all in one YAML parse, then match by value
977
987
  const imgSrc = entry.imageMetadata!.src
988
+ const allResults = await findFieldsInCollectionEntry(
989
+ new Set(imageFields.map(f => f.name)),
990
+ entry.collectionName!,
991
+ entry.collectionSlug!,
992
+ collectionDefinitions,
993
+ )
994
+
978
995
  let firstFieldResult: SourceLocation | undefined
979
996
  for (const field of imageFields) {
980
- const fieldResult = await findFieldInCollectionEntry(
981
- field.name,
982
- entry.collectionName!,
983
- entry.collectionSlug!,
984
- collectionDefinitions,
985
- )
997
+ const fieldResult = allResults.get(field.name)
986
998
  if (!fieldResult?.snippet) continue
987
999
 
988
- // Remember the first resolved field as fallback
989
1000
  firstFieldResult ??= fieldResult
990
1001
 
991
- // Check if the field's value matches the rendered URL (exact or after Astro processing)
992
- const yamlKeyMatch = fieldResult.snippet.match(/^\s*[\w][\w-]*:\s*/)
993
- if (yamlKeyMatch) {
994
- try {
995
- const parsed = parseYaml(fieldResult.snippet)
996
- if (parsed && typeof parsed === 'object') {
997
- const key = fieldResult.snippet.match(/^\s*([\w][\w-]*):/)?.[1]
998
- const value = key ? (parsed as Record<string, unknown>)[key] : undefined
999
- if (typeof value === 'string' && (value === imgSrc || imgSrc.includes(value) || value.includes(imgSrc))) {
1000
- return applyCollectionSource(entry, fieldResult, referenceIndex)
1001
- }
1002
+ try {
1003
+ const cleaned = fieldResult.snippet.replace(/,\s*$/, '')
1004
+ const parsed = parseYaml(cleaned)
1005
+ if (parsed && typeof parsed === 'object') {
1006
+ const value = (parsed as Record<string, unknown>)[field.name]
1007
+ if (typeof value === 'string' && (value === imgSrc || imgSrc.includes(value) || value.includes(imgSrc))) {
1008
+ return applyCollectionSource(entry, fieldResult, referenceIndex)
1002
1009
  }
1003
- } catch {
1004
- // Not valid YAML
1005
1010
  }
1011
+ } catch {
1012
+ // Not valid YAML/JSON
1006
1013
  }
1007
1014
  }
1008
1015
 
@@ -1014,6 +1021,110 @@ async function resolveCollectionImageField(
1014
1021
  return undefined
1015
1022
  }
1016
1023
 
1024
+ // ============================================================================
1025
+ // Collection Text Resolution
1026
+ // ============================================================================
1027
+
1028
+ /**
1029
+ * Resolve a collection text entry directly from the data file.
1030
+ * Two strategies, tried in order:
1031
+ *
1032
+ * 1. **Source-map** — read the template expression (e.g., {post.data.title}),
1033
+ * extract the field name, look it up by name in the data file.
1034
+ * 2. **Value match** — iterate over collection fields and compare rendered
1035
+ * text against field values. Handles static/hardcoded text that exists
1036
+ * in both the template and a collection data file.
1037
+ */
1038
+ async function resolveCollectionTextField(
1039
+ entry: ManifestEntry,
1040
+ collectionDefinitions: Record<string, CollectionDefinition>,
1041
+ referenceIndex?: Map<string, Array<{ collection: string; fieldName: string; isArray?: boolean }>>,
1042
+ ): Promise<ManifestEntry | undefined> {
1043
+ const colDef = collectionDefinitions[entry.collectionName!]
1044
+ if (!colDef) return undefined
1045
+
1046
+ // Try template expression as source map (e.g., {post.data.title} → "title")
1047
+ const fieldNames = await extractDataFieldNames(entry)
1048
+ if (fieldNames.size === 1) {
1049
+ const fieldResult = await findFieldInCollectionEntry(
1050
+ fieldNames.values().next().value!,
1051
+ entry.collectionName!,
1052
+ entry.collectionSlug!,
1053
+ collectionDefinitions,
1054
+ )
1055
+ if (fieldResult) {
1056
+ return applyCollectionSource(entry, fieldResult, referenceIndex, { allowStyling: false })
1057
+ }
1058
+ } else if (fieldNames.size > 1) {
1059
+ const result = await matchFieldByValue(entry, fieldNames, collectionDefinitions, referenceIndex)
1060
+ if (result) return result
1061
+ }
1062
+
1063
+ // Fallback: match rendered text against all non-image field values
1064
+ const allFieldNames = new Set(colDef.fields.filter(f => f.type !== 'image').map(f => f.name))
1065
+ if (allFieldNames.size > 0) {
1066
+ return matchFieldByValue(entry, allFieldNames, collectionDefinitions, referenceIndex)
1067
+ }
1068
+
1069
+ return undefined
1070
+ }
1071
+
1072
+ /**
1073
+ * Extract .data.fieldName references from the template expression at the entry's source location.
1074
+ * Returns an empty set if the entry lacks source info or the template has no data field expressions.
1075
+ */
1076
+ async function extractDataFieldNames(entry: ManifestEntry): Promise<Set<string>> {
1077
+ const fieldNames = new Set<string>()
1078
+ if (!entry.sourcePath || !entry.sourceLine || !entry.tag) return fieldNames
1079
+
1080
+ const cached = await getCachedParsedFile(resolveSourcePath(entry.sourcePath))
1081
+ if (!cached) return fieldNames
1082
+
1083
+ const snippet = extractCompleteTagSnippet(cached.lines, entry.sourceLine - 1, entry.tag)
1084
+ if (!snippet) return fieldNames
1085
+
1086
+ let match: RegExpExecArray | null
1087
+ const pattern = /\.data\.(\w+)/g
1088
+ while ((match = pattern.exec(snippet)) !== null) {
1089
+ fieldNames.add(match[1]!)
1090
+ }
1091
+ return fieldNames
1092
+ }
1093
+
1094
+ /** Match entry text against collection field values to find the source field. */
1095
+ async function matchFieldByValue(
1096
+ entry: ManifestEntry,
1097
+ fieldNames: Set<string>,
1098
+ collectionDefinitions: Record<string, CollectionDefinition>,
1099
+ referenceIndex?: Map<string, Array<{ collection: string; fieldName: string; isArray?: boolean }>>,
1100
+ ): Promise<ManifestEntry | undefined> {
1101
+ const normalizedText = normalizeText(entry.text!)
1102
+ const fieldResults = await findFieldsInCollectionEntry(
1103
+ fieldNames,
1104
+ entry.collectionName!,
1105
+ entry.collectionSlug!,
1106
+ collectionDefinitions,
1107
+ )
1108
+
1109
+ for (const [fieldName, fieldResult] of fieldResults) {
1110
+ if (!fieldResult.snippet) continue
1111
+
1112
+ try {
1113
+ const cleaned = fieldResult.snippet.replace(/,\s*$/, '')
1114
+ const parsed = parseYaml(cleaned)
1115
+ if (parsed && typeof parsed === 'object') {
1116
+ const value = (parsed as Record<string, unknown>)[fieldName]
1117
+ if (typeof value === 'string' && normalizeText(value) === normalizedText) {
1118
+ return applyCollectionSource(entry, fieldResult, referenceIndex, { allowStyling: false })
1119
+ }
1120
+ }
1121
+ } catch {
1122
+ // Not valid YAML/JSON
1123
+ }
1124
+ }
1125
+ return undefined
1126
+ }
1127
+
1017
1128
  // ============================================================================
1018
1129
  // Image Expression Resolution
1019
1130
  // ============================================================================