@nuasite/cms 0.37.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.37.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)
@@ -1,6 +1,6 @@
1
1
  import { useEffect, useRef } from 'preact/hooks'
2
2
  import { Z_INDEX } from '../constants'
3
- import { getOutlineColor } from '../dom'
3
+ import { getOutlineColor, isElementVisible } from '../dom'
4
4
  import * as signals from '../signals'
5
5
 
6
6
  export interface EditableHighlightsProps {
@@ -164,72 +164,34 @@ function collectEditableElements(): HighlightRect[] {
164
164
  const highlights: HighlightRect[] = []
165
165
  const manifest = signals.manifest.value
166
166
 
167
- // Query all elements with CMS data attributes
168
- const textElements = document.querySelectorAll('[data-cms-id]')
169
- const componentElements = document.querySelectorAll('[data-cms-component-id]')
170
- const imageElements = document.querySelectorAll('img[data-cms-img]')
171
- const bgImageElements = document.querySelectorAll('[data-cms-bg-img]')
172
-
173
- // Process text elements
174
- textElements.forEach((el) => {
175
- const cmsId = el.getAttribute('data-cms-id')
167
+ const tryPush = (el: Element, cmsId: string | null, type: HighlightRect['type']) => {
176
168
  if (!cmsId) return
177
-
178
- // Skip if this is also a component or image
179
- if (el.hasAttribute('data-cms-component-id') || el.tagName === 'IMG') return
180
-
181
- // Skip if not in manifest (invalid element)
182
- if (!manifest.entries[cmsId]) return
183
-
169
+ // Cheap rect gate first — skips most off-screen / collapsed elements without
170
+ // touching computed style. Only survivors pay the visibility check cost.
184
171
  const rect = el.getBoundingClientRect()
185
- // Skip elements not in viewport or too small
186
172
  if (rect.width < 10 || rect.height < 10) return
187
173
  if (rect.bottom < 0 || rect.top > window.innerHeight) return
188
174
  if (rect.right < 0 || rect.left > window.innerWidth) return
175
+ if (!isElementVisible(el)) return
176
+ highlights.push({ cmsId, type, rect })
177
+ }
189
178
 
190
- highlights.push({ cmsId, type: 'text', rect })
179
+ document.querySelectorAll('[data-cms-id]').forEach((el) => {
180
+ // Routed to the component/image branches below.
181
+ if (el.hasAttribute('data-cms-component-id') || el.tagName === 'IMG') return
182
+ const cmsId = el.getAttribute('data-cms-id')
183
+ if (cmsId && !manifest.entries[cmsId]) return
184
+ tryPush(el, cmsId, el.hasAttribute('data-cms-bg-img') ? 'image' : 'text')
191
185
  })
192
186
 
193
- // Process component elements
194
- componentElements.forEach((el) => {
187
+ document.querySelectorAll('[data-cms-component-id]').forEach((el) => {
195
188
  const componentId = el.getAttribute('data-cms-component-id')
196
- if (!componentId) return
197
-
198
- // Skip if not in manifest
199
- if (!manifest.components[componentId]) return
200
-
201
- const rect = el.getBoundingClientRect()
202
- if (rect.width < 10 || rect.height < 10) return
203
- if (rect.bottom < 0 || rect.top > window.innerHeight) return
204
- if (rect.right < 0 || rect.left > window.innerWidth) return
205
-
206
- highlights.push({ cmsId: componentId, type: 'component', rect })
189
+ if (componentId && !manifest.components[componentId]) return
190
+ tryPush(el, componentId, 'component')
207
191
  })
208
192
 
209
- // Process image elements
210
- imageElements.forEach((el) => {
211
- const cmsId = el.getAttribute('data-cms-img')
212
- if (!cmsId) return
213
-
214
- const rect = el.getBoundingClientRect()
215
- if (rect.width < 10 || rect.height < 10) return
216
- if (rect.bottom < 0 || rect.top > window.innerHeight) return
217
- if (rect.right < 0 || rect.left > window.innerWidth) return
218
-
219
- highlights.push({ cmsId, type: 'image', rect })
220
- })
221
-
222
- // Process background image elements
223
- bgImageElements.forEach((el) => {
224
- const cmsId = el.getAttribute('data-cms-id')
225
- if (!cmsId) return
226
-
227
- const rect = el.getBoundingClientRect()
228
- if (rect.width < 10 || rect.height < 10) return
229
- if (rect.bottom < 0 || rect.top > window.innerHeight) return
230
- if (rect.right < 0 || rect.left > window.innerWidth) return
231
-
232
- highlights.push({ cmsId, type: 'image', rect })
193
+ document.querySelectorAll('img[data-cms-img]').forEach((el) => {
194
+ tryPush(el, el.getAttribute('data-cms-img'), 'image')
233
195
  })
234
196
 
235
197
  return highlights
@@ -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
  /**
package/src/editor/dom.ts CHANGED
@@ -51,6 +51,43 @@ export function getOutlineColor(): string {
51
51
  return isPageDark() ? '#FFFFFF' : '#1A1A1A'
52
52
  }
53
53
 
54
+ /**
55
+ * Check if an element is actually visible to the user. Catches `display:none`,
56
+ * `visibility:hidden|collapse`, `opacity:0`, and `content-visibility` on the
57
+ * element or any ancestor — `getBoundingClientRect` alone reports a non-zero
58
+ * box for `opacity:0`/`visibility:hidden`, so highlight overlays would otherwise
59
+ * draw over hidden mobile menus and modal panels.
60
+ */
61
+ export function isElementVisible(el: Element): boolean {
62
+ const anyEl = el as Element & {
63
+ checkVisibility?: (opts?: {
64
+ checkOpacity?: boolean
65
+ checkVisibilityCSS?: boolean
66
+ contentVisibilityAuto?: boolean
67
+ opacityProperty?: boolean
68
+ visibilityProperty?: boolean
69
+ }) => boolean
70
+ }
71
+ if (typeof anyEl.checkVisibility === 'function') {
72
+ return anyEl.checkVisibility({
73
+ checkOpacity: true,
74
+ checkVisibilityCSS: true,
75
+ contentVisibilityAuto: true,
76
+ opacityProperty: true,
77
+ visibilityProperty: true,
78
+ })
79
+ }
80
+ let cur: Element | null = el
81
+ while (cur && cur !== document.documentElement) {
82
+ const cs = getComputedStyle(cur)
83
+ if (cs.display === 'none') return false
84
+ if (cs.visibility === 'hidden' || cs.visibility === 'collapse') return false
85
+ if (parseFloat(cs.opacity) === 0) return false
86
+ cur = cur.parentElement
87
+ }
88
+ return true
89
+ }
90
+
54
91
  /** Style element for contenteditable focus styles injected into the host page */
55
92
  let focusStyleElement: HTMLStyleElement | null = null
56
93
 
@@ -100,6 +100,47 @@ export function notifyLockedElement(): void {
100
100
  signals.showToast("This text can't be edited here — no source file is linked to it", 'info')
101
101
  }
102
102
 
103
+ /**
104
+ * Manifest is built in two phases (fast HTML marking, then background source
105
+ * resolution). Entering edit mode before phase 2 completes can pre-lock elements
106
+ * that later gain a valid sourcePath, leaving stale locks on the DOM.
107
+ *
108
+ * On click, re-check the local snapshot, then re-fetch from the server in case
109
+ * phase 2 finished since the editor took its snapshot. Toggle edit mode once
110
+ * afterwards to make the now-unlocked element editable.
111
+ */
112
+ let inFlightLockedFetch: Promise<unknown> | null = null
113
+ function handleLockedClick(event: Event): void {
114
+ const target = event.currentTarget as HTMLElement | null
115
+ const id = target?.getAttribute(CSS.ID_ATTRIBUTE)
116
+ if (!target || !id) {
117
+ notifyLockedElement()
118
+ return
119
+ }
120
+
121
+ if (signals.manifest.value.entries[id]?.sourcePath) {
122
+ target.removeAttribute(CSS.LOCKED_ATTRIBUTE)
123
+ return
124
+ }
125
+
126
+ // In-memory signal can lag phase-2 writes. Coalesce concurrent clicks into
127
+ // one fetch so the dev server doesn't get hammered.
128
+ if (!inFlightLockedFetch) {
129
+ inFlightLockedFetch = fetchManifest()
130
+ .then((fresh) => signals.setManifest(fresh))
131
+ .catch(() => {})
132
+ .finally(() => {
133
+ inFlightLockedFetch = null
134
+ })
135
+ }
136
+ inFlightLockedFetch.then(() => {
137
+ if (signals.manifest.value.entries[id]?.sourcePath) {
138
+ target.removeAttribute(CSS.LOCKED_ATTRIBUTE)
139
+ }
140
+ })
141
+ notifyLockedElement()
142
+ }
143
+
103
144
  /** Test-only: reset toast throttle state between test cases. */
104
145
  export function _resetToastThrottles(): void {
105
146
  lastFormattingBlockedToastAt = 0
@@ -167,6 +208,27 @@ export async function startEditMode(
167
208
  editModeAbortController = new AbortController()
168
209
  const editModeSignal = editModeAbortController.signal
169
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
+
170
232
  try {
171
233
  const manifest = await fetchManifest()
172
234
  signals.setManifest(manifest)
@@ -176,11 +238,6 @@ export async function startEditMode(
176
238
  console.error('[CMS] Failed to load manifest:', err)
177
239
  return
178
240
  }
179
-
180
- const savedEdits = loadEditsFromStorage()
181
- const savedImageEdits = loadImageEditsFromStorage()
182
- const savedColorEdits = loadColorEditsFromStorage()
183
- const savedAttributeEdits = loadAttributeEditsFromStorage()
184
241
  const savedBgImageEdits = loadBgImageEditsFromStorage()
185
242
  const currentManifest = signals.manifest.value
186
243
 
@@ -246,7 +303,7 @@ export async function startEditMode(
246
303
  logDebug(config.debug, 'Skipping element without source path:', cmsId)
247
304
  makeElementNonEditable(el)
248
305
  el.setAttribute(CSS.LOCKED_ATTRIBUTE, 'true')
249
- el.addEventListener('click', notifyLockedElement, { signal: editModeSignal })
306
+ el.addEventListener('click', handleLockedClick, { signal: editModeSignal })
250
307
  return
251
308
  }
252
309
 
@@ -302,8 +359,9 @@ export async function startEditMode(
302
359
  setupAttributeTracking(config, el, cmsId, savedAttributeEdits[cmsId])
303
360
 
304
361
  if (!signals.pendingChanges.value.has(cmsId)) {
305
- const originalHTML = el.innerHTML
306
- const originalText = getEditableTextFromElement(el)
362
+ const preEagerPaint = preEagerPaintDomState.get(cmsId)
363
+ const originalHTML = preEagerPaint?.html ?? el.innerHTML
364
+ const originalText = preEagerPaint?.text ?? getEditableTextFromElement(el)
307
365
 
308
366
  logDebug(config.debug, 'Setting up element:', cmsId, 'originalText:', originalText)
309
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',