@nuasite/cms 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/dist/editor.js +44697 -26834
  2. package/package.json +23 -21
  3. package/src/build-processor.ts +4 -1
  4. package/src/collection-scanner.ts +425 -48
  5. package/src/dev-middleware.ts +26 -203
  6. package/src/editor/api.ts +1 -22
  7. package/src/editor/components/ai-chat.tsx +3 -3
  8. package/src/editor/components/ai-tooltip.tsx +2 -1
  9. package/src/editor/components/block-editor.tsx +13 -108
  10. package/src/editor/components/collections-browser.tsx +168 -205
  11. package/src/editor/components/component-card.tsx +49 -0
  12. package/src/editor/components/confirm-dialog.tsx +34 -47
  13. package/src/editor/components/create-page-modal.tsx +529 -101
  14. package/src/editor/components/delete-page-dialog.tsx +100 -0
  15. package/src/editor/components/fields.tsx +175 -0
  16. package/src/editor/components/frontmatter-fields.tsx +281 -70
  17. package/src/editor/components/frontmatter-sidebar.tsx +223 -0
  18. package/src/editor/components/highlight-overlay.ts +3 -2
  19. package/src/editor/components/markdown-editor-overlay.tsx +131 -85
  20. package/src/editor/components/markdown-inline-editor.tsx +74 -5
  21. package/src/editor/components/mdx-block-view.tsx +102 -0
  22. package/src/editor/components/mdx-component-picker.tsx +123 -0
  23. package/src/editor/components/mdx-props-editor.tsx +94 -0
  24. package/src/editor/components/media-library.tsx +373 -100
  25. package/src/editor/components/modal-shell.tsx +87 -0
  26. package/src/editor/components/prop-editor.tsx +52 -0
  27. package/src/editor/components/redirect-countdown.tsx +3 -1
  28. package/src/editor/components/redirects-manager.tsx +269 -0
  29. package/src/editor/components/reference-picker.tsx +203 -0
  30. package/src/editor/components/seo-editor.tsx +285 -303
  31. package/src/editor/components/toast/toast-container.tsx +2 -1
  32. package/src/editor/components/toolbar.tsx +177 -46
  33. package/src/editor/constants.ts +26 -0
  34. package/src/editor/editor.ts +112 -0
  35. package/src/editor/fetch.ts +62 -0
  36. package/src/editor/index.tsx +19 -1
  37. package/src/editor/markdown-api.ts +105 -156
  38. package/src/editor/milkdown-mdx-plugin.tsx +269 -0
  39. package/src/editor/signals.ts +206 -13
  40. package/src/editor/types.ts +52 -1
  41. package/src/handlers/api-routes.ts +251 -0
  42. package/src/handlers/component-ops.ts +2 -18
  43. package/src/handlers/markdown-ops.ts +202 -47
  44. package/src/handlers/page-ops.ts +229 -0
  45. package/src/handlers/redirect-ops.ts +163 -0
  46. package/src/handlers/source-writer.ts +157 -1
  47. package/src/html-processor.ts +14 -2
  48. package/src/index.ts +76 -2
  49. package/src/manifest-writer.ts +19 -1
  50. package/src/media/contember.ts +2 -1
  51. package/src/media/local.ts +66 -28
  52. package/src/media/project-images.ts +81 -0
  53. package/src/media/s3.ts +32 -11
  54. package/src/media/types.ts +24 -2
  55. package/src/shared.ts +27 -0
  56. package/src/source-finder/collection-finder.ts +219 -41
  57. package/src/source-finder/index.ts +7 -1
  58. package/src/source-finder/search-index.ts +178 -36
  59. package/src/source-finder/snippet-utils.ts +423 -3
  60. package/src/tsconfig.json +0 -2
  61. package/src/types.ts +111 -2
  62. package/src/utils.ts +40 -4
@@ -1,224 +1,136 @@
1
1
  import { API } from './constants'
2
+ import { fetchWithTimeout, getJson, postJson } from './fetch'
2
3
  import type {
4
+ AddRedirectRequest,
3
5
  CmsConfig,
4
6
  CreateMarkdownPageRequest,
5
7
  CreateMarkdownPageResponse,
8
+ CreatePageRequest,
9
+ DeletePageRequest,
10
+ DeleteRedirectRequest,
11
+ DuplicatePageRequest,
12
+ GetRedirectsResponse,
13
+ LayoutInfo,
6
14
  MediaItem,
7
15
  MediaUploadResponse,
16
+ PageOperationResponse,
17
+ RedirectOperationResponse,
8
18
  UpdateMarkdownPageRequest,
9
19
  UpdateMarkdownPageResponse,
20
+ UpdateRedirectRequest,
10
21
  } from './types'
11
22
 
12
- /**
13
- * Create a fetch request with timeout
14
- */
15
- async function fetchWithTimeout(
16
- url: string,
17
- options: RequestInit = {},
18
- timeoutMs: number = API.REQUEST_TIMEOUT_MS,
19
- ): Promise<Response> {
20
- const controller = new AbortController()
21
- const timeoutId = setTimeout(() => controller.abort(), timeoutMs)
22
-
23
- try {
24
- const response = await fetch(url, {
25
- ...options,
26
- signal: controller.signal,
27
- })
28
- return response
29
- } finally {
30
- clearTimeout(timeoutId)
31
- }
32
- }
33
-
34
- /**
35
- * Create a new markdown page (blog post)
36
- */
37
- export async function createMarkdownPage(
38
- config: CmsConfig,
39
- request: CreateMarkdownPageRequest,
40
- ): Promise<CreateMarkdownPageResponse> {
41
- const res = await fetchWithTimeout(`${config.apiBase}/markdown/create`, {
42
- method: 'POST',
43
- credentials: 'include',
44
- headers: {
45
- 'Content-Type': 'application/json',
46
- },
47
- body: JSON.stringify(request),
48
- })
23
+ // Markdown operations
49
24
 
50
- if (!res.ok) {
51
- const text = await res.text().catch(() => '')
52
- return {
53
- success: false,
54
- error: `Create page failed (${res.status}): ${text || res.statusText}`,
55
- }
56
- }
57
-
58
- return res.json()
25
+ export function createMarkdownPage(config: CmsConfig, request: CreateMarkdownPageRequest): Promise<CreateMarkdownPageResponse> {
26
+ return postJson(`${config.apiBase}/markdown/create`, request, 'Create page failed')
59
27
  }
60
28
 
61
- /**
62
- * Update an existing markdown page
63
- */
64
- export async function updateMarkdownPage(
65
- config: CmsConfig,
66
- request: UpdateMarkdownPageRequest,
67
- ): Promise<UpdateMarkdownPageResponse> {
68
- const res = await fetchWithTimeout(`${config.apiBase}/markdown/update`, {
69
- method: 'POST',
70
- credentials: 'include',
71
- headers: {
72
- 'Content-Type': 'application/json',
73
- },
74
- body: JSON.stringify(request),
75
- })
76
-
77
- if (!res.ok) {
78
- const text = await res.text().catch(() => '')
79
- return {
80
- success: false,
81
- error: `Update page failed (${res.status}): ${text || res.statusText}`,
82
- }
83
- }
84
-
85
- return res.json()
29
+ export function updateMarkdownPage(config: CmsConfig, request: UpdateMarkdownPageRequest): Promise<UpdateMarkdownPageResponse> {
30
+ return postJson(`${config.apiBase}/markdown/update`, request, 'Update page failed')
86
31
  }
87
32
 
88
- /**
89
- * Fetch markdown content from a file path
90
- */
91
- export async function fetchMarkdownContent(
33
+ export function renameMarkdownPage(
92
34
  config: CmsConfig,
93
35
  filePath: string,
94
- ): Promise<{ content: string; frontmatter: Record<string, unknown> } | null> {
95
- const res = await fetchWithTimeout(
96
- `${config.apiBase}/markdown/content?path=${encodeURIComponent(filePath)}`,
97
- {
98
- method: 'GET',
99
- credentials: 'include',
100
- },
101
- )
102
-
103
- if (!res.ok) {
104
- return null
105
- }
36
+ newSlug: string,
37
+ ): Promise<{ success: boolean; newFilePath?: string; newSlug?: string; error?: string }> {
38
+ return postJson(`${config.apiBase}/markdown/rename`, { filePath, newSlug }, 'Rename failed')
39
+ }
106
40
 
107
- return res.json()
41
+ export function deleteMarkdownPage(config: CmsConfig, filePath: string): Promise<{ success: boolean; error?: string }> {
42
+ return postJson(`${config.apiBase}/markdown/delete`, { filePath }, 'Delete failed')
108
43
  }
109
44
 
110
- /**
111
- * Delete a markdown page (collection entry)
112
- */
113
- export async function deleteMarkdownPage(
45
+ export function fetchMarkdownContent(
114
46
  config: CmsConfig,
115
47
  filePath: string,
116
- ): Promise<{ success: boolean; error?: string }> {
117
- const res = await fetchWithTimeout(`${config.apiBase}/markdown/delete`, {
118
- method: 'POST',
119
- credentials: 'include',
120
- headers: {
121
- 'Content-Type': 'application/json',
122
- },
123
- body: JSON.stringify({ filePath }),
124
- })
125
-
126
- if (!res.ok) {
127
- const text = await res.text().catch(() => '')
128
- return {
129
- success: false,
130
- error: `Delete failed (${res.status}): ${text || res.statusText}`,
131
- }
132
- }
133
-
134
- return res.json()
48
+ ): Promise<{ content: string; frontmatter: Record<string, unknown> } | null> {
49
+ return getJson(`${config.apiBase}/markdown/content?path=${encodeURIComponent(filePath)}`, null)
135
50
  }
136
51
 
137
- /**
138
- * Fetch media library items
139
- */
52
+ // Media operations
53
+
140
54
  export async function fetchMediaLibrary(
141
55
  config: CmsConfig,
142
- cursor?: string,
143
- limit = 50,
144
- ): Promise<{ items: MediaItem[]; hasMore: boolean; cursor?: string }> {
145
- const params = new URLSearchParams({ limit: String(limit) })
146
- if (cursor) {
147
- params.set('cursor', cursor)
148
- }
56
+ options?: { cursor?: string; limit?: number; folder?: string; type?: string },
57
+ ): Promise<{ items: MediaItem[]; folders?: Array<{ name: string; path: string }>; hasMore: boolean; cursor?: string }> {
58
+ const params = new URLSearchParams({ limit: String(options?.limit ?? 50) })
59
+ if (options?.cursor) params.set('cursor', options.cursor)
60
+ if (options?.folder) params.set('folder', options.folder)
61
+ if (options?.type && options.type !== 'all') params.set('type', options.type)
149
62
 
150
63
  const res = await fetchWithTimeout(`${config.apiBase}/media/list?${params}`, {
151
64
  method: 'GET',
152
65
  credentials: 'include',
153
66
  })
154
67
 
155
- if (!res.ok) {
156
- throw new Error(`Failed to fetch media library (${res.status})`)
157
- }
158
-
68
+ if (!res.ok) throw new Error(`Failed to fetch media library (${res.status})`)
159
69
  return res.json()
160
70
  }
161
71
 
162
- /**
163
- * Upload a media file
164
- */
165
- export async function uploadMedia(
72
+ export function uploadMedia(
166
73
  config: CmsConfig,
167
74
  file: File,
168
75
  onProgress?: (percent: number) => void,
76
+ options?: { folder?: string },
169
77
  ): Promise<MediaUploadResponse> {
170
78
  const formData = new FormData()
171
79
  formData.append('file', file)
172
80
 
173
- // Use XMLHttpRequest for progress tracking
81
+ const params = new URLSearchParams()
82
+ if (options?.folder) params.set('folder', options.folder)
83
+ const qs = params.toString()
84
+
174
85
  return new Promise((resolve) => {
175
86
  const xhr = new XMLHttpRequest()
176
87
 
177
88
  xhr.upload.addEventListener('progress', (e) => {
178
89
  if (e.lengthComputable && onProgress) {
179
- const percent = Math.round((e.loaded / e.total) * 100)
180
- onProgress(percent)
90
+ onProgress(Math.round((e.loaded / e.total) * 100))
181
91
  }
182
92
  })
183
93
 
184
94
  xhr.addEventListener('load', () => {
185
95
  if (xhr.status >= 200 && xhr.status < 300) {
186
96
  try {
187
- const response = JSON.parse(xhr.responseText)
188
- resolve(response)
97
+ resolve(JSON.parse(xhr.responseText))
189
98
  } catch {
190
99
  resolve({ success: false, error: 'Invalid response format' })
191
100
  }
192
101
  } else {
193
- resolve({
194
- success: false,
195
- error: `Upload failed (${xhr.status}): ${xhr.statusText}`,
196
- })
102
+ resolve({ success: false, error: `Upload failed (${xhr.status}): ${xhr.statusText}` })
197
103
  }
198
104
  })
199
105
 
200
- xhr.addEventListener('error', () => {
201
- resolve({ success: false, error: 'Network error during upload' })
202
- })
203
-
204
- xhr.addEventListener('timeout', () => {
205
- resolve({ success: false, error: 'Upload timed out' })
206
- })
106
+ xhr.addEventListener('error', () => resolve({ success: false, error: 'Network error during upload' }))
107
+ xhr.addEventListener('timeout', () => resolve({ success: false, error: 'Upload timed out' }))
207
108
 
208
- xhr.open('POST', `${config.apiBase}/media/upload`)
109
+ xhr.open('POST', `${config.apiBase}/media/upload${qs ? `?${qs}` : ''}`)
209
110
  xhr.withCredentials = true
210
- xhr.timeout = API.REQUEST_TIMEOUT_MS * 2 // Allow more time for uploads
111
+ xhr.timeout = API.REQUEST_TIMEOUT_MS * 2
211
112
  xhr.send(formData)
212
113
  })
213
114
  }
214
115
 
215
- /**
216
- * Delete a media item
217
- */
218
- export async function deleteMedia(
116
+ export async function createMediaFolder(
219
117
  config: CmsConfig,
220
- mediaId: string,
118
+ folder: string,
221
119
  ): Promise<{ success: boolean; error?: string }> {
120
+ return postJson(`${config.apiBase}/media/folder`, { folder }, 'Create folder failed')
121
+ }
122
+
123
+ export async function fetchProjectImages(config: CmsConfig): Promise<{ items: MediaItem[] }> {
124
+ const res = await fetchWithTimeout(`${config.apiBase}/media/project-images`, {
125
+ method: 'GET',
126
+ credentials: 'include',
127
+ })
128
+
129
+ if (!res.ok) throw new Error(`Failed to fetch project images (${res.status})`)
130
+ return res.json()
131
+ }
132
+
133
+ export async function deleteMedia(config: CmsConfig, mediaId: string): Promise<{ success: boolean; error?: string }> {
222
134
  const res = await fetchWithTimeout(`${config.apiBase}/media/${mediaId}`, {
223
135
  method: 'DELETE',
224
136
  credentials: 'include',
@@ -226,11 +138,48 @@ export async function deleteMedia(
226
138
 
227
139
  if (!res.ok) {
228
140
  const text = await res.text().catch(() => '')
229
- return {
230
- success: false,
231
- error: `Delete failed (${res.status}): ${text || res.statusText}`,
232
- }
141
+ return { success: false, error: `Delete failed (${res.status}): ${text || res.statusText}` }
233
142
  }
234
143
 
235
144
  return { success: true }
236
145
  }
146
+
147
+ // Page operations
148
+
149
+ export function createPage(config: CmsConfig, request: CreatePageRequest): Promise<PageOperationResponse> {
150
+ return postJson(`${config.apiBase}/page/create`, request)
151
+ }
152
+
153
+ export function duplicatePage(config: CmsConfig, request: DuplicatePageRequest): Promise<PageOperationResponse> {
154
+ return postJson(`${config.apiBase}/page/duplicate`, request)
155
+ }
156
+
157
+ export function deletePage(config: CmsConfig, request: DeletePageRequest): Promise<PageOperationResponse> {
158
+ return postJson(`${config.apiBase}/page/delete`, request)
159
+ }
160
+
161
+ export function checkSlugExists(config: CmsConfig, slug: string, signal?: AbortSignal): Promise<{ exists: boolean; filePath?: string }> {
162
+ return getJson(`${config.apiBase}/page/check-slug?slug=${encodeURIComponent(slug)}`, { exists: false }, signal)
163
+ }
164
+
165
+ export function getLayouts(config: CmsConfig): Promise<{ layouts: LayoutInfo[] }> {
166
+ return getJson(`${config.apiBase}/page/layouts`, { layouts: [] })
167
+ }
168
+
169
+ // Redirect operations
170
+
171
+ export function getRedirects(config: CmsConfig): Promise<GetRedirectsResponse> {
172
+ return getJson(`${config.apiBase}/redirects`, { rules: [] })
173
+ }
174
+
175
+ export function addRedirect(config: CmsConfig, request: AddRedirectRequest): Promise<RedirectOperationResponse> {
176
+ return postJson(`${config.apiBase}/redirects/add`, request)
177
+ }
178
+
179
+ export function updateRedirect(config: CmsConfig, request: UpdateRedirectRequest): Promise<RedirectOperationResponse> {
180
+ return postJson(`${config.apiBase}/redirects/update`, request)
181
+ }
182
+
183
+ export function deleteRedirect(config: CmsConfig, request: DeleteRedirectRequest): Promise<RedirectOperationResponse> {
184
+ return postJson(`${config.apiBase}/redirects/delete`, request)
185
+ }
@@ -0,0 +1,269 @@
1
+ import type { Node as PmNode, NodeType } from '@milkdown/prose/model'
2
+ import type { Command } from '@milkdown/prose/state'
3
+ import type { MarkdownNode, SerializerState } from '@milkdown/transformer'
4
+ import { $command, $node, $remark, $view } from '@milkdown/utils'
5
+ import { render } from 'preact'
6
+ import remarkMdx from 'remark-mdx'
7
+ import { MdxBlockCard } from './components/mdx-block-view'
8
+ import { openMdxPropsEditor } from './signals'
9
+
10
+ /** Prefix used to distinguish expression attributes from string literals in serialized props */
11
+ export const MDX_EXPR_PREFIX = '__mdx_expr__:'
12
+
13
+ export const remarkMdxPlugin: any = $remark('remarkMdx', () => remarkMdx)
14
+
15
+ function parseJsxAttributes(attributes: any[]): { props: Record<string, string>; hasExpressions: boolean } {
16
+ const props: Record<string, string> = {}
17
+ let hasExpressions = false
18
+
19
+ if (!Array.isArray(attributes)) return { props, hasExpressions }
20
+
21
+ for (const attr of attributes) {
22
+ if (attr.type === 'mdxJsxAttribute' && attr.name) {
23
+ if (attr.value === null || attr.value === undefined) {
24
+ // Boolean attribute: <Component flag />
25
+ props[attr.name] = 'true'
26
+ } else if (typeof attr.value === 'string') {
27
+ props[attr.name] = attr.value
28
+ } else if (attr.value?.type === 'mdxJsxAttributeValueExpression') {
29
+ // Expression attribute: prop={value}
30
+ props[attr.name] = `${MDX_EXPR_PREFIX}${attr.value.value}`
31
+ hasExpressions = true
32
+ }
33
+ }
34
+ if (attr.type === 'mdxJsxExpressionAttribute') {
35
+ hasExpressions = true
36
+ }
37
+ }
38
+
39
+ return { props, hasExpressions }
40
+ }
41
+
42
+ function serializePropsToAttributes(props: Record<string, string>): any[] {
43
+ const attributes: any[] = []
44
+
45
+ for (const [name, value] of Object.entries(props)) {
46
+ if (value.startsWith(MDX_EXPR_PREFIX)) {
47
+ attributes.push({
48
+ type: 'mdxJsxAttribute',
49
+ name,
50
+ value: {
51
+ type: 'mdxJsxAttributeValueExpression',
52
+ value: value.slice(MDX_EXPR_PREFIX.length),
53
+ },
54
+ })
55
+ } else {
56
+ attributes.push({
57
+ type: 'mdxJsxAttribute',
58
+ name,
59
+ value,
60
+ })
61
+ }
62
+ }
63
+
64
+ return attributes
65
+ }
66
+
67
+ export const mdxComponentNode = $node('mdx_component', () => ({
68
+ group: 'block',
69
+ atom: true,
70
+ isolating: true,
71
+ selectable: true,
72
+ draggable: true,
73
+ attrs: {
74
+ componentName: { default: '' },
75
+ props: { default: '{}' },
76
+ hasExpressions: { default: false },
77
+ },
78
+ toDOM: (node: PmNode) => {
79
+ const div = document.createElement('div')
80
+ div.setAttribute('data-mdx-component', node.attrs.componentName)
81
+ div.setAttribute('data-mdx-props', node.attrs.props)
82
+ div.className = 'mdx-component-block'
83
+ div.textContent = `<${node.attrs.componentName} />`
84
+ return div as any
85
+ },
86
+ parseDOM: [{
87
+ tag: 'div[data-mdx-component]',
88
+ getAttrs: (dom: HTMLElement) => ({
89
+ componentName: dom.getAttribute('data-mdx-component') || '',
90
+ props: dom.getAttribute('data-mdx-props') || '{}',
91
+ }),
92
+ }],
93
+ parseMarkdown: {
94
+ match: (node: MarkdownNode) => node.type === 'mdxJsxFlowElement' || node.type === 'mdxJsxTextElement',
95
+ runner: (state, node, proseType: NodeType) => {
96
+ const name = (node as any).name as string | null
97
+ if (!name) return // Skip fragments
98
+
99
+ const { props, hasExpressions } = parseJsxAttributes((node as any).attributes)
100
+
101
+ state.addNode(proseType, {
102
+ componentName: name,
103
+ props: JSON.stringify(props),
104
+ hasExpressions,
105
+ })
106
+ },
107
+ },
108
+ toMarkdown: {
109
+ match: (node: PmNode) => node.type.name === 'mdx_component',
110
+ runner: (state: SerializerState, node: PmNode) => {
111
+ const componentName = node.attrs.componentName as string
112
+ const props: Record<string, string> = JSON.parse(node.attrs.props as string)
113
+ const attributes = serializePropsToAttributes(props)
114
+
115
+ state.addNode('mdxJsxFlowElement', undefined, undefined, {
116
+ name: componentName,
117
+ attributes,
118
+ children: [],
119
+ } as any)
120
+ },
121
+ },
122
+ }))
123
+
124
+ export interface InsertMdxComponentPayload {
125
+ componentName: string
126
+ props: Record<string, string>
127
+ }
128
+
129
+ export const insertMdxComponentCommand = $command('insertMdxComponent', (ctx) => {
130
+ return (payload?: InsertMdxComponentPayload): Command => {
131
+ return (state, dispatch) => {
132
+ if (!payload) return false
133
+ const nodeType = state.schema.nodes.mdx_component
134
+ if (!nodeType) return false
135
+
136
+ const node = nodeType.create({
137
+ componentName: payload.componentName,
138
+ props: JSON.stringify(payload.props),
139
+ hasExpressions: false,
140
+ })
141
+
142
+ if (dispatch) {
143
+ const { from } = state.selection
144
+ const tr = state.tr.insert(from, node)
145
+ dispatch(tr)
146
+ }
147
+ return true
148
+ }
149
+ }
150
+ })
151
+
152
+ export const mdxComponentView = $view(mdxComponentNode, () => {
153
+ return (pmNode, view, getPos) => {
154
+ const container = document.createElement('div')
155
+ container.className = 'mdx-block-card-wrapper'
156
+ container.setAttribute('data-cms-ui', '')
157
+ container.contentEditable = 'false'
158
+
159
+ let lastAttrs: { componentName: string; props: string; hasExpressions: boolean } | null = null
160
+
161
+ const renderCard = (node: PmNode) => {
162
+ const componentName = node.attrs.componentName as string
163
+ const propsJson = node.attrs.props as string
164
+ const props: Record<string, string> = JSON.parse(propsJson)
165
+ const hasExpressions = node.attrs.hasExpressions as boolean
166
+
167
+ lastAttrs = { componentName, props: propsJson, hasExpressions }
168
+
169
+ render(
170
+ <MdxBlockCard
171
+ componentName={componentName}
172
+ props={props}
173
+ 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
+ }}
180
+ onRemove={() => {
181
+ const pos = typeof getPos === 'function' ? getPos() : null
182
+ if (pos != null) {
183
+ const currentNode = view.state.doc.nodeAt(pos)
184
+ if (currentNode) {
185
+ const tr = view.state.tr.delete(pos, pos + currentNode.nodeSize)
186
+ view.dispatch(tr)
187
+ }
188
+ }
189
+ }}
190
+ />,
191
+ container,
192
+ )
193
+ }
194
+
195
+ renderCard(pmNode)
196
+
197
+ return {
198
+ dom: container,
199
+ stopEvent: (event: Event) => {
200
+ 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
+ }
205
+ }
206
+ return false
207
+ },
208
+ ignoreMutation: () => true,
209
+ update: (updatedNode: PmNode) => {
210
+ if (updatedNode.type.name !== 'mdx_component') return false
211
+ const attrs = updatedNode.attrs
212
+ if (
213
+ lastAttrs
214
+ && attrs.componentName === lastAttrs.componentName
215
+ && attrs.props === lastAttrs.props
216
+ && attrs.hasExpressions === lastAttrs.hasExpressions
217
+ ) {
218
+ return true
219
+ }
220
+ renderCard(updatedNode)
221
+ return true
222
+ },
223
+ destroy: () => {
224
+ render(null, container)
225
+ },
226
+ }
227
+ }
228
+ })
229
+
230
+ /**
231
+ * Hidden node that preserves `import ... from '...'` statements through the editor round-trip.
232
+ * remark-mdx parses these as `mdxjsEsm` — without a matching ProseMirror node Milkdown throws.
233
+ */
234
+ export const mdxEsmNode = $node('mdx_esm', () => ({
235
+ group: 'block',
236
+ atom: true,
237
+ attrs: {
238
+ value: { default: '' },
239
+ },
240
+ toDOM: () => {
241
+ // Render nothing — imports are invisible in the editor
242
+ const span = document.createElement('span')
243
+ span.style.display = 'none'
244
+ return span as any
245
+ },
246
+ parseDOM: [],
247
+ parseMarkdown: {
248
+ match: (node: MarkdownNode) => node.type === 'mdxjsEsm',
249
+ runner: (state, node, proseType: NodeType) => {
250
+ state.addNode(proseType, { value: (node as any).value ?? '' })
251
+ },
252
+ },
253
+ toMarkdown: {
254
+ match: (node: PmNode) => node.type.name === 'mdx_esm',
255
+ runner: (state: SerializerState, node: PmNode) => {
256
+ state.addNode('mdxjsEsm', undefined, undefined, {
257
+ value: node.attrs.value as string,
258
+ } as any)
259
+ },
260
+ },
261
+ }))
262
+
263
+ export const mdxComponentPlugin = [
264
+ remarkMdxPlugin,
265
+ mdxEsmNode,
266
+ mdxComponentNode,
267
+ mdxComponentView,
268
+ insertMdxComponentCommand,
269
+ ] as const