@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.
Files changed (39) hide show
  1. package/dist/editor.js +12615 -12689
  2. package/package.json +3 -3
  3. package/src/build-processor.ts +4 -4
  4. package/src/dev-middleware.ts +185 -189
  5. package/src/editor/api.ts +0 -251
  6. package/src/editor/components/fields.tsx +6 -6
  7. package/src/editor/components/markdown-editor-overlay.tsx +46 -70
  8. package/src/editor/components/markdown-inline-editor.tsx +34 -165
  9. package/src/editor/components/mdx-block-view.tsx +351 -47
  10. package/src/editor/components/mdx-component-picker.tsx +35 -11
  11. package/src/editor/components/media-library.tsx +1 -15
  12. package/src/editor/components/modal-shell.tsx +1 -1
  13. package/src/editor/components/toolbar.tsx +0 -75
  14. package/src/editor/constants.ts +0 -4
  15. package/src/editor/editor.ts +2 -192
  16. package/src/editor/hooks/index.ts +0 -3
  17. package/src/editor/hooks/useBlockEditorHandlers.ts +1 -8
  18. package/src/editor/hooks/useTooltipState.ts +1 -2
  19. package/src/editor/index.tsx +2 -18
  20. package/src/editor/milkdown-mdx-plugin.tsx +116 -19
  21. package/src/editor/milkdown-utils.ts +174 -0
  22. package/src/editor/post-message.ts +0 -6
  23. package/src/editor/signals.ts +0 -183
  24. package/src/editor/styles.css +0 -108
  25. package/src/editor/types.ts +0 -76
  26. package/src/html-processor.ts +9 -7
  27. package/src/source-finder/cache.ts +47 -0
  28. package/src/source-finder/collection-finder.ts +181 -0
  29. package/src/source-finder/index.ts +5 -2
  30. package/src/source-finder/search-index.ts +79 -0
  31. package/src/source-finder/snippet-utils.ts +36 -61
  32. package/src/types.ts +0 -4
  33. package/src/utils.ts +10 -0
  34. package/src/vite-plugin.ts +24 -4
  35. package/src/editor/ai.ts +0 -185
  36. package/src/editor/components/ai-chat.tsx +0 -631
  37. package/src/editor/components/ai-tooltip.tsx +0 -180
  38. package/src/editor/components/mdx-props-editor.tsx +0 -94
  39. package/src/editor/hooks/useAIHandlers.ts +0 -345
@@ -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 { openMdxPropsEditor } from './signals'
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: true,
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
- state.addNode('mdxJsxFlowElement', undefined, undefined, {
116
- name: componentName,
117
- attributes,
118
- children: [],
119
- } as any)
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
- lastAttrs = { componentName, props: propsJson, hasExpressions }
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
- onEdit={(cursorPos) => {
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
- const target = event.target as HTMLElement
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
+ }
@@ -109,8 +109,6 @@ export function buildPageNavigatedMessage(manifest: CmsManifest, pathname: strin
109
109
  export function buildEditorState(opts: {
110
110
  isEditing: boolean
111
111
  dirtyCount: CmsEditorState['dirtyCount']
112
- deploymentStatus: CmsEditorState['deployment']['status']
113
- lastDeployedAt: string | null
114
112
  canUndo: boolean
115
113
  canRedo: boolean
116
114
  }): CmsEditorState {
@@ -118,10 +116,6 @@ export function buildEditorState(opts: {
118
116
  isEditing: opts.isEditing,
119
117
  hasChanges: opts.dirtyCount.total > 0,
120
118
  dirtyCount: opts.dirtyCount,
121
- deployment: {
122
- status: opts.deploymentStatus,
123
- lastDeployedAt: opts.lastDeployedAt,
124
- },
125
119
  canUndo: opts.canUndo,
126
120
  canRedo: opts.canRedo,
127
121
  }