@nuasite/cms 0.18.1 → 0.19.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 +52746 -36711
- package/package.json +16 -14
- package/src/build-processor.ts +4 -1
- package/src/collection-scanner.ts +425 -48
- package/src/dev-middleware.ts +26 -203
- package/src/editor/api.ts +1 -22
- package/src/editor/components/ai-chat.tsx +3 -3
- package/src/editor/components/ai-tooltip.tsx +2 -1
- package/src/editor/components/block-editor.tsx +13 -108
- package/src/editor/components/collections-browser.tsx +168 -205
- package/src/editor/components/component-card.tsx +49 -0
- package/src/editor/components/confirm-dialog.tsx +34 -47
- package/src/editor/components/create-page-modal.tsx +529 -101
- package/src/editor/components/delete-page-dialog.tsx +100 -0
- package/src/editor/components/fields.tsx +175 -0
- package/src/editor/components/frontmatter-fields.tsx +281 -70
- package/src/editor/components/frontmatter-sidebar.tsx +223 -0
- package/src/editor/components/highlight-overlay.ts +3 -2
- package/src/editor/components/markdown-editor-overlay.tsx +131 -85
- package/src/editor/components/markdown-inline-editor.tsx +74 -5
- package/src/editor/components/mdx-block-view.tsx +102 -0
- package/src/editor/components/mdx-component-picker.tsx +123 -0
- package/src/editor/components/mdx-props-editor.tsx +94 -0
- package/src/editor/components/media-library.tsx +373 -100
- package/src/editor/components/modal-shell.tsx +87 -0
- package/src/editor/components/prop-editor.tsx +52 -0
- package/src/editor/components/redirect-countdown.tsx +3 -1
- package/src/editor/components/redirects-manager.tsx +269 -0
- package/src/editor/components/reference-picker.tsx +203 -0
- package/src/editor/components/seo-editor.tsx +285 -303
- package/src/editor/components/toast/toast-container.tsx +2 -1
- package/src/editor/components/toolbar.tsx +177 -46
- package/src/editor/constants.ts +26 -0
- package/src/editor/editor.ts +112 -0
- package/src/editor/fetch.ts +62 -0
- package/src/editor/index.tsx +19 -1
- package/src/editor/markdown-api.ts +105 -156
- package/src/editor/milkdown-mdx-plugin.tsx +269 -0
- package/src/editor/signals.ts +206 -13
- package/src/editor/types.ts +52 -1
- package/src/handlers/api-routes.ts +251 -0
- package/src/handlers/component-ops.ts +2 -18
- package/src/handlers/markdown-ops.ts +202 -47
- package/src/handlers/page-ops.ts +229 -0
- package/src/handlers/redirect-ops.ts +163 -0
- package/src/handlers/source-writer.ts +157 -1
- package/src/html-processor.ts +14 -2
- package/src/index.ts +78 -14
- package/src/manifest-writer.ts +19 -1
- package/src/media/contember.ts +2 -1
- package/src/media/local.ts +66 -28
- package/src/media/project-images.ts +81 -0
- package/src/media/s3.ts +32 -11
- package/src/media/types.ts +24 -2
- package/src/shared.ts +27 -0
- package/src/source-finder/collection-finder.ts +219 -41
- package/src/source-finder/index.ts +7 -1
- package/src/source-finder/search-index.ts +178 -36
- package/src/source-finder/snippet-utils.ts +423 -3
- package/src/types.ts +111 -2
- 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
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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<{
|
|
117
|
-
|
|
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
|
-
|
|
139
|
-
*/
|
|
52
|
+
// Media operations
|
|
53
|
+
|
|
140
54
|
export async function fetchMediaLibrary(
|
|
141
55
|
config: CmsConfig,
|
|
142
|
-
cursor?: string,
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
if (
|
|
147
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|