@nuasite/cms 0.19.0 → 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.
- package/dist/editor.js +11055 -10934
- package/package.json +3 -3
- package/src/build-processor.ts +4 -4
- package/src/dev-middleware.ts +171 -185
- package/src/editor/components/fields.tsx +6 -6
- package/src/editor/components/markdown-editor-overlay.tsx +41 -46
- 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/milkdown-mdx-plugin.tsx +116 -19
- package/src/editor/milkdown-utils.ts +174 -0
- package/src/editor/signals.ts +1 -18
- package/src/editor/types.ts +0 -10
- package/src/html-processor.ts +9 -7
- package/src/index.ts +3 -13
- 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/utils.ts +10 -0
- package/src/vite-plugin.ts +24 -4
- package/src/editor/components/mdx-props-editor.tsx +0 -94
|
@@ -4,12 +4,21 @@ import type { MarkdownNode, SerializerState } from '@milkdown/transformer'
|
|
|
4
4
|
import { $command, $node, $remark, $view } from '@milkdown/utils'
|
|
5
5
|
import { render } from 'preact'
|
|
6
6
|
import remarkMdx from 'remark-mdx'
|
|
7
|
+
import remarkParse from 'remark-parse'
|
|
8
|
+
import { unified } from 'unified'
|
|
7
9
|
import { MdxBlockCard } from './components/mdx-block-view'
|
|
8
|
-
import {
|
|
10
|
+
import { getComponentDefinition } from './manifest'
|
|
11
|
+
import { manifest } from './signals'
|
|
9
12
|
|
|
10
13
|
/** Prefix used to distinguish expression attributes from string literals in serialized props */
|
|
11
14
|
export const MDX_EXPR_PREFIX = '__mdx_expr__:'
|
|
12
15
|
|
|
16
|
+
/** HTML void elements that should not be treated as editable MDX components */
|
|
17
|
+
const HTML_VOID_ELEMENTS = new Set(['br', 'hr', 'img', 'input', 'meta', 'link', 'area', 'base', 'col', 'embed', 'source', 'track', 'wbr'])
|
|
18
|
+
|
|
19
|
+
/** Cached unified processor for parsing markdown children during serialization */
|
|
20
|
+
const remarkParser = unified().use(remarkParse)
|
|
21
|
+
|
|
13
22
|
export const remarkMdxPlugin: any = $remark('remarkMdx', () => remarkMdx)
|
|
14
23
|
|
|
15
24
|
function parseJsxAttributes(attributes: any[]): { props: Record<string, string>; hasExpressions: boolean } {
|
|
@@ -64,21 +73,72 @@ function serializePropsToAttributes(props: Record<string, string>): any[] {
|
|
|
64
73
|
return attributes
|
|
65
74
|
}
|
|
66
75
|
|
|
76
|
+
/** Serialize mdast children back to markdown text */
|
|
77
|
+
function serializeChildren(children: any[]): string {
|
|
78
|
+
if (!children || children.length === 0) return ''
|
|
79
|
+
const parts: string[] = []
|
|
80
|
+
for (const child of children) {
|
|
81
|
+
if (child.type === 'paragraph') {
|
|
82
|
+
const text = serializeInlineChildren(child.children ?? [])
|
|
83
|
+
parts.push(text)
|
|
84
|
+
} else if (child.type === 'text') {
|
|
85
|
+
parts.push(child.value ?? '')
|
|
86
|
+
} else if (child.type === 'mdxJsxFlowElement' || child.type === 'mdxJsxTextElement') {
|
|
87
|
+
// Nested JSX — reconstruct as string
|
|
88
|
+
const name = child.name ?? ''
|
|
89
|
+
const attrs = (child.attributes ?? [])
|
|
90
|
+
.map((a: any) => {
|
|
91
|
+
if (a.type === 'mdxJsxAttribute' && a.name) {
|
|
92
|
+
if (typeof a.value === 'string') return `${a.name}="${a.value}"`
|
|
93
|
+
if (a.value?.type === 'mdxJsxAttributeValueExpression') return `${a.name}={${a.value.value}}`
|
|
94
|
+
}
|
|
95
|
+
return ''
|
|
96
|
+
})
|
|
97
|
+
.filter(Boolean)
|
|
98
|
+
.join(' ')
|
|
99
|
+
const inner = serializeChildren(child.children ?? [])
|
|
100
|
+
if (inner) {
|
|
101
|
+
parts.push(`<${name}${attrs ? ' ' + attrs : ''}>${inner}</${name}>`)
|
|
102
|
+
} else {
|
|
103
|
+
parts.push(`<${name}${attrs ? ' ' + attrs : ''} />`)
|
|
104
|
+
}
|
|
105
|
+
} else {
|
|
106
|
+
// Fallback — use value if present
|
|
107
|
+
if (child.value) parts.push(child.value)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return parts.join('\n\n')
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function serializeInlineChildren(children: any[]): string {
|
|
114
|
+
return children.map((c: any) => {
|
|
115
|
+
if (c.type === 'text') return c.value ?? ''
|
|
116
|
+
if (c.type === 'strong') return `**${serializeInlineChildren(c.children ?? [])}**`
|
|
117
|
+
if (c.type === 'emphasis') return `*${serializeInlineChildren(c.children ?? [])}*`
|
|
118
|
+
if (c.type === 'inlineCode') return `\`${c.value ?? ''}\``
|
|
119
|
+
if (c.type === 'link') return `[${serializeInlineChildren(c.children ?? [])}](${c.url ?? ''})`
|
|
120
|
+
return c.value ?? ''
|
|
121
|
+
}).join('')
|
|
122
|
+
}
|
|
123
|
+
|
|
67
124
|
export const mdxComponentNode = $node('mdx_component', () => ({
|
|
68
125
|
group: 'block',
|
|
69
126
|
atom: true,
|
|
70
127
|
isolating: true,
|
|
71
128
|
selectable: true,
|
|
72
|
-
draggable:
|
|
129
|
+
draggable: false,
|
|
73
130
|
attrs: {
|
|
74
131
|
componentName: { default: '' },
|
|
75
132
|
props: { default: '{}' },
|
|
76
133
|
hasExpressions: { default: false },
|
|
134
|
+
children: { default: '' },
|
|
77
135
|
},
|
|
78
136
|
toDOM: (node: PmNode) => {
|
|
79
137
|
const div = document.createElement('div')
|
|
80
138
|
div.setAttribute('data-mdx-component', node.attrs.componentName)
|
|
81
139
|
div.setAttribute('data-mdx-props', node.attrs.props)
|
|
140
|
+
if (node.attrs.children) div.setAttribute('data-mdx-children', node.attrs.children)
|
|
141
|
+
if (node.attrs.hasExpressions) div.setAttribute('data-mdx-expressions', 'true')
|
|
82
142
|
div.className = 'mdx-component-block'
|
|
83
143
|
div.textContent = `<${node.attrs.componentName} />`
|
|
84
144
|
return div as any
|
|
@@ -88,6 +148,8 @@ export const mdxComponentNode = $node('mdx_component', () => ({
|
|
|
88
148
|
getAttrs: (dom: HTMLElement) => ({
|
|
89
149
|
componentName: dom.getAttribute('data-mdx-component') || '',
|
|
90
150
|
props: dom.getAttribute('data-mdx-props') || '{}',
|
|
151
|
+
children: dom.getAttribute('data-mdx-children') || '',
|
|
152
|
+
hasExpressions: dom.getAttribute('data-mdx-expressions') === 'true',
|
|
91
153
|
}),
|
|
92
154
|
}],
|
|
93
155
|
parseMarkdown: {
|
|
@@ -95,13 +157,17 @@ export const mdxComponentNode = $node('mdx_component', () => ({
|
|
|
95
157
|
runner: (state, node, proseType: NodeType) => {
|
|
96
158
|
const name = (node as any).name as string | null
|
|
97
159
|
if (!name) return // Skip fragments
|
|
160
|
+
// Skip HTML void elements — they are not editable components
|
|
161
|
+
if (HTML_VOID_ELEMENTS.has(name.toLowerCase())) return
|
|
98
162
|
|
|
99
163
|
const { props, hasExpressions } = parseJsxAttributes((node as any).attributes)
|
|
164
|
+
const children = serializeChildren((node as any).children ?? [])
|
|
100
165
|
|
|
101
166
|
state.addNode(proseType, {
|
|
102
167
|
componentName: name,
|
|
103
168
|
props: JSON.stringify(props),
|
|
104
169
|
hasExpressions,
|
|
170
|
+
children,
|
|
105
171
|
})
|
|
106
172
|
},
|
|
107
173
|
},
|
|
@@ -110,13 +176,24 @@ export const mdxComponentNode = $node('mdx_component', () => ({
|
|
|
110
176
|
runner: (state: SerializerState, node: PmNode) => {
|
|
111
177
|
const componentName = node.attrs.componentName as string
|
|
112
178
|
const props: Record<string, string> = JSON.parse(node.attrs.props as string)
|
|
179
|
+
const childrenText = (node.attrs.children as string) || ''
|
|
113
180
|
const attributes = serializePropsToAttributes(props)
|
|
114
181
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
182
|
+
if (childrenText.trim()) {
|
|
183
|
+
// Parse children markdown into proper mdast nodes so headings, lists, etc. are preserved
|
|
184
|
+
const childrenAst = remarkParser.parse(childrenText)
|
|
185
|
+
state.addNode('mdxJsxFlowElement', undefined, undefined, {
|
|
186
|
+
name: componentName,
|
|
187
|
+
attributes,
|
|
188
|
+
children: childrenAst.children,
|
|
189
|
+
} as any)
|
|
190
|
+
} else {
|
|
191
|
+
state.addNode('mdxJsxFlowElement', undefined, undefined, {
|
|
192
|
+
name: componentName,
|
|
193
|
+
attributes,
|
|
194
|
+
children: [],
|
|
195
|
+
} as any)
|
|
196
|
+
}
|
|
120
197
|
},
|
|
121
198
|
},
|
|
122
199
|
}))
|
|
@@ -124,6 +201,7 @@ export const mdxComponentNode = $node('mdx_component', () => ({
|
|
|
124
201
|
export interface InsertMdxComponentPayload {
|
|
125
202
|
componentName: string
|
|
126
203
|
props: Record<string, string>
|
|
204
|
+
children?: string
|
|
127
205
|
}
|
|
128
206
|
|
|
129
207
|
export const insertMdxComponentCommand = $command('insertMdxComponent', (ctx) => {
|
|
@@ -137,6 +215,7 @@ export const insertMdxComponentCommand = $command('insertMdxComponent', (ctx) =>
|
|
|
137
215
|
componentName: payload.componentName,
|
|
138
216
|
props: JSON.stringify(payload.props),
|
|
139
217
|
hasExpressions: false,
|
|
218
|
+
children: payload.children || '',
|
|
140
219
|
})
|
|
141
220
|
|
|
142
221
|
if (dispatch) {
|
|
@@ -156,27 +235,36 @@ export const mdxComponentView = $view(mdxComponentNode, () => {
|
|
|
156
235
|
container.setAttribute('data-cms-ui', '')
|
|
157
236
|
container.contentEditable = 'false'
|
|
158
237
|
|
|
159
|
-
let lastAttrs: { componentName: string; props: string; hasExpressions: boolean } | null = null
|
|
238
|
+
let lastAttrs: { componentName: string; props: string; hasExpressions: boolean; children: string } | null = null
|
|
160
239
|
|
|
161
240
|
const renderCard = (node: PmNode) => {
|
|
162
241
|
const componentName = node.attrs.componentName as string
|
|
163
242
|
const propsJson = node.attrs.props as string
|
|
164
243
|
const props: Record<string, string> = JSON.parse(propsJson)
|
|
165
244
|
const hasExpressions = node.attrs.hasExpressions as boolean
|
|
245
|
+
const children = (node.attrs.children as string) || ''
|
|
246
|
+
const definition = getComponentDefinition(manifest.value, componentName)
|
|
247
|
+
const hasDefaultSlot = definition?.slots?.includes('default') ?? false
|
|
248
|
+
|
|
249
|
+
lastAttrs = { componentName, props: propsJson, hasExpressions, children }
|
|
166
250
|
|
|
167
|
-
|
|
251
|
+
const updateNodeAttrs = (update: Record<string, unknown>) => {
|
|
252
|
+
const pos = typeof getPos === 'function' ? getPos() : null
|
|
253
|
+
if (pos != null) {
|
|
254
|
+
const currentNode = view.state.doc.nodeAt(pos)
|
|
255
|
+
if (currentNode) {
|
|
256
|
+
const tr = view.state.tr.setNodeMarkup(pos, undefined, { ...currentNode.attrs, ...update })
|
|
257
|
+
view.dispatch(tr)
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
168
261
|
|
|
169
262
|
render(
|
|
170
263
|
<MdxBlockCard
|
|
171
264
|
componentName={componentName}
|
|
172
265
|
props={props}
|
|
173
266
|
hasExpressions={hasExpressions}
|
|
174
|
-
|
|
175
|
-
const pos = typeof getPos === 'function' ? getPos() : null
|
|
176
|
-
if (pos != null) {
|
|
177
|
-
openMdxPropsEditor(pos, componentName, props, cursorPos)
|
|
178
|
-
}
|
|
179
|
-
}}
|
|
267
|
+
slotContent={children}
|
|
180
268
|
onRemove={() => {
|
|
181
269
|
const pos = typeof getPos === 'function' ? getPos() : null
|
|
182
270
|
if (pos != null) {
|
|
@@ -187,6 +275,12 @@ export const mdxComponentView = $view(mdxComponentNode, () => {
|
|
|
187
275
|
}
|
|
188
276
|
}
|
|
189
277
|
}}
|
|
278
|
+
onSlotContentChange={hasDefaultSlot
|
|
279
|
+
? (newContent: string) => updateNodeAttrs({ children: newContent })
|
|
280
|
+
: undefined}
|
|
281
|
+
onPropsChange={!hasExpressions
|
|
282
|
+
? (newProps: Record<string, string>) => updateNodeAttrs({ props: JSON.stringify(newProps) })
|
|
283
|
+
: undefined}
|
|
190
284
|
/>,
|
|
191
285
|
container,
|
|
192
286
|
)
|
|
@@ -197,11 +291,13 @@ export const mdxComponentView = $view(mdxComponentNode, () => {
|
|
|
197
291
|
return {
|
|
198
292
|
dom: container,
|
|
199
293
|
stopEvent: (event: Event) => {
|
|
294
|
+
const target = event.target as HTMLElement
|
|
295
|
+
// Allow all events on inline editors (textarea, input, contenteditable)
|
|
296
|
+
if (target.tagName === 'TEXTAREA' || target.tagName === 'INPUT') return true
|
|
297
|
+
if (target.isContentEditable || target.closest('[contenteditable]')) return true
|
|
298
|
+
if (target.closest('[data-mdx-action="children"]') || target.closest('[data-mdx-action="props"]')) return true
|
|
200
299
|
if (event.type === 'mousedown' || event.type === 'click') {
|
|
201
|
-
|
|
202
|
-
if (target.closest('button') || target.closest('[data-mdx-action]')) {
|
|
203
|
-
return true
|
|
204
|
-
}
|
|
300
|
+
if (target.closest('button') || target.closest('[data-mdx-action]')) return true
|
|
205
301
|
}
|
|
206
302
|
return false
|
|
207
303
|
},
|
|
@@ -214,6 +310,7 @@ export const mdxComponentView = $view(mdxComponentNode, () => {
|
|
|
214
310
|
&& attrs.componentName === lastAttrs.componentName
|
|
215
311
|
&& attrs.props === lastAttrs.props
|
|
216
312
|
&& attrs.hasExpressions === lastAttrs.hasExpressions
|
|
313
|
+
&& attrs.children === lastAttrs.children
|
|
217
314
|
) {
|
|
218
315
|
return true
|
|
219
316
|
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import type { Editor } from '@milkdown/core'
|
|
2
|
+
import { editorViewCtx } from '@milkdown/core'
|
|
3
|
+
import type { EditorView } from '@milkdown/prose/view'
|
|
4
|
+
|
|
5
|
+
export interface ActiveFormats {
|
|
6
|
+
bold: boolean
|
|
7
|
+
italic: boolean
|
|
8
|
+
strikethrough: boolean
|
|
9
|
+
link: boolean
|
|
10
|
+
linkHref: string | null
|
|
11
|
+
bulletList: boolean
|
|
12
|
+
orderedList: boolean
|
|
13
|
+
blockquote: boolean
|
|
14
|
+
heading: number | null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const defaultActiveFormats: ActiveFormats = {
|
|
18
|
+
bold: false,
|
|
19
|
+
italic: false,
|
|
20
|
+
strikethrough: false,
|
|
21
|
+
link: false,
|
|
22
|
+
linkHref: null,
|
|
23
|
+
bulletList: false,
|
|
24
|
+
orderedList: false,
|
|
25
|
+
blockquote: false,
|
|
26
|
+
heading: null,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Detect active inline/block formats at the current selection in a ProseMirror view.
|
|
31
|
+
*/
|
|
32
|
+
export function getActiveFormats(view: EditorView): ActiveFormats {
|
|
33
|
+
const { state } = view
|
|
34
|
+
const { $from, from, to } = state.selection
|
|
35
|
+
|
|
36
|
+
// Check marks (inline formatting)
|
|
37
|
+
let bold = false
|
|
38
|
+
let italic = false
|
|
39
|
+
let strikethrough = false
|
|
40
|
+
let link = false
|
|
41
|
+
let linkHref: string | null = null
|
|
42
|
+
|
|
43
|
+
const marks = state.storedMarks || $from.marks()
|
|
44
|
+
for (const mark of marks) {
|
|
45
|
+
if (mark.type.name === 'strong') bold = true
|
|
46
|
+
if (mark.type.name === 'emphasis') italic = true
|
|
47
|
+
if (mark.type.name === 'strikethrough') strikethrough = true
|
|
48
|
+
if (mark.type.name === 'link') {
|
|
49
|
+
link = true
|
|
50
|
+
linkHref = mark.attrs.href as string
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Also check marks in the selection range
|
|
55
|
+
if (from !== to) {
|
|
56
|
+
state.doc.nodesBetween(from, to, (node) => {
|
|
57
|
+
if (node.marks) {
|
|
58
|
+
for (const mark of node.marks) {
|
|
59
|
+
if (mark.type.name === 'strong') bold = true
|
|
60
|
+
if (mark.type.name === 'emphasis') italic = true
|
|
61
|
+
if (mark.type.name === 'strikethrough') strikethrough = true
|
|
62
|
+
if (mark.type.name === 'link') {
|
|
63
|
+
link = true
|
|
64
|
+
linkHref = mark.attrs.href as string
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Check block types (lists, blockquote, heading)
|
|
72
|
+
let bulletList = false
|
|
73
|
+
let orderedList = false
|
|
74
|
+
let blockquote = false
|
|
75
|
+
let heading: number | null = null
|
|
76
|
+
|
|
77
|
+
for (let depth = $from.depth; depth > 0; depth--) {
|
|
78
|
+
const node = $from.node(depth)
|
|
79
|
+
if (node.type.name === 'bullet_list') bulletList = true
|
|
80
|
+
if (node.type.name === 'ordered_list') orderedList = true
|
|
81
|
+
if (node.type.name === 'blockquote') blockquote = true
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if ($from.parent.type.name === 'heading') {
|
|
85
|
+
heading = $from.parent.attrs.level as number
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return { bold, italic, strikethrough, link, linkHref, bulletList, orderedList, blockquote, heading }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Check whether the current selection is inside a list of the given type.
|
|
93
|
+
*/
|
|
94
|
+
export function isInListType(view: EditorView, listType: string): boolean {
|
|
95
|
+
const { $from } = view.state.selection
|
|
96
|
+
for (let depth = $from.depth; depth > 0; depth--) {
|
|
97
|
+
if ($from.node(depth).type.name === listType) return true
|
|
98
|
+
}
|
|
99
|
+
return false
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Toggle a heading level at the current selection. If the selection is already
|
|
104
|
+
* a heading of the given level, convert it back to a paragraph.
|
|
105
|
+
*/
|
|
106
|
+
export function toggleHeading(view: EditorView, level: number): void {
|
|
107
|
+
const { state } = view
|
|
108
|
+
const headingType = state.schema.nodes.heading
|
|
109
|
+
const paragraphType = state.schema.nodes.paragraph
|
|
110
|
+
if (!headingType) return
|
|
111
|
+
|
|
112
|
+
const { $from } = state.selection
|
|
113
|
+
const isCurrentHeading = $from.parent.type.name === 'heading' && $from.parent.attrs.level === level
|
|
114
|
+
const targetType = isCurrentHeading ? paragraphType : headingType
|
|
115
|
+
const attrs = isCurrentHeading ? undefined : { level }
|
|
116
|
+
if (!targetType) return
|
|
117
|
+
|
|
118
|
+
const blockFrom = $from.before($from.depth)
|
|
119
|
+
const blockTo = state.selection.$to.after(state.selection.$to.depth)
|
|
120
|
+
view.dispatch(state.tr.setBlockType(blockFrom, blockTo, targetType, attrs))
|
|
121
|
+
view.focus()
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function formatsEqual(a: ActiveFormats, b: ActiveFormats): boolean {
|
|
125
|
+
return a.bold === b.bold
|
|
126
|
+
&& a.italic === b.italic
|
|
127
|
+
&& a.strikethrough === b.strikethrough
|
|
128
|
+
&& a.link === b.link
|
|
129
|
+
&& a.linkHref === b.linkHref
|
|
130
|
+
&& a.bulletList === b.bulletList
|
|
131
|
+
&& a.orderedList === b.orderedList
|
|
132
|
+
&& a.blockquote === b.blockquote
|
|
133
|
+
&& a.heading === b.heading
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Intercept dispatch on the editor view to track active formats via rAF
|
|
138
|
+
* debouncing. Fires the callback only when formats actually change.
|
|
139
|
+
* Returns a cleanup function that cancels the pending rAF.
|
|
140
|
+
*/
|
|
141
|
+
export function setupFormatTracking(editor: Editor, callback: (formats: ActiveFormats) => void): () => void {
|
|
142
|
+
let formatRaf = 0
|
|
143
|
+
let lastFormats: ActiveFormats = defaultActiveFormats
|
|
144
|
+
|
|
145
|
+
const update = () => {
|
|
146
|
+
try {
|
|
147
|
+
const view = editor.ctx.get(editorViewCtx)
|
|
148
|
+
const formats = getActiveFormats(view)
|
|
149
|
+
if (!formatsEqual(formats, lastFormats)) {
|
|
150
|
+
lastFormats = formats
|
|
151
|
+
callback(formats)
|
|
152
|
+
}
|
|
153
|
+
} catch { /* ignore */ }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
const view = editor.ctx.get(editorViewCtx)
|
|
158
|
+
const origDispatch = view.dispatch.bind(view)
|
|
159
|
+
view.dispatch = (tr) => {
|
|
160
|
+
origDispatch(tr)
|
|
161
|
+
if (tr.selectionSet || tr.docChanged) {
|
|
162
|
+
cancelAnimationFrame(formatRaf)
|
|
163
|
+
formatRaf = requestAnimationFrame(update)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
} catch { /* ignore */ }
|
|
167
|
+
|
|
168
|
+
// Fire initial check
|
|
169
|
+
update()
|
|
170
|
+
|
|
171
|
+
return () => {
|
|
172
|
+
cancelAnimationFrame(formatRaf)
|
|
173
|
+
}
|
|
174
|
+
}
|
package/src/editor/signals.ts
CHANGED
|
@@ -26,7 +26,6 @@ import type {
|
|
|
26
26
|
FieldDefinition,
|
|
27
27
|
MarkdownEditorState,
|
|
28
28
|
MarkdownPageEntry,
|
|
29
|
-
MdxPropsEditorState,
|
|
30
29
|
MediaItem,
|
|
31
30
|
MediaLibraryState,
|
|
32
31
|
PendingAttributeChange,
|
|
@@ -346,24 +345,8 @@ export const isMarkdownPreview = signal(false)
|
|
|
346
345
|
// MDX Component Block State Signals
|
|
347
346
|
// ============================================================================
|
|
348
347
|
|
|
349
|
-
export const mdxPropsEditorState = signal<MdxPropsEditorState>({
|
|
350
|
-
isOpen: false,
|
|
351
|
-
nodePos: null,
|
|
352
|
-
componentName: null,
|
|
353
|
-
props: {},
|
|
354
|
-
cursorPos: null,
|
|
355
|
-
})
|
|
356
|
-
|
|
357
348
|
export const mdxComponentPickerOpen = signal(false)
|
|
358
349
|
|
|
359
|
-
export function openMdxPropsEditor(nodePos: number, componentName: string, props: Record<string, string>, cursorPos: { x: number; y: number }): void {
|
|
360
|
-
mdxPropsEditorState.value = { isOpen: true, nodePos, componentName, props, cursorPos }
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
export function closeMdxPropsEditor(): void {
|
|
364
|
-
mdxPropsEditorState.value = { isOpen: false, nodePos: null, componentName: null, props: {}, cursorPos: null }
|
|
365
|
-
}
|
|
366
|
-
|
|
367
350
|
// ============================================================================
|
|
368
351
|
// Reference Picker State Signals
|
|
369
352
|
// ============================================================================
|
|
@@ -825,7 +808,7 @@ export function setAIStreamingContent(content: string | null): void {
|
|
|
825
808
|
}
|
|
826
809
|
|
|
827
810
|
export function setAIError(error: string | null): void {
|
|
828
|
-
aiState.value = { ...aiState.value, error
|
|
811
|
+
aiState.value = { ...aiState.value, error }
|
|
829
812
|
}
|
|
830
813
|
|
|
831
814
|
export function resetAIState(): void {
|
package/src/editor/types.ts
CHANGED
|
@@ -698,16 +698,6 @@ export type UndoAction =
|
|
|
698
698
|
// MDX Component Block Types
|
|
699
699
|
// ============================================================================
|
|
700
700
|
|
|
701
|
-
export interface MdxPropsEditorState {
|
|
702
|
-
isOpen: boolean
|
|
703
|
-
/** ProseMirror document position of the node being edited */
|
|
704
|
-
nodePos: number | null
|
|
705
|
-
componentName: string | null
|
|
706
|
-
props: Record<string, string>
|
|
707
|
-
/** Position for the floating editor panel */
|
|
708
|
-
cursorPos: { x: number; y: number } | null
|
|
709
|
-
}
|
|
710
|
-
|
|
711
701
|
declare global {
|
|
712
702
|
interface Window {
|
|
713
703
|
NuaCmsConfig?: Partial<CmsConfig>
|
package/src/html-processor.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type HTMLElement as ParsedHTMLElement, parse } from 'node-html-parser'
|
|
2
2
|
import { processSeoFromHtml } from './seo-processor'
|
|
3
|
-
|
|
3
|
+
|
|
4
4
|
import { extractBackgroundImageClasses, extractColorClasses, extractTextStyleClasses } from './tailwind-colors'
|
|
5
5
|
import type {
|
|
6
6
|
Attribute,
|
|
@@ -90,6 +90,8 @@ export interface ProcessHtmlResult {
|
|
|
90
90
|
collectionWrapperId?: string
|
|
91
91
|
/** Extracted SEO data from the page */
|
|
92
92
|
seo?: PageSeoData
|
|
93
|
+
/** Collection definitions passed through for deferred enhancement */
|
|
94
|
+
collectionDefinitions?: Record<string, CollectionDefinition>
|
|
93
95
|
}
|
|
94
96
|
|
|
95
97
|
/**
|
|
@@ -440,7 +442,10 @@ export async function processHtml(
|
|
|
440
442
|
let foundWrapper = false
|
|
441
443
|
|
|
442
444
|
// Strategy 1: Dev mode - look for source file attributes
|
|
445
|
+
const SKIP_WRAPPER_TAGS = new Set(['html', 'head', 'body', 'script', 'style', 'meta', 'link'])
|
|
443
446
|
for (const node of allElements) {
|
|
447
|
+
const tag = node.tagName?.toLowerCase?.() ?? ''
|
|
448
|
+
if (SKIP_WRAPPER_TAGS.has(tag)) continue
|
|
444
449
|
const sourceFile = node.getAttribute('data-astro-source-file')
|
|
445
450
|
if (!sourceFile) continue
|
|
446
451
|
|
|
@@ -1023,10 +1028,6 @@ export async function processHtml(
|
|
|
1023
1028
|
node.removeAttribute('data-astro-source-line')
|
|
1024
1029
|
})
|
|
1025
1030
|
|
|
1026
|
-
// Enhance manifest entries with actual source snippets from source files
|
|
1027
|
-
// This allows the CMS to match and replace dynamic content in source files
|
|
1028
|
-
const enhancedEntries = await enhanceManifestWithSourceSnippets(entries, collectionDefinitions)
|
|
1029
|
-
|
|
1030
1031
|
// Get the current HTML for SEO processing
|
|
1031
1032
|
let finalHtml = root.toString()
|
|
1032
1033
|
|
|
@@ -1048,7 +1049,7 @@ export async function processHtml(
|
|
|
1048
1049
|
|
|
1049
1050
|
// If title was marked with CMS ID, add it to entries
|
|
1050
1051
|
if (seoResult.titleId && seo.title) {
|
|
1051
|
-
|
|
1052
|
+
entries[seoResult.titleId] = {
|
|
1052
1053
|
id: seoResult.titleId,
|
|
1053
1054
|
tag: 'title',
|
|
1054
1055
|
text: seo.title.content,
|
|
@@ -1061,10 +1062,11 @@ export async function processHtml(
|
|
|
1061
1062
|
|
|
1062
1063
|
return {
|
|
1063
1064
|
html: finalHtml,
|
|
1064
|
-
entries
|
|
1065
|
+
entries,
|
|
1065
1066
|
components,
|
|
1066
1067
|
collectionWrapperId,
|
|
1067
1068
|
seo,
|
|
1069
|
+
collectionDefinitions,
|
|
1068
1070
|
}
|
|
1069
1071
|
}
|
|
1070
1072
|
|
package/src/index.ts
CHANGED
|
@@ -112,6 +112,9 @@ export default function nuaCms(options: NuaCmsOptions = {}): AstroIntegration {
|
|
|
112
112
|
name: '@nuasite/cms',
|
|
113
113
|
hooks: {
|
|
114
114
|
'astro:config:setup': async ({ updateConfig, command, injectScript, logger }) => {
|
|
115
|
+
// CMS is only needed during dev — skip all setup during build
|
|
116
|
+
if (command !== 'dev') return
|
|
117
|
+
|
|
115
118
|
// --- CMS Marker setup ---
|
|
116
119
|
idCounter.value = 0
|
|
117
120
|
manifestWriter.reset()
|
|
@@ -295,21 +298,8 @@ export default function nuaCms(options: NuaCmsOptions = {}): AstroIntegration {
|
|
|
295
298
|
},
|
|
296
299
|
|
|
297
300
|
'astro:build:done': async ({ dir, logger }) => {
|
|
298
|
-
if (generateManifest) {
|
|
299
|
-
await processBuildOutput(dir, markerConfig, manifestWriter, idCounter, logger)
|
|
300
|
-
}
|
|
301
|
-
|
|
302
301
|
// Merge CMS-managed redirects (src/_redirects) into dist/_redirects
|
|
303
302
|
await mergeRedirects(dir, logger)
|
|
304
|
-
|
|
305
|
-
const errorCollector = getErrorCollector()
|
|
306
|
-
if (errorCollector.hasWarnings()) {
|
|
307
|
-
const warnings = errorCollector.getWarnings()
|
|
308
|
-
logger.warn(`${warnings.length} warning(s) during processing:`)
|
|
309
|
-
for (const { context, message } of warnings) {
|
|
310
|
-
logger.warn(` - ${context}: ${message}`)
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
303
|
},
|
|
314
304
|
},
|
|
315
305
|
}
|
|
@@ -18,6 +18,9 @@ const textSearchIndex: SearchIndexEntry[] = []
|
|
|
18
18
|
const imageSearchIndex: ImageIndexEntry[] = []
|
|
19
19
|
let searchIndexInitialized = false
|
|
20
20
|
|
|
21
|
+
/** Files that changed since last indexing — tracked by Vite watcher */
|
|
22
|
+
const dirtyFiles = new Set<string>()
|
|
23
|
+
|
|
21
24
|
// ============================================================================
|
|
22
25
|
// Cache Access Functions
|
|
23
26
|
// ============================================================================
|
|
@@ -58,6 +61,49 @@ export function addToImageSearchIndex(entry: ImageIndexEntry): void {
|
|
|
58
61
|
imageSearchIndex.push(entry)
|
|
59
62
|
}
|
|
60
63
|
|
|
64
|
+
// ============================================================================
|
|
65
|
+
// Dirty File Tracking (incremental re-indexing)
|
|
66
|
+
// ============================================================================
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Mark a file as dirty so its index entries are refreshed on next page load.
|
|
70
|
+
* Called by the Vite file watcher when source files change.
|
|
71
|
+
* @param absPath - Absolute path to the changed file
|
|
72
|
+
*/
|
|
73
|
+
export function markFileDirty(absPath: string): void {
|
|
74
|
+
dirtyFiles.add(absPath)
|
|
75
|
+
// Also evict the parsed file cache so it's re-read from disk
|
|
76
|
+
parsedFileCache.delete(absPath)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function getDirtyFiles(): Set<string> {
|
|
80
|
+
return dirtyFiles
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function clearDirtyFiles(): void {
|
|
84
|
+
dirtyFiles.clear()
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Remove all index entries for a specific file (by relative path).
|
|
89
|
+
* Used before re-indexing a changed file to avoid duplicates.
|
|
90
|
+
*/
|
|
91
|
+
export function removeFileFromIndexes(relFile: string): void {
|
|
92
|
+
filterInPlace(textSearchIndex, (e) => e.file !== relFile)
|
|
93
|
+
filterInPlace(imageSearchIndex, (e) => e.file !== relFile)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Remove non-matching elements in-place (single pass, no per-element splice). */
|
|
97
|
+
function filterInPlace<T>(arr: T[], keep: (item: T) => boolean): void {
|
|
98
|
+
let write = 0
|
|
99
|
+
for (const item of arr) {
|
|
100
|
+
if (keep(item)) {
|
|
101
|
+
arr[write++] = item
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
arr.length = write
|
|
105
|
+
}
|
|
106
|
+
|
|
61
107
|
// ============================================================================
|
|
62
108
|
// Cache Clear Function
|
|
63
109
|
// ============================================================================
|
|
@@ -69,6 +115,7 @@ export function clearSourceFinderCache(): void {
|
|
|
69
115
|
parsedFileCache.clear()
|
|
70
116
|
directoryCache.clear()
|
|
71
117
|
markdownFileCache.clear()
|
|
118
|
+
dirtyFiles.clear()
|
|
72
119
|
// Clear arrays in-place to avoid stale references from consumers
|
|
73
120
|
textSearchIndex.length = 0
|
|
74
121
|
imageSearchIndex.length = 0
|