@nuasite/cms 0.20.1 → 0.20.4

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/package.json CHANGED
@@ -14,7 +14,7 @@
14
14
  "directory": "packages/astro-cms"
15
15
  },
16
16
  "license": "Apache-2.0",
17
- "version": "0.20.1",
17
+ "version": "0.20.4",
18
18
  "module": "src/index.ts",
19
19
  "types": "src/index.ts",
20
20
  "type": "module",
@@ -111,12 +111,11 @@ export function createDevMiddleware(
111
111
 
112
112
  const route = url.replace('/_nua/cms/', '').split('?')[0]!
113
113
 
114
- handleCmsApiRoute(route, req, res, manifestWriter, config.contentDir, options.mediaAdapter).catch(
115
- (error) => {
114
+ handleCmsApiRoute(route, req, res, manifestWriter, config.contentDir, options.mediaAdapter)
115
+ .catch((error) => {
116
116
  console.error('[astro-cms] API error:', error)
117
117
  sendError(res, 'Internal server error', 500)
118
- },
119
- )
118
+ })
120
119
  })
121
120
  }
122
121
 
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
  */
@@ -2,8 +2,7 @@ import { type Editor, editorViewCtx } from '@milkdown/core'
2
2
  import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
3
3
  import { slugify } from '../../shared'
4
4
  import { updateMarkdownPage } from '../api'
5
- import { STORAGE_KEYS, Z_INDEX } from '../constants'
6
- import { startDeploymentPolling } from '../editor'
5
+ import { schedulePageReload, STORAGE_KEYS, Z_INDEX } from '../constants'
7
6
  import { createMarkdownPage } from '../markdown-api'
8
7
  import {
9
8
  config,
@@ -72,15 +71,6 @@ export function MarkdownEditorOverlay() {
72
71
  }
73
72
  }, [isCreateMode, slugManuallyEdited, page?.frontmatter.title, page?.frontmatter.name])
74
73
 
75
- const handleDeploymentComplete = useCallback(
76
- (status: 'completed' | 'failed' | 'timeout') => {
77
- if (status === 'failed') {
78
- showToast('Deployment failed', 'error')
79
- }
80
- },
81
- [],
82
- )
83
-
84
74
  /** Find the [data-cms-markdown] wrapper element on the actual page (not CMS UI). */
85
75
  const findMarkdownWrapper = useCallback((): HTMLElement | null => {
86
76
  const SKIP_TAGS = new Set(['BODY', 'HTML', 'BUTTON', 'SPAN', 'A'])
@@ -133,14 +123,12 @@ export function MarkdownEditorOverlay() {
133
123
  isMarkdownPreview.value = false
134
124
  setIsSaving(false)
135
125
 
136
- showToast('Content saved, deploying...', 'success')
126
+ showToast('Content saved', 'success')
137
127
  // Clear pending entry navigation so editor doesn't auto-open after save
138
128
  sessionStorage.removeItem(STORAGE_KEYS.PENDING_ENTRY_NAVIGATION)
139
- // Close the editor immediately — the toolbar will show deploying state
140
129
  resetMarkdownEditorState()
141
- startDeploymentPolling(config.value, {
142
- onComplete: handleDeploymentComplete,
143
- })
130
+
131
+ schedulePageReload()
144
132
  } else {
145
133
  showToast(result.error || 'Failed to save markdown', 'error')
146
134
  setIsSaving(false)
@@ -151,7 +139,7 @@ export function MarkdownEditorOverlay() {
151
139
  setIsSaving(false)
152
140
  }
153
141
  },
154
- [isSaving, isPreview, handleDeploymentComplete, findMarkdownWrapper],
142
+ [isSaving, isPreview, findMarkdownWrapper],
155
143
  )
156
144
 
157
145
  const handleCreate = useCallback(async () => {
@@ -207,16 +195,11 @@ export function MarkdownEditorOverlay() {
207
195
  }
208
196
  }
209
197
 
210
- showToast('Page created, deploying...', 'success')
198
+ showToast('Page created', 'success')
211
199
  resetMarkdownEditorState()
212
- startDeploymentPolling(config.value, {
213
- onComplete: (status) => {
214
- handleDeploymentComplete(status)
215
- if (status === 'completed' && redirectUrl) {
216
- startRedirectCountdown(redirectUrl, title.trim())
217
- }
218
- },
219
- })
200
+ if (redirectUrl) {
201
+ startRedirectCountdown(redirectUrl, title.trim())
202
+ }
220
203
  } else {
221
204
  showToast(result.error || 'Failed to create page', 'error')
222
205
  }
@@ -226,7 +209,7 @@ export function MarkdownEditorOverlay() {
226
209
  } finally {
227
210
  setIsSaving(false)
228
211
  }
229
- }, [isSaving, handleDeploymentComplete])
212
+ }, [isSaving])
230
213
 
231
214
  const handlePreview = useCallback(() => {
232
215
  const activeId = markdownEditorState.value.activeElementId
@@ -1,5 +1,5 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks'
2
- import { clampPanelPosition, Z_INDEX } from '../constants'
2
+ import { clampPanelPosition, schedulePageReload, Z_INDEX } from '../constants'
3
3
  import { updateMarkdownPage } from '../markdown-api'
4
4
  import { closeReferencePicker, config, manifest, referencePickerState, showToast } from '../signals'
5
5
 
@@ -57,6 +57,7 @@ export function ReferencePicker() {
57
57
  })
58
58
  if (result.success) {
59
59
  showToast('Reference updated', 'success')
60
+ schedulePageReload()
60
61
  } else {
61
62
  showToast(result.error || 'Failed to update reference', 'error')
62
63
  }
@@ -1,5 +1,6 @@
1
1
  import { useCallback, useState } from 'preact/hooks'
2
2
  import { saveBatchChanges } from '../api'
3
+ import { schedulePageReload } from '../constants'
3
4
  import { isApplyingUndoRedo, recordChange } from '../history'
4
5
  import {
5
6
  clearPendingSeoChanges,
@@ -226,6 +227,7 @@ export function SeoEditor() {
226
227
  showToast(`Saved ${result.updated} SEO change(s) successfully!`, 'success')
227
228
  clearPendingSeoChanges()
228
229
  closeSeoEditor()
230
+ schedulePageReload()
229
231
  }
230
232
  } catch (error) {
231
233
  showToast(error instanceof Error ? error.message : 'Failed to save SEO changes', 'error')
@@ -13,7 +13,6 @@ export interface ToolbarCallbacks {
13
13
  onDiscard: () => void
14
14
  onSelectElement?: () => void
15
15
  onMediaLibrary?: () => void
16
- onDismissDeployment?: () => void
17
16
  onNavigateChange?: () => void
18
17
  onEditContent?: () => void
19
18
  onToggleHighlights?: () => void
@@ -39,80 +38,11 @@ const GridIcon = () => (
39
38
  </svg>
40
39
  )
41
40
 
42
- const DeploymentStatusIndicator = ({ onDismiss }: { onDismiss?: () => void }) => {
43
- const deploymentStatus = signals.deploymentStatus.value
44
- const lastDeployedAt = signals.lastDeployedAt.value
45
-
46
- if (!deploymentStatus) {
47
- return null
48
- }
49
-
50
- const isActive = deploymentStatus === 'pending' || deploymentStatus === 'queued' || deploymentStatus === 'running'
51
- const isCompleted = deploymentStatus === 'completed'
52
- const isFailed = deploymentStatus === 'failed'
53
-
54
- if (!isActive && !isCompleted && !isFailed) {
55
- return null
56
- }
57
-
58
- const formatTimeAgo = (dateStr: string) => {
59
- const date = new Date(dateStr)
60
- const now = new Date()
61
- const diffMs = now.getTime() - date.getTime()
62
- const diffSec = Math.floor(diffMs / 1000)
63
- const diffMin = Math.floor(diffSec / 60)
64
-
65
- if (diffMin < 1) return 'just now'
66
- if (diffMin === 1) return '1m ago'
67
- if (diffMin < 60) return `${diffMin}m ago`
68
- const diffHour = Math.floor(diffMin / 60)
69
- if (diffHour === 1) return '1h ago'
70
- return `${diffHour}h ago`
71
- }
72
-
73
- return (
74
- <div
75
- class={cn(
76
- 'flex items-center gap-1.5 sm:gap-2 px-3 py-2 sm:px-5 sm:py-2.5 text-sm font-medium rounded-cms-pill transition-all',
77
- isActive && 'text-white/80',
78
- isCompleted && 'bg-cms-primary text-cms-primary-text',
79
- isFailed && 'bg-cms-error/20 text-cms-error cursor-pointer hover:bg-cms-error/30',
80
- )}
81
- onClick={isFailed ? onDismiss : undefined}
82
- title={isFailed ? 'Click to dismiss' : undefined}
83
- >
84
- {isActive && (
85
- <>
86
- <span class="inline-block w-3.5 h-3.5 border-2 border-white/80 border-t-transparent rounded-full animate-spin" />
87
- <span class="hidden sm:inline">Deploying</span>
88
- </>
89
- )}
90
- {isCompleted && (
91
- <>
92
- <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
93
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 13l4 4L19 7" />
94
- </svg>
95
- <span class="hidden sm:inline">Live{lastDeployedAt ? ` ${formatTimeAgo(lastDeployedAt)}` : ''}</span>
96
- </>
97
- )}
98
- {isFailed && (
99
- <>
100
- <svg class="w-4 h-4 sm:hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
101
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
102
- </svg>
103
- <span class="hidden sm:inline">Failed</span>
104
- </>
105
- )}
106
- </div>
107
- )
108
- }
109
-
110
41
  export const Toolbar = ({ callbacks, collectionDefinitions }: ToolbarProps) => {
111
42
  const isEditing = signals.isEditing.value
112
43
  const showingOriginal = signals.showingOriginal.value
113
44
  const dirtyCount = signals.totalDirtyCount.value
114
45
  const isSaving = signals.isSaving.value
115
- const deploymentStatus = signals.deploymentStatus.value
116
46
  const showEditableHighlights = signals.showEditableHighlights.value
117
47
  const isPreviewingMarkdown = signals.isMarkdownPreview.value
118
48
  const currentPageCollection = signals.currentPageCollection.value
@@ -123,8 +53,6 @@ export const Toolbar = ({ callbacks, collectionDefinitions }: ToolbarProps) => {
123
53
 
124
54
  if (isPreviewingMarkdown) return null
125
55
 
126
- const showDeploymentStatus = deploymentStatus !== null
127
-
128
56
  const stopPropagation = (e: Event) => e.stopPropagation()
129
57
 
130
58
  const handleDiscard = async () => {
@@ -298,9 +226,6 @@ export const Toolbar = ({ callbacks, collectionDefinitions }: ToolbarProps) => {
298
226
 
299
227
  {/* Primary actions group */}
300
228
  <div class="flex items-center gap-2 sm:gap-1.5">
301
- {/* Deployment Status */}
302
- {showDeploymentStatus && <DeploymentStatusIndicator onDismiss={callbacks.onDismissDeployment} />}
303
-
304
229
  {/* Saving indicator */}
305
230
  {isSaving && !showingOriginal && (
306
231
  <div class="flex items-center gap-1.5 px-3 py-2 sm:px-5 sm:py-2.5 text-sm font-medium text-white/80">
@@ -43,8 +43,21 @@ export const TIMING = {
43
43
  PREVIEW_ERROR_DURATION_MS: 5000,
44
44
  /** Delay before focusing input after expansion (ms) */
45
45
  FOCUS_DELAY_MS: 50,
46
+ /** Delay before reloading the page after a content-modifying save (ms) */
47
+ RELOAD_DELAY_MS: 100,
48
+ /** Longer reload delay to allow collapse animation to play (ms) */
49
+ RELOAD_COLLAPSE_DELAY_MS: 300,
46
50
  } as const
47
51
 
52
+ /**
53
+ * Schedule a page reload after a content-modifying save.
54
+ * In normal dev, Vite HMR (via chokidar) usually reloads the page before this fires.
55
+ * In sandboxed environments (e.g. E2B) where HMR is unavailable, this ensures the page still refreshes.
56
+ */
57
+ export function schedulePageReload(delayMs: number = TIMING.RELOAD_DELAY_MS) {
58
+ setTimeout(() => location.reload(), delayMs)
59
+ }
60
+
48
61
  /**
49
62
  * Layout constants for UI positioning
50
63
  */
@@ -67,8 +80,6 @@ export const LAYOUT = {
67
80
  BLOCK_EDITOR_WIDTH: 400,
68
81
  /** Block editor approximate height for positioning */
69
82
  BLOCK_EDITOR_HEIGHT: 500,
70
- /** AI chat panel width */
71
- AI_CHAT_WIDTH: 400,
72
83
  } as const
73
84
 
74
85
  /**
@@ -77,8 +88,6 @@ export const LAYOUT = {
77
88
  export const API = {
78
89
  /** Default request timeout in milliseconds */
79
90
  REQUEST_TIMEOUT_MS: 30000,
80
- /** AI streaming request timeout in milliseconds */
81
- AI_STREAM_TIMEOUT_MS: 120000,
82
91
  /** Maximum retry attempts for failed requests */
83
92
  MAX_RETRIES: 3,
84
93
  /** Base delay for exponential backoff (ms) */