@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
package/src/editor/api.ts CHANGED
@@ -4,7 +4,6 @@ import { setAvailableTextStyles } from './text-styling'
4
4
  import type {
5
5
  CmsManifest,
6
6
  ComponentInsertOperation,
7
- DeploymentStatusResponse,
8
7
  SaveBatchRequest,
9
8
  SaveBatchResponse,
10
9
  UpdateMarkdownPageRequest,
@@ -20,68 +19,6 @@ export interface GetMarkdownContentResponse {
20
19
  filePath: string
21
20
  }
22
21
 
23
- /**
24
- * Types for CMS AI chat communication (SSE events)
25
- */
26
- export interface CmsAiChatRequest {
27
- prompt: string
28
- elementId?: string
29
- currentContent?: string
30
- pageUrl: string
31
- /** Source file context for the element being edited */
32
- context?: string
33
- sessionId?: string
34
- }
35
-
36
- export type CmsAiEvent =
37
- | CmsAiTokenEvent
38
- | CmsAiStatusEvent
39
- | CmsAiActionEvent
40
- | CmsAiErrorEvent
41
- | CmsAiDoneEvent
42
-
43
- export interface CmsAiTokenEvent {
44
- type: 'token'
45
- token: string
46
- fullText: string
47
- }
48
-
49
- export interface CmsAiStatusEvent {
50
- type: 'status'
51
- status: 'thinking' | 'coding' | 'building' | 'deploying' | 'complete'
52
- message?: string
53
- }
54
-
55
- export interface CmsAiActionEvent {
56
- type: 'action'
57
- action: CmsAiAction
58
- }
59
-
60
- export type CmsAiAction =
61
- | { name: 'refresh' }
62
- | { name: 'preview'; url: string }
63
- | { name: 'commit'; sha: string; message: string }
64
- | { name: 'apply-edit'; elementId: string; content: string; htmlContent?: string }
65
-
66
- export interface CmsAiErrorEvent {
67
- type: 'error'
68
- error: string
69
- code?: string
70
- }
71
-
72
- export interface CmsAiDoneEvent {
73
- type: 'done'
74
- summary?: string
75
- }
76
-
77
- export interface CmsAiStreamCallbacks {
78
- onToken?: (token: string, fullText: string) => void
79
- onStatus?: (status: CmsAiStatusEvent['status'], message?: string) => void
80
- onAction?: (action: CmsAiAction) => void
81
- onError?: (error: string, code?: string) => void
82
- onDone?: (summary?: string) => void
83
- }
84
-
85
22
  /**
86
23
  * Get the manifest URL for the current page
87
24
  * For example: /about -> /about.json
@@ -325,194 +262,6 @@ export async function removeComponent(
325
262
  return res.json()
326
263
  }
327
264
 
328
- /**
329
- * Parse SSE data line to event
330
- */
331
- export function parseSseEvent(data: string): CmsAiEvent | null {
332
- if (data === '[DONE]') {
333
- return { type: 'done' }
334
- }
335
- try {
336
- return JSON.parse(data) as CmsAiEvent
337
- } catch {
338
- return null
339
- }
340
- }
341
-
342
- /**
343
- * Stream AI chat response from the server using SSE
344
- * Provides rich events including status updates, actions (refresh, preview), and streaming tokens
345
- */
346
- export async function streamAiChat(
347
- apiBase: string,
348
- request: CmsAiChatRequest,
349
- callbacks: CmsAiStreamCallbacks,
350
- abortSignal?: AbortSignal,
351
- ): Promise<void> {
352
- const controller = new AbortController()
353
- const timeoutId = setTimeout(() => controller.abort(), API.AI_STREAM_TIMEOUT_MS)
354
-
355
- // Allow external abort signal to also abort the request
356
- if (abortSignal) {
357
- abortSignal.addEventListener('abort', () => controller.abort())
358
- }
359
-
360
- let doneCalled = false
361
-
362
- try {
363
- const res = await fetch(`${apiBase}/ai/chat`, {
364
- method: 'POST',
365
- credentials: 'include',
366
- headers: {
367
- 'Content-Type': 'application/json',
368
- },
369
- body: JSON.stringify(request),
370
- signal: controller.signal,
371
- })
372
-
373
- if (!res.ok) {
374
- const text = await res.text().catch(() => '')
375
- callbacks.onError?.(text || `Request failed (${res.status})`, String(res.status))
376
- return
377
- }
378
-
379
- if (!res.body) {
380
- callbacks.onError?.('No response body')
381
- return
382
- }
383
-
384
- const reader = res.body.getReader()
385
- const decoder = new TextDecoder()
386
- let buffer = ''
387
-
388
- while (true) {
389
- const { done, value } = await reader.read()
390
-
391
- if (done) {
392
- break
393
- }
394
-
395
- buffer += decoder.decode(value, { stream: true })
396
- const lines = buffer.split('\n')
397
-
398
- // Keep the last incomplete line in the buffer
399
- buffer = lines.pop() || ''
400
-
401
- for (const line of lines) {
402
- if (line.startsWith('data: ')) {
403
- const data = line.slice(6).trim()
404
- if (!data) continue
405
-
406
- const event = parseSseEvent(data)
407
- if (!event) continue
408
-
409
- switch (event.type) {
410
- case 'token':
411
- callbacks.onToken?.(event.token, event.fullText)
412
- break
413
- case 'status':
414
- callbacks.onStatus?.(event.status, event.message)
415
- break
416
- case 'action':
417
- callbacks.onAction?.(event.action)
418
- break
419
- case 'error':
420
- callbacks.onError?.(event.error, event.code)
421
- break
422
- case 'done':
423
- if (!doneCalled) {
424
- doneCalled = true
425
- callbacks.onDone?.(event.summary)
426
- }
427
- return
428
- }
429
- }
430
- }
431
- }
432
-
433
- // Process any remaining buffer
434
- if (buffer.startsWith('data: ')) {
435
- const data = buffer.slice(6).trim()
436
- if (data) {
437
- const event = parseSseEvent(data)
438
- if (event?.type === 'done' && !doneCalled) {
439
- doneCalled = true
440
- callbacks.onDone?.(event.summary)
441
- }
442
- }
443
- }
444
-
445
- if (!doneCalled) {
446
- doneCalled = true
447
- callbacks.onDone?.()
448
- }
449
- } catch (error) {
450
- if (error instanceof Error && error.name === 'AbortError') {
451
- callbacks.onError?.('Request timed out or was cancelled', 'TIMEOUT')
452
- return
453
- }
454
- callbacks.onError?.(error instanceof Error ? error.message : 'Unknown error')
455
- } finally {
456
- clearTimeout(timeoutId)
457
- }
458
- }
459
-
460
- /**
461
- * Chat message type from history endpoint
462
- */
463
- export interface ChatHistoryMessage {
464
- id: string
465
- role: 'user' | 'assistant' | 'tool'
466
- content: string | null
467
- created_at: string
468
- channel?: string
469
- identifier?: string
470
- }
471
-
472
- export interface ChatHistoryResponse {
473
- messages: ChatHistoryMessage[]
474
- hasMore: boolean
475
- }
476
-
477
- /**
478
- * Fetch AI chat history for the current project
479
- */
480
- export async function getChatHistory(
481
- apiBase: string,
482
- limit = 50,
483
- ): Promise<ChatHistoryResponse> {
484
- const res = await fetchWithTimeout(`${apiBase}/ai/chat/history?limit=${limit}`, {
485
- method: 'GET',
486
- credentials: 'include',
487
- })
488
-
489
- if (!res.ok) {
490
- const text = await res.text().catch(() => '')
491
- throw new Error(`Failed to fetch chat history (${res.status}): ${text || res.statusText}`)
492
- }
493
-
494
- return res.json()
495
- }
496
-
497
- /**
498
- * Fetch deployment status for the current project
499
- */
500
- export async function getDeploymentStatus(
501
- apiBase: string,
502
- ): Promise<DeploymentStatusResponse> {
503
- const res = await fetchWithTimeout(`${apiBase}/deployment/status`, {
504
- method: 'GET',
505
- credentials: 'include',
506
- })
507
-
508
- if (!res.ok) {
509
- const text = await res.text().catch(() => '')
510
- throw new Error(`Failed to fetch deployment status (${res.status}): ${text || res.statusText}`)
511
- }
512
-
513
- return res.json()
514
- }
515
-
516
265
  /**
517
266
  * Fetch markdown content from a file
518
267
  */
@@ -83,18 +83,18 @@ export function ImageField({ label, value, placeholder, onChange, onBrowse, isDi
83
83
  const hasImage = !!value && value.length > 0
84
84
 
85
85
  return (
86
- <div class="space-y-1.5">
86
+ <div class="space-y-1.5 min-w-0">
87
87
  <FieldLabel label={label} isDirty={isDirty} onReset={onReset} />
88
88
  {hasImage && (
89
89
  <div
90
- class="relative w-full h-24 rounded-cms-sm overflow-hidden bg-white/5 border border-white/10 cursor-pointer group"
90
+ class="relative w-full rounded-cms-sm overflow-hidden bg-white/5 border border-white/10 cursor-pointer group"
91
91
  onClick={onBrowse}
92
92
  data-cms-ui
93
93
  >
94
94
  <img
95
95
  src={value}
96
96
  alt={label}
97
- class="w-full h-full object-cover"
97
+ class="w-full h-auto max-h-48"
98
98
  onError={(e) => {
99
99
  ;(e.target as HTMLImageElement).style.display = 'none'
100
100
  }}
@@ -104,14 +104,14 @@ export function ImageField({ label, value, placeholder, onChange, onBrowse, isDi
104
104
  </div>
105
105
  </div>
106
106
  )}
107
- <div class="flex gap-2">
107
+ <div class="flex gap-2 min-w-0">
108
108
  <input
109
109
  type="text"
110
110
  value={value ?? ''}
111
111
  placeholder={placeholder}
112
112
  onInput={(e) => onChange((e.target as HTMLInputElement).value)}
113
113
  class={cn(
114
- 'flex-1 px-3 py-2 bg-white/10 border rounded-cms-sm text-sm text-white placeholder:text-white/40 focus:outline-none focus:ring-1 transition-colors',
114
+ 'flex-1 min-w-0 px-3 py-2 bg-white/10 border rounded-cms-sm text-sm text-white placeholder:text-white/40 focus:outline-none focus:ring-1 transition-colors',
115
115
  isDirty
116
116
  ? 'border-cms-primary focus:border-cms-primary focus:ring-cms-primary/30'
117
117
  : 'border-white/20 focus:border-white/40 focus:ring-white/10',
@@ -121,7 +121,7 @@ export function ImageField({ label, value, placeholder, onChange, onBrowse, isDi
121
121
  <button
122
122
  type="button"
123
123
  onClick={onBrowse}
124
- class="px-3 py-2 bg-white/10 hover:bg-white/20 border border-white/20 rounded-cms-sm text-sm text-white transition-colors cursor-pointer"
124
+ class="shrink-0 px-3 py-2 bg-white/10 hover:bg-white/20 border border-white/20 rounded-cms-sm text-sm text-white transition-colors cursor-pointer"
125
125
  data-cms-ui
126
126
  >
127
127
  Browse
@@ -3,7 +3,6 @@ import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
3
3
  import { slugify } from '../../shared'
4
4
  import { updateMarkdownPage } from '../api'
5
5
  import { STORAGE_KEYS, Z_INDEX } from '../constants'
6
- import { startDeploymentPolling } from '../editor'
7
6
  import { createMarkdownPage } from '../markdown-api'
8
7
  import {
9
8
  config,
@@ -50,6 +49,7 @@ export function MarkdownEditorOverlay() {
50
49
  // Preview mode state
51
50
  const [isPreview, setIsPreview] = useState(false)
52
51
  const originalHTMLRef = useRef<string | null>(null)
52
+ const previewTargetRef = useRef<HTMLElement | null>(null)
53
53
  const editorInstanceRef = useRef<Editor | null>(null)
54
54
 
55
55
  useEffect(() => {
@@ -58,23 +58,34 @@ export function MarkdownEditorOverlay() {
58
58
  }
59
59
  }, [isCreateMode, isDataCollection])
60
60
 
61
- const handleDeploymentComplete = useCallback(
62
- (status: 'completed' | 'failed' | 'timeout') => {
63
- if (status === 'failed') {
64
- showToast('Deployment failed', 'error')
65
- }
66
- },
67
- [],
68
- )
61
+ // Auto-generate slug from title in create mode (unless user manually edited the slug)
62
+ useEffect(() => {
63
+ if (!isCreateMode || slugManuallyEdited) return
64
+ const title = (page?.frontmatter.title as string) || (page?.frontmatter.name as string) || ''
65
+ if (!title) return
66
+ markdownEditorState.value = {
67
+ ...markdownEditorState.value,
68
+ currentPage: markdownEditorState.value.currentPage
69
+ ? { ...markdownEditorState.value.currentPage, slug: slugify(title), isDirty: true }
70
+ : null,
71
+ }
72
+ }, [isCreateMode, slugManuallyEdited, page?.frontmatter.title, page?.frontmatter.name])
73
+
74
+ /** Find the [data-cms-markdown] wrapper element on the actual page (not CMS UI). */
75
+ const findMarkdownWrapper = useCallback((): HTMLElement | null => {
76
+ const SKIP_TAGS = new Set(['BODY', 'HTML', 'BUTTON', 'SPAN', 'A'])
77
+ const candidates = document.querySelectorAll('[data-cms-markdown]:not([data-cms-ui])')
78
+ for (const c of candidates) {
79
+ if (!SKIP_TAGS.has(c.tagName)) return c as HTMLElement
80
+ }
81
+ return null
82
+ }, [])
69
83
 
70
84
  const restoreOriginalHTML = useCallback(() => {
71
- const activeId = markdownEditorState.value.activeElementId
72
- if (originalHTMLRef.current !== null && activeId) {
73
- const el = document.querySelector(`[data-cms-id="${activeId}"]`)
74
- if (el) {
75
- el.innerHTML = originalHTMLRef.current
76
- }
85
+ if (originalHTMLRef.current !== null && previewTargetRef.current) {
86
+ previewTargetRef.current.innerHTML = originalHTMLRef.current
77
87
  originalHTMLRef.current = null
88
+ previewTargetRef.current = null
78
89
  }
79
90
  }, [])
80
91
 
@@ -95,11 +106,8 @@ export function MarkdownEditorOverlay() {
95
106
  if (result.success) {
96
107
  // Keep the preview HTML in place so user sees changes immediately
97
108
  // If not in preview mode, inject editor HTML into the page element
98
- const activeId = markdownEditorState.value.activeElementId
99
- if (activeId && editorInstanceRef.current && !isPreview) {
100
- const el = document.querySelector(
101
- `[data-cms-id="${activeId}"]`,
102
- )
109
+ if (editorInstanceRef.current && !isPreview) {
110
+ const el = findMarkdownWrapper()
103
111
  if (el) {
104
112
  try {
105
113
  const view = editorInstanceRef.current.ctx.get(editorViewCtx)
@@ -115,14 +123,10 @@ export function MarkdownEditorOverlay() {
115
123
  isMarkdownPreview.value = false
116
124
  setIsSaving(false)
117
125
 
118
- showToast('Content saved, deploying...', 'success')
126
+ showToast('Content saved', 'success')
119
127
  // Clear pending entry navigation so editor doesn't auto-open after save
120
128
  sessionStorage.removeItem(STORAGE_KEYS.PENDING_ENTRY_NAVIGATION)
121
- // Close the editor immediately — the toolbar will show deploying state
122
129
  resetMarkdownEditorState()
123
- startDeploymentPolling(config.value, {
124
- onComplete: handleDeploymentComplete,
125
- })
126
130
  } else {
127
131
  showToast(result.error || 'Failed to save markdown', 'error')
128
132
  setIsSaving(false)
@@ -133,7 +137,7 @@ export function MarkdownEditorOverlay() {
133
137
  setIsSaving(false)
134
138
  }
135
139
  },
136
- [isSaving, isPreview, handleDeploymentComplete],
140
+ [isSaving, isPreview, findMarkdownWrapper],
137
141
  )
138
142
 
139
143
  const handleCreate = useCallback(async () => {
@@ -189,16 +193,11 @@ export function MarkdownEditorOverlay() {
189
193
  }
190
194
  }
191
195
 
192
- showToast('Page created, deploying...', 'success')
196
+ showToast('Page created', 'success')
193
197
  resetMarkdownEditorState()
194
- startDeploymentPolling(config.value, {
195
- onComplete: (status) => {
196
- handleDeploymentComplete(status)
197
- if (status === 'completed' && redirectUrl) {
198
- startRedirectCountdown(redirectUrl, title.trim())
199
- }
200
- },
201
- })
198
+ if (redirectUrl) {
199
+ startRedirectCountdown(redirectUrl, title.trim())
200
+ }
202
201
  } else {
203
202
  showToast(result.error || 'Failed to create page', 'error')
204
203
  }
@@ -208,26 +207,28 @@ export function MarkdownEditorOverlay() {
208
207
  } finally {
209
208
  setIsSaving(false)
210
209
  }
211
- }, [isSaving, handleDeploymentComplete])
210
+ }, [isSaving])
212
211
 
213
212
  const handlePreview = useCallback(() => {
214
213
  const activeId = markdownEditorState.value.activeElementId
215
214
  if (!editorInstanceRef.current || !activeId) return
216
215
 
217
216
  if (!isPreview) {
218
- // Enter preview
219
- const el = document.querySelector(`[data-cms-id="${activeId}"]`)
217
+ // Enter preview — inject editor HTML into the markdown wrapper element.
218
+ const el = findMarkdownWrapper()
220
219
  if (!el) {
221
220
  showToast('Could not find page element to preview', 'error')
222
221
  return
223
222
  }
224
223
  originalHTMLRef.current = el.innerHTML
224
+ previewTargetRef.current = el
225
225
  try {
226
226
  const view = editorInstanceRef.current.ctx.get(editorViewCtx)
227
227
  el.innerHTML = view.dom.innerHTML
228
228
  } catch (error) {
229
229
  console.error('Failed to get editor HTML for preview:', error)
230
230
  originalHTMLRef.current = null
231
+ previewTargetRef.current = null
231
232
  showToast('Failed to generate preview', 'error')
232
233
  return
233
234
  }
@@ -239,7 +240,7 @@ export function MarkdownEditorOverlay() {
239
240
  setIsPreview(false)
240
241
  isMarkdownPreview.value = false
241
242
  }
242
- }, [isPreview, restoreOriginalHTML])
243
+ }, [isPreview, restoreOriginalHTML, findMarkdownWrapper])
243
244
 
244
245
  const handleCancel = useCallback(() => {
245
246
  restoreOriginalHTML()
@@ -326,8 +327,8 @@ export function MarkdownEditorOverlay() {
326
327
  >
327
328
  {/* Header */}
328
329
  <div class="flex items-center justify-between px-5 py-4 border-b border-white/10">
329
- <div class="flex items-center gap-3">
330
- <div class="flex items-center text-white">
330
+ <div class="flex items-center gap-3 flex-1 min-w-0">
331
+ <div class="flex items-center text-white shrink-0">
331
332
  <svg
332
333
  width="20"
333
334
  height="20"
@@ -345,36 +346,11 @@ export function MarkdownEditorOverlay() {
345
346
  <line x1="10" y1="9" x2="8" y2="9" />
346
347
  </svg>
347
348
  </div>
348
- <div>
349
- <input
350
- type="text"
351
- value={(page.frontmatter.title as string) || (page.frontmatter.name as string) || ''}
352
- placeholder={isDataCollection ? 'Entry name...' : 'Page title...'}
353
- onInput={(e) => {
354
- const title = (e.target as HTMLInputElement).value
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 })
358
- // Auto-generate slug in create mode if not manually edited
359
- if (isCreateMode && !slugManuallyEdited) {
360
- markdownEditorState.value = {
361
- ...markdownEditorState.value,
362
- currentPage: markdownEditorState.value.currentPage
363
- ? {
364
- ...markdownEditorState.value.currentPage,
365
- slug: slugify(title),
366
- isDirty: true,
367
- }
368
- : null,
369
- }
370
- }
371
- }}
372
- class="text-base font-semibold text-white m-0 bg-transparent border-none outline-none placeholder-white/40 w-64"
373
- data-cms-ui
374
- />
375
- </div>
349
+ <span class="text-base font-semibold text-white truncate">
350
+ {(page.frontmatter.title as string) || (page.frontmatter.name as string) || (isDataCollection ? 'Entry name' : 'Page title')}
351
+ </span>
376
352
  </div>
377
- <div class="flex items-center gap-2">
353
+ <div class="flex items-center gap-2 shrink-0">
378
354
  {!isDataCollection && (
379
355
  <button
380
356
  type="button"