@nuasite/cms 0.19.1 → 0.20.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.
@@ -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 content = await fs.readFile(filePath, 'utf-8')
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 fs.readFile(filePath, 'utf-8')
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 = await findTextInAnyCollectionFrontmatter(trimmedText, collectionDefinitions)
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
- // Source resolution may find text via prop/expression tracking (pointing to a parent
847
- // component) before the collection frontmatter search runs. In that case the source
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
- // Cache text→result to avoid redundant file reads when the same text appears
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
- const result = bestSource ? { source: bestSource, referencedBy: bestReferencedBy } : null
886
- textLookupCache.set(trimmed, result)
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)) return
894
- const trimmed = entry.text.trim()
869
+ if (entry.collectionName && (entry.referenceCollection || referenceIndex.size === 0)) continue
895
870
 
896
- const resolved = await resolveCollectionText(trimmed)
897
- if (!resolved) return
871
+ const source = lookupCollectionText(entry.text.trim(), referenceIndex)
872
+ if (!source) continue
898
873
 
899
- const refMeta = resolved.referencedBy
900
- ? { referenceCollection: resolved.source.collectionName, referencedBy: resolved.referencedBy }
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 ?? resolved.source.collectionName,
905
- collectionSlug: entry.collectionSlug ?? resolved.source.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/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
  */
@@ -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
- // Intercept Vite's file watcher to suppress full page reloads when the CMS
39
- // deletes a content collection entry. Without this, Vite/Astro detects the
40
- // unlink and forces a reload, undoing the optimistic UI update.
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)) {
@@ -1,94 +0,0 @@
1
- import { useEffect, useState } from 'preact/hooks'
2
- import { clampPanelPosition, Z_INDEX } from '../constants'
3
- import { getComponentDefinition } from '../manifest'
4
- import { closeMdxPropsEditor, manifest, mdxPropsEditorState } from '../signals'
5
- import { MdxComponentIcon } from './mdx-block-view'
6
- import { CancelButton, CloseButton } from './modal-shell'
7
- import { PropEditor } from './prop-editor'
8
-
9
- export function MdxPropsEditor({
10
- onUpdateProps,
11
- }: {
12
- onUpdateProps: (nodePos: number, props: Record<string, string>) => void
13
- }) {
14
- const state = mdxPropsEditorState.value
15
- const isVisible = state.isOpen && state.componentName !== null && state.nodePos !== null
16
-
17
- const definition = isVisible ? getComponentDefinition(manifest.value, state.componentName!) : undefined
18
- const [propValues, setPropValues] = useState<Record<string, string>>(state.props)
19
-
20
- useEffect(() => {
21
- if (isVisible) setPropValues(state.props)
22
- }, [state.nodePos, state.componentName, state.props, isVisible])
23
-
24
- if (!isVisible) return null
25
-
26
- const panelStyle = state.cursorPos ? clampPanelPosition(state.cursorPos, 360) : {}
27
-
28
- const handleSave = () => {
29
- if (state.nodePos !== null) {
30
- onUpdateProps(state.nodePos, propValues)
31
- closeMdxPropsEditor()
32
- }
33
- }
34
-
35
- return (
36
- <>
37
- <div
38
- data-cms-ui
39
- onClick={closeMdxPropsEditor}
40
- style={{ zIndex: Z_INDEX.SELECTION }}
41
- class="fixed inset-0"
42
- />
43
-
44
- <div
45
- data-cms-ui
46
- onClick={(e: MouseEvent) => e.stopPropagation()}
47
- class="fixed w-90 bg-cms-dark shadow-[0_8px_32px_rgba(0,0,0,0.4)] font-sans text-sm overflow-hidden flex flex-col rounded-cms-xl border border-white/10"
48
- style={{ ...panelStyle, zIndex: Z_INDEX.MODAL }}
49
- >
50
- <div class="px-5 py-4 flex justify-between items-center border-b border-white/10">
51
- <div class="flex items-center gap-2">
52
- <MdxComponentIcon />
53
- <span class="font-semibold text-white">{state.componentName}</span>
54
- </div>
55
- <CloseButton onClick={closeMdxPropsEditor} />
56
- </div>
57
-
58
- <div class="p-5 overflow-y-auto flex-1">
59
- {definition
60
- ? (
61
- definition.props.map((prop) => (
62
- <PropEditor
63
- key={prop.name}
64
- prop={prop}
65
- value={propValues[prop.name] || ''}
66
- onChange={(value) => setPropValues((prev) => ({ ...prev, [prop.name]: value }))}
67
- />
68
- ))
69
- )
70
- : (
71
- <div class="text-white/50 text-[13px]">
72
- <div class="mb-3">Unknown component — props not editable.</div>
73
- <div class="font-mono text-[11px] text-white/30 bg-white/5 p-3 rounded-cms-md break-all">
74
- {Object.entries(propValues).map(([k, v]) => <div key={k}>{k}="{v}"</div>)}
75
- </div>
76
- </div>
77
- )}
78
- </div>
79
-
80
- {definition && (
81
- <div class="px-5 py-4 border-t border-white/10 flex gap-2 justify-end">
82
- <CancelButton onClick={closeMdxPropsEditor} />
83
- <button
84
- onClick={handleSave}
85
- class="px-4 py-2.5 bg-cms-primary text-cms-primary-text rounded-cms-pill cursor-pointer hover:bg-cms-primary-hover transition-all font-medium"
86
- >
87
- Save
88
- </button>
89
- </div>
90
- )}
91
- </div>
92
- </>
93
- )
94
- }