@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/dist/editor.js +11483 -11444
- package/package.json +1 -1
- package/src/dev-middleware.ts +51 -6
- 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/index.ts +21 -10
- package/src/source-finder/search-index.ts +48 -5
package/src/editor/storage.ts
CHANGED
|
@@ -14,90 +14,96 @@ import type {
|
|
|
14
14
|
} from './types'
|
|
15
15
|
|
|
16
16
|
// ============================================================================
|
|
17
|
-
//
|
|
17
|
+
// JSON-based storage helpers — all browser-storage access in this module
|
|
18
|
+
// goes through these so the try/catch + console.warn pattern lives in one place.
|
|
18
19
|
// ============================================================================
|
|
19
20
|
|
|
20
|
-
|
|
21
|
-
const edits: SavedEdits = {}
|
|
22
|
-
|
|
23
|
-
pendingChanges.forEach((change, cmsId) => {
|
|
24
|
-
if (change.isDirty) {
|
|
25
|
-
edits[cmsId] = {
|
|
26
|
-
originalText: change.originalText,
|
|
27
|
-
newText: change.newText,
|
|
28
|
-
currentHTML: change.currentHTML,
|
|
29
|
-
hasStyledContent: change.hasStyledContent,
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
})
|
|
33
|
-
|
|
21
|
+
function readJson<T>(storage: Storage, key: string, fallback: T, label: string): T {
|
|
34
22
|
try {
|
|
35
|
-
|
|
23
|
+
const raw = storage.getItem(key)
|
|
24
|
+
return raw === null ? fallback : (JSON.parse(raw) as T)
|
|
36
25
|
} catch (e) {
|
|
37
|
-
console.warn(
|
|
26
|
+
console.warn(`[CMS] ${label}:`, e)
|
|
27
|
+
return fallback
|
|
38
28
|
}
|
|
39
29
|
}
|
|
40
30
|
|
|
41
|
-
|
|
31
|
+
function writeJson(storage: Storage, key: string, value: unknown, label: string): void {
|
|
42
32
|
try {
|
|
43
|
-
|
|
44
|
-
return stored ? JSON.parse(stored) : {}
|
|
33
|
+
storage.setItem(key, JSON.stringify(value))
|
|
45
34
|
} catch (e) {
|
|
46
|
-
console.warn(
|
|
47
|
-
return {}
|
|
35
|
+
console.warn(`[CMS] ${label}:`, e)
|
|
48
36
|
}
|
|
49
37
|
}
|
|
50
38
|
|
|
51
|
-
|
|
39
|
+
function removeKey(storage: Storage, key: string, label: string): void {
|
|
52
40
|
try {
|
|
53
|
-
|
|
41
|
+
storage.removeItem(key)
|
|
54
42
|
} catch (e) {
|
|
55
|
-
console.warn(
|
|
43
|
+
console.warn(`[CMS] ${label}:`, e)
|
|
56
44
|
}
|
|
57
45
|
}
|
|
58
46
|
|
|
47
|
+
function collectDirtyEdits<C, E>(
|
|
48
|
+
pendingChanges: Map<string, C>,
|
|
49
|
+
pickFields: (change: C) => E,
|
|
50
|
+
isDirty: (change: C) => boolean,
|
|
51
|
+
): Record<string, E> {
|
|
52
|
+
const edits: Record<string, E> = {}
|
|
53
|
+
pendingChanges.forEach((change, cmsId) => {
|
|
54
|
+
if (isDirty(change)) edits[cmsId] = pickFields(change)
|
|
55
|
+
})
|
|
56
|
+
return edits
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ============================================================================
|
|
60
|
+
// Text Edits
|
|
61
|
+
// ============================================================================
|
|
62
|
+
|
|
63
|
+
export function saveEditsToStorage(pendingChanges: Map<string, PendingChange>): void {
|
|
64
|
+
const edits = collectDirtyEdits(pendingChanges, (c) => ({
|
|
65
|
+
originalText: c.originalText,
|
|
66
|
+
newText: c.newText,
|
|
67
|
+
currentHTML: c.currentHTML,
|
|
68
|
+
hasStyledContent: c.hasStyledContent,
|
|
69
|
+
}), (c) => c.isDirty)
|
|
70
|
+
writeJson(sessionStorage, STORAGE_KEYS.PENDING_EDITS, edits, 'Failed to save edits to storage')
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function loadEditsFromStorage(): SavedEdits {
|
|
74
|
+
return readJson<SavedEdits>(sessionStorage, STORAGE_KEYS.PENDING_EDITS, {}, 'Failed to load edits from storage')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function clearEditsFromStorage(): void {
|
|
78
|
+
removeKey(sessionStorage, STORAGE_KEYS.PENDING_EDITS, 'Failed to clear edits from storage')
|
|
79
|
+
}
|
|
80
|
+
|
|
59
81
|
// ============================================================================
|
|
60
82
|
// Image Edits
|
|
61
83
|
// ============================================================================
|
|
62
84
|
|
|
63
85
|
export function saveImageEditsToStorage(pendingImageChanges: Map<string, PendingImageChange>): void {
|
|
64
|
-
const edits
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
newAlt: change.newAlt,
|
|
73
|
-
originalSrcSet: change.originalSrcSet,
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
})
|
|
77
|
-
|
|
78
|
-
try {
|
|
79
|
-
sessionStorage.setItem(STORAGE_KEYS.PENDING_IMAGE_EDITS, JSON.stringify(edits))
|
|
80
|
-
} catch (e) {
|
|
81
|
-
console.warn('[CMS] Failed to save image edits to storage:', e)
|
|
82
|
-
}
|
|
86
|
+
const edits = collectDirtyEdits(pendingImageChanges, (c) => ({
|
|
87
|
+
originalSrc: c.originalSrc,
|
|
88
|
+
newSrc: c.newSrc,
|
|
89
|
+
originalAlt: c.originalAlt,
|
|
90
|
+
newAlt: c.newAlt,
|
|
91
|
+
originalSrcSet: c.originalSrcSet,
|
|
92
|
+
}), (c) => c.isDirty)
|
|
93
|
+
writeJson(sessionStorage, STORAGE_KEYS.PENDING_IMAGE_EDITS, edits, 'Failed to save image edits to storage')
|
|
83
94
|
}
|
|
84
95
|
|
|
85
96
|
export function loadImageEditsFromStorage(): SavedImageEdits {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}
|
|
97
|
+
return readJson<SavedImageEdits>(
|
|
98
|
+
sessionStorage,
|
|
99
|
+
STORAGE_KEYS.PENDING_IMAGE_EDITS,
|
|
100
|
+
{},
|
|
101
|
+
'Failed to load image edits from storage',
|
|
102
|
+
)
|
|
93
103
|
}
|
|
94
104
|
|
|
95
105
|
export function clearImageEditsFromStorage(): void {
|
|
96
|
-
|
|
97
|
-
sessionStorage.removeItem(STORAGE_KEYS.PENDING_IMAGE_EDITS)
|
|
98
|
-
} catch (e) {
|
|
99
|
-
console.warn('[CMS] Failed to clear image edits from storage:', e)
|
|
100
|
-
}
|
|
106
|
+
removeKey(sessionStorage, STORAGE_KEYS.PENDING_IMAGE_EDITS, 'Failed to clear image edits from storage')
|
|
101
107
|
}
|
|
102
108
|
|
|
103
109
|
// ============================================================================
|
|
@@ -105,40 +111,24 @@ export function clearImageEditsFromStorage(): void {
|
|
|
105
111
|
// ============================================================================
|
|
106
112
|
|
|
107
113
|
export function saveColorEditsToStorage(pendingColorChanges: Map<string, PendingColorChange>): void {
|
|
108
|
-
const edits
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
originalClasses: change.originalClasses,
|
|
114
|
-
newClasses: change.newClasses,
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
})
|
|
118
|
-
|
|
119
|
-
try {
|
|
120
|
-
sessionStorage.setItem(STORAGE_KEYS.PENDING_COLOR_EDITS, JSON.stringify(edits))
|
|
121
|
-
} catch (e) {
|
|
122
|
-
console.warn('[CMS] Failed to save color edits to storage:', e)
|
|
123
|
-
}
|
|
114
|
+
const edits = collectDirtyEdits(pendingColorChanges, (c) => ({
|
|
115
|
+
originalClasses: c.originalClasses,
|
|
116
|
+
newClasses: c.newClasses,
|
|
117
|
+
}), (c) => c.isDirty)
|
|
118
|
+
writeJson(sessionStorage, STORAGE_KEYS.PENDING_COLOR_EDITS, edits, 'Failed to save color edits to storage')
|
|
124
119
|
}
|
|
125
120
|
|
|
126
121
|
export function loadColorEditsFromStorage(): SavedColorEdits {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
}
|
|
122
|
+
return readJson<SavedColorEdits>(
|
|
123
|
+
sessionStorage,
|
|
124
|
+
STORAGE_KEYS.PENDING_COLOR_EDITS,
|
|
125
|
+
{},
|
|
126
|
+
'Failed to load color edits from storage',
|
|
127
|
+
)
|
|
134
128
|
}
|
|
135
129
|
|
|
136
130
|
export function clearColorEditsFromStorage(): void {
|
|
137
|
-
|
|
138
|
-
sessionStorage.removeItem(STORAGE_KEYS.PENDING_COLOR_EDITS)
|
|
139
|
-
} catch (e) {
|
|
140
|
-
console.warn('[CMS] Failed to clear color edits from storage:', e)
|
|
141
|
-
}
|
|
131
|
+
removeKey(sessionStorage, STORAGE_KEYS.PENDING_COLOR_EDITS, 'Failed to clear color edits from storage')
|
|
142
132
|
}
|
|
143
133
|
|
|
144
134
|
// ============================================================================
|
|
@@ -146,40 +136,24 @@ export function clearColorEditsFromStorage(): void {
|
|
|
146
136
|
// ============================================================================
|
|
147
137
|
|
|
148
138
|
export function saveAttributeEditsToStorage(pendingAttributeChanges: Map<string, PendingAttributeChange>): void {
|
|
149
|
-
const edits
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
originalAttributes: change.originalAttributes,
|
|
155
|
-
newAttributes: change.newAttributes,
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
})
|
|
159
|
-
|
|
160
|
-
try {
|
|
161
|
-
sessionStorage.setItem(STORAGE_KEYS.PENDING_ATTRIBUTE_EDITS, JSON.stringify(edits))
|
|
162
|
-
} catch (e) {
|
|
163
|
-
console.warn('[CMS] Failed to save attribute edits to storage:', e)
|
|
164
|
-
}
|
|
139
|
+
const edits = collectDirtyEdits(pendingAttributeChanges, (c) => ({
|
|
140
|
+
originalAttributes: c.originalAttributes,
|
|
141
|
+
newAttributes: c.newAttributes,
|
|
142
|
+
}), (c) => c.isDirty)
|
|
143
|
+
writeJson(sessionStorage, STORAGE_KEYS.PENDING_ATTRIBUTE_EDITS, edits, 'Failed to save attribute edits to storage')
|
|
165
144
|
}
|
|
166
145
|
|
|
167
146
|
export function loadAttributeEditsFromStorage(): SavedAttributeEdits {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
}
|
|
147
|
+
return readJson<SavedAttributeEdits>(
|
|
148
|
+
sessionStorage,
|
|
149
|
+
STORAGE_KEYS.PENDING_ATTRIBUTE_EDITS,
|
|
150
|
+
{},
|
|
151
|
+
'Failed to load attribute edits from storage',
|
|
152
|
+
)
|
|
175
153
|
}
|
|
176
154
|
|
|
177
155
|
export function clearAttributeEditsFromStorage(): void {
|
|
178
|
-
|
|
179
|
-
sessionStorage.removeItem(STORAGE_KEYS.PENDING_ATTRIBUTE_EDITS)
|
|
180
|
-
} catch (e) {
|
|
181
|
-
console.warn('[CMS] Failed to clear attribute edits from storage:', e)
|
|
182
|
-
}
|
|
156
|
+
removeKey(sessionStorage, STORAGE_KEYS.PENDING_ATTRIBUTE_EDITS, 'Failed to clear attribute edits from storage')
|
|
183
157
|
}
|
|
184
158
|
|
|
185
159
|
// ============================================================================
|
|
@@ -187,46 +161,30 @@ export function clearAttributeEditsFromStorage(): void {
|
|
|
187
161
|
// ============================================================================
|
|
188
162
|
|
|
189
163
|
export function saveBgImageEditsToStorage(pendingBgImageChanges: Map<string, PendingBackgroundImageChange>): void {
|
|
190
|
-
const edits
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
originalBgRepeat: change.originalBgRepeat,
|
|
202
|
-
newBgRepeat: change.newBgRepeat,
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
})
|
|
206
|
-
|
|
207
|
-
try {
|
|
208
|
-
sessionStorage.setItem(STORAGE_KEYS.PENDING_BG_IMAGE_EDITS, JSON.stringify(edits))
|
|
209
|
-
} catch (e) {
|
|
210
|
-
console.warn('[CMS] Failed to save bg image edits to storage:', e)
|
|
211
|
-
}
|
|
164
|
+
const edits = collectDirtyEdits(pendingBgImageChanges, (c) => ({
|
|
165
|
+
originalBgImageClass: c.originalBgImageClass,
|
|
166
|
+
newBgImageClass: c.newBgImageClass,
|
|
167
|
+
originalBgSize: c.originalBgSize,
|
|
168
|
+
newBgSize: c.newBgSize,
|
|
169
|
+
originalBgPosition: c.originalBgPosition,
|
|
170
|
+
newBgPosition: c.newBgPosition,
|
|
171
|
+
originalBgRepeat: c.originalBgRepeat,
|
|
172
|
+
newBgRepeat: c.newBgRepeat,
|
|
173
|
+
}), (c) => c.isDirty)
|
|
174
|
+
writeJson(sessionStorage, STORAGE_KEYS.PENDING_BG_IMAGE_EDITS, edits, 'Failed to save bg image edits to storage')
|
|
212
175
|
}
|
|
213
176
|
|
|
214
177
|
export function loadBgImageEditsFromStorage(): SavedBackgroundImageEdits {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
}
|
|
178
|
+
return readJson<SavedBackgroundImageEdits>(
|
|
179
|
+
sessionStorage,
|
|
180
|
+
STORAGE_KEYS.PENDING_BG_IMAGE_EDITS,
|
|
181
|
+
{},
|
|
182
|
+
'Failed to load bg image edits from storage',
|
|
183
|
+
)
|
|
222
184
|
}
|
|
223
185
|
|
|
224
186
|
export function clearBgImageEditsFromStorage(): void {
|
|
225
|
-
|
|
226
|
-
sessionStorage.removeItem(STORAGE_KEYS.PENDING_BG_IMAGE_EDITS)
|
|
227
|
-
} catch (e) {
|
|
228
|
-
console.warn('[CMS] Failed to clear bg image edits from storage:', e)
|
|
229
|
-
}
|
|
187
|
+
removeKey(sessionStorage, STORAGE_KEYS.PENDING_BG_IMAGE_EDITS, 'Failed to clear bg image edits from storage')
|
|
230
188
|
}
|
|
231
189
|
|
|
232
190
|
// ============================================================================
|
|
@@ -234,29 +192,15 @@ export function clearBgImageEditsFromStorage(): void {
|
|
|
234
192
|
// ============================================================================
|
|
235
193
|
|
|
236
194
|
export function saveSettingsToStorage(settings: CmsSettings): void {
|
|
237
|
-
|
|
238
|
-
localStorage.setItem(STORAGE_KEYS.SETTINGS, JSON.stringify(settings))
|
|
239
|
-
} catch (e) {
|
|
240
|
-
console.warn('[CMS] Failed to save settings to storage:', e)
|
|
241
|
-
}
|
|
195
|
+
writeJson(localStorage, STORAGE_KEYS.SETTINGS, settings, 'Failed to save settings to storage')
|
|
242
196
|
}
|
|
243
197
|
|
|
244
198
|
export function loadSettingsFromStorage(): CmsSettings | null {
|
|
245
|
-
|
|
246
|
-
const stored = localStorage.getItem(STORAGE_KEYS.SETTINGS)
|
|
247
|
-
return stored ? JSON.parse(stored) : null
|
|
248
|
-
} catch (e) {
|
|
249
|
-
console.warn('[CMS] Failed to load settings from storage:', e)
|
|
250
|
-
return null
|
|
251
|
-
}
|
|
199
|
+
return readJson<CmsSettings | null>(localStorage, STORAGE_KEYS.SETTINGS, null, 'Failed to load settings from storage')
|
|
252
200
|
}
|
|
253
201
|
|
|
254
202
|
export function clearSettingsFromStorage(): void {
|
|
255
|
-
|
|
256
|
-
localStorage.removeItem(STORAGE_KEYS.SETTINGS)
|
|
257
|
-
} catch (e) {
|
|
258
|
-
console.warn('[CMS] Failed to clear settings from storage:', e)
|
|
259
|
-
}
|
|
203
|
+
removeKey(localStorage, STORAGE_KEYS.SETTINGS, 'Failed to clear settings from storage')
|
|
260
204
|
}
|
|
261
205
|
|
|
262
206
|
// ============================================================================
|
|
@@ -271,36 +215,29 @@ export interface PendingEntryNavigation {
|
|
|
271
215
|
}
|
|
272
216
|
|
|
273
217
|
export function savePendingEntryNavigation(entry: PendingEntryNavigation): void {
|
|
274
|
-
|
|
275
|
-
sessionStorage.setItem(STORAGE_KEYS.PENDING_ENTRY_NAVIGATION, JSON.stringify(entry))
|
|
276
|
-
} catch (e) {
|
|
277
|
-
console.warn('[CMS] Failed to save pending entry navigation:', e)
|
|
278
|
-
}
|
|
218
|
+
writeJson(sessionStorage, STORAGE_KEYS.PENDING_ENTRY_NAVIGATION, entry, 'Failed to save pending entry navigation')
|
|
279
219
|
}
|
|
280
220
|
|
|
281
221
|
export function hasPendingEntryNavigation(): boolean {
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
}
|
|
222
|
+
const entry = readJson<PendingEntryNavigation | null>(
|
|
223
|
+
sessionStorage,
|
|
224
|
+
STORAGE_KEYS.PENDING_ENTRY_NAVIGATION,
|
|
225
|
+
null,
|
|
226
|
+
'Failed to load pending entry navigation',
|
|
227
|
+
)
|
|
228
|
+
return !!entry && window.location.pathname === entry.pathname
|
|
290
229
|
}
|
|
291
230
|
|
|
292
231
|
export function loadPendingEntryNavigation(): PendingEntryNavigation | null {
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
return null
|
|
303
|
-
}
|
|
232
|
+
const entry = readJson<PendingEntryNavigation | null>(
|
|
233
|
+
sessionStorage,
|
|
234
|
+
STORAGE_KEYS.PENDING_ENTRY_NAVIGATION,
|
|
235
|
+
null,
|
|
236
|
+
'Failed to load pending entry navigation',
|
|
237
|
+
)
|
|
238
|
+
if (!entry || window.location.pathname !== entry.pathname) return null
|
|
239
|
+
removeKey(sessionStorage, STORAGE_KEYS.PENDING_ENTRY_NAVIGATION, 'Failed to clear pending entry navigation')
|
|
240
|
+
return entry
|
|
304
241
|
}
|
|
305
242
|
|
|
306
243
|
// ============================================================================
|
|
@@ -308,20 +245,59 @@ export function loadPendingEntryNavigation(): PendingEntryNavigation | null {
|
|
|
308
245
|
// ============================================================================
|
|
309
246
|
|
|
310
247
|
export function saveEditingState(isEditing: boolean): void {
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
sessionStorage.removeItem(STORAGE_KEYS.IS_EDITING)
|
|
316
|
-
}
|
|
317
|
-
} catch (e) {
|
|
318
|
-
console.warn('[CMS] Failed to save editing state:', e)
|
|
248
|
+
if (isEditing) {
|
|
249
|
+
writeJson(sessionStorage, STORAGE_KEYS.IS_EDITING, true, 'Failed to save editing state')
|
|
250
|
+
} else {
|
|
251
|
+
removeKey(sessionStorage, STORAGE_KEYS.IS_EDITING, 'Failed to clear editing state')
|
|
319
252
|
}
|
|
320
253
|
}
|
|
321
254
|
|
|
322
255
|
export function loadEditingState(): boolean {
|
|
256
|
+
return readJson<boolean>(sessionStorage, STORAGE_KEYS.IS_EDITING, false, 'Failed to load editing state')
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ============================================================================
|
|
260
|
+
// Markdown Drafts (per-file, sessionStorage)
|
|
261
|
+
// ============================================================================
|
|
262
|
+
|
|
263
|
+
export interface MarkdownDraft {
|
|
264
|
+
frontmatter: Record<string, unknown>
|
|
265
|
+
content: string
|
|
266
|
+
savedAt: number
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function markdownDraftKey(filePath: string): string {
|
|
270
|
+
return `${STORAGE_KEYS.MARKDOWN_DRAFT_PREFIX}${filePath}`
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export function saveMarkdownDraft(
|
|
274
|
+
filePath: string,
|
|
275
|
+
frontmatter: Record<string, unknown>,
|
|
276
|
+
content: string,
|
|
277
|
+
): void {
|
|
278
|
+
if (!filePath) return
|
|
279
|
+
const draft: MarkdownDraft = { frontmatter, content, savedAt: Date.now() }
|
|
280
|
+
writeJson(sessionStorage, markdownDraftKey(filePath), draft, 'Failed to save markdown draft')
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function loadMarkdownDraft(filePath: string): MarkdownDraft | null {
|
|
284
|
+
if (!filePath) return null
|
|
285
|
+
return readJson<MarkdownDraft | null>(sessionStorage, markdownDraftKey(filePath), null, 'Failed to load markdown draft')
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export function clearMarkdownDraft(filePath: string): void {
|
|
289
|
+
if (!filePath) return
|
|
290
|
+
removeKey(sessionStorage, markdownDraftKey(filePath), 'Failed to clear markdown draft')
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** True when at least one markdown draft is present in sessionStorage. */
|
|
294
|
+
export function hasAnyMarkdownDraft(): boolean {
|
|
323
295
|
try {
|
|
324
|
-
|
|
296
|
+
for (let i = 0; i < sessionStorage.length; i++) {
|
|
297
|
+
const key = sessionStorage.key(i)
|
|
298
|
+
if (key?.startsWith(STORAGE_KEYS.MARKDOWN_DRAFT_PREFIX)) return true
|
|
299
|
+
}
|
|
300
|
+
return false
|
|
325
301
|
} catch {
|
|
326
302
|
return false
|
|
327
303
|
}
|
package/src/editor/types.ts
CHANGED
|
@@ -52,6 +52,8 @@ export interface CmsConfig {
|
|
|
52
52
|
theme?: CmsThemeConfig
|
|
53
53
|
themePreset?: CmsThemePreset
|
|
54
54
|
features?: CmsFeatures
|
|
55
|
+
/** Maximum upload size in bytes for media uploads (injected by the integration). */
|
|
56
|
+
maxUploadSize?: number
|
|
55
57
|
}
|
|
56
58
|
|
|
57
59
|
export interface ComponentProp {
|
|
@@ -12,7 +12,7 @@ import { handleInsertComponent, handleRemoveComponent } from './component-ops'
|
|
|
12
12
|
import { handleCreateMarkdown, handleDeleteMarkdown, handleGetMarkdownContent, handleRenameMarkdown, handleUpdateMarkdown } from './markdown-ops'
|
|
13
13
|
import { handleCheckSlugExists, handleCreatePage, handleDeletePage, handleDuplicatePage, handleGetLayouts } from './page-ops'
|
|
14
14
|
import { handleAddRedirect, handleDeleteRedirect, handleGetRedirects, handleUpdateRedirect } from './redirect-ops'
|
|
15
|
-
import { parseJsonBody, parseMultipartFile, readBody, sendError, sendJson } from './request-utils'
|
|
15
|
+
import { BodyTooLargeError, parseJsonBody, parseMultipartFile, readBody, sendError, sendJson } from './request-utils'
|
|
16
16
|
import { handleUpdate } from './source-writer'
|
|
17
17
|
|
|
18
18
|
export interface RouteContext {
|
|
@@ -22,6 +22,7 @@ export interface RouteContext {
|
|
|
22
22
|
manifestWriter: ManifestWriter
|
|
23
23
|
contentDir: string
|
|
24
24
|
mediaAdapter?: MediaStorageAdapter
|
|
25
|
+
maxUploadSize: number
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
type RouteHandler = (ctx: RouteContext) => Promise<void>
|
|
@@ -69,7 +70,8 @@ function custom(method: string, route: string, handler: RouteHandler): [string,
|
|
|
69
70
|
return [`${method}:${route}`, handler]
|
|
70
71
|
}
|
|
71
72
|
|
|
72
|
-
/** Allowed MIME types for media uploads
|
|
73
|
+
/** Allowed MIME types for media uploads. Videos are intentionally excluded —
|
|
74
|
+
* they belong on a CDN (Mux, YouTube), not in the repo's public/uploads dir. */
|
|
73
75
|
const ALLOWED_UPLOAD_TYPES = new Set([
|
|
74
76
|
'image/jpeg',
|
|
75
77
|
'image/png',
|
|
@@ -77,8 +79,6 @@ const ALLOWED_UPLOAD_TYPES = new Set([
|
|
|
77
79
|
'image/webp',
|
|
78
80
|
'image/avif',
|
|
79
81
|
'image/x-icon',
|
|
80
|
-
'video/mp4',
|
|
81
|
-
'video/webm',
|
|
82
82
|
'application/pdf',
|
|
83
83
|
])
|
|
84
84
|
|
|
@@ -152,8 +152,28 @@ const routeMap = new Map<string, RouteHandler>([
|
|
|
152
152
|
sendError(ctx.res, 'Expected multipart/form-data')
|
|
153
153
|
return
|
|
154
154
|
}
|
|
155
|
+
|
|
156
|
+
const limit = ctx.maxUploadSize
|
|
157
|
+
const tooLargeMsg = `File too large (max ${Math.round(limit / (1024 * 1024))} MB)`
|
|
158
|
+
|
|
159
|
+
// Reject early via Content-Length so we don't stream a huge payload only to drop it.
|
|
160
|
+
const declared = parseInt(ctx.req.headers['content-length'] ?? '', 10)
|
|
161
|
+
if (Number.isFinite(declared) && declared > limit) {
|
|
162
|
+
sendError(ctx.res, tooLargeMsg, 413)
|
|
163
|
+
return
|
|
164
|
+
}
|
|
165
|
+
|
|
155
166
|
const query = getQuery(ctx)
|
|
156
|
-
|
|
167
|
+
let body: Buffer
|
|
168
|
+
try {
|
|
169
|
+
body = await readBody(ctx.req, limit)
|
|
170
|
+
} catch (err) {
|
|
171
|
+
if (err instanceof BodyTooLargeError) {
|
|
172
|
+
sendError(ctx.res, tooLargeMsg, 413)
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
throw err
|
|
176
|
+
}
|
|
157
177
|
const file = parseMultipartFile(body, contentType)
|
|
158
178
|
if (!file) {
|
|
159
179
|
sendError(ctx.res, 'No file found in request')
|
|
@@ -246,15 +266,8 @@ const routeMap = new Map<string, RouteHandler>([
|
|
|
246
266
|
get('deployment/status', async () => ({ currentDeployment: null, pendingCount: 0, deploymentEnabled: false })),
|
|
247
267
|
])
|
|
248
268
|
|
|
249
|
-
export async function handleCmsApiRoute(
|
|
250
|
-
route
|
|
251
|
-
req: IncomingMessage,
|
|
252
|
-
res: ServerResponse,
|
|
253
|
-
manifestWriter: ManifestWriter,
|
|
254
|
-
contentDir: string,
|
|
255
|
-
mediaAdapter?: MediaStorageAdapter,
|
|
256
|
-
): Promise<void> {
|
|
257
|
-
const ctx: RouteContext = { req, res, route, manifestWriter, contentDir, mediaAdapter }
|
|
269
|
+
export async function handleCmsApiRoute(ctx: RouteContext): Promise<void> {
|
|
270
|
+
const { req, res, route } = ctx
|
|
258
271
|
|
|
259
272
|
// Exact match lookup
|
|
260
273
|
const handler = routeMap.get(`${req.method}:${route}`)
|
|
@@ -3,6 +3,15 @@ import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
|
3
3
|
/** Maximum request body size: 10 MB */
|
|
4
4
|
const MAX_BODY_SIZE = 10 * 1024 * 1024
|
|
5
5
|
|
|
6
|
+
/** Thrown by readBody when the streamed body exceeds the configured limit.
|
|
7
|
+
* Lets callers distinguish "client sent too much" from transport errors. */
|
|
8
|
+
export class BodyTooLargeError extends Error {
|
|
9
|
+
constructor(public readonly maxSize: number) {
|
|
10
|
+
super(`Request body exceeds maximum size of ${maxSize} bytes`)
|
|
11
|
+
this.name = 'BodyTooLargeError'
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
6
15
|
export function readBody(req: IncomingMessage, maxSize: number = MAX_BODY_SIZE): Promise<Buffer> {
|
|
7
16
|
return new Promise((resolve, reject) => {
|
|
8
17
|
const chunks: Buffer[] = []
|
|
@@ -11,7 +20,7 @@ export function readBody(req: IncomingMessage, maxSize: number = MAX_BODY_SIZE):
|
|
|
11
20
|
totalSize += chunk.length
|
|
12
21
|
if (totalSize > maxSize) {
|
|
13
22
|
req.destroy()
|
|
14
|
-
reject(new
|
|
23
|
+
reject(new BodyTooLargeError(maxSize))
|
|
15
24
|
return
|
|
16
25
|
}
|
|
17
26
|
chunks.push(chunk)
|
package/src/index.ts
CHANGED
|
@@ -65,8 +65,16 @@ export interface NuaCmsOptions extends CmsMarkerOptions {
|
|
|
65
65
|
* @default false
|
|
66
66
|
*/
|
|
67
67
|
usePolling?: boolean
|
|
68
|
+
/**
|
|
69
|
+
* Maximum upload size in bytes. Applied to `/_nua/cms/media/upload` requests
|
|
70
|
+
* (enforced on the server and pre-checked on the client).
|
|
71
|
+
* @default 10 * 1024 * 1024 (10 MB)
|
|
72
|
+
*/
|
|
73
|
+
maxUploadSize?: number
|
|
68
74
|
}
|
|
69
75
|
|
|
76
|
+
const DEFAULT_MAX_UPLOAD_SIZE = 10 * 1024 * 1024
|
|
77
|
+
|
|
70
78
|
const VIRTUAL_CMS_PATH = '/@nuasite/cms-editor.js'
|
|
71
79
|
|
|
72
80
|
export default function nuaCms(options: NuaCmsOptions = {}): AstroIntegration {
|
|
@@ -89,16 +97,20 @@ export default function nuaCms(options: NuaCmsOptions = {}): AstroIntegration {
|
|
|
89
97
|
mdxComponentDirs,
|
|
90
98
|
usePolling = false,
|
|
91
99
|
seo = { trackSeo: true, markTitle: true, parseJsonLd: true },
|
|
100
|
+
maxUploadSize = DEFAULT_MAX_UPLOAD_SIZE,
|
|
92
101
|
} = options
|
|
93
102
|
|
|
94
103
|
// When no proxy, enable local CMS API with default media adapter
|
|
95
104
|
const enableCmsApi = !proxy
|
|
96
105
|
const mediaAdapter = media ?? (enableCmsApi ? createLocalStorageAdapter() : undefined)
|
|
97
106
|
|
|
98
|
-
// Default apiBase to local dev server when no proxy
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
107
|
+
// Default apiBase to local dev server when no proxy; thread maxUploadSize through
|
|
108
|
+
// to the editor so the client can pre-check before sending the upload.
|
|
109
|
+
const resolvedCmsConfig = {
|
|
110
|
+
...(cmsConfig ?? {}),
|
|
111
|
+
...(enableCmsApi && !cmsConfig?.apiBase ? { apiBase: '/_nua/cms' } : {}),
|
|
112
|
+
maxUploadSize,
|
|
113
|
+
}
|
|
102
114
|
|
|
103
115
|
let componentDefinitions: Record<string, ComponentDefinition> = {}
|
|
104
116
|
let isPublicStaticFile: ((urlPath: string) => boolean) | undefined
|
|
@@ -203,9 +215,7 @@ export default function nuaCms(options: NuaCmsOptions = {}): AstroIntegration {
|
|
|
203
215
|
if (command === 'dev') {
|
|
204
216
|
const editorSrc = src ?? VIRTUAL_CMS_PATH
|
|
205
217
|
|
|
206
|
-
const configScript = resolvedCmsConfig
|
|
207
|
-
? `window.NuaCmsConfig = ${JSON.stringify(resolvedCmsConfig)};`
|
|
208
|
-
: ''
|
|
218
|
+
const configScript = `window.NuaCmsConfig = ${JSON.stringify(resolvedCmsConfig)};`
|
|
209
219
|
|
|
210
220
|
injectScript(
|
|
211
221
|
'page',
|
|
@@ -229,7 +239,8 @@ export default function nuaCms(options: NuaCmsOptions = {}): AstroIntegration {
|
|
|
229
239
|
if (hasPrebuiltBundle) {
|
|
230
240
|
// Pre-built bundle exists (npm install case):
|
|
231
241
|
// Serve it via a virtual module — no JSX pragma, Tailwind, or aliases needed.
|
|
232
|
-
|
|
242
|
+
// Read on every load() so rebuilds during dev pick up without restarting
|
|
243
|
+
// the host (Astro, pletivo, etc).
|
|
233
244
|
vitePlugins.push({
|
|
234
245
|
name: 'nuasite-cms-editor',
|
|
235
246
|
resolveId(id: string) {
|
|
@@ -239,7 +250,7 @@ export default function nuaCms(options: NuaCmsOptions = {}): AstroIntegration {
|
|
|
239
250
|
},
|
|
240
251
|
load(id: string) {
|
|
241
252
|
if (id === VIRTUAL_CMS_PATH) {
|
|
242
|
-
return
|
|
253
|
+
return readFileSync(editorBundlePath!, 'utf-8')
|
|
243
254
|
}
|
|
244
255
|
},
|
|
245
256
|
})
|
|
@@ -319,7 +330,7 @@ export default function nuaCms(options: NuaCmsOptions = {}): AstroIntegration {
|
|
|
319
330
|
manifestWriter,
|
|
320
331
|
componentDefinitions,
|
|
321
332
|
idCounter,
|
|
322
|
-
{ enableCmsApi, mediaAdapter, isPublicStaticFile },
|
|
333
|
+
{ enableCmsApi, mediaAdapter, isPublicStaticFile, maxUploadSize },
|
|
323
334
|
)
|
|
324
335
|
logger.info('CMS dev middleware initialized')
|
|
325
336
|
if (enableCmsApi) {
|