@nuasite/cms 0.38.0 → 0.39.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.
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.38.0",
17
+ "version": "0.39.0",
18
18
  "module": "src/index.ts",
19
19
  "types": "src/index.ts",
20
20
  "type": "module",
@@ -63,6 +63,8 @@ export interface DevMiddlewareOptions {
63
63
  * corrupted by injected markers).
64
64
  */
65
65
  isPublicStaticFile?: (urlPath: string) => boolean
66
+ /** Maximum upload size in bytes for /_nua/cms/media/upload. */
67
+ maxUploadSize: number
66
68
  }
67
69
 
68
70
  export function createDevMiddleware(
@@ -71,10 +73,17 @@ export function createDevMiddleware(
71
73
  manifestWriter: ManifestWriter,
72
74
  componentDefinitions: Record<string, ComponentDefinition>,
73
75
  idCounter: { value: number },
74
- options: DevMiddlewareOptions = {},
76
+ options: DevMiddlewareOptions,
75
77
  ) {
76
78
  const isPublicStaticFile = options.isPublicStaticFile ?? (() => false)
77
79
 
80
+ // Tracks in-flight Phase 2 enhancement promises per page so that requests for the
81
+ // page manifest can await source-path resolution before responding. Without this,
82
+ // hosts that serve HTML faster than the source-finder pipeline completes (e.g.
83
+ // pletivo, sandbox runtimes) leak a partial manifest where entries have tag/text
84
+ // but no sourcePath, which the editor then treats as locked elements.
85
+ const pendingPhase2: Map<string, Promise<void>> = new Map()
86
+
78
87
  // Serve uploaded media files directly from disk.
79
88
  // Vite's public dir middleware caches file listings, so newly uploaded files
80
89
  // may not be available immediately. This middleware bypasses that cache.
@@ -122,7 +131,15 @@ export function createDevMiddleware(
122
131
 
123
132
  const route = url.replace('/_nua/cms/', '').split('?')[0]!
124
133
 
125
- handleCmsApiRoute(route, req, res, manifestWriter, config.contentDir, options.mediaAdapter)
134
+ handleCmsApiRoute({
135
+ req,
136
+ res,
137
+ route,
138
+ manifestWriter,
139
+ contentDir: config.contentDir,
140
+ mediaAdapter: options.mediaAdapter,
141
+ maxUploadSize: options.maxUploadSize,
142
+ })
126
143
  .catch((error) => {
127
144
  console.error('[astro-cms] API error:', error)
128
145
  sendError(res, 'Internal server error', 500)
@@ -209,7 +226,7 @@ export function createDevMiddleware(
209
226
  })
210
227
 
211
228
  // Serve per-page manifest endpoints (e.g., /about.json for /about page)
212
- server.middlewares.use((req, res, next) => {
229
+ server.middlewares.use(async (req, res, next) => {
213
230
  const url = (req.url || '').split('?')[0]!
214
231
 
215
232
  // Match /*.json pattern (but not files that actually exist)
@@ -224,6 +241,18 @@ export function createDevMiddleware(
224
241
  pagePath = '/'
225
242
  }
226
243
 
244
+ // If Phase 2 source resolution is still in flight for this page, wait for it
245
+ // so the response includes full sourcePath data — avoids the race where the
246
+ // editor fetches a partial manifest and locks elements it can't yet edit.
247
+ const inFlight = pendingPhase2.get(pagePath)
248
+ if (inFlight) {
249
+ try {
250
+ await inFlight
251
+ } catch {
252
+ // fall through — serve whatever partial manifest we have
253
+ }
254
+ }
255
+
227
256
  const pageData = manifestWriter.getPageManifest(pagePath)
228
257
 
229
258
  // Only serve if we have manifest data for this page
@@ -328,9 +357,25 @@ export function createDevMiddleware(
328
357
  }
329
358
  res.end(transformed, ...args)
330
359
 
331
- // Phase 2 (background): resolve source locations and enhance manifest
332
- // This runs after the page is already visible to the user
333
- enhanceManifestInBackground(pagePath, entries, components, collection, seo, colDefs, config, manifestWriter)
360
+ // Phase 2 runs after the HTML is sent so the page renders without waiting on
361
+ // source resolution. The manifest endpoint awaits this promise (via
362
+ // pendingPhase2) so it never returns entries with missing sourcePath.
363
+ const phase2 = enhanceManifestInBackground(
364
+ pagePath,
365
+ entries,
366
+ components,
367
+ collection,
368
+ seo,
369
+ colDefs,
370
+ config,
371
+ manifestWriter,
372
+ )
373
+ pendingPhase2.set(pagePath, phase2)
374
+ phase2.finally(() => {
375
+ if (pendingPhase2.get(pagePath) === phase2) {
376
+ pendingPhase2.delete(pagePath)
377
+ }
378
+ })
334
379
  })
335
380
  .catch((error) => {
336
381
  console.error('[cms] Error transforming HTML:', error)
@@ -17,6 +17,7 @@ import {
17
17
  startRedirectCountdown,
18
18
  updateMarkdownFrontmatter,
19
19
  } from '../signals'
20
+ import { clearMarkdownDraft } from '../storage'
20
21
  import { CreateModeFrontmatter, EditModeFrontmatter } from './frontmatter-fields'
21
22
  import { FrontmatterSidebar, partitionFields } from './frontmatter-sidebar'
22
23
  import { MarkdownInlineEditor } from './markdown-inline-editor'
@@ -163,6 +164,7 @@ export function MarkdownEditorOverlay() {
163
164
  isMarkdownPreview.value = false
164
165
  setIsSaving(false)
165
166
 
167
+ clearMarkdownDraft(currentPage.filePath)
166
168
  showToast('Content saved', 'success')
167
169
  // Clear pending entry navigation so editor doesn't auto-open after save
168
170
  sessionStorage.removeItem(STORAGE_KEYS.PENDING_ENTRY_NAVIGATION)
@@ -93,6 +93,8 @@ export const STORAGE_KEYS = {
93
93
  SETTINGS: 'cms-settings',
94
94
  PENDING_ENTRY_NAVIGATION: 'cms-pending-entry-navigation',
95
95
  IS_EDITING: 'cms-is-editing',
96
+ /** Prefix for per-file markdown draft entries; full key is `${MARKDOWN_DRAFT_PREFIX}${filePath}`. */
97
+ MARKDOWN_DRAFT_PREFIX: 'cms-markdown-draft:',
96
98
  } as const
97
99
 
98
100
  /**
@@ -208,6 +208,27 @@ export async function startEditMode(
208
208
  editModeAbortController = new AbortController()
209
209
  const editModeSignal = editModeAbortController.signal
210
210
 
211
+ const savedEdits = loadEditsFromStorage()
212
+ const savedImageEdits = loadImageEditsFromStorage()
213
+ const savedColorEdits = loadColorEditsFromStorage()
214
+ const savedAttributeEdits = loadAttributeEditsFromStorage()
215
+
216
+ // Paint saved edits before awaiting the manifest so slow fetches don't show stale text.
217
+ // The captured pre-paint state is read by the wiring loop later as the pendingChange origin —
218
+ // without it the source writer would search the source file for the already-edited text.
219
+ const preEagerPaintDomState = new Map<string, { html: string; text: string }>()
220
+ for (const [cmsId, edit] of Object.entries(savedEdits)) {
221
+ const el = document.querySelector(`[${CSS.ID_ATTRIBUTE}="${cmsId}"]`) as HTMLElement | null
222
+ if (!el) continue
223
+ preEagerPaintDomState.set(cmsId, {
224
+ html: el.innerHTML,
225
+ text: getEditableTextFromElement(el),
226
+ })
227
+ if (el.innerHTML !== edit.currentHTML) {
228
+ el.innerHTML = edit.currentHTML
229
+ }
230
+ }
231
+
211
232
  try {
212
233
  const manifest = await fetchManifest()
213
234
  signals.setManifest(manifest)
@@ -217,11 +238,6 @@ export async function startEditMode(
217
238
  console.error('[CMS] Failed to load manifest:', err)
218
239
  return
219
240
  }
220
-
221
- const savedEdits = loadEditsFromStorage()
222
- const savedImageEdits = loadImageEditsFromStorage()
223
- const savedColorEdits = loadColorEditsFromStorage()
224
- const savedAttributeEdits = loadAttributeEditsFromStorage()
225
241
  const savedBgImageEdits = loadBgImageEditsFromStorage()
226
242
  const currentManifest = signals.manifest.value
227
243
 
@@ -343,8 +359,9 @@ export async function startEditMode(
343
359
  setupAttributeTracking(config, el, cmsId, savedAttributeEdits[cmsId])
344
360
 
345
361
  if (!signals.pendingChanges.value.has(cmsId)) {
346
- const originalHTML = el.innerHTML
347
- const originalText = getEditableTextFromElement(el)
362
+ const preEagerPaint = preEagerPaintDomState.get(cmsId)
363
+ const originalHTML = preEagerPaint?.html ?? el.innerHTML
364
+ const originalText = preEagerPaint?.text ?? getEditableTextFromElement(el)
348
365
 
349
366
  logDebug(config.debug, 'Setting up element:', cmsId, 'originalText:', originalText)
350
367
 
@@ -57,7 +57,7 @@ import {
57
57
  updateSettings,
58
58
  } from './signals'
59
59
  import * as signals from './signals'
60
- import { hasPendingEntryNavigation, loadEditingState, loadSettingsFromStorage, saveSettingsToStorage } from './storage'
60
+ import { hasAnyMarkdownDraft, hasPendingEntryNavigation, loadEditingState, loadSettingsFromStorage, saveSettingsToStorage } from './storage'
61
61
  import CMS_STYLES from './styles.css?inline'
62
62
  import { generateCSSVariables, resolveTheme } from './themes'
63
63
 
@@ -124,12 +124,17 @@ const CmsUI = () => {
124
124
  }
125
125
  }, [config, updateUI])
126
126
 
127
- // Auto-open markdown editor when there's a pending entry navigation from collections browser
127
+ // Waits for the manifest before opening currentPageCollection depends on it.
128
+ const draftAutoOpenedRef = useRef(false)
129
+ const manifestState = signals.manifest.value
128
130
  useEffect(() => {
129
- if (hasPendingEntryNavigation()) {
130
- openMarkdownEditorForCurrentPage()
131
- }
132
- }, [])
131
+ if (draftAutoOpenedRef.current) return
132
+ if (!hasPendingEntryNavigation() && !hasAnyMarkdownDraft()) return
133
+ const hasCollections = manifestState.collections && Object.keys(manifestState.collections).length > 0
134
+ if (!hasCollections) return
135
+ draftAutoOpenedRef.current = true
136
+ openMarkdownEditorForCurrentPage()
137
+ }, [manifestState])
133
138
 
134
139
  // Send select-mode click selection to parent window via postMessage
135
140
  const prevSelectModeRef = useRef<string | null>(null)
@@ -76,6 +76,15 @@ export function uploadMedia(
76
76
  onProgress?: (percent: number) => void,
77
77
  options?: { folder?: string; context?: MediaUploadContext },
78
78
  ): Promise<MediaUploadResponse> {
79
+ if (config.maxUploadSize && file.size > config.maxUploadSize) {
80
+ const limitMb = Math.round(config.maxUploadSize / (1024 * 1024))
81
+ const fileMb = (file.size / (1024 * 1024)).toFixed(1)
82
+ return Promise.resolve({
83
+ success: false,
84
+ error: `File is too large (${fileMb} MB, limit ${limitMb} MB)`,
85
+ })
86
+ }
87
+
79
88
  const formData = new FormData()
80
89
  formData.append('file', file)
81
90
 
@@ -1,8 +1,9 @@
1
- import { batch, computed, type Signal, signal } from '@preact/signals'
1
+ import { batch, computed, effect, type Signal, signal } from '@preact/signals'
2
2
  import { slugifyHref } from '../shared'
3
3
  import { fetchManifest, getMarkdownContent } from './api'
4
4
  import type { ToastMessage, ToastType } from './components/toast/types'
5
5
  import { getConfig } from './config'
6
+ import { clearMarkdownDraft, loadMarkdownDraft, saveMarkdownDraft } from './storage'
6
7
  import type {
7
8
  AttributeEditorState,
8
9
  BlockEditorState,
@@ -308,6 +309,48 @@ export const currentMarkdownPage = computed(
308
309
  )
309
310
  export const isMarkdownPreview = signal(false)
310
311
 
312
+ const DRAFT_PERSIST_DEBOUNCE_MS = 500
313
+ let draftPersistTimer: ReturnType<typeof setTimeout> | null = null
314
+ let lastPersistedSnapshot: string | null = null
315
+
316
+ function getPersistablePage(): MarkdownPageEntry | null {
317
+ const state = markdownEditorState.value
318
+ const page = state.currentPage
319
+ if (!state.isOpen || !page || !page.isDirty || state.mode !== 'edit' || !page.filePath) return null
320
+ return page
321
+ }
322
+
323
+ function persistCurrentDraftIfDirty(): void {
324
+ const page = getPersistablePage()
325
+ if (!page) return
326
+ const snapshot = `${page.filePath}${JSON.stringify(page.frontmatter)}${page.content}`
327
+ if (snapshot === lastPersistedSnapshot) return
328
+ lastPersistedSnapshot = snapshot
329
+ saveMarkdownDraft(page.filePath, page.frontmatter, page.content)
330
+ }
331
+
332
+ function flushPendingDraft(): void {
333
+ if (draftPersistTimer) {
334
+ clearTimeout(draftPersistTimer)
335
+ draftPersistTimer = null
336
+ }
337
+ persistCurrentDraftIfDirty()
338
+ }
339
+
340
+ effect(() => {
341
+ if (!getPersistablePage()) return
342
+ if (draftPersistTimer) clearTimeout(draftPersistTimer)
343
+ draftPersistTimer = setTimeout(() => {
344
+ draftPersistTimer = null
345
+ persistCurrentDraftIfDirty()
346
+ }, DRAFT_PERSIST_DEBOUNCE_MS)
347
+ })
348
+
349
+ // `pagehide` fires reliably on tab close, reload, and back/forward nav, unlike `beforeunload` which is throttled.
350
+ if (typeof window !== 'undefined') {
351
+ window.addEventListener('pagehide', flushPendingDraft)
352
+ }
353
+
311
354
  // ============================================================================
312
355
  // MDX Component Block State Signals
313
356
  // ============================================================================
@@ -853,6 +896,65 @@ function parseFrontmatterValue(value: string): unknown {
853
896
  return value
854
897
  }
855
898
 
899
+ function deepEqual(a: unknown, b: unknown): boolean {
900
+ if (a === b) return true
901
+ if (a === null || b === null || typeof a !== typeof b || typeof a !== 'object') return false
902
+ if (Array.isArray(a)) {
903
+ if (!Array.isArray(b) || a.length !== b.length) return false
904
+ return a.every((v, i) => deepEqual(v, b[i]))
905
+ }
906
+ const aObj = a as Record<string, unknown>
907
+ const bObj = b as Record<string, unknown>
908
+ const aKeys = Object.keys(aObj)
909
+ if (aKeys.length !== Object.keys(bObj).length) return false
910
+ return aKeys.every((k) => deepEqual(aObj[k], bObj[k]))
911
+ }
912
+
913
+ function formatDraftAge(savedAt: number): string {
914
+ const elapsedMs = Date.now() - savedAt
915
+ const minutes = Math.floor(elapsedMs / 60_000)
916
+ if (minutes < 1) return 'just now'
917
+ if (minutes < 60) return `${minutes} minute${minutes === 1 ? '' : 's'} ago`
918
+ const hours = Math.floor(minutes / 60)
919
+ if (hours < 24) return `${hours} hour${hours === 1 ? '' : 's'} ago`
920
+ return new Date(savedAt).toLocaleString()
921
+ }
922
+
923
+ /**
924
+ * If a sessionStorage draft exists for `filePath` and differs from the freshly fetched
925
+ * content, ask the user whether to restore it. Returns the (possibly overridden) frontmatter
926
+ * and content. The `isDirty` flag indicates whether the result came from a recovered draft.
927
+ */
928
+ async function maybeRecoverDraft(
929
+ filePath: string,
930
+ fetchedFrontmatter: Record<string, unknown>,
931
+ fetchedContent: string,
932
+ ): Promise<{ frontmatter: Record<string, unknown>; content: string; isDirty: boolean }> {
933
+ const draft = loadMarkdownDraft(filePath)
934
+ if (!draft) return { frontmatter: fetchedFrontmatter, content: fetchedContent, isDirty: false }
935
+
936
+ const sameContent = draft.content === fetchedContent
937
+ const sameFrontmatter = deepEqual(draft.frontmatter, fetchedFrontmatter)
938
+ if (sameContent && sameFrontmatter) {
939
+ clearMarkdownDraft(filePath)
940
+ return { frontmatter: fetchedFrontmatter, content: fetchedContent, isDirty: false }
941
+ }
942
+
943
+ const recover = await showConfirmDialog({
944
+ title: 'Recover unsaved changes?',
945
+ message: `An unsaved draft from ${formatDraftAge(draft.savedAt)} exists for this entry. Recover it, or discard?`,
946
+ confirmLabel: 'Recover draft',
947
+ cancelLabel: 'Discard',
948
+ variant: 'warning',
949
+ })
950
+
951
+ if (recover) {
952
+ return { frontmatter: draft.frontmatter, content: draft.content, isDirty: true }
953
+ }
954
+ clearMarkdownDraft(filePath)
955
+ return { frontmatter: fetchedFrontmatter, content: fetchedContent, isDirty: false }
956
+ }
957
+
856
958
  /**
857
959
  * Open the markdown editor for the current page's collection entry.
858
960
  * Refreshes the manifest first to ensure we have the latest content.
@@ -896,14 +998,16 @@ export async function openMarkdownEditorForCurrentPage(): Promise<boolean> {
896
998
  // Look up collection definition for schema-aware field rendering
897
999
  const collectionDefinition = manifest.value.collectionDefinitions?.[collection.collectionName]
898
1000
 
1001
+ const recovered = await maybeRecoverDraft(collection.sourcePath, frontmatter, content)
1002
+
899
1003
  markdownEditorState.value = {
900
1004
  isOpen: true,
901
1005
  currentPage: {
902
1006
  filePath: collection.sourcePath,
903
1007
  slug: collection.collectionSlug,
904
- frontmatter: frontmatter as import('./types').BlogFrontmatter,
905
- content,
906
- isDirty: false,
1008
+ frontmatter: recovered.frontmatter as import('./types').BlogFrontmatter,
1009
+ content: recovered.content,
1010
+ isDirty: recovered.isDirty,
907
1011
  },
908
1012
  activeElementId: collection.wrapperId ?? null,
909
1013
  mode: 'edit',
@@ -1114,14 +1218,16 @@ export async function openMarkdownEditorForEntry(
1114
1218
  console.error('[CMS] Failed to fetch markdown content for entry:', err)
1115
1219
  }
1116
1220
 
1221
+ const recovered = await maybeRecoverDraft(sourcePath, frontmatter, content)
1222
+
1117
1223
  markdownEditorState.value = {
1118
1224
  isOpen: true,
1119
1225
  currentPage: {
1120
1226
  filePath: sourcePath,
1121
1227
  slug,
1122
- frontmatter: frontmatter as import('./types').BlogFrontmatter,
1123
- content,
1124
- isDirty: false,
1228
+ frontmatter: recovered.frontmatter as import('./types').BlogFrontmatter,
1229
+ content: recovered.content,
1230
+ isDirty: recovered.isDirty,
1125
1231
  },
1126
1232
  activeElementId: null,
1127
1233
  mode: 'edit',