@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/dist/editor.js +329 -331
- package/package.json +1 -1
- package/src/build-processor.ts +2 -4
- package/src/content-invalidator.ts +134 -0
- package/src/dev-middleware.ts +42 -1
- package/src/editor/index.tsx +2 -3
- package/src/handlers/api-routes.ts +17 -3
- package/src/source-finder/collection-finder.ts +74 -6
- package/src/source-finder/cross-file-tracker.ts +2 -4
- package/src/source-finder/snippet-utils.ts +146 -35
- package/src/utils.ts +13 -0
- package/src/vite-plugin.ts +27 -13
package/package.json
CHANGED
package/src/build-processor.ts
CHANGED
|
@@ -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 =
|
|
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
|
+
}
|
package/src/dev-middleware.ts
CHANGED
|
@@ -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)
|
package/src/editor/index.tsx
CHANGED
|
@@ -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
|
-
|
|
113
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
668
|
+
results.set(key, {
|
|
604
669
|
file: collectionInfo.file,
|
|
605
670
|
line: startLine,
|
|
606
671
|
snippet,
|
|
607
672
|
type: 'collection',
|
|
608
|
-
variableName:
|
|
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
|
|
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 =
|
|
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 {
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 —
|
|
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 =
|
|
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
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
const
|
|
996
|
-
if (
|
|
997
|
-
|
|
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
|
// ============================================================================
|