@nuasite/cms 0.19.1 → 0.20.2
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 +12615 -12689
- package/package.json +3 -3
- package/src/build-processor.ts +4 -4
- package/src/dev-middleware.ts +185 -189
- package/src/editor/api.ts +0 -251
- package/src/editor/components/fields.tsx +6 -6
- package/src/editor/components/markdown-editor-overlay.tsx +46 -70
- package/src/editor/components/markdown-inline-editor.tsx +34 -165
- package/src/editor/components/mdx-block-view.tsx +351 -47
- package/src/editor/components/mdx-component-picker.tsx +35 -11
- package/src/editor/components/media-library.tsx +1 -15
- package/src/editor/components/modal-shell.tsx +1 -1
- package/src/editor/components/toolbar.tsx +0 -75
- package/src/editor/constants.ts +0 -4
- package/src/editor/editor.ts +2 -192
- package/src/editor/hooks/index.ts +0 -3
- package/src/editor/hooks/useBlockEditorHandlers.ts +1 -8
- package/src/editor/hooks/useTooltipState.ts +1 -2
- package/src/editor/index.tsx +2 -18
- package/src/editor/milkdown-mdx-plugin.tsx +116 -19
- package/src/editor/milkdown-utils.ts +174 -0
- package/src/editor/post-message.ts +0 -6
- package/src/editor/signals.ts +0 -183
- package/src/editor/styles.css +0 -108
- package/src/editor/types.ts +0 -76
- package/src/html-processor.ts +9 -7
- package/src/source-finder/cache.ts +47 -0
- package/src/source-finder/collection-finder.ts +181 -0
- package/src/source-finder/index.ts +5 -2
- package/src/source-finder/search-index.ts +79 -0
- package/src/source-finder/snippet-utils.ts +36 -61
- package/src/types.ts +0 -4
- package/src/utils.ts +10 -0
- package/src/vite-plugin.ts +24 -4
- package/src/editor/ai.ts +0 -185
- package/src/editor/components/ai-chat.tsx +0 -631
- package/src/editor/components/ai-tooltip.tsx +0 -180
- package/src/editor/components/mdx-props-editor.tsx +0 -94
- package/src/editor/hooks/useAIHandlers.ts +0 -345
|
@@ -8,6 +8,187 @@ import { getMarkdownFileCache } from './cache'
|
|
|
8
8
|
import { normalizeText } from './snippet-utils'
|
|
9
9
|
import type { CollectionInfo, MarkdownContent, SourceLocation } from './types'
|
|
10
10
|
|
|
11
|
+
// ============================================================================
|
|
12
|
+
// Collection Text Index — pre-built reverse index for fast text→source lookups
|
|
13
|
+
// ============================================================================
|
|
14
|
+
|
|
15
|
+
/** Pre-built index: normalizedText → SourceLocation (with collection metadata) */
|
|
16
|
+
let collectionTextIndex: Map<string, SourceLocation[]> | null = null
|
|
17
|
+
let collectionTextIndexPromise: Promise<void> | null = null
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Build a reverse index of all text values in collection data/frontmatter files.
|
|
21
|
+
* After this call, `lookupCollectionText()` returns results in O(1).
|
|
22
|
+
*/
|
|
23
|
+
export async function buildCollectionTextIndex(
|
|
24
|
+
collections: Record<string, CollectionDefinition>,
|
|
25
|
+
): Promise<void> {
|
|
26
|
+
if (collectionTextIndex) return
|
|
27
|
+
if (collectionTextIndexPromise) return collectionTextIndexPromise
|
|
28
|
+
collectionTextIndexPromise = doBuildCollectionTextIndex(collections)
|
|
29
|
+
try {
|
|
30
|
+
await collectionTextIndexPromise
|
|
31
|
+
} finally {
|
|
32
|
+
collectionTextIndexPromise = null
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function doBuildCollectionTextIndex(
|
|
37
|
+
collections: Record<string, CollectionDefinition>,
|
|
38
|
+
): Promise<void> {
|
|
39
|
+
const index = new Map<string, SourceLocation[]>()
|
|
40
|
+
|
|
41
|
+
for (const def of Object.values(collections)) {
|
|
42
|
+
if (!def.entries || def.entries.length === 0) continue
|
|
43
|
+
|
|
44
|
+
await Promise.all(def.entries.map(async (entry) => {
|
|
45
|
+
const info: CollectionInfo = { name: def.name, slug: entry.slug, file: entry.sourcePath }
|
|
46
|
+
try {
|
|
47
|
+
const filePath = path.join(getProjectRoot(), entry.sourcePath)
|
|
48
|
+
const cached = await getCachedMarkdownFile(filePath)
|
|
49
|
+
if (!cached) return
|
|
50
|
+
|
|
51
|
+
if (def.type === 'data') {
|
|
52
|
+
// Data file — index all scalars from the full YAML
|
|
53
|
+
collectScalarsFromYaml(cached.content, 0, cached.lines, info, index)
|
|
54
|
+
} else {
|
|
55
|
+
// Markdown — index scalars from frontmatter only
|
|
56
|
+
const { lines } = cached
|
|
57
|
+
let fmStart = -1
|
|
58
|
+
let fmEnd = -1
|
|
59
|
+
for (let i = 0; i < lines.length; i++) {
|
|
60
|
+
if (lines[i]?.trim() === '---') {
|
|
61
|
+
if (fmStart === -1) fmStart = i
|
|
62
|
+
else {
|
|
63
|
+
fmEnd = i
|
|
64
|
+
break
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (fmEnd > 0) {
|
|
69
|
+
const yamlStr = lines.slice(fmStart + 1, fmEnd).join('\n')
|
|
70
|
+
collectScalarsFromYaml(yamlStr, fmStart + 1, lines, info, index)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
} catch {
|
|
74
|
+
// Skip unreadable files
|
|
75
|
+
}
|
|
76
|
+
}))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
collectionTextIndex = index
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Walk a YAML document and collect all scalar values into the text index.
|
|
84
|
+
*/
|
|
85
|
+
function collectScalarsFromYaml(
|
|
86
|
+
yamlStr: string,
|
|
87
|
+
lineOffset: number,
|
|
88
|
+
fileLines: string[],
|
|
89
|
+
collectionInfo: CollectionInfo,
|
|
90
|
+
index: Map<string, SourceLocation[]>,
|
|
91
|
+
): void {
|
|
92
|
+
const lineCounter = new LineCounter()
|
|
93
|
+
const doc = parseDocument(yamlStr, { lineCounter })
|
|
94
|
+
collectFromYamlNode(doc.contents, lineOffset, fileLines, collectionInfo, lineCounter, index)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function collectFromYamlNode(
|
|
98
|
+
node: unknown,
|
|
99
|
+
lineOffset: number,
|
|
100
|
+
fileLines: string[],
|
|
101
|
+
collectionInfo: CollectionInfo,
|
|
102
|
+
lineCounter: LineCounter,
|
|
103
|
+
index: Map<string, SourceLocation[]>,
|
|
104
|
+
): void {
|
|
105
|
+
if (isMap(node)) {
|
|
106
|
+
for (const pair of node.items) {
|
|
107
|
+
if (!isPair(pair) || !isScalar(pair.key)) continue
|
|
108
|
+
const key = String(pair.key.value)
|
|
109
|
+
|
|
110
|
+
if (isScalar(pair.value)) {
|
|
111
|
+
const normalized = normalizeText(String(pair.value.value))
|
|
112
|
+
if (normalized.length < 2) continue
|
|
113
|
+
const keyRange = (pair.key as any).range as [number, number, number] | undefined
|
|
114
|
+
const valRange = (pair.value as any).range as [number, number, number] | undefined
|
|
115
|
+
const startLine = (keyRange ? lineCounter.linePos(keyRange[0]).line : 1) + lineOffset
|
|
116
|
+
const endLine = (valRange ? lineCounter.linePos(valRange[1]).line : startLine - lineOffset) + lineOffset
|
|
117
|
+
const snippet = fileLines.slice(startLine - 1, endLine).join('\n')
|
|
118
|
+
|
|
119
|
+
const loc: SourceLocation = {
|
|
120
|
+
file: collectionInfo.file,
|
|
121
|
+
line: startLine,
|
|
122
|
+
snippet,
|
|
123
|
+
type: 'collection',
|
|
124
|
+
variableName: key,
|
|
125
|
+
collectionName: collectionInfo.name,
|
|
126
|
+
collectionSlug: collectionInfo.slug,
|
|
127
|
+
}
|
|
128
|
+
const existing = index.get(normalized)
|
|
129
|
+
if (existing) existing.push(loc)
|
|
130
|
+
else index.set(normalized, [loc])
|
|
131
|
+
} else {
|
|
132
|
+
collectFromYamlNode(pair.value, lineOffset, fileLines, collectionInfo, lineCounter, index)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
} else if (isSeq(node)) {
|
|
136
|
+
for (const item of node.items) {
|
|
137
|
+
if (isScalar(item)) {
|
|
138
|
+
const normalized = normalizeText(String(item.value))
|
|
139
|
+
if (normalized.length < 2) continue
|
|
140
|
+
const range = (item as any).range as [number, number, number] | undefined
|
|
141
|
+
const startLine = (range ? lineCounter.linePos(range[0]).line : 1) + lineOffset
|
|
142
|
+
const endLine = (range ? lineCounter.linePos(range[1]).line : startLine - lineOffset) + lineOffset
|
|
143
|
+
const snippet = fileLines.slice(startLine - 1, endLine).join('\n')
|
|
144
|
+
|
|
145
|
+
const loc: SourceLocation = {
|
|
146
|
+
file: collectionInfo.file,
|
|
147
|
+
line: startLine,
|
|
148
|
+
snippet,
|
|
149
|
+
type: 'collection',
|
|
150
|
+
collectionName: collectionInfo.name,
|
|
151
|
+
collectionSlug: collectionInfo.slug,
|
|
152
|
+
}
|
|
153
|
+
const existing = index.get(normalized)
|
|
154
|
+
if (existing) existing.push(loc)
|
|
155
|
+
else index.set(normalized, [loc])
|
|
156
|
+
} else {
|
|
157
|
+
collectFromYamlNode(item, lineOffset, fileLines, collectionInfo, lineCounter, index)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* O(1) lookup in the pre-built collection text index.
|
|
165
|
+
* Returns all matching source locations, preferring referenced collections.
|
|
166
|
+
*/
|
|
167
|
+
export function lookupCollectionText(
|
|
168
|
+
textContent: string,
|
|
169
|
+
referenceIndex?: Map<string, Array<{ collection: string; fieldName: string; isArray?: boolean }>>,
|
|
170
|
+
): SourceLocation | undefined {
|
|
171
|
+
if (!collectionTextIndex) return undefined
|
|
172
|
+
const normalized = normalizeText(textContent)
|
|
173
|
+
const locations = collectionTextIndex.get(normalized)
|
|
174
|
+
if (!locations || locations.length === 0) return undefined
|
|
175
|
+
|
|
176
|
+
// Prefer locations from referenced collections
|
|
177
|
+
if (referenceIndex) {
|
|
178
|
+
for (const loc of locations) {
|
|
179
|
+
if (loc.collectionName && referenceIndex.has(loc.collectionName)) {
|
|
180
|
+
return loc
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return locations[0]
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Clear the collection text index (called when collection files change) */
|
|
188
|
+
export function clearCollectionTextIndex(): void {
|
|
189
|
+
collectionTextIndex = null
|
|
190
|
+
}
|
|
191
|
+
|
|
11
192
|
// ============================================================================
|
|
12
193
|
// Markdown File Cache
|
|
13
194
|
// ============================================================================
|
|
@@ -8,10 +8,10 @@
|
|
|
8
8
|
export type { CollectionInfo, MarkdownContent, SourceLocation, VariableReference } from './types'
|
|
9
9
|
|
|
10
10
|
// Cache management
|
|
11
|
-
export { clearSourceFinderCache } from './cache'
|
|
11
|
+
export { clearSourceFinderCache, markFileDirty } from './cache'
|
|
12
12
|
|
|
13
13
|
// Search index
|
|
14
|
-
export { initializeSearchIndex } from './search-index'
|
|
14
|
+
export { initializeSearchIndex, reindexDirtyFiles } from './search-index'
|
|
15
15
|
|
|
16
16
|
// Source location finding
|
|
17
17
|
export { findSourceLocation } from './source-lookup'
|
|
@@ -24,10 +24,13 @@ export { findImageSourceLocation } from './image-finder'
|
|
|
24
24
|
|
|
25
25
|
// Collection/markdown finding
|
|
26
26
|
export {
|
|
27
|
+
buildCollectionTextIndex,
|
|
28
|
+
clearCollectionTextIndex,
|
|
27
29
|
findCollectionSource,
|
|
28
30
|
findFieldInCollectionEntry,
|
|
29
31
|
findMarkdownSourceLocation,
|
|
30
32
|
findTextInAnyCollectionFrontmatter,
|
|
33
|
+
lookupCollectionText,
|
|
31
34
|
parseMarkdownContent,
|
|
32
35
|
} from './collection-finder'
|
|
33
36
|
|
|
@@ -9,12 +9,17 @@ import { getCachedParsedFile } from './ast-parser'
|
|
|
9
9
|
import {
|
|
10
10
|
addToImageSearchIndex,
|
|
11
11
|
addToTextSearchIndex,
|
|
12
|
+
clearDirtyFiles,
|
|
12
13
|
getDirectoryCache,
|
|
14
|
+
getDirtyFiles,
|
|
13
15
|
getImageSearchIndex,
|
|
16
|
+
getMarkdownFileCache,
|
|
14
17
|
getTextSearchIndex,
|
|
15
18
|
isSearchIndexInitialized,
|
|
19
|
+
removeFileFromIndexes,
|
|
16
20
|
setSearchIndexInitialized,
|
|
17
21
|
} from './cache'
|
|
22
|
+
import { clearCollectionTextIndex } from './collection-finder'
|
|
18
23
|
import { extractImageSnippet, extractInnerHtmlFromSnippet, normalizeText } from './snippet-utils'
|
|
19
24
|
import type { CachedParsedFile, SearchIndexEntry, SourceLocation } from './types'
|
|
20
25
|
|
|
@@ -125,6 +130,80 @@ async function doInitializeSearchIndex(): Promise<void> {
|
|
|
125
130
|
setSearchIndexInitialized(true)
|
|
126
131
|
}
|
|
127
132
|
|
|
133
|
+
// ============================================================================
|
|
134
|
+
// Incremental Re-indexing
|
|
135
|
+
// ============================================================================
|
|
136
|
+
|
|
137
|
+
/** Shared promise so concurrent callers wait for the same re-indexing */
|
|
138
|
+
let reindexPromise: Promise<void> | null = null
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Re-index only files that changed since the last indexing.
|
|
142
|
+
* Much faster than a full rebuild — only re-parses dirty files.
|
|
143
|
+
* Safe to call concurrently — all callers share the same operation.
|
|
144
|
+
*
|
|
145
|
+
* Also clears the markdown file cache so collection content is re-read.
|
|
146
|
+
*/
|
|
147
|
+
export async function reindexDirtyFiles(): Promise<void> {
|
|
148
|
+
const dirty = getDirtyFiles()
|
|
149
|
+
if (dirty.size === 0) return
|
|
150
|
+
if (reindexPromise) return reindexPromise
|
|
151
|
+
reindexPromise = doReindexDirtyFiles()
|
|
152
|
+
try {
|
|
153
|
+
await reindexPromise
|
|
154
|
+
} finally {
|
|
155
|
+
reindexPromise = null
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function doReindexDirtyFiles(): Promise<void> {
|
|
160
|
+
const dirty = getDirtyFiles()
|
|
161
|
+
if (dirty.size === 0) return
|
|
162
|
+
|
|
163
|
+
const projectRoot = getProjectRoot()
|
|
164
|
+
const filesToReindex = [...dirty]
|
|
165
|
+
clearDirtyFiles()
|
|
166
|
+
|
|
167
|
+
// Also clear the markdown file cache and collection text index
|
|
168
|
+
// so collection content is re-read and re-indexed from disk
|
|
169
|
+
getMarkdownFileCache().clear()
|
|
170
|
+
clearCollectionTextIndex()
|
|
171
|
+
|
|
172
|
+
for (const absPath of filesToReindex) {
|
|
173
|
+
const relFile = path.relative(projectRoot, absPath)
|
|
174
|
+
|
|
175
|
+
// Remove old entries for this file
|
|
176
|
+
removeFileFromIndexes(relFile)
|
|
177
|
+
|
|
178
|
+
// Re-parse and re-index if it's a source file
|
|
179
|
+
if (absPath.endsWith('.astro') || absPath.endsWith('.tsx') || absPath.endsWith('.jsx')) {
|
|
180
|
+
try {
|
|
181
|
+
const cached = await getCachedParsedFile(absPath)
|
|
182
|
+
if (cached) {
|
|
183
|
+
indexFileContent(cached, relFile)
|
|
184
|
+
indexFileImages(cached, relFile)
|
|
185
|
+
}
|
|
186
|
+
} catch {
|
|
187
|
+
// Skip files that fail to parse
|
|
188
|
+
}
|
|
189
|
+
} else if (/\.(json|ya?ml|mdx?)$/.test(absPath)) {
|
|
190
|
+
// Content collection data file — re-index images from it
|
|
191
|
+
try {
|
|
192
|
+
const content = await fs.readFile(absPath, 'utf-8')
|
|
193
|
+
if (absPath.endsWith('.json')) {
|
|
194
|
+
indexJsonImages(content, relFile)
|
|
195
|
+
} else if (absPath.endsWith('.yaml') || absPath.endsWith('.yml')) {
|
|
196
|
+
indexYamlImages(content, relFile)
|
|
197
|
+
} else if (absPath.endsWith('.md') || absPath.endsWith('.mdx')) {
|
|
198
|
+
indexFrontmatterImages(content, relFile)
|
|
199
|
+
}
|
|
200
|
+
} catch {
|
|
201
|
+
// Skip unreadable files
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
128
207
|
// ============================================================================
|
|
129
208
|
// Content Indexing
|
|
130
209
|
// ============================================================================
|
|
@@ -7,7 +7,7 @@ import type { Attribute, CollectionDefinition, ManifestEntry } from '../types'
|
|
|
7
7
|
import { escapeRegex, generateSourceHash } from '../utils'
|
|
8
8
|
import { buildDefinitionPath } from './ast-extractors'
|
|
9
9
|
import { getCachedParsedFile } from './ast-parser'
|
|
10
|
-
import { findFieldInCollectionEntry, findTextInAnyCollectionFrontmatter } from './collection-finder'
|
|
10
|
+
import { buildCollectionTextIndex, findFieldInCollectionEntry, findTextInAnyCollectionFrontmatter, lookupCollectionText } from './collection-finder'
|
|
11
11
|
import { findAttributeSourceLocation, searchForExpressionProp, searchForPropInParents } from './cross-file-tracker'
|
|
12
12
|
import { findImageElementNearLine, findImageSourceLocation } from './image-finder'
|
|
13
13
|
import { initializeSearchIndex } from './search-index'
|
|
@@ -502,6 +502,11 @@ export async function enhanceManifestWithSourceSnippets(
|
|
|
502
502
|
}
|
|
503
503
|
}
|
|
504
504
|
|
|
505
|
+
// Build collection text index upfront for O(1) lookups in both entries and augment phases
|
|
506
|
+
if (collectionDefinitions && Object.keys(collectionDefinitions).length > 0) {
|
|
507
|
+
await buildCollectionTextIndex(collectionDefinitions)
|
|
508
|
+
}
|
|
509
|
+
|
|
505
510
|
// Propagate collectionName/collectionSlug from wrapper entries to their children.
|
|
506
511
|
// The HTML processor only sets collection info on the wrapper element itself;
|
|
507
512
|
// child entries (images, text) need it for direct data-file resolution.
|
|
@@ -531,6 +536,19 @@ export async function enhanceManifestWithSourceSnippets(
|
|
|
531
536
|
}
|
|
532
537
|
}
|
|
533
538
|
|
|
539
|
+
// Shared file read cache — avoids redundant fs.readFile calls
|
|
540
|
+
// when many entries share the same source file
|
|
541
|
+
const fileContentCache = new Map<string, { content: string; lines: string[] }>()
|
|
542
|
+
const readFileWithCache = async (filePath: string): Promise<{ content: string; lines: string[] }> => {
|
|
543
|
+
const cached = fileContentCache.get(filePath)
|
|
544
|
+
if (cached) return cached
|
|
545
|
+
const content = await fs.readFile(filePath, 'utf-8')
|
|
546
|
+
const lines = content.split('\n')
|
|
547
|
+
const result = { content, lines }
|
|
548
|
+
fileContentCache.set(filePath, result)
|
|
549
|
+
return result
|
|
550
|
+
}
|
|
551
|
+
|
|
534
552
|
// Process entries in parallel for better performance
|
|
535
553
|
const entryPromises = Object.entries(entries).map(async ([id, entry]) => {
|
|
536
554
|
// Handle image entries specially - find the line with src attribute
|
|
@@ -568,8 +586,7 @@ export async function enhanceManifestWithSourceSnippets(
|
|
|
568
586
|
const filePath = path.isAbsolute(imageLocation.file)
|
|
569
587
|
? imageLocation.file
|
|
570
588
|
: path.join(getProjectRoot(), imageLocation.file)
|
|
571
|
-
const
|
|
572
|
-
const lines = content.split('\n')
|
|
589
|
+
const { lines } = await readFileWithCache(filePath)
|
|
573
590
|
const openingTagInfo = extractOpeningTagWithLine(lines, imageLocation.line - 1, entry.tag)
|
|
574
591
|
|
|
575
592
|
if (openingTagInfo) {
|
|
@@ -656,8 +673,7 @@ export async function enhanceManifestWithSourceSnippets(
|
|
|
656
673
|
? entry.sourcePath
|
|
657
674
|
: path.join(getProjectRoot(), entry.sourcePath)
|
|
658
675
|
|
|
659
|
-
const content = await
|
|
660
|
-
const lines = content.split('\n')
|
|
676
|
+
const { content, lines } = await readFileWithCache(filePath)
|
|
661
677
|
|
|
662
678
|
// Extract the complete source element
|
|
663
679
|
const sourceSnippet = extractCompleteTagSnippet(lines, entry.sourceLine - 1, entry.tag)
|
|
@@ -806,7 +822,7 @@ export async function enhanceManifestWithSourceSnippets(
|
|
|
806
822
|
// from collection entries (e.g. {post.data.title}) won't be found
|
|
807
823
|
// through AST or prop lookups since the value lives in a .md file
|
|
808
824
|
if (collectionDefinitions && Object.keys(collectionDefinitions).length > 0) {
|
|
809
|
-
const mdSource =
|
|
825
|
+
const mdSource = lookupCollectionText(trimmedText, referenceIndex)
|
|
810
826
|
if (mdSource) {
|
|
811
827
|
return [
|
|
812
828
|
id,
|
|
@@ -841,72 +857,31 @@ export async function enhanceManifestWithSourceSnippets(
|
|
|
841
857
|
for (const [id, entry] of results) {
|
|
842
858
|
enhanced[id] = entry
|
|
843
859
|
}
|
|
844
|
-
|
|
845
860
|
// Post-processing: augment entries with collection and reference metadata.
|
|
846
|
-
//
|
|
847
|
-
//
|
|
848
|
-
// location is correct for editing, but collection identity and reference metadata are
|
|
849
|
-
// missing. This pass adds both by checking if text exists in any collection.
|
|
850
|
-
// collectionName/collectionSlug are needed on owning entries (e.g. news titles) so
|
|
851
|
-
// that findOwnerEntry in the editor can locate them as siblings of reference elements.
|
|
861
|
+
// Uses the pre-built collection text index for O(1) lookups instead of
|
|
862
|
+
// re-parsing YAML/frontmatter for every entry.
|
|
852
863
|
if (collectionDefinitions && Object.keys(collectionDefinitions).length > 0) {
|
|
853
|
-
|
|
854
|
-
// in multiple manifest entries (e.g., author names repeated on listing pages)
|
|
855
|
-
const textLookupCache = new Map<
|
|
856
|
-
string,
|
|
857
|
-
{ source: SourceLocation; referencedBy?: Array<{ collection: string; fieldName: string; isArray?: boolean }> } | null
|
|
858
|
-
>()
|
|
859
|
-
|
|
860
|
-
async function resolveCollectionText(trimmed: string) {
|
|
861
|
-
const cached = textLookupCache.get(trimmed)
|
|
862
|
-
if (cached !== undefined) return cached
|
|
863
|
-
|
|
864
|
-
// Search each collection individually so we can prefer referenced collections
|
|
865
|
-
// (findTextInAnyCollectionFrontmatter returns the first match, which may be wrong
|
|
866
|
-
// when the same text exists in multiple collections like "team" and "authors")
|
|
867
|
-
let bestSource: SourceLocation | undefined
|
|
868
|
-
let bestReferencedBy: Array<{ collection: string; fieldName: string; isArray?: boolean }> | undefined
|
|
869
|
-
for (const def of Object.values(collectionDefinitions!)) {
|
|
870
|
-
if (!def.entries || def.entries.length === 0) continue
|
|
871
|
-
const singleCol = { [def.name]: def }
|
|
872
|
-
const source = await findTextInAnyCollectionFrontmatter(trimmed, singleCol)
|
|
873
|
-
if (!source?.collectionName) continue
|
|
874
|
-
const refs = referenceIndex.get(source.collectionName)
|
|
875
|
-
if (refs && refs.length > 0) {
|
|
876
|
-
bestSource = source
|
|
877
|
-
bestReferencedBy = refs
|
|
878
|
-
break
|
|
879
|
-
}
|
|
880
|
-
if (!bestSource) {
|
|
881
|
-
bestSource = source
|
|
882
|
-
}
|
|
883
|
-
}
|
|
864
|
+
await buildCollectionTextIndex(collectionDefinitions)
|
|
884
865
|
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
return result
|
|
888
|
-
}
|
|
889
|
-
|
|
890
|
-
const augmentPromises = Object.entries(enhanced).map(async ([id, entry]) => {
|
|
891
|
-
if (!entry.text?.trim()) return
|
|
866
|
+
for (const [id, entry] of Object.entries(enhanced)) {
|
|
867
|
+
if (!entry.text?.trim()) continue
|
|
892
868
|
// Skip if already fully resolved (has collection identity + reference metadata or no references exist)
|
|
893
|
-
if (entry.collectionName && (entry.referenceCollection || referenceIndex.size === 0))
|
|
894
|
-
const trimmed = entry.text.trim()
|
|
869
|
+
if (entry.collectionName && (entry.referenceCollection || referenceIndex.size === 0)) continue
|
|
895
870
|
|
|
896
|
-
const
|
|
897
|
-
if (!
|
|
871
|
+
const source = lookupCollectionText(entry.text.trim(), referenceIndex)
|
|
872
|
+
if (!source) continue
|
|
898
873
|
|
|
899
|
-
const
|
|
900
|
-
|
|
874
|
+
const referencedBy = source.collectionName ? referenceIndex.get(source.collectionName) : undefined
|
|
875
|
+
const refMeta = referencedBy
|
|
876
|
+
? { referenceCollection: source.collectionName, referencedBy }
|
|
901
877
|
: {}
|
|
902
878
|
enhanced[id] = {
|
|
903
879
|
...entry,
|
|
904
|
-
collectionName: entry.collectionName ??
|
|
905
|
-
collectionSlug: entry.collectionSlug ??
|
|
880
|
+
collectionName: entry.collectionName ?? source.collectionName,
|
|
881
|
+
collectionSlug: entry.collectionSlug ?? source.collectionSlug,
|
|
906
882
|
...refMeta,
|
|
907
883
|
}
|
|
908
|
-
}
|
|
909
|
-
await Promise.all(augmentPromises)
|
|
884
|
+
}
|
|
910
885
|
}
|
|
911
886
|
|
|
912
887
|
return enhanced
|
package/src/types.ts
CHANGED
|
@@ -567,10 +567,6 @@ export interface CmsEditorState {
|
|
|
567
567
|
seo: number
|
|
568
568
|
total: number
|
|
569
569
|
}
|
|
570
|
-
deployment: {
|
|
571
|
-
status: 'pending' | 'queued' | 'running' | 'completed' | 'failed' | 'cancelled' | null
|
|
572
|
-
lastDeployedAt: string | null
|
|
573
|
-
}
|
|
574
570
|
canUndo: boolean
|
|
575
571
|
canRedo: boolean
|
|
576
572
|
}
|
package/src/utils.ts
CHANGED
|
@@ -190,6 +190,16 @@ export async function acquireFileLock(filePath: string): Promise<() => void> {
|
|
|
190
190
|
|
|
191
191
|
export { slugify } from './shared'
|
|
192
192
|
|
|
193
|
+
/**
|
|
194
|
+
* Return the first non-empty, trimmed line from a text block (e.g. markdown body).
|
|
195
|
+
*/
|
|
196
|
+
export function firstNonEmptyLine(text: string | undefined): string | undefined {
|
|
197
|
+
return text
|
|
198
|
+
?.split('\n')
|
|
199
|
+
.find((line) => line.trim().length > 0)
|
|
200
|
+
?.trim()
|
|
201
|
+
}
|
|
202
|
+
|
|
193
203
|
/**
|
|
194
204
|
* Type-safe check for Node.js system errors (ENOENT, EEXIST, etc.).
|
|
195
205
|
*/
|
package/src/vite-plugin.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Plugin } from 'vite'
|
|
2
2
|
import { expectedDeletions } from './dev-middleware'
|
|
3
3
|
import type { ManifestWriter } from './manifest-writer'
|
|
4
|
+
import { markFileDirty } from './source-finder'
|
|
4
5
|
import type { CmsMarkerOptions, ComponentDefinition } from './types'
|
|
5
6
|
import { createArrayTransformPlugin } from './vite-plugin-array-transform'
|
|
6
7
|
|
|
@@ -35,17 +36,36 @@ export function createVitePlugin(context: VitePluginContext): Plugin[] {
|
|
|
35
36
|
},
|
|
36
37
|
}
|
|
37
38
|
|
|
38
|
-
//
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
// File extensions that are indexed by the CMS search index
|
|
40
|
+
const INDEXED_EXTENSIONS = /\.(astro|tsx|jsx|json|ya?ml|mdx?)$/
|
|
41
|
+
|
|
42
|
+
// Stable handler reference so configureServer re-entry doesn't leak listeners
|
|
43
|
+
const onFileChange = (filePath: string) => {
|
|
44
|
+
if (INDEXED_EXTENSIONS.test(filePath)) {
|
|
45
|
+
markFileDirty(filePath)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Intercept Vite's file watcher to:
|
|
50
|
+
// 1. Mark changed source files dirty for incremental re-indexing
|
|
51
|
+
// 2. Suppress full page reloads when the CMS deletes a content collection entry
|
|
41
52
|
const watcherPlugin: Plugin = {
|
|
42
53
|
name: 'cms-suppress-delete-reload',
|
|
43
54
|
configureServer(server) {
|
|
44
55
|
if (command !== 'dev') return
|
|
45
56
|
|
|
57
|
+
const watcher = server.watcher
|
|
58
|
+
|
|
59
|
+
// Mark changed files dirty so the search index re-indexes only them.
|
|
60
|
+
// Remove first to avoid duplicate listeners on Astro dev server restarts.
|
|
61
|
+
watcher.off('change', onFileChange).on('change', onFileChange)
|
|
62
|
+
watcher.off('add', onFileChange).on('add', onFileChange)
|
|
63
|
+
// Astro + Vite plugins collectively add many 'change' listeners to the
|
|
64
|
+
// shared watcher. Raise the limit to suppress the spurious warning.
|
|
65
|
+
watcher.setMaxListeners(20)
|
|
66
|
+
|
|
46
67
|
// Monkey-patch the watcher to intercept unlink events before Vite/Astro
|
|
47
68
|
// processes them. We use prependListener so our handler runs first.
|
|
48
|
-
const watcher = server.watcher
|
|
49
69
|
const origEmit = watcher.emit.bind(watcher)
|
|
50
70
|
watcher.emit = ((event: string, filePath: string, ...args: any[]) => {
|
|
51
71
|
if ((event === 'unlink' || event === 'unlinkDir') && expectedDeletions.has(filePath)) {
|