@nuasite/cms 0.38.0 → 0.39.1
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/dist/editor.js +11483 -11444
- package/package.json +1 -1
- package/src/build-processor.ts +17 -3
- package/src/dev-middleware.ts +67 -9
- package/src/editor/components/markdown-editor-overlay.tsx +2 -0
- package/src/editor/constants.ts +2 -0
- package/src/editor/editor.ts +24 -7
- package/src/editor/index.tsx +11 -6
- package/src/editor/markdown-api.ts +9 -0
- package/src/editor/signals.ts +113 -7
- package/src/editor/storage.ts +172 -196
- package/src/editor/types.ts +2 -0
- package/src/handlers/api-routes.ts +27 -14
- package/src/handlers/request-utils.ts +10 -1
- package/src/html-processor.ts +11 -0
- package/src/index.ts +21 -10
- package/src/source-finder/image-finder.ts +9 -2
- package/src/source-finder/search-index.ts +152 -62
- package/src/source-finder/snippet-utils.ts +16 -3
- package/src/source-finder/source-lookup.ts +5 -1
- package/src/types.ts +4 -0
package/package.json
CHANGED
package/src/build-processor.ts
CHANGED
|
@@ -5,7 +5,7 @@ import path from 'node:path'
|
|
|
5
5
|
import { fileURLToPath } from 'node:url'
|
|
6
6
|
import { getProjectRoot } from './config'
|
|
7
7
|
import { detectArrayPattern, extractArrayElementProps } from './handlers/array-ops'
|
|
8
|
-
import { extractPropsFromSource, findComponentInvocationLine, findFrontmatterEnd } from './handlers/component-ops'
|
|
8
|
+
import { extractPropsFromSource, findComponentInvocationLine, findFrontmatterEnd, getPageFileCandidates } from './handlers/component-ops'
|
|
9
9
|
import { extractComponentName, processHtml } from './html-processor'
|
|
10
10
|
import type { ManifestWriter } from './manifest-writer'
|
|
11
11
|
import { generateComponentPreviews } from './preview-generator'
|
|
@@ -374,13 +374,27 @@ async function processFile(
|
|
|
374
374
|
}
|
|
375
375
|
}
|
|
376
376
|
|
|
377
|
+
const pageFiles = getPageFileCandidates(pagePath)
|
|
378
|
+
|
|
377
379
|
// Process entries in parallel for better performance
|
|
378
380
|
const entryLookups = Object.values(result.entries).map(async (entry) => {
|
|
379
381
|
// Handle image entries specially - always search by image src
|
|
380
382
|
// The sourcePath from HTML attributes may point to a shared Image component
|
|
381
383
|
// rather than the file that actually uses the component with the src value
|
|
382
384
|
if (entry.imageMetadata?.src) {
|
|
383
|
-
const
|
|
385
|
+
const preferredLocation = entry.sourcePath
|
|
386
|
+
? {
|
|
387
|
+
file: entry.sourcePath,
|
|
388
|
+
line: entry.sourceLine,
|
|
389
|
+
srcOccurrence: entry.imageMetadata.srcOccurrence,
|
|
390
|
+
}
|
|
391
|
+
: undefined
|
|
392
|
+
const imageSource = await findImageSourceLocation(
|
|
393
|
+
entry.imageMetadata.src,
|
|
394
|
+
entry.imageMetadata.srcSet,
|
|
395
|
+
pageFiles,
|
|
396
|
+
preferredLocation,
|
|
397
|
+
)
|
|
384
398
|
if (imageSource) {
|
|
385
399
|
entry.sourcePath = imageSource.file
|
|
386
400
|
entry.sourceLine = imageSource.line
|
|
@@ -412,7 +426,7 @@ async function processFile(
|
|
|
412
426
|
}
|
|
413
427
|
|
|
414
428
|
// Fall back to searching Astro files
|
|
415
|
-
const sourceLocation = await findSourceLocation(entry.text, entry.tag)
|
|
429
|
+
const sourceLocation = await findSourceLocation(entry.text, entry.tag, pageFiles)
|
|
416
430
|
if (sourceLocation) {
|
|
417
431
|
entry.sourcePath = sourceLocation.file
|
|
418
432
|
entry.sourceLine = sourceLocation.line
|
package/src/dev-middleware.ts
CHANGED
|
@@ -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(
|
|
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
|
|
332
|
-
//
|
|
333
|
-
|
|
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)
|
|
@@ -546,20 +591,33 @@ export async function enhanceManifestInBackground(
|
|
|
546
591
|
}
|
|
547
592
|
}
|
|
548
593
|
|
|
549
|
-
const
|
|
594
|
+
const pageFiles = getPageFileCandidates(pagePath)
|
|
595
|
+
const enhanced = await enhanceManifestWithSourceSnippets(entries, collectionDefinitions, pageFiles)
|
|
550
596
|
|
|
551
597
|
// Fallback for entries without sourcePath — search index can still find them
|
|
552
598
|
for (const entry of Object.values(enhanced)) {
|
|
553
599
|
if (entry.sourceSnippet || entry.sourcePath) continue
|
|
554
600
|
if (entry.imageMetadata?.src) {
|
|
555
|
-
const
|
|
601
|
+
const preferredLocation = entry.sourcePath
|
|
602
|
+
? {
|
|
603
|
+
file: entry.sourcePath,
|
|
604
|
+
line: entry.sourceLine,
|
|
605
|
+
srcOccurrence: entry.imageMetadata.srcOccurrence,
|
|
606
|
+
}
|
|
607
|
+
: undefined
|
|
608
|
+
const loc = await findImageSourceLocation(
|
|
609
|
+
entry.imageMetadata.src,
|
|
610
|
+
entry.imageMetadata.srcSet,
|
|
611
|
+
pageFiles,
|
|
612
|
+
preferredLocation,
|
|
613
|
+
)
|
|
556
614
|
if (loc) {
|
|
557
615
|
entry.sourcePath = loc.file
|
|
558
616
|
entry.sourceLine = loc.line
|
|
559
617
|
entry.sourceSnippet = loc.snippet
|
|
560
618
|
}
|
|
561
619
|
} else if (entry.text && entry.tag) {
|
|
562
|
-
const loc = await findSourceLocation(entry.text, entry.tag)
|
|
620
|
+
const loc = await findSourceLocation(entry.text, entry.tag, pageFiles)
|
|
563
621
|
if (loc) {
|
|
564
622
|
entry.sourcePath = loc.file
|
|
565
623
|
entry.sourceLine = loc.line
|
|
@@ -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)
|
package/src/editor/constants.ts
CHANGED
|
@@ -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/editor.ts
CHANGED
|
@@ -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
|
|
347
|
-
const
|
|
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
|
|
package/src/editor/index.tsx
CHANGED
|
@@ -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
|
-
//
|
|
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 (
|
|
130
|
-
|
|
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
|
|
package/src/editor/signals.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
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',
|