@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.
@@ -14,90 +14,96 @@ import type {
14
14
  } from './types'
15
15
 
16
16
  // ============================================================================
17
- // Text Edits
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
- export function saveEditsToStorage(pendingChanges: Map<string, PendingChange>): void {
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
- sessionStorage.setItem(STORAGE_KEYS.PENDING_EDITS, JSON.stringify(edits))
23
+ const raw = storage.getItem(key)
24
+ return raw === null ? fallback : (JSON.parse(raw) as T)
36
25
  } catch (e) {
37
- console.warn('[CMS] Failed to save edits to storage:', e)
26
+ console.warn(`[CMS] ${label}:`, e)
27
+ return fallback
38
28
  }
39
29
  }
40
30
 
41
- export function loadEditsFromStorage(): SavedEdits {
31
+ function writeJson(storage: Storage, key: string, value: unknown, label: string): void {
42
32
  try {
43
- const stored = sessionStorage.getItem(STORAGE_KEYS.PENDING_EDITS)
44
- return stored ? JSON.parse(stored) : {}
33
+ storage.setItem(key, JSON.stringify(value))
45
34
  } catch (e) {
46
- console.warn('[CMS] Failed to load edits from storage:', e)
47
- return {}
35
+ console.warn(`[CMS] ${label}:`, e)
48
36
  }
49
37
  }
50
38
 
51
- export function clearEditsFromStorage(): void {
39
+ function removeKey(storage: Storage, key: string, label: string): void {
52
40
  try {
53
- sessionStorage.removeItem(STORAGE_KEYS.PENDING_EDITS)
41
+ storage.removeItem(key)
54
42
  } catch (e) {
55
- console.warn('[CMS] Failed to clear edits from storage:', e)
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: SavedImageEdits = {}
65
-
66
- pendingImageChanges.forEach((change, cmsId) => {
67
- if (change.isDirty) {
68
- edits[cmsId] = {
69
- originalSrc: change.originalSrc,
70
- newSrc: change.newSrc,
71
- originalAlt: change.originalAlt,
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
- try {
87
- const stored = sessionStorage.getItem(STORAGE_KEYS.PENDING_IMAGE_EDITS)
88
- return stored ? JSON.parse(stored) : {}
89
- } catch (e) {
90
- console.warn('[CMS] Failed to load image edits from storage:', e)
91
- return {}
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
- try {
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: SavedColorEdits = {}
109
-
110
- pendingColorChanges.forEach((change, cmsId) => {
111
- if (change.isDirty) {
112
- edits[cmsId] = {
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
- try {
128
- const stored = sessionStorage.getItem(STORAGE_KEYS.PENDING_COLOR_EDITS)
129
- return stored ? JSON.parse(stored) : {}
130
- } catch (e) {
131
- console.warn('[CMS] Failed to load color edits from storage:', e)
132
- return {}
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
- try {
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: SavedAttributeEdits = {}
150
-
151
- pendingAttributeChanges.forEach((change, cmsId) => {
152
- if (change.isDirty) {
153
- edits[cmsId] = {
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
- try {
169
- const stored = sessionStorage.getItem(STORAGE_KEYS.PENDING_ATTRIBUTE_EDITS)
170
- return stored ? JSON.parse(stored) : {}
171
- } catch (e) {
172
- console.warn('[CMS] Failed to load attribute edits from storage:', e)
173
- return {}
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
- try {
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: SavedBackgroundImageEdits = {}
191
-
192
- pendingBgImageChanges.forEach((change, cmsId) => {
193
- if (change.isDirty) {
194
- edits[cmsId] = {
195
- originalBgImageClass: change.originalBgImageClass,
196
- newBgImageClass: change.newBgImageClass,
197
- originalBgSize: change.originalBgSize,
198
- newBgSize: change.newBgSize,
199
- originalBgPosition: change.originalBgPosition,
200
- newBgPosition: change.newBgPosition,
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
- try {
216
- const stored = sessionStorage.getItem(STORAGE_KEYS.PENDING_BG_IMAGE_EDITS)
217
- return stored ? JSON.parse(stored) : {}
218
- } catch (e) {
219
- console.warn('[CMS] Failed to load bg image edits from storage:', e)
220
- return {}
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
- try {
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
- try {
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
- try {
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
- try {
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
- try {
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
- try {
283
- const stored = sessionStorage.getItem(STORAGE_KEYS.PENDING_ENTRY_NAVIGATION)
284
- if (!stored) return false
285
- const entry: PendingEntryNavigation = JSON.parse(stored)
286
- return window.location.pathname === entry.pathname
287
- } catch {
288
- return false
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
- try {
294
- const stored = sessionStorage.getItem(STORAGE_KEYS.PENDING_ENTRY_NAVIGATION)
295
- if (!stored) return null
296
- const entry: PendingEntryNavigation = JSON.parse(stored)
297
- if (window.location.pathname !== entry.pathname) return null
298
- sessionStorage.removeItem(STORAGE_KEYS.PENDING_ENTRY_NAVIGATION)
299
- return entry
300
- } catch (e) {
301
- console.warn('[CMS] Failed to load pending entry navigation:', e)
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
- try {
312
- if (isEditing) {
313
- sessionStorage.setItem(STORAGE_KEYS.IS_EDITING, '1')
314
- } else {
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
- return sessionStorage.getItem(STORAGE_KEYS.IS_EDITING) === '1'
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
  }
@@ -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
- const body = await readBody(ctx.req, 50 * 1024 * 1024)
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: string,
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 Error(`Request body exceeds maximum size of ${maxSize} bytes`))
23
+ reject(new BodyTooLargeError(maxSize))
15
24
  return
16
25
  }
17
26
  chunks.push(chunk)
@@ -662,6 +662,12 @@ export async function processHtml(
662
662
  sourceLine?: number
663
663
  }
664
664
  const imageEntries = new Map<string, ImageEntry>()
665
+ // Per (sourceFile, src) DOM-order occurrence counter. When the same image
666
+ // URL appears N times rendered from the same source file, this lets us
667
+ // map each rendered `<img>` to the Nth index entry for that (file, src)
668
+ // — disambiguating cases where data-astro-source-loc alone doesn't (e.g.
669
+ // when only a wrapper element carries the source attribution).
670
+ const srcOccurrenceCounts = new Map<string, number>()
665
671
  root.querySelectorAll('img').forEach((node) => {
666
672
  // Skip if already marked
667
673
  if (node.getAttribute(attributeName)) return
@@ -682,12 +688,17 @@ export async function processHtml(
682
688
 
683
689
  const { sourceFile, sourceLine } = findAncestorSourceLocation(node)
684
690
 
691
+ const occurrenceKey = `${sourceFile ?? ''}\0${src}`
692
+ const srcOccurrence = srcOccurrenceCounts.get(occurrenceKey) ?? 0
693
+ srcOccurrenceCounts.set(occurrenceKey, srcOccurrence + 1)
694
+
685
695
  // Build image metadata
686
696
  const metadata: ImageMetadata = {
687
697
  src,
688
698
  alt: node.getAttribute('alt') || '',
689
699
  srcSet: node.getAttribute('srcset') || undefined,
690
700
  sizes: node.getAttribute('sizes') || undefined,
701
+ srcOccurrence,
691
702
  }
692
703
 
693
704
  // Store image info for manifest