@nuasite/cms 0.18.1 → 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 (61) hide show
  1. package/dist/editor.js +52746 -36711
  2. package/package.json +16 -14
  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/types.ts +111 -2
  61. package/src/utils.ts +40 -4
@@ -2,6 +2,7 @@
2
2
  * Shadow DOM-based highlight overlay for CMS elements.
3
3
  * This component renders highlights without modifying the target element's styles.
4
4
  */
5
+ import { Z_INDEX } from '../constants'
5
6
 
6
7
  export interface HighlightState {
7
8
  color: string
@@ -38,7 +39,7 @@ class CmsHighlightOverlay extends HTMLElement {
38
39
  :host {
39
40
  position: absolute;
40
41
  pointer-events: none;
41
- z-index: 2147483645;
42
+ z-index: ${Z_INDEX.OVERLAY};
42
43
  box-sizing: border-box;
43
44
  }
44
45
 
@@ -167,7 +168,7 @@ export function initHighlightContainer(): void {
167
168
  width: 0;
168
169
  height: 0;
169
170
  pointer-events: none;
170
- z-index: 2147483645;
171
+ z-index: ${Z_INDEX.OVERLAY};
171
172
  `
172
173
  document.body.appendChild(highlightContainer)
173
174
  }
@@ -1,7 +1,8 @@
1
1
  import { type Editor, editorViewCtx } from '@milkdown/core'
2
2
  import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
3
+ import { slugify } from '../../shared'
3
4
  import { updateMarkdownPage } from '../api'
4
- import { STORAGE_KEYS } from '../constants'
5
+ import { STORAGE_KEYS, Z_INDEX } from '../constants'
5
6
  import { startDeploymentPolling } from '../editor'
6
7
  import { createMarkdownPage } from '../markdown-api'
7
8
  import {
@@ -14,7 +15,8 @@ import {
14
15
  startRedirectCountdown,
15
16
  updateMarkdownFrontmatter,
16
17
  } from '../signals'
17
- import { CreateModeFrontmatter, EditModeFrontmatter, slugify } from './frontmatter-fields'
18
+ import { CreateModeFrontmatter, EditModeFrontmatter } from './frontmatter-fields'
19
+ import { FrontmatterSidebar, partitionFields } from './frontmatter-sidebar'
18
20
  import { MarkdownInlineEditor } from './markdown-inline-editor'
19
21
 
20
22
  /**
@@ -28,8 +30,21 @@ export function MarkdownEditorOverlay() {
28
30
  const createOptions = editorState.createOptions
29
31
  const collectionDef = editorState.collectionDefinition
30
32
 
33
+ const activeCollectionDef = isCreateMode ? createOptions?.collectionDefinition : collectionDef
34
+ const { sidebar: sidebarFields, header: headerFields } = activeCollectionDef
35
+ ? partitionFields(activeCollectionDef.fields)
36
+ : { sidebar: [], header: [] }
37
+ const hasSidebar = sidebarFields.length > 0
38
+ const isDataCollection = activeCollectionDef?.type === 'data'
39
+ // Derive MDX mode from the actual file extension when available
40
+ // (a collection can have mixed .md and .mdx files),
41
+ // but fall back to the collection file extension in create mode before a file path exists.
42
+ const isMdx = page?.filePath
43
+ ? page.filePath.endsWith('.mdx')
44
+ : activeCollectionDef?.fileExtension === 'mdx'
45
+
31
46
  const [isSaving, setIsSaving] = useState(false)
32
- const [showFrontmatter, setShowFrontmatter] = useState(isCreateMode)
47
+ const [showFrontmatter, setShowFrontmatter] = useState(isCreateMode || isDataCollection)
33
48
  // Track whether the user has manually edited the slug (disables auto-slug from title)
34
49
  const [slugManuallyEdited, setSlugManuallyEdited] = useState(false)
35
50
  // Preview mode state
@@ -37,12 +52,11 @@ export function MarkdownEditorOverlay() {
37
52
  const originalHTMLRef = useRef<string | null>(null)
38
53
  const editorInstanceRef = useRef<Editor | null>(null)
39
54
 
40
- // Open metadata by default when entering create mode
41
55
  useEffect(() => {
42
- if (isCreateMode) {
56
+ if (isCreateMode || isDataCollection) {
43
57
  setShowFrontmatter(true)
44
58
  }
45
- }, [isCreateMode])
59
+ }, [isCreateMode, isDataCollection])
46
60
 
47
61
  const handleDeploymentComplete = useCallback(
48
62
  (status: 'completed' | 'failed' | 'timeout') => {
@@ -142,10 +156,13 @@ export function MarkdownEditorOverlay() {
142
156
 
143
157
  setIsSaving(true)
144
158
  try {
145
- // Build frontmatter excluding title and slug
159
+ const isData = opts.collectionDefinition.type === 'data'
160
+
161
+ // Build frontmatter — for data collections include all fields; for markdown exclude title
146
162
  const frontmatter: Record<string, unknown> = {}
147
163
  for (const [key, value] of Object.entries(currentPage.frontmatter)) {
148
- if (key !== 'title' && value !== undefined && value !== '') {
164
+ if (!isData && key === 'title') continue
165
+ if (value !== undefined && value !== '') {
149
166
  frontmatter[key] = value
150
167
  }
151
168
  }
@@ -155,7 +172,8 @@ export function MarkdownEditorOverlay() {
155
172
  title: title.trim(),
156
173
  slug,
157
174
  frontmatter,
158
- content: currentPage.content || '',
175
+ content: isData ? '' : (currentPage.content || ''),
176
+ fileExtension: opts.collectionDefinition.fileExtension,
159
177
  })
160
178
 
161
179
  if (result.success) {
@@ -237,7 +255,8 @@ export function MarkdownEditorOverlay() {
237
255
  if (isPreview) {
238
256
  return (
239
257
  <div
240
- class="fixed bottom-6 left-1/2 -translate-x-1/2 z-2147483647 flex items-center gap-3 px-5 py-3 bg-cms-dark/95 border border-white/15 rounded-cms-pill shadow-[0_8px_32px_rgba(0,0,0,0.4)] backdrop-blur-md"
258
+ style={{ zIndex: Z_INDEX.MODAL }}
259
+ class="fixed bottom-6 left-1/2 -translate-x-1/2 flex items-center gap-3 px-5 py-3 bg-cms-dark/95 border border-white/15 rounded-cms-pill shadow-[0_8px_32px_rgba(0,0,0,0.4)] backdrop-blur-md"
241
260
  data-cms-ui
242
261
  onMouseDown={stopPropagation}
243
262
  onClick={stopPropagation}
@@ -293,13 +312,16 @@ export function MarkdownEditorOverlay() {
293
312
 
294
313
  return (
295
314
  <div
296
- class="fixed inset-0 z-2147483647 bg-black/40 flex items-center justify-center p-4 backdrop-blur-md"
315
+ style={{ zIndex: Z_INDEX.MODAL }}
316
+ class="fixed inset-0 bg-black/40 flex items-center justify-center p-4 backdrop-blur-md"
297
317
  data-cms-ui
298
318
  onMouseDown={stopPropagation}
299
319
  onClick={stopPropagation}
300
320
  >
301
321
  <div
302
- class="bg-cms-dark rounded-cms-xl shadow-[0_8px_32px_rgba(0,0,0,0.4)] border border-white/10 w-full max-w-4xl max-h-[90vh] flex flex-col"
322
+ class={`bg-cms-dark rounded-cms-xl shadow-[0_8px_32px_rgba(0,0,0,0.4)] border border-white/10 w-full max-h-[90vh] flex flex-col ${
323
+ hasSidebar ? 'max-w-6xl' : 'max-w-4xl'
324
+ }`}
303
325
  data-cms-ui
304
326
  >
305
327
  {/* Header */}
@@ -326,11 +348,13 @@ export function MarkdownEditorOverlay() {
326
348
  <div>
327
349
  <input
328
350
  type="text"
329
- value={(page.frontmatter.title as string) || ''}
330
- placeholder="Page title..."
351
+ value={(page.frontmatter.title as string) || (page.frontmatter.name as string) || ''}
352
+ placeholder={isDataCollection ? 'Entry name...' : 'Page title...'}
331
353
  onInput={(e) => {
332
354
  const title = (e.target as HTMLInputElement).value
333
- updateMarkdownFrontmatter({ title })
355
+ // Data collections may use 'name' instead of 'title'
356
+ const titleField = isDataCollection && !('title' in page.frontmatter) && 'name' in page.frontmatter ? 'name' : 'title'
357
+ updateMarkdownFrontmatter({ [titleField]: title })
334
358
  // Auto-generate slug in create mode if not manually edited
335
359
  if (isCreateMode && !slugManuallyEdited) {
336
360
  markdownEditorState.value = {
@@ -351,49 +375,51 @@ export function MarkdownEditorOverlay() {
351
375
  </div>
352
376
  </div>
353
377
  <div class="flex items-center gap-2">
354
- <button
355
- type="button"
356
- onClick={() => setShowFrontmatter(!showFrontmatter)}
357
- class={`px-3 py-2 text-sm rounded-cms-pill transition-colors flex items-center gap-1.5 ${
358
- showFrontmatter
359
- ? 'bg-white/20 text-white'
360
- : 'text-white/70 hover:text-white hover:bg-white/10'
361
- }`}
362
- data-cms-ui
363
- >
364
- <svg
365
- class="w-4 h-4"
366
- fill="none"
367
- stroke="currentColor"
368
- viewBox="0 0 24 24"
369
- stroke-width="2"
370
- >
371
- <path
372
- stroke-linecap="round"
373
- stroke-linejoin="round"
374
- d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
375
- />
376
- <path
377
- stroke-linecap="round"
378
- stroke-linejoin="round"
379
- d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
380
- />
381
- </svg>
382
- Metadata
383
- <svg
384
- class={`w-3.5 h-3.5 transition-transform ${showFrontmatter ? 'rotate-180' : ''}`}
385
- fill="none"
386
- stroke="currentColor"
387
- viewBox="0 0 24 24"
388
- stroke-width="2.5"
378
+ {!isDataCollection && (
379
+ <button
380
+ type="button"
381
+ onClick={() => setShowFrontmatter(!showFrontmatter)}
382
+ class={`px-3 py-2 text-sm rounded-cms-pill transition-colors flex items-center gap-1.5 ${
383
+ showFrontmatter
384
+ ? 'bg-white/20 text-white'
385
+ : 'text-white/70 hover:text-white hover:bg-white/10'
386
+ }`}
387
+ data-cms-ui
389
388
  >
390
- <path
391
- stroke-linecap="round"
392
- stroke-linejoin="round"
393
- d="M19 9l-7 7-7-7"
394
- />
395
- </svg>
396
- </button>
389
+ <svg
390
+ class="w-4 h-4"
391
+ fill="none"
392
+ stroke="currentColor"
393
+ viewBox="0 0 24 24"
394
+ stroke-width="2"
395
+ >
396
+ <path
397
+ stroke-linecap="round"
398
+ stroke-linejoin="round"
399
+ d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
400
+ />
401
+ <path
402
+ stroke-linecap="round"
403
+ stroke-linejoin="round"
404
+ d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
405
+ />
406
+ </svg>
407
+ Metadata
408
+ <svg
409
+ class={`w-3.5 h-3.5 transition-transform ${showFrontmatter ? 'rotate-180' : ''}`}
410
+ fill="none"
411
+ stroke="currentColor"
412
+ viewBox="0 0 24 24"
413
+ stroke-width="2.5"
414
+ >
415
+ <path
416
+ stroke-linecap="round"
417
+ stroke-linejoin="round"
418
+ d="M19 9l-7 7-7-7"
419
+ />
420
+ </svg>
421
+ </button>
422
+ )}
397
423
  {!isCreateMode && editorState.activeElementId && (
398
424
  <button
399
425
  type="button"
@@ -467,37 +493,57 @@ export function MarkdownEditorOverlay() {
467
493
  </div>
468
494
  </div>
469
495
 
470
- {/* Frontmatter Editor */}
471
- {showFrontmatter && (
472
- <div class="px-5 py-4 border-b border-white/10 bg-white/5">
473
- {isCreateMode && createOptions
474
- ? (
475
- <CreateModeFrontmatter
476
- page={page}
477
- collectionDefinition={createOptions.collectionDefinition}
478
- onSlugManualEdit={() => setSlugManuallyEdited(true)}
479
- />
480
- )
481
- : (
482
- <EditModeFrontmatter
483
- page={page}
484
- collectionDefinition={collectionDef}
496
+ {/* Content area: main + optional sidebar */}
497
+ <div class="flex-1 min-h-0 flex">
498
+ {/* Main: frontmatter header + editor */}
499
+ <div class="flex-1 min-w-0 flex flex-col">
500
+ {/* Frontmatter Editor (header-positioned fields only) */}
501
+ {(showFrontmatter || isDataCollection) && (
502
+ <div class={`px-5 py-4 border-b border-white/10 bg-white/5 overflow-y-auto ${isDataCollection ? 'flex-1' : 'max-h-[40vh]'}`}>
503
+ {isCreateMode && createOptions
504
+ ? (
505
+ <CreateModeFrontmatter
506
+ page={page}
507
+ collectionDefinition={createOptions.collectionDefinition}
508
+ fields={headerFields}
509
+ onSlugManualEdit={() => setSlugManuallyEdited(true)}
510
+ />
511
+ )
512
+ : (
513
+ <EditModeFrontmatter
514
+ page={page}
515
+ collectionDefinition={collectionDef}
516
+ fields={headerFields}
517
+ />
518
+ )}
519
+ </div>
520
+ )}
521
+
522
+ {/* Editor — hidden for data collections (JSON/YAML have no body) */}
523
+ {!isDataCollection && (
524
+ <div class="flex-1 min-h-0 overflow-auto bg-black/20">
525
+ <MarkdownInlineEditor
526
+ elementId={page.slug || 'new-page'}
527
+ initialContent={page.content}
528
+ isMdx={isMdx}
529
+ onSave={isCreateMode ? () => handleCreate() : handleSave}
530
+ onCancel={handleCancel}
531
+ onEditorReady={(editor) => {
532
+ editorInstanceRef.current = editor
533
+ }}
485
534
  />
486
- )}
535
+ </div>
536
+ )}
487
537
  </div>
488
- )}
489
538
 
490
- {/* Editor */}
491
- <div class="flex-1 min-h-0 overflow-auto bg-black/20">
492
- <MarkdownInlineEditor
493
- elementId={page.slug || 'new-page'}
494
- initialContent={page.content}
495
- onSave={isCreateMode ? () => handleCreate() : handleSave}
496
- onCancel={handleCancel}
497
- onEditorReady={(editor) => {
498
- editorInstanceRef.current = editor
499
- }}
500
- />
539
+ {/* Sidebar (sidebar-positioned fields) */}
540
+ {hasSidebar && (
541
+ <FrontmatterSidebar
542
+ fields={sidebarFields}
543
+ page={page}
544
+ collectionDefinition={activeCollectionDef}
545
+ />
546
+ )}
501
547
  </div>
502
548
  </div>
503
549
  </div>
@@ -14,11 +14,16 @@ import { gfm, toggleStrikethroughCommand } from '@milkdown/preset-gfm'
14
14
  import { callCommand, insert, replaceAll } from '@milkdown/utils'
15
15
  import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
16
16
  import { uploadMedia } from '../markdown-api'
17
- import { config, openMediaLibraryWithCallback, resetMarkdownEditorState, showToast, updateMarkdownContent } from '../signals'
17
+ import { insertMdxComponentCommand, mdxComponentPlugin } from '../milkdown-mdx-plugin'
18
+ import { config, mdxComponentPickerOpen, openMediaLibraryWithCallback, resetMarkdownEditorState, showToast, updateMarkdownContent } from '../signals'
19
+ import { MdxComponentIcon } from './mdx-block-view'
20
+ import { MdxComponentPicker } from './mdx-component-picker'
21
+ import { MdxPropsEditor } from './mdx-props-editor'
18
22
 
19
23
  export interface MarkdownInlineEditorProps {
20
24
  elementId: string
21
25
  initialContent: string
26
+ isMdx?: boolean
22
27
  onSave: (content: string) => void
23
28
  onCancel: () => void
24
29
  onEditorReady?: (editor: Editor) => void
@@ -27,6 +32,7 @@ export interface MarkdownInlineEditorProps {
27
32
  export function MarkdownInlineEditor({
28
33
  elementId,
29
34
  initialContent,
35
+ isMdx,
30
36
  onSave,
31
37
  onCancel,
32
38
  onEditorReady,
@@ -69,6 +75,9 @@ export function MarkdownInlineEditor({
69
75
  // Store onEditorReady in ref to avoid re-initializing editor when callback changes
70
76
  const onEditorReadyRef = useRef(onEditorReady)
71
77
  onEditorReadyRef.current = onEditorReady
78
+ // Store isMdx in ref for editor initialization
79
+ const isMdxRef = useRef(isMdx ?? false)
80
+ isMdxRef.current = isMdx ?? false
72
81
 
73
82
  // Check active formatting at current selection
74
83
  const updateActiveFormats = useCallback(() => {
@@ -154,9 +163,11 @@ export function MarkdownInlineEditor({
154
163
  useEffect(() => {
155
164
  if (!editorRef.current) return
156
165
 
166
+ let formatRaf = 0
167
+
157
168
  const initEditor = async () => {
158
169
  try {
159
- const editor = await Editor.make()
170
+ const builder = Editor.make()
160
171
  .config((ctx) => {
161
172
  ctx.set(rootCtx, editorRef.current)
162
173
  ctx.set(defaultValueCtx, initialContentRef.current)
@@ -168,19 +179,29 @@ export function MarkdownInlineEditor({
168
179
  .use(commonmark)
169
180
  .use(gfm)
170
181
  .use(listener)
171
- .create()
182
+
183
+ // Add MDX component support for .mdx files
184
+ if (isMdxRef.current) {
185
+ for (const plugin of mdxComponentPlugin) {
186
+ builder.use(plugin as any)
187
+ }
188
+ }
189
+
190
+ const editor = await builder.create()
172
191
 
173
192
  editorInstanceRef.current = editor
174
193
  setIsReady(true)
175
194
  onEditorReadyRef.current?.(editor)
176
195
 
177
- // Set up selection change listener
196
+ // Set up selection change listener — debounce via rAF to avoid
197
+ // redundant mark-scanning on every keystroke
178
198
  const view = editor.ctx.get(editorViewCtx)
179
199
  const originalDispatch = view.dispatch.bind(view)
180
200
  view.dispatch = (tr) => {
181
201
  originalDispatch(tr)
182
202
  if (tr.selectionSet || tr.docChanged) {
183
- updateActiveFormats()
203
+ cancelAnimationFrame(formatRaf)
204
+ formatRaf = requestAnimationFrame(updateActiveFormats)
184
205
  }
185
206
  }
186
207
 
@@ -195,6 +216,7 @@ export function MarkdownInlineEditor({
195
216
  initEditor()
196
217
 
197
218
  return () => {
219
+ cancelAnimationFrame(formatRaf)
198
220
  editorInstanceRef.current?.destroy()
199
221
  editorInstanceRef.current = null
200
222
  }
@@ -360,6 +382,38 @@ export function MarkdownInlineEditor({
360
382
  }
361
383
  }, [])
362
384
 
385
+ // MDX component insertion
386
+ const handleInsertMdxComponent = useCallback((componentName: string, props: Record<string, string>) => {
387
+ if (editorInstanceRef.current) {
388
+ try {
389
+ editorInstanceRef.current.action(callCommand(insertMdxComponentCommand.key, { componentName, props }))
390
+ } catch (error) {
391
+ console.error('Failed to insert MDX component:', error)
392
+ }
393
+ }
394
+ }, [])
395
+
396
+ const handleOpenMdxPicker = useCallback(() => {
397
+ mdxComponentPickerOpen.value = true
398
+ }, [])
399
+
400
+ const handleUpdateMdxProps = useCallback((nodePos: number, props: Record<string, string>) => {
401
+ if (!editorInstanceRef.current) return
402
+ try {
403
+ const view = editorInstanceRef.current.ctx.get(editorViewCtx)
404
+ const node = view.state.doc.nodeAt(nodePos)
405
+ if (node && node.type.name === 'mdx_component') {
406
+ const tr = view.state.tr.setNodeMarkup(nodePos, undefined, {
407
+ ...node.attrs,
408
+ props: JSON.stringify(props),
409
+ })
410
+ view.dispatch(tr)
411
+ }
412
+ } catch (error) {
413
+ console.error('Failed to update MDX component props:', error)
414
+ }
415
+ }, [])
416
+
363
417
  // Drag and drop handlers for direct image upload
364
418
  const handleDragOver = useCallback((e: DragEvent) => {
365
419
  e.preventDefault()
@@ -682,6 +736,15 @@ export function MarkdownInlineEditor({
682
736
  />
683
737
  </svg>
684
738
  </ToolbarButton>
739
+ {isMdx && (
740
+ <>
741
+ {/* Divider */}
742
+ <div class="w-px h-5 bg-white/20 mx-1" />
743
+ <ToolbarButton onClick={handleOpenMdxPicker} title="Insert Component">
744
+ <MdxComponentIcon size="md" />
745
+ </ToolbarButton>
746
+ </>
747
+ )}
685
748
  </div>
686
749
  </div>
687
750
 
@@ -745,6 +808,12 @@ export function MarkdownInlineEditor({
745
808
  </div>
746
809
  )}
747
810
  </div>
811
+
812
+ {/* MDX Component Picker */}
813
+ {isMdx && <MdxComponentPicker onInsert={handleInsertMdxComponent} />}
814
+
815
+ {/* MDX Props Editor */}
816
+ {isMdx && <MdxPropsEditor onUpdateProps={handleUpdateMdxProps} />}
748
817
  </div>
749
818
  )
750
819
  }
@@ -0,0 +1,102 @@
1
+ import { MDX_EXPR_PREFIX } from '../milkdown-mdx-plugin'
2
+
3
+ const MDX_COMPONENT_ICON_PATH =
4
+ 'M14 10l-2 1m0 0l-2-1m2 1v2.5M20 7l-2 1m2-1l-2-1m2 1v2.5M14 4l-2-1-2 1M4 7l2-1M4 7l2 1M4 7v2.5M12 21l-2-1m2 1l2-1m-2 1v-2.5M6 18l-2-1v-2.5M18 18l2-1v-2.5'
5
+
6
+ export function MdxComponentIcon({ size = 'sm' }: { size?: 'sm' | 'md' }) {
7
+ const iconClass = size === 'md' ? 'w-4 h-4' : 'w-3 h-3'
8
+ const svg = (
9
+ <svg class={`${iconClass} text-cms-primary`} fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
10
+ <path stroke-linecap="round" stroke-linejoin="round" d={MDX_COMPONENT_ICON_PATH} />
11
+ </svg>
12
+ )
13
+ if (size === 'md') return svg
14
+ return (
15
+ <div class="w-5 h-5 rounded bg-cms-primary/20 flex items-center justify-center">
16
+ {svg}
17
+ </div>
18
+ )
19
+ }
20
+
21
+ export interface MdxBlockCardProps {
22
+ componentName: string
23
+ props: Record<string, string>
24
+ hasExpressions: boolean
25
+ onEdit: (cursorPos: { x: number; y: number }) => void
26
+ onRemove: () => void
27
+ }
28
+
29
+ export function MdxBlockCard({ componentName, props, hasExpressions, onEdit, onRemove }: MdxBlockCardProps) {
30
+ const propEntries = Object.entries(props).filter(([_, v]) => v !== '')
31
+ const displayProps = propEntries.map(([name, value]) => {
32
+ if (value.startsWith(MDX_EXPR_PREFIX)) {
33
+ return { name, value: value.slice(MDX_EXPR_PREFIX.length), isExpression: true }
34
+ }
35
+ return { name, value, isExpression: false }
36
+ })
37
+
38
+ return (
39
+ <div
40
+ class="my-3 mx-0 bg-white/5 border border-white/15 rounded-cms-md overflow-hidden select-none"
41
+ data-cms-ui
42
+ >
43
+ <div class="flex items-center justify-between px-4 py-2.5 bg-white/5 border-b border-white/10">
44
+ <div class="flex items-center gap-2">
45
+ <MdxComponentIcon />
46
+ <span class="text-[13px] font-semibold text-white">{componentName}</span>
47
+ {hasExpressions && <span class="text-[10px] px-1.5 py-0.5 bg-amber-500/20 text-amber-300 rounded font-mono">expr</span>}
48
+ </div>
49
+ <div class="flex items-center gap-1">
50
+ <button
51
+ type="button"
52
+ data-mdx-action="edit"
53
+ onClick={(e: MouseEvent) => {
54
+ const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
55
+ onEdit({ x: rect.left, y: rect.bottom + 4 })
56
+ }}
57
+ class="p-1.5 rounded-cms-sm text-white/50 hover:text-white hover:bg-white/10 transition-colors"
58
+ title="Edit props"
59
+ >
60
+ <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
61
+ <path
62
+ stroke-linecap="round"
63
+ stroke-linejoin="round"
64
+ d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
65
+ />
66
+ </svg>
67
+ </button>
68
+ <button
69
+ type="button"
70
+ data-mdx-action="remove"
71
+ onClick={onRemove}
72
+ class="p-1.5 rounded-cms-sm text-white/50 hover:text-red-400 hover:bg-red-500/10 transition-colors"
73
+ title="Remove block"
74
+ >
75
+ <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
76
+ <path
77
+ stroke-linecap="round"
78
+ stroke-linejoin="round"
79
+ d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
80
+ />
81
+ </svg>
82
+ </button>
83
+ </div>
84
+ </div>
85
+
86
+ {displayProps.length > 0 && (
87
+ <div class="px-4 py-2 flex flex-wrap gap-x-3 gap-y-1">
88
+ {displayProps.slice(0, 6).map(({ name, value, isExpression }) => (
89
+ <span key={name} class="text-[11px] text-white/40 font-mono">
90
+ <span class="text-white/60">{name}</span>
91
+ <span class="text-white/30">=</span>
92
+ {isExpression
93
+ ? <span class="text-amber-300/60">{`{${value.length > 20 ? value.slice(0, 20) + '...' : value}}`}</span>
94
+ : <span class="text-cms-primary/60">"{value.length > 25 ? value.slice(0, 25) + '...' : value}"</span>}
95
+ </span>
96
+ ))}
97
+ {displayProps.length > 6 && <span class="text-[11px] text-white/30">+{displayProps.length - 6} more</span>}
98
+ </div>
99
+ )}
100
+ </div>
101
+ )
102
+ }